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

3
templates/shader/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
/*/node_modules
node_modules
.yarn/

View File

@@ -0,0 +1,9 @@
MIT License
Copyright (c) 2024 tldraw Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

155
templates/shader/README.md Normal file
View File

@@ -0,0 +1,155 @@
<div alt style="text-align: center; transform: scale(.5);">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/tldraw/tldraw/main/assets/github-hero-dark.png" />
<img alt="tldraw" src="https://raw.githubusercontent.com/tldraw/tldraw/main/assets/github-hero-light.png" />
</picture>
</div>
This template demonstrates how to integrate WebGL shaders with [tldraw](https://github.com/tldraw/tldraw), creating dynamic backgrounds that respond to canvas interactions. It includes four complete examples and a reusable `WebGLManager` base class for building custom shader effects.
## Local development
Install dependencies with `yarn` or `npm install`.
Run the development server with `yarn dev` or `npm run dev`.
Open `http://localhost:5173/` in your browser to see the app.
## What's included
- **WebGLManager base class** ([`src/WebGLManager.ts`](src/WebGLManager.ts)) - Handles WebGL lifecycle, viewport synchronization, and animation loop integration with tldraw's reactive system
- **Four example shaders** - Fluid simulation, rainbow gradients, dynamic shadows, and a minimal starter template
- **Config panel components** ([`src/config-panel/`](src/config-panel/)) - Pre-built UI controls for adjusting shader parameters with localStorage persistence
- **Full TypeScript support** - Strongly typed throughout
Switch between examples using the toggle buttons in the tldraw style panel.
## Examples
### Fluid Simulation
A real-time fluid simulation that creates dynamic flows from shape interactions. Based on Pavel Dobryakov's WebGL fluid implementation.
- Navier-Stokes fluid dynamics
- Shape movements create velocity-based splats
- Configurable physics and visual effects
**See:** [`src/fluid/`](src/fluid/) | [Documentation](src/fluid/fluid.md)
### Rainbow
An animated gradient shader demonstrating time-based effects and uniform management.
**See:** [`src/rainbow/`](src/rainbow/)
### Shadows
Dynamic shadow casting from tldraw shapes using raymarching and signed distance fields.
**See:** [`src/shadow/`](src/shadow/)
### Minimal
A bare-bones template for starting new shader projects. Renders a solid color that adapts to dark mode.
**See:** [`src/minimal/`](src/minimal/) | [Documentation](src/minimal/minimal-example.md)
## Architecture
### WebGLManager
The `WebGLManager` class ([`src/WebGLManager.ts`](src/WebGLManager.ts)) provides the foundation for all shader examples. It handles:
- WebGL2 context creation and lifecycle management
- Automatic viewport synchronization with tldraw's canvas
- Animation loop with lifecycle hooks: `onInitialize()`, `onUpdate()`, `onRender()`, `onDispose()`
- Quality/resolution management
- Integration with tldraw's reactive state system
To create a custom shader, extend `WebGLManager` and implement the lifecycle hooks. See the minimal example for a starting point.
### Config Panel
The config panel system ([`src/config-panel/`](src/config-panel/)) provides reusable UI components for creating interactive shader controls:
- `ConfigPanel` - Collapsible container with reset functionality
- `ConfigPanelSlider` - Numeric slider control
- `ConfigPanelBooleanControl` - Checkbox control
Config values are stored in reactive atoms and automatically persisted to localStorage. See [config-panel.md](src/config-panel/config-panel.md) for usage details.
### Renderer Pattern
Each example follows this pattern:
1. Define a config interface extending `WebGLManagerConfig` in `config.ts`
2. Create a shader manager class extending `WebGLManager`
3. Write GLSL shaders (vertex and fragment)
4. Create a React renderer component using `useLayoutEffect` to initialize the manager
5. Build a config panel for live parameter adjustment
6. Register the renderer as tldraw's `Background` component
See any of the example directories for complete implementations.
## Creating a custom shader
The fastest way to create a custom shader is to copy the minimal example:
```bash
cp -r src/minimal src/my-shader
```
Then customize the following files:
- `config.ts` - Define your configuration parameters
- `fragment.glsl` / `vertex.glsl` - Write your shader code
- `MyShaderManager.ts` - Extend WebGLManager and implement rendering logic
- `MyRenderer.tsx` - Create the React component
- `MyConfigPanel.tsx` - Build UI controls
Finally, register your shader in [`src/App.tsx`](src/App.tsx).
For a detailed walkthrough, see [src/minimal/minimal-example.md](src/minimal/minimal-example.md).
## Integration with tldraw
The `WebGLManager` has direct access to the tldraw editor instance, allowing you to:
- Access shapes: `this.editor.getCurrentPageShapes()`
- Listen to shape changes: `this.editor.store.listen()`
- Get camera state: `this.editor.getCamera()`
- Convert coordinates: `this.editor.pageToViewport()`
- Track pointer position: `this.editor.inputs.getCurrentPagePoint()`
The fluid simulation example ([`src/fluid/FluidManager.ts`](src/fluid/FluidManager.ts)) demonstrates shape integration in depth.
## Resources
- [WebGL2 Fundamentals](https://webgl2fundamentals.org/) - WebGL tutorials
- [The Book of Shaders](https://thebookofshaders.com/) - GLSL shader programming
- [Shadertoy](https://www.shadertoy.com/) - Shader examples
- [tldraw Documentation](https://tldraw.dev) - tldraw SDK docs
## License
This project is provided under the MIT license found [here](https://github.com/tldraw/vite-template/blob/main/LICENSE.md). The tldraw SDK is provided under the [tldraw license](https://github.com/tldraw/tldraw/blob/main/LICENSE.md).
## Trademarks
Copyright (c) 2024-present tldraw Inc. The tldraw name and logo are trademarks of tldraw. Please see our [trademark guidelines](https://github.com/tldraw/tldraw/blob/main/TRADEMARKS.md) for info on acceptable usage.
## Distributions
You can find tldraw on npm [here](https://www.npmjs.com/package/@tldraw/tldraw?activeTab=versions).
## Contribution
Found a bug? Please [submit an issue](https://github.com/tldraw/tldraw/issues/new).
## Community
Have questions, comments or feedback? [Join our discord](https://discord.tldraw.com/?utm_source=github&utm_medium=readme&utm_campaign=sociallink). For the latest news and release notes, visit [tldraw.dev](https://tldraw.dev).
## Contact
Find us on Twitter/X at [@tldraw](https://twitter.com/tldraw).

View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>tldraw shader template</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,42 @@
{
"name": "tldraw-shader-starter-kit",
"private": true,
"version": "0.0.0",
"author": {
"name": "tldraw GB Ltd.",
"email": "hello@tldraw.com"
},
"homepage": "https://tldraw.dev",
"license": "MIT",
"type": "module",
"tldraw_template": {
"repo": "tldraw/shader-starter-kit",
"cli": {
"name": "Shader",
"description": "Create WebGL shaders that respond to shapes on the canvas.",
"shortDescription": "Shaders on the canvas.",
"order": 8
},
"scripts": {
"lint": null
}
},
"scripts": {
"dev": "vite --host",
"build": "tsc && vite build",
"lint": "yarn run -T tsx ../../internal/scripts/lint.ts",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.1",
"react-dom": "^19.2.1",
"tldraw": "workspace:*"
},
"devDependencies": {
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"typescript": "^5.8.3",
"vite": "^8.0.16"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,65 @@
import { DefaultStylePanel, Tldraw, TldrawUiButton, useLocalStorageState } from 'tldraw'
import { FluidConfigPanel } from './fluid/FluidConfigPanel'
import { FluidRenderer } from './fluid/FluidRenderer'
import { MinimalConfigPanel } from './minimal/MinimalConfigPanel'
import { MinimalRenderer } from './minimal/MinimalRenderer'
import { RainbowConfigPanel } from './rainbow/RainbowConfigPanel'
import { RainbowRenderer } from './rainbow/RainbowRenderer'
import { ShadowControlPanel } from './shadow/ShadowControlPanel'
import { ShadowRenderer } from './shadow/ShadowRenderer'
const EXAMPLES = [
{ label: 'Fluid', value: 'fluid', ConfigPanel: FluidConfigPanel, Renderer: FluidRenderer },
{
label: 'Rainbow',
value: 'rainbow',
ConfigPanel: RainbowConfigPanel,
Renderer: RainbowRenderer,
},
{ label: 'Shadows', value: 'shadows', ConfigPanel: ShadowControlPanel, Renderer: ShadowRenderer },
{
label: 'Minimal',
value: 'minimal',
ConfigPanel: MinimalConfigPanel,
Renderer: MinimalRenderer,
},
]
function App() {
const [selected, setSelected] = useLocalStorageState<string>('shader-selected', 'fluid')
const example = EXAMPLES.find((e) => e.value === selected)
const ConfigComponent = example?.ConfigPanel
return (
<div className="shader-app">
<Tldraw
persistenceKey="shader"
components={{
Background: example?.Renderer,
StylePanel: () => {
return (
<div style={{ display: 'flex', flexDirection: 'row' }}>
{ConfigComponent && <ConfigComponent />}
<div className="tlui-menu shader-app__example-menu">
{EXAMPLES.map((option) => (
<TldrawUiButton
type="menu"
key={option.value}
onClick={() => setSelected(option.value)}
data-isactive={selected === option.value}
>
{option.label}
</TldrawUiButton>
))}
</div>
<DefaultStylePanel />
</div>
)
},
}}
/>
</div>
)
}
export default App

View File

@@ -0,0 +1,12 @@
import { forwardRef } from 'react'
import { Atom, useValue } from 'tldraw'
import { WebGLManagerConfig } from './WebGLManager'
export const WebGLCanvas = forwardRef<
HTMLCanvasElement,
{ config: Atom<WebGLManagerConfig, unknown> }
>(({ config }, ref) => {
const isPixelated = useValue('config', () => config.get().pixelate, [])
const canvasClassName = `shader-app__canvas${isPixelated ? ' shader-app__canvas--pixelated' : ''}`
return <canvas ref={ref} className={canvasClassName} />
})

View File

@@ -0,0 +1,286 @@
import { Atom, computed, Editor, react } from 'tldraw'
export interface WebGLManagerConfig {
quality: number
startPaused: boolean
pixelate: boolean
contextAttributes?: WebGLContextAttributes
}
/**
* Base class for WebGL-powered canvas managers integrated with tldraw's reactive system.
* Provides lifecycle hooks, animation loop management, and automatic viewport synchronization.
*
* Lifecycle:
* 1. constructor() - Initialize reactive dependencies and quality monitoring
* 2. initialize() - Create WebGL context and configure viewport
* 3. onInitialize() - Hook for subclass resource setup (shaders, buffers, etc.)
* 4. Animation loop (if not paused):
* - onUpdate() - Logic and state updates
* - onFirstRender() - One-time setup after context creation
* - onRender() - Draw calls and rendering
* 5. dispose() - Stop animation and clean up resources
* 6. onDispose() - Hook for subclass cleanup
*/
export abstract class WebGLManager<T extends WebGLManagerConfig> {
gl: WebGL2RenderingContext | null = null
animationFrameId: number | null = null
lastFrameTime = 0
isInitialized = false
isDisposed = false
private _needsFirstRender = true
disposables = new Set<() => void>()
constructor(
readonly editor: Editor,
readonly canvas: HTMLCanvasElement,
public configAtom: Atom<T, unknown>
) {
this.disposables.add(
react('quality changed', () => {
editor.getViewportScreenBounds()
this.getQuality()
this.resize()
})
)
}
@computed getQuality() {
return this.getConfig().quality
}
@computed getConfig() {
return this.configAtom.get()
}
/**
* Creates the WebGL2 context and initializes the manager.
* Must be called before any rendering operations. Calls onInitialize() hook for subclass setup.
* Automatically starts the animation loop unless startPaused is true in config.
*/
initialize = (): void => {
const { startPaused, contextAttributes } = this.getConfig()
if (this.isInitialized) {
console.warn('WebGLManager already initialized')
return
}
if (this.isDisposed) {
console.error('Cannot initialize disposed WebGLManager')
return
}
this.gl = this.canvas.getContext('webgl2', contextAttributes)
if (!this.gl) {
throw Error('WebGL2 not available')
}
// Configure viewport to match canvas dimensions
if (this.canvas.width > 0 && this.canvas.height > 0) {
this.gl.viewport(0, 0, this.canvas.width, this.canvas.height)
} else {
console.warn('Canvas has zero dimensions, skipping viewport setup')
}
// Execute subclass initialization hook before marking as ready
this.onInitialize()
// Abort if subclass called dispose() during initialization
if (this.isDisposed) {
console.error('Initialization was aborted')
return
}
this.isInitialized = true
this.lastFrameTime = performance.now()
this.resize()
// Begin animation loop unless configured to start paused
if (!startPaused) {
this.startAnimationLoop()
}
}
/**
* Lifecycle hook for subclass-specific initialization.
* Called after WebGL context creation but before animation loop starts.
* Use this to compile shaders, create buffers, load textures, etc.
*/
protected onInitialize = (): void => {
// Override in subclass
}
/**
* Begins the requestAnimationFrame loop, calling onUpdate() and onRender() each frame.
*/
private startAnimationLoop = (): void => {
const frame = (currentTime: number) => {
if (this.isDisposed) return
const deltaTime = (currentTime - this.lastFrameTime) / 1000
this.lastFrameTime = currentTime
// Execute lifecycle hooks each frame
this.onUpdate(deltaTime, currentTime)
this.onRender(deltaTime, currentTime)
// Queue next frame
this.animationFrameId = requestAnimationFrame(frame)
}
this.animationFrameId = requestAnimationFrame(frame)
}
/**
* Lifecycle hook for logic and state updates.
* Called once per frame before onRender(). Override to update uniforms, animation state, etc.
* @param deltaTime - Seconds elapsed since previous frame
* @param currentTime - Absolute timestamp from performance.now() in milliseconds
*/
protected onUpdate = (_deltaTime: number, _currentTime: number): void => {
// Override in subclass
}
/**
* Lifecycle hook called once after context creation or recreation.
* Invoked before the first onRender() call and after resize events that recreate the canvas.
* Use this for one-time setup that depends on final canvas dimensions.
*/
protected onFirstRender = (): void => {
// Override in subclass
}
/**
* Lifecycle hook for rendering to the canvas.
* Called after onUpdate() each frame. Override to execute draw calls and render your scene.
* @param deltaTime - Seconds elapsed since previous frame
* @param currentTime - Absolute timestamp from performance.now() in milliseconds
*/
protected onRender = (_deltaTime: number, _currentTime: number): void => {
// Override in subclass
}
/**
* Lifecycle hook for cleanup of subclass-specific resources.
* Called during dispose() before clearing the WebGL context.
* Use this to delete shaders, buffers, textures, and other GPU resources.
*/
protected onDispose = (): void => {
// Override in subclass
}
/**
* Stops the animation loop and releases all resources.
* Calls onDispose() hook for subclass cleanup. Instance cannot be reused after disposal.
*/
dispose = (): void => {
this.disposables.forEach((dispose) => dispose())
this.disposables.clear()
if (this.isDisposed) {
console.warn('WebGLManager already disposed')
return
}
// Cancel any pending animation frame
this.pause()
// Execute subclass cleanup hook
this.onDispose()
// Clear WebGL context reference (avoid explicit context loss to prevent React conflicts)
this.gl = null
this.isDisposed = true
this.isInitialized = false
}
/**
* Updates canvas dimensions and WebGL viewport based on current bounding rect and quality setting.
* Automatically called when viewport bounds or quality changes via reactive dependency.
* Triggers onFirstRender() and a single frame if animation loop is paused.
*/
resize = (): void => {
if (!this.isInitialized || this.isDisposed || !this.gl) {
return
}
this.updateCanvasSize()
// Update WebGL viewport to match new canvas resolution
this.gl.viewport(0, 0, this.canvas.width, this.canvas.height)
// Flag that onFirstRender() should be called on next frame
this._needsFirstRender = true
// Render immediately if paused to reflect resize
if (!this.isRunning()) {
this.tick()
}
}
private updateCanvasSize() {
const { width, height } = this.canvas.getBoundingClientRect()
const { quality } = this.getConfig()
this.canvas.width = Math.floor(width * quality)
this.canvas.height = Math.floor(height * quality)
}
/**
* Stops the animation loop by canceling the current requestAnimationFrame.
*/
pause = (): void => {
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId)
this.animationFrameId = null
}
}
/**
* Restarts the animation loop if currently paused.
* Resets lastFrameTime to prevent large deltaTime jump.
*/
resume = (): void => {
if (this.animationFrameId === null && this.isInitialized && !this.isDisposed) {
// Reset so the first frame after resuming doesn't see a huge deltaTime
this.lastFrameTime = performance.now()
this.startAnimationLoop()
}
}
/**
* Executes a single frame update and render cycle manually.
* Useful for on-demand rendering when paused, or for controlled frame stepping.
* Calls onFirstRender() if needed, then onUpdate() and onRender().
*/
tick = (): void => {
if (this.isDisposed) return
const currentTime = performance.now()
const deltaTime = (currentTime - this.lastFrameTime) / 1000
this.lastFrameTime = currentTime
// Execute update hook
this.onUpdate(deltaTime, currentTime)
if (this._needsFirstRender) {
// Ensure canvas has correct dimensions before first render
this.updateCanvasSize()
this.onFirstRender()
this._needsFirstRender = false
}
this.onRender(deltaTime, currentTime)
}
/**
* Returns true if the animation loop is actively running via requestAnimationFrame.
*/
isRunning = (): boolean => {
return this.animationFrameId !== null
}
}

