What you’ll learn
- How to author a
MaterialEffectwith typed per-instance parameters (createMaterialEffect) - The catalog of effect nodes from
@three-flatland/nodes— color, retro, dissolve, outline, dithering - How to compose multiple effects on a single sprite via
addEffect()/removeEffect()
TSL (Three Shader Language) is a node-based shader system in Three.js that enables custom GPU effects without writing raw WGSL/GLSL code.
If you’ve been writing GLSL strings or onBeforeCompile hacks for years, this page is the unlearning. Composition replaces concatenation; the type system catches you.
three-flatland provides two ways to use TSL:
- Material Effects (recommended) — define reusable, per-sprite effects with
createMaterialEffectand apply them viaaddEffect()/removeEffect() - Raw TSL nodes — compose low-level node functions directly for full shader control
TSL node functions (tint, hueShift, outline, dissolve, etc.) are exported from @three-flatland/nodes. The effect system (createMaterialEffect, createPassEffect) and core classes are exported from three-flatland.
Material Effects Recommended
Section titled “Material Effects ”RecommendedThe Material Effect system lets you define shader effects as reusable classes with typed, animatable properties. Effects are composed into the sprite’s shader pipeline automatically.
Defining an Effect
Section titled “Defining an Effect”Use createMaterialEffect with a schema (per-sprite data) and a TSL node builder:
import { createMaterialEffect } from 'three-flatland';import { tintAdditive, hueShift } from '@three-flatland/nodes';import { vec4 } from 'three/tsl';
// Damage flash — additive white tint that fadesconst DamageFlash = createMaterialEffect({ name: 'damageFlash', schema: { intensity: 1 } as const, node: ({ inputColor, attrs }) => { const flashed = tintAdditive(inputColor, [1, 1, 1], attrs.intensity); return vec4(flashed.rgb.mul(inputColor.a), inputColor.a); },});
// Hue rotation — continuous rainbow effectconst Powerup = createMaterialEffect({ name: 'powerup', schema: { angle: 0 } as const, node: ({ inputColor, attrs }) => hueShift(inputColor, attrs.angle),});The node callback receives:
inputColor— the previous color in the effect chain (TSL vec4 node)inputUV— atlas UV coordinates (TSL vec2 node)attrs— TSL nodes for each schema field, automatically packed into GPU buffers
Using Effects
Section titled “Using Effects”// Create instance and add to spriteconst flash = new DamageFlash();sprite.addEffect(flash);
// Animate properties in your render loopflash.intensity = Math.max(0, 1 - elapsed / 0.3);
// Remove when donesprite.removeEffect(flash);import { useMemo, useEffect } from 'react';import { useFrame } from '@react-three/fiber/webgpu';
function FlashingSprite({ spriteRef }) { const flash = useMemo(() => new DamageFlash(), []);
useEffect(() => { const sprite = spriteRef.current; sprite.addEffect(flash); return () => sprite.removeEffect(flash); }, [spriteRef, flash]);
useFrame((_, delta) => { flash.intensity = Math.max(0, flash.intensity - delta / 0.3); });}Closure-Captured Textures
Section titled “Closure-Captured Textures”Effects that need texture references can capture them via closures. The node callback runs once during shader compilation:
const noiseTexture = createNoiseTexture();
const Dissolve = createMaterialEffect({ name: 'dissolve', schema: { progress: 0 } as const, node: ({ inputColor, attrs }) => dissolvePixelated(inputColor, uv(), attrs.progress, noiseTexture, 16),});See the TSL Nodes example for all 8 effects in action.
Low-Level TSL Usage Advanced
Section titled “Low-Level TSL Usage ”AdvancedFor full shader control, you can compose TSL nodes directly on a MeshBasicNodeMaterial. This is useful for custom materials outside the sprite pipeline:
import { MeshBasicNodeMaterial } from 'three/webgpu';import { texture as sampleTexture, uv, Fn } from 'three/tsl';import { SpriteSheetLoader } from 'three-flatland';import { outline8, spriteUV } from '@three-flatland/nodes';
const spriteSheet = await SpriteSheetLoader.load('/sprites/character.json');
const material = new MeshBasicNodeMaterial();material.transparent = true;
material.colorNode = Fn(() => { const frameUV = spriteUV(frameUniform); const color = sampleTexture(spriteSheet.texture, frameUV); return outline8(color, frameUV, spriteSheet.texture, { color: [1, 0, 0, 1], thickness: 0.003, });})();Sprite Sampling Nodes
Section titled “Sprite Sampling Nodes”sampleSprite
Section titled “sampleSprite”Sample a texture with frame coordinates and optional alpha test:
import { sampleSprite } from '@three-flatland/nodes';
// Sample with alpha test (discard transparent pixels)const color = sampleSprite(texture, frameUniform, { alphaTest: 0.01 });
// Sample without alpha testconst color = sampleSprite(texture, frameUniform);spriteUV
Section titled “spriteUV”Convert frame uniform to UV coordinates:
import { spriteUV } from '@three-flatland/nodes';
const frameUV = spriteUV(frameUniform);// Use frameUV for texture sampling or effectsColor Nodes
Section titled “Color Nodes”tint / tintAdditive
Section titled “tint / tintAdditive”Apply color tint to a sprite:
import { tint, tintAdditive } from '@three-flatland/nodes';
// Multiplicative tint (darkens)const tinted = tint(color, [1.0, 0.5, 0.5]); // Pink
// Additive tint with strength (damage flash)const flashed = tintAdditive(color, [1, 1, 1], flashStrength);hueShift
Section titled “hueShift”Rotate hue (rainbow effect):
import { hueShift } from '@three-flatland/nodes';
// Shift by radians (animate timeUniform for rainbow)const shifted = hueShift(color, timeUniform.mul(3.0));saturate / grayscale
Section titled “saturate / grayscale”Adjust saturation:
import { saturate, grayscale } from '@three-flatland/nodes';
// Full grayscale (petrified effect)const gray = saturate(color, 0);
// Increase saturationconst vivid = saturate(color, 1.5);
// Direct grayscale conversionconst gray = grayscale(color);brightness / contrast
Section titled “brightness / contrast”Adjust brightness and contrast:
import { brightness, contrast } from '@three-flatland/nodes';
// Brightenconst bright = brightness(color, 0.2);
// Increase contrastconst contrasty = contrast(color, 1.5);Effect Nodes
Section titled “Effect Nodes”outline8
Section titled “outline8”Add outline around sprite (8-direction sampling):
import { outline8 } from '@three-flatland/nodes';
const outlined = outline8(color, uv, texture, { color: [0.3, 1, 0.3, 1], // Green outline thickness: 0.003, // Outline width});pixelate
Section titled “pixelate”Apply pixelation effect:
import { pixelate, pixelateBySize } from '@three-flatland/nodes';
// By pixel countconst pixelatedUV = pixelate(uv, vec2(32, 32));
// By pixel sizeconst pixelatedUV = pixelateBySize(uv, vec2(0.03125));dissolve
Section titled “dissolve”Dissolve effect with noise texture:
import { dissolve, dissolvePixelated, dissolveDirectional } from '@three-flatland/nodes';
// Basic dissolveconst dissolved = dissolve(color, uv, progress, noiseTexture);
// Pixelated dissolve (retro style)const dissolved = dissolvePixelated(color, uv, progress, noiseTexture, 16);
// Directional dissolveconst dissolved = dissolveDirectional(color, uv, progress, direction);Retro Effect Nodes
Section titled “Retro Effect Nodes”colorReplace
Section titled “colorReplace”Replace one color with another:
import { colorReplace, colorReplaceHard, colorReplaceMultiple } from '@three-flatland/nodes';
// Soft replacement with toleranceconst replaced = colorReplace(color, oldColor, newColor, 0.1);
// Hard replacement (exact match)const replaced = colorReplaceHard(color, oldColor, newColor);
// Replace multiple colorsconst replaced = colorReplaceMultiple(color, colorPairs, 0.1);bayerDither
Section titled “bayerDither”Ordered dithering (retro style):
import { bayerDither2x2, bayerDither4x4, bayerDither8x8 } from '@three-flatland/nodes';
// 2x2 matrix ditheringconst dithered = bayerDither2x2(color, levels, scale);
// 4x4 matrix (default)const dithered = bayerDither4x4(color, levels, scale);
// 8x8 matrix (finest)const dithered = bayerDither8x8(color, levels, scale);palettize
Section titled “palettize”Map colors to a palette:
import { palettize, palettizeDithered, palettizeNearest } from '@three-flatland/nodes';
// With dithering (smooth gradients)const paletted = palettizeDithered(color, paletteTexture);
// Nearest color (hard edges)const paletted = palettizeNearest(color, paletteTexture);posterize
Section titled “posterize”Reduce color levels:
import { posterize } from '@three-flatland/nodes';
// Reduce to 4 levels per channelconst posterized = posterize(color, 4);Combining Effects
Section titled “Combining Effects”With Material Effects, multiple effects can be active simultaneously. Each effect chains through the previous color:
const flash = new DamageFlash();const select = new Select();
sprite.addEffect(flash);sprite.addEffect(select);
// Both effects are active — select runs after flash in the shaderFor low-level TSL, chain node functions directly:
material.colorNode = Fn(() => { const color = sampleSprite(texture, frameUniform, { alphaTest: 0.01 }); const shifted = hueShift(color, timeUniform); const outlined = outline8(shifted, spriteUV(frameUniform), texture, { color: [1, 1, 0, 1], thickness: 0.002, }); return outlined;})();Performance Tips
Section titled “Performance Tips”- Complex node graphs increase shader compile time
- Use
uniform()for values that change frequently - Cache compiled materials when reusing effects
- Avoid dynamic branching in hot paths
Next Steps
Section titled “Next Steps”- TSL Nodes Example — Interactive demo of 8 material effects
- Pass Effects Guide — Full-screen post-processing with CRT, LCD, VHS, and more