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