Split Radiance Cascades

Implementing 3D Radiance Cascades for Global Illumination using Ray Splitting

San Miguel with RC GI

Intro

Radiance cascades is a technique that massively improves global illumination without much extra cost or accumulation.
In this article I'll go over the recent technique of Ray Splitting which allows the use of RC (Radiance cascades)
in 3D without huge costs, and how to implement it.

Note that the code examples are modified for clearability, so might not fully work

Radiance Cascades

For this article I'll assume you already know the basics of RC,
if not I really recommend the interactive tutorial from jason.today or the article from MAX
Here is a quick recap as well.

RC Recap

The main point of RC is combining the samples/rays for our GI in a way that utilizes them most effectively.
This results in a more correct output with less samples. The way of combining this relies on the penumbra hypothesis.
Which states that, when you are close to a light source you need fewer rays (less angular resolution) to find the light.

Animation showing light missing, when further

In addition when you are close to a light you need more probes (spatial resolution) to get correct shadows

Showing how larger interpolation works out far away

To interpolate the half shadow, we need the light at both sides. Further away this spacing can be bigger to still get probes on both sides.

By merging layers ranging from (high resolution, low direction count) to (low resolution, high direction count) that correspond to an interval of the ray, we get best of both of these in the range that matters.

Probes

Problems of 3D

RC requires storing our light into a grid of probes.
This becomes a problem in 3D due to the massive memory cost that introduces. since probe counts scale ^3

Showcasing memory scaling

Even if we would only put this around the camera it would cost way too much memory to get a decent resolution.
Luckily we only need probes where we need light, which is on all visible surfaces.
So we need a way to only put probes on visible geometry.

Sparse probes

To only spawn probes on visible surfaces, we reconstruct world positions from the screen.
We round these positions to a grid of cells.

Cells based on world position

To store our light in these cells we need a buffer of probes.
To allocate this we use a hashtable. Creating a hash for each cell allows us to quickly allocate a slot for each of these without much fighting between threads.
We also store an AppendBuffer with a list of the probes we allocated, this will be used later to launch our next passes indirectly.
(this somewhat goes against the use of the hashtable, but the table will be needed more later)
We clear and fill this table every frame and with this we get a table of sparsely allocated probes.

// HLSL, Compute: "GatherC0Probes.hlsl"
//For now most kinds of hash tables are fine
#include "HashTable.hlsl"

RWStructuredBuffer‹uint› ActiveProbes;
RWStructuredBuffer‹uint› ActiveCounter;

[numthreads(8, 8, 1)]
void MainCS(uint2 DispatchThreadId : SV_DispatchThreadID)
{
    //We run over every pixel, to allocate a probe at its world position
	uint2 PixelId = DispatchThreadId;
	
	// Check that we are in scope
    if (any(float2(DispatchThreadId) >= ViewportPixelSize)) {
        return;
    }

    float2 UV = (float2(PixelId) + 0.5f) / float2(ViewportPixelSize);

    float DeviceZ = SampleSceneDepth(UV);
    if (DeviceZ == 0) {
        return;
    }

    //We can reconstruct the world position from the depthbuffer using our camera view, most engines have a helper for this
    float3 WorldPos = ReconstructWorldPositionFromDepth(DeviceZ);

	float VoxelSize = BaseVoxelSize;
	int3 Cell = int3(floor((WorldPos / VoxelSize)));

    //For our Key we simply shift the bits of the cell into 1 big uint
    //Later we store more info in here so we dont use all the bits
    //Make sure to add + (1u ‹‹ (BitsPerInt - 1)) to each value to make them unsigned
    //So our key is
    //Bits:
    // xxxxxxxxxxxxxxxxyyyyyyyyyyyyyyyyzzzzzzzzzzzzzzzz0000000000000000
	uint64_t Key = PackProbeKey(Cell);

    //Add our key to the table

    //Get a random index from our Key, most hashing functions work on 32 bit uints so we do this
    uint Index = Hash(uint(Key) ^ Hash(uint(Key >> 32)));

    bool Added = HashTableAdd(Key,  [&]: Index, Cascade: 0);
    if (Added)
    {
        //Add to active probes for next passes
        uint Counter;
        InterlockedAdd(ActiveCounter[0], 1, Counter);
        ActiveProbes[Counter] = Index;
    }
}

We can visualize/debug our probes in a separate pass

// HLSL, Compute: "Output.hlsl"
#include "HashTable.hlsl"
#include "ColorFromHash.hlsl"

