Shaders & Textures

Writing Shaders

WGSL concepts, conventions, and patterns for effective shader authoring in Motion GPU.


MotionGPU renders fullscreen fragment shaders written in WGSL (WebGPU Shading Language). This page covers the WGSL concepts you need for effective shader authoring, the conventions MotionGPU imposes, and common visual patterns.

The fragment contract

Every material must declare exactly this function:

fn frag(uv: vec2f) -> vec4f
fn frag(uv: vec2f) -> vec4f
  • uv — normalized coordinates from (0, 0) at the bottom-left to (1, 1) at the top-right.
  • Return value — an RGBA color. The alpha channel is typically 1.0 for opaque output.

If this signature is missing, defineMaterial throws immediately.

Coordinate space

(0, 1) ───────── (1, 1)
│                     │
│    Your canvas      │
│                     │
(0, 0) ───────── (1, 0)
(0, 1) ───────── (1, 1)
│                     │
│    Your canvas      │
│                     │
(0, 0) ───────── (1, 0)

This is the standard GPU convention (Y-up), not the DOM convention (Y-down). When converting pointer coordinates manually, flip Y: 1.0 - (clientY - top) / height. If you use usePointer(), state.current.uv already uses this Y-up orientation.

Built-in bindings

Motion GPU injects the frame uniforms plus any user uniforms/textures you declare. You do not declare these bindings yourself.

Frame uniforms (always available)

These are injected by the renderer and updated every frame:

Name Type Description
motiongpuFrame.time f32 requestAnimationFrame timestamp in seconds (monotonic clock)
motiongpuFrame.delta f32 Time since last frame in seconds (clamped by maxDelta)
motiongpuFrame.resolution vec2f Canvas size in physical pixels (width, height)

User uniforms

Any uniform you declare in defineMaterial({ uniforms: { ... } }) becomes a field on the motiongpuUniforms struct:

defineMaterial({
  fragment: `
fn frag(uv: vec2f) -> vec4f {
  let t = sin(motiongpuUniforms.uTime * 3.0);
  return vec4f(t, uv.y, motiongpuUniforms.uIntensity, 1.0);
}
`,
  uniforms: {
    uTime: 0,
    uIntensity: 0.8
  }
});
defineMaterial({
  fragment: `
fn frag(uv: vec2f) -> vec4f {
  let t = sin(motiongpuUniforms.uTime * 3.0);
  return vec4f(t, uv.y, motiongpuUniforms.uIntensity, 1.0);
}
`,
  uniforms: {
    uTime: 0,
    uIntensity: 0.8
  }
});

Here uTime and uIntensity are both f32 fields on motiongpuUniforms.

User textures

Textures declared in the material become texture_2d<f32> bindings with an associated sampler. You sample them with a generated helper:

defineMaterial({
  fragment: `
fn frag(uv: vec2f) -> vec4f {
  let color = textureSample(uAlbedo, uAlbedoSampler, uv);
  return color;
}
`,
  textures: {
    uAlbedo: { filter: 'linear' }
  }
});
defineMaterial({
  fragment: `
fn frag(uv: vec2f) -> vec4f {
  let color = textureSample(uAlbedo, uAlbedoSampler, uv);
  return color;
}
`,
  textures: {
    uAlbedo: { filter: 'linear' }
  }
});

For each texture named uFoo, the shader gets:

  • uFoo: texture_2d<f32> — the texture binding
  • uFooSampler: sampler — the associated sampler

WGSL quick reference

If you are new to WGSL, here are the most common types and functions used in fragment shaders:

Scalar and vector types

Type Description Example
f32 32-bit float let x: f32 = 3.14;
i32 32-bit signed integer let n: i32 = 42;
u32 32-bit unsigned integer let n: u32 = 7u;
bool Boolean let b: bool = true;
vec2f 2-component float vector vec2f(1.0, 2.0)
vec3f 3-component float vector vec3f(1.0, 0.5, 0.0)
vec4f 4-component float vector vec4f(r, g, b, a)

Common math functions

