# Choosing and scaling shadows

Hikari has two geometry-shadow backends: virtual shadow maps (VSM) and ray tracing (RT). **Hybrid** combines them. Choose the backend for the target hardware and lighting workload, then tune its quality independently from the shadow filter.

The VSM mode is named `raster` in `pipeline.features.shadows`; the other values are `hybrid`, `ray_traced`, and `off`. VSM does not require hardware ray tracing. Other enabled features, such as RT GI or reflections, can still require the RT scene when shadows use VSM.

## Choose a mode

| Mode | Good starting point for | Strengths | Costs and limitations |
|---|---|---|---|
| **VSM** | Small or stylized games; games targeting GPUs without RT; interiors with many mostly stationary lamps | Reuses unchanged shadow depth; scales storage, receiver detail and filtering separately; one map backend serves surfaces and fog | Demand and residency have a baseline cost. Moving lights invalidate cached depth. Filtering and transparent overdraw can remain expensive even with a warm cache. Finite resolution requires bias and can lose fine detail. |
| **Hybrid** | Outdoor games that prioritize directional shadow detail while using VSM for local lights | Directional surface visibility uses geometry rays; local-light shadows reuse maps; both sides have independent controls | Runs both backends. Fog, transparent receivers and readiness fallback still need VSM, including directional coverage. Directional softness relies on final temporal reconstruction. |
| **RT** | Games targeting RT-capable hardware, especially with few important lights, moving lighting, or fine shadow-casting geometry | Geometry visibility avoids shadow-map resolution and bias limits; pure RT builds no geometry VSM pages | Acceleration-structure work, ray traversal and reconstruction cost depend on geometry, alpha testing, light overlap and motion. Finite sampling can show noise or history artifacts. |

Use this decision sequence:

1. **Must the game support hardware without usable RT?** Provide a VSM configuration and validate it on the minimum supported GPU.
2. **Is the main requirement sharper directional surface shadows, with many local lights?** Compare Hybrid with VSM. Include fog and transparency in that comparison: Hybrid still pays for their maps.
3. **Do moving lights repeatedly invalidate the maps, or is fine geometry poorly represented at the available map budget?** Compare full RT on the target GPU. A small scene can favor RT when a moving sun forces repeated VSM rasterization.
4. **Does VSM already meet the image and frame-time targets?** Keep it. Switching modes is not inherently an upgrade, and Hybrid is not guaranteed to fall between VSM and RT in cost.

These are starting points, not measured performance rankings. Compare the same camera, lights, output resolution and other rendering features, including motion and cold history as well as a stationary warm frame.

## How VSM works

A virtual shadow map describes depth as seen from a light. The virtual address space provides fine resolution without allocating a complete high-resolution texture for every light. A shared physical depth atlas stores only resident **pages**, each 128×128 texels. Punctual views have a 16,384×16,384 virtual address space with eight mip levels. Spots use one view; point lights use six faces. Directional lights use nested world-aligned **clipmaps**, with finer coverage near the camera and coarser coverage farther away.

The producer runs after final opaque depth:

1. **Request coverage.** Opaque surfaces request pages from their projected pixel footprints and contributing lights. Fog requests coverage at froxel sample positions. Visible transparent meshes replay a demand-only pass so their resolution does not depend on opaque surfaces behind them. The selected filter expands demand to cover its sampling footprint.
2. **Protect and allocate.** Requests also cover coarser ancestors. Every active view also requests *forward coverage* — a coarse window of its whole face — at the frame's coverage floor: the tier's `coarse_mip`, coarsened until all views' coverage fits half the physical pool (`prepare.coverageFloor`), down to one root page per view. Residency protects the current demand before reclaiming unrequested pages and allocates coarse coverage before finer detail; when nothing unrequested is left, a coverage allocation may take a bounded light's *fine* page (finest mip first), whose receivers fall back to a resident ancestor for that frame. Coarse coverage for every light therefore always comes before fine detail for any, and a light switched on into a full pool never reads as lit through page-shaped holes.
3. **Update dirty pages.** Cached, unchanged depth is reused. New or invalidated pages clear and rasterize their relevant casters in bounded batches. Culling removes work outside each page while retaining complete caster coverage.
4. **Publish and sample.** A page becomes valid only after its depth work is encoded. Readers translate virtual coordinates to physical pages; a missing fine page falls back to a resident ancestor. Page boundaries resolve virtual neighbors explicitly so a filter cannot read another light's depth.

