Recursive AI

Compiling HLSL shaders in a C# project

Four ways to compile HLSL in a .NET project: dxc from MSBuild, Vortice.Dxc, OneShader’s build package or its runtime compiler, with verified projects.

A C# project can compile HLSL shaders in four ways: run Microsoft’s dxc from an MSBuild target, call DXC at run time through a binding such as Vortice.Dxc, or use a shader compiler written in .NET, OneShader, either as a build package or as a library. The first two need DXC’s native binaries on every machine that builds or runs the code. OneShader is managed code that runs inside dotnet build, and it also generates the C# structs that have to match the shader.

Each project below was built and run with the .NET 10 SDK on Linux, against DXC from the LunarG Vulkan SDK 1.4.363.0 and the OneShader 1.2.0 packages. All of them compile the same compute shader, a particle update:

Shaders/Particles.hlslHLSL
struct Particle { float3 Position; float Age; float3 Velocity; float Radius; };
 
cbuffer SimParams : register(b0)
{
    float  DeltaTime;
    float3 Gravity;
    uint   Count;
};
 
RWStructuredBuffer<Particle> Particles : register(u1);
 
[shader("compute")]
[numthreads(256, 1, 1)]
void Simulate(uint3 id : SV_DispatchThreadID)
{
    if (id.x >= Count)
        return;
    Particle p = Particles[id.x];
    p.Velocity += Gravity * DeltaTime;
    p.Position += p.Velocity * DeltaTime;
    p.Age += DeltaTime;
    Particles[id.x] = p;
}

The options at a glance

ApproachWhen it compilesNative code neededOutputsC# for buffers and bindings
dxc from an MSBuild Exec targetBuildThe dxc binary on every build machine (DXC releases ship Windows and Linux)DXIL, SPIR-VWritten by hand
Vortice.Dxc 3.8.3 (MIT)Run timedxcompiler and dxil through Vortice.Dxc.Native 1.0.6, for linux-x64, win-x64 and win-arm64DXC’s; whether the bundled build includes SPIR-V output is not documentedWritten by hand
RecursiveAI.OneShader.Build 1.2.0 (MIT OR Apache-2.0)BuildNoneSPIR-V, Metal Shading Language, WGSLGenerated from the compiled shader
RecursiveAI.OneShader 1.2.0 (MIT OR Apache-2.0)Run timeNoneSPIR-V, Metal Shading Language, WGSLReflection at run time

The native files are from each package’s file list on NuGet. Vortice.Dxc describes itself as “DirectXShaderCompiler (DXC) bindings”: the compiling is done by DXC’s own native library, shipped in Vortice.Dxc.Native. OneShader compiles vertex, pixel and compute shaders, and doesn’t write DXIL.

Two packages often found in the same search do other jobs. ComputeSharp 3.2.0 runs “C# code in parallel on the GPU through DX12 and dynamically generated HLSL compute shaders”, so it goes from C# to HLSL, the other direction. Veldrid.SPIRV 1.0.15, last published in June 2022, wraps SPIRV-Cross to “cross-compile SPIR-V bytecode into HLSL, GLSL, ESSL, and MSL”. It starts from SPIR-V, not from HLSL.

Run dxc from MSBuild

The lowest-dependency way to use DXC is a target that runs it on each shader before the build, with Inputs and Outputs so that unchanged shaders are skipped:

DxcExec.csproj.NET 10 SDK · dxc on PATH
<Project Sdk="Microsoft.NET.Sdk">
 
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>
 
  <ItemGroup>
    <Shader Include="Shaders/Particles.hlsl" Profile="cs_6_0" Entry="Simulate" />
  </ItemGroup>
 
  <!-- Compile each shader with dxc when it changed, and copy the .spv next to the app. -->
  <Target Name="CompileShaders" BeforeTargets="BeforeBuild" Inputs="@(Shader)" Outputs="@(Shader->'$(IntermediateOutputPath)%(Filename).spv')">
    <MakeDir Directories="$(IntermediateOutputPath)" />
    <Exec Command="dxc -spirv -T %(Shader.Profile) -E %(Shader.Entry) &quot;%(Shader.FullPath)&quot; -Fo &quot;$(IntermediateOutputPath)%(Shader.Filename).spv&quot;" />
    <ItemGroup>
      <None Include="$(IntermediateOutputPath)%(Shader.Filename).spv" CopyToOutputDirectory="PreserveNewest" Link="%(Shader.Filename).spv" />
    </ItemGroup>
  </Target>
 