View File

@@ -0,0 +1,62 @@
import { useCallback } from 'react'
import { TldrawUiButton, TldrawUiIcon, useLocalStorageState } from 'tldraw'
export function ConfigPanel({
children,
onReset,
}: {
children: React.ReactNode
onReset: () => void
}) {
const [isExpanded, setIsExpanded] = useLocalStorageState('shader-config-panel-expanded', false)
const toggleExpanded = useCallback(() => {
setIsExpanded((expanded) => !expanded)
}, [setIsExpanded])
return (
<div
className={`tlui-style-panel__wrapper tlui-style-panel shader-config-panel ${
isExpanded ? 'shader-config-panel--expanded' : 'shader-config-panel--collapsed'
}`}
onWheelCapture={(e) => {
e.stopPropagation()
}}
>
{/* Header with collapse/expand button */}
<div className="shader-config-panel__header">
{isExpanded && (
<TldrawUiButton
type="normal"
onClick={onReset}
className="shader-config-panel__reset-button"
>
Reset
</TldrawUiButton>
)}
<TldrawUiButton
type="icon"
onClick={toggleExpanded}
className="shader-config-panel__toggle"
>
<TldrawUiIcon icon={<MixerIcon />} small label="Expand / collapse" />
</TldrawUiButton>
</div>
{isExpanded && <div className="shader-config-panel__content">{children}</div>}
</div>
)
}
function MixerIcon() {
return (
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M5.5 3C4.67157 3 4 3.67157 4 4.5C4 5.32843 4.67157 6 5.5 6C6.32843 6 7 5.32843 7 4.5C7 3.67157 6.32843 3 5.5 3ZM3 5C3.01671 5 3.03323 4.99918 3.04952 4.99758C3.28022 6.1399 4.28967 7 5.5 7C6.71033 7 7.71978 6.1399 7.95048 4.99758C7.96677 4.99918 7.98329 5 8 5H13.5C13.7761 5 14 4.77614 14 4.5C14 4.22386 13.7761 4 13.5 4H8C7.98329 4 7.96677 4.00082 7.95048 4.00242C7.71978 2.86009 6.71033 2 5.5 2C4.28967 2 3.28022 2.86009 3.04952 4.00242C3.03323 4.00082 3.01671 4 3 4H1.5C1.22386 4 1 4.22386 1 4.5C1 4.77614 1.22386 5 1.5 5H3ZM11.9505 10.9976C11.7198 12.1399 10.7103 13 9.5 13C8.28967 13 7.28022 12.1399 7.04952 10.9976C7.03323 10.9992 7.01671 11 7 11H1.5C1.22386 11 1 10.7761 1 10.5C1 10.2239 1.22386 10 1.5 10H7C7.01671 10 7.03323 10.0008 7.04952 10.0024C7.28022 8.8601 8.28967 8 9.5 8C10.7103 8 11.7198 8.8601 11.9505 10.0024C11.9668 10.0008 11.9833 10 12 10H13.5C13.7761 10 14 10.2239 14 10.5C14 10.7761 13.7761 11 13.5 11H12C11.9833 11 11.9668 10.9992 11.9505 10.9976ZM8 10.5C8 9.67157 8.67157 9 9.5 9C10.3284 9 11 9.67157 11 10.5C11 11.3284 10.3284 12 9.5 12C8.67157 12 8 11.3284 8 10.5Z"
fill="currentColor"
fillRule="evenodd"
clipRule="evenodd"
></path>
</svg>
)
}

