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