</Project>
Program.cs.NET 10
byte[] spirv = File.ReadAllBytes(Path.Combine(AppContext.BaseDirectory, "Particles.spv"));
Console.WriteLine($"Particles.spv: {spirv.Length} bytes, magic 0x{BitConverter.ToUInt32(spirv, 0):x8}");
Output
Particles.spv: 1732 bytes, magic 0x07230203

It works, and the output passes spirv-val. MSBuild even recognizes DXC’s file(line,col): error lines, so a typo shows up on the HLSL line, with a generated code and a second error for the failed command:

dotnet builda typo in Particles.hlsl
/tmp/DxcExec/DxcExec.csproj(17,5): error MSB3075: The command "dxc -spirv -T cs_6_0 -E Simulate "/tmp/DxcExec/Shaders/Particles.hlsl" -Fo "obj/Debug/net10.0/Particles.spv"" exited with code 5. Please verify that you have sufficient rights to run this command.
/tmp/DxcExec/Shaders/Particles.hlsl(19,19): error GF494D608: use of undeclared identifier 'Gravty'; did you mean 'Gravity'? [/tmp/DxcExec/DxcExec.csproj]

What it leaves to you: a dxc binary on every developer machine and CI machine (there is no macOS build in the DXC release), a convention for profiles and entry points per file, loading the .spv files at run time, and a C# struct for SimParams that matches the shader’s constant buffer packing.

Compile at run time with Vortice.Dxc

Vortice.Dxc exposes DXC’s compiler API to C#, so an app can compile shaders while it runs, for an editor or hot reload. Its package depends on Vortice.Dxc.Native, which carries libdxcompiler.so and libdxil.so for linux-x64 and dxcompiler.dll and dxil.dll for win-x64 and win-arm64. Those binaries ship with your app, and the app runs on those platforms. The DXC API itself is described in the Vulkan Guide’s runtime compilation section. Vortice.Dxc’s README and NuGet page don’t include a usage example, so none is shown here.

Compile HLSL during dotnet build with OneShader

RecursiveAI.OneShader.Build compiles every *.hlsl file in the project during dotnet build. The compiler is a .NET task, so there is nothing to install and nothing native to ship. The project file only needs the package reference:

Fountain.csproj.NET 10
<Project Sdk="Microsoft.NET.Sdk">
 
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>
 
  <ItemGroup>
    <PackageReference Include="RecursiveAI.OneShader.Build" Version="1.2.0" />
  </ItemGroup>
 
</Project>

For each shader file, the build generates a static class in <RootNamespace>.Shaders. It holds the SPIR-V embedded in the assembly, a struct for each constant buffer and structured-buffer element with the offsets of the compiled shader, the thread-group size, and the descriptor bindings as constants:

Program.csRecursiveAI.OneShader.Build 1.2.0
using System.Numerics;
using System.Runtime.InteropServices;
using Fountain.Shaders;
 
// The SPIR-V was compiled during dotnet build and is embedded in the assembly.
ReadOnlySpan<byte> spirv = Particles.Simulate.SpirV;
Console.WriteLine($"Simulate: {spirv.Length} bytes of SPIR-V");
 
// The constant buffer as a struct with the shader's offsets: upload its bytes as they are.
var sim = new Particles.SimParams { DeltaTime = 1f / 60, Gravity = new Vector3(0, -9.81f, 0), Count = 10_000 };
Console.WriteLine($"SimParams: {Marshal.SizeOf<Particles.SimParams>()} bytes, Gravity at {Marshal.OffsetOf<Particles.SimParams>("Gravity")}, Count at {Marshal.OffsetOf<Particles.SimParams>("Count")}");
 
