@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,7 +1,7 @@
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
- import { ShadertoyProject, ShadertoyConfig } from './types';
6
- export declare function loadDemo(demoPath: string, glslFiles: Record<string, () => Promise<string>>, jsonFiles: Record<string, () => Promise<ShadertoyConfig>>, imageFiles: Record<string, () => Promise<string>>): Promise<ShadertoyProject>;
5
+ import { ShaderProject, ProjectConfig } from './types';
6
+ export declare function loadDemo(demoPath: string, glslFiles: Record<string, () => Promise<string>>, jsonFiles: Record<string, () => Promise<ProjectConfig>>, imageFiles: Record<string, () => Promise<string>>, scriptFiles?: Record<string, () => Promise<any>>): Promise<ShaderProject>;
7
7
  //# sourceMappingURL=loaderHelper.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"loaderHelper.d.ts","sourceRoot":"","sources":["../../src/project/loaderHelper.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EACL,gBAAgB,EAChB,eAAe,EAIhB,MAAM,SAAS,CAAC;AA8CjB,wBAAsB,QAAQ,CAC5B,QAAQ,EAAE,MAAM,EAChB,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,OAAO,CAAC,MAAM,CAAC,CAAC,EAChD,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,OAAO,CAAC,eAAe,CAAC,CAAC,EACzD,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,OAAO,CAAC,MAAM,CAAC,CAAC,GAChD,OAAO,CAAC,gBAAgB,CAAC,CAoB3B"}