View File

@@ -0,0 +1,23 @@
export function ConfigPanelBooleanControl({
prop,
label,
value,
onChange,
}: {
prop: string
label: string
value: boolean
onChange: (prop: string, value: boolean) => void
}) {
return (
<label className="shader-config-panel__control shader-config-panel__control--boolean">
<span className="shader-config-panel__label">{label}</span>
<input
type="checkbox"
checked={value ?? false}
onChange={(e) => onChange(prop, e.target.checked)}
className="shader-config-panel__boolean-input"
/>
</label>
)
}

View File

@@ -0,0 +1,3 @@
export function ConfigPanelLabel({ children }: { children: React.ReactNode }) {
return <span className="shader-config-panel__label">{children}</span>
}

View File

@@ -0,0 +1,43 @@
import { TldrawUiSlider } from 'tldraw'
import { ConfigPanelLabel } from './ConfigPanelLabel'
const STEPS = 100
export function ConfigPanelSlider({
prop,
label,
min,
max,
value,
onChange,
type,
}: {
prop: string
label: string
min: number
max: number
value: number
type: 'float' | 'int'
onChange: (prop: string, value: number) => void
}) {
// Map actual value (min to max) to slider value (1 to 10)
const sliderValue = value != null ? 1 + ((value - min) / (max - min)) * (STEPS - 1) : 1
return (
<div className="shader-config-panel__control shader-config-panel__control--slider">
<ConfigPanelLabel>{label}</ConfigPanelLabel>
<TldrawUiSlider
steps={STEPS}
min={1}
value={sliderValue}
label={sliderValue.toFixed(0) + '%'}
title={label}
onValueChange={(sliderValue) => {
// Map slider value (1 to 10) back to actual value (min to max)
const actualValue = min + ((sliderValue - 1) / (STEPS - 1)) * (max - min)
onChange(prop, type === 'float' ? actualValue : Math.round(actualValue))
}}
/>
</div>
)
}

View File

