@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
@@ -1,15 +1,34 @@
1
1
  /**
2
- * Helper functions for loading demo files
3
- * Called by the generated loader
2
+ * Helper functions for loading demo files in the browser.
3
+ * Called by the generated loader (Vite import.meta.glob).
4
4
  */
5
5
 
6
6
  import {
7
- ShadertoyProject,
8
- ShadertoyConfig,
7
+ ShaderProject,
8
+ ProjectConfig,
9
9
  PassName,
10
- ChannelValue,
11
- ChannelJSONObject,
10
+ ChannelSource,
11
+ Channels,
12
+ ShaderTexture2D,
13
+ UniformDefinitions,
14
+ DemoScriptHooks,
15
+ StandardBufferConfig,
12
16
  } from './types';
17
+ import {
18
+ parseChannelValue,
19
+ defaultSourceForPass,
20
+ validateConfig,
21
+ PASS_ORDER,
22
+ BUFFER_PASS_NAMES,
23
+ CHANNEL_KEYS,
24
+ DEFAULT_LAYOUT,
25
+ DEFAULT_CONTROLS,
26
+ DEFAULT_THEME,
27
+ } from './configHelpers';
28
+
29
+ // =============================================================================
30
+ // Case-Insensitive File Lookup
31
+ // =============================================================================
13
32
 
14
33
  /**
15
34
  * Case-insensitive file lookup helper.
@@ -19,10 +38,7 @@ function findFileCaseInsensitive<T>(
19
38
  files: Record<string, T>,
20
39
  path: string
21
40
  ): string | null {
22
- // First try exact match
23
41
  if (path in files) return path;
24
-
25
- // Try case-insensitive match
26
42
  const lowerPath = path.toLowerCase();
27
43
  for (const key of Object.keys(files)) {
28
44
  if (key.toLowerCase() === lowerPath) {
@@ -32,61 +48,186 @@ function findFileCaseInsensitive<T>(
32
48
  return null;
33
49
  }
34
50
 
51
+ // =============================================================================
52
+ // Script Loading
53
+ // =============================================================================
54
+
35
55
  /**
36
- * Type guard for PassName.
56
+ * Load script.js from a demo folder if present.
37
57
  */
