Skip to content

GpuRadixSort.kt

src/jvmMain/kotlin/afield/graphics/vulkan/util/GpuRadixSort.kt · 810 lines

package afield.graphics.vulkan.util

A stable GPU radix sort of uint keys in storage buffers, with an optional uint payload carried along.

GpuRadixSort owns its scratch buffers and compute shaders. asFrameBufferGroup wraps a single VulkanBuffer so it can be passed where a per-frame buffer group is expected.

import afield.graphics.vulkan.Renderer
import afield.graphics.vulkan.Target
import afield.graphics.vulkan.buffers.BufferParameters
import afield.graphics.vulkan.buffers.FrameBufferGroup
import afield.graphics.vulkan.buffers.VulkanBuffer
import afield.graphics.vulkan.buffers.buffer
import afield.graphics.vulkan.device.CommandBuffer
import afield.graphics.render.ComputeMesh
import afield.graphics.render.ComputeModule
import afield.graphics.render.Parameters
import afield.graphics.render.computeMemoryBarrier
import afield.document.context.fromContext
import afield.graphics.render.pointer
import afield.graphics.vulkan.targets.RenderTarget
import afield.math.linalg.Rectanglef
import kotlin.math.ceil

class GpuRadixSort

Stable GPU radix sort for uint keys stored in storage buffers.

This sorter owns its scratch buffers and inline compute shaders so it can live as a single Kotlin file. It sorts uint keys in-place across a pair of ping-pong passes and can optionally carry along a uint payload buffer.

Requirements: - key/value buffers must be VK_BUFFER_USAGE_STORAGE_BUFFER_BIT - each element is a single 32-bit unsigned integer - bitsPerPass must evenly divide 32

