@stevejtrettel/shader-sandbox 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/README.md +259 -235
  2. package/bin/cli.js +106 -14
  3. package/dist-lib/app/App.d.ts +143 -15
  4. package/dist-lib/app/App.d.ts.map +1 -1
  5. package/dist-lib/app/App.js +1343 -108
  6. package/dist-lib/app/app.css +349 -24
  7. package/dist-lib/app/types.d.ts +48 -5
  8. package/dist-lib/app/types.d.ts.map +1 -1
  9. package/dist-lib/editor/EditorPanel.d.ts +2 -2
  10. package/dist-lib/editor/EditorPanel.d.ts.map +1 -1
  11. package/dist-lib/editor/EditorPanel.js +1 -1
  12. package/dist-lib/editor/editor-panel.css +55 -32
  13. package/dist-lib/editor/prism-editor.css +16 -16
  14. package/dist-lib/embed.js +1 -1
  15. package/dist-lib/engine/{ShadertoyEngine.d.ts → ShaderEngine.d.ts} +134 -10
  16. package/dist-lib/engine/ShaderEngine.d.ts.map +1 -0
  17. package/dist-lib/engine/ShaderEngine.js +1523 -0
  18. package/dist-lib/engine/glHelpers.d.ts +24 -0
  19. package/dist-lib/engine/glHelpers.d.ts.map +1 -1
  20. package/dist-lib/engine/glHelpers.js +88 -0
  21. package/dist-lib/engine/std140.d.ts +47 -0
  22. package/dist-lib/engine/std140.d.ts.map +1 -0
  23. package/dist-lib/engine/std140.js +119 -0
  24. package/dist-lib/engine/types.d.ts +55 -5
  25. package/dist-lib/engine/types.d.ts.map +1 -1
  26. package/dist-lib/engine/types.js +1 -1
  27. package/dist-lib/index.d.ts +4 -3
  28. package/dist-lib/index.d.ts.map +1 -1
  29. package/dist-lib/index.js +2 -1
  30. package/dist-lib/layouts/SplitLayout.d.ts +2 -1
  31. package/dist-lib/layouts/SplitLayout.d.ts.map +1 -1
  32. package/dist-lib/layouts/SplitLayout.js +3 -0
  33. package/dist-lib/layouts/TabbedLayout.d.ts.map +1 -1
  34. package/dist-lib/layouts/UILayout.d.ts +55 -0
  35. package/dist-lib/layouts/UILayout.d.ts.map +1 -0
  36. package/dist-lib/layouts/UILayout.js +147 -0
  37. package/dist-lib/layouts/default.css +2 -2
  38. package/dist-lib/layouts/index.d.ts +11 -1
  39. package/dist-lib/layouts/index.d.ts.map +1 -1
  40. package/dist-lib/layouts/index.js +17 -1
  41. package/dist-lib/layouts/split.css +33 -31
  42. package/dist-lib/layouts/tabbed.css +127 -74
  43. package/dist-lib/layouts/types.d.ts +14 -3
  44. package/dist-lib/layouts/types.d.ts.map +1 -1
  45. package/dist-lib/main.js +33 -0
  46. package/dist-lib/project/configHelpers.d.ts +45 -0
  47. package/dist-lib/project/configHelpers.d.ts.map +1 -0
  48. package/dist-lib/project/configHelpers.js +196 -0
  49. package/dist-lib/project/generatedLoader.d.ts +2 -2
  50. package/dist-lib/project/generatedLoader.d.ts.map +1 -1
  51. package/dist-lib/project/generatedLoader.js +23 -5
  52. package/dist-lib/project/loadProject.d.ts +6 -6
  53. package/dist-lib/project/loadProject.d.ts.map +1 -1
  54. package/dist-lib/project/loadProject.js +396 -144
  55. package/dist-lib/project/loaderHelper.d.ts +4 -4
  56. package/dist-lib/project/loaderHelper.d.ts.map +1 -1
  57. package/dist-lib/project/loaderHelper.js +278 -116
  58. package/dist-lib/project/types.d.ts +292 -13
  59. package/dist-lib/project/types.d.ts.map +1 -1
  60. package/dist-lib/project/types.js +13 -1
  61. package/dist-lib/styles/base.css +5 -1
  62. package/dist-lib/uniforms/UniformControls.d.ts +60 -0
  63. package/dist-lib/uniforms/UniformControls.d.ts.map +1 -0
  64. package/dist-lib/uniforms/UniformControls.js +518 -0
  65. package/dist-lib/uniforms/UniformStore.d.ts +74 -0
  66. package/dist-lib/uniforms/UniformStore.d.ts.map +1 -0
  67. package/dist-lib/uniforms/UniformStore.js +145 -0
  68. package/dist-lib/uniforms/UniformsPanel.d.ts +53 -0
  69. package/dist-lib/uniforms/UniformsPanel.d.ts.map +1 -0
  70. package/dist-lib/uniforms/UniformsPanel.js +124 -0
  71. package/dist-lib/uniforms/index.d.ts +11 -0
  72. package/dist-lib/uniforms/index.d.ts.map +1 -0
  73. package/dist-lib/uniforms/index.js +8 -0
  74. package/package.json +16 -1
  75. package/src/app/App.ts +1469 -126
  76. package/src/app/app.css +349 -24
  77. package/src/app/types.ts +53 -5
  78. package/src/editor/EditorPanel.ts +5 -5
  79. package/src/editor/editor-panel.css +55 -32
  80. package/src/editor/prism-editor.css +16 -16
  81. package/src/embed.ts +1 -1
  82. package/src/engine/ShaderEngine.ts +1934 -0
  83. package/src/engine/glHelpers.ts +117 -0
  84. package/src/engine/std140.ts +136 -0
  85. package/src/engine/types.ts +69 -5
  86. package/src/index.ts +4 -3
  87. package/src/layouts/SplitLayout.ts +8 -3
  88. package/src/layouts/TabbedLayout.ts +3 -3
  89. package/src/layouts/UILayout.ts +185 -0
  90. package/src/layouts/default.css +2 -2
  91. package/src/layouts/index.ts +20 -1
  92. package/src/layouts/split.css +33 -31
  93. package/src/layouts/tabbed.css +127 -74
  94. package/src/layouts/types.ts +19 -3
  95. package/src/layouts/ui.css +289 -0
  96. package/src/main.ts +39 -1
  97. package/src/project/configHelpers.ts +225 -0
  98. package/src/project/generatedLoader.ts +27 -6
  99. package/src/project/loadProject.ts +459 -173
  100. package/src/project/loaderHelper.ts +377 -130
  101. package/src/project/types.ts +360 -14
  102. package/src/styles/base.css +5 -1
  103. package/src/styles/theme.css +292 -0
  104. package/src/uniforms/UniformControls.ts +660 -0
  105. package/src/uniforms/UniformStore.ts +166 -0
  106. package/src/uniforms/UniformsPanel.ts +163 -0
  107. package/src/uniforms/index.ts +13 -0
  108. package/src/uniforms/uniform-controls.css +342 -0
  109. package/src/uniforms/uniforms-panel.css +277 -0
  110. package/templates/shaders/example-buffer/config.json +1 -0
  111. package/dist-lib/engine/ShadertoyEngine.d.ts.map +0 -1
  112. package/dist-lib/engine/ShadertoyEngine.js +0 -704
  113. package/src/engine/ShadertoyEngine.ts +0 -929
