@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.
- package/README.md +220 -23
- package/bin/cli.js +106 -14
- package/dist-lib/app/App.d.ts +143 -15
- package/dist-lib/app/App.d.ts.map +1 -1
- package/dist-lib/app/App.js +1343 -108
- package/dist-lib/app/app.css +349 -24
- package/dist-lib/app/types.d.ts +48 -5
- package/dist-lib/app/types.d.ts.map +1 -1
- package/dist-lib/editor/EditorPanel.d.ts +2 -2
- package/dist-lib/editor/EditorPanel.d.ts.map +1 -1
- package/dist-lib/editor/EditorPanel.js +1 -1
- package/dist-lib/editor/editor-panel.css +55 -32
- package/dist-lib/editor/prism-editor.css +16 -16
- package/dist-lib/embed.js +1 -1
- package/dist-lib/engine/{ShadertoyEngine.d.ts → ShaderEngine.d.ts} +134 -10
- package/dist-lib/engine/ShaderEngine.d.ts.map +1 -0
- package/dist-lib/engine/ShaderEngine.js +1523 -0
- package/dist-lib/engine/glHelpers.d.ts +24 -0
- package/dist-lib/engine/glHelpers.d.ts.map +1 -1
- package/dist-lib/engine/glHelpers.js +88 -0
- package/dist-lib/engine/std140.d.ts +47 -0
- package/dist-lib/engine/std140.d.ts.map +1 -0
- package/dist-lib/engine/std140.js +119 -0
- package/dist-lib/engine/types.d.ts +55 -5
- package/dist-lib/engine/types.d.ts.map +1 -1
- package/dist-lib/engine/types.js +1 -1
- package/dist-lib/index.d.ts +4 -3
- package/dist-lib/index.d.ts.map +1 -1
- package/dist-lib/index.js +2 -1
- package/dist-lib/layouts/SplitLayout.d.ts +2 -1
- package/dist-lib/layouts/SplitLayout.d.ts.map +1 -1
- package/dist-lib/layouts/SplitLayout.js +3 -0
- package/dist-lib/layouts/TabbedLayout.d.ts.map +1 -1
- package/dist-lib/layouts/UILayout.d.ts +55 -0
- package/dist-lib/layouts/UILayout.d.ts.map +1 -0
- package/dist-lib/layouts/UILayout.js +147 -0
- package/dist-lib/layouts/default.css +2 -2
- package/dist-lib/layouts/index.d.ts +11 -1
- package/dist-lib/layouts/index.d.ts.map +1 -1
- package/dist-lib/layouts/index.js +17 -1
- package/dist-lib/layouts/split.css +33 -31
- package/dist-lib/layouts/tabbed.css +127 -74
- package/dist-lib/layouts/types.d.ts +14 -3
- package/dist-lib/layouts/types.d.ts.map +1 -1
- package/dist-lib/main.js +33 -0
- package/dist-lib/project/configHelpers.d.ts +45 -0
- package/dist-lib/project/configHelpers.d.ts.map +1 -0
- package/dist-lib/project/configHelpers.js +196 -0
- package/dist-lib/project/generatedLoader.d.ts +2 -2
- package/dist-lib/project/generatedLoader.d.ts.map +1 -1
- package/dist-lib/project/generatedLoader.js +23 -5
- package/dist-lib/project/loadProject.d.ts +6 -6
- package/dist-lib/project/loadProject.d.ts.map +1 -1
- package/dist-lib/project/loadProject.js +396 -144
- package/dist-lib/project/loaderHelper.d.ts +4 -4
- package/dist-lib/project/loaderHelper.d.ts.map +1 -1
- package/dist-lib/project/loaderHelper.js +278 -116
- package/dist-lib/project/types.d.ts +292 -13
- package/dist-lib/project/types.d.ts.map +1 -1
- package/dist-lib/project/types.js +13 -1
- package/dist-lib/styles/base.css +5 -1
- package/dist-lib/uniforms/UniformControls.d.ts +60 -0
- package/dist-lib/uniforms/UniformControls.d.ts.map +1 -0
- package/dist-lib/uniforms/UniformControls.js +518 -0
- package/dist-lib/uniforms/UniformStore.d.ts +74 -0
- package/dist-lib/uniforms/UniformStore.d.ts.map +1 -0
- package/dist-lib/uniforms/UniformStore.js +145 -0
- package/dist-lib/uniforms/UniformsPanel.d.ts +53 -0
- package/dist-lib/uniforms/UniformsPanel.d.ts.map +1 -0
- package/dist-lib/uniforms/UniformsPanel.js +124 -0
- package/dist-lib/uniforms/index.d.ts +11 -0
- package/dist-lib/uniforms/index.d.ts.map +1 -0
- package/dist-lib/uniforms/index.js +8 -0
- package/package.json +1 -1
- package/src/app/App.ts +1469 -126
- package/src/app/app.css +349 -24
- package/src/app/types.ts +53 -5
- package/src/editor/EditorPanel.ts +5 -5
- package/src/editor/editor-panel.css +55 -32
- package/src/editor/prism-editor.css +16 -16
- package/src/embed.ts +1 -1
- package/src/engine/ShaderEngine.ts +1934 -0
- package/src/engine/glHelpers.ts +117 -0
- package/src/engine/std140.ts +136 -0
- package/src/engine/types.ts +69 -5
- package/src/index.ts +4 -3
- package/src/layouts/SplitLayout.ts +8 -3
- package/src/layouts/TabbedLayout.ts +3 -3
- package/src/layouts/UILayout.ts +185 -0
- package/src/layouts/default.css +2 -2
- package/src/layouts/index.ts +20 -1
- package/src/layouts/split.css +33 -31
- package/src/layouts/tabbed.css +127 -74
- package/src/layouts/types.ts +19 -3
- package/src/layouts/ui.css +289 -0
- package/src/main.ts +39 -1
- package/src/project/configHelpers.ts +225 -0
- package/src/project/generatedLoader.ts +27 -6
- package/src/project/loadProject.ts +459 -173
- package/src/project/loaderHelper.ts +377 -130
- package/src/project/types.ts +360 -14
- package/src/styles/base.css +5 -1
- package/src/styles/theme.css +292 -0
- package/src/uniforms/UniformControls.ts +660 -0
- package/src/uniforms/UniformStore.ts +166 -0
- package/src/uniforms/UniformsPanel.ts +163 -0
- package/src/uniforms/index.ts +13 -0
- package/src/uniforms/uniform-controls.css +342 -0
- package/src/uniforms/uniforms-panel.css +277 -0
- package/templates/shaders/example-buffer/config.json +1 -0
- package/dist-lib/engine/ShadertoyEngine.d.ts.map +0 -1
- package/dist-lib/engine/ShadertoyEngine.js +0 -704
- package/src/engine/ShadertoyEngine.ts +0 -929
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Project Layer - Config Loader
|
|
2
|
+
* Project Layer - Config Loader (Node/CLI)
|
|
3
3
|
*
|
|
4
|
-
* Loads
|
|
4
|
+
* Loads shader projects from disk into normalized ShaderProject representation.
|
|
5
5
|
* Handles both single-pass (no config) and multi-pass (with config) projects.
|
|
6
6
|
*
|
|
7
7
|
* Based on docs/project-spec.md
|
|
8
8
|
*/
|
|
9
9
|
import { promises as fs } from 'fs';
|
|
10
10
|
import * as path from 'path';
|
|
11
|
+
import { isArrayUniform, } from './types';
|
|
12
|
+
import { isPassName, parseChannelValue, defaultSourceForPass, validateConfig, CHANNEL_KEYS, BUFFER_PASS_NAMES, DEFAULT_LAYOUT, DEFAULT_CONTROLS, DEFAULT_THEME, } from './configHelpers';
|
|
11
13
|
// =============================================================================
|
|
12
14
|
// Helper Functions
|
|
13
15
|
// =============================================================================
|
|
@@ -23,12 +25,6 @@ async function fileExists(p) {
|
|
|
23
25
|
return false;
|
|
24
26
|
}
|
|
25
27
|
}
|
|
26
|
-
/**
|
|
27
|
-
* Type guard for PassName.
|
|
28
|
-
*/
|
|
29
|
-
function isPassName(s) {
|
|
30
|
-
return s === 'Image' || s === 'BufferA' || s === 'BufferB' || s === 'BufferC' || s === 'BufferD';
|
|
31
|
-
}
|
|
32
28
|
/**
|
|
33
29
|
* List all .glsl files in a directory.
|
|
34
30
|
*/
|
|
@@ -49,41 +45,40 @@ async function hasTexturesDirWithFiles(root) {
|
|
|
49
45
|
return entries.some((e) => e.isFile());
|
|
50
46
|
}
|
|
51
47
|
/**
|
|
52
|
-
*
|
|
48
|
+
* Resolve common source from config or default common.glsl.
|
|
53
49
|
*/
|
|
54
|
-
function
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
return 'bufferD.glsl';
|
|
50
|
+
async function resolveCommonSource(root, commonField) {
|
|
51
|
+
if (commonField) {
|
|
52
|
+
const commonPath = path.join(root, commonField);
|
|
53
|
+
if (!(await fileExists(commonPath))) {
|
|
54
|
+
throw new Error(`Common GLSL file '${commonField}' not found in '${root}'.`);
|
|
55
|
+
}
|
|
56
|
+
return await fs.readFile(commonPath, 'utf8');
|
|
57
|
+
}
|
|
58
|
+
const defaultCommonPath = path.join(root, 'common.glsl');
|
|
59
|
+
if (await fileExists(defaultCommonPath)) {
|
|
60
|
+
return await fs.readFile(defaultCommonPath, 'utf8');
|
|
66
61
|
}
|
|
62
|
+
return null;
|
|
67
63
|
}
|
|
68
64
|
// =============================================================================
|
|
69
65
|
// Main Entry Point
|
|
70
66
|
// =============================================================================
|
|
71
67
|
/**
|
|
72
|
-
* Load a
|
|
68
|
+
* Load a shader project from disk.
|
|
73
69
|
*
|
|
74
70
|
* Automatically detects:
|
|
75
71
|
* - Single-pass mode (no config, just image.glsl)
|
|
76
72
|
* - Multi-pass mode (config.json present)
|
|
77
73
|
*
|
|
78
74
|
* @param root - Absolute path to project directory
|
|
79
|
-
* @returns Fully normalized
|
|
75
|
+
* @returns Fully normalized ShaderProject
|
|
80
76
|
* @throws Error with descriptive message if project is invalid
|
|
81
77
|
*/
|
|
82
78
|
export async function loadProject(root) {
|
|
83
79
|
const configPath = path.join(root, 'config.json');
|
|
84
80
|
const hasConfig = await fileExists(configPath);
|
|
85
81
|
if (hasConfig) {
|
|
86
|
-
// Multi-pass mode: parse config
|
|
87
82
|
const raw = await fs.readFile(configPath, 'utf8');
|
|
88
83
|
let config;
|
|
89
84
|
try {
|
|
@@ -92,10 +87,13 @@ export async function loadProject(root) {
|
|
|
92
87
|
catch (err) {
|
|
93
88
|
throw new Error(`Invalid JSON in config.json at '${root}': ${err?.message ?? String(err)}`);
|
|
94
89
|
}
|
|
95
|
-
|
|
90
|
+
validateConfig(config, root);
|
|
91
|
+
if (config.mode === 'shadertoy') {
|
|
92
|
+
return await loadShadertoyProject(root, config);
|
|
93
|
+
}
|
|
94
|
+
return await loadStandardProject(root, config);
|
|
96
95
|
}
|
|
97
96
|
else {
|
|
98
|
-
// Single-pass mode: just image.glsl
|
|
99
97
|
return await loadSinglePassProject(root);
|
|
100
98
|
}
|
|
101
99
|
}
|
|
@@ -110,37 +108,31 @@ export async function loadProject(root) {
|
|
|
110
108
|
* - Cannot have other .glsl files
|
|
111
109
|
* - Cannot have textures/ directory
|
|
112
110
|
* - No common.glsl allowed
|
|
113
|
-
*
|
|
114
|
-
* @param root - Project directory
|
|
115
|
-
* @returns ShadertoyProject with only Image pass
|
|
116
111
|
*/
|
|
117
112
|
async function loadSinglePassProject(root) {
|
|
118
113
|
const imagePath = path.join(root, 'image.glsl');
|
|
119
114
|
if (!(await fileExists(imagePath))) {
|
|
120
115
|
throw new Error(`Single-pass project at '${root}' requires 'image.glsl'.`);
|
|
121
116
|
}
|
|
122
|
-
// Check for extra GLSL files
|
|
123
117
|
const glslFiles = await listGlslFiles(root);
|
|
124
118
|
const extraGlsl = glslFiles.filter((name) => name !== 'image.glsl');
|
|
125
119
|
if (extraGlsl.length > 0) {
|
|
126
120
|
throw new Error(`Project at '${root}' contains multiple GLSL files (${glslFiles.join(', ')}) but no 'config.json'. Add a config file to use multiple passes.`);
|
|
127
121
|
}
|
|
128
|
-
// Check for textures
|
|
129
122
|
if (await hasTexturesDirWithFiles(root)) {
|
|
130
123
|
throw new Error(`Project at '${root}' uses textures (in 'textures/' folder) but has no 'config.json'. Add a config file to define texture bindings.`);
|
|
131
124
|
}
|
|
132
|
-
// Load shader source
|
|
133
125
|
const imageSource = await fs.readFile(imagePath, 'utf8');
|
|
134
126
|
const title = path.basename(root);
|
|
135
|
-
|
|
127
|
+
return {
|
|
128
|
+
mode: 'standard',
|
|
136
129
|
root,
|
|
137
|
-
meta: {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
controls: false,
|
|
130
|
+
meta: { title, author: null, description: null },
|
|
131
|
+
layout: DEFAULT_LAYOUT,
|
|
132
|
+
theme: DEFAULT_THEME,
|
|
133
|
+
controls: DEFAULT_CONTROLS,
|
|
134
|
+
startPaused: false,
|
|
135
|
+
pixelRatio: null,
|
|
144
136
|
commonSource: null,
|
|
145
137
|
passes: {
|
|
146
138
|
Image: {
|
|
@@ -155,45 +147,85 @@ async function loadSinglePassProject(root) {
|
|
|
155
147
|
},
|
|
156
148
|
},
|
|
157
149
|
textures: [],
|
|
150
|
+
uniforms: {},
|
|
151
|
+
script: null,
|
|
158
152
|
};
|
|
159
|
-
return project;
|
|
160
153
|
}
|
|
161
154
|
// =============================================================================
|
|
162
|
-
//
|
|
155
|
+
// Uniform Validation
|
|
163
156
|
// =============================================================================
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
if (
|
|
176
|
-
|
|
157
|
+
const SCALAR_TYPES = new Set(['float', 'int', 'bool', 'vec2', 'vec3', 'vec4']);
|
|
158
|
+
const ARRAY_TYPES = new Set(['float', 'vec2', 'vec3', 'vec4', 'mat3', 'mat4']);
|
|
159
|
+
const COMPONENT_COUNTS = {
|
|
160
|
+
vec2: 2, vec3: 3, vec4: 4,
|
|
161
|
+
};
|
|
162
|
+
function validateUniforms(uniforms, root) {
|
|
163
|
+
for (const [name, def] of Object.entries(uniforms)) {
|
|
164
|
+
const prefix = `Uniform '${name}' in '${root}'`;
|
|
165
|
+
if (!def.type) {
|
|
166
|
+
throw new Error(`${prefix}: missing 'type' field`);
|
|
167
|
+
}
|
|
168
|
+
if (isArrayUniform(def)) {
|
|
169
|
+
if (!ARRAY_TYPES.has(def.type)) {
|
|
170
|
+
throw new Error(`${prefix}: invalid array type '${def.type}'. Expected one of: ${[...ARRAY_TYPES].join(', ')}`);
|
|
171
|
+
}
|
|
172
|
+
if (typeof def.count !== 'number' || def.count < 1 || !Number.isInteger(def.count)) {
|
|
173
|
+
throw new Error(`${prefix}: 'count' must be a positive integer, got ${def.count}`);
|
|
174
|
+
}
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (!SCALAR_TYPES.has(def.type)) {
|
|
178
|
+
throw new Error(`${prefix}: invalid type '${def.type}'. Expected one of: ${[...SCALAR_TYPES].join(', ')}`);
|
|
177
179
|
}
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
180
|
+
switch (def.type) {
|
|
181
|
+
case 'float':
|
|
182
|
+
case 'int':
|
|
183
|
+
if (typeof def.value !== 'number') {
|
|
184
|
+
throw new Error(`${prefix}: 'value' must be a number for type '${def.type}', got ${typeof def.value}`);
|
|
185
|
+
}
|
|
186
|
+
if (def.min !== undefined && typeof def.min !== 'number') {
|
|
187
|
+
throw new Error(`${prefix}: 'min' must be a number`);
|
|
188
|
+
}
|
|
189
|
+
if (def.max !== undefined && typeof def.max !== 'number') {
|
|
190
|
+
throw new Error(`${prefix}: 'max' must be a number`);
|
|
191
|
+
}
|
|
192
|
+
if (def.step !== undefined && typeof def.step !== 'number') {
|
|
193
|
+
throw new Error(`${prefix}: 'step' must be a number`);
|
|
194
|
+
}
|
|
195
|
+
break;
|
|
196
|
+
case 'bool':
|
|
197
|
+
if (typeof def.value !== 'boolean') {
|
|
198
|
+
throw new Error(`${prefix}: 'value' must be a boolean for type 'bool', got ${typeof def.value}`);
|
|
199
|
+
}
|
|
200
|
+
break;
|
|
201
|
+
case 'vec2':
|
|
202
|
+
case 'vec3':
|
|
203
|
+
case 'vec4': {
|
|
204
|
+
const n = COMPONENT_COUNTS[def.type];
|
|
205
|
+
if (!Array.isArray(def.value) || def.value.length !== n) {
|
|
206
|
+
throw new Error(`${prefix}: 'value' must be an array of ${n} numbers for type '${def.type}'`);
|
|
207
|
+
}
|
|
208
|
+
if (def.value.some((v) => typeof v !== 'number')) {
|
|
209
|
+
throw new Error(`${prefix}: all components of 'value' must be numbers`);
|
|
210
|
+
}
|
|
211
|
+
const vecDef = def;
|
|
212
|
+
for (const field of ['min', 'max', 'step']) {
|
|
213
|
+
const arr = vecDef[field];
|
|
214
|
+
if (arr !== undefined) {
|
|
215
|
+
if (!Array.isArray(arr) || arr.length !== n) {
|
|
216
|
+
throw new Error(`${prefix}: '${field}' must be an array of ${n} numbers for type '${def.type}'`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
181
222
|
}
|
|
182
|
-
// Assume texture (file path)
|
|
183
|
-
return { texture: value };
|
|
184
223
|
}
|
|
185
|
-
// Already an object
|
|
186
|
-
return value;
|
|
187
224
|
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
* @param config - Parsed JSON config
|
|
193
|
-
* @returns Normalized ShadertoyProject
|
|
194
|
-
*/
|
|
195
|
-
async function loadProjectWithConfig(root, config) {
|
|
196
|
-
// Extract pass configs from top level
|
|
225
|
+
// =============================================================================
|
|
226
|
+
// Shadertoy Mode (iChannel-based)
|
|
227
|
+
// =============================================================================
|
|
228
|
+
async function loadShadertoyProject(root, config) {
|
|
197
229
|
const passConfigs = {
|
|
198
230
|
Image: config.Image,
|
|
199
231
|
BufferA: config.BufferA,
|
|
@@ -201,86 +233,224 @@ async function loadProjectWithConfig(root, config) {
|
|
|
201
233
|
BufferC: config.BufferC,
|
|
202
234
|
BufferD: config.BufferD,
|
|
203
235
|
};
|
|
204
|
-
// Validate: must have Image pass (or be empty config for simple shader)
|
|
205
236
|
const hasAnyPass = passConfigs.Image || passConfigs.BufferA || passConfigs.BufferB ||
|
|
206
237
|
passConfigs.BufferC || passConfigs.BufferD;
|
|
207
238
|
if (!hasAnyPass) {
|
|
208
|
-
// Empty config = simple Image pass with no channels
|
|
209
239
|
passConfigs.Image = {};
|
|
210
240
|
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
if (config.common) {
|
|
214
|
-
const commonPath = path.join(root, config.common);
|
|
215
|
-
if (!(await fileExists(commonPath))) {
|
|
216
|
-
throw new Error(`Common GLSL file '${config.common}' not found in '${root}'.`);
|
|
217
|
-
}
|
|
218
|
-
commonSource = await fs.readFile(commonPath, 'utf8');
|
|
219
|
-
}
|
|
220
|
-
else {
|
|
221
|
-
// Check for default common.glsl
|
|
222
|
-
const defaultCommonPath = path.join(root, 'common.glsl');
|
|
223
|
-
if (await fileExists(defaultCommonPath)) {
|
|
224
|
-
commonSource = await fs.readFile(defaultCommonPath, 'utf8');
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
// Texture deduplication map
|
|
241
|
+
const commonSource = await resolveCommonSource(root, config.common);
|
|
242
|
+
// Texture deduplication
|
|
228
243
|
const textureMap = new Map();
|
|
229
|
-
/**
|
|
230
|
-
* Register a texture and return its internal name.
|
|
231
|
-
*/
|
|
232
244
|
function registerTexture(j) {
|
|
233
245
|
const filter = j.filter ?? 'linear';
|
|
234
246
|
const wrap = j.wrap ?? 'repeat';
|
|
235
247
|
const key = `${j.texture}|${filter}|${wrap}`;
|
|
236
|
-
|
|
237
|
-
if (existing)
|
|
248
|
+
const existing = textureMap.get(key);
|
|
249
|
+
if (existing)
|
|
238
250
|
return existing.name;
|
|
239
|
-
}
|
|
240
251
|
const name = `tex${textureMap.size}`;
|
|
241
|
-
|
|
242
|
-
name,
|
|
243
|
-
source: j.texture,
|
|
244
|
-
filter,
|
|
245
|
-
wrap,
|
|
246
|
-
};
|
|
247
|
-
textureMap.set(key, tex);
|
|
252
|
+
textureMap.set(key, { name, source: j.texture, filter, wrap });
|
|
248
253
|
return name;
|
|
249
254
|
}
|
|
250
|
-
/**
|
|
251
|
-
* Parse a channel object into ChannelSource.
|
|
252
|
-
*/
|
|
253
255
|
function parseChannelObject(value, passName, channelKey) {
|
|
254
|
-
// Buffer channel
|
|
255
256
|
if ('buffer' in value) {
|
|
256
257
|
const buf = value.buffer;
|
|
257
258
|
if (!isPassName(buf)) {
|
|
258
259
|
throw new Error(`Invalid buffer name '${buf}' for ${channelKey} in pass '${passName}' at '${root}'.`);
|
|
259
260
|
}
|
|
260
|
-
return {
|
|
261
|
-
kind: 'buffer',
|
|
262
|
-
buffer: buf,
|
|
263
|
-
current: !!value.current,
|
|
264
|
-
};
|
|
261
|
+
return { kind: 'buffer', buffer: buf, current: !!value.current };
|
|
265
262
|
}
|
|
266
|
-
// Texture channel
|
|
267
263
|
if ('texture' in value) {
|
|
268
|
-
|
|
269
|
-
return {
|
|
270
|
-
kind: 'texture',
|
|
271
|
-
name: internalName,
|
|
272
|
-
cubemap: value.type === 'cubemap',
|
|
273
|
-
};
|
|
264
|
+
return { kind: 'texture', name: registerTexture(value), cubemap: value.type === 'cubemap' };
|
|
274
265
|
}
|
|
275
|
-
|
|
276
|
-
if ('keyboard' in value) {
|
|
266
|
+
if ('keyboard' in value)
|
|
277
267
|
return { kind: 'keyboard' };
|
|
268
|
+
if ('audio' in value)
|
|
269
|
+
return { kind: 'audio' };
|
|
270
|
+
if ('webcam' in value)
|
|
271
|
+
return { kind: 'webcam' };
|
|
272
|
+
if ('video' in value)
|
|
273
|
+
return { kind: 'video', src: value.video };
|
|
274
|
+
if ('script' in value)
|
|
275
|
+
return { kind: 'script', name: value.script };
|
|
276
|
+
throw new Error(`Invalid channel object for ${channelKey} in pass '${passName}' at '${root}'.`);
|
|
277
|
+
}
|
|
278
|
+
async function loadPass(name, passConfig) {
|
|
279
|
+
if (!passConfig)
|
|
280
|
+
return undefined;
|
|
281
|
+
const sourceRel = passConfig.source ?? defaultSourceForPass(name);
|
|
282
|
+
const sourcePath = path.join(root, sourceRel);
|
|
283
|
+
if (!(await fileExists(sourcePath))) {
|
|
284
|
+
throw new Error(`Source GLSL file for pass '${name}' not found at '${sourceRel}' in '${root}'.`);
|
|
285
|
+
}
|
|
286
|
+
const glslSource = await fs.readFile(sourcePath, 'utf8');
|
|
287
|
+
const channelSources = [];
|
|
288
|
+
for (const key of CHANNEL_KEYS) {
|
|
289
|
+
const rawValue = passConfig[key];
|
|
290
|
+
if (!rawValue) {
|
|
291
|
+
channelSources.push({ kind: 'none' });
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
const parsed = parseChannelValue(rawValue);
|
|
295
|
+
if (!parsed) {
|
|
296
|
+
channelSources.push({ kind: 'none' });
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
channelSources.push(parseChannelObject(parsed, name, key));
|
|
300
|
+
}
|
|
301
|
+
return { name, glslSource, channels: channelSources };
|
|
302
|
+
}
|
|
303
|
+
const imagePass = await loadPass('Image', passConfigs.Image);
|
|
304
|
+
const bufferAPass = await loadPass('BufferA', passConfigs.BufferA);
|
|
305
|
+
const bufferBPass = await loadPass('BufferB', passConfigs.BufferB);
|
|
306
|
+
const bufferCPass = await loadPass('BufferC', passConfigs.BufferC);
|
|
307
|
+
const bufferDPass = await loadPass('BufferD', passConfigs.BufferD);
|
|
308
|
+
if (!imagePass && (bufferAPass || bufferBPass || bufferCPass || bufferDPass)) {
|
|
309
|
+
throw new Error(`config.json at '${root}' has buffers but no Image pass.`);
|
|
310
|
+
}
|
|
311
|
+
if (!imagePass) {
|
|
312
|
+
throw new Error(`config.json at '${root}' must define an Image pass.`);
|
|
313
|
+
}
|
|
314
|
+
const title = config.title ?? path.basename(root);
|
|
315
|
+
return {
|
|
316
|
+
mode: 'shadertoy',
|
|
317
|
+
root,
|
|
318
|
+
meta: { title, author: config.author ?? null, description: config.description ?? null },
|
|
319
|
+
layout: config.layout ?? DEFAULT_LAYOUT,
|
|
320
|
+
theme: config.theme ?? DEFAULT_THEME,
|
|
321
|
+
controls: config.controls ?? DEFAULT_CONTROLS,
|
|
322
|
+
startPaused: config.startPaused ?? false,
|
|
323
|
+
pixelRatio: config.pixelRatio ?? null,
|
|
324
|
+
commonSource,
|
|
325
|
+
passes: {
|
|
326
|
+
Image: imagePass,
|
|
327
|
+
BufferA: bufferAPass,
|
|
328
|
+
BufferB: bufferBPass,
|
|
329
|
+
BufferC: bufferCPass,
|
|
330
|
+
BufferD: bufferDPass,
|
|
331
|
+
},
|
|
332
|
+
textures: Array.from(textureMap.values()),
|
|
333
|
+
uniforms: {},
|
|
334
|
+
script: null,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
// =============================================================================
|
|
338
|
+
// Standard Mode
|
|
339
|
+
// =============================================================================
|
|
340
|
+
/**
|
|
341
|
+
* Normalize buffers config: array shorthand to object form.
|
|
342
|
+
*/
|
|
343
|
+
function normalizeBuffersConfig(buffers) {
|
|
344
|
+
if (!buffers)
|
|
345
|
+
return {};
|
|
346
|
+
if (Array.isArray(buffers)) {
|
|
347
|
+
const result = {};
|
|
348
|
+
for (const name of buffers) {
|
|
349
|
+
result[name] = {};
|
|
278
350
|
}
|
|
351
|
+
return result;
|
|
352
|
+
}
|
|
353
|
+
return buffers;
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Load a standard mode project.
|
|
357
|
+
* Supports both named buffers and pass-level configs (Image/BufferA/etc.).
|
|
358
|
+
*/
|
|
359
|
+
async function loadStandardProject(root, config) {
|
|
360
|
+
// Validate uniforms early
|
|
361
|
+
if (config.uniforms) {
|
|
362
|
+
validateUniforms(config.uniforms, root);
|
|
363
|
+
}
|
|
364
|
+
const commonSource = await resolveCommonSource(root, config.common);
|
|
365
|
+
// Check if using named buffers or pass-level configs
|
|
366
|
+
const hasNamedBuffers = config.buffers && Object.keys(normalizeBuffersConfig(config.buffers)).length > 0;
|
|
367
|
+
const hasPassConfigs = config.Image || config.BufferA || config.BufferB ||
|
|
368
|
+
config.BufferC || config.BufferD;
|
|
369
|
+
if (hasNamedBuffers && hasPassConfigs) {
|
|
370
|
+
throw new Error(`Standard mode at '${root}' cannot use both named 'buffers' and pass-level configs (Image/BufferA/etc.). Choose one approach.`);
|
|
371
|
+
}
|
|
372
|
+
if (hasNamedBuffers) {
|
|
373
|
+
return loadStandardWithNamedBuffers(root, config, commonSource);
|
|
374
|
+
}
|
|
375
|
+
if (hasPassConfigs) {
|
|
376
|
+
return loadStandardWithPassConfigs(root, config, commonSource);
|
|
377
|
+
}
|
|
378
|
+
// Simple single-pass standard project (config with settings only)
|
|
379
|
+
const imagePath = path.join(root, 'image.glsl');
|
|
380
|
+
if (!(await fileExists(imagePath))) {
|
|
381
|
+
throw new Error(`Standard mode project at '${root}' requires 'image.glsl'.`);
|
|
382
|
+
}
|
|
383
|
+
const imageSource = await fs.readFile(imagePath, 'utf8');
|
|
384
|
+
const title = config.title ?? path.basename(root);
|
|
385
|
+
return {
|
|
386
|
+
mode: 'standard',
|
|
387
|
+
root,
|
|
388
|
+
meta: { title, author: config.author ?? null, description: config.description ?? null },
|
|
389
|
+
layout: config.layout ?? DEFAULT_LAYOUT,
|
|
390
|
+
theme: config.theme ?? DEFAULT_THEME,
|
|
391
|
+
controls: config.controls ?? DEFAULT_CONTROLS,
|
|
392
|
+
startPaused: config.startPaused ?? false,
|
|
393
|
+
pixelRatio: config.pixelRatio ?? null,
|
|
394
|
+
commonSource,
|
|
395
|
+
passes: {
|
|
396
|
+
Image: {
|
|
397
|
+
name: 'Image',
|
|
398
|
+
glslSource: imageSource,
|
|
399
|
+
channels: [{ kind: 'none' }, { kind: 'none' }, { kind: 'none' }, { kind: 'none' }],
|
|
400
|
+
},
|
|
401
|
+
},
|
|
402
|
+
textures: [],
|
|
403
|
+
uniforms: config.uniforms ?? {},
|
|
404
|
+
script: null,
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* Standard mode with pass-level configs (Image, BufferA, etc.) + uniforms.
|
|
409
|
+
* Reuses the same iChannel mechanism as shadertoy mode but sets mode to 'standard'.
|
|
410
|
+
*/
|
|
411
|
+
async function loadStandardWithPassConfigs(root, config, commonSource) {
|
|
412
|
+
const passConfigs = {
|
|
413
|
+
Image: config.Image,
|
|
414
|
+
BufferA: config.BufferA,
|
|
415
|
+
BufferB: config.BufferB,
|
|
416
|
+
BufferC: config.BufferC,
|
|
417
|
+
BufferD: config.BufferD,
|
|
418
|
+
};
|
|
419
|
+
// Texture deduplication
|
|
420
|
+
const textureMap = new Map();
|
|
421
|
+
function registerTexture(j) {
|
|
422
|
+
const filter = j.filter ?? 'linear';
|
|
423
|
+
const wrap = j.wrap ?? 'repeat';
|
|
424
|
+
const key = `${j.texture}|${filter}|${wrap}`;
|
|
425
|
+
const existing = textureMap.get(key);
|
|
426
|
+
if (existing)
|
|
427
|
+
return existing.name;
|
|
428
|
+
const name = `tex${textureMap.size}`;
|
|
429
|
+
textureMap.set(key, { name, source: j.texture, filter, wrap });
|
|
430
|
+
return name;
|
|
431
|
+
}
|
|
432
|
+
function parseChannelObject(value, passName, channelKey) {
|
|
433
|
+
if ('buffer' in value) {
|
|
434
|
+
if (!isPassName(value.buffer)) {
|
|
435
|
+
throw new Error(`Invalid buffer name '${value.buffer}' for ${channelKey} in pass '${passName}' at '${root}'.`);
|
|
436
|
+
}
|
|
437
|
+
return { kind: 'buffer', buffer: value.buffer, current: !!value.current };
|
|
438
|
+
}
|
|
439
|
+
if ('texture' in value) {
|
|
440
|
+
return { kind: 'texture', name: registerTexture(value), cubemap: value.type === 'cubemap' };
|
|
441
|
+
}
|
|
442
|
+
if ('keyboard' in value)
|
|
443
|
+
return { kind: 'keyboard' };
|
|
444
|
+
if ('audio' in value)
|
|
445
|
+
return { kind: 'audio' };
|
|
446
|
+
if ('webcam' in value)
|
|
447
|
+
return { kind: 'webcam' };
|
|
448
|
+
if ('video' in value)
|
|
449
|
+
return { kind: 'video', src: value.video };
|
|
450
|
+
if ('script' in value)
|
|
451
|
+
return { kind: 'script', name: value.script };
|
|
279
452
|
throw new Error(`Invalid channel object for ${channelKey} in pass '${passName}' at '${root}'.`);
|
|
280
453
|
}
|
|
281
|
-
/**
|
|
282
|
-
* Load a single pass from simplified config.
|
|
283
|
-
*/
|
|
284
454
|
async function loadPass(name, passConfig) {
|
|
285
455
|
if (!passConfig)
|
|
286
456
|
return undefined;
|
|
@@ -290,16 +460,13 @@ async function loadProjectWithConfig(root, config) {
|
|
|
290
460
|
throw new Error(`Source GLSL file for pass '${name}' not found at '${sourceRel}' in '${root}'.`);
|
|
291
461
|
}
|
|
292
462
|
const glslSource = await fs.readFile(sourcePath, 'utf8');
|
|
293
|
-
// Normalize channels (always 4 channels)
|
|
294
463
|
const channelSources = [];
|
|
295
|
-
const
|
|
296
|
-
for (const key of channelKeys) {
|
|
464
|
+
for (const key of CHANNEL_KEYS) {
|
|
297
465
|
const rawValue = passConfig[key];
|
|
298
466
|
if (!rawValue) {
|
|
299
467
|
channelSources.push({ kind: 'none' });
|
|
300
468
|
continue;
|
|
301
469
|
}
|
|
302
|
-
// Parse string shorthand or use object directly
|
|
303
470
|
const parsed = parseChannelValue(rawValue);
|
|
304
471
|
if (!parsed) {
|
|
305
472
|
channelSources.push({ kind: 'none' });
|
|
@@ -307,35 +474,29 @@ async function loadProjectWithConfig(root, config) {
|
|
|
307
474
|
}
|
|
308
475
|
channelSources.push(parseChannelObject(parsed, name, key));
|
|
309
476
|
}
|
|
310
|
-
return {
|
|
311
|
-
name,
|
|
312
|
-
glslSource,
|
|
313
|
-
channels: channelSources,
|
|
314
|
-
};
|
|
477
|
+
return { name, glslSource, channels: channelSources };
|
|
315
478
|
}
|
|
316
|
-
// Load all passes
|
|
317
479
|
const imagePass = await loadPass('Image', passConfigs.Image);
|
|
318
480
|
const bufferAPass = await loadPass('BufferA', passConfigs.BufferA);
|
|
319
481
|
const bufferBPass = await loadPass('BufferB', passConfigs.BufferB);
|
|
320
482
|
const bufferCPass = await loadPass('BufferC', passConfigs.BufferC);
|
|
321
483
|
const bufferDPass = await loadPass('BufferD', passConfigs.BufferD);
|
|
322
|
-
// If no Image pass was loaded but we have buffers, that's an error
|
|
323
484
|
if (!imagePass && (bufferAPass || bufferBPass || bufferCPass || bufferDPass)) {
|
|
324
485
|
throw new Error(`config.json at '${root}' has buffers but no Image pass.`);
|
|
325
486
|
}
|
|
326
|
-
// If still no Image pass, create empty one
|
|
327
487
|
if (!imagePass) {
|
|
328
488
|
throw new Error(`config.json at '${root}' must define an Image pass.`);
|
|
329
489
|
}
|
|
330
|
-
// Build metadata
|
|
331
490
|
const title = config.title ?? path.basename(root);
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
const project = {
|
|
491
|
+
return {
|
|
492
|
+
mode: 'standard',
|
|
335
493
|
root,
|
|
336
|
-
meta: { title, author, description },
|
|
337
|
-
layout: config.layout ??
|
|
338
|
-
|
|
494
|
+
meta: { title, author: config.author ?? null, description: config.description ?? null },
|
|
495
|
+
layout: config.layout ?? DEFAULT_LAYOUT,
|
|
496
|
+
theme: config.theme ?? DEFAULT_THEME,
|
|
497
|
+
controls: config.controls ?? DEFAULT_CONTROLS,
|
|
498
|
+
startPaused: config.startPaused ?? false,
|
|
499
|
+
pixelRatio: config.pixelRatio ?? null,
|
|
339
500
|
commonSource,
|
|
340
501
|
passes: {
|
|
341
502
|
Image: imagePass,
|
|
@@ -345,6 +506,97 @@ async function loadProjectWithConfig(root, config) {
|
|
|
345
506
|
BufferD: bufferDPass,
|
|
346
507
|
},
|
|
347
508
|
textures: Array.from(textureMap.values()),
|
|
509
|
+
uniforms: config.uniforms ?? {},
|
|
510
|
+
script: null,
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* Standard mode with named buffers and textures.
|
|
515
|
+
*/
|
|
516
|
+
async function loadStandardWithNamedBuffers(root, config, commonSource) {
|
|
517
|
+
const buffersConfig = normalizeBuffersConfig(config.buffers);
|
|
518
|
+
const bufferNames = Object.keys(buffersConfig);
|
|
519
|
+
if (bufferNames.length > 4) {
|
|
520
|
+
throw new Error(`Standard mode at '${root}' supports max 4 buffers, got ${bufferNames.length}: ${bufferNames.join(', ')}`);
|
|
521
|
+
}
|
|
522
|
+
const bufferNameToPass = new Map();
|
|
523
|
+
for (let i = 0; i < bufferNames.length; i++) {
|
|
524
|
+
bufferNameToPass.set(bufferNames[i], BUFFER_PASS_NAMES[i]);
|
|
525
|
+
}
|
|
526
|
+
// Texture deduplication
|
|
527
|
+
const textureMap = new Map();
|
|
528
|
+
function registerTexture(source, filter = 'linear', wrap = 'repeat') {
|
|
529
|
+
const key = `${source}|${filter}|${wrap}`;
|
|
530
|
+
const existing = textureMap.get(key);
|
|
531
|
+
if (existing)
|
|
532
|
+
return existing.name;
|
|
533
|
+
const name = `tex${textureMap.size}`;
|
|
534
|
+
textureMap.set(key, { name, source, filter, wrap });
|
|
535
|
+
return name;
|
|
536
|
+
}
|
|
537
|
+
// Build namedSamplers
|
|
538
|
+
const namedSamplers = new Map();
|
|
539
|
+
for (const [bufName, passName] of bufferNameToPass) {
|
|
540
|
+
namedSamplers.set(bufName, { kind: 'buffer', buffer: passName, current: false });
|
|
541
|
+
}
|
|
542
|
+
for (const [texName, texValue] of Object.entries(config.textures ?? {})) {
|
|
543
|
+
if (texValue === 'keyboard') {
|
|
544
|
+
namedSamplers.set(texName, { kind: 'keyboard' });
|
|
545
|
+
}
|
|
546
|
+
else if (texValue === 'audio') {
|
|
547
|
+
namedSamplers.set(texName, { kind: 'audio' });
|
|
548
|
+
}
|
|
549
|
+
else if (texValue === 'webcam') {
|
|
550
|
+
namedSamplers.set(texName, { kind: 'webcam' });
|
|
551
|
+
}
|
|
552
|
+
else {
|
|
553
|
+
const internalName = registerTexture(texValue);
|
|
554
|
+
namedSamplers.set(texName, { kind: 'texture', name: internalName, cubemap: false });
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
const noChannels = [{ kind: 'none' }, { kind: 'none' }, { kind: 'none' }, { kind: 'none' }];
|
|
558
|
+
// Load Image pass
|
|
559
|
+
const imagePath = path.join(root, 'image.glsl');
|
|
560
|
+
if (!(await fileExists(imagePath))) {
|
|
561
|
+
throw new Error(`Standard mode project at '${root}' requires 'image.glsl'.`);
|
|
562
|
+
}
|
|
563
|
+
const imageSource = await fs.readFile(imagePath, 'utf8');
|
|
564
|
+
const passes = {
|
|
565
|
+
Image: {
|
|
566
|
+
name: 'Image',
|
|
567
|
+
glslSource: imageSource,
|
|
568
|
+
channels: noChannels,
|
|
569
|
+
namedSamplers: new Map(namedSamplers),
|
|
570
|
+
},
|
|
571
|
+
};
|
|
572
|
+
// Load buffer passes
|
|
573
|
+
for (const [bufName, passName] of bufferNameToPass) {
|
|
574
|
+
const sourcePath = path.join(root, `${bufName}.glsl`);
|
|
575
|
+
if (!(await fileExists(sourcePath))) {
|
|
576
|
+
throw new Error(`Buffer '${bufName}' requires '${bufName}.glsl' in '${root}'.`);
|
|
577
|
+
}
|
|
578
|
+
const glslSource = await fs.readFile(sourcePath, 'utf8');
|
|
579
|
+
passes[passName] = {
|
|
580
|
+
name: passName,
|
|
581
|
+
glslSource,
|
|
582
|
+
channels: noChannels,
|
|
583
|
+
namedSamplers: new Map(namedSamplers),
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
const title = config.title ?? path.basename(root);
|
|
587
|
+
return {
|
|
588
|
+
mode: 'standard',
|
|
589
|
+
root,
|
|
590
|
+
meta: { title, author: config.author ?? null, description: config.description ?? null },
|
|
591
|
+
layout: config.layout ?? DEFAULT_LAYOUT,
|
|
592
|
+
theme: config.theme ?? DEFAULT_THEME,
|
|
593
|
+
controls: config.controls ?? DEFAULT_CONTROLS,
|
|
594
|
+
startPaused: config.startPaused ?? false,
|
|
595
|
+
pixelRatio: config.pixelRatio ?? null,
|
|
596
|
+
commonSource,
|
|
597
|
+
passes,
|
|
598
|
+
textures: Array.from(textureMap.values()),
|
|
599
|
+
uniforms: config.uniforms ?? {},
|
|
600
|
+
script: null,
|
|
348
601
|
};
|
|
349
|
-
return project;
|
|
350
602
|
}
|