@stevejtrettel/shader-sandbox 0.1.3 → 0.1.4

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 (113) hide show
  1. package/README.md +220 -23
  2. package/bin/cli.js +106 -14
  3. package/dist-lib/app/App.d.ts +143 -15
  4. package/dist-lib/app/App.d.ts.map +1 -1
  5. package/dist-lib/app/App.js +1343 -108
  6. package/dist-lib/app/app.css +349 -24
  7. package/dist-lib/app/types.d.ts +48 -5
  8. package/dist-lib/app/types.d.ts.map +1 -1
  9. package/dist-lib/editor/EditorPanel.d.ts +2 -2
  10. package/dist-lib/editor/EditorPanel.d.ts.map +1 -1
  11. package/dist-lib/editor/EditorPanel.js +1 -1
  12. package/dist-lib/editor/editor-panel.css +55 -32
  13. package/dist-lib/editor/prism-editor.css +16 -16
  14. package/dist-lib/embed.js +1 -1
  15. package/dist-lib/engine/{ShadertoyEngine.d.ts → ShaderEngine.d.ts} +134 -10
  16. package/dist-lib/engine/ShaderEngine.d.ts.map +1 -0
  17. package/dist-lib/engine/ShaderEngine.js +1523 -0
  18. package/dist-lib/engine/glHelpers.d.ts +24 -0
  19. package/dist-lib/engine/glHelpers.d.ts.map +1 -1
  20. package/dist-lib/engine/glHelpers.js +88 -0
  21. package/dist-lib/engine/std140.d.ts +47 -0
  22. package/dist-lib/engine/std140.d.ts.map +1 -0
  23. package/dist-lib/engine/std140.js +119 -0
  24. package/dist-lib/engine/types.d.ts +55 -5
  25. package/dist-lib/engine/types.d.ts.map +1 -1
  26. package/dist-lib/engine/types.js +1 -1
  27. package/dist-lib/index.d.ts +4 -3
  28. package/dist-lib/index.d.ts.map +1 -1
  29. package/dist-lib/index.js +2 -1
  30. package/dist-lib/layouts/SplitLayout.d.ts +2 -1
  31. package/dist-lib/layouts/SplitLayout.d.ts.map +1 -1
  32. package/dist-lib/layouts/SplitLayout.js +3 -0
  33. package/dist-lib/layouts/TabbedLayout.d.ts.map +1 -1
  34. package/dist-lib/layouts/UILayout.d.ts +55 -0
  35. package/dist-lib/layouts/UILayout.d.ts.map +1 -0
  36. package/dist-lib/layouts/UILayout.js +147 -0
  37. package/dist-lib/layouts/default.css +2 -2
  38. package/dist-lib/layouts/index.d.ts +11 -1
  39. package/dist-lib/layouts/index.d.ts.map +1 -1
  40. package/dist-lib/layouts/index.js +17 -1
  41. package/dist-lib/layouts/split.css +33 -31
  42. package/dist-lib/layouts/tabbed.css +127 -74
  43. package/dist-lib/layouts/types.d.ts +14 -3
  44. package/dist-lib/layouts/types.d.ts.map +1 -1
  45. package/dist-lib/main.js +33 -0
  46. package/dist-lib/project/configHelpers.d.ts +45 -0
  47. package/dist-lib/project/configHelpers.d.ts.map +1 -0
  48. package/dist-lib/project/configHelpers.js +196 -0
  49. package/dist-lib/project/generatedLoader.d.ts +2 -2
  50. package/dist-lib/project/generatedLoader.d.ts.map +1 -1
  51. package/dist-lib/project/generatedLoader.js +23 -5
  52. package/dist-lib/project/loadProject.d.ts +6 -6
  53. package/dist-lib/project/loadProject.d.ts.map +1 -1
  54. package/dist-lib/project/loadProject.js +396 -144
  55. package/dist-lib/project/loaderHelper.d.ts +4 -4
  56. package/dist-lib/project/loaderHelper.d.ts.map +1 -1
  57. package/dist-lib/project/loaderHelper.js +278 -116
  58. package/dist-lib/project/types.d.ts +292 -13
  59. package/dist-lib/project/types.d.ts.map +1 -1
  60. package/dist-lib/project/types.js +13 -1
  61. package/dist-lib/styles/base.css +5 -1
  62. package/dist-lib/uniforms/UniformControls.d.ts +60 -0
  63. package/dist-lib/uniforms/UniformControls.d.ts.map +1 -0
  64. package/dist-lib/uniforms/UniformControls.js +518 -0
  65. package/dist-lib/uniforms/UniformStore.d.ts +74 -0
  66. package/dist-lib/uniforms/UniformStore.d.ts.map +1 -0
  67. package/dist-lib/uniforms/UniformStore.js +145 -0
  68. package/dist-lib/uniforms/UniformsPanel.d.ts +53 -0
  69. package/dist-lib/uniforms/UniformsPanel.d.ts.map +1 -0
  70. package/dist-lib/uniforms/UniformsPanel.js +124 -0
  71. package/dist-lib/uniforms/index.d.ts +11 -0
  72. package/dist-lib/uniforms/index.d.ts.map +1 -0
  73. package/dist-lib/uniforms/index.js +8 -0
  74. package/package.json +1 -1
  75. package/src/app/App.ts +1469 -126
  76. package/src/app/app.css +349 -24
  77. package/src/app/types.ts +53 -5
  78. package/src/editor/EditorPanel.ts +5 -5
  79. package/src/editor/editor-panel.css +55 -32
  80. package/src/editor/prism-editor.css +16 -16
  81. package/src/embed.ts +1 -1
  82. package/src/engine/ShaderEngine.ts +1934 -0
  83. package/src/engine/glHelpers.ts +117 -0
  84. package/src/engine/std140.ts +136 -0
  85. package/src/engine/types.ts +69 -5
  86. package/src/index.ts +4 -3
  87. package/src/layouts/SplitLayout.ts +8 -3
  88. package/src/layouts/TabbedLayout.ts +3 -3
  89. package/src/layouts/UILayout.ts +185 -0
  90. package/src/layouts/default.css +2 -2
  91. package/src/layouts/index.ts +20 -1
  92. package/src/layouts/split.css +33 -31
  93. package/src/layouts/tabbed.css +127 -74
  94. package/src/layouts/types.ts +19 -3
  95. package/src/layouts/ui.css +289 -0
  96. package/src/main.ts +39 -1
  97. package/src/project/configHelpers.ts +225 -0
  98. package/src/project/generatedLoader.ts +27 -6
  99. package/src/project/loadProject.ts +459 -173
  100. package/src/project/loaderHelper.ts +377 -130
  101. package/src/project/types.ts +360 -14
  102. package/src/styles/base.css +5 -1
  103. package/src/styles/theme.css +292 -0
  104. package/src/uniforms/UniformControls.ts +660 -0
  105. package/src/uniforms/UniformStore.ts +166 -0
  106. package/src/uniforms/UniformsPanel.ts +163 -0
  107. package/src/uniforms/index.ts +13 -0
  108. package/src/uniforms/uniform-controls.css +342 -0
  109. package/src/uniforms/uniforms-panel.css +277 -0
  110. package/templates/shaders/example-buffer/config.json +1 -0
  111. package/dist-lib/engine/ShadertoyEngine.d.ts.map +0 -1
  112. package/dist-lib/engine/ShadertoyEngine.js +0 -704
  113. package/src/engine/ShadertoyEngine.ts +0 -929
package/src/app/App.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Responsibilities:
5
5
  * - Create and manage canvas
6
- * - Initialize ShadertoyEngine
6
+ * - Initialize ShaderEngine
7
7
  * - Run animation loop (requestAnimationFrame)
8
8
  * - Handle resize and mouse events
9
9
  * - Present Image pass output to screen
@@ -11,23 +11,78 @@
11
11
 
12
12
  import './app.css';
13
13
 
14
- import { ShadertoyEngine } from '../engine/ShadertoyEngine';
15
- import { ShadertoyProject } from '../project/types';
16
- import { AppOptions, MouseState } from './types';
14
+ import { ShaderEngine, LineMapping } from '../engine/ShaderEngine';
15
+ import { ShaderProject, ScriptEngineAPI } from '../project/types';
16
+ import { UniformsPanel } from '../uniforms/UniformsPanel';
17
+ import { AppOptions, MouseState, TouchState, PointerData } from './types';
18
+
19
+ // Map from KeyboardEvent.code to Shadertoy-compatible ASCII keycodes (0-255).
20
+ // This replaces the deprecated e.keyCode property.
21
+ const CODE_TO_ASCII: Record<string, number> = {};
22
+ // Letters: KeyA-KeyZ -> 65-90
23
+ for (let i = 0; i < 26; i++) {
24
+ CODE_TO_ASCII[`Key${String.fromCharCode(65 + i)}`] = 65 + i;
25
+ }
26
+ // Digits: Digit0-Digit9 -> 48-57
27
+ for (let i = 0; i < 10; i++) {
28
+ CODE_TO_ASCII[`Digit${i}`] = 48 + i;
29
+ }
30
+ // Function keys: F1-F12 -> 112-123
31
+ for (let i = 1; i <= 12; i++) {
32
+ CODE_TO_ASCII[`F${i}`] = 111 + i;
33
+ }
34
+ // Common keys
35
+ Object.assign(CODE_TO_ASCII, {
36
+ Backspace: 8, Tab: 9, Enter: 13, ShiftLeft: 16, ShiftRight: 16,
37
+ ControlLeft: 17, ControlRight: 17, AltLeft: 18, AltRight: 18,
38
+ Pause: 19, CapsLock: 20, Escape: 27, Space: 32,
39
+ PageUp: 33, PageDown: 34, End: 35, Home: 36,
40
+ ArrowLeft: 37, ArrowUp: 38, ArrowRight: 39, ArrowDown: 40,
41
+ Insert: 45, Delete: 46,
42
+ NumLock: 144, ScrollLock: 145,
43
+ Semicolon: 186, Equal: 187, Comma: 188, Minus: 189,
44
+ Period: 190, Slash: 191, Backquote: 192,
45
+ BracketLeft: 219, Backslash: 220, BracketRight: 221, Quote: 222,
46
+ });
47
+
48
+ /**
49
+ * Convert a KeyboardEvent to a Shadertoy-compatible ASCII keycode (0-255).
50
+ * Returns null if the key doesn't map to a valid code.
51
+ */
52
+ function keyEventToAscii(e: KeyboardEvent): number | null {
53
+ const code = CODE_TO_ASCII[e.code];
54
+ if (code !== undefined && code >= 0 && code < 256) {
55
+ return code;
56
+ }
57
+ return null;
58
+ }
17
59
 
