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