---
title: "Converting HLSL to WGSL for WebGPU"
url: https://recursiveai.org/oneshader/articles/hlsl-to-wgsl-converter/
description: "Convert HLSL to WGSL for WebGPU through DXC and naga, or directly with OneShader: commands, where each path breaks, push constants, atomics and validation."
date: 2026-10-26
author: OneShader team
---

# Converting HLSL to WGSL for WebGPU

Convert HLSL to WGSL for WebGPU through DXC and naga, or directly with OneShader: commands, where each path breaks, push constants, atomics and validation.

October 26, 2026 · OneShader team

There are two ways to convert HLSL to WGSL for WebGPU. One is to compile it to SPIR-V with DXC and translate the SPIR-V to WGSL with naga, the shader translator of the wgpu project. The other is to compile HLSL to WGSL directly with OneShader. Both work on simple shaders. They part ways on constant buffers with arrays, push constants and some atomics, and there each one fails differently. This page shows both on the same shaders, with every result checked by naga’s validator.

Versions used: DXC 1.9.0.5512 (Vulkan SDK 1.4.363.0), naga-cli 30.0.1 and [OneShader 1.2.0](https://www.nuget.org/packages/RecursiveAI.OneShader.Tool/1.2.0). naga’s README lists SPIR-V, WGSL and GLSL as its inputs and WGSL among its outputs, with no HLSL input ([naga README](https://github.com/gfx-rs/wgpu/tree/trunk/naga)), which is why its path goes through SPIR-V.

## Path 1: DXC, then naga

The sprite shader from OneShader’s getting-started guide, with a tint added to the constant buffer:

*Sprite.hlsl · 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; };
struct VSOut { float4 Position : SV_Position; float2 Uv : TEXCOORD0; };
 
[shader("vertex")]
VSOut VSMain(VSIn i)
{
    VSOut o;
    o.Position = mul(ViewProjection, float4(i.Position, 0, 1));
    o.Uv = i.Uv;
    return o;
}
 
[shader("pixel")]
float4 PSMain(VSOut i) : SV_Target { return Atlas.Sample(AtlasSampler, i.Uv) * Tint; }
```

*Terminal · DXC 1.9.0.5512 · naga 30.0.1*

```
dxc -spirv -T ps_6_0 -E PSMain Sprite.hlsl -Fo sprite.ps.spv
naga sprite.ps.spv sprite.ps.wgsl
naga sprite.ps.wgsl
```

*Output*

```
Validation successful
```

That works, and naga’s WGSL is easy to read. It keeps the constant buffer as a WGSL struct with named members. Install naga with `cargo install naga-cli`.

## Path 2: HLSL to WGSL with OneShader

*Terminal · OneShader 1.2.0*

```
oneshader -T ps_6_0 -E PSMain --target wgsl Sprite.hlsl -Fo sprite.ps.wgsl
naga sprite.ps.wgsl
```

*Output*

```
Validation successful
```

One step, one tool (`dotnet tool install -g RecursiveAI.OneShader.Tool`), and the same bindings as its SPIR-V and Metal output: `@group(set) @binding(binding)` equals the Vulkan set and binding. The WGSL is less readable than naga’s. Constant buffers are arrays of 16-byte rows, and every read computes its offset from the shader’s reflected layout. That choice is what makes the next section work.

## Where the SPIR-V path breaks: arrays in constant buffers

HLSL stores each element of an array in a constant buffer on its own 16-byte row, and DXC’s SPIR-V records that as `ArrayStride 16`. Here is a constant buffer with a `float Weights[3]` (the full packing rules are in [HLSL constant buffer packing rules](https://recursiveai.org/oneshader/articles/hlsl-constant-buffer-packing-rules/)):

*Packing.hlsl · HLSL*

```
struct Light { float3 Direction; float Intensity; };
 
cbuffer Scene : register(b0)
{
    float3   SunColor;     // fills x, y, z of row 0
    float    Exposure;     // packs into w of row 0
    float2   Jitter;       // row 1
    float3   FogColor;     // would straddle row 1, so moves to row 2
    float    Weights[3];   // each element starts a row: rows 3, 4, 5
    float    AfterArray;   // where this lands differs between D3D and Vulkan
    Light    Sun;          // a struct starts a new row
    float    AfterStruct;
    float3x3 Rotation;     // 3 rows of 16 bytes, column-major
    float    Last;
};
 
