-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathWebGLManager.ts
More file actions
321 lines (270 loc) · 9.07 KB
/
WebGLManager.ts
File metadata and controls
321 lines (270 loc) · 9.07 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
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: WebGLRenderingContext | WebGL2RenderingContext | null = null
animationFrameId: number | null = null
lastFrameTime: number = 0
isInitialized: boolean = false
isDisposed: boolean = false
private _needsFirstRender: boolean = 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
}
// Create WebGL2 context with optional attributes
const contextType = 'webgl2'
this.gl = this.canvas.getContext(contextType, contextAttributes) as
| WebGLRenderingContext
| WebGL2RenderingContext
| null
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
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId)
this.animationFrameId = null
}
// 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
}
/**
* Returns true if initialize() has been called successfully.
*/
getIsInitialized = (): boolean => {
return this.isInitialized
}
/**
* Returns true if dispose() has been called.
*/
getIsDisposed = (): boolean => {
return this.isDisposed
}
/**
* Returns the WebGL rendering context, or null if not initialized or disposed.
*/
getGL = (): WebGLRenderingContext | WebGL2RenderingContext | null => {
return this.gl
}
/**
* Returns the HTMLCanvasElement this manager is rendering to.
*/
getCanvas = (): HTMLCanvasElement => {
return this.canvas
}
/**
* 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 => {
const { width, height } = this.canvas.getBoundingClientRect()
if (!this.isInitialized || this.isDisposed || !this.gl) {
return
}
const { quality } = this.getConfig()
this.canvas.width = Math.floor(width * quality)
this.canvas.height = Math.floor(height * quality)
// 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()
}
}
/**
* 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) {
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
const { width, height } = this.canvas.getBoundingClientRect()
const { quality } = this.getConfig()
this.canvas.width = Math.floor(width * quality)
this.canvas.height = Math.floor(height * quality)
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
}
}