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:

float SphereDensity(float3 rayOrigin, float3 rayDirection, float3 sphereCenter, float sphereRadius, float sceneDistance)
{
	// Normalize the space to the unit-sphere
	float normalizedSceneDistance = sceneDistance / sphereRadius;
	float3 toSphereCenter = (rayOrigin - sphereCenter) / sphereRadius;

	// Find intersection with the sphere
	float b = dot(rayDirection, toSphereCenter);
	float c = dot(toSphereCenter, toSphereCenter) - 1.0;
	float h = b * b - c;

	if (h < 0.0)
		return 0.0;

	h = sqrt(h);
	float t1 = -b - h; // Distance to the closer intersection
	float t2 = -b + h; // Distance to the further intersection

	// If the sphere is behind the camera, or fully behind the scene - skip
	if (t2 < 0.0 || t1 > normalizedSceneDistance)
		return 0.0;

	float t1Clipped = max(t1, 0.0);
	float t2Clipped = min(t2, normalizedSceneDistance);
	float linearFactor = (t2Clipped - t1Clipped) / max(t2 - t1, 0.001);
	return pow((t2 - t1) * 0.5, 4.0) * linearFactor;
}
float SphereDensity(float3 rayOrigin, float3 rayDirection, float3 sphereCenter, float sphereRadius, float sceneDistance)
{
	// Normalize the space to the unit-sphere
	float normalizedSceneDistance = sceneDistance / sphereRadius;
	float3 toSphereCenter = (rayOrigin - sphereCenter) / sphereRadius;

	// Find intersection with the sphere
	float b = dot(rayDirection, toSphereCenter);
	float c = dot(toSphereCenter, toSphereCenter) - 1.0;
	float h = b * b - c;

	if (h < 0.0)
		return 0.0;

	h = sqrt(h);
	float t1 = -b - h; // Distance to the closer intersection
	float t2 = -b + h; // Distance to the further intersection

	// If the sphere is behind the camera, or fully behind the scene - skip
	if (t2 < 0.0 || t1 > normalizedSceneDistance)
		return 0.0;

	float t1Clipped = max(t1, 0.0);
	float t2Clipped = min(t2, normalizedSceneDistance);
	float linearFactor = (t2Clipped - t1Clipped) / max(t2 - t1, 0.001);
	return pow((t2 - t1) * 0.5, 4.0) * linearFactor;
}
float SphereDensity(float3 rayOrigin, float3 rayDirection, float3 sphereCenter, float sphereRadius, float sceneDistance)
{
	// Normalize the space to the unit-sphere
	float normalizedSceneDistance = sceneDistance / sphereRadius;
	float3 toSphereCenter = (rayOrigin - sphereCenter) / sphereRadius;

	// Find intersection with the sphere
	float b = dot(rayDirection, toSphereCenter);
	float c = dot(toSphereCenter, toSphereCenter) - 1.0;
	float h = b * b - c;

	if (h < 0.0)
		return 0.0;

	h = sqrt(h);
	float t1 = -b - h; // Distance to the closer intersection
	float t2 = -b + h; // Distance to the further intersection

	// If the sphere is behind the camera, or fully behind the scene - skip
	if (t2 < 0.0 || t1 > normalizedSceneDistance)
		return 0.0;

	float t1Clipped = max(t1, 0.0);
	float t2Clipped = min(t2, normalizedSceneDistance);
	float linearFactor = (t2Clipped - t1Clipped) / max(t2 - t1, 0.001);
	return pow((t2 - t1) * 0.5, 4.0) * linearFactor;
}


___

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:

uint tileIndex = tileId.y * tileResolution.x + tileId.x;
uint bucketAddress = tileIndex * TILE_BUCKET_STRIDE * 4; // 32 uints, 4 bytes each
uint tileIndex = tileId.y * tileResolution.x + tileId.x;
uint bucketAddress = tileIndex * TILE_BUCKET_STRIDE * 4; // 32 uints, 4 bytes each
uint tileIndex = tileId.y * tileResolution.x + tileId.x;
uint bucketAddress = tileIndex * TILE_BUCKET_STRIDE * 4; // 32 uints, 4 bytes each


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:

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

  2. 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(PackingRules.Exact, needAccessors = false, needSetters = false)]