[numthreads(8, 8, 1)]
void MainCS(uint2 DispatchThreadId : SV_DispatchThreadID)
{
    //Go over every pixel and check if the probe is there
	uint2 PixelId = DispatchThreadId;

	// Check that we are in scope
    if (any(float2(DispatchThreadId) >= ViewportPixelSize)) {
        return;
    }

    float VoxelSize = BaseVoxelSize;
	int3 Cell = int3(floor((WorldPos / VoxelSize)));
	uint64_t Key = PackProbeKey(Cell);

    uint Index = Hash(uint(Key) ^ Hash(uint(Key >> 32)));
	bool Found = HashTableFind(Key, [&]: Index, Cascade: 0 );

    if (Found) {
        //Since our hashtable is unstable, using debugindex will flicker, you can use the key instead for now.
        Output[PixelId] = float4(ColorFromHash(Hash(Index)), 1.0);
    }else {
        Output[PixelId] = float4(1,0,0,1);
    }
}

A random hash per probe

Cascades

With this gather pass we now have a single cascade of probes allocated.
However we need multiple cascades of lowering resolution to do RC.
For this we need to fill separate hashtables for each cascade, we could just re-run our shader for each cascade but that would be very innefficient.
We already have our highest detail probes and there are significantly less of them than we have pixels.
So instead of running over all pixels again, we can just go over all the probes we just allocated, for each of these we just halve their position to get the cascade above it. Any duplicates we get from this are handled by the hashtable.
We run this bottom up for all cascades.
The number of cascades we need is somewhat arbitrairy based on your scene size i'll be using 4

// HLSL, Compute: "FillCascades.hlsl"
#include "HashTable.hlsl"

// Since each pass continues from the last,
// this starts off with the counter from the gather, and then is set to the counter we're filling for the next cascade. 
// So each pass launches the next
RWStructuredBuffer‹uint› PrevActiveProbes;
RWStructuredBuffer‹uint› PrevActiveCounter;


RWStructuredBuffer‹uint› NextActiveProbes;
RWStructuredBuffer‹uint› NextActiveCounter;


//The cascade we are filling, so this starts at 1
uint Cascade;

[numthreads(64, 1, 1)]
void MainCS(uint DispatchThreadId : SV_DispatchThreadID)
{
	uint ProbeId = DispatchThreadId;

	// Check that we are in scope (since the dispatch gets ceiled to group count)
	if (ProbeId >= PrevActiveCounter[0])
	{
		return;
	}

    // Get the actual key out of the active list which just contains hashtable id's
    // Im adding the cascade since my hashtable stores the separate tables in 1 big buffer
    uint64_t ProbeKey = HashTable[(Cascade-1) * HashTableSize + PrevActiveProbes[ProbeId]];

    // Since our key is just bit shifted positions we can unpack it again.
	ProbeKeyInfo KeyData = UnpackProbeKey(ProbeKey);

    // Get parent key by halving the position of our current key
	uint64_t HighKey = PackProbeKey(KeyData.Cell >> 1);

    uint Index = Hash(uint(HighKey) ^ Hash(uint(HighKey >> 32)));
    if (HashTableAdd(HighKey, Cascade, Index))
	{
		// Fill in the active list which we use for next pass. 
		uint Counter;
		InterlockedAdd(NextActiveCounter[0], 1, Counter);
		NextActiveProbes[Counter] = Index;

    }
}

                    
Multiple cascades shown split next to eachother

Note that when visualizing your higher cascades you need to shift your positions when creating the key

Rays

From probes vs from screen

Normally to get the lighting at every probe, we shoot a set amount of rays into random directions from each probe.
This however comes with quite a few issues.
Ray counts become very big with good enough spatial and angular resolution.
But the main problem is probe positioning, since our probes are in a grid they are not aligned with the surfaces which can cause a bunch of rays to shoot below the surface.
This can cause overocclusion and light leaking, and also makes it so we cannot shoot rays in just a hemisphere.

A probe with 2 rays overoccluded due to being inside the ground

The 2 red rays in this probe here got unfairly blocked because the probe is too far into the ground

This would cause dark splotches in our lighting.
So instead we can trace the rays from reconstructed world positions and put these into the probes that are at that spot.
This way rays are sent from real geometry avoiding this problem.

Rays being sent from the view position

Here the blue rays are sent from the position gotten from the screen, then moved into the closest probe.

Splitting into intervals

However, in RC each cascade does not contain the full ray, only a interval/segment of it.
Sending multiple rays for each interval at each pixel would be quite inefficient.
Instead we do it the other way around, we trace the full ray and pick the interval (and thus cascade) that it would have hit in based on its hit distance.
We fill the hit interval's cascade with the lighting, and then mark transparancy / that a ray traveled through them in the cascades below the hit one.

We store this in multiple bins per probe, one for each direction. We need to lay out these directions evenly and most importantly in a way that scales so that our merging evenly combines them into lower cascades.
For shooting each ray we can just pick a random direction in a hemisphere at our hit.

// HLSL, Compute:
#include "HashTable.hlsl"
#include "EncodeDirection.hlsl"