Directional scrolling retains overlapping world pages. Equivalent pages across clipmap levels share depth storage. A changed projection invalidates its cached depth, so moving suns use the stable-projection policy below. Cache reuse saves rasterization, but demand, residency and lighting still have work each frame.

Transparent receiver demand means that glass and other transparent meshes can **receive** suitably resolved geometry shadows. It does not implement colored shadows **cast through** glass. Fog uses map visibility in VSM and Hybrid, and RT visibility in pure RT. Medium extinction and cloud shadows are separate from geometry-shadow storage; see [Volumetric media](/docs/systems/volumetric-fog.md).

## Moving and static suns

A stationary directional light uses one exact projection set. It needs no replacement views, no second allocation sweep and no second visibility sample. Moving-sun transitions are activated only when required; VSM and Hybrid use the same policy, while pure RT is unaffected.

For gradual time-of-day motion, Hikari retains fixed shadow snapshots spaced by **0.25°** of sun motion. It prepares the next snapshot ahead of the interval boundary using current motion, allowing time for allocation and asynchronous completion feedback. Direct lighting continues to use the authored sun direction. The renderer:

1. Keeps the current projection and cached pages valid while requesting both projection sets, including opaque, transparent and froxel receivers.
2. Allocates active coverage first, then replacement coverage within the **same physical pool**. New fine pages under refinement share a per-frame allowance of `min(physical_pages / 8, 256)`: 32/128/256/256 at Low/Medium/High/Ultra. Coarse coverage and updates to already allocated dirty pages bypass this allowance.
3. Starts the visibility fade after completed replacement coverage is reported through the existing frame feedback ring. The fade takes at most **8** advancing frames, shortening for faster motion; waiting for coverage does not consume its progress. Depth is always sampled with its own projection; matrices are never interpolated over cached depth. Receivers also check current coarse coverage, including any adjacent clipmap used for blending.
4. Displays the complete replacement result before promotion, retains its cached pages, and releases the old view slots. Fine refinement remains budgeted for a settling interval sized to the pool. A stopped sun settles to its exact authored direction and returns to one projection set.

Both snapshots share the selected pool. If current demand fills it, missing replacement roots may borrow a bounded number of inner detail pages from a transitioning sun whose valid outer ancestor remains protected. This trades some detail for transition progress under pressure; it adds no atlas capacity or fine-page quota. If safe fallback is unavailable, the old snapshot remains active while coverage catches up. Feedback introduces no extra GPU wait.

The interval and fade length are fixed engine constants (`Config.angle_deg` and `Config.transition_frames` in `src/hikari/src/graphics/shadow/vsm/sun_cache.zig`), not project settings. Large time jumps and motion too fast for a useful transition also update immediately. View-slot pressure disables optional replacement allocation rather than evicting another admitted light. Under physical-pool pressure, replacement pages can wait until promotion releases old pages; missing fine detail uses current coarse coverage.

**The tradeoff is temporal accuracy.** Held sun directions introduce shadow lag; crossfading can briefly soften or double edges. This is more noticeable with a low sun, tall blockers or a fast day cycle. The angle controls the capture interval, not a universal screen-pixel error bound. Test the actual day length and sunrise/sunset views. This policy is not pixel-identical to updating the projection every frame.

There is no second depth atlas. Transitions temporarily use additional view/table slots and share the tier's physical-page capacity; table storage retains its high-water allocation afterward. Visibility filtering can run twice during a blend. First activation can grow tables and rebuild residency. Static scenes that never enter a transition keep their original table footprint.

