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