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:
53
templates/shader/src/shadow/ShadowControlPanel.tsx
Normal file
53
templates/shader/src/shadow/ShadowControlPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
25
templates/shader/src/shadow/ShadowRenderer.tsx
Normal file
25
templates/shader/src/shadow/ShadowRenderer.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 { 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} />
|
||||
})
|
||||
290
templates/shader/src/shadow/ShadowShaderManager.ts
Normal file
290
templates/shader/src/shadow/ShadowShaderManager.ts
Normal 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
|
||||
}
|
||||
}
|
||||
34
templates/shader/src/shadow/config.ts
Normal file
34
templates/shader/src/shadow/config.ts
Normal 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()))
|
||||
})
|
||||
97
templates/shader/src/shadow/fragment.glsl
Normal file
97
templates/shader/src/shadow/fragment.glsl
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
23
templates/shader/src/shadow/shadow-example.md
Normal file
23
templates/shader/src/shadow/shadow-example.md
Normal 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!
|
||||
15
templates/shader/src/shadow/vertex.glsl
Normal file
15
templates/shader/src/shadow/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