Tutorial 67: Deferred Rendering

CNA Tutorials  ·  Advanced Rendering

ℹ

What you’ll learn

  • How deferred shading differs from forward, and what the G-buffer stores.
  • The geometry pass and the lighting pass as separate shaders.
  • Screen-space light volumes, and why deferred scales to many lights.
  • The costs: bandwidth, transparency, and MSAA.
  • The HiDef profile a multi-target G-buffer requires, and which renderers can run it.

Before you start — Tutorial 62: Multiple Render Targets (MRT) — the G-buffer is an MRT setup. Requires a 3D-capable renderer with multiple render targets that executes a custom ShaderEffect, such as OPENGLES3 or VULKAN (see the table below for all 14 identities); the 2D-only SDL_RENDERER throws on 3D calls by default, and STUB draws nothing.

⚠

Deferred rendering requires Multiple Render Targets (MRT) and custom Effect shaders. This needs a renderer with multiple render targets and a custom ShaderEffect that actually executes, in that renderer’s own shader language. STUB has no MRT, and the 2D-only SDL_RENDERER has no 3D pipeline at all. METAL has MRT (up to eight targets) but runs a custom ShaderEffect only in SpriteBatch, so this G-buffer pass cannot be written as one there; a compiled XNA effect can fill the extra targets when CNA_METAL_COMPILED_EFFECTS=ON. Both halves are queryable: gd.SupportsCapability(CNA::GraphicsCapability::MultipleRenderTargets) (the renderer’s answer and the profile ceiling) and CustomEffects plus gd.ExecutesShaderEffectSourceEXT().

⚠

Requirements: HiDef profile. CNA’s default GraphicsProfile is Reach, which allows one simultaneous render target, and this snapshot enforces it on every renderer. SetRenderTargets with the three G-buffer bindings below throws NotSupportedException until you request HiDef (maximum four targets) in the Game constructor, before Initialize() applies your preferences. (A full-resolution G-buffer also has to fit the profile’s render-target edge limit: 2048 on Reach, 4096 on HiDef.) The full list of profile ceilings, and the errors you will see when one is exceeded, is in Tutorial 152: Reach vs HiDef.

DeferredGame() : graphics_(this) {
    graphics_.setGraphicsProfileProperty(GraphicsProfile::HiDef);
    // Or for the whole project, before the Game is constructed:
    //   CNA::SetProjectGraphicsProfileEXT(GraphicsProfile::HiDef);   // "CNA/ProjectGraphicsProfile.hpp"
}
Renderer identities MRT (HiDef) Custom shader language This tutorial’s GLSL
OPENGLES3, WEBGL2Yes, up to 4GLSL ES 3.00Runs with a #version 300 es / precision highp float; header
OPENGL33Yes, up to 4Desktop GLSLRuns with a #version 330 core header
VULKANYes (device attachment limit)SPIR-V onlyPort to SPIR-V
WEBGPUYes, 2 to 4WGSLPort to WGSL
SDL_GPUYes, 4 mixed-format targetsSPIR-V; GLSL text only in a libshaderc buildPort, or a libshaderc build
DIRECTX11YesHLSLPort to HLSL
DIRECTX9Yes (inherited default plus the profile limit)HLSL for SpriteBatch effects; a custom 3D shader is not verifiedNot verified
FNA3D, SOFTWAREYesNone: CustomEffects is falseNot available
HEADLESSAccepted (traced)Accepted, never executedRenders nothing
METALYes, up to 8Metal Shading Language, SpriteBatch effects onlyNot available (a 3D draw with a custom effect throws)
STUBNo—Not available
SDL_RENDERERNo (2D only)—Not available

Forward vs deferred rendering

In forward rendering each object is drawn once per light that affects it, or lights are accumulated in a single pass with a fixed array of lights packed into shader uniforms. The cost scales as O(objects × lights) and becomes prohibitive when both counts are large.

Deferred rendering separates geometry processing from lighting. The geometry pass runs once per object and outputs surface properties (albedo, normal, depth) into a set of off-screen textures called the G-buffer (geometry buffer). The lighting pass then iterates over all lights and reads the G-buffer, computing lighting in screen space without touching the geometry again. The cost is O(objects) + O(lights × screen pixels covered by each light volume).

G-Buffer layout

⚠

This portable G-buffer uses SurfaceFormat::Color. Non-Color render-target formats are HiDef-only and classified per renderer (probed on the GL family, VULKAN, DIRECTX11, SDL_GPU, WEBGPU and SOFTWARE, accepted by METAL; Color only on FNA3D, DIRECTX9 and HEADLESS), and a request the profile or renderer refuses silently becomes Color. For one implementation that works everywhere, reconstruct position from packed depth rather than depending on a wider target; where you can ask (gd.SupportsSurfaceFormatAsRenderTargetEXT(...)), higher-precision normals and depth are an upgrade, not a requirement.

