MIT License NuGet

Home / Features / Render pipeline

The render pipeline

There is no frame graph and no pass scheduler. The order of a frame is written down once, in Rendering/RenderPass.cs, and every backend follows it. That costs some scheduling generality; it buys a pipeline you can read in an afternoon and compare pixel for pixel across four graphics APIs.

A frame, in order

FrameStart Compute Shadow Scene AfterScene Compute Post FinalBlit Overlay
PhaseWhat runs there
FrameStart ComputeSky and atmosphere LUTs, cloud noise, SDF voxelisation, DDGI probe tracing — anything the scene pass will sample.
ShadowCascaded sun shadows plus one punctual slot, rendered into a single depth atlas.
SceneOpaque and transparent geometry, sky, 3D sprites. Writes scene colour, depth and motion vectors.
AfterScene ComputeGTAO, TAA resolve, bloom downsample chain, debug view generation.
PostTonemap and composite of the post chain into the final colour target.
FinalBlitResolve to the swapchain, including design-resolution scaling.
Overlay2D sprites, shapes and MSDF text, drawn after everything else in screen space.

One pass is missing from that strip because it is conditional: OutlineMask sits between the AfterScene compute phase and Post, and it renders the objects that asked for a screen-space outline into a mask that the composite step then dilates. All four backends implement it, and it costs nothing on frames where nothing requested an outline — the backend early-outs before allocating the mask target.

The pass identifiers

Passes are named by an enum rather than by string, which means a backend that forgets one fails to compile rather than failing to draw.

public enum RenderPassId
{
    Shadow,
    Scene,
    Post,
    OutlineMask,
    FinalBlit,
    Overlay
}

The two compute phases are not pass identifiers. They are points in the schedule where registered compute work is dispatched, described further down.

The per-pass state contract

Each pass is described by a PassDesc carrying its colour, depth and velocity targets. The value of a fixed schedule is not the schedule itself — it is that the rules around each pass can be stated in three lines and then relied on.

Contract

Resource states are transitioned at pass boundaries, never inside a pass body. No bindings survive from one pass to the next; every pass rebinds what it needs. Viewport and scissor are set by the pass framework, and the Shadow pass is the one place that overrides the viewport itself, because it renders four tiles into one atlas.

That contract is why the four backends stay comparable. A Vulkan barrier, a D3D12 resource transition and a Metal encoder boundary all land in the same place, so a divergence between two backends is a bug in one pass rather than a difference in scheduling philosophy.

FrameSchedule: the slots everyone shares

Passes do not look each other up. They read and write a small set of static slots on FrameSchedule, which is the whole of the inter-pass communication surface.

SlotTypeMeaning
SceneColorRenderTarget?HDR scene colour written by the Scene pass.
SceneDepthRenderTarget?Scene depth, read by GTAO, TAA and the debug views.
SceneVelocityRenderTarget?Motion vectors; null when motion vectors are off.
ShadowMapRenderTarget?The shared cascade atlas.
PostColorRenderTarget?Target the Post pass composites into.
RenderShadowAction<IGraphics>?Callback the Shadow pass invokes to draw casters.
RenderPostAction<IGraphics, RenderTarget>?Callback the Post pass invokes to composite.
BloomTexturestring?Name of the bloom result, or null when bloom is off.
AoTexturestring?Name of the AO result, or null.
SceneColorOverridestring?Lets an effect substitute its own output for scene colour.
SkyViewTexturestring?Sky-view LUT published by the atmosphere effect.
CloudNoiseTexturestring?Cloud noise volume name.
AerialLutTexturestring?Aerial-perspective LUT name.
TaaActiveboolTrue when TAA is resolving, so the camera applies jitter.

The string? slots are deliberate. An effect publishes a texture under a compute:// name and writes that name into the slot; a consumer that finds null simply takes its unlit path. Switching off an effect and failing to initialise it therefore produce the same, correct, frame.

The two compute phases

Compute work is not hard-wired into the schedule. Effects register themselves against a phase, and the phase decides only whether they see the scene or precede it.

public enum ComputePhase
{
    FrameStart,
    AfterScene
}
// Returns false when the backend cannot
// run compute, or Initialize failed.
// Either way nothing is left behind.
bool ok = FrameSchedule.RegisterCompute(
    graphics, new MyEffect());

Contract. A compute effect registers itself into one of the two compute phases and must leave no residue when a backend cannot run it. That is why the same scene code produces a correct frame with GI, AO and TAA all switched off.

Post-processing and compute effects walks the effects that ship in the box; Writing a compute effect is the how-to.

What a fixed schedule costs you

Worth stating plainly, because it is the central design trade of the renderer.

  • You cannot add a pass from application code. Adding one means editing the enum and every backend. Compute effects exist precisely so that most extensions do not need a new pass.
  • There is no automatic barrier placement or lifetime analysis. Targets are allocated for the frame, not aliased by a resource allocator.
  • No G-buffer, no deferred path. Shading is forward, organised as opaque, transparent and fade groups with cull variants. Many lights per pixel is not what this renderer is built for — the punctual budget is eight per frame.
  • In exchange: the whole frame is one readable file, four backends can be diffed against each other, and a wrong pixel has a small number of possible causes.