Skip to content
hikari
RenderingEditorAIToolchainDocumentation
GitHub
hikari

© 2026 Flying Rat Studio.
All rights reserved.

Explore the engineDocumentationContributorsLicenseBack to top
Documentation / Systems
Browse docs
Overview
Tutorials16
OverviewFirst game projectFirst entityAuthored user_data and the inspectorFirst mesh and materialFirst physics body and triggerFirst character controllerFirst session servicesFirst UIFirst input actionFirst messagesPlay, Edit, and scenesFirst runtime spawnFirst motionFirst skeletal animationAssets in Play (soft refs and hot reload)Dynamic editor recompile
Guides16
OverviewDevelopment guideGameplay APIChoosing component storageBuild and packagingProject filePluginsHikari Plugin APIData-driven content, JSON, and pathsScripting with KawaShader authoringTime of dayUser interfaceUI layoutUI widgetsMigration from Unity / UnrealActor and component lifecycle
Systems28
OverviewArchitectureApplication lifecycleFrontends and driversPlatforms and supportSession services and cross-scene stateScenes and gameplayActor communication (hi.actors)Game-facing refsSave / replication wire versionCoordinate space and camera conventionsRenderingRenderer architecture mapFrame governorGPU particlesVisual ZonesVolumetric mediaInputAudioPhysicsMotion KitTemporal KitAssets and ShinraPrefabsAsset residencyAsset formats (Shinra pipeline)UI and editorEditor asset hot reloadEditor Project Selector
Language reference2
OverviewAkari language referenceKawa language reference
Engine overview
Start exploring
  • No matching sections. Try fewer words or another topic.
NavigateEnter Openesc Close
Overview
Tutorials16
OverviewFirst game projectFirst entityAuthored user_data and the inspectorFirst mesh and materialFirst physics body and triggerFirst character controllerFirst session servicesFirst UIFirst input actionFirst messagesPlay, Edit, and scenesFirst runtime spawnFirst motionFirst skeletal animationAssets in Play (soft refs and hot reload)Dynamic editor recompile
Guides16
OverviewDevelopment guideGameplay APIChoosing component storageBuild and packagingProject filePluginsHikari Plugin APIData-driven content, JSON, and pathsScripting with KawaShader authoringTime of dayUser interfaceUI layoutUI widgetsMigration from Unity / UnrealActor and component lifecycle
Systems28
OverviewArchitectureApplication lifecycleFrontends and driversPlatforms and supportSession services and cross-scene stateScenes and gameplayActor communication (hi.actors)Game-facing refsSave / replication wire versionCoordinate space and camera conventionsRenderingRenderer architecture mapFrame governorGPU particlesVisual ZonesVolumetric mediaInputAudioPhysicsMotion KitTemporal KitAssets and ShinraPrefabsAsset residencyAsset formats (Shinra pipeline)UI and editorEditor asset hot reloadEditor Project Selector
Language reference2
OverviewAkari language referenceKawa language reference
Engine overview
Systems8 min read

Visual Zones

On this page
On this pageConceptsShapeSettingsWhich fields a zone actually authoredZone valueExamplesBlend rulesOwnership and SDKGraph / GPUEditorNon-goals Back to top

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

TermRole
VisualLook / VisualLookSnapshotResolved per-frame look for the post chain
VisualLookSettingsPartial or full knobs a zone contributes
VisualZoneShape + priority + blend distance + settings
Sample pointImage-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

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 (same as probe AABBs).


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("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

LayerRole
Engine builtin archetype visual_zoneHost-registered placeable (scene/builtin_entities/); games must not redefine
Scene actor + visual_zone componentAuthored fields on that actor; transform = origin
SpawnVisualZone / setVisualZone / visualZoneStateHostApi surface
Publish → render threadResolved VisualLookSnapshot on the frame
AssetStoreLUT 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:

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

PieceNotes
BloomFirefly-resistant half-res bright extract → four-level dual-filter pyramid → tonemap/composite
ExposureMultiply HDR in tonemap / composite via PostLookParams.exposure (manual) times post.exposure, the 1x1 adapted value (metered)
Auto exposureThree 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
FogZone FogSettings drive analytic height fog (HikariUniforms.fogParams / fogColor) and, when features.volumetric_fog is on, froxel albedo / anisotropy / distance / noise (Volumetric media)
Color gradeScene-referred controls before the tone curve; optional display-referred LUT afterward
Film grainFrame-varying, luminance-responsive display grain fused into tonemap/composite
ShinraLUTs are normal cooked textures (strip layout, e.g. 256×16). Soft-ref when missing. .cube cook not implemented
RenderLookProject 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
PreviousGPU particlesNext Volumetric media

Documentation follows the current engine checkout.

Snapshot cc148c75Source docs/systems/visual-zones.md
On this pageConceptsShapeSettingsWhich fields a zone actually authoredZone valueExamplesBlend rulesOwnership and SDKGraph / GPUEditorNon-goals Back to top