RWStructuredBuffer‹uint4› TotalRadiance;
RWStructuredBuffer‹uint› Weights;
RWStructuredBuffer‹uint› DepositCounts;

static const uint BaseDirections = 4; // This means 32 actual direction bins
static const uint kNumCascades = 4;
static const float kC0Len = 1.5958; // sqrt(2*BaseDirections*BaseDirections / (4*PI)),

[shader("raygeneration")] void MainRG()
{
	uint2 PixelCoord = DispatchRaysIndex().xy;

    float2 UV = (float2(PixelCoord) + 0.5f) / float2(ViewportPixelSize);

	FGBufferData GBuffer = GetGBufferDataFromSceneTextures(UV);

    float DeviceZ = SampleSceneDepth(UV);
    if (DeviceZ ‹= 0) {
    	return;
    }
    float3 WorldPos = ReconstructWorldPositionFromDepth(DeviceZ);
	
    //Also sample worldnormal from GBuffer, could also reconstruct from depth dx/dy
    float3 Normal = SampleWorldNormal(UV);

	FRayDesc Ray;
    // Add a bit of offset along the normal, this is needed since reconstructing from the depth is not perfectly accurate.
	Ray.Origin = WorldPos + Normal * 0.1;

	uint64_t TopKey = MakeProbeKey(WorldPos, CameraPos);

    uint TopProbeIndex = Hash(uint(TopKey) ^ Hash(uint(TopKey >> 32)));
	bool Found = HashTableFind(TopKey, [&]: TopProbeIndex, Cascade: 0);
	if (!Found)
	{
		//Shouldn't really be possible unless there is a mismatch with our gather 
		return;
	}

	float3 Radiance = 0;

    Ray.Direction = RandomHemiSphere(Normal).xyz;

    //Trace the actual ray, this should trace 1 hit with direct lighting applied.
	FMaterialClosestHitPayload Payload = TraceRayWithLighting(Ray, ..., [&]: Radiance);
    

	//Fill into containing cascades

    // Find the cascade the ray hit inside based on its hit length
    uint RayHitCascade = kNumCascades - 1;
    if (Payload.IsHit())
    {
        uint HitCascade = ceil(max(0.5 * log2(Payload.HitT / (kC0Len * BaseVoxelSize)), 0.0));
        //Clamp to the highest cascade we have, since we cannot allocate infinite cascades
        RayHitCascade = min(HitCascade, kNumCascades - 1);
    }

    //We fill the hit cascade and the ones before it, since the ray traveled through those intervals and not the ones after
    for (uint Cascade = 0; Cascade ‹= RayHitCascade; ++Cascade)
    {

        uint64_t Key = MakeProbeKey(WorldPos, CameraPos);
        ProbeKeyInfo KeyData = UnpackProbeKey(Key);

        uint64_t CascadeKey = PackProbeKey(KeyData.Cell >> Cascade);

        uint ProbeIndex = Hash(uint(CascadeKey) ^ Hash(uint(CascadeKey >> 32)));
        bool Found = HashTableFind(CascadeKey, [&]: ProbeIndex, Cascade);
        if (Found)
        {
            //We only deposit the full color if the ray hit in this cascade
            bool HitCascade = (Cascade == RayHitCascade);

            //Directions increase every cascade
            uint DirectionCount = BaseDirections ‹‹ Cascade;

            //I calculate a offset to store the probes for each cascade in 1 big buffer instead of separate ones per cascade, which is also valid.
            uint CascadeIndex = Cascade * MaximumProbes * BinsPerProbe(BaseDirections);

            //Convert the ray's direction to the index of the bin we store that direction in.
            uint DirectionalBinIndex = DirToLinear(Ray.Direction.xyz, DirectionCount);
            
            uint SlotIndex = CascadeIndex + ProbeIndex * BinsPerProbe(DirectionCount) + DirectionalBinIndex;

            float3 Rad = HitCascade ? Radiance : float3(0, 0, 0);

            //Normally you cannot do atomics on floats, so I pack the float values to a fixed point uint, 
            // This is a simple multiply/divide since the values are never negative 
            
            //Attomically deposit our hit, since multiple rays deposit to the same probe
            InterlockedAdd(TotalRadiance[SlotIndex].x, PackFloatUnsigned(Rad.x));
            InterlockedAdd(TotalRadiance[SlotIndex].y, PackFloatUnsigned(Rad.y));
            InterlockedAdd(TotalRadiance[SlotIndex].z, PackFloatUnsigned(Rad.z));
            InterlockedAdd(TotalRadiance[SlotIndex].w, HitCascade ? 0u : 1u); // Transmittance, so rays that went through but didn't hit
            
            InterlockedAdd(Weights[SlotIndex], 1u); // Used for the "quality" of the bin when merging, to count how many rays got deposited in it
            InterlockedAdd(DepositCounts[Cascade * MaximumProbes + ProbeIndex], PackedLodWeight); // Same but for the whole probe
        }
	}
}