18
60
  export class App {
19
61
  private container: HTMLElement;
20
62
  private canvas: HTMLCanvasElement;
21
63
  private gl: WebGL2RenderingContext;
22
- private engine: ShadertoyEngine;
23
- private project: ShadertoyProject;
64
+ private engine: ShaderEngine;
65
+ private project: ShaderProject;
24
66
 
25
67
  private pixelRatio: number;
26
68
  private animationId: number | null = null;
27
69
  private startTime: number = 0;
28
70
 
29
- // Mouse state for iMouse uniform
30
- private mouse: MouseState = [0, 0, -1, -1];
71
+ // Mouse state for iMouse uniform (Shadertoy spec)
72
+ private mouse: MouseState = [0, 0, 0, 0];
73
+ private isMouseDown = false;
74
+
75
+ // Touch state for touch uniforms
76
+ private touchState: TouchState = {
77
+ count: 0,
78
+ touches: [[0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]],
79
+ pinch: 1.0,
80
+ pinchDelta: 0.0,
81
+ pinchCenter: [0, 0],
82
+ };
83
+
84
+ // Pointer tracking for gesture recognition
85
+ private activePointers: Map<number, PointerData> = new Map();
31
86
 
32
87
  // FPS tracking
33
88
  private fpsDisplay: HTMLElement;
@@ -35,13 +90,26 @@ export class App {
35
90
  private lastFpsUpdate: number = 0;
36
91
  private currentFps: number = 0;
37
92
 
93
+ // Stats panel (expandable from FPS counter)
94
+ private statsContainer: HTMLElement | null = null;
95
+ private statsGrid: HTMLElement | null = null;
96
+ private timeDisplay: HTMLElement | null = null;
97
+ private frameDisplay: HTMLElement | null = null;
98
+ private resolutionDisplay: HTMLElement | null = null;
99
+ private totalFrameCount: number = 0;
100
+ private isStatsOpen: boolean = false;
101
+
38
102
  // Playback controls
39
103
  private controlsContainer: HTMLElement | null = null;
104
+ private controlsGrid: HTMLElement | null = null;
105
+ private menuButton: HTMLElement | null = null;
40
106
  private playPauseButton: HTMLElement | null = null;
41
- private isPaused: boolean = false;
107
+ private isPaused: boolean = false; // Will be set from project.startPaused in constructor
108
+ private isMenuOpen: boolean = false;
42
109
 
43
110
  // Error overlay
44
111
  private errorOverlay: HTMLElement | null = null;
112
+ private mediaBanner: HTMLElement | null = null;
45
113
 
46
114
  // Resize observer
47
115
  private resizeObserver: ResizeObserver;
@@ -50,10 +118,34 @@ export class App {
50
118
  private intersectionObserver: IntersectionObserver;
51
119
  private isVisible: boolean = true;
52
120
 
121
+ // WebGL context loss handling
122
+ private contextLostOverlay: HTMLElement | null = null;
123
+ private isContextLost: boolean = false;
124
+
125
+ // Floating uniforms panel
126
+ private uniformsPanel: UniformsPanel | null = null;
127
+
128
+ // Script hooks API
129
+ private scriptAPI: ScriptEngineAPI | null = null;
130
+ private scriptErrorCount: number = 0;
131
+ private _lastOnFrameTime: number | null = null;
132
+ private static readonly MAX_SCRIPT_ERRORS = 10;
133
+
134
+ // Media initialization flag (audio/webcam need user gesture)
135
+ private mediaInitialized: boolean = false;
136
+
137
+ // Recording state
138
+ private isRecording: boolean = false;
139
+ private mediaRecorder: MediaRecorder | null = null;
140
+ private recordedChunks: Blob[] = [];
141
+ private recordButton: HTMLElement | null = null;
142
+ private recordingIndicator: HTMLElement | null = null;
143
+
53
144
  constructor(opts: AppOptions) {
54
145
  this.container = opts.container;
55
146
  this.project = opts.project;
56
- this.pixelRatio = opts.pixelRatio ?? window.devicePixelRatio;
147
+ // Priority: opts.pixelRatio > project.pixelRatio > window.devicePixelRatio
148
+ this.pixelRatio = opts.pixelRatio ?? opts.project.pixelRatio ?? window.devicePixelRatio;
57
149
 
58
150
  // Create canvas
59
151
  this.canvas = document.createElement('canvas');
@@ -62,17 +154,55 @@ export class App {
62
154
  this.canvas.style.display = 'block';
63
155
  this.container.appendChild(this.canvas);
64
156
 
65
- // Create FPS display overlay
66
- this.fpsDisplay = document.createElement('div');
157
+ // Create stats container (holds FPS button and expandable stats)
158
+ this.statsContainer = document.createElement('div');
159
+ this.statsContainer.className = 'stats-container';
160
+
161
+ // Create FPS display (clickable to expand stats)
162
+ this.fpsDisplay = document.createElement('button');
67
163
  this.fpsDisplay.className = 'fps-counter';
68
164
  this.fpsDisplay.textContent = '0 FPS';
69
- this.container.appendChild(this.fpsDisplay);
70
-
71
- // Create playback controls if enabled
72
- if (opts.project.controls) {
165
+ this.fpsDisplay.title = 'Click to show stats';
166
+ this.fpsDisplay.addEventListener('click', () => this.toggleStats());
167
+
168
+ // Create stats grid (hidden by default)
169
+ this.statsGrid = document.createElement('div');
170
+ this.statsGrid.className = 'stats-grid';
171
+
172
+ // Time display
173
+ this.timeDisplay = document.createElement('div');
174
+ this.timeDisplay.className = 'stat-item';
175
+ this.timeDisplay.innerHTML = '<span class="stat-value">0:00</span><span class="stat-label">time</span>';
176
+ this.statsGrid.appendChild(this.timeDisplay);
177
+
178
+ // Frame display
179
+ this.frameDisplay = document.createElement('div');
180
+ this.frameDisplay.className = 'stat-item';
181
+ this.frameDisplay.innerHTML = '<span class="stat-value">0</span><span class="stat-label">frame</span>';
182
+ this.statsGrid.appendChild(this.frameDisplay);
183
+
184
+ // Resolution display
185
+ this.resolutionDisplay = document.createElement('div');
186
+ this.resolutionDisplay.className = 'stat-item';
187
+ this.resolutionDisplay.innerHTML = '<span class="stat-value">0×0</span><span class="stat-label">res</span>';
188
+ this.statsGrid.appendChild(this.resolutionDisplay);
189
+
190
+ this.statsContainer.appendChild(this.statsGrid);
191
+ this.statsContainer.appendChild(this.fpsDisplay);
192
+ this.container.appendChild(this.statsContainer);
193
+
194
+ // Create playback controls if enabled (skip for 'ui' layout which has its own)
195
+ if (opts.project.controls && !opts.skipPlaybackControls) {
73
196
  this.createControls();
74
197
  }
75
198
 
199
+ // Handle startPaused option
200
+ if (opts.project.startPaused) {
201
+ this.isPaused = true;
202
+ // Update button if controls exist (will be created above)
203
+ this.updatePlayPauseButton();
204
+ }
205
+
76
206
  // Get WebGL2 context
77
207
  const gl = this.canvas.getContext('webgl2', {
78
208
  alpha: false,
@@ -89,11 +219,14 @@ export class App {
89
219
 
90
220
  this.gl = gl;
91
221
 
222
+ // Set up WebGL context loss handling
223
+ this.setupContextLossHandling();
224
+
92
225
  // Initialize canvas size
93
226
  this.updateCanvasSize();
94
227
 
95
228
  // Create engine
96
- this.engine = new ShadertoyEngine({
229
+ this.engine = new ShaderEngine({
97
230
  gl: this.gl,
98
231
  project: opts.project,
99
232
  });
@@ -103,6 +236,44 @@ export class App {
103
236
  this.showErrorOverlay(this.engine.getCompilationErrors());
104
237
  }
105
238
 
239
+ // Show media permission banner if audio/webcam needed
240
+ if (this.engine.needsAudio || this.engine.needsWebcam) {
241
+ this.showMediaBanner();
242
+ }
243
+
244
+ // Initialize script API and run setup hook
245
+ if (this.project.script) {
246
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
247
+ const self = this;
248
+ this.scriptAPI = {
249
+ setUniformValue: (name, value) => self.engine.setUniformValue(name, value),
250
+ getUniformValue: (name) => self.engine.getUniformValue(name),
251
+ updateTexture: (name, w, h, data) => self.engine.updateTexture(name, w, h, data),
252
+ readPixels: (passName, x, y, w, h) => self.engine.readPixels(passName as any, x, y, w, h),
253
+ get width() { return self.engine.width; },
254
+ get height() { return self.engine.height; },
255
+ };
256
+
257
+ if (this.project.script.setup) {
258
+ try {
259
+ this.project.script.setup(this.scriptAPI);
260
+ } catch (e) {
261
+ console.error('script.js setup() threw:', e);
262
+ }
263
+ }
264
+ }
265
+
266
+ // Create floating uniforms panel (skip for 'ui' layout which has its own)
267
+ if (!opts.skipUniformsPanel && opts.project.uniforms && Object.keys(opts.project.uniforms).length > 0) {
268
+ this.uniformsPanel = new UniformsPanel({
269
+ container: this.container,
270
+ uniforms: opts.project.uniforms,
271
+ onChange: (name, value) => {
272
+ this.engine.setUniformValue(name, value);
273
+ },
274
+ });
275
+ }
276
+
106
277
  // Set up resize observer
107
278
  this.resizeObserver = new ResizeObserver(() => {
108
279
  this.updateCanvasSize();
@@ -126,6 +297,12 @@ export class App {
126
297
  // Set up mouse tracking
127
298
  this.setupMouseTracking();
128
299
 
300
+ // Initialize video files (muted, no gesture needed)
301
+ this.initVideoFiles();
302
+
303
+ // Set up touch/pointer tracking
304
+ this.setupTouchTracking();
305
+
129
306
  // Set up keyboard tracking for shader keyboard texture
130
307
  this.setupKeyboardTracking();
131
308
 
@@ -138,6 +315,36 @@ export class App {
138
315
  }
139
316
  }
140
317
 
318
+ // ===========================================================================
319
+ // Media Initialization
320
+ // ===========================================================================
321
+
322
+ /**
323
+ * Initialize audio/webcam on first user gesture (required by browser policy).
324
+ * Video files are auto-started in the constructor since muted videos don't need gestures.
325
+ */
326
+ private initMediaOnGesture(): void {
327
+ if (this.mediaInitialized) return;
328
+ this.mediaInitialized = true;
329
+ this.hideMediaBanner();
330
+
331
+ if (this.engine.needsAudio) {
332
+ this.engine.initAudio();
333
+ }
334
+ if (this.engine.needsWebcam) {
335
+ this.engine.initWebcam();
336
+ }
337
+ }
338
+
339
+ /**
340
+ * Start video file playback (muted, doesn't require user gesture).
341
+ */
342
+ private initVideoFiles(): void {
343
+ for (const src of this.engine.videoSources) {
344
+ this.engine.initVideo(src);
345
+ }
346
+ }
347
+
141
348
  // ===========================================================================
142
349
  // Public API
143
350
  // ===========================================================================
@@ -154,7 +361,7 @@ export class App {
154
361
  * Get the underlying engine instance.
155
362
  * Used for live recompilation in editor mode.
156
363
  */
157
- getEngine(): ShadertoyEngine {
364
+ getEngine(): ShaderEngine {
158
365
  return this.engine;
159
366
  }
160
367
 
@@ -185,11 +392,22 @@ export class App {
185
392
  */
186
393
  dispose(): void {
187
394
  this.stop();
395
+ // Stop recording if active
396
+ if (this.isRecording) {
397
+ this.stopRecording();
398
+ }
188
399
  this.resizeObserver.disconnect();
189
400
  this.intersectionObserver.disconnect();
401
+ this.uniformsPanel?.destroy();
190
402
  this.engine.dispose();
191
403
  this.container.removeChild(this.canvas);
192
- this.container.removeChild(this.fpsDisplay);
404
+ if (this.statsContainer) {
405
+ this.container.removeChild(this.statsContainer);
406
+ }
407
+ this.hideContextLostOverlay();
408
+ this.hideErrorOverlay();
409
+ this.hideMediaBanner();
410
+ this.hideRecordingIndicator();
193
411
  }
194
412
 
195
413
  // ===========================================================================
@@ -200,22 +418,51 @@ export class App {
200
418
  // Schedule next frame first (even if paused or invisible)
201
419
  this.animationId = requestAnimationFrame(this.animate);
202
420
 
203
- // Skip rendering if paused or off-screen
204
- if (this.isPaused || !this.isVisible) {
421
+ // Skip rendering if paused, off-screen, or context lost
422
+ if (this.isPaused || !this.isVisible || this.isContextLost) {
205
423
  return;
206
424
  }
207
425
 
208
426
  const currentTimeSec = currentTimeMs / 1000;
209
427
  const elapsedTime = currentTimeSec - this.startTime;
210
428
 
211
- // Update FPS counter
212
- this.updateFps(currentTimeSec);
429
+ // Update FPS counter and stats
430
+ this.updateFps(currentTimeSec, elapsedTime);
213
431
 
214
432
  // Update keyboard texture with current key states
215
433
  this.engine.updateKeyboardTexture();
216
434
 
217
- // Run engine step
218
- this.engine.step(elapsedTime, this.mouse);
435
+ // Update media textures (audio FFT/waveform, video/webcam frames)
436
+ this.engine.updateAudioTexture();
437
+ this.engine.updateVideoTextures();
438
+
439
+ // Run script onFrame hook (JS computation before shader execution)
440
+ if (this.scriptAPI && this.project.script?.onFrame && this.scriptErrorCount < App.MAX_SCRIPT_ERRORS) {
441
+ const deltaTime = this._lastOnFrameTime !== null ? elapsedTime - this._lastOnFrameTime : 0;
442
+ try {
443
+ this.project.script.onFrame(this.scriptAPI, elapsedTime, deltaTime, this.totalFrameCount);
444
+ this.scriptErrorCount = 0; // Reset on success
445
+ } catch (e) {
446
+ this.scriptErrorCount++;
447
+ console.error(`script.js onFrame() threw (${this.scriptErrorCount}/${App.MAX_SCRIPT_ERRORS}):`, e);
448
+ if (this.scriptErrorCount >= App.MAX_SCRIPT_ERRORS) {
449
+ console.warn('script.js onFrame() disabled after too many errors');
450
+ }
451
+ }
452
+ this._lastOnFrameTime = elapsedTime;
453
+ }
454
+
455
+ // Run engine step with mouse and touch data
456
+ this.engine.step(elapsedTime, this.mouse, this.isMouseDown, {
457
+ count: this.touchState.count,
458
+ touches: this.touchState.touches,
459
+ pinch: this.touchState.pinch,
460
+ pinchDelta: this.touchState.pinchDelta,
461
+ pinchCenter: this.touchState.pinchCenter,
462
+ });
463
+
464
+ // Reset pinchDelta after frame (it's a per-frame delta)
465
+ this.touchState.pinchDelta = 0;
219
466
 
220
467
  // Present Image pass output to screen
221
468
  this.presentToScreen();
@@ -223,10 +470,16 @@ export class App {
223
470
 
224
471
  /**
225
472
  * Update FPS counter.
226
- * Updates the display roughly once per second.
473
+ * FPS display updates once per second, frame count updates every frame.
227
474
  */
228
- private updateFps(currentTimeSec: number): void {
475
+ private updateFps(currentTimeSec: number, elapsedTime: number): void {
229
476
  this.frameCount++;
477
+ this.totalFrameCount++;
478
+
479
+ // Update frame count display every frame if stats panel is open
480
+ if (this.isStatsOpen && this.frameDisplay) {
481
+ this.updateFrameDisplay();
482
+ }
230
483
 
231
484
  // Update FPS display once per second
232
485
  if (currentTimeSec - this.lastFpsUpdate >= 1.0) {
@@ -234,6 +487,84 @@ export class App {
234
487
  this.fpsDisplay.textContent = `${Math.round(this.currentFps)} FPS`;
235
488
  this.frameCount = 0;
236
489
  this.lastFpsUpdate = currentTimeSec;
490
+
491
+ // Update time/resolution stats once per second (they don't need per-frame updates)
492
+ if (this.isStatsOpen) {
493
+ this.updateTimeDisplay(elapsedTime);
494
+ this.updateResolutionDisplay();
495
+ }
496
+ }
497
+ }
498
+
499
+ /**
500
+ * Update frame count display.
501
+ */
502
+ private updateFrameDisplay(): void {
503
+ if (!this.frameDisplay) return;
504
+
505
+ let frameStr: string;
506
+ if (this.totalFrameCount >= 1000000) {
507
+ frameStr = (this.totalFrameCount / 1000000).toFixed(1) + 'M';
508
+ } else if (this.totalFrameCount >= 1000) {
509
+ frameStr = (this.totalFrameCount / 1000).toFixed(1) + 'K';
510
+ } else {
511
+ frameStr = this.totalFrameCount.toString();
512
+ }
513
+ this.frameDisplay.querySelector('.stat-value')!.textContent = frameStr;
514
+ }
515
+
516
+ /**
517
+ * Update time display.
518
+ */
519
+ private updateTimeDisplay(elapsedTime: number): void {
520
+ if (!this.timeDisplay) return;
521
+
522
+ const totalSeconds = Math.floor(elapsedTime);
523
+ const hours = Math.floor(totalSeconds / 3600);
524
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
525
+ const seconds = totalSeconds % 60;
526
+
527
+ let timeStr: string;
528
+ if (hours > 0) {
529
+ timeStr = `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
530
+ } else {
531
+ timeStr = `${minutes}:${seconds.toString().padStart(2, '0')}`;
532
+ }
533
+
534
+ this.timeDisplay.querySelector('.stat-value')!.textContent = timeStr;
535
+ }
536
+
537
+ /**
538
+ * Update resolution display.
539
+ */
540
+ private updateResolutionDisplay(): void {
541
+ if (!this.resolutionDisplay) return;
542
+
543
+ const w = this.canvas.width;
544
+ const h = this.canvas.height;
545
+ this.resolutionDisplay.querySelector('.stat-value')!.textContent = `${w}×${h}`;
546
+ }
547
+
548
+ /**
549
+ * Toggle the stats panel open/closed.
550
+ */
551
+ private toggleStats(): void {
552
+ this.isStatsOpen = !this.isStatsOpen;
553
+
554
+ if (this.statsGrid) {
555
+ this.statsGrid.classList.toggle('open', this.isStatsOpen);
556
+ }
557
+
558
+ if (this.statsContainer) {
559
+ this.statsContainer.classList.toggle('open', this.isStatsOpen);
560
+ }
561
+
562
+ // Update stats immediately when opening
563
+ if (this.isStatsOpen) {
564
+ const elapsedTime = (performance.now() / 1000) - this.startTime;
565
+ this.updateTimeDisplay(elapsedTime);
566
+ this.updateFrameDisplay();
567
+ this.updateResolutionDisplay();
237
568
  }
238
569
  }
239
570
 
@@ -246,21 +577,13 @@ export class App {
246
577
  private presentToScreen(): void {
247
578
  const gl = this.gl;
248
579
 
249
- const imageFramebuffer = this.engine.getImageFramebuffer();
250
- if (!imageFramebuffer) {
580
+ // Bind the Image pass's previousTexture as read source.
581
+ // After the ping-pong swap, the rendered output is in previousTexture.
582
+ if (!this.engine.bindImageForRead()) {
251
583
  console.warn('No Image pass found');
252
584
  return;
253
585
  }
254
586
 
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
587
  gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, null);
265
588
 
266
589
  gl.blitFramebuffer(
@@ -270,7 +593,8 @@ export class App {
270
593
  gl.NEAREST
271
594
  );
272
595
 
273
- gl.bindFramebuffer(gl.READ_FRAMEBUFFER, null);
596
+ // Restore FBO to normal state for next frame
597
+ this.engine.unbindImageForRead();
274
598
  }
275
599
 
276
600
  // ===========================================================================
@@ -293,26 +617,194 @@ export class App {
293
617
  // ===========================================================================
294
618
 
295
619
  private setupMouseTracking(): void {
296
- const updateMouse = (e: MouseEvent) => {
620
+ const getCoords = (e: MouseEvent): [number, number] => {
297
621
  const rect = this.canvas.getBoundingClientRect();
298
622
  const x = (e.clientX - rect.left) * this.pixelRatio;
299
623
  const y = (rect.height - (e.clientY - rect.top)) * this.pixelRatio; // Flip Y
624
+ return [x, y];
625
+ };
300
626
 
627
+ this.canvas.addEventListener('mousedown', (e: MouseEvent) => {
628
+ const [x, y] = getCoords(e);
629
+ this.isMouseDown = true;
301
630
  this.mouse[0] = x;
302
631
  this.mouse[1] = y;
303
- };
632
+ this.mouse[2] = x; // Click origin (positive = pressed)
633
+ this.mouse[3] = y;
634
+
635
+ // Initialize media inputs on first user gesture (browser policy)
636
+ this.initMediaOnGesture();
637
+ });
304
638
 
305
- const handleClick = (e: MouseEvent) => {
639
+ this.canvas.addEventListener('mousemove', (e: MouseEvent) => {
640
+ if (!this.isMouseDown) return;
641
+ const [x, y] = getCoords(e);
642
+ this.mouse[0] = x;
643
+ this.mouse[1] = y;
644
+ });
645
+
646
+ this.canvas.addEventListener('mouseup', () => {
647
+ this.isMouseDown = false;
648
+ // Negate zw to signal mouse is no longer held
649
+ this.mouse[2] = -Math.abs(this.mouse[2]);
650
+ this.mouse[3] = -Math.abs(this.mouse[3]);
651
+ });
652
+ }
653
+
654
+ // ===========================================================================
655
+ // Touch/Pointer Tracking
656
+ // ===========================================================================
657
+
658
+ /**
659
+ * Set up pointer event tracking for touch support.
660
+ * Uses Pointer Events API for unified mouse/touch/pen handling.
661
+ */
662
+ private setupTouchTracking(): void {
663
+ // Prevent default touch actions (scroll, zoom) on canvas
664
+ this.canvas.style.touchAction = 'none';
665
+
666
+ const getCanvasCoords = (clientX: number, clientY: number): [number, number] => {
306
667
  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
668
+ const x = (clientX - rect.left) * this.pixelRatio;
669
+ const y = (rect.height - (clientY - rect.top)) * this.pixelRatio; // Flip Y
670
+ return [x, y];
671
+ };
309
672
 
310
- this.mouse[2] = x;
311
- this.mouse[3] = y;
673
+ const handlePointerDown = (e: PointerEvent) => {
674
+ // Only track touch and pen (mouse is handled separately)
675
+ if (e.pointerType === 'mouse') return;
676
+
677
+ const [x, y] = getCanvasCoords(e.clientX, e.clientY);
678
+
679
+ this.activePointers.set(e.pointerId, {
680
+ id: e.pointerId,
681
+ x, y,
682
+ startX: x,
683
+ startY: y,
684
+ });
685
+
686
+ // Capture pointer to receive events even outside canvas
687
+ this.canvas.setPointerCapture(e.pointerId);
688
+
689
+ this.updateTouchState();
690
+
691
+ // Single touch also updates iMouse for compatibility
692
+ if (this.activePointers.size === 1) {
693
+ this.isMouseDown = true;
694
+ this.mouse[0] = x;
695
+ this.mouse[1] = y;
696
+ this.mouse[2] = x; // Click origin (positive = pressed)
697
+ this.mouse[3] = y;
698
+ }
699
+
700
+ e.preventDefault();
312
701
  };
313
702
 
314
- this.canvas.addEventListener('mousemove', updateMouse);
315
- this.canvas.addEventListener('click', handleClick);
703
+ const handlePointerMove = (e: PointerEvent) => {
704
+ if (e.pointerType === 'mouse') return;
705
+
706
+ const pointer = this.activePointers.get(e.pointerId);
707
+ if (!pointer) return;
708
+
709
+ const [x, y] = getCanvasCoords(e.clientX, e.clientY);
710
+ pointer.x = x;
711
+ pointer.y = y;
712
+
713
+ this.updateTouchState();
714
+
715
+ // Single touch also updates iMouse
716
+ if (this.activePointers.size === 1) {
717
+ this.mouse[0] = x;
718
+ this.mouse[1] = y;
719
+ }
720
+
721
+ e.preventDefault();
722
+ };
723
+
724
+ const handlePointerUp = (e: PointerEvent) => {
725
+ if (e.pointerType === 'mouse') return;
726
+
727
+ this.activePointers.delete(e.pointerId);
728
+ this.canvas.releasePointerCapture(e.pointerId);
729
+
730
+ // Negate zw when all touches released
731
+ if (this.activePointers.size === 0) {
732
+ this.isMouseDown = false;
733
+ this.mouse[2] = -Math.abs(this.mouse[2]);
734
+ this.mouse[3] = -Math.abs(this.mouse[3]);
735
+ }
736
+
737
+ this.updateTouchState();
738
+ e.preventDefault();
739
+ };
740
+
741
+ const handlePointerCancel = (e: PointerEvent) => {
742
+ // Same as pointer up - finger lifted or system interrupted
743
+ handlePointerUp(e);
744
+ };
745
+
746
+ this.canvas.addEventListener('pointerdown', handlePointerDown);
747
+ this.canvas.addEventListener('pointermove', handlePointerMove);
748
+ this.canvas.addEventListener('pointerup', handlePointerUp);
749
+ this.canvas.addEventListener('pointercancel', handlePointerCancel);
750
+ }
751
+
752
+ /**
753
+ * Update touch state from active pointers.
754
+ * Calculates pinch gesture when 2+ fingers are active.
755
+ */
756
+ private updateTouchState(): void {
757
+ const pointers = Array.from(this.activePointers.values());
758
+ const count = pointers.length;
759
+
760
+ this.touchState.count = count;
761
+
762
+ // Update individual touch points (up to 3)
763
+ for (let i = 0; i < 3; i++) {
764
+ if (i < pointers.length) {
765
+ const p = pointers[i];
766
+ this.touchState.touches[i] = [p.x, p.y, p.startX, p.startY];
767
+ } else {
768
+ this.touchState.touches[i] = [0, 0, 0, 0];
769
+ }
770
+ }
771
+
772
+ // Calculate pinch gesture (requires 2 fingers)
773
+ if (count >= 2) {
774
+ const p1 = pointers[0];
775
+ const p2 = pointers[1];
776
+
777
+ // Current distance
778
+ const dx = p2.x - p1.x;
779
+ const dy = p2.y - p1.y;
780
+ const currentDistance = Math.sqrt(dx * dx + dy * dy);
781
+
782
+ // Initial distance (from start positions)
783
+ const sdx = p2.startX - p1.startX;
784
+ const sdy = p2.startY - p1.startY;
785
+ const startDistance = Math.sqrt(sdx * sdx + sdy * sdy);
786
+
787
+ // Pinch scale relative to start
788
+ if (startDistance > 0) {
789
+ const newPinch = currentDistance / startDistance;
790
+ this.touchState.pinchDelta = newPinch - this.touchState.pinch;
791
+ this.touchState.pinch = newPinch;
792
+ }
793
+
794
+ // Pinch center
795
+ this.touchState.pinchCenter = [
796
+ (p1.x + p2.x) / 2,
797
+ (p1.y + p2.y) / 2,
798
+ ];
799
+ } else {
800
+ // Reset pinch when less than 2 fingers
801
+ this.touchState.pinchDelta = 0;
802
+ // Keep pinch at current value (don't reset to 1.0 until all fingers lift)
803
+ if (count === 0) {
804
+ this.touchState.pinch = 1.0;
805
+ this.touchState.pinchCenter = [0, 0];
806
+ }
807
+ }
316
808
  }
317
809
 
318
810
  // ===========================================================================
@@ -320,13 +812,24 @@ export class App {
320
812
  // ===========================================================================
321
813
 
322
814
  /**
323
- * Create playback control buttons (play/pause and reset).
815
+ * Create playback control buttons with collapsible menu.
324
816
  */
325
817
  private createControls(): void {
326
818
  // Create container
327
819
  this.controlsContainer = document.createElement('div');
328
820
  this.controlsContainer.className = 'playback-controls';
329
821
 
822
+ // Create menu toggle button
823
+ this.menuButton = document.createElement('button');
824
+ this.menuButton.className = 'controls-menu-button';
825
+ this.menuButton.title = 'Controls';
826
+ this.menuButton.textContent = '+';
827
+ this.menuButton.addEventListener('click', () => this.toggleControlsMenu());
828
+
829
+ // Create controls grid (hidden by default)
830
+ this.controlsGrid = document.createElement('div');
831
+ this.controlsGrid.className = 'controls-grid';
832
+
330
833
  // Play/Pause button (starts showing pause icon since we're playing)
331
834
  this.playPauseButton = document.createElement('button');
332
835
  this.playPauseButton.className = 'control-button';
@@ -362,13 +865,73 @@ export class App {
362
865
  `;
363
866
  screenshotButton.addEventListener('click', () => this.screenshot());
364
867
 
365
- // Add to container
366
- this.controlsContainer.appendChild(this.playPauseButton);
367
- this.controlsContainer.appendChild(resetButton);
368
- this.controlsContainer.appendChild(screenshotButton);
868
+ // Record button
869
+ this.recordButton = document.createElement('button');
870
+ this.recordButton.className = 'control-button';
871
+ this.recordButton.title = 'Record Video';
872
+ this.recordButton.innerHTML = `
873
+ <svg viewBox="0 0 16 16">
874
+ <circle cx="8" cy="8" r="5"/>
875
+ </svg>
876
+ `;
877
+ this.recordButton.addEventListener('click', () => this.toggleRecording());
878
+
879
+ // Export button
880
+ const exportButton = document.createElement('button');
881
+ exportButton.className = 'control-button';
882
+ exportButton.title = 'Export HTML';
883
+ exportButton.innerHTML = `
884
+ <svg viewBox="0 0 16 16">
885
+ <path d="M8 1a.5.5 0 0 1 .5.5v11.793l3.146-3.147a.5.5 0 0 1 .708.708l-4 4a.5.5 0 0 1-.708 0l-4-4a.5.5 0 0 1 .708-.708L7.5 13.293V1.5A.5.5 0 0 1 8 1z"/>
886
+ <path d="M2 14.5a.5.5 0 0 1 .5-.5h11a.5.5 0 0 1 0 1h-11a.5.5 0 0 1-.5-.5z"/>
887
+ </svg>
888
+ `;
889
+ exportButton.addEventListener('click', () => this.exportHTML());
890
+
891
+ // Menu button clone for inside grid (6th cell)
892
+ const menuButtonInGrid = document.createElement('button');
893
+ menuButtonInGrid.className = 'control-button';
894
+ menuButtonInGrid.title = 'Close';
895
+ menuButtonInGrid.textContent = '−';
896
+ menuButtonInGrid.style.fontSize = '20px';
897
+ menuButtonInGrid.style.fontWeight = '300';
898
+ menuButtonInGrid.addEventListener('click', () => this.toggleControlsMenu());
899
+
900
+ // Add buttons to grid (positioned in 2x3 layout)
901
+ // Row 1: Play/Pause, Reset, Export
902
+ // Row 2: Screenshot, Record, Menu (close)
903
+ this.controlsGrid.appendChild(this.playPauseButton);
904
+ this.controlsGrid.appendChild(resetButton);
905
+ this.controlsGrid.appendChild(exportButton);
906
+ this.controlsGrid.appendChild(screenshotButton);
907
+ this.controlsGrid.appendChild(this.recordButton);
908
+ this.controlsGrid.appendChild(menuButtonInGrid);
909
+
910
+ // Add grid and standalone menu button to container
911
+ this.controlsContainer.appendChild(this.controlsGrid);
912
+ this.controlsContainer.appendChild(this.menuButton);
369
913
  this.container.appendChild(this.controlsContainer);
370
914
  }
371
915
 
916
+ /**
917
+ * Toggle the controls menu open/closed.
918
+ */
919
+ private toggleControlsMenu(): void {
920
+ this.isMenuOpen = !this.isMenuOpen;
921
+
922
+ if (this.menuButton) {
923
+ this.menuButton.textContent = this.isMenuOpen ? '−' : '+';
924
+ }
925
+
926
+ if (this.controlsGrid) {
927
+ this.controlsGrid.classList.toggle('open', this.isMenuOpen);
928
+ }
929
+
930
+ if (this.controlsContainer) {
931
+ this.controlsContainer.classList.toggle('open', this.isMenuOpen);
932
+ }
933
+ }
934
+
372
935
  /**
373
936
  * Set up keyboard tracking for shader keyboard texture.
374
937
  * Tracks all key presses/releases and forwards to engine.
@@ -376,17 +939,16 @@ export class App {
376
939
  private setupKeyboardTracking(): void {
377
940
  // Track keydown events
378
941
  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) {
942
+ const keycode = keyEventToAscii(e);
943
+ if (keycode !== null) {
382
944
  this.engine.updateKeyState(keycode, true);
383
945
  }
384
946
  });
385
947
 
386
948
  // Track keyup events
387
949
  document.addEventListener('keyup', (e: KeyboardEvent) => {
388
- const keycode = e.keyCode;
389
- if (keycode >= 0 && keycode < 256) {
950
+ const keycode = keyEventToAscii(e);
951
+ if (keycode !== null) {
390
952
  this.engine.updateKeyState(keycode, false);
391
953
  }
392
954
  });
@@ -424,29 +986,157 @@ export class App {
424
986
  });
425
987
  }
426
988
 
989
+ // ===========================================================================
990
+ // WebGL Context Loss Handling
991
+ // ===========================================================================
992
+
993
+ /**
994
+ * Set up handlers for WebGL context loss and restoration.
995
+ * Context can be lost due to GPU driver issues, system sleep, etc.
996
+ */
997
+ private setupContextLossHandling(): void {
998
+ this.canvas.addEventListener('webglcontextlost', (e: Event) => {
999
+ e.preventDefault(); // Required to allow context restoration
1000
+ this.handleContextLost();
1001
+ });
1002
+
1003
+ this.canvas.addEventListener('webglcontextrestored', () => {
1004
+ this.handleContextRestored();
1005
+ });
1006
+ }
1007
+
1008
+ /**
1009
+ * Handle WebGL context loss - pause rendering and show overlay.
1010
+ */
1011
+ private handleContextLost(): void {
1012
+ this.isContextLost = true;
1013
+ this.stop();
1014
+ this.showContextLostOverlay();
1015
+ console.warn('WebGL context lost. Waiting for restoration...');
1016
+ }
1017
+
1018
+ /**
1019
+ * Handle WebGL context restoration - reinitialize and resume.
1020
+ */
1021
+ private handleContextRestored(): void {
1022
+ console.log('WebGL context restored. Reinitializing...');
1023
+
1024
+ try {
1025
+ // Dispose old engine resources (they're invalid now)
1026
+ this.engine.dispose();
1027
+
1028
+ // Reinitialize engine with fresh GL state
1029
+ this.engine = new ShaderEngine({
1030
+ gl: this.gl,
1031
+ project: this.project,
1032
+ });
1033
+
1034
+ // Check for compilation errors
1035
+ if (this.engine.hasErrors()) {
1036
+ this.showErrorOverlay(this.engine.getCompilationErrors());
1037
+ }
1038
+
1039
+ // Resize to current dimensions
1040
+ this.engine.resize(this.canvas.width, this.canvas.height);
1041
+
1042
+ // Hide context lost overlay and resume
1043
+ this.hideContextLostOverlay();
1044
+ this.isContextLost = false;
1045
+ this.reset();
1046
+ this.start();
1047
+
1048
+ console.log('WebGL context successfully restored');
1049
+ } catch (error) {
1050
+ console.error('Failed to restore WebGL context:', error);
1051
+ this.showContextLostOverlay(true); // Show with reload prompt
1052
+ }
1053
+ }
1054
+
1055
+ /**
1056
+ * Show overlay when WebGL context is lost.
1057
+ */
1058
+ private showContextLostOverlay(showReload: boolean = false): void {
1059
+ if (!this.contextLostOverlay) {
1060
+ this.contextLostOverlay = document.createElement('div');
1061
+ this.contextLostOverlay.className = 'context-lost-overlay';
1062
+ this.container.appendChild(this.contextLostOverlay);
1063
+ }
1064
+
1065
+ if (showReload) {
1066
+ this.contextLostOverlay.innerHTML = `
1067
+ <div class="context-lost-content">
1068
+ <div class="context-lost-icon">
1069
+ <svg width="48" height="48" viewBox="0 0 24 24" fill="currentColor">
1070
+ <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>
1071
+ </svg>
1072
+ </div>
1073
+ <div class="context-lost-title">WebGL Context Lost</div>
1074
+ <div class="context-lost-message">Unable to restore automatically.</div>
1075
+ <button class="context-lost-reload" onclick="location.reload()">Reload Page</button>
1076
+ </div>
1077
+ `;
1078
+ } else {
1079
+ this.contextLostOverlay.innerHTML = `
1080
+ <div class="context-lost-content">
1081
+ <div class="context-lost-spinner"></div>
1082
+ <div class="context-lost-title">WebGL Context Lost</div>
1083
+ <div class="context-lost-message">Attempting to restore...</div>
1084
+ </div>
1085
+ `;
1086
+ }
1087
+ }
1088
+
1089
+ /**
1090
+ * Hide the context lost overlay.
1091
+ */
1092
+ private hideContextLostOverlay(): void {
1093
+ if (this.contextLostOverlay) {
1094
+ this.contextLostOverlay.remove();
1095
+ this.contextLostOverlay = null;
1096
+ }
1097
+ }
1098
+
427
1099
  /**
428
1100
  * Toggle between play and pause states.
1101
+ * Public for UILayout to call.
429
1102
  */
430
- private togglePlayPause(): void {
1103
+ togglePlayPause(): void {
431
1104
  this.isPaused = !this.isPaused;
432
1105
  this.updatePlayPauseButton();
433
1106
  }
434
1107
 
1108
+ /**
1109
+ * Get current paused state.
1110
+ */
1111
+ getPaused(): boolean {
1112
+ return this.isPaused;
1113
+ }
1114
+
435
1115
  /**
436
1116
  * Reset the shader to frame 0.
1117
+ * Public for UILayout to call.
437
1118
  */
438
- private reset(): void {
1119
+ reset(): void {
439
1120
  this.startTime = performance.now() / 1000;
440
1121
  this.frameCount = 0;
1122
+ this.totalFrameCount = 0;
441
1123
  this.lastFpsUpdate = 0;
442
1124
  this.engine.reset();
1125
+
1126
+ // Update stats display if open
1127
+ if (this.isStatsOpen) {
1128
+ this.updateTimeDisplay(0);
1129
+ this.updateFrameDisplay();
1130
+ this.updateResolutionDisplay();
1131
+ }
443
1132
  }
444
1133
 
445
1134
  /**
446
1135
  * Capture and download a screenshot of the current canvas as PNG.
447
1136
  * Filename format: shadertoy-{folderName}-{timestamp}.png
1137
+ * Public for UILayout to call.
448
1138
  */
449
- private screenshot(): void {
1139
+ screenshot(): void {
450
1140
  // Extract folder name from project root (e.g., "/demos/keyboard-test" -> "keyboard-test")
451
1141
  const folderName = this.project.root.split('/').pop() || 'shader';
452
1142
 
@@ -482,6 +1172,636 @@ export class App {
482
1172
  }, 'image/png');
483
1173
  }
484
1174
 
1175
+ // ===========================================================================
1176
+ // Video Recording
1177
+ // ===========================================================================
1178
+
1179
+ /**
1180
+ * Toggle video recording on/off.
1181
+ * Public for UILayout to call.
1182
+ */
1183
+ toggleRecording(): void {
1184
+ if (this.isRecording) {
1185
+ this.stopRecording();
1186
+ } else {
1187
+ this.startRecording();
1188
+ }
1189
+ }
1190
+
1191
+ /**
1192
+ * Start recording the canvas as WebM video.
1193
+ */
1194
+ private startRecording(): void {
1195
+ // Check if MediaRecorder is supported
1196
+ if (!MediaRecorder.isTypeSupported('video/webm')) {
1197
+ console.error('WebM recording not supported in this browser');
1198
+ return;
1199
+ }
1200
+
1201
+ // Unpause if paused (can't record a paused shader)
1202
+ if (this.isPaused) {
1203
+ this.togglePlayPause();
1204
+ }
1205
+
1206
+ // Get canvas stream at 60fps
1207
+ const stream = this.canvas.captureStream(60);
1208
+
1209
+ // Create MediaRecorder with WebM format
1210
+ this.mediaRecorder = new MediaRecorder(stream, {
1211
+ mimeType: 'video/webm;codecs=vp9',
1212
+ videoBitsPerSecond: 8000000, // 8 Mbps for high quality
1213
+ });
1214
+
1215
+ this.recordedChunks = [];
1216
+
1217
+ // Collect recorded chunks
1218
+ this.mediaRecorder.ondataavailable = (event) => {
1219
+ if (event.data.size > 0) {
1220
+ this.recordedChunks.push(event.data);
1221
+ }
1222
+ };
1223
+
1224
+ // Handle recording stop - download the video
1225
+ this.mediaRecorder.onstop = () => {
1226
+ const blob = new Blob(this.recordedChunks, { type: 'video/webm' });
1227
+
1228
+ // Generate filename
1229
+ const folderName = this.project.root.split('/').pop() || 'shader';
1230
+ const now = new Date();
1231
+ const timestamp = now.getFullYear().toString() +
1232
+ (now.getMonth() + 1).toString().padStart(2, '0') +
1233
+ now.getDate().toString().padStart(2, '0') + '-' +
1234
+ now.getHours().toString().padStart(2, '0') +
1235
+ now.getMinutes().toString().padStart(2, '0') +
1236
+ now.getSeconds().toString().padStart(2, '0');
1237
+ const filename = `shadertoy-${folderName}-${timestamp}.webm`;
1238
+
1239
+ // Download
1240
+ const url = URL.createObjectURL(blob);
1241
+ const link = document.createElement('a');
1242
+ link.href = url;
1243
+ link.download = filename;
1244
+ link.click();
1245
+ URL.revokeObjectURL(url);
1246
+
1247
+ console.log(`Recording saved: ${filename}`);
1248
+ };
1249
+
1250
+ // Start recording
1251
+ this.mediaRecorder.start();
1252
+ this.isRecording = true;
1253
+ this.updateRecordButton();
1254
+ this.showRecordingIndicator();
1255
+ console.log('Recording started');
1256
+ }
1257
+
1258
+ /**
1259
+ * Stop recording and trigger download.
1260
+ */
1261
+ private stopRecording(): void {
1262
+ if (this.mediaRecorder && this.mediaRecorder.state !== 'inactive') {
1263
+ this.mediaRecorder.stop();
1264
+ }
1265
+ this.isRecording = false;
1266
+ this.mediaRecorder = null;
1267
+ this.updateRecordButton();
1268
+ this.hideRecordingIndicator();
1269
+ console.log('Recording stopped');
1270
+ }
1271
+
1272
+ /**
1273
+ * Update record button appearance based on recording state.
1274
+ */
1275
+ private updateRecordButton(): void {
1276
+ if (!this.recordButton) return;
1277
+
1278
+ if (this.isRecording) {
1279
+ // Show stop icon (square)
1280
+ this.recordButton.innerHTML = `
1281
+ <svg viewBox="0 0 16 16">
1282
+ <rect x="4" y="4" width="8" height="8"/>
1283
+ </svg>
1284
+ `;
1285
+ this.recordButton.title = 'Stop Recording';
1286
+ this.recordButton.classList.add('recording');
1287
+ } else {
1288
+ // Show record icon (circle)
1289
+ this.recordButton.innerHTML = `
1290
+ <svg viewBox="0 0 16 16">
1291
+ <circle cx="8" cy="8" r="5"/>
1292
+ </svg>
1293
+ `;
1294
+ this.recordButton.title = 'Record Video';
1295
+ this.recordButton.classList.remove('recording');
1296
+ }
1297
+ }
1298
+
1299
+ /**
1300
+ * Show the recording indicator (pulsing red dot in corner).
1301
+ */
1302
+ private showRecordingIndicator(): void {
1303
+ if (this.recordingIndicator) return;
1304
+
1305
+ this.recordingIndicator = document.createElement('div');
1306
+ this.recordingIndicator.className = 'recording-indicator';
1307
+ this.recordingIndicator.innerHTML = `
1308
+ <span class="recording-dot"></span>
1309
+ <span class="recording-text">REC</span>
1310
+ `;
1311
+ this.container.appendChild(this.recordingIndicator);
1312
+ }
1313
+
1314
+ /**
1315
+ * Hide the recording indicator.
1316
+ */
1317
+ private hideRecordingIndicator(): void {
1318
+ if (this.recordingIndicator) {
1319
+ this.recordingIndicator.remove();
1320
+ this.recordingIndicator = null;
1321
+ }
1322
+ }
1323
+
1324
+ // ===========================================================================
1325
+ // HTML Export
1326
+ // ===========================================================================
1327
+
1328
+ /**
1329
+ * Export the current shader as a standalone HTML file.
1330
+ * Bakes in current uniform values and replaces textures with procedural grid.
1331
+ */
1332
+ exportHTML(): void {
1333
+ const project = this.project;
1334
+ const uniformValues = this.engine.getUniformValues();
1335
+
1336
+ // Collect pass info
1337
+ const passOrder = ['BufferA', 'BufferB', 'BufferC', 'BufferD', 'Image'] as const;
1338
+ const passes: Array<{ name: string; source: string; channels: string[] }> = [];
1339
+
1340
+ for (const passName of passOrder) {
1341
+ const pass = project.passes[passName];
1342
+ if (!pass) continue;
1343
+
1344
+ // Map channels to their source type for the export
1345
+ const channels = pass.channels.map((ch) => {
1346
+ if (ch.kind === 'buffer') return ch.buffer;
1347
+ if (ch.kind === 'texture') return 'procedural'; // Will use grid texture
1348
+ if (ch.kind === 'keyboard') return 'keyboard';
1349
+ return 'none';
1350
+ });
1351
+
1352
+ passes.push({
1353
+ name: passName,
1354
+ source: pass.glslSource,
1355
+ channels,
1356
+ });
1357
+ }
1358
+
1359
+ // Build the HTML
1360
+ const html = this.generateStandaloneHTML({
1361
+ title: project.meta.title,
1362
+ commonSource: project.commonSource,
1363
+ passes,
1364
+ uniforms: project.uniforms,
1365
+ uniformValues,
1366
+ });
1367
+
1368
+ // Download
1369
+ const blob = new Blob([html], { type: 'text/html' });
1370
+ const folderName = project.root.split('/').pop() || 'shader';
1371
+ const filename = `${folderName}.html`;
1372
+
1373
+ const url = URL.createObjectURL(blob);
1374
+ const link = document.createElement('a');
1375
+ link.href = url;
1376
+ link.download = filename;
1377
+ link.click();
1378
+ URL.revokeObjectURL(url);
1379
+
1380
+ console.log(`Exported: ${filename}`);
1381
+ }
1382
+
1383
+ /**
1384
+ * Generate standalone HTML with embedded shaders.
1385
+ */
1386
+ private generateStandaloneHTML(opts: {
1387
+ title: string;
1388
+ commonSource: string | null;
1389
+ passes: Array<{ name: string; source: string; channels: string[] }>;
1390
+ uniforms: Record<string, { type: string; value?: unknown }>;
1391
+ uniformValues: Record<string, unknown>;
1392
+ }): string {
1393
+ const { title, commonSource, passes, uniforms, uniformValues } = opts;
1394
+
1395
+ // Escape shader sources for embedding in JS template literals
1396
+ const escapeForJS = (s: string) => s.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$/g, '\\$');
1397
+
1398
+ // Build shader source objects
1399
+ const shaderSources = passes.map(p => ({
1400
+ name: p.name,
1401
+ source: escapeForJS(p.source),
1402
+ channels: p.channels,
1403
+ }));
1404
+
1405
+ // Warn about array uniforms (not supported in export)
1406
+ const isArray = (def: { type: string; value?: unknown }) => 'count' in def;
1407
+ const arrayUniformNames = Object.entries(uniforms)
1408
+ .filter(([, def]) => isArray(def))
1409
+ .map(([name]) => name);
1410
+ if (arrayUniformNames.length > 0) {
1411
+ console.warn(
1412
+ `HTML export: array uniforms not supported, skipping: ${arrayUniformNames.join(', ')}`
1413
+ );
1414
+ }
1415
+
1416
+ // Build uniform initialization code (scalar only)
1417
+ const uniformInits = Object.entries(uniforms).filter(([, def]) => !isArray(def)).map(([name, def]) => {
1418
+ const value = uniformValues[name] ?? def.value;
1419
+ if (def.type === 'float' || def.type === 'int') {
1420
+ return ` '${name}': ${value},`;
1421
+ } else if (def.type === 'bool') {
1422
+ return ` '${name}': ${value ? 1 : 0},`;
1423
+ } else if (def.type === 'vec2') {
1424
+ const v = value as number[];
1425
+ return ` '${name}': [${v[0]}, ${v[1]}],`;
1426
+ } else if (def.type === 'vec3') {
1427
+ const v = value as number[];
1428
+ return ` '${name}': [${v[0]}, ${v[1]}, ${v[2]}],`;
1429
+ } else if (def.type === 'vec4') {
1430
+ const v = value as number[];
1431
+ return ` '${name}': [${v[0]}, ${v[1]}, ${v[2]}, ${v[3]}],`;
1432
+ }
1433
+ return '';
1434
+ }).filter(Boolean).join('\n');
1435
+
1436
+ // Build uniform declarations for shaders (scalar only)
1437
+ const uniformDeclarations = Object.entries(uniforms).filter(([, def]) => !isArray(def)).map(([name, def]) => {
1438
+ if (def.type === 'float') return `uniform float ${name};`;
1439
+ if (def.type === 'int') return `uniform int ${name};`;
1440
+ if (def.type === 'bool') return `uniform int ${name};`; // GLSL ES doesn't have bool uniforms
1441
+ if (def.type === 'vec2') return `uniform vec2 ${name};`;
1442
+ if (def.type === 'vec3') return `uniform vec3 ${name};`;
1443
+ if (def.type === 'vec4') return `uniform vec4 ${name};`;
1444
+ return '';
1445
+ }).filter(Boolean).join('\n');
1446
+
1447
+ return `<!DOCTYPE html>
1448
+ <html lang="en">
1449
+ <head>
1450
+ <meta charset="UTF-8">
1451
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1452
+ <title>${title}</title>
1453
+ <style>
1454
+ * { margin: 0; padding: 0; box-sizing: border-box; }
1455
+ html, body { width: 100%; height: 100%; background: #fff; }
1456
+ body { display: flex; align-items: center; justify-content: center; }
1457
+ .container {
1458
+ width: 90vw;
1459
+ max-width: 1200px;
1460
+ aspect-ratio: 16 / 9;
1461
+ background: #000;
1462
+ border-radius: 8px;
1463
+ overflow: hidden;
1464
+ box-shadow: 0 4px 24px rgba(0,0,0,0.15), 0 2px 8px rgba(0,0,0,0.1);
1465
+ }
1466
+ canvas { display: block; width: 100%; height: 100%; }
1467
+ </style>
1468
+ </head>
1469
+ <body>
1470
+ <div class="container">
1471
+ <canvas id="canvas"></canvas>
1472
+ </div>
1473
+ <script>
1474
+ // Shader Sandbox Export - ${title}
1475
+ // Generated ${new Date().toISOString()}
1476
+
1477
+ const VERTEX_SHADER = \`#version 300 es
1478
+ precision highp float;
1479
+ layout(location = 0) in vec2 position;
1480
+ void main() { gl_Position = vec4(position, 0.0, 1.0); }
1481
+ \`;
1482
+
1483
+ const FRAGMENT_PREAMBLE = \`#version 300 es
1484
+ precision highp float;
1485
+
1486
+ // Procedural texture for missing channels
1487
+ vec4 proceduralGrid(vec2 uv) {
1488
+ vec2 grid = step(fract(uv * 8.0), vec2(0.5));
1489
+ float checker = abs(grid.x - grid.y);
1490
+ return mix(vec4(0.2, 0.2, 0.2, 1.0), vec4(0.8, 0.1, 0.8, 1.0), checker);
1491
+ }
1492
+
1493
+ uniform vec3 iResolution;
1494
+ uniform float iTime;
1495
+ uniform float iTimeDelta;
1496
+ uniform int iFrame;
1497
+ uniform vec4 iMouse;
1498
+ uniform bool iMousePressed;
1499
+ uniform vec4 iDate;
1500
+ uniform float iFrameRate;
1501
+ uniform vec3 iChannelResolution[4];
1502
+ uniform sampler2D iChannel0;
1503
+ uniform sampler2D iChannel1;
1504
+ uniform sampler2D iChannel2;
1505
+ uniform sampler2D iChannel3;
1506
+ ${uniformDeclarations}
1507
+ \`;
1508
+
1509
+ const FRAGMENT_SUFFIX = \`
1510
+ out vec4 fragColor;
1511
+ void main() { mainImage(fragColor, gl_FragCoord.xy); }
1512
+ \`;
1513
+
1514
+ const COMMON_SOURCE = \`${commonSource ? escapeForJS(commonSource) : ''}\`;
1515
+
1516
+ const PASSES = [
1517
+ ${shaderSources.map(p => ` { name: '${p.name}', source: \`${p.source}\`, channels: ${JSON.stringify(p.channels)} }`).join(',\n')}
1518
+ ];
1519
+
1520
+ const UNIFORM_VALUES = {
1521
+ ${uniformInits}
1522
+ };
1523
+
1524
+ // WebGL setup
1525
+ const canvas = document.getElementById('canvas');
1526
+ const gl = canvas.getContext('webgl2', { alpha: false, antialias: false, preserveDrawingBuffer: true });
1527
+ if (!gl) { alert('WebGL2 not supported'); throw new Error('WebGL2 not supported'); }
1528
+
1529
+ // Fullscreen triangle
1530
+ const vao = gl.createVertexArray();
1531
+ gl.bindVertexArray(vao);
1532
+ const vbo = gl.createBuffer();
1533
+ gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
1534
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1, 3,-1, -1,3]), gl.STATIC_DRAW);
1535
+ gl.enableVertexAttribArray(0);
1536
+ gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
1537
+
1538
+ // Procedural texture (8x8 checkerboard)
1539
+ function createProceduralTexture() {
1540
+ const tex = gl.createTexture();
1541
+ gl.bindTexture(gl.TEXTURE_2D, tex);
1542
+ const data = new Uint8Array(8 * 8 * 4);
1543
+ for (let y = 0; y < 8; y++) {
1544
+ for (let x = 0; x < 8; x++) {
1545
+ const i = (y * 8 + x) * 4;
1546
+ const checker = (x + y) % 2;
1547
+ data[i] = checker ? 204 : 51;
1548
+ data[i+1] = checker ? 26 : 51;
1549
+ data[i+2] = checker ? 204 : 51;
1550
+ data[i+3] = 255;
1551
+ }
1552
+ }
1553
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 8, 8, 0, gl.RGBA, gl.UNSIGNED_BYTE, data);
1554
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
1555
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
1556
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT);
1557
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT);
1558
+ return tex;
1559
+ }
1560
+
1561
+ // Black texture for unused channels
1562
+ function createBlackTexture() {
1563
+ const tex = gl.createTexture();
1564
+ gl.bindTexture(gl.TEXTURE_2D, tex);
1565
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array([0,0,0,255]));
1566
+ return tex;
1567
+ }
1568
+
1569
+ const proceduralTex = createProceduralTexture();
1570
+ const blackTex = createBlackTexture();
1571
+
1572
+ // Compile shader
1573
+ function compileShader(type, source) {
1574
+ const shader = gl.createShader(type);
1575
+ gl.shaderSource(shader, source);
1576
+ gl.compileShader(shader);
1577
+ if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
1578
+ console.error(gl.getShaderInfoLog(shader));
1579
+ console.error(source.split('\\n').map((l,i) => (i+1) + ': ' + l).join('\\n'));
1580
+ throw new Error('Shader compile failed');
1581
+ }
1582
+ return shader;
1583
+ }
1584
+
1585
+ // Create program
1586
+ function createProgram(fragSource) {
1587
+ const vs = compileShader(gl.VERTEX_SHADER, VERTEX_SHADER);
1588
+ const fs = compileShader(gl.FRAGMENT_SHADER, fragSource);
1589
+ const program = gl.createProgram();
1590
+ gl.attachShader(program, vs);
1591
+ gl.attachShader(program, fs);
1592
+ gl.linkProgram(program);
1593
+ if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
1594
+ throw new Error('Program link failed: ' + gl.getProgramInfoLog(program));
1595
+ }
1596
+ return program;
1597
+ }
1598
+
1599
+ // Create texture for render target
1600
+ function createRenderTexture(w, h) {
1601
+ const tex = gl.createTexture();
1602
+ gl.bindTexture(gl.TEXTURE_2D, tex);
1603
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA32F, w, h, 0, gl.RGBA, gl.FLOAT, null);
1604
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
1605
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
1606
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
1607
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
1608
+ return tex;
1609
+ }
1610
+
1611
+ // Create framebuffer attached to a texture
1612
+ function createFramebuffer(tex) {
1613
+ const fb = gl.createFramebuffer();
1614
+ gl.bindFramebuffer(gl.FRAMEBUFFER, fb);
1615
+ gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0);
1616
+ const status = gl.checkFramebufferStatus(gl.FRAMEBUFFER);
1617
+ if (status !== gl.FRAMEBUFFER_COMPLETE) {
1618
+ console.error('Framebuffer not complete:', status);
1619
+ }
1620
+ return fb;
1621
+ }
1622
+
1623
+ // Initialize passes
1624
+ const container = canvas.parentElement;
1625
+ let width = canvas.width = container.clientWidth * devicePixelRatio;
1626
+ let height = canvas.height = container.clientHeight * devicePixelRatio;
1627
+
1628
+ // Enable float textures (required for multi-buffer feedback)
1629
+ const floatExt = gl.getExtension('EXT_color_buffer_float');
1630
+ if (!floatExt) console.warn('EXT_color_buffer_float not supported');
1631
+
1632
+ const runtimePasses = PASSES.map(pass => {
1633
+ const fragSource = FRAGMENT_PREAMBLE + (COMMON_SOURCE ? '\\n// Common\\n' + COMMON_SOURCE + '\\n' : '') + '\\n// User code\\n' + pass.source + FRAGMENT_SUFFIX;
1634
+ const program = createProgram(fragSource);
1635
+ const currentTexture = createRenderTexture(width, height);
1636
+ const previousTexture = createRenderTexture(width, height);
1637
+ const framebuffer = createFramebuffer(currentTexture);
1638
+ return {
1639
+ name: pass.name,
1640
+ channels: pass.channels,
1641
+ program,
1642
+ framebuffer,
1643
+ currentTexture,
1644
+ previousTexture,
1645
+ uniforms: {
1646
+ iResolution: gl.getUniformLocation(program, 'iResolution'),
1647
+ iTime: gl.getUniformLocation(program, 'iTime'),
1648
+ iTimeDelta: gl.getUniformLocation(program, 'iTimeDelta'),
1649
+ iFrame: gl.getUniformLocation(program, 'iFrame'),
1650
+ iMouse: gl.getUniformLocation(program, 'iMouse'),
1651
+ iMousePressed: gl.getUniformLocation(program, 'iMousePressed'),
1652
+ iDate: gl.getUniformLocation(program, 'iDate'),
1653
+ iFrameRate: gl.getUniformLocation(program, 'iFrameRate'),
1654
+ iChannel: [0,1,2,3].map(i => gl.getUniformLocation(program, 'iChannel' + i)),
1655
+ custom: Object.keys(UNIFORM_VALUES).reduce((acc, name) => {
1656
+ acc[name] = gl.getUniformLocation(program, name);
1657
+ return acc;
1658
+ }, {})
1659
+ }
1660
+ };
1661
+ });
1662
+
1663
+ // Find pass by name
1664
+ const findPass = name => runtimePasses.find(p => p.name === name);
1665
+
1666
+ // Mouse state (Shadertoy spec: xy=pos while down, zw=click origin, sign=held)
1667
+ let mouse = [0, 0, 0, 0];
1668
+ let mouseDown = false;
1669
+ canvas.addEventListener('mousedown', e => {
1670
+ mouseDown = true;
1671
+ const x = e.clientX * devicePixelRatio;
1672
+ const y = (canvas.clientHeight - e.clientY) * devicePixelRatio;
1673
+ mouse[0] = x; mouse[1] = y;
1674
+ mouse[2] = x; mouse[3] = y;
1675
+ });
1676
+ canvas.addEventListener('mousemove', e => {
1677
+ if (!mouseDown) return;
1678
+ mouse[0] = e.clientX * devicePixelRatio;
1679
+ mouse[1] = (canvas.clientHeight - e.clientY) * devicePixelRatio;
1680
+ });
1681
+ canvas.addEventListener('mouseup', () => {
1682
+ mouseDown = false;
1683
+ mouse[2] = -Math.abs(mouse[2]);
1684
+ mouse[3] = -Math.abs(mouse[3]);
1685
+ });
1686
+
1687
+ // Resize handler - only resize if dimensions actually changed
1688
+ let lastWidth = width, lastHeight = height;
1689
+ new ResizeObserver(() => {
1690
+ const newWidth = container.clientWidth * devicePixelRatio;
1691
+ const newHeight = container.clientHeight * devicePixelRatio;
1692
+ if (newWidth === lastWidth && newHeight === lastHeight) return;
1693
+ lastWidth = width = canvas.width = newWidth;
1694
+ lastHeight = height = canvas.height = newHeight;
1695
+ runtimePasses.forEach(p => {
1696
+ [p.currentTexture, p.previousTexture].forEach(tex => {
1697
+ gl.bindTexture(gl.TEXTURE_2D, tex);
1698
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA32F, width, height, 0, gl.RGBA, gl.FLOAT, null);
1699
+ });
1700
+ gl.bindFramebuffer(gl.FRAMEBUFFER, p.framebuffer);
1701
+ gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, p.currentTexture, 0);
1702
+ });
1703
+ frame = 0;
1704
+ startTime = performance.now() / 1000;
1705
+ lastTime = 0;
1706
+ }).observe(container);
1707
+
1708
+ // Animation
1709
+ let frame = 0;
1710
+ let startTime = performance.now() / 1000;
1711
+ let lastTime = 0;
1712
+
1713
+ function render(now) {
1714
+ requestAnimationFrame(render);
1715
+
1716
+ const time = now / 1000 - startTime;
1717
+ const deltaTime = Math.max(0, time - lastTime);
1718
+ lastTime = time;
1719
+
1720
+ const date = new Date();
1721
+ const iDate = [date.getFullYear(), date.getMonth(), date.getDate(),
1722
+ date.getHours() * 3600 + date.getMinutes() * 60 + date.getSeconds() + date.getMilliseconds() / 1000];
1723
+
1724
+ gl.bindVertexArray(vao);
1725
+
1726
+ runtimePasses.forEach(pass => {
1727
+ gl.useProgram(pass.program);
1728
+ gl.bindFramebuffer(gl.FRAMEBUFFER, pass.framebuffer);
1729
+ gl.viewport(0, 0, width, height);
1730
+
1731
+ // Bind uniforms
1732
+ gl.uniform3f(pass.uniforms.iResolution, width, height, 1);
1733
+ gl.uniform1f(pass.uniforms.iTime, time);
1734
+ gl.uniform1f(pass.uniforms.iTimeDelta, deltaTime);
1735
+ gl.uniform1i(pass.uniforms.iFrame, frame);
1736
+ gl.uniform4fv(pass.uniforms.iMouse, mouse);
1737
+ gl.uniform1i(pass.uniforms.iMousePressed, mouseDown ? 1 : 0);
1738
+ gl.uniform4fv(pass.uniforms.iDate, iDate);
1739
+ gl.uniform1f(pass.uniforms.iFrameRate, 1 / deltaTime);
1740
+
1741
+ // Bind custom uniforms
1742
+ Object.entries(UNIFORM_VALUES).forEach(([name, value]) => {
1743
+ const loc = pass.uniforms.custom[name];
1744
+ if (!loc) return;
1745
+ if (Array.isArray(value)) {
1746
+ if (value.length === 2) gl.uniform2fv(loc, value);
1747
+ else if (value.length === 3) gl.uniform3fv(loc, value);
1748
+ else if (value.length === 4) gl.uniform4fv(loc, value);
1749
+ } else {
1750
+ gl.uniform1f(loc, value);
1751
+ }
1752
+ });
1753
+
1754
+ // Bind channels
1755
+ pass.channels.forEach((ch, i) => {
1756
+ gl.activeTexture(gl.TEXTURE0 + i);
1757
+ if (ch === 'none') {
1758
+ gl.bindTexture(gl.TEXTURE_2D, blackTex);
1759
+ } else if (ch === 'procedural') {
1760
+ gl.bindTexture(gl.TEXTURE_2D, proceduralTex);
1761
+ } else if (['BufferA', 'BufferB', 'BufferC', 'BufferD', 'Image'].includes(ch)) {
1762
+ const srcPass = findPass(ch);
1763
+ gl.bindTexture(gl.TEXTURE_2D, srcPass ? srcPass.previousTexture : blackTex);
1764
+ } else {
1765
+ gl.bindTexture(gl.TEXTURE_2D, blackTex);
1766
+ }
1767
+ gl.uniform1i(pass.uniforms.iChannel[i], i);
1768
+ });
1769
+
1770
+ gl.drawArrays(gl.TRIANGLES, 0, 3);
1771
+
1772
+ // Swap textures and re-attach framebuffer
1773
+ const temp = pass.currentTexture;
1774
+ pass.currentTexture = pass.previousTexture;
1775
+ pass.previousTexture = temp;
1776
+ gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, pass.currentTexture, 0);
1777
+ });
1778
+
1779
+ // Blit Image pass to screen
1780
+ const imagePass = findPass('Image');
1781
+ if (imagePass) {
1782
+ // Attach previousTexture (just rendered) for reading
1783
+ gl.bindFramebuffer(gl.FRAMEBUFFER, imagePass.framebuffer);
1784
+ gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, imagePass.previousTexture, 0);
1785
+
1786
+ // Blit to screen
1787
+ gl.bindFramebuffer(gl.READ_FRAMEBUFFER, imagePass.framebuffer);
1788
+ gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, null);
1789
+ gl.blitFramebuffer(0, 0, width, height, 0, 0, width, height, gl.COLOR_BUFFER_BIT, gl.NEAREST);
1790
+
1791
+ // Restore framebuffer to currentTexture for next frame
1792
+ gl.bindFramebuffer(gl.FRAMEBUFFER, imagePass.framebuffer);
1793
+ gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, imagePass.currentTexture, 0);
1794
+ }
1795
+
1796
+ frame++;
1797
+ }
1798
+
1799
+ requestAnimationFrame(render);
1800
+ </script>
1801
+ </body>
1802
+ </html>`;
1803
+ }
1804
+
485
1805
  /**
486
1806
  * Update play/pause button icon based on current state.
487
1807
  */
@@ -505,6 +1825,38 @@ export class App {
505
1825
  }
506
1826
  }
507
1827
 
1828
+ // ===========================================================================
1829
+ // Media Permission Banner
1830
+ // ===========================================================================
1831
+
1832
+ private showMediaBanner(): void {
1833
+ this.mediaBanner = document.createElement('div');
1834
+ this.mediaBanner.className = 'media-permission-banner';
1835
+
1836
+ const features: string[] = [];
1837
+ if (this.engine.needsAudio) features.push('microphone');
1838
+ if (this.engine.needsWebcam) features.push('webcam');
1839
+
1840
+ this.mediaBanner.innerHTML = `
1841
+ <span class="media-banner-text">This shader uses ${features.join(' and ')}</span>
1842
+ <button class="media-banner-button">Click to enable</button>
1843
+ `;
1844
+
1845
+ const button = this.mediaBanner.querySelector('.media-banner-button')!;
1846
+ button.addEventListener('click', () => {
1847
+ this.initMediaOnGesture();
1848
+ });
1849
+
1850
+ this.container.appendChild(this.mediaBanner);
1851
+ }
1852
+
1853
+ private hideMediaBanner(): void {
1854
+ if (this.mediaBanner) {
1855
+ this.mediaBanner.remove();
1856
+ this.mediaBanner = null;
1857
+ }
1858
+ }
1859
+
508
1860
  // ===========================================================================
509
1861
  // Error Handling
510
1862
  // ===========================================================================
@@ -518,6 +1870,7 @@ export class App {
518
1870
  source: string;
519
1871
  isFromCommon: boolean;
520
1872
  originalLine: number | null;
1873
+ lineMapping: LineMapping;
521
1874
  }>): void {
522
1875
  // Create overlay if it doesn't exist
523
1876
  if (!this.errorOverlay) {
@@ -537,23 +1890,33 @@ export class App {
537
1890
  const allErrors = [...uniqueCommonErrors, ...passErrors];
538
1891
 
539
1892
  // 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
1893
+ const formattedErrors = allErrors.map(({passName, error, isFromCommon, originalLine, lineMapping}) => {
542
1894
  const glslError = error.replace('Shader compilation failed:\n', '');
543
1895
 
544
- // For common errors, adjust line number in error message
1896
+ // Use originalLine (already computed by engine relative to user/common source)
1897
+ const displayLine = originalLine;
1898
+
1899
+ // Adjust error message to show user-relative line numbers
545
1900
  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}:`);
1901
+ if (displayLine !== null) {
1902
+ adjustedError = glslError.replace(/ERROR:\s*\d+:(\d+):/g, `ERROR: 0:${displayLine}:`);
1903
+ }
1904
+
1905
+ // Get user's original source for code context
1906
+ let userSource: string | null = null;
1907
+ if (isFromCommon) {
1908
+ userSource = this.engine.project.commonSource;
1909
+ } else {
1910
+ const pass = this.engine.project.passes[passName as 'Image' | 'BufferA' | 'BufferB' | 'BufferC' | 'BufferD'];
1911
+ userSource = pass?.glslSource ?? null;
549
1912
  }
550
1913
 
551
1914
  return {
552
1915
  passName: isFromCommon ? 'common.glsl' : passName,
553
- error: this.parseShaderError(adjustedError),
554
- codeContext: isFromCommon
555
- ? this.extractCodeContextFromCommon(originalLine!)
556
- : this.extractCodeContext(adjustedError, source),
1916
+ error: this.parseShaderError(adjustedError, lineMapping, isFromCommon),
1917
+ codeContext: displayLine !== null && userSource
1918
+ ? this.buildCodeContext(userSource, displayLine)
1919
+ : null,
557
1920
  };
558
1921
  });
559
1922
 
@@ -591,91 +1954,71 @@ export class App {
591
1954
  }
592
1955
 
593
1956
  /**
594
- * Parse and improve WebGL shader error messages.
1957
+ * Parse WebGL error messages into user-friendly format with correct line numbers.
595
1958
  */
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
-
1959
+ private parseShaderError(error: string, lineMapping: LineMapping, isFromCommon: boolean): string {
600
1960
  return error.split('\n').map(line => {
601
- // Match pattern: ERROR: 0:lineNumber: message
602
1961
  const match = line.match(/^ERROR:\s*(\d+):(\d+):\s*(.+)$/);
603
1962
  if (match) {
604
- const [, , lineNum, message] = match;
605
- return `Line ${lineNum}: ${message}`;
1963
+ const [, , rawLineStr, message] = match;
1964
+ const rawLine = parseInt(rawLineStr, 10);
1965
+
1966
+ // Convert compiled line number to user-relative line
1967
+ let userLine = rawLine;
1968
+ if (isFromCommon && lineMapping.commonStartLine > 0) {
1969
+ userLine = rawLine - lineMapping.commonStartLine + 1;
1970
+ } else if (lineMapping.userCodeStartLine > 0 && rawLine >= lineMapping.userCodeStartLine) {
1971
+ userLine = rawLine - lineMapping.userCodeStartLine + 1;
1972
+ }
1973
+
1974
+ return `Line ${userLine}: ${this.friendlyGLSLError(message)}`;
606
1975
  }
607
1976
  return line;
608
1977
  }).join('\n');
609
1978
  }
610
1979
 
611
1980
  /**
612
- * Extract code context around error line (±3 lines).
613
- * Returns HTML with the error line highlighted.
1981
+ * Add helpful hints to common GLSL error messages.
614
1982
  */
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;
1983
+ private friendlyGLSLError(msg: string): string {
1984
+ if (msg.includes('no matching overloaded function found'))
1985
+ return msg + ' (check function name spelling and argument types)';
1986
+ if (msg.includes('undeclared identifier'))
1987
+ return msg + ' (variable not declared — check spelling)';
1988
+ if (msg.includes('syntax error'))
1989
+ return msg + ' (check for missing semicolons, brackets, or commas)';
1990
+ if (msg.includes('is not a function'))
1991
+ return msg + ' (identifier exists but is not callable)';
1992
+ if (msg.includes('wrong operand types'))
1993
+ return msg + ' (type mismatch check vec/float/int types)';
1994
+ return msg;
645
1995
  }
646
1996
 
647
1997
  /**
648
- * Extract code context from common.glsl file.
649
- * Similar to extractCodeContext but uses the original common source.
1998
+ * Build code context HTML around an error line (±3 lines) from source.
650
1999
  */
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');
2000
+ private buildCodeContext(source: string, errorLine: number): string | null {
2001
+ const lines = source.split('\n');
2002
+ if (errorLine < 1 || errorLine > lines.length) return null;
656
2003
 
657
- // Extract context (3 lines before and after)
658
2004
  const contextRange = 3;
659
2005
  const startLine = Math.max(0, errorLine - contextRange - 1);
660
2006
  const endLine = Math.min(lines.length, errorLine + contextRange);
661
2007
 
662
2008
  const contextLines = lines.slice(startLine, endLine);
663
2009
 
664
- // Build HTML with line numbers and highlighting
665
- const html = contextLines.map((line, idx) => {
2010
+ return contextLines.map((line, idx) => {
666
2011
  const lineNum = startLine + idx + 1;
667
- const isErrorLine = lineNum === errorLine;
2012
+ const isError = lineNum === errorLine;
668
2013
  const lineNumPadded = String(lineNum).padStart(4, ' ');
669
2014
  const escapedLine = this.escapeHTML(line);
670
2015
 
671
- if (isErrorLine) {
2016
+ if (isError) {
672
2017
  return `<span class="error-line-highlight">${lineNumPadded} │ ${escapedLine}</span>`;
673
2018
  } else {
674
2019
  return `<span class="context-line">${lineNumPadded} │ ${escapedLine}</span>`;
675
2020
  }
676
- }).join(''); // No newline - spans already have display:block
677
-
678
- return html;
2021
+ }).join('');
679
2022
  }
680
2023
 
681
2024
  /**