Skip to content
hikari
RenderingEditorAIToolchainDocumentation
Download

© 2026 Flying Rat Studio.
All rights reserved.

Explore the engineDocumentationContributorsDownloadLicenseBack to top
Documentation / Systems
Browse docs
Overview
Tutorials17
OverviewPlay, Edit, and scenesFirst UI documentFirst game projectFirst entityComponent properties and the inspectorFirst mesh and materialFirst physics body and triggerFirst character controllerFirst session servicesFirst UIFirst input actionFirst messagesFirst runtime spawnFirst motionFirst skeletal animationAssets in Play (soft refs and hot reload)Dynamic editor recompile
Guides25
OverviewWorking in the editorDevelopment guideGameplay APIActor and component lifecycleThreads, jobs, events, and routinesGameplay AI tasksGame AI: perception and movement playgroundBrain assetsBrain execution for swarm populationsLocalizationGame cursorsChoosing component storageBuild and packagingProject filePluginsHikari Plugin APIData-driven content, JSON, and pathsScripting with KawaShader authoringChoosing and scaling shadowsTime of dayUser interfaceUI layoutUI widgetsMigration from Unity / Unreal
Systems32
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 governorPipeline compilationSkeletal animationGPU particlesVisual ZonesVolumetric mediaInputAudioPhysicsMotion KitTemporal KitUI documentsAssets and ShinraPrefabsAsset residencyAsset formats (Shinra pipeline)Document schemasUI and editorEditor asset hot reloadEditor Project Selector
Language reference3
OverviewAkari GPU ABIAkari language referenceKawa language reference
Engine overview
Start exploring
  • No matching sections. Try fewer words or another topic.
NavigateEnter Openesc Close
Overview
Tutorials17
OverviewPlay, Edit, and scenesFirst UI documentFirst game projectFirst entityComponent properties and the inspectorFirst mesh and materialFirst physics body and triggerFirst character controllerFirst session servicesFirst UIFirst input actionFirst messagesFirst runtime spawnFirst motionFirst skeletal animationAssets in Play (soft refs and hot reload)Dynamic editor recompile
Guides25
OverviewWorking in the editorDevelopment guideGameplay APIActor and component lifecycleThreads, jobs, events, and routinesGameplay AI tasksGame AI: perception and movement playgroundBrain assetsBrain execution for swarm populationsLocalizationGame cursorsChoosing component storageBuild and packagingProject filePluginsHikari Plugin APIData-driven content, JSON, and pathsScripting with KawaShader authoringChoosing and scaling shadowsTime of dayUser interfaceUI layoutUI widgetsMigration from Unity / Unreal
Systems32
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 governorPipeline compilationSkeletal animationGPU particlesVisual ZonesVolumetric mediaInputAudioPhysicsMotion KitTemporal KitUI documentsAssets and ShinraPrefabsAsset residencyAsset formats (Shinra pipeline)Document schemasUI and editorEditor asset hot reloadEditor Project Selector
Language reference3
OverviewAkari GPU ABIAkari language referenceKawa language reference
Engine overview
Systems28 min read

Volumetric media

On this page
On this pageFeature gatesQuality tiersPure RT sampling and reconstructionDual-lobe phase + multiple scatteringVolume-local material cacheFog volumes shadow surfacesPass orderZero-cost-offPerformance notesCross-platformAuthoring bounded smokeInspecting the cache Back to top

Shared froxel participating-media system: global height fog and bounded smoke volumes feed the same inject → integrate → composite path, with clustered lights, soft geometry shadows, shafts, and emission. Medium evaluation and integration remain current-frame; pure RT optionally reuses geometry visibility and sampled lighting.

Reference scenes: hikari-games/example/scenes/volumetric_reference.json (global fog) and hikari-games/example/scenes/volumetric_media_showcase.json (bounded media — three interiors, three graph materials, no global fog at all).

Two names, on purpose. What an author places is a Fog Volume (fog_volume component, FogVolumeComponent, hi.ComponentFogVolume) — named for the thing, because "media" reads as a media player in a component list. The renderer below it keeps the standard term participating media (volumetric_media.zig, HikariMediaVolume, media_volumes binding), because that layer also serves global height fog and "fog volume" would be wrong there. The seam is the publish boundary: publishFogVolumes reads fog_volume components and writes media_volumes.

Look ownership (density, height, albedo, anisotropy, distance, noise): Visual Zones. Pipeline placement: Rendering.


Feature gates

