reckfellENGINE

GPU compute: complete C# and GLSL example

Workgroups, storage buffers, bindings, synchronization and result readback.

On this page

A compute shader runs general-purpose work on the GPU. This example doubles float values in a storage buffer and reads the actual result back to C#. Each invocation owns one element, so no atomic operation or cross-workgroup coordination is needed.

Requirements and expected result

Use a RenderingDevice-based renderer, such as Forward+ or Mobile. The executed example uses Windows Forward+/Vulkan; other drivers and devices require their own validation. Compatibility does not provide the RenderingDevice used by this workflow. A null local device means the required context is unavailable, not a reason to continue with invalid RIDs.

Save the following class as ComputeDouble.cs. In an Entity's _Ready method, create an owned instance with using var compute = new ComputeDouble();, then call float[] output = compute.Run(new float[] { 1, 2, 3 });. The expected output is [2, 4, 6]. Keep the same instance for multiple calls and dispose it when finished. Creation, Run and disposal must stay on the device's owning thread; this class is synchronous and is not safe for concurrent calls.

Complete C# example

The kernel is raw GLSL supplied through RDShaderSource.SourceCompute. It starts with #version 450 and does not contain the editor import marker #[compute]. That marker belongs to the separate .glsl asset-import workflow; it is not a GLSL directive to pass to this compiler input.

C#
using System;
using Reckfell;

public sealed class ComputeDouble : IDisposable
{
    public const string Kernel = """
        #version 450
        layout(local_size_x = 64, local_size_y = 1, local_size_z = 1) in;
        layout(set = 0, binding = 0, std430) restrict buffer Values {
            float data[];
        } values;
        void main() {
            uint index = gl_GlobalInvocationID.x;
            if (index >= uint(values.data.length())) return;
            values.data[index] *= 2.0;
        }
        """;

    private readonly RenderingDevice device;
    private Rid shader;
    private Rid pipeline;
    private bool disposed;

    public ComputeDouble()
    {
        device = RenderingServer.CreateLocalRenderingDevice()
            ?? throw new NotSupportedException("A local RenderingDevice is unavailable.");
        try
        {
            if (device.LimitGet(RenderingDevice.Limit.MaxComputeWorkgroupSizeX) < 64 ||
                device.LimitGet(RenderingDevice.Limit.MaxComputeWorkgroupInvocations) < 64)
                throw new NotSupportedException("The kernel requires 64 invocations per group.");
            using var source = new RDShaderSource { SourceCompute = Kernel };
            using var spirV = device.ShaderCompileSpirVFromSource(source);
            if (!string.IsNullOrEmpty(spirV.CompileErrorCompute))
                throw new InvalidOperationException(spirV.CompileErrorCompute);
            shader = device.ShaderCreateFromSpirV(spirV);
            if (!shader.IsValid) throw new InvalidOperationException("Shader creation failed.");
            pipeline = device.ComputePipelineCreate(shader);
            if (!pipeline.IsValid) throw new InvalidOperationException("Pipeline creation failed.");
        }
        catch { Dispose(); throw; }
    }

    public float[] Run(float[] input)
    {
        ObjectDisposedException.ThrowIf(disposed, this);
        ArgumentNullException.ThrowIfNull(input);
        if (input.Length == 0) return Array.Empty<float>();
        uint groups = (uint)((input.Length + 63L) / 64L);
        if (groups > device.LimitGet(RenderingDevice.Limit.MaxComputeWorkgroupCountX))
            throw new ArgumentOutOfRangeException(nameof(input), "Too many workgroups.");
        int byteCount = checked(input.Length * sizeof(float));
        byte[] bytes = new byte[byteCount];
        Buffer.BlockCopy(input, 0, bytes, 0, byteCount);
        Rid buffer = default, uniformSet = default;
        try
        {
            buffer = device.StorageBufferCreate((uint)byteCount, bytes);
            if (!buffer.IsValid) throw new InvalidOperationException("Buffer creation failed.");
            using var uniform = new RDUniform
            {
                UniformType = RenderingDevice.UniformType.StorageBuffer, Binding = 0
            };
            uniform.AddId(buffer);
            var uniforms = new Reckfell.Collections.Array<RDUniform> { uniform };
            using var uniformsOwner = (Reckfell.Collections.Array)uniforms;
            uniformSet = device.UniformSetCreate(uniforms, shader, 0);
            if (!uniformSet.IsValid) throw new InvalidOperationException("Uniform set creation failed.");
            long list = device.ComputeListBegin();
            device.ComputeListBindComputePipeline(list, pipeline);
            device.ComputeListBindUniformSet(list, uniformSet, 0);
            device.ComputeListDispatch(list, groups, 1, 1);
            device.ComputeListEnd();
            device.Submit();
            device.Sync();
            byte[] resultBytes = device.BufferGetData(buffer);
            if (resultBytes.Length != byteCount)
                throw new InvalidOperationException("Unexpected readback size.");
            float[] result = new float[input.Length];
            Buffer.BlockCopy(resultBytes, 0, result, 0, byteCount);
            return result;
        }
        finally
        {
            if (uniformSet.IsValid) device.FreeRid(uniformSet);
            if (buffer.IsValid) device.FreeRid(buffer);
        }
    }