@@ -0,0 +1,1934 @@
1
+ /**
2
+ * Engine - Shadertoy Execution Engine
3
+ *
4
+ * Implements the execution model described in docs/engine-spec.md.
5
+ *
6
+ * Responsibilities:
7
+ * - Own WebGL resources for passes (programs, VAOs, textures, FBOs).
8
+ * - Execute passes each frame in Shadertoy order: BufferA→BufferB→BufferC→BufferD→Image.
9
+ * - Bind Shadertoy uniforms (iResolution, iTime, iTimeDelta, iFrame, iMouse).
10
+ * - Bind iChannel0..3 according to ChannelSource.
11
+ */
12
+
13
+ import {
14
+ ShaderProject,
15
+ ChannelSource,
16
+ PassName,
17
+ UniformValue,
18
+ UniformValues,
19
+ ArrayUniformDefinition,
20
+ isArrayUniform,
21
+ } from '../project/types';
22
+
23
+ import { UniformStore } from '../uniforms/UniformStore';
24
+ import { std140ByteSize, std140FloatCount, tightFloatCount, packStd140, glslTypeName } from './std140';
25
+
26
+ import {
27
+ EngineOptions,
28
+ RuntimePass,
29
+ RuntimeTexture2D,
30
+ RuntimeKeyboardTexture,
31
+ RuntimeAudioTexture,
32
+ RuntimeVideoTexture,
33
+ RuntimeScriptTexture,
34
+ EngineStats,
35
+ PassUniformLocations,
36
+ } from './types';
37
+
38
+ import {
39
+ createProgramFromSources,
40
+ createFullscreenTriangleVAO,
41
+ createRenderTargetTexture,
42
+ createFramebufferWithColorAttachment,
43
+ createBlackTexture,
44
+ createKeyboardTexture,
45
+ updateKeyboardTexture,
46
+ createAudioTexture,
47
+ updateAudioTextureData,
48
+ createVideoPlaceholderTexture,
49
+ updateVideoTexture as glUpdateVideoTexture,
50
+ createOrUpdateScriptTexture,
51
+ } from './glHelpers';
52
+
53
+ // =============================================================================
54
+ // Vertex Shader (Shared across all passes)
55
+ // =============================================================================
56
+
57
+ const VERTEX_SHADER_SOURCE = `#version 300 es
58
+ precision highp float;
59
+
60
+ layout(location = 0) in vec2 position;
61
+
62
+ void main() {
63
+ gl_Position = vec4(position, 0.0, 1.0);
64
+ }
65
+ `;
66
+
67
+ // =============================================================================
68
+ // Fragment Shader Boilerplate (before common code)
69
+ // =============================================================================
70
+
71
+ const FRAGMENT_PREAMBLE = `#version 300 es
72
+ precision highp float;
73
+
74
+ // Shadertoy compatibility: equirectangular texture sampling
75
+ const float ST_PI = 3.14159265359;
76
+ const float ST_TWOPI = 6.28318530718;
77
+ vec2 _st_dirToEquirect(vec3 dir) {
78
+ float phi = atan(dir.z, dir.x);
79
+ float theta = asin(dir.y);
80
+ return vec2(phi / ST_TWOPI + 0.5, theta / ST_PI + 0.5);
81
+ }
82
+ `;
83
+
84
+ // =============================================================================
85
+ // Keyboard Helpers (auto-injected in standard mode when keyboard texture bound)
86
+ // =============================================================================
87
+
88
+ const KEYBOARD_HELPERS = `// --- Keyboard helpers (auto-injected) ---
89
+ // Letter keys
90
+ const int KEY_A = 65; const int KEY_B = 66; const int KEY_C = 67; const int KEY_D = 68;
91
+ const int KEY_E = 69; const int KEY_F = 70; const int KEY_G = 71; const int KEY_H = 72;
92
+ const int KEY_I = 73; const int KEY_J = 74; const int KEY_K = 75; const int KEY_L = 76;
93
+ const int KEY_M = 77; const int KEY_N = 78; const int KEY_O = 79; const int KEY_P = 80;
94
+ const int KEY_Q = 81; const int KEY_R = 82; const int KEY_S = 83; const int KEY_T = 84;
95
+ const int KEY_U = 85; const int KEY_V = 86; const int KEY_W = 87; const int KEY_X = 88;
96
+ const int KEY_Y = 89; const int KEY_Z = 90;
97
+
98
+ // Digit keys
99
+ const int KEY_0 = 48; const int KEY_1 = 49; const int KEY_2 = 50; const int KEY_3 = 51;
100
+ const int KEY_4 = 52; const int KEY_5 = 53; const int KEY_6 = 54; const int KEY_7 = 55;
101
+ const int KEY_8 = 56; const int KEY_9 = 57;
102
+
103
+ // Arrow keys
104
+ const int KEY_LEFT = 37; const int KEY_UP = 38; const int KEY_RIGHT = 39; const int KEY_DOWN = 40;
105
+
106
+ // Special keys
107
+ const int KEY_SPACE = 32;
108
+ const int KEY_ENTER = 13;
109
+ const int KEY_TAB = 9;
110
+ const int KEY_ESC = 27;
111
+ const int KEY_BACKSPACE = 8;
112
+ const int KEY_DELETE = 46;
113
+ const int KEY_SHIFT = 16;
114
+ const int KEY_CTRL = 17;
115
+ const int KEY_ALT = 18;
116
+
117
+ // Function keys
118
+ const int KEY_F1 = 112; const int KEY_F2 = 113; const int KEY_F3 = 114; const int KEY_F4 = 115;
119
+ const int KEY_F5 = 116; const int KEY_F6 = 117; const int KEY_F7 = 118; const int KEY_F8 = 119;
120
+ const int KEY_F9 = 120; const int KEY_F10 = 121; const int KEY_F11 = 122; const int KEY_F12 = 123;
121
+
122
+ // Returns 1.0 if key is held down, 0.0 otherwise
123
+ float keyDown(int key) {
124
+ return textureLod(keyboard, vec2((float(key) + 0.5) / 256.0, 0.25), 0.0).x;
125
+ }
126
+
127
+ // Returns 1.0/0.0, toggling each time the key is pressed
128
+ float keyToggle(int key) {
129
+ return textureLod(keyboard, vec2((float(key) + 0.5) / 256.0, 0.75), 0.0).x;
130
+ }
131
+
132
+ // Boolean convenience helpers
133
+ bool isKeyDown(int key) { return keyDown(key) > 0.5; }
134
+ bool isKeyToggled(int key) { return keyToggle(key) > 0.5; }
135
+ `;
136
+
137
+ // =============================================================================
138
+ // ShaderEngine Implementation
139
+ // =============================================================================
140
+
141
+ /** Line mapping for error reporting — maps compiled shader lines back to user source. */
142
+ export interface LineMapping {
143
+ /** 1-indexed line where common.glsl starts in compiled source (0 if no common). */
144
+ commonStartLine: number;
145
+ /** Number of lines in common.glsl. */
146
+ commonLines: number;
147
+ /** 1-indexed line where user shader code starts in compiled source. */
148
+ userCodeStartLine: number;
149
+ }
150
+
151
+ /** Runtime state for a single UBO-backed array uniform */
152
+ interface UBOEntry {
153
+ name: string;
154
+ def: ArrayUniformDefinition;
155
+ buffer: WebGLBuffer;
156
+ bindingPoint: number;
157
+ byteSize: number;
158
+ dirty: boolean;
159
+ /** Pre-allocated std140-padded buffer, reused across frames */
160
+ paddedData: Float32Array;
161
+ /** Number of elements currently active (may be less than def.count) */
162
+ activeCount: number;
163
+ }
164
+
165
+ export class ShaderEngine {
166
+ readonly project: ShaderProject;
167
+ readonly gl: WebGL2RenderingContext;
168
+
169
+ private _width: number;
170
+ private _height: number;
171
+
172
+ private _frame: number = 0;
173
+ private _time: number = 0;
174
+ private _lastStepTime: number | null = null;
175
+
176
+ private _passes: RuntimePass[] = [];
177
+ private _textures: RuntimeTexture2D[] = [];
178
+ private _keyboardTexture: RuntimeKeyboardTexture | null = null;
179
+
180
+ private _blackTexture: WebGLTexture | null = null;
181
+
182
+ // Keyboard state tracking (Maps keycodes to state)
183
+ private _keyStates: Map<number, boolean> = new Map(); // true = down, false = up
184
+ private _toggleStates: Map<number, number> = new Map(); // 0.0 or 1.0
185
+
186
+ // Compilation errors (if any occurred during initialization)
187
+ private _compilationErrors: Array<{
188
+ passName: PassName;
189
+ error: string;
190
+ source: string;
191
+ isFromCommon: boolean;
192
+ originalLine: number | null;
193
+ lineMapping: LineMapping;
194
+ }> = [];
195
+
196
+ // Custom uniform state manager (initialized in initCustomUniforms called by constructor)
197
+ private _uniforms!: UniformStore;
198
+
199
+ // UBO-backed array uniforms
200
+ private _ubos: UBOEntry[] = [];
201
+
202
+ // Dirty tracking for scalar uniforms (only re-bind when changed)
203
+ private _dirtyScalars: Set<string> = new Set();
204
+
205
+ // Audio texture (microphone FFT + waveform)
206
+ private _audioTexture: RuntimeAudioTexture | null = null;
207
+ private _needsAudio: boolean = false;
208
+
209
+ // Video/webcam textures
210
+ private _videoTextures: RuntimeVideoTexture[] = [];
211
+
212
+ // Script-uploaded textures
213
+ private _scriptTextures: Map<string, RuntimeScriptTexture> = new Map();
214
+
215
+ constructor(opts: EngineOptions) {
216
+ this.gl = opts.gl;
217
+ this.project = opts.project;
218
+
219
+ // Initialize width/height from current drawing buffer
220
+ this._width = this.gl.drawingBufferWidth;
221
+ this._height = this.gl.drawingBufferHeight;
222
+
223
+ // 1. Initialize extensions
224
+ this.initExtensions();
225
+
226
+ // 2. Create black texture for unused channels
227
+ this._blackTexture = createBlackTexture(this.gl);
228
+
229
+ // 3. Create keyboard texture (256x3, Shadertoy format)
230
+ const keyboardTex = createKeyboardTexture(this.gl);
231
+ this._keyboardTexture = {
232
+ texture: keyboardTex,
233
+ width: 256,
234
+ height: 3,
235
+ };
236
+
237
+ // 4. Initialize external textures (from project.textures)
238
+ // NOTE: This requires actual image data; for now just stub the array.
239
+ // Real implementation would load images here.
240
+ this.initProjectTextures();
241
+
242
+ // 5. Initialize audio/video textures if any channel needs them
243
+ this.initMediaTextures();
244
+
245
+ // 6. Initialize custom uniform values and UBOs (must happen before shader compilation)
246
+ this.initCustomUniforms();
247
+
248
+ // 6. Compile shaders + create runtime passes
249
+ this.initRuntimePasses();
250
+ }
251
+
252
+ /**
253
+ * Initialize custom uniform store and UBOs from project config.
254
+ */
255
+ private initCustomUniforms(): void {
256
+ this._uniforms = new UniformStore(this.project.uniforms);
257
+ this.initUBOs();
258
+
259
+ // Mark all scalar uniforms dirty so they bind on the first frame
260
+ for (const [name, def] of Object.entries(this.project.uniforms)) {
261
+ if (!isArrayUniform(def)) {
262
+ this._dirtyScalars.add(name);
263
+ }
264
+ }
265
+ }
266
+
267
+ /**
268
+ * Create WebGL UBO buffers for all array uniforms.
269
+ */
270
+ private initUBOs(): void {
271
+ const gl = this.gl;
272
+ const maxSize = gl.getParameter(gl.MAX_UNIFORM_BLOCK_SIZE) as number;
273
+ const maxBindings = gl.getParameter(gl.MAX_UNIFORM_BUFFER_BINDINGS) as number;
274
+ let bindingPoint = 0;
275
+
276
+ for (const [name, def] of Object.entries(this.project.uniforms)) {
277
+ if (!isArrayUniform(def)) continue;
278
+
279
+ const byteSize = std140ByteSize(def.type, def.count);
280
+ if (byteSize > maxSize) {
281
+ throw new Error(
282
+ `Array uniform '${name}' requires ${byteSize} bytes but GL MAX_UNIFORM_BLOCK_SIZE is ${maxSize}`
283
+ );
284
+ }
285
+
286
+ const buffer = gl.createBuffer();
287
+ if (!buffer) throw new Error(`Failed to create UBO buffer for '${name}'`);
288
+
289
+ // Allocate GPU buffer
290
+ gl.bindBuffer(gl.UNIFORM_BUFFER, buffer);
291
+ gl.bufferData(gl.UNIFORM_BUFFER, byteSize, gl.DYNAMIC_DRAW);
292
+ gl.bindBuffer(gl.UNIFORM_BUFFER, null);
293
+
294
+ // Check binding point limit
295
+ if (bindingPoint >= maxBindings) {
296
+ throw new Error(
297
+ `Too many array uniforms: binding point ${bindingPoint} exceeds GL MAX_UNIFORM_BUFFER_BINDINGS (${maxBindings})`
298
+ );
299
+ }
300
+
301
+ // Bind to a binding point
302
+ gl.bindBufferBase(gl.UNIFORM_BUFFER, bindingPoint, buffer);
303
+
304
+ // Pre-allocate the padded data buffer
305
+ const paddedData = new Float32Array(byteSize / 4);
306
+
307
+ this._ubos.push({
308
+ name,
309
+ def,
310
+ buffer,
311
+ bindingPoint,
312
+ byteSize,
313
+ dirty: false,
314
+ paddedData,
315
+ activeCount: 0,
316
+ });
317
+
318
+ bindingPoint++;
319
+ }
320
+ }
321
+
322
+ /**
323
+ * Scan all passes for audio/video/webcam channels and create placeholder textures.
324
+ */
325
+ private initMediaTextures(): void {
326
+ const allChannels = this.getAllChannelSources();
327
+
328
+ // Audio: create texture if any channel uses audio
329
+ if (allChannels.some(ch => ch.kind === 'audio')) {
330
+ this._needsAudio = true;
331
+ this._audioTexture = {
332
+ texture: createAudioTexture(this.gl),
333
+ audioContext: null,
334
+ analyser: null,
335
+ stream: null,
336
+ frequencyData: new Uint8Array(512),
337
+ waveformData: new Uint8Array(512),
338
+ width: 512,
339
+ height: 2,
340
+ initialized: false,
341
+ };
342
+ }
343
+
344
+ // Video/Webcam: create placeholder textures
345
+ for (const ch of allChannels) {
346
+ if (ch.kind === 'webcam') {
347
+ const existing = this._videoTextures.find(v => v.kind === 'webcam');
348
+ if (!existing) {
349
+ this._videoTextures.push({
350
+ texture: createVideoPlaceholderTexture(this.gl),
351
+ video: null,
352
+ stream: null,
353
+ width: 1,
354
+ height: 1,
355
+ ready: false,
356
+ kind: 'webcam',
357
+ });
358
+ }
359
+ } else if (ch.kind === 'video') {
360
+ const existing = this._videoTextures.find(v => v.kind === 'video' && v.src === ch.src);
361
+ if (!existing) {
362
+ this._videoTextures.push({
363
+ texture: createVideoPlaceholderTexture(this.gl),
364
+ video: null,
365
+ stream: null,
366
+ width: 1,
367
+ height: 1,
368
+ ready: false,
369
+ kind: 'video',
370
+ src: ch.src,
371
+ });
372
+ }
373
+ }
374
+ }
375
+ }
376
+
377
+ /**
378
+ * Collect all channel sources from all passes.
379
+ */
380
+ private getAllChannelSources(): ChannelSource[] {
381
+ const sources: ChannelSource[] = [];
382
+ const passes = this.project.passes;
383
+ for (const pass of [passes.Image, passes.BufferA, passes.BufferB, passes.BufferC, passes.BufferD]) {
384
+ if (pass) {
385
+ sources.push(...pass.channels);
386
+ }
387
+ }
388
+ return sources;
389
+ }
390
+
391
+ /**
392
+ * Initialize audio input (microphone). Must be called from a user gesture.
393
+ */
394
+ async initAudio(): Promise<void> {
395
+ if (!this._audioTexture || this._audioTexture.initialized) return;
396
+
397
+ try {
398
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
399
+ const audioContext = new AudioContext();
400
+ const source = audioContext.createMediaStreamSource(stream);
401
+ const analyser = audioContext.createAnalyser();
402
+ analyser.fftSize = 1024; // 512 frequency bins
403
+ analyser.smoothingTimeConstant = 0.8;
404
+ source.connect(analyser);
405
+
406
+ this._audioTexture.audioContext = audioContext;
407
+ this._audioTexture.analyser = analyser;
408
+ this._audioTexture.stream = stream;
409
+ this._audioTexture.initialized = true;
410
+ } catch (e) {
411
+ console.warn('Failed to initialize audio input:', e);
412
+ }
413
+ }
414
+
415
+ /**
416
+ * Update audio texture with latest FFT/waveform data. Call per-frame.
417
+ */
418
+ updateAudioTexture(): void {
419
+ if (!this._audioTexture?.analyser) return;
420
+
421
+ this._audioTexture.analyser.getByteFrequencyData(this._audioTexture.frequencyData);
422
+ this._audioTexture.analyser.getByteTimeDomainData(this._audioTexture.waveformData);
423
+ updateAudioTextureData(
424
+ this.gl,
425
+ this._audioTexture.texture,
426
+ this._audioTexture.frequencyData,
427
+ this._audioTexture.waveformData,
428
+ );
429
+ }
430
+
431
+ /**
432
+ * Initialize webcam. Must be called from a user gesture.
433
+ */
434
+ async initWebcam(): Promise<void> {
435
+ const entry = this._videoTextures.find(v => v.kind === 'webcam' && !v.ready);
436
+ if (!entry) return;
437
+
438
+ try {
439
+ const stream = await navigator.mediaDevices.getUserMedia({ video: true });
440
+ const video = document.createElement('video');
441
+ video.srcObject = stream;
442
+ video.muted = true;
443
+ video.playsInline = true;
444
+ await video.play();
445
+
446
+ entry.video = video;
447
+ entry.stream = stream;
448
+ entry.width = video.videoWidth;
449
+ entry.height = video.videoHeight;
450
+
451
+ // Update dimensions when metadata loads (may not be available immediately)
452
+ video.addEventListener('loadedmetadata', () => {
453
+ entry.width = video.videoWidth;
454
+ entry.height = video.videoHeight;
455
+ });
456
+
457
+ entry.ready = true;
458
+ } catch (e) {
459
+ console.warn('Failed to initialize webcam:', e);
460
+ }
461
+ }
462
+
463
+ /**
464
+ * Initialize video file playback.
465
+ */
466
+ async initVideo(src: string): Promise<void> {
467
+ const entry = this._videoTextures.find(v => v.kind === 'video' && v.src === src && !v.ready);
468
+ if (!entry) return;
469
+
470
+ const video = document.createElement('video');
471
+ video.src = src;
472
+ video.muted = true;
473
+ video.loop = true;
474
+ video.playsInline = true;
475
+ video.crossOrigin = 'anonymous';
476
+
477
+ video.addEventListener('loadedmetadata', () => {
478
+ entry.width = video.videoWidth;
479
+ entry.height = video.videoHeight;
480
+ });
481
+
482
+ try {
483
+ await video.play();
484
+ entry.video = video;
485
+ entry.ready = true;
486
+ } catch (e) {
487
+ console.warn(`Failed to play video '${src}':`, e);
488
+ }
489
+ }
490
+
491
+ /**
492
+ * Update all video/webcam textures with latest frame. Call per-frame.
493
+ */
494
+ updateVideoTextures(): void {
495
+ for (const entry of this._videoTextures) {
496
+ if (!entry.ready || !entry.video) continue;
497
+ if (entry.video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) continue;
498
+
499
+ glUpdateVideoTexture(this.gl, entry.texture, entry.video);
500
+ // Update dimensions in case they weren't available at init
501
+ if (entry.video.videoWidth > 0) {
502
+ entry.width = entry.video.videoWidth;
503
+ entry.height = entry.video.videoHeight;
504
+ }
505
+ }
506
+ }
507
+
508
+ /**
509
+ * Upload or update a named texture from JavaScript (for script channel).
510
+ */
511
+ updateTexture(name: string, width: number, height: number, data: Uint8Array | Float32Array): void {
512
+ const existing = this._scriptTextures.get(name);
513
+ const isFloat = data instanceof Float32Array;
514
+
515
+ if (existing && existing.width === width && existing.height === height && existing.isFloat === isFloat) {
516
+ // Same size and format — just update data
517
+ const gl = this.gl;
518
+ gl.bindTexture(gl.TEXTURE_2D, existing.texture);
519
+ if (isFloat) {
520
+ gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, width, height, gl.RGBA, gl.FLOAT, data);
521
+ } else {
522
+ gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, data);
523
+ }
524
+ gl.bindTexture(gl.TEXTURE_2D, null);
525
+ } else {
526
+ // Create or recreate
527
+ const texture = createOrUpdateScriptTexture(
528
+ this.gl,
529
+ existing?.texture ?? null,
530
+ width,
531
+ height,
532
+ data,
533
+ );
534
+ this._scriptTextures.set(name, { texture, width, height, isFloat });
535
+ }
536
+ }
537
+
538
+ /**
539
+ * Read pixels from a buffer pass (reads previous frame's data).
540
+ */
541
+ readPixels(passName: PassName, x: number, y: number, w: number, h: number): Uint8Array {
542
+ const pass = this._passes.find(p => p.name === passName);
543
+ if (!pass) {
544
+ console.warn(`readPixels: pass '${passName}' not found`);
545
+ return new Uint8Array(w * h * 4);
546
+ }
547
+
548
+ const gl = this.gl;
549
+ gl.bindFramebuffer(gl.FRAMEBUFFER, pass.framebuffer);
550
+ // Attach previousTexture (has last completed frame)
551
+ gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, pass.previousTexture, 0);
552
+
553
+ const pixels = new Uint8Array(w * h * 4);
554
+ gl.readPixels(x, y, w, h, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
555
+
556
+ // Restore currentTexture
557
+ gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, pass.currentTexture, 0);
558
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
559
+
560
+ return pixels;
561
+ }
562
+
563
+ /** Whether this project uses audio channels. */
564
+ get needsAudio(): boolean {
565
+ return this._needsAudio;
566
+ }
567
+
568
+ /** Whether this project uses webcam channels. */
569
+ get needsWebcam(): boolean {
570
+ return this._videoTextures.some(v => v.kind === 'webcam');
571
+ }
572
+
573
+ /** Get video sources that need initialization. */
574
+ get videoSources(): string[] {
575
+ return this._videoTextures
576
+ .filter(v => v.kind === 'video' && !v.ready && v.src)
577
+ .map(v => v.src!);
578
+ }
579
+
580
+ // ===========================================================================
581
+ // Public API
582
+ // ===========================================================================
583
+
584
+ get width(): number {
585
+ return this._width;
586
+ }
587
+
588
+ get height(): number {
589
+ return this._height;
590
+ }
591
+
592
+ get stats(): EngineStats {
593
+ const dt = this._lastStepTime === null ? 0 : this._time - this._lastStepTime;
594
+ return {
595
+ frame: this._frame,
596
+ time: this._time,
597
+ deltaTime: dt,
598
+ width: this._width,
599
+ height: this._height,
600
+ };
601
+ }
602
+
603
+ /**
604
+ * Get shader compilation errors (if any occurred during initialization).
605
+ * Returns empty array if all shaders compiled successfully.
606
+ */
607
+ getCompilationErrors(): Array<{
608
+ passName: PassName;
609
+ error: string;
610
+ source: string;
611
+ isFromCommon: boolean;
612
+ originalLine: number | null;
613
+ lineMapping: LineMapping;
614
+ }> {
615
+ return this._compilationErrors;
616
+ }
617
+
618
+ /**
619
+ * Check if there were any compilation errors.
620
+ */
621
+ hasErrors(): boolean {
622
+ return this._compilationErrors.length > 0;
623
+ }
624
+
625
+ /**
626
+ * Get the uniform store for direct access to uniform state.
627
+ */
628
+ getUniformStore(): UniformStore {
629
+ return this._uniforms;
630
+ }
631
+
632
+ /**
633
+ * Get the current value of a custom uniform.
634
+ */
635
+ getUniformValue(name: string): UniformValue | undefined {
636
+ return this._uniforms.get(name);
637
+ }
638
+
639
+ /**
640
+ * Get all custom uniform values.
641
+ */
642
+ getUniformValues(): UniformValues {
643
+ return this._uniforms.getAll();
644
+ }
645
+
646
+ /**
647
+ * Set the value of a custom uniform.
648
+ * For scalar uniforms, the value will be applied on the next render frame.
649
+ * For array uniforms (UBOs), the data is packed to std140 and uploaded on next bind.
650
+ */
651
+ setUniformValue(name: string, value: UniformValue): void {
652
+ const def = this.project.uniforms[name];
653
+ if (!def) {
654
+ console.warn(`setUniformValue('${name}'): uniform not defined in config`);
655
+ return;
656
+ }
657
+
658
+ // Validate scalar uniform types
659
+ if (!isArrayUniform(def)) {
660
+ const t = def.type;
661
+ if ((t === 'float' || t === 'int') && typeof value !== 'number') {
662
+ console.warn(`setUniformValue('${name}'): expected number for ${t}, got ${typeof value}`);
663
+ return;
664
+ }
665
+ if (t === 'bool' && typeof value !== 'boolean') {
666
+ console.warn(`setUniformValue('${name}'): expected boolean, got ${typeof value}`);
667
+ return;
668
+ }
669
+ if (t === 'vec2' || t === 'vec3' || t === 'vec4') {
670
+ if (!Array.isArray(value)) {
671
+ console.warn(`setUniformValue('${name}'): expected array for ${t}, got ${typeof value}`);
672
+ return;
673
+ }
674
+ const expected = t === 'vec2' ? 2 : t === 'vec3' ? 3 : 4;
675
+ if (value.length !== expected) {
676
+ console.warn(`setUniformValue('${name}'): expected array of length ${expected} for ${t}, got ${value.length}`);
677
+ return;
678
+ }
679
+ }
680
+ }
681
+
682
+ // Store validated value
683
+ this._uniforms.set(name, value);
684
+
685
+ // If this is an array uniform, pack and mark dirty
686
+ if (isArrayUniform(def)) {
687
+ const ubo = this._ubos.find(u => u.name === name);
688
+ if (ubo) {
689
+ const data = value as Float32Array;
690
+ const maxLength = tightFloatCount(def.type, def.count);
691
+ const componentsPerElement = tightFloatCount(def.type, 1);
692
+
693
+ if (data.length > maxLength) {
694
+ console.warn(
695
+ `setUniformValue('${name}'): Float32Array length ${data.length} exceeds max ` +
696
+ `${maxLength} (${def.count} × ${def.type})`
697
+ );
698
+ return;
699
+ }
700
+ if (data.length % componentsPerElement !== 0) {
701
+ console.warn(
702
+ `setUniformValue('${name}'): Float32Array length ${data.length} is not a multiple ` +
703
+ `of ${componentsPerElement} (components per ${def.type})`
704
+ );
705
+ return;
706
+ }
707
+
708
+ const actualCount = data.length / componentsPerElement;
709
+
710
+ // Pack actual elements into std140 layout
711
+ const packed = packStd140(def.type, actualCount, data, ubo.paddedData);
712
+ // Fast-path types (vec4, mat4) return input directly; copy into paddedData
713
+ if (packed !== ubo.paddedData) {
714
+ ubo.paddedData.set(packed);
715
+ }
716
+
717
+ // Zero-fill the remainder of the buffer beyond the active elements
718
+ const activeStd140Floats = std140FloatCount(def.type, actualCount);
719
+ const totalStd140Floats = ubo.paddedData.length;
720
+ if (activeStd140Floats < totalStd140Floats) {
721
+ ubo.paddedData.fill(0, activeStd140Floats);
722
+ }
723
+
724
+ ubo.activeCount = actualCount;
725
+ ubo.dirty = true;
726
+ }
727
+ } else {
728
+ // Mark scalar uniform as dirty
729
+ this._dirtyScalars.add(name);
730
+ }
731
+ }
732
+
733
+ /**
734
+ * Set multiple custom uniform values at once.
735
+ */
736
+ setUniformValues(values: Partial<UniformValues>): void {
737
+ for (const [name, value] of Object.entries(values)) {
738
+ if (value !== undefined) {
739
+ this.setUniformValue(name, value);
740
+ }
741
+ }
742
+ }
743
+
744
+ /**
745
+ * Get the framebuffer for the Image pass (for presenting to screen).
746
+ */
747
+ getImageFramebuffer(): WebGLFramebuffer | null {
748
+ const imagePass = this._passes.find((p) => p.name === 'Image');
749
+ return imagePass?.framebuffer ?? null;
750
+ }
751
+
752
+ /**
753
+ * Bind the Image pass output as the READ_FRAMEBUFFER for blitting to screen.
754
+ *
755
+ * After the ping-pong swap, the rendered output is in previousTexture,
756
+ * but the framebuffer is attached to currentTexture. This method temporarily
757
+ * attaches previousTexture so blitFramebuffer reads the correct data.
758
+ */
759
+ bindImageForRead(): boolean {
760
+ const gl = this.gl;
761
+ const imagePass = this._passes.find((p) => p.name === 'Image');
762
+ if (!imagePass) return false;
763
+
764
+ gl.bindFramebuffer(gl.READ_FRAMEBUFFER, imagePass.framebuffer);
765
+ gl.framebufferTexture2D(
766
+ gl.READ_FRAMEBUFFER,
767
+ gl.COLOR_ATTACHMENT0,
768
+ gl.TEXTURE_2D,
769
+ imagePass.previousTexture,
770
+ 0
771
+ );
772
+ return true;
773
+ }
774
+
775
+ /**
776
+ * Restore the Image pass framebuffer to its normal state (attached to currentTexture).
777
+ * Call after blitting to screen.
778
+ */
779
+ unbindImageForRead(): void {
780
+ const gl = this.gl;
781
+ const imagePass = this._passes.find((p) => p.name === 'Image');
782
+ if (!imagePass) return;
783
+
784
+ // Restore FBO attachment to currentTexture for next frame's render
785
+ gl.framebufferTexture2D(
786
+ gl.READ_FRAMEBUFFER,
787
+ gl.COLOR_ATTACHMENT0,
788
+ gl.TEXTURE_2D,
789
+ imagePass.currentTexture,
790
+ 0
791
+ );
792
+ gl.bindFramebuffer(gl.READ_FRAMEBUFFER, null);
793
+ }
794
+
795
+ /**
796
+ * Run one full frame of all passes.
797
+ *
798
+ * @param timeSeconds - global time in seconds (monotone, from App)
799
+ * @param mouse - iMouse as [x, y, clickX, clickY]
800
+ * @param touch - optional touch state for touch uniforms
801
+ */
802
+ step(timeSeconds: number, mouse: [number, number, number, number], mousePressed: boolean, touch?: {
803
+ count: number;
804
+ touches: [[number, number, number, number], [number, number, number, number], [number, number, number, number]];
805
+ pinch: number;
806
+ pinchDelta: number;
807
+ pinchCenter: [number, number];
808
+ }): void {
809
+ const gl = this.gl;
810
+
811
+ // Compute time/deltaTime/iFrame
812
+ const deltaTime =
813
+ this._lastStepTime === null ? 0.0 : timeSeconds - this._lastStepTime;
814
+ this._lastStepTime = timeSeconds;
815
+ this._time = timeSeconds;
816
+
817
+ const iResolution = [this._width, this._height, 1.0] as const;
818
+ const iTime = this._time;
819
+ const iTimeDelta = deltaTime;
820
+ const iFrame = this._frame;
821
+ const iMouse = mouse;
822
+ const iMousePressed = mousePressed;
823
+
824
+ // Compute iDate: (year, month, day, seconds since midnight)
825
+ const now = new Date();
826
+ const iDate = [
827
+ now.getFullYear(),
828
+ now.getMonth(), // 0-11 (matches Shadertoy)
829
+ now.getDate(), // 1-31
830
+ now.getHours() * 3600 + now.getMinutes() * 60 + now.getSeconds() + now.getMilliseconds() / 1000
831
+ ] as const;
832
+
833
+ // Compute iFrameRate (smoothed via deltaTime)
834
+ const iFrameRate = deltaTime > 0 ? 1.0 / deltaTime : 60.0;
835
+
836
+ // Default touch state if not provided
837
+ const touchState = touch ?? {
838
+ count: 0,
839
+ touches: [[0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]] as [[number, number, number, number], [number, number, number, number], [number, number, number, number]],
840
+ pinch: 1.0,
841
+ pinchDelta: 0.0,
842
+ pinchCenter: [0, 0] as [number, number],
843
+ };
844
+
845
+ // Set viewport for all passes
846
+ gl.viewport(0, 0, this._width, this._height);
847
+
848
+ // Execute passes in Shadertoy order
849
+ const passOrder: PassName[] = ['BufferA', 'BufferB', 'BufferC', 'BufferD', 'Image'];
850
+
851
+ for (const passName of passOrder) {
852
+ const runtimePass = this._passes.find((p) => p.name === passName);
853
+ if (!runtimePass) continue;
854
+
855
+ this.executePass(runtimePass, {
856
+ iResolution,
857
+ iTime,
858
+ iTimeDelta,
859
+ iFrame,
860
+ iMouse,
861
+ iMousePressed,
862
+ iDate,
863
+ iFrameRate,
864
+ iTouchCount: touchState.count,
865
+ iTouch: touchState.touches,
866
+ iPinch: touchState.pinch,
867
+ iPinchDelta: touchState.pinchDelta,
868
+ iPinchCenter: touchState.pinchCenter,
869
+ });
870
+
871
+ // Swap ping-pong textures after pass execution
872
+ this.swapPassTextures(runtimePass);
873
+ }
874
+
875
+ // Clear scalar dirty flags after all passes have been bound
876
+ this._dirtyScalars.clear();
877
+
878
+ // Monotone frame counter (increment AFTER all passes)
879
+ this._frame += 1;
880
+ }
881
+
882
+ /**
883
+ * Resize all internal render targets to new width/height.
884
+ * Does not reset time or frame count.
885
+ */
886
+ resize(width: number, height: number): void {
887
+ this._width = width;
888
+ this._height = height;
889
+
890
+ const gl = this.gl;
891
+
892
+ // Reallocate ALL pass textures to new resolution
893
+ for (const pass of this._passes) {
894
+ // Delete old textures and framebuffer
895
+ gl.deleteTexture(pass.currentTexture);
896
+ gl.deleteTexture(pass.previousTexture);
897
+ gl.deleteFramebuffer(pass.framebuffer);
898
+
899
+ // Create new textures at new resolution
900
+ pass.currentTexture = createRenderTargetTexture(gl, width, height);
901
+ pass.previousTexture = createRenderTargetTexture(gl, width, height);
902
+
903
+ // Create new framebuffer (attached to current texture)
904
+ pass.framebuffer = createFramebufferWithColorAttachment(gl, pass.currentTexture);
905
+ }
906
+ }
907
+
908
+ /**
909
+ * Reset frame counter and clear all render targets.
910
+ * Used for playback controls to restart shader from frame 0.
911
+ */
912
+ reset(): void {
913
+ this._frame = 0;
914
+
915
+ // Clear all pass textures (both current and previous for ping-pong)
916
+ // This is critical for accumulation shaders that read from previous frame
917
+ const gl = this.gl;
918
+ for (const pass of this._passes) {
919
+ // Clear current texture (already attached to framebuffer)
920
+ gl.bindFramebuffer(gl.FRAMEBUFFER, pass.framebuffer);
921
+ gl.clearColor(0, 0, 0, 0);
922
+ gl.clear(gl.COLOR_BUFFER_BIT);
923
+
924
+ // Also clear previous texture (temporarily attach it)
925
+ gl.framebufferTexture2D(
926
+ gl.FRAMEBUFFER,
927
+ gl.COLOR_ATTACHMENT0,
928
+ gl.TEXTURE_2D,
929
+ pass.previousTexture,
930
+ 0
931
+ );
932
+ gl.clear(gl.COLOR_BUFFER_BIT);
933
+
934
+ // Re-attach current texture
935
+ gl.framebufferTexture2D(
936
+ gl.FRAMEBUFFER,
937
+ gl.COLOR_ATTACHMENT0,
938
+ gl.TEXTURE_2D,
939
+ pass.currentTexture,
940
+ 0
941
+ );
942
+ }
943
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
944
+ }
945
+
946
+ /**
947
+ * Update keyboard key state (called from App on keydown/keyup events).
948
+ *
949
+ * @param keycode ASCII keycode (e.g., 65 for 'A')
950
+ * @param isDown true if key pressed, false if released
951
+ */
952
+ updateKeyState(keycode: number, isDown: boolean): void {
953
+ const wasDown = this._keyStates.get(keycode) || false;
954
+
955
+ // Update current state
956
+ this._keyStates.set(keycode, isDown);
957
+
958
+ // Toggle on press (down transition)
959
+ if (isDown && !wasDown) {
960
+ const currentToggle = this._toggleStates.get(keycode) || 0.0;
961
+ this._toggleStates.set(keycode, currentToggle === 0.0 ? 1.0 : 0.0);
962
+ }
963
+ }
964
+
965
+ /**
966
+ * Update keyboard texture with current key states.
967
+ * Should be called once per frame before rendering.
968
+ */
969
+ updateKeyboardTexture(): void {
970
+ if (!this._keyboardTexture) {
971
+ return; // No keyboard texture to update
972
+ }
973
+
974
+ updateKeyboardTexture(
975
+ this.gl,
976
+ this._keyboardTexture.texture,
977
+ this._keyStates,
978
+ this._toggleStates
979
+ );
980
+ }
981
+
982
+ /**
983
+ * Recompile a single pass with new GLSL source code.
984
+ * Used for live editing - keeps the old shader running if compilation fails.
985
+ *
986
+ * @param passName - Name of the pass to recompile ('Image', 'BufferA', etc.)
987
+ * @param newSource - New GLSL source code for the pass
988
+ * @returns Object with success status and error message if failed
989
+ */
990
+ recompilePass(passName: PassName, newSource: string): { success: boolean; error?: string } {
991
+ const gl = this.gl;
992
+
993
+ // Find the runtime pass
994
+ const runtimePass = this._passes.find((p) => p.name === passName);
995
+ if (!runtimePass) {
996
+ return { success: false, error: `Pass '${passName}' not found` };
997
+ }
998
+
999
+ // Update the project's pass source (so buildFragmentShader uses it)
1000
+ const projectPass = this.project.passes[passName];
1001
+ if (!projectPass) {
1002
+ return { success: false, error: `Project pass '${passName}' not found` };
1003
+ }
1004
+
1005
+ // Build new fragment shader
1006
+ const { source: fragmentSource } = this.buildFragmentShader(newSource, projectPass.channels, projectPass.namedSamplers);
1007
+
1008
+ try {
1009
+ // Try to compile new program
1010
+ const newProgram = createProgramFromSources(gl, VERTEX_SHADER_SOURCE, fragmentSource);
1011
+
1012
+ // Success! Delete old program and update runtime pass
1013
+ gl.deleteProgram(runtimePass.uniforms.program);
1014
+
1015
+ // Cache new uniform locations
1016
+ runtimePass.uniforms = this.cacheUniformLocations(newProgram, projectPass.namedSamplers);
1017
+
1018
+ // Update the stored source in the project
1019
+ projectPass.glslSource = newSource;
1020
+
1021
+ // Clear any previous compilation errors for this pass
1022
+ this._compilationErrors = this._compilationErrors.filter(e => e.passName !== passName);
1023
+
1024
+ // Mark all scalar uniforms dirty so they bind to the new program
1025
+ for (const [uName, uDef] of Object.entries(this.project.uniforms)) {
1026
+ if (!isArrayUniform(uDef)) {
1027
+ this._dirtyScalars.add(uName);
1028
+ }
1029
+ }
1030
+
1031
+ return { success: true };
1032
+ } catch (err) {
1033
+ // Compilation failed - keep old shader running
1034
+ const errorMessage = err instanceof Error ? err.message : String(err);
1035
+ return { success: false, error: errorMessage };
1036
+ }
1037
+ }
1038
+
1039
+ /**
1040
+ * Recompile common.glsl and all passes that use it.
1041
+ * Used for live editing of common code.
1042
+ *
1043
+ * @param newCommonSource - New GLSL source code for common.glsl
1044
+ * @returns Object with success status and errors for each failed pass
1045
+ */
1046
+ recompileCommon(newCommonSource: string): { success: boolean; errors: Array<{ passName: PassName; error: string }> } {
1047
+ const oldCommonSource = this.project.commonSource;
1048
+
1049
+ // Temporarily update common source
1050
+ this.project.commonSource = newCommonSource;
1051
+
1052
+ const errors: Array<{ passName: PassName; error: string }> = [];
1053
+ const passOrder: PassName[] = ['BufferA', 'BufferB', 'BufferC', 'BufferD', 'Image'];
1054
+
1055
+ // Try to recompile all passes
1056
+ for (const passName of passOrder) {
1057
+ const projectPass = this.project.passes[passName];
1058
+ if (!projectPass) continue;
1059
+
1060
+ const result = this.recompilePass(passName, projectPass.glslSource);
1061
+ if (!result.success) {
1062
+ errors.push({ passName, error: result.error || 'Unknown error' });
1063
+ }
1064
+ }
1065
+
1066
+ // If any failed, restore old common source and recompile successful ones back
1067
+ if (errors.length > 0) {
1068
+ this.project.commonSource = oldCommonSource;
1069
+
1070
+ // Recompile passes back to working state
1071
+ for (const passName of passOrder) {
1072
+ const projectPass = this.project.passes[passName];
1073
+ if (!projectPass) continue;
1074
+
1075
+ // Skip passes that failed (they still have old shader)
1076
+ if (errors.some(e => e.passName === passName)) continue;
1077
+
1078
+ // Recompile with old common source
1079
+ this.recompilePass(passName, projectPass.glslSource);
1080
+ }
1081
+
1082
+ return { success: false, errors };
1083
+ }
1084
+
1085
+ return { success: true, errors: [] };
1086
+ }
1087
+
1088
+ /**
1089
+ * Delete all GL resources.
1090
+ */
1091
+ dispose(): void {
1092
+ const gl = this.gl;
1093
+
1094
+ // Delete passes (programs, VAOs, FBOs, textures)
1095
+ for (const pass of this._passes) {
1096
+ gl.deleteProgram(pass.uniforms.program);
1097
+ gl.deleteVertexArray(pass.vao);
1098
+ gl.deleteFramebuffer(pass.framebuffer);
1099
+ gl.deleteTexture(pass.currentTexture);
1100
+ gl.deleteTexture(pass.previousTexture);
1101
+ }
1102
+
1103
+ // Delete external textures
1104
+ for (const tex of this._textures) {
1105
+ gl.deleteTexture(tex.texture);
1106
+ }
1107
+
1108
+ // Delete keyboard texture
1109
+ if (this._keyboardTexture) {
1110
+ gl.deleteTexture(this._keyboardTexture.texture);
1111
+ }
1112
+
1113
+ // Delete black texture
1114
+ if (this._blackTexture) {
1115
+ gl.deleteTexture(this._blackTexture);
1116
+ }
1117
+
1118
+ // Delete UBO buffers
1119
+ for (const ubo of this._ubos) {
1120
+ gl.deleteBuffer(ubo.buffer);
1121
+ }
1122
+
1123
+ // Clear arrays
1124
+ this._passes = [];
1125
+ this._textures = [];
1126
+ this._ubos = [];
1127
+ this._keyboardTexture = null;
1128
+ this._blackTexture = null;
1129
+ }
1130
+
1131
+ // ===========================================================================
1132
+ // Initialization Helpers
1133
+ // ===========================================================================
1134
+
1135
+ private initExtensions(): void {
1136
+ const gl = this.gl;
1137
+
1138
+ // MUST enable EXT_color_buffer_float for RGBA32F render targets
1139
+ const ext = gl.getExtension('EXT_color_buffer_float');
1140
+ if (!ext) {
1141
+ throw new Error(
1142
+ 'EXT_color_buffer_float not supported. WebGL2 with float rendering is required.'
1143
+ );
1144
+ }
1145
+
1146
+ // Optionally check for OES_texture_float_linear (for smooth filtering of float textures)
1147
+ // Not strictly required for Shadertoy, but nice to have
1148
+ gl.getExtension('OES_texture_float_linear');
1149
+ }
1150
+
1151
+ /**
1152
+ * Cache uniform locations for a compiled program.
1153
+ * Returns a PassUniformLocations object with all standard and custom uniform locations.
1154
+ */
1155
+ private cacheUniformLocations(program: WebGLProgram, namedSamplers?: Map<string, ChannelSource>): PassUniformLocations {
1156
+ const gl = this.gl;
1157
+
1158
+ // Cache custom uniform locations (skip array uniforms — they use UBOs)
1159
+ const customLocations = new Map<string, WebGLUniformLocation | null>();
1160
+ for (const [name, def] of Object.entries(this.project.uniforms)) {
1161
+ if (isArrayUniform(def)) continue;
1162
+ customLocations.set(name, gl.getUniformLocation(program, name));
1163
+ }
1164
+
1165
+ // Bind UBO block indices and cache _count uniform locations for this program
1166
+ for (const ubo of this._ubos) {
1167
+ const blockIndex = gl.getUniformBlockIndex(program, `_ub_${ubo.name}`);
1168
+ if (blockIndex !== gl.INVALID_INDEX) {
1169
+ gl.uniformBlockBinding(program, blockIndex, ubo.bindingPoint);
1170
+ }
1171
+ customLocations.set(`${ubo.name}_count`, gl.getUniformLocation(program, `${ubo.name}_count`));
1172
+ }
1173
+
1174
+ return {
1175
+ program,
1176
+ iResolution: gl.getUniformLocation(program, 'iResolution'),
1177
+ iTime: gl.getUniformLocation(program, 'iTime'),
1178
+ iTimeDelta: gl.getUniformLocation(program, 'iTimeDelta'),
1179
+ iFrame: gl.getUniformLocation(program, 'iFrame'),
1180
+ iMouse: gl.getUniformLocation(program, 'iMouse'),
1181
+ iMousePressed: gl.getUniformLocation(program, 'iMousePressed'),
1182
+ iDate: gl.getUniformLocation(program, 'iDate'),
1183
+ iFrameRate: gl.getUniformLocation(program, 'iFrameRate'),
1184
+ iChannel: [
1185
+ gl.getUniformLocation(program, 'iChannel0'),
1186
+ gl.getUniformLocation(program, 'iChannel1'),
1187
+ gl.getUniformLocation(program, 'iChannel2'),
1188
+ gl.getUniformLocation(program, 'iChannel3'),
1189
+ ],
1190
+ iChannelResolution: [
1191
+ gl.getUniformLocation(program, 'iChannelResolution[0]'),
1192
+ gl.getUniformLocation(program, 'iChannelResolution[1]'),
1193
+ gl.getUniformLocation(program, 'iChannelResolution[2]'),
1194
+ gl.getUniformLocation(program, 'iChannelResolution[3]'),
1195
+ ],
1196
+ // Touch uniforms
1197
+ iTouchCount: gl.getUniformLocation(program, 'iTouchCount'),
1198
+ iTouch: [
1199
+ gl.getUniformLocation(program, 'iTouch0'),
1200
+ gl.getUniformLocation(program, 'iTouch1'),
1201
+ gl.getUniformLocation(program, 'iTouch2'),
1202
+ ],
1203
+ iPinch: gl.getUniformLocation(program, 'iPinch'),
1204
+ iPinchDelta: gl.getUniformLocation(program, 'iPinchDelta'),
1205
+ iPinchCenter: gl.getUniformLocation(program, 'iPinchCenter'),
1206
+ custom: customLocations,
1207
+ namedSamplers: (() => {
1208
+ const m = new Map<string, WebGLUniformLocation | null>();
1209
+ if (namedSamplers) {
1210
+ for (const [name] of namedSamplers) {
1211
+ m.set(name, gl.getUniformLocation(program, name));
1212
+ }
1213
+ }
1214
+ return m;
1215
+ })(),
1216
+ namedSamplerResolutions: (() => {
1217
+ const m = new Map<string, WebGLUniformLocation | null>();
1218
+ if (namedSamplers) {
1219
+ for (const [name] of namedSamplers) {
1220
+ m.set(name, gl.getUniformLocation(program, `${name}_resolution`));
1221
+ }
1222
+ }
1223
+ return m;
1224
+ })(),
1225
+ };
1226
+ }
1227
+
1228
+ /**
1229
+ * Initialize external textures based on project.textures.
1230
+ *
1231
+ * NOTE: This function as written assumes that actual image loading
1232
+ * is handled elsewhere. For now we just construct an empty array.
1233
+ * In a real implementation, you would load images here.
1234
+ */
1235
+ private initProjectTextures(): void {
1236
+ const gl = this.gl;
1237
+ this._textures = [];
1238
+
1239
+ // Load each texture from the project
1240
+ for (const texDef of this.project.textures) {
1241
+ // Create a placeholder 1x1 texture immediately
1242
+ const texture = gl.createTexture();
1243
+ if (!texture) {
1244
+ throw new Error('Failed to create texture');
1245
+ }
1246
+
1247
+ // Bind and set initial 1x1 black pixel
1248
+ gl.bindTexture(gl.TEXTURE_2D, texture);
1249
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array([0, 0, 0, 255]));
1250
+
1251
+ // Store in runtime array
1252
+ const runtimeTex: RuntimeTexture2D = {
1253
+ name: texDef.name,
1254
+ texture,
1255
+ width: 1,
1256
+ height: 1,
1257
+ };
1258
+ this._textures.push(runtimeTex);
1259
+
1260
+ // Load the actual image asynchronously
1261
+ const image = new Image();
1262
+ image.crossOrigin = 'anonymous';
1263
+ image.onload = () => {
1264
+ gl.bindTexture(gl.TEXTURE_2D, texture);
1265
+ gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
1266
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
1267
+ gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
1268
+
1269
+ // Set filter
1270
+ const filter = texDef.filter === 'nearest' ? gl.NEAREST : gl.LINEAR;
1271
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, filter);
1272
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, filter);
1273
+
1274
+ // Set wrap mode
1275
+ const wrap = texDef.wrap === 'clamp' ? gl.CLAMP_TO_EDGE : gl.REPEAT;
1276
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, wrap);
1277
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, wrap);
1278
+
1279
+ // Generate mipmaps if using linear filtering
1280
+ if (texDef.filter === 'linear') {
1281
+ gl.generateMipmap(gl.TEXTURE_2D);
1282
+ }
1283
+
1284
+ // Update dimensions
1285
+ runtimeTex.width = image.width;
1286
+ runtimeTex.height = image.height;
1287
+
1288
+ console.log(`Loaded texture '${texDef.name}': ${image.width}x${image.height}`);
1289
+ };
1290
+ image.onerror = () => {
1291
+ console.error(`Failed to load texture '${texDef.name}' from ${texDef.source}`);
1292
+ };
1293
+ image.src = texDef.source;
1294
+ }
1295
+ }
1296
+
1297
+ /**
1298
+ * Compile shaders, create VAOs/FBOs/textures, and build RuntimePass array.
1299
+ */
1300
+ private initRuntimePasses(): void {
1301
+ const gl = this.gl;
1302
+ const project = this.project;
1303
+
1304
+ // Shared VAO (all passes use the same fullscreen triangle)
1305
+ const sharedVAO = createFullscreenTriangleVAO(gl);
1306
+
1307
+ // Build passes in Shadertoy order
1308
+ const passOrder: PassName[] = ['BufferA', 'BufferB', 'BufferC', 'BufferD', 'Image'];
1309
+
1310
+ for (const passName of passOrder) {
1311
+ const projectPass = project.passes[passName];
1312
+ if (!projectPass) continue;
1313
+
1314
+ // Build fragment shader source (outside try so we can access in catch)
1315
+ const { source: fragmentSource, lineMapping } = this.buildFragmentShader(projectPass.glslSource, projectPass.channels, projectPass.namedSamplers);
1316
+
1317
+ try {
1318
+ // Compile program
1319
+ const program = createProgramFromSources(gl, VERTEX_SHADER_SOURCE, fragmentSource);
1320
+
1321
+ // Cache uniform locations
1322
+ const uniforms = this.cacheUniformLocations(program, projectPass.namedSamplers);
1323
+
1324
+ // Create ping-pong textures (MUST allocate both for all passes)
1325
+ const currentTexture = createRenderTargetTexture(gl, this._width, this._height);
1326
+ const previousTexture = createRenderTargetTexture(gl, this._width, this._height);
1327
+
1328
+ // Create framebuffer (attached to current texture)
1329
+ const framebuffer = createFramebufferWithColorAttachment(gl, currentTexture);
1330
+
1331
+ // Build RuntimePass
1332
+ const runtimePass: RuntimePass = {
1333
+ name: passName,
1334
+ projectChannels: projectPass.channels,
1335
+ vao: sharedVAO,
1336
+ uniforms,
1337
+ framebuffer,
1338
+ currentTexture,
1339
+ previousTexture,
1340
+ namedSamplers: projectPass.namedSamplers,
1341
+ };
1342
+
1343
+ this._passes.push(runtimePass);
1344
+ } catch (err) {
1345
+ // Store compilation error with source code for context display
1346
+ const errorMessage = err instanceof Error ? err.message : String(err);
1347
+
1348
+ // Detect if error is from common.glsl or user code
1349
+ const errorLineMatch = errorMessage.match(/ERROR:\s*\d+:(\d+):/);
1350
+ let isFromCommon = false;
1351
+ let originalLine: number | null = null;
1352
+
1353
+ if (errorLineMatch) {
1354
+ const errorLine = parseInt(errorLineMatch[1], 10);
1355
+
1356
+ if (lineMapping.commonStartLine > 0 && lineMapping.commonLines > 0) {
1357
+ const commonEndLine = lineMapping.commonStartLine + lineMapping.commonLines - 1;
1358
+ if (errorLine >= lineMapping.commonStartLine && errorLine <= commonEndLine) {
1359
+ isFromCommon = true;
1360
+ originalLine = errorLine - lineMapping.commonStartLine + 1;
1361
+ }
1362
+ }
1363
+
1364
+ if (!isFromCommon && lineMapping.userCodeStartLine > 0 && errorLine >= lineMapping.userCodeStartLine) {
1365
+ originalLine = errorLine - lineMapping.userCodeStartLine + 1;
1366
+ }
1367
+ }
1368
+
1369
+ this._compilationErrors.push({
1370
+ passName,
1371
+ error: errorMessage,
1372
+ source: fragmentSource,
1373
+ isFromCommon,
1374
+ originalLine,
1375
+ lineMapping,
1376
+ });
1377
+ console.error(`Failed to compile ${passName}:`, errorMessage);
1378
+ }
1379
+ }
1380
+ }
1381
+
1382
+
1383
+ /**
1384
+ * Build complete fragment shader source with Shadertoy boilerplate.
1385
+ *
1386
+ * @param userSource - The user's GLSL source code
1387
+ * @param channels - Channel configuration for this pass (to detect cubemap textures)
1388
+ */
1389
+ private buildFragmentShader(userSource: string, channels: ChannelSource[], namedSamplers?: Map<string, ChannelSource>): { source: string; lineMapping: LineMapping } {
1390
+ const parts: string[] = [FRAGMENT_PREAMBLE];
1391
+
1392
+ // Common code (if any)
1393
+ if (this.project.commonSource) {
1394
+ parts.push('// Common code');
1395
+ parts.push(this.project.commonSource);
1396
+ parts.push('');
1397
+ }
1398
+
1399
+ if (namedSamplers && namedSamplers.size > 0) {
1400
+ // Standard mode: named samplers + core time/mouse uniforms (no iChannel)
1401
+ parts.push(`// Core uniforms
1402
+ uniform vec3 iResolution;
1403
+ uniform float iTime;
1404
+ uniform float iTimeDelta;
1405
+ uniform int iFrame;
1406
+ uniform vec4 iMouse;
1407
+ uniform bool iMousePressed;
1408
+ uniform vec4 iDate;
1409
+ uniform float iFrameRate;
1410
+
1411
+ // Shader Sandbox touch extensions
1412
+ uniform int iTouchCount;
1413
+ uniform vec4 iTouch0;
1414
+ uniform vec4 iTouch1;
1415
+ uniform vec4 iTouch2;
1416
+ uniform float iPinch;
1417
+ uniform float iPinchDelta;
1418
+ uniform vec2 iPinchCenter;
1419
+ `);
1420
+
1421
+ // Named sampler declarations
1422
+ parts.push('// Named samplers');
1423
+ for (const [name] of namedSamplers) {
1424
+ parts.push(`uniform sampler2D ${name};`);
1425
+ parts.push(`uniform vec3 ${name}_resolution;`);
1426
+ }
1427
+ parts.push('');
1428
+
1429
+ // Auto-inject keyboard constants and helpers when keyboard texture is bound
1430
+ if (namedSamplers.has('keyboard')) {
1431
+ parts.push(KEYBOARD_HELPERS);
1432
+ parts.push('');
1433
+ }
1434
+ } else {
1435
+ // Shadertoy mode: iChannel0-3
1436
+ parts.push(`// Shadertoy built-in uniforms
1437
+ uniform vec3 iResolution;
1438
+ uniform float iTime;
1439
+ uniform float iTimeDelta;
1440
+ uniform int iFrame;
1441
+ uniform vec4 iMouse;
1442
+ uniform bool iMousePressed;
1443
+ uniform vec4 iDate;
1444
+ uniform float iFrameRate;
1445
+ uniform vec3 iChannelResolution[4];
1446
+ uniform sampler2D iChannel0;
1447
+ uniform sampler2D iChannel1;
1448
+ uniform sampler2D iChannel2;
1449
+ uniform sampler2D iChannel3;
1450
+
1451
+ // Shader Sandbox touch extensions (not in Shadertoy)
1452
+ uniform int iTouchCount; // Number of active touches (0-10)
1453
+ uniform vec4 iTouch0; // Primary touch: (x, y, startX, startY)
1454
+ uniform vec4 iTouch1; // Second touch
1455
+ uniform vec4 iTouch2; // Third touch
1456
+ uniform float iPinch; // Pinch scale factor (1.0 = no pinch)
1457
+ uniform float iPinchDelta; // Pinch change since last frame
1458
+ uniform vec2 iPinchCenter; // Center point of pinch gesture
1459
+ `);
1460
+ }
1461
+
1462
+ // Array uniform blocks (UBOs) - auto-injected so user doesn't need to declare them.
1463
+ // Each block is named with a `_ub_` prefix (e.g., `_ub_matrices`) to avoid
1464
+ // collisions with user code. The array inside uses the original uniform name,
1465
+ // so shader code references it directly (e.g., `matrices[i]`).
1466
+ for (const ubo of this._ubos) {
1467
+ parts.push(`// Array uniform: ${ubo.name} (max ${ubo.def.count})`);
1468
+ parts.push(`layout(std140) uniform _ub_${ubo.name} {`);
1469
+ parts.push(` ${glslTypeName(ubo.def.type)} ${ubo.name}[${ubo.def.count}];`);
1470
+ parts.push(`};`);
1471
+ parts.push(`uniform int ${ubo.name}_count;`);
1472
+ parts.push('');
1473
+ }
1474
+
1475
+ // Scalar custom uniforms - auto-injected so user doesn't need to declare them
1476
+ const scalarUniforms = Object.entries(this.project.uniforms)
1477
+ .filter(([, def]) => !isArrayUniform(def));
1478
+ if (scalarUniforms.length > 0) {
1479
+ parts.push('// Custom uniforms');
1480
+ for (const [name, def] of scalarUniforms) {
1481
+ const glslType = def.type === 'bool' ? 'bool' : def.type;
1482
+ parts.push(`uniform ${glslType} ${name};`);
1483
+ }
1484
+ parts.push('');
1485
+ }
1486
+
1487
+ // Preprocess user shader code to handle cubemap-style texture sampling
1488
+ const processedSource = this.preprocessCubemapTextures(userSource, channels);
1489
+
1490
+ // User shader code
1491
+ parts.push('// User shader code');
1492
+ parts.push(processedSource);
1493
+ parts.push('');
1494
+
1495
+ // mainImage() wrapper
1496
+ parts.push(`// Main wrapper
1497
+ out vec4 fragColor;
1498
+
1499
+ void main() {
1500
+ mainImage(fragColor, gl_FragCoord.xy);
1501
+ }`);
1502
+
1503
+ const source = parts.join('\n');
1504
+
1505
+ // Compute line mapping by finding marker comments
1506
+ const sourceLines = source.split('\n');
1507
+ let commonStartLine = 0;
1508
+ let commonLines = 0;
1509
+ let userCodeStartLine = 0;
1510
+
1511
+ for (let i = 0; i < sourceLines.length; i++) {
1512
+ if (sourceLines[i] === '// Common code') {
1513
+ commonStartLine = i + 2; // 1-indexed, skip the comment itself
1514
+ commonLines = this.project.commonSource ? this.project.commonSource.split('\n').length : 0;
1515
+ }
1516
+ if (sourceLines[i] === '// User shader code') {
1517
+ userCodeStartLine = i + 2; // 1-indexed, skip the comment itself
1518
+ }
1519
+ }
1520
+
1521
+ return {
1522
+ source,
1523
+ lineMapping: { commonStartLine, commonLines, userCodeStartLine },
1524
+ };
1525
+ }
1526
+
1527
+ /**
1528
+ * Preprocess shader to convert cubemap-style texture() calls to equirectangular.
1529
+ *
1530
+ * Uses the channel configuration to determine which channels are cubemaps.
1531
+ * Only channels explicitly marked as `type: 'cubemap'` in config.json will have
1532
+ * their texture() calls wrapped with _st_dirToEquirect().
1533
+ *
1534
+ * @param source - User's GLSL source code
1535
+ * @param channels - Channel configuration for this pass
1536
+ */
1537
+ private preprocessCubemapTextures(source: string, channels: ChannelSource[]): string {
1538
+ // Build set of channel names that are cubemaps
1539
+ const cubemapChannels = new Set<string>();
1540
+ channels.forEach((ch, i) => {
1541
+ if (ch.kind === 'texture' && ch.cubemap) {
1542
+ cubemapChannels.add(`iChannel${i}`);
1543
+ }
1544
+ });
1545
+
1546
+ // If no cubemap channels, return source unchanged
1547
+ if (cubemapChannels.size === 0) {
1548
+ return source;
1549
+ }
1550
+
1551
+ // Match: texture(iChannelN, ...)
1552
+ const textureCallRegex = /texture\s*\(\s*(iChannel[0-3])\s*,\s*([^)]+)\)/g;
1553
+
1554
+ return source.replace(textureCallRegex, (match, channel, coord) => {
1555
+ // Only wrap if this channel is explicitly marked as cubemap
1556
+ if (cubemapChannels.has(channel)) {
1557
+ return `texture(${channel}, _st_dirToEquirect(${coord}))`;
1558
+ } else {
1559
+ return match;
1560
+ }
1561
+ });
1562
+ }
1563
+
1564
+ // ===========================================================================
1565
+ // Pass Execution
1566
+ // ===========================================================================
1567
+
1568
+ private executePass(
1569
+ runtimePass: RuntimePass,
1570
+ builtinUniforms: {
1571
+ iResolution: readonly [number, number, number];
1572
+ iTime: number;
1573
+ iTimeDelta: number;
1574
+ iFrame: number;
1575
+ iMouse: [number, number, number, number];
1576
+ iMousePressed: boolean;
1577
+ iDate: readonly [number, number, number, number];
1578
+ iFrameRate: number;
1579
+ iTouchCount: number;
1580
+ iTouch: [[number, number, number, number], [number, number, number, number], [number, number, number, number]];
1581
+ iPinch: number;
1582
+ iPinchDelta: number;
1583
+ iPinchCenter: [number, number];
1584
+ }
1585
+ ): void {
1586
+ const gl = this.gl;
1587
+
1588
+ // Bind framebuffer (write to current texture)
1589
+ gl.bindFramebuffer(gl.FRAMEBUFFER, runtimePass.framebuffer);
1590
+
1591
+ // Use program
1592
+ gl.useProgram(runtimePass.uniforms.program);
1593
+
1594
+ // Bind VAO
1595
+ gl.bindVertexArray(runtimePass.vao);
1596
+
1597
+ // Bind built-in uniforms
1598
+ this.bindBuiltinUniforms(runtimePass.uniforms, builtinUniforms);
1599
+
1600
+ // Bind custom uniforms
1601
+ this.bindCustomUniforms(runtimePass.uniforms);
1602
+
1603
+ // Bind textures: named samplers (standard mode) or iChannels (shadertoy mode)
1604
+ if (runtimePass.namedSamplers && runtimePass.namedSamplers.size > 0) {
1605
+ this.bindNamedSamplers(runtimePass);
1606
+ } else {
1607
+ this.bindChannelTextures(runtimePass);
1608
+ }
1609
+
1610
+ // Draw fullscreen triangle
1611
+ gl.drawArrays(gl.TRIANGLES, 0, 3);
1612
+
1613
+ // Unbind
1614
+ gl.bindVertexArray(null);
1615
+ gl.useProgram(null);
1616
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
1617
+ }
1618
+
1619
+ private bindBuiltinUniforms(
1620
+ uniforms: PassUniformLocations,
1621
+ values: {
1622
+ iResolution: readonly [number, number, number];
1623
+ iTime: number;
1624
+ iTimeDelta: number;
1625
+ iFrame: number;
1626
+ iMouse: [number, number, number, number];
1627
+ iMousePressed: boolean;
1628
+ iDate: readonly [number, number, number, number];
1629
+ iFrameRate: number;
1630
+ iTouchCount: number;
1631
+ iTouch: [[number, number, number, number], [number, number, number, number], [number, number, number, number]];
1632
+ iPinch: number;
1633
+ iPinchDelta: number;
1634
+ iPinchCenter: [number, number];
1635
+ }
1636
+ ): void {
1637
+ const gl = this.gl;
1638
+
1639
+ if (uniforms.iResolution) {
1640
+ gl.uniform3f(uniforms.iResolution, values.iResolution[0], values.iResolution[1], values.iResolution[2]);
1641
+ }
1642
+
1643
+ if (uniforms.iTime) {
1644
+ gl.uniform1f(uniforms.iTime, values.iTime);
1645
+ }
1646
+
1647
+ if (uniforms.iTimeDelta) {
1648
+ gl.uniform1f(uniforms.iTimeDelta, values.iTimeDelta);
1649
+ }
1650
+
1651
+ if (uniforms.iFrame) {
1652
+ gl.uniform1i(uniforms.iFrame, values.iFrame);
1653
+ }
1654
+
1655
+ if (uniforms.iMouse) {
1656
+ gl.uniform4f(uniforms.iMouse, values.iMouse[0], values.iMouse[1], values.iMouse[2], values.iMouse[3]);
1657
+ }
1658
+
1659
+ if (uniforms.iMousePressed) {
1660
+ gl.uniform1i(uniforms.iMousePressed, values.iMousePressed ? 1 : 0);
1661
+ }
1662
+
1663
+ if (uniforms.iDate) {
1664
+ gl.uniform4f(uniforms.iDate, values.iDate[0], values.iDate[1], values.iDate[2], values.iDate[3]);
1665
+ }
1666
+
1667
+ if (uniforms.iFrameRate) {
1668
+ gl.uniform1f(uniforms.iFrameRate, values.iFrameRate);
1669
+ }
1670
+
1671
+ // Touch uniforms
1672
+ if (uniforms.iTouchCount) {
1673
+ gl.uniform1i(uniforms.iTouchCount, values.iTouchCount);
1674
+ }
1675
+
1676
+ // Bind individual touch points (iTouch0, iTouch1, iTouch2)
1677
+ for (let i = 0; i < 3; i++) {
1678
+ const loc = uniforms.iTouch[i];
1679
+ if (loc) {
1680
+ const t = values.iTouch[i];
1681
+ gl.uniform4f(loc, t[0], t[1], t[2], t[3]);
1682
+ }
1683
+ }
1684
+
1685
+ if (uniforms.iPinch) {
1686
+ gl.uniform1f(uniforms.iPinch, values.iPinch);
1687
+ }
1688
+
1689
+ if (uniforms.iPinchDelta) {
1690
+ gl.uniform1f(uniforms.iPinchDelta, values.iPinchDelta);
1691
+ }
1692
+
1693
+ if (uniforms.iPinchCenter) {
1694
+ gl.uniform2f(uniforms.iPinchCenter, values.iPinchCenter[0], values.iPinchCenter[1]);
1695
+ }
1696
+ }
1697
+
1698
+ /**
1699
+ * Bind custom uniform values to the current program.
1700
+ */
1701
+ private bindCustomUniforms(uniforms: PassUniformLocations): void {
1702
+ const gl = this.gl;
1703
+
1704
+ // Upload dirty UBOs; always bind _count (regular uniforms reset per-program use)
1705
+ for (const ubo of this._ubos) {
1706
+ if (ubo.dirty) {
1707
+ gl.bindBuffer(gl.UNIFORM_BUFFER, ubo.buffer);
1708
+ gl.bufferSubData(gl.UNIFORM_BUFFER, 0, ubo.paddedData);
1709
+ ubo.dirty = false;
1710
+ }
1711
+ const loc = uniforms.custom.get(`${ubo.name}_count`);
1712
+ if (loc) {
1713
+ gl.uniform1i(loc, ubo.activeCount);
1714
+ }
1715
+ }
1716
+
1717
+ // Only re-bind scalar uniforms that have changed since last frame
1718
+ for (const name of this._dirtyScalars) {
1719
+ const def = this.project.uniforms[name];
1720
+ if (!def || isArrayUniform(def)) continue;
1721
+
1722
+ const value = this._uniforms.get(name);
1723
+ if (value === undefined) continue;
1724
+
1725
+ const location = uniforms.custom.get(name);
1726
+ if (!location) continue;
1727
+
1728
+ switch (def.type) {
1729
+ case 'float':
1730
+ gl.uniform1f(location, value as number);
1731
+ break;
1732
+ case 'int':
1733
+ gl.uniform1i(location, value as number);
1734
+ break;
1735
+ case 'bool':
1736
+ gl.uniform1i(location, (value as boolean) ? 1 : 0);
1737
+ break;
1738
+ case 'vec2': {
1739
+ const v = value as number[];
1740
+ gl.uniform2f(location, v[0], v[1]);
1741
+ break;
1742
+ }
1743
+ case 'vec3': {
1744
+ const v = value as number[];
1745
+ gl.uniform3f(location, v[0], v[1], v[2]);
1746
+ break;
1747
+ }
1748
+ case 'vec4': {
1749
+ const v = value as number[];
1750
+ gl.uniform4f(location, v[0], v[1], v[2], v[3]);
1751
+ break;
1752
+ }
1753
+ }
1754
+ }
1755
+ }
1756
+
1757
+ private bindChannelTextures(runtimePass: RuntimePass): void {
1758
+ const gl = this.gl;
1759
+
1760
+ for (let i = 0; i < 4; i++) {
1761
+ const channelSource = runtimePass.projectChannels[i];
1762
+ const texture = this.resolveChannelTexture(channelSource);
1763
+ const resolution = this.resolveChannelResolution(channelSource);
1764
+
1765
+ // Bind texture to texture unit i
1766
+ gl.activeTexture(gl.TEXTURE0 + i);
1767
+ gl.bindTexture(gl.TEXTURE_2D, texture);
1768
+
1769
+ // Set uniform to use texture unit i
1770
+ const uniformLoc = runtimePass.uniforms.iChannel[i];
1771
+ if (uniformLoc) {
1772
+ gl.uniform1i(uniformLoc, i);
1773
+ }
1774
+
1775
+ // Set iChannelResolution[i]
1776
+ const resLoc = runtimePass.uniforms.iChannelResolution[i];
1777
+ if (resLoc) {
1778
+ gl.uniform3f(resLoc, resolution[0], resolution[1], 1.0);
1779
+ }
1780
+ }
1781
+ }
1782
+
1783
+ /**
1784
+ * Bind named samplers (standard mode).
1785
+ * Each named sampler gets its own texture unit.
1786
+ */
1787
+ private bindNamedSamplers(runtimePass: RuntimePass): void {
1788
+ const gl = this.gl;
1789
+ let textureUnit = 0;
1790
+
1791
+ for (const [name, source] of runtimePass.namedSamplers!) {
1792
+ const texture = this.resolveChannelTexture(source);
1793
+ const resolution = this.resolveChannelResolution(source);
1794
+
1795
+ gl.activeTexture(gl.TEXTURE0 + textureUnit);
1796
+ gl.bindTexture(gl.TEXTURE_2D, texture);
1797
+
1798
+ const loc = runtimePass.uniforms.namedSamplers.get(name);
1799
+ if (loc) gl.uniform1i(loc, textureUnit);
1800
+
1801
+ const resLoc = runtimePass.uniforms.namedSamplerResolutions.get(name);
1802
+ if (resLoc) gl.uniform3f(resLoc, resolution[0], resolution[1], 1.0);
1803
+
1804
+ textureUnit++;
1805
+ }
1806
+ }
1807
+
1808
+ /**
1809
+ * Resolve a ChannelSource to an actual WebGLTexture to bind.
1810
+ */
1811
+ private resolveChannelTexture(source: ChannelSource): WebGLTexture {
1812
+ switch (source.kind) {
1813
+ case 'none':
1814
+ // Unused channel → bind black texture
1815
+ if (!this._blackTexture) {
1816
+ throw new Error('Black texture not initialized');
1817
+ }
1818
+ return this._blackTexture;
1819
+
1820
+ case 'buffer': {
1821
+ // Buffer reference → find RuntimePass and return current or previous texture
1822
+ const targetPass = this._passes.find((p) => p.name === source.buffer);
1823
+ if (!targetPass) {
1824
+ throw new Error(`Buffer '${source.buffer}' not found`);
1825
+ }
1826
+
1827
+ // Default to previous frame (safer, matches common use case)
1828
+ // Only use current frame if explicitly requested with current: true
1829
+ return source.current ? targetPass.currentTexture : targetPass.previousTexture;
1830
+ }
1831
+
1832
+ case 'texture': {
1833
+ // External texture → find RuntimeTexture by name
1834
+ const tex = this._textures.find((t) => t.name === source.name);
1835
+ if (!tex) {
1836
+ throw new Error(`Texture '${source.name}' not found`);
1837
+ }
1838
+ return tex.texture;
1839
+ }
1840
+
1841
+ case 'keyboard':
1842
+ if (!this._keyboardTexture) {
1843
+ throw new Error('Internal error: keyboard texture not initialized');
1844
+ }
1845
+ return this._keyboardTexture.texture;
1846
+
1847
+ case 'audio':
1848
+ if (!this._audioTexture) {
1849
+ return this._blackTexture!;
1850
+ }
1851
+ return this._audioTexture.texture;
1852
+
1853
+ case 'webcam': {
1854
+ const webcam = this._videoTextures.find(v => v.kind === 'webcam');
1855
+ return webcam?.texture ?? this._blackTexture!;
1856
+ }
1857
+
1858
+ case 'video': {
1859
+ const video = this._videoTextures.find(v => v.kind === 'video' && v.src === source.src);
1860
+ return video?.texture ?? this._blackTexture!;
1861
+ }
1862
+
1863
+ case 'script': {
1864
+ const scriptTex = this._scriptTextures.get(source.name);
1865
+ return scriptTex?.texture ?? this._blackTexture!;
1866
+ }
1867
+ }
1868
+ }
1869
+
1870
+ /**
1871
+ * Resolve a ChannelSource to its resolution [width, height].
1872
+ * Returns [0, 0] for unused channels.
1873
+ */
1874
+ private resolveChannelResolution(source: ChannelSource): [number, number] {
1875
+ switch (source.kind) {
1876
+ case 'none':
1877
+ return [0, 0];
1878
+
1879
+ case 'buffer':
1880
+ return [this._width, this._height];
1881
+
1882
+ case 'texture': {
1883
+ const tex = this._textures.find((t) => t.name === source.name);
1884
+ if (!tex) return [0, 0];
1885
+ return [tex.width, tex.height];
1886
+ }
1887
+
1888
+ case 'keyboard':
1889
+ return [256, 3];
1890
+
1891
+ case 'audio':
1892
+ return this._audioTexture ? [this._audioTexture.width, this._audioTexture.height] : [0, 0];
1893
+
1894
+ case 'webcam': {
1895
+ const webcam = this._videoTextures.find(v => v.kind === 'webcam');
1896
+ return webcam ? [webcam.width, webcam.height] : [0, 0];
1897
+ }
1898
+
1899
+ case 'video': {
1900
+ const video = this._videoTextures.find(v => v.kind === 'video' && v.src === source.src);
1901
+ return video ? [video.width, video.height] : [0, 0];
1902
+ }
1903
+
1904
+ case 'script': {
1905
+ const scriptTex = this._scriptTextures.get(source.name);
1906
+ return scriptTex ? [scriptTex.width, scriptTex.height] : [0, 0];
1907
+ }
1908
+ }
1909
+ }
1910
+
1911
+ /**
1912
+ * Swap current and previous textures for a pass (ping-pong).
1913
+ * Also reattach framebuffer to new current texture.
1914
+ */
1915
+ private swapPassTextures(pass: RuntimePass): void {
1916
+ const gl = this.gl;
1917
+
1918
+ // Swap texture references
1919
+ const temp = pass.currentTexture;
1920
+ pass.currentTexture = pass.previousTexture;
1921
+ pass.previousTexture = temp;
1922
+
1923
+ // Re-attach framebuffer to new current texture
1924
+ gl.bindFramebuffer(gl.FRAMEBUFFER, pass.framebuffer);
1925
+ gl.framebufferTexture2D(
1926
+ gl.FRAMEBUFFER,
1927
+ gl.COLOR_ATTACHMENT0,
1928
+ gl.TEXTURE_2D,
1929
+ pass.currentTexture,
1930
+ 0
1931
+ );
1932
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
1933
+ }
1934
+ }