Hikari particles are GPU-resident effects with CPU orchestration. The CPU advances one fixed-step clock per system and publishes compact emitter commands; it never stores, updates, compacts, or reads individual particles. Metal and D3D12 execute the same compiled module program, maintain alive/dead lists, and produce indirect draw arguments entirely on the GPU.
Authoring and cooking
Source assets use strict *.particle.json documents. Shinra validates and resolves them into typed particle IR, removes disabled operations, fuses compatible adjacent operations, samples curves to fixed 64-entry float4 tables, and asks Akari to compile one target-native update program per unique structural hash. The resulting immutable *.shinparticle SPT2 asset contains cooked metadata, operands, and deduplicated native program blobs. Authoring JSON never enters the runtime data path.
The editor treats particle assets as placeable resources. Drag one into a scene to create a _particle actor, or add a Particle component and select its System. The inspector exposes Enabled, Play on Start, and Destroy on Finish for one-shot actors.
Particle asset detail is a Class 2 GPU viewport with transport controls over a three-pane authoring surface, under src/hikari/src/editor/asset_detail/kinds/particle/:
| Pane | Owns |
|---|---|
Emitters (stack_panel.zig) | System selection plus the emitter list; add, duplicate, remove, solo, and mute. Shows each emitter's capacity and dims disabled or preview-muted ones. |
Stack (stack_panel.zig) | The selected emitter heads its own stack — spawn and shape run before any module — followed by the ordered modules, each with an on-card enable toggle, reorder, and remove. The toggle flips enabled without a select-then-checkbox trip through the properties pane, because A/B-ing one module is the whole workflow. |
Properties (properties_panel.zig) | Typed parameters for the system (capacity, clock, space, seed, and bounds), emitter (name, capacity, transform, spawn, shape, initialize ranges, texture/atlas, facing, soft fade, stretch, and blend), or module. Numeric rows are the shared components.value_rows grammar; the birth color is the shared color field (swatch + popup with HDR presets — emissive particles are authored above 1); the texture row has a pick and a clear. Curve and gradient modules use the reusable fixed-capacity key editor under editor/ui/components/. |
Structure and values are deliberately separated. document.zig owns the parsed JSON and every structural edit; fields.zig reads and writes only the leaf values a control binds, so keys no surface exposes yet survive a save; defaults.zig holds templates that are valid source documents on their own, because Compile runs the real cooker. Structural edits invalidate widget ids, so panes report intent and the state machine applies the edit and rebuilds in one place — value edits never rebuild, which keeps dragging a number cheap.
Every document mutation is undoable (edits.zig, on the editor's shared snapshot history — toolbar buttons plus the editor-wide ⌘Z routing of editor/undo.zig). The snapshot is the serialized source document: the JSON DOM lives in one arena, so clone is stringify-and-reparse, the same round trip Save exercises. Value edits hold one coalescing scope open across frames — the graph canvas's one-scope-per-drag pattern — and the scope settles on the first frame that produced no edit, so a numeric drag or a burst of typing lands as one step. Undo restores through the document slot and then rebuilds panes and reprojects the live preview, exactly like any structural edit.
Controls bind to a scratch copy of the typed fields, and a single diff per frame turns any number of touched controls into one document write. Adding an emitter raises the system capacity when the existing emitters already claim it all, because the alternative — silently shrinking someone else's capacity — is the edit an author cannot see.
Every edit sends the document as it stands to Shinra's compile lane (see compile requests); the answer is a complete SPT2 image, validated by the normal runtime parser and swapped through normal render residency. Native programs are cached by structural hash, so a value edit reuses the compiled kernels and a structural edit (shape, renderer, stable order) previews its new program live. The preview therefore runs exactly what a cook ships, with no editor-side particle compiler. Unsaved texture picks are retained and bound directly by the preview host, avoiding a cook merely to inspect an atlas. Solo and mute are preview-only: they disable emitters in the text sent for the preview and never modify the source document.
The transport exposes deterministic timeline seeking. A seek restarts the GPU pools from the authored seed and advances fixed steps toward the requested time in bounded batches; it never attempts to reconstruct state by changing only the displayed clock. Stop resets and pauses at zero, while Restart resets and continues playing. Shape and conservative system-bound overlays can be toggled independently in the preview toolbar.
The status line reports planner-side capacity, fixed-step dispatches, particles spawned by the last submission, a conservative live-particle upper bound, and discarded catch-up time. These statistics introduce no GPU readback or render-thread synchronization. Cooker pending/failure/removal state is forwarded to the open particle detail as an inline diagnostic; a failed cook explicitly retains the last valid preview and points to the Asset Pipeline log for the full tool output.
Save atomically rewrites the strict source document; Compile is save-and-await-pipeline. It sends no private rebuild command: Shinra observes the filesystem change, cooks it, and publishes the result through the normal hot-reload path. A failed cook leaves the last valid program alive.
The authoring model is deliberately a layered emitter/module stack instead of a free-form graph. Common effects remain quick to read and edit, while typed IR keeps JSON out of runtime code. Shared initialize/finalize/render entry points remain engine Akari packages. Shinra generates the spawn and straight-line update entry points selected by the optimized stack and embeds Akari's target-native result in SPT2.
Version 1 document
{
"kind": "com.hikari.particle",
"version": 1,
"capacity": 65536,
"duration": 5.0,
"looping": true,
"space": "local",
"fixed_timestep": 0.016666667,
"seed": 42,
"bounds": { "center": [0, 2, 0], "extent": [8, 8, 8] },
"emitters": []
}Each emitter contains name, optional enabled and capacity, plus:
-
transform: optionalpositionandrotationplacing the emitter inside the system, so emitters do not all fire from the origin. Rotation is degrees(pitch, yaw, roll)under the engine-wideR = Ry·Rx·Rzconvention, and rotates the shape and the initialized velocity. Shinra ships the authored angles rather than a baked matrix: the runtime composes them withquaternion.fromEuler, so the cooker cannot drift into a second euler convention. -
spawn: non-negativerateand optional bursts withtime,count,cycles, andinterval. -
shape:point,box(extent),sphere(radius), orcone(radius,anglein degrees). -
initialize: scalar or[min,max]ranges forlifetime,size, androtation; velocity accepts a float3 or[min,max]float3 pair; color is linear RGBA. -
modules: orderedgravity,drag,turbulence,color_over_life, andsize_over_lifeoperations. Curve and gradient times are normalized to[0,1]; size-over-life values multiply the particle's initialized size.turbulenceis an analytic divergence-free curl force, evaluated as the closed-form curl of an axis-mixed periodic vector potential, withstrength(m/s²),frequency(field cells per metre), andscroll(field velocity in m/s). Two octaves cost six scalar cosine lanes per particle per fixed step, with no lattice hashes, branches, texture traffic, or finite-difference sampling. -
renderer: abillboardwithpremultiplied,straight, oradditiveblending.textureselects an optional sprite/atlas;columns,rows, andframesdescribe its grid;cyclesadvances the flipbook over normalized particle life.frame_blendcross-fades adjacent flipbook frames in the fragment (one extra sample) instead of stepping, which reads as roughly four times the authored frame rate;random_start_frameoffsets each particle's flipbook phase by its own seed so a field of puffs never plays in sync.litmakes the fragment receive the scene: SH ambient plus the light cluster's point/spot/directional lights as isotropic in-scattering — without a surface normal. Shadow reception is a separate opt-in below.soft_fade_distancefades against the scene's linear depth.velocity_stretchaligns a camera-facing billboard to view-space velocity and extends it by speed.camera_facing: falsefollows the authored emitter and actor axes instead of the view. An empty texture retains the procedural soft-disc fallback.stable_order: truedraws the billboards in spawn order from a FIFO ring pool instead of the compacted alive list, whose order races across lane groups and reshuffles every frame — straight-alpha overlap renders that reshuffle as popping, while spawn order makes the blend the same (depth-approximate) result every frame, which is what dense smoke needs to read as smooth. It shares the ring contract with ribbons: constant lifetime, and capacity sized to at leastrate × lifetimeor the oldest particles are overwritten.A renderer may instead be a
ribbon: one camera-facing strip through the emitter's particles in spawn order — sword arcs, projectile trails, wisp streaklines. Fields:width(multiplies the particle's size, so size-over-life tapers the trail),uv_tile(texture repetition along the strip), plus the sharedtexture,blend,lit, andsoft_fade_distance. Ribbons require a constant lifetime: death must be FIFO, because connectivity is spawn order and nothing is ever sorted. The pool becomes a ring — spawn writeshead % capacity, and the CPU computes the exact alive window from one u32 of spawn history per fixed step (capped at 4096 window steps), so ribbon emitters have no free list, no compaction, no GPU counters, and a plain non-indirect draw ofalive - 1segments. A full ring overwrites its oldest particle: drop-oldest is the intended failure mode, so size capacity to at leastrate × lifetime. One emitter is one strip; independent trails are independent emitters. Untextured ribbons feather across the width only. Billboard-only keys (atlas, facing, stretch) are rejected on a ribbon rather than ignored. Custom materials and mesh particles are not supported.
See hikari-games/example/assets/particles/gpu_fountain.particle.json for a complete two-emitter effect, and inferno_bonfire.particle.json for a six-emitter showcase that exercises the full renderer surface: a 4×4 looping turbulence flipbook atlas (textures/fx/fx_puff_atlas, generated procedurally) shared by an additive flame core, faster additive flame tongues, and a straight-alpha smoke plume; velocity-stretched ember sparks with periodic burst cycles; a point-source ribbon wisp whose strip winds through the curl field as a coherent streakline; and an additive ground glow — all using emitter transforms, HDR gradient colors, size curves, buoyancy/gravity, analytic curl turbulence, drag, and per-emitter soft depth fade. The flame layers blend flipbook frames from random start phases, and the smoke plume and wisp are scene-lit.
Particle shadows
Select an emitter in the particle editor and use its renderer controls:
| Control | Purpose |
|---|---|
| Receive shadows | Shadow direct scene lighting on lit particles, including attenuation through local fog and smoke. Requires Lit by scene; emissive/unlit colour remains emissive. |
| Shadow casting: Off | No caster geometry, acceleration structure, page work, or density brick for this emitter. This is the default. |
| Shadow casting: Solid / Cutout | Cast an opaque silhouette from the sprite or ribbon alpha. Alpha cutoff selects the silhouette; flipbook frame blending and particle opacity are included. |
| Shadow casting: Volumetric | Cast soft, translucent smoke shadows from particle density. Extinction controls light loss per metre. Requires volumetrics in render quality and the light's volumetric-shadow flag. |
Reception defaults off as well. These are independent per-emitter choices, persisted in renderer.shadows:
{ "shadows": { "receive": true, "casting": "volumetric", "cutoff": 0.5, "extinction": 1.0 } }The source mode names are off, cutout, and volumetric. Switching them does not specialize the simulation program. Unsaved edits use the same preview projection and runtime parser as other particle parameters.
Cutout geometry is expanded on the GPU with the visible billboard/ribbon geometry functions. VSM culls it against dirty pages into a bounded work list; overflow uses a conservative draw instead of losing casters. RT uses a refittable, shadow-only triangle stream with the same alpha test. Procedural disc billboards tighten their shadow triangles to the conservative support of the current alpha cutoff before VSM culling or RT acceleration-structure updates. Position and alpha UVs are cropped together, preserving the shadow silhouette while avoiding traversal and raster work over transparent margins. This uses the existing triangle count and storage; textured sprites and ribbons retain their full support. The stream does not become reflection or GI geometry. Camera-facing cards cast their current camera-facing silhouette, so use fixed-facing cards when that rotation would be distracting.
Lit particle receivers use stable PCF in VSM/Hybrid and material-aware rays in RT mode. Local geometry shadow work respects the light budget; fog-volume shadows are one medium shadow map fetch per mapped fog caster. Hybrid retains its normal division: directional opaque shading can use RT, while particles, transparent receivers, and froxels use VSM. RT-only mode does not request or build geometry shadow maps for particles. Cutout receivers use their two-sided geometric plane for VSM receiver bias and the shared robust RT origin offset; this prevents cards from shadowing themselves as depth rounding changes.
Volumetric casting represents each particle as a soft density puff; it does not reinterpret sprite textures as a three-dimensional material. Ribbon vertices become puffs along the ribbon. Density is GPU-scattered into a 32³ brick, with large puffs using coarser splats to bound per-particle work, then filtered through six levels. Sub-voxel integration preserves density during motion. The bricks join the bounded-media BVH, so the medium shadow maps built from it shade surfaces, fog and receiving particles, in either backend. Visible sprites remain the colour producer; the density proxy is excluded from visual fog integration to avoid rendering smoke twice. Coloured transmission and multiple-scattering particle materials are separate features.
Receiving volumetric casters share incident lighting on a 9×9 lattice per billboard or ribbon segment whenever particle density is active. The GPU evaluates the same light budgets, geometry visibility, and medium shadow map transmittance in the medium_shadow_build pass, right after the maps are built, then fragments bilinearly interpolate that lighting. Cost scales with live particles rather than shaded pixels times overlapping layers. Sprite colour, alpha, flipbook animation and depth fading remain per pixel. The lattice follows current geometry and lights every frame, with no temporal history to leave trails. This deliberately smooths sharp shadow boundaries on smoke; ordinary receivers and cutout casters retain per-pixel geometry visibility. Per-pixel reception reads fog-volume shadows from the same medium shadow maps, one fetch per fog caster, so lattice and per-pixel paths cannot disagree about the medium. VSM smoke demand visits those same lattice sample positions and footprints before page allocation, rather than replaying every covered sprite pixel. This includes samples needed to interpolate partially visible sprites. If no particle density producer is active, reception and demand use the per-pixel path. The buffer is surface-owned, grows on demand, and allocates nothing for disabled or absent receivers.
graphics/particles/lighting.zig owns reception job preparation, texture-index resolution, dispatch sizing, and readiness publication for both backends. Platform particle-lighting adapters own pipeline creation, native binding, barriers, and dispatch encoding. Pending pipelines and failed bakes leave reception unavailable; readiness is published only after successful backend completion. graphics/particles/shadow_media.zig also owns density-brick selection, fingerprint reuse, clear/scatter/resolve sequencing and receipt updates. Both backends retain their native scratch allocation, constant uploads and barriers.
Smoke bricks rebuild when committed simulation or emitter parameters change and reuse paused results. Scratch storage and shadow pipelines are allocated on first use. Density shares the render.capacity.fog_volumes admission limit with authored fog (fog first) and needs one of the render.capacity.media_bricks density bricks (default 64), which producers take before fog; producers past either limit cast no volumetric shadow, with a diagnostic logged once per budget value. Medium shadow reach and sample counts follow the existing volumetric settings. Keep authored system bounds conservative and reasonably tight: they determine density resolution. World-space birth bounds are retained for the maximum particle lifetime so moving an emitter does not clip its detached smoke or leave stale VSM pages.
The Example game’s Particles scene includes separate receiving-dust, cutout-card and shadow-casting-smoke stations alongside its original fountain and lit smoke. Each new station uses a dedicated asset so its controls can be compared independently.
Runtime pipeline
Diagram source
flowchart LR
A["*.particle.json"] --> B["Shinra typed particle IR"]
B --> C["Optimize + structural hash"]
C --> D["Akari specialized GPU program"]
D --> E["SPT2 *.shinparticle"]
E --> F["Scene Particle component"]
F --> G["Fixed-step emitter commands"]
G --> H["GPU spawn + specialized update + compaction"]
H --> I["GPU billboard (indirect) or ribbon (ring draw)"]GPU pools are keyed by World ID, entity generation/slot, program and emitter. Edit and Play therefore own separate simulation state while sharing compiled programs. Program references also retain their World ID, allowing stopped Play pools to retire even while the editor still uses the same asset.
Particle programs can bind before their sprite textures arrive. Missing sprites participate in the world's texture residency tickets and rebind on texture readiness. Texture recooks release matching retains in every owned World before eviction and preserve simulation state; program recooks release the cooked blob and render ownership before rebinding. Keeping an Edit-world sprite retain through eviction would strand the old texture generation and prevent Play from acquiring it.
The OIT composite loads the raster pixel from each accumulation attachment. Its viewport may cover a reduced render rectangle inside a larger allocation, so fullscreen UVs cannot address these inputs directly: doing so moves and clips particles as reconstruction changes render resolution. Additive particles write coverage-weighted depth themselves because their MRT blend factors are ONE/ONE. A zero-opacity particle must contribute neither colour nor depth. Headless regressions against the compiled Metal shaders are documented in dev helpers.
Each published step carries a monotonic receipt, and each restart carries a generation that persists in subsequent publications. The executor consumes a receipt only after encoding: retained frames and additional views draw existing state without replaying simulation or resets. Initialization, FIFO history and live bounds also commit after encoding; encoder or parameter-staging failure leaves the pool retryable. The render-side live bound advances with encoded steps, so replaced CPU publications cannot age particles out of the update grid. This adds constant work per emitter step and no GPU readbacks.
Each emitter owns a 48-byte particle-state buffer (ParticleState: two float4 + half4 colour + half2 size/rotation + base size), ping-pong compact alive lists, a dead-list stack, atomic counters, and indirect arguments. Initialize, update, spawn, and finalize kernels run during frame preparation, before shadow-map demand and RT scene preparation. Their committed GPU state is shared by shadow producers and the later transparent draw. Finalize rearms the compaction cursor for the next step rather than a separate opening kernel doing it, which removes one dispatch and one barrier per emitter per fixed step.
Neither backend gets that ordering for free, so both state it. Metal 4 performs no implicit hazard tracking: the whole simulation is one compute encoder that waits on and updates the graph's shared frame fence, with an explicit encoder barrier between kernel phases — a barrier at the end of a single-dispatch encoder orders nothing, and a null fence leaves the indirect draw racing the simulation. D3D12 issues per-resource UAV barriers and transitions the state, alive, and argument buffers for the draw. Each backend encodes phase-major over waves of emitters whose pools are pairwise distinct, so one barrier per phase covers a whole fixed step instead of one per emitter.
Per-step constants never travel in a shared buffer. Metal and D3D12 stage distinct frame-local constants for each encoded step. Jobs with no GPU dispatch commit their CPU receipts and ring resets without staging constants. One constant buffer per instance would give every fixed step recorded into a frame the last step's dt, time, and spawn count.
The block carries one matrix, composed on the CPU in graphics/particles/params.zig. Spawn receives emitter-local → simulation space: the emitter's own placement for a local-space system, pre-multiplied by the actor model for a world-space one, which detaches its particles at birth. Draw receives the actor model and applies it only in local space. Because the two never travel in the same staged block, the spawn kernel needs no branch on simulation space and the emitter transform costs one 4×4 multiply per emitter per fixed step — not per particle.
Because spawn runs after update within a step, a fresh particle is drawn once before its first update — so the generated spawn kernel evaluates the over-life curve ops at age zero (forces are dt-scaled no-ops at spawn). Without this, every particle flashes its raw init color and size for one frame, which straight-alpha smoke renders as a constant spawn pop.
particle_spawn claims free-list slots by thread rank from a head it does not mutate, and particle_finalize commits the shrink once. This avoids contended allocation atomics in the spawn kernel. particle_update takes its grid bound from the previous step's published instance count with a plain load rather than a device atomic run from every thread.
The update grid is sized on the render thread from a sliding-window bound (LiveBound in graphics/particles/runtime.zig), not by pool capacity. A particle dies at age >= lifetime and lifetime never exceeds the emitter's authored maximum, so anything spawned more than one lifetime-window ago is provably gone; two counters rolled per window bound the live set in O(1) memory. The bound must only ever over-estimate — a low one would leave the update kernel skipping particles that are then neither drawn nor returned to the free list. An idle system dispatches nothing at all, and a steady one dispatches the particles it actually has instead of the pool it reserved.
Publishing survivors to the compact alive list costs one device atomic per lane group, not one per particle. particle_append_alive in the generated program has every lane vote, prefix-sums the votes into each lane's slot, and lets a single lane reserve the group's base — Akari's wave intrinsics (see the language reference) lower it to simd_* on Metal and Wave* on D3D12. This is why particle_update contains no early return: lane ops read the active mask, so a thread that left the dispatch is a thread missing from the vote. Threads past the grid bound ride along contributing zero, and there is a cooker test asserting the kernel stays return-free.
Rendering indexes the compact alive list and issues six billboard vertices per live particle through an indirect draw. Shared draw resolution rejects the wrong transparency lane before cache lookups and skips pools whose committed state proves they have no drawable particles on both backends. Atlas frame selection, depth fading, facing, and velocity alignment are vertex/fragment work over existing state; they add no per-particle CPU storage or upload. Raster pipelines are cached by renderer type and transparency lane: alpha emitters use OIT, while additive emitters write scene colour. All alpha particle systems share the same weighted transparency accumulation, regardless of spawn/declaration order. Its smooth inverse-quartic depth weight favors nearby layers without a flat near-camera range. It is still an approximation to sorted alpha compositing: overlapping smoke can mix, but an effect is not moved to the foreground simply because it was submitted later.
Paused systems still publish draw commands, so their last simulated state remains visible. Restart resets every emitter pool. Catch-up is capped at eight fixed steps per rendered frame to bound CPU command generation and GPU work after stalls; excess accumulated time is discarded deterministically.
Gameplay control
The game SDK exposes a required particle Host API table via hi.particle() (with raw host tables under hi.abi), keyed by stable ActorRef. It provides play, pause, stopAndClear, restart, emitBurst, isPlaying, isAlive, and isFinished, plus per-instance color, size, spawn-rate, seed, and 64-bit emitter-mask overrides. Overrides remain CPU-side constants on RuntimeInstance; cooked assets and shared GPU programs are never cloned. Kawa exposes the same control under Particle.*; its set_emitter_enabled operation avoids representing a 64-bit mask through a lossy script number.
For a non-looping system, duration ends emission rather than simulation. Fixed steps continue through the conservative lifetime window until the GPU pool is known to be empty, then isFinished becomes true. Scene destroy_on_finish marks the owning actor for normal deferred termination at that edge. This makes one-shot impacts and ability effects usable without actor-spawn/despawn workarounds while keeping completion queries free of GPU readback.
Scalability and project settings
configs/game.json → particles (editor: Project Settings → Particles) supplies global cost levers; the same values are live-tunable from the game through hi.particle().setGlobalSpawnScale / setGlobalCullDistance / setGlobalCullFadeBand / setGlobalCapacityBudget / setGlobalSystemBudget / setGlobalQuality. Because the sim is fixed-step and deterministic, tick-rate reduction is deliberately not offered — batching catch-up steps runs the same GPU work with worse spikes. The real levers:
- Spawn scale multiplies authored continuous rates and authored bursts at plan time (CPU-side, before
spawnCountis staged), so quality presets shrink every effect without touching assets. ExplicitemitBurstcalls are gameplay events and are never scaled. Ring emitters stay exact: the spawn history records post-scale counts. - Distance sleep: a system farther from the primary camera than its resolved cull distance is frozen — no
prepare, no frame commands, GPU pool untouched — and dims out across a fade band (color and alpha, so straight-alpha and additive emitters both fade) before the edge.setGlobalCullFadeBandsets that band width (project defaultcull_fade_band, 5 m). Residency retires an instance only when its program is also gone, so a slept system's state survives and resumes seamlessly. Per-componentcull_distance(-1inherit /0never />0explicit) and theimportantflag are scene-JSON + inspector fields with matchinghi.particle()setters. - Capacity budget (
max_total_capacity): each publish, live systems' asset capacities are summed; when over budget the farthest non-importantsystems sleep (same freeze mechanism) until the total fits. Important systems count against the budget but are never evicted. Candidate sorting is skipped when all gathered systems fit; the gather list grows with the scene; only a failed allocation puts the overflow to sleep. - System budget (
max_systems, default 64, 1–4096; live:setGlobalSystemBudget, KawaParticle.set_global_system_budget): the number of non-culled systems simulated and drawn at once. Past it the farthest non-importantsystems sleep (same freeze and nearest-first ranking as the capacity budget); important systems count against it but never sleep. A warning is logged once per budget value. Cost: 16 B of CPU scratch per particle component; each awake system costs its own dispatches and pool memory. - Spatial skip: a simulating frame does not visit every loaded system. Systems that inherit the project
cull_distancesit in 32 m world buckets (World.particle_index,render/content_index.zig); a bucket wholly past the cull distance is skipped without touching its systems, so cost follows what is near the camera, not what streamed cells loaded. Systems with their owncull_distanceor a game-set LOD distance (setLodDistance) are tested every frame. Moves reach the index through the transform paths (markRenderDirty, the hierarchy walk); writes ofcull_distanceand LOD distances re-place the system. Editor Edit mode and a project cull distance of 0 visit every system. - Catch-up limits (
max_steps_per_frame1–8,max_frame_delta): how much sim time a hitch is allowed to make up versus drop. - Quality toggles (
lit,soft_fade): pure uniform masks resolved at publish onto every frame command — one pipeline serves both states, so flipping them is free.
The editor preview always simulates with neutral limits (SimLimits{}) so an asset previews as authored regardless of project scalability.
Limits and invariants
- At most 64 emitters and 16,777,216 total configured particle slots per system.
- Per-emitter capacity must be non-zero and the sum cannot exceed system capacity.
- Simulation storage is GPU-only. CPU work scales with enabled emitters and fixed steps, not particle count.
- Bounds are authored and conservative; the runtime does not read GPU positions back for dynamic bounds.
spacechooses where the emitter transform is applied, and exactly one place applies it:worldbakes it into position and velocity at spawn, so particles detach from a moving emitter;localcarries it on the billboard, so the whole system follows.- Particle authoring cooks are not gated by the content-hash cook cache: every pass regenerates the program source and lets Akari's own cache decide. A change to the generator therefore reaches cooked assets without any cache-version bump.
- SPT2 parsers validate section bounds, native program ranges, structural-hash coverage, enum values, capacities, finite timing/bounds, and the mandatory initialize/velocity/color data prefix before GPU residency is created.
- Particle programs are content-addressed and shared. Instance pools are keyed independently, so actors simulate separately while identical programs share immutable instructions and curves.