@forgeax/engine-vfx 0.1.28 → 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.
- package/README.md +62 -14
- package/dist/assets/particle-effect-decoder.d.ts.map +1 -1
- package/dist/authoring-descriptor.d.ts +4 -4
- package/dist/authoring-descriptor.d.ts.map +1 -1
- package/dist/code-source-v3.d.ts +101 -0
- package/dist/code-source-v3.d.ts.map +1 -0
- package/dist/code-source.d.ts +12 -11
- package/dist/code-source.d.ts.map +1 -1
- package/dist/data-interface.d.ts +17 -1
- package/dist/data-interface.d.ts.map +1 -1
- package/dist/effect-contract.d.ts +11 -1
- package/dist/effect-contract.d.ts.map +1 -1
- package/dist/gpu-loader.d.ts +5 -5
- package/dist/gpu-loader.d.ts.map +1 -1
- package/dist/gpu-program.d.ts +52 -27
- package/dist/gpu-program.d.ts.map +1 -1
- package/dist/gpu-runtime.d.ts +32 -7
- package/dist/gpu-runtime.d.ts.map +1 -1
- package/dist/index.d.ts +10 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.mjs +647 -117
- package/dist/index.mjs.map +1 -1
- package/dist/particle-layout.d.ts +62 -0
- package/dist/particle-layout.d.ts.map +1 -0
- package/package.json +5 -5
- package/src/__tests__/authoring-descriptor.unit.test.ts +18 -8
- package/src/__tests__/channel-input.unit.test.ts +4 -1
- package/src/__tests__/channel-overflow-policy-owner.test-d.ts +2 -2
- package/src/__tests__/code-source-v2.unit.test.ts +15 -14
- package/src/__tests__/data-interface-contract.unit.test.ts +41 -2
- package/src/__tests__/effect-contract.unit.test.ts +6 -3
- package/src/__tests__/gpu-program-simulation-culling-owner.test-d.ts +6 -6
- package/src/__tests__/gpu-reflection-vocabulary-owner.test-d.ts +2 -2
- package/src/__tests__/gpu-runtime.integration.test.ts +244 -11
- package/src/__tests__/instance-public-api.unit.test.ts +4 -1
- package/src/__tests__/instance.unit.test.ts +4 -1
- package/src/__tests__/loader-v1-rejection.unit.test.ts +14 -14
- package/src/__tests__/player-reflection.unit.test.ts +2 -2
- package/src/__tests__/player-type.test-d.ts +4 -1
- package/src/__tests__/renderer-source.unit.test.ts +6 -6
- package/src/__tests__/replay-canonical.unit.test.ts +4 -1
- package/src/__tests__/vfx-data-interface-vocabulary-owner.test-d.ts +3 -8
- package/src/assets/particle-effect-decoder.ts +4 -3
- package/src/authoring-descriptor.ts +26 -28
- package/src/code-source-v3.ts +460 -0
- package/src/code-source.ts +23 -27
- package/src/data-interface.ts +66 -1
- package/src/effect-contract.ts +73 -20
- package/src/gpu-loader.ts +61 -35
- package/src/gpu-program.ts +57 -35
- package/src/gpu-runtime.ts +115 -42
- package/src/index.ts +53 -9
- package/src/particle-layout.ts +214 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
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:
|
|
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
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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: '
|
|
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
|
|
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<
|
|
94
|
+
const candidate = value as Partial<VfxGpuEffectAssetAny>;
|
|
95
|
+
const program = candidate.program;
|
|
101
96
|
return (
|
|
102
97
|
candidate.kind === 'particle-effect' &&
|
|
103
|
-
candidate.schemaVersion ===
|
|
98
|
+
candidate.schemaVersion === 3 &&
|
|
104
99
|
typeof candidate.guid === 'string' &&
|
|
105
100
|
candidate.guid.length > 0 &&
|
|
106
|
-
typeof
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
typeof
|
|
110
|
-
Array.isArray(
|
|
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:
|
|
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(
|
|
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:
|
|
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:
|
|
331
|
+
export function describeVfxGpuEffect(effect: VfxGpuEffectAssetAny): VfxAuthoringDescriptor {
|
|
334
332
|
return Object.freeze({
|
|
335
333
|
version: 1,
|
|
336
334
|
assetGuid: effect.guid,
|
|
337
|
-
schemaVersion:
|
|
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:
|
|
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
|
+
}
|