Rendering

Render Passes

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


MotionGPU 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, and the three built-in pass types.

For named render targets, see Render Targets.

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]} />
<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
    target
    swap → A’s output is now in
    source
  3. Pass B: reads
    source
    , writes
    target
    swap → 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 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 colour
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]} />
<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