Porting WebGL shaders to WebGPU

6
Official Construct Team Post
Ashley's avatar
Ashley
  • 26 Oct, 2021
  • 2,413 words
  • ~10-16 mins
  • 25,294 visits
  • 1 favourites

This is a technical post where I'm going to dive in to the nitty-gritty of the shader languages used by WebGL and WebGPU. WebGL uses GLSL (OpenGL Shading Language), and WebGPU uses WGSL (WebGPU Shading Language). WGSL is quite a different language to GLSL. I've written two past blog posts about WebGPU: A brief history of graphics on the web and WebGPU, and From WebGL to WebGPU in Construct which provide a bit more context, but in short WebGPU is a completely new technology designed to modernize computer graphics on the web (also introducing compute capabilities), and it introduces an entirely new shading language in WGSL.

WebGPU is still experimental and hasn't made any browser's stable release yet, and so resources for learning about WGSL are scarce - the only good reference is the specification, which is fairly impenetrable. I've been working on a WebGPU renderer for our game creation software Construct and recently finished porting over 80 shaders from GLSL to WGSL, and so this post aims to cover some basics of WGSL, what I've learned and what I wish I knew before I started. Hopefully this will be some help to others porting WebGL content to WebGPU.

Disclaimer: while this post is correct to the best of my knowledge at time of writing, WebGPU and WGSL are subject to change! Some details may go out of date. It's also focused on Construct which has its own particular way of doing things. In particular we mostly worked with WebGL 1 code for broadest compatibility. So I'll only be talking about a subset that was most relevant in our case with a bias to WebGL 1; there'll be plenty I've missed out.

A look at a simple shader

Here's a GLSL fragment shader that Construct uses for tinting a texture, which is basically a multiply by a fixed color specified in a uniform. For compatibility, it's written for WebGL 1.

varying mediump vec2 vTex;
uniform lowp sampler2D samplerFront;
uniform lowp vec3 tintColor;

void main(void)
{
	lowp vec4 front = texture2D(samplerFront, vTex);
	gl_FragColor = front * vec4(tintColor.rgb, 1.0);
}

Here's the equivalent we use in WGSL.

[[binding(0), group(0)]] var samplerFront : sampler;
[[binding(1), group(0)]] var textureFront : texture_2d<f32>;

[[block]] struct ShaderParams {
	tintColor : vec3<f32>;
};
[[binding(2), group(0)]] var<uniform> shaderParams : ShaderParams;

struct FragmentInput {
	[[location(0)]] fragUV : vec2<f32>;
	[[location(1)]] fragColor : vec4<f32>;
};

struct FragmentOutput {
	[[location(0)]] color : vec4<f32>;
};

[[stage(fragment)]]
fn main(input : FragmentInput) -> FragmentOutput {
	var front : vec4<f32> = textureSample(textureFront, samplerFront, input.fragUV);
	var output : FragmentOutput;
	output.color = front * vec4<f32>(shaderParams.tintColor, 1.0);
	return output;
}

You'll immediately notice a lot of differences! Including:

  • It's a lot longer in WGSL! WGSL is in general much more verbose than GLSL. However it is also a lot less ambiguous - almost everything is spelled out explicitly.
  • There's lots of syntax differences.
  • Lots of details have to be hard-coded in, like the specific location of inputs and outputs.
  • Even a small shader has a few structs.

If you already write your shaders for WebGL 2 and use features like uniform buffers, there are probably fewer conceptual differences, but it's still quite a jump. Let's dig in to a few of the key differences.

Attributes

Lots of things are marked up with attributes in [[ and ]], such as [[binding(1), group(0)]]. These imbue otherwise normal variables and functions with special meanings, such as in this example, precisely which binding slot in which bind group a texture corresponds to (which corresponds to WebGPU API calls). This is analogous to some layout annotations in GLSL with WebGL 2. WGSL's attributes are pretty exhaustive and can be used for everything from describing shader stages to the precise binary layout of a struct.

There's no referring to things by their name in WebGPU. You have to set it all out in the shader. For an engine like ours, this is a downside: we don't really want to have to hard-code lots of details like binding slots in shaders across a small ecosystem of third-party shaders, and WGSL does not provide a preprocessor or even any good rules about using constants instead of literals in attributes. So we actually invented a mini preprocessor to fill in details like the binding number so nobody has to hard-code them, replacing things like %%SAMPLERFRONT_BINDING%% with [[binding(0), group(0)]]. It's a bit ugly, but there doesn't seem to be a better option.

