GLSL 256-Bin Luminance Histogram Using Shared Atomics
Constructing a parallel 256-bin luminance histogram in GLSL is
achieved using compute shaders to tally pixel intensity values into a
shared memory array before writing the merged results to global memory.
By utilizing a workgroup-level histogram stored in shared
memory, individual threads process image pixels concurrently and use
fast, local atomicAdd operations to update bins. This
approach minimizes high-latency contention on global memory by reducing
global atomic writes to a single merge pass per workgroup.
1. Workgroup Initialization and Shared Memory Allocation
The compute shader allocates an array of 256 unsigned integers in shared memory. A common workgroup layout uses 256 threads (such as a 16x16 tile) so that each thread corresponds directly to one bin index during the initialization and merge phases.
Before processing image pixels, the shared histogram must be zeroed out. A workgroup barrier ensures all threads wait until the memory is completely cleared.
layout(local_size_x = 16, local_size_y = 16) in;
shared uint localHistogram[256];
layout(binding = 0, rgba8) uniform readonly image2D inputImage;
layout(std430, binding = 1) buffer HistogramBuffer {
uint globalHistogram[256];
};Within the main function, initialization is handled by
clearing the memory using the linear invocation index:
uint localIndex = gl_LocalInvocationIndex;
localHistogram[localIndex] = 0u;
barrier();2. Luminance Calculation and Local Accumulation
Each thread computes its assigned global pixel coordinates. If the coordinates fall within the image boundaries, the thread samples the RGB color and converts it to a standard perceptual scalar luminance value using the Rec. 709 coefficients:
\[\text{Luminance} = 0.2126R + 0.7152G + 0.0722B\]
The calculated float value (ranging from \(0.0\) to \(1.0\)) is scaled and clamped to an integer
between \(0\) and \(255\). The thread then executes
atomicAdd on the shared memory array at that calculated bin
index:
ivec2 pixelCoord = ivec2(gl_GlobalInvocationID.xy);
ivec2 imgSize = imageSize(inputImage);
if (pixelCoord.x < imgSize.x && pixelCoord.y < imgSize.y) {
vec4 color = imageLoad(inputImage, pixelCoord);
float luma = dot(color.rgb, vec3(0.2126, 0.7152, 0.0722));
uint bin = uint(clamp(luma * 255.0, 0.0, 255.0));
atomicAdd(localHistogram[bin], 1u);
}Because localHistogram resides in on-chip shared memory,
these atomic operations complete with significantly lower latency and
bandwidth overhead than global memory operations.
3. Synchronization and Global Histogram Merge
Once all threads in the workgroup have finished processing their respective pixels, a second execution and memory barrier ensures all local atomic writes have settled:
barrier();Finally, each thread reads its corresponding bin count from
localHistogram and transfers that count to the global
storage buffer using a global atomicAdd. Only threads that
hold a non-zero local bin count execute the atomic operation, which
optimizes global memory traffic:
uint binValue = localHistogram[localIndex];
if (binValue > 0u) {
atomicAdd(globalHistogram[localIndex], binValue);
}This two-tier reduction strategy ensures high parallel throughput across the GPU, effectively transforming thousands of potential global atomic conflicts into localized, fast on-chip operations.