[shader("pixel")]
float4 main() : SV_Target
{
    return float4(SunColor * Exposure + FogColor * Jitter.x + Sun.Direction * Weights[2] + Rotation[0],
                  AfterArray + AfterStruct + Sun.Intensity + Last);
}
```

*Terminal · DXC 1.9.0.5512 · naga 30.0.1*

```
dxc -spirv -T ps_6_0 -E main Packing.hlsl -Fo packing.spv
spirv-val --target-env vulkan1.2 packing.spv
naga packing.spv packing.wgsl
naga packing.wgsl
```

*Output · exit code 255*

```
error: Global variable [0] 'Scene' is invalid
   ┌─ packing.wgsl:20:1
   │
20 │ var<uniform> Scene: type_u002e_Scene;
   │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ naga::ir::GlobalVariable [0]
   │
   = Alignment requirements for address space Uniform are not met by [4]
   = The array stride 4 is not a multiple of the required alignment 16
```

The SPIR-V is valid, but naga translates the array to `array<f32, 3>` with a 4-byte stride, and then its own validator rejects the result. WGSL requires 16-byte-aligned array elements in the uniform address space unless the implementation supports the `uniform_buffer_standard_layout` language extension ([WGSL](https://www.w3.org/TR/WGSL/), address space layout constraints). Even where that extension exists, a 4-byte stride would read `Weights[1]` and `Weights[2]` from the wrong bytes of a buffer laid out for HLSL. OneShader computes each offset from the HLSL layout, so the same file comes out valid:

*Terminal · OneShader 1.2.0*

```
oneshader -T ps_6_0 -E main --target wgsl Packing.hlsl -Fo packing.wgsl
naga packing.wgsl
```

*Output*

```
Validation successful
```

The buffer you upload is byte for byte the one the SPIR-V and Metal versions read, which the OneShader repository checks for every scalar, vector and matrix of its test corpus ([WGSL backend](https://recursiveai.org/oneshader/docs/wgsl-backend/)).

## Push constants on WebGPU

Core WebGPU has no push constants, while HLSL written for Vulkan often uses `[[vk::push_constant]]`:

*Push.hlsl · HLSL*

```
struct DrawData { float4x4 Model; uint MaterialIndex; };
[[vk::push_constant]] DrawData Draw;
 
[shader("vertex")]
float4 VSMain(float3 p : POSITION) : SV_Position { return mul(Draw.Model, float4(p, 1)) + Draw.MaterialIndex * 0; }
```

*Terminal · DXC 1.9.0.5512 · naga 30.0.1*

```
dxc -spirv -T vs_6_0 -E VSMain Push.hlsl -Fo push.spv
naga push.spv push.wgsl
grep -n "var<" push.wgsl
```

*Output*

```
6:var<immediate> Draw: type_u002e_PushConstant_u002e_DrawData;
7:var<private> in_u002e_var_u002e_POSITION_1: vec3<f32>;
8:var<private> global: vec4<f32> = vec4<f32>(0f, 0f, 0f, 1f);
```

naga writes `var<immediate>`. The current WGSL draft defines that as the `immediate_address_space` language extension, “allowing variables to be declared with var<immediate>”, which is available only where an implementation supports it. OneShader writes the block as an ordinary uniform buffer at a binding that can’t collide with the shader’s own, and tells you where. From C#:

*Shaders/Mesh.hlsl · HLSL*

```
struct DrawData { float4x4 Model; float4 Color; };
[[vk::push_constant]] DrawData Draw;
 
cbuffer Camera : register(b0) { float4x4 ViewProjection; };
[[vk::binding(1)]] Texture2D<float4> Albedo;
[[vk::binding(2)]] SamplerState LinearWrap;
 
struct VSOut { float4 Position : SV_Position; float2 Uv : TEXCOORD0; };
 
[shader("vertex")]
VSOut VSMain(float3 position : POSITION, float2 uv : TEXCOORD0)
{
    VSOut o;
    o.Position = mul(ViewProjection, mul(Draw.Model, float4(position, 1)));
    o.Uv = uv;
    return o;
}
 
[shader("pixel")]
float4 PSMain(VSOut i) : SV_Target { return Albedo.Sample(LinearWrap, i.Uv) * Draw.Color; }
```

*Program.cs · RecursiveAI.OneShader 1.2.0*

```
using OneShader;
using OneShader.Layouts;
using OneShader.Layouts.WebGpu;
 
