Short Question: How can i integrate a buffer variable in a compute shader over each pixel i.e. how can i ensure memory coherence in such a case?

Long Question: I've written a deferred renderer with hdr and i want to implement automatic exposure. For that purpose i orignally used mipmapping, but that is not very performant and i want to improve my knowledge about compute shaders. So i tried to write a compute shader that approximates the average luminance of a image (approximation in the sense that i don't process every pixel but e.g. only 64 x 36 samples).

That would require me to accumulate the luminance of each pixel i.e. of each shader invocation. So i created a buffer which holds my luminance value:

volatile layout(std430, binding = 0) buffer averageBlock{
    float luminance;
} average;

BUT: there are no atomic operations for floats so i changed it to

uint luminance[10];

Were each entry holds a part of the luminance (i.e. the first pixel writes to entry 0, the second to 1, ... the tenth to 0, ...) to increase the value range. I then mapped the float values to ints (multiplied them with 280, since that uses the available memory range nearly completly in the edge case were everything as a luminance of 1).

I then accumulated the values with:

uint discl = uint(lum * 280.0);
int slot = int(mod(gl_GlobalInvocationID.x, 10));
atomicAdd(average.luminance[slot], discl);

But it seems that this also leads to visibility problems, since when i set lum to a certain value, only a random proportion of that value is present after barrier() (maybe because between those memoryBarrier calls the atomic adds don't see the changed values of the other ones).

So how do i do this correctly? Thanks for your help!


First of all, I don't think you need volatile or memory barriers if you're just using atomic operations. Atomic operations are always supposed to be atomic regardless.

However, having everything accumulate directly into one (or a small number) of atomic variables is not advisable because there will be too much contention on those few memory locations, thus largely serializing the computation.

Usually, the way to do reduction operations (like sums, averages, histograms etc) on the GPU is to do it in multiple passes, similar to building mipmaps.

You don't need to go just by steps of 2x, however. As you don't need all the individual mipmaps in the chain, only the final result, you can reduce by larger factors at a time; then you will get down to the target size in fewer passes, and it should be faster. For example, you can write a pixel shader that does an 8x8 reduction in one step, by sampling 4x4 times, each with a bilinear tap that averages 2x2 pixels, and outputs the average of those 16 samples (totaling 64 pixels). In that way, you can get from a 4K input image down to 60x33 in just two passes. You could experiment with how much reduction factor per pass gives the best performance.

Alternatively, if you wanted to do some other operation than just averaging (for instance computing a luminance histogram), you could use a compute shader with say an 8x8 workgroup. Have each invocation load a pixel from the texture, and do the reduction of the workgroup into a shared memory variable (either using atomic operations or by memory barriers, etc). This is all staying within the workgroup, so it remains parallel across different workgroups. Then a single invocation writes out the result from shared memory into a destination buffer. Additional compute dispatches can be used for further reduction passes (summing histogram elements etc) and again you can get down to a very small target size in just a handful of passes.

For more information, search "parallel reduction" as that's the generic term for this type of operation.


Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.