With this we should now have every cascade filled with its interval of light.
If we display this by just averaging all bins in an output pass, we can see the rings of light this contains.

Merging

We now want to merge these intervals together so we can get our entire path of light.
We do this by going through our cascades from top down.

We need to interpolate the directions and probes.
Starting with directions, since our directions are evenly mapped to 4 times more in the above cascade we evenly interpolate these in our own cascade.

However we need to also keep in account the transparency in this merging.
If a ray was already blocked in the direction we're merging in the lower cascade/interval we shouldn't merge it downwards.

Showing One interval being blocked by a wall, the further interval is blocked off

Here the middle interval is blocked, so we should not merge the light from the last interval, or it will leak in front of the wall

// HLSL, Compute: "Merging.hlsl"
#include "HashTable.hlsl"

RWStructuredBuffer‹uint› ActiveProbes;
RWStructuredBuffer‹uint› ActiveCounter;

uint Cascade;

RWStructuredBuffer‹uint4› TotalRadiance;
RWStructuredBuffer‹uint› Weights;
RWStructuredBuffer‹uint› DepositCounts;

//Separate outputbuffer our merging outputs into
RWStructuredBuffer‹float4› MergedRadiance;

[numthreads(64, 1, 1)]
void MainCS(uint DispatchThreadId : SV_DispatchThreadID)
{
	uint ProbeId = DispatchThreadId;

	// Check that we are in scope
	if (ProbeId >= ActiveCounter[0])
	{
		return;
	}

    //Get current probe    
	uint ProbeIndex = ActiveProbes[ProbeId];
    
	uint64_t ProbeKey = HashTable[(Cascade) * HashTableSize + ProbeIndex];
    ProbeKeyInfo KeyData = UnpackProbeKey(ProbeKey);

    //The parent cell is bigger so halve its position
	int3 ParentCell = (KeyData.Cell) >> 1;
    
    //Direction count of this cascade
	uint DirectionCount = BaseDirections ‹‹ Cascade;

	uint CascadeIndex = Cascade * HashTableSize * BinsPerProbe(BaseDirections);
	uint ParentCascadeIndex = (Cascade + 1) * HashTableSize * BinsPerProbe(BaseDirections);
	uint ParentDirCount = BaseDirections ‹‹ (Cascade + 1);

	for (int i = 0; i ‹ BinsPerProbe(DirectionCount); ++i)
	{
		//Load own Radiance
	    uint SlotIndex = CascadeIndex + ProbeIndex * BinsPerProbe(DirectionCount) + i;

        //How much rays got into this bin.
	    float Weight = UnpackFloatUnsigned(Weights[SlotIndex]);

	    uint4 PackedRadiance = TotalRadiance[SlotIndex];
        float4 UnpackedRadiance = float4(UnpackFloatUnsigned(PackedRadiance.x), UnpackFloatUnsigned(PackedRadiance.y),
	                                UnpackFloatUnsigned(PackedRadiance.z), UnpackFloatUnsigned(PackedRadiance.w));
	    
        float3 CurrentRadiance = UnpackedRadiance.xyz / Weight;
	    //Amount of transmission through this probe, based on the rays that traveled through but didnt hit.
        float TransmittedWeight = UnpackedRadiance.w / Weight;

        float3 ParentRadiance = 0;
        //Cant merge the parent of the highest cascade
        if (Cascade ‹ NumCascades - 1)
        {
                uint64_t ParentCascadeKey = PackProbeKey(ParentCell);

                uint ParentIndex = Hash(uint(ParentCascadeKey) ^ Hash(uint(ParentCascadeKey >> 32)));
                bool Found = HashTableFind(ParentCascadeKey, [&]: ParentIndex, Cascade + 1);
                if (Found)
                {
                    // The parent has 4 times as many directions, so go over the 4 directions in the parent
                    float4 DirRadiance = 0;
                    for (uint d = 0; d ‹ 4; ++d)
                    {
                        uint ParentSlotIndex = ParentCascadeIndex + ParentIndex * BinsPerProbe(ParentDirCount) + i * 4 + d;
                        DirRadiance += MergedRadiance[ParentSlotIndex];
                    }
                    ParentRadiance = (DirRadiance.xyz / DirRadiance.w);
            }
        }        
        MergedRadiance[SlotIndex] = (Weight ‹= 0.0001)
                                            ? 0
                                            : float4(CurrentRadiance + ParentRadiance * TransmittedWeight, 1);
    }
}

This merges our directions from the closest probe. Instead we want to check all the above probes and blend between them.
For this we need to keep in mind probe spacing, each lower cascade has 8 probes inside 1 upper probe. So we shouldnt evenly blend.