CompileResult result = ShaderCompiler.CompileFile("Shaders/Mesh.hlsl", new CompileOptions { Targets = [CompileTarget.Wgsl] });
foreach (var d in result.Diagnostics) Console.WriteLine(d);
if (!result.Success) return 1;
 
File.WriteAllText("mesh.vs.wgsl", result.Find("VSMain", CompileTarget.Wgsl)!.AsText());
File.WriteAllText("mesh.fs.wgsl", result.Find("PSMain", CompileTarget.Wgsl)!.AsText());
 
// One layout for the vertex and fragment entry points, as WebGPU bind group layouts.
EntryLayout.TryCreate(result.Reflection!, "VSMain", out EntryLayout? vs, out _);
EntryLayout.TryCreate(result.Reflection!, "PSMain", out EntryLayout? fs, out _);
if (!PipelineLayout.Create(vs!, fs!).TryToWebGpu(out WebGpuPipelineLayout? layout, out LayoutError? error))
{
    Console.WriteLine(error);
    return 1;
}
 
foreach (BindGroupLayoutInfo group in layout!.Groups)
    foreach (BindGroupLayoutEntryInfo e in group.Entries)
        Console.WriteLine($"group {group.Group} binding {e.Binding}: {e.Name} ({e.Kind}, visibility {e.Visibility})");
 
// WebGPU has no push constants: the block is a uniform buffer the host binds.
foreach (PushConstantEmulation p in layout.PushConstants)
    Console.WriteLine($"push constants {p.Name}: uniform buffer at group {p.Group} binding {p.Binding}, {p.BufferSize} bytes");
return 0;
```

*Output*

```
group 0 binding 0: Camera (Buffer, visibility Vertex)
group 0 binding 1: Albedo (Texture, visibility Fragment)
group 0 binding 2: LinearWrap (Sampler, visibility Fragment)
group 0 binding 3: Draw (Buffer, visibility Vertex, Fragment)
push constants Draw: uniform buffer at group 0 binding 3, 80 bytes
```

Both WGSL files pass naga. The entries map one to one onto `GPUBindGroupLayoutEntry`, and the push-constant block is a uniform buffer of 80 bytes at binding 3 that you write with `queue.writeBuffer` where Vulkan would call `vkCmdPushConstants`. The [push constants recipe](https://recursiveai.org/oneshader/cookbook/push-constants/) covers all three targets.

## Where OneShader breaks: some atomics

`InterlockedAdd`, `InterlockedExchange`, the bitwise atomics and unsigned `InterlockedMin`/`InterlockedMax` compile to valid WGSL. This histogram passes naga:

*Histogram.hlsl · HLSL*

```
Texture2D<float4> Image : register(t0);
RWStructuredBuffer<uint> Bins : register(u1);
 
[shader("compute")]
[numthreads(8, 8, 1)]
void CSMain(uint3 id : SV_DispatchThreadID)
{
    float luma = dot(Image[id.xy].rgb, float3(0.2126, 0.7152, 0.0722));
    InterlockedAdd(Bins[min(uint(luma * 255), 255)], 1);
}
```

*Terminal · OneShader 1.2.0*

```
oneshader -T cs_6_0 -E CSMain --target wgsl Histogram.hlsl -Fo histogram.wgsl
naga histogram.wgsl
```

*Output*

```
Validation successful
```

Signed `InterlockedMin` and `InterlockedMax`, and `InterlockedCompareExchange`, are different in OneShader 1.2.0. They compile without an error, but the WGSL isn’t valid: the helper functions they use take a storage-buffer pointer as a parameter, which naga rejects:

*Atomics.hlsl · HLSL*

```
RWStructuredBuffer<int> Signed : register(u0);
RWStructuredBuffer<uint> Unsigned : register(u1);
 
[shader("compute")] [numthreads(64, 1, 1)]
void CSAdd(uint3 id : SV_DispatchThreadID) { InterlockedAdd(Unsigned[0], 1); InterlockedMax(Unsigned[1], id.x); }
 
[shader("compute")] [numthreads(64, 1, 1)]
void CSSignedMin(uint3 id : SV_DispatchThreadID) { InterlockedMin(Signed[0], (int)id.x - 32); }
 
