AtomicCounterBuffer (compute shaders, advanced)

I’ve been figuring out how to use atomic counters in compute shaders. You can read about them at https://wikis.khronos.org/opengl/Atomic_Counter.

In compute shaders, hundreds or thousands of GPU threads execute simultaneously. Without proper synchronization, multiple threads trying to modify the same memory location creates a race condition where the outcome depends on unpredictable thread execution order, leading to corrupted or incorrect results.

My main interest in them so far is accumulating statistics.

I’ll post below some example programs using such buffers.

ps. I’m aware I’m posting these without having explained anything about compute shaders or computeStyle. If someone asks I may write about it :slight_smile:

ps2. Thanks to @Alessandro for the help with the atomic counter buffers :slight_smile:

Visualizing an AtomicCounter

This program runs a compute shader for every pixel of an image. When processing each pixel, an atomic counter is incremented. The counter-value is visualized on every pixel using a unique color. This is not very useful, but demonstrates how to create and use an AtomicCounterBuffer and how to reset its value.

If you take a screenshot of the produced image, you can use photo-editing software to verify that every pixel has a unique color (in Gimp: use the Fuzzy Select tool, threshold 0, Sample merged).

See https://wikis.khronos.org/opengl/Atomic_Counter


import org.openrndr.application
import org.openrndr.color.ColorRGBa
import org.openrndr.draw.*
import org.openrndr.extra.imageFit.imageFit
import org.openrndr.math.IntVector3
import kotlin.system.measureNanoTime

fun main() = application {
    program {
        val img = colorBuffer(1920, 1080, type = ColorType.FLOAT32)

        // The atomic counter incremented each time the compute shader processes one pixel
        val counter = AtomicCounterBuffer.create(1)

        val csInc = computeStyle {
            computeTransform = """
                ivec2 pixel = ivec2(gl_GlobalInvocationID.xy);
                
                // atomically increments the value of the counter and returns its prior value.
                uint m = atomicCounterIncrement(b_counter[0]);
                
                // Convert the current counter value into an rgb value
                float r = float(m % 256) / 256.0;
                float g = float((m / 256) % 256) / 256.0;
                float b = float((m / 65536) % 256) / 256.0;
                
                imageStore(p_img, pixel, vec4(r, g, b, 1.0));
            """.trimIndent()
        }
        // The default `workGroupSize` is (1, 1, 1). Here we choose a larger one for performance.
        // The width and height should be divisible by these values: 1920/32 = 60, 1080/24 = 45.
        // Larger values were not accepted in my AMD GPU because their product was larger than 1000.
        csInc.workGroupSize = IntVector3(32, 24, 1)

        extend {
            // Run the shader to give a unique color to each pixel in the image
            println(measureNanoTime {
                csInc.buffer("counter", counter)
                csInc.image("img", img.imageBinding(0, ImageAccess.WRITE))
                csInc.execute(img.width / csInc.workGroupSize.x, img.height / csInc.workGroupSize.y)
            } / 1_000_000.0)

            // Draw the texture generated by the compute shader
            drawer.clear(ColorRGBa.PINK)
            drawer.imageFit(img, drawer.bounds)

            // Reset the counter before the next animation frame
            counter.reset()
        }
    }
}

The program prints the compute shader execution times in milliseconds. With my laptop the values look like these:

0.126686
0.185455
0.252961
0.264633
0.405766
0.205853
0.170187
0.345844
0.244926
0.153526
0.187509
0.206926
0.31114
0.204381

I haven’t done much testing, but since we have 16.66 milliseconds to render our frame to achieve 60 FPS, those values look acceptable.

I did try in Gimp and the color of each pixel seemed to be unique. I could have written more code to verify that… maybe another day :slight_smile:

Normalize the brightness of an image

This program is potentially more useful than the first one above.

It uses an atomic counter buffer to find the brightness of the brightest pixel on an image to do automatic brightness normalization. We could then use that value in a custom shader, or as in this example, use a tint effect to adjust the luminosity of the image.

The image shows an original image above, and with the brightness of its pixels stretched to cover the full luminosity spectrum below. This could be useful in situations where our program produces algorithmic visuals with unpredictable luminosity.

See https://wikis.khronos.org/opengl/Atomic_Counter

import org.openrndr.application
import org.openrndr.color.ColorRGBa
import org.openrndr.draw.*
import org.openrndr.drawImage
import org.openrndr.extensions.Screenshots
import org.openrndr.extra.color.colormatrix.tint
import org.openrndr.extra.imageFit.imageFit
import org.openrndr.extra.noise.gaussian
import org.openrndr.math.IntVector3
import kotlin.system.measureNanoTime

fun main() = application {
    configure {
        width = 640
        height = 480 * 2
    }
    program {
        // Sample, dark image
        val img = drawImage(1920, 1080, type = ColorType.FLOAT32) {
            clear(ColorRGBa.BLACK)
            stroke = null
            fill = ColorRGBa.WHITE.opacify(0.01)
            repeat(25) {
                circle(
                    Double.gaussian(bounds.width * 0.5, bounds.width * 0.1),
                    Double.gaussian(bounds.height * 0.5, bounds.height * 0.1),
                    50.0
                )
            }
        }

        // The atomic counter used to compare all pixels against
        val counter = AtomicCounterBuffer.create(1)

        val csMax = computeStyle {
            computeTransform = """
                ivec2 pixel = ivec2(gl_GlobalInvocationID.xy);
                vec3 rgb = imageLoad(p_img, pixel).rgb;
                // We multiply by 1000 because we work with uint. Use a higher number for more resolution.
                uint lum = uint(dot(rgb, vec3(0.299, 0.587, 0.114)) * 1000.0);
                
                // if lum is greater that the atomic counter, make the counter equal to lum
                atomicCounterMax(b_counter[0], lum);                
            """.trimIndent()
        }
        csMax.workGroupSize = IntVector3(32, 24, 1)

        extend(Screenshots())
        extend {
            drawer.clear(ColorRGBa.PINK)
            // Draw the original image above
            drawer.imageFit(img, drawer.bounds.sub(0.0, 0.0, 1.0, 0.5))

            // Execute the compute shader to find out the brightest pixel and print how long it took in milliseconds
            println(measureNanoTime {
                csMax.buffer("counter", counter)
                csMax.image("img", img.imageBinding(0, ImageAccess.READ))
                csMax.execute(img.width / csMax.workGroupSize.x, img.height / csMax.workGroupSize.y)
            } / 1_000_000.0)

            // Calculate a color matrix to normalize the image rendering
            val maxLum = counter.read()[0] / 1000.0
            val l = 1.0 / maxLum
            val colorMat = tint(ColorRGBa(l, l, l))

            // Draw the normalized image below
            drawer.drawStyle.colorMatrix = colorMat
            drawer.imageFit(img, drawer.bounds.sub(0.0, 0.5, 1.0, 1.0))

            // Reset the counter before the next animation frame
            counter.reset()
        }
    }
}