./jerome{m}

~/Notes/Deferred vs forward rendering.md · note · seedling · 4 min · updated

Deferred vs forward

// status: seedling

"Where does the shading happen?" sounds like a boring question. It isn't. It decides your lighting cost model, your MSAA story, your material flexibility, your transparency strategy, and a pile of other downstream choices.

Forward rendering

Classic: each object is rasterized, and for every pixel of every object you iterate all the lights that affect it and shade.

Pros:

  • Simple.
  • MSAA works naturally.
  • Transparency works naturally.
  • Arbitrary per-material shading.

Cons:

  • Cost is O(pixels × lights) — naive many-light scenes collapse.
  • Overdraw penalises you shaded: you pay the full BRDF per occluded pixel.
  • Depth pre-pass helps but doesn't scale to hundreds of lights.

Deferred rendering

Split the pipeline:

  1. G-buffer pass — rasterize all opaque geometry, write material attributes (albedo, normal, roughness, metallic, depth…) to multiple render targets.
  2. Lighting pass — for each light, draw its volume; for each covered pixel, read the G-buffer, compute shading.

Pros:

  • Cost decouples from overdraw. You shade each pixel once.
  • Many lights are cheap.
  • Post-processing passes (SSAO, SSR, fog) have everything they need.

Cons:

  • Fat G-buffer = memory bandwidth. The single biggest cost on modern hardware.
  • MSAA is miserable — you'd have to store and shade per-sample, which is expensive enough that almost everyone uses TAA instead.
  • Transparency doesn't fit; you need a separate forward pass on top.
  • Material variety is constrained — everything must fit in the G-buffer channels.

Tiled and clustered forward (Forward+)

The modern "best of both" approach:

  1. Depth pre-pass.
  2. Bin the view into screen-space tiles (e.g. 16×16 pixels) — or 3D clusters that also slice depth.
  3. For each tile/cluster, determine which lights affect it via a compute shader.
  4. Forward-shade normally, but each pixel only iterates the lights listed for its tile/cluster.

Gets deferred's many-light scaling without paying the G-buffer bandwidth. Handles MSAA and transparency naturally. Has become the default in most modern engines (Doom 2016 popularised clustered; Unity HDRP and Unreal's forward renderer use it).

Visibility buffer / deferred texturing

The newest family. Instead of shading into a G-buffer, store what to shade:

  1. Rasterize to a tiny buffer containing (triangleID, barycentrics) per pixel.
  2. In a compute pass, fetch the vertex data for that triangle, interpolate, shade.

Tradeoffs:

  • G-buffer bandwidth vanishes — the visibility buffer is ~8 bytes per pixel.
  • You re-fetch and re-interpolate vertex data per pixel.
  • Material branching is painful; materials are usually sorted/binned.
  • Pairs beautifully with mesh shaders and huge numbers of small triangles (cf. Nanite).

Likely where the industry is going for opaque geometry.

Typical G-buffer layouts

A common AAA-ish G-buffer (fits in 12–16 bytes/pixel with packing):

TargetContentsFormat
RT0Albedo.rgb + AORGBA8
RT1Normal.xyz (oct-encoded to rg) + roughness + metallicRGBA8 / RG16F + RG8
RT2Motion vectors + material IDRGBA16F or packed
DepthHardware depthD32F or D24_S8

Tricks used everywhere:

  • Octahedron-encoded normals — pack a unit vector into 2×16 or 2×8 bits.
  • Pack roughness² — gets perceptually linear quantisation.
  • Material ID — 8 bits that switches between BRDFs in the lighting pass.
  • Stencil for material type — free, avoids branching in the lighting pass.

Smaller is better. Every additional byte is multiplied by 1920 × 1080 × MSAA samples × draws-per-frame of bandwidth.

Transparency

No deferred technique handles transparency gracefully. The standard approach:

  • Opaque pass → G-buffer → lighting → HDR colour buffer.
  • Transparent objects → forward shaded on top, sorted back-to-front.
  • For many transparent lights: order-independent transparency (OIT) via weighted-blended OIT, moment-based OIT, or per-pixel linked lists.

What I'd pick, given the shape of the project

  • Indie / small-scene / stylised — forward with a reasonable light cap. Simplicity wins.
  • Realistic, many lights, opaque-heavy — clustered forward. Modern default.
  • Heavy dynamic lighting, post-processing pipeline — deferred, accept the TAA tradeoff.
  • Film-quality asset density, mesh shader pipeline — visibility buffer.

Things that tripped me up

  • G-buffer clears — you must clear every component every frame, otherwise stale normals/roughness from last frame leak into skybox pixels etc.
  • Oct encoding breaks at -Z — handle the edge case or use a mapping that doesn't.
  • MSAA in deferred — just don't. Use TAA + (optional) SMAA.
  • Light volume geometry — point-light spheres clipped against the near plane need a special render path (front faces vs back faces, depth test flip). Or use a full-screen quad and tile-cull.
  • Tile/cluster indirection — the light list per tile needs a max cap, and you will hit it in worst cases. Profile with pathological scenes early.

Back to Notes · see also Physically Based Rendering · Shadow mapping · Compute shaders