It's also important to keep in mind that our grid is sparse, meaning the 8 surrounding probes will probably not all be there.
This can be resolved by only weighing and sampling the probes that actually exist.
Additionally, not all our probes are of the same quality. The amount of rays a probe contains and thus how accurate it is, is based on its visibility on the screen.
We should also adjust for this, giving a higher weight to probes that contain more rays.

// HLSL, Compute: "Merging.hlsl"
#include "HashTable.hlsl"

RWStructuredBuffer‹uint› ActiveProbes;
RWStructuredBuffer‹uint› ActiveCounter;

uint Cascade;

RWStructuredBuffer‹uint4› TotalRadiance;
RWStructuredBuffer‹uint› Weights;
RWStructuredBuffer‹uint› DepositCounts;

//Separate outputbuffer our merging outputs into
RWStructuredBuffer‹float4› MergedRadiance;

[numthreads(64, 1, 1)]
void MainCS(uint DispatchThreadId : SV_DispatchThreadID)
{
	uint ProbeId = DispatchThreadId;

	// Check that we are in scope
	if (ProbeId >= ActiveCounter[0])
	{
		return;
	}

    //Get current probe    
	uint ProbeIndex = ActiveProbes[ProbeId];
    
	uint64_t ProbeKey = HashTable[(Cascade) * HashTableSize + ProbeIndex];
    ProbeKeyInfo KeyData = UnpackProbeKey(ProbeKey);

    //The parent cell is bigger so halve its position
	int3 ParentCell = (KeyData.Cell - 1) >> 1;
    
    //Get the part of the corner we are in, this is needed so we can weigh by the corner, <changed>
    // since we sit at 75% to the nearest parent and 25% to the furthest on each axis <changed>
	int3 NearCorner = ~KeyData.Cell & 1; <changed>

    //Direction count of this cascade
	uint DirectionCount = BaseDirections ‹‹ Cascade;

	uint CascadeIndex = Cascade * HashTableSize * BinsPerProbe(BaseDirections);
	uint ParentCascadeIndex = (Cascade + 1) * HashTableSize * BinsPerProbe(BaseDirections);
	uint ParentDirCount = BaseDirections ‹‹ (Cascade + 1);

	for (int i = 0; i ‹ BinsPerProbe(DirectionCount); ++i)
	{
		//Load own Radiance
	    uint SlotIndex = CascadeIndex + ProbeIndex * BinsPerProbe(DirectionCount) + i;

        //Weight of this bin, this was based on how much rays got binned into it.
	    float Weight = UnpackFloatUnsigned(Weights[SlotIndex]);

	    uint4 PackedRadiance = TotalRadiance[SlotIndex];
        float4 UnpackedRadiance = float4(UnpackFloatUnsigned(PackedRadiance.x), UnpackFloatUnsigned(PackedRadiance.y),
	                                UnpackFloatUnsigned(PackedRadiance.z), UnpackFloatUnsigned(PackedRadiance.w));
	    
        float3 CurrentRadiance = UnpackedRadiance.xyz / Weight;
	    //Amount of transmission through this probe, based on the rays that traveled through but didnt hit.
        float TransmittedWeight = UnpackedRadiance.w / Weight;

        float3 ParentRadianceSum = 0; <changed>
        float ParentRadianceWeight = 0; <changed>
        //Cant merge the parent of the highest cascade
        if (Cascade ‹ NumCascades - 1)
        {
            //Trilinearly interpolate each of the 8 probes above us <changed>
            for (uint c = 0; c ‹ 8; ++c) <changed>
            { <changed>
                int3 Offset = int3(c & 1, (c >> 1) & 1, c >> 2); <changed>
                int3 Corner = ParentCell + Offset; <changed>

                //Trilinear weight based on where we are compared to parent <changed>
                float3 AxisWeight = Offset == NearCorner ? 0.75 : 0.25; <changed>
                float InterpWeight = AxisWeight.x * AxisWeight.y * AxisWeight.z; <changed>

                uint64_t ParentCascadeKey = PackProbeKey(Corner); <changed>

                uint ParentIndex = Hash(uint(ParentCascadeKey) ^ Hash(uint(ParentCascadeKey >> 32)));
                bool Found = HashTableFind(ParentCascadeKey, [&]: ParentIndex, Cascade + 1);
                if (Found)
                {
                    //Weight of this probe, this was based on how much rays got binned into it. <changed>
                    uint DepositCount = DepositCounts[((Cascade + 1) * HashTableSize) + ParentIndex]; <changed>
                    //Empty probes have no contribution <changed>
                    if (DepositCount ‹= 0) <changed>
                    { <changed>
                        continue; <changed>
                    } <changed>

                    // The parent has 4 times as much directions, so go over the 4 directions in the parent
                    float4 DirRadiance = 0;
                    for (uint d = 0; d ‹ 4; ++d)
                    {
                        uint ParentSlotIndex = ParentCascadeIndex + ParentIndex * BinsPerProbe(ParentDirCount) + i * 4 + d;
                        DirRadiance += MergedRadiance[ParentSlotIndex];
                    }
                    if (DirRadiance.w > 0.0) <changed>
                    { <changed>
                        float DepositQuality = UnpackFloatUnsigned(DepositCount); <changed>
                        float QualityInterpWeight = InterpWeight * DepositQuality; <changed>
                        ParentRadianceSum += (DirRadiance.xyz / DirRadiance.w) * QualityInterpWeight; <changed>
                        //Accumulate weight, since some probes are skipped <changed>
                        ParentRadianceWeight += QualityInterpWeight; <changed>
                    } <changed>
                }
            } <changed>
        }
        //Normalize parent <changed>
        float3 ParentRadiance = ParentRadianceWeight > 0.00001 ? (ParentRadianceSum / ParentRadianceWeight) : 0; <changed>
        
        MergedRadiance[SlotIndex] = (Weight ‹= 0.0001)
                                            ? 0
                                            : float4(CurrentRadiance + ParentRadiance * TransmittedWeight, 1);
    }
}

