Recursive AI

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.

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. naga’s README lists SPIR-V, WGSL and GLSL as its inputs and WGSL among its outputs, with no HLSL input (naga README), 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.hlslHLSL
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; }
TerminalDXC 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

TerminalOneShader 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):

Packing.hlslHLSL
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);
}
TerminalDXC 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
Outputexit 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, 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:

TerminalOneShader 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).

Push constants on WebGPU

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

Push.hlslHLSL
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; }
TerminalDXC 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.hlslHLSL
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.csRecursiveAI.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 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.hlslHLSL
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);
}
TerminalOneShader 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.hlslHLSL
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); }
TerminalOneShader 1.2.0
oneshader -T cs_6_0 -E CSSignedMin --target wgsl Atomics.hlsl -Fo min.wgsl
naga min.wgsl
Outputexit 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:

TerminalDXC 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). The full list is on the WGSL backend page.

Which path to use

Shader usesDXC, then naga 30OneShader 1.2.0
Textures, samplers, simple constant buffersValid WGSL, readable structsValid WGSL, raw rows
Arrays in a constant bufferRejected by naga’s validator (stride 4, needs 16)Valid, HLSL offsets
[[vk::push_constant]]var<immediate>, needs the immediate_address_space language extensionA uniform buffer, slot in reflection
Signed atomic min/max, compare-exchangeValid WGSLInvalid WGSL, rejected by naga
Wave intrinsicsNot tested hereenable subgroups;, which naga 30 can’t parse, so check in a browser
Tools to installDXC (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).

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). 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. To start from scratch, follow getting started.