Forward+ rendering is a workhorse for many real-time applications, but its fragment shaders often become dumping grounds for material complexity. Every surface's BRDF, subsurface scattering, clear coat, anisotropy, and procedural noise end up in the same monolithic kernel that also handles tile-based light culling and accumulation. The result: high register pressure, poor instruction cache utilization, and redundant computation for fragments that end up occluded or under threshold. Multi-pass precomputation offers a way out—by splitting expensive but view-independent material terms into separate render targets, computed once and reused across lighting passes. This guide is for engineers who already run a Forward+ pipeline and want to reduce fragment shader cost without scrapping their existing architecture.
Where Multi-Pass Precomputation Shows Up in Real Projects
In practice, the technique appears when a team hits a wall with monolithic shaders. A typical project might have a single forward pass that evaluates a 200-instruction material function for every visible fragment. After profiling, they find that 40% of those instructions are view-independent—things like normal map filtering, height-blending for parallax occlusion, or pre-integrated noise for weathering. These terms don't change when the camera moves, yet they're re-evaluated every frame.
The natural fix is to write those terms into a G-buffer-like set of textures during an earlier pass, then read them back in the main lighting pass. This is not deferred shading—you're not storing full lighting data. You're storing intermediate material values that are expensive to compute but cheap to sample. For example, a layered material with a clear coat and a base coat might precompute the clear coat Fresnel term and the base coat normal into two 16-bit render targets. The lighting pass then samples these instead of recomputing the layer blending logic.
One common scenario is in open-world games with terrain blending. A single fragment might sample four different material layers (grass, rock, dirt, snow) and blend them based on a splat map. The blending itself is cheap, but each layer's normal fetch and derivative calculation adds up. Teams have precomputed the blended normal into an intermediate target at half resolution, then used that in the lighting pass. The trade-off is extra bandwidth for the intermediate target, but the savings in ALU and texture fetches often win, especially on mobile GPUs where arithmetic is expensive.
Another real-world pattern is in cinematic real-time projects that use subsurface scattering. Instead of computing the scattering convolution in the lighting pass, they precompute a thickness or translucency map in a separate pass, then blur it and feed it back. The main pass samples that blurred map, reducing the per-fragment cost from dozens of texture samples to one.
Tile Reuse Considerations
Multi-pass precomputation interacts with Forward+'s tile-based light culling. If your precomputed targets are at full resolution, you risk increasing the tile's memory footprint and reducing occupancy. Some teams choose to precompute at half resolution and bilaterally upsample, accepting a small quality loss for significant bandwidth savings. The decision hinges on whether your bottleneck is ALU or memory bandwidth—profile first.
Foundations Readers Confuse
The most common confusion is that multi-pass precomputation is the same as deferred shading. It's not. In deferred shading, you store final shading attributes (albedo, normals, roughness, metalness) and compute lighting entirely in a later pass. Here, you're only storing intermediate results that are expensive to compute but view-independent. The lighting pass still evaluates the BRDF per fragment—it just skips the precomputed parts.
Another confusion is thinking that any expensive instruction is a good candidate. Some expensive operations, like shadow map sampling, are view-dependent and cannot be precomputed without artifacts. Others, like screen-space reflections, depend on the final color buffer and must remain in the main pass. The key is view-independence: if the result doesn't change when the camera moves (or changes slowly), it's a candidate.
Engineers also confuse bandwidth cost with ALU cost. Writing an intermediate render target costs bandwidth, and if your pipeline is already bandwidth-bound, adding a pass can make things worse. The precomputation pass should reduce total work, not just shift it. For example, if your material function uses 20 texture samples and 100 arithmetic instructions, but the intermediate target is one 16-bit value that requires only 2 samples to reconstruct, you've traded 20 samples for 2 plus a write. That's a win if your ALU is the bottleneck.
Render Pass Granularity
A related mistake is precomputing too many values, turning the pipeline into a mini-deferred renderer with multiple G-buffers. Stick to one or two precomputed targets per material group. Overdoing it increases draw calls and state changes, which can hurt CPU performance on some consoles.
Patterns That Usually Work
The most reliable pattern is to identify a single expensive, view-independent function in your fragment shader and extract it into a separate pass. Common candidates include:
- Procedural noise functions (e.g., 3D Worley noise for weathering) that are sampled at world-space coordinates.
- Height-based blending for parallax occlusion mapping.
- Subsurface scattering pre-integration (thickness map generation).
- Layered material blending where the blend weights are static per frame.
- Anisotropic tangent rotation calculations that depend only on UV coordinates.
For each candidate, create a new render pass that outputs to a small-format render target (R8G8, R16F, or R11G11B10F). Use a compute shader or a full-screen pass with a simplified shader that only computes the precomputed term. Then in the main Forward+ pass, sample that texture instead of recomputing. The main pass's shader becomes simpler, reducing register pressure and allowing the compiler to better optimize the lighting loop.
Resolution Scaling
Another pattern that works is to precompute at a lower resolution and upsample. Many material terms are low-frequency—normals after filtering, roughness, ambient occlusion. If you precompute these at half resolution and use bilinear filtering, the visual difference is often negligible. This cuts the bandwidth cost of the intermediate pass by 75% and reduces the ALU cost of the main pass because you're sampling fewer texels.
Anti-Patterns and Why Teams Revert
The biggest anti-pattern is treating multi-pass precomputation as a universal optimization. Teams sometimes precompute everything they can, ending up with five or six extra render targets. The memory cost balloons, and the GPU spends more time writing intermediate buffers than it saves. The main pass becomes dependent on many textures, increasing cache pressure. In one project, the team reverted to a monolithic shader after seeing a 15% frame time increase from the extra passes.
Another anti-pattern is precomputing view-dependent data. For example, precomputing a specular occlusion term that depends on the camera direction. This causes obvious artifacts when the camera moves—the occlusion doesn't update until the next frame, leading to temporal lag. Teams that try this often revert after seeing ghosting.
Poor tile reuse is another reason for reversion. In Forward+, the lighting pass iterates over tiles and lights. If the precomputed pass writes to a full-resolution target, the tile's working set expands, and occupancy drops. Some teams found that the ALU savings were offset by slower tile culling because the shader had to read more textures per fragment. The fix is to keep precomputed targets small and use fewer of them.
Driver and Compiler Quirks
On some GPU architectures, splitting shaders into multiple passes can prevent the driver from performing cross-pass optimizations. For example, the driver might merge two passes into one if it detects that the intermediate target is only used once. But if the passes are separate, the driver may not optimize across them, and you lose the benefit of constant folding. Testing on target hardware is essential.
Maintenance, Drift, and Long-Term Costs
Multi-pass precomputation introduces state management overhead. Each precomputed pass adds a render target, a shader permutation, and a draw call. Over months of development, artists and engineers may add new material features that depend on the precomputed data, but forget to update the precomputation pass. The result is visual discrepancies—the main pass uses stale data, and the precomputation pass is out of sync. This drift is a common source of subtle bugs that are hard to track down.
Another long-term cost is shader compilation time. More passes mean more shaders to compile. On platforms with aggressive shader caching, this might be a one-time cost, but on consoles with limited cache, it can increase load times. Teams should weigh the runtime benefit against the compile-time penalty.
Documentation becomes critical. Without clear comments and a diagram of the pass dependencies, new team members may not understand why a particular value is precomputed. They might add a new feature that invalidates the precomputed value, or they might accidentally introduce a dependency cycle. We've seen teams revert to monolithic shaders simply because the multi-pass system became too complex to maintain.
Testing and Validation
To prevent drift, we recommend adding automated tests that compare the precomputed value against the recomputed value in the main shader for a few representative frames. This catches mismatches early. Also, keep the precomputation pass as simple as possible—if it starts to look like a full G-buffer pass, you've gone too far.
When Not to Use This Approach
Multi-pass precomputation is not a silver bullet. Here are five scenarios where it's likely the wrong call:
- Bandwidth-bound pipelines. If your GPU is already saturating memory bandwidth, adding extra render target writes will hurt. Profile memory transactions before adding passes.
- Simple materials. If your fragment shader is under 50 instructions, the overhead of an extra pass (draw call, state change, texture write) likely outweighs the savings. Keep it monolithic.
- High-resolution targets with many lights. In scenes with hundreds of lights, the main pass is already heavy on texture reads. Adding precomputed textures increases cache pressure and can degrade performance.
- Rapidly changing materials. If your materials change every frame (e.g., dynamic tessellation or vertex animation), the precomputed data becomes stale quickly. You'd need to recompute it every frame, negating the savings.
- CPU-limited scenarios. On platforms where draw calls are expensive (e.g., some mobile GPUs), splitting a single pass into two can double the CPU cost. If your bottleneck is CPU, optimize elsewhere.
In each case, the decision should be based on profiling data, not intuition. Run a before-and-after comparison on representative frames, measuring both GPU and CPU time. If the savings are less than 10%, the maintenance cost may not be worth it.
Open Questions and FAQ
Can this technique be combined with variable rate shading?
Yes, but carefully. If you precompute at a lower resolution and use VRS to shade the main pass at full resolution, you can get additional savings. However, VRS can introduce artifacts if the precomputed data is high-frequency. Test on your content.
How do I choose between compute shaders and rasterization for the precomputation pass?
Compute shaders are often more flexible and avoid rasterization overhead. However, if your precomputation involves texture sampling with derivatives (e.g., normal map filtering), you need the hardware derivatives that come with pixel shaders. Use compute for simple arithmetic, pixel shaders for texture-heavy work.
What about temporal reprojection?
Temporal reprojection can extend the lifetime of precomputed data across frames. For slowly changing materials, you can reuse the previous frame's precomputed values and only update a subset. This reduces the per-frame cost further but introduces temporal artifacts if the camera moves quickly.
Is this technique relevant for ray tracing pipelines?
Yes, with modifications. In hybrid renderers that mix rasterization and ray tracing, precomputing material terms in the rasterization pass can reduce the cost of ray shaders. However, ray tracing often requires different data layouts (e.g., hit group shader parameters). Adapt accordingly.
To get started, pick one expensive material function from your current pipeline, extract it into a separate pass, and measure the result. Start with a single precomputed target at half resolution. If the savings are clear, expand to other candidates. If not, revert and look elsewhere. The goal is to reduce fragment complexity, not to add architectural overhead.
Comments (0)
Please sign in to post a comment.
Don't have an account? Create one
No comments yet. Be the first to comment!