// The dispatch size and the descriptor bindings, as constants.
uint groups = (sim.Count + Particles.Simulate.ThreadGroupSizeX - 1) / Particles.Simulate.ThreadGroupSizeX;
Console.WriteLine($"Dispatch({groups}, 1, 1) for {sim.Count} particles");
Console.WriteLine($"SimParams at set {Particles.Bindings.SimParams.Set}, binding {Particles.Bindings.SimParams.Binding}");
Console.WriteLine($"Particles at set {Particles.Bindings.Particles.Set}, binding {Particles.Bindings.Particles.Binding}");
Console.WriteLine($"Particle: {Marshal.SizeOf<Particles.Particle>()} bytes per element");
Output
Simulate: 2116 bytes of SPIR-V
SimParams: 32 bytes, Gravity at 4, Count at 16
Dispatch(40, 1, 1) for 10000 particles
SimParams at set 0, binding 0
Particles at set 0, binding 1
Particle: 32 bytes per element

Gravity is at offset 4, not 16. HLSL packs a float3 into the rest of the 16-byte row after DeltaTime, and Count starts the next row. The generated struct follows the shader, so the bytes you upload are the bytes the shader reads. If the shader changes, the struct changes with it on the next build. A typo is a build error on the HLSL line, with a stable code:

dotnet builda typo in Particles.hlsl
/tmp/Fountain/Shaders/Particles.hlsl(19,19): error OSH0300: 'Gravty' is not declared. Did you mean 'Gravity'? [/tmp/Fountain/Fountain.csproj]

To also get Metal Shading Language and WGSL, set <OneShaderTargets>SpirV;Msl;Wgsl</OneShaderTargets>, and each entry point gains Msl and Wgsl members. The compile HLSL in a C# project recipe covers includes, defines, namespaces and per-file targets, and generated bindings lists everything the build writes. The build task needs the .NET SDK’s dotnet build: Visual Studio’s .NET Framework MSBuild can’t load it.

Compile HLSL at run time with OneShader

For tools, editors and hot reload, RecursiveAI.OneShader is the same compiler as a library:

Program.csRecursiveAI.OneShader 1.2.0
using OneShader;
 
CompileResult result = ShaderCompiler.CompileFile("Shaders/Particles.hlsl", new CompileOptions
{
    Targets = [CompileTarget.SpirV, CompileTarget.Wgsl],
});
foreach (var d in result.Diagnostics)
    Console.WriteLine(d);   // file(line,col): error OSHnnnn: message
if (!result.Success)
    return 1;
 
foreach (ShaderBlob blob in result.Blobs)
    Console.WriteLine($"{blob.EntryPoint} {blob.Target}: {blob.Data.Length} bytes");
return 0;
Output
Simulate SpirV: 2116 bytes
Simulate Wgsl: 1888 bytes

A failed compile returns diagnostics in the same file(line,col): error OSHnnnn form rather than throwing. result.Reflection describes every resource, buffer layout and entry point, and InterfaceCompatibility.Compare tells a hot-reload loop whether the new shader still fits the C# that was generated for the old one (hot reload recipe).

Which one to use

  • Keep DXC (from MSBuild or through Vortice.Dxc) if you need DXIL for Direct3D 12, or geometry, tessellation, mesh or ray-tracing shaders.
  • Use OneShader’s build package for vertex, pixel and compute shaders that should compile with the rest of the project on Windows, Linux and macOS, with C# structs and bindings that can’t drift from the shader, and Vulkan, Metal and WebGPU output from one source.
  • Use OneShader’s runtime compiler for editors, asset pipelines and hot reload, where shipping a native DXC per platform is the cost you want to avoid.

Common questions

Can I compile HLSL without Windows?

Yes. DXC has Linux release builds, and OneShader runs anywhere the .NET 10 SDK or runtime runs, including macOS. FXC, the older fxc.exe, is for Shader Models 2 through 5.1: “For Shader Model 6, you use DXC.EXE instead” (Microsoft Learn).

How do I share a struct between HLSL and C#?

Give the C# struct an explicit layout with the shader’s offsets, or let the build generate it. The rules are in HLSL constant buffer packing rules, and the generated version is in the constant buffer structs recipe.

To start, run dotnet add package RecursiveAI.OneShader.Build, add a Shaders/*.hlsl file and build. Getting started walks through a first sprite shader, and the OneShader overview shows everything the compiler does.