-
Notifications
You must be signed in to change notification settings - Fork 0
SceneEngine Architecture
Companion pages: Technical Index · end-user counterpart: Scene Engine User Guide · the phase-by-phase planning + shipped-status record: Scene Engine Roadmap.
This is the developer reference for the Scene Engine — the cinematic layer that composes shots into a timeline, flies a keyframed camera through the eight 3-D raymarchers, sequences transitions, applies scene-wide look tracks, and renders the result offline to video. It assumes you have read the Architecture Overview and know the animation bus from the Animation Roadmap.
The guiding design constraint, from the roadmap: the pure, deterministic,
unit-tested core lands in Abstractions/; the impure consumers (persistence,
render host, editor UI) live in Engine/, UI.Avalonia/, and Batch/ and are
kept thin. Every phase shipped its core behind current behaviour first, then
wired a consumer. That layering is why almost everything on this page is a pure
function you can call from a test.
| File | Project | Role |
|---|---|---|
Render/RenderMode.cs |
Abstractions | S0 — RenderMode enum + RenderModePolicy + thread-affine RenderModeScope (realtime vs. offline vs. offline-fast-GPU). |
Render/ResourceGovernor.cs |
Abstractions | S1 — pure adaptive quality control loop + ProcessResourceSampler + IResourceCapBackstop. |
Render/PerformanceTier.cs |
Abstractions | S2 — PerformanceTier (Potato/Balanced/Wow), TierKnobs, PerformanceTierProfile. |
Render/CameraTrack.cs |
Abstractions | S3 — CameraState, CameraKey, CameraTrack, CameraInterpolation, CameraEase. The new engine surface. |
Render/CameraParamBinding.cs |
Abstractions | S3 — the seam from a CameraState onto the per-type camera fields on FractalParameters. |
Animation/CameraTrackAnimator.cs |
Abstractions | S3 — IParameterAnimator that advances a scene clock and drives a track onto the bus. |
Animation/SceneData.cs |
Abstractions | S4 — the SceneData / SceneShot DTOs + SceneTransitionKind. |
Models/SceneLibrary.cs |
Engine | S4 — singleton JSON library + built-in demo scenes. |
Assets/AssetSources.cs (SceneAssetSource) |
Engine | S5 — Asset Manager node type. |
ViewModels/SceneEditorViewModel.cs + Views/SceneEditorView.axaml
|
UI.Avalonia | S5 — the editor. |
Animation/SceneTimeline.cs |
Abstractions | S6 — pure playback schedule + SceneTransitions (visual resolution + light-sweep weight). |
Animation/SceneRenderPlan.cs |
Abstractions | S7 — pure offline frame plan (motion-blur sub-frames + transition composites). |
Export/SceneVideoRenderer.cs |
Engine | S7 — the offline renderer that consumes the plan. |
Animation/SceneParamMorph.cs |
Abstractions | S8 — component-wise param lerp for the ParamMorph transition. |
Animation/SceneGlobalTrack.cs |
Abstractions | S8 — scene-wide keyframed post scalars + binding + multi-track apply. |
Animation/SceneGlobalTrackAnimator.cs |
Abstractions | S8 — bus animator for realtime global tracks. |
Batch/BatchRenderer.cs (RenderScene) |
Batch | S7 — the --batch --mode scene driver. |
Tests (all in Server.Tests): RenderModeScopeTests (8), ResourceGovernorTests
(9), PerformanceTierTests (12), CameraTrackTests (17 + 6 for D.1 easing),
SceneLibraryTests (8 + 2 tone-map), SceneTimelineTests (9),
SceneRenderPlanTests (12), SceneTransitionVisualsTests (6),
SceneGlobalTrackTests (18), plus AssetSourceTests growth for the ninth source.
The whole engine hangs off one split, formalised in Render/RenderMode.cs:
public enum RenderMode { Realtime, Offline, OfflineFastGpu }- Realtime — the interactive preview path, under governor control. Sheds resolution / param count / effect stack to hold framerate.
-
Offline — frame-locked. Each frame renders to completion, decoupled from
wall-clock. This is what produces video. Pins the CPU (
double) path by default for reproducibility (the GPU path isfloatand not bit-identical). - OfflineFastGpu — an opt-in for the high-end case that accepts the GPU path's non-determinism to render faster.
RenderModePolicy is a record carrying the frame-time budget, the
deterministic-CPU pin, and whether the mode participates in the governor.
ResolveUseGpuRender() is the single gate that keeps deterministic exports off
the float GPU path. RenderModeScope is the thread-affine ambient current
policy — it nests and restores on dispose, defaulting to Realtime. Determinism
rationale (roadmap R3): a pinned CPU path means an exported MP4 is byte-for-byte
reproducible across machines.
These are the "never crash the host" machinery. They are independent of the camera/scene track — you can read this section or skip it.
Render/ResourceGovernor.cs is a pure control loop:
GovernorState Evaluate(ResourceSample sample, bool participatesInGovernor);It ratchets a QualityScale ∈ [floor, 1] down when CPU ≥ 85 % (soft target)
or memory ≥ 0.80 (watermark), and back up only after RecoverHoldTicks
sustained calm below the recover band (75 % / 0.70). The gap between the
soft-target band and the recover band is deliberate hysteresis to stop
oscillation. HardCapBreached flags the OS backstop at the 90 % / 0.90 ceiling.
Two important behaviours:
-
Offline freezes the scale. When
participatesInGovernor == falsethe quality scale is pinned to 1 (full fidelity) — an export must not throttle itself. But the memory cache-shed signal stays unconditional, so an offline render still drops caches under memory pressure. -
The OS cap is deferred to the host.
IResourceCapBackstop+NoOpResourceCapBackstopis the injection seam; the Windows Job Object implementation (which P/Invokes and can kill the process) is intentionally not shipped as an unverifiable default. The managed governor is the primary, portable mechanism; the job-object cap is an additive Windows-only hardener (roadmap R2, R6).
ProcessResourceSampler provides the live sample: cross-platform CPU %
(process CPU-time delta ÷ wall × cores) and memory fraction (working set ÷
TotalAvailableMemoryBytes, cgroup-aware).
Render/PerformanceTier.cs wires the existing perf knobs to a single selector
(roadmap R1 — knob explosion is the top confusion risk, so tiers wire existing
knobs, they do not add parallel ones):
-
PerformanceTierenum:Potato/Balanced/Wow. -
TierKnobsrecord: preview scale, volume steps, animated-param ceiling, AA, precision tier, GPU / CPU-fallback gates. -
PerformanceTierProfilewith three pure operations:-
Baseline(tier)— default knobs per tier. -
DefaultTier(HardwareProfile)— picks a tier from the same logical-core count + discrete-GPU probe the animation ceiling already uses. -
Resolve(baseline, qualityScale)— folds the live governor scale onto the continuous knobs (proportional throttle, floor clamps, no boost past baseline) while leaving structural knobs (precision tier, GPU gate) untouched.
-
Resolve is the "apply" half of the sample→evaluate→apply loop; the periodic
driver that pushes resolved knobs onto FractalParameters / LightingFxData is
the UI consumer, wired through AvaloniaShellBootstrap.
Everything else in a shot (region, theme, animation, lighting preset) already existed and already played through the animation bus and slideshow engine. The camera path is the new capability.
The 8 distance-estimation raymarchers (Mandelbulb, Mandelbox, KIFS, Quaternion
Julia, Quaternion Mandelbrot, Kleinian, Bicomplex, User Bulb) each already
consume an orbit camera as three scalars on FractalParameters:
<Type>CameraDistance / Theta / Phi. A CameraTrack keyframes exactly those
three scalars:
public readonly record struct CameraState(double Distance, double Theta, double Phi);
public sealed class CameraKey {
public double Time { get; set; } // seconds from track start
public CameraState State { get; set; }
public CameraEase Ease { get; set; } // per-key time reparam (D.1)
}
public sealed class CameraTrack {
public List<CameraKey> Keys { get; set; } // ascending Time
public CameraInterpolation Interpolation { get; set; } // Linear|CatmullRom|Bezier
public double Duration => last key Time;
public void Add(CameraKey key); // inserts sorted
public CameraState Evaluate(double time);
}CameraState is (Distance, Theta, Phi) — the orbit triple, not a full
(position, target, FOV, roll) pose. The roadmap's original CameraKey sketch
listed FOV / focal-distance / roll; the shipped surface is the orbit triple the
raymarchers actually read today. A FOV zoom track (for a true dolly-zoom) needs
a new raymarcher input and is future work.
Evaluate(time) clamps outside the key range (below first key → first pose;
above last → last pose) and blends inside it per Interpolation:
-
Linear — component-wise lerp. Constant velocity, a velocity discontinuity at each key.
-
CatmullRom (default) — uniform Catmull-Rom spline, C¹ continuous, tangents from neighbouring keys, one-sided at the ends. Passes through every key; overshoots slightly on sharp direction changes. Basis:
$$ p(u) = \tfrac{1}{2}\big[,2p_1 + (-p_0 + p_2)u + (2p_0 - 5p_1 + 4p_2 - p_3)u^2 + (-p_0 + 3p_1 - 3p_2 + p_3)u^3,\big] $$
-
Bezier — cubic Hermite with zero endpoint tangents, i.e. smoothstep
$u^2(3-2u)$ . Settles to a stop at every key. (Per-key handle authoring — a true graphical Bezier curve editor — remains future polish.)
Per-key easing (CameraEase, the D.1 slice) reparametrises the normalised
segment parameter of the segment that starts at a key, before the spatial
basis reads it, so easing composes with the path shape:
public static double ApplyEase(CameraEase ease, double u) => ease switch {
CameraEase.EaseIn => u * u,
CameraEase.EaseOut => 1 - (1 - u) * (1 - u),
CameraEase.EaseInOut => u * u * (3 - 2 * u), // smoothstep
_ => u, // None
};Endpoints are fixed (0→0, 1→1), so keys are always passed through exactly —
easing only changes traversal speed, never which pose a key lands on.
Note
Angles interpolate literally, not shortest-path. A track from θ = 0 to θ = 4π orbits twice on purpose. This is a deliberate authoring affordance — the alternative (shortest-path angle wrapping) would make multi-turn orbits impossible to express. The cost is that a non-monotonic θ (e.g. 6.0 → 0.1) unwinds a whole turn; the user guide warns authors to keep θ monotonic.
CameraParamBinding maps a type-agnostic CameraState onto the concrete
per-type fields. It is data-driven off one authoritative dictionary:
[FractalType.Mandelbulb] = ("BulbCameraDistance", "BulbCameraTheta", "BulbCameraPhi"),
[FractalType.Mandelbox] = ("MandelboxCameraDistance", ...),
// ... 8 entries totalPropertyInfo is resolved once via reflection and cached. Apply(params, type, state) writes the three fields; Read is the inverse. Supports(type) /
SupportedTypes gate camera authoring to exactly the 8 raymarch types.
The round-trip test (CameraTrackTests) is load-bearing: it asserts every
property name in the map exists on FractalParameters as a read/write double,
and that Apply→Read is the identity. That test is what lets the reflection
be safe — a renamed field fails the test, not production.
CameraTrackAnimator is an IParameterAnimator (same contract as the
procedural param animators). It advances a scene clock each Tick(dt), samples
the track, and applies via the binding. Its cost is Moderate so the
animated-param ceiling drops it first under load — camera counts as
raymarched-3-D work, so it sheds ahead of a cheap post track (roadmap R4: the
bus already defines a deterministic tick order; the camera slots in as one more
registered animator with a defined precedence). Bus registration is the S6
consumer, below.
SceneData mirrors AnimationData's shape (name key + category + tags) so it
slots into the Asset Manager identically:
public sealed class SceneData {
public string Name; // library key (case-insensitive)
public string Description;
public string Category; // "User" | "Built-in"
public List<SceneShot> Shots;
public List<SceneGlobalTrack> GlobalTracks; // S8, scene-wide
public List<string> Tags;
[JsonIgnore] public double TotalDurationSeconds; // computed sum
}
public sealed class SceneShot {
public string Name;
public string RegionName; // "" = render FractalType's defaults
public string? ThemeName; // null = region's own theme
public string? AnimationName; // null = region's own animation
public FractalType FractalType;
public ToneMapOperator? ToneMap; // S8, null = inherit region lighting
public CameraTrack? Camera; // S3, 3D-only, null for 2D
public double DurationSeconds;
public SceneTransitionKind Transition;
public double TransitionSeconds;
}The loose coupling is deliberate (same as AnimationTrack naming a param by
string): a Scene serialises without embedding copies of its assets, and a
renamed / missing asset degrades to a resolve-time fallback rather than a
load-time crash.
Engine/Models/SceneLibrary.cs is a line-for-line mirror of AnimationLibrary:
lazy singleton, %APPDATA%\FracturingFog\scenes.json, indented enums-as-string
JSON via BuildJsonOptions() (JsonStringEnumConverter +
WhenWritingNull), non-fatal load/save, Add / ReplaceOrAdd / Remove /
GetByName, and built-in demo scenes merged on first Load().
BuildJsonOptions() is the canonical serializer — the Asset Manager source uses
it too (rather than the shared AssetSizing helpers) so the nested CameraTrack
and the SceneTransitionKind / CameraEase / CameraInterpolation enums
round-trip as human-editable strings.
The built-ins are deliberately region-free (empty RegionName → render the
fractal type directly) so they can never break from a renamed region:
| Built-in | Demonstrates |
|---|---|
| Mandelbulb Orbit | the S3 keyframed camera — one calm 360° fly-around |
| Bulb → Box | multi-shot sequencing + a cross-fade (visible in export) |
| Exposure Ramp | an S8 scene-wide exposure global track over a shot |
The shared orbit helper OrbitTrack(distance, turns, seconds, phi) is worth
reading: it explains why a bare azimuth sweep reads as an in-place spin and
layers an elevation swing (a 1-cos ride, 0 at the ends so the loop is seamless)
plus a gentle dolly to add the parallax that reads as a real camera move.
SceneAssetSource (Engine/Assets/AssetSources.cs) wraps SceneLibrary,
registered ninth in AssetSourceRegistry. The persistence seam is five members
on IColorThemeService (EnumerateSceneNames / GetScene /
SceneExistsInLibrary / SaveScene / DeleteScene) with inert default impls
(Abstractions can't reach Engine) overridden in HostColorThemeService. This is
the same VM-through-IColorThemeService seam the Animation Editor uses so
UI.Avalonia never references Engine. AvaloniaShellBootstrap warms
SceneLibrary.Instance.Load() at startup.
Animation/SceneTimeline.cs is the pure, deterministic playback schedule. It
turns a SceneData into a back-to-back timeline and answers "at global time t,
which shot, how far in, and are we in an opening transition?".
Shots do not overlap in play time — each occupies [Start, End). A shot's
transition is its opening window: for the first TransitionSeconds of shot
i (i > 0, kind ≠ Cut) the composite blends the frozen last frame of shot
i-1 into the live frame of shot i (blend 0→1). Freezing the outgoing frame is
what keeps realtime inside the resource cap — two shots never run live at once.
Build(scene):
- Drops non-positive-duration shots (
OriginalIndexpreserves the mapping back toSceneData.Shots). - First playable shot starts at 0 with no opening transition.
-
Cutshots and the first shot get a zero-length window. -
TransitionSecondsclamps to[0, shot.Duration].
Sample(t) returns a SceneSample: the current (authoritative) entry, its local
time, whether we are in a transition, the outgoing entry, the blend factor,
and the transition kind. Callers that loop pass t % TotalDuration.
SceneTransitions.ResolveVisual(authored) maps the authored kind to what the
build renders. As of S8 every kind is honoured directly (Cut, Crossfade,
LightSweep, ParamMorph) — the pre-S8 collapse of LightSweep/ParamMorph to
Crossfade is gone. LightSweepWeight(u, blend, feather) supplies the pure
per-column incoming weight for the left→right wipe (monotonic in both args; the
soft edge sweeps across as blend rises).
AnimationBusHost.LoadSceneShot(shot, shotAnimation, target) registers the
shot's param animators and its keyframed camera as a CameraTrackAnimator.
This is the deferred S3 consumer — scene-camera motion inherits the bus's
render-completion gate and the animated-param ceiling.
ShellViewModel.PlayScene / StopScene is the realtime driver: a 50 ms
DispatcherTimer walks the timeline; on each shot boundary it jumps the live
view to the shot (region + theme + tone-map) and (re)loads its camera + param +
global-track motion onto the bus. Intra-shot motion is the bus's job. It loops
at the end.
Important
Realtime playback cuts between shots. Cross-fade / light-sweep / param-morph compositing (blending two rendered frames) needs both sides rendered at once; for two live 3-D raymarchers that breaches the ~90 % CPU/mem cap. So frame-composited transitions belong to the offline path (S7), which renders sub-frames anyway. The timeline already computes the blend factor for S7 to consume — no re-work, just a consumer.
Animation/SceneRenderPlan.cs turns a SceneData + SceneRenderSettings (fps,
motion-blur sub-frames, shutter fraction) into the exact list of output frames an
encoder must emit. It is pure — no render, no I/O — and is the deferred consumer
the S6 note promised.
SceneRenderSettings (clamped by Build): Fps ≥ 1,
MotionBlurSubframes ≥ 1, ShutterFraction ∈ (0, 1].
Build(scene, settings):
- Frame count is
ceil(total * fps - 1e-9)— the trailing partial frame is emitted (so the last shot's tail isn't truncated), with the-1e-9guarding the float edge so an exact multiple doesn't add a spurious frame. - Per output frame f, each sub-frame k samples at
frameStart + (k + 0.5)/sub * shutterDur, whereshutterDur = frameDur * shutter. Sub-samples spread evenly across the open-shutter window at the frame's leading edge. Weights are uniform (1/sub, a box filter, summing to 1) — this is the classic Reyes-style accumulation blur. - The transition is resolved at the frame midpoint (a stable,
shutter-independent choice). If the midpoint sample is in a resolvable
transition, the frame is flagged
CompositeTransitionwith the outgoing shot index, its frozen local time (its full duration = its final frame), the blend, and the resolved kind.
Engine/Export/SceneVideoRenderer.cs consumes the plan. It resolves each shot
once against the region / theme / animation libraries (self-contained — no
live render host, so it is callable headless), then for each output frame:
- Renders every sub-frame via
PosterRenderer's offscreen calculator, applying the shot's param animation + keyframed camera at that sub-frame's local time. - Weight-averages the sub-frames (accumulation motion blur).
- Inside a transition window, composites the frozen outgoing frame by the
plan's blend — the frame-composited cross-fade S6 deferred here. LightSweep
uses
LightSweepWeightper column; ParamMorph renders the incoming shot with morphed params (below) instead of compositing two frames. - Applies the shot region lighting, then the scene global tracks, then the per-shot tone-map — in that order, so each overrides the last.
Peak memory is a single frame's accumulators plus the pending PNG queue — one
calculator is live at a time, keeping it inside the cap. Frames go through the
cross-platform PngSequenceWriter → FfmpegEncoder pipeline the batch
video/slideshow paths already use; a missing ffmpeg keeps the recoverable PNG
sequence rather than failing.
-
Headless:
--batch --mode scene --scene NAME(BatchRenderer.RenderScene) with--motion-blur N(1–64) /--shutter F/--fps/--encode/--width/--height/--out/--keep-frames. -
GUI: the Scene Editor's ⤓ Export… button raises
ExportSceneRequested(SceneExportEventArgs, an Engine-free DTO); the host (AvaloniaShellBootstrap) picks the path, maps the knobs ontoSceneVideoOptions, and runsSceneVideoRenderer.Renderon a background thread — keeping UI.Avalonia free of the Engine, per theSaveFileRequested/MessageRequestedhost-fulfilled pattern.
Animation/SceneParamMorph.cs — Lerp(from, to, t) is a component-wise lerp
over every public read/write double on FractalParameters (the continuous
shape knobs), on top of a clone of the incoming shot for all discrete state.
The renderer renders the incoming shot with these morphed params across the
window — the shape itself morphs — rather than compositing two frames. Guarded
to same-fractal-type shot pairs; degrades to a crossfade otherwise (the one
decision made at render time, from the resolved shot types).
Animation/SceneGlobalTrack.cs — a scene-wide keyframed scalar, sampled at
global scene time and applied on top of every shot. It reuses the S3/D.1
CameraInterpolation + CameraEase vocabulary (default Linear — a look ramp
wants a monotonic sweep, not spline overshoot that could push exposure below 0).
SceneGlobalTarget names the continuous FractalParameters.Lighting post
knobs: Exposure (the headline), BloomStrength, BloomThreshold, Vignette,
ChromaticAberration. SceneGlobalBinding.Apply/Read is the one-switch,
data-driven seam (mirrors CameraParamBinding); because Lighting is a struct
it read-modify-writes the whole value. SceneGlobalTracks.Apply runs the whole
set at one time — later track wins on a shared target (mirrors
AnimationData.Tracks), a null/empty list is a no-op.
Consumers: the offline renderer applies them at each sub-frame's global time,
last; the realtime driver re-installs a SceneGlobalTrackAnimator per shot (the
bus clears its dynamic set on each cut) seeded at the shot's global start, so the
sweep continues mid-timeline across a cut instead of restarting. Cost is Cheap,
so the ceiling never sheds it ahead of a raymarch track.
Note
Why tone-map is per-shot, not a global track. A tone-map operator is a
discrete look decision (None / Reinhard / ReinhardExtended / ACES), not a
continuous scalar you can keyframe — so SceneShot.ToneMap lives next to the
region/theme picks, and SceneGlobalTarget carries only the continuous knobs.
SceneShot.ToneMap is a nullable ToneMapOperator; null inherits the shot's
region lighting, a value pins the shot's HDR tone-map. The offline renderer
applies it last (after region lighting + global tracks); the realtime driver
pins it on the live params at each shot cut. Null omits from scenes.json.
Per the roadmap's S8 "still open" list — none block the core author → preview → export loop:
- Graphical Bezier-handle curve editor — beyond the per-key ease enum + the JSON global-track authoring. A heavier follow-up.
- A Scene-Editor global-track row — global tracks are authored today via the Asset Manager's JSON-editable Scene node.
-
FOV / dolly-zoom camera track —
CameraStateis the orbit triple only; a true field-of-view zoom needs a new raymarcher input. -
IBL-sky-rotation global track — no field exists yet (the HDRI sampler
reads the surface normal with no yaw offset). The
SceneGlobalTargetenum + binding are built so it slots in for free once the Lighting-FX field lands. - Rack-focus preset and audio-reactive scenes (Animation-roadmap D.4).
Add a global-track target. Add an enum entry to SceneGlobalTarget, then two
lines each in SceneGlobalBinding.Apply and Read. Add a SceneGlobalTrackTests
round-trip case. Done — the editor/JSON pick it up via enum reflection.
Add a camera-bearing fractal type. Add the three <Type>Camera* double
properties to FractalParameters, then one entry to CameraParamBinding.Names.
The round-trip test in CameraTrackTests will confirm the names resolve.
Add a transition kind. Add to SceneTransitionKind; teach
SceneTransitions.ResolveVisual how it resolves; implement the composite in
SceneVideoRenderer. Realtime will cut (correct — it can't composite live);
offline renders it. Add a SceneTransitionVisualsTests case for any pure weight
function.
Consume the timeline elsewhere. SceneTimeline and SceneRenderPlan are
pure and headless — a new consumer (a different encoder, a network render farm)
just walks Frames / Sample(t). No re-work, just a consumer, as every phase
here demonstrates.
- Scene Engine User Guide — the end-user view.
- Scene Engine Roadmap — phase-by-phase status.
- Animation Roadmap — the param-animation bus the camera track and scene playback build on.
- Lighting + FX Roadmap — the shipped 3-D fidelity stack Scenes render with.
- Performance Roadmap — the perf knobs S2 wires into tiers.
-
Architecture Overview — where
SceneLibraryandResourceGovernorslot into the module map. - Resources & Bibliography — citations for the splines, motion blur, and tone-map operators.