public struct SphereDensityData
{
	public float4 positionWS_radiusWS; // xyz - position, w - radius
	public float4 color;
}
[GenerateHLSL(PackingRules.Exact, needAccessors = false, needSetters = false)]
public struct SphereDensityData
{
	public float4 positionWS_radiusWS; // xyz - position, w - radius
	public float4 color;
}
[GenerateHLSL(PackingRules.Exact, needAccessors = false, needSetters = false)]
public struct SphereDensityData
{
	public float4 positionWS_radiusWS; // xyz - position, w - radius
	public float4 color;
}


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:

var localToWorld = (float4x4)transform.localToWorldMatrix;
float radius = math.length(localToWorld.c0.xyz) * 0.5f;

dataPtr->positionWS_radiusWS = new float4(localToWorld.c3.xyz, radius);
dataPtr->color = color.linear;
var localToWorld = (float4x4)transform.localToWorldMatrix;
float radius = math.length(localToWorld.c0.xyz) * 0.5f;

dataPtr->positionWS_radiusWS = new float4(localToWorld.c3.xyz, radius);
dataPtr->color = color.linear;
var localToWorld = (float4x4)transform.localToWorldMatrix;
float radius = math.length(localToWorld.c0.xyz) * 0.5f;

dataPtr->positionWS_radiusWS = new float4(localToWorld.c3.xyz, radius);
dataPtr->color = color.linear;

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:

var instances = SphereDensity.AllInstances;
int count = math.min(instances.Count, C_MaxSpheres);

var sphereData = new NativeArray<SphereDensityData>(count, Allocator.Temp, NativeArrayOptions.UninitializedMemory);
for (int i = 0; i < count; i++)
	sphereData[i] = *(SphereDensityData*)instances[i];

context.cmd.SetBufferData(passData.sphereBufferHandle, sphereData);
sphereData.Dispose();
var instances = SphereDensity.AllInstances;
int count = math.min(instances.Count, C_MaxSpheres);

var sphereData = new NativeArray<SphereDensityData>(count, Allocator.Temp, NativeArrayOptions.UninitializedMemory);
for (int i = 0; i < count; i++)
	sphereData[i] = *(SphereDensityData*)instances[i];

context.cmd.SetBufferData(passData.sphereBufferHandle, sphereData);
sphereData.Dispose();
var instances = SphereDensity.AllInstances;
int count = math.min(instances.Count, C_MaxSpheres);

var sphereData = new NativeArray<SphereDensityData>(count, Allocator.Temp, NativeArrayOptions.UninitializedMemory);
for (int i = 0; i < count; i++)
	sphereData[i] = *(SphereDensityData*)instances[i];

context.cmd.SetBufferData(passData.sphereBufferHandle, sphereData);
sphereData.Dispose();


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:

uint bucketCount = 0;
for (uint sphereId = 0; sphereId < _SphereCount && bucketCount < MAX_SPHERES_PER_TILE; sphereId++)
{
	SphereDensityData sphere = _SphereBuffer[sphereId];
	float3 center = sphere.positionWS_radiusWS.xyz;
	float radius = sphere.positionWS_radiusWS.w;

	// Sphere vs near plane
	bool overlaps = dot(_NearPlaneWS.xyz, center) + _NearPlaneWS.w >= -radius;

	// Sphere vs tile side planes
	[unroll]
	for (uint p = 0; p < 4; p++)
		overlaps = overlaps && (dot(planes[p].xyz, center) + planes[p].w >= -radius);

	if (overlaps)
	{
		bucketCount++;
		_TileBucketsBuffer.Store(bucketAddress + bucketCount * 4, sphereId);
	}
}

// First element is the sphere count
_TileBucketsBuffer.Store(bucketAddress, bucketCount);
uint bucketCount = 0;
for (uint sphereId = 0; sphereId < _SphereCount && bucketCount < MAX_SPHERES_PER_TILE; sphereId++)
{
	SphereDensityData sphere = _SphereBuffer[sphereId];
	float3 center = sphere.positionWS_radiusWS.xyz;
	float radius = sphere.positionWS_radiusWS.w;

	// Sphere vs near plane
	bool overlaps = dot(_NearPlaneWS.xyz, center) + _NearPlaneWS.w >= -radius;

	// Sphere vs tile side planes
	[unroll]
	for (uint p = 0; p < 4; p++)
		overlaps = overlaps && (dot(planes[p].xyz, center) + planes[p].w >= -radius);

	if (overlaps)
	{
		bucketCount++;
		_TileBucketsBuffer.Store(bucketAddress + bucketCount * 4, sphereId);
	}
}

// First element is the sphere count
_TileBucketsBuffer.Store(bucketAddress, bucketCount);
uint bucketCount = 0;
for (uint sphereId = 0; sphereId < _SphereCount && bucketCount < MAX_SPHERES_PER_TILE; sphereId++)
{
	SphereDensityData sphere = _SphereBuffer[sphereId];
	float3 center = sphere.positionWS_radiusWS.xyz;
	float radius = sphere.positionWS_radiusWS.w;

	// Sphere vs near plane
	bool overlaps = dot(_NearPlaneWS.xyz, center) + _NearPlaneWS.w >= -radius;

	// Sphere vs tile side planes
	[unroll]
	for (uint p = 0; p < 4; p++)
		overlaps = overlaps && (dot(planes[p].xyz, center) + planes[p].w >= -radius);

	if (overlaps)
	{
		bucketCount++;
		_TileBucketsBuffer.Store(bucketAddress + bucketCount * 4, sphereId);
	}
}

// First element is the sphere count
_TileBucketsBuffer.Store(bucketAddress, bucketCount);


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:

uint count = _TileBucketsBuffer.Load(bucketAddress);
if (count == 0)
	return 0;

// Ray and scene distance - calculated once per pixel
float rawDepth = SampleSceneDepth(uv);
float3 rayOriginWS = UnprojectNdc(ndc, UNITY_NEAR_CLIP_VALUE);
float3 sceneWS = UnprojectNdc(ndc, rawDepth);
float3 toScene = sceneWS - rayOriginWS;
float distanceToScene = length(toScene);
float3 rayDirectionWS = toScene / max(distanceToScene, 1e-5);

// Accumulate every sphere from the bucket
half4 color = 0;
for (uint i = 1; i <= count; i++)
{
	SphereDensityData sphere = _SphereBuffer[_TileBucketsBuffer.Load(bucketAddress + i * 4)];

	float sphereDensity = SphereDensity(
		rayOriginWS, rayDirectionWS,
		sphere.positionWS_radiusWS.xyz, sphere.positionWS_radiusWS.w,
		distanceToScene);

	color += sphereDensity * sphere.color * sphere.color.a;
}

return color * (unity_FogParams.x * 25);
uint count = _TileBucketsBuffer.Load(bucketAddress);
if (count == 0)
	return 0;

// Ray and scene distance - calculated once per pixel
float rawDepth = SampleSceneDepth(uv);
float3 rayOriginWS = UnprojectNdc(ndc, UNITY_NEAR_CLIP_VALUE);
float3 sceneWS = UnprojectNdc(ndc, rawDepth);
float3 toScene = sceneWS - rayOriginWS;
float distanceToScene = length(toScene);
float3 rayDirectionWS = toScene / max(distanceToScene, 1e-5);

// Accumulate every sphere from the bucket
half4 color = 0;
for (uint i = 1; i <= count; i++)
{
	SphereDensityData sphere = _SphereBuffer[_TileBucketsBuffer.Load(bucketAddress + i * 4)];

	float sphereDensity = SphereDensity(
		rayOriginWS, rayDirectionWS,
		sphere.positionWS_radiusWS.xyz, sphere.positionWS_radiusWS.w,
		distanceToScene);

	color += sphereDensity * sphere.color * sphere.color.a;
}

return color * (unity_FogParams.x * 25);
uint count = _TileBucketsBuffer.Load(bucketAddress);
if (count == 0)
	return 0;

// Ray and scene distance - calculated once per pixel
float rawDepth = SampleSceneDepth(uv);
float3 rayOriginWS = UnprojectNdc(ndc, UNITY_NEAR_CLIP_VALUE);
float3 sceneWS = UnprojectNdc(ndc, rawDepth);
float3 toScene = sceneWS - rayOriginWS;
float distanceToScene = length(toScene);
float3 rayDirectionWS = toScene / max(distanceToScene, 1e-5);

// Accumulate every sphere from the bucket
half4 color = 0;
for (uint i = 1; i <= count; i++)
{
	SphereDensityData sphere = _SphereBuffer[_TileBucketsBuffer.Load(bucketAddress + i * 4)];

	float sphereDensity = SphereDensity(
		rayOriginWS, rayDirectionWS,
		sphere.positionWS_radiusWS.xyz, sphere.positionWS_radiusWS.w,
		distanceToScene);

	color += sphereDensity * sphere.color * sphere.color.a;
}

return color * (unity_FogParams.x * 25);


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:

[numthreads(1, 1, 1)]
void BuildBuckets(uint3 tileId : SV_GroupID)
[numthreads(1, 1, 1)]
void BuildBuckets(uint3 tileId : SV_GroupID)
[numthreads(1, 1, 1)]
void BuildBuckets(uint3 tileId : SV_GroupID)


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:

[numthreads(4, 4, 1)]
void BuildBuckets(uint3 tileId : SV_DispatchThreadID)
[numthreads(4, 4, 1)]
void BuildBuckets(uint3 tileId : SV_DispatchThreadID)
[numthreads(4, 4, 1)]
void BuildBuckets(uint3 tileId : SV_DispatchThreadID)


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.

int2 groupCount = (passData.tileBufferResolution + (C_BuildBucketsGroupSize - 1)) / C_BuildBucketsGroupSize;
context.cmd.DispatchCompute(passData.buildBucketsCompute, 0, groupCount.x, groupCount.y, 1);
int2 groupCount = (passData.tileBufferResolution + (C_BuildBucketsGroupSize - 1)) / C_BuildBucketsGroupSize;
context.cmd.DispatchCompute(passData.buildBucketsCompute, 0, groupCount.x, groupCount.y, 1);
int2 groupCount = (passData.tileBufferResolution + (C_BuildBucketsGroupSize - 1)) / C_BuildBucketsGroupSize;
context.cmd.DispatchCompute(passData.buildBucketsCompute, 0, groupCount.x, groupCount.y, 1);


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:

groupshared uint gs_SphereCount;
groupshared SphereDensityData gs_Spheres[MAX_SPHERES_PER_TILE];

[numthreads(GROUP_SIZE, GROUP_SIZE, 1)]
void RenderSpheres(uint3 groupId : SV_GroupID, uint3 tileSpacePixel : SV_DispatchThreadID, uint groupIndex : SV_GroupIndex)
{
	if (groupIndex == 0)
	{
		uint2 tileId = groupId.xy / (TILE_SIZE / GROUP_SIZE);
		uint bucketAddress = (tileId.y * (uint)_TileBufferResolution.x + tileId.x) * TILE_BUCKET_STRIDE * 4;

		uint count = min(_TileBucketsBuffer.Load(bucketAddress), MAX_SPHERES_PER_TILE);
		for (uint i = 0; i < count; i++)
			gs_Spheres[i] = _SphereBuffer[_TileBucketsBuffer.Load(bucketAddress + (i + 1) * 4)];

		gs_SphereCount = count;
	}

	GroupMemoryBarrierWithGroupSync();

	// Every thread now reads the spheres from gs_Spheres
	...
}
groupshared uint gs_SphereCount;
groupshared SphereDensityData gs_Spheres[MAX_SPHERES_PER_TILE];

