@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.
- package/README.md +259 -235
- package/bin/cli.js +106 -14
- package/dist-lib/app/App.d.ts +143 -15
- package/dist-lib/app/App.d.ts.map +1 -1
- package/dist-lib/app/App.js +1343 -108
- package/dist-lib/app/app.css +349 -24
- package/dist-lib/app/types.d.ts +48 -5
- package/dist-lib/app/types.d.ts.map +1 -1
- package/dist-lib/editor/EditorPanel.d.ts +2 -2
- package/dist-lib/editor/EditorPanel.d.ts.map +1 -1
- package/dist-lib/editor/EditorPanel.js +1 -1
- package/dist-lib/editor/editor-panel.css +55 -32
- package/dist-lib/editor/prism-editor.css +16 -16
- package/dist-lib/embed.js +1 -1
- package/dist-lib/engine/{ShadertoyEngine.d.ts → ShaderEngine.d.ts} +134 -10
- package/dist-lib/engine/ShaderEngine.d.ts.map +1 -0
- package/dist-lib/engine/ShaderEngine.js +1523 -0
- package/dist-lib/engine/glHelpers.d.ts +24 -0
- package/dist-lib/engine/glHelpers.d.ts.map +1 -1
- package/dist-lib/engine/glHelpers.js +88 -0
- package/dist-lib/engine/std140.d.ts +47 -0
- package/dist-lib/engine/std140.d.ts.map +1 -0
- package/dist-lib/engine/std140.js +119 -0
- package/dist-lib/engine/types.d.ts +55 -5
- package/dist-lib/engine/types.d.ts.map +1 -1
- package/dist-lib/engine/types.js +1 -1
- package/dist-lib/index.d.ts +4 -3
- package/dist-lib/index.d.ts.map +1 -1
- package/dist-lib/index.js +2 -1
- package/dist-lib/layouts/SplitLayout.d.ts +2 -1
- package/dist-lib/layouts/SplitLayout.d.ts.map +1 -1
- package/dist-lib/layouts/SplitLayout.js +3 -0
- package/dist-lib/layouts/TabbedLayout.d.ts.map +1 -1
- package/dist-lib/layouts/UILayout.d.ts +55 -0
- package/dist-lib/layouts/UILayout.d.ts.map +1 -0
- package/dist-lib/layouts/UILayout.js +147 -0
- package/dist-lib/layouts/default.css +2 -2
- package/dist-lib/layouts/index.d.ts +11 -1
- package/dist-lib/layouts/index.d.ts.map +1 -1
- package/dist-lib/layouts/index.js +17 -1
- package/dist-lib/layouts/split.css +33 -31
- package/dist-lib/layouts/tabbed.css +127 -74
- package/dist-lib/layouts/types.d.ts +14 -3
- package/dist-lib/layouts/types.d.ts.map +1 -1
- package/dist-lib/main.js +33 -0
- package/dist-lib/project/configHelpers.d.ts +45 -0
- package/dist-lib/project/configHelpers.d.ts.map +1 -0
- package/dist-lib/project/configHelpers.js +196 -0
- package/dist-lib/project/generatedLoader.d.ts +2 -2
- package/dist-lib/project/generatedLoader.d.ts.map +1 -1
- package/dist-lib/project/generatedLoader.js +23 -5
- package/dist-lib/project/loadProject.d.ts +6 -6
- package/dist-lib/project/loadProject.d.ts.map +1 -1
- package/dist-lib/project/loadProject.js +396 -144
- package/dist-lib/project/loaderHelper.d.ts +4 -4
- package/dist-lib/project/loaderHelper.d.ts.map +1 -1
- package/dist-lib/project/loaderHelper.js +278 -116
- package/dist-lib/project/types.d.ts +292 -13
- package/dist-lib/project/types.d.ts.map +1 -1
- package/dist-lib/project/types.js +13 -1
- package/dist-lib/styles/base.css +5 -1
- package/dist-lib/uniforms/UniformControls.d.ts +60 -0
- package/dist-lib/uniforms/UniformControls.d.ts.map +1 -0
- package/dist-lib/uniforms/UniformControls.js +518 -0
- package/dist-lib/uniforms/UniformStore.d.ts +74 -0
- package/dist-lib/uniforms/UniformStore.d.ts.map +1 -0
- package/dist-lib/uniforms/UniformStore.js +145 -0
- package/dist-lib/uniforms/UniformsPanel.d.ts +53 -0
- package/dist-lib/uniforms/UniformsPanel.d.ts.map +1 -0
- package/dist-lib/uniforms/UniformsPanel.js +124 -0
- package/dist-lib/uniforms/index.d.ts +11 -0
- package/dist-lib/uniforms/index.d.ts.map +1 -0
- package/dist-lib/uniforms/index.js +8 -0
- package/package.json +16 -1
- package/src/app/App.ts +1469 -126
- package/src/app/app.css +349 -24
- package/src/app/types.ts +53 -5
- package/src/editor/EditorPanel.ts +5 -5
- package/src/editor/editor-panel.css +55 -32
- package/src/editor/prism-editor.css +16 -16
- package/src/embed.ts +1 -1
- package/src/engine/ShaderEngine.ts +1934 -0
- package/src/engine/glHelpers.ts +117 -0
- package/src/engine/std140.ts +136 -0
- package/src/engine/types.ts +69 -5
- package/src/index.ts +4 -3
- package/src/layouts/SplitLayout.ts +8 -3
- package/src/layouts/TabbedLayout.ts +3 -3
- package/src/layouts/UILayout.ts +185 -0
- package/src/layouts/default.css +2 -2
- package/src/layouts/index.ts +20 -1
- package/src/layouts/split.css +33 -31
- package/src/layouts/tabbed.css +127 -74
- package/src/layouts/types.ts +19 -3
- package/src/layouts/ui.css +289 -0
- package/src/main.ts +39 -1
- package/src/project/configHelpers.ts +225 -0
- package/src/project/generatedLoader.ts +27 -6
- package/src/project/loadProject.ts +459 -173
- package/src/project/loaderHelper.ts +377 -130
- package/src/project/types.ts +360 -14
- package/src/styles/base.css +5 -1
- package/src/styles/theme.css +292 -0
- package/src/uniforms/UniformControls.ts +660 -0
- package/src/uniforms/UniformStore.ts +166 -0
- package/src/uniforms/UniformsPanel.ts +163 -0
- package/src/uniforms/index.ts +13 -0
- package/src/uniforms/uniform-controls.css +342 -0
- package/src/uniforms/uniforms-panel.css +277 -0
- package/templates/shaders/example-buffer/config.json +1 -0
- package/dist-lib/engine/ShadertoyEngine.d.ts.map +0 -1
- package/dist-lib/engine/ShadertoyEngine.js +0 -704
- package/src/engine/ShadertoyEngine.ts +0 -929
package/dist-lib/app/App.js
CHANGED
|
@@ -3,68 +3,207 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Responsibilities:
|
|
5
5
|
* - Create and manage canvas
|
|
6
|
-
* - Initialize
|
|
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 {
|
|
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,
|
|
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
|
|
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
|
-
//
|
|
46
|
-
this.engine.
|
|
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
|
-
|
|
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
|
|
60
|
-
this.
|
|
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.
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
189
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
237
|
-
|
|
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
|
|
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
|
-
//
|
|
282
|
-
this.
|
|
283
|
-
this.
|
|
284
|
-
this.
|
|
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
|
-
|
|
295
|
-
|
|
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
|
|
303
|
-
if (keycode
|
|
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,
|
|
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
|
-
//
|
|
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 (
|
|
436
|
-
adjustedError = glslError.replace(/
|
|
437
|
-
|
|
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:
|
|
443
|
-
? this.
|
|
444
|
-
:
|
|
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
|
|
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 [, ,
|
|
488
|
-
|
|
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
|
-
*
|
|
495
|
-
* Returns HTML with the error line highlighted.
|
|
1748
|
+
* Add helpful hints to common GLSL error messages.
|
|
496
1749
|
*/
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
if (
|
|
501
|
-
return
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
529
|
-
const
|
|
530
|
-
if (
|
|
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
|
-
|
|
539
|
-
const html = contextLines.map((line, idx) => {
|
|
1774
|
+
return contextLines.map((line, idx) => {
|
|
540
1775
|
const lineNum = startLine + idx + 1;
|
|
541
|
-
const
|
|
1776
|
+
const isError = lineNum === errorLine;
|
|
542
1777
|
const lineNumPadded = String(lineNum).padStart(4, ' ');
|
|
543
1778
|
const escapedLine = this.escapeHTML(line);
|
|
544
|
-
if (
|
|
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('');
|
|
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;
|