ControlWhereEffect
features.volumetric_fogProject RenderFeaturesoff | froxel (creation-time)
quality.volumetric_fogProject RenderQualitylow | medium (default) | high
Zone / look fog fieldsVisualLookSettings.fog + volume knobsDensity, height, albedo, anisotropy, distance, noise
Scene componentfog_volumeBounded box/sphere medium; active even when global fog density is zero
Material domainmaterial.domain = "volume"Extinction, albedo, emission, anisotropy, phase-lobe/multi-scatter, and density-noise defaults
Volume graphVolume OutputDensity, Albedo and Emission compile to bytecode; the rest (incl. Second Lobe, Lobe Blend, Multi Scatter) folds to constants
Per-lightLightComponentvolumetric_scattering_intensity (default 1; 0 skips light), cast_volumetric_shadow — shown as Fog shadows (default true)

Quality tiers

PresetTileSlicesMedium shadow map (texels² × slices)Mapped fog castersTaps per slice segment
low16px3232² × 1682
medium8px6448² × 24162
high8px12864² × 32244

Slice counts are authored for the full 1080p grid (gridDimensions). Below it the slice count follows the column count (slicesForColumns: rounded to eight, floored at the low tier's 32), so a smaller viewport keeps the tier's froxel proportions instead of its froxel count — a 1128-wide editor viewport at high has 141×88×72 froxels, not 141×88×128.

Medium resolution and fog-shadow quality are independent of RT light sampling. The grid and the medium shadow maps above are unchanged by the shadow backend. Raster/hybrid evaluate every contributing light against the map provider. Pure RT evaluates directional lights and unshadowed lights directly, caches the first sun's four visibility bits, and uses bounded sampling for crowded local lighting. Neither RT cache stores fog density, albedo, emission, or integrated fog.

The medium is sampled at the exact slab centre, never dithered. The density field is band-limited (edge feather floored at one froxel extent, noise octaves fading at the footprint), so a random offset within the slab would sample decorrelated values at every boundary and re-break the band limit at the scale it protects, printing froxel-scale grain.

Only directional geometry visibility is dithered, along the slab axis, by a hash of the froxel index (spatial, bit-identical every frame). It is the one integrand term that is not analytically band-limited: the 4-tap PCF quantizes the area average to five levels and slabs are 5–12× wider than the XY texels, so a hard shadow edge crossing the slabs prints a staircase. The dither and the composite kernel are a matched pair (the kernel's spatial spread never drops below 0.3). Never advance the offset per frame. Punctual lights evaluate visibility at the same slab centre as attenuation and phase, so a dithered position cannot apply lit-side visibility to fog behind a wall.

RT history is limited to lighting. Earlier whole-medium temporal designs caused density trails and are still excluded. The RT resolve blends only the shadowed local-light estimate, before multiplication by current albedo and extinction. Ambient, emission, directional lighting, and Beer-Lambert integration update every frame. Directional visibility reuse does not blend sun radiance. The per-surface noise-advection offset (wind) also persists.

Pure RT sampling and reconstruction

  • The first shadowed directional light retains four footprint visibility bits, a valid bit and a six-bit full-refresh timestamp in R16F. All packed values are integers from 0 to 2047, exactly representable without increasing texture storage. History is reprojected with a nearest load.

    • No usable history, or a sun tap whose ray can reach a moved caster (below): trace all four taps.
    • Settled, aligned froxels reuse visibility until their threadgroup's turn. Each 4×4×4 group refreshes all four taps once per sixteen frames, with staggered turns so whole waves skip traversal together.
    • Shadow edges, samples displaced by more than a quarter froxel, and unknown caster motion validate one rotating tap every frame. A changed tap retraces the other three immediately.
    • A stored full-refresh age enforces the deadline even when reprojection moves history between groups. A one-tap check never renews that timestamp.

    The host sums sun-direction step lengths over the preceding sixteen frames. It selects a maximum age of 16, 8, 4, 2 or 1 frames, keeping total direction travel since a reused full footprint within 0.25 degrees. Reversals count toward travel. Sustained gradual rotation therefore shortens the staggered schedule instead of periodically discarding the entire volume. An abrupt direction step, light identity change or reach change still rejects the sun history immediately; local lighting keeps its independent validity.

    Additional suns retain four current-frame rays. Unreported visibility changes in settled regions are observed by the next full refresh, within sixteen frames. Current sun radiance and medium properties are always evaluated anew. While recording, volumetric_sun_froxels and volumetric_sun_rays report visibility work. Other counters distinguish cold history, frame gaps, incompatible grids/ranges, cuts, sun changes, texture replacement, failed reprojection, empty history, caster intersections and tap disagreements. Validation counters identify edges, alignment failures, unknown caster motion and samples traced while the sun moves; these categories overlap. Local counters report fresh recovery froxels and visibility rays. Tallying remains recording-only.

  • Moving geometry never discards history. Each frame the host gathers every shadow caster that changed as world spheres at its previous and current pose: render commands (rigid moves, creates, releases, re-mapped cutouts, from the frame's CasterDirtyList), every RT-visible skinned caster (deformation is not a command) and changed cutout particle geometry. They are merged into at most 16 enclosing spheres (median splits, so nearby movers share one) in RtFroxelMotion. A froxel whose sun footprint rays can reach a sphere re-traces its footprint, exactly as without history; elsewhere the cache stands. Local lighting at a froxel within the largest shadowed local range of a sphere keeps the bounded recovery budget and replaces its history. A change with no knowable extent, or a frame without a change feed (alternate surfaces), sets motion_unbounded: the sun then validates one tap per froxel per frame and local lighting keeps the resolve's neighbourhood clamp, so an unknown change costs the schedule's savings, never a full re-trace.

  • Crowded shadowed local lights use systematic weighted samples. Importance includes intensity, attenuation, RGB magnitude and phase. Each selected contribution receives its inverse-probability weight; duplicate selections within a batch share one exact RT query. Selected rays run in coherent iterations after the ordinary light loop, avoiding lane-divergent traversal through every candidate. Candidate sets within the current sample budget use exact evaluation. Every selected light reads its medium shadow map; an unselected local contributes zero, never unshadowed light.

  • volumetric_resolve_rt reprojects only the local-light estimate, checks density agreement within 10%, clips history to six compatible axial neighbors and blends 87.5% history. Current medium response is applied afterward. Cold or invalid frames, and froxels near moving casters, use four local samples at low quality and eight at medium/high. Lighting alpha 1 marks the fresh estimate, so the resolve stores it unblended. Recovery does not scale visibility rays with the scene's light count. Eight-sample recovery refills four selection slots midway through the same light loop; settled and low-tier frames retain four-slot storage. This is a biased reconstruction of an unbiased sampled estimator, with noise and possible edge lag; it is not pixel-identical to tracing every light each frame.

  • Receipts belong to a surface and publish only after the producing pass finishes. The sun bits and the local estimate keep separate validity. Gaps, cuts, incompatible grids/ranges and scene swaps reject both. With unchanged grid, near plane and depth distribution, a fog-distance step of at most one linear depth slice retains history. Reprojection uses the previous depth range; samples outside its coverage reject history, and displaced sun samples validate. While distance changes, sun footprints have at most a four-frame refresh deadline to limit accumulated reprojection error. Larger distance jumps reject both histories. A discontinuous sun change or a replaced sun texture rejects only the sun bits; a changed local-light set or phase, or a replaced lighting texture, only the local estimate. Aborted frames and surface/world resets clear receipts.

  • Frames with at most four shadowed local lights skip local reconstruction and its full-size textures. The sun ring costs six bytes per froxel across three frames in flight (~24 MiB at 240×136×128). Crowded lighting additionally uses three RGBA16F histories plus transient base, sample and response volumes (~175 MiB at that grid). All rings are per surface; raster/hybrid allocate none.

The map-backed wrapper stays independent: replacing its atlas with virtual shadow maps does not change RT ray queries, history formats, or reconstruction. The cloud layer's own shadow map remains separate from geometry and medium shadows.

Dual-lobe phase + multiple scattering

Three folded per-material scalars (Volume Output pins 7–9, mirrored as fog_volume actor overrides) turn the single-lobe HG into the standard film-smoke response:

  • Second Lobe (phase_second, default −0.35) + Lobe Blend (phase_blend, default 0): mix(HG(g), HG(g_second), blend) per light. One lobe forces a choice between the bright forward silver lining and the soft glow when the light is behind the camera; real dense media show both. Blend 0 is bit-identical to a single lobe.
  • Multi Scatter (multi_scatter, default 0): Hillaire's octave approximation in the inject light loop. Octave k sees the medium thinner (self-shadow transmittance T^0.5, T^0.25) and more isotropic (g ×0.6 per octave), weighted by ms^k and normalized by the octave energy sum — light blooms a couple of metres into dense smoke instead of the medium going black in its own shadow, and the lit side keeps its authored intensity. Only the medium transmittance is raised to fractional powers; the geometric visibility term stays linear (a wall blocks every scattering order alike), and surface fog shadows stay pure Beer-Lambert.

Overlapping volumes blend all three density-weighted, like anisotropy; global height fog contributes zeros so a bounded medium's lobes fade out at its own boundary. The defaults preserve the single-lobe response. Cook materials with the matching Shinra version; the current binary material format is documented in Asset formats.

The froxel grid is camera-fitted, and two things about that are load-bearing.

The range must enclose the subject from where the camera actually is. Slices span near→far only; anything past the far plane is simply not sampled. A far plane derived from a subject's own size is wrong whenever the camera sits several radii back — the medium ends up entirely behind the grid, and every gate still reports success (media=1, correct grid, no fog). Derive it from camera distance + content radius.

The ray reconstruction must include the frustum centre offset. proj[2].xy carries an authored off-centre frustum (a preview pane centres its subject inside a larger surface) plus the frame's TAA jitter. hikari_froxel_world_pos inverts ndc = A·v/view_z − C, so it needs + C; without it every sample is displaced proportionally to depth and the medium renders shifted and sheared off its own geometry. The jitter half must be subtracted back out — letting it through would shift the whole field every frame, which is the temporal instability this design exists to avoid. A symmetric frustum nets exactly zero.

Depth-column skip is on when a real Hi-Z exists: inject reads a conservative pyramid-max (media.z = mip+1) plus 0.15 m slack. Sky columns stay alive. This is not a hard per-column terminator.

Integrated volumes are FIF-persistent (one pair per ring slot), not graph transients. Async inject/integrate can otherwise overwrite the texture the previous frame's composite is still sampling — the other way a whole volume trails the camera, on every scene. The pre-integration scatter volumes (phased in-scatter + extinction) are ordinary graph transients: written and consumed inside one frame.

A 3D blur before integrate smears shafts. Hard per-column depth skip prints camera-relative bands on mixed sky/geometry tiles.

Volume-local material cache

Each admitted volume owns a 32³ local-space brick with density and albedo (RGBA16F: RGB albedo, A extinction). Six independently evaluated levels, 32³ through 1³, reuse the material interpreter's noise band-limiting; queries interpolate between levels according to their local-space sample footprint. Footprints larger than the whole volume evaluate directly. Shape feathering and the runtime density envelope are applied at the receiver, not baked.

Spatial-emission materials additionally own an RGB emission brick in the same atlas, with the same six filtered levels. Constant emission stays in the volume record and allocates no extra brick; an actor emission override also removes the spatial-emission brick. Shadow queries do not sample emission. Material bricks precede emission bricks; phase.w numerically packs their one-based indices into the low and next byte, with zero meaning absent. Reassigning an emission index invalidates the material's bake receipt as well.

The tiled atlas uses 512 × (96 × capacity) texels; capacity rounds the required brick count up to a power of two, at most 128. Each brick uses 384 KiB per frame-in-flight copy: up to 72 MiB per surface for 64 ordinary volumes, or 144 MiB if all 64 also have spatial emission. Padding and unassigned slots are not sampled. Both bricks are written in one material evaluation/dispatch. Surface scope and frame-in-flight ownership prevent async writes from racing previous frames' lighting. All three persistent keys are declared every frame; a successful producer commits fingerprints only after final barriers. Frame abort, surface teardown/resize, and texture replacement invalidate reuse. Destroying the pooled targets also clears every surface's brick fingerprints and RT lighting receipts, including viewport-inset, reconstruction-extent and display-format changes. Native addresses can be recycled, so pointer equality alone cannot validate a texture after pool destruction. Noise advection remains continuous; newly allocated bricks rebuild once per frame-in-flight slot.

Static bricks rebuild only when material values, overrides or bytecode change (or a slot is reassigned/reallocated). Placement, scale, camera movement, shape feather, lighting and runtime density envelopes do not invalidate the material field. Publication assigns slots in stable actor order before BVH sorting. Changing the selected volume set can still reassign slots.

At most eight time-driven volumes are cache-admitted per surface frame. Those beyond the budget run the interpreter at the current time instead of using stale animation. This bounds animated cache construction, not total shader cost. The first admitted animated actors have stable priority; the budget is engine policy in volumetric_cache.zig, not an authoring setting. Newly admitted static volumes can require an initial full bake in each ring slot. Animated bricks reuse a slot when the supplied shader time is unchanged.

Fog volumes shadow surfaces

A fog volume darkens everything behind it — geometry, transparent surfaces, particles and the medium itself — through one structure: medium shadow maps (effects/medium_shadow.zig, medium_shadow.akari). Every receiver reads the same light-space optical depth with one filtered fetch, so fog shadows cost the same per pixel whether a scene has one fog-casting light or twenty, and no receiver marches the media.

Admission. Each frame the render thread admits fog-casting lights into map slots: a light qualifies when it has cast_volumetric_shadow on, emits, and (for a local light) its range touches a bounded leaf. Directional lights rank first; local lights by luminous power over squared camera distance, so the lights the viewer sees through fog keep their maps when a scene has more casters than the tier's capacity. An unadmitted light reads as unattenuated by fog volumes (it still has its geometry shadow). medium_light_slots maps every logical light index to slot + 1, or zero.

Layout. One R16F 3D atlas per surface frame; each slot is (N+2)² texels (an interior N² plus a one-texel border) by S slices, stacked along Z. A slot stores cumulative optical depth τ along the light's own rays:

  • Point, spot and area lights use an octahedral direction map around the light. Slice k holds τ from the light out to distance (k+1)/S·R, R the light range; below slice 0 it is linear from zero at the light.
  • Directional lights use an orthographic map over the bounded media's AABB in the light's basis, clamped to ±128 m around the camera across the light (ortho_reach). Slice k holds τ from height (k+½)/S toward the light-side face.

Build. medium_shadow_build dispatches one thread per column per slot, border included, after volumetric_density. It walks the bounded-media BVH once along the column and integrates each intersected leaf's exact interval in world metres with the tier's taps per slice segment (2/2/4), reading the volume-local material cache, so a column is many short, densely sampled segments rather than one long sparse ray. Empty space costs nothing. Missing cache entries evaluate the material directly; they are never vacuum. Overlapping leaves add optical depth. The atlas is sized for the tier's whole capacity while any light is admitted, so it never reallocates as lights come and go; with no admitted light it is a 1×1×1 placeholder no receiver reads.

Receivers. hikari_medium_shadows_at(shadows, light_index, position) turns the receiver into slot coordinates, takes one trilinear fetch and returns exp(-min(τ, 80)). Consumers: deferred and hybrid lighting, forward and forward-RT transparents, RT direct (trace and refine) and raytraced_lighting, the froxel inject (every evaluated fog caster, raster and pure RT, so height fog behind a fog volume is shadowed by it too), particle reception (per fragment and the reception lattice) and game shaders that use the shared light loop. Surface consumers receive the atlas as a bindless 3D texture index with a ready flag in HikariLightingResources; compute consumers bind it directly, and the froxel kernels read readiness from FroxelParams.frustum_right.w.

Accuracy is regression-tested in volumetric_correctness_test against a fine receiver-to-light march over dense heterogeneous slabs and a sharp feathered ellipsoid. Mean transmittance error is 0.74 % / 0.41 % / 0.07 % (point light, low/medium/high) with the worst texels, up to 10.6 % / 5.6 % / 3.1 %, on the ellipsoid's edge; directional maps are slightly better. Memory is (N+2)²·S·capacity·2 bytes: 0.3 MiB (low), 1.8 MiB (medium), 6.4 MiB (high). On an M5 Max (volumetric_shadow_bench) a medium-tier build with six overlapping volumes costs 0.08 ms for one light and 0.41 ms at full capacity; a 1080p receiver lookup costs 0.03 ms per light, against 1.37 ms for the per-pixel march it replaced.

Publication retains volumes intersecting the receiver frustum expanded by 16 m (volumetric_media.shadow_reach). It does not discard volumes past the fog-grid distance: opaque receivers can be farther away and still receive a volume shadow. The render.capacity.fog_volumes budget (default 64) still applies to this expanded set; the maps build from the same published list, so a volume farther than that outside the view neither scatters nor shadows.

Because the map term multiplies whatever visibility the shadow path already produced, it is independent of how that visibility was obtained — VSM, ray traced occlusion, or none. Fog volumes are deliberately absent from the acceleration structure: a participating medium attenuates a ray rather than occluding it, so no traversal can express it, and an RT-shadow build would otherwise show no fog shadows at all.

The local atlas reader used by the build takes two hardware-bilinear slice fetches per level, then interpolates along Z. Coordinates clamp to the current slice's texel centres before normalization, so filtering cannot leak from a neighbouring slice, level, or material/emission slot. Atlas addressing uses power-of-two masks and shifts instead of division. The GPU regressions compare the reader against an independent eight-load reference across every atlas slot and level.

Evaluating the medium away from the froxel grid is what made this possible: hikari_fog_volume_density / hikari_cached_volume_sample take an explicit sample_extent and time rather than HikariFroxelParams, so any shader can evaluate the volumes. Each consumer band-limits to its own sampling rate — the froxel to its extent, the map build to its segment stride — instead of borrowing a footprint from a sampler with different spacing.

One authored toggle governs both. Fog shadows on a light controls whether it shafts through smoke and whether smoke dims it on the floor, because those are the same physical fact. There is deliberately no per-volume opt-out yet.


Pass order

PassNotes
volumetric_densityRebuild dirty local density/albedo and optional emission bricks with filtered levels; retain unchanged bricks
medium_shadow_buildBuild the admitted lights' medium shadow maps from the bricks, then bake volumetric smoke's reception lattice from them
volumetric_injectCurrent medium and light evaluation; map-backed wrapper or dedicated pure RT wrapper
volumetric_resolve_rtPure RT with more than four shadowed local lights: reconstruct sampled lighting and apply current medium response
volumetric_integrateIntegrate current-view scattering with slab thickness measured in metres along each viewing ray
volumetric_compositescene * transmittance + inscatter, in the render domain before glass and temporal reconstruction

Density/integrate and raster/hybrid injection use the compute queue when async compute is available, else graphics. RT injection and optional lighting resolve stay on graphics, ordered with the pre-graph TLAS producer; graph barriers transfer its scatter outputs to integration. Assembly places these passes after G-buffer / optional depth pyramid (and shadow depth for raster/hybrid) and before AO, deferred lighting, and reflections. Pure RT never builds a geometry atlas for fog. Composite stays on graphics and writes fog opacity for the temporal resolve's motion reactivity.

Slice coordinates use view-space Z; optical depth uses physical ray distance. Each column multiplies its Z thickness by the length of the unnormalized view-depth ray, including authored off-centre projection. Without that factor, wide-angle image edges under-attenuate the scene.

Forward transparent samples the integrated volume at its own depth when volumetrics own the medium. Analytic height fog no-ops in that case.


Zero-cost-off

  1. Feature off → no compute/composite PSOs, no volume allocate, no passes in schedule, and no media publish or upload: publishFogVolumes returns before culling anything and prepareFrame skips both media buffer commits, so an authored volume costs one branch per frame. It also warns once, and the editor paints its gizmo red — a volume that can never render is otherwise indistinguishable from one that simply looks subtle.
  2. Feature on, resolved global density 0, and no visible fog volumes → same for that frame.
  3. Feature on with no bounded media at all → the froxel kernel takes a node_count == 0 early-out and returns the global fog directly, so a scene that uses only height fog pays nothing for the media path beyond one uniform branch.
  4. Any pipeline missing → whole stack drops (never partial composite on empty volume).
  5. Per-light intensity 0 → light skipped in inject loop.

Accepted residual when off: shaders still cook into the bundle (~76 KB), like SSR/AO/RT.


Performance notes

  • No hard per-column depth termination. That optimization is discontinuous for participating media and produced camera-relative bands around narrow geometry gaps. Cost instead scales through XY tile size, slice count, and the 1080p cap.
  • Fog-volume shadows are built once per light in light space and fetched once per receiver (see Fog volumes shadow surfaces). Receiver cost no longer scales with the number of fog casters times media overlap; the build costs columns × slices × leaves crossed, independent of screen resolution.
  • Zero-extinction early-out; per-light radius/attenuation/intensity reject.
  • Bounded media use a shadow-reach-expanded CPU frustum, keep volumes marked Important (counted against the budget, never dropped) and then the nearest render.capacity.fog_volumes (default 64, 1–1024) candidate leaves, and traverse a balanced stackless sphere BVH in the froxel shader. Shape/noise/program work therefore runs only after broad-phase hits. Static components cache their inverse matrix; buffers stream only changed rows. Bounds enclose transformed box corners, including parent-induced shear.
  • Material bricks follow render.capacity.media_bricks (default 64, 8–512; plus as many emission bricks, 384 KiB each per frame in flight); fog past them evaluates its material directly per froxel, and particle density producers take bricks first. The atlas is a grid 16 bricks wide sized by the slots in use (volumetric_cache.atlasExtent); phase.w packs material and emission slot+1 in 12 bits each.
  • Lit smoke self-shadows through the medium shadow maps for every evaluated fog caster. Geometry visibility uses RT in pure RT mode and the cached atlas in raster/hybrid; the two terms multiply. RT local-importance ranking starts only above four candidates; below that bound every eligible local light follows the exact path, except a caster whose importance is under 1 % of the froxel's shadowed-local total (HIKARI_FROXEL_IMPORTANCE_FLOOR, the fog counterpart of the surface walk's shadow importance floor): it is skipped, since its ray would cost what the dominant light's ray costs to resolve under a hundredth of the in-scatter. The ranking pass that supplies the total runs wherever two or more sources compete.
  • Froxel ranking, selection and shading walk an ordered fog subset produced by light binning. It excludes only bounded lights with nonpositive light intensity or nonpositive volumetric scattering. Strictly positive scattering remains, including values below the final shading threshold, so ranking and CDF sums retain their original contributors and order. The original surface candidate count still gates the four-light RT selection. All-active clusters reuse the surface run; no contributing light, sample or shadow quality is removed. See Light clustering.
  • The map build accumulates bounded extinction only. It shares the per-volume density with the full sample but skips global fog, albedo/phase weighting and the normalising divides, none of which it reads.
  • Bounded media sample their secondary noise in local space, decorrelated by a per-record seed; a material that wants motion says so with Volume Time. (Pushing the global fog's advected world position through worldToLocal made small volumes slide faster than large ones, forever.)
  • Constant volume materials carry no program and take the direct record path. Spatial extinction graphs use at most 24 fixed-width instructions, with no shader permutation or runtime allocation.
  • Geometry shadow backend follows shadow mode. Raster/hybrid sample the cached surface atlas with four diagonal hardware-PCF taps and no surface receiver bias. Pure RT dispatches volumetric_inject_rt, with no atlas binding or graph read. Both wrappers call the same scattering implementation in volumetric_inject_common.
  • RT sampling: the cache and bounded estimator above use the current TLAS, opaque fast traversal and the same alpha-cut material acceptance as surface RT shadows. Directional reach is independent of VSM clipmap coverage. Local rays start at the medium sample with zero surface offset and exact light endpoints. Either geometry-shadow or fog-shadow opt-out skips geometry queries.
  • Geometry visibility is mandatory for every evaluated shadowed contribution; a medium shadow map never stands in for wall occlusion. Sampling fewer contributions changes estimator variance, not whether selected lights trace geometry.
  • Fog shadow samples have no surface self-intersection: do not apply surface normal offsets or receiver depth bias. Those offsets can cross thin casters.
  • Composite soft upsample: deterministic 3×3 depth-aware froxel taps whose spatial width is earned by depth disagreement: a depth-flat window resolves to the single trilinear centre tap and the kernel widens only where the depths it reconciles diverge. Depth → slice sampling is half-texel corrected (texel k stores the integral to boundary k+1), so surfaces do not average in half a slice of fog from behind themselves. Every neighbour's integration depth is additionally capped at the current receiver depth: a soft depth weight is not an occlusion bound. Composite adds no independent jitter and runs before temporal reconstruction.
  • Retina cap: quality tier keeps tile density through 1080p, then aspect-preserving ceiling.
  • Scatter volumes packed as r11g11b10 RGB (phased in-scatter) + r16 scalar (extinction), both graph transients written by inject and marched by integrate the same frame.
  • Spatial density noise: world-space trilinear value noise (1–2 octaves by quality); zone wind/strength/scale/contrast. Wind is integrated into a persistent world-space offset, so blending wind between visual zones cannot reinterpret the whole scene age and jump the density field.
  • Deterministic slab-centre sampling; local-light attenuation floored by froxel size so the centre sample cannot sit in a lamp core.

Cross-platform

Metal and D3D12 share the same graph passes, froxel formats (r11g11b10 + r16), and Akari packages. Volume shadows and composite upsample are identical on both backends.

Authoring bounded smoke

Use a first-class fog_volume component. The actor transform defines its box or sphere; non-uniform scale is supported. A volume material is a specific material domain, not a flag on a surface material:

In the editor, choose Create → New Volume Material. It opens in the same graph editor as a surface material, with a Volume Output contract and a domain-filtered node palette. Its live froxel preview can switch between Sphere and Box and includes a shadow blocker so shafts remain visible while authoring. Dragging that asset into the viewport creates a spherical Fog Volume actor; alternatively add a Fog Volume component and use its domain-filtered material picker. Shape, edge fade, and per-actor overrides stay on the component. Undo/redo, debounced cooking, and live reload use the shared material workflow.

Density, Albedo and Emission may be spatial expressions; Anisotropy and the secondary noise controls stay folded constants. The light loop reads the resulting medium properties without knowing which material produced them. All three chains share one 24-instruction budget and one register file, so a shared noise expression is evaluated only once.

Connect a colour expression to the existing Emission input on Volume Output (for example, noise → Scale Color, modulated by Volume Time). It is an HDR emission coefficient, clamped to 0–32 per channel rather than 0–1. As with constant emission, the local source is emission × density; runtime density envelopes and shape fading therefore also fade the glow. It contributes to the visible fog radiance, not to surface lighting or GI. Actor emission overrides replace the graph result. Unadmitted animated volumes evaluate their graph at the current time instead of displaying stale cached glow.

Volume-material Color nodes expose linear HDR RGB entry up to 32 in the inspector's existing picker (Albedo still clamps to 0–1). For separate tint and intensity, connect Color and Float nodes to Scale Color, then its result to Emission; noise or Volume Time can drive Scale instead. Selecting Volume Output or Scale Color shows authoring guidance. These graphs use the same live preview, save/reload and undo path as density and albedo; no file editing or diagnostic toggle is required.

Bytecode ends with optional emission, optional albedo, then mandatory extinction outputs. Output instructions do not produce readable registers.

The spatial inputs are Volume Position (local [−0.5, 0.5]³, a colour so Split Color can take out an axis), Volume Time, and five noise fields:

NodeFieldReads as
Volume Noisesingle-octave value noisesoft, even haze
Volume FBM1–4 octaves, lacunarity 2 / gain 0.5billowing smoke
Volume Ridgedthe same octaves folded through 1 − |2n − 1|wisps, steam tendrils, creases
Volume CellularWorley F1, invertedclumping — dust and ash, which value noise never does
Volume BillowPerlin-Worley: per-octave saturate((value + cellular − 1)·2)cauliflower-lobed smoke — the cloud-renderer basis; FBM's blobs carved by cell walls. Damped octaves relax to the kernel's own mean (7/24), like ridged's 1/3

Octaves is an ordinary input, not a node setting, so it can be driven like any other value; it clamps to 4 because the froxel pays for every octave at every froxel and the medium is integrated along the ray anyway. All four normalise to [0, 1] whatever the octave count, so changing octaves does not change what Density means.

Shinra validates the graph and emits the same compact program the live preview runs. Noise is band-limited to the froxel grid. A noise feature smaller than about two froxels cannot be represented and would crawl as the camera-fitted grid slides through the world. Nothing in the authored graph knows how big a froxel is, so the kernel refuses detail it cannot carry: every noise op measures its own footprint (the larger of the column's XY extent and the slab's ray length, converted through the largest inverse-transform row length into local noise cells) and fades octaves out across one octave as they approach the limit. A fully damped octave relaxes to the field's mean (0.5 for value noise and cellular, 1/3 for ridged), so distance changes detail without changing density. The shape edge obeys the same rule: the authored edge fade is floored at one froxel extent. Authoring finer noise than the grid can resolve costs nothing and shows nothing.

Noise Scale is in cells across the volume, not metres. Volume Position spans [−0.5, 0.5] whatever the actor's world size and the noise lattice cell is one unit, so the graph Scale input and the folded material noise_scale both multiply local position. A Scale below about 2 puts the entire volume inside one cell; four to eight is the usable range, and rescaling an actor does not change the number of billows. Global height fog keeps the world-space convention: fog_noise_scale there is metres per cell.

json
{
  "kind": "com.hikari.material",
  "version": 1,
  "id": "smoke",
  "material": {
    "name": "Smoke",
    "domain": "volume",
    "volume": {
      "extinction": 2.25,
      "albedo": [0.70, 0.72, 0.75],
      "anisotropy": 0.4,
      "noise_strength": 0.8,
      "noise_scale": 2.0,
      "noise_contrast": 1.5
    }
  }
}

Assign it on an actor whose archetype includes ComponentFogVolume — the engine's fog_volume core placeable, or a game archetype that lists it. empty is not one of them, so an empty actor carrying a fog_volume block gets no component and renders nothing:

json
{
  "components": {
    "fog_volume": {
      "material": "asset://game/materials/smoke",
      "shape": "sphere",
      "edge_fade": 0.1,
      "is_static": true
    }
  }
}

Scene fields are optional overrides of those cooked defaults. emission is also supported. Disabling features.volumetric_fog removes the shared froxel pipeline, so both global fog and bounded smoke disappear by design.

Overriding is a value, not a latch. A medium property is "overridden" while it differs from the cooked material — nothing records that a slider was once touched, and the scene document stores only the properties that actually differ. In the inspector every medium row displays the volume material's cooked value until you edit it, and the row's own reset puts it back — the same contract the render component's material params already use. Typing the material's number back has the same effect. This matters most for Density, which also decides whether the material's extinction program runs: an override means the actor wants one constant density, so the program is skipped. Latching on first edit would freeze an animated medium on a single nudge with nothing in the UI to say so.

Editing a volume material updates every actor that is still following it. An actor that overrode a property keeps its own value for that property only.

For graphs with spatial albedo/emission and constant density, preview bytecode emits the folded authored density, including constant arithmetic chains, just as Shinra does. The Volume Output pin default applies only to an unconnected input.

Inspecting the cache

The editor's Effects toolbar button cycles effects → volume diagnostics → off. There is no engine-defined keyboard binding; games may author their own input actions. The diagnostic grid replaces the tonemap without changing fog or temporal history inputs. Its compute-written strips also use per-frame-in-flight persistent storage while graphics reads them:

RowLeftCentreRight
1Mean extinction along the camera ray, mapped as 1−exp(−σ)Integrated in-scatter, mapped as L/(1+L)Transmittance: white clear, black opaque
2Local density at z=¼Local albedo at z=½Local density at z=¾
3Cached count: green, with animated count cyanRebuilt count: amberNon-admitted/direct count: magenta

Rows 2–3 use 8×8 mini-tiles (one tile per cache slot, or one tile per counted volume in the count panels). Counts are not per-pixel hit rates: large-footprint direct evaluation is not included in the last panel. Purple means no active volumetric producer. Density slices exclude the runtime envelope and shape feather because those are deliberately not stored in the material field. The existing profiler's volumetric pass timings include cache production. The fog-volume budget warning still reports dropped candidates (once per budget value).

Numerical regressions run without the editor or game: see volumetric_correctness_test.swift and the invocation in tools/README.md.

Full production injection/resolve comparisons and sequence checks live in tools/metal/README.md. Helper-only timings do not establish an end-to-end scene improvement. Compare product captures with the same camera, render dimensions, build and governor policy.

PreviousVisual ZonesNext Input

Documentation follows the current engine checkout.

Snapshot 86f12d68Source docs/systems/volumetric-fog.md
On this pageFeature gatesQuality tiersPure RT sampling and reconstructionDual-lobe phase + multiple scatteringVolume-local material cacheFog volumes shadow surfacesPass orderZero-cost-offPerformance notesCross-platformAuthoring bounded smokeInspecting the cache Back to top