Chapter 01
Fundamentals & Structure
WGSL is not only shader syntax. A useful module is a contract between your browser code, WebGPU validation, and the GPU pipeline. This chapter teaches the shape of that contract, then keeps sending you back to Studio so the compiler can show what it understood.
Start with the whole module
A WGSL file is a module. It can contain structs, constants, overrides, module-scope variables, helper functions, and entry-point functions. The smallest practical habit is to read a module in this order: resources first, helpers second, entry points last.
The example below is deliberately tiny, but it is already a complete compute pipeline shape. It declares a storage buffer, receives an invocation id from WebGPU, turns that id into an array index, and writes data that can be read back after dispatch.
@group(0) @binding(0)
var<storage, read_write> data : array<f32>;
@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) id : vec3u) {
let i = id.x;
data[i] = sin(f32(i) * 0.0625);
}
The first pass should answer four questions
What data crosses the API boundary?
Here it is one storage buffer named data. Reflection should expose it as group 0, binding 0.
Which function starts the pipeline?
@compute marks main as the entry point. Helpers are ordinary functions; entry
points are pipeline doors.
How many lanes run together?
@workgroup_size(64) defines workgroups of 64 local invocations along the x dimension.
What does each invocation write?
The global id becomes i. Lane 0 writes data[0], lane 1 writes
data[1], and so on.
Attributes are not decoration
WGSL uses attributes to connect plain-looking declarations to GPU pipeline meaning. The same function body can be ordinary helper code or a pipeline entry point depending on the attribute in front of it.
Read attributes as compiler-facing labels. They are compact, but they carry the information WebGPU needs to validate a shader against a pipeline layout.
@group(0) @binding(0)
var<storage, read_write> data : array<f32>;
@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) id : vec3u) {
data[id.x] = 1.0;
}
@group and @binding name the external resource slot.
@compute selects the pipeline stage. @workgroup_size fixes local parallel shape.
@builtin requests a value that WebGPU provides instead of a value stored in your buffer.
@binding(0) and open Diagnostics. Then put it back and open
Reflection. The point is to connect the visible line to the reflected resource, not to memorize the spelling in
isolation.
Bindings and address spaces are the host contract
Module-scope var declarations with bindings describe data owned outside the shader. The address
space says where the data lives and what access pattern the shader has. That is why var can feel
more important at module scope than inside a function.
struct Params {
scale : f32,
offset : f32,
};
@group(0) @binding(0)
var<uniform> params : Params;
@group(0) @binding(1)
var<storage, read_write> data : array<f32>;
A uniform binding is read-only shader configuration: camera matrices, time, resolution, scalar
knobs. A storage binding is usually larger data: arrays, particles, counters, or compute output.
The read_write access mode matters because WebGPU will reject a pipeline layout that promises less
access than the shader asks for.
Small, shared, read-only settings
Best for frame constants such as resolution, time, transforms, material flags, or dispatch parameters.
Large buffers and writable output
Best for compute results, structured arrays, particle state, prefix sums, and data you want to read back.
Entry-point inputs: WGSL does not guess
An entry point receives only what you declare. In compute, the common first builtin is
global_invocation_id. In fragment shaders, the common first builtin is position. Both
are coordinates, but they answer different questions.
@compute @workgroup_size(8, 8, 1)
fn main(@builtin(global_invocation_id) id : vec3u) {
let x = id.x;
let y = id.y;
}
struct Frame {
resolution : vec2f,
time : f32,
};
@group(0) @binding(0)
var<uniform> frame : Frame;
@fragment
fn fs(@builtin(position) frag : vec4f) -> @location(0) vec4f {
let uv = frag.xy / frame.resolution;
return vec4f(uv.x, uv.y, 0.5, 1.0);
}
The fragment snippet is the first visual trick in one sentence: divide pixel position by resolution and you get UV coordinates. Left-to-right becomes 0 to 1 in x. Top-to-bottom becomes 0 to 1 in y. Writing UV directly into color gives you a gradient and proves the coordinate model is real.
The dispatch mental model
A compute shader does not run once. It runs many times. The JavaScript side chooses how many workgroups to dispatch, and the WGSL entry point chooses how many local invocations live inside each workgroup.
If you dispatch 4 workgroups and each workgroup has 64 local invocations, you scheduled 256 invocations. That does not automatically mean your buffer has 256 safe elements, so real kernels usually guard the edge.
@group(0) @binding(0)
var<storage, read_write> data : array<f32>;
@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) id : vec3u) {
let i = id.x;
if (i >= arrayLength(&data)) {
return;
}
data[i] = sin(f32(i) * 0.0625);
}
The guard is not glamorous, but it is the difference between "works for exactly one demo size" and "survives real input sizes." Studio's generated host code and readback panel are useful here because you can change the dispatch shape and watch whether the output still matches the mental model.
A small visual ladder
Compute teaches data flow. Fragment shaders teach spatial intuition. A useful visual ladder moves from flat color to UVs, time, distance fields, and anti-aliasing. Here is that ladder compressed into one fragment module.
struct Frame {
resolution : vec2f,
time : f32,
};
@group(0) @binding(0)
var<uniform> frame : Frame;
@fragment
fn fs(@builtin(position) frag : vec4f) -> @location(0) vec4f {
let aspect = frame.resolution.x / frame.resolution.y;
let uv = (frag.xy / frame.resolution - vec2f(0.5, 0.5)) * vec2f(aspect, 1.0);
let radius = 0.28 + 0.06 * sin(frame.time * 1.8);
let distance_to_circle = length(uv) - radius;
let aa = 1.5 / frame.resolution.y;
let inside = 1.0 - smoothstep(-aa, aa, distance_to_circle);
let background = vec3f(0.05, 0.07, 0.10);
let foreground = vec3f(0.13, 0.83, 0.93);
return vec4f(mix(background, foreground, inside), 1.0);
}
This example introduces four reusable ideas. UVs make pixels comparable across screen sizes. Time animates the
formula without changing the shader source. length(uv) - radius is a signed distance field for a
circle. smoothstep turns the hard edge into a small anti-aliased band.
What Studio should show you
Once the source parses, the compiler can describe much more than pass/fail. These are the surfaces to check while learning, because each one answers a different question.
Is the source legal WGSL?
Syntax, type, validation, attribute, and entry-point errors should point back to source locations.
What API contract did the compiler find?
Entry points, resources, bind groups, address spaces, and layout-facing metadata become inspectable.
What does one lane do?
The interpreter path lets you follow values through a single logical invocation before thinking in bulk.
What did the GPU actually write?
Compute output becomes a buffer you can inspect, compare, and use to catch indexing mistakes.
Tiny mistakes worth making
A good first WGSL session should include small controlled failures. They teach the compiler's language faster than a clean happy path.
Delete @binding(0)
Expected result: diagnostics complain because the resource no longer has a complete bind slot.
Write data[i] = id.x;
Expected result: a type mismatch, because data stores f32 and
id.x is u32.
Remove the guard
Expected result: the shader still validates, but the runtime behavior depends on dispatch and buffer size.
Change workgroup size
Expected result: reflection changes immediately, and the dispatch math needs to be reconsidered.
@group(0) @binding(0)
var<storage, read_write> data : array<f32>;
@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) id : vec3u) {
let i = id.x;
data[i] = id.x;
}
The study loop
Use the same loop for every later chapter. It keeps the documentation from becoming passive reading and makes the Studio do real teaching work.
- Read the declarations before the function body. Name the host-visible resources and the entry points.
- Open the module in Studio. Confirm that diagnostics, reflection, and bind-group layout match what you expected.
- Trace one invocation. Write down which values are per-invocation and which values are shared.
- Run it on WebGPU. Inspect readback or preview output, then change one number and predict the result before rerunning.
- Break one thing on purpose. Let the compiler explain the boundary you crossed.