@@ -0,0 +1,239 @@
# Config Panel
A collapsible, reusable configuration panel component for building interactive controls for shader templates. The config panel provides a consistent UI for adjusting shader parameters in real-time.
The config panel system consists of:
- **ConfigPanel**: Container component with collapse/expand functionality and reset button
- **ConfigPanelSlider**: Slider control for numeric values (int or float)
- **ConfigPanelBooleanControl**: Checkbox control for boolean values
- **ConfigPanelLabel**: Text label component
All controls are styled to match the tldraw UI and automatically handle state persistence.
## ConfigPanel Component
The main container component that wraps all configuration controls.
### Props
- `children`: React nodes containing the control components
- `onReset`: Callback function to reset configuration to defaults
### Features
- **Collapsible**: Click the mixer icon to expand/collapse the panel
- **Persistent State**: Panel expanded/collapsed state is saved to localStorage
- **Reset Button**: Visible when expanded, calls the `onReset` callback
- **Scroll Prevention**: Prevents wheel events from affecting the canvas behind it
### Usage
```tsx
import { ConfigPanel } from '../config-panel/ConfigPanel'
import { resetMyConfig } from './config'
function MyConfigPanel() {
return <ConfigPanel onReset={resetMyConfig}>{/* Add controls here */}</ConfigPanel>
}
```
## ConfigPanelSlider
A slider control for numeric values with automatic range mapping.
### Props
- `prop`: Property name (passed to onChange)
- `label`: Display label for the slider
- `min`: Minimum value
- `max`: Maximum value
- `value`: Current value
- `type`: Either `'float'` or `'int'`
- `onChange`: Callback `(prop: string, value: number) => void`
### Features
- Automatically maps between slider steps (1-100) and actual value range
- Rounds to integers when `type='int'`
- Uses tldraw's built-in `TldrawUiSlider` component
### Usage
```tsx
<ConfigPanelSlider
prop="splatRadius"
label="Splat Radius"
min={0.01}
max={1}
value={config.splatRadius}
type="float"
onChange={handleChange}
/>
```
## ConfigPanelBooleanControl
A checkbox control for boolean values.
### Props
- `prop`: Property name (passed to onChange)
- `label`: Display label for the checkbox
- `value`: Current boolean value
- `onChange`: Callback `(prop: string, value: boolean) => void`
### Usage
```tsx
<ConfigPanelBooleanControl
prop="paused"
label="Paused"
value={config.paused}
onChange={handleChange}
/>
```
## Reactive Configuration Pattern
The config panel works with tldraw's reactive `atom` system for automatic state management and persistence.
### Setting Up Config
```tsx
import { atom, react } from 'tldraw'
// 1. Define default configuration
const DEFAULT_CONFIG = {
quality: 0.5,
paused: false,
// ... other properties
}
// 2. Load from localStorage with fallback
const STORAGE_KEY = 'my-shader-config'
let initialValue = DEFAULT_CONFIG
try {
const value = localStorage.getItem(STORAGE_KEY)
if (value) initialValue = JSON.parse(value)
} catch {
// Use defaults if parse fails
}
// 3. Create reactive atom
export const myConfig = atom('my-config', initialValue)
// 4. Reset function
export function resetMyConfig() {
myConfig.set(DEFAULT_CONFIG)
}
// 5. Auto-save to localStorage
react('save to local storage', () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(myConfig.get()))
})
```
### Using Config in Component
```tsx
import { useValue } from 'tldraw'
import { myConfig } from './config'
export function MyConfigPanel() {
// Subscribe to reactive config changes
const config = useValue('config', () => myConfig.get(), [])
// Update config properties
const handleChange = useCallback((prop: string, value: number | boolean) => {
myConfig.update((prev) => ({ ...prev, [prop]: value }))
}, [])
return (
<ConfigPanel onReset={resetMyConfig}>
<ConfigPanelSlider
prop="quality"
label="Quality"
min={0}
max={1}
value={config.quality}
type="float"
onChange={handleChange}
/>
<ConfigPanelBooleanControl
prop="paused"
label="Paused"
value={config.paused}
onChange={handleChange}
/>
</ConfigPanel>
)
}
```
## Styling
The config panel uses CSS classes that can be customized:
- `.shader-config-panel`: Main panel container
- `.shader-config-panel--expanded`: Applied when expanded
- `.shader-config-panel--collapsed`: Applied when collapsed
- `.shader-config-panel__header`: Header with reset and toggle buttons
- `.shader-config-panel__content`: Content area (only visible when expanded)
- `.shader-config-panel__control`: Base class for individual controls
- `.shader-config-panel__control--slider`: Modifier for slider controls
- `.shader-config-panel__control--boolean`: Modifier for boolean controls
- `.shader-config-panel__boolean-input`: Checkbox input element
- `.shader-config-panel__label`: Label text for controls
## Integration with Shader Manager
The config panel is typically placed in the tldraw component tree and accessed by a shader manager:
```tsx
function MyShaderRenderer({ editor }: { editor: Editor }) {
const canvas = useRef<HTMLCanvasElement>(null)
useEffect(() => {
if (!canvas.current) return
// Initialize shader with reactive config
const manager = new MyShaderManager(canvas.current, editor, myConfig)
return () => manager.dispose()
}, [editor])
return <canvas ref={canvas} className="shader-app__canvas" />
}
export default function ShaderExample() {
return (
<div className="tldraw__editor">
<Tldraw>
<MyShaderRenderer />
<MyConfigPanel />
</Tldraw>
</div>
)
}
```
## Best Practices
1. **Group Related Controls**: Organize controls by category (e.g., "General Settings", "Simulation Settings", "Visual Effects")
2. **Sensible Ranges**: Choose min/max values that provide useful adjustment without breaking the effect
3. **Clear Labels**: Use concise, descriptive labels (abbreviate if needed to fit the UI)
4. **Reasonable Defaults**: Set defaults that work well out of the box
5. **Persist State**: Always use the reactive atom pattern with localStorage persistence
6. **Type Safety**: Define TypeScript interfaces for your config objects
7. **Reset Function**: Always provide a reset function for returning to defaults
## Example: Complete Config Panel Setup
See `templates/shader/src/fluid/` for a full example implementing:
- `config.ts`: Reactive atom with localStorage persistence
- `FluidConfigPanel.tsx`: Complete configuration UI with 20+ controls
- `FluidManager.ts`: Shader manager that reads from the config atom
- Integration with WebGL fluid simulation
This demonstrates the full pattern for creating a configurable shader effect with a professional UI.

View File

@@ -0,0 +1,251 @@
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 { fluidConfig, resetFluidConfig } from './config'
/**
* Configuration panel for the fluid simulation.
* Provides UI controls for adjusting all fluid simulation parameters including:
* - General settings (quality, velocity)
* - Simulation parameters (resolution, dissipation, pressure)
* - Splat settings (radius, force, colors)
* - Post-processing effects (bloom, sunrays)
*/
export function FluidConfigPanel() {
const config = useValue('config', () => fluidConfig.get(), [])
const handleChange = useCallback((prop: string, value: number | boolean) => {
fluidConfig.update((prev) => ({ ...prev, [prop]: value }))
}, [])
return (
<ConfigPanel onReset={resetFluidConfig}>
{/* General Settings */}
<ConfigPanelSlider
prop="quality"
label="Quality"
min={0.001}
max={1}
value={config.quality}
type="float"
onChange={handleChange}
/>
<ConfigPanelSlider
prop="velocityScale"
label="Velocity Scale"
min={0}
max={0.1}
value={config.velocityScale}
type="float"
onChange={handleChange}
/>
<ConfigPanelSlider
prop="boundsSampleCount"
label="Bounds Sample Ct."
min={4}
max={100}
value={config.boundsSampleCount}
type="int"
onChange={handleChange}
/>
<ConfigPanelBooleanControl
prop="paused"
label="Paused"
value={config.paused}
onChange={handleChange}
/>
<ConfigPanelBooleanControl
prop="transparent"
label="Transparent Bg"
value={config.transparent}
onChange={handleChange}
/>
{/* Simulation Settings */}
<ConfigPanelSlider
prop="simResolution"
label="Sim Quality"
min={32}
max={512}
value={config.simResolution}
type="int"
onChange={handleChange}
/>
<ConfigPanelSlider
prop="dyeResolution"
label="Dye Quality"
min={256}
max={2048}
value={config.dyeResolution}
type="int"
onChange={handleChange}
/>
<ConfigPanelSlider
prop="densityDissipation"
label="Density Diss."
min={0}
max={4}
value={config.densityDissipation}
type="float"
onChange={handleChange}
/>
<ConfigPanelSlider
prop="velocityDissipation"
label="Velocity Diss."
min={0}
max={1}
value={config.velocityDissipation}
type="float"
onChange={handleChange}
/>
<ConfigPanelSlider
prop="pressure"
label="Pressure"
min={0}
max={2}
value={config.pressure}
type="float"
onChange={handleChange}
/>
<ConfigPanelSlider
prop="pressureIterations"
label="Pressure Iter."
min={1}
max={60}
value={config.pressureIterations}
type="int"
onChange={handleChange}
/>
<ConfigPanelSlider
prop="curl"
label="Curl (Vorticity)"
min={0}
max={100}
value={config.curl}
type="int"
onChange={handleChange}
/>
{/* Splat Settings */}
<ConfigPanelSlider
prop="splatRadius"
label="Splat Radius"
min={0.01}
max={1}
value={config.splatRadius}
type="float"
onChange={handleChange}
/>
<ConfigPanelSlider
prop="splatForce"
label="Splat Force"
min={1000}
max={20000}
value={config.splatForce}
type="int"
onChange={handleChange}
/>
<ConfigPanelBooleanControl
prop="shading"
label="Shading"
value={config.shading}
onChange={handleChange}
/>
<ConfigPanelBooleanControl
prop="colorful"
label="Colorful Mode"
value={config.colorful}
onChange={handleChange}
/>
<ConfigPanelSlider
prop="colorUpdateSpeed"
label="Color Speed"
min={1}
max={50}
value={config.colorUpdateSpeed}
type="int"
onChange={handleChange}
/>
{/* Post-Processing */}
<ConfigPanelBooleanControl
prop="bloom"
label="Bloom Effect"
value={config.bloom}
onChange={handleChange}
/>
<ConfigPanelSlider
prop="bloomIterations"
label="Bloom Iter."
min={1}
max={20}
value={config.bloomIterations}
type="int"
onChange={handleChange}
/>
<ConfigPanelSlider
prop="bloomResolution"
label="Bloom Res."
min={64}
max={512}
value={config.bloomResolution}
type="int"
onChange={handleChange}
/>
<ConfigPanelSlider
prop="bloomIntensity"
label="Bloom Int."
min={0}
max={2}
value={config.bloomIntensity}
type="float"
onChange={handleChange}
/>
<ConfigPanelSlider
prop="bloomThreshold"
label="Bloom Thresh."
min={0}
max={1}
value={config.bloomThreshold}
type="float"
onChange={handleChange}
/>
<ConfigPanelSlider
prop="bloomSoftKnee"
label="Bloom Soft Knee"
min={0}
max={1}
value={config.bloomSoftKnee}
type="float"
onChange={handleChange}
/>
<ConfigPanelBooleanControl
prop="sunrays"
label="Sunrays Effect"
value={config.sunrays}
onChange={handleChange}
/>
<ConfigPanelSlider
prop="sunraysResolution"
label="Sunrays Res."
min={64}
max={512}
value={config.sunraysResolution}
type="int"
onChange={handleChange}
/>
<ConfigPanelSlider
prop="sunraysWeight"
label="Sunrays Weight"
min={0}
max={2}
value={config.sunraysWeight}
type="float"
onChange={handleChange}
/>
</ConfigPanel>
)
}

