templates/shader/src/rainbow/rainbow-example.md
A WebGL shader that renders colorful rainbow distance fields around tldraw shapes, creating discrete color bands that adapt to dark/light mode.
react() to subscribe to shape changes, camera changes, and theme changesonUpdate iterates through all shapes on the current pageeditor.getShapeGeometry() to get shape verticesThe fragment shader (fragment.glsl) calculates distance from each pixel to the nearest shape edge:
pixelPos = v_uv * u_resolutionmaxSegments)closestPointOnSegment to find closest point on each segmentrotateHue function to get rainbow color for the bandThe rotateHue function (fragment.glsl:47-58) cycles through the full spectrum:
offset parameter rotates colors along the spectrummaxSegments calculation: Math.floor(Math.min(512, 2000 / quality))stepSize (1-50): Distance between color bands in pixels
steps (1-100): Number of color bands to render
offset (0-1): Color rotation offset
The rotateHue function (fragment.glsl:47-58) can be modified for different color schemes:
Warm colors only:
vec3 rotateHue(float step, float steps, float offset) {
float t = mod(step + offset * steps, steps) / steps;
return mix(vec3(1.0, 0.0, 0.0), vec3(1.0, 1.0, 0.0), t); // Red to Yellow
}
Custom gradient:
vec3 rotateHue(float step, float steps, float offset) {
float t = mod(step + offset * steps, steps) / steps;
vec3 color1 = vec3(0.2, 0.5, 1.0); // Light blue
vec3 color2 = vec3(1.0, 0.2, 0.5); // Pink
return mix(color1, color2, t);
}
Modify the quantization (fragment.glsl:139-140):
Smooth gradient instead of bands:
// Remove these lines:
// float bandIndex = floor(minDist / (maxDistance/steps));
// minDist = bandIndex * (maxDistance/steps);
// Use continuous distance:
float t = minDist / maxDistance;
vec3 rainbowColor = rotateHue(t * steps, steps, u_offset);
Non-uniform band spacing:
float bandIndex = pow(minDist / maxDistance, 2.0) * steps; // Exponential spacing
The shader includes fbm (Fractal Brownian Motion) function (lines 74-86):
void main() {
// ... existing distance calculation ...
// Add noise to distance
float noiseValue = fbm(pixelPos * 0.01);
minDist += noiseValue * 5.0;
// ... rest of shader ...
}
Use the time parameter in onRender:
onRender = (deltaTime: number, currentTime: number): void => {
// ... existing code ...
// Auto-rotate colors
const animatedOffset = (currentTime * 0.1) % 1.0
if (this.u_offset) {
this.gl.uniform1f(this.u_offset, animatedOffset)
}
// Pulse effect
const pulseStepSize = 10 + Math.sin(currentTime * 2) * 5
if (this.u_stepSize) {
this.gl.uniform1f(this.u_stepSize, pulseStepSize)
}
}
Modify onUpdate to process only certain shapes (RainbowShaderManager.ts:155):
onUpdate = (): void => {
const shapes = this.editor.getCurrentPageShapes()
this.geometries = []
for (const shape of shapes) {
// Filter by type
if (shape.type === 'geo' || shape.type === 'draw') {
const geometry = this.extractGeometry(shape, camera, vsb)
if (geometry) {
this.geometries.push(geometry)
}
}
}
}
Extend extractGeometry for shape-specific effects (line 284):
private extractGeometry = (
shape: TLShape,
camera: { x: number; y: number; z: number },
viewportScreenBounds: Box
): Array<{ start: Vec; end: Vec }> | null => {
// Get base geometry
const segments = /* ... existing code ... */
// Add extra segments for specific shape types
if (shape.type === 'draw') {
// Sample more points from draw shapes for smoother curves
// ... custom sampling logic ...
}
return segments
}
In fragment.glsl: Add uniform declaration
uniform float u_brightness;
In RainbowShaderManager.ts:
private u_brightness: WebGLUniformLocation | null = null
onInitialize (after line 127):
this.u_brightness = this.gl.getUniformLocation(this.program, 'u_brightness')
onRender (around line 213):
if (this.u_brightness) {
this.gl.uniform1f(this.u_brightness, this.getConfig().brightness)
}
In config.ts: Add to interface and defaults (lines 4-17)
In RainbowConfigPanel.tsx: Add slider range (line 8)
The shader has a maximum segment limit to stay within WebGL uniform limits:
this.maxSegments = Math.floor(Math.min(512, 2000 / quality))
The example uses startPaused: true (config.ts:11) for better performance:
false for continuous animationtick() manually to trigger rendersquality to process fewer pixelssteps to reduce color calculationsrotateHue if using solid colorsvoid main() {
// ... distance calculation ...
// Different effects based on distance
if (minDist < 10.0) {
fragColor = vec4(1.0, 1.0, 1.0, 1.0); // White core
} else if (minDist < 50.0) {
fragColor = vec4(rainbowColor, 1.0); // Solid rainbow
} else {
fragColor = vec4(rainbowColor, alpha); // Faded rainbow
}
}
Pass additional data through a separate uniform array:
// In RainbowShaderManager.ts
const allColors: number[] = []
for (const geometry of this.geometries) {
// Extract color from shape
const color = getShapeColor(shape)
allColors.push(color.r, color.g, color.b)
}
this.gl.uniform3fv(this.u_colors, allColors)
Visualize the distance field directly:
void main() {
// ... distance calculation ...
// Grayscale visualization of distance
float visualDist = minDist / maxDistance;
fragColor = vec4(vec3(visualDist), 1.0);
}
For other shader patterns, see: