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):
… → volumetric composite → transparent → TAA → auto exposure → depth of field → motion blur → camera lens
→ bloom → tonemap (+ exposure + grade + grain + LUT) → UIMetering 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.
Shape
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:
- Inside the solid shape → weight
1 - Outside, within
blend_distance→ smooth falloff to0(smoothstep) - Beyond blend → weight
0(ignored)
unbounded weight is always 1. Boxes are axis-aligned in world space (same as probe AABBs).
Settings
Optional fields mean “do not contribute this channel.” Concrete values override lower-priority stack when the zone has weight.
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:
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
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
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("asset://./looks/interior"),
},
};
const world_look = VisualZone{
.shape = .unbounded,
.priority = 0,
.settings = .{
.exposure = 1.0,
.bloom = .{ .intensity = 0.35, .threshold = 1.0 },
},
};Blend rules
- Start from project defaults (
RenderPipelineConfig.look→ baselineVisualLook). - Collect enabled zones with weight > 0 at the sample point.
- Sort by
priorityascending; for each channel a zone sets, lerpcurrent → zone_valueby weight (higher priority applied later) — field by field, skipping the ones the zone's*_statedmask says it never authored. - 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. - 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.
- 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 retain/release (like probe cubemaps) |
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:
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 → four-level dual-filter pyramid → 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) |
| 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