View File

@@ -0,0 +1,442 @@
import { Box, Editor, react, throttle, TLCamera, TLShape, Vec } from 'tldraw'
import { FluidSimulation } from './fluid'
export interface FluidManagerConfig {
/** Quality mode affects canvas resolution */
quality: number
/** Scale factor for velocity calculations */
velocityScale: number
/** Number of sample points for bounds-based shapes */
boundsSampleCount: number
/** Range for random splat counts [min, max] */
randomSplatsRange: [number, number]
/** Custom color map for dark mode */
darkModeColorMap: Record<string, [number, number, number]>
/** Custom color map for light mode */
lightModeColorMap: Record<string, [number, number, number]>
/** Resolution of the simulation grid (lower = faster, higher = more detailed) */
simResolution: number
/** Resolution of the dye texture (affects visual quality) */
dyeResolution: number
/** Rate at which dye density dissipates (0 = never fades, higher = fades faster) */
densityDissipation: number
/** Rate at which velocity dissipates (0 = perpetual motion, higher = stops faster) */
velocityDissipation: number
/** Pressure strength for velocity field calculations */
pressure: number
/** Number of iterations for pressure solver (higher = more accurate but slower) */
pressureIterations: number
/** Vorticity confinement strength (creates swirling patterns) */
curl: number
/** Radius of splat effect (size of fluid disturbance) */
splatRadius: number
/** Force applied to splats (strength of fluid disturbance) */
splatForce: number
/** Enable shading effect (adds depth perception) */
shading: boolean
/** Enable colorful mode (automatically cycles colors) */
colorful: boolean
/** Speed of automatic color updates (when colorful is true) */
colorUpdateSpeed: number
/** Pause the simulation */
paused: boolean
/** Background color (RGB values 0-255) */
backColor: { r: number; g: number; b: number }
/** Transparent background */
transparent: boolean
/** Enable bloom post-processing effect */
bloom: boolean
/** Number of bloom blur iterations */
bloomIterations: number
/** Resolution for bloom effect */
bloomResolution: number
/** Bloom effect intensity */
bloomIntensity: number
/** Brightness threshold for bloom effect */
bloomThreshold: number
/** Bloom soft knee (smoothness of threshold transition) */
bloomSoftKnee: number
/** Enable sunrays post-processing effect */
sunrays: boolean
/** Resolution for sunrays effect */
sunraysResolution: number
/** Sunrays effect weight (intensity) */
sunraysWeight: number
/** Enable pixelated rendering style */
pixelate: boolean
}
const DEFAULT_DARK_MODE_COLOR_MAP: Record<string, [number, number, number]> = {
black: [0.001, 0.043, 0.084],
grey: [0.08, 0.08, 0.08],
'light-violet': [0.07, 0.03, 0.06],
violet: [0.04, 0.02, 0.08],
blue: [0.02, 0.04, 0.09],
'light-blue': [0.03, 0.06, 0.09],
yellow: [0.09, 0.08, 0.02],
orange: [0.08, 0.03, 0.01],
green: [0.03, 0.07, 0.03],
'light-green': [0.06, 0.09, 0.04],
'light-red': [0.08, 0.03, 0.03],
red: [0.08, 0.02, 0.02],
}
const DEFAULT_LIGHT_MODE_COLOR_MAP: Record<string, [number, number, number]> = {
black: [0.001, 0.043, 0.084],
grey: [0.083, 0.083, 0.084],
'light-violet': [0.125, 0.063, 0.062],
violet: [0.125, 0.05, 0.075],
blue: [0.05, 0.075, 0.125],
'light-blue': [0.05, 0.1, 0.1],
yellow: [0.125, 0.1, 0.025],
orange: [0.15, 0.075, 0.025],
green: [0.05, 0.15, 0.05],
'light-green': [0.075, 0.125, 0.05],
'light-red': [0.15, 0.05, 0.05],
red: [0.15, 0.05, 0.05],
}
export const DEFAULT_CONFIG: FluidManagerConfig = {
quality: 0.5,
velocityScale: 0.01,
boundsSampleCount: 20,
randomSplatsRange: [5, 20],
darkModeColorMap: DEFAULT_DARK_MODE_COLOR_MAP,
lightModeColorMap: DEFAULT_LIGHT_MODE_COLOR_MAP,
// Fluid simulation defaults
simResolution: 128,
dyeResolution: 1024,
densityDissipation: 1,
velocityDissipation: 0.2,
pressure: 0.8,
pressureIterations: 20,
curl: 10,
splatRadius: 0.25,
splatForce: 6000,
shading: true,
colorful: true,
colorUpdateSpeed: 10,
paused: false,
backColor: { r: 0, g: 0, b: 0 },
transparent: false,
bloom: true,
bloomIterations: 8,
bloomResolution: 256,
bloomIntensity: 0.8,
bloomThreshold: 0.6,
bloomSoftKnee: 0.7,
sunrays: true,
sunraysResolution: 196,
sunraysWeight: 1.0,
pixelate: false,
}
/**
* Manages fluid simulation interactions with tldraw shapes.
* Handles shape tracking, geometry extraction, and fluid simulation lifecycle.
*/
export class FluidManager {
private fluidSim: FluidSimulation | null = null
private config: FluidManagerConfig
private darkMode: boolean = false
private disposables = new Set<() => void>()
private isPointerEffectActive = false
constructor(
private canvas: HTMLCanvasElement,
private editor: Editor,
config?: Partial<FluidManagerConfig>
) {
this.config = {
...DEFAULT_CONFIG,
...config,
}
this.disposables.add(
react('tool changes', () => {
const currentTool = editor.getCurrentToolId()
if (currentTool === 'eraser') {
this.isPointerEffectActive = true
} else {
if (this.isPointerEffectActive) {
this.handlePointerUp()
}
this.isPointerEffectActive = false
}
})
)
this.disposables.add(
editor.store.listen((diff) => {
const updatedRecords = Object.values(diff.changes.updated)
const updatedCamera = updatedRecords.find(([record]) => record.typeName === 'camera')
if (updatedCamera) {
const [prevCamera, newCamera] = updatedCamera as [TLCamera, TLCamera]
const { velocityScale } = this.config
this.handleViewportChange(
this.editor.getViewportScreenBounds(),
Vec.Sub(newCamera, prevCamera).mul(velocityScale)
)
return
}
const added = Object.values(diff.changes.added).filter(
(record) => record.typeName === 'shape'
) as TLShape[]
const updated = updatedRecords.filter(([record]) => record.typeName === 'shape') as [
TLShape,
TLShape,
][]
this.updateShapes(added, updated)
})
)
}
/**
* Initialize the fluid simulation with the canvas.
* Must be called before using other methods.
*/
initialize(darkMode: boolean = false): void {
this.darkMode = darkMode
// Set canvas internal resolution based on display size
const rect = this.canvas.getBoundingClientRect()
this.canvas.width = rect.width
this.canvas.height = rect.height
const backgroundColor = darkMode ? { r: 16, g: 16, b: 17 } : { r: 249, g: 250, b: 251 }
this.fluidSim = new FluidSimulation(this.canvas, {
SIM_RESOLUTION: this.config.quality * this.config.simResolution,
DYE_RESOLUTION: this.config.quality * this.config.dyeResolution,
DENSITY_DISSIPATION: this.config.densityDissipation,
VELOCITY_DISSIPATION: this.config.velocityDissipation,
PRESSURE: this.config.pressure,
PRESSURE_ITERATIONS: this.config.pressureIterations,
CURL: this.config.curl,
SPLAT_RADIUS: this.config.splatRadius,
SPLAT_FORCE: this.config.splatForce,
SHADING: this.config.shading,
COLORFUL: this.config.colorful,
COLOR_UPDATE_SPEED: this.config.colorUpdateSpeed,
PAUSED: this.config.paused,
BACK_COLOR: backgroundColor,
TRANSPARENT: this.config.transparent,
BLOOM: this.config.bloom,
BLOOM_ITERATIONS: this.config.bloomIterations,
BLOOM_RESOLUTION: this.config.bloomResolution,
BLOOM_INTENSITY: this.config.bloomIntensity,
BLOOM_THRESHOLD: this.config.bloomThreshold,
BLOOM_SOFT_KNEE: this.config.bloomSoftKnee,
SUNRAYS: this.config.sunrays,
SUNRAYS_RESOLUTION: this.config.sunraysResolution,
SUNRAYS_WEIGHT: this.config.sunraysWeight,
})
this.fluidSim.start()
this.handleViewportChange(this.editor.getViewportScreenBounds(), new Vec(0, -0.2))
}
/**
* Clean up resources and dispose of the fluid simulation.
* Destroys the fluid simulation instance and clears all registered disposables.
*/
dispose = (): void => {
if (this.fluidSim) {
this.fluidSim.destroy()
this.fluidSim = null
}
this.disposables.forEach((dispose) => dispose())
this.disposables.clear()
}
/**
* Handle pointer down event.
* Initiates drag interaction with the fluid simulation when the eraser tool is active.
*/
handlePointerDown = (): void => {
if (!this.isPointerEffectActive) return
const { x, y } = this.getNormalizedPosition()
this.fluidSim?.startDrag(x, y)
}
/**
* Handle pointer move event.
* Updates drag position in the fluid simulation during active dragging.
*/
handlePointerMove = (): void => {
if (!this.isPointerEffectActive || !this.editor.inputs.getIsDragging()) return
const { x, y } = this.getNormalizedPosition()
this.fluidSim?.updateDrag(x, y)
}
/**
* Handle pointer up event.
* Ends drag interaction with the fluid simulation.
*/
handlePointerUp = (): void => {
if (!this.isPointerEffectActive || !this.editor.inputs.getIsDragging()) return
this.fluidSim?.endDrag()
}
/**
* Get the current pointer position normalized to fluid simulation coordinates.
* @returns Normalized coordinates where x and y are in the range [0, 1], with y inverted for WebGL.
*/
private getNormalizedPosition() {
const position = this.editor.inputs.getCurrentScreenPoint()
const vsb = this.editor.getViewportScreenBounds()
return {
x: position.x / vsb.w,
y: (position.y / vsb.h) * -1 + 1,
}
}
/**
* Process shape changes and update fluid simulation accordingly.
* Handles both newly created shapes and updated shapes, including group shapes.
* Throttled to run at most once every 32ms for performance.
* @param created - Array of newly created shapes
* @param updated - Array of tuples containing [previousShape, currentShape] for updated shapes
*/
updateShapes = throttle((created: TLShape[], updated: [TLShape, TLShape][]): void => {
if (!this.fluidSim) return
const vsb = this.editor.getViewportScreenBounds()
for (const shape of created) {
this.splatShapeOrGroup(shape, vsb, { x: 0, y: 0 })
}
const { velocityScale } = this.config
for (const [prevShape, shape] of updated) {
this.splatShapeOrGroup(shape, vsb, {
x: (shape.x - prevShape.x) * velocityScale,
y: (shape.y - prevShape.y) * velocityScale - 0.05,
})
}
}, 32)
private splatShapeOrGroup(shape: TLShape, vsb: Box, velocity: { x: number; y: number }) {
if (shape.type !== 'group') {
this.splatShape(shape, vsb, velocity)
return
}
// Group children always splat without velocity, matching how a new group is handled
for (const childId of this.editor.getSortedChildIdsForParent(shape.id)) {
const child = this.editor.getShape(childId)
if (child) this.splatShape(child, vsb, { x: 0, y: 0 })
}
}
private splatShape(shape: TLShape, vsb: Box, velocity: { x: number; y: number }) {
const { points, isClosed } = this.extractShapeGeometry(shape, vsb)
if (points.length === 0) return
this.fluidSim!.createSplatsFromGeometry(points, velocity, isClosed, this.getShapeColor(shape))
}
/**
* Handle viewport/camera changes by creating splats for all visible shapes.
* Throttled to prevent excessive updates during camera movements.
* @param vsb - The viewport screen bounds
* @param cameraVelocity - The velocity of the camera movement
*/
private handleViewportChange = throttle((vsb: Box, cameraVelocity: Vec): void => {
for (const { shape } of this.editor.getRenderingShapes()) {
this.splatShape(shape, vsb, cameraVelocity)
}
}, 32)
/**
* Extract the color from a shape and convert it to RGB values.
* Uses the appropriate color map based on dark mode setting.
* @param shape - The shape to extract color from
* @returns RGB color values in the range [0, 1]
*/
private getShapeColor(shape: TLShape): [number, number, number] {
const colorMap = this.darkMode ? this.config.darkModeColorMap : this.config.lightModeColorMap
const color = (shape.props as { color?: unknown }).color
return colorMap[typeof color === 'string' ? color : 'black'] || colorMap.blue
}
/**
* Extract geometry points from a shape using tldraw's geometry helpers.
* Points are returned in normalized screen coordinates (0-1 range).
*/
private extractShapeGeometry = (
shape: TLShape,
vsb: Box
): {
points: Array<{ x: number; y: number }>
isClosed: boolean
} => {
// Convert page coordinates to normalized coordinates
const toNormalized = (pagePoint: { x: number; y: number }) => {
const screenPoint = this.editor.pageToScreen(pagePoint)
return {
x: (screenPoint.x - vsb.x) / vsb.w,
y: ((screenPoint.y - vsb.y) / vsb.h) * -1 + 1,
}
}
const isOpenShapeType = shape.type === 'arrow' || shape.type === 'line'
try {
// Get the shape's geometry using tldraw's built-in helpers
const geometry = this.editor.getShapeGeometry(shape)
// Get the shape's transform
const transform = this.editor.getShapePageTransform(shape)
if (!transform) return { points: [], isClosed: false }
const points: Array<{ x: number; y: number }> = []
// Check if geometry has isClosed property
let isClosed = isOpenShapeType ? false : geometry.isClosed
// Sample points along the geometry
// Use the vertices property directly
if (geometry.vertices.length > 0) {
for (const vertex of geometry.vertices) {
points.push(toNormalized(transform.applyToPoint(vertex)))
}
} else {
// Sample points around the perimeter using bounds
const corners = geometry.bounds.corners
const pointsPerEdge = Math.ceil(this.config.boundsSampleCount / 4)
for (let j = 0; j < 4; j++) {
for (let i = 0; i < pointsPerEdge; i++) {
points.push(
toNormalized(
transform.applyToPoint(Vec.Lrp(corners[j], corners[(j + 1) % 4], i / pointsPerEdge))
)
)
}
}
// Bounds-based shapes are typically closed
isClosed = true
}
return { points, isClosed }
} catch (error) {
console.warn('Failed to extract geometry for shape:', shape.type, error)
// Fallback to bounds-based approach
const bounds = this.editor.getShapePageBounds(shape)
if (!bounds) return { points: [], isClosed: false }
const { x, y, w, h } = bounds
return {
points: [
toNormalized({ x, y }),
toNormalized({ x: x + w, y }),
toNormalized({ x: x + w, y: y + h }),
toNormalized({ x, y: y + h }),
],
isClosed: !isOpenShapeType,
}
}
}
}

