wgsl.runLearn WGSL
English Türkçe

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.

Open the chapter kernel in Studio.

Keep this tab for reading, then inspect diagnostics, reflection, bind groups, invocation state, generated host code, and compute readback from the same source.

Open in Studio

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.

hello-compute.wgslcomplete module
@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);
}
@attributes keywords types address spaces builtins

The first pass should answer four questions

resource

What data crosses the API boundary?

Here it is one storage buffer named data. Reflection should expose it as group 0, binding 0.

entry

Which function starts the pipeline?

@compute marks main as the entry point. Helpers are ordinary functions; entry points are pipeline doors.

shape

How many lanes run together?

@workgroup_size(64) defines workgroups of 64 local invocations along the x dimension.

effect

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.

attribute rolesread from left to right
@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.

Studio move: remove @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.

host-visible resourcesuniform + storage
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.

uniform

Small, shared, read-only settings

Best for frame constants such as resolution, time, transforms, material flags, or dispatch parameters.

storage

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 coordinatewhich lane am i?
@compute @workgroup_size(8, 8, 1)
fn main(@builtin(global_invocation_id) id : vec3u) {
  let x = id.x;
  let y = id.y;
}
fragment coordinatewhich pixel am i shading?
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.

workgroup_size lanes per workgroup
dispatchWorkgroups number of workgroups
global_invocation_id coordinate of one lane

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.

bounded writesafe final group
@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.

uv-time-sdf.wgslfragment intuition
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.

diagnostics

Is the source legal WGSL?

Syntax, type, validation, attribute, and entry-point errors should point back to source locations.

reflection

What API contract did the compiler find?

Entry points, resources, bind groups, address spaces, and layout-facing metadata become inspectable.

invocation

What does one lane do?

The interpreter path lets you follow values through a single logical invocation before thinking in bulk.

readback

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.

binding

Delete @binding(0)

Expected result: diagnostics complain because the resource no longer has a complete bind slot.

type

Write data[i] = id.x;

Expected result: a type mismatch, because data stores f32 and id.x is u32.

bounds

Remove the guard

Expected result: the shader still validates, but the runtime behavior depends on dispatch and buffer size.

shape

Change workgroup size

Expected result: reflection changes immediately, and the dispatch math needs to be reconsidered.

intentional type errordebug with diagnostics
@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.

  1. Read the declarations before the function body. Name the host-visible resources and the entry points.
  2. Open the module in Studio. Confirm that diagnostics, reflection, and bind-group layout match what you expected.
  3. Trace one invocation. Write down which values are per-invocation and which values are shared.
  4. Run it on WebGPU. Inspect readback or preview output, then change one number and predict the result before rerunning.
  5. Break one thing on purpose. Let the compiler explain the boundary you crossed.

Next station: memory layout.

Open a uniform struct and a storage buffer, then compare source order with reflected offsets and generated host code.

Inspect layout