A minimal G-buffer for Blinn-Phong lighting needs three colour targets plus a hardware depth buffer:

TargetFormatContents
RT0 — AlbedoColor (RGBA8)Diffuse colour RGB, specular power A
RT1 — NormalColor (RGBA8)World-space normal XYZ remapped from [−1,+1] to [0,1] (A unused)
RT2 — DepthColor (RGBA8)Clip-space depth packed across all four channels
Depth bufferDepth24Stencil8Hardware depth + stencil for light volume masking

Eight bits per normal axis is coarse and shows up as banding on smooth, slowly curving surfaces lit by a strong specular highlight. If that becomes visible, switch to octahedral encoding, which spends 16 bits on two channels for the same normal and leaves the remaining two free.

Geometry pass

The geometry pass draws every opaque object exactly once. The fragment shader writes to all three colour targets simultaneously using GLSL's layout(location = N) out syntax:

// gbuffer.frag — Geometry pass output
layout(location = 0) out vec4 gAlbedo;  // RT0
layout(location = 1) out vec4 gNormal;  // RT1
layout(location = 2) out vec4 gDepth;   // RT2

uniform sampler2D DiffuseTexture;
uniform float     SpecularPower;

in vec3 vWorldNormal;
in vec2 vTexCoord;

// Spread a float in [0,1] across four 8-bit channels.
vec4 packDepth(float depth) {
    const vec4 bitShift = vec4(1.0, 255.0, 65025.0, 16581375.0);
    const vec4 bitMask  = vec4(1.0 / 255.0, 1.0 / 255.0, 1.0 / 255.0, 0.0);
    vec4 res = fract(depth * bitShift);
    res -= res.gbaa * bitMask;
    return res;
}

void main() {
    gAlbedo = vec4(texture(DiffuseTexture, vTexCoord).rgb,
                   SpecularPower / 255.0);
    gNormal = vec4(normalize(vWorldNormal) * 0.5 + 0.5, 1.0);
    gDepth  = packDepth(gl_FragCoord.z);
}

CNA MRT support

CNA exposes Multiple Render Targets through GraphicsDevice::SetRenderTargets, which takes a const std::vector<RenderTargetBinding>&. RenderTargetBinding(RenderTarget2D*) is the XNA-shaped, implicit constructor, so pass .get(); the code below names the type at each element for readability:

// LoadContent — create the G-buffer targets
auto& gd = getGraphicsDeviceProperty();
int w = gd.getPresentationParametersProperty().getBackBufferWidthProperty();
int h = gd.getPresentationParametersProperty().getBackBufferHeightProperty();

// SurfaceFormat::Color throughout — the portable common denominator here.
albedoRT_ = std::make_unique<RenderTarget2D>(gd, w, h, false,
              SurfaceFormat::Color, DepthFormat::Depth24Stencil8);
normalRT_ = std::make_unique<RenderTarget2D>(gd, w, h, false,
              SurfaceFormat::Color, DepthFormat::None);
depthRT_  = std::make_unique<RenderTarget2D>(gd, w, h, false,
              SurfaceFormat::Color, DepthFormat::None);

// Bind all three as MRT — albedoRT_ owns the shared depth buffer
gBufferBindings_ = std::vector<RenderTargetBinding>{
    RenderTargetBinding(albedoRT_.get()),
    RenderTargetBinding(normalRT_.get()),
    RenderTargetBinding(depthRT_.get())
};
ⓘ

The number of simultaneous attachments can be queried: gd.GetRendererLimitEXT(CNA::RendererLimit::MaxColorAttachments) returns a value with a known flag (known on VULKAN, SDL_GPU and WEBGPU; unknown elsewhere), and gd.GetRendererCapabilityProfileEXT() carries the whole picture. XNA’s ceiling is four (the profile allows one on Reach), so three or four targets is the safe number to design against.

Building the two shaders

⚠

Both passes in this example are ShaderEffects. This snapshot’s XNA/FNA Effect Framework bytecode loader is a separate renderer-qualified path (FNA3D always, ten more identities behind default-OFF CNA_*_COMPILED_EFFECTS options). Here ShaderEffect takes renderer-native source and has no Parameters collection or techniques; uniforms go through SetUniformXxx() and textures through SetTexture(unit, tex), with Apply() called first. See Tutorial 52.