View File

@@ -0,0 +1,60 @@
import { memo, useLayoutEffect, useRef } from 'react'
import { useColorMode, useEditor, useValue } from 'tldraw'
import { fluidConfig } from './config'
import { FluidManager } from './FluidManager'
/**
* React component that renders the fluid simulation canvas.
* Initializes and manages the FluidManager lifecycle, including:
* - WebGL context setup
* - Pointer event handling
* - Cleanup on unmount
*/
export const FluidRenderer = memo(() => {
const editor = useEditor()
const rCanvas = useRef<HTMLCanvasElement>(null)
const darkMode = useColorMode() === 'dark'
const config = useValue('config', () => fluidConfig.get(), [])
// Initialize FluidManager
useLayoutEffect(() => {
const manager = new FluidManager(rCanvas.current!, editor, config)
// Initialize the WebGL context and start the animation loop
manager.initialize(darkMode)
function handlePointerDown(e: PointerEvent) {
const elm = e.target! as HTMLElement
elm.setPointerCapture(e.pointerId)
manager.handlePointerDown()
}
function handlePointerUp(e: PointerEvent) {
const elm = e.target! as HTMLElement
elm.releasePointerCapture(e.pointerId)
manager.handlePointerUp()
}
function handlePointerMove() {
manager.handlePointerMove()
}
document.addEventListener('pointermove', handlePointerMove)
document.addEventListener('pointerdown', handlePointerDown)
document.addEventListener('pointerup', handlePointerUp)
return () => {
document.removeEventListener('pointermove', handlePointerMove)
document.removeEventListener('pointerdown', handlePointerDown)
document.removeEventListener('pointerup', handlePointerUp)
manager.handlePointerUp()
manager.dispose()
}
}, [darkMode, editor, config])
const canvasClassName = `shader-app__canvas${config.pixelate ? ' shader-app__canvas--pixelated' : ''}`
return <canvas ref={rCanvas} className={canvasClassName} />
})