38
- function isPassName(s: string): s is PassName {
39
- return s === 'Image' || s === 'BufferA' || s === 'BufferB' || s === 'BufferC' || s === 'BufferD';
58
+ async function loadScript(
59
+ demoPath: string,
60
+ scriptFiles?: Record<string, () => Promise<any>>
61
+ ): Promise<DemoScriptHooks | null> {
62
+ if (!scriptFiles) return null;
63
+
64
+ const scriptPath = `${demoPath}/script.js`;
65
+ const actualPath = findFileCaseInsensitive(scriptFiles, scriptPath);
66
+ if (!actualPath) return null;
67
+
68
+ const mod = await scriptFiles[actualPath]();
69
+ const hooks: DemoScriptHooks = {};
70
+ if (typeof mod.setup === 'function') hooks.setup = mod.setup;
71
+ if (typeof mod.onFrame === 'function') hooks.onFrame = mod.onFrame;
72
+
73
+ return (hooks.setup || hooks.onFrame) ? hooks : null;
40
74
  }
41
75
 
76
+ // =============================================================================
77
+ // Common Source Loading
78
+ // =============================================================================
79
+
42
80
  /**
43
- * Parse a channel value (string shorthand or object) into normalized ChannelJSONObject.
81
+ * Load common.glsl source (explicit path or default).
44
82
  */
45
- function parseChannelValue(value: ChannelValue): ChannelJSONObject | null {
46
- if (typeof value === 'string') {
47
- if (isPassName(value)) {
48
- return { buffer: value };
49
- }
50
- if (value === 'keyboard') {
51
- return { keyboard: true };
83
+ async function loadCommonSource(
84
+ demoPath: string,
85
+ glslFiles: Record<string, () => Promise<string>>,
86
+ commonPath?: string
87
+ ): Promise<string | null> {
88
+ if (commonPath) {
89
+ const fullPath = `${demoPath}/${commonPath}`;
90
+ const actualPath = findFileCaseInsensitive(glslFiles, fullPath);
91
+ return actualPath ? await glslFiles[actualPath]() : null;
92
+ }
93
+ // Check for default common.glsl
94
+ const defaultPath = `${demoPath}/common.glsl`;
95
+ const actualPath = findFileCaseInsensitive(glslFiles, defaultPath);
96
+ return actualPath ? await glslFiles[actualPath]() : null;
97
+ }
98
+
99
+ // =============================================================================
100
+ // Channel Normalization
101
+ // =============================================================================
102
+
103
+ /**
104
+ * Normalize a channel value into a ChannelSource.
105
+ */
106
+ function normalizeChannel(
107
+ channelValue: any,
108
+ texturePathToName?: Map<string, string>
109
+ ): ChannelSource {
110
+ if (!channelValue) return { kind: 'none' };
111
+
112
+ const parsed = parseChannelValue(channelValue);
113
+ if (!parsed) return { kind: 'none' };
114
+
115
+ if ('buffer' in parsed) {
116
+ return { kind: 'buffer', buffer: parsed.buffer, current: !!parsed.current };
117
+ }
118
+ if ('texture' in parsed) {
119
+ const textureName = texturePathToName?.get(parsed.texture) || parsed.texture;
120
+ return { kind: 'texture', name: textureName, cubemap: parsed.type === 'cubemap' };
121
+ }
122
+ if ('keyboard' in parsed) return { kind: 'keyboard' };
123
+ if ('audio' in parsed) return { kind: 'audio' };
124
+ if ('webcam' in parsed) return { kind: 'webcam' };
125
+ if ('video' in parsed) return { kind: 'video', src: (parsed as any).video };
126
+ if ('script' in parsed) return { kind: 'script', name: (parsed as any).script };
127
+
128
+ return { kind: 'none' };
129
+ }
130
+
131
+ // =============================================================================
132
+ // Named Buffers Normalization (Standard Mode)
133
+ // =============================================================================
134
+
135
+ /**
136
+ * Normalize buffers config: array shorthand to object form.
137
+ */
138
+ function normalizeBuffersConfig(
139
+ buffers: string[] | Record<string, StandardBufferConfig> | undefined
140
+ ): Record<string, StandardBufferConfig> {
141
+ if (!buffers) return {};
142
+ if (Array.isArray(buffers)) {
143
+ const result: Record<string, StandardBufferConfig> = {};
144
+ for (const name of buffers) {
145
+ result[name] = {};
52
146
  }
53
- return { texture: value };
147
+ return result;
54
148
  }
55
- return value;
149
+ return buffers;
56
150
  }
57
151
 
152
+ // =============================================================================
153
+ // Title Helper
154
+ // =============================================================================
155
+
156
+ /**
157
+ * Generate a display title from a demo path.
158
+ * e.g. "./demos/my-shader" → "My Shader"
159
+ */
160
+ function titleFromPath(demoPath: string): string {
161
+ const demoName = demoPath.split('/').pop() || demoPath;
162
+ return demoName.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
163
+ }
164
+
165
+ // =============================================================================
166
+ // Main Entry Point
167
+ // =============================================================================
168
+
58
169
  export async function loadDemo(
59
170
  demoPath: string,
60
171
  glslFiles: Record<string, () => Promise<string>>,
61
- jsonFiles: Record<string, () => Promise<ShadertoyConfig>>,
62
- imageFiles: Record<string, () => Promise<string>>
63
- ): Promise<ShadertoyProject> {
64
- // Normalize path - handle both "./shaders/name" and "shaders/name" formats
172
+ jsonFiles: Record<string, () => Promise<ProjectConfig>>,
173
+ imageFiles: Record<string, () => Promise<string>>,
174
+ scriptFiles?: Record<string, () => Promise<any>>
175
+ ): Promise<ShaderProject> {
176
+ // Normalize path
65
177
  const normalizedPath = demoPath.startsWith('./') ? demoPath : `./${demoPath}`;
66
178
  const configPath = `${normalizedPath}/config.json`;
67
179
  const hasConfig = configPath in jsonFiles;
68
180
 
69
- if (hasConfig) {
70
- const config = await jsonFiles[configPath]();
71
- const hasPassConfigs = config.Image || config.BufferA || config.BufferB ||
72
- config.BufferC || config.BufferD;
181
+ if (!hasConfig) {
182
+ // No config = simple single-pass project
183
+ return loadSinglePass(normalizedPath, glslFiles, 'standard');
184
+ }
73
185
 
74
- if (hasPassConfigs) {
75
- return loadWithConfig(normalizedPath, config, glslFiles, imageFiles);
76
- } else {
77
- // Config with only settings (layout, controls, etc.) but no passes
78
- return loadSinglePass(normalizedPath, glslFiles, config);
79
- }
80
- } else {
81
- return loadSinglePass(normalizedPath, glslFiles);
186
+ const config = await jsonFiles[configPath]();
187
+ validateConfig(config as Record<string, any>, normalizedPath);
188
+ const mode: 'shadertoy' | 'standard' = config.mode === 'shadertoy' ? 'shadertoy' : 'standard';
189
+
190
+ // Load script hooks (available in both modes)
191
+ const script = await loadScript(normalizedPath, scriptFiles);
192
+
193
+ // Get uniforms (only from standard mode configs)
194
+ const uniforms = mode === 'standard' && 'uniforms' in config ? config.uniforms : undefined;
195
+
196
+ // Check if config uses named buffers or textures (standard mode only)
197
+ const hasNamedBuffers = mode === 'standard' && (('buffers' in config && config.buffers) || ('textures' in config && config.textures));
198
+
199
+ if (hasNamedBuffers) {
200
+ return loadStandardWithNamedBuffers(
201
+ normalizedPath, config as any, glslFiles, imageFiles, uniforms, script
202
+ );
82
203
  }
204
+
205
+ // Check for pass-level configs (Image, BufferA, etc.)
206
+ const hasPassConfigs = config.Image || config.BufferA || config.BufferB ||
207
+ config.BufferC || config.BufferD;
208
+
209
+ if (hasPassConfigs) {
210
+ return loadWithPassConfigs(
211
+ normalizedPath, config, glslFiles, imageFiles, mode, uniforms, script
212
+ );
213
+ }
214
+
215
+ // Config with only settings (layout, controls, etc.) but no passes
216
+ return loadSinglePass(normalizedPath, glslFiles, mode, config, uniforms, script);
83
217
  }
84
218
 
219
+ // =============================================================================
220
+ // Single Pass (no pass configs in JSON)
221
+ // =============================================================================
222
+
85
223
  async function loadSinglePass(
86
224
  demoPath: string,
87
225
  glslFiles: Record<string, () => Promise<string>>,
88
- configOverrides?: Partial<ShadertoyConfig>
89
- ): Promise<ShadertoyProject> {
226
+ mode: 'shadertoy' | 'standard',
227
+ configOverrides?: Partial<ProjectConfig>,
228
+ uniforms?: UniformDefinitions,
229
+ script?: DemoScriptHooks | null
230
+ ): Promise<ShaderProject> {
90
231
  const imagePath = `${demoPath}/image.glsl`;
91
232
  const actualImagePath = findFileCaseInsensitive(glslFiles, imagePath);
92
233
 
@@ -95,23 +236,21 @@ async function loadSinglePass(
95
236
  }
96
237
 
97
238
  const imageSource = await glslFiles[actualImagePath]();
98
-
99
- const layout = configOverrides?.layout || 'tabbed';
100
- const controls = configOverrides?.controls ?? true;
101
- // Extract name from path for title (e.g., "./shaders/example-gradient" -> "example-gradient")
102
- const demoName = demoPath.split('/').pop() || demoPath;
103
- const title = configOverrides?.title ||
104
- demoName.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
239
+ const title = configOverrides?.title || titleFromPath(demoPath);
105
240
 
106
241
  return {
242
+ mode,
107
243
  root: demoPath,
108
244
  meta: {
109
245
  title,
110
246
  author: configOverrides?.author || null,
111
247
  description: configOverrides?.description || null,
112
248
  },
113
- layout,
114
- controls,
249
+ layout: configOverrides?.layout ?? DEFAULT_LAYOUT,
250
+ theme: configOverrides?.theme ?? DEFAULT_THEME,
251
+ controls: configOverrides?.controls ?? DEFAULT_CONTROLS,
252
+ startPaused: configOverrides?.startPaused ?? false,
253
+ pixelRatio: configOverrides?.pixelRatio ?? null,
115
254
  commonSource: null,
116
255
  passes: {
117
256
  Image: {
@@ -126,17 +265,25 @@ async function loadSinglePass(
126
265
  },
127
266
  },
128
267
  textures: [],
268
+ uniforms: uniforms ?? {},
269
+ script: script ?? null,
129
270
  };
130
271
  }
131
272
 
132
- async function loadWithConfig(
273
+ // =============================================================================
274
+ // Pass-Config Mode (both shadertoy and standard with Image/BufferA/etc.)
275
+ // =============================================================================
276
+
277
+ async function loadWithPassConfigs(
133
278
  demoPath: string,
134
- config: ShadertoyConfig,
279
+ config: ProjectConfig,
135
280
  glslFiles: Record<string, () => Promise<string>>,
136
- imageFiles: Record<string, () => Promise<string>>
137
- ): Promise<ShadertoyProject> {
281
+ imageFiles: Record<string, () => Promise<string>>,
282
+ mode: 'shadertoy' | 'standard',
283
+ uniforms?: UniformDefinitions,
284
+ script?: DemoScriptHooks | null
285
+ ): Promise<ShaderProject> {
138
286
 
139
- // Extract pass configs from top level
140
287
  const passConfigs = {
141
288
  Image: config.Image,
142
289
  BufferA: config.BufferA,
@@ -146,45 +293,42 @@ async function loadWithConfig(
146
293
  };
147
294
 
148
295
  // Load common source
149
- let commonSource: string | null = null;
150
- if (config.common) {
151
- const commonPath = `${demoPath}/${config.common}`;
152
- const actualCommonPath = findFileCaseInsensitive(glslFiles, commonPath);
153
- if (actualCommonPath) {
154
- commonSource = await glslFiles[actualCommonPath]();
155
- }
156
- } else {
157
- const defaultCommonPath = `${demoPath}/common.glsl`;
158
- const actualCommonPath = findFileCaseInsensitive(glslFiles, defaultCommonPath);
159
- if (actualCommonPath) {
160
- commonSource = await glslFiles[actualCommonPath]();
161
- }
162
- }
296
+ const commonSource = await loadCommonSource(demoPath, glslFiles, config.common);
163
297
 
164
- // Collect all texture paths
165
- const texturePathsSet = new Set<string>();
166
- const passOrder = ['Image', 'BufferA', 'BufferB', 'BufferC', 'BufferD'] as const;
298
+ // Collect texture references for deduplication
299
+ interface TextureRef {
300
+ path: string;
301
+ filter: 'nearest' | 'linear';
302
+ wrap: 'clamp' | 'repeat';
303
+ }
304
+ const textureRefs = new Map<string, TextureRef>();
167
305
 
168
- for (const passName of passOrder) {
306
+ for (const passName of PASS_ORDER) {
169
307
  const passConfig = passConfigs[passName];
170
308
  if (!passConfig) continue;
171
309
 
172
- for (const channelKey of ['iChannel0', 'iChannel1', 'iChannel2', 'iChannel3'] as const) {
310
+ for (const channelKey of CHANNEL_KEYS) {
173
311
  const channelValue = passConfig[channelKey];
174
312
  if (!channelValue) continue;
175
313
 
176
314
  const parsed = parseChannelValue(channelValue);
177
315
  if (parsed && 'texture' in parsed) {
178
- texturePathsSet.add(parsed.texture);
316
+ if (!textureRefs.has(parsed.texture)) {
317
+ textureRefs.set(parsed.texture, {
318
+ path: parsed.texture,
319
+ filter: parsed.filter ?? 'linear',
320
+ wrap: parsed.wrap ?? 'repeat',
321
+ });
322
+ }
179
323
  }
180
324
  }
181
325
  }
182
326
 
183
327
  // Load textures
184
- const textures: any[] = [];
328
+ const textures: ShaderTexture2D[] = [];
185
329
  const texturePathToName = new Map<string, string>();
186
330
 
187
- for (const texturePath of texturePathsSet) {
331
+ for (const [texturePath, ref] of textureRefs) {
188
332
  const fullPath = `${demoPath}/${texturePath.replace(/^\.\//, '')}`;
189
333
  const actualPath = findFileCaseInsensitive(imageFiles, fullPath);
190
334
 
@@ -198,31 +342,23 @@ async function loadWithConfig(
198
342
 
199
343
  textures.push({
200
344
  name: textureName,
201
- filename: textureFilename, // Preserve original filename for display
345
+ filename: textureFilename,
202
346
  source: imageUrl,
203
- filter: 'linear' as const,
204
- wrap: 'repeat' as const,
347
+ filter: ref.filter,
348
+ wrap: ref.wrap,
205
349
  });
206
350
 
207
351
  texturePathToName.set(texturePath, textureName);
208
352
  }
209
353
 
210
354
  // Build passes
211
- const passes: any = {};
355
+ const passes: ShaderProject['passes'] = {} as any;
212
356
 
213
- for (const passName of passOrder) {
357
+ for (const passName of PASS_ORDER) {
214
358
  const passConfig = passConfigs[passName];
215
359
  if (!passConfig) continue;
216
360
 
217
- const defaultNames: Record<string, string> = {
218
- Image: 'image.glsl',
219
- BufferA: 'bufferA.glsl',
220
- BufferB: 'bufferB.glsl',
221
- BufferC: 'bufferC.glsl',
222
- BufferD: 'bufferD.glsl',
223
- };
224
-
225
- const sourceFile = passConfig.source || defaultNames[passName];
361
+ const sourceFile = passConfig.source || defaultSourceForPass(passName);
226
362
  const sourcePath = `${demoPath}/${sourceFile}`;
227
363
  const actualSourcePath = findFileCaseInsensitive(glslFiles, sourcePath);
228
364
 
@@ -232,75 +368,186 @@ async function loadWithConfig(
232
368
 
233
369
  const glslSource = await glslFiles[actualSourcePath]();
234
370
 
235
- const channels = [
371
+ const channels: Channels = [
236
372
  normalizeChannel(passConfig.iChannel0, texturePathToName),
237
373
  normalizeChannel(passConfig.iChannel1, texturePathToName),
238
374
  normalizeChannel(passConfig.iChannel2, texturePathToName),
239
375
  normalizeChannel(passConfig.iChannel3, texturePathToName),
240
376
  ];
241
377
 
242
- passes[passName] = {
243
- name: passName,
244
- glslSource,
245
- channels,
246
- };
378
+ passes[passName] = { name: passName, glslSource, channels };
247
379
  }
248
380
 
249
381
  if (!passes.Image) {
250
382
  throw new Error(`Demo '${demoPath}' must have an Image pass`);
251
383
  }
252
384
 
253
- // Extract name from path for title (e.g., "./shaders/example-gradient" -> "example-gradient")
254
- const demoName = demoPath.split('/').pop() || demoPath;
255
- const title = config.title ||
256
- demoName.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
257
- const author = config.author || null;
258
- const description = config.description || null;
259
- const layout = config.layout || 'tabbed';
260
- const controls = config.controls ?? true;
385
+ const title = config.title || titleFromPath(demoPath);
261
386
 
262
387
  return {
388
+ mode,
263
389
  root: demoPath,
264
- meta: { title, author, description },
265
- layout,
266
- controls,
390
+ meta: {
391
+ title,
392
+ author: config.author || null,
393
+ description: config.description || null,
394
+ },
395
+ layout: config.layout ?? DEFAULT_LAYOUT,
396
+ theme: config.theme ?? DEFAULT_THEME,
397
+ controls: config.controls ?? DEFAULT_CONTROLS,
398
+ startPaused: config.startPaused ?? false,
399
+ pixelRatio: config.pixelRatio ?? null,
267
400
  commonSource,
268
401
  passes,
269
402
  textures,
403
+ uniforms: uniforms ?? {},
404
+ script: script ?? null,
270
405
  };
271
406
  }
272
407
 
273
- function normalizeChannel(channelValue: ChannelValue | undefined, texturePathToName?: Map<string, string>): any {
274
- if (!channelValue) {
275
- return { kind: 'none' };
408
+ // =============================================================================
409
+ // Standard Mode with Named Buffers
410
+ // =============================================================================
411
+
412
+ async function loadStandardWithNamedBuffers(
413
+ demoPath: string,
414
+ config: {
415
+ title?: string;
416
+ author?: string;
417
+ description?: string;
418
+ layout?: 'fullscreen' | 'default' | 'split' | 'tabbed';
419
+ theme?: any;
420
+ controls?: boolean;
421
+ common?: string;
422
+ startPaused?: boolean;
423
+ pixelRatio?: number;
424
+ buffers?: string[] | Record<string, StandardBufferConfig>;
425
+ textures?: Record<string, string>;
426
+ },
427
+ glslFiles: Record<string, () => Promise<string>>,
428
+ imageFiles: Record<string, () => Promise<string>>,
429
+ uniforms?: UniformDefinitions,
430
+ script?: DemoScriptHooks | null
431
+ ): Promise<ShaderProject> {
432
+
433
+ const buffersConfig = normalizeBuffersConfig(config.buffers);
434
+ const bufferNames = Object.keys(buffersConfig);
435
+
436
+ if (bufferNames.length > 4) {
437
+ throw new Error(
438
+ `Standard mode at '${demoPath}' supports max 4 buffers, got ${bufferNames.length}: ${bufferNames.join(', ')}`
439
+ );
276
440
  }
277
441
 
278
- // Parse string shorthand
279
- const parsed = parseChannelValue(channelValue);
280
- if (!parsed) {
281
- return { kind: 'none' };
442
+ // Map buffer names → PassNames
443
+ const bufferNameToPass = new Map<string, PassName>();
444
+ for (let i = 0; i < bufferNames.length; i++) {
445
+ bufferNameToPass.set(bufferNames[i], BUFFER_PASS_NAMES[i]);
282
446
  }
283
447
 
284
- if ('buffer' in parsed) {
285
- return {
286
- kind: 'buffer',
287
- buffer: parsed.buffer,
288
- current: !!parsed.current,
289
- };
448
+ // Texture deduplication
449
+ const textureMap = new Map<string, ShaderTexture2D>();
450
+
451
+ function registerTexture(source: string, filter: 'nearest' | 'linear' = 'linear', wrap: 'clamp' | 'repeat' = 'repeat'): string {
452
+ const key = `${source}|${filter}|${wrap}`;
453
+ const existing = textureMap.get(key);
454
+ if (existing) return existing.name;
455
+
456
+ const name = `tex${textureMap.size}`;
457
+ textureMap.set(key, { name, source, filter, wrap });
458
+ return name;
290
459
  }
291
460
 
292
- if ('texture' in parsed) {
293
- const textureName = texturePathToName?.get(parsed.texture) || parsed.texture;
294
- return {
295
- kind: 'texture',
296
- name: textureName,
297
- cubemap: parsed.type === 'cubemap',
298
- };
461
+ // Build namedSamplers map (shared by all passes)
462
+ const namedSamplers = new Map<string, ChannelSource>();
463
+
464
+ // Add buffers
465
+ for (const [bufName, passName] of bufferNameToPass) {
466
+ namedSamplers.set(bufName, { kind: 'buffer', buffer: passName, current: false });
299
467
  }
300
468
 
301
- if ('keyboard' in parsed) {
302
- return { kind: 'keyboard' };
469
+ // Add textures
470
+ for (const [texName, texValue] of Object.entries(config.textures ?? {})) {
471
+ if (texValue === 'keyboard') {
472
+ namedSamplers.set(texName, { kind: 'keyboard' });
473
+ } else if (texValue === 'audio') {
474
+ namedSamplers.set(texName, { kind: 'audio' });
475
+ } else if (texValue === 'webcam') {
476
+ namedSamplers.set(texName, { kind: 'webcam' });
477
+ } else if (/\.\w+$/.test(texValue)) {
478
+ // Image file — resolve via imageFiles
479
+ const fullPath = `${demoPath}/${texValue.replace(/^\.\//, '')}`;
480
+ const actualPath = findFileCaseInsensitive(imageFiles, fullPath);
481
+ if (!actualPath) {
482
+ throw new Error(`Texture not found: ${texValue} (expected at ${fullPath})`);
483
+ }
484
+ const imageUrl = await imageFiles[actualPath]();
485
+ const internalName = registerTexture(imageUrl);
486
+ namedSamplers.set(texName, { kind: 'texture', name: internalName, cubemap: false });
487
+ } else {
488
+ // Script-uploaded texture — name matched by engine.updateTexture() calls
489
+ namedSamplers.set(texName, { kind: 'script', name: texValue });
490
+ }
303
491
  }
304
492
 
305
- return { kind: 'none' };
493
+ const noChannels: Channels = [{ kind: 'none' }, { kind: 'none' }, { kind: 'none' }, { kind: 'none' }];
494
+
495
+ // Load common source
496
+ const commonSource = await loadCommonSource(demoPath, glslFiles, config.common);
497
+
498
+ // Load Image pass
499
+ const imagePath = `${demoPath}/image.glsl`;
500
+ const actualImagePath = findFileCaseInsensitive(glslFiles, imagePath);
501
+ if (!actualImagePath) {
502
+ throw new Error(`Standard mode project at '${demoPath}' requires 'image.glsl'.`);
503
+ }
504
+ const imageSource = await glslFiles[actualImagePath]();
505
+
506
+ const passes: ShaderProject['passes'] = {
507
+ Image: {
508
+ name: 'Image',
509
+ glslSource: imageSource,
510
+ channels: noChannels,
511
+ namedSamplers: new Map(namedSamplers),
512
+ },
513
+ };
514
+
515
+ // Load buffer passes
516
+ for (const [bufName, passName] of bufferNameToPass) {
517
+ const sourcePath = `${demoPath}/${bufName}.glsl`;
518
+ const actualPath = findFileCaseInsensitive(glslFiles, sourcePath);
519
+ if (!actualPath) {
520
+ throw new Error(`Buffer '${bufName}' requires '${bufName}.glsl' in '${demoPath}'.`);
521
+ }
522
+ const glslSource = await glslFiles[actualPath]();
523
+
524
+ passes[passName] = {
525
+ name: passName,
526
+ glslSource,
527
+ channels: noChannels,
528
+ namedSamplers: new Map(namedSamplers),
529
+ };
530
+ }
531
+
532
+ const title = config.title || titleFromPath(demoPath);
533
+
534
+ return {
535
+ mode: 'standard',
536
+ root: demoPath,
537
+ meta: {
538
+ title,
539
+ author: config.author ?? null,
540
+ description: config.description ?? null,
541
+ },
542
+ layout: config.layout ?? DEFAULT_LAYOUT,
543
+ theme: config.theme ?? DEFAULT_THEME,
544
+ controls: config.controls ?? DEFAULT_CONTROLS,
545
+ startPaused: config.startPaused ?? false,
546
+ pixelRatio: config.pixelRatio ?? null,
547
+ commonSource,
548
+ passes,
549
+ textures: Array.from(textureMap.values()),
550
+ uniforms: uniforms ?? {},
551
+ script: script ?? null,
552
+ };
306
553
  }