Rendering

Render Passes

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


Motion GPU supports post-processing through a post-scene render graph that sequences render passes over ping-pong buffers and optional named render targets. The same passes array also accepts compute passes, but compute passes are extracted into a separate pre-scene compute phase and never participate in slot routing.

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 with two execution groups:

  1. All enabled compute passes dispatch first, in declaration order. They update storage buffers/textures before the scene shader samples them.
  2. The base shader renders your material’s fn frag(...). If post-process render passes or a final color pipeline are enabled, it renders into an internal surface; otherwise it renders directly to canvas.
  3. Enabled render passes execute after the base shader, in declaration order. Each render pass reads from an input slot, processes the image, and writes to an output slot.
  4. The renderer presents the resolved final surface to the physical canvas. With color.toneMapping, color.dynamicRange, or color.workingFormat, this final step is a private presentation pass that applies color transforms after all render passes.
<FragCanvas {material} passes={[passA, passB, passC]} />
<FragCanvas {material} passes={[passA, passB, passC]} />

Without render passes and without a color presentation pipeline, the base shader renders directly to the canvas. Compute-only pipelines keep that direct path. Enabling Khronos PBR Neutral, HDR presentation, or a custom workingFormat allocates an internal presentation surface so the final color transform can remain last.

Slot semantics

Slot Purpose
source Current scene/result surface
target Ping-pong companion (allocated when needed)
canvas Final output slot. Usually the user-visible canvas; in HDR/tone-mapped presentation it is a logical internal target that is presented to the physical canvas after all passes.
<targetName> Named off-screen surface declared in renderTargets

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

When a final color pipeline is active, render passes should still write output: 'canvas' for their final result. The renderer maps that logical slot to an internal rgba16float target when needed, then runs the private presentation pass to the physical swapchain. Custom passes should use context.output or context.beginRenderPass() instead of manually writing to the physical canvas.

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 render-pass slot usage before execution. Compute passes are kept in the plan for diagnostics and pre-scene dispatch ordering, but they do not make slots available and cannot satisfy render-pass read dependencies.

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: final composite to canvas
const composite = new ShaderPass({
  needsSwap: false,
  input: 'source',
  output: 'canvas',
  fragment: `
fn shade(inputColor: vec4f, uv: vec2f) -> vec4f {
  return inputColor;
}
`
});
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: final composite to canvas
const composite = new ShaderPass({
  needsSwap: false,
  input: 'source',
  output: 'canvas',
  fragment: `
fn shade(inputColor: vec4f, uv: vec2f) -> vec4f {
  return inputColor;
}
`
});
<FragCanvas {material} passes={[bloomPrefilter, composite]} />
<FragCanvas {material} passes={[bloomPrefilter, composite]} />

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). canvas is logical when a final color pipeline is active.
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 from the same passes prop, but they execute in the pre-scene phase. 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 frame

Compute and render passes coexist in the passes array:

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

All enabled compute passes dispatch before the scene render, regardless of where render passes appear in the array. Their relative order against other compute passes is preserved. Enabled render passes then execute after the scene as post-processing steps, preserving their relative order against other render passes. All passes share the same command encoder and one queue submit.

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