Skip to content
hikari
RenderingEditorAIToolchainDocumentation
GitHub
hikari

© 2026 Flying Rat Studio.
All rights reserved.

Explore the engineDocumentationContributorsLicenseBack to top
Documentation / Systems
Browse docs
Overview
Tutorials16
OverviewFirst game projectFirst entityAuthored user_data and the inspectorFirst mesh and materialFirst physics body and triggerFirst character controllerFirst session servicesFirst UIFirst input actionFirst messagesPlay, Edit, and scenesFirst runtime spawnFirst motionFirst skeletal animationAssets in Play (soft refs and hot reload)Dynamic editor recompile
Guides16
OverviewDevelopment guideGameplay APIChoosing component storageBuild and packagingProject filePluginsHikari Plugin APIData-driven content, JSON, and pathsScripting with KawaShader authoringTime of dayUser interfaceUI layoutUI widgetsMigration from Unity / UnrealActor and component lifecycle
Systems28
OverviewArchitectureApplication lifecycleFrontends and driversPlatforms and supportSession services and cross-scene stateScenes and gameplayActor communication (hi.actors)Game-facing refsSave / replication wire versionCoordinate space and camera conventionsRenderingRenderer architecture mapFrame governorGPU particlesVisual ZonesVolumetric mediaInputAudioPhysicsMotion KitTemporal KitAssets and ShinraPrefabsAsset residencyAsset formats (Shinra pipeline)UI and editorEditor asset hot reloadEditor Project Selector
Language reference2
OverviewAkari language referenceKawa language reference
Engine overview
Start exploring
  • No matching sections. Try fewer words or another topic.
NavigateEnter Openesc Close
Overview
Tutorials16
OverviewFirst game projectFirst entityAuthored user_data and the inspectorFirst mesh and materialFirst physics body and triggerFirst character controllerFirst session servicesFirst UIFirst input actionFirst messagesPlay, Edit, and scenesFirst runtime spawnFirst motionFirst skeletal animationAssets in Play (soft refs and hot reload)Dynamic editor recompile
Guides16
OverviewDevelopment guideGameplay APIChoosing component storageBuild and packagingProject filePluginsHikari Plugin APIData-driven content, JSON, and pathsScripting with KawaShader authoringTime of dayUser interfaceUI layoutUI widgetsMigration from Unity / UnrealActor and component lifecycle
Systems28
OverviewArchitectureApplication lifecycleFrontends and driversPlatforms and supportSession services and cross-scene stateScenes and gameplayActor communication (hi.actors)Game-facing refsSave / replication wire versionCoordinate space and camera conventionsRenderingRenderer architecture mapFrame governorGPU particlesVisual ZonesVolumetric mediaInputAudioPhysicsMotion KitTemporal KitAssets and ShinraPrefabsAsset residencyAsset formats (Shinra pipeline)UI and editorEditor asset hot reloadEditor Project Selector
Language reference2
OverviewAkari language referenceKawa language reference
Engine overview
Systems16 min read

GPU particles

On this page
On this pageAuthoring and cookingVersion 1 documentRuntime pipelineGameplay controlScalability and project settingsLimits and invariants Back to top

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/:

PaneOwns
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.

Leaf edits are projected into a fresh SPT2 image immediately, validated by the normal runtime parser, and swapped through normal render residency. The projector preserves the cooked native program and only patches runtime operands, sampled curve tables, clocks, bounds, transforms, renderer constants, and emitter visibility, so the preview uses the shipping GPU kernels rather than an editor-only approximation. Unsaved texture picks are retained and bound directly by the preview host, avoiding a cook merely to inspect an atlas. Structural edits that require a different specialized program report that Compile is required. Solo and mute are preview-only projections 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

json
{
  "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: optional position and rotation placing the emitter inside the system, so emitters do not all fire from the origin. Rotation is degrees (pitch, yaw, roll) under the engine-wide R = Ry·Rx·Rz convention, and rotates the shape and the initialized velocity. Shinra ships the authored angles rather than a baked matrix: the runtime composes them with quaternion.fromEuler, so the cooker cannot drift into a second euler convention.

  • spawn: non-negative rate and optional bursts with time, count, cycles, and interval.

  • shape: point, box (extent), sphere (radius), or cone (radius, angle in degrees).

  • initialize: scalar or [min,max] ranges for lifetime, size, and rotation; velocity accepts a float3 or [min,max] float3 pair; color is linear RGBA.

  • modules: ordered gravity, drag, turbulence, color_over_life, and size_over_life operations. Curve and gradient times are normalized to [0,1]; size-over-life values multiply the particle's initialized size. turbulence is an analytic divergence-free curl force, evaluated as the closed-form curl of an axis-mixed periodic vector potential, with strength (m/s²), frequency (field cells per metre), and scroll (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: a billboard with premultiplied, straight, or additive blending. texture selects an optional sprite/atlas; columns, rows, and frames describe its grid; cycles advances the flipbook over normalized particle life. frame_blend cross-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_frame offsets each particle's flipbook phase by its own seed so a field of puffs never plays in sync. lit makes the fragment receive the scene: SH ambient plus the light cluster's point/spot/directional lights as isotropic in-scattering — deliberately no normal and no shadow taps, because a billboard is a volume sample, not a surface. soft_fade_distance fades against the scene's linear depth. velocity_stretch aligns a camera-facing billboard to view-space velocity and extends it by speed. camera_facing: false follows the authored emitter and actor axes instead of the view. An empty texture retains the procedural soft-disc fallback. stable_order: true draws 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 least rate × lifetime or 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 shared texture, blend, lit, and soft_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 writes head % 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 of alive - 1 segments. A full ring overwrites its oldest particle: drop-oldest is the intended failure mode, so size capacity to at least rate × 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 src/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.

Runtime pipeline

Diagram
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 before the transparent forward pass on that pass's own command buffer. 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. Compare-exchange is unusable here: it lowers to the weak form, which fails spuriously without changing the expected value, so two threads read that as success and allocate the same particle. 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.

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.host_api.particle() (not a root hi.particle() barrel), 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.host_api.particle().setGlobalSpawnScale / setGlobalCullDistance / setGlobalCullFadeBand / setGlobalCapacityBudget / 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 spawnCount is staged), so quality presets shrink every effect without touching assets. Explicit emitBurst calls 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. setGlobalCullFadeBand sets that band width (project default cull_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-component cull_distance (-1 inherit / 0 never / >0 explicit) and the important flag are scene-JSON + inspector fields with matching hi.host_api.particle() setters.
  • Capacity budget (max_total_capacity): each publish, live systems' asset capacities are summed; when over budget the farthest non-important systems 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; systems beyond the bounded scratch list still sleep.
  • Catch-up limits (max_steps_per_frame 1–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.
  • space chooses where the emitter transform is applied, and exactly one place applies it: world bakes it into position and velocity at spawn, so particles detach from a moving emitter; local carries 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 a COOK_SCHEMA_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.
PreviousFrame governorNext Visual Zones

Documentation follows the current engine checkout.

Snapshot cc148c75Source docs/systems/particles.md
On this pageAuthoring and cookingVersion 1 documentRuntime pipelineGameplay controlScalability and project settingsLimits and invariants Back to top