TSL Nodes
TSL (Three Shader Language) is a node-based shader system in Three.js that enables custom GPU effects without writing raw WGSL/GLSL code.
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
The 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
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
// 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
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
For 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
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
Convert frame uniform to UV coordinates:
import { spriteUV } from '@three-flatland/nodes';
const frameUV = spriteUV(frameUniform);// Use frameUV for texture sampling or effectsColor Nodes
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
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
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
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
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
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
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
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
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
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
Reduce color levels:
import { posterize } from '@three-flatland/nodes';
// Reduce to 4 levels per channelconst posterized = posterize(color, 4);Combining Effects
With Material Effects, multiple effects can be active simultaneously. Each effect chains through the previous color:
const flash = new DamageFlash();const outline = new SelectOutline();
sprite.addEffect(flash);sprite.addEffect(outline);
// Both effects are active — outline 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
- 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
- Material Effects pack per-sprite data into fixed-size GPU buffers, avoiding per-effect draw calls
Next Steps
- TSL Nodes Example — Interactive demo of 8 material effects
- Pass Effects Guide — Full-screen post-processing with CRT, LCD, VHS, and more