View File

@@ -0,0 +1,32 @@
import { atom, react } from 'tldraw'
import { DEFAULT_CONFIG, FluidManagerConfig } from './FluidManager'
/** Storage key for persisting fluid configuration in localStorage */
const STORAGE_KEY = 'shader-fluid-config-1'
let initialValue = DEFAULT_CONFIG
try {
const value = localStorage.getItem(STORAGE_KEY)
if (value) initialValue = JSON.parse(value)
} catch {
// noop
}
/**
* Reactive atom containing the fluid simulation configuration.
* Changes to this atom are automatically persisted to localStorage.
*/
export const fluidConfig = atom<FluidManagerConfig>('fluid-config', initialValue)
/**
* Resets the fluid configuration to default values.
*/
export function resetFluidConfig() {
fluidConfig.set(DEFAULT_CONFIG)
}
// When the config changes, save it to localStorage
react('save to local storage', () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(fluidConfig.get()))
})

View File

@@ -0,0 +1,125 @@
# Fluid Simulation Example
A WebGL-based fluid simulation that reacts to shape interactions on the tldraw canvas, creating dynamic visual effects as shapes are created, moved, and manipulated. The incredible fluid shader is adapted from the work of Pavel Dobryakov, see license in fluid.ts.
This example demonstrates how to:
- Create a real-time fluid simulation background using WebGL
- Extract and track tldraw shape geometry
- Map shape movements to fluid dynamics
- Create splats (fluid disturbances) based on shape velocity and position
- Build an interactive configuration panel for live parameter tuning
## Key Components
### FluidManager
Manages the integration between tldraw and the fluid simulation:
- Tracks shape creation and updates using store listeners
- Extracts shape geometry and converts to normalized screen coordinates
- Calculates velocity from shape position changes
- Creates fluid splats along shape edges based on movement
- Handles eraser tool for direct user interaction with fluid
### FluidSimulation (fluid.ts)
The core WebGL fluid dynamics engine:
- Implements Navier-Stokes equations for fluid motion
- Uses framebuffer ping-ponging for real-time computation
- Supports post-processing effects (bloom, sunrays)
- Renders velocity and dye fields with configurable dissipation
### FluidConfigPanel
Live UI for adjusting simulation parameters:
- Simulation resolution and quality settings
- Dissipation rates for velocity and density
- Visual effects (bloom, sunrays, shading)
- Color mode (colorful with auto-color cycling)
- Splat force and radius controls
## Configuration
The fluid simulation is highly configurable via [config.ts](./config.ts):
**Performance Settings:**
- `quality`: Canvas resolution multiplier (multiplied with simResolution and dyeResolution)
- `simResolution`: Simulation grid detail (higher = more detailed, lower = faster)
- `dyeResolution`: Visual quality (higher = sharper)
**Physics Parameters:**
- `velocityDissipation`: How quickly motion decays (0 = perpetual motion, higher = stops faster)
- `densityDissipation`: How quickly colors fade (0 = never fades, higher = fades faster)
- `pressure`: Strength of incompressibility constraint
- `pressureIterations`: Number of iterations for pressure solver (higher = more accurate but slower)
- `curl`: Vorticity confinement (creates swirls)
**Visual Effects:**
- `bloom`: Enable glow effect on bright areas
- `bloomIterations`: Number of blur passes for bloom
- `bloomResolution`: Texture size for bloom effect
- `bloomIntensity`: Brightness multiplier for bloom
- `bloomThreshold`: Minimum brightness to trigger bloom
- `bloomSoftKnee`: Smoothness of threshold transition
- `sunrays`: Enable radial light rays effect
- `sunraysResolution`: Texture size for sunrays effect
- `sunraysWeight`: Intensity of sunrays effect
- `shading`: Depth perception
- `colorful`: Automatic color cycling
- `colorUpdateSpeed`: Speed of color changes when colorful is enabled
- `transparent`: Transparent background
- `pixelate`: Pixelated rendering style
**Shape Integration:**
- `velocityScale`: Multiplier for shape velocity → fluid velocity
- `boundsSampleCount`: Points sampled for bounds-based shapes
- `splatRadius`: Size of fluid disturbance
- `splatForce`: Strength of fluid disturbance
- `darkModeColorMap`/`lightModeColorMap`: Shape color → fluid color mapping
- `paused`: Freeze the simulation
## How It Works
1. `FluidRenderer` initializes `FluidManager` with the canvas and editor
2. `FluidManager` listens to the tldraw store for shape changes
3. When shapes are created or updated:
- Geometry is extracted using `extractShapeGeometry`
- Points are converted to normalized coordinates (0-1 range)
- Velocity is calculated from position delta
- Color is mapped from shape color to RGB using color maps
4. `FluidSimulation.createSplatsFromGeometry` creates fluid disturbances along shape edges
5. The fluid simulation updates continuously via `requestAnimationFrame`
6. Camera movements also create splats for all visible shapes
7. Configuration changes are automatically persisted to localStorage
## Interactive Features
- **Eraser Tool**: Drag to create fluid disturbances directly
- **Shape Movement**: Moving shapes creates velocity-based splats
- **Camera Panning**: Canvas movement affects all visible shapes
- **Live Config**: Adjust parameters in real-time via the config panel
## Customization Ideas
- Modify color maps for different visual styles
- Add shape-type-specific splat behaviors
- Implement collision detection with shape boundaries
- Create tool-specific fluid effects (e.g., drawing creates trails)
- Add audio-reactive parameters
- Experiment with different splat patterns
## Performance Tips
- Lower `simResolution` for better performance
- Reduce `dyeResolution` if visual quality isn't critical
- Decrease `pressureIterations` for faster computation
- Disable `bloom` and `sunrays` effects if needed
- Use `paused` config to freeze the simulation
- Adjust `velocityScale` to control splat intensity

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,7 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@500;700&display=swap');
@import url('tldraw/tldraw.css');
body {
font-family: 'Inter', sans-serif;
overscroll-behavior: none;
}

View File

@@ -0,0 +1,11 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
import './shader.css'
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>
)

View File

