Deferred Rendering & the G-buffer
You add a row of street lamps, a few headlights and a muzzle flash, and the frame rate drops with every light, because a simple forward renderer lights every object's pixels once per light, including pixels a nearer object later covers. Deferred rendering draws the scene once into a G-buffer, a set of screen-sized images that record each visible pixel's surface (color, normal, roughness, depth), then lights each visible pixel once per light. Lighting cost stops depending on how many objects you draw. The price is memory bandwidth, and the choice pulls in MSAA, transparency, tiled and clustered light culling, and Forward+.
- Lay out a G-buffer that fits Vulkan 1.3's guaranteed four color attachments, with each target in the right format and color space.
- Rebuild a pixel's world position from the depth buffer and pack its normal into two channels.
- Write the MRT geometry pass and the full-screen lighting pass, with the barrier between them.
- Estimate what a G-buffer costs in bandwidth, and say when deferred beats forward and what it gives up in MSAA and transparency.
- Explain tiled and clustered light culling, and why they work with Forward+ as well as with deferred.
01Why forward struggles
Picture a night street with forty lamps, a few cars with headlights and a dozen characters. A naive forward renderer runs the fragment shader for every object it draws, and that shader loops over every light, so its work is proportional to (fragments shaded × lights). The shader also runs on fragments that a nearer object later covers. The depth test throws those away, but only after they've been lit. That wasted shading is overdraw. As the number of dynamic lights climbs, it gets expensive[1].
Worked example
A 1920 × 1080 screen has 2,073,600 pixels. Say each pixel is covered by 2.5 surfaces on average before the nearest one wins (an overdraw of 2.5). With 100 lights, naive forward runs 2,073,600 × 2.5 × 100 = 518 million light evaluations a frame. Lighting only the visible surface brings that to 2,073,600 × 100 = 207 million. If only 8 lights actually reach each pixel and it looped just those, it would be 2,073,600 × 8 = 16.6 million. The rest of this page is about getting from the first number to the last.
Real forward renderers cull lights per object (per-object light lists) and run a depth pre-pass, a cheap first pass that writes only depth, so the expensive shading runs only on the front-most surface. The bad case is specifically naive forward: every light evaluated for every shaded fragment, with no pre-pass. "Overdraw" here means redundant shading of fragments that get overwritten; the vertex and raster cost of extra triangles is small by comparison. Forward also gained per-tile light culling (Forward+, §8), so the scaling problem belongs to the naive version, and forward as a family handles many lights fine.
02The deferred idea
The fix is to hold off lighting anything until you know which surface is visible. splits rendering in two. The geometry pass rasterizes the scene once and writes each pixel's surface attributes (material parameters, normal, depth) into the , a set of screen-sized images the pass draws into, called render targets. It fills all of them in one pass with : one fragment shader writing a separate output to each image. The lighting pass then runs once per screen pixel: read the G-buffer and add up every light's contribution for that one visible surface. Shading becomes proportional to (visible pixels × lights), independent of object count and of overdraw, because only the front-most surface survives into the G-buffer[1].
Deferred trades the overdraw/object-count win for G-buffer bandwidth: you write N targets in the geometry pass and read them all in the lighting pass (§6). The geometry pass still rasterizes every triangle, and overdrawn fragments still run its cheap G-buffer shader while the depth test keeps the nearest; what's eliminated is redundant lighting of overdrawn fragments.
Deferred shading vs. the older deferred lighting
This page builds "deferred shading", which stores full material parameters and runs the whole BRDF later. The older "deferred lighting", or light pre-pass, stores only normals and a specular exponent, accumulates light, then renders the geometry a second time to apply the materials. It runs on hardware without MRT and cuts G-buffer bandwidth and storage[3].
Worked example
One pixel is covered by three overlapping spheres, and the scene has 50 lights. Naive forward lights all three: 3 × 50 = 150 light evaluations, two-thirds of them thrown away. Deferred runs the cheap G-buffer shader up to three times (the depth test keeps the nearest sphere's data), then the lighting pass evaluates the 50 lights once, for the sphere that's actually visible. The other 100 evaluations never happen.
03The G-buffer
By the time the lighting pass runs, the meshes, textures and material constants are gone; everything it knows about a pixel has to be in the G-buffer. So the G-buffer stores everything lighting needs about the one visible surface per pixel: base color, normal, metallic + roughness (the glTF params from PBR), depth (the depth attachment, which doubles as the position source), and emissive. Channel count and precision are a budget, because every byte is written and read back every frame: a fat G-buffer stores more at higher precision (flexible, more bandwidth); a thin one packs aggressively. DOOM 2016, a clustered forward renderer, still writes a thin G-buffer during its forward pass for later screen-space effects: normals in R16G16 float and specular in R8G8B8A8[8].
The core Vulkan guaranteed minimum for maxColorAttachments is 4; the Roadmap 2022 profile raises it to 7, and Roadmap 2024 and Vulkan 1.4 to 8[11]. Desktop GPUs commonly report 8, but a portable Vulkan 1.3 design fits the G-buffer in ≤4 color attachments + depth or queries the limit. Base color and emissive are sRGB-encoded; metallic, roughness, and the packed normal are linear data, so keep data channels out of sRGB views. Fat versus thin trades bandwidth against precision and flexibility.
Worked example
The layout in this page's code uses three color targets plus depth: base color in RGBA8 (4 bytes), the normal in two 16-bit channels (4 bytes), metallic, roughness and occlusion in RGBA8 (4 bytes), and 32-bit depth (4 bytes). That's 16 bytes per pixel, or 33.2 MB at 1080p. The geometry pass writes it and the lighting pass reads it back, so at least 66 MB moves every frame, about 4 GB/s at 60 fps, before a single light is evaluated. At 4K the same layout moves about 16 GB/s. That's deferred's fixed cost, the floor in the §6 widget.
The widget shows what each G-buffer target holds for a small scene:
04Position & normals
Every G-buffer byte costs bandwidth every frame, so store only what you can't recompute. Two packing decisions save a lot. Don't store a position target: the depth buffer plus the pixel's place on screen already pin down where the surface is, so reconstruct position from them. And store the normal in two channels instead of three, because a vector of length 1 only has two degrees of freedom.
Build the NDC position from the screen UV and the sampled depth, multiply by the inverse projection (view space) or inverse view-projection (world space), and divide by w[4]. The Vulkan catch (same as Going 3D and Shadows): NDC depth is already 0..1, so set ndc.z = depthSample directly, not depthSample*2−1 (the GL-style remap is the classic reconstruction bug). Use the right inverse matrix for the space you want. Storing a position target works but is redundant: it burns a high-precision target's bandwidth on data the depth buffer already holds.
Worked example
Take a Vulkan perspective camera with near = 0.1 m and far = 100 m. A surface 10 m away stores depth 0.991. Depth is far from linear: a surface 1 m away stores 0.901, and everything past 5 m stores more than 0.98. Unprojecting 0.991 with the inverse projection gives back 10 m. Apply the OpenGL habit depth * 2 − 1 first and the shader unprojects 0.982 instead, which lands at 5.3 m, so every light is computed for a point about half as far away as the real surface.
// --- position from depth (Vulkan 0..1) ---
float depth = texture(gDepth, uv).r; // Vulkan: already in [0,1]
vec4 ndc = vec4(uv * 2.0 - 1.0, depth, 1.0); // XY: [0,1]->[-1,1]; Z: NO *2-1 on Vulkan
vec4 world = invViewProj * ndc; // inverse view-projection -> world
world /= world.w; // perspective divide
// --- octahedral normal: unit vector <-> 2 channels (Cigolle et al.) ---
vec2 signNotZero(vec2 v) { return vec2(v.x >= 0.0 ? 1.0 : -1.0, v.y >= 0.0 ? 1.0 : -1.0); }
vec2 octEncode(vec3 n) {
n /= (abs(n.x) + abs(n.y) + abs(n.z)); // project onto the octahedron
vec2 e = n.xy;
if (n.z < 0.0) e = (1.0 - abs(e.yx)) * signNotZero(e); // fold lower hemisphere
return e;
}
// ...and the inverse the lighting pass calls: 2 channels -> unit vector
vec3 octDecode(vec2 e) {
vec3 n = vec3(e.xy, 1.0 - abs(e.x) - abs(e.y)); // unfold the octahedron
if (n.z < 0.0) n.xy = (1.0 - abs(n.yx)) * signNotZero(n.xy); // restore lower hemisphere
return normalize(n);
}
Octahedral encoding is a common 2-channel packing: it squashes the sphere of directions onto an octahedron (a double pyramid), then unfolds that flat into a square. The survey below ranks it among the most accurate encodings per bit, and unlike the "store X,Y, reconstruct Z" hemisphere trick it represents the full sphere with no sign ambiguity[5].
Worked example
Take the unit normal (0.6, 0, 0.8). octEncode divides by |x| + |y| + |z| = 1.4, giving (0.429, 0, 0.571), and stores (0.429, 0). octDecode rebuilds z as 1 − 0.429 − 0 = 0.571 and normalizes back to (0.6, 0, 0.8). Flip it to (0.6, 0, −0.8) and z is negative, so the fold kicks in: it's stored as (1, 0.571), out on the edge of the square, and decodes back to (0.6, 0, −0.8). Two 16-bit channels take 4 bytes. Vulkan doesn't guarantee three-channel 16-bit render targets, so storing x, y and z as 16-bit floats usually means an RGBA16F target: 8 bytes.
05The two passes
With the layout decided, you need two pipelines: one that fills the G-buffer and one that reads it. The geometry pass binds N color attachments plus depth (MRT) and the fragment shader writes one output per target. Under dynamic rendering, the pipeline lists the formats and the render lists the attachments; the count and order must match the shader's out locations[11].
// pipeline: list the G-buffer color formats (<=4) + depth (extends Going 3D)
let color_formats = [albedo_format, normal_format, material_format]; // order must match the shader
let mut rendering = vk::PipelineRenderingCreateInfo::default()
.color_attachment_formats(&color_formats)
.depth_attachment_format(depth_format);
// record: one RenderingAttachmentInfo per target (CLEAR/STORE), plus depth;
// gbuf() is a small helper that fills image_view, layout, load/store ops, and clear value
let color_attachments = [gbuf(albedo_view), gbuf(normal_view), gbuf(material_view)];
let info = vk::RenderingInfo::default()
.color_attachments(&color_attachments) // MRT
.depth_attachment(&depth_attachment);
// geometry-pass fragment shader: one output per G-buffer target
layout(location = 0) out vec4 gAlbedo; // rgb base color (sRGB target)
layout(location = 1) out vec2 gNormal; // octahedral world normal in [-1,1]: an R16G16_SNORM target (UNORM needs *0.5+0.5)
layout(location = 2) out vec4 gMaterial; // r=metallic g=roughness b=occlusion (linear)
void main() {
gAlbedo = vec4(baseColor, 1.0);
gNormal = octEncode(normalize(worldNormal));
gMaterial = vec4(metallic, perceptualRoughness, occlusion, 1.0);
}
The lighting pass draws one full-screen primitive, samples the G-buffer, reconstructs position, decodes the normal, and runs the PBR BRDF once per light. (The BRDF is the per-light formula from PBR that turns a surface's color, metalness and roughness into the light it reflects toward the camera.) Prefer a single oversized triangle over a two-triangle quad: the quad's shared diagonal makes the GPU run the fragment shader twice on the seam's 2×2 quads, and a single triangle has better cache behavior; generate it from gl_VertexIndex with no vertex buffer at all[13].
Worked example
The two commented lines at the top of the listing below turn vertex indices 0, 1 and 2 into UVs (0, 0), (2, 0) and (0, 2), and clip positions (−1, −1), (3, −1) and (−1, 3). That one triangle covers the whole −1..1 screen square, and the GPU clips away the parts hanging off the edges. At 1080p with 100 lights, the loop below runs the BRDF 207 million times a frame, the middle number from §1; §7 cuts it down.
// full-screen triangle: 3 verts, no vertex buffer
// vec2 uv = vec2((gl_VertexIndex << 1) & 2, gl_VertexIndex & 2);
// gl_Position = vec4(uv * 2.0 - 1.0, 0.0, 1.0);
void main() {
vec3 albedo = texture(gAlbedo, uv).rgb;
vec3 N = octDecode(texture(gNormal, uv).rg);
vec4 mat = texture(gMaterial, uv);
float metallic = mat.r, perceptualRoughness = mat.g;
vec3 worldPos = positionFromDepth(uv); // reconstruct (no position target)
vec3 V = normalize(cameraPos - worldPos);
vec3 Lo = vec3(0.0);
for (int i = 0; i < lightCount; ++i) { // naive: all lights (culled in §7)
vec3 L = normalize(lights[i].pos - worldPos);
vec3 radiance = lights[i].color * attenuation(lights[i], worldPos);
float NdotL = max(dot(N, L), 0.0);
Lo += cookTorrance(N, V, L, albedo, metallic, perceptualRoughness)
* radiance * NdotL; // cosine OUTSIDE the BRDF (from PBR)
}
// emissive and ambient come from their own targets or are added in the geometry pass
outColor = vec4(Lo + emissive + ambient, 1.0); // HDR target -> tonemap later
}
Between the geometry and lighting passes, transition each G-buffer image from COLOR_ATTACHMENT_OPTIMAL to SHADER_READ_ONLY_OPTIMAL (depth likewise): a write-then-read barrier, the same hazard the Textures upload covered. On tile-based mobile GPUs this DRAM round-trip is the expensive path. Vulkan subpasses (or dynamic-rendering local reads) let the lighting step read the G-buffer straight from on-chip tile memory; in Arm's deferred subpasses sample, merging the two passes cut read bytes by 45% and write bytes by 56%[12]. On those GPUs the separate-pass-plus-barrier version leaves that saving on the table.
06Costs & limits
Deferred moves cost around rather than removing it, and the bill arrives in four well-known places[2]:
- Bandwidth, the bytes moved between the GPU and its memory: the G-buffer is written by every geometry-pass fragment and read by every lighting-pass pixel, about 4 GB/s at 1080p and 60 fps for the 16-byte layout in §3. On bandwidth-limited GPUs this can dominate.
- MSAA is hard: MSAA smooths jagged edges by storing several samples per pixel, and a one-surface-per-pixel G-buffer has nowhere to put them. Correct MSAA needs per-sample G-buffers or edge detection with per-sample shading only at edges. At 4× MSAA, per-sample storage takes the 16-byte layout to 64 bytes per pixel, 133 MB at 1080p. This drove many engines to post-AA (FXAA/SMAA/TAA) or to Forward+.
- Transparency doesn't work: a G-buffer holds one surface per pixel, so translucent surfaces (which blend several layers) must be drawn in a separate forward pass after the deferred composite. This follows from the one-layer G-buffer itself.
- Material variety is constrained: every pixel is shaded by the one model the G-buffer layout encodes. Skin, cloth or car paint that need a different lighting model need extra channels, or a material ID the lighting pass branches on.
Deferred pays a fixed G-buffer cost up front and then adds less per light; naive forward starts cheaper and climbs steeply. The widget plots both:
07Tiled & clustered
A street lamp lights a few meters of pavement, yet the lighting pass above still evaluates it for every pixel on screen. The naive lighting pass loops all lights for every pixel, so it scales as (pixels × lights). To go further you cull lights by where they are. Tiled shading divides the screen into 2D tiles, small squares of pixels, and, in a compute pass, builds each tile's list of the lights that overlap it, bounded in depth by the nearest and farthest surface in the tile. A pixel then loops only its tile's list. shading adds a third, depth dimension: it cuts each tile into depth slices, and each tile-and-slice box, a cluster, gets its own list[6].
Worked example
A screen tile covers a railing 2 m from the camera and a wall 30 m behind it, with 40 lights hanging in the space between. Tiled culling bounds the tile from 2 m to 30 m, so every pixel in it, railing and wall alike, loops all 40 lights. Cut the tile into depth slices and a railing pixel loops only the lights that reach the railing's slice, say 3, while a wall pixel loops only those near the wall, say 5.
A flat 2D tile spanning a near rail and a far wall bounds its lights by the whole depth range between them, so every light anywhere in that range lands on every pixel in the tile. Clusters split the range, so a pixel loops only the lights in its own cluster; robustness under depth discontinuities is the main reason to prefer clusters[6]. Tiled and clustered culling are orthogonal to deferred versus forward: there is tiled deferred (Battlefield 3[10]), clustered deferred, and clustered forward (DOOM 2016[8]). Light caps are per-title configuration: DOOM allows up to 256 lights per cluster across its 3072 clusters.
Where you put the slice boundaries matters. Slice the depth range evenly and the clusters near the camera, where a screen tile covers only a few centimeters of the world, become long thin slabs. Slice it evenly in NDC (the post-projection depth the depth buffer stores, which bunches up near the camera) and you get the opposite: tiny near clusters and huge far ones[7]. The usual answer is exponential slices, where each boundary is the same ratio farther away than the last[7]. The formula below gives the depth where slice k begins:
In words: start at the near plane and multiply by the same ratio once per slice, chosen so the last boundary lands exactly on the far plane. Each slice is a fixed percentage deeper than the distance where it starts, just as a screen tile covers more of the world the farther away it looks, so every cluster has the same shape at every distance. Hover any term to see what it stands for.
Worked example
With near = 0.1 m, far = 100 m and N = 16, the ratio per slice is 10001/16 = 1.54. The boundaries run 0.1, 0.154, 0.237 … 1.33, 2.05, 3.16 … 64.9, 100 m: the nearest slice is 5.4 cm deep and the farthest 35 m. The railing at 2 m falls in slice 6 (1.33 to 2.05 m) and the wall at 30 m in slice 13 (27.4 to 42.2 m), so that one tile spans 8 of the 16 slices. Even slices would each be 6.24 m deep. At 1 m from the camera, where a 64-pixel tile on a 1080-row screen with a 60° field of view is 6.8 cm tall, that's a slab about 90 times deeper than it is tall. With exponential slices the same tile's cluster is about 8 times deeper than tall at every distance; making clusters roughly cube-shaped with these tiles would take about 104 slices.
The widget is a side view of the railing-and-wall problem, with lights drifting through the volume:
08Forward+ & the verdict
Light culling doesn't need a G-buffer, so a renderer that wants hardware MSAA and ordinary transparency can cull lights and stay forward. Forward+ (tiled forward) is that branch: a depth pre-pass, a compute light-culling pass (the same culling), then a normal forward shading pass where each fragment loops only its tile's lights[9]. It keeps forward's strengths (hardware MSAA, transparency, per-material shaders) while getting deferred-like light scaling, because the tile list bounds the lights each fragment loops without a G-buffer.
Worked example
Forward+ draws the geometry twice, once for the depth pre-pass and once to shade. In exchange, its pre-pass writes only 4 bytes of depth per pixel, 8.3 MB at 1080p, where the 16-byte G-buffer from §3 writes 33.2 MB and reads it all back. Both then cull lights the same way. Forward+ pays in vertex and draw-call work what deferred pays in bandwidth.
Few lights: forward or Forward+ wins (no G-buffer bandwidth, hardware MSAA, transparency works as usual). Many dynamic lights: tiled or clustered culling wins, whether the shading is deferred or Forward+. Many engines moved toward clustered forward, Forward+, or hybrids because they recover deferred's light scaling while keeping MSAA, transparency, and material flexibility (DOOM 2016 is clustered forward), while others stayed deferred with a forward pass for transparency (Battlefield 3's tiled deferred)[8][10]. One near-constant: transparents get their own forward pass after the opaques, whatever the opaque architecture.
Wrong answers, and why: transparency is the one-surface-per-pixel limit (fixed by a forward pass, not an alpha channel); and tile over-assignment is a depth-discontinuity problem fixed by clustered depth slices, independent of deferred vs forward.
09Pitfalls
10What's next
The renderer can light many surfaces efficiently, but the ambient term is still a flat constant. The next module, Ambient Occlusion & Global Illumination, darkens that ambient in crevices (SSAO, reusing this G-buffer) and surveys how engines fake indirect bounce light, then post-processing and the 3D capstone. The full 3D path is on the series hub.
- Joey de Vries. LearnOpenGL, "Deferred Shading." learnopengl.com. The two-pass structure, the MRT G-buffer, the lighting loop, light volumes, and the disadvantages.
- "Deferred shading." Wikipedia. en.wikipedia.org. History, the deferred-shading vs light-pre-pass distinction, and the MSAA/transparency/material limits.
- Naty Hoffman. "Deferred lighting approaches." Real-Time Rendering blog, 2009. realtimerendering.com. Deferred shading versus deferred lighting (light pre-pass): what the first pass stores, and why it suits hardware without MRT and cuts bandwidth and storage.
- Matt Pettineo. "Reconstructing Position From Depth." therealmjp.github.io. The inverse-projection unproject and perspective divide (mind the Vulkan 0..1 depth convention).
- Zina Cigolle et al. "A Survey of Efficient Representations for Independent Unit Vectors." JCGT 2014. jcgt.org. Octahedral as the recommended 2-channel normal packing, with accuracy comparisons.
- Ola Olsson, Markus Billeter, Ulf Assarsson. "Clustered Deferred and Forward Shading." HPG 2012. cse.chalmers.se. Clustered shading: depth partitioning, robustness over tiled, and that it serves both forward and deferred.
- Angel Ortiz. "A Primer on Efficient Rendering Algorithms & Clustered Shading." aortiz.me. The exponential depth-slice formula and the two-stage compute culling pipeline.
- Adrian Courrèges. "DOOM (2016) Graphics Study." adriancourreges.com. Clustered forward (16×8×24 = 3072 clusters, logarithmic depth slices, up to 256 lights per cluster), the thin G-buffer written during the forward pass (R16G16 float normals, R8G8B8A8 specular), and back-to-front forward transparency.
- Takahiro Harada, Jay McKee, Jason Yang. "Forward+: Bringing Deferred Lighting to the Next Level." 2012. takahiroharada.github.io. Tiled forward: light culling while keeping MSAA, transparency, and material flexibility.
- Johan Andersson. "DirectX 11 Rendering in Battlefield 3." GDC 2011. slideshare.net. Compute-based tiled deferred plus a forward transparency pass (the hybrid).
- The Khronos Group. Vulkan Required Limits, and
VkPipelineRenderingCreateInfo. docs.vulkan.org.maxColorAttachments: 4 in core Vulkan, 7 under Roadmap 2022, 8 under Roadmap 2024 and Vulkan 1.4; the MRT format wiring. - Arm. "Vulkan samples: bandwidth and throughput optimizations." developer.arm.com. Merged subpasses versus two render passes in the deferred subpasses sample: 45% fewer read bytes and 56% fewer write bytes. Background: "Deferred shading on mobile," developer.arm.com, on bandwidth as the dominant mobile cost.
- Chris Wallis. "Optimizing Triangles for a Full-screen Pass." wallisc.github.io. The full-screen triangle vs quad (the 2×2-quad seam double-shading).