Structural map (layers, RHI, graph, code layout): Renderer architecture.
World / view / clip conventions (RH Y-up −Z-forward, reverse-Z depth): Coordinate space.
Renderer model
Shared graphics/ owns policy, frame construction, and graph scheduling. Platform renderers own native resources, encoding, sync, and present. Keep platform trees thin.
macOS / Metal product floor: shaders compile as MSL metal4.0 (Akari metalc -std=metal4.0, min OS 26); packaging LSMinimumSystemVersion defaults to 26.0. Device init requires MTLGPUFamilyMetal4; submit/encoding uses MTL4CommandQueue / reusable MTL4CommandBuffer with reused argument tables, an MTL4Compiler for engine PSOs, a texture-view pool for mip views, and three queue-attached residency sets (persistent / scene / frame). MetalFX temporal plugin encodes via MTL4FXTemporalScaler on the shared MTL4CommandBuffer when the GPU supports Metal 4 FX; otherwise internal TAAU remains the fallback.
World render data
→ RendererCore / render graph policy
→ RHI states and pass dependencies
→ Metal or D3D12 driver
→ swapchain presentationDraw-list publish skips renderables that are not effectively visible: entity active and render.is_visible and not soft-pending (hi.render().setVisible / scene is_visible). Lights and probes also skip inactive owners. See Active / enable trio.
RHI (graphics/rhi/) is backend-neutral vocabulary for resources, sync, queues, compute, and acceleration structures. The graph tracks texture/buffer/AS hazards and queue dependencies; backends lower contracts without recreating policy.
Rendering contracts (composability)
Three feature layers must stay orthogonal: classical deferred lighting, optional visibility/effect backends, and optional content async compute. They may only meet at the contracts below. No layer may hardcode knowledge of another.
[content async compute] ──optional result texture──► [G-buffer geometry]
│
G-buffer attachments
│
[direct lighting + shadow mode: off | raster | RT]
│
optional AO (SS | RT) → optional GI (SS | RT) → optional reflections (SS | RT)
│
reconstruction / camera post / bloom (orthogonal) → tonemap | debug grid → UIVolumetrics, TAA/TAAU, exposure, DOF, and motion blur are feature-gated around this bus — they do not change G-buffer or lighting contracts. Assemble order: Implemented pipeline.
Contract A — G-buffer (shared bus)
A successful graph producer must initialize its declared outputs and complete its final barriers. The first G-buffer pass clears every color/depth attachment even while material tables or fallback textures are unavailable; the late pass preserves them. Draw readiness gates geometry emission, not attachment initialization.
Owner: src/hikari/src/graphics/effects/gbuffer.zig (layout + target_formats); geometry packages write it; lighting/AO/reflections only read it. PSO color formats for other kinds live in material/pipeline_kind.zig — backends map rhi.Format only.
| Attachment | Format | Contents |
|---|---|---|
| Albedo | RGBA8 | RGB base color; A = emissive pack lo (with ARM.A) |
| Normal | RG16F | Octahedral world normal |
| ARM | RGBA8 | AO, specular-AA filtered roughness, metallic; A = emissive pack hi |
| Linear depth | RG32F | Current view depth (R), corresponding previous-pose view depth (G; nonpositive = invalid) |
| Material | R32_UINT | Bits 0–7 MaterialFlags; bits 8–31 meshlet visualizer payload |
| Motion | RG16F | Motion vectors |
| Anisotropy | RGBA8 | Octahedral world-space anisotropy tangent, strength, reserved |
| Clearcoat | RGBA8 | Octahedral world-space coat normal, factor, roughness |
Rules:
- Opaque geometry always fills this layout (engine
_engine/gbufferor a game package with the same outputs). - The material target is an integer attachment, not a hardware stencil — the engine enables no stencil plane on either backend. Shaders declare it
Tex2D<uint>and read it withload; sampling an integer texture is a compile error in Akari, which is the point: filtering would blend unrelated flag bits across edges. It clears to zero, soMaterialFlags.geometrydistinguishes covered pixels from sky. - Lighting reconstructs world position from depth + camera matrices; do not invent a second depth convention per effect.
- Direct-light BRDF evaluation is shared. Shadow mode chooses unshadowed, raster-atlas, or inline-ray visibility without changing material or geometry contracts.
- Anisotropy and clearcoat are first-class surface lobes. Imported anisotropy (glTF
KHR_materials_anisotropy, and the equivalent FBX PBR maps) rotates the tangent-space direction, transforms it into the shaded world frame, and feeds anisotropic GGX direct and image-based lighting. Imported clearcoat (glTFKHR_materials_clearcoatand FBX coat) evaluates a separate dielectric GGX lobe with its own normal and roughness, then attenuates the base layer by the coat Fresnel. Neutral payloads preserve the ordinary metallic/roughness path: a zero-strength surface evaluates isotropic GGX and never builds a tangent frame. When a frame is needed,hikari_surface_tangent(brdf.akari) is the only place a tangent hint becomes a frame axis — it projects onto the shading normal and falls back to a fixed axis when the projection is degenerate (zero UV derivative, hit tangent collapsed along one axis, unwritten G-buffer texel). A NaN tangent is not black downstream (max(NaN, ε)returnsε), so that choke point is what keeps one bad tangent from becoming a white firefly. Both lobes keep the Schlick-remapped Smith masking term (hikari_geometry_smith, k = (r+1)²/8) rather than the exact height-correlated form: the exact term is ~1/(2·α·NoL) at grazing incidence on smooth surfaces, two orders of magnitude hotter, and RT hit shading (which has neither specular AA nor a firefly clamp) meets exactly that configuration on the inner wall of every refracting bottle. Deferred, forward, and RT hit lighting share the same lobe representation. - Specular is multiple-scattering compensated (
hikari_specular_energy_compensation,helpers.akari— Turquin 2019). Smith masking-shadowing accounts for one bounce, so energy that would have scattered again between microfacets is dropped — approaching half of it as roughness nears 1, and visible mainly on metals, whose response is entirely specular: rough gold and brushed steel read dark and desaturated without it. The single-scattering directional albedo comes from the split-sum DFG the specular term already evaluates (hikari_env_brdf_approxatf0 = 1returnsA + B), so there is no LUT and no new sampling; the deficit is re-added tinted byf0, which is what keeps saturated metals saturated instead of merely brighter. The factor is cached onHikariBrdfSurface— it depends only onf0, roughness andNoV— so the cluster loop pays it once per surface, not once per light. Applied inhikari_evaluate_direct_brdf_surfaceand in both IBL paths (hikari_eval_ibl_impl,hikari_eval_ibl_hit), so raster, forward, and RT hit shading agree. - Stored roughness is already specular-antialiased (
hikari_filter_specular_roughness,brdf.akari— Tokuyoshi & Kaplanyan 2019). The geometry pass folds the sub-pixel normal distribution, measured from screen-space derivatives of the final shading normal, into the GGX roughness it writes. Filtering at the write is what makes this one fix for both pipelines: deferred lighting and every RT effect's primary surface read roughness from here, so neither re-derives it and neither pays per light. Un-filtered, sub-pixel normal variance resolves as single-pixel glints that crawl under camera motion — which temporal filters handle worst, since a glint is a real radiance change rather than a reprojection failure — and as ray-traced fireflies the denoiser then smears. Derivatives are per render pixel, so the filter strengthens automatically under temporal upscaling.forward_lit_commonapplies the identical filter so a material does not shade differently across the opaque/transparent split. Not covered: RT secondary hits, which load roughness from the material table rather than this buffer; that needs cook-time normal-map variance plus ray-cone LOD at hit points. - Content (e.g. water) may change what is written into the G-buffer- Content (e.g. water) may change what is written into the G-buffer (displaced positions, FD normals). It must not branch on lighting mode.
Light clustering
Direct, forward, and RT lighting consume one logarithmic 24×14×24 light-cluster grid. Only point and spot lights are in it. Directional and ambient lights have no bounded extent, so consume.zig sorts them to the front of the light record array (sortLightsGlobalsFirst) and passes the count in ShadowFrameUniforms.rt_hit_params2.w; every pixel reads records 0 .. global_count unconditionally, then walks its cluster's run. Sorting happens once, before shadows or clusters are built, because a light's index is its identity in GpuLightRecord rows, ShadowPassFace.light_index, and cluster runs alike.
- One producer, all GPU.
light_binning.akarirecords seven kernels in one pre-graph submission: init, per-light coarse count, coarse prefix sum, per-light scatter into a 6×4×6 coarse arena, per-fine-cluster exact sphere/AABB count, fine prefix sum, per-cluster re-test and compacted write. Both arenas are handed out by prefix sums over measured counts, so storage tracks what cells actually hold; coarse is 256 Ki references per frame slot, fine iscluster_count × max_lights_per_clusterwords (asserted at comptime, so the prefix sum can never overflow it). The CPU packsLightBounds(32 B: world sphere plusluminance × intensity) and uniforms; assignment is never host-generated. Host packing is dirty-aware per ring slot when ordinal identity is stable, streaming only changed rows. - The layout is compacted. Cluster
iowns[offset, offset + count);countis the exact run length. There is no overflow escape and no fall-back to the full light list: a pixel's light loop is bounded bymax_lights_per_cluster(128) in every scene. - Over budget keeps the most important lights. Importance is the upper bound
luminance × intensity × (1 - d/radius)at the cluster box's nearest point, so it subsumes the overlap test. Selection histograms scores into 16 log2 buckets in group-shared memory and takes whole buckets brightest-first; which candidates fill the boundary bucket is arbitrary but identical in the count and write passes. Lights whose best possible contribution is belowmin_contribution(1e-3) are not binned at all. - Fail-open survives only in the coarse level: a coarse cell whose run overflows its arena makes its fine clusters test every bounded light. That is binning cost per cluster, never per pixel.
- Both projections share one formula (
ProjectionAxes.depth_weight, 1 for perspective, 0 for orthographic).graphics/effects/light_binning.zigcarries a host transcription the tests hold to the requirement (no point inside a light may lose it); the same module owns thePhaseenum and bind-slot table both backends drive from. A backend creates all seven pipelines at startup or none. A frame with zero bounded lights still runs the init kernel; a frame with no frustum zeros the headers per ring slot. - Diagnostics: fixed GPU counters (bounded lights, arena words used, coarse overflows, candidates tested, accepted,
fine_dropped, fine words used) publish throughlight_binning.logStatswhile GPU timing recording is active.fine_droppednon-zero means a cluster shows its best 128 lights and not all of them. - Host shading records (
GpuLightHostCache) are dirty-aware: static clean lights copy prior packed 96 B rows; GPU upload is sparse per ring slot, with a count change forcing a full rewrite.
Deferred decals
After opaque G-buffer geometry (including two-phase late draws) and before AO / GI / reflections / lighting, published projectors rewrite albedo, world normal, and ARM. Depth, motion, and material flags are not written. Visible projector bounds are CPU-binned into compact 16×16 screen tiles; one compute dispatch loads each pixel's source G-buffer once, applies its ordered tile list through bindless projector maps, and writes once. Tile overlap is bounded to the highest 64 stable sort layers. The volume is the actor transform: local box [-0.5, 0.5]³, scale is the box, local −Z is the projection axis. Pixels whose material lacks MaterialFlags.receives_decals (default on) are skipped. The test is against the G-buffer surface, so the box must contain the visible face — a projector hovering in front of a wall paints nothing (the editor flags that on a selected decal). Zero published decals omit the pass. Design: docs/design/deferred-decals.md.
Contract B — Classical deferred
Graph role: default opaque path for shadows off or raster shadows.
Passes: G-buffer + deferred lighting is the baseline. Optional AO / GI / reflections / volumetrics / reconstruction / camera post / bloom sit around it. Exact assemble order and gates: Implemented pipeline.
Inputs: G-buffer, shadow atlas, lights, global environment.
Outputs: HDR scene_color (or backbuffer when no post).
Independence: runs with no RT master, no acceleration structure, and no content compute. May run with content compute (vertex texture only) and/or screen-space AO/reflections.
Must not: require async_compute materials, TLAS, or ray-query shaders.
Hybrid shadows
features.shadows = hybrid is a single fullscreen lighting path, not the full sparse RT-direct producer and denoising chain. It traces exact alpha-tested visibility for directional lights within the first four global slots (normally the sun), while punctual lights and any additional globals sample the cached raster atlas through the same clustered deferred evaluator. With temporal reconstruction active the directional ray rotates across the authored emitter and converges to a soft shadow in the final reconstruction; without a temporal consumer it uses the emitter centre for a stable hard-shadow fallback instead of exposing unconverged one-ray noise. This keeps the global-light ray count bounded and avoids paying for both a full RT direct-light image and a deferred lighting image.
The four traced lanes are global slots, not the first four directionals: the lane a light reads is its record index. sortLightsGlobalsFirst therefore ranks directionals to the front of the unbounded run, so ambients — which are unbounded, and have no direction to trace along — cannot spend a lane they can never read. Reordering there is what keeps four lanes sufficient; widening the packet would not have been.
What it costs. Hybrid does not replace the sun's cascades, it adds rays on top of them. The atlas is prepared on every hybrid frame (usesRasterShadows is unconditionally true for the mode), because it is what forward transparency samples, what the froxel pass marches for shafts, and what a mid-frame readiness loss degrades onto. The saving is entirely on the punctual side, where an atlas tile already answers what a per-light ray would have re-derived. The rays it does spend are declared to the shared ray budget (rtEffectSet admits .shadows for hybrid as well as ray_traced), so GI, AO and reflections share down against them rather than being granted a frame that has already been spent.
Because TAA is hybrid's only accumulator — there is no dedicated denoiser or spatial resolve behind it — expect the traced penumbra to be the first thing that shows noise on a history reset or under fast motion. That is the trade the mode makes against ray_traced, and the place to look before suspecting the trace itself.
Hybrid always prepares the raster atlas, builds the shared RT scene, and requires the dedicated hybrid SDR/HDR lighting PSOs plus hit-shading tables. If the TLAS, hit tables, or hybrid PSOs are unavailable for a frame, effective mode degrades to raster; it never degrades to unshadowed lighting because the punctual atlas is already valid. That degrade is visually complete: screen-space contact shadows apply to any global reading the atlas under hybrid — the ones past the four lanes, and every global on a degraded frame — so a fallback frame looks like raster rather than like raster with its contacts missing. A traced lane never takes them, since the ray already resolved the detail the march approximates. Full ray_traced shadows retain their independent sparse producer, temporal reconstruction, and spatial resolve. Forward transparency remains atlas-shadowed in every mode.
Raster shadow atlas
Directional lights use four practical-split cascades. Defaults live under RenderQuality.directional / JSON quality.directional (DirectionalShadowConfig):
| Knob | Default | Role |
|---|---|---|
far | 120 | Max view-linear depth for directional CSM cascades (atlas density stays fixed; does not grow with camera far) |
rt_far | 0 (auto) | Max directional RT shadow ray length (metres). 0 = auto max(CSM far, camera projection far); explicit value clamps to [1, 1e5] |
padding | 4 m | Light-space pad on cascade XY (+ depth slack) |
caster_pullback | 80 m | Light-near pull-back so off-frustum casters still cast |
fit | stable | stable = camera-centered circle covering the slice (yaw-stable); tight = legacy frustum AABB |
max_extent_scale | 1.15 | Cap cascade XY half-size to scale × cascade far split (0 = off). Buys texel density by letting inner pages fall short of their slice; the shader falls through to the next cascade for what they miss. Never applied to the last cascade, which has no coarser partner |
sun_snap_deg / sun_snap_ease | 0.25° / 0.6 | Angular grid the sun snaps to when building cascades, and the share of each step spent easing onto the next. Snapping holds shadow edges still as time-of-day advances; easing keeps the handover from reading as a tick. ease = 0 is the old instant step, 1 never holds still |
cascade_blend | 0.1 | Split-boundary blend as a fraction of split width (0 = hard seams) |
depth_bias / normal_bias | 0.001 / 0.02 | Base receiver depth bias (NDC) and world normal offset; both gain a cascade-texel term in the shader so far outdoor pages do not acne |
Each cascade snaps the projection center to the page texel grid, ceils half-extents (so texel size only changes in discrete steps), and for fit=stable uses a camera-sphere depth range. The light direction used to build cascade matrices is angularly snapped (sun_snap_deg) so a time-of-day sun does not spin light-space axes every frame (shading uses the continuous sun), and the snap eases onto each step (sun_snap_ease): the basis holds still for 1 - ease of every step and moves at about 1.5 / ease times the sun's rate while stepping. Lower sun_snap_deg to shrink how far the basis travels per handover. CPU cascade frustums overlap by the blend band on the near side, so dual-sample boundaries always hit valid depth. Cascade selection is depth-first then containment: linear depth picks the sharpest cascade, then the shader walks outward to the first page that contains the receiver; that fallthrough is what makes max_extent_scale safe. Sampling scales normal/depth bias by the page's world-metres-per-texel. World positions for lighting reconstruct with the TAA projection jitter (projection[2].xy). Cascade blend is packed in ShadowFrameUniforms.rt_hit_params2.y. Point lights use six 1024² faces; spot lights one 1024² perspective face. All pages share one depth atlas and the backend-neutral face loop.
Depth-format atlas + hardware PCF. The atlas is the hardware depth buffer. The shadow pass binds it as the depth attachment, has no colour attachment and no fragment shader, and runs on the fast depth-only raster path. Every light kind compares projected z (hikari_sample_shadow_face).
Alpha-cut casters get a second pipeline, not a wider first one. Coverage is a property of the material — gbuffer.akari discards below alpha_cutoff — so a pass that ignores it disagrees with the G-buffer about what the surface is: a leaf card, a chain-link fence, or a grate laid down its whole quad as an occluder while the camera saw the cut-out. _engine/shadow_depth_masked is the same page fill with a fragment stage that re-runs the G-buffer's alpha term (base × vertex × albedo map, sampled only when the albedo bit is set) and discards. It reads no normals: a depth page needs position and coverage and nothing else.
A pixel shader roughly halves depth-path rasteriser throughput and almost nothing in a scene cuts, so alpha_masked rides on the DrawKey: the GPU shadow meshlet cull writes solid and masked caster classes to separate indirect streams and raster traverses them pipeline-major (a D3D12 PSO bind drops every root argument, so each class run re-issues its binds).
The cutoff is per primitive, not per material. ResolvedPrimitive stores alpha_maskable (the binding layout can reach an albedo map) and alpha_cutoff (the effective value from the ObjectData row the G-buffer discards against), because MaterialParams overrides resolve per instance and the shadow page must follow the instance the camera draws. A scene with no cut-out material never binds the masked pipeline.
The masked run binds only the albedo slot of the geometry table (D3D12 fills the rest of the declared range with the first bound descriptor, so a partial fill leaves no uninitialized entry) and falls back to the white default when the map is not yet resident — a streaming-in leaf casts solid for a frame instead of sampling whatever the slot last held. When the masked package is missing entirely, every caster goes back on the depth-only path with ShadowCasterSelect.all: solid silhouettes, which is wrong but visible, rather than alpha-cut casters dropping out of the atlas.
A masked caster's page is not pure depth, so map rebinds invalidate it. update_primitive_textures joins the commands that dirty the shadow cache — gated on the primitive being a cutting shadow caster and either side of the swap being able to reach an albedo map, since losing that reach also leaves the cached page wrong. A cutoff change arrives as an ObjectDataUpdate, which already invalidates for casters. Async texture uploads reach no render command at all, so residency latches masked_caster_maps_dirty whenever a masked caster's maps change and createRenderQueue drains it into the caster revision. Like a caster being created or released, this has no bounded extent to test pages against — a completed upload says nothing about where in the world its users are — so it invalidates the atlas wholesale. Only masked casters trip it; an ordinary streaming burst does not.
Ray-traced visibility reproduces the raster alpha cut inside the traversal. Alpha-cut instances enter the TLAS as non-opaque (FORCE_NON_OPAQUE / MTLAccelerationStructureInstanceOptionNonOpaque; BLAS geometry stays opaque), so their triangles surface as candidates in the inline query loop. Each candidate runs hikari_rt_candidate_accepts (UV and vertex alpha from the bindless geometry row, the instance's effective alpha_cutoff, material/albedo alpha) and commits only when the texel is really there. One traversal per ray regardless of cut-out depth; rays that never touch masked geometry pay nothing. Invalid or stale rows fail closed as opaque. Shadows and AO use this exact path; there is no project-wide coverage approximation or authoring dial.
Software opacity micromaps remove most of those exact tests without a hardware-specific extension. The RT scene reserves one persistent run per exact (BLAS range, material handle, alpha cutoff) and both backends lazily dispatch _engine/opacity_micromap_bake into one renderer-owned GPU arena. The bake conservatively classifies each N=8 micro-triangle as opaque, transparent, or unknown from vertex alpha and the albedo footprint. Traversal accepts/rejects the first two from one compact-state load. Baked unknown cells still perform the exact filtered material sample and cutoff, but reuse full-precision UV and vertex-alpha data stored in a capacity-stable fallback section; this removes three index reads and six dependent vertex-attribute reads per unresolved candidate. State zero is reserved for absent or invalid storage and takes the original source-geometry path. New, resized, missing, or failed ranges are never published: their instance row carries no_resource, so allocation, shader, residency, or dispatch failure changes performance only, never coverage. Buffer replacement keeps the old native allocation alive for the frame ring, invalidates every classification, and republishes runs only after the replacement bake has been submitted. D3D12 uses the direct-indexed frame descriptor heap; Metal binds the same per-frame argument tables and residency set to compute.
Alpha-cut casters retain the instance_mask.shadow_masked / RtMask.ShadowMasked classification, while visibility queries include both the ordinary and masked shadow sets. The shared RT instance/material tables therefore make a canopy, chain-link fence, and per-instance cutoff override agree with the G-buffer and raster shadow atlas. This intentionally makes RT shadows/AO consumers of hit-table readiness; the renderer falls back until those tables are valid.
Primary RT soft shadows reconstruct normalized shadow attenuation, not the scene's direct-light image. The raw pass evaluates the same clustered direct lighting with and without geometry visibility and stores the bounded ratio sum(L × S) / sum(L). The final lighting pass evaluates deterministic sum(L) at full resolution, then multiplies it by the denoised ratio. This is NVIDIA SIGMA's documented multi-light preparation: bright colored lights cannot become high-energy temporal fireflies, while unshadowed fill lights, BRDF/specular variation, attenuation, and material detail remain in deterministic full-resolution shading. One 8×8 group maps an alternating diagonal into its first contiguous 32 lanes, so every receiver receives a fresh observation every second frame while the other SIMD half only clears temporal sentinels. Every admitted shadow-casting light receives one source sample per update and advances the 16-slot disk sequence by the receiver's actual visit index. Independent per-pixel rotations shift both the angular and equal-area radial coordinates; a frame-global radius would make every edge sharpen on inner-disk frames and soften on outer-disk frames in lockstep. Raw alpha is an internal validity sentinel (2 traced, −1 missing). The temporal pass uses a geometry-gated traced population for current moments, retains exact reprojected history on missing phases, and integrates same-pixel observations at fixed weight. RGB stores normalized attenuation and alpha its temporal second luminance moment. The full-resolution spatial pass filters only measured variance with a centered 3×3 joint-bilateral kernel. AO, GI, and reflections retain their own reduced-resolution histories. quality.shadows derives the admitted punctual-light count (1 / 2 / 4 / 8); optional quality.shadow_lights pins it. Forward transparent materials remain on the raster atlas so layered transparency cannot multiply primary visibility rays.
RTAO is one fresh cosine-weighted visibility ray per raw pixel. Independent blue-noise radial and angular phases cover the whole hemisphere over time; varying both dimensions avoids the bias of merely rotating a fixed-radius sample. Noise is indexed by the AO target's own contiguous pixel coordinates—never by every second full-resolution depth texel—so half-resolution RTAO retains the intended blue-noise spectrum. The temporal history is RG16F (E[V], E[V²]), and that second moment is temporal on purpose: this producer is noisy across frames while looking spatially smooth after the temporal filter, so a cascade that re-measures variance from its own taps under-reads exactly the noise it exists to remove and the receiver shimmers. Its current estimate remains the five-ray receiver-compatible cross instead of restoring a single binary centre after convergence. The full-resolution spatial pass reconstructs the phase-correct 2×2 owners, derives visibility variance from the stored moments, and adds one dilated geometry-gated cross only where partial visibility remains noisy. quality.ambient_occlusion remains the SSAO slice/step budget and cannot multiply exact alpha-tested RTAO traversal. A zero shared RT grant writes neutral visibility without traversing a ray; it is not replaced by a hidden one-ray fallback. Exact material alpha acceptance and software opacity micromaps remain unchanged for both AO and shadows.
Ray scheduling is local to each RT producer. Direct shadows trace the packed alternating diagonal. RT GI owns a sparse screen-probe pass: one centre-biased diffuse receiver represents each ceil-rounded 8×8 render-pixel tile and traces the quality ladder (2 / 4 / 6 / 8 rays). The normal path tests one of the four central pixels; only a sky/metal centre scans the tile for its nearest diffuse surface. The following gather shades the raw GI grid without traversal when a 3×3 probe neighbourhood passes normal, depth, and bilateral world-plane tests. Silhouettes, thin geometry, and mixed-surface tiles that reject interpolation take two receiver rays through the same screen/world-cache/exact hierarchy. This keeps probe amortization while avoiding the separate one-ray noise regime previously exposed at every rejected edge.
Bounded direct signal. The normalized shadow ratio stays in 0…1. Channels with effectively zero unoccluded energy publish neutral attenuation and remain zero after the deterministic baseline multiply. This removes both albedo-frequency contamination and inverse-dark-channel amplification without a material-specific demodulation heuristic.
Closest-depth snap on reduced-resolution G-buffer reads. A reduced trace pixel centre can sit on a full-resolution texel boundary. A nearest fetch there arbitrarily picks one covered owner, and TAA jitter then swaps which texel is a grass blade vs the ground every Halton phase — history rejects, the upsample follows, and small foliage shimmers. hikari_gbuffer_snap_uv picks the closest linear-depth texel in the even-aligned 2×2 for full/half paths; the screen-space compute producers extend the same rule to their actual 2×2 or 4×4 trace footprint. Both skip sky. AO and GI use geometry-aware reconstruction. RT direct is already render-resolution and uses its own centred moment-guided filter; the producer's local depth/normal test still protects surface discontinuities before temporal reconstruction. Every spatial path falls back to the geometrically closest tap when bilateral weights collapse — typical of fanned foliage normals.
Idle scene-encode freeze is an editor Efficient gate (Editor Settings → Appearance → Idle encode), not a game path; standalone games and the editor's default Realtime always encode. Under Efficient, when canSkipStaticPublish succeeds the world keeps its last published frame, and after scene_encode_freeze_after_static (8) consecutive static skips with chrome settled, the present list goes idle and the render thread is skipped. A non-empty content-compute set keeps encoding on (water advances only on GPU dispatch). Camera motion, world UI, chrome changes, live graphics setters (wakeSceneEncode), a texture.residency_epoch bump, or any still-soft_pending mesh bind resume encoding, so a cook or HDRI that lands after the gate opens is not frozen out.
Point/spot receivers use perspective reverse-Z depth (ndc.z = A/d - A/far, A = near*far/(far-near)). Authored depth_bias keeps its meaning as a fraction of the far plane in world units and the shader scales it by A/d² with d = clip.w. The normal offset scales by the face's texel footprint 2d/(focal·tile_px) so large-radius point lights do not under-bias at range.
Sampling uses a comparison sampler (@sampler(comparison, greater_equal, …) — greater_equal because reverse-Z makes larger mean closer, so the fetch returns the lit fraction with no inversion). Each tap is a hardware 2x2 comparison with bilinear weights, so footprints are twice the tap grid: low = 1 tap (2x2), medium/high = 4 diagonal taps (3x3 / 4x4), ultra = a 3x3 grid (6x6). That replaces a manual loop of up to 25 unfiltered loads whose result was quantised to 1/25 steps — fewer fetches and smoother, because the texture unit interpolates instead of counting. The volumetric inject path uses the medium (4 diagonal) tap set rather than a single low-quality tap — one-tap volume shadows quantised narrow shafts into whole froxel columns and printed vertical bands on receivers. Taps are clamped in texel space (hikari_shadow_cmp_tap): the atlas packs every cascade and cube face into one texture, and hardware filtering cannot be told about page bounds, so an unclamped footprint would pull a neighbouring light's depths across the gutter.
Backend specifics: Metal uses a depth-only MTLRenderPassDescriptor with a nil fragment function and color_attachment_count = 0; the comparison sampler is a constexpr sampler the shader carries, so no host object exists. D3D12 needs the atlas as R32_TYPELESS storage with a D32_FLOAT DSV and an R32_FLOAT SRV (a D32_FLOAT resource cannot carry an SRV at all), a PSO with NumRenderTargets = 0 and a null PS, and a static comparison sampler in every root signature that samples it — fullscreen_shadowed s2, both forward variants s2, volumetric inject s0, matching Akari's declaration-order register assignment, which is why the comparison sampler is declared last in each shader. The D3D12 ComparisonFunc must equal the Akari attribute (greater_equal); MSL bakes the compare into the shader, HLSL takes it from the root signature, so a mismatch inverts shadows on one backend only.
Because the atlas is depth-format it cannot be bound to a float texture slot. The debug grids therefore read it through a dedicated depth binding (debug.shadow_depth), bound straight from the shadow renderer rather than routed through the graph, and by load rather than sample — those cells are point-sampled anyway.
Shadow budget and selection. Which lights get atlas pages, and the pressure cap on each punctual tile, are decided before tile requests are built (graphics/shadow/shadow_selection.zig), so collection work is proportional to the atlas rather than to the scene. Face scratch is sized to selection.admitted; compactShadowTable restamps dense upload indices after cache and face-cap settle. Per-face bias constants (world-metres-per-texel, NDC-depth-per-metre, punctual 1/(focal × tile_px)) are computed once where the face matrix is chosen (shadow_types.faceBiasScale → GpuLightShadowRecord.face_bias_scale), not per pixel. Shadow rows are read through the buffer by slot (hikari_sample_shadow_face / hikari_cascade_contains / hikari_directional_cascade_select), never copied by value: a dynamically indexed float4x4[6] cannot live in registers.
The budget is the atlas: (max_atlas_dimension / shadow_page_size)² pages, mirroring the packer's own clamp (a cap below the largest single tile is raised, not honoured, so budgeting against the raw cap would deny lights the atlas then had room for). When the wanted set exceeds it, selection walks from the least-important punctual light upward and halves tile caps until the set fits or every tile reaches quality.punctual.min_tile_size. Only the remaining floor-tier overflow loses whole lights. Thus ordinary coverage LOD chooses the desired resolution, atlas pressure may lower it, and complete shadow loss is the final step—not the first.
Importance is luminance(color) × intensity × radius / distance-to-eye — the same power scale light_binning ranks by, so a light too dim to shade is not treated as important to shadow. It deliberately does not use shadow_lod.screenCoverage, which reports zero at or behind the eye plane: a lamp a metre behind the camera casts the shadow you are looking at, and ranking by projected size would evict exactly that light first. A light that already owns pages gets a 1.5× bonus, the same hysteresis shadow_lod applies to tile sizes and for the same reason — a light oscillating across the cutoff re-rasterises its page every time it returns.
Directional lights are not ranked. They take their pages off the top and are never denied here: a directional degrades partially (two of four cascades placed gives sharp shadows near and none far), which an all-or-nothing page cost cannot express. Selection governs the punctual crowd; the packer keeps governing the suns.
Nothing changes while everything fits — the ranking and pressure caps are only consulted when the alternative is the packer dropping lights arbitrarily. PreparedShadowFrame.shadow_lights_denied counts what remained beyond capacity after resolution shedding, distinct from overflow_lights (what the packer unexpectedly could not place); both mean "more shadow casters than atlas", opposite fixes.
Selection is a raster budget only. Ray-traced opaque shadow visibility uses the material-aware ray-query path, gated on GpuLightRecord.casts_shadow, and needs no atlas page. Alpha-cut hits sample the same material alpha test as raster and continue traversal after rejected texels. A denied light therefore loses its pages and keeps its rays. Denial sets nothing: it simply leaves shadow_face_count at zero, which is already what the raster path reads as unshadowed. Under features.shadows == .ray_traced, an opaque-only frame requests volumetric_only when fog needs the atlas and otherwise skips it. Resolved forward-transparent geometry promotes that frame to the normal all atlas profile because transparency always uses bounded raster visibility.
Per-face caster culling. Every atlas face gets its own answer from shadow_meshlet_cull: a GPU selection pass rejects whole objects and chooses LOD from light-space texel footprint, then an indirect cull visits only the selected (instance, meshlet) pairs and appends visible triangles into face/class streams. The camera result cannot stand in for this because an object outside the view frustum can still cast a visible shadow. The kernel uses the same reverse-Z Gribb-Hartmann planes and exact face matrices as raster, including directional caster_pullback. A primitive whose LocalBounds were never computed is not treated as a point at the origin; it fails open to every face and produces a rate-limited warning. Raster then issues plain non-indexed indirect meshlet draws for the non-empty face/class streams—there is no CPU caster compaction or indexed shadow submission path.
The atlas cache is content-addressed, per page. shadow.faceSignature hashes one page — its light and face index, its atlas tile, the atlas size, and the view-projection it rasterises with — and retainCachedFaces keeps every page whose signature the atlas already holds, submitting only the rest. A shadow-caster transform, create, release, or policy change bumps the render-thread caster revision (caster geometry is not in a signature); non-caster updates do not. The revision only says something changed — which pages are stale is answered separately, by CasterDirtyList: a list of world spheres tested against each page's own frustum. Every command that can name an extent contributes one instead of staling the atlas wholesale. A pose row gives two (before and after); a create carries its own bounds and transform; a release and an alpha-cut map rebind read the sphere ResolvedPrimitive.world_sphere recorded the last time the primitive was posed. Only a geometry rewrite, a caster whose local bounds were never computed, and an overflowing list (512 spheres) fall back to invalidating everything. This is what streaming depends on: an open-world residency scheduler creates and releases shadow casters continuously, and a blanket invalidation on each one meant the cache was empty on nearly every frame a cell committed — with the per-frame face budget far below atlas capacity, the atlas could then never finish restaging and lights whose whole face set did not fit rendered unshadowed.
Preparation retains only pages already present in the atlas. Scheduled dirty pages enter the cache through commitRenderedFaces after the depth pass encodes successfully. Missing required pipelines, material data, or caster dispatches fail the frame; an abort invalidates shadow residency along with temporal histories, since a submitted prefix may have changed the atlas.
Hashing pages rather than lights is what makes the cache cover the cases that matter: recolouring a lamp changes nothing in the atlas, nudging the camera refocuses every directional cascade and nothing else, and is_static is not consulted, so a dynamic light that comes to rest reuses its pages. Pages match by content, not slot, so faces reordering between frames costs nothing.
Partial atlas updates. A render-pass clear cannot express "keep these pages": it clears the whole depth attachment or none of it. So when pages are being retained (PreparedShadowFrame.loads_existing) the pass loads the atlas and each stale page is reset individually — renderFaces sets the face viewport and draws the _engine/shadow_clear triangle, a vertex-only package at the far plane with depth compare always (pipeline_kind.depthCompare, the sole exception to engine-wide reverse-Z greater; under greater a far-plane triangle fails against everything already in the page, which is the opposite of a clear). When nothing is retained — first frame, a repack, a moved caster — the pass clears wholesale instead, which is cheaper than a reset draw per page. A retained-mode page with no surviving casters still gets its reset draw: "nothing to draw" is not "nothing is there" once the attachment is loaded.
shadow_clear shares the standard binding layout with shadow_depth deliberately, so both pipelines share a root signature and alternating between them mid-pass leaves the caster draws' root arguments bound and valid.
The per-frame face limit is a render budget, not a capacity cap. max_shadow_pass_faces is applied inside retainCachedFaces, to dirty pages only. Pages beyond the budget are deferred, not discarded: left out of this frame's list and out of the new cache state, so they return dirty next frame. Refresh spreads over frames, and the number of shadowed lights a scene can hold is bounded by atlas capacity rather than by per-frame face throughput. A light with no cached page is all-or-nothing (its tiles hold unrelated content, so half a cube map shows garbage); those take the budget first, in complete sets, and one that cannot fit renders unshadowed for the frame. A light that already has valid pages is safe to refresh a face at a time. The page cache is sized to atlas capacity (max_atlas_pages), not to the budget, and is an open-addressed set — at 1024 resident pages a linear scan per page per frame is not viable.
Tiles are owned, not packed (shadow_tile_allocator.zig). A page's cache signature includes its atlas tile, so a repacking allocator would defeat the cache whenever the light set changed. Instead a face claims a page block keyed by (LightId, face_index) and keeps it for as long as it keeps asking; steady state is zero movement. Ownership lookup is an open-addressed index with backward-shift deletion (no tombstones), hit twice per tile request.
LightId is the owning actor's handle (generation << 32 | slot), plumbed onto LightSnapshot at publish. It exists because a light's index is a per-frame ordinal that spawning, despawning, and sortLightsGlobalsFirst all renumber — nothing persistent may key on it, including faceSignature, which hashes the id for exactly this reason.
The atlas is a square grid of 512-texel pages; a tile of n pages is placed on an n-aligned block, because without alignment a scatter of small tiles leaves plenty of free pages and nowhere to put a cascade. When nothing fits, the least-recently-requested entries are evicted, never one touched this frame — a frame that cannot fit its own working set reports overflow_lights and those lights render unshadowed, as before. Entries idle for 60 frames are trimmed so departed lights stop holding pages. atlas_size only grows: tile UVs are atlas-relative, so a resize necessarily invalidates every page, and one-way growth pays that once per high-water mark instead of oscillating with demand.
Contract C — Ray tracing (shadows + AO + reflections + GI)
Config owner: RenderPipelineConfig in src/hikari/sdk/src/render_config.zig.
| Field | Values | Meaning |
|---|---|---|
tonemap | none / reinhard / aces | Global tonemapping mode; none is off |
ray_tracing | off / on | Master switch (device must support inline ray queries) |
shadows.mode | off / raster / ray_traced | Independent shadow visibility backend |
shadows.quality | low / medium / high / ultra | Raster PCF footprint, or RT shadowed punctual lights per cluster (1 / 2 / 4 / 8). Primary RT visibility is always one temporally rotated ray per admitted light; optional quality.shadow_lights overrides the light count |
reflections | off / screen_space / ray_traced | Independent of shadows |
ambient_occlusion | off / screen_space / ray_traced | Independent of shadows and reflections |
quality.screen_space | performance / balanced / high | Deterministic raw trace resolution shared by SSAO/SSGI/SSR (Performance = quarter for all; Balanced = quarter AO and half GI/reflections; High = half for all) and the reflection roughness ceiling (0.35 / 0.5 / 0.6, reflectionRoughnessCeiling) |
quality.reflections | low / medium / high (default) / ultra | Reflection march / resolve budget per traced pixel: Hi-Z march steps 24/40/64/64, neighbour resolve taps 4/6/8/12, spiral radius 2/3/4/5 texels (ReflectionBudget.fromQuality). Shared by both producers — the ray-traced one traverses a BVH and spends no march steps. Independent of the roughness ceiling (which materials reflect, screen_space), the raw resolution (screen_space), and RT bounce depth (rt_hit) |
quality.ambient_occlusion | low / medium / high / ultra | SSAO slices × steps per side (2×4 / 4×6 / 6×8 / 8×10, bounded by ssao_slices_max / ssao_steps_max); RTAO authors one fresh ray per raw pixel before the shared RT budget; default medium |
global_illumination | off / screen_space / ray_traced | Indirect diffuse (SSGI + RT GI). Default off = pure IBL ambient. SS/RT availability flip when their PSOs + shared temporal/spatial chain exist |
quality.gi | low / medium / high / ultra | SSGI slices × steps (1×6 / 2×8 / 3×10 / 4×12), fresh RT screen-probe rays (2 / 4 / 6 / 8), SS/RT range (15–60 m / 25–100 m), and RT hit-lighting quality (default medium; ignored unless GI is on) |
quality.rt_hit | RtHitConfig (performance / balanced / high, or explicit knobs) | Full hit re-light budgets for RT reflections and RT GI (shadow rays at hit, hybrid depth tolerances). Material alpha continuation for shadows/AO has no quality dial |
reconstruction | provider / preset / custom_scale | Sole policy for temporal AA and upscaling (see Dual resolution). Provider is a stable plugin-owned id; empty disables reconstruction |
environment_lighting | bool | Enable global-environment indirect diffuse/specular lighting |
gpu_frustum_culling | bool | Launch-time camera-cull gate. Off keeps meshlet expansion but makes it fail open (no frustum/cone/Hi-Z rejection). |
scheduling.async_compute | auto / off | Device-wide queue policy. auto uses async compute when supported; off demotes eligible work to graphics without removing passes |
quality.occlusion.mode | off / single_phase / two_phase | How occlusion works when the feature is on (default two_phase). Project Settings → Quality (editor viewport uses the same pipeline) |
quality.occlusion.profile | stable / balanced / performance | Camera-motion policy for Hi-Z (default stable). Project Settings → Quality |
RT scene state is device-wide, not per surface. One layer owns the TLAS, the RT instance / hit tables, and the bindless heap, so renderer_prepare runs RT preparation for the device-primary surface only. renderer_common.rayTracingSceneReady reports false on secondary surfaces (asset previews), whose ray-traced features resolve down to raster / screen-space through the normal availability path. Per-surface ray tracing would need per-surface RT scene state.
Dual resolution and temporal reconstruction
Internal render resolution and presentation (output) resolution are separate domains:
| Domain | What sizes it | Used by |
|---|---|---|
| Render | resolveRenderDimensions(output, reconstruction) | G-buffer, lighting, motion, depth, pre-recon volumetrics path inputs |
| Output | Drawable / inset presentation size | Temporal history ping-pong, bloom, tonemap, UI, editor composite |
RenderPipelineConfig.reconstruction is the source of truth:
| Field | Values | Role |
|---|---|---|
provider | empty, com.hikari.internal_temporal, or any plugin-owned id | Selected temporal path. Vendor ids are data, not engine enum cases |
preset | native_aa, quality, balanced, performance, ultra_performance, custom | Scale of render vs output (native_aa = 1.0; performance = 0.5; …) |
custom_scale | 0.25–2.0 | Used only when preset is custom. Below 1 renders smaller and reconstructs up; above 1 supersamples and the resolve filters down (vendor providers are capped at 1.0 — a scaler reconstructs upward only) |
sharpness | −1, or 0–1 | Post-reconstruction RCAS strength. Negative (the default) is automatic: a mild base for the internal resolve, ramping with the upscale ratio, and zero under a vendor resolve. An authored value applies everywhere including over a vendor resolve, and 0 means off |
resolveReconstruction produces ResolvedReconstruction (requested_provider, effective_provider, render/output dims, reset_history). Live changes use GraphicsUpdate.render.reconstruction; the session resolves the patch into one complete pipeline transaction.
Internal temporal (TAA / TAAU): when effective_provider is the built-in com.hikari.internal_temporal key, the graph runs temporal_reconstruction after transparency. Volumetric fog composites first in the render domain, where its depth and color share the exact jittered raster sample; transparent forward shading applies the same medium independently, and temporal reconstruction then filters both. The fog composite writes opacity into color alpha. Temporal reconstruction keeps that signal separate from transparent coverage: opacity reduces the final history color only while reprojection indicates motion, but never shortens the stationary geometry accumulator. At scale 1.0 this is native-res TAA; at scale < 1 it is mixed-res TAAU (render-res current/depth/motion → output-res history); at scale > 1 it is supersampling — the raster is larger than the display and the same 3x3 reconstruction filter widens its footprint to one output pixel (filter_span) so every extra sample is averaged in rather than dropped. Supersampling is the internal resolve's alone: resolveReconstruction caps a vendor provider at native, since an upscaler reconstructs upward only. It is the answer for a low-DPI display, where one displayed pixel is one sample and temporal accumulation has nothing to average over. Any scale < 1 also biases geometry material reads by log2(render/output) mips through derivative-preserving geometry_sample_bias / SampleBias. The value is published per frame, so governed resolution changes keep texture LOD tied to the fixed display grid without rebuilding D3D12 static-sampler root signatures; the same shader path supplies Metal parity, where the sampler descriptor has no LOD-bias field. The internal resolve is followed by the sharpen RCAS pass.
Internal TAAU history: geometry supplies the corresponding previous rendered pose's view depth, including object transforms, skinning and authored deformation; transforming the current position by the old camera is insufficient for radial object motion. History rejection compares that depth against individual history texels, never a min/max interval spanning unrelated surfaces. Thin-feature coverage bridging remains a separate bounded lock policy. Every current reconstruction and edge-search tap stays inside the active render rectangle, including during dynamic resolution changes.
Color history remains RGBA16F. Stationary accumulation is bounded to 32–64 samples according to stability, with shorter moving windows; explicit unbiased stochastic rounding to adjacent FP16 values prevents small updates from disappearing at storage. Depth and classification remain deterministic. Radiance changes release history even under a live stability lock. Transparent passes supply a separate previous-depth/valid-coverage guide; particles without a retained previous rendered pose mark it invalid and use current reconstruction instead of inventing a static correspondence. CPU numerical regressions execute the production scalar shader policy: cargo test --manifest-path src/akari/Cargo.toml -p akari --test temporal_reconstruction.
Provider seam (Zig-only): plugins register a stable, plugin-owned id via hi.render().registerReconstructionProvider with Zig on_config / on_frame callbacks. Authored configuration stores the id as a string; composition hashes it to a POD key, and registration computes the same key. The engine has no vendor provider enum or method table, so XeSS, DLSS, or another adapter can be added without changing engine source. The temporal pass fills a ReconstructionFramePayload with native handles (Metal: id<MTLTexture> / id<MTL4CommandBuffer>; D3D12: ID3D12Resource* / ID3D12GraphicsCommandList*), dimensions, jitter, frame delta, and camera projection parameters, then dispatches on_frame before internal TAAU. Both reverse-Z device depth and linear view depth are exposed because vendor APIs use different depth conventions. Reconstruction outputs carry an explicit backend usage bit; on Metal this creates the ShaderRead | ShaderWrite | RenderTarget texture MetalFX requires without adding shader-write usage to unrelated render targets, while D3D12 adds UAV capability for FSR. A matching .handled callback replaces encode (and must write output). .skip, .failed, or a missing selected provider falls back to internal TAAU. Config (provider / preset / scale) is pushed on every live change and at register. Private C/vendor SDKs stay behind plugin Zig glue.
FSR 4.1 (Windows/D3D12): src/games/example/plugins/fsr4/ wraps AMD FSR SDK 2.3.0's signed FidelityFX loader and FSR Upscaling 4.1.1 provider. It caches the upscaler context across scale and preset changes, chains ffxCreateContextDescUpscaleVersion (FFX_UPSCALER_VERSION) at context create, supplies Hikari motion/jitter in FSR pixel conventions, passes the reverse-Z camera convention, and encodes on the open D3D12 graphics command list into the render graph's UAV-capable reconstruction output. Hardware depth is host-guaranteed sampleable on D3D12 (R32_TYPELESS + D32 DSV + R32 SRV — same layout as the shadow atlas; pure D32_FLOAT cannot carry an SRV and will TDR if a vendor binds it). Resize recreates the context at the renderer's GPU-idle boundary; unavailable hardware (FSR 4.1.1 requires AMD RDNA 3 discrete / RDNA 4+) or failed dispatches retain internal TAAU fallback. The package advertises supported_os: ["windows"]; on macOS its Zig wrapper registers nothing, stages no native artifacts, and Project Settings marks it incompatible.
MetalFX Temporal (macOS/Metal): src/games/example/plugins/metalfx_temporal/ registers only when the active Apple GPU reports Metal 4 FX temporal-scaler support (supportsMetal4FX: / supportsDevice: plus a successful MTL4Compiler create). It borrows the current render targets and shared frame MTL4CommandBuffer, converts Hikari's de-jittered NDC curr→prev motion and NDC projection offset to MetalFX pixel conventions, supplies reverse-Z device depth, and encodes via MTL4FXTemporalScaler (newTemporalScalerWithDevice:compiler: + encodeToCommandBuffer:) into the output history. MetalFX 4 signposts that encoder with globalTraceObjectID; Metal 4 debug wrappers omit the selector, so the native bridge installs a forwarding shim (hikari_mtl_debug_install_global_trace_id_shim) before the vendor encode — otherwise a Debug build with the validation layer on aborts on the first MetalFX frame. The same shim scores those vendor encoders as having side effects; the debug layer does not, and would otherwise log endEncoding called for an encoder with no side effects every frame. The scaler and compiler are cached by device, formats, and the fixed input/output envelope; steady-state dispatch performs no adapter allocation or texture copy. MetalFX dynamic input-content properties carry the envelope and only the valid content extent changes when the frame governor steps. A scaler is recreated only after an allocation / output / policy-envelope / format change and requests asynchronous internal pipeline compilation. Unsupported ratios/devices and creation failures return .skip; invalid frame inputs return .failed; a non-MTL4CommandBuffer payload returns .skip; all fallback paths reset MetalFX history before it resumes.
Plugin post-process passes
Typed post-process is a separate contract from reconstruction. Plugins register fixed insertion points via hi.render().registerPostProcessPass (game thread, typically onLaunch). pass_registry.Kind stays closed; the host owns a PostProcessRegistry and inserts graph passes around existing closed work.
| Slot | When | Color domain |
|---|---|---|
before_reconstruction | Before internal/plugin temporal | Render-res HDR |
after_reconstruction | After temporal (or no-op section) | Output HDR if temporal on, else render |
before_tonemap | After bloom (if any), only if tonemap runs | Output HDR |
after_tonemap | Immediately after tonemap (only if tonemap runs) | Display-referred LDR |
before_ui | Near-final display color after debug visualizers and before UI | output |
Execute callbacks run on the render thread with:
- Frame-scoped opaque
RenderTextureRef(not raw MTL/D3D pointers) - Dims +
color_domainon the payload - Host encoder utilities — v1:
drawFullscreenTint(rgb, strength)via the composite PSO (display slots:after_tonemap/before_ui; no plugin shaders). HDR slots identity-copy only when plugins skip (bloom_composite PSO).
Host ping-pongs color writes through intermediates. For after_tonemap, tonemap is redirected into a sampleable BGRA8 intermediate (matches tonemap/composite PSO; present swapchain is never sampled). Sample package: src/games/example/plugins/host_tint/ (com.hikari.sample_host_tint).
A skipped plugin gets an identity copy. Missing fallback textures or pipelines and host encoding failures propagate to the frame transaction, including errors a plugin catches from a host callback. An unwritten ping-pong target is never reported as a successful pass.
The registry permits at most eight passes globally and four in one slot. Frame assembly performs an allocation-free, conservative transient-cost preflight (RGBA16F for HDR slots, BGRA8 for display slots); Metal and D3D12 currently expose a 256 MiB plugin-PP budget. Rejection disables the whole plugin chain for that frame, avoids forcing HDR solely for plugins, and publishes a coherent postProcessDiagnostics() snapshot. Each graph node uses its stable plugin id as the profiler label.
Project Settings → Rendering exposes the reconstruction provider id, render scale preset, and custom scale as live knobs.
Motion is intentionally responsive across the temporal stack. Correctly reprojected camera motion keeps a bounded 4–16 sample window in the final TAA resolve; reducing it to one or two samples makes sub-pixel geometry, stochastic LOD fades, and low-resolution transparency expose the jitter sequence instead of converging. Geometry/radiance rejection still collapses true disocclusions to one sample. Transparent composition uses a short window capped at four samples when fully covered rather than disabling history. Each stochastic effect owns its response: AO falls from a 0.92 stationary ceiling to 0.65 in motion, SSGI/direct fall to 0.70, and RT GI pairs a responsive short history with its dedicated stable long-tail policy. Stationary opaque pixels retain the longer integration needed for noise and Halton convergence.
TAA jitters current/previous projections (Halton, perspective cameras only), then reprojects in UV space with nine-tap Catmull-Rom history reconstruction, scale-aware YCoCg rectification, depth rejection, and output-resolution color
- state history ping-pongs. Current color deliberately filters across coverage edges: a low-resolution raster contains point coverage, so depth-gating color taps turns a sub-pixel cable into a binary hit/miss sequence instead of reconstructing fractional coverage. Depth remains the authority for motion, surface identity, and disocclusion—not for the color kernel.
Render textures can be larger than the active picture under the frame governor.
Addressing current color/depth/motion therefore uses allocation texels, while a
render pixel's footprint in output history uses picture texels. The history
probe covers the coarsest of the output, current active-render, and previous
active-render grids: history was produced by the previous grid, so using only
the current extent still made coarse-to-fine governor steps reject legitimate
coverage. When a jitter phase misses a sub-pixel primitive, the resolve searches
compatible current and provisional-history depth footprints and borrows motion
only from the current tap at that depth.
Both hit→background and background→hit results become one bounded
coverage_transition fact. That fact now creates the same contrast-weighted
stability lock consumed by rectification, HDR guarding, and accumulation; it is
not reinterpreted independently by each stage.
A 3×3 luminance-ridge test modelled on FSR's thin-feature lock rejects nuclei belonging to a solid 2×2 quadrant. Below quality scale, a compact three-frame analysis also recognizes periodic coverage. Both consume the equal-weight luma of the fixed 3×3 footprint, not the phase-weighted reconstructed sample: the latter intentionally changes its weights with Halton jitter and is therefore not evidence that scene radiance changed. The persistent state stores sample weight, two fixed-footprint analysis lumas, lock confidence, and signed geometry/reactivity facts. Diagnostics decode this exact state rather than re-running depth or luma policy. When a vendor reconstruction provider handles the frame, TAA diagnostics explicitly report no internal state; the provider writes color but does not own this codec, and a persistent target left from an earlier internal frame is not evidence.
A supported lock decays over roughly ten jitter cycles and renews whenever intermittent thin or coverage evidence returns. If the center owner changes on a phase where the persisted lock is the only remaining evidence, that lock is a bounded second history authority: motion and transparent composition still attenuate it, and unsupported carry expires within one sequence. This preserves the classifier across the no-coverage phase it exists to bridge without making it a general disocclusion override.
The camera is the single source of the jitter period, passed unchanged through
reconstructionParams.z; the shader does not derive or cap it again. The exact
FidelityFX sequence is 8 at native/mild ratios, 18 at 1.5×, 23 at 1.7×, 32 at
2×, 72 at 3×, and 128 at the supported custom 4× ratio. The sequence advances
with surface history writes rather than the host frame counter, so a dirty-only
editor viewport cannot skip phases while idle.
Valid stationary surfaces use a true running integration, so every Halton phase receives equal weight and may reach the 1024-frame ceiling. Stability locks own history admission and rectification, not a second accumulation-window policy: visible motion remains on the single absolute 4–16-frame reprojection-error bound, and transparent composition can only shorten it. Disocclusion, radiance/rectification reactivity, cuts, and provider fallback release it. Ordinary settled history may relax variance clipping only to the current 3×3 AABB. A validated lock may also admit its reprojected history sample when the feature is absent from every current tap; the final HDR guard expands only to that admitted value. When trust fails, ordinary current-neighborhood rectification resumes immediately.
Motion vectors are computed in the fixed temporal-output grid: exact
current/previous jitter offsets are removed before perspective-correct
interpolation. History is the previous resolve output, not an untouched
jittered raster; retaining J_prev - J_curr in velocity would make static
content walk once per Halton phase. Transparent forward draws emit corrected
motion, linear depth, and composition weight. Engine raster targets and
pipelines are single-sample. Resize, viewport, camera discontinuity, and live
mode changes invalidate history; reconstruction plugins receive the same reset
contract.
AO and reflection modes use independent temporal policies. Their guide representation is shared with direct shadows, SSGI, and RTGI: RG stores the octahedral world normal, B stores positive linear view depth, and abs(A) stores temporal confidence in [0,1]. Negative A marks reactive history without discarding the confidence magnitude. Signal alpha is deliberately outside this contract and remains estimator-owned metadata. Screen-space AO integrates a visibility bitmask (below); RTAO and SSAO feed a common RG16F moment history (mean visibility, second moment). AO's dedicated temporal entry gathers a five-tap scalar neighborhood, weights every tap by depth and normal before computing moments, clamps visibility to [0,1], and releases history when independently moving/off-screen occluders change the receiver even if its own geometry remains valid. Both AO producers then share half-resolution scalar a-trous stages at strides 1, 2, and 4. The stages propagate that temporally accumulated estimator variance and gate every tap by receiver depth, normal, and variance-scaled visibility; stable receivers return after the center fetch. A directional AO output (a bent normal in the spare channels, both producers writing it) was built and reverted: making room for it cost the temporal second moment, and a one-ray producer depends on exactly that — a denoiser's variance channel is not spare space. Both GI producers have a cascade too, so usesAtrous is true for GI in either mode and the fused AO+GI kernel serves screen-space GI as well as ray-traced; the estimator plane is ray-traced GI's alone and its absence no longer refuses the fusion. The final full-resolution resolve is conservative at coverage boundaries: if none of the four half-resolution owners represents the current surface, AO fades to neutral visibility instead of borrowing another face and blinking as jitter changes coverage. SSR/RT reflections use RGBA16F and their radiance-reactive filter, which reprojects history through the virtual reflected point (hikari_reflection_virtual_distance in the resolve output's alpha) instead of the mirror surface. Raw screen-space signals use the deterministic quality.screen_space half/quarter-resolution policy; their full-resolution outputs and geometry/confidence guides stay unchanged. AO multiplies the filtered visibility by material AO; reflections filter prefiltered radiance, then apply Karis hikari_env_brdf_approx from the full-resolution G-buffer at composite so metallic sky specular cannot bleed onto neighboring dielectrics.
SSAO and SSGI are 8x8 async-compute producers; both integrate visibility bitmasks over the same Hi-Z through one shared module (screen_space_bitmask.akari: slice frame, sector mapping, per-sample arc, the closed-form cosine weight, and the fresh-bit accounting). What differs is the range — a metre against thirty — the step density inside it, and what a newly occluded arc means: AO subtracts the arc from the open hemisphere, GI fills it with the arc's radiance. Their marches stay separate because a shared one would have to serve both ranges and the near field is where AO spends all of its samples; at the default balanced tier they are not even on the same grid. One lane per workgroup classifies the covered tile from the shared min/max linear-depth pyramid; sky-only groups write their neutral value without entering the march. Both raw producers are registered before either raster reconstruction chain, keeping them contiguous on the compute queue and avoiding a compute→graphics→compute queue ping-pong. SSR consumes the same pyramid but remains after deferred lighting because its trace reads lit scene colour; moving it earlier or pretending it is independent would sample an incomplete frame. SSR, SSGI, RTGI's screen-first tier, and raster contact shadows use one typed hierarchical traversal (screen_space_trace.akari): clip projection, coarse free-flight, min/max slab tests, mip refinement, full-resolution binary search, and termination classification live there. A proved range miss is distinct from off-screen, near-plane, invalid-origin, and step-budget exits, so consumers cannot turn an unresolved ray into sky visibility. The result also carries hit UV/depth, travel, confidence, depth disagreement, last mip, and a roughness-cone footprint. Effect profiles tune the physical range/slab and quality budget without duplicating traversal. Reflections are stochastic. Both producers are compute kernels writing one raw contract per half-res pixel: the radiance along ONE GGX visible-normal sample (Heitz 2018 VNDF, alpha = roughness²) and its hit distance in alpha. The screen march spends quality.reflections' step budget (24/40/64/64, clamped by the trace's own 64-step loop bound, which render_config/reflections.zig mirrors); fewer steps shorten how far a reflection reaches before the far field fills in, so the tier trades reach and convergence rather than brightness. Both budgets travel in the producer and resolve pass params via LightingTargets.reflection_budget, resolved once at assembly. Roughness up to reflectionParams.x (the quality.screen_space ceiling: 0.35 / 0.5 / 0.6) is traced; hikari_reflection_trace_gate(uniforms, r) fades over the last 0.1 and lighting applies the prefiltered lobe only where the gate is 0, so the two never sum. A screen hit is read from scene_color_pyramid at the mip its cone footprint covers; a miss takes the far field (sky or parallax-corrected probe) at half the roughness, since the neighbour resolve widens the lobe back. reflections_resolve gathers quality.reflections neighbours on a blue-noise-rotated spiral (4/6/8/12 taps; radius 1 to the tier's 2/3/4/5 texels by roughness/ceiling, the ring spread over however many taps the tier buys so a low tier still reaches full radius), regenerates each neighbour's direction and pdf from its G-buffer and the same blue noise (nothing about the ray is stored), forms the direction to its hit point from the centre, and weights by f·NoL / pdf (no Fresnel — the composite's EnvBRDF is Fresnel), clamped at 4 and gated by depth/normal/roughness agreement. Its alpha is the virtual reflected distance (hit distance faded to 0 over roughness 0.08–0.4); the temporal filter reprojects P + view_dir × a through the previous camera and blends that with the motion-vector reprojection by a / (a + 0.1), relaxing the depth gate under virtual reprojection (the history texel belongs to another point of the same mirror). Glossy metal receives real hits; there is no radiance floor in the composite. Ray-traced reflections: on-screen hits may reuse already-lit scene color at the cone mip (hybrid); off-screen hits bindless-fetch materials and re-light at the hit (capped shadow rays + cheap IBL). Optional nested specular bounces (RtHitConfig.specular_bounces, 1–4, primary inclusive) continue the mirror chain by swapping each hit’s IBL specular lobe for a traced child; values above 1 bypass hybrid reuse on smooth hits. Misses stay on prefiltered env. EnvBRDF is applied at the same full-res composite.
RT GI and RT reflections shade hits through GpuMaterialRecord[instance.material_index]. That index is the object-data handle id (same row the G-buffer uses). The game-published ObjectData.instance_id is not that id — prepare stamps the handle onto the RT source list. If it is left 0, every hit samples the white default row: warm sun bounce on the whole scene and blocky chrome, which clears when Hardware RT is off (raster G-buffer) or after Stop rebuilds the RT tables.
Environment lighting is split-sum IBL: GGX-prefiltered specular cubemap mips (cooked by Shinra), Karis hikari_env_brdf_approx at runtime, and L2 spherical-harmonic irradiance for diffuse (cooked SH coeffs in the frame uniforms). Specular env samples use a roughness floor (0.08) for mip selection and EnvBRDF, a luminance + soft channel knee, dielectric albedo occlusion, and horizon occlusion so HDR probe/sun discs and dark mirror cables cannot firefly. Legacy cubemaps without the ibl_split_sum trailer fall back to last-mip diffuse. There are no placed reflection probes: past the reflection trace gate, where no SSR/RT producer answers, lighting leans the prefiltered sky lobe toward the GI gather's irradiance (E/π, by GI confidence and by how far past the gate the roughness sits), so a rough interior surface stops mirroring a sky it cannot see. Below the gate SSR/RT reflections own specular. AO is an R8 pre-light visibility buffer and attenuates ambient/IBL without rewriting HDR scene color. Directional raster deferred lighting multiplies CSM by a short-range screen-space contact pass (contact_shadows.akari) after the atlas sample: ~6-15 cm world ray only, capped screen-space thickness, temporal interleaved-gradient-noise ray dither, shared Hi-Z confidence, and a distance fade to zero by ~22 m so far city facades stay pure stable CSM. The contact profile caps hierarchy descent at mip 2 because its entire ray covers only a few pixels; this replaces 8–14 fixed full-resolution depth fetches with the same coarse/refine traversal used by SSR and SSGI. The short ray and the distance fade are load-bearing: an unbounded march with depth-scaled thickness degenerates at city distance, where the whole ray falls inside one depth texel and every surface not facing the sun reads as occluded - that printed sun-aligned black streaks that swam with the camera and was long mistaken for a CSM defect. Not a substitute for cascades. Only evaluate_lighting (deferred) applies it; evaluate_lighting_forward and the RT paths do not (rays already resolve contact).
GPU culling computes one local bounding sphere when a primitive becomes resident. Each frame a 64-thread compute kernel tests transformed spheres against six camera planes, then (when Hi-Z is active) tests screen-space bounds against a min/max depth hierarchy. It compacts visible ObjectData inside fixed batch segments and atomically fills 32-byte indirect argument records. Occlusion is skipped on the first frame, resize, camera discontinuity, and when camera velocity exceeds the selected motion profile; those frames remain GPU frustum-culled. This motion guard is deliberate: finite Hi-Z sampling near a moving occluder edge can turn a few uncertain pixels into a whole-primitive rejection, which is conspicuous for buildings. Motion is measured in metres/second and radians/second, not frame displacement, so the policy is refresh-rate independent. The complete guarded-frame depth lets Hi-Z resume immediately when velocity falls below the profile. Metal dispatches on the graphics queue; D3D12 uses a direct-queue compute list with explicit copy, UAV, shader-resource, and indirect-argument transitions. The buffers are ringed per frame. Missing shaders, pipeline creation failure, or disabled config retain the direct-instanced path. Shadow draws do not reuse the camera result because off-camera objects may cast visible shadows; they are culled per atlas face instead (below).
Four controls (do not conflate):
| Layer | Control | Role |
|---|---|---|
| Feature | features.gpu_frustum_culling | Launch-time camera-cull policy; meshlet submission itself is unconditional |
| Quality | quality.occlusion.mode | off (frustum only), single_phase (frustum + Hi-Z), two_phase (predict + correct + late G-buffer) |
| Motion quality | quality.occlusion.profile | stable guards nearly all deliberate motion; balanced guards fast traversal; performance keeps Hi-Z active |
| Camera | camera.occlusion (inherit / disabled / enabled) | Whether this view uses occlusion; disabled forces frustum-only. Does not invent the feature or force Hi-Z when quality mode is off. Editor: camera inspector |
Dual view (lens vs game camera)
Publish writes two camera snapshots into the render frame (never one overloaded “primary”):
lens— image formation: G-buffer VP, TAA/motion, transparent sort, light clusters. Edit normally uses the neutral editor fly-cam; the camera toolbar button explicitly previews the primary camera and its authored optics. Possessed Play uses the game camera, while unpossessed Play returns to the neutral fly-cam.game_camera— player authority: visual-zone look sample, and while unpossessed both the directional cascade focus and the GPU frustum cull. In Edit and possessed Play the cascades are fitted to thelens, because the froxel grid, clusters and any raster shadow lookup all walk the image frustum; fitting them to a primary camera that is not forming the image leaves everything outside its pages reading as unshadowed. HostApiprimaryCamera/ audio stay on the live primary actor.
Unpossess cull-debug: freecam draws the picture; frustum planes stay on the game camera so orbiting outside shows what the player view drops (missing geometry = culled). Hi-Z occlusion is forced off in that mode — the depth pyramid is from the freecam G-buffer and must not be mixed with game frustum planes. Possessed and Edit freecam keep cull = draw.
Visual-zone sampling follows the image-forming fly-cam in Edit, so spatial looks preview where the author is looking. Play keeps player authority: possessed and detached views both sample at the game camera, preventing the inspection freecam from changing live fog or exposure.
Hi-Z footprint / bias / sample-pattern tuning lives in the cull constant buffer (CullTuning packed into FrameUniforms) as internal defaults — not project or inspector settings in v1.
When quality mode is two_phase, occlusion runs in two phases while the motion profile permits Hi-Z, because a single test against last frame's depth makes objects blink out whenever an occluder moves off them. The predict phase runs before the graph and samples the previous frame's pyramid reprojected with the previous camera; the correct phase (occlusion_cull) runs after the G-buffer and its pyramid rebuild, and re-tests only the instances the predict phase rejected on occlusion — using this frame's depth and the current camera. Survivors are tagged with the late bit in their visible-triangle records and land in the late bucket's own indirect arguments, so gbuffer_geometry_late draws them into the loaded G-buffer without re-issuing the first draw. A wrong prediction therefore costs one extra small draw instead of a missing object. Frustum rejects are marked settled by the predict phase and never re-tested, since both phases use the same current-frame planes. Guarded camera motion, per-camera disabled, and dual-view inspection do not reassemble the graph; prepare skips Hi-Z and late CPU/GPU staging and leaves isLateReady false (empty correct/late graph passes may still run). single_phase / off size cull rings for one phase only; mode == off also omits the depth pyramid unless screen-space AO/GI/reflections, raster contact shadows, or volumetrics need it.
Effective resolution (resolvedForAvailability / resolved*Mode):
- Requested = project/session policy.
- Effective = requested ∩ device support ∩ RT scene ready (non-empty TLAS after prepare) ∩ optional pipeline existence.
- Ray-traced shadows with the RT master off fall back to raster (a deliberate, persistent config state;
usesRasterShadowsmakes the same config-level decision, so the atlas is prepared on exactly those frames). With the master on but the TLAS not ready (transient), they fall back to off — the atlas was not prepared that frame, so raster would sample nothing. Hybrid always prepares the atlas and therefore falls back to raster whenever its TLAS, hit tables, or dedicated lighting PSOs are not ready. Ray-traced AO/reflections/GI fall back to screen-space; screen-space falls back to off if its pipeline is missing. - Runtime HUD must expose effective modes (
RuntimeInfo.graphics, includingray_tracing.scene_ready). Do not claim “ray traced active” from device support alone.
Two layers (do not conflate):
ray_tracing master
└── RT scene (TLAS/BLAS) ← shadows, AO, reflections, GI (usesRayTracingScene)
└── RT material hits ← shadows, AO, reflections, GI (usesRtHitShading)
bindless geometry/materials + HikariRtInstance
reflections/GI additionally re-light full hits| Layer | Cost when active | Consumers |
|---|---|---|
| RT scene | AS build/refit + visibility rays | RT shadows, RTAO, RT reflections, RT GI |
| RT material hits | Bindless geometry/material tables + alpha fetch; full re-light where required | RT shadows, RTAO, RT reflections, RT GI |
- Master off ⇒ no TLAS or RT effect cost. The renderer-global texture namespace remains active for raster materials.
- Master on + only shadows/AO ⇒ AS + RT material-hit tables for exact in-traversal alpha testing, but no full surface re-light.
- RT reflections need master and
reflections == ray_tracedand hit-shading ready (else SSR fallback). - RT GI participates in the same hit-shading gate (
usesRtHitShading); without master RT or scene readiness it falls back to screen-space (then off until SSGI availability is true).
Shared scene (TLAS):
-
Built in
graphics/raytracing/raytracing_scene.zigfrom the opaque draw list (not only shadow casters). Transparent draws stay out (FORCE_OPAQUE queries would hard-shadow glass). -
All RT effects share one TLAS /
frame_ready. Empty opaque set ⇒ no RT scene ⇒ all RT modes fall back together. -
BLAS geometry: triangle soup and indexed draws. Imported rigid meshes preserve Shinra's welded vertices; only their triangle indices are spatially sorted during model decode. RT independently selects a stabilized LOD and divides its index slice into at most 32K-triangle BLAS ranges; cache identity includes the first vertex/index element. This prevents a material-wide Bistro mesh from becoming one city-sized BLAS without multiplying raster draws or geometry storage. Indexed skinned geometry remains one refittable BLAS.
-
BLASes are cached with delayed eviction; static BLASes are compacted after build. An unchanged instance set reuses its TLAS, transform-only changes refit it, and membership, mask, or BLAS identity changes rebuild it. Replaced acceleration structures retire after the frames-in-flight window.
-
Submission model — what may block the render thread.
prepare()is bracketed bybeginAccelerationStructureBatch/flushAccelerationStructureBatch(both required, comptime-checked), and inside it the scene guarantees every BLAS is resolved before the TLAS. That order is what lets a backend seal the batch on the first TLAS call. Compaction is the only thing that may drain the GPU, and only because the compacted size is written by the build, so the destination cannot be allocated until the builds have run — a scene load pays that once, not once per structure. Compacted destinations are admitted in 384 MiB waves on both Metal and D3D12; sources outside the wave remain valid BLASes and compact on later prepares. This bounds source/destination overlap without changing geometry or traversal quality. Copies submit asynchronously, and a wrapper publishes its compacted handle only after submission. Everything else is recorded and ordered by GPU fences. D3D12 records frame-time BLAS/TLAS batches on the compute queue, with direct→compute input and compute→direct consumer waits; the CPU never waits. Metal keeps AS encoding in the pre-graph frame buffer because its current encoder fence contract is local to that queue. Static Metal builds suballocate disjoint scratch ranges from a persistent 128 MiB arena and insert an acceleration-stage barrier only when the arena wraps; scratch high-water is therefore bounded by the arena (or one unusually large BLAS), rather than by the sum of every BLAS in a Play/scene rebuild. Residency telemetry phase-tags live structures, pending uncompacted sources, retired copy sources, scratch, and the construction high-water. -
Analytic-light shadow visibility (primary, hybrid, and secondary GI/reflection hit lighting) uses
ray_origin.akari: a representable floating-point displacement along the geometric surface normal, following Wächter and Binder, Ray Tracing Gems chapter 6. The offset scales with position precision (256 ULP steps, with a 1/65536 m near-zero fallback); it does not grow with grazing incidence. Fixed 3–6 cm offsets can jump past thin wall/roof geometry and produce rotating false visibility even with a stationary camera. -
Primary shadow receivers share the discontinuity-aware depth geometry reconstruction in
surface_geometry.akariwith GI probes. Secondary hits carry the actual triangle normal separately from the authored/interpolated and normal-mapped BRDF normals. This adds no G-buffer attachment or RT instance-table field; material lighting and authored foliage-normal policy stay separate from ray-origin placement. -
Depth-reconstructed primary/hybrid receivers also include a floating-point reconstruction margin before the representable offset:
32 * FP32 epsilon / (1 - 32 * FP32 epsilon)times the sum of the maximum absolute world-position and view-translation components. A floor near world Y=0 can be reconstructed from camera-space values tens of metres large; an offset based only on the final Y coordinate can remain below its own triangle. The margin is computed once per receiver and shared by its light queries. It adds no ray or texture reads and leaves the secondary-hit policy independent.akari'sray_originregression executes the shader scalar IR on cooked JapaneseStreet floor geometry and checks that the corrected origin clears its own triangle while retaining a caster 0.5 mm above it. -
Shadow queries use an explicit zero near bound after displacing the origin. Opaque and alpha-tested visibility honor the same caller-provided interval on Metal and HLSL. Punctual rays aim from the displaced receiver to the sampled emitter point and exclude only the endpoint's final representable distance; there is no 4 cm light-end hole. The emitter ring and ray count are unchanged. AO and the reflection/refraction/GI transport rays retain their separate
raytracing_commonpolicy; their secondary analytic-light shadows use the shared visibility policy above. -
Metal usage split:
PreferFastIntersection(static BLAS) must not be combined withRefit. Refittable BLAS/TLAS usePreferFastBuild|Refit. Compacted sizes are read back as 64-bit values throughwriteCompactedAccelerationStructureSize:toBuffer:(MetalRayTracing.m); a 32-bit readback corrupts the compact copy. -
RT shadow lights:
GpuLightRecord.casts_shadowgates ray visibility (atlasshadow_face_countstays 0 when the atlas is skipped). Directional ray length usesradius_and_shadow.y, packed fromDirectionalShadowConfig.rt_far(auto = max of CSMfarand camera projection far when available; independent of cascade construction so large worlds are not clipped at the default 120 m CSM far). Punctual RT soft penumbra uses authoredsource_radius(metres) packed ininner_cone_and_cascade_splits.ywhen non-zero; otherwise falloff ×HIKARI_SOFT_POINT_FRAC. -
Transparent shadow policy: both
_engine/forward_litand its RT companion sample the raster shadow atlas, even when opaque direct lighting uses RT shadows. The atlas is promoted to the normal surfaceallprofile only when resolved transparent geometry exists, so opaque-only RT frames retain the cheaper disabled /volumetric_onlybehavior._engine/forward_lit_rttraces the reflected scene for smooth thin interfaces when RT reflections are active, traces refraction automatically for authored volumes and for thin interfaces carrying the explicit override, but does not trace direct-shadow visibility per transparent fragment. Shadows-off frames publish no atlas faces, which is the forward shader's unshadowed gate; no shadow material flag is required.
RT material-hit data and full hit shading:
- Owners:
residency/bindless_heap.zig(pipeline-agnostic and shared with raster),raytracing/raytracing_bindings.zig(HikariRtInstancein TLAS order), shadersraytraced_scene+hikari_shade_hit_rt. - Gate:
usesRtHitShading()— true when any ray-traced visibility feature needs material-aware hits: shadows, AO, reflections, or GI. Shadows/AO use the rows only to reproduce raster alpha acceptance.RtHitQualityaffects the full reflection/GI re-light path: shadow-ray budget, hybrid depth tolerances (ShadowFrameUniforms.rt_hit_params), specular bounce count (rt_hit_params2.x, primary inclusive, clamped 1–4), and the bounded punctual-light estimator (4/6/8 samples; globals remain exact). - Secondary-hit material sampling uses an explicit ray footprint: triangle UV/world-area density and travelled cone width select the mip for albedo, normal, metallic/roughness, AO, and emissive maps. Ray shaders therefore neither force LOD0 nor depend on nonexistent screen derivatives.
- The shared RT budget is a deterministic authored ceiling (
RenderQuality.rt_budget.tier;render.jsonpathpipeline.quality.rt_budget.tier). Adaptive feedback belongs to the separate total-frame governor (RenderQuality.governor;pipeline.quality.governor), enabled by default withmode: adaptive. Its quality ladder first changes render resolution when allowed, then reduces above-floor ray grants, and finally lowers the stochastic input grids for RT reflections and the RT GI gather/denoise grid, down to half their authored linear size. RTAO stays on its fixed half-resolution producer/history lattice so a pressure transition neither reallocates the target nor changes which surface owns a ray. RT GI's separate 8×8 probe lattice remains tied to render geometry; its quality-scaled probe rays are reduced by the ray lever and its fixed one-ray floor is already only one trace per 64 render pixels. Histories remain at authored extent and the temporal/bilateral chains reconstruct their normal outputs. The authored configuration remains the ceiling and all feature gates remain authoritative. Setpipeline.quality.governor.mode: offfor deterministic captures or fixed-budget benchmarks; anunlimitedRT tier disables the governor altogether. - Hybrid path: on-screen hits can reuse scene color; off-screen hits re-shade. Nested specular bounces (
specular_bounces> 1) skip hybrid reuse on smooth hits. Hit shading not ready ⇒ SSR for that frame. - D3D12: one logical namespace replicated into fixed prefixes of the frame-slot shader-visible heaps; descriptors are written only after that slot's fence wait, and shaders index that heap directly (no descriptor table, no per-pass
CopyDescriptors) — see Dynamic resources below. Metal: direct-write argument-buffer bindless + residency lists. RT instance tables remain root/storage buffers. - Static scenes: skip the instance-table pack when the bound ring slot already records this frame's TLAS + geometry-row fingerprints; skip the GPU upload when that slot's content hash also matches. Material changes update the shared table without repacking RT geometry. Reuse is decided per ring slot, never from the previous frame: the table binds without size information (root SRV / device pointer), so a slot still holding a shorter table would let the hit shader read past the buffer as soon as the TLAS grows (D3D12 page fault / device hung in
ray_traced_reflections). - Backstop: the rows actually uploaded for the bound slot travel in
rt_hit_params2.z, andhikari_rt_load_hit/hikari_rt_load_materialrefuse instance ids at or beyond it. A regression in the reuse rule then shades a neutral surface instead of removing the device.
Shared material indirection
Opaque G-buffer, forward, alpha-masked shadow, mesh-vertex content, and RT hit shading use one 112-byte GpuMaterialRecord, addressed by the existing stable HikariObjectData.instanceId. Each surface owns a frame-ringed table because instance ids are surface-local. A row carries base colour, metallic/roughness/occlusion, emissive state, anisotropy/clearcoat factors, nine stable texture indices, a dense geometry sampler catalog index, and optional content-compute heightfield indices. Pipeline binds install the material buffer, global texture range, and geometry sampler catalog once; draw loops no longer construct or flush per-material texture tables or rebind wrap/filter samplers. Shaders sample maps via geometry_sample(tex, uv, material.sampler). The table binds as an unbounded root SRV / device pointer, so every consumer checks the per-surface commit flag (gpuMaterialsReady) first: a frame with no committed table (bindless heap cold, default textures pending, upload failure) skips scene raster draws, disables masked shadow casters, and reports RT hit shading not ready — never a draw against an unbound table. .mesh_vertex_texture shares the same standard bind model: heightfields are resolved into the bindless texture heap at encode and written onto the material row. A row that cannot name a heightfield (no content key, producer with no output yet, heap full) gets the default texture's index rather than invalid_index — the vertex shader indexes the table untested, so an unwritten row is an out-of-range descriptor read, not a black surface.
The catalog is carried by the geometry layouts only — standard, mesh_vertex_texture, forward_lit, forward_lit_rt, fullscreen_raytraced. That list exists in three places that must agree: the D3D12 root signatures built from geometryCatalogStaticSamplers (material.zig), layout_uses_geometry_catalog in akari_emit_common (which decides which shaders declare it), and anisotropic.bindingLayoutUsesGeometryAniso (which rebuilds exactly those signatures when the AF tier moves). Particles and SSR are deliberately outside it: neither samples a material map, and particle.akari keeps its own @sampler at s0, space0 — the catalog lives in space1 precisely so per-layout samplers keep their own registers. On Metal the catalog is an argument-buffer sampler table bound on render passes only, so only vertex/fragment entry points that actually reach a geometry_sample take it as a parameter; a compute kernel that declared it would read an argument-table entry the renderer never sets.
HikariRtInstance is geometry-only apart from its material-row index: normal transform, bindless vertex/index buffers, range/counts, and flags. RT no longer packs a second copy of scalar material state or texture identity. The packed row is 112 bytes; the added range origin lets multiple spatial BLAS instances share one raster vertex buffer.
Batching
DrawKey carries pipeline variants, geometry/LOD identity, submission lane, and cull/shadow policy — not material. Every byte a shader reads about a material lives on GpuMaterialRecord, addressed per instance, so two objects that share a compatible pipeline bucket batch together no matter how differently they are textured. Anything that genuinely differs by material (binding layout, blend mode, cull) already differs by pipeline, because the pipeline is built from exactly those properties.
The one consumer that needs the material object rather than its shader-visible state is content-compute: heightfield indices are completed at encode by material_table.patchContentTextures, which walks draw items and keys by object_data_handle.id, the same as build. Batching therefore never enters into it, and transparents are covered by the same pass. Emission does not touch the material table at all.
What still splits authored work: pipeline, geometry/LOD identity, shadow and alpha-mask policy, and skinning. Meshlet submission then merges adjacent work with the same raster pipeline into one GPU bucket; material identity remains per object and does not split it.
Bindless geometry (raster + RT)
Every live mesh vertex buffer and derived index expansion is resolved into the renderer-global bindless buffer heap used by raster pull shaders and RT hit shading (residency/bindless_heap.zig + gpu/geometry_table.zig). A 48-byte GpuGeometryRecord per instanceId carries stable buffer indices, stride, LOD range, fade state, and the selected meshlet range. Prepare builds that table alongside materials; the ready flag gates both.
G-buffer, shadow depth (masked included), and every forward scene-mesh vertex shader attribute-pull positions and attributes through meshlet descriptors, remaps, and micro-triangles. Skinned variants pull the packed bone row and perform current/previous LBS from the joint palette. No scene-mesh PSO declares a vertex input layout and no scene-mesh draw binds a vertex buffer. The derived u32 index expansion exists for BLAS and physics, not raster submission.
Pull reads are vector loads, not one load per component. bindless_load_f32x2/3/4 lower to ByteAddressBuffer::LoadN on D3D12 and a packed_floatN dereference on Metal; both need only 4-byte alignment, so rigid (stride 12) and skinned (stride 16) rows share the same address path. Casting the heap to a HikariMeshVertex* would be wrong: MSL's float3 layout differs from HLSL and neither implicit struct pitch respects vertexFloatStride.
A scene-mesh @vertex_in under these layouts is a compile error (akari_emit_common::check_vertex_in_supported): it would create an input-assembler dependency the renderer never binds. UI, particles, and debug primitives remain ordinary non-mesh scene geometry and keep their own vertex-input contracts.
Raster work graphs retain all resident LOD ranges. GPU selection appends the chosen meshlet pairs and carries fade coverage in visible-triangle records. Geometry-owned remap/micro-triangle buffers produce absolute source-vertex indices; moving the camera does not rebuild raster batches or topology.
A row that cannot be seated (evicted mesh or bindless heap high-water) keeps no_resource and drops only its own work. Primitive creation rejects incomplete meshlet topology; defensive invalid-range and pair-cap checks isolate and report only the affected batch, and never select a second submission path. Only a real table allocation failure clears the frame-ready gate.
raytracing_bindings reuses geometry_table.resolveVertexBuffer / resolveIndexBuffer so a mesh drawn in the G-buffer already owns its heap slots before the TLAS packs.
Compute-driven meshlet submission
meshlet_lod_cs selects per-object raster LOD and generates an active pair list plus indirect cull dispatch arguments on Metal and D3D12. Predict/correct culling shares that list. Opaque and transparent delivery use persistent rows and revision-covered deltas; swarm expansion and preview documents retain unchanged data. Shadow LOD and active work are selected independently per dirty face on the GPU; CPU RT range policy remains independent. See persistent scene Stage 4 for ownership, memory cost and remaining CPU work.
Camera geometry has one shared path on Metal and D3D12:
- Prepare packs one work row per resident LOD meshlet and one pair per
(instance, meshlet). Adjacent work with the same raster pipeline shares a submission bucket. meshlet_cull_csruns once per pair. It applies object/meshlet frustum, cone, and Hi-Z policy, appends packed visible-triangle records to the bucket stream, and atomically grows only that bucket's plain non-indexed drawvertexCount.- The raster pull VS decodes a two-word visible-tri record
{late|work}{object|tri}(work ∥ object, not a pair→work chase), then descriptor → micro-triangle → remap, then pulls vertex data and optional skin weights from the bindless table. - Emission binds each pipeline bucket once and issues one plain non-indexed indirect draw. Metal lowers this to
drawPrimitives(indirectBuffer:); D3D12 uses the singleD3D12_DRAW_ARGUMENTScommand signature.
The two-phase camera path owns separate predict/correct visible-triangle streams and bucket arguments. Predict tests the previous pyramid; correct re-runs only unsettled pairs against the completed current pyramid. The render graph explicitly tracks the late bucket-argument buffer between occlusion_cull and gbuffer_geometry_late.
Shadows use the same pull ABI and plain draw arguments. shadow_meshlet_lod_cs builds the active pair list and indirect dispatch for each dirty face; shadow_meshlet_cull_cs writes face/class bucket streams directly; the CPU schedules atlas faces and pipeline classes but performs no caster sphere loop, list compaction, or per-batch mesh draw. Every transparent scene-mesh lane also uses the shared meshlet graph, including skinned LBS. Cone and Hi-Z policy are disabled where transparency requires it; the bucket lane chooses refractive depth/shading, additive, or OIT output. Refractive depth and shading replay the same visible-triangle arguments, so none of those lanes has a per-item or direct-vertex fallback.
There is no scene-mesh fallback pipeline. Missing kernels or allocation failure make the meshlet frame unavailable; malformed topology and cap pressure are isolated and diagnosed per batch.
Instance uploads retain packed-row dirt independently for each prepare slot. Ordinary updates compare and upload only pending changed rows, coalescing adjacent rows into writes; quiet frames perform no full instance-array hash/diff scan. Buffer growth and structural owner changes receive complete seeds. See persistent GPU scene Stage 5.
Dynamic resources (D3D12 SM 6.6)
Compute parameter blocks have two transports on D3D12. Root constants are
cheapest for a small block and most expensive for a large one, because every
DWORD occupies the 64-DWORD root signature the pass shares with its descriptor
table, CBVs and root SRVs. compute_passes.paramsBinding picks per command by
size: at or under 32 DWORDs the block stays inline, above it travels in a
per-frame constant-buffer ring and binds as a root CBV. Both present to the
shader as cbuffer : register(b0), so the Akari source never mentions which,
and Metal — which has no such budget — delivers every block as inline bytes at
buffer slot 0 and ignores the distinction entirely.
Only FroxelParams crosses the line today, and it is why the mechanism exists:
at 48 DWORDs it made volumetric_inject the widest signature in the engine
(61 of 64), so the two root SRVs bounded media needed had to be paid for out of
the reserve. Through the ring the same pass costs 15. The ring is one buffer per
frame in flight, rewound in beginFrame right after the slot's fence — the same
point the descriptor heap is reset, and for the same reason — and bumped 256
bytes per push, because D3D12 requires every constant-buffer view to start
256-byte aligned. That alignment is also the cap on a params block, asserted at
comptime against the contract so a block the contract accepts always fits the
slot it is copied into.
The bindless heap is not a binding on D3D12. Shaders read it with ResourceDescriptorHeap[i], so it has no descriptor table, no root parameter, and no register space; the only requirement is that the command list has the right shader-visible heap set and that the root signature carries CBV_SRV_UAV_HEAP_DIRECTLY_INDEXED. That flag is set on every graphics root signature the engine builds rather than per layout — there is one heap, it is renderer-global, and a signature that omitted the flag would fail PSO creation against any shader that samples a material map or pulls a vertex. D3D12 also requires that heap to be SetDescriptorHeaps'd after Reset and before SetGraphicsRootSignature / SetComputeRootSignature (validation #1419), and forbids swapping it afterwards (#1420). The command list binds the current-frame heap on Reset.
space1 carries only the geometry sampler catalog; there is no space2.
Three things follow that are easy to get wrong:
- Both halves share one heap, so the index spaces do not match Metal's. Buffer SRVs occupy
[0, 4096)and texture SRVs follow, andResourceDescriptorHeapindexes absolutely — so the HLSL backend biases every texture index byBINDLESS_TEXTURE_HEAP_BASEand leaves buffer indices alone. Metal keeps one argument-buffer table per kind, each from zero, and applies no bias. The.akarisource is identical either way; the bias is an emitter concern.bindless.zigcarries a@compileErrortripwire against the Rust constant. NonUniformResourceIndexis mandatory. Since materials leftDrawKey, one draw routinely covers several materials, so lanes in a wave carry different indices by design. Every emitted heap read is wrapped; a golden test counts the wrappers against the reads so a new lowering cannot skip it.- A wrong heap is silent. Indexing the wrong heap just returns someone else's descriptors, so
bindBindlessHeapbinds nothing; it verifies that the pass's shader-visible heap is this frame's bindless heap, and fails the draw if not.
The shader model is engine-wide, not per package: D3D12_SM_FLOOR in akari_hir names the profile the cooker compiles and shader_artifact.d3d12_shader_model names the .cso the loader asks for. They must match — drift is not a link error, it is every D3D12 shader silently missing from the AssetStore, which is why both carry a test. Adapter selection requires SM 6.6 and resource binding tier 3 and rejects anything less by name; there is no fallback binding model to fall back to. DriverRecipe.gpu_preference changes enumeration order only: Auto uses DXGI's unspecified order and High Performance uses DXGI_GPU_PREFERENCE_HIGH_PERFORMANCE. The adapter that wins is logged with its dedicated VRAM against the preference that was asked for — ungated, because "am I on the right GPU" is the one question this setting exists to answer.
Metal does not implement the preference: hikari_mtl_create_device always takes MTLCreateSystemDefaultDevice (the Metal 4 floor means Apple silicon, one GPU). A non-auto value is logged at device init. Changing the preference always requires a process restart; no backend migrates a live resource graph between adapters.
Root descriptors (uniforms, object data, shadow casters, joint palettes, material rows, geometry rows, the RT instance table) and every static sampler stay as root arguments with hand-numbered registers; they are not heap indices.
Bindless lighting / IBL
Every scene-lighting texture — G-buffer targets, shadow atlas, skybox pair, AO/GI/direct, scene captures, forward fog volumes, Hi-Z, bounce — reaches shaders as an index into the renderer-global bindless texture heap.
- The carrier is an 84-byte per-pass block (
gpu/lighting_resources.zig::Indices↔ akariHikariLightingResources), set as root constants (D3D12b3, 21 dwords) / fragment bytes (Metal buffer 8) at encode — after the graph has fixed physical texture identity, so transient-pool targets cannot go stale in a prepare-time upload. Engine textures have noRenderHandle; heap residency is keyed by synthetic per-pointer handles (lighting_resources.KeyMap). The last dword is the engine's 128×128 blue-noise phase texture for the stochastic RT samplers. - Every hoisted heap read must land on a valid descriptor. The shader hoists
ResourceDescriptorHeap[...]per declared field even when a uniform gates the sample, so absent resources substitute type-compatible fallbacks (drivers already bound same-typed stand-ins for the old table; the same substitutions now resolve to indices). - One heap, four typed views. Cube (probes/sky), 3D (fog volumes), and depth (shadow atlas) reads go through
bindless_texcube/tex3d/texdepthbuiltins against the one bound table — MSL forbids two parameters on the same[[buffer(N)]], so non-2D views reinterpret the table pointer; HLSL reads the heap with the matching element type. Comparison shadow sampling works unchanged: the compare function lives on the static/constexprSamplerCmp, not the texture. - Lighting layouts carry no descriptor table:
fullscreen_shadowed,fullscreen_reflections,forward_lit/forward_lit_rt, andfullscreen_raytracedtake the index block as root constants. - The effect chains keep a 9-wide window. Temporal/spatial/post passes use the
fullscreen_effectlayout with a singlet1–t9range, because their inputs vary per pass instance within a frame, which a per-frame block cannot express.
Independence among RT features: shadows, AO, reflections, and GI select separately and may be mixed. They share the master switch, TLAS readiness, and material-hit tables needed for exact alpha continuation. The bindless texture namespace and shared material table also serve raster draws; only reflections/GI perform full hit re-light.
Geometry update policy: static_compacted is the default. dynamic_refit accepts revisioned in-place vertex updates; dynamic_rebuild accepts revisioned geometry that cannot refit. excluded geometry does not enter the TLAS. Skinned/deformed geometry must use one of the dynamic policies only when its AS vertex buffer contains the deformed positions; otherwise it must be excluded.
Skinned meshes fail closed through a per-primitive RT stream. Every RT-eligible skinned render primitive owns a unique GPU-writable 48 B rigid vertex stream (position3 + normal3 + color4 + uv2), even when several primitives share the same packed 64 B rest-pose geometry. After the current joint palette upload, a compute kernel deforms that source into the RT stream. Metal submits the writeback on the graphics queue before AS work under the shared frame fence; D3D12 uses the direct queue with UAV and non-pixel-resource transitions, so queue order makes the result visible to the following BLAS refit.
A successful submission stamps the output with the current deformation epoch. TLAS preparation admits a skinned primitive only when its output handle is live and that stamp exactly matches the scene epoch; otherwise it skips only that primitive and retries next frame. First allocation attaches the unique stream to residency and starts on the next consumed frame, after the draw revision publishes the handle. Ordinary animation advances the epoch and refits the dynamic BLAS without rebuilding the render queue. Allocation, pipeline, shader, input, or dispatch failure stamps nothing. Rebind, release, and scene unload retire the stream through normal deferred vertex-resource lifetime. Cull and raster-shadow bounds continue using the cooked expanded local AABB rather than a per-frame skinned-bound readback.
Independence from content compute: RT visibility shaders bind the acceleration structure and shared material-hit tables, but no content packages. RT reflections/GI additionally bind lighting inputs for full hit re-light. Current mesh_vertex_texture displacement is excluded from the TLAS because its output is a texture, not AS-compatible vertex positions.
Live changes: gameplay and Project Settings submit one sparse RenderPipelinePatch through GraphicsCommand.apply_render_config. SessionCore resolves it against the canonical RenderPipelineConfig, synchronizes runtime state once, and sends one coherent value through the render-thread mailbox and backend vtable. Backend-local diffing owns pipeline creation, temporal invalidation, display reconfiguration, sampler refresh, and async-compute queue selection. Adding a render setting therefore changes its canonical field and patch—not another per-setting command/thread/vtable chain. Async-compute policy applies at the next render-frame boundary and does not require a restart; content-compute producers recreate once when their execution queue changes, using the normal in-flight retirement path. GPU preference is captured before device creation and requires a process restart. The editor viewport inherits the composed game pipeline from render.json → pipeline; optional sparse overrides may live under editor.json → render.
Contract D — Global illumination (indirect diffuse)
Status: SSGI and screen-probed, hybrid radiance-cached 1-bounce RT GI live. Both produce the same reconstructed indirect_diffuse contract. SSGI raw resolution follows quality.screen_space; RT GI traces one sparse receiver per 8×8 render-pixel tile, then geometry-aware gathers that signal onto a raw grid capped at 921,600 pixels (1280×720 at 16:9), with the frame governor able to reduce the gather/denoise grid further. Unsafe interpolation is classified and compacted into a bounded exact-receiver queue; the queue traces at most one receiver per 32 receivers of the RT GI raw grid, at two rays each. Every receiver also publishes which tier answered it and how much probe support it had (rt_gi_classification), and the temporal pass reads that as a ceiling on accumulated confidence. Lighting and the long-history/output contracts are shared; RTGI adds its short history and variance-guided a-trous stages internally. The bounce capture is consumed by SSGI and retained in the RT graph only for mid-frame SSGI fallback.
Config: features.global_illumination (off / screen_space / ray_traced) and optional quality.gi (low…ultra, default medium). Helpers: withGlobalIllumination, withGiQuality, GiQualityBudget.fromQuality, usesGlobalIllumination / usesScreenSpaceGlobalIllumination / usesRayTracedGlobalIllumination, resolvedGlobalIlluminationMode. Availability: screen_space_global_illumination when the SSGI, bounce-align, bounce-mip, GI-temporal, GI-spatial and bounce-capture PSOs all exist; ray_traced_global_illumination additionally requires the radiance-cache update, short- and long-history temporal PSOs, and all three RTGI a-trous PSOs, plus the RT GI compute producer, screen-probe and surface-cache compute PSOs. Both GI modes produce through compute. The bounce capture remains part of the RT chain because mid-frame RT readiness loss falls back to the SSGI compute producer. RT GI also requires master RT + TLAS readiness (resolved mode falls back to SSGI, then off).
quality.gi budgets (render_config/gi.zig → GiQualityBudget):
| Tier | SSGI slices × steps per side | RT probe rays | max distance (m) SS / RT | RT secondary shadow budget |
|---|---|---|---|---|
| low | 1 × 6 | 2 | ~15 / ~25 | 0× (one reweighted shadowed-light floor) |
| medium | 2 × 8 | 4 | ~30 / ~50 | 0.35 of rt_hit max per probe/fallback hit |
| high | 3 × 10 | 6 | ~45 / ~75 | 0.75 |
| ultra | 4 × 12 | 8 | ~60 / ~100 | 1.0 |
SS range deliberately stays within the same order as RT: a ray that escapes contributes sky, which is what the IBL baseline already was, so a short SSGI range makes the lighting mix a no-op rather than a cheaper effect. Range is not the cost driver; the hierarchical Hi-Z march crosses empty space at coarse mips and the step cap bounds work.
RT probe rays are normally paid once per 64 render pixels. Rejected gather receivers do not trace inline: a ray-free classification pass preserves their best geometrically safe probe estimate, appends them to a compact queue up to the frame-wide quota, and publishes native indirect-dispatch arguments. The exact pass spends two blue-noise rays per admitted receiver; the existing short/long temporal filters accumulate those sparse updates. Two is a correctness floor: hikari_rt_gi_sample bounds a ray's positive residual against the other rays of the same pixel, and with one ray that leave-one-out reference is empty. The receiver's reactive bit rides the queue entry's high bit, because the trace has no probe neighbourhood to rediscover it from.
Overflow retains the safe probe estimate (or invalid zero support), and admission is a deterministic per-receiver hash tested against a threshold the reset pass derives from a previous frame’s rejected count, clamped to the receiver population so an uninitialised ring slot cannot produce a wild threshold. The admitted set is scattered across the whole image, rotates with the frame phase, and is reproducible for a frozen camera. Exact fallback work is therefore bounded by resolution policy rather than rejected coverage or the authored per-probe ray tier.
The gather declares a read and a write on the fallback counter and argument buffers, because it read-modify-writes both. Graph liveness runs backward and a write kills a value, so a write-only declaration would cull the reset pass out of the schedule.
Estimator authority. rt_gi_classification carries tier plus probe support (normalized against a full 3×3 neighbourhood). The cascade samples it directly through effect_spatial.estimator, and the temporal pass turns it into an authority in [0, 1] that is a ceiling on accumulated confidence, never a multiplier on the history blend: confidence answers "how much history do I have", authority answers "how much can this estimate ever be trusted", and a receiver interpolated from one distant probe is smooth, stable, and biased, so variance alone would never filter it. One module, three readers: producer, temporal pass, debug visualizer.
SSGI's slice and step counts travel in the producer's own pass params (SsgiParams), resolved once at assembly into LightingTargets.ssgi_slices/steps; gi.zig mirrors the shader's loop bounds (ssgi_slices_max 4, ssgi_steps_max 16) and a test holds every tier inside them, because the shader clamps rather than errors. Uniform globalIlluminationParams.y carries the runtime path's sample count (RT probe rays, or SSGI slices) for shaders that branch on the mode. Host writes runtime-path max distance (SS vs RT, matching the driver fallback when RT hit tables are not ready) into uniforms each frame.
Orthogonal combinability: GI mode is independent of shadow mode (raster deferred vs RT shadows), AO, and reflections. Valid mixes include raster shadows + SSGI, RT shadows + SSGI, raster shadows + RT GI, RT shadows + RT GI.
Graph order: independent AO/GI raw producers first → their temporal/spatial reconstruction → deferred/RT lighting → bounce capture → reflections. Screen-space AO and GI use the async-compute queue when available; RT producers stay on graphics. Any non-off GI builds Hi-Z: SSGI uses it as its visibility estimator, while RT GI uses it as the cheap first tier before triangle traversal and also needs it for mid-frame SSGI fallback. Screen-space AO shares that same hierarchy rather than building or sampling a separate depth structure. Under two-phase occlusion culling, any screen-space reader causes the pyramid to be rebuilt over completed depth rather than left holding the predict-phase subset. RT GI additionally needs TLAS + bindless hit tables. Pass names: rt_gi_radiance_cache_update → rt_gi_fallback_reset → surface_cache_prepare → rt_gi_screen_probes → ray_traced_global_illumination (gather/classify/compact) → rt_gi_fallback_trace (indirect) → surface_cache_update (requested cells); then reconstruction and gi_bounce_capture. That ordering is asserted by a graph test (the fake backend returns real fallback buffers so usesRayTraced is true there). The probe radiance/guide pair is transient and uses render_tiles(8, 8), so odd dimensions and governed render sub-rects retain a covering right/bottom tile. The fallback queue/counter/argument buffers are prepare-partition ring resources, so concurrent product/editor surfaces neither overwrite one another nor require a GPU drain.
Bounce capture: GI runs before lighting — that ordering is what keeps its cost independent of light count — so the only lit surface SSGI can reuse is the previous frame's. gi_bounce_capture writes a half-res RGBA16F secondary-radiance pair (gi_bounce_0/1, ping-ponged on frame parity like the temporal histories) after lighting and before reflections and transparents. Each texel selects one nearest exact G-buffer owner rather than averaging materials across a 2×2 edge. RGB analytically removes height fog, replaces the current GI-mixed diffuse IBL with the non-recursive environment baseline, and subtracts sky/probe environment specular. Opaque lighting temporarily publishes admitted diffuse response divided by full direct response in scene alpha; the numerator contains only affect_indirect lights. The capture applies that admission only to the recovered direct residual, preserving environment and emissive radiance while preventing artist fills and camera-view direct highlights from being bounced as diffuse light. This alpha hand-off avoids a full-resolution HDR secondary-radiance MRT and is consumed before reflections and transparents. The persistent bounce alpha stores owner linear depth. globalIlluminationBounceValid (uniform float at offset 344, in the alignment gap ahead of globalIlluminationParams) is 0 until a capture has actually run. SSGI reads the cleaned RGB and falls back to its sky-lit proxy while the capture is invalid. RTGI does not sample the capture: its current-screen and triangle tiers both re-evaluate the same diffuse-only secondary transport, preventing previous camera-view punctual specular from entering GI when the tier boundary moves.
Technique (SSGI): visibility-bitmask slice integration (ssgi_bitmask.akari, producer screen_space_global_illumination_cs), fed by an aligned bounce (gi_bounce_align.akari).
- Aligned bounce.
gi_bounce_alignreprojects last frame's cleaned capture (gi_bounce_{0,1}) onto this frame's half-res grid once per texel — motion at the texel's exact G-buffer owner, the pre-TAA jitter delta, and a depth test against the capture's stored owner depth so a disocclusion cannot borrow the surface behind it. The output is irradiance-unitπL, already firefly-clamped with the ceiling its source deserves (captured bounce ~32× local env, sky-lit albedo proxy ~6×), alpha 1 on a surface and 0 on sky.gi_bounce_mipsthen builds four reduction levels (surface-weighted, one compute pass with barriers, the depth pyramid's shape). The march reads radiance with a plainsample_level, and a coarse step reads its footprint's mean instead of one texel. - Slices. Per raw pixel,
slicesdirections through the view vector (per-pixel IGN phase, rotated per frame), each marched both ways withstepsexponentially spaced samples from 1.5 Hi-Z texels out to the projectedparams.zrange (capped at 35% of the picture height). Depth comes from the Hi-Z min at the step's footprint mip, so the pyramid and the bounce chain share one mip index. - Bitmask. Each sample's front face and a view-space thickness behind it (
0.12 m + 0.12 × distance) span an arc of the slice's normal hemisphere; the arc maps to a run of bits in a 32-bit mask. Bits set for the first time are hemisphere the receiver sees that surface over — the sample's radiance replaces the far field on exactly that cosine-weighted arc (GTAO's∫ cos(θ−n)|sin θ|in closed form, scaled by the fresh-bit fraction). Bits already set belong to a nearer surface. Every visited sample contributes; thickness is per sample, so light passes behind thin occluders and a railing occludes only itself. - Far field and control variate.
E = E_far(N) + mean over slices of |n_proj| × Σ w(arc) × (πL_sample − πL_far(dir)). An uncovered arc cancels exactly and returns the far field it was already lit by. The far field is the sky. An interior lit by a sky it cannot see is left to the RT tier, whose radiance clipmap carries world-space light; the screen-space tier has no source beyond the picture. A sample whose surface faces away from the receiver occludes but contributes no radiance; a footprint that was partly sky (mip alpha < 1) fills that share from the far field. - Unknown arcs. A side that leaves the picture charges the unset bits on its side of the normal, scaled by the march it had left, as unknown: those arcs take the material-cavity prior (0.75–0.90 ×
E_far) and are subtracted from confidence, so lighting leans toward the deterministic far field exactly where the screen could not answer. - Temporal filter (shared geometry-rejected history path; signal format rgba16 like reflections) → three a-trous stages at strides 1, 2 and 4 → confidence-aware spatial bilateral upsample to full-res
indirect_diffuse(neighbor weight × signal alpha so low-conf texels do not smear into high-conf). SSGI's alpha is estimator confidence, not a second moment — lighting reads it as the mix weight against the far field — sohikari_ssgi_atrous_filtermeasures luminance variance from its own axis cross each stage, the same trade the AO stage makes, and carries confidence on the same weights as the radiance it describes. That cascade is what lets SSGI share the fused kernel with AO: both chains pin signal, history and cascade to half resolution regardless of trace tier, soao_gi_atrous_*runs at everyquality.screen_spacesetting and the separatessgi_atrous_*stages exist only for the frames AO is off or the fused PSO is missing.AtrousFusedParams.gi_ray_tracedselects the stage and follows the runtime producer, because a frame whose RT readiness lapsed ran the screen-space producer into that signal and its alpha then means something else. - rgb = diffuse irradiance (E) (drop-in for
hikari_skybox_env_diffuse); a = confidence ∈ [0,1] — never folded into rgb. Pure metals (metallic > 0.95) early-out with confidence 0. - Cost.
slices × steps × 2samples per raw pixel (medium: 32), each one Hi-Z fetch, one bounce fetch, one normal fetch and one sky/probe fetch, and no per-sample reprojection. - Graph.
gi_bounce_align → gi_bounce_mips → screen_space_global_illumination, all on the screen-space (async compute) queue. Both align passes are assembled in RT graphs too, because a mid-frame RT readiness lapse runs the SSGI producer; under RT GI their drivers execute only the graph transitions. The SSGI kernel binds the probe records and the bindless texture table (D3D12: the geometry-catalog root family) and needscountbits, which is why that builtin exists in akari.
Technique (RT GI):
-
Primary surfaces never read the world radiance cache directly. A sparse screen-probe grid selects a centre-biased diffuse receiver for each 8×8 tile and traces 2 / 4 / 6 / 8 blue-noise-rotated cosine samples for low / medium / high / ultra. A valid central receiver avoids a tile scan; a sky/metal centre searches for the nearest valid diffuse fallback. Converged probes may fall toward the two-ray floor, but that decision reconstructs the same geometry-tested bilinear guide footprint as the temporal consumer and scales confidence by accepted coverage. A fast-camera disocclusion therefore keeps the authored ray count on the frame that rejects history instead of exposing a prematurely reduced probe. Each direction first performs a bounded Hi-Z screen trace. A current-screen hit reads current G-buffer material data and uses the same diffuse-only secondary-hit shader as hardware-traced geometry; Hi-Z therefore saves the primary triangle traversal without changing the estimator. Probe results store radiance plus a finite-difference geometric normal, linear depth, and exact sub-tile coordinate. The geometric guide—not normal-map microdetail—owns surface-plane rejection; shading-normal agreement remains a soft interpolation weight.
-
rt_gi_radiance_cache_updatevisits the whole small atlas but updates only one quarter of its lobes per frame: 9,216 lobe updates / 36,864 fixed primary rays, independent of viewport size. Each scheduled lobe traces a four-ray equal-area stratified batch, and every punctual light evaluated at one of those farther hits is shadowed — the cache is a converged indirect diffuse irradiance estimate, so an unshadowed lamp baked into a lobe is a wrong value rather than noise that averages out. Cache hit shading is diffuse-only; secondary specular remains the reflection path's responsibility and cannot enter a world-space irradiance cell. Before temporal publication, a per-channel leave-one-out positive-residual bound requires a hot path to be supported by another stratum (or by the local environment scale). Broad bright transport is therefore retained, while one tiny emissive hit cannot contaminate a spatially reused lobe. Negative residual remains signed sky occlusion and is not firefly-clamped; the render look may soften its final contribution independently of positive bounce energy. Bounded punctual-light selection rotates across updates so scenes with more than eight lights remain unbiased; batching and receiver interpolation prevent that rotation from pulsing a whole cell. Published stable lobes retain 95% history, while a batch outside the deliberately broad stability envelope retains 85% so moving-sun and day/night illumination cannot leave neighboring cells at visibly different moments of the transition. -
The secondary-hit cache is a camera-centred, world-addressed three-level clipmap with 1.5 / 3 / 6 m cells. Every probe owns six axis-aligned cosine lobes in a 96×384 RGBA16F atlas; persistent atlases ping-pong per surface. Its value occupies only the secondary hit's indirect/environment slot: analytic direct light and emissive radiance are evaluated at the actual hit on both cache acceptance and rejection. A volume probe therefore cannot replace local surface lighting or make a nearby lamp blink at a cache boundary. Lookup deterministically trilinear-reconstructs same-side probes, visibility-tests the highest-energy corner that actually contributed (not a skipped young corner), and continuously blends overlapping levels. Insufficient same-side support, an unpublished lobe, occlusion, or out-of-range lookup rejects cache use and selects environment irradiance as the indirect prior; it does not change the direct/emissive estimator. New lobes require four stable batches before publication; published matching world cells remain valid and adapt with bounded 85–95% history.
-
The gather maps every raw receiver to the surrounding 3×3 probes. A candidate contributes only when its normal agrees and both surfaces satisfy linear-depth and bilateral world-plane tolerances; spatial, normal, and plane weights form the interpolation weight. Below the support threshold, the gather writes its normalized safe support when any exists, then compact-appends the receiver under the fixed frame quota. A GPU-authored indirect pass overwrites admitted receivers with two exact blue-noise samples using instance mask Gi = 8 and
TMax = params.z; temporal reconstruction supplies the sample count rather than widening a worst-case frame. Overflow never relaxes the surface tests and therefore cannot turn a performance limit into cross-surface light leakage. Diffuse GI deliberately excludes secondary specular; the reflection path owns that transport. Secondary-hit lighting honors each light's surface-shadow flag. Lights with shadows disabled retain their contribution without a visibility ray; shadow-enabled lights retain exact visibility.Secondary diffuse transport first evaluates the material's diffuse reflectance (
base_color * (1 - metallic)). An exactly zero reflectance skips direct-light walks, shadow rays and indirect/sky lookup in both screen/triangle hit shading and world radiance-cache updates. This is lobe admission, with no brightness threshold: partially metallic surfaces and arbitrarily dark nonzero colours retain their existing transport. Emission, hit distance, motion validity and the signed environment-occlusion residual remain present. The surface-cache producer stores incident irradiance without albedo, so it retains its material-independent publication contract. -
World surface cache. Cache-eligible secondary hits read a 512×512 spatial hash of shadowed direct plus clipmap indirect irradiance. The hit always applies its own diffuse reflectance and emission. A miss evaluates the same transport immediately. The key uses 25 cm finest cells, distance-scaled levels, six normal buckets and four probes; the cache occupies 8 MiB per surface including its age plane.
Demand-driven publication.
surface_cache_prepareresets an 8,192-bucket request table before tracing. Both the screen-probe and exact-receiver fallback paths request only hits that pass the exact-zero diffuse, distance and material gates. Hits and misses both request refreshes, so useful entries continue tracking lighting. One atomic claim owns each 64-byte payload; duplicate cells coalesce, and unrelated hash collisions drop a request without changing that hit's immediate shading. The bucket hash rotates each frame to redistribute collisions. Claims and payloads occupy 544 KiB per prepare slot, are reset per surface encode, and never carry hit geometry into a later frame. There is no CPU readback or scene/material heuristic in admission.surface_cache_updateruns after both tracing passes, with graph barriers on the request buffers and cache texture. It scans the bounded request table and shades occupied buckets with the existing eight-light producer budget. It fires no speculative camera/bounce rays and publishes nothing without real demand. An empty request table incurs only the fixed preparation/scan dispatches. The update is retained as a future-frame history side effect even without a current-frame reader. Requests become useful after three successful refreshes; while bootstrapping or after a dropped request, consumers retain the direct miss path. Unconverged clipmap results are never published as a frozen sky fallback.Freshness and ownership. Geometry motion expires unvisited entries after eight render frames instead of clearing the table. Revisiting an expired entry starts bootstrap without blending stale irradiance; fresh entries blend with 0.8 history weight. Lights, sky, materials and opaque-structure revisions sweep both texture planes before consumers run. Exact 24-bit frame stamps plus one reserve zero for empty; timestamp-era changes also sweep. Aborted frames and released/replaced surface resources invalidate CPU sweep receipts. Request reset remains independent of profiler sampling and lighting revisions.
Key, refresh count and irradiance remain in one RGBA32F texel (
.rpackskey << 2 | refreshes,.gbais irradiance). The age plane stores one shared dispatch stamp. The producer rechecks the stamp and resident key before blending; readers never overlap these stores. Integer decoding usesround, avoiding+ 0.5rounding errors near 2²⁴.Trace diagnostics. The
scobject now includes secondary-hit outcomes from both probes and fallback tracing; older captures counted probes alone.missincludes six mutually exclusive reasons:zero_diffuse,bypass_distance,bypass_material,lookup_no_key,lookup_bootstrap, andlookup_expired. Their sum equalsmiss; actual lookup attempts arehit + lookup_no_key + lookup_bootstrap + lookup_expired.screen_rays,screen_hits,hardware_rays, andhardware_hitsdistinguish accepted screen hits from hardware traversal and hit-shading populations; these are operation counts, not separate traversal/shading milliseconds.Eligible demand partitions into
request_accepted + request_duplicate + request_collision. Accepted requests partition intorequest_clip_rejected + store_skipped + store_written;store_readyis the subset of writes reaching bootstrap.producer_clearcounts sweeps. Diagnostic atomics are sampling-gated. Each receiver accumulates its own counts; a wave reduces every field over the same active lanes, then one elected lane publishes the complete set. Keep reductions outside the elected-writer branch, including zero-valued fields and miss reasons. Ownership atomics always run. Readbacks arrive one or two prepare-ring turns late and need not match adjacent timing samples. Compare steady distributions and the separately timedsurface_cache_prepare/surface_cache_updatepasses. GPU request ownership and bounded writes have a headless regression insrc/hikari/tools/surface_cache_request_test.swift.Counter integrity.
sample_inconsistentcounts receiver results that violate eitherscreen_rays = screen_hits + hardware_rays,hit + miss = screen_hits + hardware_hits, orhardware_hits <= hardware_raysbefore aggregation. After the probe/fallback barriers, one producer lane repeats these checks on the GPU totals, incrementstrace_checked, and incrementstrace_inconsistenton failure. The check runs even with empty demand. A complete sampled trace should havetrace_checked = 1and both error counts zero; the raw counters are never repaired or suppressed. Local failures locate the problem before aggregation; aggregate-only failures point to publication/reset ordering or storage. If both GPU checks pass but recorded totals disagree, investigate readback/serialization. Missing check fields in older captures provide no such localization.tools/gi_trace_counters_test.swiftexercises emitted helpers through Metal 4 command buffers, argument tables and explicit fences. It covers GPU resets between reused frames, two producer dispatches, 8×8 and 64×1 groups, partially/fully inactive waves, empty demand, profiler-off execution, and deliberately corrupt local/aggregate inputs. -
Energy / units: probe results, cache lobes, fallback rays, and
hikari_skybox_env_diffusealways store diffuse irradiance (E). Cosine sampling gives (E = (\pi/N)\sum L_i); the last-mip cubemap/solid fallback is promoted from its legacy (E/\pi) approximation byπ, and lighting always applies the Lambert1/π, so non-SH output is algebraically unchanged. The environment control variate uses exact (E(N)) as its deterministic baseline and (\pi L_\mathrm{sky}(\omega)) as each directional reference, whose cosine-sampled expectation is (E(N)); an unobstructed ray therefore cancels exactly without substituting a differently convolved diffuse lobe. Passing either cached or environment irradiance through a secondary diffuse hit multiplies by that material's diffuse factor without another1/π: the secondary Lambert factor and the receiver's cosine-final-gatherπcancel. Shadowed direct diffuse and emissive are already expressed asπLoand are invariant across the cache decision. Firefly rejection operates per channel on the leave-one-out positive geometry residual. Negative residual is sky occlusion; it bypasses that gate but is multiplied bylook.rt_gi_occlusion_strength(default 0.85, physical 1.0) at the final per-pixel gather. Raw alpha is signed mean final-gather hit distance: its non-zero magnitude marks a valid receiver, a negative sign means a contributing secondary path touched moving geometry, and sky/metal receivers write 0. Probe interpolation preserves the validity/reactive contract but never invents validity from a rejected surface. -
Probe-level temporal accumulation. A probe reads its own previous estimate through the probe's reprojected receiver and blends at a 0.75 ceiling (0.4 when the path touched moving geometry), ramping in over four frames.
The probe lattice is anchored to the screen and surfaces to the world, so while the camera moves a world point is covered by a different set of probes every frame; accumulating only at the receiver, after interpolation, cannot see that re-association and reads as GI noise during movement. Accumulating at the probe makes the estimate follow the world. The ray-count taper is suspended alongside it: a motion term holds the probe at four rays above one render pixel of screen motion, so a fast pan roughly doubles probe cost. Two EMAs in series add about a tenth more latency.
-
The gathered raw signal first updates an RTGI-owned short radiance/luminance-moment history, then enters the stable temporal + three-stage a-trous + full-resolution bilateral resolve (
rt_gi_fast_temporal→gi_temporal→rt_gi_atrous_{1,2,4}→gi_bilateral). Both short and long history alpha channels storeE[L²], with-1as the invalid sentinel. During TLAS warm-up or mid-frame readiness loss, the short pair remains initialized from the SSGI fallback: SSGI alpha supplies producer validity only, and the fast pass writes the same radiance/moment contract while the main temporal driver stays on its SSGI filter.
Secondary-light participation: LightComponent.affect_indirect is independent of shadow casting. It defaults on for physical emitters. Artist-authored fill and fake-bounce lights set it off so SSGI bounce capture, RTGI, reflection-hit shading and traced transmission do not count already-indirect energy again. The frame partitions participating bounded lights into a dense prefix after globals; the RT secondary estimator samples only that prefix, so excluded fills consume neither a light stratum nor a visibility ray. Primary raster and RT direct lighting still evaluate the light normally. SSGI receives the same policy through the opaque-lighting alpha admission described above.
Temporal accumulation: GI runs its own global_illumination_temporal package rather than sharing temporal_effect with reflections, whose ~1.25σ history drop and 0.02-screen-motion invalidation are wrong for a stochastic indirect signal. SSGI uses its current neighbourhood to bound new history. RTGI does not clamp geometry-valid long history to that bound: pixels inside one 8×8 probe footprint share a deliberately correlated current sample, so their local variance can be near zero while the probe still rotates stochastically; clamping to it would replace accumulated history with fresh grain every frame. Camera speed likewise does not shorten geometry-valid RTGI history: moving the view rotates probe directions and changes tile representatives, which is sampler noise rather than evidence of a lighting change. Primary-surface disocclusion and locally reactive secondary paths are the release authorities. Raw RTGI alpha carries signed final-gather hit distance (zero invalid, negative reactive). rt_gi_fast_temporal maintains a short radiance EMA (β = 0.80, roughly a three-frame half-life) and its luminance second moment; the long history (β = 0.97 when converged) stores the same pair. Their change detector compares the two means against the conservative standard error sqrt(Vfast·(1−βfast)/(1+βfast) + Vlong·(1−βlong)/(1+βlong)), with a two-standard-error dead band. Positive covariance between the histories is deliberately omitted, so shared noise cannot make the detector more eager. After the long temporal resolve, signal alpha remains luminance second moment E[L²]; -1 is the invalid sentinel, and the geometry guide stores explicit signed confidence. Geometry rejection reconstructs world position from current depth, transforms with prevViewMatrix, and compares against expected previous-view depth rather than comparing depths from different camera spaces. RTGI history reprojection gathers four taps and validates every signal/guide pair before renormalization, so bilinear filtering cannot turn a mixed valid/invalid footprint into a bright accepted history. Current neighbourhood taps are clamped to the governed active sub-rect rather than sampling stale allocation margins. RTGI then runs three half-resolution a-trous stages at strides 1, 2, and 4, followed by a geometry-aware 2×2 full-resolution resolve. The signed temporal confidence accompanies the moment history into every spatial stage: a disoccluded or reactive receiver cannot report zero variance and masquerade as converged, while full-confidence low-variance receivers keep the center-only early-out. Depth, material, luminance variance, and broad shading-normal weights preserve indirect structure without crossing primary-surface boundaries.
Dynamic secondary geometry: Receiver depth/normal reprojection cannot detect a rotating occluder above a stationary floor. RT instance rows therefore mark changed rigid transforms and deforming geometry; screen-visible bounce reuse independently removes camera motion from the hit's G-buffer velocity. A probe sample that interacts with either publishes a local reactive bit. Temporal reconstruction dilates that evidence only across compatible receiver taps and shortens history there. On the next frame, the probe producer reprojects and geometry-validates the guide before expanding a low-sample reactive probe to a bounded four rays. Classification comes from previous-frame metadata rather than from the samples currently being averaged, so the estimator remains unbiased and motion elsewhere never raises the whole GI grid's cost.
Lighting mix (inside hikari_eval_ibl):
// GI off or confidence ≈ 0: bit-identical to pure IBL diffuse
env_diffuse = mix(ibl_env_diffuse, gi.rgb, gi.a) // irradiance E; GI active only
// then existing lambert * env_diffuse * inv_pi
// ambient diffuse AO fades from (baked x AO pass) toward baked-only as GI
// confidence rises: the AO pass answers the question the GI rays just traced,
// so applying both darkens twice. Baked material AO always stays -- it is
// detail finer than a half-res GI trace resolves.
// Specular keeps full AO; specular IBL / reflections otherwise unchangedLighting also skips sampling the GI texture on near-pure metals (metallic > 0.95). Firefly control is preferred in producers (SSGI/RT GI); the IBL mix does not add a second hard clamp that would kill legitimate bright bounces.
Uniforms: float globalIlluminationBounceValid at offset 344 (occupies the alignment gap, so it costs nothing), then float4 globalIlluminationParams at 352:
| Component | Meaning |
|---|---|
.x | active (0/1) — lighting gate |
.y | active backend sample count: quality-tier SSGI rays, or quality-tier rays per RT screen probe |
.z | max distance (metres) for the active backend |
.w | RTGI negative-residual/sky-occlusion strength from look.rt_gi_occlusion_strength (0–1; default 0.85; SSGI ignores) |
When GI is off the lighting pass binds a black 1×1 (confidence 0) and .x is 0 so the texture is never sampled. The same holds for the forward/transparent pass, which samples indirect_diffuse at its own screen position — without that, glass and foliage would keep pure IBL ambient while everything opaque behind them shifted. Host packing: ShaderUniformsData.updateGlobalIlluminationParams.
Secondary-hit resolve order. A final-gather ray's hit is answered by the cheapest converged source that can answer it, and only shaded stochastically as a bootstrap:
- Hi-Z visible bounce — current-screen geometry reprojected into the previous cleaned secondary-radiance capture under strict depth/world/material gates. A failed or ambiguous match continues with triangle RT along the same direction.
- Triangle-hit visible bounce — the hardware hit reprojected under those same strict gates, covering cases the bounded screen march did not resolve.
- Exact local hit terms — every triangle hit evaluates the same shadowed analytic diffuse and emissive response at the real surface. The sparse path uses the resolved RT-hit light width; it does not divide that width across final-gather rays and nest a second high-variance estimator.
- Indirect irradiance selection — a rough, non-metallic hit at least one
cache cell (
HIKARI_RC_BASE_CELL_SIZE) away may replace its environment prior with the world-cache estimate. A screen probe or exact fallback always traces its own final-gather direction; only that ray's secondary hit reads the cache. Primary interpolation is governed by the separate depth/normal/plane- tested screen-probe grid, never by cache-cell boundaries. Cache rejection changes only this indirect term, not local direct or emissive lighting.
This is the invariant the whole path rests on: a punctual light is never added
to a secondary hit unless its visibility was paid for. Sampling fewer lights
and reweighting by bounded_count / bounded_samples is unbiased; lighting a hit
through its occluder is a bias no denoiser can remove, and in a closed interior —
where every gather ray lands on a wall rather than escaping to sky — it was the
dominant error.
Cost: the scalable final gather is one 8×8 screen probe, not one trace per raw
receiver. At 1920×1080 that is 32,400 probes; medium/high request 129,600/194,400
final-gather samples before cheap Hi-Z resolutions. Geometry-rejected
raw receivers add the resolved tier grant only at unsupported silhouettes, thin
geometry, or mixed-surface tiles. Every triangle hit evaluates the small
directional/ambient prefix and the resolved punctual RT-hit width (up to eight
shadowed lights). That local term is deliberately not delegated to the volume
cache: screen-probe amortization bounds its receiver cost, while preserving
surface-local visibility prevents cache cells from turning lamps on through
walls. The fixed-size cache update carries the additional indirect bounce and
can afford to shadow every farther-hit light it evaluates.
Reflections retain the wider per-hit ceiling because their resolved specular path
is not the diffuse GI estimator. Prefer @branch early-outs (sky, metal, env off).
Reconstruction: RT GI runs its authored half-resolution SVGF-style a-trous cascade at
steps 1, 2, and 4, then a cheap geometry-aware 2×2 full-resolution resolve. The
RGB mean and luminance second moment flow through the cascade; the responsive
temporal guide supplies a signed history-confidence value to each stage. Each stage
propagates estimator variance, skips full-confidence converged receivers, and uses
depth, normal, roughness/metallic, and variance-scaled luminance stops. This
gives newly revealed receivers and indirect-contact regions enough spatial evidence
without crossing silhouettes or globally multiplying rays. RTAO and SSAO share
one cascade over one contract, followed by a conservative coverage-aware
resolve that does not transfer AO between half-resolution surface owners. SSGI
keeps the plain hikari_bilateral_upsample.
RT GI with master RT on warms the TLAS/hit-shading prepare path from the requested config (same request-driven model as RT reflections); graph-effective mode falls back to SSGI then off until RT PSOs exist.
Contract E — Content async compute
Owners: graphics/effects/content_compute.zig (producer set, lag policy, ring helpers); materials declare intent; backends run ringed sims; graph declares one pass per producer.
Material declaration (JSON / MaterialDesc):
"binding_layout": "mesh_vertex_texture",
"async_compute": {
"package": "water_sim",
"entry": "water_sim",
"resolution": 512,
"threads": 8,
"format": "rg16f",
"lag_frames": 0
}Optional "format": "r32f" (default), "rg16f", or "rgba16f". Optional "lag_frames": only 0 or 1 (default 0; missing = 0; other values are rejected). Channel meaning is content-defined. SMA2: format id in the reserved header byte; FLAG_ASYNC_LAG1 for lag 1.
Identity vs lag:
- Producer key = package + entry + resolution + threads + format (deduped on the frame).
- Lag is per-material consumption policy and is not part of the producer key. Two materials may share one producer with different lag needs. Each lag retains an opaque/transparent consumer mask, so sharing never moves a queue wait earlier than the first pass that actually samples that version.
Frame path:
- Visible materials with
async_computepublish into a fixedRequestSet(cap 8; overflow logs + debug assert; rejected draws bind a fallback texture). - Prepare ensures GPU resources (ring textures, PSOs, frame CBs, dynamic-input buffers) for each producer; prefers async compute queue, falls back to graphics.
- Graph assembly adds one pass per producer (queue = that entry’s actual queue,
never_cull), imports the write and valid-history slots once, and attaches each sampled version only to its opaque or transparent consumer pass. A transparent-only producer can overlap opaque G-buffer work. - Pass drivers dispatch into ring slot N; lag-1 consumers read slot N−1 when that slot’s
produced_frame == frame−1, otherwise bootstrap to lag-0 (same-frame sample). - Raster encode writes the selected current result and the prior produced frame into the bindless texture heap and stores those indices on
GpuMaterialRecord.vertexTextureIndex/previousVertexTextureIndex. Both are graph-declared reads at the material's consumer phase; deforming vertex shaders sample the pair throughresources.texture_tableto emit deformation-correct motion vectors.
Producer history: a producer may declare
@bind(content_compute.history) history: Tex2D. It receives the previous ring
output (Metal texture 1 / D3D12 SRV t1), while frame.historyValid is one only
when that texture was produced on frame N-1. frame.deltaTime is elapsed
simulation time between those outputs, not wall time; it remains zero while
paused and a clock reset invalidates history. frame.frameIndex supports
stochastic sequences. The engine assigns no meaning to history channels or
lifetime: feedback, advection, decay, and rejection remain producer policy.
Dynamic producer inputs: games may replace up to 64 fixed-size
ContentComputeInput records per producer with
hi.render().setContentComputeInputs(package, entry, records). Publication
copies only records for visible producers into the render-frame triple buffer;
each backend performs one contiguous upload and exposes the records at
HK_BUFFER(content_compute,inputs). frame.inputCount is the only valid range.
The three float4 payloads are intentionally shader-defined, keeping hull
footprints, terrain stamps, and other simulations out of engine policy. The
water sample workgroup-culls those records before evaluating contact foam, so
per-pixel cost follows local overlap rather than the scene-wide body count.
Its contact source is a narrow, texel-feathered world-space waterline driven by
normal impact energy; wake energy is carried separately. Bodies wholly below
the sampled free surface do not publish an interaction. Result alpha feeds back
through generic producer history, is bilinearly backtraced by analytic surface
velocity, and decays in simulation time. This preserves lace and wake trails
without widening crate footprints into filled blobs. The water surface samples
foam per fragment and footprint-fades distant detail.
Sync authority:
- Graph owns resource hazards, layout barriers, and cross-queue release/acquire edges for textures, buffers, and acceleration structures. Exclusive accesses form an ordered chain: reads wait for writes, and writes wait for both earlier readers and writers. Texture ownership is per mip/layer, so disjoint subresources remain independent. Timeline tokens are keyed by native resource identity and producer queue.
- Same-queue producer/consumer pairs emit no sync edges. Backends without a usable async compute queue run compute-domain passes on the graphics queue; the compiler then emits no cross-queue waits/signals for those edges.
- D3D12: compute lists cannot transition out of graphics-only states.
compile.zig::compileAccesspublishesCOMMONon the last owning pass before exclusive texture/buffer handoffs. A final shader-read phase may instead share ownership: when a graphics pass can publishshader_readand no later access or terminal transition changes it, graphics and compute readers consume that publication concurrently. The publisher owns synchronization; readers do not overwrite each other’s fences. Final transitions attach to the last owning pass of each subresource. - D3D12: the graph tracks one layout per subresource, and a 3D texture has one per mip —
GraphTextureDesc.subresourceLayers()returns 1 for.d3so barrier tracking never walkszas array slices (validation error 527). Terminal states pinned withsetTextureTerminalStateare checked in debug against next frame's first writer: pinningshader_readon a resource a compute pass writes first is the compute-demote bug, andcompile.zig::debugValidateTerminalStatespanics on it rather than letting it become an intermittent device removal. - Lag-0 content-compute consumers acquire the current write production; lag-1 acquires the previous frame’s production (via
markCrossQueueProducerwhen there is no in-frame writer). Acquires attach to the recorded raster phase, so transparent-only work does not stall G-buffer geometry. Lag-1 skips waiting on this frame’s dispatch, not sync entirely. Do not also wait in prepare for those edges. - Prepare
retainOnlys the liveRequestSet; unused producer GPU resources are retired and freed afterframes_in_flightso in-flight compute writes and G-buffer samples stay valid.
Independence:
- Engine has no water policy. It transports opaque producer inputs, a neutral
output/historytexture pair, simulation timing, and raster-consumer metadata. Sample game packageswater+water_simandscenes/water_compute.jsoninterpret those facilities as displacement, foam, and buoyant-body footprints. - Compute shaders must not reference G-buffer, lights, or RT. Surface shaders using the result only sample the produced texture.
- Works under deferred or RT lighting unchanged (both read the G-buffer after displacement).
Profiler: CPU zones content_compute (+ package name, ensure/dispatch). Encode/submit wall time, not GPU sim duration.
Allowed cross-edges only
| From → To | Allowed interface |
|---|---|
| Content compute → raster consumer | Optional current/history textures at the material vertex-texture bindings; consumer phase is request metadata |
| Geometry → lighting / AO / reflections | G-buffer attachments + camera uniforms |
| Config → graph | RenderPipelineConfig after resolvedForAvailability |
| RT prepare → runtime UI | ray_tracing.scene_ready / effective modes in RuntimeInfo |
Backend execution binds the pass-specific handles recorded in FrameTargets; only final consumers such as tonemapping read scene_color.
Anything else (water checking RT flags, RT passes naming content packages, deferred requiring async compute) is a contract violation.
Mesh LOD
Shinra generates LOD chains at cook (quadric edge-collapse, cook/model/simplify.rs). model.generate_lods is on by default; when on, cook builds up to three extra levels for rigid meshes ≥ 8192 triangles, each halving the triangle budget. There are no per-asset level or triangle-floor knobs. Collapses land on existing vertices (attributes stay authored), seam and boundary vertices are locked, and a normal-flip test rejects fold-overs. Skinned models stay LOD0-only.
At runtime Hikari preserves Shinra's welded vertices and meshlet topology. It concatenates each LOD's vertex and meshlet slices, keeping the Morton triangle order Shinra cooked into each level; a raster LOD switch changes which meshlet run is emitted, never uploads geometry again. A derived index stream remains only for BLAS and physics.
Selection is GPU-side. meshlet_lod (shaders/akari/packages/meshlet_lod.akari) runs one lane per object and keeps a per-object MeshletLodState { level, from, t }. It picks the level from bounding-sphere screen coverage (LOD0 while the sphere spans ≥ 40% of the half screen height, one level per further halving) with ±0.1 hysteresis, applies the per-primitive lod_bias and force_lod overrides (inspector + scene JSON render.lod_bias / render.force_lod, or render().applyRender at runtime), and multiplies coverage by the global world.mesh_lod_distance_scale (world().setMeshLodDistanceScale) — coverage goes as 1/depth, so multiplying it is multiplying the switch distance. The host only publishes the tuning (LodTuning, world_render_publish/lod.zig) and per-frame MeshletLodParams; lod_settings_dirty forces one publish when a setting changes so a parked camera sees it. Cull bounds stay LOD0's so a shrunken silhouette cannot shrink its cull proxy.
RT follows a CPU pick. Ray tracing does not inherit the raster span. Publish selects the BLAS level per primitive on the CPU, holds automatic changes while the camera moves (lod_camera_motion_active, which also defeats the idle static skip exactly once so the first stationary publish commits the final picks), and biases alpha-masked geometry one level coarser (or the cooked rt_lod_bias, whichever is larger — rtProxyBias) because a coarser opaque BLAS leaks light at every seam while coarser foliage merely has fewer leaves. The selected range is partitioned into spatial BLAS chunks.
Cross-fade
A switch is a discontinuity, so a level change spends world.mesh_lod_fade_time seconds (default 0.25, 0 disables) showing both levels and hands the blend to the temporal resolve.
One span, not two draws. While t < 1 the kernel emits both the target level's meshlet run and the previous level's (HikariMeshletPairSpan.first/second) for the same object row, so the fade costs no extra HikariObjectData row and no parallel direct draw.
Coverage rides the visible-triangle record. meshlet_cull packs the fade into word 0 of each visible-triangle record ({late:1 | target:1 | fade:8 | work:22}, gpu_culling/types.zig): zero means settled, otherwise the target level owns fade/255 of the coverage and the old level its complement. Nothing per-frame is written to ObjectData or the geometry row for a fade.
Screen door, then TAA. The G-buffer is opaque, so each pixel picks exactly one slice: hikari_meshlet_lod_fade (bindless_geometry) turns the record into one @flat fade code, and hikari_lod_fade_discard tests it against animated interleaved-gradient noise. The two tests are exact complements — no seam, no double-shaded pixel — and the discard runs before any texture work, so the hidden half costs no bandwidth. TAA's depth verdict (2×2 footprint, continuous weight, 2%-of-depth tolerance) is far looser than the geometric error between adjacent QEM levels, so the dither converges instead of being rejected as disocclusion. Blended geometry skips the screen door: forward_lit / forward_lit_rt scale alpha by hikari_lod_fade_alpha for an exact dissolve.
Shadows do not fade. shadow_meshlet_lod picks one deterministic level per atlas face from projected texel size (no temporal state, so a cached page stays valid), and emits only that run. A screen-door pattern baked into a shadow map would have no temporal filter behind it to resolve.
Edges. A reversal mid-dissolve swaps level and from and continues from 1 - t rather than restarting, so a camera hesitating at a threshold does not pop; any other change while a fade is in flight waits for it to settle. force_lod snaps. The step is min(frame dt, 1/15 s) / fade_time per frame (renderer_prepare/objects.zig), off the render frame clock rather than the sim clock, so a fade completes while the editor is paused and a hitch cannot turn a fade back into a snap. The state advances only on frames the culling kernels run; the idle scene-encode freeze (Contract B) counts static publish skips and is not fade-aware.
Swarm instancing
A swarm is a population, not a set of actors: every copy shares its template's mesh, material, colour, and shadow flag, and only the transform differs. Publish therefore emits one run per template — SwarmDrawRun = proxy + shared ObjectData + an instance range — plus a flat array of SwarmInstance (position + yaw, 16 bytes, against ObjectData's 224). The visible list interleaves variants, so the grouping is a counting sort: one pass tallies per template, a second scatters into reserved contiguous ranges, with the tally array rewritten in place into the scatter write heads.
Template shading lives in the resident primitive's ObjectShading row. A material replacement or late texture arrival must publish both the texture rebind and updated shading, including the sampling mask; transform-only instance runs cannot refresh that row. These updates run during render publication in Edit as well as Play and cost one row per changed template, independent of the population size.
The render thread expands those rows into the persistent scene's swarm tail (PersistentOpaqueScene.rebuildSwarmTail), the segment above actor_count. A tail has no stable identity — instance i is not the same copy it was last frame — so it is truncated and refilled rather than patched, and its rows publish prev == curr so a re-snapshotted population cannot invent motion vectors for TAA. rebuildSwarmTail returns whether the tail's shape changed, which is the only thing that forces the draw queue to be rebuilt: a flock that moves without gaining or losing members keeps its resolved queue.
Keeping the swarm segment separate from actor rows is what keeps the actors' stable patch path alive: a publish carrying swarm instances bumps opaque_synced_revision and marks the delta untracked so the pack store re-packs the stream, while the structure, the queue, and the culling staging survive. The GPU instance format is still ObjectData; the 16-byte row is a saving on the published frame and on per-instance publish work, not on GPU memory.
Implemented pipeline
Owner of graph order: graphics/rendergraph/pipeline.zig assemble. Packages and PipelineKind tags: Shader authoring.
Pre-graph work (prepare / encode)
GPU traces split pre-graph culling into gpu_culling.pair_index (pair setup, including table uploads on D3D12), gpu_culling.camera_lod, gpu_culling.camera_meshlets, and gpu_culling.shadow_prepare (shadow argument reset plus per-face LOD selection and culling). LOD selection feeds the shared wave-cooperative meshlet pair queue described in persistent GPU scene. Metal measures the boundaries inside the existing compute encoder. Timestamps and cache counter atomics run only on sampled frames and can add measurement overhead. The profiler and native timestamp backends share a 128-pass capacity so detailed culling cannot truncate the tail of a full RT graph.
| Step | When | Notes |
|---|---|---|
| Content async compute | Material declares async_compute | Prepare ensures ringed producers; graph pass dispatches; lag-0 wait at G-buffer |
| Light-cluster build | Always (light_binning hierarchical compute: init → coarse scatter → fine build) | Logarithmic 24×14×24 fine grid fed by a 6×4×6 coarse pre-bin (header + index pool) shared by deferred, forward, and RT lighting |
| Shadow meshlet cull | Shadow faces exist | Per-face/class meshlet cull and visible-triangle expansion; writes plain indirect draw counts |
| Skinned RT writeback | RT-eligible skinned primitives | Deform each packed source into its unique rigid RT stream after palette upload; successful queue-ordered submissions stamp the current epoch |
| TLAS prepare | RT master on and an RT mode needs the scene | Shared by RT shadows / AO / reflections; empty opaque set ⇒ all RT fall back |
| Bindless texture namespace + raster material table | Any scene raster draw | Frame-ring resident; shared with RT hit shading |
| RT instance table | Any ray-traced visibility feature this frame | Carries geometry/material rows for alpha continuation; reflections/GI also use it for full hit shading |
| Camera meshlet culling | Mesh geometry exists and meshlet PSO is ready | Expands visible triangles into pipeline buckets; gpu_frustum_culling=off uses the same path in fail-open mode |
Render-graph passes
Order below is the assembly order. Conditional rows are skipped when their gate is false. Pass names match graph / profiler labels.
| Pass name | Gate | Role |
|---|---|---|
shadow_depth | Shadow faces exist and (raster/transparent surface shadows, or active volumetrics needing volume shadows) | Fill shadow atlas (all for raster or transparent surfaces; volumetric_only when opaque RT surfaces skip it but froxels still need shafts). GPU meshlet culling writes exact rigid/skinned, sided/two-sided, and solid/masked face-class streams; atlas updates traverse pipeline-major so each shadow PSO binds at most once |
content_compute:<package>:<entry>:<i> | Visible async_compute producers | One pass per producer; writes ring slot N. Profiler / GPU timing use this unique name. |
gbuffer_geometry | Always | Opaque deferred G-buffer + motion vectors; per-batch mesh_vertex_texture from content compute |
depth_pyramid / depth_pyramid:<start> | Occlusion Hi-Z (gpu_frustum_culling and quality.occlusion.mode != off), raster contact shadows, screen-space AO/reflections, or any GI mode | One half-res RG16F mip chain (min/max), built in compute chunks of up to eight levels per encoder; feeds the correct phase this frame, the predict phase next frame, contact shadows, SSAO, SSR, SSGI, and RT GI's screen-first trace and mid-frame fallback. Chunks after the first are named depth_pyramid:<mip> |
occlusion_cull | GPU culling and quality.occlusion.mode == two_phase | Correct phase: re-tests predict-phase occlusion rejects against the pyramid just built |
gbuffer_geometry_late | same as occlusion_cull | Draws the instances the correct phase recovered into the loaded (not cleared) G-buffer |
depth_pyramid_rebuild / depth_pyramid_rebuild:<start> | two-phase GPU culling and another pyramid consumer (raster contact shadows / SSR / any GI mode / volumetrics) | Re-runs over the completed depth so later passes do not sample the predict-phase subset |
deferred_decal | At least one decal with non-empty screen bounds and the PSO + bindless white fallback exist | One tiled compute rewrite of albedo / normal / ARM after all G-buffer geometry (including late). CPU-built 16×16 tile lists preserve stable overlap order; every pixel loads and stores the G-buffer triple once. Lighting, AO, and GI sample that output. Depth and material stay the originals. Zero visible decals ⇒ the pass is not in the graph |
velocity_tile_max / velocity_tile_max:<start> | features.motion_blur != off and the active camera's shutter is open | Conservative search pyramid over gbuffer_motion (4×4 base tile, halving per mip), built only across the active dynamic-resolution sub-rect. Motion blur checks the nearest 2×2 cells at the selected coarse level so a moving silhouette can cross a tile boundary without a separate dilation pass. It bounds where a long shutter gather looks; it is never the destination pixel's motion. |
volumetric_inject → volumetric_integrate | volumetric_fog on and density active | Froxel scatter then integrate; .compute queue when async compute is available, else graphics; scheduled before AO/lighting/reflections for overlap |
screen_space_ambient_occlusion / ray_traced_ambient_occlusion | AO ≠ off | Quality-scaled screen-space compute or fixed-half-resolution RT visibility trace |
ao_temporal → ao_atrous_1 → ao_atrous_2 → ao_atrous_4 → ao_bilateral | AO ≠ off | Shared RTAO/SSAO half-resolution scalar variance cascade and conservative full-resolution coverage-aware R8 visibility |
rt_gi_radiance_cache_update | RT GI active | Fixed 96×384 directional world-cache maintenance; 9,216 four-ray lobe batches per encoded frame |
rt_gi_screen_probes → ray_traced_global_illumination | RT GI active | One quality-scaled final gather per 8×8 probe, then geometry-aware interpolation with exact one-ray receiver fallback |
screen_space_global_illumination | SSGI active | Screen-space quality-scaled diffuse irradiance + confidence |
(rt_gi_fast_temporal, RT only) → gi_temporal → (rt_gi_atrous_1 → rt_gi_atrous_2 → rt_gi_atrous_4, RT only) → gi_bilateral | GI ≠ off | Geometry-rejected history; RTGI adds a responsive radiance/luminance-moment history and half-resolution variance-guided a-trous cascade before the full-resolution indirect_diffuse resolve (RGBA16F) |
raytraced_direct | Full RT shadows active | Full-resolution RGBA16F direct-light samples; rotating 2×2 phase in smooth interiors, alternating diagonal half-rate pairs at depth/normal/coverage discontinuities |
direct_temporal → direct_bilateral | RT shadows active | Full-resolution validity-aware reconstruction and history; the spatial stage is an exact centre resolve at matching extent |
deferred_lighting, hybrid_lighting, or raytraced_lighting | Always one | Classical deferred (shadow atlas), bounded hybrid (RT globals + atlas punctuals), or full RT-shadow lighting; all write HDR color and sample GI into IBL diffuse when active |
gi_bounce_capture | GI ≠ off and an HDR scene target exists | Half-res depth-owned secondary radiance (prior GI, view fog, aerial perspective, and environment specular removed) into the gi_bounce ping-pong for next-frame SSGI and RT-mode fallback |
scene_color_pyramid | Reflections ≠ off | Box mip chain of the lit opaque scene (level 0 = half res, four levels) for cone-footprint hit reads |
screen_space_reflections / ray_traced_reflections | Reflections ≠ off | Half-resolution stochastic producer (compute): one GGX visible-normal sample per pixel, radiance + hit distance (RT path: hybrid reuse at the cone mip + optional hit re-light) |
reflections_resolve | Reflections ≠ off | BRDF/pdf neighbour reuse (ratio estimator) over the raw samples; writes the lobe mean plus the virtual reflected distance |
reflections_temporal → reflections_bilateral | Reflections ≠ off | History reprojected through the virtual reflected point, then the full-resolution split-sum composite |
volumetric_composite | Volumetrics on | Render-domain scene * transmittance + inscatter onto atmosphere-composited opaque HDR before transparent draws and TAA/TAAU; writes fog opacity for motion-only history reactivity |
transmission_capture | A visible transparent material has transmission | Builds a four-level persistent RGBA16F pyramid from the current opaque HDR, including reflections, for rough refraction |
forward_transparent | Transparent draws present | Refractive vertex-only depth prepass, nearest-depth refractive shading, then the additive lane; shares light clusters and always uses bounded raster-atlas shadow visibility. The RT companion traces authored volumes automatically and thin interfaces only by explicit override. Samples transmission_capture and the integrated volume when volumetrics owns the medium. Every transparent scene mesh is a meshlet bucket drawn through the same visible-triangle stream as the opaque scene — there is no CPU-sorted transparent item loop |
forward_oit | Alpha-blended transparents present | Weighted blended OIT (McGuire/Bavoil weight #10, optical-depth revealage) into an RGBA32F accumulation + metadata pair with one additive blend state; reads scene depth, writes none |
oit_composite | Alpha-blended transparents present | Resolves the three accumulation targets over the loaded HDR scene (premultiplied), writes weighted-mean motion/depth into transparent_temporal, and previous depth/valid coverage into transparent_previous_depth |
temporal_reconstruction | Anti-aliasing = TAA / internal reconstruction | Output-domain history ping-pong; render-res current/depth/motion; mixed-res TAAU when scale < 1 |
sharpen | Internal temporal resolve ran (not a registered plugin provider) | RCAS on the resolved output into a transient target — never into the history ping-pong. Strength scales with the upscale ratio (mild at native TAA); HDR handled by Reinhard-compress → kernel → decompress, then bounded to the contributing HDR neighbourhood so negative lobes cannot create silhouette halos |
exposure_downsample → exposure_reduce → exposure_adapt | features.auto_exposure != off and all three pipelines ready | Centre-weighted, hierarchically trimmed log-luminance reduction of the clean post-reconstruction image (full → 64×64 → 8×8 → 1×1), then cut-aware stops-per-second adaptation into a persistent 1×1 ping-pong the tonemap samples |
auto_focus | DOF scheduled and the camera asks for auto-focus, pipeline ready | One 1×1 draw: five-tap subject median + 24-tap bilateral centre meter, then persistent diopter-space adaptation; gather/composite sample the current value |
depth_of_field_gather → depth_of_field_composite | features.depth_of_field != off, pipelines ready, and the active camera's CoC scale is non-zero | Half-resolution golden-angle bokeh gather, then a full-resolution composite that keeps in-focus geometry pixel-sharp |
motion_blur | features.motion_blur != off, pipeline ready, and the active camera's shutter is open | Cut-safe exact opaque/forward-transparent velocity plus rotational sky reprojection, with a tile-max search envelope. Each tap must have a shutter segment that reaches the destination, then passes the depth test; coarse foreground velocity therefore cannot smear background over a rectangular tile. |
camera_lens_visibility → camera_lens | The active camera enables lens flare and at least one enabled light opts in; vignette or chromatic aberration alone schedules only camera_lens | CPU selection ranks the four strongest opted-in directional, point, or spot emitters visible in the lens; punctual rank follows apparent bulb size (source_radius / distance), never the light's shading cutoff radius. A 4×1 pass projects and depth-tests their source discs with 25 taps each. The HDR finish synthesizes aperture-coupled round/polygonal cat's-eye ghosts, radius-scaled spectral separation, halo, veiling glare, starburst, and a cool anamorphic streak analytically—scene pixels are never transformed into ghost shapes. |
bloom | Bloom intensity active and pipelines ready | Karis-stabilised half-res bright pass and four-level dual-filter pyramid (half through sixteenth resolution). Seven dependent dispatches stay in one compute encoder, each binding one storage output and sampled source/detail inputs. Hardware bilinear filtering replaces manual four-load interpolation; explicit storage/sample transitions order stages and restore graph state before publication; normalised reconstruction prevents energy growth |
bloom_composite | Bloom on and debug grid replaces tonemap | Full-res add into a dedicated HDR target (grid samples scene_color directly) |
tonemap | Any HDR post path, with debug grid off | Selected operator or passthrough to backbuffer; adds the half-res pyramid itself when bloom ran (post.bloom_add); triangular-PDF dither at ±1 LSB hides 8-bit banding in shallow gradients |
debug_visualizers | Any non-off VisualizerMode | G-buffer/effects/shadow grids, HDR-nits false colour, full-screen TAA/GI diagnostics, or the meshlet id / settled views; reads diagnostic resources without modifying their histories and replaces tonemap |
ui | UI execute callback present | One flattened UI draw on the backbuffer |
OIT uses two RGBA32F additive targets for HDR radiance/weight and motion/current depth/log coverage, plus one RG32F target for previous depth/valid weight (40 bytes per render pixel total). Relative weights are normalized to a maximum of one; zero-alpha fragments contribute no weighted color. Resolve divides by the actual positive weight, preserving very thin coverage. The resolved scene and motion/current-depth metadata remain RGBA16F; the additional previous-depth/valid-coverage guide is RG16F (4 bytes per render pixel). The opaque RG32F depth pair costs 4 extra bytes per render pixel relative to a single R32F depth channel.
An HDR scene_color target is created for tonemap, debug grid, reflections, TAA, or transparency. AO alone stays on the direct lighting path. Every HDR path uses the passthrough composite when tonemap is none; transmissive materials additionally build the pre-transparent pyramid. When tonemap runs, bloom is folded into that pass rather than writing a second full-resolution HDR target only for tonemap to re-read.
Transparency is intentionally outside the reflection geometry domain. Screen-space and ray-traced reflections use the opaque G-buffer/TLAS and finish before transmission capture. Transparent surfaces receive reflection in forward lighting: the raster path uses environment/probe specular, while smooth thin interfaces trace the opaque scene when RT reflections are active. They can also refract the already-reflected opaque scene, but they neither appear as reflection geometry nor receive the opaque reflection composite afterward.
Transparent material flags.alpha_mode is straight by default or premultiplied. It selects the shared RHI blend state; the forward shader premultiplies its final lit RGB only for the premultiplied mode. Albedo texture alpha always contributes to coverage, and premultiplied texture payloads are normalized before lighting. params.transmission > 0 selects the refractive path (no blend): a vertex-only companion first writes nearest reverse-Z depth using the material's exact vertex entry, then forward_refractive shades with greater_equal and no depth write. Hidden glass layers fail before fragment lighting, capture lookup, or refraction; alpha-only transparents accumulate afterward through weighted blended OIT (forward_oit → oit_composite), so no transparent draw depends on CPU depth sorting. This is a global material policy, not an asset flag or quality option. Content-compute is not the transmission signal. Mesh geometry and forward PSOs cull back faces on Metal and D3D12 (pipeline_kind.cullMode); the forward-lit shader also flips normals for back-facing fragments so closed glass shells do not shade the far wall as an opaque dielectric when both sides are drawn. The transmitting BRDF is an IOR dielectric: one F0 from IOR for direct, IBL, and the transmission mix; metalness cannot steal that F0; IBL specular is not gated by baked AO. Raster refraction selects the opaque-capture prefilter from material roughness because the capture has no hit distance; treating authored volume thickness as distance to the first visible content over-blurs embedded objects. Thin-walled (thickness == 0) still refracts in screen space (one pixel of world length at the surface); volumes use authored thickness for displacement and Beer-Lambert, while the RT companion obtains the real hit distance.
Thin transmission is also its own automatic lighting model. A transmissive material with thickness == 0 treats the opaque capture as the already-lit diffuse/punctual/GI result behind the interface, then adds the interface's global-light dielectric highlight (with raster-atlas visibility), split-sum reflection, refraction, cached forward fog, emissive and temporal metadata. The raster path sources that reflection from the environment/probe set. When RT reflections are active, the forward RT companion instead traces the reflected ray against opaque scene geometry and shades its hit, because the earlier opaque reflection pass cannot receive transparent geometry rendered afterward. A miss retains the environment/probe result; the frame RT budget can disable the extra ray. The reflected lobe is already Fresnel-weighted, so thin transmission adds rather than re-mixes it while the transmitted capture receives (1 - Fresnel). It does not walk the bounded punctual-light cluster, sample screen-space GI again, evaluate ambient diffuse again, or march bounded media once per light on every glass pixel; its unused AO texture read is suppressed as well. Local lamps remain visible through the lit capture but do not add a second analytic highlight to the pane itself. Authored volumes (thickness > 0) retain the complete forward light cluster, surface GI and per-light medium attenuation; when RT and its refraction budget are available they also trace the refracted hit automatically, because a 2D capture cannot represent an embedded object or a curved volume's real exit. traced_refraction remains an override for hero-quality thin interfaces. The classification comes from the existing physical thickness value and has no Bistro-specific flag or user-facing quality level.
Editor composite is outside this graph: when the viewport is inset (ViewportInsets), _engine/composite blits the offscreen scene color into the drawable chrome region (renderer_viewport.zig). That blit is the viewport_composite GPU pass; HDR UI-into-layer is editor_ui_layer. Editor chrome then renders at native drawable resolution, after scene temporal processing and without scene scaling or filtering. Standalone games present the backbuffer directly.
Pass entry load operations apply equally to offscreen textures and the swapchain. In particular, the standalone HDR ui_composite pass loads the tonemapped backbuffer before blending its SDR UI layer over it. Overriding that request with a swapchain clear erases the entire scene while leaving a working HUD; the editor's separate offscreen composite can mask this error. graphics/renderer/pass_targets.zig preserves the caller's load operation and clear color for both targets.
Atmosphere textures stay cached on the device when switching sources or drawing another viewport. Each consumed view selects whether that cache is active: solid-color and authored-cubemap views bypass its bindings, cloud passes, and fog ambient. With both skybox slots empty, the Environment panel tint (or preview Backdrop) supplies a uniform background; choosing a resident cubemap selects that texture immediately.
Particle billboards, stable-order billboards, and ribbons select raster pipelines by their draw lane. Alpha emitters accumulate into the shared OIT attachment pair (pipeline_kind.oit_formats, RGBA32F); additive emitters draw into the HDR scene pair (RGBA16F). Both lanes blend additively, but their attachment formats require distinct pipelines. Metal and D3D12 derive those formats from DrawLane.pipelineKind() so changes to OIT precision apply to particles as well as mesh transparency.
The global environment is owned by World and has two explicit sources. setAtmosphere selects the dynamic clear-sky source: one canonical state feeds the visible sky, diffuse irradiance, roughness-filtered specular fallback, GI integrands, reflections, raster/RT misses, and aerial perspective. Sun and moon disks are visible-only; directional lights own direct energy and shadows. The profile's coefficients define one optical path and everything crossing it shares that one definition: the sky's in-scatter, the visible disks, and atmosphere.sunlightColor / moonlightColor handing the same result to a linked directional light. So a low sun reddens and dims the disk in the sky and the light casting the scene's shadows by the same amount, with sun_color remaining the top-of-atmosphere reference and the light's authored intensity the extraterrestrial scale. The disks read that extinction from a uniform the CPU already computed for the lights (atmosphereSunLit), so the two cannot drift apart by construction rather than by two evaluators agreeing.
The path is a ray-sphere march against planet_radius and atmosphere_height through exponential Rayleigh and Mie layers plus a linear ozone tent at 25 km, baked into three tables in the Hillaire arrangement (atmosphere.akari, atmosphere_lut.akari): transmittance 256x64 and multiple scattering 32x32, both functions of the medium alone and rebaked only on a profile edit; and the sky-view table at 192x108, which follows the sun. The environment cube (6x128^2, mip chained) is a resampling of the sky-view table plus the cloud layer marched into it, not a second evaluation — so the sky the camera sees and the sky that lights the scene cannot disagree, and the cloud layer composited into the cube reaches ambient, specular and ray-traced misses without any of those paths changing. The camera's own view of the clouds is a separate reduced-resolution compute march (kumo_screen) scheduled before lighting and composited by it, so one hikari_kumo_march serves both: the cube converges (no jitter, no history, world-origin anchor) because its tail level is the scene's diffuse irradiance, while the screen pass is anchored at the camera, stops each ray on the depth buffer, and carries a spatial-only dither so a still frame is bit-identical frame to frame. quality.clouds tiers the two march budgets and the screen scale; off spends nothing anywhere — not the two marches, and not the costs the camera never sees either: the two noise volumes are not allocated or baked, the per-frame shadow map is not baked, hikari_kumo_enabled closes so no lit surface samples it, and cloud drift stops making the environment cube stale. The tier is ANDed with the authored cloud_coverage in one place per layer (clouds_active for the graph, cloudLayer.w for the shader) precisely so the two cannot disagree — a tier that skipped the bake while leaving the shader gate open would shade the ground under clouds it had not drawn. Anything written into the cube is clamped first (hikari_sky_cube_clamp): the tail level is the scene's whole diffuse irradiance, so one bright texel — a sunlit cloud edge against a dark base — would otherwise propagate into every surface in the frame through L0. The sky-view table reaches every consumer as one bindless index (HikariLightingResources.skyView); the visible sky pass, the cube's write kernel and aerial perspective's asymptotic source all read it and nothing else. Aerial perspective reads it through hikari_atmosphere_haze_radiance, which clamps the lookup to the horizon row: the table's below-horizon half models a Lambertian planet surface, so using it as haze both double-counts the ground the scene already draws and steps by about 3.4x in red across the horizon — a hard, hue-shifted line over every surface that straddles it, and over the whole frame on a scene with a raised aerial_perspective_distance_scale. The air between camera and a surface below the horizon scatters the same light the horizon sky does, so the horizon row is the physically correct limit as well as the continuous one. None of these resources scales with render resolution.
Curvature is what makes twilight physical rather than tuned: the horizon is a real intersection, so a low sun keeps reddening down to it, and the per-sample test for whether the path to the sun meets the ground is Earth shadow and the belt of Venus. The 96-sample CPU spherical-harmonic projection is retired — diffuse irradiance is the cube's cosine-convolved tail level, which the diffuse path already reads through its last-mip lookup. The full atmosphere profile is published as POD frame state, so a game clock changes no texture residency and allocates nothing; the sky rebuilds on a profile edit or 0.25-degree celestial movement, and a still sky rebuilds once. Aerial perspective integrates the profile's exponential Rayleigh and Mie density fields analytically from the camera to each opaque or forward surface, applies wavelength-dependent extinction, and converges toward the same directional sky radiance used by misses. It is fused into the lighting paths instead of adding a full-screen copy: base lighting receives extinction and in-scatter once, the later reflection lobe receives extinction only, and bounded fog composes last. Sky pixels remain untouched because their radiance is already evaluated at the observer. aerial_perspective_distance_scale converts world units to atmosphere metres (1.0 means metres), while aerial_perspective_strength is the bounded art-direction multiplier; either may be zero to make the shader branch an identity without disabling the procedural sky. The astronomical helper and authoring contract are in Time of day.
The alternative authored-cubemap source uses setSkyboxColor / setSkyboxTexture / setSkyboxTextureSecondary / setSkyboxBlend. Texture setters are value-idempotent: only a cubemap identity change in a side with nonzero blend weight cuts temporal history, so a runtime transition may stream or release the fully hidden side without exposing a raw stochastic-lighting frame. Dual sides mix with a single path: mix(sampleA or solid, sampleB or solid, blend) * exposure (color.a is exposure). An intentionally empty side is the authored solid environment and participates in the blend; an authored cubemap not yet GPU-ready clamps the fade toward the resident primary (no streaming flash). IBL specular and diffuse irradiance track the same blend; a solid side is represented analytically by its constant L00 SH coefficient. Missing or failed cubemap loads fall back to the solid color so hot reload stays safe. Cubemap blending remains for authored environment transitions and preview, not an astronomical day cycle. Kawa: Scene.set_skybox_color(r,g,b[,a]), Scene.set_skybox_texture(path), Scene.set_skybox_texture_secondary(path), Scene.set_skybox_blend(t).
A whole-world scene persists its default source and both source configurations in
the root environment block. The editor writes that block through document
history; it is resolved after actor IDs/transforms are registered and before
component Start, including optional astronomical rotation of linked sun/moon
lights. Runtime setters then remain authoritative—the scene default is not
re-applied every frame—and additive scene layers do not replace global
environment ownership. This makes scriptless scene setup and scripted full-day
choreography use one render path rather than two competing systems.
The medium's ambient floor is the published environment, not a constant: the
froxel passes never bind HikariUniforms, so FroxelParams.sky_ambient carries
the isotropic sky radiance (the irradiance SH DC term with exposure applied,
falling back to the authored solid colour while a cubemap streams) and
volumetric_inject scales it by a fixed sky-visibility fraction standing in for
the ambient occlusion the medium does not compute. Fog therefore takes its
colour from whichever environment source is active — blue under a noon sky,
warm at sunrise, nearly black at night — from the same SH the diffuse IBL reads.
The fraction is calibrated so a clear noon Earth profile reproduces the constant
floor this term used before, and it stays small deliberately: the floor is
unshadowed and direction-free, and shaft visibility is the ratio of lit to
shadowed fog, so a large one flattens the shafts.
The medium is cloud-shadowed on the same terms surfaces are. lighting.akari
applies hikari_kumo_surface_shadow at its one directional choke point, and
volumetric_inject applies the same occlusion to its own directional in-scatter
after the atlas term and before anything scatters — so a shaft of sunlight and
the ground it lands on are dimmed by the same deck. Without it a solid overcast
darkened every surface while the shafts above them stayed at full sun, which
reads as fog glowing inside its own shadow. hikari_kumo_shadow_occlusion is
the single implementation: the surface path reads the sun, the slab plane and
the camera out of HikariUniforms, and the froxel path — which deliberately
never binds that block — reads them out of FroxelParams.cloudShadow and
cameraPos, deriving the identical snapped map centre rather than trusting a
published copy of it. That row is all-zero when there is no layer, filled from
cloudLayer.w, which is the very word hikari_kumo_enabled reads and already
carries the cost tier: clouds at off stops the bake and stops the fog
sampling it in one move. The lookup is hoisted out of the light loop (one deck,
one sun, one texel) and taken at the froxel centre rather than the atlas term's
dithered point — at 31 m per texel there is no staircase for a dither to break
up. What still does not know about clouds is the ambient floor above: it is a
CPU average of the clear-sky field, so an overcast scene keeps a clear-sky haze
term under correctly shadowed shafts.
Fog Volume material graphs own the spatial medium: extinction, albedo,
emission, height shaping and noise. FogVolumeUpdate.density_scale is the
runtime weather envelope and multiplies the graph's final extinction during
froxel injection; zero therefore preserves the authored graph while making it
inert. enabled should also be cleared once a volume is fully faded so it is
omitted from volume staging. The scale is per actor and cloned with its World,
which keeps editor and Play weather independent.
World position is reconstructed from depth in lighting. Deferred lighting uses a Cook–Torrance metallic/roughness model. Static geometry residency is content-keyed and refcounted; scene meshes are packed into one shared meshlet work graph and emitted as one plain non-indexed indirect draw per pipeline/lane bucket. Opaque G-buffer, shadows, refractive depth/shading, additive, and OIT all use that path; transparent order dependence is handled by the refractive depth contract or order-independent blend policy, not a CPU sort. The final UI pass consumes a flattened vertex stream and remains one draw call per backend.
Asset-bound static geometry uses a stable key derived from its normalized asset stem and the store's reload generation. The authored and Play worlds therefore join the same device-resident buffers without hashing the decoded payload. The render-owned residency table publishes a mutex-protected key census to worlds; when a key is present, a bind carries only identity, draw metadata, and bounds, and skips retaining or decoding the engine mesh. CPU-access meshes deliberately bypass this path. A render-thread check remains authoritative: eviction between the census read and command application reports a geometry miss, demotes the primitive to soft-pending, and retries through the ordinary decode/rebind path. Model-family hot reload bumps the store generation before any replacement bind, so an old resident allocation cannot satisfy changed geometry under the same asset stem.
Camera post
Defocus, shutter, vignette, chromatic aberration, and flare/ghost simulation are
lens optics, so they live on the camera that forms
the image (scene/camera.zig, authored on SceneCameraDesc, POD in
sdk/src/camera_post.zig) rather than in a Visual Zone: a zone describes the
world, and two cameras standing in the same volume can legitimately disagree
about focus. Project RenderFeatures gate the heavyweight defocus and shutter
passes. The vignette / aberration / flare finishing chain has no project toggle:
flare adds one 4×1 emitter-visibility pass, while vignette or aberration alone
stays a single full-resolution pass. Lights participate only when their
contributes_to_lens_flare authoring checkbox is enabled; ambient lights never
participate. The CPU rejects off-screen candidates and keeps four. Punctual
ranking and energy use apparent emitter size from source_radius / distance;
the light's shading radius therefore cannot alter either one. Directionals use
a projected mean solar angular radius and test against sky coverage; punctual
emitters test their projected source radius against linear depth. Flare geometry
is analytic and depth-occluded; it never resamples the frame into ghosts. The camera's f_stop
continuously changes ghost bodies from soft/round to harder six-blade polygons
and introduces a twelve-ray starburst across the cinema-lens range f/1.4–f/5.6.
Off-axis pupil clipping shapes ghosts into cat's eyes. Per-ghost support bounds
skip aperture SDF work away from the ghost, while the expensive diffraction and
streak math has bounded screen support. The chain is absent whenever the camera
flare intensity is zero or no enabled light opts in.
The Edit and unpossessed-Play fly-cams use neutral optics. The camera toolbar
button previews the primary actor as a complete lens in Edit—transform and
optics together—and returns to the parked fly-cam when toggled off. Possessed
Play also renders the primary camera directly.
Game code uses the same grouped settings for spawn, update, and readback:
const post: hi.CameraPost = .{
.vignette = hi.VignetteSettings{ .intensity = 0.35 },
.chromatic_aberration = hi.ChromaticAberrationSettings{ .intensity = 0.002 },
.lens_flare = hi.LensFlareSettings{ .intensity = 0.6, .ghost_count = 5 },
};
const camera = try hi.world().spawn(&.{
.archetype = "camera",
.components = .{ .camera = .{ .post = post } },
});
try hi.world().updateCamera(camera, .{ .post = post });
const current = hi.world().cameraState(camera).post;Sensor size is not a knob. The projection already fixes it at
2 * focal_length * tan(fovY/2), so authoring it separately would let a
camera's optics disagree with its own frustum. The CPU folds the whole thin-lens
constant into HikariUniforms.depthOfFieldParams.x, leaving the shader one
multiply: coc = x * (z - focus) / z, signed, as a fraction of screen height.
RenderQuality.depth_of_field / .motion_blur own both the tap budget and the
kernel clamp, because a disk wider than its taps can cover starts to show
individual taps — raising the budget is what buys a bigger bokeh.
Depth of field remains a half-resolution gather plus full-resolution composite. The gather uses each source sample's own signed CoC as a scatter-as-gather admission test. Focused destinations first run eight depth-only probes across the maximum disk; only when a near-field source can reach that destination do they enter the colour gather. Its coverage is carried in bokeh alpha so blurred foreground can spill over sharp geometry without making every focused pixel pay the full 16/32/48-tap quality budget or adding another pass. Near and far radiance accumulate separately and combine only through aperture coverage, so opposite sides of a depth discontinuity do not average into a fringe. Tap directions advance through baked golden-angle rotation constants; the gather does not evaluate sine or cosine per pixel.
Per-frame gating is settled once, in renderer_prepare, and reaches the shaders
as a zero tap count / shutter scale rather than a flag. Assembly asks the same
question to decide whether to schedule the passes, so the two must agree: a
scheduled pass with a zero kernel is wasted bandwidth, and an unscheduled pass
with a live kernel leaves the composite reading nothing.
Colour is output-domain after reconstruction while linear depth, opaque velocity, and transparent metadata stay render-domain under TAAU. Post effects map each output UV to the render texel that owned the current jittered sample; a plain proportional point fetch can select its neighbour and make silhouettes pulse. Motion blur uses the forward pass's refracted/surface velocity and transparent depth wherever its coverage metadata is present. Its taps are deterministic because the pass runs after temporal reconstruction—animating their phase would have no later history in which to converge. The rotary shutter is centred on the resolved frame, with the authored shutter fraction describing the complete sweep rather than each side of it. Sampling is velocity-adaptive: short motion uses one symmetric tap pair, then adds a pair per four pixels of sweep up to the quality tier's ceiling. The tile-max pyramid supplies only a conservative search radius. Opaque pixels retain their exact G-buffer velocity, and a gathered source is admitted only when its own oriented shutter segment reaches the destination; this scatter-as-gather test is what lets moving silhouettes extend over their background without lending one close surface's velocity to a whole coarse tile. The selected coarse cell and the nearest cell on each axis form a four-fetch search footprint, covering tile-boundary spill without a separate dilation pass. Sky pixels derive rotation-only motion from current/previous camera directions, while history discontinuities bypass the effect entirely. Off-screen and rejected taps skip colour fetches. This preserves the tier's long-blur quality while small camera motion no longer pays its maximum full-resolution tap budget.
The four post packages (_engine/exposure, _engine/depth_of_field,
_engine/motion_blur, _engine/camera_lens) use the fullscreen_effect binding layout (same 9-wide
scene-effect window as the temporal resolve). Eye adaptation also writes the
fourth SRV (post.exposure) on fullscreen_texture_table so tonemap can
multiply the adapted 1×1 without a second layout.
Pipeline configuration
Games set RenderPipelineConfig in their ProjectConfig. The sample project uses raster shadows at high quality and screen-space reflections/AO (RT is an optional runtime knob). Live tonemapping, shadow, RT, reflection, AO, TAA, environment-lighting, and RT hit-quality changes pass through SessionCore / GraphicsCommand (see table above). gpu_frustum_culling is selected at project config time only.
RenderLook supplies the project baseline (tonemap, environment lighting, manual exposure, eye-adaptation limits, bloom). Spatial / global overrides are Visual Zones — host/scene wiring publishes a resolved VisualLookSnapshot each frame.
See Rendering contracts above for feature independence, effective vs requested modes, and TLAS readiness. Tonemapping and requested ray-tracing behavior are project policy. A driver may expose a narrower capability set; code must test the reported feature support instead of assuming parity.
Render graph and resources
Pass ordering and barriers use the shared RHI state vocabulary. Extend that vocabulary for compute, copy, queue transfer, or ray-tracing states; do not invent backend-local graph states. Renderer graph policy belongs above the RHI. Platform-specific code must not fork frame orchestration just to add a future Vulkan or console driver.
Frames-in-flight + free policy: graphics/renderer/frames.zig defines the shared protocol (ring slot wait → write → submit → mark; deferred free age ≥ FIF). Metal and D3D12 both implement that protocol; present depth is not a substitute for the ring wait. See Renderer architecture — Sync points.
Handle ownership: use graph/RHI handles and declared dependencies when extending the graph. Shared pass code must not free native GPU objects or poke platform resource tables — platforms lower handles in encode/destroy only.
Shaders and debugging
Engine shaders are under src/hikari/shaders/; game-authored material/effect packages belong under src/games/example/assets/shaders/. Graphics debug requests use World.renderer_debug (VisualizerMode: off, the three buffer grids, hdr_nits, the TAA/GI diagnostic families, or the meshlet views — meshlet_id gives each visible MeshletWork its own colour, meshlet_settled paints predict-phase records green and late-phase records magenta; both decode the payload the G-buffer packs above the material flag byte, whose layout is owned by akari/modules/meshlet_debug_id.akari and cross-checked against the enum by a render_debug.zig test). Shared graph wiring lives in graphics/debug/render_debug.zig and graphics/rendergraph/passes/debug_visualizers.zig; platform drawing stays in each backend. G-buffer grid: albedo/normal/ARM/depth/material flags/motion/shadow atlas/HDR/cluster load (magenta = overflow). Effects grid: AO raw/history/out, reflections raw/history/out, pre-reflection lighting, depth pyramid, AO temporal delta. Shadows grid: full atlas (inverted depth), faces 0–3 of the first packed shadow light (prefer directional cascades; else point/spot cube faces), split map (cascade id for directional; face palette for point/spot), linear depth, HDR, mode badge (green=raster, purple=hybrid, blue=full RT, red=off; right strip = quality). Hybrid displays the atlas because punctual shadows still consume it. TAA diagnostics are a 3×2 overview: accumulated count, stability lock, count retention, final geometry authority, input motion, and final reactivity. Count, lock, geometry authority, and reactivity come directly from the state written by reconstruction; the debug shader does not rerun depth/luma policy. Blue geometry means history was accepted (including a bounded lock bridge), red means rejected; black reactivity means the final policy kept ordinary accumulation, pink means it shortened the window. The count tile is black at reset/no usable history, red for a short unlocked window, magenta for a short window with a live thin-feature lock, yellow near one complete jitter sequence, and white after at least one complete sequence. Leaving diagnostics enabled does not change the behavior being measured. Sample game hotkeys cover the grids/HDR; the editor viewport toolbar exposes every mode.
Double-sided normal-mapped surfaces use mirrored-normal semantics in raster and ray-hit shading: back faces preserve the tangent-space X/Y detail and reverse only the normal axis. This is the default expected by thin cutout geometry such as leaves and prevents card winding from changing the apparent lighting direction.
The editor profiler reports render-thread CPU work: command drain, draw batching, shadow and cluster preparation, instance packing/upload, frame preparation, and command encoding per graph pass. Optional Entity timing (same UI) measures play-frame entity lifecycle cost separately; see UI and editor.
GPU pass timestamps (when Profiler Recording is on): profiler/gpu_timing.zig + backend queries. Metal 4 uses per-slot MTL4CounterHeap timestamps (writeTimestampIntoHeap start/end pairs, resolved via resolveCounterRange + queryTimestampFrequency after the ring-slot wait); D3D12 uses a TIMESTAMP query heap + readback, resolved after the ring-slot wait (FIF). The resolved snapshot is process-wide (hikari_profiler_gpu_publish / capture in the platform ProfilerRegistry) so dynamic render dylibs and the editor UI share one buffer. Graph pass_profiling is installed whenever -Dprofiler-timing is compiled in (does not require --profiler-timing:modules=markers). Pre-graph GPU work (light_binning, gpu_culling.prepare, skinning_writeback, raytracing.acceleration_structures) and the editor viewport_composite / editor_ui_layer blit register the same way. Independent of --profiler-residency (CPU/GPU residency tabs). Results appear under a GPU section in the Profiler tab and overlay; until the first resolve, that section shows “waiting for first samples…”. Markers still only add Xcode/PIX labels.
GPU capture markers (--profiler-timing:modules=markers) come from two sources. Graph passes are labelled by a debug region the graph installs around each pass — the pass code itself passes no name. Everything submitted outside the graph has no region to inherit, so it names itself: gpu_culling.prepare, gpu_culling.occlusion_correct, gpu_culling.shadow_meshlet_cull, the seven light_binning.<phase> kernels and light_binning.clear_headers, skinning_writeback, content_compute, raytracing.acceleration_structures, viewport_composite, editor_ui_layer, and reconstruction.provider_encode (which brackets a temporal provider's own vendor commands — the provider emits no markers of its own). Nested phase markers are capture-only; GPU timestamps stay on the parent producer. On D3D12 that is command_list.beginMarker (Zig) or hikari_d3d12_begin_debug_event(list, name) (native); passing a null name falls back to the region. New pre-graph submissions must name themselves or their dispatches and barriers show up in a capture unattributed, which is what makes them impossible to budget against.
See Shader authoring for package layout and build integration.
Debug drawing
hi.debug() carries immediate-mode world-space geometry. It is frame-scoped:
call it every frame for as long as the shape should be visible, and stop calling
it to make the shape disappear — there is no handle and nothing to clean up.
if (hi.debug()) |d| {
d.line(a, b, .{ 1, 1, 1, 1 });
d.ray(muzzle, forward, 25, .{ 1, 0.9, 0.2, 1 });
d.box(center, half_extents, .{ 0.05, 0.05, 0.05, 0.85 }); // axis-aligned wireframe
d.sphere(center, radius, .{ 0.3, 0.9, 0.35, 1 }); // three great circles
d.point(position, 0.5, .{ 1, 0.25, 0.2, 1 }); // three-axis cross
}Uses: aim rays, selection outlines, contact points, spawn volumes, trigger bounds.
How it draws: segments are projected with the frame's camera and appended to the
UI vertex stream ahead of the UI, so they render at constant pixel width and
the HUD stays on top. There is no depth test — debug geometry draws over the
scene. hi.debug() is null when the debug API is stripped; guard with orelse return.
Tuning lives on World.debug_draw: line_width (UI points), ring_segments
(sphere tessellation), and max_segments — a hard cap so a runaway loop drops
lines and bumps dropped instead of exhausting memory.