# Volumetric media

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](/docs/systems/visual-zones.md). Pipeline placement: [Rendering](/docs/systems/rendering.md).

---

## 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, Albedo and Emission 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 | Medium shadow map (texels² × slices) | Mapped fog casters | Taps per slice segment |
|--------|------|--------|--------------------------------------|--------------------|------------------------|
| `low` | 16px | 32 | 32² × 16 | 8 | 2 |
| `medium` | 8px | 64 | 48² × 24 | 16 | 2 |
| `high` | 8px | 128 | 64² × 32 | 24 | 4 |

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](/docs/systems/asset-formats.md).

**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

| Pass | Notes |
|------|--------|
| `volumetric_density` | Rebuild dirty local density/albedo and optional emission bricks with filtered levels; retain unchanged bricks |
| `medium_shadow_build` | Build the admitted lights' medium shadow maps from the bricks, then bake volumetric smoke's reception lattice from them |
| `volumetric_inject` | Current medium and light evaluation; map-backed wrapper or dedicated pure RT wrapper |
| `volumetric_resolve_rt` | Pure RT with more than four shadowed local lights: reconstruct sampled lighting and apply current medium response |
| `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/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](/docs/systems/volumetric-fog.md#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](/docs/systems/rendering.md#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:

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

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

| 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 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`](https://github.com/Flying-Rat/hikari/blob/develop/src/hikari/tools/metal/Harnesses/volumetric_correctness_test/main.swift)
and the invocation in [`tools/README.md`](https://github.com/Flying-Rat/hikari/blob/develop/src/hikari/tools/README.md).

Full production injection/resolve comparisons and sequence checks live in
[`tools/metal/README.md`](https://github.com/Flying-Rat/hikari/blob/develop/src/hikari/tools/metal/README.md#shadow-optimization-comparisons).
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.
