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:
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; }
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
Validation successfulThat 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
oneshader -T ps_6_0 -E PSMain --target wgsl Sprite.hlsl -Fo sprite.ps.wgsl naga sprite.ps.wgsl
Validation successfulOne 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):
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); }
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
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:
oneshader -T ps_6_0 -E main --target wgsl Packing.hlsl -Fo packing.wgsl naga packing.wgsl
Validation successfulThe 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]]:
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; }
dxc -spirv -T vs_6_0 -E VSMain Push.hlsl -Fo push.spv naga push.spv push.wgsl grep -n "var<" push.wgsl
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#:
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; }
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;
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:
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); }
oneshader -T cs_6_0 -E CSMain --target wgsl Histogram.hlsl -Fo histogram.wgsl naga histogram.wgsl
Validation successfulSigned 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:
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); }
oneshader -T cs_6_0 -E CSSignedMin --target wgsl Atomics.hlsl -Fo min.wgsl naga min.wgsl
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:
dxc -spirv -T cs_6_0 -E CSSignedMin Atomics.hlsl -Fo min.spv naga min.spv min.wgsl naga min.wgsl
Validation successfulSo 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 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).
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.