Compile HLSL shaders as part of dotnet build in a C# project, with shader errors reported as build errors on the HLSL line, shared include files, defines, a custom namespace and per-file targets.
Reference RecursiveAI.OneShader.Build and every *.hlsl file in the project is compiled on build into SPIR-V embedded in the assembly, next to generated C# for its constant buffers, bindings and entry points. This recipe shows the project-level settings. Getting started shows the generated code itself.
The project
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <OutputType>Exe</OutputType> <TargetFramework>net10.0</TargetFramework> <ImplicitUsings>enable</ImplicitUsings> <Nullable>enable</Nullable> <OneShaderNamespace>Game.Rendering.Shaders</OneShaderNamespace> <OneShaderIncludeDirectories>Shaders/Common</OneShaderIncludeDirectories> <OneShaderDefines>RADIUS=4</OneShaderDefines> </PropertyGroup> <ItemGroup> <PackageReference Include="RecursiveAI.OneShader.Build" Version="1.2.0" /> </ItemGroup> <ItemGroup> <!-- Every *.hlsl is compiled. Listing one gives it metadata: this one also ships to the web build. --> <OneShader Include="Shaders/Present.hlsl" Targets="SpirV;Wgsl" /> </ItemGroup> </Project>
Shaders/Common/Blur.hlsli, shared by two variants:
Texture2D<float4> Source : register(t0); SamplerState Linear : register(s1); cbuffer Blur : register(b2) { float2 TexelSize; }; [shader("pixel")] float4 PSMain(float2 uv : TEXCOORD0) : SV_Target { float2 step = HORIZONTAL ? float2(TexelSize.x, 0) : float2(0, TexelSize.y); float4 sum = 0; [unroll] for (int i = -RADIUS; i <= RADIUS; i++) sum += Source.Sample(Linear, uv + step * i); return sum / (2 * RADIUS + 1); }
Shaders/BlurH.hlsl:
#define HORIZONTAL 1 #include "Blur.hlsli"
Shaders/BlurV.hlsl:
#define HORIZONTAL 0 #include "Blur.hlsli"
Shaders/Present.hlsl, a full-screen triangle:
Texture2D<float4> Frame : register(t0); SamplerState Point : register(s1); struct VSOut { float4 Position : SV_Position; float2 Uv : TEXCOORD0; }; [shader("vertex")] VSOut VSMain(uint id : SV_VertexID) { VSOut o; o.Uv = float2((id << 1) & 2, id & 2); o.Position = float4(o.Uv * float2(2, -2) + float2(-1, 1), 0, 1); return o; } [shader("pixel")] float4 PSMain(VSOut i) : SV_Target { return Frame.Sample(Point, i.Uv); }
Program.cs:
using Game.Rendering.Shaders; Console.WriteLine($"BlurH.PSMain: {BlurH.PSMain.SpirV.Length} bytes of SPIR-V"); Console.WriteLine($"BlurV.PSMain: {BlurV.PSMain.SpirV.Length} bytes of SPIR-V"); Console.WriteLine($"Blur constant buffer: {BlurH.Blur.Size} bytes at binding {BlurH.Bindings.Blur.Binding}"); Console.WriteLine($"Present.VSMain: {Present.VSMain.SpirV.Length} bytes of SPIR-V, {Present.VSMain.Wgsl.Length} bytes of WGSL");
BlurH.PSMain: 1580 bytes of SPIR-V BlurV.PSMain: 1580 bytes of SPIR-V Blur constant buffer: 16 bytes at binding 2 Present.VSMain: 836 bytes of SPIR-V, 740 bytes of WGSL
An error in an included file
Misspell step in Blur.hlsli and dotnet build stops on the include’s own line:
/tmp/oneshader-docs/cookbook/Build/Shaders/Common/Blur.hlsli(11,43): error OSH0300: 'stepp' is not declared. Did you mean 'step'? [/tmp/oneshader-docs/cookbook/Build/Build.csproj]Good to know
- Default items. Every
*.hlslin the project is aOneShaderitem..hlslifiles aren’t, so they’re only compiled where they’re included. To give one file metadata, list it with<OneShader Include="...">, as above: since 1.2.0 the default glob skips files that are already listed.<OneShader Update="...">doesn’t work, because the package adds the default items after your project file. SettingEnableDefaultOneShaderItemstofalseturns the glob off. - One class per file. The class is named after the file in PascalCase, in
$(OneShaderNamespace)(default<RootNamespace>.Shaders). TwoOneShaderitems for the same file with differentClassNameandDefinesgive only one class, without a warning. For variants of one source, useAxesorOneShaderVariantitems (Shader variants with Axes), or give each variant its own small.hlslfile, as above. - Item metadata, all optional:
EntryPoints(VSMain:Vertex;PSMain:Pixel) (instead of the[shader]attributes),Defines="A;B=2",IncludeDirectories,Namespace,ClassName,Targets,Enable16BitTypes,Axes,MaxVariants. Project properties:OneShaderNamespace,OneShaderTargets(defaultSpirV),OneShaderDefines,OneShaderIncludeDirectories,OneShaderEnable16BitTypes,OneShaderMaxVariants. - Includes are searched in the including file’s folder first, then in the include directories.
- Name clashes are errors. A
cbuffernamed like its file (cbuffer LightinginLighting.hlsl) is error OSH0951, because the struct and the class would share a name. - Incremental. A shader is compiled again only when it, a file it includes, the options or the compiler change. The output is deterministic.
dotnet build -v:dlogs the count; after the typo above was fixed it wasOneShader: 2 compiled, 1 up to date.(the two blur files share the include). - The build package doesn’t flow to projects that reference yours: only projects with shaders reference it. The support types live in
RecursiveAI.OneShader, so a rendering layer in another assembly can take them. dotnet buildonly. Visual Studio’s .NET Framework MSBuild can’t load the task.
Every generated member is listed in Generated bindings. For variants compiled at run time instead, see Shader variants with defines and includes.
The options compared, dxc, Vortice.Dxc and OneShader: Compile HLSL shaders in C#.