Shaders & Textures

Writing Shaders

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


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 mouse coordinates from DOM events, flip Y:

1.0 - (clientY - top) / height
.

Built-in bindings

MotionGPU 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 colours:

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.