Skip to content

Full-screen post-processing with composable pass effects.

Same scene with no post-process vs a single CRT pass — barrel curvature, scanlines, and vignette all from one chain. — Raw scene (still)
Same scene with no post-process vs a single CRT pass — barrel curvature, scanlines, and vignette all from one chain. — CRT pass applied (still)
Raw scene CRT pass applied
Same scene with no post-process vs a single CRT pass — barrel curvature, scanlines, and vignette all from one chain.

What you’ll learn

  • The three effect layers (MaterialEffect / LightEffect / PassEffect) and which job each owns
  • How to author a PassEffect with createPassEffect and chain multiple in insertion order
  • The full DefaultLightEffect schema — uniforms vs compile-time constants, removed fields, categoryQuotas

Pass Effects are full-screen post-processing effects applied after sprite rendering. They transform the entire scene output — CRT curvature, scanlines, color quantization, VHS distortion, and more. Pass effects require a Flatland instance.

Pass Effects — three composable effect layers in the render pipelinesampleSpritediffuse rgbaMaterialEffect chainper-instance · orderedEffect 1tint · outline· dissolveEffect 2Effect NNormalMapProvidernormal + elevationLightEffect — SINGLETONexactly one · sharedLightStoretile bins + SDFshadeN·L + attenuation+ shadow traceScene RTcompositedlit colorPassEffect chainpost-process · orderedposterizelcdGridvignette / vhs / crt
Three effect layers in the render pipeline

three-flatland’s effect system is layered, and the layers are easy to conflate because they share a factory shape (createXEffect) and a schema-driven API. Pick the right one for the job:

LayerFactoryScopeWhen to Reach For It
MaterialEffectcreateMaterialEffectPer-sprite fragment shaderDamage flash, outline, dissolve, hue shift — anything that transforms one sprite’s color. Channel providers (NormalMapProvider) are also MaterialEffects — see the Baked Normal Pipeline for how to get a normal-map texture into NormalMapProvider.
LightEffectcreateLightEffectScene singleton — drives every lit spriteChoose or author the lighting model: tiled Forward+, GI, banded cel, etc. Exactly one is bound to a Flatland instance via setLighting(). See the Lighting guide.
PassEffectcreatePassEffectFull-screen post-processCRT, LCD grid, scanlines, VHS, chromatic aberration. Chained on the rendered scene after lighting.

The rest of this guide focuses on PassEffect; for LightEffect authoring see the Lighting guide, and for MaterialEffect see the TSL Nodes guide.

Use createPassEffect with a name, a schema defining parameters, and a pass builder function:

import { createPassEffect } from 'three-flatland'
import { posterize } from '@three-flatland/nodes'
const PosterizePass = createPassEffect({
name: 'posterize',
schema: { bands: 6 },
pass: ({ uniforms }) => (input, uv) => {
return posterize(input, uniforms.bands)
},
})

The pass callback receives a context with TSL uniform nodes for each schema field and returns a PassEffectFn:

type PassEffectFn = (input: Node<'vec4'>, uv: Node<'vec2'>) => Node<'vec4'>
  • input — the scene color (or previous pass output)
  • uv — screen-space UV coordinates

Schema fields become typed properties on instances with zero-cost uniform updates — changing a value updates the GPU uniform directly without rebuilding the shader graph.

import { Flatland, createPassEffect } from 'three-flatland'
import { posterize, crtVignette } from '@three-flatland/nodes'
const PosterizePass = createPassEffect({
name: 'posterize',
schema: { bands: 6 },
pass: ({ uniforms }) => (input, uv) => posterize(input, uniforms.bands),
})
const VignettePass = createPassEffect({
name: 'vignette',
schema: { intensity: 0.4, curvature: 2 },
pass: ({ uniforms }) => (input, uv) =>
crtVignette(input, uv, uniforms.intensity, uniforms.curvature),
})
const flatland = new Flatland({ viewSize: 400, clearColor: 0x1a1a2e })
// Add passes — they chain in insertion order
const post = new PosterizePass()
const vig = new VignettePass()
flatland.addPass(post).addPass(vig)
// Zero-cost parameter updates
post.bands = 10
vig.intensity = 0.25

Multiple passes compose into a single pipeline. Each pass receives the previous pass’s output:

PassEffect chain — color-math passes fuse, UV-sampling passes force a texture boundaryaddEffect() folds the chain into one TSL node graph: node = passFn(node, uv)scene colorpass()ONE FUSED SHADER · no extra texture readsposterizeinputlcdGridinputvignetteinputEach reads only input → math → output, so theyinline into one GPU program. Stack freely;cost stays ~constant.rendertargetconvertToTexture()write to texture,then sample it backUV-SAMPLING PASSchromaticAberrationinput.sample(uv + offset)Samples neighboring pixels → theinput must already be a texture.Each such pass costs a fullRT round-trip.also: crtCurvature, vhsDistortion(any pass that bends/samples UV)Takeaway: stack color-math passes for free — they fuse into one program.Each UV-sampling pass inserts a texture round-trip, so reach for them with intent.
Pass-chain cost — color-math passes fuse into one GPU program; a UV-sampling pass forces a convertToTexture boundary (render to texture, then sample).
flatland.addPass(posterize) // 1. Reduce color bands
flatland.addPass(lcdGrid) // 2. Apply LCD pixel grid
flatland.addPass(backlight) // 3. Add backlight bleed
flatland.addPass(vignette) // 4. Darken edges

Swap pass chains at runtime:

flatland.clearPasses()
// Apply a new preset
const crt = new CRTPass()
flatland.addPass(crt)
MethodDescription
flatland.addPass(pass, order?)Add a pass (optional explicit order)
flatland.removePass(pass)Remove a specific pass
flatland.clearPasses()Remove all passes
flatland.passesRead-only array of current passes
pass.enabledToggle a pass without removing it

Some effects distort UVs — they read pixels at offset positions. These require convertToTexture(input) to create a sampable texture from the node graph:

import { convertToTexture } from 'three/tsl'
import { createPassEffect } from 'three-flatland'
import { vhsDistortion } from '@three-flatland/nodes'
const VHSPass = createPassEffect({
name: 'vhs',
schema: { time: 0, intensity: 0.012, noiseAmount: 0.05 },
pass: ({ uniforms }) => (input, uv) => {
const tex = convertToTexture(input)
return vhsDistortion(tex, uv, uniforms.time, uniforms.intensity, uniforms.noiseAmount)
},
})

Effects that need convertToTexture: crtComplete, crtCurvature, vhsDistortion, chromaticAberration.

Effects that work directly on the color node: posterize, quantize, scanlines, lcdGrid, crtVignette, lcdBacklightBleed, staticNoise.

Schema properties update the GPU uniform directly — no shader recompilation:

const vhs = new VHSPass()
flatland.addPass(vhs)
// In the render loop
vhs.time = elapsed // Drive distortion animation
vhs.intensity = 0.02 // Adjust strength

All post-processing nodes are exported from @three-flatland/nodes:

NodeDescription
crtCompleteFull CRT simulation (curvature + scanlines + bloom + vignette + color bleed)
crtCurvatureBarrel distortion for curved screen
crtVignetteEdge darkening
crtBloomGlow from bright pixels
crtColorBleedRGB channel offset
crtConvergenceRGB convergence offset
NodeDescription
scanlinesSmoothSine-wave scanline overlay
scanlinesHard scanline pattern
scanlinesGlowBright scanline edges
scanlinesInterlacedAlternating-field interlace
NodeDescription
lcdGridVisible pixel grid (handheld console)
lcdBacklightBleedUneven backlight glow
dotMatrixDot matrix display pattern
lcdPocketGame Boy-style LCD
lcdGBCGame Boy Color LCD
NodeDescription
posterizeReduce color bands
quantize8-bit color reduction
bayerDither4x4Ordered dithering
palettizeMap to a color palette
NodeDescription
vhsDistortionVHS tracking errors and wave distortion
staticNoiseAnalog TV snow
chromaticAberrationRGB channel separation
ntscCompositeNTSC signal artifacts
analogGlitchRandom glitch bands

DefaultLightEffect is a LightEffect, not a PassEffect — but it’s the most heavily-customized effect in the preset set, and its schema is documented here as the canonical place readers will look for “what knobs does the recommended lighting actually have?”. For an introduction to LightEffects in general, see the Lighting guide.

