@forgeax/engine-vfx 0.1.27 → 0.1.29

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 (53) hide show
  1. package/README.md +62 -14
  2. package/dist/assets/particle-effect-decoder.d.ts.map +1 -1
  3. package/dist/authoring-descriptor.d.ts +4 -4
  4. package/dist/authoring-descriptor.d.ts.map +1 -1
  5. package/dist/code-source-v3.d.ts +101 -0
  6. package/dist/code-source-v3.d.ts.map +1 -0
  7. package/dist/code-source.d.ts +12 -11
  8. package/dist/code-source.d.ts.map +1 -1
  9. package/dist/data-interface.d.ts +17 -1
  10. package/dist/data-interface.d.ts.map +1 -1
  11. package/dist/effect-contract.d.ts +11 -1
  12. package/dist/effect-contract.d.ts.map +1 -1
  13. package/dist/gpu-loader.d.ts +5 -5
  14. package/dist/gpu-loader.d.ts.map +1 -1
  15. package/dist/gpu-program.d.ts +52 -27
  16. package/dist/gpu-program.d.ts.map +1 -1
  17. package/dist/gpu-runtime.d.ts +32 -7
  18. package/dist/gpu-runtime.d.ts.map +1 -1
  19. package/dist/index.d.ts +10 -6
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.mjs +647 -117
  22. package/dist/index.mjs.map +1 -1
  23. package/dist/particle-layout.d.ts +62 -0
  24. package/dist/particle-layout.d.ts.map +1 -0
  25. package/package.json +5 -5
  26. package/src/__tests__/authoring-descriptor.unit.test.ts +18 -8
  27. package/src/__tests__/channel-input.unit.test.ts +4 -1
  28. package/src/__tests__/channel-overflow-policy-owner.test-d.ts +2 -2
  29. package/src/__tests__/code-source-v2.unit.test.ts +15 -14
  30. package/src/__tests__/data-interface-contract.unit.test.ts +41 -2
  31. package/src/__tests__/effect-contract.unit.test.ts +6 -3
  32. package/src/__tests__/gpu-program-simulation-culling-owner.test-d.ts +6 -6
  33. package/src/__tests__/gpu-reflection-vocabulary-owner.test-d.ts +2 -2
  34. package/src/__tests__/gpu-runtime.integration.test.ts +244 -11
  35. package/src/__tests__/instance-public-api.unit.test.ts +4 -1
  36. package/src/__tests__/instance.unit.test.ts +4 -1
  37. package/src/__tests__/loader-v1-rejection.unit.test.ts +14 -14
  38. package/src/__tests__/player-reflection.unit.test.ts +2 -2
  39. package/src/__tests__/player-type.test-d.ts +4 -1
  40. package/src/__tests__/renderer-source.unit.test.ts +6 -6
  41. package/src/__tests__/replay-canonical.unit.test.ts +4 -1
  42. package/src/__tests__/vfx-data-interface-vocabulary-owner.test-d.ts +3 -8
  43. package/src/assets/particle-effect-decoder.ts +4 -3
  44. package/src/authoring-descriptor.ts +26 -28
  45. package/src/code-source-v3.ts +460 -0
  46. package/src/code-source.ts +23 -27
  47. package/src/data-interface.ts +66 -1
  48. package/src/effect-contract.ts +73 -20
  49. package/src/gpu-loader.ts +61 -35
  50. package/src/gpu-program.ts +57 -35
  51. package/src/gpu-runtime.ts +115 -42
  52. package/src/index.ts +53 -9
  53. package/src/particle-layout.ts +214 -0
@@ -1,4 +1,4 @@
1
- import type { VfxGpuEffectAsset, VfxGpuEmitterProgram } from './gpu-program.js';
1
+ import type { VfxGpuEffectAssetAny, VfxGpuEmitterProgramAny } from './gpu-program.js';
2
2
 
3
3
  export type VfxAuthoringValue =
