Tutorial 67: Deferred 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:
| Target | Format | Contents |
|---|---|---|
| RT0 — Albedo | Color (RGBA8) | Diffuse colour RGB, specular power A |
| RT1 — Normal | Rgba1010102 or HalfVector4 | World-space normal XYZ (A unused) |
| RT2 — Position (optional) | Vector4 / HdrBlendable | World-space position XYZ (can be reconstructed from depth) |
| Depth buffer | Depth24Stencil8 | Hardware 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.