VFX particles
This file covers the particle system: the authoring model both backends compile from, the deterministic CPU backend and the cosmetic GPU path, every render alignment and material feature layered on them, and the component-shaped authoring surface in the editor.
1. VFX particle system (authoring model, dual backends, GPU render — phase VFX1)
Design: docs/design/VFX_PARTICLE_SYSTEM.md. One authored effect asset feeds two simulation
backends behind one seam — a GPU-cosmetic path (millions of particles, render-side, outside the
deterministic sim island, like skinning/audio) and a CPU-deterministic path (bounded,
byte-reproducible, rollback-safe, like AnimatorInstance). Both consume the same compiled POD.
Authoring model (engine/domain/vfx/include/SushiEngine/vfx/, header-only, C++17, depends only
on engine/foundation/core/include/SushiEngine/core/types.hpp). An emitter is a stack of
modules across four stages (spawn / shape / init / update) plus a render module; each module is
its own trivially-copyable descriptor struct (the Open/Closed seam — a new behaviour adds a
descriptor, a compiler handler, and a shader/integrator branch, touching no existing module).
AnimationCurve and ColorGradient are keyframed authoring types bakeable to fixed-width LUTs.
EmitterCompiler flattens a ParticleEffect (a list of EmitterDescriptor) into a
CompiledEffect: an array of POD CompiledEmitter records plus two baked LUT atlases — the
single artifact both backends and the GPU consume, the particle equivalent of resolving authored
RenderSettings into a POD QualityParameters. EffectDatabase is the AssetId registry (lazy
compilation), mirroring AnimationDatabase. GPUParticle is the shared 80-byte, five-vec4
std430 record used by the CPU backend, the GPU pools, and the shaders.
Deterministic backend
(engine/domain/vfx/include/SushiEngine/vfx/deterministic_backend.hpp +
Simulation::ParticleEmitter). A fixed-pool integrator run as an ECS system inside the fixed
step. Its particle state is a DeterministicEmitterState — a capped GPUParticle pool, a count, a
PCG32, and a few scalars, pointer-free — and its stepping state is the
VFX::Driver::EmitterRuntime beside it, holding the spawn carry and the spawn ordinal. Both are
pointer-free, so a tick is byte-snapshottable and a rolled-back-then-replayed tick reproduces it
exactly, but a snapshot that takes the pool and leaves the runtime behind does not
(Integration_ParticleDeterminism, tests/integration/test_particle_determinism.cpp). Shapes,
forces (gravity, buoyancy, drag, curl-noise turbulence, force fields), and the
size/colour-over-life LUTs all run here too, sampling the same baked LUTs the GPU does. Collision
is the one update module it does not run; see what a backend cannot
run.
GPU render path. Render::Scene::ParticleSystem
(engine/presentation/render/source/scene/particle_system.*) owns the shared, persistent,
device-local particle pool (zero-cleared once), the per-slot host-visible emitter table
(GPUEmitter, a std430 mirror of the compute-visible CompiledEmitter subset with the emitter’s
world transform and ring cursor baked in), and the uploaded LUT atlases.
ParticleSimPass (engine/presentation/render/source/passes/particle_sim_pass.*, a compute
IRenderPass, after skinning_pass_) sweeps the pool: a simulate dispatch advances/ages/retires
and appends survivors to a compacted draw list, then a per-emitter emit dispatch allocates ring
slots and initialises new particles — both atomically build a VkDrawIndirectCommand.
ParticlePass (engine/presentation/render/source/passes/particle_pass.*, a graphics
IRenderPass, between ssr_pass_ and taa_pass_, writing scene_final) draws vertex-lessly:
six vertices per alive particle, expanded into a camera-facing billboard pulled from the draw
list, sampling the scene depth (never attaching it) to discard fragments behind geometry,
additively blended.
The compacted draw list and indirect args are graph transients (the graph derives the
compute→draw barriers); the pool is system-owned. Render::FrameView gained an
emitters channel of ParticleEmitterView (opaque CompiledEmitter bytes + LUT pointers +
host-computed spawn count), the same shape as skinned, which ISceneView::render reads off the
view it is handed. Additive-only for the slice;
alpha depth-sorting, lit particles, ribbons, mesh particles, and GPU collision are phases
VFX2–VFX5.
Enabling seams. Resources::GraphicsPipelineDescription
(engine/presentation/render/source/resources/) gained a ColorBlend member folded into the
fragment-output pipeline-library key (defaults reproduce opaque, so transparent draws are now
expressible without disturbing existing passes). QualityParameters carries gpu_particles,
which the lowest tier turns off so that tier drops cosmetic particles entirely; the deterministic
CPU path is unaffected, being gameplay rather than a quality knob.
Build: this phase’s four shaders (engine/presentation/render/shaders/particle_emit.comp,
engine/presentation/render/shaders/particle_simulate.comp,
engine/presentation/render/shaders/particle.vert,
engine/presentation/render/shaders/particle.frag, sharing
engine/presentation/render/shaders/particle_common.glsl, all under
engine/presentation/render/shaders/) go through sushiengine_compile_shader + the shader
catalogue; its two systems and two passes into sushiengine_render.
1.1. Deterministic emitter entities + alpha particles (Bağla + VFX2a)
Deterministic emitter entities. A gameplay entity carries a particle emitter through the
sim’s IWorldEditor — create_particle_emitter, has_particle_emitter,
particle_emitter_parameters, set_particle_emitter_parameters, set_has_particle_emitter, and
the effect pair particle_effect_source/set_particle_effect_source with its texture-asset
sibling particle_effect_texture_assets/set_particle_effect_texture_assets. The authoring is
a ParticleEmitterParameters archetype column; the live state is not, because it is neither
fixed-size nor authored: RuntimeSimulation::Record holds a VFX::Driver::EffectRuntime (off
the ECS chunk, one ~80 KB VFX::DeterministicEmitterState per deterministic emitter and none at
all for an effect that has none), plus the effect handle and play head.
step_particle_emitters() runs inside step_once() (after the schedule, before extract),
advancing every playing emitter whose domain is Deterministic one fixed step, each into its own
pool, via CPUDeterministicBackend::step; extract() emits one RenderScene::particle_billboard
per live particle. The sim builds one built-in effect, Fire (make_fire_effect, Deterministic
domain), which seed_emitter_effect adds to its EffectDatabase for a freshly added component;
Sparks, Smoke and Trail are the editor’s templates. The renderer draws these through the
billboards channel of Render::FrameView, which Frame::compose converts that extract channel
into —
already-simulated world-space particles billboarded directly, uploaded to a host-visible
GPUParticle buffer by
ParticleSystem::prepare_billboards and drawn by a vkCmdDraw in ParticlePass. The editor
adds an Entity ▸ Objects ▸ Particle System entry, the matching Add Component ▸ Particle System,
an Inspector section (effect/seed/playing), and .scene persistence.
Alpha particles (VFX2a). GPUEmitter now carries the emitter’s blend and sort modes. During the
compute compaction,
engine/presentation/render/shaders/particle_simulate.comp/engine/presentation/render/shaders/particle_emit.comp
bucket each particle by blend — additive/premultiplied into particle_draw, true-alpha into
particle_alpha — each with its own VkDrawIndirectCommand in a single particle_args buffer
(additive at offset 0, alpha at 16). ParticlePass draws the two buckets with two pipelines: the
additive glow (source+destination, order-independent) and a premultiplied “over” for true alpha
(source + destination*(1-a)), both on the premultiplied fragment output. Smoke and dust composite
correctly instead of only glowing. Deferred to the next VFX2 slice: clustered-punctual-light
particles (the froxel version — needs camera-relative conversion).
Alpha depth-sort (VFX2a). The alpha bucket is bitonic-sorted back-to-front on the GPU. A new
ParticleSortPass (engine/presentation/render/source/passes/particle_sort_pass.*, compute,
between the sim and draw passes) runs engine/presentation/render/shaders/particle_sort.comp: mode
0 seeds one {-distance², index} key per pool slot from the alpha list’s camera distance (padding
slots sink to the end); mode 1 is one bitonic compare-exchange stage, dispatched
log2(N)*(log2(N)+1)/2 times by the host over the power-of-two pool capacity — the engine’s
“dispatch a host-known max, read the alive count on the GPU” idiom, so no indirect dispatch is
needed (the tree’s first GPU sort).
The seed stage always runs so the key buffer has a producer; the bitonic stages run only when
ParticleSystem::needs_alpha_sort() — that is, when at least one active emitter both blends
true-alpha and asks for VFX::SortMode::ViewDistance, which is the authored RenderModule::sort
field and defaults to sorting. SortMode::None is the opt-out, and it is a whole-pass one: the
alpha bucket is shared across emitters and the bitonic pass costs the padded pool whatever fraction
of it is occupied, so excluding one emitter’s particles from the bucket would save nothing and cost
their blending (the only other bucket composites additively). The alpha draw uses
engine/presentation/render/shaders/particle_sorted.vert, which indexes the alpha list through the
sorted keys (binding 2 on the draw pass’s set), so gl_InstanceIndex walks particles far-to-near;
the additive and billboard draws keep the direct engine/presentation/render/shaders/particle.vert.
The pool capacity was set to 2^16 to keep the sort tractable.
Lit particles (VFX2b). The true-alpha bucket (smoke, dust) is lit by the sun; the additive
bucket (fire, sparks) stays emissive. The sun is a world-space directional light
(Environment::sun), so lit particles need no camera-relative conversion — sidestepping the
biggest hazard of clustered-light particles. The billboard fragment shades the sprite as a
camera-facing hemisphere (a spherical normal from the sprite offset), takes
max(dot(n, sun_dir), 0), and adds a flat ambient; the sun direction, radiance, and a per-draw
lit flag ride the 128-byte push constant (which replaced the unused viewport lanes). The
clustered-punctual-light and IBL-SH version (camera-relative, set-0 b13-17) is the deferred
refinement.
1.2. Clustered lights and shadows on particles (VFX2c)
Clustered punctual lights. The lit bucket also receives the scene’s point and spot lights.
The fragment maps the sprite to a froxel and accumulates that cluster’s lights with the same
windowed inverse-square and spot-cone falloff the meshes use, without the BRDF — a puff is a
diffuse blob, not a surface — and the flat VFX2b ambient became gi_sh_irradiance from the
environment’s SH.
The hazard is that the froxel lights live in camera-relative space while particles are absolute
world space, so engine/presentation/render/shaders/particle.vert carries the sprite’s centre − eye and its view depth (gl_Position.w, the same quantity the mesh path’s z-slice is keyed on)
down to the fragment in one varying. Rather than re-architecting SceneLayout, the pass declares
the light buffer, cluster grid, index list, froxel config, and IBL SH on its own set (bindings
3–7), and the binding-free froxel primitives were extracted from
engine/presentation/render/shaders/clustered_lighting.glsl into
engine/presentation/render/shaders/clustered_lighting_common.glsl, now shared with
engine/presentation/render/shaders/pbr.frag.
Shadowed particles. The sun term is multiplied by the sun’s cascade visibility, so a smoke
column standing in the shadow of geometry goes dark instead of reading as uniformly sunlit. The
cascade matrices are already camera-relative, so the carried centre serves the shadow projection as
well as the cluster lookup. engine/presentation/render/shaders/particle_shadow.glsl is a
particle-specific sampler, not the mesh path’s: a puff has no surface normal for the normal-bias
acne fix to work with, and the mesh path’s blocker search plus twenty-odd filter taps would be paid
once per overlapping sprite layer under a transparency pass’s overdraw. It picks the cascade,
projects once, takes a four-tap fixed-radius Vogel disc through the comparison sampler, and fades
out over the last cascade — a soft, chunky shadow, which is what a volumetric medium wants.
The cascade block and atlas are bound on the pass’s own set at the scene set’s own binding
numbers (10 and 11, free here), so the shared
engine/presentation/render/shaders/shadow_common.glsl declaration is reused verbatim rather than
copied; the sampler-free cascade arithmetic (cascade select, atlas tiling, the Vogel disc, the atlas
texel) moved into that file from engine/presentation/render/shaders/shadow_sampling.glsl, which
keeps only what needs a sampler — the split the file’s own header already described.
1.3. Velocity-stretched particles (VFX3a)
RenderAlignment::VelocityStretched had been in the authoring model since VFX1 with no renderer
behind it. It now works: the vertex stage aims the quad’s long axis down the particle’s
screen-projected velocity and lengthens it by size + speed * velocity_stretch, so a spark reads
as a streak. RenderModule::velocity_stretch (streak metres per m/s) is the authored scale,
carried through CompiledEmitter into GPUEmitter’s spare lanes — the record’s size did not
change. Two degenerate cases fall back to camera-facing, which is what a zero-length streak is:
a particle barely moving, and one flying straight at the eye (whose screen-projected velocity is
null).
The alignment is a property of the emitter while the draw list is per-particle, so the two
GPU draws bind this frame’s emitter table to the vertex stage (binding 12) and index it by the
particle’s emitter_index. Deterministic billboards belong to no GPU emitter — index 0 of the table
is an unrelated cosmetic emitter, and on a billboard-only frame there is no table at all — so they
got their own engine/presentation/render/shaders/particle_billboard.vert and pipeline instead of a
flag on the shared one: the two shaders differ in what they may read, which is a pipeline
property. The quad expansion itself is not duplicated across the three: it moved into
particle_quad_offset in engine/presentation/render/shaders/particle_common.glsl, which was
already the shared home of the record layouts, so a future alignment mode (ribbon, mesh) is added in
one function.
1.4. Ribbons and trails (VFX3b)
RenderAlignment::Ribbon draws a particle as a tapered strip through its own recent positions. A
trail is state that has to outlive the frame that recorded it, so it lives where the pool lives:
ParticleSystem owns a second device-local buffer of capacity * TRAIL_POINTS vec4s (xyz
sample position, w its size), zero-cleared on the pool’s one-time clear path. The simulate step
shifts every sample one place down and writes the new position at index 0 — a ring would save
seven vec4 moves but would need a head index in the vertex stage, and the 128-byte draw push
has no room for one. The emit step collapses the whole history onto the birth point, because a
recycled slot still holds the previous occupant’s trail and would otherwise streak from wherever
that particle died.
Ribbons form a third indirect draw bucket, keyed on geometry rather than blend: a strip is a
different vertex count per instance than a quad, which is a property of the draw and cannot be a
branch inside a shader. particle_args grew to three VkDrawIndirectCommands (ribbons at offset
32) and engine/presentation/render/shaders/particle_ribbon.vert expands each instance into
(TRAIL_POINTS - 1) * 6 vertices. Each sample’s side offset is perpendicular to both the local
trail direction — taken from the neighbouring samples, so a corner shared by two segments resolves
identically from both and the band has no crease — and the eye ray, so the strip presents its width
to the camera and twists with the path. Width and alpha taper to zero at the tail, the width coming
from the sample’s own recorded size so a size-over-life curve is already in the strip’s profile. No
ribbon fragment shader was needed: the shared sprite fragment’s radial falloff becomes a soft-edged
band when the vertex stage emits v = 0.5, collapsing it to 1 - u².
Because the bucket is keyed on geometry it mixes authored blends, so ribbons composite with the premultiplied “over” whatever the emitter asked for; splitting the bucket by blend is the increment that would lift that. Shading is no longer part of the limitation — the particle material moved the lit flag onto the emitter, so a trail is lit if its author said so. Ribbons remain outside the alpha sort.
RenderAlignment::Beam shares that bucket outright rather than opening a fourth one. A beam is a
strip between two authored endpoints instead of through a particle’s own recent positions, and that
is the only difference — the strip expansion, the texturing and the draw are the ribbon’s, and the
two differ by which function engine/presentation/render/shaders/particle_ribbon.vert samples and
by the tail taper, which a beam does not apply because both its ends are authored.
The endpoints, width, sag and lateral jitter live in VFX::BeamModule on the emitter descriptor,
emitter-local so the span travels with its emitter; ParticleSystem::prepare bakes them to world
space by the same transform it applies to a force field’s centre, and particle_beam_sample
evaluates a point along the span for a given t. Sag bends the line under an imagined gravity
and the jitter — seeded from the drawing particle, so several live particles draw several
distinct arcs between the same two points — displaces it laterally; with both at zero the beam is
exactly straight. It is cosmetic only: the deterministic CPU backend draws nothing, so a beam
never affects simulation state.
1.5. Mesh particles (VFX4)
RenderAlignment::Mesh draws each particle as a solid mesh instance. It is the one particle path
that is not transparent, and that decides where it runs: ParticleMeshPass
(engine/presentation/render/source/passes/particle_mesh_pass.*) sits immediately after the
opaque pass, loads the scene’s HDR colour and depth, and depth-tests and depth-writes like any
other solid surface. In the transparency pass, overlapping debris would composite in list order,
which no blend mode fixes — so this is a pass rather than a fourth bucket in ParticlePass.
A draw binds one mesh, so mesh particles cannot share a single indirect command the way sprites do.
Up to MAX_MESH_EMITTERS (4) mesh-aligned emitters each claim an equal slice of one shared list and
one VkDrawIndexedIndirectCommand; the emitter carries its slice in GPUEmitter::mesh_slot, and an
emitter past the last slice draws nothing rather than borrowing another’s mesh. The command’s index
count is a host fact and its instance count a GPU fact, so the sim pass seeds the whole command from
ParticleSystem’s mesh-draw table — which is why prepare now takes the MeshRegistry. The
converse constrains the shader: the instance count is a GPU atomic the host cannot clamp, so
engine/presentation/render/shaders/particle_mesh.vert clamps its index to the slice and an
overflowing instance redraws the last particle instead of reading into the next emitter’s slice.
The mesh supplies geometry; the particle supplies placement — position, uniform scale from its size,
a tumble about its direction of travel (Rodrigues about the velocity axis, angle from the roll the
update step already integrates), and a colour tint over the mesh’s vertex colour. Faces are not
culled, since debris turns. Shading is deliberately not
engine/presentation/render/shaders/pbr.frag: a mesh particle carries no material, and reaching the
bindless material heap would tie the pass to the whole scene set, so it is a diffuse surface lit by
the sun (through engine/presentation/render/shaders/particle_shadow.glsl, its second consumer)
plus a flat ambient. A mesh particle that needs a real material is a mesh instance, not a particle.
The design originally sketched wiring this onto the GPU-driven instance path; that is not
possible as written, because InstanceSystem builds its records host-side each frame and a
GPU-simulated particle’s transform never reaches the host. Consequences of the pass writing only
colour and depth: mesh particles are not pickable, contribute no motion vectors, and are absent
from SSR, GTAO, and the GPU cull.
1.6. Force fields (VFX5a)
Gravity, buoyancy, drag, and turbulence act everywhere alike; a force field has a place, which is
what makes it the tool for authored motion. ForceFieldModule places up to MAX_FORCE_FIELDS (4)
of them per emitter: Point pulls toward or pushes from a centre, Vortex swirls about an axis
through it, Drag damps velocity inside it. Point and Vortex contribute acceleration; Drag
returns a factor applied to velocity after integration, alongside the emitter’s own drag.
The weight is pow(1 - distance/radius, falloff) — one at the centre, zero at the rim. That
bound is the point: a field touches nothing outside its radius, so it costs nothing to particles
elsewhere, and it never spikes to infinity at the centre the way a raw inverse-square does. The
count is fixed because CompiledEmitter is the byte-comparable POD both backends and the GPU
read, so a variable-length list cannot live in it; the compiler takes the first four enabled
entries.
Fields are authored in the emitter’s local frame so they travel with their emitter, and placed
into world space once per step rather than per particle — ParticleSystem bakes the emitter
matrix into GPUEmitter when it flattens the frame, and CPUDeterministicBackend::integrate_range
(which now takes the emitter pose for exactly this) applies the pose at the top of the step. The
GLSL particle_force_fields and the deterministic backend’s field loop are line-for-line
counterparts: they read the same authored record, so they must agree on what it means. They are
not required to be bit-identical to each other — they are different simulation domains — only
each deterministic in itself.
The noise field they both call is not a counterpart written twice;
engine/domain/vfx/include/SushiEngine/vfx/spec/particle_kernels.inc defines particle_curl_noise
once — and particle_curl_noise_static, the frozen field an emitter with a zero
turbulence_time_scale takes instead — and both backends include it: the deterministic backend as
C++, the GPU through engine/presentation/render/shaders/particle_kernels_prelude.glsl. The instant
the moving curl is sampled at is the effect’s own VFX::Driver::EffectRuntime::time_seconds,
reaching the deterministic backend as an argument to step and the compute path as
Emitter::age_seconds. Shape sampling reaches them the same way, through particle_sample_shape in
that file; see one shape, one sampler. What is still written twice is
the field loop above and the random number generator — a stateful PCG32 on the host, a stateless
per-thread hash on the GPU — and the generator is meant to stay that way.
The host driver is not duplicated. Both
engine/world/simulation/source/runtime/particle_runtime_host.cpp and
applications/editor/source/vfx/effect_preview.cpp step an effect through
engine/domain/vfx/include/SushiEngine/vfx/driver/emitter_driver.hpp; see one driver, one
clock.
1.7. Depth collision (VFX5b)
Cosmetic particles bounce off whatever the camera can see, tested against depth the renderer
already produced — no collision geometry, no broadphase. The apparent obstacle was pass order:
ParticleSimPass runs before depth_prepass, so this frame’s depth does not exist when the
particles move. But HiZPass owns a persistent image rather than a graph transient, so at
sim time its level 0 still holds last frame’s linearised depth (near / depth — exactly the
linear view distance the test needs). One frame of lag on a spark’s bounce is invisible; this is
the same cross-frame read cull_pass already makes of the occlusion pyramid.
HiZPass::has_history() reports whether there is anything to read: false before the first build
and while the pass is off (it follows SSR), because an image never written holds garbage and has
never left UNDEFINED. The sim pass then binds a 1×1 stand-in it owns and clears the collision
bit in its push constant — a combined-image-sampler binding needs a real view even on a frame the
shader will not read it.
The test projects the particle, reads the pyramid at its pixel, and compares view depths: in
front is free, further behind than the authored thickness is also free (the depth buffer records
a surface, not a solid), and between the two is contact. The normal comes from the depth gradient
— two neighbouring taps turned back into camera-relative positions and crossed — falling back to
camera-facing where a depth jump says the neighbours are on a different surface, since a gradient
across a silhouette points nowhere real. The response keeps restitution of the normal velocity,
sheds friction of the tangential, and lifts the particle out along the normal by its
penetration. The sim push block reached exactly its 128-byte budget doing this: a
view-projection, the camera basis with the half-fov tangents in the spare w lanes, and two
vec4s of counts.
What it cannot do follows from the same design: particles off screen, behind the viewer, or hidden behind something nearer pass straight through. That is the right trade for sparks skittering off a visible floor and the wrong tool for gameplay, so it is a cosmetic-path feature — the deterministic backend has no counterpart, and the authoring UI says so.
1.8. Effect assets, library, and timeline (VFX6)
engine/world/serialization/include/SushiEngine/serialization/effect_serializer.hpp and
engine/world/serialization/source/effect_serializer.cpp read and write .effect files:
JSON in the same shape and spirit as .scene, one object per emitter with a sub-object per
module. What is persisted is the descriptor tree, never the compiled record — the compiled
form is a build product whose layout has changed with every phase of this subsystem, so writing
it would tie a saved asset to a moving target. Curves and gradients go out as their authored key
lists rather than baked LUTs for the same reason. Reads default rather than fail: a missing key
keeps the module’s default, so a file written by an older build loads with the newer defaults,
and a numeric enum from a newer build that is out of range falls back instead of becoming an
out-of-range enum the consumers would later switch on.
The particle panel gained a “Library” section listing assets/effects/*.effect with load
and save, re-reading the directory on demand rather than watching it — an author saves far less
often than the panel redraws. It also gained a timeline: the emitter’s cycle as a bar with its
burst times marked and a draggable play head, calling EffectPreview::seek
(applications/editor/source/vfx/effect_preview.*).
What that seek means depends on which backend is previewing, and the split is the honest one. In the GPU preview it moves the emission schedule: the rate and burst evaluation jump, and the fractional-particle accumulators are cleared so their debt does not spill as a burst at the new time, but the particles already alive do not wind back and cannot — the cosmetic pool lives on the GPU and is dispatched once per rendered frame, over whatever simulated time that frame consumed, so there is no host copy to rewind.
A “CPU (scrubbable)” mode previews through CPUDeterministicBackend instead, and there the
scrub is exact: that backend is a pure function of (state, emitter, dt), so seeking replays at the
fixed step from the newest stored checkpoint at or before the target, reproducing precisely the
frame that time would have shown. A checkpoint is the pools and their VFX::Driver::EmitterRuntime
states at one instant, so a scrub late in a long timeline costs the checkpoint interval rather than
the whole timeline; a target further back than any stored state replays from zero, bounded by
REPLAY_STEP_BUDGET (3600 steps), and a scrub that hits the bound reports it through
EffectPreview::scrub_limited rather than showing the wrong instant as the right one. The CPU
preview steps every emitter whatever domain it declares — the domain says which backend ships
the effect, while the preview’s job is to show the author the same asset through
the other one — and its particles come out as Render::ParticleBillboards, the channel the sim’s
own deterministic emitters already use, which the viewport concatenates with the sim’s rather
than adding a channel for.
The panel also draws the module stack as a left-to-right node graph with click-to-toggle. That is a presentation of the authoring model, not a second one: the stack order (spawn → shape → init → update → render) is the pipeline a particle actually goes through, so the graph is that pipeline laid out rather than a free-form canvas whose edges would only have to be validated back into the same fixed order.
1.9. Scene emitters are entities
The Scene view used to draw the previewed effect, which belonged to no entity: a fire nobody could select, move, or delete, sitting in a view whose whole job is to show the world. It is gone from there and has its own Effect Preview viewport, which draws that effect and nothing else — no instances, no world lights, no world emitters. What the Scene view shows is the world’s emitters, and those are entities.
Both backends, chosen by the effect. Simulation::RenderScene gained particle_emitters
alongside particle_billboards. Which channel an emitter entity feeds is not a new component
field: it follows the domain each CompiledEmitter already declares. A Deterministic emitter is
stepped on the fixed tick into its own host pool and extracted as finished billboards; a
Cosmetic one is not integrated on the host — the sim places it (transform, this frame’s spawn
count and the ordinal that count starts from, the compiled record and its LUT atlases) and the
renderer emits and integrates it on the GPU. That is what lets a scene emitter reach ribbons, mesh
particles, and depth collision, none of which the host path can do. The per-emitter
runtime state (play head, fractional-spawn carry) lives on the sim’s record rather than on
ParticleEmitterParameters, because those are the authored parameters the scene file round-trips
and a play head is neither authored nor persisted.
How an authored effect reaches the world. The sim owns no asset loader — .effect files
are the editor’s business — so an authored effect arrives one entity at a time, through
IWorldEditor::set_particle_effect_source
(engine/world/simulation/include/SushiEngine/simulation/simulation.hpp:2002), which replaces
the effect that entity owns and recompiles it in place, so a running emitter picks the change up
on its next tick. There is no name-keyed registry and no shared entry to replace: the effect is
the component’s own data rather than a handle into a library
(engine/world/simulation/include/SushiEngine/simulation/simulation.hpp:273), which is what lets
the scene file round-trip it.
The particle panel scans assets/effects through
Scene::list_effect_files and writes the selected entry into the selected emitter
(applications/editor/source/vfx/particle_panel.cpp:923); a scene load
(engine/world/serialization/source/scene_serializer.cpp:1546,2345) and the editor’s scene
commands (applications/editor/source/scene/scene_commands.cpp:512) reach the world the same
way. The consequence is that an edit lands on the emitter being edited, not on every emitter
playing an effect of the same name.
Sparks, Smoke and Trail are plain .effect files under assets/effects/ (PS2c) — the panel’s
Library list finds them through Scene::list_effect_files exactly as it finds anything an author
has saved, so they need no code of their own any more. Campfire is the one built-in that still has a
C++ entry point, Editor::default_emitter_effect, because EffectPreview’s constructor needs
something to seed a brand-new preview with before any emitter is authored; the function itself is
a thin Scene::load_effect of assets/effects/Campfire.effect, not a builder. The simulation’s
seeded Fire is still built in code (ParticleRuntimeHost::make_fire_effect in
engine/world/simulation/source/runtime/particle_runtime_host.cpp, only to seed a freshly added
component) because simulation cannot depend on serialization to load a file — serialization
already depends on simulation, so the reverse would be a circular module dependency.
The deterministic step walks the effect’s emitters and steps every one whose domain is Deterministic, each into its own pool, so a mixed-domain effect plays both halves and a multi-emitter deterministic effect plays all of it. An effect with no deterministic emitter allocates no pool.
1.10. The Particle System is a component, not a panel
There is no Particle Editor window. Adding a Particle System to an entity is what makes that entity emit, so the whole authoring surface — emission, shape, forces, force fields, collision, over-life curves, render alignment, the module graph, the timeline — is drawn inside that component’s Inspector section. A particle system is not a mode the editor is in; it is something an entity has.
The effect is the component’s own data. It lives on the entity, in the world, reachable
through IWorldEditor::particle_effect_source / set_particle_effect_source, and not as an
index into a shared library. Two consequences follow, and both were the point: editing one
emitter can never change another, and the scene file round-trips the effect with the entity
(capture_effect under the entity’s particle_emitter.source) instead of saving an index whose
meaning depends on load order. A freshly added component is seeded with a visible default,
because a component that shows nothing reads as broken rather than as an invitation to author.
Files written before the effect moved onto the component simply keep that default.
The Inspector holds a scratch copy of the selected entity’s effect for the widgets to bind to,
re-read whenever the selection moves, and writes it back while a widget is active — by which
point the value has already changed, so a drag is covered frame by frame — plus a dirty flag for
the one change no widget reports, a library load. EffectDatabase::replace makes the write in
place, so dragging a slider neither grows the database nor leaves dead compiled forms behind.
Library entries under assets/effects are templates, not bindings: clicking one copies it
into the selected emitter, so editing that emitter never touches another that started from the
same file.
One preview surface. The Preview viewport is the single screen anything being authored is shown on, in isolation — no world instances, no world lights, no world emitters — and it carries the transport for whatever it shows, effect and character alike, because “the thing being authored, playing or paused” is a property of the surface rather than of the subject. The Inspector mirrors the edited effect into it and points it at the entity’s position, so the isolated view and the Scene view never disagree. Nothing else calls itself a preview window: what was “Animator Preview” is now “Animator”, since layers, masks, and IK are authoring, not a preview. The Scene view shows the world, where emitter entities are simply live; a “Preview in Scene” toggle additionally draws the previewed effect there, off by default because an effect belonging to no entity cannot be selected or deleted and should not squat in the view that shows the world.
1.11. The particle material
Four render-module fields were authored, compiled, serialized, and shown in the Inspector from
VFX1 onward, and ignored by the renderer: the sprite texture, its flipbook grid, the
soft-particle fade, and lit. Closing that gap is what the particle material is.
It is deliberately not a PBR material. A puff has no roughness, no normal, no parallax; what it
has is a base-colour sheet, a cell in that sheet, a fade where it meets geometry, and a choice
about whether light touches it. A particle that genuinely needs a surface is a Mesh-aligned
one, drawn by ParticleMeshPass against real geometry. Keeping the split means the sprite path
never has to bind the scene set.
Texture. ParticlePass’s pipeline layout gained set 1 — the same slot, the same
DescriptorHeap::layout(), and the same sampler2D bindless_textures[] array
engine/presentation/render/shaders/pbr.frag samples — so a sprite texture is registered once and
addressed by the very index a material map is. The authored value is a texture-library id; only the
library knows which heap slot that id currently occupies, so ParticleSystem::prepare resolves it
while it flattens the frame (which is why it takes the TextureLibrary alongside the
MeshRegistry). A RENDER_TEXTURED flag, not a sentinel index, says whether the slot is real: the
untextured and textured cases differ in more than the sample — untextured is the built-in radial
dot, textured hands the falloff to the texture’s own alpha, and applying both would vignette every
authored sheet twice — and it is the bit the renderer clears when a texture cannot be resolved,
which is what keeps the fragment stage from indexing a slot that was never allocated.
Flipbook. engine/presentation/render/shaders/particle_simulate.comp has chosen a cell from the
particle’s normalised age since VFX1; nothing read it. particle_sprite_uv maps a quad corner into
that cell — picked, never interpolated, so the sub-image swaps cleanly instead of smearing between
frames. A ribbon gets a strip mapping instead, running head-to-tail down the trail, because that is
how a trail sheet is drawn.
Soft particles. Reverse-Z with an infinite far plane, so a stored depth linearises to
near / depth and the sky reads as infinitely far, which is the right answer when nothing was
hit. The fragment’s own view depth is the interpolated 1 / gl_FragCoord.w rather than the
sprite centre’s, so a large billboard fades correctly across its own extent. The hard occlusion
discard stays as the cheap early-out; the fade handles the contact band it leaves behind.
Lit is per emitter, not per draw. Whether a particle receives the sun, its cascade shadow, the clustered punctual lights, and the SH ambient used to be inferred from the bucket — so the true-alpha bucket was always lit and the additive and ribbon buckets never could be. A bucket mixes emitters, so it was always the wrong granularity. The flag now rides the emitter table down to the fragment as a flat varying, and the push constant’s spare lane carries only the ambient scale. This also retires the documented limitation in ribbons and trails that a trail could not be lit.
Persistence is by path. A texture id means nothing to the next session, so .effect
files and the .scene particle emitters round-trip RenderModule::texture_path, and
resolve_effect_textures derives the handle after a load. The capture carries the live handle
too, because the same code serves the in-memory snapshots undo/redo and play-mode take — where
the handles are still the right ones and re-reading every texture off disk to restore them would
be absurd; a load from disk overwrites it from the path, so a stale one never crosses a session
boundary.
1.12. One driver, one clock
The editor preview and play mode step an effect through one implementation,
engine/domain/vfx/include/SushiEngine/vfx/driver/emitter_driver.hpp. It owns the fixed step and
the catch-up ceiling (VFX::Driver::Cadence, VFX::Driver::consume), the per-emitter fractional
spawn carry and the monotonic spawn ordinal (VFX::Driver::take_spawn_count), and the derivation of
a deterministic pool’s seed from the effect seed and the emitter index (VFX::Driver::pool_seed).
The two hosts differ only where the difference is real: the simulation is already on a fixed tick
and never calls consume, while the preview is paced by the editor’s real frame time and calls it
every frame, reporting a dropped backlog rather than absorbing it. The step’s value is not the
driver’s: RuntimeSimulation owns the tick duration, and the editor host reads it from
ISimulation::fixed_dt_seconds into EffectPreview::set_fixed_step at startup rather than
restating the number. The Cadence default and the simulation’s tick were both one sixtieth of a
second by two unrelated constants before that, which is a coincidence, not an agreement.
A frame carries its own timestep. How much simulated time a frame advances is
Render::FrameView::particle_timestep — one field of the frame, not a field of a
ParticleEmitterView, because it is the same number for every emitter in the frame. Its default
is zero, so a caller that leaves it unset gets a visibly frozen frame rather than a plausible
wrong step. The simulation publishes the fixed step times the number of steps the
host frame consumed, and so does the preview; a frame that consumed no step publishes zero.
A particle’s seed comes from its name. engine/presentation/render/shaders/particle_emit.comp
derives a particle’s seed from the effect seed, the emitter index and the spawn ordinal, through the
particle_spawn_seed definition in
engine/domain/vfx/include/SushiEngine/vfx/spec/particle_kernels.inc that VFX::Driver::pool_seed
also calls. No frame counter and no clock reaches the shader, so the same particle carries the same
seed at any frame pacing.
Spawn accounting rides the tick. RuntimeSimulation::step_particle_emitters performs it once
per fixed step and accumulates into Record::cosmetic_spawns;
RuntimeSimulation::begin_particle_spawn_frame opens the frame exactly once per tick, before any
step, and extract publishes it. The accounting is therefore a function of simulated time alone —
an editor edit reaches extract through extract_after_edit any number of times a frame,
republishing the same counts, and moves neither a carry nor an ordinal.
apply_surface_constraints runs on the same tick, once per fixed step — and again from
extract_after_edit, which is not covered by the sentence above. That second call is a recorded
defect rather than a design: the function’s path-dependent half advances a surface entity’s heading
by parallel transport, so a frame carrying several edits transports further than simulated time
warrants. Separating the level-triggered half (the clamp, the universe bind, the recompose) from
the transport is the fix, and it is a design change.
Both domains are posed alike. A deterministic emitter is stepped from the entity’s world
transform, the same pose the cosmetic emitter’s model matrix is built from, so the two halves of one
effect are born in one place even when the entity is parented or bound to a planetary surface. The
editor’s preview is posed from that same world transform every frame the Particle Editor draws, and
composes it into its cosmetic model matrix in the order RuntimeSimulation::world_matrix composes
it — position, rotation and scale — so a scaled emitter entity now produces one result in the
preview and in play mode.
What is not shared. The random number generator, by design — stateful on the host, stateless
per thread on the GPU. Beyond it: force-field placement is written twice and collision runs on the
cosmetic path alone. engine/domain/vfx/README.md records these against the code that carries
them. Five more
gaps sit in the hosts and the renderer rather than in the module:
CPUDeterministicBackend::steptakes a position and a rotation and no scale, while the cosmetic view’s model matrix carries the entity’s scale, so a scaled emitter’s two domains disagree about how large its shape is.- A ribbon’s drawn arc depends on what other emitters spawned that frame:
engine/presentation/render/shaders/particle_emit.compstores the pool slot inGPUParticle::seed, andengine/presentation/render/shaders/particle_ribbon.vertreads that field as both the trail index and the noise stream the beam jitter is drawn from. RuntimeSimulation::world_transformallocates astd::vectorper call and runs once per emitter entity per fixed step, on top of the identical parent walk the extract performs.- In edit mode the editor never calls
ISimulation::tick, so the last tick’s snapshot stands and the renderer re-submits its spawn counts every frame the paused editor draws. - A ribbon’s arc length is a function of the host frame rate.
engine/presentation/render/shaders/particle_simulate.compshifts every trail sample one slot per dispatch, with no timestep term, so trail sample spacing tracks how often the pass runs rather than how much simulated time passed. The shift is described in ribbons and trails, which does not mention the dependence.
Every built-in effect that rises does so through VFX::BuoyancyModule: the editor’s Fire (3.5
m/s²) and Smoke (1.2) templates, the simulation’s make_fire_effect (3.2), which seeds a
freshly added component, and the sample effect in samples/rendering/particle_demo.cpp (4.0).
The two Fires keep their own magnitudes; they are different effects and always were. Sparks
(-9.0) and Trail (-2.0) author real downward gravity, which is what gravity is for.
1.13. One shape, one sampler
Kernels::particle_sample_shape, in
engine/domain/vfx/include/SushiEngine/vfx/spec/particle_kernels.inc, is the one definition of
where a particle is born and which way it leaves. CPUDeterministicBackend’s spawn_one and
engine/presentation/render/shaders/particle_emit.comp both call it, and neither carries a shape’s
arithmetic any more.
The sampler draws no random numbers, which is what lets one text serve two generators that must
stay different. It takes PARTICLE_SHAPE_UNIFORM_COUNT uniforms as positional arguments and writes
a position and a direction; each backend draws that many from its own generator and passes them in.
Positional rather than streamed is the stronger contract: what a given uniform means to one shape
no longer depends on how many an earlier branch consumed. The count lives in the core rather than
as a number written at each call site, so raising it moves both backends at once — and because both
now draw the same fixed count whatever the shape, the shapes that used fewer advance their
generator further per particle than they did, which moves every value drawn after the shape.
A direction is a property of a shape, not of the world. Every branch derives its direction from
its own geometry: a cone from its half angle about the axis, a circle radially within its own
plane, a shell box from the face of the axis the particle is furthest along in normalised terms, a
sphere from the point on it, a point emitter along the axis it is handed. Nothing names a world
axis. particle_axis_basis builds two vectors across the forward axis without assuming which way
it points, so turning an emitter turns its
whole shape, and the hemisphere folds about its pole by reflecting the part of the direction that
lies behind it rather than by clamping a coordinate. The callers pass the emitter’s local +Y as
that axis, the convention the compiled emitter and the editor’s gizmos already use, and the
emitter’s orientation rotates the result outside the sampler.
The core owns its own arithmetic. particle_sine, particle_cosine, particle_square_root
and particle_cube_root are defined in the same file, over particle_two_pi and its reciprocal,
so the shared text calls no platform maths function in either language. The sine reduces its
argument in turns rather than radians, which keeps the reduction exact many turns out from zero,
and the cosine is a quarter turn of it rather than a half of pi added to a large radian value.
That arithmetic is paid once per BIRTH, not once per frame. particle_sample_shape is called only
from spawn_one and from engine/presentation/render/shaders/particle_emit.comp; the simulate path
evaluates neither a root nor a polynomial, so a living particle costs exactly what it did before.
engine/domain/vfx/include/SushiEngine/vfx/deterministic_backend.hpp binds the core’s shape
numbering to VFX::EmitterShape with static_asserts, one per shape plus one on
EMITTER_SHAPE_COUNT, so reordering or extending the enumeration fails the build instead of turning
every authored cone into a box. The numbering exists in two places, the enumeration and the core;
the shader’s third copy is gone.
1.14. Buoyancy has a direction, not a sign
VFX::BuoyancyModule accelerates a particle along the emitter’s own up axis, scaled by an optional
curve over the particle’s normalised age. It is a stand-in for a density difference and not a
simulation of one: nothing here knows what the medium weighs or what the particle weighs, and what
an author has is a number of metres per second squared and a curve to shape it with.
It is a module rather than an author’s negative gravity because gravity is authored as a
world-space vector. An effect that rises by authoring a positive Y component has asserted that
up is +Y everywhere, and the rest of the system then has to keep believing it: false on a globe,
false on any body an entity is surface-bound to, and false for an emitter tilted along a slope whose
author meant the plume to tilt with it. Buoyancy names the direction instead of encoding it. The
direction is not authored at all — it is read from the pose, which the transform system has already
composed against whatever body the entity stands on, so the module needs no dependency on the
planetary code. Gravity stays the module for a real downward pull, and the two compose.
Both backends resolve the axis once per step and normalise it, so a scaled emitter does not float
faster than an unscaled one, and both sample the curve at the age the particle carries at the
start of the step. CPUDeterministicBackend::integrate_range rotates the emitter’s local +Y
through the pose rotation; engine/presentation/render/shaders/particle_simulate.comp reads
Scene::GPUEmitter::buoyancy_up, filled from Render::ParticleEmitterView::up.
That view field is set together with the model matrix by
Render::ParticleEmitterView::place, in
engine/presentation/render/include/SushiEngine/render/scene_types.hpp. The two are not
independent — at every site placing a real entity the axis is basis column Y of the matrix,
normalised — and a forgotten axis does not read as forgotten. It reads as world up, which is right
on flat ground and wrong everywhere else, so it survives every test written on a plane. The
synthetic rain and wisp emitters in RuntimeSimulation are the documented exception: they build
their matrices with an identity rotation, so the column would answer world +Y, and they assign
the direction away from the planet — which they already computed to place the deck — directly
instead of calling place.
Nothing needed migrating at the time. When the buoyancy module landed there was no .effect
file in the tree, so no serialized asset carried an authored upward gravity into the new module and
converting the built-ins was a code change rather than an asset sweep. assets/effects/ exists
now and ships five effects, each with its own authored buoyancy block – they were written
against the module, not migrated onto it.
engine/world/serialization/source/effect_serializer.cpp does read and write the module, and
defaults it when the key is absent, so the first .effect file anyone writes round-trips it and a
file predating it loads with buoyancy disabled.
1.15. What a backend cannot run
The two backends do not run the same module set, and
engine/domain/vfx/include/SushiEngine/vfx/capability.hpp states the difference as a value rather
than leaving it to be discovered. Each backend declares what
it supports as a constant, VFX::particle_required_capabilities reads what a compiled emitter asked
for, and VFX::particle_unsupported_capabilities returns the difference. It is a pure function of
its arguments: no logger, no stream, no registry, no global state.
The vocabulary separates the two collision surfaces because their futures differ. Depth-buffer
collision is a screen-space test against the frame the renderer already drew, so it can never be
deterministic — there is no depth buffer on a headless server, and the answer would depend on where
the camera happened to be pointing. Signed-distance-field collision reads a volume rather than a
frame, so a host counterpart was possible, and PS2d wrote it: the deterministic integrator takes a
VFX::ParticleDistanceField — a function pointer and a context, because a clipmap texture is not
something it can sample — and shares the contact response with the shader through
particle_kernels.inc. VFX::DETERMINISTIC_BACKEND_CAPABILITIES gained the one bit and will never
gain the other.
That constant now carries a condition it cannot express. The integrator’s branch also needs the host to have supplied a field; a host that supplies none runs an authored emitter with no collision and the constant still says the capability is supported. The compute path has the same gate on whether a clipmap was bound, so the backends behave alike, but the honest answer to “will this emitter collide” is per-host and a compile-time constant cannot give it.
Nothing calls the query. tests/unit/test_particle_capability.cpp is its only caller. No
editor panel, no simulation host, and no renderer asks the question, so an author who enables
collision on a deterministic emitter sees exactly what they saw before: particles passing through
the floor and nothing anywhere saying why. The reporting surface was deferred to its own change, so
the authoring experience is unchanged.
1.16. Gaps in the particle system
Two behaviour differences the shared thresholds’ naming turned up were closed by moving the
arithmetic into particle_kernels.inc so both backends call one function instead of writing their
own comparison; they are recorded here as closed rather than removed, because the shape of the bug
is what made the fix’s shared-function form worth choosing.
-
Lifetime is clamped the same way on both backends (closed).
Kernels::particle_clamp_ lifetimeraises any authored lifetime belowparticle_minimum_lifetime()to that minimum, andCPUDeterministicBackend::spawn_oneandengine/presentation/render/shaders/particle_emit.compboth call it. It used not to be: the deterministic backend substituted the minimum only for a non-positive lifetime, the shader took a true clamp, and an authored lifetime strictly between zero and1e-4seconds reached the two backends’ curve samplers at different points — lifetime is the divisor every over-life curve is sampled through, so it was every curve differing, not one number. -
The vortex axis threshold is a distance for any authored axis length (closed).
Kernels::particle_vortex_tangentnormalises a vortex field’s axis before the cross product, soparticle_vortex_swirl_distance_epsilonreads as metres regardless of what the author typed for the axis’s length. It used not to be: the axis reached both backends unnormalised, so the threshold scaled with the axis length, and an axis ten long made the field give up ten times closer to itself than the same axis at unit length. -
The deterministic pose carries no scale.
CPUDeterministicBackend::steptakes a position and a rotation, while the cosmetic view’s model matrix carries the entity’s scale, so the two domains of one effect disagree about how large the shape is. -
Three duplications remain, and none of the three can move into the shared core. An audit found seven places the two backends wrote the same arithmetic twice; five are now one text in
particle_kernels.inc— the death predicate, the two damping clamps, gravity and buoyancy, and the velocity integration order. Three are still written twice, for two different reasons:- Force-field iteration (
particle_force_fieldsinengine/presentation/render/shaders/particle_common.glslagainst the field loop inCPUDeterministicBackend::integrate_range) and LUT sampling (sample_curveinengine/presentation/render/shaders/particle_simulate.compagainstsample_curve_lut/sample_gradient_lutinengine/domain/vfx/include/SushiEngine/vfx/compiled_emitter.hpp) each index an authored or baked array by an offset computed at runtime, andparticle_kernels.incforbids array indexing in its shared text, so neither can move there under the current design. - Spawn initialisation — the draw order for a newborn particle’s speed, size, lifetime,
rotation and angular velocity — is written once at
engine/domain/vfx/include/SushiEngine/vfx/deterministic_backend.hpp:525-560and again atengine/presentation/render/shaders/particle_emit.comp:75-104. This one is not an array-indexing problem: the two sides draw those five fields from different generators by design,state.rng.next_rangeagainst a host-side PCG32 stream on the CPU andrand_rangeagainst a per-particle hash derived fromparticle_spawn_seedon the GPU, so there is no single expression that would still produce each side’s own numbers if moved into the shared text. Only the shape sampler inside it and the lifetime clamp it calls are already shared. The rest of the spawn record — colour, alpha,birth_sizeandflipbook_frame— is not a generator disagreement at all: both sides setcolor = emitter.color,alpha = 1.0,birth_size = size, andflipbook_frame = 0, plain constant or derived assignments that match by construction, not by review.
All three sites carry a cross-reference comment naming their counterpart in the other language, the host half of each is pinned by
tests/unit/test_particle_shared_edge.cpp, and the three are kept agreeing by review alone. - Force-field iteration (
-
The shader half of those three duplications is unverified. Nothing in this repository runs
particle_force_fields,sample_curve, or the GPU spawn draw order in a compute shader and compares the result against the host functions they are meant to agree with. Review is the only thing holding the two languages together for force-field iteration, LUT sampling, and spawn initialisation; a divergence introduced on the shader side of any of the three would compile, run and pass every existing test. -
A drag behaviour changed, deliberately, as part of moving the clamp into the shared core.
particle_integrate_velocitynow skips the drag multiply entirely whendrag_factor >= 1, where the two backends used to apply it unconditionally wheneverUPDATE_DRAGwas set. For an authored drag coefficient that makes the factor exceed 1 — only reachable with a negative coefficient — the old code amplified velocity every step and the new code leaves it alone. The editor clamps drag to[0, 10](applications/editor/source/vfx/particle_panel.cpp:676), so the old behaviour was reachable only through a hand-edited or deserialized asset, and both backends changed identically, so the two still agree with each other. Recorded here as a parity-preserving change, not a regression. -
Depth-buffer collision runs on the cosmetic path alone. The deterministic integrator has the distance-field branch and will never have the screen-space one. Reportable, and permanent.
-
The deterministic pool has no fixed cap.
DeterministicEmitterStateis now a 32-byte counter and RNG state, snapshotted for rollback beside the pool; the particles themselves live in host-owned storage sized to the emitter’s authored capacity. Every host steps that storage throughVFX::Driver::step_deterministic; what differs between hosts is only the job runner each one hands it.RuntimeSimulationand the standalone runtime pass a real thread-pool runner, and the editor’s Effect Preview now passes aJobs::SerialJobRunnerit owns through the same driver call, so a serial step and a partitioned one are the same code taking a different runner rather than two paths.CPUDeterministicBackend::stepstill exists for a host that supplies no runner at all, and the throughput instrument measures through it directly. -
The backend is authored, not derived. Which domain an emitter runs in is a field on the emitter descriptor rather than a consequence of whether anything reads the result. There is no significance ranking and no cull proxy.

