Tutorial 67: Deferred 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.
Before you start — Tutorial 62: Multiple Render Targets (MRT) — the G-buffer is an MRT setup. Requires a 3D-capable renderer such as OPENGLES3 or VULKAN; the thirteen 2D-only renderers (SDL_RENDERER, DIRECT2D, CANVAS, HTML_DOM, SKIA, BLEND2D, FREEDIRECT, DIRECTX1, GDI, SVG_DOM, OPENVG, NANOVG, PIXIJS) throw on 3D calls. Requires a renderer with render-target support.
Deferred rendering requires Multiple Render Targets (MRT) and custom Effect shaders. This needs a renderer with multiple render targets and a working custom ShaderEffect — OPENGLES3 and VULKAN both qualify. OPENGLES2 and WEBGL1 lose MRT entirely, and the thirteen 2D-only renderers have no 3D pipeline at all.
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. The OPENGLES3/Vulkan configurations in this tutorial defer to the framework's Color-only public gate. Skia and IGL have renderer-qualified format promotions, but not a common portable MRT contract for this shader path. Reconstruct position from packed depth rather than depending on a wider target.
A minimal G-buffer for Blinn-Phong lighting needs three colour targets plus a hardware depth buffer:
| Target | Format | Contents |
|---|---|---|
| RT0 — Albedo | Color (RGBA8) | Diffuse colour RGB, specular power A |
| RT1 — Normal | Color (RGBA8) | World-space normal XYZ remapped from [−1,+1] to [0,1] (A unused) |
| RT2 — Depth | Color (RGBA8) | Clip-space depth packed across all four channels |
| Depth buffer | Depth24Stencil8 | Hardware 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's constructor is explicit and takes a Texture*, so pass .get() and name the type at each element:
// 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())
};
There is no GraphicsCapabilities type in CNA, so the maximum number of simultaneous attachments cannot be queried at runtime. Three or four targets is the safe ceiling to design against.
Building the two shaders
Both passes in this example are ShaderEffects. Alpha.1's XNA/FNA Effect Framework bytecode loader is a separate renderer-qualified path. Here ShaderEffect takes renderer-native source and has no Parameters collection or techniques; uniforms go through SetUniformXxx() and textures through SetTexture(unit, tex), after Apply(). 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 -- this is the only signal.
if (!gBufferEffect_->IsEffectValid() || !lightEffect_->IsEffectValid()) {
// The GLSL did not compile. Do not draw with it.
}
// Sampler unit assignments, set once. Apply() binds the program first;
// SetUniformXxx() writes into whatever program is currently bound.
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() binds the program; SetUniformXxx() and SetTexture() write into
// whatever program is currently bound, so this order is mandatory.
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);
gd.Present();
}
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 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.