Recursive AI

Debug HLSL in RenderDoc, Nsight and Xcode

All oneshader recipes

Build HLSL shaders with source-level debug information, so RenderDoc and Nsight step through your .hlsl lines with their variables and Xcode maps Metal back to HLSL. Debug builds only, from dotnet build, the compiler API or the command line.

A GPU debugger can show only what the shader binary records. Without debug information you step through disassembly. Since 1.3.0, OneShader writes what DXC writes for the same job. SPIR-V gets either the HLSL source with line numbers (DXC’s -Zi) or the full NonSemantic.Shader.DebugInfo.100 records that RenderDoc and Nsight read (DXC’s -fspv-debug=vulkan-with-source). Metal gets #line directives. WGSL is unchanged. It’s off by default, and then every blob is byte for byte what it was before.

In Debug builds only

The shader includes a file, to show that both files reach the debugger. Shaders/Common/Color.hlsli:

HLSL
float3 Aces(float3 x)
{
    float3 a = x * (2.51 * x + 0.03);
    float3 b = x * (2.43 * x + 0.59) + 0.14;
    return saturate(a / b);
}

Shaders/Tonemap.hlsl:

HLSL
#include "Common/Color.hlsli"
 
cbuffer Params : register(b0) { float Exposure; };
Texture2D<float4> Scene : register(t1);
SamplerState Linear : register(s2);
 
[shader("pixel")]
float4 PSMain(float4 position : SV_Position, float2 uv : TEXCOORD0) : SV_Target
{
    float3 hdr = Scene.Sample(Linear, uv).rgb * Exposure;
    float3 ldr = Aces(hdr);
    return float4(ldr, 1);
}

The project file turns debug information on and the optimizer off for the Debug configuration:

XML
<Project Sdk="Microsoft.NET.Sdk">
 
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <OneShaderTargets>SpirV;Msl</OneShaderTargets>
  </PropertyGroup>
 
  <!-- Debug builds: HLSL source and lines in the blobs, optimizer off. -->
  <PropertyGroup Condition="'$(Configuration)' == 'Debug'">
    <OneShaderDebugInfo>true</OneShaderDebugInfo>
    <OneShaderOptimize>false</OneShaderOptimize>
  </PropertyGroup>
 
  <ItemGroup>
    <PackageReference Include="RecursiveAI.OneShader.Build" Version="1.4.0" />
  </ItemGroup>
 
</Project>

OneShaderDebugInfo takes true (full records), lines, or false. Item metadata DebugInfo and Optimize set them for one file. Program.cs writes out the embedded blobs and compiles the same file at run time at each level:

C#
using OneShader;
using Debug.Shaders;
 
// What the build embedded: with debug information in Debug, without in Release.
Console.WriteLine($"PSMain: {Tonemap.PSMain.SpirV.Length} bytes of SPIR-V, {Tonemap.PSMain.Msl.Length} bytes of MSL");
File.WriteAllBytes("tonemap.ps.spv", Tonemap.PSMain.SpirV.ToArray());
File.WriteAllBytes("tonemap.ps.metal", Tonemap.PSMain.Msl.ToArray());
 
// The same at run time, at each level.
foreach (var level in new[] { DebugInfoLevel.None, DebugInfoLevel.Lines, DebugInfoLevel.Full })
{
    CompileResult r = ShaderCompiler.CompileFile("Shaders/Tonemap.hlsl", new CompileOptions
    {
        DebugInfo = level,
        DebugSourceRoot = Directory.GetCurrentDirectory(),   // file names relative to the project
    });
    Console.WriteLine($"{level,-5}: {r.Find("PSMain", CompileTarget.SpirV)!.Data.Length} bytes");
}

dotnet run (Debug):

Text
PSMain: 7640 bytes of SPIR-V, 2943 bytes of MSL
None : 1696 bytes
Lines: 2556 bytes
Full : 4900 bytes

