@stevejtrettel/shader-sandbox 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/README.md +391 -0
  2. package/bin/cli.js +389 -0
  3. package/dist-lib/app/App.d.ts +134 -0
  4. package/dist-lib/app/App.d.ts.map +1 -0
  5. package/dist-lib/app/App.js +570 -0
  6. package/dist-lib/app/types.d.ts +32 -0
  7. package/dist-lib/app/types.d.ts.map +1 -0
  8. package/dist-lib/app/types.js +6 -0
  9. package/dist-lib/editor/EditorPanel.d.ts +39 -0
  10. package/dist-lib/editor/EditorPanel.d.ts.map +1 -0
  11. package/dist-lib/editor/EditorPanel.js +274 -0
  12. package/dist-lib/editor/prism-editor.css +99 -0
  13. package/dist-lib/editor/prism-editor.d.ts +19 -0
  14. package/dist-lib/editor/prism-editor.d.ts.map +1 -0
  15. package/dist-lib/editor/prism-editor.js +96 -0
  16. package/dist-lib/embed.d.ts +17 -0
  17. package/dist-lib/embed.d.ts.map +1 -0
  18. package/dist-lib/embed.js +35 -0
  19. package/dist-lib/engine/ShadertoyEngine.d.ts +160 -0
  20. package/dist-lib/engine/ShadertoyEngine.d.ts.map +1 -0
  21. package/dist-lib/engine/ShadertoyEngine.js +704 -0
  22. package/dist-lib/engine/glHelpers.d.ts +79 -0
  23. package/dist-lib/engine/glHelpers.d.ts.map +1 -0
  24. package/dist-lib/engine/glHelpers.js +298 -0
  25. package/dist-lib/engine/types.d.ts +77 -0
  26. package/dist-lib/engine/types.d.ts.map +1 -0
  27. package/dist-lib/engine/types.js +7 -0
  28. package/dist-lib/index.d.ts +12 -0
  29. package/dist-lib/index.d.ts.map +1 -0
  30. package/dist-lib/index.js +9 -0
  31. package/dist-lib/layouts/DefaultLayout.d.ts +17 -0
  32. package/dist-lib/layouts/DefaultLayout.d.ts.map +1 -0
  33. package/dist-lib/layouts/DefaultLayout.js +27 -0
  34. package/dist-lib/layouts/FullscreenLayout.d.ts +17 -0
  35. package/dist-lib/layouts/FullscreenLayout.d.ts.map +1 -0
  36. package/dist-lib/layouts/FullscreenLayout.js +27 -0
  37. package/dist-lib/layouts/SplitLayout.d.ts +26 -0
  38. package/dist-lib/layouts/SplitLayout.d.ts.map +1 -0
  39. package/dist-lib/layouts/SplitLayout.js +61 -0
  40. package/dist-lib/layouts/TabbedLayout.d.ts +38 -0
  41. package/dist-lib/layouts/TabbedLayout.d.ts.map +1 -0
  42. package/dist-lib/layouts/TabbedLayout.js +305 -0
  43. package/dist-lib/layouts/index.d.ts +24 -0
  44. package/dist-lib/layouts/index.d.ts.map +1 -0
  45. package/dist-lib/layouts/index.js +36 -0
  46. package/dist-lib/layouts/split.css +196 -0
  47. package/dist-lib/layouts/tabbed.css +345 -0
  48. package/dist-lib/layouts/types.d.ts +48 -0
  49. package/dist-lib/layouts/types.d.ts.map +1 -0
  50. package/dist-lib/layouts/types.js +4 -0
  51. package/dist-lib/main.d.ts +15 -0
  52. package/dist-lib/main.d.ts.map +1 -0
  53. package/dist-lib/main.js +102 -0
  54. package/dist-lib/project/generatedLoader.d.ts +3 -0
  55. package/dist-lib/project/generatedLoader.d.ts.map +1 -0
  56. package/dist-lib/project/generatedLoader.js +17 -0
  57. package/dist-lib/project/loadProject.d.ts +22 -0
  58. package/dist-lib/project/loadProject.d.ts.map +1 -0
  59. package/dist-lib/project/loadProject.js +350 -0
  60. package/dist-lib/project/loaderHelper.d.ts +7 -0
  61. package/dist-lib/project/loaderHelper.d.ts.map +1 -0
  62. package/dist-lib/project/loaderHelper.js +240 -0
  63. package/dist-lib/project/types.d.ts +192 -0
  64. package/dist-lib/project/types.d.ts.map +1 -0
  65. package/dist-lib/project/types.js +7 -0
  66. package/dist-lib/styles/base.css +29 -0
  67. package/package.json +48 -0
  68. package/src/app/App.ts +699 -0
  69. package/src/app/app.css +208 -0
  70. package/src/app/types.ts +36 -0
  71. package/src/editor/EditorPanel.ts +340 -0
  72. package/src/editor/editor-panel.css +175 -0
  73. package/src/editor/prism-editor.css +99 -0
  74. package/src/editor/prism-editor.ts +124 -0
  75. package/src/embed.ts +55 -0
  76. package/src/engine/ShadertoyEngine.ts +929 -0
  77. package/src/engine/glHelpers.ts +432 -0
  78. package/src/engine/types.ts +118 -0
  79. package/src/index.ts +13 -0
  80. package/src/layouts/DefaultLayout.ts +40 -0
  81. package/src/layouts/FullscreenLayout.ts +40 -0
  82. package/src/layouts/SplitLayout.ts +81 -0
  83. package/src/layouts/TabbedLayout.ts +371 -0
  84. package/src/layouts/default.css +22 -0
  85. package/src/layouts/fullscreen.css +15 -0
  86. package/src/layouts/index.ts +44 -0
  87. package/src/layouts/split.css +196 -0
  88. package/src/layouts/tabbed.css +345 -0
  89. package/src/layouts/types.ts +58 -0
  90. package/src/main.ts +114 -0
  91. package/src/project/generatedLoader.ts +23 -0
  92. package/src/project/loadProject.ts +421 -0
  93. package/src/project/loaderHelper.ts +300 -0
  94. package/src/project/types.ts +243 -0
  95. package/src/styles/base.css +29 -0
  96. package/src/styles/embed.css +14 -0
  97. package/src/vite-env.d.ts +1 -0
  98. package/templates/index.html +28 -0
  99. package/templates/main.ts +126 -0
  100. package/templates/package.json +12 -0
  101. package/templates/shaders/example-buffer/bufferA.glsl +14 -0
  102. package/templates/shaders/example-buffer/config.json +10 -0
  103. package/templates/shaders/example-buffer/image.glsl +5 -0
  104. package/templates/shaders/example-gradient/config.json +4 -0
  105. package/templates/shaders/example-gradient/image.glsl +7 -0
  106. package/templates/vite.config.js +35 -0