4
4
  | null
@@ -61,7 +61,7 @@ export interface VfxAuthoringCapabilityDescriptor {
61
61
  export interface VfxAuthoringDescriptor {
62
62
  readonly version: 1;
63
63
  readonly assetGuid: string;
64
- readonly schemaVersion: 2;
64
+ readonly schemaVersion: 3;
65
65
  readonly artifactFingerprint: string;
66
66
  readonly emitters: readonly VfxAuthoringEmitterDescriptor[];
67
67
  readonly timeline: readonly VfxAuthoringTimelineDescriptor[];
@@ -69,25 +69,19 @@ export interface VfxAuthoringDescriptor {
69
69
  readonly capabilities: readonly VfxAuthoringCapabilityDescriptor[];
70
70
  }
71
71
 
72
- const CAPABILITIES: readonly VfxAuthoringCapabilityDescriptor[] = Object.freeze([
72
+ const V3_CAPABILITIES: readonly VfxAuthoringCapabilityDescriptor[] = Object.freeze([
73
73
  Object.freeze({ id: 'wgsl-behavior', state: 'executable' }),
74
74
  Object.freeze({ id: 'multi-emitter', state: 'executable' }),
75
75
  Object.freeze({ id: 'deterministic-replay', state: 'executable' }),
76
76
  Object.freeze({ id: 'emitter-visibility', state: 'executable' }),
77
- Object.freeze({
78
- id: 'runtime-parameters',
79
- state: 'partial',
80
- reason: 'reflected and packed, but not yet bound to managed author WGSL',
81
- }),
82
- Object.freeze({
83
- id: 'custom-attributes',
84
- state: 'partial',
85
- reason: 'reflected without executable per-particle custom storage',
86
- }),
77
+ Object.freeze({ id: 'runtime-parameters', state: 'executable' }),
78
+ Object.freeze({ id: 'custom-attributes', state: 'executable' }),
79
+ Object.freeze({ id: 'renderer-semantics', state: 'executable' }),
80
+ Object.freeze({ id: 'material-particle-inputs', state: 'executable' }),
87
81
  Object.freeze({
88
82
  id: 'data-interfaces',
89
83
  state: 'partial',
90
- reason: 'requirements resolve provider readiness without author resource bindings',
84
+ reason: 'camera, single-sample scene depth, and noise require generation-owned providers',
91
85
  }),
92
86
  ]);
93
87
 
@@ -95,19 +89,20 @@ const CAPABILITIES: readonly VfxAuthoringCapabilityDescriptor[] = Object.freeze(
95
89
  * the cooked program shape. This deliberately validates the stable producer
96
90
  * discriminants only; detailed artifact validation remains the pack loader's
97
91
  * responsibility. */
98
- export function isVfxGpuEffectAsset(value: unknown): value is VfxGpuEffectAsset {
92
+ export function isVfxGpuEffectAsset(value: unknown): value is VfxGpuEffectAssetAny {
99
93
  if (typeof value !== 'object' || value === null) return false;
100
- const candidate = value as Partial<VfxGpuEffectAsset>;
94
+ const candidate = value as Partial<VfxGpuEffectAssetAny>;
95
+ const program = candidate.program;
101
96
  return (
102
97
  candidate.kind === 'particle-effect' &&
103
- candidate.schemaVersion === 2 &&
98
+ candidate.schemaVersion === 3 &&
104
99
  typeof candidate.guid === 'string' &&
105
100
  candidate.guid.length > 0 &&
106
- typeof candidate.program === 'object' &&
107
- candidate.program !== null &&
108
- candidate.program.format === 'forgeax-vfx-program-2' &&
109
- typeof candidate.program.fingerprint === 'string' &&
110
- Array.isArray(candidate.program.emitters)
101
+ typeof program === 'object' &&
102
+ program !== null &&
103
+ program.format === 'forgeax-vfx-program-3' &&
104
+ typeof program.fingerprint === 'string' &&
105
+ Array.isArray(program.emitters)
111
106
  );
112
107
  }
113
108
 
@@ -174,7 +169,7 @@ function node(
174
169
  }
175
170
 
176
171
  function reflectionNodes(
177
- emitter: VfxGpuEmitterProgram,
172
+ emitter: VfxGpuEmitterProgramAny,
178
173
  path: string,
179
174
  ): VfxAuthoringNodeDescriptor[] {
180
175
  const nodes: VfxAuthoringNodeDescriptor[] = [];
@@ -235,7 +230,10 @@ function reflectionNodes(
235
230
  return nodes;
236
231
  }
237
232
 
238
- function emitterNode(emitter: VfxGpuEmitterProgram, index: number): VfxAuthoringEmitterDescriptor {
233
+ function emitterNode(
234
+ emitter: VfxGpuEmitterProgramAny,
235
+ index: number,
236
+ ): VfxAuthoringEmitterDescriptor {
239
237
  const path = `emitters[${index}]`;
240
238
  const children: VfxAuthoringNodeDescriptor[] = [
241
239
  node({
@@ -293,7 +291,7 @@ function emitterNode(emitter: VfxGpuEmitterProgram, index: number): VfxAuthoring
293
291
  });
294
292
  }
295
293
 
296
- function dependencies(effect: VfxGpuEffectAsset): readonly VfxAuthoringDependencyDescriptor[] {
294
+ function dependencies(effect: VfxGpuEffectAssetAny): readonly VfxAuthoringDependencyDescriptor[] {
297
295
  const entries: VfxAuthoringDependencyDescriptor[] = [];
298
296
  const seen = new Set<string>();
299
297
  const add = (entry: VfxAuthoringDependencyDescriptor): void => {
@@ -330,11 +328,11 @@ function dependencies(effect: VfxGpuEffectAsset): readonly VfxAuthoringDependenc
330
328
  }
331
329
 
332
330
  /** Project the cooked runtime asset into a compiler-free, UI-neutral authoring read model. */
333
- export function describeVfxGpuEffect(effect: VfxGpuEffectAsset): VfxAuthoringDescriptor {
331
+ export function describeVfxGpuEffect(effect: VfxGpuEffectAssetAny): VfxAuthoringDescriptor {
334
332
  return Object.freeze({
335
333
  version: 1,
336
334
  assetGuid: effect.guid,
337
- schemaVersion: 2,
335
+ schemaVersion: effect.schemaVersion,
338
336
  artifactFingerprint: effect.program.fingerprint,
339
337
  emitters: Object.freeze(effect.program.emitters.map(emitterNode)),
340
338
  timeline: Object.freeze(
@@ -350,6 +348,6 @@ export function describeVfxGpuEffect(effect: VfxGpuEffectAsset): VfxAuthoringDes
350
348
  ),
351
349
  ),
352
350
  dependencies: dependencies(effect),
353
- capabilities: CAPABILITIES,
351
+ capabilities: V3_CAPABILITIES,
354
352
  });
355
353
  }
@@ -0,0 +1,460 @@
1
+ import { err, ok, type Result } from '@forgeax/engine-types';
2
+ import {
3
+ type ParticleBoundsSource,
4
+ type ParticleChannelSource,
5
+ type ParticleCodeSourceError,
6
+ type ParticleEmitterSourceStructure,
7
+ type ParticleEventSource,
8
+ type ParticleRendererOverflowPolicy,
9
+ parseParticleEffectSourceStructure,
10
+ } from './code-source.js';
11
+ import type { VfxParticleCoreAttribute } from './particle-layout.js';
12
+
13
+ /** Renderer sorting modes in Program v3. Segment topology order is unaffected. */
14
+ export type ParticleRendererSortingV3 =
15
+ | 'none'
16
+ | 'view-depth'
17
+ | 'view-distance'
18
+ | 'custom-ascending'
19
+ | 'custom-descending';
20
+
21
+ export type ParticleAttributeRef =
22
+ | { readonly source: 'core'; readonly name: VfxParticleCoreAttribute }
23
+ | { readonly source: 'custom'; readonly name: string };
24
+
25
+ /**
26
+ * Renderer fields with a concrete Program v3 projection. Keep this union
27
+ * execution-backed; renderer.materialInputs is the separate material bridge.
28
+ */
29
+ export type ParticleRendererSemantic =
30
+ | 'position'
31
+ | 'color'
32
+ | 'size'
33
+ | 'rotation'
34
+ | 'subImage'
35
+ | 'age'
36
+ | 'sort'
37
+ | 'orientation'
38
+ | 'scale'
39
+ | 'visibility'
40
+ | 'width'
41
+ | 'taper'
42
+ | 'endpoint';
43
+
44
+ export type ParticleRendererSemanticMap = Readonly<
45
+ Partial<Record<ParticleRendererSemantic, ParticleAttributeRef>>
46
+ >;
47
+
48
+ export interface ParticleRendererSourceV3Base {
49
+ readonly material: string;
50
+ readonly enabled?: boolean;
51
+ readonly capacity?: number;
52
+ readonly overflow?: ParticleRendererOverflowPolicy;
53
+ readonly width?: number;
54
+ readonly attributes?: ParticleRendererSemanticMap;
55
+ /** Names must match the cooked MaterialShaderArtifact particleInputs declaration. */
56
+ readonly materialInputs?: readonly string[];
57
+ }
58
+
59
+ export type ParticleRendererSourceV3 =
60
+ | (ParticleRendererSourceV3Base & {
61
+ readonly kind: 'billboard';
62
+ readonly blend?: 'additive' | 'alpha' | 'opaque-cutout';
63
+ readonly textureSheet?: {
64
+ readonly columns: number;
65
+ readonly rows: number;
66
+ readonly frameRate: number;
67
+ readonly frameCount?: number;
68
+ };
69
+ readonly pivot?: readonly [number, number];
70
+ readonly softParticle?: { readonly fadeDistance: number };
71
+ readonly sorting?: ParticleRendererSortingV3;
72
+ })
73
+ | (ParticleRendererSourceV3Base & {
74
+ readonly kind: 'mesh';
75
+ readonly mesh: string;
76
+ readonly submesh?: number;
77
+ readonly lighting?: 'unlit' | 'standard';
78
+ readonly castShadows?: boolean;
79
+ readonly receiveShadows?: boolean;
80
+ })
81
+ | (ParticleRendererSourceV3Base & {
82
+ readonly kind: 'ribbon';
83
+ readonly stripKey: 'alive-index';
84
+ readonly capacity: number;
85
+ readonly twist?: number;
86
+ readonly facing?: 'camera' | 'velocity' | 'custom';
87
+ })
88
+ | (ParticleRendererSourceV3Base & {
89
+ readonly kind: 'trail';
90
+ readonly historyLength: number;
91
+ readonly capacity: number;
92
+ readonly taper?: number;
93
+ })
94
+ | (ParticleRendererSourceV3Base & {
95
+ readonly kind: 'beam';
96
+ readonly endpointField: 'velocity';
97
+ readonly capacity: number;
98
+ readonly taper?: number;
99
+ });
100
+
101
+ export interface ParticleEmitterSourceV3 {
102
+ readonly id: string;
103
+ readonly capacity: number;
104
+ readonly backend: { readonly required: 'gpu' };
105
+ readonly space: 'local' | 'world';
106
+ readonly bounds: ParticleBoundsSource;
107
+ readonly schedule: ParticleEmitterSourceStructure['schedule'];
108
+ readonly program: { readonly module: string };
109
+ readonly renderers: readonly ParticleRendererSourceV3[];
110
+ /** Existing channels/events are intentionally carried unchanged into v3. */
111
+ readonly channels?: readonly ParticleChannelSource[];
112
+ readonly events?: readonly ParticleEventSource[];
113
+ readonly simulationWhenCulled?: ParticleEmitterSourceStructure['simulationWhenCulled'];
114
+ }
115
+
116
+ export interface ParticleEffectRootSourceV3 {
117
+ readonly schemaVersion: 3;
118
+ readonly emitters: readonly ParticleEmitterSourceV3[];
119
+ }
120
+
121
+ export type ParticleEffectSourceV3 = ParticleEffectRootSourceV3;
122
+
123
+ /**
124
+ * Renderer semantic vocabulary shared by source validation and compiler
125
+ * reflection. Keeping the allowed keys here prevents a parser/compiler drift
126
+ * where a typo is accepted by one boundary and ignored by the other.
127
+ */
128
+ export const PARTICLE_RENDERER_SEMANTICS: Readonly<
129
+ Record<ParticleRendererSourceV3['kind'], readonly ParticleRendererSemantic[]>
130
+ > = Object.freeze({
131
+ billboard: Object.freeze([
132
+ 'position',
133
+ 'color',
134
+ 'size',
135
+ 'rotation',
136
+ 'age',
137
+ 'subImage',
138
+ 'sort',
139
+ 'visibility',
140
+ ] as const),
141
+ mesh: Object.freeze(['position', 'color', 'orientation', 'scale', 'visibility'] as const),
142
+ ribbon: Object.freeze(['position', 'color', 'width'] as const),
143
+ trail: Object.freeze(['position', 'color', 'width', 'taper'] as const),
144
+ beam: Object.freeze(['position', 'endpoint', 'color', 'width'] as const),
145
+ });
146
+
147
+ function coreAttribute(name: VfxParticleCoreAttribute): ParticleAttributeRef {
148
+ return { source: 'core', name };
149
+ }
150
+
151
+ /** Default mapping for fields with implicit runtime behavior; overrides merge in the compiler. */
152
+ export function defaultParticleRendererAttributes(
153
+ kind: ParticleRendererSourceV3['kind'],
154
+ ): ParticleRendererSemanticMap {
155
+ switch (kind) {
156
+ case 'billboard':
157
+ return {
158
+ position: coreAttribute('position'),
159
+ color: coreAttribute('color'),
160
+ size: coreAttribute('sprite_size'),
161
+ rotation: coreAttribute('sprite_rotation'),
162
+ age: coreAttribute('age'),
163
+ visibility: coreAttribute('alive'),
164
+ };
165
+ case 'mesh':
166
+ return {
167
+ position: coreAttribute('position'),
168
+ color: coreAttribute('color'),
169
+ orientation: coreAttribute('mesh_orientation'),
170
+ scale: coreAttribute('mesh_scale'),
171
+ visibility: coreAttribute('alive'),
172
+ };
173
+ case 'ribbon':
174
+ return {
175
+ position: coreAttribute('position'),
176
+ color: coreAttribute('color'),
177
+ };
178
+ case 'trail':
179
+ return {
180
+ position: coreAttribute('position'),
181
+ color: coreAttribute('color'),
182
+ };
183
+ case 'beam':
184
+ return {
185
+ position: coreAttribute('position'),
186
+ endpoint: coreAttribute('velocity'),
187
+ color: coreAttribute('color'),
188
+ };
189
+ }
190
+ }
191
+
192
+ function record(value: unknown): value is Record<string, unknown> {
193
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
194
+ }
195
+
196
+ function fail(path: string, expected: string): Result<never, ParticleCodeSourceError> {
197
+ return err({
198
+ code: 'vfx-source-invalid',
199
+ expected,
200
+ hint: `repair ${path} and recook the Program v3 particle effect`,
201
+ detail: { path },
202
+ });
203
+ }
204
+
205
+ function extra(value: Record<string, unknown>, allowed: readonly string[]): string | undefined {
206
+ const keys = new Set(allowed);
207
+ return Object.keys(value).find((key) => !keys.has(key));
208
+ }
209
+
210
+ const CORE_NAMES = new Set<VfxParticleCoreAttribute>([
211
+ 'position',
212
+ 'age',
213
+ 'velocity',
214
+ 'lifetime',
215
+ 'color',
216
+ 'sprite_size',
217
+ 'sprite_rotation',
218
+ 'sub_image',
219
+ 'mesh_orientation',
220
+ 'mesh_scale',
221
+ 'material_random',
222
+ 'id',
223
+ 'alive',
224
+ ]);
225
+
226
+ function validateAttributes(
227
+ value: unknown,
228
+ path: string,
229
+ ): Result<ParticleRendererSemanticMap | undefined, ParticleCodeSourceError> {
230
+ if (value === undefined) return ok(undefined);
231
+ if (!record(value)) return fail(path, 'a semantic-to-attribute object');
232
+ for (const [semantic, reference] of Object.entries(value)) {
233
+ if (!record(reference) || (reference.source !== 'core' && reference.source !== 'custom')) {
234
+ return fail(`${path}.${semantic}`, "{ source: 'core' | 'custom', name: string }");
235
+ }
236
+ if (typeof reference.name !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(reference.name)) {
237
+ return fail(`${path}.${semantic}.name`, 'a non-empty identifier');
238
+ }
239
+ if (
240
+ reference.source === 'core' &&
241
+ !CORE_NAMES.has(reference.name as VfxParticleCoreAttribute)
242
+ ) {
243
+ return fail(`${path}.${semantic}.name`, 'a VfxParticle core attribute');
244
+ }
245
+ const referenceExtra = extra(reference, ['source', 'name']);
246
+ if (referenceExtra !== undefined)
247
+ return fail(`${path}.${semantic}.${referenceExtra}`, 'source and name only');
248
+ }
249
+ return ok(value as ParticleRendererSemanticMap);
250
+ }
251
+
252
+ function validateMaterialInputs(
253
+ value: unknown,
254
+ path: string,
255
+ ): Result<readonly string[] | undefined, ParticleCodeSourceError> {
256
+ if (value === undefined) return ok(undefined);
257
+ if (
258
+ !Array.isArray(value) ||
259
+ value.length > 4 ||
260
+ value.some((entry) => typeof entry !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(entry))
261
+ ) {
262
+ return fail(path, 'at most four unique particle input names');
263
+ }
264
+ if (new Set(value).size !== value.length) return fail(path, 'unique particle input names');
265
+ return ok(Object.freeze([...value]));
266
+ }
267
+
268
+ function rendererAllowed(kind: string): readonly string[] {
269
+ const common = [
270
+ 'kind',
271
+ 'material',
272
+ 'enabled',
273
+ 'capacity',
274
+ 'overflow',
275
+ 'width',
276
+ 'attributes',
277
+ 'materialInputs',
278
+ ];
279
+ switch (kind) {
280
+ case 'billboard':
281
+ return [...common, 'blend', 'textureSheet', 'pivot', 'softParticle', 'sorting'];
282
+ case 'mesh':
283
+ return [
284
+ 'kind',
285
+ 'material',
286
+ 'mesh',
287
+ 'submesh',
288
+ 'enabled',
289
+ 'attributes',
290
+ 'materialInputs',
291
+ 'lighting',
292
+ 'castShadows',
293
+ 'receiveShadows',
294
+ ];
295
+ case 'ribbon':
296
+ return [...common, 'stripKey', 'twist', 'facing'];
297
+ case 'trail':
298
+ return [...common, 'historyLength', 'taper'];
299
+ case 'beam':
300
+ return [...common, 'endpointField', 'taper'];
301
+ default:
302
+ return ['kind'];
303
+ }
304
+ }
305
+
306
+ function asStructureRenderer(value: Record<string, unknown>): Record<string, unknown> {
307
+ const common = ['kind', 'material', 'enabled', 'capacity', 'overflow', 'width'] as const;
308
+ const result: Record<string, unknown> = {};
309
+ for (const key of common) if (value[key] !== undefined) result[key] = value[key];
310
+ switch (value.kind) {
311
+ case 'billboard':
312
+ for (const key of ['blend', 'textureSheet', 'pivot', 'softParticle'] as const)
313
+ if (value[key] !== undefined) result[key] = value[key];
314
+ if (value.sorting === 'view-depth' || value.sorting === 'view-distance')
315
+ result.sorting = 'back-to-front';
316
+ else if (value.sorting === 'custom-ascending' || value.sorting === 'custom-descending')
317
+ result.sorting = 'emitter';
318
+ else if (value.sorting !== undefined) result.sorting = value.sorting;
319
+ break;
320
+ case 'mesh':
321
+ for (const key of ['mesh', 'submesh'] as const)
322
+ if (value[key] !== undefined) result[key] = value[key];
323
+ break;
324
+ case 'ribbon':
325
+ result.stripKey = value.stripKey;
326
+ break;
327
+ case 'trail':
328
+ result.historyLength = value.historyLength;
329
+ break;
330
+ case 'beam':
331
+ result.endpointField = value.endpointField;
332
+ break;
333
+ }
334
+ return result;
335
+ }
336
+
337
+ function validateRenderer(
338
+ value: unknown,
339
+ emitterIndex: number,
340
+ index: number,
341
+ ): Result<ParticleRendererSourceV3, ParticleCodeSourceError> {
342
+ const path = `emitters[${emitterIndex}].renderers[${index}]`;
343
+ if (!record(value) || typeof value.kind !== 'string' || typeof value.material !== 'string') {
344
+ return fail(path, 'a Program v3 renderer object');
345
+ }
346
+ const unknown = extra(value, rendererAllowed(value.kind));
347
+ if (unknown !== undefined)
348
+ return fail(`${path}.${unknown}`, 'a supported Program v3 renderer field');
349
+ if (value.lighting !== undefined && value.lighting !== 'unlit' && value.lighting !== 'standard')
350
+ return fail(`${path}.lighting`, 'unlit or standard');
351
+ for (const field of ['castShadows', 'receiveShadows'] as const) {
352
+ if (value[field] !== undefined && typeof value[field] !== 'boolean')
353
+ return fail(`${path}.${field}`, 'a boolean');
354
+ }
355
+ for (const field of ['twist', 'taper'] as const) {
356
+ if (
357
+ value[field] !== undefined &&
358
+ (typeof value[field] !== 'number' || !Number.isFinite(value[field]))
359
+ )
360
+ return fail(`${path}.${field}`, 'a finite number');
361
+ }
362
+ if (
363
+ value.facing !== undefined &&
364
+ value.facing !== 'camera' &&
365
+ value.facing !== 'velocity' &&
366
+ value.facing !== 'custom'
367
+ )
368
+ return fail(`${path}.facing`, 'camera, velocity, or custom');
369
+ if (
370
+ value.sorting !== undefined &&
371
+ value.kind === 'billboard' &&
372
+ !['none', 'view-depth', 'view-distance', 'custom-ascending', 'custom-descending'].includes(
373
+ value.sorting as string,
374
+ )
375
+ )
376
+ return fail(`${path}.sorting`, 'a Program v3 sorting mode');
377
+ const attrs = validateAttributes(value.attributes, `${path}.attributes`);
378
+ if (!attrs.ok) return attrs;
379
+ if (attrs.value !== undefined) {
380
+ for (const semantic of Object.keys(attrs.value)) {
381
+ if (
382
+ !PARTICLE_RENDERER_SEMANTICS[value.kind as ParticleRendererSourceV3['kind']]?.includes(
383
+ semantic as ParticleRendererSemantic,
384
+ )
385
+ ) {
386
+ return fail(
387
+ `${path}.attributes.${semantic}`,
388
+ 'a semantic supported by this renderer topology',
389
+ );
390
+ }
391
+ }
392
+ }
393
+ const inputs = validateMaterialInputs(value.materialInputs, `${path}.materialInputs`);
394
+ if (!inputs.ok) return inputs;
395
+ return ok({
396
+ ...(value as unknown as ParticleRendererSourceV3),
397
+ ...(attrs.value === undefined ? {} : { attributes: attrs.value }),
398
+ ...(inputs.value === undefined ? {} : { materialInputs: inputs.value }),
399
+ });
400
+ }
401
+
402
+ /** Parse the single Program v3 source shape. Older source versions are rejected. */
403
+ export function parseParticleEffectSourceV3(
404
+ value: unknown,
405
+ ): Result<ParticleEffectSourceV3, ParticleCodeSourceError> {
406
+ if (!record(value)) return fail('$', 'a Program v3 particle effect source object');
407
+ if (value.schemaVersion !== 3) {
408
+ return err({
409
+ code: 'vfx-source-version-unsupported',
410
+ expected: 'ParticleEffectSource schemaVersion 3',
411
+ hint: 'cold-cook the source with the Program v3 compiler; older versions are not executable',
412
+ detail: { path: 'schemaVersion' },
413
+ });
414
+ }
415
+ const rootExtra = extra(value, ['schemaVersion', 'emitters']);
416
+ if (rootExtra !== undefined) return fail(rootExtra, 'schemaVersion and emitters only');
417
+ if (!Array.isArray(value.emitters) || value.emitters.length === 0)
418
+ return fail('emitters', 'at least one Program v3 emitter');
419
+ const renderers = new Map<string, readonly ParticleRendererSourceV3[]>();
420
+ for (const [emitterIndex, emitter] of value.emitters.entries()) {
421
+ if (record(emitter) && Array.isArray(emitter.renderers)) {
422
+ const parsed: ParticleRendererSourceV3[] = [];
423
+ for (const [index, renderer] of emitter.renderers.entries()) {
424
+ const result = validateRenderer(renderer, emitterIndex, index);
425
+ if (!result.ok) return result;
426
+ parsed.push(result.value);
427
+ }
428
+ renderers.set(typeof emitter.id === 'string' ? emitter.id : String(renderers.size), parsed);
429
+ }
430
+ }
431
+ // Reuse the established bounds/schedule/channel/event validator after removing
432
+ // renderer-only v3 fields. Renderer semantics have already been checked above.
433
+ const structuralInput = {
434
+ schemaVersion: 3,
435
+ emitters: value.emitters.map((emitter) => {
436
+ if (!record(emitter)) return emitter;
437
+ return {
438
+ ...emitter,
439
+ renderers: Array.isArray(emitter.renderers)
440
+ ? emitter.renderers.map((renderer) =>
441
+ record(renderer) ? asStructureRenderer(renderer) : renderer,
442
+ )
443
+ : emitter.renderers,
444
+ };
445
+ }),
446
+ };
447
+ const parsed = parseParticleEffectSourceStructure(structuralInput);
448
+ if (!parsed.ok) return parsed;
449
+ const emitters: ParticleEmitterSourceV3[] = parsed.value.emitters.map((emitter) => ({
450
+ ...emitter,
451
+ renderers: renderers.get(emitter.id) ?? [],
452
+ }));
453
+ return ok(Object.freeze({ schemaVersion: 3, emitters: Object.freeze(emitters) }));
454
+ }
455
+
456
+ export function defineParticleEffectSourceV3<T extends ParticleEffectSourceV3>(source: T): T {
457
+ const parsed = parseParticleEffectSourceV3(source);
458
+ if (!parsed.ok) throw new TypeError(`${parsed.error.code}: ${parsed.error.expected}`);
459
+ return source;
460
+ }