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