[shader("compute")] [numthreads(64, 1, 1)]
void CSCompareExchange(uint3 id : SV_DispatchThreadID) { uint old; InterlockedCompareExchange(Unsigned[2], 0, id.x, old); }
```

*Terminal · OneShader 1.2.0*

```
oneshader -T cs_6_0 -E CSSignedMin --target wgsl Atomics.hlsl -Fo min.wgsl
naga min.wgsl
```

*Output · exit code 255*

```
error: Function [0] 'osh_atomic_min_i32' is invalid
   ┌─ min.wgsl:5:1
   │  
 5 │ ╭ fn osh_atomic_min_i32(p: ptr<storage, atomic<u32>, read_write>, value: i32) -> i32 {
 6 │ │   var old = atomicLoad(p);
 7 │ │   loop {
 8 │ │     let wanted = bitcast<u32>(min(bitcast<i32>(old), value));
   · │
14 │ │   }
15 │ │ }
   │ ╰─^ naga::ir::Function [0]
   │  
   = Argument 'p' at index 0 is a pointer of space Storage { access: StorageAccess(LOAD | STORE) }, which can't be passed into functions.
```

DXC and naga handle these. naga writes `atomicMin` on an `i32` and `atomicCompareExchangeWeak`:

*Terminal · DXC 1.9.0.5512 · naga 30.0.1*

```
dxc -spirv -T cs_6_0 -E CSSignedMin Atomics.hlsl -Fo min.spv
naga min.spv min.wgsl
naga min.wgsl
```

*Output*

```
Validation successful
```

So for a WebGPU build with OneShader 1.2.0, use unsigned values with `InterlockedMin`/`Max` (flip the sign bit of a signed value with `^ 0x80000000`, which keeps its order), avoid `InterlockedCompareExchange`, or route those shaders through DXC and naga. Whichever path you use, run `naga file.wgsl` in the build. It catches both invalid files on this page, though not `var<immediate>`, which naga 30 accepts and a browser without the extension won’t.

## What WGSL can’t express at all

Some HLSL has no WGSL form whichever converter you use. OneShader reports these as error OSH5202 at the first use, naming WGSL, and only when WGSL is a selected target: `double` and 64-bit integers, 16-bit integers, resource arrays (WebGPU has no binding arrays), `Buffer<T>`, append and consume buffers, atomics on textures, clip and cull distances, and wave intrinsics in vertex shaders. `precise` compiles with warning OSH5205, because WGSL allows any implementation to fuse a multiply and an add ([precise and determinism](https://recursiveai.org/oneshader/articles/hlsl-precise-keyword-determinism/)). The full list is on the [WGSL backend](https://recursiveai.org/oneshader/docs/wgsl-backend/) page.

## Which path to use

| Shader uses | DXC, then naga 30 | OneShader 1.2.0 |
|---|---|---|
| Textures, samplers, simple constant buffers | Valid WGSL, readable structs | Valid WGSL, raw rows |
| Arrays in a constant buffer | Rejected by naga’s validator (stride 4, needs 16) | Valid, HLSL offsets |
| `[[vk::push_constant]]` | `var<immediate>`, needs the `immediate_address_space` language extension | A uniform buffer, slot in reflection |
| Signed atomic min/max, compare-exchange | Valid WGSL | Invalid WGSL, rejected by naga |
| Wave intrinsics | Not tested here | `enable subgroups;`, which naga 30 can’t parse, so check in a browser |
| Tools to install | DXC (native) and naga (Rust) | The .NET 10 SDK |

OneShader also writes the WebGPU bind group layouts and vertex buffer layouts from C#, and builds WGSL during `dotnet build` with `<OneShaderTargets>Wgsl</OneShaderTargets>`. The repository runs every corpus shader’s WGSL in headless Chromium on a real WebGPU adapter and compares the results with DXC’s SPIR-V on Vulkan ([WebGPU execution parity](https://recursiveai.org/oneshader/docs/webgpu-parity/)).

## Common questions

### Can WebGPU load SPIR-V or HLSL directly?

No. `createShaderModule` takes a `code` string that the specification defines as “the WGSL source code for the shader” ([WebGPU](https://www.w3.org/TR/webgpu/)). Every HLSL or SPIR-V shader has to become WGSL first, in your build or at run time.

### Can I convert SPIR-V to WGSL?

Yes, with naga: `naga shader.spv shader.wgsl`. That is path 1 above, starting from SPIR-V you already have. OneShader starts from HLSL source, not SPIR-V.

For the step-by-step recipe, from the command line, C# and `dotnet build`, see [convert HLSL to WGSL](https://recursiveai.org/oneshader/cookbook/hlsl-to-wgsl/). To start from scratch, follow [getting started](https://recursiveai.org/oneshader/docs/getting-started/).
