Under the Hood
Webgpu

WGSL: the shading language WebGPU speaks

WebGPU rejects GLSL entirely and accepts only WGSL, a brand-new, strictly-typed shading language designed to be validated once by the browser and translated identically into Metal, Vulkan (SPIR-V), or D3D12 (HLSL) on whatever backend the machine actually runs.

WGSL: the shading language WebGPU speaks

Every code string passed to device.createShaderModule in the previous lesson has to be written in one specific language, and it is not GLSL. WebGPU introduces its own shading language from scratch — WGSL, the WebGPU Shading Language — and refuses to accept anything else. If you've read the Three.js shaders lesson, you already know GLSL's shape: attributes, uniforms, varyings, a C-like main(). This lesson is what replaced that shape, why WebGPU didn't just keep using GLSL, and how to read WGSL by mapping each piece back to the GLSL concept you already understand.

Why not just reuse GLSL?

GLSL was designed for OpenGL, and OpenGL runs on one native model. WebGPU has no such luxury — it has to run identically on top of three completely different native graphics APIs, depending on the operating system: Vulkan on Linux and Android (which wants shader bytecode called SPIR-V), Metal on macOS and iOS (which wants Apple's own MSL), and Direct3D 12 on Windows (which wants HLSL). A browser implementing WebGPU has to take whatever shader source you hand it and translate that into all three, correctly, for every WebGPU app on the web.

GLSL was never designed with that translation in mind, and browsers had already learned this lesson the hard way — WebGL shaders are GLSL, and translating GLSL faithfully into HLSL or MSL is notoriously full of edge cases and dialect drift. Rather than repeat that, the WebGPU standard defines a new language from first principles, specified precisely enough that a browser can validate it rigorously and lower it to any of the three native targets with no ambiguity. WGSL is also deliberately safe: no unbounded pointer arithmetic, no undefined behavior the browser can't reason about, because a browser has to be able to trust untrusted shader code the same way it trusts untrusted JavaScript.

Reading WGSL: entry points and explicit types

A WGSL module is plain text, and a single module can hold multiple entry-point functions, each tagged with an attribute saying which pipeline stage it belongs to:

@vertex
fn vs_main() -> @builtin(position) vec4<f32> {
  // ...
}

@fragment
fn fs_main() -> @location(0) vec4<f32> {
  // ...
}

@vertex, @fragment, and @compute (lesson 8) mark which stage a function runs as — the WGSL equivalent of GLSL's split into two entirely separate shader source strings. Because one file can hold several entry points, a single WGSL module is often both the vertex and fragment shader the pipeline lesson's createShaderModule call referenced.

Every value has an explicit type, always: f32 and i32 and u32 for scalars, vec2<f32>, vec3<f32>, vec4<f32> for vectors, mat4x4<f32> for a 4x4 matrix of floats. GLSL lets you write vec3 and move on; WGSL makes the component type part of the type name itself. There's no implicit conversion between f32 and i32 the way GLSL sometimes allows between float and int — you convert explicitly, which is one more piece of the "nothing ambiguous" design goal.

Inter-stage I/O: @location is the new varying

Recall the GLSL mental model: a vertex shader writes a varying, the rasterizer interpolates it across the triangle, and the fragment shader reads the interpolated result. WGSL keeps the exact same mechanism — the rasterizer still interpolates — but expresses it differently. Instead of a shared varying vec3 vNormal name in two shader source strings, the vertex entry point returns a struct whose fields are tagged @location(n), and the fragment entry point takes a struct (or plain parameters) tagged with matching @location(n) numbers:

struct VertexOutput {
  @builtin(position) clipPosition: vec4<f32>,
  @location(0) color: vec3<f32>,
}

The number, not the name, is what has to match between stages — the vertex shader's @location(0) output is the fragment shader's @location(0) input, whatever each side calls the field locally. @builtin(position) is WGSL's gl_Position: the one output every vertex entry point must produce, the vertex's location in clip space. Other builtins cover things GLSL exposed as special globals too — @builtin(vertex_index), for instance, is the per-vertex counter you'd read to index into data without a dedicated attribute at all.

Resources: @group and @binding replace uniform

GLSL's uniform keyword covered a lot of ground loosely — a single float, a matrix, a texture sampler, all declared the same way, all just "constant for the draw call." WGSL splits this into an explicit two-part address: @group(g) @binding(b), naming exactly which bind group (lesson 6) and which slot within it a resource comes from:

struct Uniforms {
  mvp: mat4x4<f32>,
}

@group(0) @binding(0) var<uniform> uniforms: Uniforms;

A uniform "block" in WGSL is just a struct bound at a @binding, and you can bind several — one for per-frame data, one for per-object data — as long as each has its own @group/@binding pair. Textures and samplers get the same treatment: @group(0) @binding(1) var mySampler: sampler; and @group(0) @binding(2) var myTexture: texture_2d<f32>; sit right alongside the uniform buffer, each at its own explicit slot. This is the hook into bind groups: the pipeline's layout field from the previous lesson describes exactly this set of @group/@binding expectations, and a bind group is what supplies the actual buffer or texture to fill each slot at draw time.

The map, side by side

GLSL conceptWGSL concept
attribute (vertex-stage input)a @location(n) parameter on the @vertex entry point
varying (vertex → fragment)a @location(n) field on the struct passed between stages
uniforma resource declared @group(g) @binding(b)
gl_Position@builtin(position) on the vertex entry point's return value
two separate shader source stringsone module, multiple @vertex / @fragment / @compute entry points

A real pair, mapped piece by piece

Here's the WGSL version of the normal-shading pair from the Three.js shaders lesson: a vertex shader taking a position input, applying a uniform matrix, and outputting clip position plus a color; a fragment shader receiving that color and returning it.

struct Uniforms {
  mvp: mat4x4<f32>, // was: uniform mat4 modelViewProjection
}

@group(0) @binding(0) var<uniform> uniforms: Uniforms;

struct VertexOutput {
  @builtin(position) clipPosition: vec4<f32>, // was: gl_Position
  @location(0) color: vec3<f32>,              // was: varying vec3 vColor
}

@vertex
fn vs_main(
  @location(0) position: vec3<f32>, // was: attribute vec3 position
  @location(1) color: vec3<f32>,    // was: attribute vec3 color
) -> VertexOutput {
  var out: VertexOutput;
  out.clipPosition = uniforms.mvp * vec4<f32>(position, 1.0);
  out.color = color;
  return out;
}
@fragment
fn fs_main(
  @location(0) color: vec3<f32>, // interpolated, same as a GLSL varying read
) -> @location(0) vec4<f32> {
  return vec4<f32>(color, 1.0); // was: gl_FragColor
}

The rasterizer interpolates color across the triangle's fragments exactly the way it interpolated a GLSL varying — that mechanism didn't change at all. What changed is purely how the language names the two ends of the pipe: a @location(0) field on a struct at each end, instead of a matching varying vec3 name in two separate source strings.

Where this goes next

You can now read a WGSL module and translate every attribute back to the GLSL concept it replaces — entry points for the two shader stages, @location for both per-vertex inputs and inter-stage data, @group/@binding for every resource a shader touches. That last piece — @group/@binding — is only half the story: WGSL declares what a shader expects, but something still has to supply the actual buffer, texture, and sampler at those exact slots. Bind groups is where those declarations meet real GPU resources.

Go deeper

  • W3C — WGSL specification The full normative language spec: every type, attribute, and builtin this lesson introduced, defined precisely.
  • WebGPU Fundamentals — WGSL A hands-on tour of WGSL's syntax and type system with runnable examples, the counterpart to this lesson's GLSL comparison.
  • MDN — WGSL MDN's overview of WGSL within the broader WebGPU API docs, useful for cross-referencing entry points and builtins by name.

Check yourself

Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.

  1. Why does WebGPU define a brand-new language instead of reusing GLSL, given that WebGL already had a working GLSL pipeline?
  2. Name the three native shader targets a browser's WebGPU implementation must be able to translate WGSL into.
  3. What WGSL mechanism replaces a GLSL varying, and does the rasterizer's interpolation behavior change at all between the two?
  4. In `@location(0) color: vec3<f32>` on both a vertex output struct and a fragment input, what has to match between the two — the field name or the number?
  5. What does @group(g) @binding(b) specify, and which GLSL keyword did it replace? Which later lesson supplies the resources it points at?
  6. What is @builtin(position) the WGSL equivalent of, and which shader stage must produce it?
  7. How many entry-point functions can a single WGSL module contain, and how does a stage attribute like @vertex or @fragment distinguish them?