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 base shader renders your material’s into the
fn frag(...)slot.source - Each pass reads from an input slot, processes the image, and writes to an output slot.
- After all passes execute, the final output is presented to the canvas (direct if output is , otherwise via blit from
canvas,source, or named target).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 |
canvasDefault ping-pong flow
By default, passes read from
sourcetarget- Base shader →
source - Pass A: reads , writes
source→ swap → A’s output is now intargetsource - Pass B: reads , writes
source→ swap → B’s output is now intargetsource - Final: is blitted to
sourcecanvas
Non-swapping passes
If
needsSwap: falsecanvasconst 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(...)| Rule | Error condition |
|---|---|
needsSwap: truesource → target | Any other slot pairing with swap enabled |
| Input slot must be available | Reading target |
canvas | 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
inputoutputimport { 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 (sourcetarget |
output | 'target' | Output slot (sourcetargetcanvas |
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
copyTextureToTextureimport { 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 === falsepreserve === true- Source and target are different textures
- Neither is the canvas texture
- Same width, height, and format
When any condition fails,
CopyPassBlitPassOptions are identical to
BlitPassShaderPass
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) -> vec4ffn shade(inputColor: vec4f, uv: vec2f) -> vec4fWhere
inputColorHot-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| 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 | setSize(width, height) |
| Canvas or render target resizes | setSize(width, height) |
| Pass is removed from array | dispose() |
| Renderer is destroyed | dispose() |
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)| Field | Description |
|---|---|
device | GPUDevice |
commandEncoder | Current frame’s GPUCommandEncoder |
sourcetargetcanvas | Slot surfaces (texture + view + dimensions + format) |
inputoutput | Resolved surfaces for this pass |
targets | Named render targets map |
timedelta | Frame timing |
widthheight | Canvas dimensions |
clearclearColorpreserve | Resolved flags |
beginRenderPass(options?) | Helper that creates a GPURenderPassEncoder |