Tutorial 67: Deferred Rendering

CNA Tutorials  ·  Advanced Rendering

Deferred rendering requires Multiple Render Targets (MRT) and custom Effect shaders. This is only available on the EASYGL and VULKAN backends. The SDL_RENDERER backend does not support MRT.

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, position) 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

A minimal G-buffer for Blinn-Phong lighting needs four channels of data per pixel:

TargetFormatContents
RT0 — AlbedoColor (RGBA8)Diffuse colour RGB, specular power A
RT1 — NormalRgba1010102 or HalfVector4World-space normal XYZ (A unused)
RT2 — Position (optional)Vector4 / HdrBlendableWorld-space position XYZ (can be reconstructed from depth)
Depth bufferDepth24Stencil8Hardware depth + stencil for light volume masking

Storing world-space position is the simplest approach. In production, position is typically reconstructed from depth using the inverse view-projection matrix to save bandwidth.

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 gPosition;  // RT2

uniform sampler2D DiffuseTexture;
uniform float     SpecularPower;

in vec3 vWorldPos;
in vec3 vWorldNormal;
in vec2 vTexCoord;

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

CNA MRT support

CNA exposes Multiple Render Targets through the RenderTargetBinding array overload of GraphicsDevice::SetRenderTargets:

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

albedoRT_   = std::make_unique<RenderTarget2D>(gd, w, h, false,
                SurfaceFormat::Color, DepthFormat::Depth24Stencil8);
normalRT_   = std::make_unique<RenderTarget2D>(gd, w, h, false,
                SurfaceFormat::Rgba1010102, DepthFormat::None);
positionRT_ = std::make_unique<RenderTarget2D>(gd, w, h, false,
                SurfaceFormat::Vector4, DepthFormat::None);

// Bind all three as MRT — albedoRT_ owns the shared depth buffer
RenderTargetBinding bindings[] = {
    RenderTargetBinding(*albedoRT_),
    RenderTargetBinding(*normalRT_),
    RenderTargetBinding(*positionRT_)
};
// Store for use in Draw:
gBufferBindings_ = std::vector<RenderTargetBinding>(
    std::begin(bindings), std::end(bindings));

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 GPosition;
uniform vec3      LightPos;
uniform vec3      LightColor;
uniform float     LightRadius;
uniform vec3      CameraPos;

in vec2 vTexCoord;
out vec4 fragColor;

void main() {
    vec4 albedoSpec = texture(GAlbedo,   vTexCoord);
    vec3 normal     = texture(GNormal,   vTexCoord).xyz * 2.0 - 1.0;
    vec3 worldPos   = texture(GPosition, vTexCoord).xyz;

    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_.data(),
                        static_cast<int>(gBufferBindings_.size()));
    gd.Clear(ClearOptions::Target | ClearOptions::DepthBuffer,
             Color::Black, 1.0f, 0);
    gBufferEffect_->CurrentTechnique().Passes[0].Apply();
    for (auto& obj : scene_) {
        gd.SetVertexBuffer(obj.vertexBuffer);
        gd.SetIndexBuffer(obj.indexBuffer);
        gBufferEffect_->Parameters()["World"].SetValue(obj.world);
        gBufferEffect_->Parameters()["View"].SetValue(view_);
        gBufferEffect_->Parameters()["Projection"].SetValue(proj_);
        gBufferEffect_->Parameters()["DiffuseTexture"].SetValue(obj.texture);
        gBufferEffect_->CurrentTechnique().Passes[0].Apply();
        gd.DrawIndexedPrimitives(PrimitiveType::TriangleList,
                                 0, 0, obj.vertexCount,
                                 0, obj.primitiveCount);
    }

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

    // Bind G-buffer textures
    lightEffect_->Parameters()["GAlbedo"].SetValue(*albedoRT_);
    lightEffect_->Parameters()["GNormal"].SetValue(*normalRT_);
    lightEffect_->Parameters()["GPosition"].SetValue(*positionRT_);
    lightEffect_->Parameters()["CameraPos"].SetValue(cameraPos_);

    gd.BlendState = BlendState::Additive;
    for (auto& light : lights_) {
        lightEffect_->Parameters()["LightPos"].SetValue(light.position);
        lightEffect_->Parameters()["LightColor"].SetValue(light.color.ToVector3());
        lightEffect_->Parameters()["LightRadius"].SetValue(light.radius);
        lightEffect_->CurrentTechnique().Passes[0].Apply();
        DrawFullscreenQuad(gd);
    }
    gd.BlendState = BlendState::Opaque;

    gd.Present();
}

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 does not work with MRT without extensions (requires per-sample evaluation). Use FXAA or TAA as a post-process antialiasing alternative.
  • 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.