Rendering

Render Passes

Building post-processing pipelines with the render graph and built-in pass types.


Motion GPU supports post-processing through a render graph that sequences passes over ping-pong buffers and optional named render targets. This page covers the render graph model, slot semantics, the three built-in render pass types, and two compute pass types.

For named render targets, see Render Targets. For detailed compute shader documentation, see Compute Shaders.

Pass constructors are framework-agnostic and can be imported from root/core entrypoints.

Render graph model

When you add passes to FragCanvas, the renderer builds an execution plan:

  1. The base shader renders your material’s fn frag(...) into the source slot.
  2. Each pass reads from an input slot, processes the image, and writes to an output slot.
  3. After all passes execute, the final output is presented to the canvas (direct if output is canvas, otherwise via blit from source, target, or named target).
<FragCanvas {material} passes={[passA, passB, passC]} />

Without any passes, the base shader renders directly to the canvas.

Slot semantics

Slot Purpose
source Current scene/result surface
target Ping-pong companion (allocated when needed)
canvas Presentation surface (the user-visible canvas)
<targetName> Named off-screen surface declared in renderTargets

canvas is output-only (cannot be used as a pass input).

Default ping-pong flow

By default, passes read from source, write to target, and then swap the two:

  1. Base shader → source
  2. Pass A: reads source, writes targetswap → A’s output is now in source
  3. Pass B: reads source, writes targetswap → B’s output is now in source
  4. Final: source is blitted to canvas

Non-swapping passes

If needsSwap: false, the pass writes to its output slot without swapping. This is useful for the final pass that writes directly to canvas:

const finalPass = new ShaderPass({
  needsSwap: false,
  input: 'source',
  output: 'canvas',
  fragment: `
fn shade(inputColor: vec4f, uv: vec2f) -> vec4f {
  return vec4f(pow(inputColor.rgb, vec3f(1.0 / 2.2)), inputColor.a);
}
`
});
const finalPass = new ShaderPass({
  needsSwap: false,
  input: 'source',
  output: 'canvas',
  fragment: `
fn shade(inputColor: vec4f, uv: vec2f) -> vec4f {
  return vec4f(pow(inputColor.rgb, vec3f(1.0 / 2.2)), inputColor.a);
}
`
});

You can also route through named targets:

const pre = new ShaderPass({
  needsSwap: false,
  output: 'fxMain',
  fragment: `
fn shade(inputColor: vec4f, uv: vec2f) -> vec4f {
  return vec4f(inputColor.rgb * vec3f(uv, 1.0), inputColor.a);
}
`
});

const composite = new ShaderPass({
  needsSwap: false,
  input: 'fxMain',
  output: 'canvas',
  fragment: `
fn shade(inputColor: vec4f, uv: vec2f) -> vec4f {
  return vec4f(inputColor.bgr, inputColor.a);
}
`
});
const pre = new ShaderPass({
  needsSwap: false,
  output: 'fxMain',
  fragment: `
fn shade(inputColor: vec4f, uv: vec2f) -> vec4f {
  return vec4f(inputColor.rgb * vec3f(uv, 1.0), inputColor.a);
}
`
});

const composite = new ShaderPass({
  needsSwap: false,
  input: 'fxMain',
  output: 'canvas',
  fragment: `
fn shade(inputColor: vec4f, uv: vec2f) -> vec4f {
  return vec4f(inputColor.bgr, inputColor.a);
}
`
});

Validation rules

planRenderGraph(...) validates the pass sequence before execution:

Rule Error condition
needsSwap: true must use source → target Any other slot pairing with swap enabled
Input slot must be available Reading target before any pass has written to it
canvas cannot be input Any pass with input: 'canvas'
Named input must exist Reading undeclared target name
Named output must exist Writing undeclared target name
Named input must be available Reading declared named target before first write in frame
Disabled passes (enabled: false) Fully skipped from the plan

Built-in render passes

BlitPass

Fullscreen texture sample pass. Copies input to output using a fragment shader with configurable filter mode.

import { BlitPass } from '@motion-core/motion-gpu';

const blit = new BlitPass({
  filter: 'nearest' // Default: 'linear'
});
import { BlitPass } from '@motion-core/motion-gpu';

const blit = new BlitPass({
  filter: 'nearest' // Default: 'linear'
});
Option Default Description
enabled true Whether this pass is active
needsSwap true Whether to swap source/target after render
input 'source' Input slot (source, target, or named target key)
output 'target' (if swap) Output slot (source, target, canvas, or named target key)
clear false Clear output before drawing
clearColor [0,0,0,1] RGBA clear color
preserve true Preserve output after pass ends
filter 'linear' Texture sampling filter

CopyPass

Optimized texture copy with a fullscreen-blit fallback. Attempts copyTextureToTexture when possible — a GPU-side copy that avoids shader execution entirely.

import { CopyPass } from '@motion-core/motion-gpu';

const copy = new CopyPass();
import { CopyPass } from '@motion-core/motion-gpu';

const copy = new CopyPass();

Direct copy conditions (all must be true):

  • clear === false
  • preserve === true
  • Source and target are different textures
  • Neither is the canvas texture
  • Same width, height, and format

When any condition fails, CopyPass falls back to an internal BlitPass.

Options are identical to BlitPass.

ShaderPass

Programmable post-process pass with custom WGSL fragment shader:

import { ShaderPass } from '@motion-core/motion-gpu';

const vignette = new ShaderPass({
  fragment: `
fn shade(inputColor: vec4f, uv: vec2f) -> vec4f {
  let dist = distance(uv, vec2f(0.5, 0.5));
  let v = smoothstep(0.9, 0.35, dist);
  return vec4f(inputColor.rgb * v, inputColor.a);
}
`
});
import { ShaderPass } from '@motion-core/motion-gpu';