dotnet run -c Release:

Text
PSMain: 1696 bytes of SPIR-V, 1541 bytes of MSL
None : 1696 bytes
Lines: 2556 bytes
Full : 4900 bytes
  • The Release blob is the same size as None, and the same bytes: switching the property off gives exactly the shaders you had before. The generated C# doesn’t change either, apart from the blob bytes.
  • The Debug blob is the largest because the optimizer is off as well. That keeps every local in memory, where the debugger can show it, and gives one step per statement. With the optimizer on (the run-time Full), line information survives every pass, but locals that end up in registers aren’t shown.

The Debug tonemap.ps.spv passes spirv-val --target-env vulkan1.2. spirv-dis shows the records the debuggers read, with both files under names relative to the project:

Text
               OpExtension "SPV_KHR_non_semantic_info"
          %1 = OpExtInstImport "NonSemantic.Shader.DebugInfo.100"
          %3 = OpString "Shaders/Tonemap.hlsl"
          %6 = OpString "Shaders/Common/Color.hlsli"
         %78 = OpExtInst %void %1 DebugLine %8 %uint_1 %uint_1 %uint_13 %uint_21
         %84 = OpExtInst %void %1 DebugLine %8 %uint_3 %uint_3 %uint_12 %uint_37

Each file’s full text is embedded too (DebugSource), so the debugger shows the source without finding the files on disk. Names are relative to the project directory, never absolute, so a copy of the project in another folder builds the same bytes.

In RenderDoc

  1. Run the Debug build and capture a frame with RenderDoc.
  2. Select a draw, open the pixel shader in the pipeline state, and choose Debug this Pixel (or Debug this Vertex, or the compute thread debugger).
  3. The source view shows Tonemap.hlsl and Color.hlsli. Stepping follows the HLSL lines, into Aces and back, and hdr, ldr, a and b are in the variables view.

Pass the blob to Vulkan as it is. A pipeline cache or a spirv-opt --strip-debug step that removes the debug records also removes what RenderDoc reads. Nsight Graphics reads the same records.

On Metal and in Xcode

The MSL has #line directives that point at the HLSL:

Text
#line 10 "Shaders/Tonemap.hlsl"
    float2 v31 = uv_2;
    float4 v32 = Scene.sample(Linear, v31);
    float3 v33 = v32.xyz;
    float v34 = Params.Exposure;
    float3 v35 = float3(v34);
    float3 v36 = v33 * v35;
    hdr = v36;
#line 11 "Shaders/Tonemap.hlsl"
    float3 v37 = hdr;
    float3 v38 = Aces(v37);
    ldr = v38;

Compile it with xcrun metal -gline-tables-only -frecord-sources, and Xcode’s shader debugger shows the HLSL lines. Errors that Apple’s compiler reports then point at HLSL lines too, so compile without debug information to see them in the MSL.

From the command line

oneshader takes DXC’s flags for this:

Terminal
oneshader -T ps_6_0 -E PSMain -fspv-debug=vulkan-with-source -Od Shaders/Tonemap.hlsl -Fo tonemap.spv
oneshader -T ps_6_0 -E PSMain -Zi Shaders/Tonemap.hlsl -Fo tonemap.spv

The first gives the same bytes as the Debug build above. The second writes DXC’s -Zi form, the sources with OpLine. File names are relative paths as given; --debug-root <dir> makes them relative to <dir>, and any other absolute path becomes a bare file name. Unlike DXC, -Fd isn’t supported: the debug information is always inside the output.

Checking. The samples here ran on Linux. Their SPIR-V was checked with spirv-val and read with spirv-dis, but no frame was captured, and Apple’s compiler isn’t available there. The RenderDoc and Xcode steps follow the repository’s walkthrough, and its test suite validates the debug output of its whole corpus, optimized and not.

The levels, the switches and what the optimizer keeps are in Debugging shaders. Every flag is in Switch from the DXC command line.