package/src/app/App.ts ADDED
@@ -0,0 +1,699 @@
1
+ /**
2
+ * App Layer - Browser Runtime Coordinator
3
+ *
4
+ * Responsibilities:
5
+ * - Create and manage canvas
6
+ * - Initialize ShadertoyEngine
7
+ * - Run animation loop (requestAnimationFrame)
8
+ * - Handle resize and mouse events
9
+ * - Present Image pass output to screen
10
+ */
11
+
12
+ import './app.css';
13
+
14
+ import { ShadertoyEngine } from '../engine/ShadertoyEngine';
15
+ import { ShadertoyProject } from '../project/types';
16
+ import { AppOptions, MouseState } from './types';
17
+
18
+ export class App {
19
+ private container: HTMLElement;
20
+ private canvas: HTMLCanvasElement;
21
+ private gl: WebGL2RenderingContext;
22
+ private engine: ShadertoyEngine;
23
+ private project: ShadertoyProject;
24
+
25
+ private pixelRatio: number;
26
+ private animationId: number | null = null;
27
+ private startTime: number = 0;
28
+
29
+ // Mouse state for iMouse uniform
30
+ private mouse: MouseState = [0, 0, -1, -1];
31
+
32
+ // FPS tracking
33
+ private fpsDisplay: HTMLElement;
34
+ private frameCount: number = 0;
35
+ private lastFpsUpdate: number = 0;
36
+ private currentFps: number = 0;
37
+
38
+ // Playback controls
39
+ private controlsContainer: HTMLElement | null = null;
40
+ private playPauseButton: HTMLElement | null = null;
41
+ private isPaused: boolean = false;
42
+
43
+ // Error overlay
44
+ private errorOverlay: HTMLElement | null = null;
45
+
46
+ // Resize observer
47
+ private resizeObserver: ResizeObserver;
48
+
49
+ // Visibility observer (auto-pause when off-screen)
50
+ private intersectionObserver: IntersectionObserver;
51
+ private isVisible: boolean = true;
52
+
53
+ constructor(opts: AppOptions) {
54
+ this.container = opts.container;
55
+ this.project = opts.project;
56
+ this.pixelRatio = opts.pixelRatio ?? window.devicePixelRatio;
57
+
58
+ // Create canvas
59
+ this.canvas = document.createElement('canvas');
60
+ this.canvas.style.width = '100%';
61
+ this.canvas.style.height = '100%';
62
+ this.canvas.style.display = 'block';
63
+ this.container.appendChild(this.canvas);
64
+
65
+ // Create FPS display overlay
66
+ this.fpsDisplay = document.createElement('div');
67
+ this.fpsDisplay.className = 'fps-counter';
68
+ this.fpsDisplay.textContent = '0 FPS';
69
+ this.container.appendChild(this.fpsDisplay);
70
+
71
+ // Create playback controls if enabled
72
+ if (opts.project.controls) {
73
+ this.createControls();
74
+ }
75
+
76
+ // Get WebGL2 context
77
+ const gl = this.canvas.getContext('webgl2', {
78
+ alpha: false,
79
+ antialias: false,
80
+ depth: false,
81
+ stencil: false,
82
+ preserveDrawingBuffer: true, // Required for screenshots
83
+ powerPreference: 'high-performance',
84
+ });
85
+
86
+ if (!gl) {
87
+ throw new Error('WebGL2 not supported');
88
+ }
89
+
90
+ this.gl = gl;
91
+
92
+ // Initialize canvas size
93
+ this.updateCanvasSize();
94
+
95
+ // Create engine
96
+ this.engine = new ShadertoyEngine({
97
+ gl: this.gl,
98
+ project: opts.project,
99
+ });
100
+
101
+ // Check for compilation errors and show overlay if needed
102
+ if (this.engine.hasErrors()) {
103
+ this.showErrorOverlay(this.engine.getCompilationErrors());
104
+ }
105
+
106
+ // Set up resize observer
107
+ this.resizeObserver = new ResizeObserver(() => {
108
+ this.updateCanvasSize();
109
+ this.engine.resize(this.canvas.width, this.canvas.height);
110
+ // Reset frame counter so shaders can reinitialize (important for accumulators)
111
+ this.startTime = performance.now() / 1000;
112
+ this.engine.reset();
113
+ });
114
+ this.resizeObserver.observe(this.container);
115
+
116
+ // Set up intersection observer for auto-pause when off-screen
117
+ this.intersectionObserver = new IntersectionObserver(
118
+ (entries) => {
119
+ const entry = entries[0];
120
+ this.isVisible = entry.isIntersecting;
121
+ },
122
+ { threshold: 0.1 } // Trigger when 10% visible
123
+ );
124
+ this.intersectionObserver.observe(this.container);
125
+
126
+ // Set up mouse tracking
127
+ this.setupMouseTracking();
128
+
129
+ // Set up keyboard tracking for shader keyboard texture
130
+ this.setupKeyboardTracking();
131
+
132
+ // Set up global keyboard shortcuts (always available)
133
+ this.setupGlobalShortcuts();
134
+
135
+ // Set up keyboard shortcuts if controls are enabled
136
+ if (opts.project.controls) {
137
+ this.setupKeyboardShortcuts();
138
+ }
139
+ }
140
+
141
+ // ===========================================================================
142
+ // Public API
143
+ // ===========================================================================
144
+
145
+ /**
146
+ * Check if there were any shader compilation errors.
147
+ * Returns true if the engine has errors and should not be started.
148
+ */
149
+ hasErrors(): boolean {
150
+ return this.engine.hasErrors();
151
+ }
152
+
153
+ /**
154
+ * Get the underlying engine instance.
155
+ * Used for live recompilation in editor mode.
156
+ */
157
+ getEngine(): ShadertoyEngine {
158
+ return this.engine;
159
+ }
160
+
161
+ /**
162
+ * Start the animation loop.
163
+ */
164
+ start(): void {
165
+ if (this.animationId !== null) {
166
+ return; // Already running
167
+ }
168
+
169
+ this.startTime = performance.now() / 1000;
170
+ this.animate(this.startTime);
171
+ }
172
+
173
+ /**
174
+ * Stop the animation loop.
175
+ */
176
+ stop(): void {
177
+ if (this.animationId !== null) {
178
+ cancelAnimationFrame(this.animationId);
179
+ this.animationId = null;
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Clean up all resources.
185
+ */
186
+ dispose(): void {
187
+ this.stop();
188
+ this.resizeObserver.disconnect();
189
+ this.intersectionObserver.disconnect();
190
+ this.engine.dispose();
191
+ this.container.removeChild(this.canvas);
192
+ this.container.removeChild(this.fpsDisplay);
193
+ }
194
+
195
+ // ===========================================================================
196
+ // Animation Loop
197
+ // ===========================================================================
198
+
199
+ private animate = (currentTimeMs: number): void => {
200
+ // Schedule next frame first (even if paused or invisible)
201
+ this.animationId = requestAnimationFrame(this.animate);
202
+
203
+ // Skip rendering if paused or off-screen
204
+ if (this.isPaused || !this.isVisible) {
205
+ return;
206
+ }
207
+
208
+ const currentTimeSec = currentTimeMs / 1000;
209
+ const elapsedTime = currentTimeSec - this.startTime;
210
+
211
+ // Update FPS counter
212
+ this.updateFps(currentTimeSec);
213
+
214
+ // Update keyboard texture with current key states
215
+ this.engine.updateKeyboardTexture();
216
+
217
+ // Run engine step
218
+ this.engine.step(elapsedTime, this.mouse);
219
+
220
+ // Present Image pass output to screen
221
+ this.presentToScreen();
222
+ };
223
+
224
+ /**
225
+ * Update FPS counter.
226
+ * Updates the display roughly once per second.
227
+ */
228
+ private updateFps(currentTimeSec: number): void {
229
+ this.frameCount++;
230
+
231
+ // Update FPS display once per second
232
+ if (currentTimeSec - this.lastFpsUpdate >= 1.0) {
233
+ this.currentFps = this.frameCount / (currentTimeSec - this.lastFpsUpdate);
234
+ this.fpsDisplay.textContent = `${Math.round(this.currentFps)} FPS`;
235
+ this.frameCount = 0;
236
+ this.lastFpsUpdate = currentTimeSec;
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Present the Image pass output to the screen.
242
+ *
243
+ * Since Image is the final pass and we execute all passes to their FBOs,
244
+ * we need to blit the Image pass output to the default framebuffer.
245
+ */
246
+ private presentToScreen(): void {
247
+ const gl = this.gl;
248
+
249
+ const imageFramebuffer = this.engine.getImageFramebuffer();
250
+ if (!imageFramebuffer) {
251
+ console.warn('No Image pass found');
252
+ return;
253
+ }
254
+
255
+ // Bind default framebuffer (screen)
256
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
257
+
258
+ // Clear screen
259
+ gl.clearColor(0, 0, 0, 1);
260
+ gl.clear(gl.COLOR_BUFFER_BIT);
261
+
262
+ // Blit Image pass FBO to screen
263
+ gl.bindFramebuffer(gl.READ_FRAMEBUFFER, imageFramebuffer);
264
+ gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, null);
265
+
266
+ gl.blitFramebuffer(
267
+ 0, 0, this.canvas.width, this.canvas.height, // src
268
+ 0, 0, this.canvas.width, this.canvas.height, // dst
269
+ gl.COLOR_BUFFER_BIT,
270
+ gl.NEAREST
271
+ );
272
+
273
+ gl.bindFramebuffer(gl.READ_FRAMEBUFFER, null);
274
+ }
275
+
276
+ // ===========================================================================
277
+ // Resize Handling
278
+ // ===========================================================================
279
+
280
+ private updateCanvasSize(): void {
281
+ const rect = this.container.getBoundingClientRect();
282
+ const width = Math.floor(rect.width * this.pixelRatio);
283
+ const height = Math.floor(rect.height * this.pixelRatio);
284
+
285
+ if (this.canvas.width !== width || this.canvas.height !== height) {
286
+ this.canvas.width = width;
287
+ this.canvas.height = height;
288
+ }
289
+ }
290
+
291
+ // ===========================================================================
292
+ // Mouse Tracking
293
+ // ===========================================================================
294
+
295
+ private setupMouseTracking(): void {
296
+ const updateMouse = (e: MouseEvent) => {
297
+ const rect = this.canvas.getBoundingClientRect();
298
+ const x = (e.clientX - rect.left) * this.pixelRatio;
299
+ const y = (rect.height - (e.clientY - rect.top)) * this.pixelRatio; // Flip Y
300
+
301
+ this.mouse[0] = x;
302
+ this.mouse[1] = y;
303
+ };
304
+
305
+ const handleClick = (e: MouseEvent) => {
306
+ const rect = this.canvas.getBoundingClientRect();
307
+ const x = (e.clientX - rect.left) * this.pixelRatio;
308
+ const y = (rect.height - (e.clientY - rect.top)) * this.pixelRatio; // Flip Y
309
+
310
+ this.mouse[2] = x;
311
+ this.mouse[3] = y;
312
+ };
313
+
314
+ this.canvas.addEventListener('mousemove', updateMouse);
315
+ this.canvas.addEventListener('click', handleClick);
316
+ }
317
+
318
+ // ===========================================================================
319
+ // Playback Controls
320
+ // ===========================================================================
321
+
322
+ /**
323
+ * Create playback control buttons (play/pause and reset).
324
+ */
325
+ private createControls(): void {
326
+ // Create container
327
+ this.controlsContainer = document.createElement('div');
328
+ this.controlsContainer.className = 'playback-controls';
329
+
330
+ // Play/Pause button (starts showing pause icon since we're playing)
331
+ this.playPauseButton = document.createElement('button');
332
+ this.playPauseButton.className = 'control-button';
333
+ this.playPauseButton.title = 'Play/Pause (Space)';
334
+ this.playPauseButton.innerHTML = `
335
+ <svg viewBox="0 0 16 16">
336
+ <path d="M5 3h2v10H5V3zm4 0h2v10H9V3z"/>
337
+ </svg>
338
+ `;
339
+ this.playPauseButton.addEventListener('click', () => this.togglePlayPause());
340
+
341
+ // Reset button
342
+ const resetButton = document.createElement('button');
343
+ resetButton.className = 'control-button';
344
+ resetButton.title = 'Reset (R)';
345
+ resetButton.innerHTML = `
346
+ <svg viewBox="0 0 16 16">
347
+ <path d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z"/>
348
+ <path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466z"/>
349
+ </svg>
350
+ `;
351
+ resetButton.addEventListener('click', () => this.reset());
352
+
353
+ // Screenshot button
354
+ const screenshotButton = document.createElement('button');
355
+ screenshotButton.className = 'control-button';
356
+ screenshotButton.title = 'Screenshot (S)';
357
+ screenshotButton.innerHTML = `
358
+ <svg viewBox="0 0 16 16">
359
+ <path d="M10.5 8.5a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0z"/>
360
+ <path d="M2 4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2h-1.172a2 2 0 0 1-1.414-.586l-.828-.828A2 2 0 0 0 9.172 2H6.828a2 2 0 0 0-1.414.586l-.828.828A2 2 0 0 1 3.172 4H2zm.5 2a.5.5 0 1 1 0-1 .5.5 0 0 1 0 1zm9 2.5a3.5 3.5 0 1 1-7 0 3.5 3.5 0 0 1 7 0z"/>
361
+ </svg>
362
+ `;
363
+ screenshotButton.addEventListener('click', () => this.screenshot());
364
+
365
+ // Add to container
366
+ this.controlsContainer.appendChild(this.playPauseButton);
367
+ this.controlsContainer.appendChild(resetButton);
368
+ this.controlsContainer.appendChild(screenshotButton);
369
+ this.container.appendChild(this.controlsContainer);
370
+ }
371
+
372
+ /**
373
+ * Set up keyboard tracking for shader keyboard texture.
374
+ * Tracks all key presses/releases and forwards to engine.
375
+ */
376
+ private setupKeyboardTracking(): void {
377
+ // Track keydown events
378
+ document.addEventListener('keydown', (e: KeyboardEvent) => {
379
+ // Get keycode - use e.keyCode which is the ASCII code
380
+ const keycode = e.keyCode;
381
+ if (keycode >= 0 && keycode < 256) {
382
+ this.engine.updateKeyState(keycode, true);
383
+ }
384
+ });
385
+
386
+ // Track keyup events
387
+ document.addEventListener('keyup', (e: KeyboardEvent) => {
388
+ const keycode = e.keyCode;
389
+ if (keycode >= 0 && keycode < 256) {
390
+ this.engine.updateKeyState(keycode, false);
391
+ }
392
+ });
393
+ }
394
+
395
+ /**
396
+ * Set up global keyboard shortcuts (always available).
397
+ */
398
+ private setupGlobalShortcuts(): void {
399
+ document.addEventListener('keydown', (e: KeyboardEvent) => {
400
+ // S - Screenshot
401
+ if (e.code === 'KeyS' && !e.repeat) {
402
+ e.preventDefault();
403
+ this.screenshot();
404
+ }
405
+ });
406
+ }
407
+
408
+ /**
409
+ * Set up keyboard shortcuts for playback control.
410
+ */
411
+ private setupKeyboardShortcuts(): void {
412
+ document.addEventListener('keydown', (e: KeyboardEvent) => {
413
+ // Space - Play/Pause
414
+ if (e.code === 'Space' && !e.repeat) {
415
+ e.preventDefault();
416
+ this.togglePlayPause();
417
+ }
418
+
419
+ // R - Reset
420
+ if (e.code === 'KeyR' && !e.repeat) {
421
+ e.preventDefault();
422
+ this.reset();
423
+ }
424
+ });
425
+ }
426
+
427
+ /**
428
+ * Toggle between play and pause states.
429
+ */
430
+ private togglePlayPause(): void {
431
+ this.isPaused = !this.isPaused;
432
+ this.updatePlayPauseButton();
433
+ }
434
+
435
+ /**
436
+ * Reset the shader to frame 0.
437
+ */
438
+ private reset(): void {
439
+ this.startTime = performance.now() / 1000;
440
+ this.frameCount = 0;
441
+ this.lastFpsUpdate = 0;
442
+ this.engine.reset();
443
+ }
444
+
445
+ /**
446
+ * Capture and download a screenshot of the current canvas as PNG.
447
+ * Filename format: shadertoy-{folderName}-{timestamp}.png
448
+ */
449
+ private screenshot(): void {
450
+ // Extract folder name from project root (e.g., "/demos/keyboard-test" -> "keyboard-test")
451
+ const folderName = this.project.root.split('/').pop() || 'shader';
452
+
453
+ // Generate timestamp (YYYYMMDD-HHMMSS)
454
+ const now = new Date();
455
+ const timestamp = now.getFullYear().toString() +
456
+ (now.getMonth() + 1).toString().padStart(2, '0') +
457
+ now.getDate().toString().padStart(2, '0') + '-' +
458
+ now.getHours().toString().padStart(2, '0') +
459
+ now.getMinutes().toString().padStart(2, '0') +
460
+ now.getSeconds().toString().padStart(2, '0');
461
+
462
+ const filename = `shadertoy-${folderName}-${timestamp}.png`;
463
+
464
+ // Capture canvas as PNG blob
465
+ this.canvas.toBlob((blob) => {
466
+ if (!blob) {
467
+ console.error('Failed to create screenshot blob');
468
+ return;
469
+ }
470
+
471
+ // Create download link
472
+ const url = URL.createObjectURL(blob);
473
+ const link = document.createElement('a');
474
+ link.href = url;
475
+ link.download = filename;
476
+ link.click();
477
+
478
+ // Clean up
479
+ URL.revokeObjectURL(url);
480
+
481
+ console.log(`Screenshot saved: ${filename}`);
482
+ }, 'image/png');
483
+ }
484
+
485
+ /**
486
+ * Update play/pause button icon based on current state.
487
+ */
488
+ private updatePlayPauseButton(): void {
489
+ if (!this.playPauseButton) return;
490
+
491
+ if (this.isPaused) {
492
+ // Show play icon
493
+ this.playPauseButton.innerHTML = `
494
+ <svg viewBox="0 0 16 16">
495
+ <path d="M4 3v10l8-5-8-5z"/>
496
+ </svg>
497
+ `;
498
+ } else {
499
+ // Show pause icon
500
+ this.playPauseButton.innerHTML = `
501
+ <svg viewBox="0 0 16 16">
502
+ <path d="M5 3h2v10H5V3zm4 0h2v10H9V3z"/>
503
+ </svg>
504
+ `;
505
+ }
506
+ }
507
+
508
+ // ===========================================================================
509
+ // Error Handling
510
+ // ===========================================================================
511
+
512
+ /**
513
+ * Display shader compilation errors in an overlay.
514
+ */
515
+ private showErrorOverlay(errors: Array<{
516
+ passName: string;
517
+ error: string;
518
+ source: string;
519
+ isFromCommon: boolean;
520
+ originalLine: number | null;
521
+ }>): void {
522
+ // Create overlay if it doesn't exist
523
+ if (!this.errorOverlay) {
524
+ this.errorOverlay = document.createElement('div');
525
+ this.errorOverlay.className = 'shader-error-overlay';
526
+ this.container.appendChild(this.errorOverlay);
527
+ }
528
+
529
+ // Group errors: separate common.glsl errors from pass-specific errors
530
+ const commonErrors = errors.filter(e => e.isFromCommon);
531
+ const passErrors = errors.filter(e => !e.isFromCommon);
532
+
533
+ // Deduplicate common errors (same error reported for multiple passes)
534
+ const uniqueCommonErrors = commonErrors.length > 0 ? [commonErrors[0]] : [];
535
+
536
+ // Combine: show common errors first, then pass-specific errors
537
+ const allErrors = [...uniqueCommonErrors, ...passErrors];
538
+
539
+ // Parse and format errors with source context
540
+ const formattedErrors = allErrors.map(({passName, error, source, isFromCommon, originalLine}) => {
541
+ // Extract the actual GLSL error from the thrown error message
542
+ const glslError = error.replace('Shader compilation failed:\n', '');
543
+
544
+ // For common errors, adjust line number in error message
545
+ let adjustedError = glslError;
546
+ if (isFromCommon && originalLine !== null) {
547
+ adjustedError = glslError.replace(/Line \d+:/, `Line ${originalLine}:`);
548
+ adjustedError = adjustedError.replace(/ERROR:\s*\d+:(\d+):/, `ERROR: 0:${originalLine}:`);
549
+ }
550
+
551
+ return {
552
+ passName: isFromCommon ? 'common.glsl' : passName,
553
+ error: this.parseShaderError(adjustedError),
554
+ codeContext: isFromCommon
555
+ ? this.extractCodeContextFromCommon(originalLine!)
556
+ : this.extractCodeContext(adjustedError, source),
557
+ };
558
+ });
559
+
560
+ // Build error HTML
561
+ const errorHTML = formattedErrors.map(({passName, error, codeContext}) => `
562
+ <div class="error-section">
563
+ <div class="error-pass-name">${passName}</div>
564
+ <pre class="error-content">${this.escapeHTML(error)}</pre>
565
+ ${codeContext ? `<pre class="error-code-context">${codeContext}</pre>` : ''}
566
+ </div>
567
+ `).join('');
568
+
569
+ this.errorOverlay.innerHTML = `
570
+ <div class="error-overlay-content">
571
+ <div class="error-header">
572
+ <span class="error-title">
573
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" style="vertical-align: text-bottom;">
574
+ <path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0zM3.5 7.5a.75.75 0 0 0 0 1.5h9a.75.75 0 0 0 0-1.5h-9z"/>
575
+ </svg>
576
+ Shader Compilation Failed
577
+ </span>
578
+ <button class="error-close" title="Dismiss">×</button>
579
+ </div>
580
+ <div class="error-body">
581
+ ${errorHTML}
582
+ </div>
583
+ </div>
584
+ `;
585
+
586
+ // Add close button handler
587
+ const closeButton = this.errorOverlay.querySelector('.error-close');
588
+ if (closeButton) {
589
+ closeButton.addEventListener('click', () => this.hideErrorOverlay());
590
+ }
591
+ }
592
+
593
+ /**
594
+ * Parse and improve WebGL shader error messages.
595
+ */
596
+ private parseShaderError(error: string): string {
597
+ // WebGL errors typically look like: "ERROR: 0:45: 'texure' : no matching overloaded function found"
598
+ // Let's make them more readable by highlighting line numbers and adding context
599
+
600
+ return error.split('\n').map(line => {
601
+ // Match pattern: ERROR: 0:lineNumber: message
602
+ const match = line.match(/^ERROR:\s*(\d+):(\d+):\s*(.+)$/);
603
+ if (match) {
604
+ const [, , lineNum, message] = match;
605
+ return `Line ${lineNum}: ${message}`;
606
+ }
607
+ return line;
608
+ }).join('\n');
609
+ }
610
+
611
+ /**
612
+ * Extract code context around error line (±3 lines).
613
+ * Returns HTML with the error line highlighted.
614
+ */
615
+ private extractCodeContext(error: string, source: string): string | null {
616
+ // Extract line number from error
617
+ const match = error.match(/ERROR:\s*\d+:(\d+):/);
618
+ if (!match) return null;
619
+
620
+ const errorLine = parseInt(match[1], 10);
621
+ const lines = source.split('\n');
622
+
623
+ // Extract context (3 lines before and after)
624
+ const contextRange = 3;
625
+ const startLine = Math.max(0, errorLine - contextRange - 1);
626
+ const endLine = Math.min(lines.length, errorLine + contextRange);
627
+
628
+ const contextLines = lines.slice(startLine, endLine);
629
+
630
+ // Build HTML with line numbers and highlighting
631
+ const html = contextLines.map((line, idx) => {
632
+ const lineNum = startLine + idx + 1;
633
+ const isErrorLine = lineNum === errorLine;
634
+ const lineNumPadded = String(lineNum).padStart(4, ' ');
635
+ const escapedLine = this.escapeHTML(line);
636
+
637
+ if (isErrorLine) {
638
+ return `<span class="error-line-highlight">${lineNumPadded} │ ${escapedLine}</span>`;
639
+ } else {
640
+ return `<span class="context-line">${lineNumPadded} │ ${escapedLine}</span>`;
641
+ }
642
+ }).join(''); // No newline - spans already have display:block
643
+
644
+ return html;
645
+ }
646
+
647
+ /**
648
+ * Extract code context from common.glsl file.
649
+ * Similar to extractCodeContext but uses the original common source.
650
+ */
651
+ private extractCodeContextFromCommon(errorLine: number): string | null {
652
+ const commonSource = this.engine.project.commonSource;
653
+ if (!commonSource) return null;
654
+
655
+ const lines = commonSource.split('\n');
656
+
657
+ // Extract context (3 lines before and after)
658
+ const contextRange = 3;
659
+ const startLine = Math.max(0, errorLine - contextRange - 1);
660
+ const endLine = Math.min(lines.length, errorLine + contextRange);
661
+
662
+ const contextLines = lines.slice(startLine, endLine);
663
+
664
+ // Build HTML with line numbers and highlighting
665
+ const html = contextLines.map((line, idx) => {
666
+ const lineNum = startLine + idx + 1;
667
+ const isErrorLine = lineNum === errorLine;
668
+ const lineNumPadded = String(lineNum).padStart(4, ' ');
669
+ const escapedLine = this.escapeHTML(line);
670
+
671
+ if (isErrorLine) {
672
+ return `<span class="error-line-highlight">${lineNumPadded} │ ${escapedLine}</span>`;
673
+ } else {
674
+ return `<span class="context-line">${lineNumPadded} │ ${escapedLine}</span>`;
675
+ }
676
+ }).join(''); // No newline - spans already have display:block
677
+
678
+ return html;
679
+ }
680
+
681
+ /**
682
+ * Escape HTML to prevent XSS.
683
+ */
684
+ private escapeHTML(text: string): string {
685
+ const div = document.createElement('div');
686
+ div.textContent = text;
687
+ return div.innerHTML;
688
+ }
689
+
690
+ /**
691
+ * Hide the error overlay.
692
+ */
693
+ private hideErrorOverlay(): void {
694
+ if (this.errorOverlay) {
695
+ this.errorOverlay.remove();
696
+ this.errorOverlay = null;
697
+ }
698
+ }
699
+ }