[numthreads(GROUP_SIZE, GROUP_SIZE, 1)]
void RenderSpheres(uint3 groupId : SV_GroupID, uint3 tileSpacePixel : SV_DispatchThreadID, uint groupIndex : SV_GroupIndex)
{
	if (groupIndex == 0)
	{
		uint2 tileId = groupId.xy / (TILE_SIZE / GROUP_SIZE);
		uint bucketAddress = (tileId.y * (uint)_TileBufferResolution.x + tileId.x) * TILE_BUCKET_STRIDE * 4;

		uint count = min(_TileBucketsBuffer.Load(bucketAddress), MAX_SPHERES_PER_TILE);
		for (uint i = 0; i < count; i++)
			gs_Spheres[i] = _SphereBuffer[_TileBucketsBuffer.Load(bucketAddress + (i + 1) * 4)];

		gs_SphereCount = count;
	}

	GroupMemoryBarrierWithGroupSync();

	// Every thread now reads the spheres from gs_Spheres
	...
}
groupshared uint gs_SphereCount;
groupshared SphereDensityData gs_Spheres[MAX_SPHERES_PER_TILE];

[numthreads(GROUP_SIZE, GROUP_SIZE, 1)]
void RenderSpheres(uint3 groupId : SV_GroupID, uint3 tileSpacePixel : SV_DispatchThreadID, uint groupIndex : SV_GroupIndex)
{
	if (groupIndex == 0)
	{
		uint2 tileId = groupId.xy / (TILE_SIZE / GROUP_SIZE);
		uint bucketAddress = (tileId.y * (uint)_TileBufferResolution.x + tileId.x) * TILE_BUCKET_STRIDE * 4;

		uint count = min(_TileBucketsBuffer.Load(bucketAddress), MAX_SPHERES_PER_TILE);
		for (uint i = 0; i < count; i++)
			gs_Spheres[i] = _SphereBuffer[_TileBucketsBuffer.Load(bucketAddress + (i + 1) * 4)];

		gs_SphereCount = count;
	}

	GroupMemoryBarrierWithGroupSync();

	// Every thread now reads the spheres from gs_Spheres
	...
}


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:

uint count = _TileBucketsBuffer.Load(bucketAddress);
if (count == 0)
	return 0;
uint count = _TileBucketsBuffer.Load(bucketAddress);
if (count == 0)
	return 0;
uint count = _TileBucketsBuffer.Load(bucketAddress);
if (count == 0)
	return 0;


After:

uint count = _TileBucketsBuffer.Load(bucketAddress);
if (count == 0)
	discard;
uint count = _TileBucketsBuffer.Load(bucketAddress);
if (count == 0)
	discard;
uint count = _TileBucketsBuffer.Load(bucketAddress);
if (count == 0)
	discard;


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:

int tileCount = passData.tileBufferResolution.x * passData.tileBufferResolution.y;
context.cmd.DrawProcedural(Matrix4x4.identity, passData.material, 0, MeshTopology.Triangles, 6, tileCount, rasterProperties);
int tileCount = passData.tileBufferResolution.x * passData.tileBufferResolution.y;
context.cmd.DrawProcedural(Matrix4x4.identity, passData.material, 0, MeshTopology.Triangles, 6, tileCount, rasterProperties);
int tileCount = passData.tileBufferResolution.x * passData.tileBufferResolution.y;
context.cmd.DrawProcedural(Matrix4x4.identity, passData.material, 0, MeshTopology.Triangles, 6, tileCount, rasterProperties);


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:

Varyings vert(uint vertexID : SV_VertexID, uint tileIndex : SV_InstanceID)
{
	static const float2 quad[6] =
	{
		float2(0, 0), float2(0, 1), float2(1, 1),
		float2(0, 0), float2(1, 1), float2(1, 0)
	};

	uint tileResolutionX = (uint)_TileBufferResolution.x;
	uint2 tileId = uint2(tileIndex % tileResolutionX, tileIndex / tileResolutionX);
	uint bucketAddress = tileIndex * TILE_BUCKET_STRIDE * 4;
	uint count = _TileBucketsBuffer.Load(bucketAddress);

	Varyings OUT;
	OUT.bucketAddress = bucketAddress;
	OUT.count = count;

	// Empty tile - move the quad outside the clip volume
	if (count == 0)
	{
		OUT.positionCS = float4(-2.0, -2.0, -2.0, 1.0);
		return OUT;
	}

	// Place the quad over its tile
	float2 ndc = (float2(tileId) + quad[vertexID]) / _TileBufferResolution * 2.0 - 1.0;
	OUT.positionCS = float4(ndc, UNITY_RAW_FAR_CLIP_VALUE, 1.0);
	return OUT;
}
Varyings vert(uint vertexID : SV_VertexID, uint tileIndex : SV_InstanceID)
{
	static const float2 quad[6] =
	{
		float2(0, 0), float2(0, 1), float2(1, 1),
		float2(0, 0), float2(1, 1), float2(1, 0)
	};

	uint tileResolutionX = (uint)_TileBufferResolution.x;
	uint2 tileId = uint2(tileIndex % tileResolutionX, tileIndex / tileResolutionX);
	uint bucketAddress = tileIndex * TILE_BUCKET_STRIDE * 4;
	uint count = _TileBucketsBuffer.Load(bucketAddress);

	Varyings OUT;
	OUT.bucketAddress = bucketAddress;
	OUT.count = count;

	// Empty tile - move the quad outside the clip volume
	if (count == 0)
	{
		OUT.positionCS = float4(-2.0, -2.0, -2.0, 1.0);
		return OUT;
	}

	// Place the quad over its tile
	float2 ndc = (float2(tileId) + quad[vertexID]) / _TileBufferResolution * 2.0 - 1.0;
	OUT.positionCS = float4(ndc, UNITY_RAW_FAR_CLIP_VALUE, 1.0);
	return OUT;
}
Varyings vert(uint vertexID : SV_VertexID, uint tileIndex : SV_InstanceID)
{
	static const float2 quad[6] =
	{
		float2(0, 0), float2(0, 1), float2(1, 1),
		float2(0, 0), float2(1, 1), float2(1, 0)
	};

	uint tileResolutionX = (uint)_TileBufferResolution.x;
	uint2 tileId = uint2(tileIndex % tileResolutionX, tileIndex / tileResolutionX);
	uint bucketAddress = tileIndex * TILE_BUCKET_STRIDE * 4;
	uint count = _TileBucketsBuffer.Load(bucketAddress);

	Varyings OUT;
	OUT.bucketAddress = bucketAddress;
	OUT.count = count;

	// Empty tile - move the quad outside the clip volume
	if (count == 0)
	{
		OUT.positionCS = float4(-2.0, -2.0, -2.0, 1.0);
		return OUT;
	}

	// Place the quad over its tile
	float2 ndc = (float2(tileId) + quad[vertexID]) / _TileBufferResolution * 2.0 - 1.0;
	OUT.positionCS = float4(ndc, UNITY_RAW_FAR_CLIP_VALUE, 1.0);
	return OUT;
}

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

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. uint buffers 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!

Join the community

If you are interested in learning more,
join the community discussions on Saturday at 15:00 CET

We meet weekly, discussing the rendering and optimization problems.
Join our mastermind sessions, chill discussions and small online conference events.

Join the community

If you are interested in learning more,
join the community discussions on Saturday at 15:00 CET

We meet weekly, discussing the rendering and optimization problems.
Join our mastermind sessions, chill discussions and small online conference events.

I write expert content on optimizing Unity games, customizing rendering pipelines, and enhancing the Unity Editor.

Copyright © 2026 Jan Mróz | Procedural Pixels

I write expert content on optimizing Unity games, customizing rendering pipelines, and enhancing the Unity Editor.

Copyright © 2026 Jan Mróz | Procedural Pixels

I write expert content on optimizing Unity games, customizing rendering pipelines, and enhancing the Unity Editor.

Copyright © 2026 Jan Mróz | Procedural Pixels