---
title: "Debug HLSL in RenderDoc, Nsight and Xcode"
url: https://recursiveai.org/oneshader/cookbook/debug-hlsl-renderdoc/
description: "Build HLSL shaders with source-level debug information, so RenderDoc and Nsight step through your .hlsl lines with their variables and Xcode maps Metal…"
version: "1.4.0"
---

# Debug HLSL in RenderDoc, Nsight and Xcode

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:

```csharp
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:

```sh
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](https://recursiveai.org/oneshader/docs/debugging/). Every flag is in [Switch from the DXC command line](https://recursiveai.org/oneshader/cookbook/migrate-from-dxc/).
