Recursive AI

Convert HLSL to WGSL for WebGPU

All oneshader recipes

Compile an HLSL vertex, pixel or compute shader to WGSL for WebGPU with OneShader, from the command line, from C# or during dotnet build, check it with naga, and see what WGSL can’t express.

OneShader writes WGSL from the same front end as its SPIR-V and Metal output, with the same bindings and the same buffer byte layout. The sample is Sprite.hlsl from Getting started.

From the command line

Terminal
oneshader --target wgsl Sprite.hlsl -Fo sprite.wgsl
naga sprite.VSMain.wgsl
naga sprite.PSMain.wgsl

Without -T, every [shader("...")] entry point is compiled, one file each: sprite.VSMain.wgsl and sprite.PSMain.wgsl. naga 30.0.1 prints Validation successful for both. The start of the vertex shader:

Text
struct VSMain_Output {
  @builtin(position) VSMain_Position: vec4<f32>,
  @location(0) VSMain_Uv: vec2<f32>,
  @location(1) VSMain_Color: vec4<f32>,
}
 
@group(0) @binding(0) var<uniform> Frame: array<vec4<u32>, 5>;
...
@vertex
fn VSMain(@location(0) in_VSMain_i_Position: vec2<f32>, @location(1) in_VSMain_i_Uv: vec2<f32>, @location(2) in_VSMain_i_Color: vec4<f32>) -> VSMain_Output {
  • Bindings are @group(set) @binding(binding), the same numbers as on Vulkan.
  • The constant buffer is raw 16-byte rows (array<vec4<u32>, 5> for the 80-byte Frame), read with bitcasts at the offsets HLSL packing gives. So the bytes you upload are the same for WebGPU, Vulkan and Metal, and one generated C# struct serves all three. Structured and byte-address buffers are array<u32> in the same way.
  • The entry function keeps the HLSL name, unless WGSL reserves it (loop becomes loop_).

From C# or with dotnet build

  • At run time, add CompileTarget.Wgsl to CompileOptions.Targets and read the text with result.Find("PSMain", CompileTarget.Wgsl)!.AsText(). See Compile at run time.
  • In a build-package project, set OneShaderTargets set to SpirV;Wgsl. Each entry-point class then has Wgsl (UTF-8 bytes) and WgslEntryPoint, and Pipeline.TryToWebGpu() gives the bind group layouts with WebGPU’s enum strings. See Write a compute shader for Vulkan and WebGPU.

What WGSL can’t express

A construct that WebGPU doesn’t have is a compile error, OSH5202, naming the target and what to do instead. For example, clip distances:

HLSL
cbuffer Frame : register(b0) { float4x4 ViewProjection; float4 ClipPlane; };
struct VSOut { float4 Position : SV_Position; float Clip : SV_ClipDistance0; };
[shader("vertex")]
VSOut VSMain(float3 p : POSITION)
{
    VSOut o;
    o.Position = mul(ViewProjection, float4(p, 1));
    o.Clip = dot(float4(p, 1), ClipPlane);
    return o;
}
Terminal
oneshader --target wgsl Clip.hlsl -Fo clip.wgsl
Text
Clip.hlsl(2,53): error OSH5202: SV_ClipDistance and SV_CullDistance need WebGPU's optional clip-distances feature, which this backend does not use; clip in the pixel shader with clip() or discard instead.

The same file compiles for SPIR-V. In a build-package project the error only appears when WGSL is selected, which is why SPIR-V alone is the default.

The others, from the WGSL backend page: CalculateLevelOfDetail; wave intrinsics in vertex shaders; resource arrays (WebGPU has no binding arrays); Buffer<T> and RWBuffer<T>; append and consume buffers; atomics on textures; double, 64-bit integers and 16-bit integers (half works, as f16); integer and bool matrices; PrimitiveID, layer and viewport index; recursion; storage textures without a format, or read and written in a format other than r32float, r32uint or r32sint; bool stage variables; texel offsets on cube textures.

Good to know

  • Specialization constants become @id(N) override declarations, set through the pipeline’s constants. See Specialization constants.
  • precise has no WGSL equivalent: it compiles with warning OSH5205. See The precise keyword.
  • Wave intrinsics use WebGPU’s subgroups feature in compute and fragment shaders. See Wave intrinsics.
  • Compare-exchange atomics produce WGSL that naga rejects at 1.2.0. See InterlockedAdd and float atomics.
  • Clip-space Y points up in WebGPU and down in Vulkan, and the shader isn’t adjusted: flip it with the viewport.
  • How it’s checked. Every corpus file either validates with naga or reports one of the errors above, and the repository runs the corpus in headless Chromium on a real WebGPU adapter against DXC’s SPIR-V on Vulkan (WebGPU execution parity).

DXC with naga compared with OneShader: HLSL to WGSL converter.