@@ -0,0 +1,52 @@
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 }> = {
count: { min: 0, max: 100 },
}
export function MinimalConfigPanel() {
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 { MinimalShaderManager } from './MinimalShaderManager'
export const MinimalRenderer = memo(() => {
const editor = useEditor()
const rCanvas = useRef<HTMLCanvasElement>(null)
useLayoutEffect(() => {
const manager = new MinimalShaderManager(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,170 @@
import { Atom, Editor, react, 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 class MinimalShaderManager extends WebGLManager<ShaderManagerConfig> {
private program: WebGLProgram | null = null
private positionBuffer: WebGLBuffer | null = null
private vao: WebGLVertexArrayObject | null = null
pointer: Vec = new Vec(0, 0)
private u_bgColor: WebGLUniformLocation | null = null
constructor(
editor: Editor,
canvas: HTMLCanvasElement,
config: Atom<ShaderManagerConfig, unknown>
) {
super(editor, canvas, config)
this.initialize()
}
onInitialize = (): void => {
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_bgColor = this.gl.getUniformLocation(this.program, 'u_bgColor')
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 => {
// implement here...
}
onFirstRender = (): void => {
if (!this.gl || !this.program) return
this.gl.enable(this.gl.BLEND)
this.gl.clearColor(0, 0, 0, 0)
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
if (this.u_bgColor) {
const isDarkMode = this.editor.user.getIsDarkMode()
const bgColor = isDarkMode ? [0.1, 0.1, 0.1] : [0.9, 0.9, 0.9]
this.gl.uniform3f(this.u_bgColor, bgColor[0], bgColor[1], bgColor[2])
}
this.gl.clearColor(0, 0, 0, 0)
this.gl.clear(this.gl.COLOR_BUFFER_BIT)
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()
}
}

View File

@@ -0,0 +1,36 @@
import { atom, react } from 'tldraw'
import { WebGLManagerConfig } from '../WebGLManager'
export interface ShaderManagerConfig extends WebGLManagerConfig {
count: number
}
export const DEFAULT_CONFIG: ShaderManagerConfig = {
// default properties
quality: 0.5,
pixelate: true,
startPaused: false,
// Custom properties
count: 10,
}
const STORAGE_KEY = 'shader-minimal-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,12 @@
#version 300 es
precision highp float;
precision highp int;
in vec2 v_uv;
out vec4 fragColor;
uniform vec3 u_bgColor;
void main() {
fragColor = vec4(u_bgColor, 1.0);
}

View File

@@ -0,0 +1,199 @@
# Minimal Shader Example
A bare-bones WebGL shader template that renders a solid color background responding to tldraw's dark mode. Use this as the starting point for a new shader.
## What This Example Does
- Creates a full-screen quad with a simple shader
- Adapts background color based on tldraw's dark/light mode
- Provides the minimal foundation for building custom shader effects
## Key Files
- **[config.ts](config.ts)** - Configuration with localStorage persistence
- **[vertex.glsl](vertex.glsl)** - Basic vertex shader for full-screen quad
- **[fragment.glsl](fragment.glsl)** - Simple fragment shader outputting solid color
- **[MinimalShaderManager.ts](MinimalShaderManager.ts)** - Core WebGL setup and render loop incorporating glsl files
- **[MinimalRenderer.tsx](MinimalRenderer.tsx)** - React component integrating the shader manager
- **[MinimalConfigPanel.tsx](MinimalConfigPanel.tsx)** - UI panel for runtime configuration controls
## How to Modify This Example
### Adding New Uniforms
1. **In fragment.glsl**: Add uniform declaration
```glsl
uniform float u_myValue;
```
2. **In MinimalShaderManager.ts**:
- Add property to store uniform location (line ~14):
```typescript
private u_myValue: WebGLUniformLocation | null = null
```
- Get uniform location in `onInitialize` after line 103:
```typescript
this.u_myValue = this.gl.getUniformLocation(this.program, 'u_myValue')
```
- Set uniform value in `onRender` (around line 144):
```typescript
if (this.u_myValue) {
this.gl.uniform1f(this.u_myValue, this.config.get().myValue)
}
```
### Adding Configuration Values
1. **In config.ts**:
- Add property to `ShaderManagerConfig` interface (line ~4):
```typescript
export interface ShaderManagerConfig extends WebGLManagerConfig {
count: number
myValue: number // Add here
}
```
- Add default value in `DEFAULT_CONFIG` (line ~8):
```typescript
myValue: 0.5,
```
2. **In MinimalConfigPanel.tsx**:
- Add slider range to `SLIDER_CONFIGS` (line ~8):
```typescript
const SLIDER_CONFIGS: Record<string, { min: number; max: number }> = {
count: { min: 0, max: 100 },
myValue: { min: 0, max: 1 }, // Add here
}
```
- The panel will automatically generate a slider for the new value
### Modifying the Fragment Shader
The fragment shader (fragment.glsl) is where visual effects happen:
- **Input**: `v_uv` - normalized UV coordinates (0-1) from vertex shader
- **Output**: `fragColor` - RGBA color for the pixel
- **Current behavior**: Outputs solid color from `u_bgColor` uniform
Example modifications:
**Gradient effect**:
```glsl
void main() {
vec3 color = mix(vec3(1.0, 0.0, 0.0), vec3(0.0, 0.0, 1.0), v_uv.x);
fragColor = vec4(color, 1.0);
}
```
**Animated pattern with time**:
```glsl
uniform float u_time;
void main() {
float pattern = sin(v_uv.x * 10.0 + u_time) * cos(v_uv.y * 10.0 + u_time);
fragColor = vec4(vec3(pattern), 1.0);
}
```
### Using Pointer Position
The shader manager tracks pointer position (MinimalShaderManager.ts:184). To use it:
1. Add uniform to fragment.glsl:
```glsl
uniform vec2 u_pointer;
```
2. Get and set in MinimalShaderManager.ts:
```typescript
// In onInitialize:
this.u_pointer = this.gl.getUniformLocation(this.program, 'u_pointer')
// In onRender:
if (this.u_pointer) {
this.gl.uniform2f(this.u_pointer, this.pointer.x, this.pointer.y)
}
```
### Adding Time-Based Animation
Time values are available in `onRender`:
```typescript
onRender = (deltaTime: number, currentTime: number): void => {
// deltaTime: time since last frame (seconds)
// currentTime: total elapsed time (seconds)
if (this.u_time) {
this.gl.uniform1f(this.u_time, currentTime)
}
}
```
### Working with tldraw Shapes
The shader manager has access to the editor instance. To react to shapes:
```typescript
onUpdate = (): void => {
const shapes = this.editor.getCurrentPageShapes()
// Process shapes and update shader uniforms
}
```
Use the `pageToCanvas` helper (line 199) to convert shape coordinates to shader UV space.
## Common Patterns
### Multiple Uniforms
Group related uniforms into arrays for efficiency:
```typescript
// Instead of separate calls:
this.gl.uniform1f(this.u_value1, val1)
this.gl.uniform1f(this.u_value2, val2)
// Use vectors:
this.gl.uniform2f(this.u_values, val1, val2)
```
### Conditional Rendering
Toggle effects based on config:
```glsl
uniform bool u_enableEffect;
void main() {
vec3 color = u_bgColor;
if (u_enableEffect) {
color = applyEffect(color);
}
fragColor = vec4(color, 1.0);
}
```
### Debug Visualization
Visualize UV coordinates or other values:
```glsl
void main() {
// Red-green gradient showing UV space
fragColor = vec4(v_uv.x, v_uv.y, 0.0, 1.0);
}
```
## Next Steps
For more complex examples, see:
- **rainbow/** - Animated color effects with multiple uniforms
- **fluid/** - Interactive fluid simulation with pointer tracking
- **shadow/** - Shape-aware rendering using tldraw editor data

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

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

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 { 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} />
})

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

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

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

View 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

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

View File

@@ -0,0 +1,108 @@
.shader-app {
position: fixed;
inset: 0;
}
.shader-app__example-menu {
z-index: 1000;
margin: 4px;
overflow: hidden;
color: var(--tl-color-text);
background-color: var(--tl-color-background);
}
.shader-app__canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1000;
background-color: var(--tl-color-background);
}
/* Pixelated rendering - nearest-neighbor instead of smooth interpolation */
.shader-app__canvas--pixelated {
image-rendering: pixelated;
image-rendering: -moz-crisp-edges;
image-rendering: crisp-edges;
}
.shader-config-panel {
width: fit-content;
max-width: none;
z-index: 1000;
pointer-events: all;
font-size: 12px;
color: var(--tl-color-text);
}
.shader-config-panel--expanded {
height: fit-content;
min-width: 220px;
max-height: calc(min(500px, 80vh));
overflow-y: auto;
padding-bottom: var(--tl-space-3);
}
.shader-config-panel--collapsed {
height: min-content;
overflow-y: visible;
}
.shader-config-panel__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--tl-space-3);
z-index: 200;
color: var(--tl-color-text);
border-bottom: 1px solid var(--tl-color-muted-2);
cursor: pointer;
}
.shader-config-panel__toggle {
padding: var(--tl-space-4);
}
.shader-config-panel__content {
height: fit-content;
padding: 0;
}
.shader-config-panel__reset-button {
color: var(--tl-color-text);
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
font-weight: bold;
}
.shader-config-panel__control {
height: 44px;
}
.shader-config-panel__control--slider {
display: grid;
grid-template-columns: 1fr 1fr;
}
.shader-config-panel__control--boolean {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
cursor: pointer;
padding-right: var(--tl-space-4);
}
.shader-config-panel__boolean-input {
cursor: pointer;
}
.shader-config-panel__label {
padding: var(--tl-space-4);
font-family: var(--tl-font-mono);
display: block;
}

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

1
templates/shader/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"experimentalDecorators": true
},
"include": ["src", "vite.config.ts", "vite-env.d.ts"],
"references": [{ "path": "../../packages/tldraw" }]
}

View File

@@ -0,0 +1,6 @@
{
"framework": "vite",
"installCommand": "yarn",
"buildCommand": "yarn run -T lazy build",
"ignoreCommand": "bash ../../internal/scripts/vercel/should-build-template.sh shader"
}

1
templates/shader/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,7 @@
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
})