With this we now have our full ray inside of the lowest cascade.
To display this in our output shader, we have to merge the directions of the cascade into 1 value for each pixel, using the normal of the pixel to weigh the probes.

// HLSL, Compute: "Output.hlsl"
                
... Find probe of the pixel
                
... Trilinearly interpolate probes
                
float3 TotalRadiance = 0;
float TotalWeight = 0;

for (uint i = 0; i ‹ BinsPerProbe(DirectionCount); ++i)
{
    float3 Dir = LinearToDir(i, DirectionCount);
    //We have to normalize by NDotL to keep correct incomming light
    float NdotL = max(0.0, dot(Dir, Normal));
    if (NdotL ‹= 0)
    {
        continue;
    }

    uint CascadeIndex = DisplayCascade * HashTableSize * BinsPerProbe(BaseDirections);
    uint BuffIndex = CascadeIndex + Index * BinsPerProbe(DirectionCount) + i;
    float4 MergedRad = ProbeMergedRadiance[BuffIndex];
    if (MergedRad.w > 0.0)
    {
        float Weight = NdotL * UnpackFloatUnsigned(DepositCount);
        TotalRadiance += MergedRad.xyz * Weight;
        TotalWeight += Weight;
    }
}
//Renormalize
if (TotalWeight > 0.0)
{
    RadSum += TotalWeight >= 0 ? (TotalRadiance / TotalWeight) * InterpWeight * LodWeights[Level] : 0;
    WeightSum += InterpWeight * (LodWeights[Level]);
}
            
Full light ray

LODs

Currently we have a even grid of probes, which works fine for small scenes. But our probes get very small on the screen further away.
This has 2 major problems.
We shoot our rays from the screen, so the number of rays in a probe is based on how many pixels it occupies on the screen. So very small probes only get one ray or maybe even less.
Secondly, the number of probes we spawn explodes further away which costs a lot of memory and is expensive to merge.

By adding a form of LODs to our probes we solve both these problems, at only the cost of lower resolution lighting far away.
This is very similar to cascaded shadow maps which makes naming confusing and thus we call them LODs/Levels.
For each higher LOD we also halve the resolution similarly to our higher cascades although we keep the direction count the same.
To do this we just halve our cell position each level. However for this we need to add a level index to our per probe key.
We need to do this since our halved positions can overlap with the lower level.
We can just bitshift this index after the rest of our key.

Since everything else uses the hashtable, this should automatically work with the rest of our code assuming we calculate the key in the same way.

// HLSL,

//We compute the level based on distance to the camera
uint ComputeLevel(float3 WorldPos, float3 CameraPos)
{
    //Using Chebyshev distance, aka distance as a square this makes the transition less noticable. 
	float3 Distance = abs(WorldPos - CameraPos);
	float Radius = max(Distance.x, max(Distance.y, Distance.z));

	float LodFloat = log2(Radius / LevelBaseDist);
	uint Lower = clamp((uint)ceil(LodFloat), 0, MaxLOD);
	return Lower;
}

//Everywhere where we use the voxel size, we should instead use the size based on the level
float LevelVoxelSize(uint Level)
{
	return BaseVoxelSize * (float)(1u ‹‹ Level);
}

uint64_t MakeProbeKey(float3 WorldPos, float3 CameraPos)
{
	uint Level = ComputeLevel(WorldPos, CameraPos);
	float VoxelSize = LevelVoxelSize(Level);
	int3 Cell = int3(floor((WorldPos / VoxelSize)));

	return PackProbeKey(Cell, Level);
}
                    