    public void Dispose()
    {
        if (disposed) return;
        disposed = true;
        if (pipeline.IsValid) device.FreeRid(pipeline);
        if (shader.IsValid) device.FreeRid(shader);
        device.Free();
    }
}

The class owns a local device, shader and pipeline. Each Run creates a buffer and uniform set, waits for its result, then frees those per-call RIDs. The typed uniforms collection is backed by an owned untyped Array; the explicit owner using scope releases that temporary collection. It does not dispose a collection supplied by a caller.

Match the CPU and shader layouts

ContractValue in this example
Element formatGLSL float and C# float: 32 bits per element. Do not substitute double[] with the same byte count.
Buffer layoutstd430 unsized float array, 4 bytes per element. Structs, vec3 and matrices need their own alignment/stride calculation.
Descriptor setGLSL set=0, UniformSetCreate shaderSet=0, ComputeListBindUniformSet setIndex=0.
BindingGLSL binding=0 and RDUniform.Binding=0. Binding is not the set index.
Resource kindStorageBuffer on both the GLSL block and RDUniform.
Local group size64 invocations in X; 1 in Y and Z.
Dispatch(elementCount + 63) / 64 groups, not that many individual threads.

For 65 elements the dispatch starts 128 invocations. The shader's bounds check discards indices 65–127 before reading or writing memory. Rounding down would skip the final element; rounding up without the check would access beyond the buffer. The runtime checks exercise lengths 1, 2, 63, 64, 65 and 257.

Workgroups run independently; do not assume execution order between them. Invocations can access shared storage buffers, but competing writes need a deliberately designed synchronization/atomic strategy. A workgroup barrier is not a global barrier across all groups. This element-wise example avoids those dependencies.

Record, submit, wait, read

ComputeListBegin opens a command list. Bind its compute pipeline and required uniform sets before Dispatch, then call ComputeListEnd. Ending the list records work; it does not mean the CPU can already read the result.

For a local device, Submit sends the work and Sync waits for that submission. Do not call Sync without a preceding Submit, submit the same local device again before synchronizing, or apply this local-device lifecycle to the engine's borrowed global device.

BufferGetData is blocking and flushes/stalls work for readback. Waiting a fixed number of game frames is not a completion fence. This small synchronous example is for correctness; repeated GPU-to-CPU transfers can erase any benefit from the compute work. Reuse GPU data and batch work when the application allows it. Measure the full operation, including upload, synchronization and readback, before claiming a speedup.

Update or read only part of a buffer

BufferUpdate offsets and sizes are in bytes. The executed test updates two floats with offset 4 and size 8, then reuses the same buffer, uniform set and pipeline for another dispatch. Update outside an active compute/draw list, validate the supplied byte count, and preserve the buffer's allocation bounds.

For a partial read, pass an explicit size: device.BufferGetData(buffer, 4, 8) reads two float values beginning at the second element. In this version, sizeBytes=0 selects the full buffer size; do not interpret it as “the remaining bytes after any offset.” Use offset 0 for the default full read.

Diagnose failures

SymptomCheck
Local device is nullActual renderer/context and device availability.
Compilation failsCompileErrorCompute; raw GLSL versus editor import syntax. Do not create a shader from errored bytecode.
Uniform-set creation failsShader set, binding, resource kind and RIDs belonging to the same device.
Last elements remain unchangedCeiling group count and shader bounds check.
Values are scrambledCPU element width, byte count and shader alignment/stride.
GPU work appears slower than CPU workInclude creation, transfers, synchronization and readback in the measurement.
Errors after cleanupDo not use freed RIDs, share them across devices or dispose the engine-owned global device.

The checks confirm real GPU results, repeat use, unchanged managed input, empty/null/disposed calls and byte-range updates/readback. They do not establish performance, asynchronous readback, global-device rendering interop or support on every GPU/export target. Keep workloads bounded; long-running kernels can trigger a platform driver timeout, so do not assume a universal timeout duration.

Reckfell Engine documentationBack to top ↑
QUICK LINKSGetting startedEntity hierarchyC# & .NET 10Scenes and prefabs
Reckfell guides and full API K
reckfellENGINE
RECKFELL ACCOUNT

Sign in

Sign in to your Reckfell account.

One account for the entire Reckfell platform.