@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
@@ -1,929 +0,0 @@
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
- ShadertoyProject,
15
- ChannelSource,
16
- PassName,
17
- } from '../project/types';
18
-
19
- import {
20
- EngineOptions,
21
- RuntimePass,
22
- RuntimeTexture2D,
23
- RuntimeKeyboardTexture,
24
- EngineStats,
25
- PassUniformLocations,
26
- } from './types';
27
-
28
- import {
29
- createProgramFromSources,
30
- createFullscreenTriangleVAO,
31
- createRenderTargetTexture,
32
- createFramebufferWithColorAttachment,
33
- createBlackTexture,
34
- createKeyboardTexture,
35
- updateKeyboardTexture,
36
- } from './glHelpers';
37
-
38
- // =============================================================================
39
- // Vertex Shader (Shared across all passes)
40
- // =============================================================================
41
-
42
- const VERTEX_SHADER_SOURCE = `#version 300 es
43
- precision highp float;
44
-
45
- layout(location = 0) in vec2 position;
46
-
47
- void main() {
48
- gl_Position = vec4(position, 0.0, 1.0);
49
- }
50
- `;
51
-
52
- // =============================================================================
53
- // Fragment Shader Boilerplate (before common code)
54
- // =============================================================================
55
-
56
- const FRAGMENT_PREAMBLE = `#version 300 es
57
- precision highp float;
58
-
59
- // Shadertoy compatibility: equirectangular texture sampling
60
- const float ST_PI = 3.14159265359;
61
- const float ST_TWOPI = 6.28318530718;
62
- vec2 _st_dirToEquirect(vec3 dir) {
63
- float phi = atan(dir.z, dir.x);
64
- float theta = asin(dir.y);
65
- return vec2(phi / ST_TWOPI + 0.5, theta / ST_PI + 0.5);
66
- }
67
- `;
68
-
69
- // Line count computed from actual preamble (for error line mapping)
70
- const PREAMBLE_LINE_COUNT = FRAGMENT_PREAMBLE.split('\n').length - 1; // -1 because split adds empty at end
71
-
72
- // =============================================================================
73
- // ShadertoyEngine Implementation
74
- // =============================================================================
75
-
76
- export class ShadertoyEngine {
77
- readonly project: ShadertoyProject;
78
- readonly gl: WebGL2RenderingContext;
79
-
80
- private _width: number;
81
- private _height: number;
82
-
83
- private _frame: number = 0;
84
- private _time: number = 0;
85
- private _lastStepTime: number | null = null;
86
-
87
- private _passes: RuntimePass[] = [];
88
- private _textures: RuntimeTexture2D[] = [];
89
- private _keyboardTexture: RuntimeKeyboardTexture | null = null;
90
-
91
- private _blackTexture: WebGLTexture | null = null;
92
-
93
- // Keyboard state tracking (Maps keycodes to state)
94
- private _keyStates: Map<number, boolean> = new Map(); // true = down, false = up
95
- private _toggleStates: Map<number, number> = new Map(); // 0.0 or 1.0
96
-
97
- // Compilation errors (if any occurred during initialization)
98
- private _compilationErrors: Array<{
99
- passName: PassName;
100
- error: string;
101
- source: string;
102
- isFromCommon: boolean;
103
- originalLine: number | null;
104
- }> = [];
105
-
106
- constructor(opts: EngineOptions) {
107
- this.gl = opts.gl;
108
- this.project = opts.project;
109
-
110
- // Initialize width/height from current drawing buffer
111
- this._width = this.gl.drawingBufferWidth;
112
- this._height = this.gl.drawingBufferHeight;
113
-
114
- // 1. Initialize extensions
115
- this.initExtensions();
116
-
117
- // 2. Create black texture for unused channels
118
- this._blackTexture = createBlackTexture(this.gl);
119
-
120
- // 3. Create keyboard texture (256x3, Shadertoy format)
121
- const keyboardTex = createKeyboardTexture(this.gl);
122
- this._keyboardTexture = {
123
- texture: keyboardTex,
124
- width: 256,
125
- height: 3,
126
- };
127
-
128
- // 4. Initialize external textures (from project.textures)
129
- // NOTE: This requires actual image data; for now just stub the array.
130
- // Real implementation would load images here.
131
- this.initProjectTextures();
132
-
133
- // 5. Compile shaders + create runtime passes
134
- this.initRuntimePasses();
135
- }
136
-
137
- // ===========================================================================
138
- // Public API
139
- // ===========================================================================
140
-
141
- get width(): number {
142
- return this._width;
143
- }
144
-
145
- get height(): number {
146
- return this._height;
147
- }
148
-
149
- get stats(): EngineStats {
150
- const dt = this._lastStepTime === null ? 0 : this._time - this._lastStepTime;
151
- return {
152
- frame: this._frame,
153
- time: this._time,
154
- deltaTime: dt,
155
- width: this._width,
156
- height: this._height,
157
- };
158
- }
159
-
160
- /**
161
- * Get shader compilation errors (if any occurred during initialization).
162
- * Returns empty array if all shaders compiled successfully.
163
- */
164
- getCompilationErrors(): Array<{
165
- passName: PassName;
166
- error: string;
167
- source: string;
168
- isFromCommon: boolean;
169
- originalLine: number | null;
170
- }> {
171
- return this._compilationErrors;
172
- }
173
-
174
- /**
175
- * Check if there were any compilation errors.
176
- */
177
- hasErrors(): boolean {
178
- return this._compilationErrors.length > 0;
179
- }
180
-
181
- /**
182
- * Get the framebuffer for the Image pass (for presenting to screen).
183
- */
184
- getImageFramebuffer(): WebGLFramebuffer | null {
185
- const imagePass = this._passes.find((p) => p.name === 'Image');
186
- return imagePass?.framebuffer ?? null;
187
- }
188
-
189
- /**
190
- * Run one full frame of all passes.
191
- *
192
- * @param timeSeconds - global time in seconds (monotone, from App)
193
- * @param mouse - iMouse as [x, y, clickX, clickY]
194
- */
195
- step(timeSeconds: number, mouse: [number, number, number, number]): void {
196
- const gl = this.gl;
197
-
198
- // Compute time/deltaTime/iFrame
199
- const deltaTime =
200
- this._lastStepTime === null ? 0.0 : timeSeconds - this._lastStepTime;
201
- this._lastStepTime = timeSeconds;
202
- this._time = timeSeconds;
203
-
204
- const iResolution = [this._width, this._height, 1.0] as const;
205
- const iTime = this._time;
206
- const iTimeDelta = deltaTime;
207
- const iFrame = this._frame;
208
- const iMouse = mouse;
209
-
210
- // Set viewport for all passes
211
- gl.viewport(0, 0, this._width, this._height);
212
-
213
- // Execute passes in Shadertoy order
214
- const passOrder: PassName[] = ['BufferA', 'BufferB', 'BufferC', 'BufferD', 'Image'];
215
-
216
- for (const passName of passOrder) {
217
- const runtimePass = this._passes.find((p) => p.name === passName);
218
- if (!runtimePass) continue;
219
-
220
- this.executePass(runtimePass, {
221
- iResolution,
222
- iTime,
223
- iTimeDelta,
224
- iFrame,
225
- iMouse,
226
- });
227
-
228
- // Swap ping-pong textures after pass execution
229
- this.swapPassTextures(runtimePass);
230
- }
231
-
232
- // Monotone frame counter (increment AFTER all passes)
233
- this._frame += 1;
234
- }
235
-
236
- /**
237
- * Resize all internal render targets to new width/height.
238
- * Does not reset time or frame count.
239
- */
240
- resize(width: number, height: number): void {
241
- this._width = width;
242
- this._height = height;
243
-
244
- const gl = this.gl;
245
-
246
- // Reallocate ALL pass textures to new resolution
247
- for (const pass of this._passes) {
248
- // Delete old textures and framebuffer
249
- gl.deleteTexture(pass.currentTexture);
250
- gl.deleteTexture(pass.previousTexture);
251
- gl.deleteFramebuffer(pass.framebuffer);
252
-
253
- // Create new textures at new resolution
254
- pass.currentTexture = createRenderTargetTexture(gl, width, height);
255
- pass.previousTexture = createRenderTargetTexture(gl, width, height);
256
-
257
- // Create new framebuffer (attached to current texture)
258
- pass.framebuffer = createFramebufferWithColorAttachment(gl, pass.currentTexture);
259
- }
260
- }
261
-
262
- /**
263
- * Reset frame counter and clear all render targets.
264
- * Used for playback controls to restart shader from frame 0.
265
- */
266
- reset(): void {
267
- this._frame = 0;
268
-
269
- // Clear all pass textures (both current and previous for ping-pong)
270
- // This is critical for accumulation shaders that read from previous frame
271
- const gl = this.gl;
272
- for (const pass of this._passes) {
273
- // Clear current texture (already attached to framebuffer)
274
- gl.bindFramebuffer(gl.FRAMEBUFFER, pass.framebuffer);
275
- gl.clearColor(0, 0, 0, 0);
276
- gl.clear(gl.COLOR_BUFFER_BIT);
277
-
278
- // Also clear previous texture (temporarily attach it)
279
- gl.framebufferTexture2D(
280
- gl.FRAMEBUFFER,
281
- gl.COLOR_ATTACHMENT0,
282
- gl.TEXTURE_2D,
283
- pass.previousTexture,
284
- 0
285
- );
286
- gl.clear(gl.COLOR_BUFFER_BIT);
287
-
288
- // Re-attach current texture
289
- gl.framebufferTexture2D(
290
- gl.FRAMEBUFFER,
291
- gl.COLOR_ATTACHMENT0,
292
- gl.TEXTURE_2D,
293
- pass.currentTexture,
294
- 0
295
- );
296
- }
297
- gl.bindFramebuffer(gl.FRAMEBUFFER, null);
298
- }
299
-
300
- /**
301
- * Update keyboard key state (called from App on keydown/keyup events).
302
- *
303
- * @param keycode ASCII keycode (e.g., 65 for 'A')
304
- * @param isDown true if key pressed, false if released
305
- */
306
- updateKeyState(keycode: number, isDown: boolean): void {
307
- const wasDown = this._keyStates.get(keycode) || false;
308
-
309
- // Update current state
310
- this._keyStates.set(keycode, isDown);
311
-
312
- // Toggle on press (down transition)
313
- if (isDown && !wasDown) {
314
- const currentToggle = this._toggleStates.get(keycode) || 0.0;
315
- this._toggleStates.set(keycode, currentToggle === 0.0 ? 1.0 : 0.0);
316
- }
317
- }
318
-
319
- /**
320
- * Update keyboard texture with current key states.
321
- * Should be called once per frame before rendering.
322
- */
323
- updateKeyboardTexture(): void {
324
- if (!this._keyboardTexture) {
325
- return; // No keyboard texture to update
326
- }
327
-
328
- updateKeyboardTexture(
329
- this.gl,
330
- this._keyboardTexture.texture,
331
- this._keyStates,
332
- this._toggleStates
333
- );
334
- }
335
-
336
- /**
337
- * Recompile a single pass with new GLSL source code.
338
- * Used for live editing - keeps the old shader running if compilation fails.
339
- *
340
- * @param passName - Name of the pass to recompile ('Image', 'BufferA', etc.)
341
- * @param newSource - New GLSL source code for the pass
342
- * @returns Object with success status and error message if failed
343
- */
344
- recompilePass(passName: PassName, newSource: string): { success: boolean; error?: string } {
345
- const gl = this.gl;
346
-
347
- // Find the runtime pass
348
- const runtimePass = this._passes.find((p) => p.name === passName);
349
- if (!runtimePass) {
350
- return { success: false, error: `Pass '${passName}' not found` };
351
- }
352
-
353
- // Update the project's pass source (so buildFragmentShader uses it)
354
- const projectPass = this.project.passes[passName];
355
- if (!projectPass) {
356
- return { success: false, error: `Project pass '${passName}' not found` };
357
- }
358
-
359
- // Build new fragment shader
360
- const fragmentSource = this.buildFragmentShader(newSource, projectPass.channels);
361
-
362
- try {
363
- // Try to compile new program
364
- const newProgram = createProgramFromSources(gl, VERTEX_SHADER_SOURCE, fragmentSource);
365
-
366
- // Success! Delete old program and update runtime pass
367
- gl.deleteProgram(runtimePass.uniforms.program);
368
-
369
- // Cache new uniform locations
370
- const uniforms: PassUniformLocations = {
371
- program: newProgram,
372
- iResolution: gl.getUniformLocation(newProgram, 'iResolution'),
373
- iTime: gl.getUniformLocation(newProgram, 'iTime'),
374
- iTimeDelta: gl.getUniformLocation(newProgram, 'iTimeDelta'),
375
- iFrame: gl.getUniformLocation(newProgram, 'iFrame'),
376
- iMouse: gl.getUniformLocation(newProgram, 'iMouse'),
377
- iChannel: [
378
- gl.getUniformLocation(newProgram, 'iChannel0'),
379
- gl.getUniformLocation(newProgram, 'iChannel1'),
380
- gl.getUniformLocation(newProgram, 'iChannel2'),
381
- gl.getUniformLocation(newProgram, 'iChannel3'),
382
- ],
383
- };
384
-
385
- runtimePass.uniforms = uniforms;
386
-
387
- // Update the stored source in the project
388
- projectPass.glslSource = newSource;
389
-
390
- // Clear any previous compilation errors for this pass
391
- this._compilationErrors = this._compilationErrors.filter(e => e.passName !== passName);
392
-
393
- return { success: true };
394
- } catch (err) {
395
- // Compilation failed - keep old shader running
396
- const errorMessage = err instanceof Error ? err.message : String(err);
397
- return { success: false, error: errorMessage };
398
- }
399
- }
400
-
401
- /**
402
- * Recompile common.glsl and all passes that use it.
403
- * Used for live editing of common code.
404
- *
405
- * @param newCommonSource - New GLSL source code for common.glsl
406
- * @returns Object with success status and errors for each failed pass
407
- */
408
- recompileCommon(newCommonSource: string): { success: boolean; errors: Array<{ passName: PassName; error: string }> } {
409
- const oldCommonSource = this.project.commonSource;
410
-
411
- // Temporarily update common source
412
- this.project.commonSource = newCommonSource;
413
-
414
- const errors: Array<{ passName: PassName; error: string }> = [];
415
- const passOrder: PassName[] = ['BufferA', 'BufferB', 'BufferC', 'BufferD', 'Image'];
416
-
417
- // Try to recompile all passes
418
- for (const passName of passOrder) {
419
- const projectPass = this.project.passes[passName];
420
- if (!projectPass) continue;
421
-
422
- const result = this.recompilePass(passName, projectPass.glslSource);
423
- if (!result.success) {
424
- errors.push({ passName, error: result.error || 'Unknown error' });
425
- }
426
- }
427
-
428
- // If any failed, restore old common source and recompile successful ones back
429
- if (errors.length > 0) {
430
- this.project.commonSource = oldCommonSource;
431
-
432
- // Recompile passes back to working state
433
- for (const passName of passOrder) {
434
- const projectPass = this.project.passes[passName];
435
- if (!projectPass) continue;
436
-
437
- // Skip passes that failed (they still have old shader)
438
- if (errors.some(e => e.passName === passName)) continue;
439
-
440
- // Recompile with old common source
441
- this.recompilePass(passName, projectPass.glslSource);
442
- }
443
-
444
- return { success: false, errors };
445
- }
446
-
447
- return { success: true, errors: [] };
448
- }
449
-
450
- /**
451
- * Delete all GL resources.
452
- */
453
- dispose(): void {
454
- const gl = this.gl;
455
-
456
- // Delete passes (programs, VAOs, FBOs, textures)
457
- for (const pass of this._passes) {
458
- gl.deleteProgram(pass.uniforms.program);
459
- gl.deleteVertexArray(pass.vao);
460
- gl.deleteFramebuffer(pass.framebuffer);
461
- gl.deleteTexture(pass.currentTexture);
462
- gl.deleteTexture(pass.previousTexture);
463
- }
464
-
465
- // Delete external textures
466
- for (const tex of this._textures) {
467
- gl.deleteTexture(tex.texture);
468
- }
469
-
470
- // Delete keyboard texture
471
- if (this._keyboardTexture) {
472
- gl.deleteTexture(this._keyboardTexture.texture);
473
- }
474
-
475
- // Delete black texture
476
- if (this._blackTexture) {
477
- gl.deleteTexture(this._blackTexture);
478
- }
479
-
480
- // Clear arrays
481
- this._passes = [];
482
- this._textures = [];
483
- this._keyboardTexture = null;
484
- this._blackTexture = null;
485
- }
486
-
487
- // ===========================================================================
488
- // Initialization Helpers
489
- // ===========================================================================
490
-
491
- private initExtensions(): void {
492
- const gl = this.gl;
493
-
494
- // MUST enable EXT_color_buffer_float for RGBA32F render targets
495
- const ext = gl.getExtension('EXT_color_buffer_float');
496
- if (!ext) {
497
- throw new Error(
498
- 'EXT_color_buffer_float not supported. WebGL2 with float rendering is required.'
499
- );
500
- }
501
-
502
- // Optionally check for OES_texture_float_linear (for smooth filtering of float textures)
503
- // Not strictly required for Shadertoy, but nice to have
504
- gl.getExtension('OES_texture_float_linear');
505
- }
506
-
507
- /**
508
- * Initialize external textures based on project.textures.
509
- *
510
- * NOTE: This function as written assumes that actual image loading
511
- * is handled elsewhere. For now we just construct an empty array.
512
- * In a real implementation, you would load images here.
513
- */
514
- private initProjectTextures(): void {
515
- const gl = this.gl;
516
- this._textures = [];
517
-
518
- // Load each texture from the project
519
- for (const texDef of this.project.textures) {
520
- // Create a placeholder 1x1 texture immediately
521
- const texture = gl.createTexture();
522
- if (!texture) {
523
- throw new Error('Failed to create texture');
524
- }
525
-
526
- // Bind and set initial 1x1 black pixel
527
- gl.bindTexture(gl.TEXTURE_2D, texture);
528
- gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array([0, 0, 0, 255]));
529
-
530
- // Store in runtime array
531
- const runtimeTex: RuntimeTexture2D = {
532
- name: texDef.name,
533
- texture,
534
- width: 1,
535
- height: 1,
536
- };
537
- this._textures.push(runtimeTex);
538
-
539
- // Load the actual image asynchronously
540
- const image = new Image();
541
- image.crossOrigin = 'anonymous';
542
- image.onload = () => {
543
- gl.bindTexture(gl.TEXTURE_2D, texture);
544
- gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
545
-
546
- // Set filter
547
- const filter = texDef.filter === 'nearest' ? gl.NEAREST : gl.LINEAR;
548
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, filter);
549
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, filter);
550
-
551
- // Set wrap mode
552
- const wrap = texDef.wrap === 'clamp' ? gl.CLAMP_TO_EDGE : gl.REPEAT;
553
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, wrap);
554
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, wrap);
555
-
556
- // Generate mipmaps if using linear filtering
557
- if (texDef.filter === 'linear') {
558
- gl.generateMipmap(gl.TEXTURE_2D);
559
- }
560
-
561
- // Update dimensions
562
- runtimeTex.width = image.width;
563
- runtimeTex.height = image.height;
564
-
565
- console.log(`Loaded texture '${texDef.name}': ${image.width}x${image.height}`);
566
- };
567
- image.onerror = () => {
568
- console.error(`Failed to load texture '${texDef.name}' from ${texDef.source}`);
569
- };
570
- image.src = texDef.source;
571
- }
572
- }
573
-
574
- /**
575
- * Compile shaders, create VAOs/FBOs/textures, and build RuntimePass array.
576
- */
577
- private initRuntimePasses(): void {
578
- const gl = this.gl;
579
- const project = this.project;
580
-
581
- // Shared VAO (all passes use the same fullscreen triangle)
582
- const sharedVAO = createFullscreenTriangleVAO(gl);
583
-
584
- // Build passes in Shadertoy order
585
- const passOrder: PassName[] = ['BufferA', 'BufferB', 'BufferC', 'BufferD', 'Image'];
586
-
587
- for (const passName of passOrder) {
588
- const projectPass = project.passes[passName];
589
- if (!projectPass) continue;
590
-
591
- // Build fragment shader source (outside try so we can access in catch)
592
- const fragmentSource = this.buildFragmentShader(projectPass.glslSource, projectPass.channels);
593
-
594
- try {
595
- // Compile program
596
- const program = createProgramFromSources(gl, VERTEX_SHADER_SOURCE, fragmentSource);
597
-
598
- // Cache uniform locations
599
- const uniforms: PassUniformLocations = {
600
- program,
601
- iResolution: gl.getUniformLocation(program, 'iResolution'),
602
- iTime: gl.getUniformLocation(program, 'iTime'),
603
- iTimeDelta: gl.getUniformLocation(program, 'iTimeDelta'),
604
- iFrame: gl.getUniformLocation(program, 'iFrame'),
605
- iMouse: gl.getUniformLocation(program, 'iMouse'),
606
- iChannel: [
607
- gl.getUniformLocation(program, 'iChannel0'),
608
- gl.getUniformLocation(program, 'iChannel1'),
609
- gl.getUniformLocation(program, 'iChannel2'),
610
- gl.getUniformLocation(program, 'iChannel3'),
611
- ],
612
- };
613
-
614
- // Create ping-pong textures (MUST allocate both for all passes)
615
- const currentTexture = createRenderTargetTexture(gl, this._width, this._height);
616
- const previousTexture = createRenderTargetTexture(gl, this._width, this._height);
617
-
618
- // Create framebuffer (attached to current texture)
619
- const framebuffer = createFramebufferWithColorAttachment(gl, currentTexture);
620
-
621
- // Build RuntimePass
622
- const runtimePass: RuntimePass = {
623
- name: passName,
624
- projectChannels: projectPass.channels,
625
- vao: sharedVAO,
626
- uniforms,
627
- framebuffer,
628
- currentTexture,
629
- previousTexture,
630
- };
631
-
632
- this._passes.push(runtimePass);
633
- } catch (err) {
634
- // Store compilation error with source code for context display
635
- const errorMessage = err instanceof Error ? err.message : String(err);
636
-
637
- // Detect if error is from common.glsl
638
- const lineMapping = this.getLineMapping();
639
- const errorLineMatch = errorMessage.match(/ERROR:\s*\d+:(\d+):/);
640
- let isFromCommon = false;
641
- let originalLine: number | null = null;
642
-
643
- if (errorLineMatch && this.project.commonSource) {
644
- const errorLine = parseInt(errorLineMatch[1], 10);
645
- const commonStartLine = lineMapping.boilerplateLinesBeforeCommon + 2; // +1 for comment, +1 for 1-indexed
646
- const commonEndLine = commonStartLine + lineMapping.commonLineCount - 1;
647
-
648
- if (errorLine >= commonStartLine && errorLine <= commonEndLine) {
649
- isFromCommon = true;
650
- // Calculate line number relative to common.glsl
651
- originalLine = errorLine - commonStartLine + 1;
652
- }
653
- }
654
-
655
- this._compilationErrors.push({
656
- passName,
657
- error: errorMessage,
658
- source: fragmentSource,
659
- isFromCommon,
660
- originalLine,
661
- });
662
- console.error(`Failed to compile ${passName}:`, errorMessage);
663
- }
664
- }
665
- }
666
-
667
- /**
668
- * Calculate line number mappings for error reporting.
669
- * Returns info about where common.glsl code lives in the compiled shader.
670
- */
671
- private getLineMapping(): { boilerplateLinesBeforeCommon: number; commonLineCount: number } {
672
- // +1 for the "// Common code" comment line added before common source
673
- const boilerplateLinesBeforeCommon = PREAMBLE_LINE_COUNT + 1;
674
-
675
- const commonLineCount = this.project.commonSource
676
- ? this.project.commonSource.split('\n').length
677
- : 0;
678
-
679
- return { boilerplateLinesBeforeCommon, commonLineCount };
680
- }
681
-
682
- /**
683
- * Build complete fragment shader source with Shadertoy boilerplate.
684
- *
685
- * @param userSource - The user's GLSL source code
686
- * @param channels - Channel configuration for this pass (to detect cubemap textures)
687
- */
688
- private buildFragmentShader(userSource: string, channels: ChannelSource[]): string {
689
- const parts: string[] = [FRAGMENT_PREAMBLE];
690
-
691
- // Common code (if any)
692
- if (this.project.commonSource) {
693
- parts.push('// Common code');
694
- parts.push(this.project.commonSource);
695
- parts.push('');
696
- }
697
-
698
- // Shadertoy built-in uniforms
699
- parts.push(`// Shadertoy built-in uniforms
700
- uniform vec3 iResolution;
701
- uniform float iTime;
702
- uniform float iTimeDelta;
703
- uniform int iFrame;
704
- uniform vec4 iMouse;
705
- uniform sampler2D iChannel0;
706
- uniform sampler2D iChannel1;
707
- uniform sampler2D iChannel2;
708
- uniform sampler2D iChannel3;
709
- `);
710
-
711
- // Preprocess user shader code to handle cubemap-style texture sampling
712
- const processedSource = this.preprocessCubemapTextures(userSource, channels);
713
-
714
- // User shader code
715
- parts.push('// User shader code');
716
- parts.push(processedSource);
717
- parts.push('');
718
-
719
- // mainImage() wrapper
720
- parts.push(`// Main wrapper
721
- out vec4 fragColor;
722
-
723
- void main() {
724
- mainImage(fragColor, gl_FragCoord.xy);
725
- }`);
726
-
727
- return parts.join('\n');
728
- }
729
-
730
- /**
731
- * Preprocess shader to convert cubemap-style texture() calls to equirectangular.
732
- *
733
- * Uses the channel configuration to determine which channels are cubemaps.
734
- * Only channels explicitly marked as `type: 'cubemap'` in config.json will have
735
- * their texture() calls wrapped with _st_dirToEquirect().
736
- *
737
- * @param source - User's GLSL source code
738
- * @param channels - Channel configuration for this pass
739
- */
740
- private preprocessCubemapTextures(source: string, channels: ChannelSource[]): string {
741
- // Build set of channel names that are cubemaps
742
- const cubemapChannels = new Set<string>();
743
- channels.forEach((ch, i) => {
744
- if (ch.kind === 'texture' && ch.cubemap) {
745
- cubemapChannels.add(`iChannel${i}`);
746
- }
747
- });
748
-
749
- // If no cubemap channels, return source unchanged
750
- if (cubemapChannels.size === 0) {
751
- return source;
752
- }
753
-
754
- // Match: texture(iChannelN, ...)
755
- const textureCallRegex = /texture\s*\(\s*(iChannel[0-3])\s*,\s*([^)]+)\)/g;
756
-
757
- return source.replace(textureCallRegex, (match, channel, coord) => {
758
- // Only wrap if this channel is explicitly marked as cubemap
759
- if (cubemapChannels.has(channel)) {
760
- return `texture(${channel}, _st_dirToEquirect(${coord}))`;
761
- } else {
762
- return match;
763
- }
764
- });
765
- }
766
-
767
- // ===========================================================================
768
- // Pass Execution
769
- // ===========================================================================
770
-
771
- private executePass(
772
- runtimePass: RuntimePass,
773
- builtinUniforms: {
774
- iResolution: readonly [number, number, number];
775
- iTime: number;
776
- iTimeDelta: number;
777
- iFrame: number;
778
- iMouse: [number, number, number, number];
779
- }
780
- ): void {
781
- const gl = this.gl;
782
-
783
- // Bind framebuffer (write to current texture)
784
- gl.bindFramebuffer(gl.FRAMEBUFFER, runtimePass.framebuffer);
785
-
786
- // Use program
787
- gl.useProgram(runtimePass.uniforms.program);
788
-
789
- // Bind VAO
790
- gl.bindVertexArray(runtimePass.vao);
791
-
792
- // Bind built-in uniforms
793
- this.bindBuiltinUniforms(runtimePass.uniforms, builtinUniforms);
794
-
795
- // Bind iChannel textures
796
- this.bindChannelTextures(runtimePass);
797
-
798
- // Draw fullscreen triangle
799
- gl.drawArrays(gl.TRIANGLES, 0, 3);
800
-
801
- // Unbind
802
- gl.bindVertexArray(null);
803
- gl.useProgram(null);
804
- gl.bindFramebuffer(gl.FRAMEBUFFER, null);
805
- }
806
-
807
- private bindBuiltinUniforms(
808
- uniforms: PassUniformLocations,
809
- values: {
810
- iResolution: readonly [number, number, number];
811
- iTime: number;
812
- iTimeDelta: number;
813
- iFrame: number;
814
- iMouse: [number, number, number, number];
815
- }
816
- ): void {
817
- const gl = this.gl;
818
-
819
- if (uniforms.iResolution) {
820
- gl.uniform3f(uniforms.iResolution, values.iResolution[0], values.iResolution[1], values.iResolution[2]);
821
- }
822
-
823
- if (uniforms.iTime) {
824
- gl.uniform1f(uniforms.iTime, values.iTime);
825
- }
826
-
827
- if (uniforms.iTimeDelta) {
828
- gl.uniform1f(uniforms.iTimeDelta, values.iTimeDelta);
829
- }
830
-
831
- if (uniforms.iFrame) {
832
- gl.uniform1i(uniforms.iFrame, values.iFrame);
833
- }
834
-
835
- if (uniforms.iMouse) {
836
- gl.uniform4f(uniforms.iMouse, values.iMouse[0], values.iMouse[1], values.iMouse[2], values.iMouse[3]);
837
- }
838
- }
839
-
840
- private bindChannelTextures(runtimePass: RuntimePass): void {
841
- const gl = this.gl;
842
-
843
- for (let i = 0; i < 4; i++) {
844
- const channelSource = runtimePass.projectChannels[i];
845
- const texture = this.resolveChannelTexture(channelSource);
846
-
847
- // Bind texture to texture unit i
848
- gl.activeTexture(gl.TEXTURE0 + i);
849
- gl.bindTexture(gl.TEXTURE_2D, texture);
850
-
851
- // Set uniform to use texture unit i
852
- const uniformLoc = runtimePass.uniforms.iChannel[i];
853
- if (uniformLoc) {
854
- gl.uniform1i(uniformLoc, i);
855
- }
856
- }
857
- }
858
-
859
- /**
860
- * Resolve a ChannelSource to an actual WebGLTexture to bind.
861
- */
862
- private resolveChannelTexture(source: ChannelSource): WebGLTexture {
863
- switch (source.kind) {
864
- case 'none':
865
- // Unused channel → bind black texture
866
- if (!this._blackTexture) {
867
- throw new Error('Black texture not initialized');
868
- }
869
- return this._blackTexture;
870
-
871
- case 'buffer': {
872
- // Buffer reference → find RuntimePass and return current or previous texture
873
- const targetPass = this._passes.find((p) => p.name === source.buffer);
874
- if (!targetPass) {
875
- throw new Error(`Buffer '${source.buffer}' not found`);
876
- }
877
-
878
- // Default to previous frame (safer, matches common use case)
879
- // Only use current frame if explicitly requested with current: true
880
- return source.current ? targetPass.currentTexture : targetPass.previousTexture;
881
- }
882
-
883
- case 'texture': {
884
- // External texture → find RuntimeTexture by name
885
- const tex = this._textures.find((t) => t.name === source.name);
886
- if (!tex) {
887
- throw new Error(`Texture '${source.name}' not found`);
888
- }
889
- return tex.texture;
890
- }
891
-
892
- case 'keyboard':
893
- // Keyboard texture (always available)
894
- if (!this._keyboardTexture) {
895
- throw new Error('Internal error: keyboard texture not initialized');
896
- }
897
- return this._keyboardTexture.texture;
898
-
899
- default:
900
- // Exhaustive check
901
- const _exhaustive: never = source;
902
- throw new Error(`Unknown channel source: ${JSON.stringify(_exhaustive)}`);
903
- }
904
- }
905
-
906
- /**
907
- * Swap current and previous textures for a pass (ping-pong).
908
- * Also reattach framebuffer to new current texture.
909
- */
910
- private swapPassTextures(pass: RuntimePass): void {
911
- const gl = this.gl;
912
-
913
- // Swap texture references
914
- const temp = pass.currentTexture;
915
- pass.currentTexture = pass.previousTexture;
916
- pass.previousTexture = temp;
917
-
918
- // Re-attach framebuffer to new current texture
919
- gl.bindFramebuffer(gl.FRAMEBUFFER, pass.framebuffer);
920
- gl.framebufferTexture2D(
921
- gl.FRAMEBUFFER,
922
- gl.COLOR_ATTACHMENT0,
923
- gl.TEXTURE_2D,
924
- pass.currentTexture,
925
- 0
926
- );
927
- gl.bindFramebuffer(gl.FRAMEBUFFER, null);
928
- }
929
- }