# Visual Zones

Spatial and global ownership of the **look stack**: exposure, bloom, fog,
tonemap, color grading, and film grain. Project `RenderLook` is the baseline;
zones blend overrides at the primary camera.

Pipeline (post look; volumetrics optional):

```text
… → volumetric composite → transparent → TAA → auto exposure → depth of field → motion blur → camera lens
  → bloom → tonemap (+ exposure + grade + grain + LUT) → UI
```

Metering reads the clean post-reconstruction image, before the lens effects:
defocus and shutter change where light lands, not how much of it there is, and
metering a blurred frame makes the meter chase its own output.

Resolved `VisualLookSnapshot` on `ConsumedFrame` is the only contract GPU post passes read (`PostLookParams` + optional LUT).

**Code:** `sdk/src/visual_zone.zig`, `graphics/effects/visual_look.zig`, scene `visual_zone` component.

---

## Concepts

| Term | Role |
|------|------|
| `VisualLook` / `VisualLookSnapshot` | Resolved per-frame look for the post chain |
| `VisualLookSettings` | Partial or full knobs a zone contributes |
| `VisualZone` | Shape + priority + blend distance + settings |
| Sample point | Image-forming editor camera in Edit; **game camera** in Play (unpossess does not retarget the live look) |

Spatial zones honor `is_static` (bake origin) vs dynamic resample each publish. Post look uses `fullscreen_texture_table` (4 SRVs: HDR, LUT-or-bloom, `post.bloom_add`, `post.exposure` + `PostLookParams`). The lens post packages (exposure, defocus, shutter) use `fullscreen_effect` — see [Rendering](/docs/systems/rendering.md#camera-post).

---

## Shape

```zig
pub const VisualZoneShape = union(enum) {
    /// Always active. No spatial weight. Scene / global look.
    unbounded,
    /// Axis-aligned box; half-extents in world units around the actor origin.
    box: struct { half_extents: @Vector(3, f32) },
    /// Sphere influence around the actor origin.
    sphere: struct { radius: f32 },
};
```

Weight for spatial shapes:

1. Inside the solid shape → weight `1`
2. Outside, within `blend_distance` → smooth falloff to `0` (smoothstep)
3. Beyond blend → weight `0` (ignored)

`unbounded` weight is always `1`. Boxes are axis-aligned in world space.

---

## Settings

Optional fields mean “do not contribute this channel.” Concrete values override lower-priority stack when the zone has weight.

```zig
pub const BloomSettings = struct {
    intensity: f32 = 0.0,
    threshold: f32 = 1.0, // HDR luminance threshold, compared *after* exposure
};

/// Eye adaptation. Limits are EV100 (`L = 2^(EV-3)`); ignored unless
/// `RenderFeatures.auto_exposure` is on. Lives in `render_config` because the
/// project baseline (`RenderLook.auto_exposure`) is what zones blend onto.
pub const AutoExposureSettings = struct {
    min_ev: f32 = -4.0,       // darkest scene the meter will still lift
    max_ev: f32 = 14.0,       // brightest scene it will still pull down
    compensation: f32 = 0.0,  // artistic bias, in stops
    key: f32 = 0.18,          // middle-grey target
    speed_up: f32 = 3.0,      // stops/second when the scene gets brighter
    speed_down: f32 = 1.0,    // eyes open slower than they close
};

pub const FogNoiseSettings = struct {
    strength: f32 = 0.0, // 0 = homogeneous; shader fast path
    scale: f32 = 8.0,    // world metres, low-frequency size
    contrast: f32 = 1.0,
    wind: [3]f32 = .{ 0.15, 0.0, 0.05 }, // m/s advection
};

pub const FogSettings = struct {
    density: f32 = 0.0, // 0 = off
    height: f32 = 0.0,
    height_falloff: f32 = 0.35, // 1/m; higher hugs the base plane
    color: [3]f32 = .{ 0.55, 0.6, 0.7 }, // analytic fallback tint
    // Volumetric-only (ignored when features.volumetric_fog == off):
    scattering_albedo: [3]f32 = .{ 1, 1, 1 },
    anisotropy: f32 = 0.6,           // HG; (-1, 1)
    volumetric_distance: f32 = 64.0, // froxel far, metres
    noise: FogNoiseSettings = .{},
};

pub const VisualLookSettings = struct {
    exposure: ?f32 = null, // manual linear multiplier; 1.0 = unchanged
    auto_exposure: ?AutoExposureSettings = null,
    auto_exposure_stated: AutoExposureStated = allStated(AutoExposureStated),
    bloom: ?BloomSettings = null,
    bloom_stated: BloomStated = allStated(BloomStated),
    grading: ?ColorGradingSettings = null,
    grading_stated: ColorGradingStated = allStated(ColorGradingStated),
    film_grain: ?FilmGrainSettings = null,
    film_grain_stated: FilmGrainStated = allStated(FilmGrainStated),
    fog: ?FogSettings = null,
    fog_stated: FogStated = allStated(FogStated),
    /// Extension-free AssetRef stem to a cooked strip LUT texture.
    color_grading: ?AssetRef = null,
    tonemap: ?TonemapMode = null,
};
```

`ColorGradingSettings` covers strength, temperature/tint, color filter, hue,
saturation, log contrast and pivot, lift/gamma/gain, and independently tinted
shadow/midtone/highlight ranges. It runs scene-referred before the tone curve,
so HDR highlight structure is preserved. The optional strip LUT remains a
display-referred finishing transform after the curve.

`FilmGrainSettings` supplies intensity, luminance response, grain-cluster size,
and monochrome-to-color amount. A texture-free integer hash generates a new,
triangular-distributed structure per rendered frame; a second sheared lattice
clusters the fine grain without magnified square texels. A fixed shadow toe
preserves the black floor, while response suppresses noise progressively toward
display white. Color variation uses zero-luminance opponent axes. Grain remains
fused into tonemap/composite rather than adding a fullscreen pass or texture
fetch.

### Which fields a zone actually authored

`?FogSettings` says *whether* a zone contributes fog, not *which fields* the author set, and the settings struct has no room for "unset" (`density` is an `f32`). A producer that fills the gaps would hand over values nobody authored, and a whole-struct lerp would blend them over a project baseline the author never meant to touch.

The `*_stated` masks carry that intent. Each names one field of its channel (nested for `FogSettings.noise`); `lerpStated` blends only the fields the mask marks and leaves the rest at whatever the baseline or a lower-priority zone already resolved to. Inheritance resolves at blend time, not in the producer, because a lower zone may already have moved the value. Every mask defaults to all fields stated, so a caller building a whole channel keeps replace semantics; only a gap-filling producer narrows it (`VisualZoneComponent.toZone` derives the mask from which of its `?f32` fields are non-null). `lerpStated` is derived from the settings type's layout, so a new field blends automatically and a field the mask does not name fails to compile; `schema_lock` locks counts and names in both directions.

Resolved frame look:

```zig
pub const VisualLook = struct {
    exposure: f32 = 1.0,
    auto_exposure: AutoExposureSettings = .{},
    bloom: BloomSettings = .{},
    grading: ColorGradingSettings = .{},
    film_grain: FilmGrainSettings = .{},
    fog: FogSettings = .{},
    color_grading: AssetRef = .empty, // empty = skip grade
    tonemap: TonemapMode = .none,     // project RenderLook wins unless tonemap_override
    tonemap_override: bool = false,
};
```

---

## Zone value

```zig
pub const VisualZone = struct {
    shape: VisualZoneShape = .unbounded,
    priority: i16 = 0, // higher wins when weights compete
    blend_distance: f32 = 0.0, // soft shell; ignored for unbounded
    enabled: bool = true,
    settings: VisualLookSettings = .{},
};
```

### Examples

```zig
const interior = VisualZone{
    .shape = .{ .box = .{ .half_extents = .{ 6, 3, 8 } } },
    .priority = 10,
    .blend_distance = 4.0,
    .settings = .{
        .exposure = 1.2,
        .bloom = .{ .intensity = 0.6 },
        .color_grading = AssetRef.must(.texture, "asset://./looks/interior"),
    },
};

const world_look = VisualZone{
    .shape = .unbounded,
    .priority = 0,
    .settings = .{
        .exposure = 1.0,
        .bloom = .{ .intensity = 0.35, .threshold = 1.0 },
    },
};
```

---

## Blend rules

1. Start from project defaults (`RenderPipelineConfig.look` → baseline `VisualLook`).
2. Collect enabled zones with weight > 0 at the sample point.
3. Sort by `priority` ascending; for each channel a zone sets, lerp `current → zone_value` by weight (higher priority applied later) — **field by field**, skipping the ones the zone's `*_stated` mask says it never authored.
4. Tone curves crossfade continuously at zone edges. The selected curve remains
   the compile-time primary PSO; the secondary curve and blend reuse two words
   in `PostLookParams`, so settled frames pay no second curve evaluation.
5. LUT ids cannot be interpolated and the fullscreen layout deliberately keeps
   one LUT binding. A LUT change therefore fades the old LUT to identity,
   switches at zero strength, then fades the new LUT in. The handoff is
   continuous without another texture fetch or root-table slot.
6. Cap active spatial zones considered per frame (`max_zones_considered` = 8).

Priority stack with soft edges — not a full weighted average of every overlapping volume.

---

## Ownership and SDK

| Layer | Role |
|-------|------|
| Engine builtin archetype `visual_zone` | Host-registered placeable (`scene/builtin_entities/`); games must not redefine |
| Scene actor + `visual_zone` component | Authored fields on that actor; transform = origin |
| `SpawnVisualZone` / `setVisualZone` / `visualZoneState` | HostApi surface |
| Publish → render thread | Resolved `VisualLookSnapshot` on the frame |
| AssetStore | LUT texture retain/release |

Prefer **one component type** for unbounded and spatial zones so editor/inspector stay unified.

The public game SDK exposes the grouped update/readback types directly:

```zig
try hi.render().setVisualZone(zone, .{
    .grading = hi.ColorGradingSettings{
        .temperature = 0.15,
        .contrast = 1.1,
        .saturation = 0.9,
    },
    .film_grain = hi.FilmGrainSettings{
        .intensity = 0.2,
        .response = 0.85,
        .size = 1.25,
    },
});
const current = hi.render().visualZoneState(zone);
```

`clear_grading` and `clear_film_grain` restore inheritance. Spawn-time
`SpawnVisualZone` mirrors every individual `grade_*` and `film_grain_*` field,
so a game can author only the fields it wants the zone to override.

**Kawa:** `VisualZone.state(actor)` / `VisualZone.update(actor, {…})`. Spawn via `World.spawn({ components = { visual_zone = {…} } })`. Field names match Zig (`snake_case`; `clear_exposure` / `clear_bloom` / `clear_grading` / `clear_film_grain` / `clear_fog` / `clear_tonemap`).

---

## Graph / GPU

| Piece | Notes |
|-------|--------|
| Bloom | Firefly-resistant half-res bright extract → dual-filter pyramid (3/4/5 levels by `quality.bloom`; `off` disables bloom whatever a zone authors) → tonemap/composite |
| Exposure | Multiply HDR in tonemap / composite via `PostLookParams.exposure` (manual) times `post.exposure`, the 1x1 adapted value (metered) |
| Auto exposure | Three fullscreen draws post-reconstruction: full -> 64x64 -> 8x8 -> 1x1 log-luminance reduction, then temporal adaptation into a persistent 1x1 ping-pong. Off means no chain and no texture: the tonemap samples the neutral 1x1 white default, so the multiply is a no-op |
| Fog | Zone `FogSettings` drive analytic height fog (`HikariUniforms.fogParams` / `fogColor`) and, when `features.volumetric_fog` is on, froxel albedo / anisotropy / distance / noise ([Volumetric media](/docs/systems/volumetric-fog.md)) |
| Color grade | Scene-referred controls before the tone curve; optional display-referred LUT afterward |
| Film grain | Frame-varying, luminance-responsive display grain fused into tonemap/composite |
| Shinra | LUTs are normal cooked textures (strip layout, e.g. 256×16). Soft-ref when missing. `.cube` cook not implemented |
| `RenderLook` | Project baseline; zones override the fields they set — a field a zone leaves unset keeps the baseline's value rather than the settings type's default |

Zones (or an unbounded zone) own content look — not only `SessionCore` debug setters.

---

## Editor

- Wireframe box / sphere gizmo + blend shell (axis-aligned; runtime ignores rotation)
- Inspector: shape, priority, blend distance, exposure / auto-exposure / bloom / fog / tonemap, LUT pick
- Viewport “active look” readout (resolved exposure / bloom / fog / tonemap at camera)

---

## Non-goals

- Per-object look (materials stay on materials)
- Replacing IBL
- Lens optics (defocus, shutter) — those describe one camera, not the world; see [Rendering](/docs/systems/rendering.md#camera-post)
