A compute shader runs calculations on the GPU without drawing a frame. This example runs HLSL on Vulkan; it needs a Vulkan 1.3 driver on Windows or Linux.
On Apple silicon Macs, OneGpu uses Metal; this SPIR-V-only example targets Vulkan (see platform support).
dotnet new console dotnet add package RecursiveAI.OneGpu.Backends dotnet add package RecursiveAI.OneShader.Build
Requires .NET 10 (net10.0); .NET 9 and earlier aren’t supported.
Fill.Main is generated from Fill.hlsl by OneShader.Build; both the shader and project settings are shown below and included in the zip.
using RecursiveAI.OneGpu; using Example.Shaders; using var device = GpuBackends.CreateDevice(); using var output = device.CreateBuffer<uint>(4); using var kernel = device.CreateComputeKernel( new ShaderSource(ShaderStage.Compute, Fill.Main.EntryPoint) { SpirV = Fill.Main.SpirV, Layout = Fill.Main.Layout }); using var group = kernel.CreateBindGroup(device, 0, [BindGroupEntry.ForBuffer(0, output.Buffer)]); var commands = device.BeginCommands(); var pass = commands.BeginComputePass(); pass.SetPipeline(kernel.Pipeline); pass.SetBindGroup(0, group); pass.Dispatch(1, 1, 1); pass.EndPass(); device.Submit(commands); var values = new uint[4]; await output.ReadAsync(values); Console.WriteLine(string.Join(", ", values));
Writes four GPU-generated integers and prints:
0, 10, 20, 30Save this as Fill.hlsl beside Program.cs:
[[vk::binding(0, 0)]] RWStructuredBuffer<uint> Result; [shader("compute")] [numthreads(4, 1, 1)] void Main(uint3 id : SV_DispatchThreadID) { Result[id.x] = id.x * 10; }
The project pins the released packages and the generated namespace:
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <OutputType>Exe</OutputType> <TargetFramework>net10.0</TargetFramework> <ImplicitUsings>enable</ImplicitUsings> <Nullable>enable</Nullable> <OneShaderNamespace>Example.Shaders</OneShaderNamespace> </PropertyGroup> <ItemGroup> <PackageReference Include="RecursiveAI.OneGpu.Backends" Version="1.0.0" /> <PackageReference Include="RecursiveAI.OneShader.Build" Version="1.7.0" PrivateAssets="all" /> </ItemGroup> </Project>
If the shader requires an unsupported device capability, CreateComputeKernel throws GpuUnsupportedException naming it before creating the pipeline.
Unzip the example, then dotnet run.
Variations
Replace the fixed dispatch with a group count calculated from the reflected workgroup size:
var (x, y, z) = kernel.GroupsFor(4); pass.Dispatch(x, y, z);
0, 10, 20, 30Related recipes
- Read a GPU buffer back to the CPU in C#
- Choose a Vulkan physical device in C#
- GPU matrix multiplication in C#
The short answer
Run an HLSL compute entry point through OneGpu’s CreateComputeKernel, bind its storage buffers, record a compute pass and submit it. ReadAsync then copies the result into managed memory. The GPU executes the shader; C# creates resources and records commands. No window or render surface is needed for this calculation.
The example uses OneGpu 1.0.0 and OneShader.Build 1.7.0. The build package compiles Fill.hlsl into SPIR-V and generates the Fill.Main C# type. It is not a handwritten wrapper, and installing only the runtime package does not generate it. Keep the HLSL file in the project and the namespace setting shown above when copying this example.
How to run a compute shader?
There are two different steps: compile the shader when building the project, then dispatch it when running the program. OneShader.Build supplies the compiled entry point and its reflection. The reflection describes the shader’s resource bindings and workgroup size. OneGpu uses it to build the pipeline layout instead of asking the application to duplicate that layout.
In this shader, [[vk::binding(0, 0)]] declares a writable storage buffer at binding zero in group zero. The C# bind group connects that slot to output.Buffer. Both sides use unsigned 32-bit integers, so each C# uint corresponds to one HLSL uint. Matching the element layout is the application’s responsibility; changing only one side to an unrelated struct does not convert the data.
A workgroup is a group of shader invocations. [numthreads(4, 1, 1)] declares four invocations per workgroup. Dispatch(1, 1, 1) launches one workgroup, so the shader writes indices zero through three. This particular shader has no bounds check and must not be dispatched over more than those four elements. When adapting it to variable-sized arrays, add an element-count parameter and guard accesses in the shader before rounding up the dispatch size.
EndPass finishes recording the pass, not executing it. Submit sends the recorded work to the device. The awaited readback is where the CPU result becomes available. Reading the newly allocated C# array before that await completes is not a way to inspect GPU progress.
What does a compute shader do?
It executes an entry point over a dispatch grid rather than producing rasterized triangles. Here each invocation computes its own array index multiplied by ten. The HLSL file does the arithmetic; the C# program prints the returned values. A compute shader can therefore produce data without producing an image.
The compute surface documentation describes the capability checks applied when a kernel is created. Workgroup dimensions, required scalar types and subgroup operations are checked against the chosen device. Unsupported requirements throw GpuUnsupportedException with Capability, Expected and Actual; selecting a discrete adapter alone does not establish that every shader feature is available.
This example passes SPIR-V only and is intentionally a Vulkan program. OneGpu also exposes other backends, but changing the selected backend is not enough to make a SPIR-V-only ShaderSource work there. Supply the corresponding generated shader representation when targeting another backend. Dispose the kernel, bind group and buffers when finished; the using declarations in the example keep their lifetimes inside the device’s lifetime.