Moving casters still invalidate intersecting pages in both active projections. Those pages redraw their complete caster set immediately; this is spatial invalidation, not separate static/dynamic depth layers. Animated foliage and broad invalidations can therefore reduce reuse. Camera movement requests new coverage normally. The fine-page allowance is not a hard GPU-time budget: coarse coverage, resident caster updates, cold resets and time jumps can still cause expensive frames.

## Scale VSM storage and coverage

Set **Project Settings → Quality → Shadows → Shadow quality** (`quality.shadows`). It follows `quality.preset`, which defaults to **Medium**; games should explicitly select and validate their own profile. The same tier also sets shadowed punctual lights per pixel and the RT shadow trace grid (see [Configure the two sides](/docs/guides/shadows.md#configure-the-two-sides)); in pure RT no VSM is built, so the storage columns below apply to VSM, Hybrid and the raster fallback.

| Shadow quality | Depth atlas memory | Physical pages | Directional levels | Receiver mip bias | Maximum view slots |
|---|---:|---:|---:|---:|---:|
| Low | 16 MiB | 256 | 4 | +2 | 32 |
| Medium | 64 MiB | 1,024 | 6 | +1 | 128 |
| High | 144 MiB | 2,304 | 8 | 0 | 256 |
| Ultra | 256 MiB | 4,096 | 8 | 0 | 512 |

The memory column is the D32 atlas alone, not total shadow memory. Page tables, request buffers, transparent claims and caster scratch are additional. Point lights use aligned eight-slot blocks for their six views; view limits are not light counts.

Lower tiers reduce receiver detail, map capacity and bookkeeping. They preserve the authored outer directional coverage and caster geometry; fewer clipmaps remove the finest levels. High and Ultra have the same receiver bias and directional level count: Ultra principally supplies more capacity, rather than automatically making every shadow sharper.

Page tables grow with the surface's view high-water mark instead of reserving every possible view. A one-spot table is about 152 KiB, and four directional levels need about 414 KiB. Transparent claim storage ranges from 16 KiB at Low to 4 MiB at Ultra; its demand pass is absent without transparent meshes. Pool metadata, dirty lists and batch capacity follow the selected physical pool. Local-only scenes omit directional allocation phases, and lights with no relevant contribution do not reserve views.

These policies keep small scenes from paying all of Ultra's maximum bookkeeping cost, but a selected Ultra atlas still has its listed capacity. Low is not a zero-overhead path. Under pressure, ancestor fallback reduces detail and diagnostics report denied requests; the Shadows debug grid's **Virtual pages** cell turning orange (coarse) everywhere with **Free pages** at zero is the pool at its limit, and the lever is the shadow tier's pool or fewer shadow-casting local lights (every point light is six views). Dirty resident pages update in the current frame. During sun transitions, new fine detail can be deferred while current coarse coverage remains available; invalid resident depth is never deliberately retained as valid.

Changing shadow tiers rebuilds VSM residency at a frame boundary and safely retires old resources; the transition can stall. Use tier changes as quality selections, not a per-frame animation. See [VSM implementation details](/docs/systems/rendering.md#virtual-shadow-maps) for allocation and synchronization contracts.

## Choose filtering independently

Set **Project Settings → Quality → Shadow filter**. Unless set explicitly it follows the quality preset (PCF at Low, SMRT Low at Medium, SMRT High at High and Ultra). An explicit shadow tier does not choose a filter, and an explicit filter survives any shadow tier.

| Filter | How it works | Image tradeoff |
|---|---|---|
| Hard | One point depth comparison | Sharp, potentially aliased edges; no spatial softness |
| PCF | Fixed 3×3 bilinear comparison footprint | Smooths edges with a fixed kernel; no light-size contact hardening |
| SMRT Low | Up to 2 shadow-map rays × 4 steps | Light-size contact hardening with fewer samples |
| SMRT High | Up to 4 shadow-map rays × 8 steps | More samples for the same bounded penumbra model |

**Shadow-map ray tracing (SMRT)** traces short segments against stored VSM depth. It does not use hardware ray queries or a scene acceleration structure. Samples cover the light's emitter: point/spot lights use `source_radius` in metres, area lights derive a bounding radius from their dimensions, and directional lights use `source_angle`, the full angular diameter in degrees. Area lights use six centre-projected faces; their raster visibility is an approximation. See [area lighting](/docs/systems/rendering.md#area-lights) for shape and RT behavior. Shadows can stay tight near contact and soften as the blocker separates from the receiver. The walk runs only where it can resolve a penumbra of at least 0.75 atlas texels at the receiver's sampled mip (`hikari_smrt_resolvable`: emitter spread over the ray reach); a small bulb far from its shadow takes the filtered kernel instead, and the demand pass skips the soft page skirt there too, so pages are never rasterized for rays that will not walk them.

The projected ray footprint is bounded to eight texels at the requested mip. Demand includes that footprint and its guards. Traversal reuses page addresses for rays contained in one page and resolves new virtual addresses at boundaries. Far occluders retain hard visibility beyond the bounded penumbra. A single depth layer cannot represent every overlapping blocker; very wide penumbrae and layered occlusion remain approximations.

With temporal reconstruction, the emitter pattern advances through 16 phases and existing motion/disocclusion handling stabilizes the result. Without it, the pattern stays fixed in world space. History resets and rapid motion can expose the finite sample count; VSM filtering adds no dedicated full-screen shadow denoiser.

For an SMRT selection, zero-size surface emitters use PCF. Froxels use their independent four-tap PCF regardless of the surface filter. Hard and PCF omit SMRT demand expansion. Filter-only changes reset temporal reconstruction but retain the VSM cache. Pure RT ignores this filter selection and preserves it for a later mode switch.

A small stylized game can start with **shadow quality Low + Hard or PCF**, then increase the dimension responsible for visible defects: storage/detail for missing fine coverage, filtering for edge softness. This is a configuration starting point, not a guaranteed speed ranking; page boundaries and cache behavior can make PCF cost more than SMRT Low in a particular workload.

## How Hybrid and RT divide the work

| Receiver | VSM | Hybrid | Pure RT |
|---|---|---|---|
| Opaque directional lighting | VSM | RT for directionals in the first four global slots; VSM for remaining globals | RT |
| Opaque point and spot lighting | VSM | VSM | RT |
| Engine forward transparent meshes | VSM | VSM | RT |
| Froxel geometry visibility | VSM | VSM | RT |

Hybrid uses one fullscreen lighting evaluator with bounded directional rays, rather than producing and denoising a separate full RT direct-light image. Final temporal reconstruction accumulates directional softness; without a temporal consumer it uses a stable emitter-centre ray. It retains directional VSM coverage for its other readers and raster fallback, so selecting Hybrid does not eliminate moving-sun map updates. See [Hybrid implementation](/docs/systems/rendering.md#hybrid-shadows).

Full RT uses its own sparse direct-light producer, temporal reconstruction and spatial resolve. Engine forward transparency uses deterministic emitter-centre visibility because it has no independent shadow history. Froxel RT has separate visibility reuse and bounded local-light sampling; its history and memory costs are described in [RT volumetric sampling](/docs/systems/volumetric-fog.md#pure-rt-sampling-and-reconstruction). Pure RT does not request, allocate or rasterize geometry VSM pages.

The transparent routing above applies to engine forward materials. Custom raster-only shader packages do not silently enable maps in pure RT; custom packages need an RT-capable lighting path.

Hybrid falls back to VSM if its RT resources are unavailable. An RT selection with the RT master disabled resolves to raster. With the master enabled but RT resources transiently unavailable, pure RT cannot fall back to an unprepared map; shadow visibility can be unavailable for that frame. See [Rendering](/docs/systems/rendering.md) for feature-readiness rules.

## Configure the two sides

Save developer choices in **Project Settings → Quality**; they persist under `pipeline.quality` in `configs/render.json`. Mode selection lives under `pipeline.features.shadows`.

| Setting | Controls |
|---|---|
| `quality.preset` | Sets every tier row below; `directional.*` values are not tiered; see [Quality preset](/docs/guides/project-file.md#quality-preset-renderjson) |
| `quality.shadows` | The one shadow cost tier: VSM storage, view capacity and receiver density; shadowed punctual lights per pixel (1 / 2 / 4 / 8); with RT shadows, the trace grid (half resolution below Ultra, every pixel at Ultra). Not the filter or SMRT sample count. At every tier a bounded caster contributing under 1 % of a pixel's direct light is shaded unshadowed (the importance floor, [rendering](/docs/systems/rendering.md#light-clustering)); directional lights are always shadowed |
| `quality.shadow_lights` | Optional override of the tier's shadowed punctual lights per pixel |
| `quality.shadow_filter` | Surface VSM filtering; independent of storage and RT |
| `quality.contact_shadows` | Raster/hybrid screen-space contact march toward the sun: `off` / `low` / `medium` / `high` — 8/12/16 steps (1 cm each) on receivers within 8/16/22 m |
| `quality.directional.far` | Outermost VSM directional clipmap half-width. Every `directional` field is a sparse override of a fixed engine default (far 120 m, padding 4 m, caster pull-back 80 m, clipmap blend 0.1); none follows the preset or shadow tier |
| `quality.directional.rt_far` | Directional trace reach; set explicitly to tune independently (`0` derives it from map/camera range) |
| `quality.rt_budget` | Shared RT scheduling budget; not a VSM setting or a dedicated Hybrid rays-per-light control |

Hybrid respects both sets of controls. `quality.rt_hit` concerns secondary GI/reflection hit shading, not primary directional shadow precision.

Inside an ordinary gameplay callback, use the runtime render API:

```zig
hi.render().setShadowQuality(.low);
hi.render().setShadowFilter(.pcf);

// Apply mode and quality together when switching a profile.
hi.render().setGraphics(.{ .render = .{
    .features = .{ .ray_tracing = .on, .shadows = .hybrid },
    .quality = .{
        .shadows = .medium,
        .shadow_filter = .smrt_high,
        .directional = .{ .rt_far = 800 },
    },
} });

const current = hi.world().runtimeInfo().graphics.quality;
```

Runtime changes affect the session and do not save developer JSON. A game that offers player preferences owns their persistence.

## Find the actual cost

At 60 FPS the entire frame has about **16.67 ms**, not 16.67 ms for shadows alone. Compare GPU and CPU frame times instead of assuming a particular backend must be cheaper or treating fan speed as a shadow-pass measurement.

| Trace or diagnostic | What to investigate |
|---|---|
| `vsm.requests` / residency | Receiver coverage, contributing lights, page pressure, cold versus warm cache |
| `vsm.transparent_demand` | Transparent layer count and overdraw, even when page depth is cached |
| `vsm.cull`, `vsm.raster`, `vsm.tail` | Dirty-page count, moving sun/lights, caster changes, geometry cost; sum repeated batch events |
| Lighting with a warm map cache | Filter choice, overlapping shadowed lights and sampling boundaries |
| Volumetric injection / RT resolve | Froxel resolution, light overlap and medium work, separately from surface filtering |
| Shadow debug grid | Dirty/cache-hit/denied/free counters and reconstructed virtual depth; it is not a picture of every physical atlas texel |
| RT shadows debug grid | Raw rays vs. history vs. prefilter vs. resolve, the penumbra bound the filter honours, how many frames the history holds, and which pixels the refinement re-traced. Grain in the raw cell that the history cell removes is working as designed; grain the history keeps while its frame count reads settled is emitter variance the ring cannot remove; a resolve cell full of orange or cyan is refinement cost (`direct_refine_pixels`) |

Test stationary and moving cameras, moving lights, transparency and heavy fog on the minimum supported device. Measure cold residency and quality transitions separately from steady state. A cache optimization may improve one case without changing another, and an isolated kernel improvement does not establish a full-frame FPS or power saving.
