Skip to content
Back to Examples

Pass Effects

Dynamic post-processing with composable PassEffects.

import { WebGPURenderer } from 'three/webgpu'
import { convertToTexture } from 'three/tsl'
import { Color } from 'three'
import type TextureNode from 'three/src/nodes/accessors/TextureNode.js'
import {
Flatland,
Sprite2D,
TextureLoader,
createPassEffect,
} from 'three-flatland'
import { gemGradientNode } from './GemBackground'
import { GEM } from './gem'
import type { PassEffect } from 'three-flatland'
import {
// CRT display nodes
crtComplete,
crtVignette,
// Scanline nodes
scanlinesSmooth,
// LCD display nodes
lcdGrid,
lcdBacklightBleed,
// Retro color nodes
posterize,
quantize,
// Analog video nodes
vhsDistortion,
staticNoise,
chromaticAberration,
} from '@three-flatland/nodes'
import { createPane } from '@three-flatland/devtools'
import type { FolderApi } from 'tweakpane'
// ─── PassEffect Definitions ─────────────────────────────────────────────────
/**
* CRT Arcade — Full CRT monitor simulation with curvature, scanlines,
* bloom, vignette, and color bleed. Like playing at the local arcade.
*/
const CRTPass = createPassEffect({
name: 'crt',
schema: {
curvature: 0.08,
scanlineIntensity: 0.18,
vignetteIntensity: 0.3,
bloomIntensity: 0.15,
colorBleed: 0.0012,
},
pass: ({ uniforms }) => (input, uv) => {
const tex = convertToTexture(input) as TextureNode<'vec4'>
return crtComplete(tex, uv, {
curvature: uniforms.curvature,
scanlineIntensity: uniforms.scanlineIntensity,
vignetteIntensity: uniforms.vignetteIntensity,
bloomIntensity: uniforms.bloomIntensity,
colorBleed: uniforms.colorBleed,
})
},
})
/**
* LCD Grid — Visible pixel grid like a GBA or handheld console.
* Applied after posterization for the chunky handheld look.
*/
const LCDGridPass = createPassEffect({
name: 'lcdGrid',
schema: {
resolution: 200,
gridIntensity: 0.18,
subpixelIntensity: 0.12,
},
pass: ({ uniforms }) => (input, uv) => {
return lcdGrid(input, uv, uniforms.resolution, uniforms.gridIntensity, uniforms.subpixelIntensity)
},
})
/**
* Posterize — Reduce color bands for retro palette looks.
* 4 bands = Game Boy feel, 8 bands = early PC, 16 bands = subtle.
*/
const PosterizePass = createPassEffect({
name: 'posterize',
schema: { bands: 6 },
pass: ({ uniforms }) => (input, _uv) => {
return posterize(input, uniforms.bands)
},
})
/**
* Quantize — 8-bit color reduction for retro PC look.
*/
const QuantizePass = createPassEffect({
name: 'quantize',
schema: { levels: 8 },
pass: ({ uniforms }) => (input, _uv) => {
return quantize(input, uniforms.levels)
},
})
/**
* Smooth Scanlines — Sine-wave scanline overlay.
*/
const ScanlinesPass = createPassEffect({
name: 'scanlines',
schema: {
resolution: 300,
intensity: 0.2,
},
pass: ({ uniforms }) => (input, uv) => {
return scanlinesSmooth(input, uv, uniforms.resolution, uniforms.intensity)
},
})
/**
* VHS Distortion — Tracking errors, color separation, and wave distortion.
* Needs texture sampling for UV distortion. Time-driven animation.
*/
const VHSPass = createPassEffect({
name: 'vhs',
schema: {
time: 0,
intensity: 0.012,
noiseAmount: 0.05,
},
pass: ({ uniforms }) => (input, uv) => {
const tex = convertToTexture(input) as TextureNode<'vec4'>
return vhsDistortion(tex, uv, uniforms.time, uniforms.intensity, uniforms.noiseAmount)
},
})
/**
* Static Noise — Analog TV snow/static overlay.
*/
const StaticPass = createPassEffect({
name: 'static',
schema: {
time: 0,
intensity: 0.04,
},
pass: ({ uniforms }) => (input, uv) => {
return staticNoise(input, uv, uniforms.time, uniforms.intensity)
},
})
/**
* Chromatic Aberration — RGB channel separation for worn analog feel.
*/
const AberrationPass = createPassEffect({
name: 'aberration',
schema: { amount: 0.003 },
pass: ({ uniforms }) => (input, uv) => {
const tex = convertToTexture(input) as TextureNode<'vec4'>
return chromaticAberration(tex, uv, uniforms.amount)
},
})
/**
* Vignette — Edge darkening for focused display look.
*/
const VignettePass = createPassEffect({
name: 'vignette',
schema: {
intensity: 0.4,
curvature: 2,
},
pass: ({ uniforms }) => (input, uv) => {
return crtVignette(input, uv, uniforms.intensity, uniforms.curvature)
},
})
/**
* LCD Backlight Bleed — Uneven backlight for handheld LCD feel.
*/
const BacklightPass = createPassEffect({
name: 'backlight',
schema: { intensity: 0.12 },
pass: ({ uniforms }) => (input, uv) => {
return lcdBacklightBleed(input, uv, uniforms.intensity)
},
})
// ─── Preset Configurations ──────────────────────────────────────────────────
type PresetName = 'clean' | 'crt' | 'lcd' | 'vhs' | 'retro'
interface ActivePreset {
passes: PassEffect[]
/** Passes that need `time` updated each frame */
timeDriven: { pass: PassEffect & { time: number } }[]
}
function createPreset(name: PresetName): ActivePreset {
switch (name) {
case 'clean':
return { passes: [], timeDriven: [] }
case 'crt': {
const crt = new CRTPass()
return { passes: [crt], timeDriven: [] }
}
case 'lcd': {
const post = new PosterizePass()
;(post as PassEffect & { bands: number }).bands = 10
const grid = new LCDGridPass()
const bleed = new BacklightPass()
const vig = new VignettePass()
;(vig as PassEffect & { intensity: number }).intensity = 0.25
return { passes: [post, grid, bleed, vig], timeDriven: [] }
}
case 'vhs': {
const vhs = new VHSPass()
const noise = new StaticPass()
const aber = new AberrationPass()
return {
passes: [vhs, noise, aber],
timeDriven: [
{ pass: vhs as PassEffect & { time: number } },
{ pass: noise as PassEffect & { time: number } },
],
}
}
case 'retro': {
const quant = new QuantizePass()
const scan = new ScanlinesPass()
const vig = new VignettePass()
;(vig as PassEffect & { intensity: number }).intensity = 0.2
return { passes: [quant, scan, vig], timeDriven: [] }
}
}
}
// ─── Default Slider Values per Preset ──────────────────────────────────────
const CRT_DEFAULTS = {
curvature: 0.08,
scanlineIntensity: 0.18,
vignetteIntensity: 0.3,
bloomIntensity: 0.15,
colorBleed: 0.0012,
}
const LCD_DEFAULTS = {
resolution: 200,
gridIntensity: 0.18,
subpixelIntensity: 0.12,
bands: 10,
}
const VHS_DEFAULTS = {
intensity: 0.012,
noiseAmount: 0.05,
aberration: 0.003,
}
const RETRO_DEFAULTS = {
levels: 8,
scanResolution: 300,
scanIntensity: 0.2,
}
// ─── Scene Setup ────────────────────────────────────────────────────────────
/** Sprite arrangement — a small grid of tinted sprites like game pickups */
const SPRITE_LAYOUT = [
{ x: 0, y: 0, scale: 120, tint: 0xffffff }, // Center — white
{ x: -100, y: 60, scale: 60, tint: 0xff6b9d }, // Top-left — pink
{ x: 100, y: 60, scale: 60, tint: 0x47cca9 }, // Top-right — teal
{ x: -100, y: -60, scale: 60, tint: 0xffd166 }, // Bottom-left — gold
{ x: 100, y: -60, scale: 60, tint: 0x6b9dff }, // Bottom-right — blue
{ x: 0, y: 120, scale: 40, tint: 0xbb86fc }, // Top — purple
{ x: 0, y: -120, scale: 40, tint: 0xff8a65 }, // Bottom — orange
]
/* HMR-tracked teardown state. Without this, every dev save accumulates
* a fresh renderer + animate() loop while the previous one keeps
* RAFing forever. Dev-only — `import.meta.hot` is undefined in prod. */
let rafId = 0
let activeRenderer: WebGPURenderer | null = null
async function main() {
// Gem-tinted L2 backdrop matching the masonry tile poster. The
// Flatland clearColor matches the docs --card token (#16191e) so
// any pre-shader-compile flash matches body bg, no color jump.
const flatland = new Flatland({
viewSize: 400,
clearColor: 0x16191e,
})
;(flatland.scene as any).backgroundNode = gemGradientNode({ gem: GEM })
const renderer = new WebGPURenderer({ antialias: false })
activeRenderer = renderer
renderer.setSize(window.innerWidth, window.innerHeight)
renderer.setPixelRatio(1) // Pixel-perfect for pixel art
renderer.domElement.style.imageRendering = 'pixelated'
document.body.appendChild(renderer.domElement)
await renderer.init()
flatland.resize(window.innerWidth, window.innerHeight)
// Load texture and create sprite scene
const texture = await TextureLoader.load('./icon.svg')
const sprites: Sprite2D[] = []
for (const layout of SPRITE_LAYOUT) {
const sprite = new Sprite2D({
texture,
anchor: [0.5, 0.5],
})
sprite.scale.set(layout.scale, layout.scale, 1)
sprite.position.set(layout.x, layout.y, 0)
sprite.tint = new Color(layout.tint)
flatland.add(sprite)
sprites.push(sprite)
}
// ─── Pass Effect State ──────────────────────────────────────────────────
let activePreset: ActivePreset = createPreset('clean')
let elapsed = 0
// Slider param objects (mutated by tweakpane bindings)
const crtParams = { ...CRT_DEFAULTS }
const lcdParams = { ...LCD_DEFAULTS }
const vhsParams = { ...VHS_DEFAULTS }
const retroParams = { ...RETRO_DEFAULTS }
function applyPreset(name: PresetName) {
// Remove old passes
flatland.clearPasses()
// Reset slider params to defaults
Object.assign(crtParams, CRT_DEFAULTS)
Object.assign(lcdParams, LCD_DEFAULTS)
Object.assign(vhsParams, VHS_DEFAULTS)
Object.assign(retroParams, RETRO_DEFAULTS)
// Create and add new preset
activePreset = createPreset(name)
for (const p of activePreset.passes) {
flatland.addPass(p)
}
// Refresh pane to show reset values
pane.refresh()
}
// ─── Tweakpane UI ───────────────────────────────────────────────────────
const { pane, update: updateDevtools } = createPane({ driver: 'manual' })
// ─── CRT Folder ─────────────────────────────────────────────────────────
const crtFolder = pane.addFolder({ title: 'CRT', hidden: true })
crtFolder.addBinding(crtParams, 'curvature', { min: 0, max: 0.2, step: 0.01 })
crtFolder.addBinding(crtParams, 'scanlineIntensity', { min: 0, max: 0.5, step: 0.01 })
crtFolder.addBinding(crtParams, 'vignetteIntensity', { min: 0, max: 1, step: 0.01 })
crtFolder.addBinding(crtParams, 'bloomIntensity', { min: 0, max: 0.5, step: 0.01 })
crtFolder.addBinding(crtParams, 'colorBleed', { min: 0, max: 0.005, step: 0.0001 })
crtFolder.on('change', () => {
const crt = activePreset.passes[0]
if (!crt) return
const c = crt as PassEffect & typeof CRT_DEFAULTS
c.curvature = crtParams.curvature
c.scanlineIntensity = crtParams.scanlineIntensity
c.vignetteIntensity = crtParams.vignetteIntensity
c.bloomIntensity = crtParams.bloomIntensity
c.colorBleed = crtParams.colorBleed
})
// ─── LCD Folder ─────────────────────────────────────────────────────────
const lcdFolder = pane.addFolder({ title: 'LCD', hidden: true })
lcdFolder.addBinding(lcdParams, 'resolution', { min: 50, max: 500, step: 10 })
lcdFolder.addBinding(lcdParams, 'gridIntensity', { min: 0, max: 0.5, step: 0.01 })
lcdFolder.addBinding(lcdParams, 'subpixelIntensity', { min: 0, max: 0.3, step: 0.01 })
lcdFolder.addBinding(lcdParams, 'bands', { min: 2, max: 16, step: 1 })
lcdFolder.on('change', () => {
// LCD preset: [posterize, lcdGrid, backlight, vignette]
const post = activePreset.passes[0] as PassEffect & { bands: number } | undefined
const grid = activePreset.passes[1] as PassEffect & { resolution: number; gridIntensity: number; subpixelIntensity: number } | undefined
if (post) post.bands = lcdParams.bands
if (grid) {
grid.resolution = lcdParams.resolution
grid.gridIntensity = lcdParams.gridIntensity
grid.subpixelIntensity = lcdParams.subpixelIntensity
}
})
// ─── VHS Folder ─────────────────────────────────────────────────────────
const vhsFolder = pane.addFolder({ title: 'VHS', hidden: true })
vhsFolder.addBinding(vhsParams, 'intensity', { min: 0, max: 0.05, step: 0.001 })
vhsFolder.addBinding(vhsParams, 'noiseAmount', { min: 0, max: 0.2, step: 0.005 })
vhsFolder.addBinding(vhsParams, 'aberration', { min: 0, max: 0.01, step: 0.001 })
vhsFolder.on('change', () => {
// VHS preset: [vhs, static, aberration]
const vhs = activePreset.passes[0] as PassEffect & { intensity: number; noiseAmount: number } | undefined
const aber = activePreset.passes[2] as PassEffect & { amount: number } | undefined
if (vhs) {
vhs.intensity = vhsParams.intensity
vhs.noiseAmount = vhsParams.noiseAmount
}
if (aber) aber.amount = vhsParams.aberration
})
// ─── Retro Folder ───────────────────────────────────────────────────────
const retroFolder = pane.addFolder({ title: 'Retro', hidden: true })
retroFolder.addBinding(retroParams, 'levels', { min: 2, max: 16, step: 1 })
retroFolder.addBinding(retroParams, 'scanResolution', { min: 100, max: 500, step: 10 })
retroFolder.addBinding(retroParams, 'scanIntensity', { min: 0, max: 0.5, step: 0.01 })
retroFolder.on('change', () => {
// Retro preset: [quantize, scanlines, vignette]
const quant = activePreset.passes[0] as PassEffect & { levels: number } | undefined
const scan = activePreset.passes[1] as PassEffect & { resolution: number; intensity: number } | undefined
if (quant) quant.levels = retroParams.levels
if (scan) {
scan.resolution = retroParams.scanResolution
scan.intensity = retroParams.scanIntensity
}
})
// ─── Monitors ───────────────────────────────────────────────────────────
const monitors = { passCount: 0 }
const monitorFolder = pane.addFolder({ title: 'Passes', expanded: false })
monitorFolder.addBinding(monitors, 'passCount', { readonly: true, format: (v: number) => v.toFixed(0) })
// ─── Preset Selector (at bottom) ───────────────────────────────────────
const params = { preset: 'clean' as string }
const presetBinding = pane.addBinding(params, 'preset', {
label: 'Preset',
options: {
Clean: 'clean',
'CRT Arcade': 'crt',
Handheld: 'lcd',
'VHS Tape': 'vhs',
'Retro PC': 'retro',
},
})
// ─── Folder Visibility Toggle ───────────────────────────────────────────
const folders: Record<string, FolderApi> = {
crt: crtFolder,
lcd: lcdFolder,
vhs: vhsFolder,
retro: retroFolder,
}
presetBinding.on('change', (ev) => {
const value = ev.value as PresetName
for (const [key, folder] of Object.entries(folders)) {
folder.hidden = key !== value
}
applyPreset(value)
})
// ─── Resize ─────────────────────────────────────────────────────────────
window.addEventListener('resize', () => {
renderer.setSize(window.innerWidth, window.innerHeight)
flatland.resize(window.innerWidth, window.innerHeight)
})
// ─── Render Loop ────────────────────────────────────────────────────────
let lastTime = performance.now()
let refreshTimer = 0
function animate() {
rafId = requestAnimationFrame(animate)
const now = performance.now()
const delta = (now - lastTime) / 1000
lastTime = now
elapsed += delta
// Gentle floating animation on sprites
for (let i = 0; i < sprites.length; i++) {
const layout = SPRITE_LAYOUT[i]!
const offset = i * 0.7
sprites[i]!.position.y = layout.y + Math.sin(elapsed * 1.2 + offset) * 6
}
// Update time-driven passes
for (const { pass } of activePreset.timeDriven) {
pass.time = elapsed
}
flatland.render(renderer)
updateDevtools()
// Update monitors periodically
refreshTimer += delta
if (refreshTimer >= 0.5) {
monitors.passCount = activePreset.passes.length
pane.refresh()
refreshTimer = 0
}
}
animate()
// ─── Single-shot scene recorder ──────────────────────────────────
//
// Console-callable:
// await window.__captureScene('passfx-off', 3000)
// await window.__captureScene('passfx-on', 3000)
//
// Records the *current* visual state. Two files land in Downloads:
// <name>.webm — durationMs of canvas video
// <name>-poster.jpg — first-frame still
//
// Manual workflow:
// 1. Pick the "off" preset (Clean) via Tweakpane.
// 2. Run `await __captureScene('passfx-off', 3000)`.
// 3. Switch to the "on" preset (e.g. CRT Arcade).
// 4. Run `await __captureScene('passfx-on', 3000)`.
// 5. Drop the four files into docs/public/diagrams/.
//
// To keep both videos animation-phase-aligned, the capture resets
// `elapsed` (which drives sprite float + VHS/static pass time) so
// both clips start at t=0.
;(window as Window & {
__captureScene?: (name: string, durationMs?: number) => Promise<void>
}).__captureScene = async function captureScene(name: string, durationMs = 3000): Promise<void> {
if (!name || typeof name !== 'string') {
console.error('[captureScene] usage: __captureScene("passfx-off", 3000)')
return
}
const mainCanvas = renderer.domElement as HTMLCanvasElement
function pickMimeType(): string {
const candidates = [
'video/webm;codecs=vp9',
'video/webm;codecs=vp8',
'video/webm',
'video/mp4',
]
for (const m of candidates) {
if (typeof MediaRecorder !== 'undefined' && MediaRecorder.isTypeSupported(m)) return m
}
return ''
}
function downloadBlob(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
a.remove()
setTimeout(() => URL.revokeObjectURL(url), 1000)
}
async function capturePoster(filename: string): Promise<void> {
const dataUrl = mainCanvas.toDataURL('image/jpeg', 0.9)
const blob = await (await fetch(dataUrl)).blob()
downloadBlob(blob, filename)
}
async function recordVideo(filename: string): Promise<void> {
const stream = mainCanvas.captureStream(60)
const mimeType = pickMimeType()
const recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined)
const chunks: Blob[] = []
recorder.ondataavailable = (e: BlobEvent) => {
if (e.data && e.data.size > 0) chunks.push(e.data)
}
return new Promise<void>((resolve) => {
recorder.onstop = () => {
const blob = new Blob(chunks, { type: mimeType || 'video/webm' })
downloadBlob(blob, filename)
resolve()
}
recorder.start()
setTimeout(() => recorder.stop(), durationMs)
})
}
// Reset animation phase so back-to-back captures start at t=0.
elapsed = 0
for (const { pass } of activePreset.timeDriven) pass.time = 0
await new Promise((r) => requestAnimationFrame(r))
console.log(`[captureScene] poster + ${durationMs}ms video → ${name}.webm + ${name}-poster.jpg`)
await capturePoster(`${name}-poster.jpg`)
await recordVideo(`${name}.webm`)
console.log(`[captureScene] done — ${name}.webm + ${name}-poster.jpg in Downloads.`)
}
}
main()
if (import.meta.hot) {
import.meta.hot.dispose(() => {
if (rafId) {
cancelAnimationFrame(rafId)
rafId = 0
}
if (activeRenderer) {
activeRenderer.dispose?.()
activeRenderer.domElement.remove()
activeRenderer = null
}
})
}
import { Canvas, extend, useLoader, useFrame, useThree } from '@react-three/fiber/webgpu'
import { useRef, useEffect } from 'react'
import { convertToTexture } from 'three/tsl'
import type { WebGPURenderer } from 'three/webgpu'
import type TextureNode from 'three/src/nodes/accessors/TextureNode.js'
import {
Flatland,
Sprite2D,
TextureLoader,
createPassEffect,
} from 'three-flatland/react'
import type { PassEffect } from 'three-flatland/react'
import { GemBackground } from './GemBackground'
import { GEM } from './gem'
import {
crtComplete,
crtVignette,
scanlinesSmooth,
lcdGrid,
lcdBacklightBleed,
posterize,
quantize,
vhsDistortion,
staticNoise,
chromaticAberration,
} from '@three-flatland/nodes'
import { usePane, usePaneInput } from '@three-flatland/devtools/react'
extend({ Flatland, Sprite2D })
// ─── PassEffect Definitions (from original — uses `pass:` API) ──────────────
const CRTPass = createPassEffect({
name: 'crt',
schema: {
curvature: 0.08,
scanlineIntensity: 0.18,
vignetteIntensity: 0.3,
bloomIntensity: 0.15,
colorBleed: 0.0012,
},
pass: ({ uniforms }) => (input, uv) => {
const tex = convertToTexture(input) as TextureNode<'vec4'>
return crtComplete(tex, uv, {
curvature: uniforms.curvature,
scanlineIntensity: uniforms.scanlineIntensity,
vignetteIntensity: uniforms.vignetteIntensity,
bloomIntensity: uniforms.bloomIntensity,
colorBleed: uniforms.colorBleed,
})
},
})
const LCDGridPass = createPassEffect({
name: 'lcdGrid',
schema: { resolution: 200, gridIntensity: 0.18, subpixelIntensity: 0.12 },
pass: ({ uniforms }) => (input, uv) =>
lcdGrid(input, uv, uniforms.resolution, uniforms.gridIntensity, uniforms.subpixelIntensity),
})
const PosterizePass = createPassEffect({
name: 'posterize',
schema: { bands: 6 },
pass: ({ uniforms }) => (input) => posterize(input, uniforms.bands),
})
const QuantizePass = createPassEffect({
name: 'quantize',
schema: { levels: 8 },
pass: ({ uniforms }) => (input) => quantize(input, uniforms.levels),
})
const ScanlinesPass = createPassEffect({
name: 'scanlines',
schema: { resolution: 300, intensity: 0.2 },
pass: ({ uniforms }) => (input, uv) =>
scanlinesSmooth(input, uv, uniforms.resolution, uniforms.intensity),
})
const VHSPass = createPassEffect({
name: 'vhs',
schema: { time: 0, intensity: 0.012, noiseAmount: 0.05 },
pass: ({ uniforms }) => (input, uv) => {
const tex = convertToTexture(input) as TextureNode<'vec4'>
return vhsDistortion(tex, uv, uniforms.time, uniforms.intensity, uniforms.noiseAmount)
},
})
const StaticPass = createPassEffect({
name: 'static',
schema: { time: 0, intensity: 0.04 },
pass: ({ uniforms }) => (input, uv) =>
staticNoise(input, uv, uniforms.time, uniforms.intensity),
})
const AberrationPass = createPassEffect({
name: 'aberration',
schema: { amount: 0.003 },
pass: ({ uniforms }) => (input, uv) => {
const tex = convertToTexture(input) as TextureNode<'vec4'>
return chromaticAberration(tex, uv, uniforms.amount)
},
})
const VignettePass = createPassEffect({
name: 'vignette',
schema: { intensity: 0.4, curvature: 2 },
pass: ({ uniforms }) => (input, uv) =>
crtVignette(input, uv, uniforms.intensity, uniforms.curvature),
})
const BacklightPass = createPassEffect({
name: 'backlight',
schema: { intensity: 0.12 },
pass: ({ uniforms }) => (input, uv) =>
lcdBacklightBleed(input, uv, uniforms.intensity),
})
// ─── Preset Types ───────────────────────────────────────────────────────────
type PresetName = 'clean' | 'crt' | 'lcd' | 'vhs' | 'retro'
interface ActivePreset {
passes: PassEffect[]
timeDriven: { pass: PassEffect & { time: number } }[]
}
function createPreset(name: PresetName): ActivePreset {
switch (name) {
case 'clean':
return { passes: [], timeDriven: [] }
case 'crt':
return { passes: [new CRTPass()], timeDriven: [] }
case 'lcd': {
const post = new PosterizePass()
;(post as PassEffect & { bands: number }).bands = 10
const grid = new LCDGridPass()
const bleed = new BacklightPass()
const vig = new VignettePass()
;(vig as PassEffect & { intensity: number }).intensity = 0.25
return { passes: [post, grid, bleed, vig], timeDriven: [] }
}
case 'vhs': {
const vhs = new VHSPass()
const noise = new StaticPass()
const aber = new AberrationPass()
return {
passes: [vhs, noise, aber],
timeDriven: [
{ pass: vhs as PassEffect & { time: number } },
{ pass: noise as PassEffect & { time: number } },
],
}
}
case 'retro': {
const quant = new QuantizePass()
const scan = new ScanlinesPass()
const vig = new VignettePass()
;(vig as PassEffect & { intensity: number }).intensity = 0.2
return { passes: [quant, scan, vig], timeDriven: [] }
}
}
}
// ─── Sprite Layout ──────────────────────────────────────────────────────────
const SPRITE_LAYOUT = [
{ x: 0, y: 0, scale: 120, tint: '#ffffff' },
{ x: -100, y: 60, scale: 60, tint: '#ff6b9d' },
{ x: 100, y: 60, scale: 60, tint: '#47cca9' },
{ x: -100, y: -60, scale: 60, tint: '#ffd166' },
{ x: 100, y: -60, scale: 60, tint: '#6b9dff' },
{ x: 0, y: 120, scale: 40, tint: '#bb86fc' },
{ x: 0, y: -120, scale: 40, tint: '#ff8a65' },
] as const
function SpriteScene() {
const texture = useLoader(TextureLoader, './icon.svg')
const spritesRef = useRef<Sprite2D[]>([])
const timeRef = useRef(0)
useFrame((_, delta) => {
timeRef.current += delta
const t = timeRef.current
for (let i = 0; i < spritesRef.current.length; i++) {
const sprite = spritesRef.current[i]
const layout = SPRITE_LAYOUT[i]
if (sprite && layout) {
sprite.position.y = layout.y + Math.sin(t * 1.2 + i * 0.7) * 1.2
}
}
})
return (
<>
{SPRITE_LAYOUT.map((layout, i) => (
<sprite2D
key={i}
ref={(el: Sprite2D | null) => {
if (el) spritesRef.current[i] = el
}}
texture={texture}
tint={layout.tint}
anchor={[0.5, 0.5]}
position={[layout.x, layout.y, 0]}
scale={[layout.scale, layout.scale, 1]}
/>
))}
</>
)
}
// ─── FlatlandScene (receives preset as prop, matches original architecture) ─
function FlatlandScene({ preset }: { preset: PresetName }) {
const flatlandRef = useRef<Flatland>(null)
const gl = useThree((s) => s.gl)
const presetRef = useRef<ActivePreset>({ passes: [], timeDriven: [] })
const elapsedRef = useRef(0)
// Apply preset when it changes (effect fires after mount, flatlandRef is ready)
useEffect(() => {
const flatland = flatlandRef.current
if (!flatland) return
flatland.clearPasses()
const active = createPreset(preset)
for (const p of active.passes) flatland.addPass(p)
presetRef.current = active
elapsedRef.current = 0
}, [preset])
useFrame((_state, delta) => {
elapsedRef.current += delta
for (const { pass } of presetRef.current.timeDriven) {
pass.time = elapsedRef.current
}
})
// Render in the 'render' phase so R3F skips its own render.
const size = useThree((s) => s.size)
useFrame(() => {
const flatland = flatlandRef.current
if (!flatland) return
flatland.resize(size.width, size.height)
flatland.render(gl as unknown as WebGPURenderer)
}, { phase: 'render' })
return (
<flatland ref={flatlandRef} viewSize={400} clearColor={0x1a1a2e}>
<SpriteScene />
</flatland>
)
}
// ─── App ────────────────────────────────────────────────────────────────────
export default function App() {
const { pane } = usePane()
const [preset] = usePaneInput<string>(pane, 'preset', 'clean', {
label: 'Preset',
options: {
Clean: 'clean',
'CRT Arcade': 'crt',
Handheld: 'lcd',
'VHS Tape': 'vhs',
'Retro PC': 'retro',
},
})
return (
<Canvas
orthographic
dpr={1}
camera={{ zoom: 5, position: [0, 0, 100] }}
renderer={{ antialias: false }}
onCreated={({ gl }) => {
gl.domElement.style.imageRendering = 'pixelated'
}}
>
<GemBackground gem={GEM} />
<FlatlandScene preset={preset as PresetName} />
</Canvas>
)
}

