2.3 · Renderer
A deferred Vulkan renderer built on NVRHI. Three cooperating layers — a static Renderer facade, the SceneRenderer deferred pipeline, and the Renderer2D batcher — talk to the GPU through a single global render command queue that is drained either by a real worker thread (runtime) or by the main thread at end of frame (editor).
Three layers, one queue
The renderer is split into three deliberately small layers. Each one has a clear job and the layer above never reaches past it.
Renderer
Static front door. Owns the global RenderCommandQueue, the default textures, the ShaderLibrary, and the frame index. This is what gameplay / editor / tools call.
SceneRenderer
Owns the deferred pipeline: pre-depth, four cascaded shadows + spot atlas, geometry, GTAO, SSR, composite, bloom chain, DOF, SMAA, jump flood, outline, grid. Materials and pipeline state are cached at Init().
Renderer2D
Batched 2D: quads, lines, circles, MSDF text. Used for UI, debug draws, and 2D gameplay layers. Plays cleanly on top of the SceneRenderer's final image.
The deferred pipeline
SceneRenderer drives a long but linear pass chain. Each pass reads from the previous one's outputs and writes into images that SceneRenderer::Init() allocated up front. Pipeline state objects (PSOs) are cached — they are never recreated per frame.
Pass-by-pass
| Pass | Reads | Writes | Notes |
|---|---|---|---|
| Pre-Depth | opaque meshes | depth buffer | Z prepass — lets later passes early-out on overdraw. |
| Shadow Cascades (4) | opaque meshes per cascade | 4 shadow maps | Cascaded shadow maps for the directional light. |
| Spot Shadow Atlas | opaque meshes per spot | tiled atlas | One atlas tile per shadow-casting spot light. |
| Geometry | scene meshes + materials | G-buffer (albedo, normal, MR, emissive) + depth | The deferred fill pass — one draw per material batch. |
| GTAO | depth + normal | AO buffer | Ground-truth ambient occlusion. |
| GTAO Denoise | AO buffer | denoised AO | Spatial filter to clean the AO output. |
| SSR | G-buffer + depth + previous frame | reflection buffer | Screen-space reflections for glossy materials. |
| Composite | G-buffer + AO + SSR + shadow maps + IBL | HDR scene colour | The deferred resolve: lighting + IBL + reflections combined into one HDR target. |
| Bloom Downsample | HDR scene colour | mip chain | Successive half-res passes building a thumbnail. |
| Bloom Upsample | mip chain | bloom buffer | Progressive upsample with blur for soft bloom. |
| DOF | HDR colour + depth | blurred colour | Circle-of-confusion based depth of field. |
| SMAA · Edge | colour / luma | edge texture | Subpixel morphological AA — edge detection. |
| SMAA · Blend | edge texture | blend weights | Computes per-pixel blend weights. |
| SMAA · Resolve | colour + blend weights | anti-aliased colour | Final SMAA combine. |
| Jump Flood | selection mask | distance field | Jump-flooding algorithm used by selection outlines. |
| Edge Outline | distance field | outline overlay | Selected-entity halo — editor mostly. |
| Grid Overlay | depth | final image | Editor grid — world-space lines on the ground plane. |
Submit / queue mechanism
The whole renderer is built around Renderer::Submit. You call it with a lambda; the lambda is enqueued on the global RenderCommandQueue and executed later in a context that holds the NVRHI command list. This is how every pass in the pipeline above issues its draws.
Renderer::Submit([=]()
{
nvrhi::CommandListHandle cmd = Renderer::GetCommandList();
cmd->setGraphicsState(state);
cmd->draw(args);
});
Who drains the queue depends on the threading policy:
| Policy | Used by | Submit boundary | Drained by | When |
|---|---|---|---|---|
MultiThreaded |
Runtime, headless | Crosses thread — main → render | Dedicated render thread | While main thread builds frame N+1 |
SingleThreaded |
Editor | Same thread — no boundary | Main thread | End of frame |
It is tempting to assume Renderer::Submit(lambda) always crosses a thread boundary — that the lambda runs "later, on the render thread", and that captured state must be valid then. In the editor it does not.
Main and render are the same thread; the lambda runs at end-of-frame on the same call stack you submitted from. Code that "fixes" a non-existent race by deep-copying state into the lambda will silently work in MT and waste copies in ST — or worse, code that captures by reference will silently work in ST and tear in MT.
Treat the submit as "deferred but the deferral may be zero-latency on the same thread." See Threading § "Editor vs. runtime".
Triple-buffered frames
The renderer triple-buffers per-frame resources (uniform buffers, transient descriptors, etc.). With three slots in rotation, frame N can be built on the main thread while frame N−1 is being recorded by the render thread and frame N−2 is in flight on the GPU.
This is only meaningful when CoreThreadingPolicy == MultiThreaded. In single-threaded mode the queue is drained at end-of-frame, so frames overlap only on the GPU side — CPU-side, frames are strictly sequential. Code that depends on "the render thread is one frame behind" works in runtime; in the editor that gap is zero.
Frame flow at a glance
Shaders & materials
Shaders live in Resources/Shaders/. They are HLSL — full stop. The renderer compiles them to SPIR-V through DXC and feeds the result to NVRHI. There is exactly one shading language allowed in the source tree.
Do not commit raw GLSL, raw SPIR-V, MSL, or any other shading language. New shaders go under Resources/Shaders/ in HLSL and are registered with the ShaderLibrary.
Pipeline state objects (PSOs) are cached, not built per frame. SceneRenderer::Init() is the right place to materialise every pipeline you'll need; the per-frame path looks them up by handle.
Materials hang off the asset system — see 2.9 Asset System. Texture dependencies are tracked: when a texture reloads, dependent materials are notified via OnDependencyUpdated and pick up the new image without a manual reload.
Adding a new render pass
- Write the shader in HLSL under
Resources/Shaders/. - Register it with
ShaderLibrary. - In
SceneRenderer::Init, create the cachednvrhi::GraphicsPipeline/ComputePipelineand the material that binds inputs. - Allocate any intermediate images / framebuffers there too — never per-frame.
- Insert the pass execution in
SceneRenderer::PreRenderor between two existing passes inEndScene, depending on where it fits in the swimlane diagram above. - Register shader dependencies via
Renderer::RegisterShaderDependencyso live-reload picks the new pass up.
If you find yourself constructing a graphics pipeline inside EndScene or any per-frame path, stop. Lift it into Init and reference the cached handle.
Extending
| You want… | Do this |
|---|---|
| A new full-screen post-process pass | HLSL shader + cached PSO in SceneRenderer::Init + insert in EndScene (see Adding a pass). |
| A new material type | Add to the material system + ensure the geometry pass binds the new inputs. Hook into asset dependency tracking. |
| New 2D primitive (e.g. capsule, arc) | Extend Renderer2D — new batch type + shader + flush path. Keep the batched-quad invariant. |
| Debug overlay drawn over the scene | DebugRenderer or Renderer2D on top of the final pass image — not as a new SceneRenderer pass unless it really needs the G-buffer. |
| Off-screen capture (telemetry, gRPC streaming) | Tap SceneRenderer::GetFinalPassImage() — see gRPC / Cross-System for ViewportService and CameraService. |
Pitfalls
See Submit / queue mechanism. In the editor, lambdas run on the main thread; in the runtime they cross to the render thread. Write code that is correct under both — capture by value, don't rely on "I'm definitely on a different thread now".
HLSL only. New shaders that show up in any other language will be rejected at review.
InitNVRHI PSOs and intermediate images are constructed in SceneRenderer::Init. Per-frame allocation is a bug.
Calling NVRHI directly from gameplay or editor code skips the queue and the frame-index machinery, and breaks in MT immediately. Always go through Renderer::Submit.
Key types
| Type | Header | Role |
|---|---|---|
Renderer | Hazel/src/Hazel/Renderer/Renderer.h | Static facade. Owns global queue, default textures, ShaderLibrary, frame index. |
SceneRenderer | Renderer/SceneRenderer.h | Deferred 3D pipeline. Init builds cached PSOs / images; EndScene issues the pass chain. |
Renderer2D | Renderer/Renderer2D.h | Batched 2D — quads, lines, circles, MSDF text. |
RenderCommandQueue | Renderer/ | Global lambda queue drained by render thread (MT) or main thread end-of-frame (ST). |
ShaderLibrary | Renderer/ | Compiled-shader cache. Live-reload aware via dependency tracking. |
DebugRenderer | Renderer/ | Immediate-mode debug draws (lines, gizmos). Backed by the gRPC DebugService.Draw RPC for external tooling. |
Material / Pipeline | Renderer/ | Material parameter sets and cached NVRHI pipeline state. |