Compile HLSL shaders during dotnet build with errors on the HLSL line, use the C# structs and bindings generated from them, emit Metal and WGSL next to SPIR-V, build pipeline layouts, and compile at run time or from the command line.
OneShader is an HLSL compiler written in C# with no dependency beyond .NET itself. Modern HLSL (Shader Model 6 style, HLSL 2021) for vertex, pixel and compute shaders goes in. SPIR-V for Vulkan 1.2 and newer, Metal Shading Language and WGSL come out, each with reflection. Every sample on this page was compiled and run against the 1.2.0 packages from NuGet.
Install
OneShader targets .NET 10 (net10.0), so you need the .NET 10 SDK. For shaders built into a project, reference the build package:
dotnet new console -o Start cd Start dotnet add package RecursiveAI.OneShader.Build --version 1.2.0
Build with the .NET SDK (dotnet build or dotnet run). Visual Studio’s .NET Framework MSBuild can’t load the build task. The build package brings the compiler package, RecursiveAI.OneShader, with it. The license is MIT or Apache-2.0, at your choice.
Compile a shader with the project
Every *.hlsl file in the project is compiled on build. Save this as Shaders/Sprite.hlsl:
cbuffer Frame : register(b0) { float4x4 ViewProjection; float4 Tint; }; [[vk::binding(1)]] Texture2D<float4> Atlas; [[vk::binding(2)]] SamplerState AtlasSampler; struct VSIn { float2 Position : POSITION; float2 Uv : TEXCOORD0; [[oneshader::format("unorm8x4")]] float4 Color : COLOR0; }; struct VSOut { float4 Position : SV_Position; float2 Uv : TEXCOORD0; float4 Color : COLOR0; }; [shader("vertex")] VSOut VSMain(VSIn i) { VSOut o; o.Position = mul(ViewProjection, float4(i.Position, 0, 1)); o.Uv = i.Uv; o.Color = i.Color * Tint; return o; } [shader("pixel")] float4 PSMain(VSOut i) : SV_Target { return Atlas.Sample(AtlasSampler, i.Uv) * i.Color; }
The build generates a static class named after the file, Sprite, in the namespace <RootNamespace>.Shaders. Program.cs:
using System.Numerics; using System.Runtime.InteropServices; using Start.Shaders; // <RootNamespace>.Shaders // The SPIR-V of each entry point is embedded in the assembly. ReadOnlySpan<byte> vs = Sprite.VSMain.SpirV; ReadOnlySpan<byte> ps = Sprite.PSMain.SpirV; Console.WriteLine($"VSMain: {vs.Length} bytes of SPIR-V, PSMain: {ps.Length} bytes"); // The constant buffer as a C# struct, laid out like the shader's. var frame = new Sprite.Frame { ViewProjection = Matrix4x4.Identity, Tint = Vector4.One }; ReadOnlySpan<byte> upload = MemoryMarshal.AsBytes(new ReadOnlySpan<Sprite.Frame>(in frame)); Console.WriteLine($"Frame: {Sprite.Frame.Size} bytes, Tint at offset {Marshal.OffsetOf<Sprite.Frame>("Tint")}, {upload.Length} bytes to upload"); // Vertex input: one interleaved buffer. Console.WriteLine($"Vertex stride: {Sprite.VSMain.Stride}"); foreach (var a in Sprite.VSMain.Inputs) Console.WriteLine($" location {a.Location}: {a.Format} at offset {a.Offset}"); // Where each resource is bound. foreach (var b in Sprite.Bindings.All) Console.WriteLine($"{b.Name}: set {b.Set}, binding {b.Binding}, {b.Kind}, stages {b.Stages}");
dotnet run prints:
VSMain: 1272 bytes of SPIR-V, PSMain: 856 bytes Frame: 80 bytes, Tint at offset 64, 80 bytes to upload Vertex stride: 20 location 0: Float32x2 at offset 0 location 1: Float32x2 at offset 8 location 2: Unorm8x4 at offset 16 Frame: set 0, binding 0, ConstantBuffer, stages Vertex Atlas: set 0, binding 1, SampledTexture, stages Pixel AtlasSampler: set 0, binding 2, Sampler, stages Pixel
- Entry points come from the
[shader("vertex")],[shader("pixel")]and[shader("compute")]attributes. Each becomes a nested class withStage,EntryPointandSpirV, a span over bytes embedded in the assembly. Nothing is read from disk at run time. Sprite.Frameis a blittable struct with an explicit layout whose field offsets equal the compiled shader’s, so its bytes go to the GPU as they are. Vectors and matrices areSystem.Numericstypes. AMatrix4x4member’s element (i, j) is HLSL’sM[i][j].[[oneshader::format("unorm8x4")]]declares a packed vertex format: the buffer holds 4 bytes, the shader sees afloat4. That’s why the stride is 20, not 32. DXC compiles the same file with a warning that it ignores the attribute.- Without
[[vk::binding]]or a register, resources are numbered in declaration order. Layouts and bindings follow the rules DXC uses for Vulkan, so existing shaders keep theirs.
All the generated members are listed in Generated bindings.
Shader errors are build errors
Misspell Atlas in the pixel shader and build again:
/tmp/oneshader-docs/Start/Shaders/Sprite.hlsl(24,45): error OSH0300: 'Atlass' is not declared. Did you mean 'Atlas'? [/tmp/oneshader-docs/Start/Start.csproj]The error is on the HLSL line and column, with a stable OSHnnnn code, so the IDE jumps to it like a C# error. A shader is compiled again only when it, a file it includes, or the options change.
Add Metal and WebGPU
SPIR-V is the default. Select more targets in the project file:
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <OutputType>Exe</OutputType> <TargetFramework>net10.0</TargetFramework> <ImplicitUsings>enable</ImplicitUsings> <Nullable>enable</Nullable> <OneShaderTargets>SpirV;Msl;Wgsl</OneShaderTargets> </PropertyGroup> <ItemGroup> <PackageReference Include="RecursiveAI.OneShader.Build" Version="1.2.0" /> </ItemGroup> </Project>
With the same Shaders/Sprite.hlsl:
using System.Text; using Targets.Shaders; // WGSL for WebGPU and MSL for Metal, next to the SPIR-V. string wgsl = Encoding.UTF8.GetString(Sprite.PSMain.Wgsl); string msl = Encoding.UTF8.GetString(Sprite.PSMain.Msl); Console.WriteLine($"WGSL entry: {Sprite.PSMain.WgslEntryPoint}, MSL function: {Sprite.PSMain.MslFunctionName}"); Console.WriteLine($"Metal vertex buffer index: {Sprite.VSMain.MslVertexBufferIndex}"); Console.WriteLine($"Atlas: @group({Sprite.Bindings.Atlas.Set}) @binding({Sprite.Bindings.Atlas.Binding}), Metal texture({Sprite.Bindings.Atlas.MslIndex})"); Console.WriteLine(); Console.WriteLine(wgsl); File.WriteAllText("sprite.ps.metal", msl);
The start of the output:
WGSL entry: PSMain, MSL function: PSMain Metal vertex buffer index: 30 Atlas: @group(0) @binding(1), Metal texture(0) diagnostic(off, derivative_uniformity); struct PSMain_Output { @location(0) PSMain_result: vec4<f32>, } @group(0) @binding(1) var Atlas: texture_2d<f32>; @group(0) @binding(2) var AtlasSampler: sampler; ... @fragment fn PSMain(@builtin(position) in_PSMain_i_Position: vec4<f32>, @location(0) in_PSMain_i_Uv: vec2<f32>, @location(1) in_PSMain_i_Color: vec4<f32>) -> PSMain_Output { ...
- WGSL uses the Vulkan numbers as
@groupand@binding. Metal counts buffers, textures and samplers separately, soAtlasis texture 0. The interleaved vertex buffer is Metal buffer 30. - The constant-buffer and vertex structs are the same for every target: all three backends use one byte layout.
- A shader that a target can’t express fails the build with an error that names the target, never a silent miscompile. Clip distances on WGSL, for example, are error OSH5202. That’s why the default is SPIR-V only. A target you don’t select costs no build time.
- Clip-space Y points up in Metal and WebGPU and down in Vulkan. The shader isn’t adjusted, so flip it with the viewport.
Build pipeline layouts
OneShader.Layouts turns a shader into the layouts a renderer creates, as plain data with no graphics API behind it. In the project with all three targets:
using OneShader.Layouts; using OneShader.Layouts.Metal; using OneShader.Layouts.Vulkan; using OneShader.Layouts.WebGpu; using Layouts.Shaders; // The vertex and pixel entry points of Sprite.hlsl, merged. PipelineLayout pipeline = Sprite.Pipeline; // Vulkan: descriptor set layouts and vertex input, with the numeric Vk* enum values. PipelineLayoutInfo vulkan = pipeline.ToVulkan(); foreach (DescriptorSetLayoutInfo set in vulkan.SetLayouts) foreach (DescriptorSetLayoutBindingInfo b in set.Bindings) Console.WriteLine($"set {set.Set} binding {b.Binding}: {b.DescriptorType} ({(uint)b.DescriptorType}) x{b.DescriptorCount}, stages {b.StageFlags}"); VertexInputInfo input = Sprite.VSMain.Layout.Vertex!.ToVulkan(); foreach (VertexInputAttributeInfo a in input.Attributes) Console.WriteLine($"location {a.Location}: {a.Format} ({(uint)a.Format}) at {a.Offset}, stride {input.Bindings[0].Stride}"); // Metal: the argument index of each resource, per stage. if (pipeline.TryToMetal(out MetalPipelineLayout? metal, out var metalError)) foreach (MetalStageLayout stage in metal!.Stages) foreach (MetalArgument arg in stage.Arguments) Console.WriteLine($"Metal {stage.Stage}: {arg.Name} -> {arg.Kind}({arg.Index})"); else Console.WriteLine(metalError); // WebGPU: bind group layout entries, with the API's strings. if (pipeline.TryToWebGpu(out WebGpuPipelineLayout? webgpu, out var webgpuError)) foreach (BindGroupLayoutInfo group in webgpu!.Groups) foreach (BindGroupLayoutEntryInfo e in group.Entries) Console.WriteLine($"WebGPU group {group.Group} binding {e.Binding}: {e.Name}, {e.Kind}, visibility {e.Visibility}"); else Console.WriteLine(webgpuError);
set 0 binding 0: UniformBuffer (6) x1, stages Vertex set 0 binding 1: SampledImage (2) x1, stages Fragment set 0 binding 2: Sampler (0) x1, stages Fragment location 0: R32G32Sfloat (103) at 0, stride 20 location 1: R32G32Sfloat (103) at 8, stride 20 location 2: R8G8B8A8Unorm (37) at 16, stride 20 Metal Vertex: Frame -> Buffer(0) Metal Pixel: Atlas -> Texture(0) Metal Pixel: AtlasSampler -> Sampler(0) WebGPU group 0 binding 0: Frame, Buffer, visibility Vertex WebGPU group 0 binding 1: Atlas, Texture, visibility Fragment WebGPU group 0 binding 2: AtlasSampler, Sampler, visibility Fragment
- The numbers in parentheses are the values of
VkDescriptorTypeandVkFormat, ready to cast into whichever Vulkan binding you use. Sprite.Pipelineis the file’s vertex and pixel entry points (or its one compute entry point). For other combinations,PipelineLayout.TryCreatemerges any entry layouts and returns a conflict as aLayoutErrorvalue, never an exception.TryToMetalfails withMissingTargetwhenMslwasn’t built.TryToWebGpureports what WebGPU can’t express, such as resource arrays, with what to change.- Creating layouts allocates, so do it when a shader loads, not every frame.
Compile at run time
For tools, editors and hot reload, call the compiler directly. It’s the RecursiveAI.OneShader package (dotnet add package RecursiveAI.OneShader --version 1.2.0), namespace OneShader:
using OneShader; using OneShader.Reflection; CompileResult result = ShaderCompiler.CompileFile("Shaders/Sprite.hlsl", new CompileOptions { Targets = [CompileTarget.SpirV, CompileTarget.Wgsl], }); foreach (var d in result.Diagnostics) Console.WriteLine(d); // Shaders/Sprite.hlsl(24,45): error OSH0300: ... if (!result.Success) return 1; foreach (ShaderBlob blob in result.Blobs) Console.WriteLine($"{blob.EntryPoint} ({blob.Stage}) -> {blob.Target}: {blob.Data.Length} bytes"); File.WriteAllBytes("sprite.ps.spv", result.Find("PSMain", CompileTarget.SpirV)!.ToArray()); File.WriteAllText("sprite.ps.wgsl", result.Find("PSMain", CompileTarget.Wgsl)!.AsText()); // Reflection: what the shader binds, where, and in which stages. foreach (ResourceInfo r in result.Reflection!.Resources) Console.WriteLine($"{r.Name}: {r.Kind}, set {r.Slots[0].Set} binding {r.Slots[0].Binding}, {r.Stages}"); return 0;
VSMain (Vertex) -> SpirV: 1272 bytes VSMain (Vertex) -> Wgsl: 1640 bytes PSMain (Pixel) -> SpirV: 856 bytes PSMain (Pixel) -> Wgsl: 841 bytes Frame: ConstantBuffer, set 0 binding 0, Vertex Atlas: SampledTexture, set 0 binding 1, Pixel AtlasSampler: Sampler, set 0 binding 2, Pixel
- Errors come back as diagnostics with file, line and column, never as exceptions: bad input gives diagnostics, not an exception or a stack overflow.
ShaderCompiler.Compile(source, path, options)takes the text instead of a path. - Output is deterministic: the same source and options give the same bytes.
Compilecan be called from several threads at once. sprite.ps.spvpassesspirv-valfor Vulkan 1.2, andsprite.ps.wgslpasses naga.result.Fileslists every file the compile read, including#includes, for a file watcher. See Reload shaders while the app runs.
Compile from the command line
RecursiveAI.OneShader.Tool is the oneshader command, with DXC’s core flags:
dotnet tool install -g RecursiveAI.OneShader.Tool --version 1.2.0 oneshader -T ps_6_0 -E PSMain -spirv Sprite.hlsl -Fo sprite.ps.spv oneshader --all-entries --target spirv --target msl --target wgsl --reflect sprite.json Sprite.hlsl -Fo out/sprite
The second command writes sprite.VSMain.spv, .metal and .wgsl and the same three for PSMain, plus the reflection as JSON. The tool needs the .NET 10 runtime. Exit code 0 is success, 1 a shader that didn’t compile, 2 a wrong command line. See Switch from the DXC command line.
New in 1.2.0
- Shader variants in the build.
Axesmetadata on an item (such asALPHA_TESTandLIGHTING=NONE|LAMBERT|PBR) compiles every combination, and the generatedKeypicks one at run time. See Shader variants with Axes. - Specialization constants.
[[vk::constant_id(N)]] constvalues set when the pipeline is created, on Vulkan, Metal and WebGPU, with a generatedSpecializationstruct. See Specialization constants. - 16-bit and 64-bit types.
half,int16_t,double,uint64_tand 64-bit atomics, as DXC compiles them, with the device features each entry point needs. See 16-bit and 64-bit types. - A compile cache for run-time compiles. See Cache shader compiles.
The full list is in the changelog.
The packages
All three packages share one version number.
| Package | What it is | License |
|---|---|---|
RecursiveAI.OneShader.Build | MSBuild task and source generator: compiles *.hlsl during dotnet build, reports errors on the HLSL line, embeds the selected targets, builds shader variants and generates C# structs, bindings and layouts. Guide: the build package. Brings RecursiveAI.OneShader with it. | MIT or Apache-2.0 |
RecursiveAI.OneShader | The compiler: ShaderCompiler, reflection, InterfaceCompatibility for hot reload, ShaderCompileCache, pipeline layouts and specialization values (OneShader.Layouts), and the support types the generated code uses. Namespace OneShader. Guide: the compiler package. No unsafe code, NativeAOT and trimming supported. | MIT or Apache-2.0 |
RecursiveAI.OneShader.Tool | The oneshader command-line compiler, a .NET tool with DXC-style flags, for CMake, Make and scripts. Guide: the command line. | MIT or Apache-2.0 |
What OneShader 1.2.0 doesn’t do
- Other shader stages. Vertex, pixel and compute only: no geometry, tessellation, mesh or ray-tracing shaders.
- Direct3D bytecode. There’s no DXIL output. For Direct3D 12, keep DXC.
- GLSL. The input is HLSL and the outputs are SPIR-V, MSL and WGSL: GLSL is neither read nor written.
- Unbounded resource arrays (
Texture2D Tex[]) and the partitioned wave operationsWaveMatchandWaveMultiPrefix*are errors (OSH0350). Bounded arrays and the other wave and quad intrinsics work. - Every feature on every target. Some constructs exist on one target and not another:
double, for example, is SPIR-V only. The MSL and WGSL pages list them, and each one is a compile error naming the target.
Where next
Cookbook recipes, each with a complete, run sample:
- Compile HLSL to SPIR-V for Vulkan, convert HLSL to WGSL, compile HLSL to Metal Shading Language
- Compile HLSL in a C# project with dotnet build, match constant buffers with C# structs, push constants on every target
- Read shader reflection in C#, reload shaders while the app runs, shader variants with defines and includes
- Write a compute shader for Vulkan and WebGPU, wave intrinsics on Vulkan, Metal and WebGPU, InterlockedAdd and float atomics, the precise keyword
- Shader variants with Axes, specialization constants, 16-bit and 64-bit types, cache shader compiles
- Switch from the DXC command line
The reference docs:
- The overview and the package guides: build, compiler, command line
- Generated bindings and the reflection file format
- The backends: SPIR-V, Metal Shading Language, WGSL, and the intermediate representation they share
- How it’s checked and how fast it is: WebGPU execution parity, performance against DXC, and the changelog
Guides: HLSL vs GLSL and HLSL to SPIR-V compiler compared.