Skip to content

Create custom shader effects using Three Shader Language.

What you’ll learn

  • How to author a MaterialEffect with 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:

  1. Material Effects (recommended) — define reusable, per-sprite effects with createMaterialEffect and apply them via addEffect() / removeEffect()
  2. 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.

Composing a sprite's effect chaineach MaterialEffect's node receives inputColor and returns a vec4 — effects chain in addEffect order into one materialbase colorsampleSprite(texture,frame) -> vec4atlas readtintinputColor.mul(rgb)attrs: colorhueShiftrotate hue by angleattrs: angleoutline88-dir edge sampleattrs: color, thicknessfragment outputcolorNode -> screen(then lighting)final coloreffect stack — sprite.addEffect(...) order = chain ordereffect stack — sprite.addEffect(...) order = chain orderinputvec4vec4vec4const Tint = createMaterialEffect({ name: 'tint', schema: { color: [1, 1, 1] } as const, node: ({ inputColor, attrs }) => tint(inputColor, attrs.color), // returns vec4})sprite.addEffect(new Tint()) // appends to the chainWhy a chain, not string concatenation:- each node is a pure vec4 -> vec4 transform; the previous color flows in as inputColor.- the type system checks attrs against the schema.- per-sprite attrs pack into fixed GPU buffers, so adding effects costs shader ops, not draw calls.- order matters: outline after tint outlines the tinted sprite. Reorder by addEffect order.
Composing a sprite's effect chain — the base texture color flows through each effect node as inputColor and out as a vec4, chaining in addEffect order into one node material.

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.

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 fades
const 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 effect
const 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
// Create instance and add to sprite
const flash = new DamageFlash();
sprite.addEffect(flash);
// Animate properties in your render loop
flash.intensity = Math.max(0, 1 - elapsed / 0.3);
// Remove when done
sprite.removeEffect(flash);

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.

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,
});
})();

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 test
const color = sampleSprite(texture, frameUniform);

Convert frame uniform to UV coordinates:

import { spriteUV } from '@three-flatland/nodes';
const frameUV = spriteUV(frameUniform);
// Use frameUV for texture sampling or effects

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);

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));

Adjust saturation:

import { saturate, grayscale } from '@three-flatland/nodes';
// Full grayscale (petrified effect)
const gray = saturate(color, 0);
// Increase saturation
const vivid = saturate(color, 1.5);
// Direct grayscale conversion
const gray = grayscale(color);

Adjust brightness and contrast:

import { brightness, contrast } from '@three-flatland/nodes';
// Brighten
const bright = brightness(color, 0.2);
// Increase contrast
const contrasty = contrast(color, 1.5);

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
});

Apply pixelation effect:

import { pixelate, pixelateBySize } from '@three-flatland/nodes';
// By pixel count
const pixelatedUV = pixelate(uv, vec2(32, 32));
// By pixel size
const pixelatedUV = pixelateBySize(uv, vec2(0.03125));

Dissolve effect with noise texture:

import { dissolve, dissolvePixelated, dissolveDirectional } from '@three-flatland/nodes';
// Basic dissolve
const dissolved = dissolve(color, uv, progress, noiseTexture);
// Pixelated dissolve (retro style)
const dissolved = dissolvePixelated(color, uv, progress, noiseTexture, 16);
// Directional dissolve
const dissolved = dissolveDirectional(color, uv, progress, direction);

Replace one color with another:

import { colorReplace, colorReplaceHard, colorReplaceMultiple } from '@three-flatland/nodes';
// Soft replacement with tolerance
const replaced = colorReplace(color, oldColor, newColor, 0.1);
// Hard replacement (exact match)
const replaced = colorReplaceHard(color, oldColor, newColor);
// Replace multiple colors
const replaced = colorReplaceMultiple(color, colorPairs, 0.1);

Ordered dithering (retro style):

import { bayerDither2x2, bayerDither4x4, bayerDither8x8 } from '@three-flatland/nodes';
// 2x2 matrix dithering
const dithered = bayerDither2x2(color, levels, scale);
// 4x4 matrix (default)
const dithered = bayerDither4x4(color, levels, scale);
// 8x8 matrix (finest)
const dithered = bayerDither8x8(color, levels, scale);

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);

Reduce color levels:

import { posterize } from '@three-flatland/nodes';
// Reduce to 4 levels per channel
const posterized = posterize(color, 4);

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 shader

For 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;
})();
  • 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