Welcome,
This is the second part of demystifying calculating of average luminance in "The Witcher 3: Wild Hunt". Being familiar with the first part is highly recommended.
Before we start the battle with another compute shader, let's do a quick recap of what happened previously: We were working on 1/4x1/4 downscaled HDR color buffer. What we have after the first pass is histogram of luminance (structured buffer of 256 unsigned integers). We calculated logarithm of each pixel's luma, distributed it across 256 cells and increased corresponding value of structured buffer by 1 per pixel. This way, total sum of all values in these 256 cells is equal to the number of pixels.
| Example output of the first pass. There are 256 elements here. |
After this brief introduction, we're all ready to go to the next stage: calculation.
This time only one thread group is dispatched ( Dispatch(1, 1, 1) ).
Let's see the assembly of the compute shader:
cs_5_0
dcl_globalFlags refactoringAllowed
dcl_constantbuffer cb0[1], immediateIndexed
dcl_uav_structured u0, 4
dcl_uav_typed_texture2d (float,float,float,float) u1
dcl_input vThreadIDInGroup.x
dcl_temps 4
dcl_tgsm_structured g0, 4, 256
dcl_thread_group 64, 1, 1
0: ld_structured_indexable(structured_buffer, stride=4)(mixed,mixed,mixed,mixed) r0.x, vThreadIDInGroup.x, l(0), u0.xxxx
1: store_structured g0.x, vThreadIDInGroup.x, l(0), r0.x
2: iadd r0.xyz, vThreadIDInGroup.xxxx, l(64, 128, 192, 0)
3: ld_structured_indexable(structured_buffer, stride=4)(mixed,mixed,mixed,mixed) r0.w, r0.x, l(0), u0.xxxx
4: store_structured g0.x, r0.x, l(0), r0.w
5: ld_structured_indexable(structured_buffer, stride=4)(mixed,mixed,mixed,mixed) r0.x, r0.y, l(0), u0.xxxx
6: store_structured g0.x, r0.y, l(0), r0.x
7: ld_structured_indexable(structured_buffer, stride=4)(mixed,mixed,mixed,mixed) r0.x, r0.z, l(0), u0.xxxx
8: store_structured g0.x, r0.z, l(0), r0.x
9: sync_g_t
10: if_z vThreadIDInGroup.x
11: mul r0.x, cb0[0].y, cb0[0].x
12: ftou r0.x, r0.x
13: utof r0.y, r0.x
14: mul r0.yz, r0.yyyy, cb0[0].zzwz
15: ftoi r0.yz, r0.yyzy
16: iadd r0.x, r0.x, l(-1)
17: imax r0.y, r0.y, l(0)
18: imin r0.y, r0.x, r0.y
19: imax r0.z, r0.y, r0.z
20: imin r0.x, r0.x, r0.z
21: mov r1.z, l(-1)
22: mov r2.xyz, l(0, 0, 0, 0)
23: loop
24: breakc_nz r2.x
25: ld_structured r0.z, r2.z, l(0), g0.xxxx
26: iadd r3.x, r0.z, r2.y
27: ilt r0.z, r0.y, r3.x
28: iadd r3.y, r2.z, l(1)
29: mov r1.xy, r2.yzyy
30: mov r3.z, r2.x
31: movc r2.xyz, r0.zzzz, r1.zxyz, r3.zxyz
32: endloop
33: mov r0.w, l(-1)
34: mov r1.yz, r2.yyzy
35: mov r1.xw, l(0, 0, 0, 0)
36: loop
37: breakc_nz r1.x
38: ld_structured r2.x, r1.z, l(0), g0.xxxx
39: iadd r1.y, r1.y, r2.x
40: utof r2.x, r2.x
41: utof r2.w, r1.z
42: add r2.w, r2.w, l(0.500000)
43: mul r2.w, r2.w, l(0.011271)
44: exp r2.w, r2.w
45: add r2.w, r2.w, l(-1.000000)
46: mad r3.z, r2.x, r2.w, r1.w
47: ilt r2.x, r0.x, r1.y
48: iadd r2.w, -r2.y, r1.y
49: itof r2.w, r2.w
50: div r0.z, r3.z, r2.w
51: iadd r3.y, r1.z, l(1)
52: mov r0.y, r1.z
53: mov r3.w, r1.x
54: movc r1.xzw, r2.xxxx, r0.wwyz, r3.wwyz
55: endloop
56: store_uav_typed u1.xyzw, l(0, 0, 0, 0), r1.wwww
57: endif
58: ret
There is one constant buffer:
Quick look at the assembly: There are two UAVs attached ( u0: input buffer from the first part
and u1: output 1x1 R32_FLOAT texture). We can see that we also have 64 threads per group and 256 elements of 4-bytes groupshared memory.
We start by filling groupshared memory with data from input buffer. We have 64 threads, so we can do it pretty much the same way as before.
To be absolutely sure all data have been loaded for futher processing, we set a barrier after that.
// The first step is to set whole shared data with data from previous stage.
// Because each thread group has 64 threads, each one can fill 4 elements in one thread
// using a simple offset.
[unroll] for (uint idx=0; idx < 4; idx++)
{
const uint offset = threadID + idx*64;
shared_data[ offset ] = g_buffer[offset];
}
// We set a barrier here, which means we block execution of all threads in a group until all group
// shared accesses have been completed and all threads in the group have reached this call.
GroupMemoryBarrierWithGroupSync();
All calculatons take part in one thread only, all the other ones are used just to load values from buffer to shared memory.
The 'calculating' thread has index of zero. Why? In theory, we could use any thread from [0-63] range, but by comparing with 0, we can avoid extra integer-integer comparison (ieq instruction).
The algorithm is based on specifying range of pixels which will be taken into consideration.
At line 11, we multiply width*height, getting total number of pixels and multiply them by two numbers from [0.0f-1.0f] range which indicate start and end of range. There are some clamps later to make sure that 0 <= Start <= End <= totalPixels - 1:
// Perform calculations only with the thread with '0' index.
[branch] if (threadID == 0)
{
// Total number of pixels in downscaled buffer
uint totalPixels = cb0_v0.x * cb0_v0.y;
// Range of pixels (or, more specifically, range of luminance in the screen)
// we want to incorporate in average luminance calculation.
int pixelsToConsiderStart = totalPixels * cb0_v0.z;
int pixelsToConsiderEnd = totalPixels * cb0_v0.w;
int pixelsMinusOne = totalPixels - 1;
pixelsToConsiderStart = clamp( pixelsToConsiderStart, 0, pixelsMinusOne );
pixelsToConsiderEnd = clamp( pixelsToConsiderEnd, pixelsToConsiderStart, pixelsMinusOne );
As you can see, there are two loops later. The problem with them (or, their assembly) is they have strange conditional moves at the ends. I had a hard time with reconstructing them. Also, take note at line 21. Why is there "-1"? I will reveal it in a few moments.
The purpose of the first loop is to omit pixelsToConsiderStart and give us index of buffer cell in which pixelsToConsiderStart +1 pixel is present (and also number of all pixels in previous cells).
For instance, let's assume that pixelsToConsiderStart is about 30000 and in buffer there are 37000 pixels in cell "zero" (happens during night in the game). So, we want to start analyzing luminance from pixel ~30001, which is present in cell zero. In this scenario, we will exit the loop immediately, having starting index '0' and zero ommitted pixels.
Take a look at the HLSL code:
// Number of already processed pixels
int numProcessedPixels = 0;
// Luma cell [0-255]
int lumaValue = 0;
// Whether to continue execution of loop
bool bExitLoop = false;
// The purpose of the first loop is to omit "pixelsToConsiderStart" pixels.
// We keep number of omitted pixels from previous cells and lumaValue to use in the next loop.
[loop]
while (!bExitLoop)
{
// Get number of pixels with specific luma value.
uint numPixels = shared_data[lumaValue];
// Check how many pixels we would have with lumaValue
int tempSum = numProcessedPixels + numPixels;
// If more than pixelsToConsiderStart, exit the loop.
// Therefore, we will start calculating luminance from lumaValue.
// Simply speaking, pixelsToConsiderStart is number of "darken" pixels to omit before starting calculation.
[flatten]
if (tempSum > pixelsToConsiderStart)
{
bExitLoop = true;
}
else
{
numProcessedPixels = tempSum;
lumaValue++;
}
}
This mysterious "-1" from line 21 of the assembly is related with boolean condition of loop execution (I found it out quite accidentally).
Having the number of pixels from lumaValue cells and lumaValue itself we can go the second loop.
The purpose of the second loop is to calculate contribution of pixels and average luminance.
We start from lumaValue calculated in the first loop.
float finalAvgLuminance = 0.0f;
// Number of omitted pixels in the first loop
uint numProcessedPixelStart = numProcessedPixels;
// The purpose of this loop is to calculate contribution of pixels and average luminance.
// We start from point calculated in the previous loop, keeping number of omitted pixels and starting lumaValue positon.
// We decode luma value from [0-255] range, multiply it by number of pixels which have this specific luma, and sum it up until
// we process pixelsToConsiderEnd pixels.
// After that, we divide total contribution by number of analyzed pixels.
bExitLoop = false;
[loop]
while (!bExitLoop)
{
// Get number of pixels with specific luma value.
uint numPixels = shared_data[lumaValue];
// Add to all processed pixels
numProcessedPixels += numPixels;
// Currently processed luma, distributed in [0-255] range (uint)
uint encodedLumaUint = lumaValue;
// Number of pixels with currently processed luma
float numberOfPixelsWithCurrentLuma = numPixels;
// Currently processed, encoded [0-255] luma (float)
float encodedLumaFloat = encodedLumaUint;
At this point we have encoded luma value here [0.0f-255.f].
The decoding process is quite simple - we have to revert calculations from encoding stage.
A quick recap of encoding process:
float luma = dot( hdrPixelColor, float3(0.2126, 0.7152, 0.0722) );
...
float outLuma;
// because log(0) is undef and log(1) = 0
outLuma = luma + 1.0;
// logarithmically distribute
outLuma = log( outLuma );
// scale by 128, which means log(1) * 128 = 0, log(2,71828) * 128 = 128, log(7,38905) * 128 = 256
outLuma = outLuma * 128
// to uint
uint outLumaUint = min( (uint) outLuma, 255);
To decode luma, we simply revert the encoding process, for example like this:
// we start by adding 0.5f (we don't want to have zero result)
float fDecodedLuma = encodedLumaFloat + 0.5;
// and decode luminance:
// Divide by 128
fDecodedLuma /= 128.0;
// exp(x) which cancels log(x)
fDecodedLuma = exp(fDecodedLuma);
// Subtract 1.0
fDecodedLuma -= 1.0;
The we calculate contribution by multiplying the number of pixels which have this specific luma times decoded luma, and sum it up until we process pixelsToConsiderEnd pixels.
After that, we divide total contribution by number of analyzed pixels.
See the rest of the loop (and shader):
// Calculate contribution of this luma
float fCurrentLumaContribution = numberOfPixelsWithCurrentLuma * fDecodedLuma;
// (Temporary) contribution from all previous passes and current one.
float tempTotalContribution = fCurrentLumaContribution + finalAvgLuminance;
[flatten]
if (numProcessedPixels > pixelsToConsiderEnd )
{
// to exit the loop
bExitLoop = true;
// We already processed all pixels we wanted, so perform final division here.
// Number of all processed pixels from user-selected start
int diff = numProcessedPixels - numProcessedPixelStart;
// Calculate final average luminance
finalAvgLuminance = tempTotalContribution / float(diff);
}
else
{
// Pass current contribution further and increase lumaValue
finalAvgLuminance = tempTotalContribution;
lumaValue++;
}
}
// Save average luminance
g_avgLuminance[uint2(0,0)] = finalAvgLuminance;
The full shader is here, completely compatible with my HLSLexplorer which was crucial for me to efficiently reconstruct calculating average luminance from The Witcher 3 (well, all the other effects too!).
Phew.... some thoughts at the end. In terms of calculating average luminance, that was difficult shader to reconstruct. Main reasons:
1) strange 'deferred' checks of loop executing, it took me much more time than I initially assumed,
2) Problems with debugging this compute shader with RenderDoc (v. 1.2).
"ld_structured_indexable" operations are not completely supported, while the result of reading from index 0 is fine, all the other ones give zeroes - which makes the loops going to infinity and beyond.
Although I haven't managed to get the same assembly as the original (see screenshot with difference below), I managed to inject this shader with help of RenderDoc into the pipeline and - guess what - the output result was the same! :)
| The result of the battle. Left - my shader, right- original assembly. |
I hope you enjoyed it,
Thanks for reading.
M.
No comments:
Post a Comment