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

This commit is contained in:
upstream-mirror
2026-09-23 16:12:52 +05:00
commit a774ac673a
4541 changed files with 770499 additions and 0 deletions

View File

@@ -0,0 +1,53 @@
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 },
shadowContrast: { min: 0, max: 1 },
}
export function ShadowControlPanel() {
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>
)
}

View File

@@ -0,0 +1,25 @@
import { memo, useLayoutEffect, useRef } from 'react'
import { useEditor } from 'tldraw'
import { WebGLCanvas } from '../WebGLCanvas'
import { shaderConfig } from './config'
import { ShadowShaderManager } from './ShadowShaderManager'
export const ShadowRenderer = memo(() => {
const editor = useEditor()
const rCanvas = useRef<HTMLCanvasElement>(null)
useLayoutEffect(() => {
const manager = new ShadowShaderManager(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} />
})

View File

@@ -0,0 +1,290 @@
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 ShadowShaderManager 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_segments: WebGLUniformLocation | null = null
private u_segmentCount: WebGLUniformLocation | null = null
private u_lightPos: WebGLUniformLocation | null = null
private u_shadowContrast: 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_segments = this.gl.getUniformLocation(this.program, 'u_segments')
this.u_segmentCount = this.gl.getUniformLocation(this.program, 'u_segmentCount')
this.u_lightPos = this.gl.getUniformLocation(this.program, 'u_lightPos')
this.u_shadowContrast = this.gl.getUniformLocation(this.program, 'u_shadowContrast')
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.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
// Clear the canvas each frame to prevent flickering
this.gl.clear(this.gl.COLOR_BUFFER_BIT)
const isDarkMode = this.editor.user.getIsDarkMode()
const { quality, shadowContrast } = 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_lightPos) {
this.gl.uniform2f(this.u_lightPos, this.pointer.x, this.pointer.y)
}
if (this.u_shadowContrast) {
this.gl.uniform1f(this.u_shadowContrast, shadowContrast)
}
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
}
}

View File

@@ -0,0 +1,34 @@
import { atom, react } from 'tldraw'
import { WebGLManagerConfig } from '../WebGLManager'
export interface ShaderManagerConfig extends WebGLManagerConfig {
shadowContrast: number
}
export const DEFAULT_CONFIG: ShaderManagerConfig = {
startPaused: true,
quality: 0.5,
shadowContrast: 0.5,
pixelate: true,
}
const STORAGE_KEY = 'shader-shadowcasting-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()))
})

View File

@@ -0,0 +1,97 @@
#version 300 es
precision highp float;
precision highp 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 vec2 u_lightPos;
uniform float u_shadowContrast;
// Constants
#define MAX_SEGMENTS 512
const float LIGHT_SHADOW_ALPHA = 0.5;
const float DARK_SHADOW_ALPHA = 0.15;
const float EPSILON_SMALL = 0.001;
const float DARK_MODE_THRESHOLD = 0.5;
// Geometry data
uniform vec4 u_segments[MAX_SEGMENTS]; // xy = start, zw = end
uniform float u_segmentCount;
// Ray-segment intersection test
// Returns t value if ray intersects segment, -1.0 otherwise
float raySegmentIntersect(vec2 rayOrigin, vec2 rayDir, vec2 segStart, vec2 segEnd) {
vec2 v1 = rayOrigin - segStart;
vec2 v2 = segEnd - segStart;
vec2 v3 = vec2(-rayDir.y, rayDir.x);
float det = dot(v2, v3);
if (abs(det) < EPSILON_SMALL) return -1.0;
float t1 = (v2.x * v1.y - v2.y * v1.x) / det;
float t2 = dot(v1, v3) / det;
if (t1 >= 0.0 && t2 >= 0.0 && t2 <= 1.0) {
return t1;
}
return -1.0;
}
// Check if a point is visible from the light (not in shadow)
bool isVisible(vec2 point, vec2 lightPos) {
vec2 toLight = lightPos - point;
float distToLight = length(toLight);
vec2 rayDir = normalize(toLight);
// Check if any segment blocks the light
for (int i = 0; i < MAX_SEGMENTS; i++) {
if (float(i) >= u_segmentCount) break;
vec4 segment = u_segments[i];
vec2 start = segment.xy;
vec2 end = segment.zw;
float t = raySegmentIntersect(point, rayDir, start, end);
// If intersection exists and is closer than the light, we're blocked
// Using small epsilon for normalized coordinates (0-1 range)
if (t > EPSILON_SMALL && t < distToLight - EPSILON_SMALL) {
return false;
}
}
return true;
}
void main() {
// v_uv is already in normalized coordinates (0-1)
// Check if this pixel is visible from the light
bool visible = isVisible(v_uv, u_lightPos);
bool isDark = u_darkMode > DARK_MODE_THRESHOLD;
if (isDark) {
if (visible) {
// Visible area is transparent (show canvas)
// Shadow area - semi-transparent black overlay
fragColor = vec4(1.0, 1.0, 1.0, u_shadowContrast * DARK_SHADOW_ALPHA);
} else {
fragColor = vec4(0.0, 0.0, 0.0, 0.0);
}
} else {
if (visible) {
// Visible area is transparent (show canvas)
fragColor = vec4(0.0, 0.0, 0.0, 0.0);
} else {
// Shadow area - semi-transparent black overlay
fragColor = vec4(0.0, 0.0, 0.0, u_shadowContrast * LIGHT_SHADOW_ALPHA);
}
}
}

View File

@@ -0,0 +1,23 @@
# Shadow Casting Example
This example demonstrates real-time shadow casting in tldraw using WebGL shaders.
## Features
- **Dynamic Shadow Casting**: Shadows are cast from shapes based on your cursor position, which acts as a light source
- **Soft Shadows**: Penumbra effect creates realistic soft shadow edges
- **Ray-Segment Intersection**: Uses geometric ray casting to determine which pixels are occluded
- **Performance Optimized**: Only renders within the light radius and uses quality settings to balance performance
## How it works
The shader:
1. Takes the cursor position as a light source
2. For each pixel, casts a ray from the pixel to the light
3. Checks if any shape geometry segments intersect with the ray
4. If a segment blocks the ray, the pixel is in shadow
5. Applies soft shadow sampling for realistic penumbra
6. Renders a warm light with distance-based falloff
Move your cursor around the canvas to see the light and shadows update in real-time!

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