The constructor takes three arguments — the device and the two shader sources. The strings are the GLSL text itself, never a file path, so read the files yourself:

#include "Microsoft/Xna/Framework/Graphics/ShaderEffect.hpp"
#include "System/IO/File.hpp"

gBufferEffect_ = std::make_unique<ShaderEffect>(
    gd,
    System::IO::File::ReadAllText("Content/effects/gbuffer.vert.glsl"),
    System::IO::File::ReadAllText("Content/effects/gbuffer.frag.glsl"));
lightEffect_ = std::make_unique<ShaderEffect>(
    gd,
    System::IO::File::ReadAllText("Content/effects/lighting.vert.glsl"),
    System::IO::File::ReadAllText("Content/effects/lighting.frag.glsl"));

// Neither constructor throws on a compile failure. IsEffectValid() is the quick
// check; GetCompileErrorEXT() returns the compiler log (also written to stderr).
if (!gBufferEffect_->IsEffectValid() || !lightEffect_->IsEffectValid()) {
    // The shader did not compile. Do not draw with it.
}

// Sampler unit assignments, set once. Apply() first is the portable order.
gBufferEffect_->Apply();
gBufferEffect_->SetUniformInt("DiffuseTexture", 0);

lightEffect_->Apply();
lightEffect_->SetUniformInt("GAlbedo", 0);
lightEffect_->SetUniformInt("GNormal", 1);
lightEffect_->SetUniformInt("GDepth",  2);

ShaderEffect has no matrix-typed uniform setter that takes a Matrix directly — SetUniformMat4 wants raw column-major floats. One small helper keeps the call sites readable:

// ShaderEffect's matrix setter takes raw column-major floats.
static void SetMat4(ShaderEffect& fx, const char* name, const Matrix& m) {
    float cm[16];
    m.ToColumnMajor(cm);
    fx.SetUniformMat4(name, cm);
}

Lighting pass

After the geometry pass, restore the back buffer as the render target and draw a full-screen quad. The lighting shader reads the G-buffer textures and accumulates contributions from each light. For many lights, iterate in a loop inside the shader or call the quad draw once per light with additive blending:

// lighting.frag — single point light contribution
uniform sampler2D GAlbedo;
uniform sampler2D GNormal;
uniform sampler2D GDepth;
uniform mat4      InvViewProjection;
uniform vec3      LightPos;
uniform vec3      LightColor;
uniform float     LightRadius;
uniform vec3      CameraPos;

in vec2 vTexCoord;
out vec4 fragColor;

// Inverse of the geometry pass's packDepth().
float unpackDepth(vec4 rgba) {
    const vec4 bitShift = vec4(1.0,
                               1.0 / 255.0,
                               1.0 / 65025.0,
                               1.0 / 16581375.0);
    return dot(rgba, bitShift);
}

// Rebuild world-space position from packed depth and screen UV.
vec3 reconstructWorldPos(vec2 uv, float depth) {
    vec4 ndc   = vec4(uv * 2.0 - 1.0, depth * 2.0 - 1.0, 1.0);
    vec4 world = InvViewProjection * ndc;
    return world.xyz / world.w;
}

void main() {
    vec4  albedoSpec = texture(GAlbedo, vTexCoord);
    vec3  normal     = texture(GNormal, vTexCoord).xyz * 2.0 - 1.0;
    float depth      = unpackDepth(texture(GDepth, vTexCoord));
    vec3  worldPos   = reconstructWorldPos(vTexCoord, depth);

    vec3 albedo    = albedoSpec.rgb;
    float specPow  = albedoSpec.a * 255.0;

    vec3  L        = LightPos - worldPos;
    float dist     = length(L);
    L              = normalize(L);
    float attenuation = max(0.0, 1.0 - dist / LightRadius);
    attenuation   *= attenuation; // quadratic falloff

    // Diffuse
    float NdotL = max(0.0, dot(normal, L));
    vec3  diff  = albedo * LightColor * NdotL * attenuation;

    // Specular (Blinn-Phong)
    vec3  V     = normalize(CameraPos - worldPos);
    vec3  H     = normalize(L + V);
    float spec  = pow(max(0.0, dot(normal, H)), specPow) * attenuation;

    fragColor = vec4(diff + LightColor * spec, 1.0);
}

In C++, the full Draw sequence looks like this:

void Draw(const GameTime&) override {
    auto& gd = getGraphicsDeviceProperty();

    // ── Geometry pass ──────────────────────────────────────
    gd.SetRenderTargets(gBufferBindings_);
    gd.Clear(ClearOptions::Target | ClearOptions::DepthBuffer,
             Color::Black, 1.0f, 0);

    // Apply() first is the portable order for a ShaderEffect.
    gBufferEffect_->Apply();
    SetMat4(*gBufferEffect_, "View",       view_);
    SetMat4(*gBufferEffect_, "Projection", proj_);

    for (auto& obj : scene_) {
        gd.SetVertexBuffer(obj.vertexBuffer);
        gd.SetIndexBuffer(obj.indexBuffer);

        // Per-object uniforms. The program is still bound from the Apply()
        // above, so re-binding it once per object is unnecessary.
        SetMat4(*gBufferEffect_, "World", obj.world);
        gBufferEffect_->SetUniformFloat("SpecularPower", obj.specularPower);

        // Unit 0 is where "DiffuseTexture" was pointed in LoadContent().
        gBufferEffect_->SetTexture(0, *obj.texture);

        gd.DrawIndexedPrimitives(PrimitiveType::TriangleList,
                                 0, 0, obj.vertexCount,
                                 0, obj.primitiveCount);
    }

    // ── Lighting pass ──────────────────────────────────────
    gd.SetRenderTarget(nullptr);
    gd.Clear(Color::Black);

    lightEffect_->Apply();

    // Bind the G-buffer on the units assigned in LoadContent().
    // SetTexture takes a reference, and RenderTarget2D derives from Texture2D.
    lightEffect_->SetTexture(0, *albedoRT_);
    lightEffect_->SetTexture(1, *normalRT_);
    lightEffect_->SetTexture(2, *depthRT_);

    SetMat4(*lightEffect_, "InvViewProjection", Matrix::Invert(view_ * proj_));
    lightEffect_->SetUniformVec3("CameraPos",
                                  cameraPos_.X, cameraPos_.Y, cameraPos_.Z);

    gd.setBlendStateProperty(BlendState::Additive);
    for (auto& light : lights_) {
        const Vector3 lightColor = light.color.ToVector3();
        lightEffect_->SetUniformVec3("LightPos",
                                      light.position.X,
                                      light.position.Y,
                                      light.position.Z);
        lightEffect_->SetUniformVec3("LightColor",
                                      lightColor.X, lightColor.Y, lightColor.Z);
        lightEffect_->SetUniformFloat("LightRadius", light.radius);
        DrawFullscreenQuad(gd);
    }
    gd.setBlendStateProperty(BlendState::Opaque);
    // No gd.Present(): Game presents after Draw() returns, so a manual call
    // would present the frame twice.
}

Note what is not in that loop: there is no per-light Apply(). Apply() binds the compiled program, and the program stays bound across the whole light loop, so calling it again per light would only rebind what is already there. The uniforms change per iteration; the program does not.

Screen-space light volumes

Drawing a full-screen quad per light is wasteful when a light only illuminates a small region of the screen. A better approach is to draw a sphere mesh (for point lights) or cone mesh (for spot lights) that covers exactly the light's screen-space footprint. The stencil buffer can be used to avoid lighting pixels that the camera is inside the light volume from the wrong side.

For a basic implementation the full-screen quad approach is sufficient and easier to reason about. Optimise to light volumes once the correctness is established.

Advantages: handling many lights

With forward rendering, 100 dynamic point lights typically requires 100 draw calls per object or a large shader loop. With deferred rendering the geometry pass is always one draw call per object regardless of light count. Adding 100 more lights costs 100 more full-screen quad draws in the lighting pass — an additive cost independent of scene complexity.

Disadvantages

  • Transparency — transparent objects cannot be stored in the G-buffer correctly because their fragments must blend with what is behind them. The standard workaround is to render all opaque geometry through the deferred pipeline, then render transparent objects in a separate forward pass on top.
  • MSAA — hardware MSAA combined with MRT is supported where every target in the set has the same applied sample count: on the GL family (OPENGLES3, OPENGL33, WEBGL2, true MRT MSAA with a resolve) and on VULKAN; mismatched counts throw. The resolved multisampled G-buffer is more memory-hungry and shading it per sample is a further cost, so many deferred renderers use FXAA or TAA as a post-process antialiasing alternative instead (see Tutorial 65).
  • Memory bandwidth — reading and writing several G-buffer textures per pixel has a significant bandwidth cost. On mobile GPUs with tile-based architectures this can be mitigated using render pass subpasses (Vulkan) or framebuffer fetch extensions (OpenGL ES).
  • Single material model — the G-buffer layout bakes in the shading model. Mixed material types (PBR + toon) require storing a material ID and branching in the lighting shader.
  • Stock effects write one target — BasicEffect and the other stock effects write attachment 0 only, so a G-buffer fill needs your own ShaderEffect (as above) or a compiled effect.