1
+ {"version":3,"file":"loaderHelper.d.ts","sourceRoot":"","sources":["../../src/project/loaderHelper.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EACL,aAAa,EACb,aAAa,EAQd,MAAM,SAAS,CAAC;AAyJjB,wBAAsB,QAAQ,CAC5B,QAAQ,EAAE,MAAM,EAChB,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,OAAO,CAAC,MAAM,CAAC,CAAC,EAChD,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,OAAO,CAAC,aAAa,CAAC,CAAC,EACvD,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,OAAO,CAAC,MAAM,CAAC,CAAC,EACjD,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,OAAO,CAAC,GAAG,CAAC,CAAC,GAC/C,OAAO,CAAC,aAAa,CAAC,CA0CxB"}
@@ -1,16 +1,18 @@
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
+ import { parseChannelValue, defaultSourceForPass, validateConfig, PASS_ORDER, BUFFER_PASS_NAMES, CHANNEL_KEYS, DEFAULT_LAYOUT, DEFAULT_CONTROLS, DEFAULT_THEME, } from './configHelpers';
6
+ // =============================================================================
7
+ // Case-Insensitive File Lookup
8
+ // =============================================================================
5
9
  /**
6
10
  * Case-insensitive file lookup helper.
7
11
  * Returns the actual key from the record that matches the path (case-insensitive).
8
12
  */
9
13
  function findFileCaseInsensitive(files, path) {
10
- // First try exact match
11
14
  if (path in files)
12
15
  return path;
13
- // Try case-insensitive match
14
16
  const lowerPath = path.toLowerCase();
15
17
  for (const key of Object.keys(files)) {
16
18
  if (key.toLowerCase() === lowerPath) {
@@ -19,70 +21,161 @@ function findFileCaseInsensitive(files, path) {
19
21
  }
20
22
  return null;
21
23
  }
24
+ // =============================================================================
25
+ // Script Loading
26
+ // =============================================================================
22
27
  /**
23
- * Type guard for PassName.
28
+ * Load script.js from a demo folder if present.
24
29
  */
25
- function isPassName(s) {
26
- return s === 'Image' || s === 'BufferA' || s === 'BufferB' || s === 'BufferC' || s === 'BufferD';
30
+ async function loadScript(demoPath, scriptFiles) {
31
+ if (!scriptFiles)
32
+ return null;
33
+ const scriptPath = `${demoPath}/script.js`;
34
+ const actualPath = findFileCaseInsensitive(scriptFiles, scriptPath);
35
+ if (!actualPath)
36
+ return null;
37
+ const mod = await scriptFiles[actualPath]();
38
+ const hooks = {};
39
+ if (typeof mod.setup === 'function')
40
+ hooks.setup = mod.setup;
41
+ if (typeof mod.onFrame === 'function')
42
+ hooks.onFrame = mod.onFrame;
43
+ return (hooks.setup || hooks.onFrame) ? hooks : null;
27
44
  }
45
+ // =============================================================================
46
+ // Common Source Loading
47
+ // =============================================================================
28
48
  /**
29
- * Parse a channel value (string shorthand or object) into normalized ChannelJSONObject.
49
+ * Load common.glsl source (explicit path or default).
30
50
  */
31
- function parseChannelValue(value) {
32
- if (typeof value === 'string') {
33
- if (isPassName(value)) {
34
- return { buffer: value };
35
- }
36
- if (value === 'keyboard') {
37
- return { keyboard: true };
51
+ async function loadCommonSource(demoPath, glslFiles, commonPath) {
52
+ if (commonPath) {
53
+ const fullPath = `${demoPath}/${commonPath}`;
54
+ const actualPath = findFileCaseInsensitive(glslFiles, fullPath);
55
+ return actualPath ? await glslFiles[actualPath]() : null;
56
+ }
57
+ // Check for default common.glsl
58
+ const defaultPath = `${demoPath}/common.glsl`;
59
+ const actualPath = findFileCaseInsensitive(glslFiles, defaultPath);
60
+ return actualPath ? await glslFiles[actualPath]() : null;
61
+ }
62
+ // =============================================================================
63
+ // Channel Normalization
64
+ // =============================================================================
65
+ /**
66
+ * Normalize a channel value into a ChannelSource.
67
+ */
68
+ function normalizeChannel(channelValue, texturePathToName) {
69
+ if (!channelValue)
70
+ return { kind: 'none' };
71
+ const parsed = parseChannelValue(channelValue);
72
+ if (!parsed)
73
+ return { kind: 'none' };
74
+ if ('buffer' in parsed) {
75
+ return { kind: 'buffer', buffer: parsed.buffer, current: !!parsed.current };
76
+ }
77
+ if ('texture' in parsed) {
78
+ const textureName = texturePathToName?.get(parsed.texture) || parsed.texture;
79
+ return { kind: 'texture', name: textureName, cubemap: parsed.type === 'cubemap' };
80
+ }
81
+ if ('keyboard' in parsed)
82
+ return { kind: 'keyboard' };
83
+ if ('audio' in parsed)
84
+ return { kind: 'audio' };
85
+ if ('webcam' in parsed)
86
+ return { kind: 'webcam' };
87
+ if ('video' in parsed)
88
+ return { kind: 'video', src: parsed.video };
89
+ if ('script' in parsed)
90
+ return { kind: 'script', name: parsed.script };
91
+ return { kind: 'none' };
92
+ }
93
+ // =============================================================================
94
+ // Named Buffers Normalization (Standard Mode)
95
+ // =============================================================================
96
+ /**
97
+ * Normalize buffers config: array shorthand to object form.
98
+ */
99
+ function normalizeBuffersConfig(buffers) {
100
+ if (!buffers)
101
+ return {};
102
+ if (Array.isArray(buffers)) {
103
+ const result = {};
104
+ for (const name of buffers) {
105
+ result[name] = {};
38
106
  }
39
- return { texture: value };
107
+ return result;
40
108
  }
41
- return value;
109
+ return buffers;
110
+ }
111
+ // =============================================================================
112
+ // Title Helper
113
+ // =============================================================================
114
+ /**
115
+ * Generate a display title from a demo path.
116
+ * e.g. "./demos/my-shader" → "My Shader"
117
+ */
118
+ function titleFromPath(demoPath) {
119
+ const demoName = demoPath.split('/').pop() || demoPath;
120
+ return demoName.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
42
121
  }
43
- export async function loadDemo(demoPath, glslFiles, jsonFiles, imageFiles) {
44
- // Normalize path - handle both "./shaders/name" and "shaders/name" formats
122
+ // =============================================================================
123
+ // Main Entry Point
124
+ // =============================================================================
125
+ export async function loadDemo(demoPath, glslFiles, jsonFiles, imageFiles, scriptFiles) {
126
+ // Normalize path
45
127
  const normalizedPath = demoPath.startsWith('./') ? demoPath : `./${demoPath}`;
46
128
  const configPath = `${normalizedPath}/config.json`;
47
129
  const hasConfig = configPath in jsonFiles;
48
- if (hasConfig) {
49
- const config = await jsonFiles[configPath]();
50
- const hasPassConfigs = config.Image || config.BufferA || config.BufferB ||
51
- config.BufferC || config.BufferD;
52
- if (hasPassConfigs) {
53
- return loadWithConfig(normalizedPath, config, glslFiles, imageFiles);
54
- }
55
- else {
56
- // Config with only settings (layout, controls, etc.) but no passes
57
- return loadSinglePass(normalizedPath, glslFiles, config);
58
- }
130
+ if (!hasConfig) {
131
+ // No config = simple single-pass project
132
+ return loadSinglePass(normalizedPath, glslFiles, 'standard');
133
+ }
134
+ const config = await jsonFiles[configPath]();
135
+ validateConfig(config, normalizedPath);
136
+ const mode = config.mode === 'shadertoy' ? 'shadertoy' : 'standard';
137
+ // Load script hooks (available in both modes)
138
+ const script = await loadScript(normalizedPath, scriptFiles);
139
+ // Get uniforms (only from standard mode configs)
140
+ const uniforms = mode === 'standard' && 'uniforms' in config ? config.uniforms : undefined;
141
+ // Check if config uses named buffers or textures (standard mode only)
142
+ const hasNamedBuffers = mode === 'standard' && (('buffers' in config && config.buffers) || ('textures' in config && config.textures));
143
+ if (hasNamedBuffers) {
144
+ return loadStandardWithNamedBuffers(normalizedPath, config, glslFiles, imageFiles, uniforms, script);
59
145
  }
60
- else {
61
- return loadSinglePass(normalizedPath, glslFiles);
146
+ // Check for pass-level configs (Image, BufferA, etc.)
147
+ const hasPassConfigs = config.Image || config.BufferA || config.BufferB ||
148
+ config.BufferC || config.BufferD;
149
+ if (hasPassConfigs) {
150
+ return loadWithPassConfigs(normalizedPath, config, glslFiles, imageFiles, mode, uniforms, script);
62
151
  }
152
+ // Config with only settings (layout, controls, etc.) but no passes
153
+ return loadSinglePass(normalizedPath, glslFiles, mode, config, uniforms, script);
63
154
  }
64
- async function loadSinglePass(demoPath, glslFiles, configOverrides) {
155
+ // =============================================================================
156
+ // Single Pass (no pass configs in JSON)
157
+ // =============================================================================
158
+ async function loadSinglePass(demoPath, glslFiles, mode, configOverrides, uniforms, script) {
65
159
  const imagePath = `${demoPath}/image.glsl`;
66
160
  const actualImagePath = findFileCaseInsensitive(glslFiles, imagePath);
67
161
  if (!actualImagePath) {
68
162
  throw new Error(`Demo '${demoPath}' not found. Expected ${imagePath}`);
69
163
  }
70
164
  const imageSource = await glslFiles[actualImagePath]();
71
- const layout = configOverrides?.layout || 'tabbed';
72
- const controls = configOverrides?.controls ?? true;
73
- // Extract name from path for title (e.g., "./shaders/example-gradient" -> "example-gradient")
74
- const demoName = demoPath.split('/').pop() || demoPath;
75
- const title = configOverrides?.title ||
76
- demoName.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
165
+ const title = configOverrides?.title || titleFromPath(demoPath);
77
166
  return {
167
+ mode,
78
168
  root: demoPath,
79
169
  meta: {
80
170
  title,
81
171
  author: configOverrides?.author || null,
82
172
  description: configOverrides?.description || null,
83
173
  },
84
- layout,
85
- controls,
174
+ layout: configOverrides?.layout ?? DEFAULT_LAYOUT,
175
+ theme: configOverrides?.theme ?? DEFAULT_THEME,
176
+ controls: configOverrides?.controls ?? DEFAULT_CONTROLS,
177
+ startPaused: configOverrides?.startPaused ?? false,
178
+ pixelRatio: configOverrides?.pixelRatio ?? null,
86
179
  commonSource: null,
87
180
  passes: {
88
181
  Image: {
@@ -97,10 +190,14 @@ async function loadSinglePass(demoPath, glslFiles, configOverrides) {
97
190
  },
98
191
  },
99
192
  textures: [],
193
+ uniforms: uniforms ?? {},
194
+ script: script ?? null,
100
195
  };
101
196
  }
102
- async function loadWithConfig(demoPath, config, glslFiles, imageFiles) {
103
- // Extract pass configs from top level
197
+ // =============================================================================
198
+ // Pass-Config Mode (both shadertoy and standard with Image/BufferA/etc.)
199
+ // =============================================================================
200
+ async function loadWithPassConfigs(demoPath, config, glslFiles, imageFiles, mode, uniforms, script) {
104
201
  const passConfigs = {
105
202
  Image: config.Image,
106
203
  BufferA: config.BufferA,
@@ -109,42 +206,32 @@ async function loadWithConfig(demoPath, config, glslFiles, imageFiles) {
109
206
  BufferD: config.BufferD,
110
207
  };
111
208
  // Load common source
112
- let commonSource = null;
113
- if (config.common) {
114
- const commonPath = `${demoPath}/${config.common}`;
115
- const actualCommonPath = findFileCaseInsensitive(glslFiles, commonPath);
116
- if (actualCommonPath) {
117
- commonSource = await glslFiles[actualCommonPath]();
118
- }
119
- }
120
- else {
121
- const defaultCommonPath = `${demoPath}/common.glsl`;
122
- const actualCommonPath = findFileCaseInsensitive(glslFiles, defaultCommonPath);
123
- if (actualCommonPath) {
124
- commonSource = await glslFiles[actualCommonPath]();
125
- }
126
- }
127
- // Collect all texture paths
128
- const texturePathsSet = new Set();
129
- const passOrder = ['Image', 'BufferA', 'BufferB', 'BufferC', 'BufferD'];
130
- for (const passName of passOrder) {
209
+ const commonSource = await loadCommonSource(demoPath, glslFiles, config.common);
210
+ const textureRefs = new Map();
211
+ for (const passName of PASS_ORDER) {
131
212
  const passConfig = passConfigs[passName];
132
213
  if (!passConfig)
133
214
  continue;
134
- for (const channelKey of ['iChannel0', 'iChannel1', 'iChannel2', 'iChannel3']) {
215
+ for (const channelKey of CHANNEL_KEYS) {
135
216
  const channelValue = passConfig[channelKey];
136
217
  if (!channelValue)
137
218
  continue;
138
219
  const parsed = parseChannelValue(channelValue);
139
220
  if (parsed && 'texture' in parsed) {
140
- texturePathsSet.add(parsed.texture);
221
+ if (!textureRefs.has(parsed.texture)) {
222
+ textureRefs.set(parsed.texture, {
223
+ path: parsed.texture,
224
+ filter: parsed.filter ?? 'linear',
225
+ wrap: parsed.wrap ?? 'repeat',
226
+ });
227
+ }
141
228
  }
142
229
  }
143
230
  }
144
231
  // Load textures
145
232
  const textures = [];
146
233
  const texturePathToName = new Map();
147
- for (const texturePath of texturePathsSet) {
234
+ for (const [texturePath, ref] of textureRefs) {
148
235
  const fullPath = `${demoPath}/${texturePath.replace(/^\.\//, '')}`;
149
236
  const actualPath = findFileCaseInsensitive(imageFiles, fullPath);
150
237
  if (!actualPath) {
@@ -155,27 +242,20 @@ async function loadWithConfig(demoPath, config, glslFiles, imageFiles) {
155
242
  const textureName = textureFilename.replace(/\.[^.]+$/, '');
156
243
  textures.push({
157
244
  name: textureName,
158
- filename: textureFilename, // Preserve original filename for display
245
+ filename: textureFilename,
159
246
  source: imageUrl,
160
- filter: 'linear',
161
- wrap: 'repeat',
247
+ filter: ref.filter,
248
+ wrap: ref.wrap,
162
249
  });
163
250
  texturePathToName.set(texturePath, textureName);
164
251
  }
165
252
  // Build passes
166
253
  const passes = {};
167
- for (const passName of passOrder) {
254
+ for (const passName of PASS_ORDER) {
168
255
  const passConfig = passConfigs[passName];
169
256
  if (!passConfig)
170
257
  continue;
171
- const defaultNames = {
172
- Image: 'image.glsl',
173
- BufferA: 'bufferA.glsl',
174
- BufferB: 'bufferB.glsl',
175
- BufferC: 'bufferC.glsl',
176
- BufferD: 'bufferD.glsl',
177
- };
178
- const sourceFile = passConfig.source || defaultNames[passName];
258
+ const sourceFile = passConfig.source || defaultSourceForPass(passName);
179
259
  const sourcePath = `${demoPath}/${sourceFile}`;
180
260
  const actualSourcePath = findFileCaseInsensitive(glslFiles, sourcePath);
181
261
  if (!actualSourcePath) {
@@ -188,59 +268,141 @@ async function loadWithConfig(demoPath, config, glslFiles, imageFiles) {
188
268
  normalizeChannel(passConfig.iChannel2, texturePathToName),
189
269
  normalizeChannel(passConfig.iChannel3, texturePathToName),
190
270
  ];
191
- passes[passName] = {
192
- name: passName,
193
- glslSource,
194
- channels,
195
- };
271
+ passes[passName] = { name: passName, glslSource, channels };
196
272
  }
197
273
  if (!passes.Image) {
198
274
  throw new Error(`Demo '${demoPath}' must have an Image pass`);
199
275
  }
200
- // Extract name from path for title (e.g., "./shaders/example-gradient" -> "example-gradient")
201
- const demoName = demoPath.split('/').pop() || demoPath;
202
- const title = config.title ||
203
- demoName.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
204
- const author = config.author || null;
205
- const description = config.description || null;
206
- const layout = config.layout || 'tabbed';
207
- const controls = config.controls ?? true;
276
+ const title = config.title || titleFromPath(demoPath);
208
277
  return {
278
+ mode,
209
279
  root: demoPath,
210
- meta: { title, author, description },
211
- layout,
212
- controls,
280
+ meta: {
281
+ title,
282
+ author: config.author || null,
283
+ description: config.description || null,
284
+ },
285
+ layout: config.layout ?? DEFAULT_LAYOUT,
286
+ theme: config.theme ?? DEFAULT_THEME,
287
+ controls: config.controls ?? DEFAULT_CONTROLS,
288
+ startPaused: config.startPaused ?? false,
289
+ pixelRatio: config.pixelRatio ?? null,
213
290
  commonSource,
214
291
  passes,
215
292
  textures,
293
+ uniforms: uniforms ?? {},
294
+ script: script ?? null,
216
295
  };
217
296
  }
218
- function normalizeChannel(channelValue, texturePathToName) {
219
- if (!channelValue) {
220
- return { kind: 'none' };
297
+ // =============================================================================
298
+ // Standard Mode with Named Buffers
299
+ // =============================================================================
300
+ async function loadStandardWithNamedBuffers(demoPath, config, glslFiles, imageFiles, uniforms, script) {
301
+ const buffersConfig = normalizeBuffersConfig(config.buffers);
302
+ const bufferNames = Object.keys(buffersConfig);
303
+ if (bufferNames.length > 4) {
304
+ throw new Error(`Standard mode at '${demoPath}' supports max 4 buffers, got ${bufferNames.length}: ${bufferNames.join(', ')}`);
221
305
  }
222
- // Parse string shorthand
223
- const parsed = parseChannelValue(channelValue);
224
- if (!parsed) {
225
- return { kind: 'none' };
306
+ // Map buffer names → PassNames
307
+ const bufferNameToPass = new Map();
308
+ for (let i = 0; i < bufferNames.length; i++) {
309
+ bufferNameToPass.set(bufferNames[i], BUFFER_PASS_NAMES[i]);
226
310
  }
227
- if ('buffer' in parsed) {
228
- return {
229
- kind: 'buffer',
230
- buffer: parsed.buffer,
231
- current: !!parsed.current,
232
- };
311
+ // Texture deduplication
312
+ const textureMap = new Map();
313
+ function registerTexture(source, filter = 'linear', wrap = 'repeat') {
314
+ const key = `${source}|${filter}|${wrap}`;
315
+ const existing = textureMap.get(key);
316
+ if (existing)
317
+ return existing.name;
318
+ const name = `tex${textureMap.size}`;
319
+ textureMap.set(key, { name, source, filter, wrap });
320
+ return name;
233
321
  }
234
- if ('texture' in parsed) {
235
- const textureName = texturePathToName?.get(parsed.texture) || parsed.texture;
236
- return {
237
- kind: 'texture',
238
- name: textureName,
239
- cubemap: parsed.type === 'cubemap',
240
- };
322
+ // Build namedSamplers map (shared by all passes)
323
+ const namedSamplers = new Map();
324
+ // Add buffers
325
+ for (const [bufName, passName] of bufferNameToPass) {
326
+ namedSamplers.set(bufName, { kind: 'buffer', buffer: passName, current: false });
241
327
  }
242
- if ('keyboard' in parsed) {
243
- return { kind: 'keyboard' };
328
+ // Add textures
329
+ for (const [texName, texValue] of Object.entries(config.textures ?? {})) {
330
+ if (texValue === 'keyboard') {
331
+ namedSamplers.set(texName, { kind: 'keyboard' });
332
+ }
333
+ else if (texValue === 'audio') {
334
+ namedSamplers.set(texName, { kind: 'audio' });
335
+ }
336
+ else if (texValue === 'webcam') {
337
+ namedSamplers.set(texName, { kind: 'webcam' });
338
+ }
339
+ else if (/\.\w+$/.test(texValue)) {
340
+ // Image file — resolve via imageFiles
341
+ const fullPath = `${demoPath}/${texValue.replace(/^\.\//, '')}`;
342
+ const actualPath = findFileCaseInsensitive(imageFiles, fullPath);
343
+ if (!actualPath) {
344
+ throw new Error(`Texture not found: ${texValue} (expected at ${fullPath})`);
345
+ }
346
+ const imageUrl = await imageFiles[actualPath]();
347
+ const internalName = registerTexture(imageUrl);
348
+ namedSamplers.set(texName, { kind: 'texture', name: internalName, cubemap: false });
349
+ }
350
+ else {
351
+ // Script-uploaded texture — name matched by engine.updateTexture() calls
352
+ namedSamplers.set(texName, { kind: 'script', name: texValue });
353
+ }
244
354
  }
245
- return { kind: 'none' };
355
+ const noChannels = [{ kind: 'none' }, { kind: 'none' }, { kind: 'none' }, { kind: 'none' }];
356
+ // Load common source
357
+ const commonSource = await loadCommonSource(demoPath, glslFiles, config.common);
358
+ // Load Image pass
359
+ const imagePath = `${demoPath}/image.glsl`;
360
+ const actualImagePath = findFileCaseInsensitive(glslFiles, imagePath);
361
+ if (!actualImagePath) {
362
+ throw new Error(`Standard mode project at '${demoPath}' requires 'image.glsl'.`);
363
+ }
364
+ const imageSource = await glslFiles[actualImagePath]();
365
+ const passes = {
366
+ Image: {
367
+ name: 'Image',
368
+ glslSource: imageSource,
369
+ channels: noChannels,
370
+ namedSamplers: new Map(namedSamplers),
371
+ },
372
+ };
373
+ // Load buffer passes
374
+ for (const [bufName, passName] of bufferNameToPass) {
375
+ const sourcePath = `${demoPath}/${bufName}.glsl`;
376
+ const actualPath = findFileCaseInsensitive(glslFiles, sourcePath);
377
+ if (!actualPath) {
378
+ throw new Error(`Buffer '${bufName}' requires '${bufName}.glsl' in '${demoPath}'.`);
379
+ }
380
+ const glslSource = await glslFiles[actualPath]();
381
+ passes[passName] = {
382
+ name: passName,
383
+ glslSource,
384
+ channels: noChannels,
385
+ namedSamplers: new Map(namedSamplers),
386
+ };
387
+ }
388
+ const title = config.title || titleFromPath(demoPath);
389
+ return {
390
+ mode: 'standard',
391
+ root: demoPath,
392
+ meta: {
393
+ title,
394
+ author: config.author ?? null,
395
+ description: config.description ?? null,
396
+ },
397
+ layout: config.layout ?? DEFAULT_LAYOUT,
398
+ theme: config.theme ?? DEFAULT_THEME,
399
+ controls: config.controls ?? DEFAULT_CONTROLS,
400
+ startPaused: config.startPaused ?? false,
401
+ pixelRatio: config.pixelRatio ?? null,
402
+ commonSource,
403
+ passes,
404
+ textures: Array.from(textureMap.values()),
405
+ uniforms: uniforms ?? {},
406
+ script: script ?? null,
407
+ };
246
408
  }