Contents

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::step takes 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.comp stores the pool slot in GPUParticle::seed, and engine/presentation/render/shaders/particle_ribbon.vert reads that field as both the trail index and the noise stream the beam jitter is drawn from.
  • RuntimeSimulation::world_transform allocates a std::vector per 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.comp shifts 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_ lifetime raises any authored lifetime below particle_minimum_lifetime() to that minimum, and CPUDeterministicBackend::spawn_one and engine/presentation/render/shaders/particle_emit.comp both 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 and 1e-4 seconds 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_tangent normalises a vortex field’s axis before the cross product, so particle_vortex_swirl_distance_epsilon reads 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::step takes 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_fields in engine/presentation/render/shaders/particle_common.glsl against the field loop in CPUDeterministicBackend::integrate_range) and LUT sampling (sample_curve in engine/presentation/render/shaders/particle_simulate.comp against sample_curve_lut/sample_gradient_lut in engine/domain/vfx/include/SushiEngine/vfx/compiled_emitter.hpp) each index an authored or baked array by an offset computed at runtime, and particle_kernels.inc forbids 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-560 and again at engine/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_range against a host-side PCG32 stream on the CPU and rand_range against a per-particle hash derived from particle_spawn_seed on 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_size and flipbook_frame — is not a generator disagreement at all: both sides set color = emitter.color, alpha = 1.0, birth_size = size, and flipbook_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.

  • 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_velocity now skips the drag multiply entirely when drag_factor >= 1, where the two backends used to apply it unconditionally whenever UPDATE_DRAG was 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. DeterministicEmitterState is 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 through VFX::Driver::step_deterministic; what differs between hosts is only the job runner each one hands it. RuntimeSimulation and the standalone runtime pass a real thread-pool runner, and the editor’s Effect Preview now passes a Jobs::SerialJobRunner it 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::step still 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.