Skip to content

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:

  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.

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

Using Effects

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

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 test
const 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 effects

Color 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 saturation
const vivid = saturate(color, 1.5);
// Direct grayscale conversion
const gray = grayscale(color);

brightness / contrast

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

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 count
const pixelatedUV = pixelate(uv, vec2(32, 32));
// By pixel size
const pixelatedUV = pixelateBySize(uv, vec2(0.03125));

dissolve

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

Retro Effect Nodes

colorReplace

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

bayerDither

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

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

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