snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
This commit is contained in:
54
templates/shader/src/rainbow/RainbowConfigPanel.tsx
Normal file
54
templates/shader/src/rainbow/RainbowConfigPanel.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useValue } from 'tldraw'
|
||||
import { ConfigPanel } from '../config-panel/ConfigPanel'
|
||||
import { ConfigPanelBooleanControl } from '../config-panel/ConfigPanelBooleanControl'
|
||||
import { ConfigPanelSlider } from '../config-panel/ConfigPanelSlider'
|
||||
import { resetShaderConfig, shaderConfig } from './config'
|
||||
|
||||
const SLIDER_CONFIGS: Record<string, { min: number; max: number }> = {
|
||||
quality: { min: 0.1, max: 1 },
|
||||
stepSize: { min: 1, max: 50 },
|
||||
steps: { min: 1, max: 100 },
|
||||
}
|
||||
|
||||
export function RainbowConfigPanel() {
|
||||
const config = useValue('config', () => shaderConfig.get(), [])
|
||||
|
||||
const handleChange = useCallback((prop: string, value: number | boolean) => {
|
||||
shaderConfig.update((prev) => ({ ...prev, [prop]: value }))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<ConfigPanel onReset={resetShaderConfig}>
|
||||
{Object.entries(config).map(([prop, value]) => {
|
||||
if (typeof value === 'number') {
|
||||
const sliderConfig = SLIDER_CONFIGS[prop] || { min: 0, max: 1 }
|
||||
return (
|
||||
<ConfigPanelSlider
|
||||
key={prop}
|
||||
prop={prop}
|
||||
label={prop}
|
||||
min={sliderConfig.min}
|
||||
max={sliderConfig.max}
|
||||
value={value}
|
||||
type="float"
|
||||
onChange={handleChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return (
|
||||
<ConfigPanelBooleanControl
|
||||
key={prop}
|
||||
prop={prop}
|
||||
label={prop}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})}
|
||||
</ConfigPanel>
|
||||
)
|
||||
}
|
||||
25
templates/shader/src/rainbow/RainbowRenderer.tsx
Normal file
25
templates/shader/src/rainbow/RainbowRenderer.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { memo, useLayoutEffect, useRef } from 'react'
|
||||
import { useEditor } from 'tldraw'
|
||||
import { WebGLCanvas } from '../WebGLCanvas'
|
||||
import { shaderConfig } from './config'
|
||||
import { RainbowShaderManager } from './RainbowShaderManager'
|
||||
|
||||
export const RainbowRenderer = memo(() => {
|
||||
const editor = useEditor()
|
||||
const rCanvas = useRef<HTMLCanvasElement>(null)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const manager = new RainbowShaderManager(editor, rCanvas.current!, shaderConfig)
|
||||
|
||||
const handlePointerMove = (e: PointerEvent) => manager.pointerMove(e.clientX, e.clientY)
|
||||
|
||||
window.addEventListener('pointermove', handlePointerMove)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', handlePointerMove)
|
||||
manager.dispose()
|
||||
}
|
||||
}, [editor])
|
||||
|
||||
return <WebGLCanvas ref={rCanvas} config={shaderConfig} />
|
||||
})
|
||||
299
templates/shader/src/rainbow/RainbowShaderManager.ts
Normal file
299
templates/shader/src/rainbow/RainbowShaderManager.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
import { Atom, Box, Editor, Group2d, react, TLShape, Vec } from 'tldraw'
|
||||
import { WebGLManager } from '../WebGLManager'
|
||||
import { ShaderManagerConfig } from './config'
|
||||
import FRAGMENT_SHADER from './fragment.glsl?raw'
|
||||
import VERTEX_SHADER from './vertex.glsl?raw'
|
||||
|
||||
export type Geometry = Array<{ start: Vec; end: Vec }>
|
||||
|
||||
export class RainbowShaderManager extends WebGLManager<ShaderManagerConfig> {
|
||||
private program: WebGLProgram | null = null
|
||||
private positionBuffer: WebGLBuffer | null = null
|
||||
private vao: WebGLVertexArrayObject | null = null
|
||||
|
||||
private u_resolution: WebGLUniformLocation | null = null
|
||||
private u_darkMode: WebGLUniformLocation | null = null
|
||||
private u_quality: WebGLUniformLocation | null = null
|
||||
private u_zoom: WebGLUniformLocation | null = null
|
||||
private u_stepSize: WebGLUniformLocation | null = null
|
||||
private u_steps: WebGLUniformLocation | null = null
|
||||
private u_offset: WebGLUniformLocation | null = null
|
||||
private u_segments: WebGLUniformLocation | null = null
|
||||
private u_segmentCount: WebGLUniformLocation | null = null
|
||||
private u_pointerPos: WebGLUniformLocation | null = null
|
||||
|
||||
private geometries: Geometry[] = []
|
||||
private maxSegments: number = 2000
|
||||
|
||||
private pointer: Vec = new Vec(0.5, 0.5)
|
||||
|
||||
constructor(
|
||||
editor: Editor,
|
||||
canvas: HTMLCanvasElement,
|
||||
config: Atom<ShaderManagerConfig, unknown>
|
||||
) {
|
||||
super(editor, canvas, config)
|
||||
this.initialize()
|
||||
}
|
||||
|
||||
onInitialize = (): void => {
|
||||
this.maxSegments = Math.floor(Math.min(512, 2000 / this.getConfig().quality))
|
||||
|
||||
if (!this.gl) {
|
||||
console.error('No WebGL context available')
|
||||
return
|
||||
}
|
||||
|
||||
if (this.gl.isContextLost()) {
|
||||
console.error('WebGL context is lost, cannot initialize')
|
||||
return
|
||||
}
|
||||
|
||||
const maxTextureSize = this.gl.getParameter(this.gl.MAX_TEXTURE_SIZE)
|
||||
|
||||
if (!maxTextureSize) {
|
||||
console.error('WebGL context appears invalid (max texture size is null)')
|
||||
return
|
||||
}
|
||||
|
||||
const gl = this.gl
|
||||
const compileShader = (type: number, source: string): WebGLShader | null => {
|
||||
const shader = gl.createShader(type)
|
||||
if (!shader) {
|
||||
console.error('Failed to create shader - createShader returned null')
|
||||
return null
|
||||
}
|
||||
|
||||
gl.shaderSource(shader, source)
|
||||
gl.compileShader(shader)
|
||||
|
||||
if (gl.getShaderParameter(shader, gl.COMPILE_STATUS) !== true) {
|
||||
const shaderType = type === gl.VERTEX_SHADER ? 'VERTEX' : 'FRAGMENT'
|
||||
const log = gl.getShaderInfoLog(shader)
|
||||
console.error(`${shaderType} shader compile error:`, log || 'No error log available')
|
||||
console.error('Shader source:', source)
|
||||
gl.deleteShader(shader)
|
||||
return null
|
||||
}
|
||||
|
||||
return shader
|
||||
}
|
||||
|
||||
const vertexShader = compileShader(this.gl.VERTEX_SHADER, VERTEX_SHADER)
|
||||
if (!vertexShader) {
|
||||
console.error('Failed to compile vertex shader, aborting initialization')
|
||||
return
|
||||
}
|
||||
|
||||
const fragmentShader = compileShader(this.gl.FRAGMENT_SHADER, FRAGMENT_SHADER)
|
||||
if (!fragmentShader) {
|
||||
console.error('Failed to compile fragment shader, aborting initialization')
|
||||
this.gl.deleteShader(vertexShader)
|
||||
return
|
||||
}
|
||||
|
||||
this.program = this.gl.createProgram()
|
||||
|
||||
if (!this.program) {
|
||||
console.error('Failed to create program')
|
||||
return
|
||||
}
|
||||
|
||||
this.gl.attachShader(this.program, vertexShader)
|
||||
this.gl.attachShader(this.program, fragmentShader)
|
||||
this.gl.linkProgram(this.program)
|
||||
|
||||
if (!this.gl.getProgramParameter(this.program, this.gl.LINK_STATUS)) {
|
||||
console.error('Program link error:', this.gl.getProgramInfoLog(this.program))
|
||||
return
|
||||
}
|
||||
|
||||
this.u_resolution = this.gl.getUniformLocation(this.program, 'u_resolution')
|
||||
this.u_darkMode = this.gl.getUniformLocation(this.program, 'u_darkMode')
|
||||
this.u_quality = this.gl.getUniformLocation(this.program, 'u_quality')
|
||||
this.u_zoom = this.gl.getUniformLocation(this.program, 'u_zoom')
|
||||
this.u_stepSize = this.gl.getUniformLocation(this.program, 'u_stepSize')
|
||||
this.u_steps = this.gl.getUniformLocation(this.program, 'u_steps')
|
||||
this.u_offset = this.gl.getUniformLocation(this.program, 'u_offset')
|
||||
this.u_segments = this.gl.getUniformLocation(this.program, 'u_segments')
|
||||
this.u_segmentCount = this.gl.getUniformLocation(this.program, 'u_segmentCount')
|
||||
this.u_pointerPos = this.gl.getUniformLocation(this.program, 'u_pointerPos')
|
||||
|
||||
this.positionBuffer = this.gl.createBuffer()
|
||||
|
||||
this.vao = this.gl.createVertexArray()
|
||||
this.gl.bindVertexArray(this.vao)
|
||||
|
||||
this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.positionBuffer)
|
||||
const positions = new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1])
|
||||
this.gl.bufferData(this.gl.ARRAY_BUFFER, positions, this.gl.STATIC_DRAW)
|
||||
|
||||
const a_position = this.gl.getAttribLocation(this.program, 'a_position')
|
||||
this.gl.enableVertexAttribArray(a_position)
|
||||
this.gl.vertexAttribPointer(a_position, 2, this.gl.FLOAT, false, 0, 0)
|
||||
|
||||
this.gl.bindVertexArray(null)
|
||||
|
||||
this.disposables.add(react('dependencies', this.tick))
|
||||
|
||||
this.tick()
|
||||
}
|
||||
|
||||
onUpdate = (): void => {
|
||||
const { editor } = this
|
||||
const vsb = editor.getViewportScreenBounds()
|
||||
const camera = editor.getCamera()
|
||||
this.geometries = []
|
||||
|
||||
for (const shape of editor.getCurrentPageShapes()) {
|
||||
try {
|
||||
const geometry = this.extractGeometry(shape, camera, vsb)
|
||||
if (geometry) {
|
||||
this.geometries.push(geometry)
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(`Error extracting geometry for shape: ${shape.type}`, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onFirstRender = (): void => {
|
||||
if (!this.gl || !this.program) return
|
||||
|
||||
this.gl.viewport(0, 0, this.canvas.width, this.canvas.height)
|
||||
this.gl.clearColor(0, 0, 0, 0)
|
||||
this.gl.enable(this.gl.BLEND)
|
||||
this.gl.blendFunc(this.gl.SRC_ALPHA, this.gl.ONE_MINUS_SRC_ALPHA)
|
||||
this.gl.useProgram(this.program)
|
||||
}
|
||||
|
||||
onRender = (_deltaTime: number, _currentTime: number): void => {
|
||||
if (!this.gl || !this.program) return
|
||||
|
||||
this.gl.clear(this.gl.COLOR_BUFFER_BIT)
|
||||
|
||||
const isDarkMode = this.editor.user.getIsDarkMode()
|
||||
|
||||
const { quality, stepSize, steps, offset } = this.getConfig()
|
||||
|
||||
if (this.u_resolution) {
|
||||
this.gl.uniform2f(this.u_resolution, this.canvas.width, this.canvas.height)
|
||||
}
|
||||
if (this.u_darkMode) {
|
||||
this.gl.uniform1f(this.u_darkMode, isDarkMode ? 1.0 : 0.0)
|
||||
}
|
||||
if (this.u_quality) {
|
||||
this.gl.uniform1f(this.u_quality, quality)
|
||||
}
|
||||
if (this.u_zoom) {
|
||||
this.gl.uniform1f(this.u_zoom, this.editor.getZoomLevel())
|
||||
}
|
||||
if (this.u_stepSize) {
|
||||
this.gl.uniform1f(this.u_stepSize, stepSize)
|
||||
}
|
||||
if (this.u_steps) {
|
||||
this.gl.uniform1i(this.u_steps, steps)
|
||||
}
|
||||
if (this.u_offset) {
|
||||
this.gl.uniform1f(this.u_offset, offset)
|
||||
}
|
||||
if (this.u_pointerPos) {
|
||||
this.gl.uniform2f(this.u_pointerPos, this.pointer.x, this.pointer.y)
|
||||
}
|
||||
|
||||
const allSegments: number[] = []
|
||||
|
||||
for (const geometry of this.geometries) {
|
||||
for (const segment of geometry) {
|
||||
if (allSegments.length < this.maxSegments * 4) {
|
||||
allSegments.push(segment.start.x, segment.start.y, segment.end.x, segment.end.y)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const farAway = 999999
|
||||
while (allSegments.length < this.maxSegments * 4)
|
||||
allSegments.push(farAway, farAway, farAway, farAway)
|
||||
|
||||
if (this.u_segments) {
|
||||
this.gl.uniform4fv(this.u_segments, allSegments)
|
||||
}
|
||||
if (this.u_segmentCount) {
|
||||
this.gl.uniform1f(this.u_segmentCount, Math.min(allSegments.length / 4, this.maxSegments))
|
||||
}
|
||||
|
||||
this.gl.bindVertexArray(this.vao)
|
||||
this.gl.drawArrays(this.gl.TRIANGLE_STRIP, 0, 4)
|
||||
this.gl.bindVertexArray(null)
|
||||
}
|
||||
|
||||
onDispose = (): void => {
|
||||
if (this.gl) {
|
||||
if (this.vao) {
|
||||
this.gl.deleteVertexArray(this.vao)
|
||||
this.vao = null
|
||||
}
|
||||
if (this.positionBuffer) {
|
||||
this.gl.deleteBuffer(this.positionBuffer)
|
||||
this.positionBuffer = null
|
||||
}
|
||||
if (this.program) {
|
||||
this.gl.deleteProgram(this.program)
|
||||
this.program = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pointerMove = (x: number, y: number): void => {
|
||||
const vsb = this.editor.getViewportScreenBounds()
|
||||
this.pointer.x = (x - vsb.x) / vsb.width
|
||||
this.pointer.y = 1.0 - (y - vsb.y) / vsb.height
|
||||
this.tick()
|
||||
}
|
||||
|
||||
private pageToCanvas = (
|
||||
point: Vec,
|
||||
camera: { x: number; y: number; z: number },
|
||||
viewportScreenBounds: Box
|
||||
): Vec => {
|
||||
const canvasX = ((point.x + camera.x) * camera.z) / viewportScreenBounds.width
|
||||
const canvasY = 1.0 - ((point.y + camera.y) * camera.z) / viewportScreenBounds.height
|
||||
return new Vec(canvasX, canvasY)
|
||||
}
|
||||
|
||||
private extractGeometry = (
|
||||
shape: TLShape,
|
||||
camera: { x: number; y: number; z: number },
|
||||
viewportScreenBounds: Box
|
||||
): Geometry | null => {
|
||||
const geometry = this.editor.getShapeGeometry(shape)
|
||||
const transform = this.editor.getShapePageTransform(shape)
|
||||
if (!transform) return null
|
||||
|
||||
const canvasPoints = geometry.vertices.map((c) =>
|
||||
this.pageToCanvas(transform.applyToPoint(c), camera, viewportScreenBounds)
|
||||
)
|
||||
|
||||
const segments: Geometry = []
|
||||
|
||||
for (let i = 0; i < canvasPoints.length - 1; i++) {
|
||||
segments.push({ start: canvasPoints[i], end: canvasPoints[i + 1] })
|
||||
}
|
||||
|
||||
const isClosed =
|
||||
geometry instanceof Group2d
|
||||
? (geometry.children[0]?.isClosed ?? true)
|
||||
: 'isClosed' in geometry
|
||||
? geometry.isClosed
|
||||
: true
|
||||
|
||||
if (isClosed && canvasPoints.length > 0) {
|
||||
segments.push({
|
||||
start: canvasPoints[canvasPoints.length - 1],
|
||||
end: canvasPoints[0],
|
||||
})
|
||||
}
|
||||
|
||||
return segments
|
||||
}
|
||||
}
|
||||
38
templates/shader/src/rainbow/config.ts
Normal file
38
templates/shader/src/rainbow/config.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { atom, react } from 'tldraw'
|
||||
import { WebGLManagerConfig } from '../WebGLManager'
|
||||
|
||||
export interface ShaderManagerConfig extends WebGLManagerConfig {
|
||||
stepSize: number
|
||||
steps: number
|
||||
offset: number
|
||||
}
|
||||
|
||||
export const DEFAULT_CONFIG: ShaderManagerConfig = {
|
||||
startPaused: true,
|
||||
quality: 0.5,
|
||||
stepSize: 10,
|
||||
steps: 10,
|
||||
offset: 0,
|
||||
pixelate: true,
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'shader-rainbow-config-1'
|
||||
|
||||
let initialValue = DEFAULT_CONFIG
|
||||
|
||||
try {
|
||||
const value = localStorage.getItem(STORAGE_KEY)
|
||||
if (value) initialValue = JSON.parse(value)
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
|
||||
export const shaderConfig = atom<ShaderManagerConfig>('shader-config', initialValue)
|
||||
|
||||
export function resetShaderConfig() {
|
||||
shaderConfig.set(DEFAULT_CONFIG)
|
||||
}
|
||||
|
||||
react('save to local storage', () => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(shaderConfig.get()))
|
||||
})
|
||||
172
templates/shader/src/rainbow/fragment.glsl
Normal file
172
templates/shader/src/rainbow/fragment.glsl
Normal file
@@ -0,0 +1,172 @@
|
||||
#version 300 es
|
||||
precision mediump float;
|
||||
precision mediump int;
|
||||
|
||||
in vec2 v_uv;
|
||||
out vec4 fragColor;
|
||||
|
||||
uniform vec2 u_resolution;
|
||||
uniform float u_darkMode;
|
||||
uniform float u_quality;
|
||||
uniform float u_zoom;
|
||||
uniform float u_stepSize;
|
||||
uniform int u_steps;
|
||||
uniform float u_offset;
|
||||
uniform vec2 u_pointerPos;
|
||||
|
||||
// Constants
|
||||
#define MAX_SEGMENTS 512
|
||||
const float HASH_SEED_X = 127.1;
|
||||
const float HASH_SEED_Y = 311.7;
|
||||
const float HASH_MULTIPLIER = 43758.5453;
|
||||
const float HUE_RANGE = 6.0;
|
||||
const float HUE_TRANSITION = 2.0;
|
||||
const float SMOOTHING_FACTOR = 3.0;
|
||||
const int FBM_OCTAVES = 4;
|
||||
const float FBM_AMPLITUDE_START = 0.5;
|
||||
const float FBM_AMPLITUDE_DECAY = 0.5;
|
||||
const float FBM_FREQUENCY_START = 1.0;
|
||||
const float FBM_FREQUENCY_GROWTH = 2.0;
|
||||
const float CLAMP_MIN = 0.0;
|
||||
const float CLAMP_MAX = 1.0;
|
||||
const float DARK_MODE_BRIGHTNESS = 0.8;
|
||||
const float LIGHT_MODE_DESATURATION = 0.1;
|
||||
const vec3 LUMINANCE_WEIGHTS = vec3(0.299, 0.587, 0.114);
|
||||
const float QUINTIC_POWER = 5.0;
|
||||
const float MAX_INIT_DISTANCE = 1000.0;
|
||||
|
||||
// Geometry data
|
||||
uniform vec4 u_segments[MAX_SEGMENTS]; // xy = start, zw = end
|
||||
uniform float u_segmentCount;
|
||||
|
||||
// Simple noise function
|
||||
float hash(vec2 p) {
|
||||
return fract(sin(dot(p, vec2(HASH_SEED_X, HASH_SEED_Y))) * HASH_MULTIPLIER);
|
||||
}
|
||||
|
||||
// Rotate through full rainbow spectrum based on step count
|
||||
vec3 rotateHue(float step, float steps, float offset) {
|
||||
float t = mod(step + offset * steps, steps) / steps;
|
||||
float hue = t * HUE_RANGE; // 0-6 range for six color transitions
|
||||
float x = 1.0 - abs(mod(hue, HUE_TRANSITION) - 1.0);
|
||||
|
||||
if (hue < 1.0) return vec3(1.0, x, 0.0); // Red to Yellow
|
||||
else if (hue < 2.0) return vec3(x, 1.0, 0.0); // Yellow to Green
|
||||
else if (hue < 3.0) return vec3(0.0, 1.0, x); // Green to Cyan
|
||||
else if (hue < 4.0) return vec3(0.0, x, 1.0); // Cyan to Blue
|
||||
else if (hue < 5.0) return vec3(x, 0.0, 1.0); // Blue to Magenta
|
||||
else return vec3(1.0, 0.0, x); // Magenta to Red
|
||||
}
|
||||
|
||||
float noise(vec2 p) {
|
||||
vec2 i = floor(p);
|
||||
vec2 f = fract(p);
|
||||
f = f * f * (SMOOTHING_FACTOR - 2.0 * f);
|
||||
|
||||
float a = hash(i);
|
||||
float b = hash(i + vec2(1.0, 0.0));
|
||||
float c = hash(i + vec2(0.0, 1.0));
|
||||
float d = hash(i + vec2(1.0, 1.0));
|
||||
|
||||
return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
|
||||
}
|
||||
|
||||
// Fractal Brownian Motion for more organic noise
|
||||
float fbm(vec2 p) {
|
||||
float value = 0.0;
|
||||
float amplitude = FBM_AMPLITUDE_START;
|
||||
float frequency = FBM_FREQUENCY_START;
|
||||
|
||||
for (int i = 0; i < FBM_OCTAVES; i++) {
|
||||
value += amplitude * noise(p * frequency);
|
||||
amplitude *= FBM_AMPLITUDE_DECAY;
|
||||
frequency *= FBM_FREQUENCY_GROWTH;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
// Find closest point on line segment to point p
|
||||
vec2 closestPointOnSegment(vec2 p, vec2 a, vec2 b) {
|
||||
vec2 pa = p - a;
|
||||
vec2 ba = b - a;
|
||||
float h = clamp(dot(pa, ba) / dot(ba, ba), CLAMP_MIN, CLAMP_MAX);
|
||||
return a + ba * h;
|
||||
}
|
||||
|
||||
// Ease out quintic for smooth falloff
|
||||
float easeOutQuint(float t) {
|
||||
return 1.0 - pow(1.0 - t, QUINTIC_POWER);
|
||||
}
|
||||
|
||||
void main() {
|
||||
// Convert UV to pixel coordinates
|
||||
vec2 pixelPos = v_uv * u_resolution;
|
||||
|
||||
float minDist = MAX_INIT_DISTANCE;
|
||||
vec2 closestPoint = vec2(0.0);
|
||||
|
||||
// Find actual closest point on any segment
|
||||
for (int i = 0; i < MAX_SEGMENTS; i++) {
|
||||
if (float(i) >= u_segmentCount) break;
|
||||
|
||||
vec4 segment = u_segments[i];
|
||||
vec2 start = segment.xy * u_resolution;
|
||||
vec2 end = segment.zw * u_resolution;
|
||||
|
||||
vec2 pointOnSegment = closestPointOnSegment(pixelPos, start, end);
|
||||
float dist = distance(pixelPos, pointOnSegment);
|
||||
|
||||
if (dist < minDist) {
|
||||
minDist = dist;
|
||||
closestPoint = pointOnSegment;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Proximity-based rainbow
|
||||
// Scale by quality and zoom to maintain consistent appearance
|
||||
float maxDistance = u_stepSize * float(u_steps) * u_quality * u_zoom;
|
||||
|
||||
if (minDist > maxDistance) {
|
||||
fragColor = vec4(0.0, 0.0, 0.0, 0.0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Apply pointer erosion effect
|
||||
vec2 pointerPixelPos = u_pointerPos * u_resolution;
|
||||
float pointerDist = distance(pixelPos, pointerPixelPos);
|
||||
float pointerRadius = 100.0 * u_zoom; // Radius of effect in pixels
|
||||
|
||||
// Only affect pixels within the pointer radius
|
||||
if (pointerDist < pointerRadius) {
|
||||
// Erosion strength falls off from center to edge
|
||||
float erosionStrength = smoothstep(pointerRadius, 0.0, pointerDist);
|
||||
// Subtract from geometry distance, creating erosion effect
|
||||
minDist = max(0.0, minDist - erosionStrength * maxDistance * 0.3);
|
||||
}
|
||||
|
||||
// Quantize distance into discrete bands
|
||||
float steps = float(u_steps);
|
||||
|
||||
float bandIndex = floor(minDist / (maxDistance/steps));
|
||||
minDist = bandIndex * (maxDistance/steps);
|
||||
float proximity = smoothstep(maxDistance, 0.0, minDist);
|
||||
|
||||
// Generate rainbow color for this band using rotateHue
|
||||
vec3 rainbowColor = rotateHue(bandIndex, steps, u_offset);
|
||||
|
||||
// Adjust colors for dark mode
|
||||
if (u_darkMode > 0.5) {
|
||||
// In dark mode, reduce brightness and increase saturation
|
||||
rainbowColor = rainbowColor * DARK_MODE_BRIGHTNESS;
|
||||
} else {
|
||||
// In light mode, slightly desaturate for better visibility
|
||||
rainbowColor = mix(rainbowColor, vec3(dot(rainbowColor, LUMINANCE_WEIGHTS)), LIGHT_MODE_DESATURATION);
|
||||
}
|
||||
|
||||
// Opacity with ease out quintic for smooth falloff
|
||||
float alpha = easeOutQuint(proximity);
|
||||
|
||||
fragColor = vec4(rainbowColor, alpha);
|
||||
}
|
||||
325
templates/shader/src/rainbow/rainbow-example.md
Normal file
325
templates/shader/src/rainbow/rainbow-example.md
Normal file
@@ -0,0 +1,325 @@
|
||||
# Rainbow Shader Example
|
||||
|
||||
A WebGL shader that renders colorful rainbow distance fields around tldraw shapes, creating discrete color bands that adapt to dark/light mode.
|
||||
|
||||
## What This Example Does
|
||||
|
||||
- Extracts geometry from all tldraw shapes in real-time
|
||||
- Calculates distance fields from shape edges
|
||||
- Renders rainbow color bands based on distance
|
||||
- Adapts colors for dark/light mode
|
||||
- Provides on-demand rendering for performance
|
||||
|
||||
## Key Files
|
||||
|
||||
- **[config.ts](config.ts)** - Configuration with localStorage persistence
|
||||
- **[vertex.glsl](vertex.glsl)** - Standard full-screen quad vertex shader
|
||||
- **[fragment.glsl](fragment.glsl)** - Distance field shader with rainbow color generation
|
||||
- **[RainbowShaderManager.ts](RainbowShaderManager.ts)** - Shape geometry extraction and WebGL rendering
|
||||
- **[RainbowRenderer.tsx](RainbowRenderer.tsx)** - React component integrating the shader manager
|
||||
- **[RainbowConfigPanel.tsx](RainbowConfigPanel.tsx)** - UI panel for runtime configuration controls
|
||||
|
||||
## How It Works
|
||||
|
||||
### Shape Geometry Extraction
|
||||
|
||||
1. **Reactive Updates** (RainbowShaderManager.ts:150): Uses tldraw's `react()` to subscribe to shape changes, camera changes, and theme changes
|
||||
2. **Extract All Shapes** (line 156): `onUpdate` iterates through all shapes on the current page
|
||||
3. **Get Geometry** (line 290): Uses `editor.getShapeGeometry()` to get shape vertices
|
||||
4. **Transform to Canvas Space** (lines 272-282, 294-295):
|
||||
- Apply shape transform matrix to vertices
|
||||
- Convert from page coordinates to screen coordinates
|
||||
- Normalize to canvas UV space (0-1 range)
|
||||
5. **Build Segments** (lines 298-318): Connect vertices into line segments, handling closed vs open shapes
|
||||
|
||||
### Distance Field Calculation
|
||||
|
||||
The fragment shader (fragment.glsl) calculates distance from each pixel to the nearest shape edge:
|
||||
|
||||
1. **Convert UV to Pixels** (line 103): `pixelPos = v_uv * u_resolution`
|
||||
2. **Find Nearest Segment** (lines 109-123):
|
||||
- Loop through all segments (up to `maxSegments`)
|
||||
- Use `closestPointOnSegment` to find closest point on each segment
|
||||
- Track minimum distance across all segments
|
||||
3. **Quantize Distance** (lines 139-140): Floor distance into discrete bands for stepped effect
|
||||
4. **Generate Color** (line 144): Use `rotateHue` function to get rainbow color for the band
|
||||
5. **Apply Alpha** (line 156): Smooth falloff using ease-out quintic function
|
||||
|
||||
### Rainbow Color Generation
|
||||
|
||||
The `rotateHue` function (fragment.glsl:47-58) cycles through the full spectrum:
|
||||
|
||||
- Red → Yellow → Green → Cyan → Blue → Magenta → Red
|
||||
- Each band gets a color based on its index
|
||||
- `offset` parameter rotates colors along the spectrum
|
||||
|
||||
## Configuration
|
||||
|
||||
### Performance Settings
|
||||
|
||||
- **quality** (0.1-1.0): Resolution multiplier
|
||||
- Affects `maxSegments` calculation: `Math.floor(Math.min(512, 2000 / quality))`
|
||||
- Lower quality = supports more shapes
|
||||
- Default: 0.5
|
||||
|
||||
### Visual Parameters
|
||||
|
||||
- **stepSize** (1-50): Distance between color bands in pixels
|
||||
- Larger values = wider bands
|
||||
- Default: 10
|
||||
|
||||
- **steps** (1-100): Number of color bands to render
|
||||
- More steps = rainbow extends further from shapes
|
||||
- Default: 10
|
||||
|
||||
- **offset** (0-1): Color rotation offset
|
||||
- Animating this value rotates the rainbow
|
||||
- Default: 0
|
||||
|
||||
### Rendering Options
|
||||
|
||||
- **pixelate**: Apply pixelated rendering style
|
||||
- **startPaused**: Render on-demand instead of continuously (default: true)
|
||||
|
||||
## How to Modify This Example
|
||||
|
||||
### Changing Color Schemes
|
||||
|
||||
The `rotateHue` function (fragment.glsl:47-58) can be modified for different color schemes:
|
||||
|
||||
**Warm colors only**:
|
||||
|
||||
```glsl
|
||||
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**:
|
||||
|
||||
```glsl
|
||||
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);
|
||||
}
|
||||
```
|
||||
|
||||
### Adjusting Distance Bands
|
||||
|
||||
Modify the quantization (fragment.glsl:139-140):
|
||||
|
||||
**Smooth gradient instead of bands**:
|
||||
|
||||
```glsl
|
||||
// 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**:
|
||||
|
||||
```glsl
|
||||
float bandIndex = pow(minDist / maxDistance, 2.0) * steps; // Exponential spacing
|
||||
```
|
||||
|
||||
### Adding Noise Effects
|
||||
|
||||
The shader includes `fbm` (Fractal Brownian Motion) function (lines 74-86):
|
||||
|
||||
```glsl
|
||||
void main() {
|
||||
// ... existing distance calculation ...
|
||||
|
||||
// Add noise to distance
|
||||
float noiseValue = fbm(pixelPos * 0.01);
|
||||
minDist += noiseValue * 5.0;
|
||||
|
||||
// ... rest of shader ...
|
||||
}
|
||||
```
|
||||
|
||||
### Animating the Effect
|
||||
|
||||
Use the time parameter in `onRender`:
|
||||
|
||||
```typescript
|
||||
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)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Filtering Shapes
|
||||
|
||||
Modify `onUpdate` to process only certain shapes (RainbowShaderManager.ts:155):
|
||||
|
||||
```typescript
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Geometry Processing
|
||||
|
||||
Extend `extractGeometry` for shape-specific effects (line 284):
|
||||
|
||||
```typescript
|
||||
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
|
||||
}
|
||||
```
|
||||
|
||||
### Adding New Uniforms
|
||||
|
||||
1. **In fragment.glsl**: Add uniform declaration
|
||||
|
||||
```glsl
|
||||
uniform float u_brightness;
|
||||
```
|
||||
|
||||
2. **In RainbowShaderManager.ts**:
|
||||
- Add property (line ~25):
|
||||
```typescript
|
||||
private u_brightness: WebGLUniformLocation | null = null
|
||||
```
|
||||
- Get location in `onInitialize` (after line 127):
|
||||
```typescript
|
||||
this.u_brightness = this.gl.getUniformLocation(this.program, 'u_brightness')
|
||||
```
|
||||
- Set value in `onRender` (around line 213):
|
||||
```typescript
|
||||
if (this.u_brightness) {
|
||||
this.gl.uniform1f(this.u_brightness, this.getConfig().brightness)
|
||||
}
|
||||
```
|
||||
|
||||
3. **In config.ts**: Add to interface and defaults (lines 4-17)
|
||||
|
||||
4. **In RainbowConfigPanel.tsx**: Add slider range (line 8)
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Segment Limits
|
||||
|
||||
The shader has a maximum segment limit to stay within WebGL uniform limits:
|
||||
|
||||
```typescript
|
||||
this.maxSegments = Math.floor(Math.min(512, 2000 / quality))
|
||||
```
|
||||
|
||||
- Lower quality = more segments allowed
|
||||
- Segments beyond limit are clipped (line 219)
|
||||
- Consider simplifying geometry for complex shapes
|
||||
|
||||
### On-Demand Rendering
|
||||
|
||||
The example uses `startPaused: true` (config.ts:11) for better performance:
|
||||
|
||||
- Renders only when shapes change
|
||||
- Set to `false` for continuous animation
|
||||
- Call `tick()` manually to trigger renders
|
||||
|
||||
### Optimization Tips
|
||||
|
||||
- Reduce `quality` to process fewer pixels
|
||||
- Lower `steps` to reduce color calculations
|
||||
- Simplify `rotateHue` if using solid colors
|
||||
- Cache geometry if shapes don't change frequently
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Conditional Rendering by Distance
|
||||
|
||||
```glsl
|
||||
void 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
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Shape-Specific Colors
|
||||
|
||||
Pass additional data through a separate uniform array:
|
||||
|
||||
```typescript
|
||||
// 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)
|
||||
```
|
||||
|
||||
### Debug Visualization
|
||||
|
||||
Visualize the distance field directly:
|
||||
|
||||
```glsl
|
||||
void main() {
|
||||
// ... distance calculation ...
|
||||
|
||||
// Grayscale visualization of distance
|
||||
float visualDist = minDist / maxDistance;
|
||||
fragColor = vec4(vec3(visualDist), 1.0);
|
||||
}
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
For other shader patterns, see:
|
||||
|
||||
- **minimal/** - Basic shader template with minimal features
|
||||
- **fluid/** - Interactive fluid simulation responding to shape movements
|
||||
- **shadow/** - Shape-aware shadows with soft falloff
|
||||
15
templates/shader/src/rainbow/vertex.glsl
Normal file
15
templates/shader/src/rainbow/vertex.glsl
Normal file
@@ -0,0 +1,15 @@
|
||||
#version 300 es
|
||||
precision mediump float;
|
||||
precision mediump int;
|
||||
|
||||
// Constants
|
||||
const float UV_SCALE = 0.5;
|
||||
const float UV_OFFSET = 0.5;
|
||||
|
||||
in vec2 a_position;
|
||||
out vec2 v_uv;
|
||||
|
||||
void main() {
|
||||
v_uv = a_position * UV_SCALE + UV_OFFSET;
|
||||
gl_Position = vec4(a_position, 0.0, 1.0);
|
||||
}
|
||||
Reference in New Issue
Block a user