Variable declarations

WGSL has a different syntax for variable declarations based on var with explicit types.

// In GLSL:
lowp vec4 color;

// In WGSL:
var color : vec4<f32>;

Struct members and function parameters use a similar syntax but omit var.

WGSL has no precision specifiers like lowp. You have to give everything a specific type like f32 (a 32-bit float). Currently there's no f16 type either, but that should come as an extension in future - until then there's a whole lot of f32 going on.

One thing I wish I knew before I started is WGSL does support automatic type deduction, which can save a lot of typing. So if you initialise a variable to something, you don't have to specify the type, and it will be taken from the thing assigned.

// This way specifies the type twice
var color : vec4<f32> = vec4<f32>(1.0, 0.0, 0.0, 1.0);

// The variable type can be omitted though
var color = vec4<f32>(1.0, 0.0, 0.0, 1.0);

One seemingly cruel syntax choice is that coming from JavaScript, var in WGSL means let in JS (i.e. reassignable), and let in WGSL means const in JS (i.e. not reassignable), and in JS var is the old thing you're not meant to use any more. I think the rationale for this is based on the potential wider appeal of WGSL outside the web. It feels weird at first, but in the end I just went with pretty much always using var in WGSL and it wasn't too confusing, especially since the syntax is different to JS.

Structs

In WGSL structs are used to represent uniform buffers as well as shader inputs and outputs. Uniform buffers are similar to the equivalent in WebGL 2, but if you have WebGL 1 code for compatibility, they're now mandatory. They also have a specific binary layout which your JS code will need to match when updating them. If these are programmatically determined, such as loaded from a file, you'll also need to wrap your head around the struct alignment rules in the WGSL spec. You can also add attributes to explicitly place everything.

More uniquely structs are used for both shader inputs and outputs. This actually makes sense and is a nice way to clearly define inputs and outputs, with the main function accepting an input struct, returning an output struct, and all members of the struct providing annotations specifying their location. If there's only one input or output using a struct is optional, but my taste is to always use structs as I find them clearer. Either way, it beats assigning to magic variables in GLSL.

Function syntax

The WGSL function syntax is actually pretty nice. It's a good example of how WGSL departs from the C-like syntax of GLSL and goes with something that looks a lot more like Rust (although since I've hardly touched Rust, that's probably all I'll say on that). A simple add function looks like this:

fn add(a : f32, b : f32) -> f32
{
	return a + b;
}

This also applies to the shader main function, plus annotations, and using the special input and output structs as parameter and return value.

Texture sampling

Our previous engine was written with WebGL 1 for compatibility, and so textures and samplers are tightly bound: the shader gets a sampler, and you can sample it with builtins like texture2D. WebGL 2 relaxes this, and naturally WGSL takes the modern approach: textures and samplers are different and both have to be specified, and you just use textureSample which derives the type from the texture, which has the type texture_2d<f32>.

More WGSL details

Here's some more things I came across while porting lots of GLSL shaders to WGSL that others might find useful to know.

Increased capabilities

Coming from a WebGL 1-compatible engine, the increased capabilities of WGSL are really nice as they are WebGL 2 level and beyond, and you can just use them without worrying about fallbacks. For example you can use the textureDimensions built-in to get the size of a texture in a shader, and you don't have to figure out what happens in WebGL 1, since if you can get a WebGPU context you get all these capabilities as a baseline. In our case, the old WebGL renderer is itself the fallback. The WebGPU spec mandates minimum capabilities, such as being able to load 2D textures sized at least 8192x8192, which you can rely on unconditionally in WebGPU code.

Ternary operator

GLSL supports the ternary ?: operator. WGSL does not support this, but provides the built-in function select(falseValue, trueValue, condition) which does much the same thing (although mind that parameter order!). It also provides vector overloads, which is a nice benefit.

If/else

In WGSL braces around if are mandatory. As a result you can't write the usual C-style else if, as it would need to be else { if. To avoid this WGSL provides a special elseif keyword.

// Sample WGSL if/elseif/else
if (colorDistance == blackDistance)
{
	finalColor = colorBlack;
}
elseif (colorDistance == whiteDistance)
{
	finalColor = colorWhite;
}
else
{
	finalColor = colorMagenta;
}

It's easy to forget that the braces are mandatory, leave them out, and get a parse error. It's another way WGSL code tends to end up verbose. But it's not the end of the world.

No arithmetic assignment or increment

Arithmetic assignment operators like += are not currently supported in WGSL. You'll end up with a lot of code like n = n * 2.

Further, there are no increment ++ or decrement -- operators. The rationale again appears to follow Rust. Given the missing arithmetic assignment operators, it feels a bit silly to have to write i = i + 1 in a for loop, but assuming we get arithmetic assignment then that can at least become i += 1.

Assigning to vector components

WGSL doesn't yet have what's technically known as "l-value swizzling". In practice this means you can't assign to just a few components of a vector - you have to replace the entire vector. For example:

// Declare a vector with 4 components
var color : vec4<f32>;

// ...

// Not yet supported: assigning to just RGB components
color.rgb = someVec3;

// Workaround: assign a whole new vec4<f32>
color = vec4<f32>(someVec3, color.a);

Presumably support for this will be added later. Fortunately you can combine components in vector constructors, such as vec4<f32>(someVec2, z, w), as well as repeating components, such as vec4<f32>(f) being equivalent to vec4<f32>(f, f, f, f).

Some vector/scalar overloads missing

Some overloads are missing compared to GLSL, which may trip you up when porting GLSL. It's usually easy to work around with a bit of extra code though.

For example addition + can be used to add vec3 + float and return a vec3 with the float added to every component. However the less-than < operator only accepts vectors with the same number of components on both sides. So if you write someVec3 < someFloat, you'll get a syntax error and have to change it to someVec3 < vec3<f32>(someFloat).

Similarly several built-in functions currently require the same vector types for all parameters, such as pow(a, b) and clamp(x, a, b). So in GLSL you may have been able to write clamp(someVec2, 0.0, 1.0); in WGSL that will have to be clamp(someVec2, vec2<f32>(0.0), vec2<f32>(1.0)).

Odds and ends

WGSL doesn't aim to be compatible with GLSL. The syntax departure is good evidence of how this was a clean-slate redesign of a modern shader language. So don't assume the way something worked in GLSL will transfer across to WGSL.

Two minor examples of this are the WGSL % operator works slightly differently to the GLSL mod function (one uses trunc, the other floor); and atan(y, x) in GLSL is called atan2(y, x) in WGSL. There are probably several more examples. They're generally easy to work around if you refer to both specifications to see how each work.

Verdict

WGSL has a great syntax, apparently moving in a much more Rust-like direction compared to GLSL's C-like syntax. It is more powerful but also much more verbose, with every last detail of your shader having to be spelled out. However having worked with WGSL a fair bit now, I think the verbosity is actually a feature. "Explicit is better than implicit" is a good language design principle that WGSL follows. WebGL code often ends up feeling kind of vague, even though the specification does explain how everything works, which I think is down to a lot of implicitness in its design. I don't get that feeling with WebGPU - it's always clear exactly what it is doing, because you have to specify it yourself to every last detail.

The main downside of WGSL is basically that it's a young technology. It's a workable minimum viable product, but it lacks things you can take for granted in pretty much every other language, like a += operator. Once you get used to these limitations it's easy to just type your way around them, and surely with time the gaps will be filled in.

WGSL also still has some things that are mysterious to me. It's not entirely clear to me why [[block]] needs to be there, along with var<uniform>. Searching the specification can be hard work: search terms often produce dozens of results across the entire document. You have to be prepared to do a bit of trawling, and if you're really serious, think about just sitting down and reading it start to finish. It will still be great to have proper developer documentation for WGSL that is more readable than the spec - this blog post is just a band-aid for the time being and hopefully will help some other intrepid WebGPU developers along the way. But for those of us working with it already, it's an exciting technology with a lot of promise, and I'm looking forwards to working with it more in future!

Subscribe

Get emailed when there are new posts!

  • 2 Comments

  • Order by
Want to leave a comment? Login or Register an account!