LODs increasing size on distance

This fixes a lot of flickering or overallocation further away, however it creates big seams where the LODs change.
So we want to blend in between these seams. To do this we keep some probes of overlap, allocating probes of both levels and also binning the ray into both.
Wherever we calculate our level we instead calculate the range, making sure to keep correct weights so we do not double the light at the seam.

// HLSL, Compute:

//Compute a range instead, outputs weights so that we blend correctly
void ComputeLevelRange(float3 WorldPos, float3 CameraPos, out uint2 Lods, out float2 LodWeights)
{
	float3 Distance = abs(WorldPos - CameraPos);
	float Radius = max(Distance.x, max(Distance.y, Distance.z));
	float LodFloat = log2(Radius / LevelBaseDist);
	float Blend = min(1.0, (1.0 - frac(LodFloat))); <changed>
	uint Lower = clamp((uint)ceil(LodFloat), 0, MaxLOD); 
	Lods = uint2(Lower, Lower + 1); <changed>
	LodWeights = float2(Blend, 1.0 - Blend); <changed>
}

//When depositing and applying
//We loop over the range and deposit/apply both levels, following their weights.

This should result in smooth, although flickering results.

no mirror mirrored pixels
Not blended
Blended

Temporal

Due to the benefits of splitting & merging with RC our single frame result is already pretty good.
But since we still send random rays, there is still some flickering especially in probes that did not get many rays.
We can fix this by accumulating our probes over time. This doesnt come with the drawbacks of most temporal effects.
Since our probes are spatially stable, there is no smearing from bad reprojection.
And since there is not a lot of noise in after a single frame there is barely any ghosting.
So the only drawback is slower lighting updates.

Example of smearing using Lumen

No smearing or much ghosting with RC

Stable hash table

Currently we clear and allocate the hashtable every frame, so our probe index moves every frame.
To temporally accumulate we want our probes to keep the same index, so that we keep the old frame's result in their spot in the buffer.
One way of doing this is by adding a layer of indirection to our hashtable, instead of storing the index into our data buffer in the table we store an index into another list of indices.
That way we can add a pass that re-allocates our previous frame's results with the same index as the data.

We also need to keep track of the lifetime of probes, to know which probes to reallocate or not, and resetting their lifetime if they are on screen.

// HLSL, Compute:
#include "StableHashTable.hlsl"

RWStructuredBuffer‹uint› ProbeLifetime;

RWStructuredBuffer‹uint› ActiveProbes;
RWStructuredBuffer‹uint› ActiveCounter;

float HistoryConservation;

RWStructuredBuffer‹uint4› TotalRadiance;
RWStructuredBuffer‹uint› ProbeWeights;
RWStructuredBuffer‹uint› DepositCounts;

//This should be launched for the maximum amount of probes, each thread reallocating that probe if its there
[numthreads(64, 1, 1)]
void MainCS(uint DispatchThreadId : SV_DispatchThreadID)
{
	uint DataKeyId = DispatchThreadId;

    //Get which cascade this probe belongs to
	uint Table = DataKeyId / MaximumProbes;
	
    uint Local = DataKeyId - BaseTable * MaximumProbes;
	if (Table ›= NumCascades || Local ›= (MaximumProbes ›› (2 * Table)))
	{
		return;
	}

	uint64_t Key = TableKeys[DataKeyId];

    //If the key is invalid i.e. the clear value of 0 dont de or re allocate
	bool Occupied = Key != 0;
	if (Occupied)
	{
		//Remove older probes, ProbeLifetime should be reset whenever the probe is used, i.e. inside of the probe gathering and cascade filling passes
		if (ProbeLifetime[DataKeyId] == 0)
		{
			TableKeys[DataKeyId] = 0;
			Occupied = false;

			//Clear probe data
			DepositCounts[DataKeyId] = 0;

			uint LocalIndex = DataKeyId - Table * MaximumProbes;
			uint CascadeIndex = Table * MaximumProbes * BinsPerProbe(BaseDirections);
			uint Bins = BinsPerProbe(BaseDirections ‹‹ Table);
            //Clear each direction bin
			for (int i = 0; i ‹ Bins; ++i)
			{
				uint SlotIndex = CascadeIndex + LocalIndex * Bins + i;

				TotalRadiance[SlotIndex] = 0;
				ProbeWeights[SlotIndex] = 0;
			}
		}
		else
		{
			// Re-add surviving probe

            // Reduce its lifetime
            ProbeLifetime[DataKeyId] -= 1;

            //Add it to the hashtable with the same index as last frame
			uint64_t Key = TableKeys[DataKeyId];
			uint LocalIndex = DataKeyId - Table * MaximumProbes;
			HashTableAddIndex(Key, Table, LocalIndex);

            //Add it to the active list,
			uint ActiveIndex;
			InterlockedAdd(ActiveCounter[Table], 1, ActiveIndex);
            ActiveProbes[Table * MaximumProbes + ActiveIndex] = LocalIndex;
		}
	}

    //Add any unoccupied (or just deallocated) probe to the freelist for the hashtable to use
	if (!Occupied)
	{
		uint FreeListIndex;
		InterlockedAdd(TableFreeListIndex[Table], 1, FreeListIndex);
		uint LocalIndex = DataKeyId - Table * MaximumProbes;
		TableFreeList[FreeListIndex] = LocalIndex;
	}
}