class GpuRadixSort(
    val renderer: Renderer = fromContext(),
    val workgroupSize: Int = 256,
    val bitsPerPass: Int = 4
) {

init

    init {
        require(workgroupSize > 0) { "workgroupSize must be > 0" }
        require(bitsPerPass in setOf(1, 2, 4, 8)) { "bitsPerPass must be one of 1, 2, 4 or 8" }
        require(32 % bitsPerPass == 0) { "bitsPerPass must evenly divide 32" }
    }

data class SortResult

body — 42 lines, from line 49
    data class SortResult(
        val keys: FrameBufferGroup,
        val values: FrameBufferGroup?
    )

    private data class SortPass(
        val histogramModule: afield.graphics.render.ComputeModulePointer,
        val histogramMesh: ComputeMesh,
        val prefixModule: afield.graphics.render.ComputeModulePointer,
        val prefixMesh: ComputeMesh,
        val scatterModule: afield.graphics.render.ComputeModulePointer,
        val scatterMesh: ComputeMesh,
        val histogramBuffer: FrameBufferGroup,
        val prefixBuffer: FrameBufferGroup,
        val bucketBaseBuffer: FrameBufferGroup,
        val outputKeysBuffer: FrameBufferGroup,
        val outputValuesBuffer: FrameBufferGroup?
    )

    private data class SortPlan(
        val keys: FrameBufferGroup,
        val values: FrameBufferGroup?,
        val usesPayloads: Boolean,
        val scratchKeysBuffer: FrameBufferGroup,
        val scratchValuesBuffer: FrameBufferGroup?,
        val histogramBuffer: FrameBufferGroup,
        val prefixBuffer: FrameBufferGroup,
        val bucketBaseBuffer: FrameBufferGroup,
        val passes: List<SortPass>
    )

    private val radix = 1 shl bitsPerPass
    private val radixMask = radix - 1
    private val passCount = 32 / bitsPerPass

    private var scratchCapacity = 0
    private var scratchKeys: BufferParameters? = null
    private var scratchValues: BufferParameters? = null
    private var groupHistogram: BufferParameters? = null
    private var groupPrefix: BufferParameters? = null
    private var bucketBase: BufferParameters? = null
    private var cachedPlan: SortPlan? = null

fun sort

    fun sort(
        commandBuffer: CommandBuffer,
        keys: VulkanBuffer,
        count: Int,
        values: VulkanBuffer? = null,
        signedKeys: Boolean = false
    ): SortResult {
        return sort(
            commandBuffer = commandBuffer,
            keys = keys.asFrameBufferGroup(),
            count = count,
            values = values?.asFrameBufferGroup(),
            signedKeys = signedKeys
        )
    }

fun sort

    fun sort(
        commandBuffer: CommandBuffer,
        keys: FrameBufferGroup,
        count: Int,
        values: FrameBufferGroup? = null,
        signedKeys: Boolean = false
    ): SortResult {
        require(count >= 0) { "count must be >= 0" }
        if (count <= 1) return SortResult(keys, values)

        val keyCapacity = keys.get().requestedSize / UInt.SIZE_BYTES
        require(count.toLong() <= keyCapacity) {
            "Requested $count keys but key buffer only has capacity for $keyCapacity uint elements"
        }

        if (values != null) {
            val valueCapacity = values.get().requestedSize / UInt.SIZE_BYTES
            require(count.toLong() <= valueCapacity) {
                "Requested $count payload values but payload buffer only has capacity for $valueCapacity uint elements"
            }
        }

        ensureScratch(count, values != null)
        val plan = getOrCreatePlan(keys, values)
        executePlan(plan, commandBuffer, count, signedKeys)

        return SortResult(keys = keys, values = values)
    }

fun installInto

    fun installInto(
        target: RenderTarget,
        keys: VulkanBuffer,
        count: Int,
        values: VulkanBuffer? = null,
        signedKeys: Boolean = false,
        name: String = "gpuRadixSort"
    ) {
        target.others[name] = Target { command, _ ->
            sort(command, keys, count, values, signedKeys)
        }
    }

fun installInto

    fun installInto(
        target: RenderTarget,
        keys: FrameBufferGroup,
        count: Int,
        values: FrameBufferGroup? = null,
        signedKeys: Boolean = false,
        name: String = "gpuRadixSort"
    ) {
        target.others[name] = Target { command, _ ->
            sort(command, keys, count, values, signedKeys)
        }
    }

fun installInto

    fun installInto(
        target: RenderTarget,
        keys: VulkanBuffer,
        count: Int,
        values: VulkanBuffer? = null,
        signedKeys: Boolean = false,
        name: String = "gpuRadixSort",
        onSorted: (SortResult) -> Unit
    ) {
        target.others[name] = Target { command, _ ->
            onSorted(sort(command, keys, count, values, signedKeys))
        }
    }

fun installInto

    fun installInto(
        target: RenderTarget,
        keys: FrameBufferGroup,
        count: Int,
        values: FrameBufferGroup? = null,
        signedKeys: Boolean = false,
        name: String = "gpuRadixSort",
        onSorted: (SortResult) -> Unit
    ) {
        target.others[name] = Target { command, _ ->
            onSorted(sort(command, keys, count, values, signedKeys))
        }
    }

fun reserve(count: Int, withValues: Boolean = true)

Allocates scratch for sorting up to count keys now, rather than on the first sort that needs it. A sort whose count grows a little every frame (particles being painted in) would otherwise reallocate its scratch on every one of those frames, and the old scratch is only freed with the renderer.

    fun reserve(count: Int, withValues: Boolean = true) {
        require(count >= 0) { "count must be >= 0" }
        ensureScratch(count.coerceAtLeast(2), withValues)
    }

fun cleanup()

body — 604 lines, from line 202
    fun cleanup() {
        cachedPlan?.cleanupNow()
        scratchKeys?.result?.buffers?.forEach { it.cleanup() }
        scratchValues?.result?.buffers?.forEach { it.cleanup() }
        groupHistogram?.result?.buffers?.forEach { it.cleanup() }
        groupPrefix?.result?.buffers?.forEach { it.cleanup() }
        bucketBase?.result?.buffers?.forEach { it.cleanup() }
        cachedPlan = null
        scratchKeys = null
        scratchValues = null
        groupHistogram = null
        groupPrefix = null
        bucketBase = null
        scratchCapacity = 0
    }

    private fun ensureScratch(count: Int, withValues: Boolean) {
        if (count <= scratchCapacity && (!withValues || scratchValues != null)) return
        dropCachedStateDeferred()

        val groupCount = ceil(count.toDouble() / workgroupSize.toDouble()).toInt().coerceAtLeast(1)

        scratchKeys = storageBuffer("radixScratchKeys", count.toLong() * UInt.SIZE_BYTES)
        scratchValues = null
        if (withValues) {
            scratchValues = storageBuffer("radixScratchValues", count.toLong() * UInt.SIZE_BYTES)
        }
        groupHistogram = storageBuffer("radixGroupHistogram", groupCount.toLong() * radix * UInt.SIZE_BYTES)
        groupPrefix = storageBuffer("radixGroupPrefix", groupCount.toLong() * radix * UInt.SIZE_BYTES)
        bucketBase = storageBuffer("radixBucketBase", radix.toLong() * UInt.SIZE_BYTES)
        scratchCapacity = count
    }

    private fun getOrCreatePlan(keys: FrameBufferGroup, values: FrameBufferGroup?): SortPlan {
        val existing = cachedPlan
        if (existing != null &&
            existing.keys === keys &&
            existing.values === values &&
            existing.scratchKeysBuffer === requireNotNull(scratchKeys).result &&
            existing.scratchValuesBuffer === scratchValues?.result &&
            existing.histogramBuffer === requireNotNull(groupHistogram).result &&
            existing.prefixBuffer === requireNotNull(groupPrefix).result &&
            existing.bucketBaseBuffer === requireNotNull(bucketBase).result
        ) {
            return existing
        }

        existing?.let { deferCleanup(it) }

        val plan = buildPlan(keys, values)
        cachedPlan = plan
        return plan
    }

    private fun buildPlan(keys: FrameBufferGroup, values: FrameBufferGroup?): SortPlan {
        val externalKeys = keys.asStorageBinding("InputKeys")
        val externalValues = values?.asStorageBinding("InputValues")
        val scratchKeysBinding = requireNotNull(scratchKeys).renamed("OutputKeys")
        val scratchValuesBinding = scratchValues?.renamed("OutputValues")
        val histogramBinding = requireNotNull(groupHistogram).renamed("GroupHistogram")
        val prefixBinding = requireNotNull(groupPrefix).renamed("GroupPrefix")
        val bucketBaseBinding = requireNotNull(bucketBase).renamed("BucketBase")

        var inputKeys = externalKeys
        var outputKeys = scratchKeysBinding
        var inputValues = externalValues
        var outputValues = scratchValuesBinding

        val passes = buildList {
            repeat(passCount) {
                val histogramModule = buildHistogramModule(inputKeys, histogramBinding, it)
                val histogramMesh = ComputeMesh(renderer, histogramModule).apply {
                    this.y = 1
                    this.z = 1
                }
                histogramModule += "dispatch" to histogramMesh

                val prefixModule = buildPrefixModule(histogramBinding, prefixBinding, bucketBaseBinding, it)
                val prefixMesh = ComputeMesh(renderer, prefixModule).apply {
                    this.x = 1
                    this.y = 1
                    this.z = 1
                }
                prefixModule += "dispatch" to prefixMesh

                val scatterModule = if (values == null) {
                    buildScatterKeysModule(inputKeys, outputKeys, prefixBinding, bucketBaseBinding, it)
                } else {
                    buildScatterPairsModule(
                        inputKeys = inputKeys,
                        outputKeys = outputKeys,
                        inputValues = requireNotNull(inputValues),
                        outputValues = requireNotNull(outputValues),
                        prefix = prefixBinding,
                        bucketBase = bucketBaseBinding,
                        passIndex = it
                    )
                }
                val scatterMesh = ComputeMesh(renderer, scatterModule).apply {
                    this.y = 1
                    this.z = 1
                }
                scatterModule += "dispatch" to scatterMesh

                add(
                    SortPass(
                        histogramModule = histogramModule,
                        histogramMesh = histogramMesh,
                        prefixModule = prefixModule,
                        prefixMesh = prefixMesh,
                        scatterModule = scatterModule,
                        scatterMesh = scatterMesh,
                        histogramBuffer = requireNotNull(histogramBinding.result),
                        prefixBuffer = requireNotNull(prefixBinding.result),
                        bucketBaseBuffer = requireNotNull(bucketBaseBinding.result),
                        outputKeysBuffer = requireNotNull(outputKeys.result),
                        outputValuesBuffer = outputValues?.result
                    )
                )

                val nextInputKeys = outputKeys.renamed("InputKeys")
                val nextOutputKeys = inputKeys.renamed("OutputKeys")
                inputKeys = nextInputKeys
                outputKeys = nextOutputKeys

                if (values != null) {
                    val nextInputValues = requireNotNull(outputValues).renamed("InputValues")
                    val nextOutputValues = requireNotNull(inputValues).renamed("OutputValues")
                    inputValues = nextInputValues
                    outputValues = nextOutputValues
                }
            }
        }

        return SortPlan(
            keys = keys,
            values = values,
            usesPayloads = values != null,
            scratchKeysBuffer = requireNotNull(scratchKeys).result!!,
            scratchValuesBuffer = scratchValues?.result,
            histogramBuffer = requireNotNull(groupHistogram).result!!,
            prefixBuffer = requireNotNull(groupPrefix).result!!,
            bucketBaseBuffer = requireNotNull(bucketBase).result!!,
            passes = passes
        )
    }

    private fun executePlan(plan: SortPlan, commandBuffer: CommandBuffer, count: Int, signedKeys: Boolean) {
        val groupCount = ceil(count.toDouble() / workgroupSize.toDouble()).toInt().coerceAtLeast(1)
        val signedInt = if (signedKeys) 1 else 0

        // If an upstream compute step produced these buffers earlier in the same frame,
        // make those writes visible before radix pass 0 reads them.
        computeMemoryBarrier(commandBuffer, plan.keys.get().buffer, plan.keys.get().requestedSize)
        plan.values?.let {
            computeMemoryBarrier(commandBuffer, it.get().buffer, it.get().requestedSize)
        }

        plan.passes.forEachIndexed { passIndex, pass ->
            val shift = passIndex * bitsPerPass

            pass.histogramMesh.x = groupCount
            pass.histogramMesh.count = count
            pass.histogramMesh.shift = shift
            pass.histogramMesh.groupCount = groupCount
            pass.histogramMesh.signedKeys = signedInt
            pass.histogramModule.render(commandBuffer, Rectanglef())
            computeMemoryBarrier(commandBuffer, pass.histogramBuffer.get().buffer, pass.histogramBuffer.get().requestedSize)

            pass.prefixMesh.groupCount = groupCount
            pass.prefixModule.render(commandBuffer, Rectanglef())
            computeMemoryBarrier(commandBuffer, pass.prefixBuffer.get().buffer, pass.prefixBuffer.get().requestedSize)
            computeMemoryBarrier(commandBuffer, pass.bucketBaseBuffer.get().buffer, pass.bucketBaseBuffer.get().requestedSize)

            pass.scatterMesh.x = groupCount
            pass.scatterMesh.count = count
            pass.scatterMesh.shift = shift
            pass.scatterMesh.groupCount = groupCount
            pass.scatterMesh.signedKeys = signedInt
            pass.scatterModule.render(commandBuffer, Rectanglef())
            computeMemoryBarrier(commandBuffer, pass.outputKeysBuffer.get().buffer, pass.outputKeysBuffer.get().requestedSize)
            pass.outputValuesBuffer?.let {
                computeMemoryBarrier(commandBuffer, it.get().buffer, it.get().requestedSize)
            }
        }
    }

    private fun dropCachedStateDeferred() {
        cachedPlan?.let { deferCleanup(it) }
        scratchKeys?.result?.let { buffers ->
            renderer.cleanup.add(Runnable { buffers.buffers.forEach { it.cleanup() } })
        }
        scratchValues?.result?.let { buffers ->
            renderer.cleanup.add(Runnable { buffers.buffers.forEach { it.cleanup() } })
        }
        groupHistogram?.result?.let { buffers ->
            renderer.cleanup.add(Runnable { buffers.buffers.forEach { it.cleanup() } })
        }
        groupPrefix?.result?.let { buffers ->
            renderer.cleanup.add(Runnable { buffers.buffers.forEach { it.cleanup() } })
        }
        bucketBase?.result?.let { buffers ->
            renderer.cleanup.add(Runnable { buffers.buffers.forEach { it.cleanup() } })
        }
        cachedPlan = null
        scratchKeys = null
        scratchValues = null
        groupHistogram = null
        groupPrefix = null
        bucketBase = null
        scratchCapacity = 0
    }

    private fun deferCleanup(plan: SortPlan) {
        renderer.cleanup.add(Runnable { plan.cleanupNow() })
    }

    private fun SortPlan.cleanupNow() {
        passes.forEach { pass ->
            pass.histogramModule.current.cleanup()
            pass.prefixModule.current.cleanup()
            pass.scatterModule.current.cleanup()
        }
    }

    private fun storageBuffer(name: String, sizeBytes: Long): BufferParameters {
        return buffer(name = name) {
            size = sizeBytes
            storage()
            hosted()
            // The sorter can run once per in-flight frame, so scratch must be per-frame.
            // Reusing a single histogram/prefix/scratch buffer across swapchain images causes
            // multi-frame contamination that looks like the sort "settles" a few frames later.
            perFrame = true
        }.also { it.toBuffer(renderer) }
    }

    private fun buildHistogramModule(
        inputKeys: BufferParameters,
        histogram: BufferParameters,
        passIndex: Int
    ): afield.graphics.render.ComputeModulePointer {
        val shader = """
            #version 450

            layout(local_size_x = $workgroupSize, local_size_y = 1, local_size_z = 1) in;

            layout(set = 0, binding = 0) readonly buffer InputKeys {
                uint keys[];
            } inputKeys;

            layout(set = 0, binding = 1) writeonly buffer GroupHistogram {
                uint counts[];
            } groupHistogram;

            layout(push_constant) uniform PushConstants {
                int count;
                int shift;
                int groupCount;
                int signedKeys;
            } pc;

            shared uint localCounts[$radix];

            uint normalizedKey(uint keyValue) {
                return (pc.signedKeys != 0) ? (keyValue ^ 0x80000000u) : keyValue;
            }

            void main() {
                uint localIndex = gl_LocalInvocationID.x;
                uint groupIndex = gl_WorkGroupID.x;
                uint globalIndex = gl_GlobalInvocationID.x;

                if (localIndex < $radix) {
                    localCounts[localIndex] = 0u;
                }
                barrier();

                if (globalIndex < uint(pc.count)) {
                    uint keyValue = normalizedKey(inputKeys.keys[globalIndex]);
                    uint digit = (keyValue >> uint(pc.shift)) & uint($radixMask);
                    atomicAdd(localCounts[digit], 1u);
                }
                barrier();

                if (localIndex < $radix) {
                    groupHistogram.counts[groupIndex * uint($radix) + localIndex] = localCounts[localIndex];
                }
            }
        """.trimIndent()

        return ComputeModule(
            Parameters(
                renderer = renderer,
                compute = shader,
                name = "GpuRadixSort_Histogram[$passIndex]",
                ssboResolver = { descriptor ->
                    when (descriptor.name) {
                        "InputKeys" -> inputKeys
                        "GroupHistogram" -> histogram
                        else -> null
                    }
                }
            )
        ).pointer()
    }

    private fun buildPrefixModule(
        histogram: BufferParameters,
        prefix: BufferParameters,
        bucketBaseBuffer: BufferParameters,
        passIndex: Int
    ): afield.graphics.render.ComputeModulePointer {
        val shader = """
            #version 450

            layout(local_size_x = 1, local_size_y = 1, local_size_z = 1) in;

            layout(set = 0, binding = 0) readonly buffer GroupHistogram {
                uint counts[];
            } groupHistogram;

            layout(set = 0, binding = 1) writeonly buffer GroupPrefix {
                uint offsets[];
            } groupPrefix;

            layout(set = 0, binding = 2) writeonly buffer BucketBase {
                uint base[];
            } bucketBase;

            layout(push_constant) uniform PushConstants {
                int groupCount;
            } pc;

            void main() {
                if (gl_GlobalInvocationID.x != 0u) return;

                uint running = 0u;
                for (uint bucket = 0u; bucket < uint($radix); ++bucket) {
                    bucketBase.base[bucket] = running;

                    uint bucketCount = 0u;
                    for (uint group = 0u; group < uint(pc.groupCount); ++group) {
                        uint index = group * uint($radix) + bucket;
                        groupPrefix.offsets[index] = bucketCount;
                        bucketCount += groupHistogram.counts[index];
                    }

                    running += bucketCount;
                }
            }
        """.trimIndent()

        return ComputeModule(
            Parameters(
                renderer = renderer,
                compute = shader,
                name = "GpuRadixSort_Prefix[$passIndex]",
                ssboResolver = { descriptor ->
                    when (descriptor.name) {
                        "GroupHistogram" -> histogram
                        "GroupPrefix" -> prefix
                        "BucketBase" -> bucketBaseBuffer
                        else -> null
                    }
                }
            )
        ).pointer()
    }

    private fun buildScatterKeysModule(
        inputKeys: BufferParameters,
        outputKeys: BufferParameters,
        prefix: BufferParameters,
        bucketBaseBuffer: BufferParameters,
        passIndex: Int
    ): afield.graphics.render.ComputeModulePointer {
        val shader = """
            #version 450

            layout(local_size_x = $workgroupSize, local_size_y = 1, local_size_z = 1) in;

            layout(set = 0, binding = 0) readonly buffer InputKeys {
                uint keys[];
            } inputKeys;

            layout(set = 0, binding = 1) writeonly buffer OutputKeys {
                uint keys[];
            } outputKeys;

            layout(set = 0, binding = 2) readonly buffer GroupPrefix {
                uint offsets[];
            } groupPrefix;

            layout(set = 0, binding = 3) readonly buffer BucketBase {
                uint base[];
            } bucketBase;

            layout(push_constant) uniform PushConstants {
                int count;
                int shift;
                int groupCount;
                int signedKeys;
            } pc;

            shared uint localDigit[$workgroupSize];

            uint normalizedKey(uint keyValue) {
                return (pc.signedKeys != 0) ? (keyValue ^ 0x80000000u) : keyValue;
            }

            void main() {
                uint localIndex = gl_LocalInvocationID.x;
                uint groupIndex = gl_WorkGroupID.x;
                uint globalIndex = gl_GlobalInvocationID.x;
                bool valid = globalIndex < uint(pc.count);

                uint keyValue = 0u;
                uint digit = 0u;
                if (valid) {
                    keyValue = inputKeys.keys[globalIndex];
                    digit = (normalizedKey(keyValue) >> uint(pc.shift)) & uint($radixMask);
                }
                localDigit[localIndex] = digit;
                barrier();

                if (!valid) return;

                uint localRank = 0u;
                for (uint i = 0u; i < localIndex; ++i) {
                    if (localDigit[i] == digit) {
                        localRank += 1u;
                    }
                }

                uint baseIndex = bucketBase.base[digit];
                uint groupOffset = groupPrefix.offsets[groupIndex * uint($radix) + digit];
                uint destination = baseIndex + groupOffset + localRank;
                outputKeys.keys[destination] = keyValue;
            }
        """.trimIndent()

        return ComputeModule(
            Parameters(
                renderer = renderer,
                compute = shader,
                name = "GpuRadixSort_ScatterKeys[$passIndex]",
                ssboResolver = { descriptor ->
                    when (descriptor.name) {
                        "InputKeys" -> inputKeys
                        "OutputKeys" -> outputKeys
                        "GroupPrefix" -> prefix
                        "BucketBase" -> bucketBaseBuffer
                        else -> null
                    }
                }
            )
        ).pointer()
    }

    private fun buildScatterPairsModule(
        inputKeys: BufferParameters,
        outputKeys: BufferParameters,
        inputValues: BufferParameters,
        outputValues: BufferParameters,
        prefix: BufferParameters,
        bucketBase: BufferParameters,
        passIndex: Int
    ): afield.graphics.render.ComputeModulePointer {
        val shader = """
            #version 450

            layout(local_size_x = $workgroupSize, local_size_y = 1, local_size_z = 1) in;

            layout(set = 0, binding = 0) readonly buffer InputKeys {
                uint keys[];
            } inputKeys;

            layout(set = 0, binding = 1) writeonly buffer OutputKeys {
                uint keys[];
            } outputKeys;

            layout(set = 0, binding = 2) readonly buffer InputValues {
                uint values[];
            } inputValues;

            layout(set = 0, binding = 3) writeonly buffer OutputValues {
                uint values[];
            } outputValues;

            layout(set = 0, binding = 4) readonly buffer GroupPrefix {
                uint offsets[];
            } groupPrefix;

            layout(set = 0, binding = 5) readonly buffer BucketBase {
                uint base[];
            } bucketBase;

            layout(push_constant) uniform PushConstants {
                int count;
                int shift;
                int groupCount;
                int signedKeys;
            } pc;

            shared uint localDigit[$workgroupSize];

            uint normalizedKey(uint keyValue) {
                return (pc.signedKeys != 0) ? (keyValue ^ 0x80000000u) : keyValue;
            }

            void main() {
                uint localIndex = gl_LocalInvocationID.x;
                uint groupIndex = gl_WorkGroupID.x;
                uint globalIndex = gl_GlobalInvocationID.x;
                bool valid = globalIndex < uint(pc.count);

                uint keyValue = 0u;
                uint payloadValue = 0u;
                uint digit = 0u;
                if (valid) {
                    keyValue = inputKeys.keys[globalIndex];
                    payloadValue = inputValues.values[globalIndex];
                    digit = (normalizedKey(keyValue) >> uint(pc.shift)) & uint($radixMask);
                }
                localDigit[localIndex] = digit;
                barrier();

                if (!valid) return;

                uint localRank = 0u;
                for (uint i = 0u; i < localIndex; ++i) {
                    if (localDigit[i] == digit) {
                        localRank += 1u;
                    }
                }

                uint baseIndex = bucketBase.base[digit];
                uint groupOffset = groupPrefix.offsets[groupIndex * uint($radix) + digit];
                uint destination = baseIndex + groupOffset + localRank;
                outputKeys.keys[destination] = keyValue;
                outputValues.values[destination] = payloadValue;
            }
        """.trimIndent()

        return ComputeModule(
            Parameters(
                renderer = renderer,
                compute = shader,
                name = "GpuRadixSort_ScatterPairs[$passIndex]",
                ssboResolver = { descriptor ->
                    when (descriptor.name) {
                        "InputKeys" -> inputKeys
                        "OutputKeys" -> outputKeys
                        "InputValues" -> inputValues
                        "OutputValues" -> outputValues
                        "GroupPrefix" -> prefix
                        "BucketBase" -> bucketBase
                        else -> null
                    }
                }
            )
        ).pointer()
    }

    private var ComputeMesh.count by ComputeMesh.FromStructure<Int>()
    private var ComputeMesh.shift by ComputeMesh.FromStructure<Int>()
    private var ComputeMesh.groupCount by ComputeMesh.FromStructure<Int>()
    private var ComputeMesh.signedKeys by ComputeMesh.FromStructure<Int>()

    private fun BufferParameters.renamed(name: String): BufferParameters {
        val renamed = BufferParameters(source = source, name = name)
        renamed.perFrame = perFrame
        renamed.size = size
        renamed.usage = usage
        renamed.memory = memory
        renamed.needsDeviceAddress = needsDeviceAddress
        renamed.result = result
        return renamed
    }

    private fun FrameBufferGroup.asStorageBinding(name: String): BufferParameters {
        val first = buffers.firstOrNull() ?: error("FrameBufferGroup for '$name' is empty")
        require(first.usage and org.lwjgl.vulkan.VK10.VK_BUFFER_USAGE_STORAGE_BUFFER_BIT != 0) {
            "Buffer '$name' must have VK_BUFFER_USAGE_STORAGE_BUFFER_BIT set"
        }

        val descriptor = BufferParameters(name = name).apply {
            size = first.requestedSize
            usage = first.usage
            memory = first.reqMask
            needsDeviceAddress = first.needsDeviceAddress
            result = this@asStorageBinding
        }

        buffers.forEach { buffer ->
            if (buffer.source == null) {
                buffer.source = descriptor.store()
            }
        }

        return descriptor
    }
}

fun VulkanBuffer.asFrameBufferGroup(renderer: Renderer = this.renderer): FrameBufferGroup

fun VulkanBuffer.asFrameBufferGroup(renderer: Renderer = this.renderer): FrameBufferGroup {
    return FrameBufferGroup(renderer = renderer, n = 1, buffers = mutableListOf(this))
}