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 — evaluated deterministically, current-frame only.
Reference scenes: src/games/example/scenes/volumetric_reference.json (global fog) and
src/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
| Control | Where | Effect |
|---|---|---|
features.volumetric_fog | Project RenderFeatures | off | froxel (creation-time) |
quality.volumetric_fog | Project RenderQuality | low | medium (default) | high |
| Zone / look fog fields | VisualLookSettings.fog + volume knobs | Density, height, albedo, anisotropy, distance, noise |
| Scene component | fog_volume | Bounded box/sphere medium; active even when global fog density is zero |
| Material domain | material.domain = "volume" | Extinction, albedo, emission, anisotropy, phase-lobe/multi-scatter, and density-noise defaults |
| Volume graph | Volume Output | Density and Albedo compile to bytecode; the rest (incl. Second Lobe, Lobe Blend, Multi Scatter) folds to constants |
| Per-light | LightComponent | volumetric_scattering_intensity (default 1; 0 skips light), cast_volumetric_shadow — shown as Fog shadows (default true) |
Quality tiers
| Preset | Tile | Slices | Shadow samples per intersected volume |
|---|---|---|---|
low | 16px | 32 | 4 |
medium | 8px | 64 | 8 |
high | 8px | 128 | 16 |
The froxel stack is deterministic and current-frame only. Every froxel is
evaluated at one fixed point, every frame, and every integrand term is an area
average over the froxel: noise is band-limited to the footprint, shadow
visibility is PCF-filtered over the froxel radius, local-light attenuation is
floored by min(froxel_radius, 0.25 * light_radius) so the centre sample
cannot sit in a lamp core. One sample of a field with no sub-froxel content is the froxel average, so
there is nothing left for temporal accumulation to do.
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 shadow-atlas 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.
Temporal accumulation is rejected by design, twice. A stochastic in-slab walk converged by EMA is never bit-stable at rest; a deterministic sample blended against reprojected history is bit-stable but drags the medium behind every camera move. With a deterministic integrand, history is pure inertia. Do not re-propose either.
HG is evaluated per light with this camera in the same pass, so sun shafts
and lamp coronas track a pan/strafe by construction. Ray integrals are
current-frame (volumetric_integrate). The 3×3 composite is a spatial
upsample. The only state that survives between frames is the per-surface
noise-advection offset (wind).
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 the old 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 transmittanceT^0.5,T^0.25) and more isotropic (g ×0.6 per octave), weighted byms^kand 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 shadow-atlas term stays linear (a wall blocks every scattering order alike), and the surface fog-shadow march stays 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. Defaults are inert: v2 .shinmaterial blobs and untouched
materials render exactly as before (SMA2 v3 carries the new floats).
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.
The tiled atlas uses 512 × (96 × capacity) texels; capacity rounds the selected cached-volume count up to a power of two, at most 64 (384 KiB per slot per frame-in-flight copy, up to 72 MiB per surface at capacity 64). Padding and unassigned slots are not sampled. 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.
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 the geometry behind it as well as itself. Raster, RT, transparent surface lighting and froxel self-shadows share an interval-adaptive Beer-Lambert march through the bounded-media BVH. Each admitted leaf samples a camera-independent local material cache. Missing cache producers and unadmitted leaves evaluate the material directly; they are never treated as vacuum. Every consumer filters to its own footprint. These remain bounded spatial approximations, rather than an exact transport solution.
The ray traverses the BVH once, rejecting entire subtrees against the finite ray segment. Each surviving leaf clips that segment to its local box or sphere (an ellipsoid after scaling); its fixed tier budget is then distributed across only that intersection. Empty gaps consume no material samples, and thin volumes cannot fall between globally spaced taps. The transformed direction is not normalized: interval endpoints and optical-depth integration stay in world metres under rotation, non-uniform/negative scale, and parent shear.
Overlapping leaves add optical depth independently, so no volume is dropped when several overlap. The bound is 4/8/16 material evaluations per intersected leaf per admitted light (at most 64 leaves: 256/512/1024 evaluations). This is spatial interval adaptation, not an error-driven variable sample count or an unbounded refinement loop. Dense overlap can cost more than the old four-tap march; the improvement targets thin-volume coverage and empty-space work. Sampling remains deterministic and current-frame-only. Each leaf filters its material and shape to its own interval stride; quality changes can therefore change edge softness as well as detail.
Publication retains volumes intersecting the receiver frustum expanded by the 16 m shadow-march reach. It does not discard volumes past the fog-grid distance: opaque receivers can be farther away and still receive a volume shadow. The nearest-64 cap still applies to this expanded candidate set. Increasing shadow reach requires changing the shared CPU/shader contract, not just the march.
Because it multiplies whatever visibility the shadow path already produced, it is independent of how that visibility was obtained — raster cascades, 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 march is gated by cast_volumetric_shadow, medium-march admission, and
the bounded-media count. Raster lighting skips it after fully blocked geometry
visibility. RT normalization also needs it in the unoccluded baseline. Cache
hits avoid material interpretation; misses pay the bounded BVH/program cost.
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 shadow march to its 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
| Pass | Notes |
|---|---|
volumetric_density | Rebuild dirty local density/albedo bricks and their analytically filtered levels; retain unchanged bricks |
volumetric_inject | Scatter into froxels; cluster light loop + per-light current-view HG + soft volume shadows; deterministic slab-centre evaluation |
volumetric_integrate | Integrate current-view scattering with slab thickness measured in metres along each viewing ray |
volumetric_composite | scene * transmittance + inscatter, in the render domain before glass and temporal reconstruction |
Density/inject/integrate use the compute queue when async compute is available, else graphics. Assembly places them after shadow depth / G-buffer / optional depth pyramid and before AO, deferred lighting, and reflections. Under RT surface shadows the atlas may still run in volumetric_only mode for volume shafts. 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
- Feature
off→ no compute/composite PSOs, no volume allocate, no passes in schedule, and no media publish or upload:publishFogVolumesreturns before culling anything andprepareFrameskips 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. - Feature on, resolved global density 0, and no visible fog volumes → same for that frame.
- Feature on with no bounded media at all → the froxel kernel takes a
node_count == 0early-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. - Any pipeline missing → whole stack drops (never partial composite on empty volume).
- 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 (shipped)
- 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.
- Zero-extinction early-out; per-light radius/attenuation/intensity reject.
- Bounded media use a shadow-reach-expanded CPU frustum, keep the nearest 64 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.
- Lit smoke self-shadows with an interval-adaptive, 16 m capped Beer-Lambert march for at most two contributing lights per froxel. Geometry visibility still comes from the shared shadow atlas for every light; the two terms multiply. The two are the strongest contributors, ranked by intensity × falloff in a cheap pre-pass over the cluster's candidates, not the first two the loop reaches (order-of-arrival selection jumps between frames on a grid rebuild).
- The march 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
worldToLocalmade 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.
- Volume shadows: medium bilinear PCF (4 diagonal hardware-cmp taps), without surface receiver bias; the wider footprint prevents narrow shafts from quantizing whole froxel columns on/off. Under RT surface shadows the atlas still runs in
volumetric_onlymode — a ray per froxel per light is millions of traces; froxels do not share the surface RT visibility signal. - A
volumetric_onlyatlas is built for the froxel grid, not for the screen.AtlasModechooses which lights get pages;AtlasProfile(shadow_types.zig, sole constructorAtlasProfile.forMode) chooses page size and cascade reach. Fog tiles are sized to two texels per froxel column (volumetric.maxGridColumns, a property of the quality tier alone since the grid caps at 1920×1080), and cascades clamp tofog_volumetric_distance. The RT ray length is resolved before that clamp, so surface visibility keeps the authored range.faceSignaturealready hashestile.sizeandfar_plane, so a tier or fog-distance change re-rasterises on content. - Geometry visibility is evaluated for every contributing light with authored fog shadows and atlas pages. The cluster's two-light volumetric admission bit only limits surface-to-light medium marches; it must never bypass wall occlusion in injection. The inject pass independently keeps its two strongest medium marches. Exceeding that budget therefore does not turn an occluded lantern into an unshadowed fog light.
- 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
r11g11b10RGB (phased in-scatter) +r16scalar (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 and Albedo may be spatial expressions; Emission, Anisotropy and the secondary noise controls stay folded constants. That split is what keeps the light loop material-agnostic — it reads one extinction and one albedo per froxel and never asks which material produced them — while the per-froxel program stays strictly bounded. Both chains share one 24-instruction budget and one register file, so a noise field feeding density and colour costs one instruction, not two.
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:
| Node | Field | Reads as |
|---|---|---|
| Volume Noise | single-octave value noise | soft, even haze |
| Volume FBM | 1–4 octaves, lacunarity 2 / gain 0.5 | billowing smoke |
| Volume Ridged | the same octaves folded through 1 − |2n − 1| | wisps, steam tendrils, creases |
| Volume Cellular | Worley F1, inverted | clumping — dust and ash, which value noise never does |
| Volume Billow | Perlin-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.
{
"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:
"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 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:
| Row | Left | Centre | Right |
|---|---|---|---|
| 1 | Mean extinction along the camera ray, mapped as 1−exp(−σ) | Integrated in-scatter, mapped as L/(1+L) | Transmittance: white clear, black opaque |
| 2 | Local density at z=¼ | Local albedo at z=½ | Local density at z=¾ |
| 3 | Cached count: green, with animated count cyan | Rebuilt count: amber | Non-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 existing nearest-64 overflow warning still reports dropped candidates.
Numerical regressions run without the editor or game: see
volumetric_correctness_test.swift
and the invocation in tools/README.md.