//Make sure to clear the freelistindex counter to the table's start after this

The hashtable should now be stable, you can test this using the debug output code from earlier, outputting the index. Probes shouldn't change color unless they go off the screen.
With stable probes temporally accumulating is easy, when going over the probes (in the merge pass or in a separate pass) we just re-add the probes accumulated light and weights with a falloff.
With that you should now have clean single bounce GI.

Multi bounce

Currently light only bounces once from where we are looking. Things only start looking really good when we do multiple bounces.
To achieve this we need to trace 1 extra bounce ray everywhere, from which we can get infinite bounces over time.
To get one bounce, we just shoot an extra light ray from the hits of our primary ray.
To do this we output our hit positions and normals in our ray generation shader, then run a second one from that spot.

One ray is shot, afterwards another is shot from the spot

The first hit is stored, then another random ray is sent from that hit position

This gives us a single real bounce, but increasing this to more would be very expensive. Instead we can sample the probes to apply GI to this, with the temporal accumulation that gives us an extra bounce every frame. However since our probes are only allocated where we are looking, we wouldn't be able to get this bounce light from outside of the screen, since there are no probes to sample from.
To resolve this we allocate a separate secondary probe cache for this, we allocate extra probes at the hit positions of our primary ray. We fill these with radiance from the bounce ray, in the same way as our primary ray.
To accumulate this to multiple bounces, we sample this cache at the hits of our primary and secondary rays.

Animation of secondary probes

In one frame we send the primary ray, which allocates a probe filled with the bounce ray.
The next frame a different bounce ray hits the same secondary probe. Since it samples the cache it then also contains the previous bounce.
Over frames this stacks to infinite bounces

This gives us infinite bounces, however it is limited to only the area covered by the single bounce ray, since that one allocates the secondary probes.

We can re-use almost our whole system for this secondary probe. We already have LODs over distance, by allocating the secondary probes at at least 2 LODs higher it can fit into the same hashtable/probes without conflicts.
If we also add these to the active probes the merge pass will merge their cascades as well.

With that we get nice looking multi bounce GI.

Single Bounce Multi Bounce
Single bounce
Multi bounce

Basic optimizations & improvements

For simplicity I kept out a lot of optimizations or better ways to do things.
I won't go over how to fully optimize but will briefly go over the main improvements that can be done to get this running well.

Choosing directions

One big improvement in quality is choosing directions in a better way, currently we choose completely random directions.
But there are multiple semi random sequences that accumulate to good results significantly quicker.
By using a deposit count per probe as an index into something like the R2 sequence or blue noise, direction coverage accumulates significantly faster.

Group sharing

Apart from tracing, our merging & applying pass is the most expensive.
It's sampling a lot of data across a low amount of threads.
For example the bin merging can be done across threads, loading the 8 probes together and having each thread load a bin, merging them as a group.

Lowering ray count

Raytracing cost is by far the most expensive part.
We can skip a percentage of pixels every frame to reduce cost significantly, although with the drawback of slower accumulation and thus more ghosting.
Balancing this with probe sizes can improve the accumulation but at the cost of light resolution.
This allows for a nice way of tweaking quality vs performance.
It might also be worth replacing (some of) the rays with cheaper ones like screen space rays or sampling an sdf or voxel approximation of the world

Conclusions

Performance

Almost all of the cost of this goes to actual tracing.
For my implemention in Unreal on my 5070 Laptop GPU in San Miguel the cost of RC is about ~2ms depending on probe sizing.
Although with ~13ms on top of raytracing at 2spp (1 primary 1 bounce) and another ~2ms for depositing the rays.

The expensive tracing cost is very variable, depending on scene size and tracing performance in general.

Comparisons/Showcase

Path traced RC
Path traced
RC

The main points of error are light leaking in spots smaller than a (secondary) probe, and softer shadows. (presumably due to not enough base directions)

References

"Split Radiance Cascades: Real-Time Global Illumination via Sparse Radiance Probes": https://arxiv.org/abs/2607.20384

Jason Mcghee's article: https://jason.today/rc

RC Wiki: https://radiance.wiki/

Back to Articles