Simulation · RadianceLab
Five physics solvers, a Gray-Scott reaction and a bank of shaders that carry state between frames run inside a real-time path tracer, and three of the solvers put their state into the transport itself — as media that shadow rays intersect, not as an overlay. Which of these systems a photon can actually see is the subject of this page.
Every claim on this page was read out of the source and anchored to a line, not measured off a film, so the marks are seen and never viewed rather than measured. Almost none of it has been photographed yet: simulation is the one subject on this site a still cannot carry, so the placeholders below are shooting lists for clips.
01 — Where simulation meets transport
The test of a renderer with a physics engine inside it is not whether the physics runs. It is whether a photon can see the result. Here it can, by three genuinely different mechanisms — and the strongest of the three is not the one the UI advertises.
The advertised route is the Physics density source: a button in the volume properties panel whose tooltip reads “Euler/SPH/MPM fluid density from physics simulation.” That tooltip is a mislabel and the catalogue records it as one. uploadPhysDensityTexture reads m_physics.eulerDensity() and nothing else, and m_eulerDensity[…] is written only inside the three Euler routines. Choose SPH, MPM, Simple Particles or Soft Body and the grid stays at the zeros init() filled it with, so the volume renders empty.
The unadvertised route is the real coupling. When Show Particles is on and the solver is SPH or MPM, every particle is pushed into the tracer’s primitive buffer as a real volume sphere — radius 0.03, σs 20, σa 2, isotropic phase, material 0. These are not an overlay. They are scene primitives that shadow rays intersect, that appear in mirrors, and that scatter along every bounce. It is the only place in the renderer where a particle simulation becomes participating media.
| Route | What travels | Who can see it | Mark |
|---|---|---|---|
| Euler grid → density texture EnginePhysics.cpp:278-300 | An R16F 3-D texture, 64³, re-uploaded every physics frame | GPU megakernel only, as volType == 5. The transport side of this route is complete; the initialisation side has an ordering problem — §03 | scene-dependent |
| SPH / MPM particles → volume spheres EngineRender.cpp:2010-2026 | One GPUPrimitive per particle, appended after the scene’s own primitives | GPU megakernel only. Every CPU integrator builds its scene from scene.entities, which never contains them | never viewed |
| Node-graph live procedural → surface channels pathTrace.comp:1017-1136 | A time-linked 3-D pattern overriding albedo, roughness, metallic, emission colour or opacity per hit | GPU megakernel only. NEE reads the material buffer and never sees the override | never viewed |
| Live Gray-Scott 3-D texture EngineRender.cpp:1835-1841 | An RGBA16F 64³ ping-pong sim, bound as uRDTexture | Two shader branches, both behind a gate the presets that start the sim happen to close — §04 | superseded |
| SPH / MPM / Soft Body particles → GL points | Screen-space points coloured by speed or material type | Drawn over the finished frame. No shadow, no reflection, no light | seen |
The coupling, and its edge in the same breath. Three of the five solvers reach the traced image — two by particle injection, one by the density grid — and they reach it on the GPU megakernel, the renderer that runs in real time, not on any of the other eight integrators. The remaining two, Simple Particles and Soft Body, reach it by no route at all; their output is the GL point overlay and nothing else. So “five physics solvers coupled to a path-traced medium,” the phrase on the landing page, is exact for three of them on one renderer, and this is the page that names the other two.
The injection route also costs more than it looks. invalidateScene() is called on the frame the particle list changes, which rebuilds and re-uploads the entire primitive SSBO; while the simulation is running that is every frame. And the GPU intersector is a linear scan, with a second full scan per bounce for the volume interaction, so the SPH Dam Break preset’s 2,000 particles are 2,000 extra entries in a list that is walked twice on every bounce of every path. This is a physically correct visualisation mode rather than a production path — and physically correct is the part that matters in a renderer whose subject is where light goes.
02 — What motion costs a progressive renderer
A progressive path tracer accumulates. Anything that moves invalidates what has been accumulated. This renderer solves it twice, with two different mechanisms; one of them is genuinely good, and the subsystem that gets it is not the one that needs it most.
Animated volume noise gets the good one. The Animate Volumes toggle does not reset accumulation. It changes the accumulator’s weight from the uniform running mean 1/(n+1) to a per-pixel floor derived from how much that pixel actually changed:
That is a genuinely good design, and the code says so out loud — the reaction-diffusion stepper carries the comment “Do NOT resetAccumulation here — the adaptive temporal EMA in the shader handles the time-varying RD texture, keeping static parts smooth.” The walls of an RD room converge to a clean image while the volume in the middle churns.
Physics gets the blunt one. The physics step sets m_ui.needsAccumReset = true unconditionally at the end of every stepped frame, and the render loop turns that into a full resetAccumulation() — the accumulation image is cleared and the frame counter returns to zero. So while a simulation is playing, every displayed frame is a fresh one-sample-per-pixel path trace, everywhere in the image, including the parts of the room that are not moving at all. The adaptive EMA that would have fixed exactly this is sitting in the same shader.
The wall clock in that last row has a consequence worth stating. Because scene.nodeTime is assigned ImGui::GetTime() unconditionally every frame, and the uniform prefers it over the frame-counter fallback, a slow render skips animation rather than slowing it. A heterogeneous volume that runs at 8 fps advances the same distance per second as one running at 60, so the EMA is averaging over larger jumps and the fog reads blurrier. The frame-counter fallback at ComputePathTracer.cpp:1803-1805 would have made animation deterministic per frame; it is unreachable, because nodeTime is positive from the first frame onward.
A second self-adjusting knob, in the other direction. The physics stepper measures its own wall time and edits the UI. SPH and MPM get a minimum substep count computed from a target sub-timestep — 0.004 s for SPH, 0.002 s for MPM, hard-capped at 16 — written back into m_ui.physSubsteps so the slider visibly jumps up when you select a solver. Then, if a step takes longer than 16 ms, the substep count is decremented and written back again. Under load the simulation silently trades accuracy for frame rate and moves the slider while you watch. Honest, undocumented, and worth one clip.
03 — The physics bank
All five live in PhysicsWorld behind one simType integer, stepped from Engine::update with a substep loop. Four of the five are guarded by an emptiness check, so selecting a solver does nothing until something seeds it; Euler is the exception and steps unconditionally, which is part of why it is the one that ends up with a grid worth uploading.
Sixteen is what the buttons come to. Exactly sixteen named preset buttons exist — SPH 4, MPM 6, Simple Particles 6, all built from one presetBtn lambda at UIManagerRender.cpp:5009–5123. The Seeding column below also mentions SPH's default uniform grid and Soft Body's two shapes, which are not buttons; an earlier heading rounded the whole thing up to twenty, and twenty is not derivable from anything.
| Solver | Method | Seeding presets | Feeds the tracer | Mark |
|---|---|---|---|---|
| SPH Fluid | Smoothed-particle hydrodynamics; kernel radius and rest density auto-solved from particle spacing on init and logged | 4 — Dam Break, Droplet, Double Dam, Column — plus a default uniform grid | Yes, as injected volume spheres | seen |
| MPM | MLS-MPM with an APIC affine velocity field; three material types — elastic, snow, fluid | 6 — Snowball, Elastic Cube, Two Material, Fluid Pour, Wall Smash, Snow Stack | Yes, as injected volume spheres | seen |
| Euler Fluid | MAC grid, semi-Lagrangian advection with Jacobi pressure projection, buoyancy and vorticity confinement. Labelled [WIP] in its own panel | None. initEulerFluidTopEmpty fills the lower half and is called once; initEulerFluidBlob has no caller | Its grid is the only one uploaded — but see the ordering problem below | scene-dependent |
| Simple Particles | AABB-bounded balls, two collision modes — soft repulsive spring or hard positional penalty | 6 — Rain, Fountain, Explosion, Dam Break, Snow Globe, Zero-G | No | seen |
| Soft Body | Mass-spring with structural, shear and bend springs; box or ball. Labelled [WIP] | 2 shapes × grid resolution 2–10 | No | never viewed |
| Rigid bodies | stepRigidBodies runs every substep regardless of simType, with restitution, friction and a sphere inertia model | None. addRigidBody has zero call sites anywhere in src/ | No — the list is always empty | absent |
Reachability, not call sites. The rigid-body row is the one case here that survives the audit rule that caught the light tracer. The function has a caller — it runs on every substep of every solver — so a naive grep grades it live. But the feature has no route: nothing anywhere constructs a RigidBody, no preset, no panel, no config key. Q1 is no, so this is genuinely absent, and it is absent while still costing a loop iteration sixteen times a frame.
This is a source read, not a measurement, and it comes with the one capture that settles it, because it bears directly on the claim the landing page makes.
One press of Play, or any change that sets physNeedsInit, runs a single block in Engine::update in this order:
PhysicsWorld::init is four statements long. It stores the config, sets m_eulerRes = 64 unconditionally, and reassigns both the density and the velocity vector to 64³ zeros. So it runs after the seeding and undoes it: the resolution the slider asked for is replaced by 64, and the “bottom half dense” field initEulerFluidTopEmpty just built is replaced by zeros. uploadPhysDensityTexture(), three lines later, uploads that.
Nothing puts density back. stepEulerFluid has no source term — it advects and dissipates what is already there, at 0.995 per step — and the wind injects velocity, not density. The only two routines that write density are the two initialisers, one of which is erased and the other of which, initEulerFluidBlob, has no caller anywhere in src/.
What this predicts, and how to falsify it in one screenshot. If the reading is right, a VolumeBox on the Physics density source renders empty for every solver, Euler included, and the Resolution slider is inert while still visibly restarting the sim — the same “fakes a response” class as the three photon-map controls on the catalogue’s inert list. The test is the shot specified at the top of §01: preset 0, a VolumeBox with density source Physics, Sim Type Euler Fluid, press Play, wait five seconds. Smoke means the reading is wrong and this paragraph should be deleted. An empty box means the advertised coupling has been broken by a four-line function called one line too late. Either way it is one capture, and it is the highest-value capture on this page.
Three more Euler parameters sit just behind that. eulerBuoyancy, eulerVorticity and eulerDissipation each occur exactly twice in the entire source tree — the struct default and the read inside the solver. There is no slider, no config key and no writer, so buoyancy is pinned at 1.0, vorticity confinement at 0.5 and dissipation at 0.995 forever. The solver implements them properly; nothing can reach them.
Pressing R in FPS capture toggles a wind force whose origin is the camera position and whose direction is the camera forward vector. You aim it by looking. On the CPU path it adds a quadratic-falloff velocity impulse to the Euler grid cells within the radius, and separately pushes SPH, MPM and Simple particles; on the GPU particle path the same parameters go to particleSim.comp as uniforms.
The Wind Blower panel offers a Shape combo — Cone or Cylinder — and a Width slider that only appears for Cylinder. Neither reaches the solver. windShape occurs in five places in EngineRenderViz.cpp, three in the panel, one in the header; windWidth in two, one and one. applyWindForce takes origin, direction, strength and radius, and the field it applies is a sphere. So the shape control changes the overlay you are shown and not the force you are applying, which is the same defect class as the physics-density tooltip: a widget that draws its own promise.
One more asymmetry: the wind pushes SPH, MPM and Simple particles, and does not touch soft-body nodes. A soft body is immune to the wind.
04 — The simulation nobody has seen
This is the sharpest example on the page of a system that evolves state, feeds the transport, and is invisible — and unlike most such stories the mechanism is short enough to state in full.
Loading any of the eight RD scene presets does four things. It applies a Gray-Scott parameter pair to ReactionDiffusion, initialises a 64³ RGBA16F ping-pong pair seeded with five overlapping blobs plus noise, runs 500 warm-up steps so the pattern is already formed when the first frame draws, and builds a scene whose volume spheres carry volNoiseScale = 51.0f with the source comment // Style 51 = RD 3D texture. From then on the sim steps rdSubsteps times per frame — a 1 to 32 slider — and the result is bound as uRDTexture.
Two shader branches read that texture. One turns the V concentration into density through a smoothstep(0.05, 0.35, v) with a boundary fade. The other — the better one — turns it into coloured emission: a hue cycled by V concentration and world position, masked by pow(smoothstep(0.25, 0.5, v), 2.0) and multiplied by 8.0, so the activated regions of the reaction glow and light the room.
Both branches sit inside if (!typeHandled). And the same scene builders that set volNoiseScale = 51 also set volumeType = ReactionDiffusion, which is volType == 2, which is dispatched about eighty lines earlier and sets typeHandled = true. What you see in presets 5–8 and 10–13 is therefore reactionDiffusionPattern3DNode — a hand-tuned procedural emulation built from Worley cells, a sine fold and a domain warp — while the actual simulation runs, steps, and is thrown away.
| What | Where | State | Mark |
|---|---|---|---|
| Gray-Scott 64³ GPU compute sim | ReactionDiffusion.h:29-145, reactionDiffusion.comp | Runs, steps, is bound as a sampler | superseded |
| — density branch | pathTrace.comp:384-395 | Behind !typeHandled and vNoise > 50 | never viewed |
| — coloured emission branch | pathTrace.comp:671-686 | Also needs a non-zero volume emission strength, which no RD preset sets — the field defaults to 0 | never viewed |
| Procedural RD emulation (what you actually see) | pt_noise.glsl:267-318 | Three pattern families in the function; the volume path hardcodes rdType = 0 | seen |
| RD panel — 8 presets, feed / kill / diffusion U and V, substeps 1–32, Reset Grid | UIManagerRender.cpp:5148-5182 | Drives the sim that is not sampled | seen |
There is a way to see the real simulation, and it is one click. The volume properties panel’s Remove Source button sets volumeType = None, clears useNoiseLab and clears proceduralStyle. It does not touch volNoiseScale. So the entity drops out of the typed dispatch, falls through to the legacy fallback, arrives at vNoise > 50.0 with its 51 still set, and samples uRDTexture — the live sim, stepping, warmed. Add a volume emission strength above 0.01 and the emission branch lights up too.
Correction to a page already published. ten-integrators-one-scene.html §06 grades this row “branch runs, result discarded” and describes the investigation honestly: the branch was forced to density = 0.0 and the probe re-run byte-identical, centre pixel 0.04631 either way, concluding “something downstream throws its result away. Unfixed.” The measurement was right and the conclusion was one step short. Nothing downstream discards anything — the branch that was zeroed is never reached, because a typed dispatch upstream claims the entity first. The same page’s practical advice, shoot one preset per RD group, holds for a different reason than the one given: the emulation does vary with preset index, but only through two coefficients, so the four RD presets differ far less than the four Gray-Scott parameter pairs would.
05 — Every generator, and who reads it
All procedural density in this renderer comes from shaders/modules/pt_noise.glsl, a single 853-line module built on one value-noise base. It generates eight density sources and six Noise Lab types, animated; the finding is which renderer is allowed to look.
| Function | What it is | Calls in the volume path | Mark |
|---|---|---|---|
| hash3 / noise3 | The value-noise base everything else is built on | 14 / 39 | seen |
| fbmNoise(p, octaves) | Fixed-parameter FBM; the workhorse of the legacy style bands | 5 | seen |
| worleyNoise | Cellular F1 distance, inverted so cells read dense | 7 | seen |
| voronoiNoise | F2 minus F1 — cell-edge detection | 3 | seen |
| fbmConfigurable | FBM with explicit lacunarity and persistence | 1 | seen |
| ridgedNoise, marbleNoise, turbulenceNoise, worleyFBM | Named, compiled, never called | 0 each | superseded |
The last row is a Rule-1 case in the good direction. Those four functions have no call site, but the noise types they name are all reachable, because noiseLabDensity inlines its own octave loop with the same formulae so it can apply the eight per-octave amplitude mutes. Ridged is rn² where rn = 1 − |2n − 1|, turbulence is |2n − 1|, Voronoi is 1 − voronoiNoise, and Marble skips the loop entirely for a multi-axis sinusoidal warp with 1 − |vein|0.3 giving thin bright veins. The functions are dead; the features are not. Grade superseded, and say where they live.
Animation enters the Noise Lab as an anisotropic drift of the sample position, vt · timeSpeed · (1, 0.7, −0.5), gated by uAnimateVolumes. Every legacy style band advects differently — the Fire band falls at −0.4t in y so the flames rise, the Lightning band shifts its filaments on a sine.
This is the finding, counted. Whole-word occurrences of the six entity fields that carry heterogeneous density — noiseConfig, volNoiseScale, proceduralStyle, volumeType, sdfShape, rdPreset — across every file in src/render/:
| Integrator | Refs | What a structured medium looks like there | Mark |
|---|---|---|---|
| GPU megakernel (ComputePathTracer.cpp) | 17 | All eight density sources, all six Noise Lab types, all three phase functions, animated | measured |
| UPBP (UPBPIntegrator.cpp) | 7 | A partial CPU re-implementation — see below | scene-dependent |
| Photon primitives (PPIntegrator.cpp, mode 9) | 0 | Homogeneous. Structured fog is inert; the Noise Lab scale slider does nothing | superseded |
| VCM, MLT / PSSMLT, Path Guiding, Manifold, Photon Mapper, Light Tracer | 0 each | Homogeneous, and static — none of them has a time input either | superseded |
Two counting notes, because both numbers have been wrong on this page before and the corrections stay visible. The nine is nine integrator .cpp files — src/render/ holds twelve, of which RasterRenderer.cpp, PPDiagram.cpp and PhotonPrimitive.cpp are not integrators. MLT and PSSMLT share MLTIntegrator.cpp, and the ninth file is LightTracer.cpp, which an earlier version of this table left out. It belongs in the row: the file is superseded, but the feature is reachable as two Light Strategy combo entries running inside the megakernel — the exemplar case in docs/FEATURE-REACHABILITY.md, and not one to leave off a list of integrators on this of all sites. And the reference counts are whole-word occurrences of the six fields. A substring grep returns 23 for the megakernel, because it also matches noiseConfigs — the local vector of packed GPU structs, a different identifier; counting matching lines instead returns 21, because two of those lines carry two matches each. Seventeen and seven are the occurrences of the six named fields, and that is the number this row means.
UPBP’s seven references are a partial re-implementation with its own Perlin FBM, and its failure modes are specific enough to be worth naming. Its Noise Lab branch honours octaves, lacunarity, persistence and frequency and ignores the noise type, so Worley, Ridged, Marble, Turbulence and Voronoi all render as Perlin FBM. Its legacy-procedural branch covers styles 1–10 and falls back to FBM for 11–15. SDF, Physics and NanoVDB are not handled at all. And the fall-through case, volNoiseScale > 0.01, is a plain FBM at that frequency — which means loading an RD scene in UPBP renders the volume as fine-grained FBM at frequency 51, because 51 was a style selector, not a frequency. Nothing in UPBP takes a time argument, so all of it is frozen.
The finding, stated plainly. A noise field that drives density in one renderer and is silently ignored by another is not a bug report; it is a property of the system, and a reader needs it before comparing two images. Loading preset 15 and switching from the GPU tracer to mode 9 does not give you the same scene at a different noise level. It gives you a different scene: uniform fog at the volume’s base σ, holding still.
The volume noise config drives density and nothing else. It never displaces geometry and never touches surface colour, and evalVolDensityAt returns a single float.
The node graph is the other mechanism, and it does more. A live 3-D procedural wired into a BSDF or Emission output is evaluated per hit in the shader and can override albedo, roughness, metallic, emission colour or opacity, either replacing the material value or multiplying it, chosen per channel by a blend bitmask. On the volume side the same machinery reaches σa, σs, medium tint, volume emission colour and volume emission strength, from up to two procedural slots per primitive.
Two limits sit on that, and both are one line of source each.
The eight density sources above are the noise a reader comes to this page for. Two others sit outside them: the random stream that drives every sample in the megakernel, and the only sensor-side noise model in the renderer. Both are worth a line because in both cases the source is narrower than the name.
| What | Where | What it actually is | Mark |
|---|---|---|---|
| Sampler RNG | pt_random.glsl:5-66, seeded pathTrace.comp:3685 | One PCG hash and a five-line rng() advancing a single uint in place. Three samplers are built on it — cosine-hemisphere, isotropic GGX, anisotropic GGX — and so is the sub-pixel filter offset | seen |
| Sensor noise models | tofCW.frag:164-175, tofTracer.frag:263-267, 347-349 | An ambient offset and a “shot noise” term added to a synthetic four-shot phase measurement, in the two time-of-flight benches. Uniform, signal-independent, and frozen — see below | never viewed |
The RNG is seeded once per pixel per frame as pcgHash(pixelIndex ^ pcgHash(uGlobalFrame)), and everything a path needs is drawn from that one stream in order. There is no low-discrepancy sequence and no stratification: sobol, halton, low-discrepancy and owen have zero occurrences anywhere in shaders/; blue noise appears only as a 64×64 R8 texture generated for the dithering post-effect, and never in the tracer; stratify appears only in the photon-primitive picker, where it thins a hover selection for display. The tracer is plain independent Monte Carlo, and the site should not be read as claiming otherwise.
A catalogue row this page has to correct. The inventory’s sampler row names four samplers built on rng(), the fourth being VNDF — visible-normal-distribution sampling. vndf and visible normal have zero occurrences in src/ and in shaders/, case-insensitively. The GGX samplers here draw a half-vector from the NDF itself, and the renderer states that in the one place it had to be precise: the photon-primitive integrator’s white-furnace self-test records that it integrates the GGX arms “by sampling the NDF half-vector … pdf_wi = D NdotH / (4 VdotH)”, because a uniform hemisphere cannot integrate a roughness-0.05 lobe. That is the NDF form and not the visible-normal one. Three of the four names are right; the fourth is not, and where a row and the source disagree the source wins.
The two time-of-flight benches each synthesise a four-shot continuous-wave measurement per pixel and recover the phase from it. Both use the same two lines:
Its only remaining route to the recovered phase is the clamp, and over the sliders’ full range the upper bound is unreachable: the largest a shot can be is 1.0 + 0.3 + 0.1 = 1.4, against a ceiling of 1.5. The lower bound at zero does bite, and only near a trough, and only when the ambient offset is smaller than the noise amplitude. So the one effect the Ambient slider has on a recovered depth is to reduce the error, by lifting the shots off the zero clamp — the opposite of what a control labelled Ambient reads as promising.
The noise term is (hash(uv, k) − 0.5) · 2 · uNoise: uniform on a symmetric interval and independent of the signal. Shot noise in a real sensor is Poisson and scales with the square root of the collected charge; this does not, so the slider is a fixed-amplitude dither wearing the name of a physical process. And neither shader takes a time or frame uniform, so the pattern is identical every frame — it is fixed-pattern noise, pinned to sensor uv, and accumulating passes will never average it away. That is the property that decides whether the multi-frequency unwrapping cascade beside it is being tested or merely decorated, and it is the reason this row is worth a paragraph rather than a line.
06 — Simulations that are patterns
Sitting in the node editor’s texture palette are three nodes whose names promise simulation. All three are procedural fields. They are not fake — a shaped noise field that reads as fluid is a legitimate texture, and one of the three is a rather good one — but the naming invites a reader to expect a coupling that is not there, and one of the three exposes a solver selector that changes almost nothing.
| Node | Name promises | Implementation | Mark |
|---|---|---|---|
| Eulerian Fluid 3D pt_noise.glsl:360-396 | An Eulerian fluid | A curl-like flow field built from six noise3 differences advecting the sample point, then a billow FBM mixed with inverted Worley filaments and a sine eddy term. Snapped to a voxel lattice by a gridRes parameter, which is what gives it the grid look. Genuinely time-varying and rather good | never viewed |
| Physics 2D / Physics 3D NodeGraph.cpp:1096-1112 | A Source combo reading Euler / SPH / MPM | Orbiting Gaussian blobs on sine and cosine paths. The solver selector sets the blob count — 3 for Euler, 6 for SPH, 4 for MPM — and changes nothing else. No connection to PhysicsWorld of any kind. The panel says so: “Physics nodes are still preview approximations while the real source path is being restructured.” | honest stub |
| Reaction Diffusion 3D pt_noise.glsl:267-318 | Gray-Scott, SmoothLife or Stylized, by a Type combo | All three families are implemented and reachable from the node editor. The volume Properties panel’s RD density source hardcodes rdType = 0, so two of the three are unreachable by that route | never viewed |
| Volume Incremental 3D pt_noise.glsl:412-432 | — | A translating soft shape with a velocity vector, shape selector and softness — the closest thing in the file to a genuine advected emitter | never viewed |
Where the Physics node ends up. detectProcType, which decides what a live 3-D procedural becomes on the GPU, lists ten node types and does not list Physics3D. But isSpatial3DNodeType does. So a Physics node is treated as a live 3-D procedural for one purpose and as an ordinary bakeable node for the other. Whatever reaches the render from it reaches through the CPU bake, not through the per-hit path the other nine take. The panel’s own note is the correct summary and this is a footnote to it, not a contradiction.
The Eulerian Fluid node deserves better than its neighbours. It is called from two sites in the megakernel — the volume density path and the per-hit surface override path — so the same field can drive fog density and a surface’s albedo in one scene, at the same wall-clock time, from one node. Nobody has rendered that yet.
07 — Which of these actually evolve
The image-space bank contains twenty operators filed under “field-based and animated” plus three under “statistical physics and stochastic,” and their names — Navier-Stokes, Schrödinger evolution, Ising model, percolation, morphogenesis — all suggest state evolving over time. Most of them have none. The distinction is mechanical and cheap to check: does the shader sample a previous-frame texture? Note sample, not declare — two shaders here declare one and never read it, and both say so in the declaration. The image-operator page applies the same test to the whole display chain, NPR combo and tail included, and finds seventeen; this table is the twenty-three field-based and statistical-physics operators only, which is why the two numbers differ.
Five do — four from those twenty-three, plus SmoothLife, which is NPR mode 13 rather than a bank toggle and is here because it is the cleanest example of the mechanism. The rest are pure functions of the current frame, re-derived from scratch every time it is drawn, which is why they hold still when the camera does and why they change discontinuously when it moves.
| Operator | Reads prior state | What that means on screen | Mark |
|---|---|---|---|
| Wave equation | Yes — two levels, uPrevTex and uPrev2Tex, ping-ponging over three buffers | A genuine explicit leapfrog. Ripples propagate, reflect and interfere across frames | never viewed |
| SmoothLife (NPR mode 13) | Yes — ping-pong RGBA16F pair, with a scene-influence parameter for continuous coupling to the render | The only true cellular automaton in the repository. Evolves over frames rather than filtering one | never viewed |
| IBFV | Yes — advects the previous IBFV frame along the flow | Image-based flow visualisation as designed; streaks build up over time | never viewed |
| Curl noise flow | Yes | Divergence-free advection of the accumulated frame | never viewed |
| n-body attractor | Yes — warps the previous warped frame | Compounding deformation | never viewed |
| Reaction flow | No — uPrevTex is declared and commented “unused but kept for C++ binding compat” | A per-frame filter with a reaction-diffusion look | stateless |
| Morphogenesis | No — uPrevTex declared, marked unused | Same | stateless |
| Navier-Stokes, Schrödinger evolution, magnetic field, plasma spectrum, EM scattering, gradient-descent flow, Hodge and Helmholtz decomposition, heat method, Laplace eigenfunctions, Ricci flow, parallel transport, spinor field, gauge theory | No — no previous-frame sampler declared at all | Each is a closed-form or fixed-iteration function of the current frame | stateless |
| Ising model | No — N Metropolis sweeps run inside one shader invocation, on a lattice re-seeded from scene luminance every frame | Domains form, but from the image, not from the last frame. The shader header states this in full | never viewed |
| Percolation | No — occupancy hashed from luminance, then N label-propagation passes in one invocation | Connected components resolved out to a neighbourhood of 2N pixels; the spanning cluster is highlighted separately | never viewed |
| Markov chain walk | No — N steps back-evolved per pixel in one invocation | A stationary-like density over the image’s luminance basins | never viewed |
Being stateless is not a defect here and the source does not pretend otherwise — the Ising shader’s header explains the choice and its reason: “no ping-pong state needed … a re-seeded run per frame … visualises how correlations build up from the image content.” That is a design decision with an argument attached. But it changes what you are looking at. A per-frame Ising is a function of the image, so once the path tracer has converged and the input stops changing, the Ising output stops changing too. It looks like a simulation that has reached equilibrium. It is a still image of a fixed computation.
The three statistical-physics operators are also the only ones in the bank shipped with their critical constant on a button: p_c = 0.593 for percolation, kT_c = 2.269 for Ising. One press puts you on the critical point, which is the only place the fractal structure exists, and the sliders around it are wide enough to walk off it in either direction.
08 — The absences
Everything above happens in real time in a viewport and then stops existing. The renderer has no timeline, no keyframes, no motion blur and no frame export. That is the single largest gap on this page, and unlike most gaps on this site it is not a case of a feature living somewhere else.
| What is not there | What exists instead | Mark |
|---|---|---|
| Animation timeline | src/ui/Timeline.h is a complete 108-line design — tracks with string target paths like entity.0.transform.position.x, four interpolation modes including cubic Bezier with tangent handles, track groups with mute and lock, playback state. There is no Timeline.cpp, no file includes the header, and it is not in CMakeLists.txt. Nothing is reachable, so this is absent rather than superseded | design only |
| Motion blur | shutterOpen and shutterClose exist in CameraParams and Camera::generateRay writes ray.time from them. Nothing downstream reads ray.time. The field is not merely unused: it consumes an RNG draw, which the photon-primitive integrator has to account for explicitly in its correlated-batch stream split — a dead feature that still perturbs the sample sequence | absent |
| Frame or sequence export | File ▸ Export Image and Render ▸ Take Snapshot (F12) are stubs; F12 logs “Snapshot requested” and returns. The only working image writer in the program is the comparison-report generator | absent |
| NanoVDB volumes | An enum value, a name in the type-label array, and a disabled button reading “NanoVDB (soon)”. No loader, no shader branch; volType == 4 falls through to the legacy path | absent |
| A GPU Euler solver | shaders/eulerFluid.comp exists, complete with its own wind term, and is loaded by nothing. The live solver is CPU, in PhysicsWorld.cpp:565-732 | superseded |
| Surfacing a simulated field | marchingCubes and splatNodesToDensity compile and have no callers, so a fluid can be a medium but never a mesh | absent |
| A SmoothLife volume | Presets 4 and 14 are labelled SmoothLife and are legacy noise band 41–50: an FBM bell with a tentacle falloff and a sine pulse. The only real SmoothLife in the repository is the NPR post-process in §07 | mislabelled |
The timeline row is the one to sit with. Somebody designed an animation system carefully — the target-path scheme is the right one, the Bezier handles are stored normalised, groups carry mute and lock — and then stopped before the implementation file. Every simulation on this page is consequently a live performance: you can drive it with the wind key, watch it, and lose it. That is why this page is mostly video placeholders, and why every one of them is a screen recording of a session rather than a render.
Two of the rows above — the Euler solver and the soft body, both of which print [WIP] on their own panel — are also entries in the program-wide register of thirteen partly-built modules on the page for everything that did not get one. That register counts them; this page is the census behind them, and neither repeats the other.
What would change the picture. Four of these gaps are one route each rather than one subsystem. Moving m_physics.init(cfg) above the seeding block instead of below it would give the Euler grid its initial density and give the resolution slider an effect — §03, and one screenshot away from being confirmed or dropped. Injecting Simple Particles alongside SPH and MPM in the tracer-visualisation path would take the coupled-solver count from three to four. Clearing volNoiseScale in Remove Source and adding the missing dispatch case would make the live Gray-Scott sim the thing the RD presets show. And moving the physics stepper from resetAccumulation() onto the adaptive EMA that reaction-diffusion already uses would let a simulated fluid converge everywhere it is not moving. None of the four is a research problem; each is a short edit to a route that already exists, and they are listed here so the next reader knows exactly where the coupling widens.