Function Description
sin(x), cos(x), tan(x) Trigonometry
abs(x) Absolute value
min(a, b), max(a, b) Min/max
clamp(x, lo, hi) Range clamping
mix(a, b, t) Linear interpolation
step(edge, x) Step function (0.0 or 1.0)
smoothstep(lo, hi, x) Hermite interpolation
length(v) Vector magnitude
distance(a, b) Distance between two points
normalize(v) Unit vector
dot(a, b) Dot product
cross(a, b) Cross product (vec3f only)
floor(x), ceil(x) Rounding
fract(x) Fractional part (x - floor(x))
pow(x, y) Power
exp(x), log(x) Exp / natural log
sqrt(x) Square root

Common shader patterns

UV gradient

The simplest shader maps UV coordinates directly to colors:

fn frag(uv: vec2f) -> vec4f {
  return vec4f(uv.x, uv.y, 0.5, 1.0);
}
fn frag(uv: vec2f) -> vec4f {
  return vec4f(uv.x, uv.y, 0.5, 1.0);
}

Animated sine wave

fn frag(uv: vec2f) -> vec4f {
  let wave = 0.5 + 0.5 * sin(motiongpuFrame.time * 2.0 + uv.x * 10.0);
  return vec4f(vec3f(wave), 1.0);
}
fn frag(uv: vec2f) -> vec4f {
  let wave = 0.5 + 0.5 * sin(motiongpuFrame.time * 2.0 + uv.x * 10.0);
  return vec4f(vec3f(wave), 1.0);
}

Radial glow

fn frag(uv: vec2f) -> vec4f {
  let center = vec2f(0.5, 0.5);
  let dist = distance(uv, center);
  let glow = 0.01 / (dist * dist + 0.001);
  return vec4f(glow * 0.2, glow * 0.4, glow * 0.8, 1.0);
}
fn frag(uv: vec2f) -> vec4f {
  let center = vec2f(0.5, 0.5);
  let dist = distance(uv, center);
  let glow = 0.01 / (dist * dist + 0.001);
  return vec4f(glow * 0.2, glow * 0.4, glow * 0.8, 1.0);
}

Circle SDF (Signed Distance Field)

fn frag(uv: vec2f) -> vec4f {
  let center = vec2f(0.5, 0.5);
  let radius = 0.3;
  let dist = length(uv - center) - radius;
  let edge = smoothstep(0.005, 0.0, dist);
  return vec4f(vec3f(edge), 1.0);
}
fn frag(uv: vec2f) -> vec4f {
  let center = vec2f(0.5, 0.5);
  let radius = 0.3;
  let dist = length(uv - center) - radius;
  let edge = smoothstep(0.005, 0.0, dist);
  return vec4f(vec3f(edge), 1.0);
}

Aspect-ratio correction

The UV space is always [0..1] × [0..1], which can stretch non-square canvases. To get square pixels:

fn frag(uv: vec2f) -> vec4f {
  let aspect = motiongpuFrame.resolution.x / motiongpuFrame.resolution.y;
  var corrected = uv;
  corrected.x *= aspect;

  // Now corrected.x ranges from 0 to aspect, corrected.y from 0 to 1
  let dist = length(corrected - vec2f(aspect * 0.5, 0.5));
  let circle = smoothstep(0.305, 0.3, dist);
  return vec4f(vec3f(circle), 1.0);
}
fn frag(uv: vec2f) -> vec4f {
  let aspect = motiongpuFrame.resolution.x / motiongpuFrame.resolution.y;
  var corrected = uv;
  corrected.x *= aspect;

  // Now corrected.x ranges from 0 to aspect, corrected.y from 0 to 1
  let dist = length(corrected - vec2f(aspect * 0.5, 0.5));
  let circle = smoothstep(0.305, 0.3, dist);
  return vec4f(vec3f(circle), 1.0);
}

Color output and color space

By default, FragCanvas operates in sRGB output color space (outputColorSpace: 'srgb'). This means the renderer applies a linear-to-sRGB conversion to your fragment output automatically.

If you are doing your own color management and want raw linear output, set outputColorSpace="linear" on FragCanvas. Note that changing this triggers a renderer rebuild since it changes the pipeline signature.