Advanced
Rendering
Optimization
Case study
I Made My Volumetric Fog 2x Faster
10 min

In the previous article, I faked volumetric fog with additively blended spheres placed around each light. It looks good and avoids raymarching, but every sphere requires a separate transparent draw. When many spheres overlap on screen, the GPU blends the same pixels repeatedly.
Here, I optimize that effect.
In this article:
I recap the volumetric sphere effect
I profile it and find the bottleneck
I build per-tile sphere buckets in a compute shader
I render all spheres in one fullscreen pass
I optimize the compute shader, the fragment shader, and the cost of rendering "nothing"
This is the view I will profile throughout the article. I render it in 4K on an RTX 5060 Ti.

What we did before
Each light in the scene has a sphere around it. The sphere renders its back faces with additive blending, and the fragment shader calculates how much fog the camera ray passes through.
The shader intersects the camera ray with the sphere analytically:

Then it clips the segment against the camera and the scene depth, so the fog fades smoothly when the camera enters the sphere or when an opaque object cuts through it:

I placed about 42 of those spheres in the scene. This was the final result:

The core of the effect is the SphereDensity function, which I reuse unchanged in this article:
___
Performance issue
I profiled the original effect. It takes 0.496 ms in this view:

I profiled the effect with the NVIDIA Nsight Graphics GPU Trace Profiler. The bottleneck is Screen Pipe throughput, which covers rasterization and color blending. The GPU spends most of its time writing and blending pixel colors:

This makes sense. Every sphere is rendered separately, so for each sphere and each covered pixel, the GPU:
launches a fragment shader,
reconstructs the camera ray,
reads the scene depth,
evaluates a single sphere,
blends the result into the color buffer.
When spheres overlap, the same pixel goes through all those steps several times. This heatmap shows the overdraw created by the spheres:

The profiler shows the same thing: almost 15 million blended pixels, while the screen contains 8 million pixels.

The ray and scene depth are the same for every sphere covering a pixel. I only need to calculate them once, then add each sphere's contribution. This way, each pixel is blended only once.
___
Optimization idea
I want to render all spheres in one fullscreen pass. Each pixel creates one ray, reads the depth once, and loops through the relevant spheres.
But looping through all 42 spheres for every pixel would be wasteful. Most pixels are covered by only a few spheres, or none at all. I need a cheap way to know which spheres can affect a pixel.
Tiles and buckets
I divide the screen into 32x32 pixel tiles. For each tile, I build a list of overlapping spheres. I call these lists buckets.

Each bucket is just 32 uint values:
the first value stores how many spheres are in the bucket,
the remaining 31 values store the IDs of those spheres in the sphere buffer.
All buckets are stored one after another in a single raw buffer, so its size is 32 * sizeof(uint) * tileCount. Converting a tile coordinate into a bucket address is simple:
Note: The hard limit is 31 spheres per tile. If more spheres overlap a tile, the extra spheres are ignored, starting with the spheres created and initialized later in the scene. This changes the rendered result, but 31 overlapping spheres per pixel should be more than enough for most games. If it is not, the effect setup probably needs to be reconsidered. You can also increase the bucket size if the target hardware has enough memory and bandwidth.
Rendering plan
The rendering is split into two passes, both executed every frame:
A compute shader builds the buckets. A single GPU thread handles a single tile. It iterates through all spheres, tests whether each one overlaps the tile, and writes the IDs of the overlapping spheres into the bucket.
A fullscreen fragment shader renders the fog. Each pixel finds its tile, creates one ray, reads the depth once, and evaluates only the spheres from its bucket.

___
Implementation
I implemented this effect as a URP Renderer Feature that uses the Render Graph API. Here are the most important parts.
1. Sphere data
Each sphere is now a small component instead of a mesh with a material. It stores its data in a struct that is uploaded to a structured buffer every frame:
GenerateHLSL creates a matching HLSL struct, so the C# and shader layouts stay in sync. Packing the radius into the w component keeps each sphere at 32 bytes.
The component updates its data from the transform, using the same unit-sphere convention as before:
Note: Material colors are converted from gamma to linear space automatically, but colors uploaded from C# are not. Because this project renders in linear space, I use
color.linear. Otherwise, colors transferred from the original materials would look different.
2. Uploading the spheres
Every frame, the render pass copies all active spheres into the sphere buffer before dispatching the compute shader:
3. Building the buckets
The compute shader runs one thread per tile. Each thread builds a small frustum with four side planes. It reconstructs the planes from the tile's corners using the inverse view-projection matrix.

A sphere overlaps the tile when it is not fully outside any of these planes. I also test it against the near plane so spheres far behind the camera do not enter the buckets:
Each thread owns its bucket, so I don't need atomics or clearing the buffer.
4. Rendering all spheres at once
The fragment shader finds the bucket of its tile, prepares the ray and the scene distance once, then loops through the spheres from the bucket:
The pass still uses Blend One One, but now each pixel is blended only once, no matter how many spheres cover it.
5. Debugging the buckets
Before rendering the fog, I visualized the buckets. This heatmap shows how many spheres each tile contains. Blue tiles contain one sphere, while brighter and redder tiles contain more.
I added a DEBUG_HEATMAP define to the fragment shader to switch between this view and the fog. It is a plain #define, not a shader variant, so it costs nothing when disabled.
___
Results
Here, I switch between the original spheres and the new tiled rendering. The look is the same.
Well… Almost the same, I was to lazy to tweak all the spheres to look exactly the same. But the math checks out!
Optimization iterations
In each iteration, I profiled this view:

Baseline: transparent spheres
The original transparent sphere meshes take 0.496 ms. The first screenshot shows the timings, and the second shows the throughput of the individual GPU units. Writing pixel colors is the bottleneck.


First iteration: tiled rendering
The first version of the tiled rendering takes 0.33 ms in total:
0.07 ms to build the buckets for 42 spheres,
0.24 ms to render them.

Compute shader bottlenecks:

Fragment shader bottlenecks:

This version is already faster than the original. However, the compute shader's SM usage is high, so I optimized it first.
Second iteration: compute shader group size
The profiler reported low thread activity, with about 1.2 active threads per group:

The cause was the group size. In the first version, each thread group contained a single thread that computed a single tile:
NVIDIA GPUs execute threads in warps of 32. A group with one thread still occupies a warp, so most of its lanes stay idle. I changed the group size to 4x4 tiles and dispatched fewer groups:
Note: A 4x4 group contains 16 threads, so it still fills only half of a 32-lane NVIDIA warp. Even so, it uses the GPU much better than the original one-thread group.
The compute shader time dropped to 0.01 ms, about 7x faster:
:center-50:


Throughput is now low on all GPU units. I could use smaller tiles to run more threads and make the buckets more accurate, but that would add work to this shader. At 0.01 ms, I will leave it here. This leaves some budget for more complex bucket building in the future.
Third iteration: fragment shader and groupshared memory
Next, I optimize the fragment shader. In this view, it takes 0.237 ms.

Most units show high utilization and thread occupancy. When optimizing fragment shaders, I usually check color blending first, represented here by Screen Pipe throughput. It is already high at 54%, leaving little room for improvement. The shader is also compute-heavy, with high SM throughput, so further optimization will be difficult.
However, the shader spends most of its time waiting for texture or global memory reads. The likely causes are depth buffer reads and fetching sphere data.

Every pixel reads its tile's entire bucket, so all pixels in a tile read the same spheres. I tried replacing the fragment shader with a compute shader that uses groupshared memory. This gives me manual control over on-chip memory. One thread can load the spheres from VRAM once, then the other threads can read them from fast shared memory, reducing L2 and VRAM bandwidth.
I used 8x8 thread groups so each group stays within one tile. The first thread loads the spheres into groupshared memory while the rest of the group waits at a barrier.

This is the code:
But I could not make it faster this way. Every compute shader version was slower than the fragment shader, and performance depended heavily on group size:
Version | Group size | Render time |
|---|---|---|
Fragment shader | - | 0.237 ms |
Compute + groupshared | 8x8 | 0.28 ms |
Compute + groupshared | 4x4 | 0.45 ms |
Compute + groupshared | 32x32 | 1.11 ms |
:center-50:


In the end, I reverted this "optimization".
Note: One thread loads up to 31 spheres while the others wait at the barrier. The compute version also writes every pixel to a new color texture. Compute shaders cannot use the fixed-function blending hardware available to raster passes, so compositing requires additional work. These costs probably make this version slower.
My next idea was to calculate the minimum and maximum scene depth within each tile and include it in sphere culling. This would use the depth buffer to cull spheres hidden behind the scene. However, building a depth pyramid just for this would probably cost more than the entire effect, and this project has no existing depth pyramid to reuse. I decided not to pursue it.
So the optimized version renders in 0.247 ms: 0.01 ms for the compute shader and the rest for the fog. It is ~2x faster than the original:

Fourth iteration: rendering nothing
One problem remains: performance when the camera sees no spheres or only one. The previous effect was much more efficient in this case.
This is the view I profiled to measure the overhead when no spheres are visible. The spheres are still in the scene, but the camera faces away from them:

The original effect takes 0.003 ms here because no spheres are visible:
:center-50:

But the new effect runs fullscreen and shades every pixel like a post-processing effect. It takes 0.15 ms to render nothing:

I need to fix that.
Discard
A quick fix is the discard instruction. A pixel in a tile without spheres discards itself instead of blending black into the screen.
Before:
After:
The effect now renders in 0.074 ms, which is a constant overhead:

Instanced tiles
The fragment shader still runs for every pixel on the screen. My next idea was to skip empty tiles before the rasterizer gets to them.
Instead of one fullscreen quad, I use instancing to render one quad per tile:
Quads for empty tiles move outside the screen, while quads for tiles with spheres stay in place.

The instance ID becomes the tile ID, which simplifies bucket reads. The vertex shader reads the bucket count once per vertex. For an empty tile, it moves the quad outside the clip volume so the GPU clips the entire tile:
The bucket address and count are passed to the fragment shader as nointerpolation values, so it no longer needs to find its tile.
This is how the tiles look when visualized:
I profiled the original view again. It became about 0.03 ms slower after I replaced the fullscreen triangle with many small quads. I did not investigate the exact cause. Possible costs include primitive setup, additional vertex work, bucket reads, and rasterization overhead. It is still much faster than the transparent spheres.

The view without spheres is now faster. It takes only 0.024 ms, one-third of the overhead of discard.

It is still not nothing. To eliminate the GPU overhead, I would need to cull all spheres on the CPU and disable the effect when none are visible.
Memory usage
One more quick note before the summary. Tiled rendering trades GPU time for extra memory, so it is worth checking how much memory the buckets actually use.
The bucket buffer adds 32 uint values, or 128 bytes, per tile. With 32x32 tiles, each bucket covers 1,024 pixels, so the buffer costs 128 / 1024 = 0.125 bytes per pixel.
Smaller tiles improve culling accuracy, but they also increase memory usage:
Tile size | Pixels per tile | Calculation | Additional memory per pixel | Buffer size at 4K |
|---|---|---|---|---|
8x8 | 64 | 128 / 64 | 2.000 bytes | 15.82 MiB |
16x16 | 256 | 128 / 256 | 0.500 bytes | 3.96 MiB |
32x32 | 1,024 | 128 / 1,024 | 0.125 bytes | 1.00 MiB |
64x64 | 4,096 | 128 / 4,096 | 0.03125 bytes | 0.25 MiB |
The 4K values use a 3840x2160 render target and round the tile count up at the screen edges. For example, 16x16 tiles create 240 * 135 = 32,400 buckets. At 128 bytes each, the buffer uses 4,147,200 bytes, or about 3.96 MiB.
Be careful with memory usage when implementing this technique, especially with small tiles or larger bucket capacities. This is why each bucket stores only sphere IDs, not the full sphere data. An ID needs 4 bytes, while one SphereDensityData value needs 32 bytes. The IDs index a single shared sphere buffer without duplicating that data across every overlapping tile.
___
Summary
I replaced dozens of transparent sphere draws with tiled rendering. A compute shader builds a bucket of sphere IDs for every 32x32 pixel tile, and a single pass evaluates only the spheres from each tile's bucket. Each pixel creates one ray, reads the depth once, and blends once.
View with many spheres:
Version | Buckets | Rendering | Total |
|---|---|---|---|
Transparent spheres | - | - | 0.496 ms |
Tiled, 1x1 compute groups | 0.07 ms | 0.24 ms | 0.33 ms |
Tiled, 4x4 compute groups | 0.01 ms | 0.237 ms | 0.247 ms |
Tiled, instanced tiles | 0.01 ms | 0.274 ms | 0.284 ms |
View with almost no spheres:
Version | Total |
|---|---|
Transparent spheres | 0.003 ms |
Tiled, fullscreen | 0.15 ms |
Tiled, fullscreen + | 0.074 ms |
Tiled, instanced tiles | 0.024 ms |
There is no single fastest version for every view. The fullscreen version is faster when many tiles contain spheres, while instanced tiles have much lower overhead when most tiles are empty. I use the instanced version as the final implementation and accept its small cost in dense views. A more advanced implementation could switch between both paths, but that adds complexity and requires a reliable selection heuristic.
Key takeaways
Tiled rendering removes repeated sphere blending. Before the instanced-tile optimization, the pass shades every screen pixel once, whether zero, one, or many spheres affect it. The shader still loops through the spheres in that pixel's tile, but the final color is blended only once.
It is more complex and has a constant overhead. The original effect costs nothing when no spheres are visible. The tiled version always builds the buckets and pays a small cost for rendering nothing. Instanced tiles with collapsed empty quads reduced that overhead to 0.024 ms.
Check the group size of compute shaders. A 1x1 group leaves most of the warp idle. Changing it to 4x4 made the bucket building ~7x faster.
Groupshared memory is not a free win. It gives manual control over on-chip memory and lets one thread prepare local data for the whole group. Here, serialized loading and extra fullscreen writes cost more than the memory reads they saved. Always profile it.
Keep the data small.
uintbuffers are great for storing lists, and each memory operation counts. Packing the radius into the position kept each sphere at 32 bytes.
There is still room for improvement. Storing the minimum and maximum scene depth per tile would add occlusion culling to the buckets. In my experience, this is worthwhile only when occlusion culling is critical to the game or several effects can share the same depth pyramid. I did not measure it here.
The same technique works for any effect that affects an area around a point in 2D or 3D: decals, lights, reflection probes, and more. This is the core idea behind tiled and clustered lighting.
Here is some eye candy to finish. The fog now renders about 2x faster. Not bad!