const vignette = new ShaderPass({
  fragment: `
fn shade(inputColor: vec4f, uv: vec2f) -> vec4f {
  let dist = distance(uv, vec2f(0.5, 0.5));
  let v = smoothstep(0.9, 0.35, dist);
  return vec4f(inputColor.rgb * v, inputColor.a);
}
`
});

Fragment contract

The fragment must declare:

fn shade(inputColor: vec4f, uv: vec2f) -> vec4f
fn shade(inputColor: vec4f, uv: vec2f) -> vec4f

Where inputColor is the sampled result from the previous pass (or the base shader).

Hot-swapping shaders

You can change the shader at runtime:

vignette.setFragment(`
fn shade(inputColor: vec4f, uv: vec2f) -> vec4f {
  return inputColor; // Passthrough
}
`);
vignette.setFragment(`
fn shade(inputColor: vec4f, uv: vec2f) -> vec4f {
  return inputColor; // Passthrough
}
`);

This invalidates the pipeline cache and recompiles the shader module on next render.

Options

Same as BlitPass, plus:

Option Type Description
fragment string Required. WGSL shader with fn shade(...)
filter GPUFilterMode Sampling filter for the input texture

Pass lifecycle

The renderer tracks each pass by object identity:

Event Action
Pass is new (first appearance in passes array) setSize(width, height) is called
Canvas or render target resizes setSize(width, height) is called again
Pass is removed from array dispose() is called
Renderer is destroyed dispose() is called on all active passes

Multi-pass example

import { ShaderPass } from '@motion-core/motion-gpu';

// Pass 1: Threshold bright pixels
const bloomPrefilter = new ShaderPass({
  fragment: `
fn shade(inputColor: vec4f, uv: vec2f) -> vec4f {
  let luma = dot(inputColor.rgb, vec3f(0.2126, 0.7152, 0.0722));
  let threshold = step(0.8, luma);
  return vec4f(inputColor.rgb * threshold, inputColor.a);
}
`
});

// Pass 2: Gamma correction to canvas
const gamma = new ShaderPass({
  needsSwap: false,
  input: 'source',
  output: 'canvas',
  fragment: `
fn shade(inputColor: vec4f, uv: vec2f) -> vec4f {
  return vec4f(pow(inputColor.rgb, vec3f(1.0 / 2.2)), inputColor.a);
}
`
});
import { ShaderPass } from '@motion-core/motion-gpu';

// Pass 1: Threshold bright pixels
const bloomPrefilter = new ShaderPass({
  fragment: `
fn shade(inputColor: vec4f, uv: vec2f) -> vec4f {
  let luma = dot(inputColor.rgb, vec3f(0.2126, 0.7152, 0.0722));
  let threshold = step(0.8, luma);
  return vec4f(inputColor.rgb * threshold, inputColor.a);
}
`
});

// Pass 2: Gamma correction to canvas
const gamma = new ShaderPass({
  needsSwap: false,
  input: 'source',
  output: 'canvas',
  fragment: `
fn shade(inputColor: vec4f, uv: vec2f) -> vec4f {
  return vec4f(pow(inputColor.rgb, vec3f(1.0 / 2.2)), inputColor.a);
}
`
});
<FragCanvas {material} passes={[bloomPrefilter, gamma]} />

RenderPassContext

When implementing custom passes, the render(context) method receives:

Field Description
device GPUDevice instance
commandEncoder Current frame’s GPUCommandEncoder
source, target, canvas Slot surfaces (texture + view + dimensions + format)
input, output Resolved surfaces for this pass
targets Named render targets map
time, delta Frame timing
width, height Canvas dimensions
clear, clearColor, preserve Resolved flags
beginRenderPass(options?) Helper that creates a GPURenderPassEncoder with correct load/store ops

Compute passes

Compute passes run GPU compute workloads within the same render graph. They do not participate in slot routing and operate on storage buffers and storage textures instead.

ComputePass

Single-dispatch compute pass:

import { ComputePass } from '@motion-core/motion-gpu';

const simulate = new ComputePass({
  compute: `
@compute @workgroup_size(64)
fn compute(@builtin(global_invocation_id) id: vec3u) {
  particles[id.x] = vec4f(f32(id.x) * 0.01, 0.0, 0.0, 1.0);
}
`,
  dispatch: [16]
});
import { ComputePass } from '@motion-core/motion-gpu';

const simulate = new ComputePass({
  compute: `
@compute @workgroup_size(64)
fn compute(@builtin(global_invocation_id) id: vec3u) {
  particles[id.x] = vec4f(f32(id.x) * 0.01, 0.0, 0.0, 1.0);
}
`,
  dispatch: [16]
});

PingPongComputePass

Iterative compute pass for multi-step simulations:

import { PingPongComputePass } from '@motion-core/motion-gpu';

const diffuse = new PingPongComputePass({
  compute: myDiffuseShader,
  target: 'sim',
  iterations: 8,
  dispatch: 'auto'
});
import { PingPongComputePass } from '@motion-core/motion-gpu';

const diffuse = new PingPongComputePass({
  compute: myDiffuseShader,
  target: 'sim',
  iterations: 8,
  dispatch: 'auto'
});

Compute in the render graph

Compute and render passes coexist in the passes array:

<FragCanvas {material} passes={[simulate, bloomPrefilter, gamma]} />

Compute passes dispatch their workgroups before the scene render, so storage textures and buffers are up-to-date when the fragment shader samples them. Render passes execute after the scene as post-processing steps. All passes share the same command encoder.

For full compute shader documentation, see Compute Shaders and Storage Buffers.