This example demonstrates the Pass Effect system for full-screen post-processing. Switch between 5 presets to see different effect chains applied to a sprite scene:

PresetPassesDescription
Clean0No post-processing
CRT Arcade1Full CRT composite — curvature, scanlines, bloom, vignette, and color bleed
Handheld4Posterize + LCD grid + backlight bleed + vignette
VHS Tape3VHS distortion + static noise + chromatic aberration
Retro PC3Color quantize + scanlines + vignette

Use createPassEffect with a schema for parameters and a TSL node builder:

import { createPassEffect } from 'three-flatland'
import { crtComplete } from '@three-flatland/nodes'
import { convertToTexture } from 'three/tsl'
const CRTPass = createPassEffect({
name: 'crt',
schema: {
curvature: 0.08,
scanlineIntensity: 0.18,
vignetteIntensity: 0.3,
bloomIntensity: 0.15,
colorBleed: 0.0012,
},
pass: ({ uniforms }) => (input, uv) => {
const tex = convertToTexture(input)
return crtComplete(tex, uv, {
curvature: uniforms.curvature,
scanlineIntensity: uniforms.scanlineIntensity,
vignetteIntensity: uniforms.vignetteIntensity,
bloomIntensity: uniforms.bloomIntensity,
colorBleed: uniforms.colorBleed,
})
},
})

Instantiate a pass and add it to Flatland. Passes chain in order — each receives the previous output:

const crt = new CRTPass()
flatland.addPass(crt)
// Animate parameters in the render loop
crt.curvature = 0.12
// Swap presets by clearing and adding new passes
flatland.clearPasses()
flatland.addPass(new PosterizePass()).addPass(new VignettePass())