The schema splits cleanly into two flavors: uniforms that update at zero cost, and compile-time constants that trigger a shader rebuild when assigned.

Assigning these is a .value = write on a TSL uniform node — costs nothing. Animate them in your render loop without thinking about cost.

UniformDefaultDescription
shadowStrength0.6Lerp factor from lit → traced shadow. 0 disables shadow contribution; 1 is full opacity.
shadowBias0.5Hit epsilon (world units) — SDF samples below this count as an occluder strike, terminating the trace.
shadowStartOffsetScale1Multiplier on each sprite’s per-instance shadowRadius when the trace origin sits inside that caster’s silhouette. Nudge above 1.0 if you see residual self-shadow on elongated sprites.
shadowMaxDistance0Max world-space distance a shadow extends from the receiver before fading. 0 disables falloff (binary shadow at any distance). Typical: 100–300 — keeps near-caster shadows solid while hiding cone-fan artifacts far from the caster.
shadowPixelSize0Snap the shadow trace’s surface position to a world-unit block grid (1/2/4/8). Purely aesthetic — does NOT reduce GPU cost.
bands8Cel-band count for the direct-light contribution.
pixelSize0Surface-position snap cell width for the lighting math.
glowRadius0Broad secondary falloff radius (multiplied into per-light effective distance).
glowIntensity0Strength of the broad glow added to point/spot attenuation.
lightHeight0.75Universal +Z component added to every light’s direction — higher reads as more top-lit.
rimIntensity0Strength of the rim-lighting accumulator. Rim power is fixed at (1 − N·L)².

Compile-Time Constants (writable, but each write rebuilds)

Section titled “Compile-Time Constants (writable, but each write rebuilds)”

These are stored as primitive constants on the effect instance. Setting them is legal at runtime, but each assignment triggers a shader rebuild and emits a dev-mode warning:

[three-flatland] defaultLight.glowEnabled changed at runtime — triggers shader rebuild

The payoff: when off, the gated branch never reaches the GPU, so a scene that doesn’t use glow / rim / snap pays zero ops for those features.

ConstantDefaultWhat it gates
glowEnabledfalseBroad-falloff “glow” added to point/spot attenuation. ~9 ops per light per fragment when on.
rimEnabledfalseRim-lighting accumulator + the inner (1 − N·L)² per-light term. ~6 ops per light per fragment when on.
pixelSnapEnabledfalseSurface-position snap for the lighting math (chunky cel cells).
shadowPixelSnapEnabledfalseSurface-position snap for the shadow trace origin (blocky shadow silhouettes).
bandsEnabledtrueCel-band quantization of the direct-light contribution.

Pattern in the example: derive the boolean from the uniform crossing zero, so the shader rebuilds on the 0 → N transition and live-updates while the slider stays above 0.

const bandsEnabled = bands > 0
const pixelSnapEnabled = pixelSize > 0
const shadowPixelSnapEnabled = shadowPixelSize > 0
const glowEnabled = glowRadius > 0
const rimEnabled = rimIntensity > 0

The following fields existed in earlier versions and have been removed. If you’re migrating, delete the assignments — they’ll be silently undefined now.

  • rimPower — rim sharpness is now hardcoded as (1 − N·L)². Saves a per-light pow() (an SFU instruction) for the case nobody actually slid at runtime. Fork the effect if you need a different curve.
  • capShadowStrength / capShadowThreshold — the manual wall-cap darkening hack is replaced by elevation-aware N·L. Walls whose elevation exceeds lightHeight physically receive a negative L.z, clamp N·L to zero, and end up with only ambient — no special-case uniform required.

DefaultLightEffect instances expose a categoryQuotas accessor that proxies to the underlying forwardPlus.setFillQuota(category, quota) API. It’s purely a declarative convenience for JSX users who don’t want to grab a ref and reach into forwardPlus:

<defaultLightEffect
attach={attachLighting}
categoryQuotas={{ slime: 4 }}
/>

Each set RESETS all buckets to default first, then applies the record — so removing a key between renders correctly clears that bucket’s quota override (rather than leaving it stuck at the previous value).

For the full story on what these buckets are and how category maps onto them — including the per-category quota tuning APIs — see Shadow Casting, Importance & Categories in the Lighting guide.