Map of how Hikari owns rendering: layers, RHI vocabulary, render graph, passes, and sync points. Behavior details and pass gates live in Rendering; this page is the structural overview.
Active backends: Metal (macOS) and D3D12 (Windows) share one graph path. Linux Vulkan is scaffolding only.
Layer stack
Diagram source
flowchart TB
subgraph game_thread [Game thread]
World[World / entities]
RF[RenderFrame publish]
RCQ[RenderCommandQueue]
World --> RF
World --> RCQ
end
subgraph render_thread [Render thread]
RT[renderer/render_thread.zig]
Res[renderer_residency / consume]
Core[renderer/renderer_core.executeFrame]
Prep[prepareFrame — clusters, shadows, RT, cull]
Assemble[pipeline.assemble]
Compile[GraphBuilder.compile]
Exec[pipeline.execute]
Present[Adapter.finishOutput]
RT --> Res --> Core
Core --> Prep --> Assemble --> Compile --> Exec --> Present
end
subgraph shared_policy [Shared graphics — policy]
Graph[rendergraph/*]
RHI[rhi/types.zig]
Drivers[rendergraph/drivers/*]
Pipelines[renderer_pipelines / gbuffer / shadows]
end
subgraph platform [Platform — native only]
Adapter[renderer_frame Adapter]
Enc[pass_encoder]
Native[Metal / D3D12 native]
Adapter --> Enc --> Native
end
RF --> RT
RCQ --> Res
Assemble --> Graph
Compile --> Graph
Exec --> Drivers
Drivers --> Enc
Graph --> RHI
Compile --> RHI
Core --> Adapter| Layer | Owns | Must not own |
|---|---|---|
| Game / World | Visibility, materials, lights, publish snapshot | Native GPU objects |
renderer_core | Frame algorithm: prepare → assemble → compile → execute → present | API-specific barriers |
rendergraph | Pass order, resource decls, liveness, barriers, queue acquires | Native command lists |
rhi/ | States, queues, formats, barrier POD, capabilities | Allocation / encoding |
Platform Adapter | Resource tables, encode, GPU signal/wait, present | Pass ordering / feature gates |
| Native Metal/D3D12 | Device, heaps, PSO bytes, swapchain | Graph policy |
Invariant: policy above RHI; platforms only lower. Do not fork frame orchestration per API. Shared-type renderer policy state is renderer.common (CommonState).
Host capability is declared, never probed: every renderer states pub const surface_support: renderer_contract.SurfaceSupport (.ui_only for Class 1 test/headless hosts, .scene_composite for the full pipeline). validateCommonState checks the claim against the machinery scene assemble dispatches through, and encodeSurfaceAssemble returns error.SurfaceClassUnsupported rather than downgrading a scene surface to a UI-only graph. Inferring support from incidental fields is what let that downgrade happen silently in release.
Shared code never asks what a backend has
The same rule applies to every structural question. Shared render code never branches on @hasDecl / @hasField; a probe compiles clean on both hosts and on every stub, so a rename would delete a code path on one backend instead of failing a build. Three replacements, in order of preference:
- Require the decl and let the backend with nothing to do write an explicit no-op (
MetalRenderer.waitBeforeNextFrame,MetalResources.endUpload) whose surrounding contract says why it is empty. - Make a genuine capability difference a declared constant both sides give a value to (
BackendOps.raytraced_light_clusters_slot). - When the question is unavoidable, give it one named predicate with its backstop contract written next to it (
rendergraph/backend.supportsPluginPostProcess,dispatchesByKind).
renderer_shape.zig states the shape shared code requires of a renderer, because Zig only analyses the branches it reaches. zig build check-probes (a dependency of zig build test) fails the build on a new branch probe; a probe used as a @compileError assertion is the fix and is not counted, and the remaining allowlisted branches carry a reason in src/hikari/build/check_probes.zig.
Ownership and threading
Diagram source
sequenceDiagram
participant GT as Game thread
participant Lock as world.lock
participant Pub as RenderFrame triple-buffer
participant RT as Render thread
participant GPU as GPU queues
GT->>Lock: fill write RenderFrame
GT->>Lock: unlock
GT->>Pub: publish()
Note over RT: does not take world.lock
RT->>Pub: consume latest
RT->>RT: applyRenderCommands / residency
RT->>RT: prepareFrame
RT->>RT: assemble + compile graph
RT->>GPU: execute passes + barriers
RT->>GPU: present / finishOutput- Frames in flight:
graphics/renderer/frames.zig→frames_in_flight = 3. Ringed uniforms/object data; both Metal and D3D12 wait per ring slot before rewrite (Metal last CB on slot; D3D12 fence token). Present/swapchain pacing is orthogonal. - Two handle dialects (do not merge):
- Residency:
RenderHandle/TypedHandle(native/handle.zig) - Graph/RHI:
BufferHandle/TextureHandle/ graph handles (rhi/types.zig,rendergraph/types.zig)
- Residency:
Module map
src/hikari/src/graphics/
├── api.zig Public barrel
├── rhi/ Backend-neutral vocabulary (types, barriers, queues)
├── renderer/ Frame policy: core, prepare, FIF, residency consume, pass encode
├── rendergraph/ Graph builder, compile, schedule, passes/, drivers/
├── shadow/ Atlas policy, matrices, per-face culling
├── raytracing/ TLAS policy, bindings, geometry update rules
├── material/ PSO kinds, shader artifacts, uniforms
├── particles/ GPU particle runtime, emitter commands, live-bound
├── residency/ CPU/GPU residency, bindless heap, geometry_residency, telemetry
├── effects/ G-buffer, content compute, GPU cull, visual look, lighting helpers
├── gpu/ Vertex buffers, object data, material table, staging
└── debug/ Visualizers, viewport pick/state
src/hikari/src/platform/{macOS,Windows}/graphics/
├── renderer.zig Facade + EngineHandles
├── renderer_frame.zig Graph backend + queue prepare/acquire/release/abort hooks
├── queue_timeline.zig Native event/fence Signal·Wait for graph timeline tokens
├── pass_encoder.zig begin / barrier / bind / finish (typed compute lists when async)
├── content_compute.zig Content-compute ring dispatch (uses graph release for cross-queue tokens)
└── passes/backend_ops.zig Draw/dispatch emission table
src/hikari/sdk/src/render_config.zig Feature gates (POD)RHI vocabulary
graphics/rhi/types.zig is the only place to extend graph sync language.
Diagram source
flowchart LR
subgraph resources [Resources]
Tex[TextureHandle]
Buf[BufferHandle]
AS[AccelerationStructureHandle]
end
subgraph queues [QueueClass]
G[graphics]
C[compute]
Cop[copy]
end
subgraph domain [PipelineDomain]
Pg[graphics]
Pc[compute]
Pcop[copy]
Prt[ray_tracing]
end
subgraph sync [Sync POD]
RS[ResourceState]
PS[PipelineStage]
AM[AccessMask]
RB[ResourceBarrier]
ST[SubmissionToken]
end
Tex --> RB
Buf --> RB
AS --> RB
RS --> RB
PS --> RB
AM --> RB
G --> ST
C --> ST| Concept | Role |
|---|---|
ResourceState | Logical layout: color/depth attach, shader read/write, copy, AS build/read, present, … |
PipelineStage / AccessMask | Hazard granularity for barriers |
QueueClass | graphics / compute / copy — independent of PipelineDomain |
SubmissionToken | { queue, value } for timeline waits |
Capabilities | What the device actually supports (async compute, copy queue, bindless, RT, …) |
Backends map these to Metal/D3D12 transitions; they must not invent parallel state enums for the graph.
Render-graph lifecycle (one frame)
Diagram source
flowchart TB
A[Adapter.prepareFrame] --> B[GraphBuilder.init]
B --> C[pipeline.assemble]
C --> D[declare textures / FrameTargets]
C --> E[add conditional passes + accesses]
E --> F[graph_builder.compile]
F --> G[liveness cull dead passes]
F --> H[emit CompiledBarrier list]
F --> I[emit QueueSyncEdge acquires]
G --> J[RenderGraphSchedule]
H --> J
I --> J
J --> K[pipeline.execute]
K --> L[for each live pass: apply barriers → driver.run]
L --> M[Adapter.finishOutput / present]Assemble vs execute
| Phase | File | Job |
|---|---|---|
| Assemble | pipeline.assemble | Given RenderPipelineConfig + FrameCtx, create targets and register passes in order |
| Compile | compile.zig | Cull unreachable work; derive barriers from access transitions; record cross-queue releases (producer) and acquires (consumer) for every graphics↔compute edge |
| Execute | pipeline.execute + drivers | prepare producer token → queueAcquire → barriers/work → flushPassBatch (when batch_boundary) → publish queueRelease; abort clears staged state on error |
Submit batching: CompiledPass.batch_boundary forces a flush only on structural boundaries: queue switch, non-empty acquires/releases, and the last pass. GPU profiling does not participate in scheduling; Metal writes pass timestamp pairs into the active MTL4CommandBuffer, while D3D12 writes queries into the active command list. D3D12 keeps one closed command list per pass and batches ExecuteCommandLists; Metal shares one open command buffer per QueueClass (frame_submission.zig) and commits at the structural boundary. Graph flush is batch_boundary / flushPassBatch; encoder finish only ends the pass. Present stays frame-owned in finishOutput.
Graph texture allocation distinguishes scratch from history. Scratch textures with exact matching descriptors may share a physical pool entry when their compiled lifetimes are strictly disjoint, all accesses use the graphics queue, and the first logical texture's terminal state restores the next one's initial state. Cross-queue, overlapping, storage-only, or descriptor-mismatched textures remain separate. Persistent histories are declared per successfully encoded surface: an omitted history enters the same GPU-safe retirement window as cached scratch, while a skipped surface or failed encode leaves its prior declaration intact. Residency reports transient and persistent rows separately; retiring persistent rows are awaiting that safety window.
Pass kinds are the closed set in pass_registry.Kind. Platform registers callbacks once; assemble only schedules which kinds run this frame.
Pass inventory and data flow
Structural order (conditional rows omitted when gated off):
Diagram source
flowchart TB
Prep["Pre-graph: clusters · shadow pack · TLAS · GPU cull · content ensure"]
Prep --> SD[shadow_depth]
Prep --> CC[content_compute*]
SD --> GB[gbuffer_geometry]
CC --> GB
GB --> DP[depth_pyramid]
GB --> Late["occlusion_cull + gbuffer_geometry_late"]
Late --> DP
GB --> VTM[velocity_tile_max]
SD --> VolD[volumetric_density]
DP --> VolD
GB --> VolD
VolD --> VolI[volumetric_inject]
VolI --> VolG[volumetric_integrate]
GB --> AO[ambient_occlusion ± temporal]
DP --> AO
AO --> GI[global_illumination ± temporal]
GI --> Lit{deferred_lighting / raytraced_direct+light}
AO --> Lit
GB --> Lit
Lit --> Bounce[gi_bounce_capture]
Bounce --> Ref[reflections ± temporal]
Lit --> Ref
Ref --> VolC[volumetric_composite]
VolC --> TC[transmission_capture]
Ref --> TC
TC --> FT[forward_transparent]
VolG --> FT
FT --> TAA[temporal_reconstruction TAA/TAAU]
TAA --> Post["exposure → auto_focus / DOF → motion_blur"]
FT --> Post
VTM --> Post
Post --> Bloom[bloom_extract → blur ×2]
Bloom --> TM["tonemap (+bloom) / debug_visualizers"]
TM --> UI[ui]
UI --> BB[(backbuffer / present)]Gates, plugin post-process slots, and notes: Rendering — Implemented pipeline. Volumetric density/inject/integrate are scheduled before AO/lighting/reflections so froxel work can overlap those graphics passes on the async compute queue (graphics-queue fallback when the device has no usable async compute). Composite is always in the render domain after lighting, before glass and before TAA — fog therefore enters colour history (motion-only trail accepted). Glass applies the same integrated volume in forward. Camera post (exposure, DOF, motion blur) meters the post-reconstruction image, then bloom/tonemap.
Feature layers (orthogonal)
Diagram source
flowchart LR
subgraph A [Contract A — bus]
GBuf[G-buffer attachments]
end
subgraph B [Contract B — classical]
Def[Deferred + raster atlas]
end
subgraph C [Contract C — RT optional]
RTS[RT scene TLAS]
RTH[RT hit shading / bindless]
end
subgraph D [Contract D — GI]
GI[Indirect diffuse SS / RT]
end
subgraph E [Contract E — content]
AC[Async compute producers]
end
AC -.->|optional vertex texture| GBuf
GBuf --> Def
GBuf --> RTS
RTS --> RTH
Def --> Post[AO / GI / reflections / vol / TAA / bloom / tonemap / UI]
RTS --> Post
GBuf --> GI
RTS --> GI
GI --> DefMaster switches live in RenderPipelineConfig (sdk/src/render_config.zig). Disabled features skip assemble (no passes / no PSOs where documented) — see volumetrics zero-cost-off and RT prepare short-circuit in Rendering.
Sync points
1. CPU frames-in-flight (shared contract)
Canonical source: graphics/renderer/frames.zig.
Protocol (Metal and D3D12 — same logic, different wait primitive):
BEGIN:
slot = frame_counter % frames_in_flight // default 3
waitUntilSlotComplete(slot) // Metal: last CB on slot; D3D12: fence token on slot
write uniforms / lights / object data / per-slot heaps into ring[slot]
advance orphan free lists (age ≥ frames_in_flight)
RECORD + SUBMIT (binds ring[slot])
END:
markSlotSubmitted(slot, token) // Metal: track last CB; D3D12: ring_timeline + fence signal
present / swapchain pacing // orthogonal — protects backbuffers only| Rule | Detail |
|---|---|
| SSOT depth | frames.frames_in_flight; native mirrors HIKARI_MTL_FRAMES_IN_FLIGHT / HIKARI_D3D12_FRAME_POOL_COUNT (compile-time assert) |
| Ring wait | Always required before rewriting a slot — never substitute present/swapchain depth |
| Present wait | Independent; bounds backbuffer reuse (buffer_count, often 2) |
| Deferred free | Orphan age 0 (idle only) or ≥ frames_in_flight |
| Handle dialects | Residency RenderHandle vs graph RHI handles — do not merge |
Tests: frames.zig (slot math, SlotTimeline, RingWriteTracker, protocol simulation) run under zig build test / graphics tests.
GPU pass timestamps
| Backend | Stamp | Resolve |
|---|---|---|
| Metal | Per-slot MTL4CounterHeap timestamps (writeTimestampIntoHeap start/end pairs). Graph CBs stamp at create/commit; pre-graph producers sharing a CB stamp when the current pass changes. | After ring-slot wait via resolveCounterRange + queryTimestampFrequency |
| D3D12 | TIMESTAMP query heap pair per pass + ResolveQueryData on the list. Graph render/compute passes and pre-graph raw lists (beginTimedPass) both write. | After ring-slot fence wait + map readback |
Shared publish: profiler/gpu_timing.zig → Profiler UI GPU rows. Gated by compile-time -Dprofiler-timing and runtime Recording. Independent of --profiler-residency (residency tabs). Pre-graph producers (light_binning, gpu_culling.prepare, skinning_writeback, raytracing.acceleration_structures) and editor viewport_composite / editor_ui_layer are timestamped the same way as graph passes.
2. Intra-queue barriers (same QueueClass)
Compiled from consecutive PassAccess transitions on a resource (mip/layer aware for textures):
CompiledBarrier {
before → after ResourceState
source/destination stages + access
optional texture_range
}Applied by pass_encoder immediately before the consuming pass encodes.
3. Cross-queue sync (QueueSync + QueueSyncEdge)
Bidirectional: graphics→compute (e.g. depth/shadow → volumetric inject) and compute→graphics (content compute → G-buffer; integrated volumes → forward/composite). Same-queue edges are no-ops. Content compute and engine compute share one graph-owned path.
Diagram source
sequenceDiagram
participant Prod as Producer queue
participant QS as QueueSync map
participant Cons as Consumer queue
Prod->>QS: queuePrepareRelease → nextSignal
Prod->>Prod: submit work + native Signal (Metal event / D3D12 fence)
Note over Prod: After successful submit
Prod->>QS: queueRelease → recordProduced
Note over Cons: Before consumer encode
Cons->>QS: producedToken(resource_id)
Cons->>Cons: queueAcquire → native Wait
Cons->>Cons: sample / read resourceRules (queue_sync.zig + platform queue_timeline.zig):
- Graph owns timeline counters, produced-token map, and compiled release/acquire edges; backends only Signal/Wait.
- Resource ids cover graph textures, buffers, and acceleration structures (
crossQueueResourceId), not only content-compute ring slots. - Identity is stable across frames (
markCrossQueueProducerfor lag-1 reads with no in-frame writer). - Resize / recreate →
QueueSync.clear()so ids cannot alias stale tokens. - Metal encodes waits when a shared command buffer opens and the prepared signal at flush; only a no-submission pass needs the checked signal-only fallback. Intra-queue hazards use per-resource
MTLFences (platform/macOS/graphics/barriers.zig). - No water/volumetrics ⇒ no edges, no signals (empty-list checks only).
- Pre-graph jobs (light binning, GPU cull phase 1, skinning writeback) publish
queue_sync.pre_graph_resource_idon the graphics timeline after prepare submits. The first graph pass on eachQueueClassacquires that token. Metalframe_fenceremains an extra safety net; it is not the Windows contract.
4. Texture upload (residency)
| Backend | Path | Sync |
|---|---|---|
| D3D12 | COPY queue upload → DIRECT promote | GPU fence wait (no CPU spin); render thread pollPendingTextureUploads |
| Metal | CPU replaceRegion | Completes before publish ready |
States: pending → uploading → ready / failed. Loading gates treat uploading as not ready.
5. Ray-tracing structures
- Shared
raytracing_scene.zig: reuse / refit / rebuild TLAS; static BLAS compact; retire afterframes_in_flight. Structures are recorded onto the pre-graph buffer and fence-ordered; compaction is the only work allowed to drain the GPU, once per batch rather than once per structure (see Submission model inrendering.md). - Effective RT modes need
scene_ready(non-empty opaque TLAS), not device support alone. - The renderer-global bindless heap activates for raster material preparation; RT hit shading reuses the same stable indices.
6. Present / output
- Standalone: graph writes backbuffer → present.
- Editor inset viewport: scene finishes in graph;
_engine/compositeblit + chrome are outside the scene graph (renderer_viewport.zig).
Lite vs full schedule (mental model)
Diagram source
flowchart LR
subgraph lite [Typical small-title lite]
S1[shadow_depth]
S2[gbuffer_geometry]
S3[depth_pyramid?]
S4[deferred_lighting]
S5[forward_transparent?]
S6[tonemap?]
S7[ui]
S1 --> S2 --> S3 --> S4 --> S5 --> S6 --> S7
end
subgraph full [Optional heavy stack]
F1[+ AO / GI temporal]
F2[+ reflections temporal]
F3[+ volumetric inject/integrate/composite]
F4[+ TAA]
F5[+ exposure / DOF / motion blur]
F6[+ bloom]
F7[raytraced_direct + raytraced_lighting / RT scene]
end
lite -.->|feature gates| fullTurning RT and volumetrics off removes those passes and (for volumetrics) PSO/alloc work. Deferred G-buffer + shadow atlas remain the baseline cost.
Where to change what
| Goal | Touch |
|---|---|
| New pass / reorder | pass_registry.Kind + passes/ + pipeline.assemble + driver + platform callback |
| New resource state / queue hazard | rhi/types.zig then compile/encoder lowering |
| Feature toggle | render_config.zig + assemble gates (+ project settings schema if user-facing) |
| Draw/dispatch encoding | platform/*/passes/backend_ops.zig / pass_encoder.zig |
| G-buffer layout | effects/gbuffer.zig + geometry/lighting shaders (both backends) |
| Shadow atlas policy | shadow/shadow_types.zig / shadow/shadow_pass.zig |
| Cross-queue producer | Graph accesses on differing QueueClass → compile emits release+acquire; queue_timeline Signal/Wait; markCrossQueueProducer for cross-frame only |
| Async volumetric inject | passes/volumetric.zig queue param + pipeline.assemble earliest-after-depth placement + supportsAsyncComputeQueue |
Code map
Shared modules (src/hikari/src/graphics/):
| File | Responsibility |
|---|---|
renderer/renderer_core.zig | Frame/graph algorithm over a platform Adapter: prepare → assemble → compile → execute → present |
renderer/frame_epoch.zig | Editor multi-surface protocol (present list, dual-publish, chrome rebind) |
renderer/renderer_shared.zig | Facade; split under renderer_shared/ (frame, commands/ + facade, consume, prepare, emit/ + facade) |
renderer/renderer_prepare.zig | Facade; prepareFrame stages under renderer_prepare/ (temporal, objects, rt, uniforms, lighting, …) |
renderer/renderer_pipelines.zig | Fullscreen/effect PSOs, engine pipelines, UI atlas, optional RT |
effects/content_compute.zig | Content-compute producers, lag policy, ring helpers |
effects/gpu_culling.zig | Cull prep + slot capacity helpers + shared frustum-plane extraction |
shadow/shadow_culling.zig | Per-face/class meshlet work staging for GPU visible-triangle expansion |
renderer/renderer_lifecycle.zig | Teardown: residency, PSOs, materials, textures |
renderer/renderer_residency.zig | Per-frame command drain + consumeRenderFrame |
renderer/renderer_common.zig | EngineHandles, primary target helpers, feature availability, facade setters |
renderer/dynamic_buffer_manifest.zig | Compile-time shared frame-ring buffer membership and create/assign/destroy lifecycle; backend types come from platform/*/graphics/dynamic_buffer_types.zig |
renderer/pass_encoder.zig / renderer/pass_targets.zig | PassEncoder contract + graph texture unwrap |
rendergraph/drivers/ | Shared pass run drivers |
rendergraph/graph.zig | Facade; types / schedule / compile / builder |
rendergraph/pass_registry.zig | Pass-kind registry + typed callbacks |
shadow/shadow_pass.zig | Atlas size policy, face uniforms, per-face draws |
renderer/platform_resources.zig | PlatformResources(Backend) tables |
material/shader_uniforms.zig | Global HikariUniforms CPU mirror |
residency/bindless_heap.zig | Renderer-global stable buffer/texture indices (raster + RT) |
gpu/geometry_table.zig | Per-instance bindless geometry rows for meshlet attribute-pull + RT resolve |
gpu/material_table.zig | Surface-local, frame-ring shared raster/RT material records indexed by object instance id |
residency/geometry_residency.zig | CPU mesh residency ledger (--profiler-residency=cpu) |
residency/render_residency/telemetry.zig | GPU ownership telemetry (--profiler-residency=gpu) |
raytracing/raytracing_scene.zig / raytracing/raytracing_bindings.zig | TLAS policy + instance packing |
material/pipeline_kind.zig | PSO kind tags + color/depth/blend policy |
effects/gbuffer.zig | Attachment slots + target_formats |
Platform layout (Metal / D3D12 — native only):
| File | Responsibility |
|---|---|
platform/*/graphics/renderer.zig | Device lifecycle, handles, tick |
renderer_frame.zig | Render-graph backend + FrameAdapter |
pass_encoder.zig | Native begin/barrier/bind/finish |
renderer_viewport.zig | Editor insets and offscreen composite |
resources.zig | Backend traits for PlatformResources |
passes/backend_ops.zig | Geometry, lighting, shadow draw emission |
shadow_renderer.zig | Atlas GPU resources + pass create |
Do not dump new policy into the platform facade. Shared behaviour goes under graphics/.
Related docs
| Doc | Focus |
|---|---|
| Rendering | Contracts, pass gates, lighting/RT/TAA behavior |
| Shaders | Packages, PipelineKind, binding layouts |
| Lifecycle | Session tick → publish → render |
| Platforms | Metal / D3D12 / Linux status |
| Volumetric media | Global fog and bounded smoke, froxel path, async compute queue contract, zero-cost-off |
src/hikari/AGENTS.md | Agent invariants for graphics / handles / FIF |