GPU compute: complete C# and GLSL example
Workgroups, storage buffers, bindings, synchronization and result readback.
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.
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
| Contract | Value in this example |
|---|---|
| Element format | GLSL float and C# float: 32 bits per element. Do not substitute double[] with the same byte count. |
| Buffer layout | std430 unsized float array, 4 bytes per element. Structs, vec3 and matrices need their own alignment/stride calculation. |
| Descriptor set | GLSL set=0, UniformSetCreate shaderSet=0, ComputeListBindUniformSet setIndex=0. |
| Binding | GLSL binding=0 and RDUniform.Binding=0. Binding is not the set index. |
| Resource kind | StorageBuffer on both the GLSL block and RDUniform. |
| Local group size | 64 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
| Symptom | Check |
|---|---|
| Local device is null | Actual renderer/context and device availability. |
| Compilation fails | CompileErrorCompute; raw GLSL versus editor import syntax. Do not create a shader from errored bytecode. |
| Uniform-set creation fails | Shader set, binding, resource kind and RIDs belonging to the same device. |
| Last elements remain unchanged | Ceiling group count and shader bounds check. |
| Values are scrambled | CPU element width, byte count and shader alignment/stride. |
| GPU work appears slower than CPU work | Include creation, transfers, synchronization and readback in the measurement. |
| Errors after cleanup | Do 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.