All tutorials Mighty Professional
Build a Game Engine · Rendering: into 3D

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+.

Time~55 min LevelSenior PrereqsHelpful, not required: the Going 3D tutorial (MRT-capable dynamic rendering, depth), PBR (the BRDF the lighting pass runs), and Shadows. StackC++ & Rust (Vulkan) · GLSL
◂ Build a Game Engine Phase 7 · Rendering: into 3D Next · Ambient Occlusion & GI ▸
What you'll be able to do by the end

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.

Scope it: "naive" forward, and overdraw is wasted shading

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].

Decoupled but not free: lighting runs once per visible pixel

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].

On Vulkan 1.3, you only get 4 color attachments guaranteed

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:

Three spheres overlap under a light that orbits on its own. Click through albedo, normal, depth and metal/rough, then tick the glass pane. The normal view shows each normal's x, y and z as red, green and blue, remapped from −1..1 to 0..1; the depth view runs from dark (near) to white (far); metal/rough puts metallic in red and roughness in green. The G-buffer channels (albedo, normal, depth, material) are written once and stay fixed as the light orbits; only final updates. Where spheres overlap, each channel holds only the nearest surface. Add the glass pane and it appears only in the final composite, never in any G-buffer channel: a G-buffer holds one surface per pixel, which is why transparency is drawn by a separate forward pass (§6).

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.

Position from depth: the Vulkan-0..1 reconstruction

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 + octahedral normals (GLSL)
// --- 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].

MRT geometry pass (Rust · ash) + the G-buffer fragment outputs (GLSL)
// 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.

The lighting pass: full-screen, read G-buffer, loop lights (GLSL)
// 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
}
The barrier between the passes (and the mobile shortcut)

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]:

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:

The orange curve is naive forward and the blue one deferred, in proxy cost units against light count on a log axis; the line under the chart says which is cheaper at the current setting. Drag Lights to either side of the dashed crossover, then drag Objects. At the default 400 objects the crossover sits at 21 lights; at 10 objects it moves out to 66, and at 2,000 it drops to 8. Naive forward climbs steeply with lights, and more steeply with more objects (more overdraw). Deferred starts from a fixed floor, the geometry pass plus G-buffer traffic, and adds less per light. The dashed line marks the crossover. Below it deferred loses: at one or two lights its fixed cost has nothing to amortize over. The curves are a proxy model in arbitrary units to show the shape of the trade; real crossovers depend on resolution, G-buffer size, and the GPU.

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.

Clustered beats tiled on depth discontinuities, and these are orthogonal to deferred

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:

eq. 1 · exponential depth slices Zk = near × (far / near)k / N

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:

Screen tiles run across, view depth goes up from the camera at the bottom, and the readout gives the lights each pixel loops in the current mode next to the number that actually touch it. Switch between no culling, tiled and clustered, then drag Lights. Each pixel loops the list it's given: all lights (no culling), its tile's list (tiled), or its own cluster's list (clustered). Every tile here holds both railing and wall, so a tile's depth bounds span nearly the whole volume and its list collects every light floating in between. With the default 24 lights the readout hovers around 5.5 per pixel for tiled, 1.8 for clustered and 0.5 actually touching: tiled cuts the work about fourfold and clusters about threefold again. Clustered still loops more lights than touch the pixel, because a cluster is a box far bigger than the one surface point a pixel sees, and a light that grazes any corner of it lands on the list. Lights that really do pile onto one surface stay expensive under any scheme; culling only removes lights that can't reach the pixel.

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.

No architecture is universally best

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

"Deferred is just faster"It's a bandwidth-for-shading trade; at few lights, forward wins.
Storing a position targetRedundant. Reconstruct from depth (Vulkan z is already 0..1, no *2−1).
Assuming 8 color attachmentsVulkan 1.3 guarantees only 4. Fit the G-buffer in ≤4 + depth, or query.
Data channels as sRGBNormal/metallic/roughness are linear; only base color and emissive are sRGB.
Transparency in the G-bufferOne surface per pixel. Translucents go in a separate forward pass.
Forgetting MSAA costDeferred MSAA needs per-sample G-buffers; many engines use post-AA instead.
"Clustered = deferred"Culling is orthogonal; DOOM 2016 is clustered forward.
Uniform depth slicesCluster shapes vary wildly with distance. Exponential slices keep every cluster the same shape.

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.

  1. Joey de Vries. LearnOpenGL, "Deferred Shading." learnopengl.com. The two-pass structure, the MRT G-buffer, the lighting loop, light volumes, and the disadvantages.
  2. "Deferred shading." Wikipedia. en.wikipedia.org. History, the deferred-shading vs light-pre-pass distinction, and the MSAA/transparency/material limits.
  3. 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.
  4. Matt Pettineo. "Reconstructing Position From Depth." therealmjp.github.io. The inverse-projection unproject and perspective divide (mind the Vulkan 0..1 depth convention).
  5. 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.
  6. 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.
  7. Angel Ortiz. "A Primer on Efficient Rendering Algorithms & Clustered Shading." aortiz.me. The exponential depth-slice formula and the two-stage compute culling pipeline.
  8. 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.
  9. 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.
  10. Johan Andersson. "DirectX 11 Rendering in Battlefield 3." GDC 2011. slideshare.net. Compute-based tiled deferred plus a forward transparency pass (the hybrid).
  11. 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.
  12. 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.
  13. 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).

See also