@forgeax/engine-vfx 0.1.2
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/LICENSE +202 -0
- package/README.md +214 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/__tests__/authoring-descriptor.unit.test.d.ts +2 -0
- package/dist/__tests__/authoring-descriptor.unit.test.d.ts.map +1 -0
- package/dist/__tests__/channel-input.unit.test.d.ts +2 -0
- package/dist/__tests__/channel-input.unit.test.d.ts.map +1 -0
- package/dist/__tests__/channel-inspect.unit.test.d.ts +2 -0
- package/dist/__tests__/channel-inspect.unit.test.d.ts.map +1 -0
- package/dist/__tests__/code-source-v2.unit.test.d.ts +2 -0
- package/dist/__tests__/code-source-v2.unit.test.d.ts.map +1 -0
- package/dist/__tests__/data-interface-contract.unit.test.d.ts +2 -0
- package/dist/__tests__/data-interface-contract.unit.test.d.ts.map +1 -0
- package/dist/__tests__/effect-contract.unit.test.d.ts +2 -0
- package/dist/__tests__/effect-contract.unit.test.d.ts.map +1 -0
- package/dist/__tests__/gpu-program-simulation-culling-owner.test-d.d.ts +2 -0
- package/dist/__tests__/gpu-program-simulation-culling-owner.test-d.d.ts.map +1 -0
- package/dist/__tests__/gpu-reflection-vocabulary-owner.test-d.d.ts +2 -0
- package/dist/__tests__/gpu-reflection-vocabulary-owner.test-d.d.ts.map +1 -0
- package/dist/__tests__/gpu-runtime.integration.test.d.ts +2 -0
- package/dist/__tests__/gpu-runtime.integration.test.d.ts.map +1 -0
- package/dist/__tests__/inspect-snapshot.unit.test.d.ts +2 -0
- package/dist/__tests__/inspect-snapshot.unit.test.d.ts.map +1 -0
- package/dist/__tests__/instance-public-api.unit.test.d.ts +2 -0
- package/dist/__tests__/instance-public-api.unit.test.d.ts.map +1 -0
- package/dist/__tests__/instance.unit.test.d.ts +2 -0
- package/dist/__tests__/instance.unit.test.d.ts.map +1 -0
- package/dist/__tests__/loader-v1-rejection.unit.test.d.ts +2 -0
- package/dist/__tests__/loader-v1-rejection.unit.test.d.ts.map +1 -0
- package/dist/__tests__/player-reflection.unit.test.d.ts +2 -0
- package/dist/__tests__/player-reflection.unit.test.d.ts.map +1 -0
- package/dist/__tests__/player-type.test-d.d.ts +2 -0
- package/dist/__tests__/player-type.test-d.d.ts.map +1 -0
- package/dist/__tests__/renderer-source.unit.test.d.ts +2 -0
- package/dist/__tests__/renderer-source.unit.test.d.ts.map +1 -0
- package/dist/__tests__/replay-canonical.unit.test.d.ts +2 -0
- package/dist/__tests__/replay-canonical.unit.test.d.ts.map +1 -0
- package/dist/__tests__/runtime-dist-isolation.test.d.ts +2 -0
- package/dist/__tests__/runtime-dist-isolation.test.d.ts.map +1 -0
- package/dist/__tests__/scriptable-pack-consumer.test-d.d.ts +2 -0
- package/dist/__tests__/scriptable-pack-consumer.test-d.d.ts.map +1 -0
- package/dist/__tests__/stage-source.unit.test.d.ts +2 -0
- package/dist/__tests__/stage-source.unit.test.d.ts.map +1 -0
- package/dist/__tests__/vfx-data-interface-vocabulary-owner.test-d.d.ts +2 -0
- package/dist/__tests__/vfx-data-interface-vocabulary-owner.test-d.d.ts.map +1 -0
- package/dist/__tests__/vfx-performance-contract.unit.test.d.ts +2 -0
- package/dist/__tests__/vfx-performance-contract.unit.test.d.ts.map +1 -0
- package/dist/__tests__/vfx-value-type-owner.test-d.d.ts +2 -0
- package/dist/__tests__/vfx-value-type-owner.test-d.d.ts.map +1 -0
- package/dist/assets/particle-effect-decoder.d.ts +3 -0
- package/dist/assets/particle-effect-decoder.d.ts.map +1 -0
- package/dist/authoring-descriptor.d.ts +60 -0
- package/dist/authoring-descriptor.d.ts.map +1 -0
- package/dist/code-source.d.ts +140 -0
- package/dist/code-source.d.ts.map +1 -0
- package/dist/data-interface.d.ts +53 -0
- package/dist/data-interface.d.ts.map +1 -0
- package/dist/effect-contract.d.ts +57 -0
- package/dist/effect-contract.d.ts.map +1 -0
- package/dist/gpu-loader.d.ts +33 -0
- package/dist/gpu-loader.d.ts.map +1 -0
- package/dist/gpu-program.d.ts +80 -0
- package/dist/gpu-program.d.ts.map +1 -0
- package/dist/gpu-runtime.d.ts +174 -0
- package/dist/gpu-runtime.d.ts.map +1 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +1986 -0
- package/dist/index.mjs.map +1 -0
- package/dist/instance.d.ts +86 -0
- package/dist/instance.d.ts.map +1 -0
- package/dist/player.d.ts +17 -0
- package/dist/player.d.ts.map +1 -0
- package/package.json +66 -0
- package/src/__tests__/authoring-descriptor.unit.test.ts +138 -0
- package/src/__tests__/channel-input.unit.test.ts +65 -0
- package/src/__tests__/channel-inspect.unit.test.ts +65 -0
- package/src/__tests__/code-source-v2.unit.test.ts +121 -0
- package/src/__tests__/data-interface-contract.unit.test.ts +92 -0
- package/src/__tests__/effect-contract.unit.test.ts +62 -0
- package/src/__tests__/gpu-program-simulation-culling-owner.test-d.ts +58 -0
- package/src/__tests__/gpu-reflection-vocabulary-owner.test-d.ts +23 -0
- package/src/__tests__/gpu-runtime.integration.test.ts +549 -0
- package/src/__tests__/inspect-snapshot.unit.test.ts +51 -0
- package/src/__tests__/instance-public-api.unit.test.ts +27 -0
- package/src/__tests__/instance.unit.test.ts +107 -0
- package/src/__tests__/loader-v1-rejection.unit.test.ts +125 -0
- package/src/__tests__/player-reflection.unit.test.ts +91 -0
- package/src/__tests__/player-type.test-d.ts +54 -0
- package/src/__tests__/renderer-source.unit.test.ts +58 -0
- package/src/__tests__/replay-canonical.unit.test.ts +68 -0
- package/src/__tests__/runtime-dist-isolation.test.ts +100 -0
- package/src/__tests__/scriptable-pack-consumer.test-d.ts +9 -0
- package/src/__tests__/stage-source.unit.test.ts +64 -0
- package/src/__tests__/vfx-data-interface-vocabulary-owner.test-d.ts +34 -0
- package/src/__tests__/vfx-performance-contract.unit.test.ts +19 -0
- package/src/__tests__/vfx-value-type-owner.test-d.ts +32 -0
- package/src/assets/particle-effect-decoder.ts +34 -0
- package/src/authoring-descriptor.ts +355 -0
- package/src/code-source.ts +827 -0
- package/src/data-interface.ts +181 -0
- package/src/effect-contract.ts +220 -0
- package/src/gpu-loader.ts +279 -0
- package/src/gpu-program.ts +101 -0
- package/src/gpu-runtime.ts +807 -0
- package/src/index.ts +110 -0
- package/src/instance.ts +379 -0
- package/src/player.ts +21 -0
|
@@ -0,0 +1,827 @@
|
|
|
1
|
+
import { err, ok, type Result } from '@forgeax/engine-types';
|
|
2
|
+
|
|
3
|
+
/** Engine-owned seed module used by newly-authored particle effects. */
|
|
4
|
+
export const PARTICLE_CODE_DEFAULT_MODULE_ID = 'forgeax_vfx::default' as const;
|
|
5
|
+
|
|
6
|
+
export type ParticleBoundsSource =
|
|
7
|
+
| {
|
|
8
|
+
readonly kind: 'aabb';
|
|
9
|
+
readonly min: readonly [number, number, number];
|
|
10
|
+
readonly max: readonly [number, number, number];
|
|
11
|
+
}
|
|
12
|
+
| {
|
|
13
|
+
readonly kind: 'sphere';
|
|
14
|
+
readonly center: readonly [number, number, number];
|
|
15
|
+
readonly radius: number;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type ParticleRendererOverflowPolicy = 'drop-newest' | 'drop-oldest';
|
|
19
|
+
export type ParticleRendererSorting = 'none' | 'emitter' | 'back-to-front';
|
|
20
|
+
|
|
21
|
+
export interface ParticleTextureSheetSource {
|
|
22
|
+
readonly columns: number;
|
|
23
|
+
readonly rows: number;
|
|
24
|
+
readonly frameRate: number;
|
|
25
|
+
readonly frameCount?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ParticleSoftParticleSource {
|
|
29
|
+
readonly fadeDistance: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type ParticleRendererSource =
|
|
33
|
+
| {
|
|
34
|
+
readonly kind: 'billboard';
|
|
35
|
+
readonly material: string;
|
|
36
|
+
readonly blend?: 'additive' | 'alpha' | 'opaque-cutout';
|
|
37
|
+
readonly enabled?: boolean;
|
|
38
|
+
readonly capacity?: number;
|
|
39
|
+
readonly overflow?: ParticleRendererOverflowPolicy;
|
|
40
|
+
readonly textureSheet?: ParticleTextureSheetSource;
|
|
41
|
+
readonly pivot?: readonly [number, number];
|
|
42
|
+
readonly softParticle?: ParticleSoftParticleSource;
|
|
43
|
+
readonly sorting?: ParticleRendererSorting;
|
|
44
|
+
}
|
|
45
|
+
| {
|
|
46
|
+
readonly kind: 'mesh';
|
|
47
|
+
readonly material: string;
|
|
48
|
+
readonly mesh: string;
|
|
49
|
+
readonly submesh?: number;
|
|
50
|
+
readonly enabled?: boolean;
|
|
51
|
+
}
|
|
52
|
+
| {
|
|
53
|
+
readonly kind: 'ribbon';
|
|
54
|
+
readonly material: string;
|
|
55
|
+
readonly stripKey: 'alive-index';
|
|
56
|
+
readonly capacity: number;
|
|
57
|
+
readonly overflow?: ParticleRendererOverflowPolicy;
|
|
58
|
+
readonly enabled?: boolean;
|
|
59
|
+
readonly width?: number;
|
|
60
|
+
}
|
|
61
|
+
| {
|
|
62
|
+
readonly kind: 'trail';
|
|
63
|
+
readonly material: string;
|
|
64
|
+
readonly historyLength: number;
|
|
65
|
+
readonly capacity: number;
|
|
66
|
+
readonly overflow?: ParticleRendererOverflowPolicy;
|
|
67
|
+
readonly enabled?: boolean;
|
|
68
|
+
readonly width?: number;
|
|
69
|
+
}
|
|
70
|
+
| {
|
|
71
|
+
readonly kind: 'beam';
|
|
72
|
+
readonly material: string;
|
|
73
|
+
readonly endpointField: 'velocity';
|
|
74
|
+
readonly capacity: number;
|
|
75
|
+
readonly overflow?: ParticleRendererOverflowPolicy;
|
|
76
|
+
readonly enabled?: boolean;
|
|
77
|
+
readonly width?: number;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export type ParticleChannelOverflowPolicy = 'drop-newest' | 'drop-oldest';
|
|
81
|
+
|
|
82
|
+
export type ParticleStageDomain = 'particle';
|
|
83
|
+
export type ParticleStageResourceAccess = 'read' | 'write' | 'read-write';
|
|
84
|
+
|
|
85
|
+
export interface ParticleStageResourceSource {
|
|
86
|
+
readonly name: string;
|
|
87
|
+
readonly access: ParticleStageResourceAccess;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface ParticleStageSource {
|
|
91
|
+
readonly id: string;
|
|
92
|
+
readonly entry: string;
|
|
93
|
+
readonly domain: ParticleStageDomain;
|
|
94
|
+
readonly resources: readonly ParticleStageResourceSource[];
|
|
95
|
+
readonly dependsOn: readonly string[];
|
|
96
|
+
readonly iterationBudget: number;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export const PARTICLE_STAGE_RESOURCE_NAMES = Object.freeze([
|
|
100
|
+
'particles',
|
|
101
|
+
'runtime',
|
|
102
|
+
'aliveIndices',
|
|
103
|
+
'counters',
|
|
104
|
+
'indirect',
|
|
105
|
+
'scratch',
|
|
106
|
+
'billboardInstances',
|
|
107
|
+
'channelInputs',
|
|
108
|
+
'events',
|
|
109
|
+
'eventCounters',
|
|
110
|
+
] as const);
|
|
111
|
+
|
|
112
|
+
export interface ParticleChannelSource {
|
|
113
|
+
readonly id: string;
|
|
114
|
+
readonly payload?: 'impact';
|
|
115
|
+
readonly capacity: number;
|
|
116
|
+
readonly overflow: ParticleChannelOverflowPolicy;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface ParticleEventSource {
|
|
120
|
+
readonly id: string;
|
|
121
|
+
readonly channel: string;
|
|
122
|
+
readonly subEmitter: string;
|
|
123
|
+
readonly fanOut: number;
|
|
124
|
+
readonly recursionDepth: number;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface ParticleEmitterSourceV2 {
|
|
128
|
+
readonly id: string;
|
|
129
|
+
readonly capacity: number;
|
|
130
|
+
readonly backend: { readonly required: 'gpu' };
|
|
131
|
+
readonly space: 'local' | 'world';
|
|
132
|
+
readonly bounds: ParticleBoundsSource;
|
|
133
|
+
readonly schedule: {
|
|
134
|
+
readonly rate: number;
|
|
135
|
+
readonly bursts?: readonly { readonly time: number; readonly count: number }[];
|
|
136
|
+
readonly loopDuration?: number;
|
|
137
|
+
};
|
|
138
|
+
readonly program: { readonly module: string };
|
|
139
|
+
readonly renderers: readonly ParticleRendererSource[];
|
|
140
|
+
readonly channels?: readonly ParticleChannelSource[];
|
|
141
|
+
readonly events?: readonly ParticleEventSource[];
|
|
142
|
+
readonly simulationWhenCulled?: 'continue' | 'pause' | 'restart-on-visible';
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export interface ParticleEffectRootSourceV2 {
|
|
146
|
+
readonly schemaVersion: 2;
|
|
147
|
+
readonly emitters: readonly ParticleEmitterSourceV2[];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export type ParticleEffectSourceV2 = ParticleEffectRootSourceV2;
|
|
151
|
+
|
|
152
|
+
export interface ParticleCodeSourceInvalidDetail {
|
|
153
|
+
readonly path: string;
|
|
154
|
+
readonly emitterId?: string;
|
|
155
|
+
readonly stageId?: string;
|
|
156
|
+
readonly resource?: string;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export interface ParticleCodeSourceError {
|
|
160
|
+
readonly code:
|
|
161
|
+
| 'vfx-source-invalid'
|
|
162
|
+
| 'vfx-source-version-unsupported'
|
|
163
|
+
| 'vfx-source-channel-invalid'
|
|
164
|
+
| 'vfx-source-event-invalid'
|
|
165
|
+
| 'vfx-source-stage-invalid'
|
|
166
|
+
| 'vfx-source-renderer-invalid';
|
|
167
|
+
readonly expected: string;
|
|
168
|
+
readonly hint: string;
|
|
169
|
+
readonly detail: ParticleCodeSourceInvalidDetail;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function invalid(
|
|
173
|
+
path: string,
|
|
174
|
+
expected: string,
|
|
175
|
+
emitterId?: string,
|
|
176
|
+
code: ParticleCodeSourceError['code'] = 'vfx-source-invalid',
|
|
177
|
+
): Result<never, ParticleCodeSourceError> {
|
|
178
|
+
return err({
|
|
179
|
+
code,
|
|
180
|
+
expected,
|
|
181
|
+
hint: `repair ${path} and recook the particle effect`,
|
|
182
|
+
detail: emitterId === undefined ? { path } : { path, emitterId },
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function eventInvalid(
|
|
187
|
+
code: 'vfx-source-channel-invalid' | 'vfx-source-event-invalid',
|
|
188
|
+
path: string,
|
|
189
|
+
expected: string,
|
|
190
|
+
emitterId?: string,
|
|
191
|
+
): Result<never, ParticleCodeSourceError> {
|
|
192
|
+
return invalid(path, expected, emitterId, code);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function record(value: unknown): value is Record<string, unknown> {
|
|
196
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function finite(value: unknown): value is number {
|
|
200
|
+
return typeof value === 'number' && Number.isFinite(value);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function positiveInteger(value: unknown): value is number {
|
|
204
|
+
return finite(value) && Number.isInteger(value) && value > 0;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function nonNegativeInteger(value: unknown): value is number {
|
|
208
|
+
return finite(value) && Number.isInteger(value) && value >= 0;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function text(value: unknown): value is string {
|
|
212
|
+
return typeof value === 'string' && value.length > 0;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function vector(value: unknown, size: number): value is readonly number[] {
|
|
216
|
+
return Array.isArray(value) && value.length === size && value.every(finite);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function allowed(value: Record<string, unknown>, keys: readonly string[]): string | undefined {
|
|
220
|
+
const set = new Set(keys);
|
|
221
|
+
return Object.keys(value).find((key) => !set.has(key));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function stageInvalid(
|
|
225
|
+
path: string,
|
|
226
|
+
expected: string,
|
|
227
|
+
stageId?: string,
|
|
228
|
+
resource?: string,
|
|
229
|
+
): Result<never, ParticleCodeSourceError> {
|
|
230
|
+
return err({
|
|
231
|
+
code: 'vfx-source-stage-invalid',
|
|
232
|
+
expected,
|
|
233
|
+
hint: `repair stage ${path} and recook the stage declaration`,
|
|
234
|
+
detail: {
|
|
235
|
+
path,
|
|
236
|
+
...(stageId === undefined ? {} : { stageId }),
|
|
237
|
+
...(resource === undefined ? {} : { resource }),
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const STAGE_FIELDS = ['entry', 'domain', 'resources', 'dependsOn', 'iterationBudget'] as const;
|
|
243
|
+
const STAGE_RESOURCE_NAMES = new Set<string>(PARTICLE_STAGE_RESOURCE_NAMES);
|
|
244
|
+
|
|
245
|
+
function parseStageResources(
|
|
246
|
+
value: string,
|
|
247
|
+
path: string,
|
|
248
|
+
stageId: string,
|
|
249
|
+
): Result<readonly ParticleStageResourceSource[], ParticleCodeSourceError> {
|
|
250
|
+
if (value.length === 0)
|
|
251
|
+
return stageInvalid(path, 'at least one explicit stage resource', stageId);
|
|
252
|
+
const resources: ParticleStageResourceSource[] = [];
|
|
253
|
+
const names = new Set<string>();
|
|
254
|
+
for (const item of value.split(',')) {
|
|
255
|
+
const [name, access, extra] = item.split(':');
|
|
256
|
+
if (
|
|
257
|
+
name === undefined ||
|
|
258
|
+
access === undefined ||
|
|
259
|
+
extra !== undefined ||
|
|
260
|
+
!STAGE_RESOURCE_NAMES.has(name) ||
|
|
261
|
+
(access !== 'read' && access !== 'write' && access !== 'read-write') ||
|
|
262
|
+
names.has(name)
|
|
263
|
+
) {
|
|
264
|
+
return stageInvalid(
|
|
265
|
+
path,
|
|
266
|
+
'known resources with unique read, write, or read-write access',
|
|
267
|
+
stageId,
|
|
268
|
+
name,
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
names.add(name);
|
|
272
|
+
resources.push({ name, access });
|
|
273
|
+
}
|
|
274
|
+
return ok(Object.freeze(resources));
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Parse compiler-owned stage metadata from authored WGSL comments. */
|
|
278
|
+
export function parseVfxStageDeclarations(
|
|
279
|
+
source: string,
|
|
280
|
+
): Result<readonly ParticleStageSource[], ParticleCodeSourceError> {
|
|
281
|
+
const stages: ParticleStageSource[] = [];
|
|
282
|
+
const ids = new Set<string>();
|
|
283
|
+
const pattern = /^\s*\/\/\s*#vfx\s+stage\s+([^\s]+)\s+(.+)$/gm;
|
|
284
|
+
for (const match of source.matchAll(pattern)) {
|
|
285
|
+
const id = match[1];
|
|
286
|
+
const fieldsText = match[2];
|
|
287
|
+
if (id === undefined || fieldsText === undefined || !/^[A-Za-z_]\w*$/.test(id) || ids.has(id)) {
|
|
288
|
+
return stageInvalid(
|
|
289
|
+
`stage.${id ?? 'unknown'}`,
|
|
290
|
+
'a unique stage id and supported stage declaration',
|
|
291
|
+
id,
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
const fields: Record<string, string> = {};
|
|
295
|
+
for (const token of fieldsText.trim().split(/\s+/)) {
|
|
296
|
+
const separator = token.indexOf('=');
|
|
297
|
+
const key = separator < 0 ? undefined : token.slice(0, separator);
|
|
298
|
+
const value = separator < 0 ? undefined : token.slice(separator + 1);
|
|
299
|
+
if (
|
|
300
|
+
key === undefined ||
|
|
301
|
+
value === undefined ||
|
|
302
|
+
!STAGE_FIELDS.includes(key as (typeof STAGE_FIELDS)[number]) ||
|
|
303
|
+
fields[key] !== undefined
|
|
304
|
+
) {
|
|
305
|
+
return stageInvalid(
|
|
306
|
+
`stage.${id}`,
|
|
307
|
+
'a supported stage declaration with entry, domain, resources, dependsOn, and iterationBudget fields',
|
|
308
|
+
id,
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
fields[key] = value;
|
|
312
|
+
}
|
|
313
|
+
const entry = fields.entry;
|
|
314
|
+
const domain = fields.domain;
|
|
315
|
+
const resourcesValue = fields.resources;
|
|
316
|
+
const dependsOnValue = fields.dependsOn;
|
|
317
|
+
const budgetValue = fields.iterationBudget;
|
|
318
|
+
if (
|
|
319
|
+
entry === undefined ||
|
|
320
|
+
!/^[A-Za-z_]\w*$/.test(entry) ||
|
|
321
|
+
entry.startsWith('forgeax_vfx_') ||
|
|
322
|
+
domain !== 'particle' ||
|
|
323
|
+
resourcesValue === undefined ||
|
|
324
|
+
dependsOnValue === undefined ||
|
|
325
|
+
budgetValue === undefined
|
|
326
|
+
) {
|
|
327
|
+
return stageInvalid(
|
|
328
|
+
`stage.${id}`,
|
|
329
|
+
'a particle-domain stage with an author entry and explicit fields',
|
|
330
|
+
id,
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
const resources = parseStageResources(resourcesValue, `stage.${id}.resources`, id);
|
|
334
|
+
if (!resources.ok) return resources;
|
|
335
|
+
const iterationBudget = Number(budgetValue);
|
|
336
|
+
if (!Number.isInteger(iterationBudget) || iterationBudget < 1 || iterationBudget > 64) {
|
|
337
|
+
return stageInvalid(
|
|
338
|
+
`stage.${id}.iterationBudget`,
|
|
339
|
+
'an integer iteration budget from 1 through 64',
|
|
340
|
+
id,
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
const dependsOn =
|
|
344
|
+
dependsOnValue === 'none'
|
|
345
|
+
? []
|
|
346
|
+
: dependsOnValue.split(',').filter((dependency) => dependency.length > 0);
|
|
347
|
+
if (dependsOn.some((dependency) => !/^[A-Za-z_]\w*$/.test(dependency))) {
|
|
348
|
+
return stageInvalid(`stage.${id}.dependsOn`, 'stage identifiers or none', id);
|
|
349
|
+
}
|
|
350
|
+
ids.add(id);
|
|
351
|
+
stages.push({
|
|
352
|
+
id,
|
|
353
|
+
entry,
|
|
354
|
+
domain: 'particle',
|
|
355
|
+
resources: resources.value,
|
|
356
|
+
dependsOn: Object.freeze(dependsOn),
|
|
357
|
+
iterationBudget,
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
return ok(Object.freeze(stages));
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function parseEmitter(
|
|
364
|
+
value: unknown,
|
|
365
|
+
index: number,
|
|
366
|
+
ids: Set<string>,
|
|
367
|
+
): Result<ParticleEmitterSourceV2, ParticleCodeSourceError> {
|
|
368
|
+
const path = `emitters[${index}]`;
|
|
369
|
+
if (!record(value)) return invalid(path, 'a v2 emitter object');
|
|
370
|
+
const extra = allowed(value, [
|
|
371
|
+
'id',
|
|
372
|
+
'capacity',
|
|
373
|
+
'backend',
|
|
374
|
+
'space',
|
|
375
|
+
'bounds',
|
|
376
|
+
'schedule',
|
|
377
|
+
'program',
|
|
378
|
+
'renderers',
|
|
379
|
+
'channels',
|
|
380
|
+
'events',
|
|
381
|
+
'simulationWhenCulled',
|
|
382
|
+
]);
|
|
383
|
+
if (extra !== undefined) return invalid(`${path}.${extra}`, 'a v2 emitter field');
|
|
384
|
+
if (!text(value.id) || ids.has(value.id)) return invalid(`${path}.id`, 'a unique non-empty id');
|
|
385
|
+
const id = value.id;
|
|
386
|
+
ids.add(id);
|
|
387
|
+
if (!positiveInteger(value.capacity))
|
|
388
|
+
return invalid(`${path}.capacity`, 'a positive integer', id);
|
|
389
|
+
if (!record(value.backend) || value.backend.required !== 'gpu') {
|
|
390
|
+
return invalid(`${path}.backend`, "the explicit policy { required: 'gpu' }", id);
|
|
391
|
+
}
|
|
392
|
+
const backendExtra = allowed(value.backend, ['required']);
|
|
393
|
+
if (backendExtra !== undefined) {
|
|
394
|
+
return invalid(`${path}.backend.${backendExtra}`, 'the required GPU policy', id);
|
|
395
|
+
}
|
|
396
|
+
if (value.space !== 'local' && value.space !== 'world') {
|
|
397
|
+
return invalid(`${path}.space`, 'local or world', id);
|
|
398
|
+
}
|
|
399
|
+
if (!record(value.bounds)) return invalid(`${path}.bounds`, 'fixed aabb or sphere bounds', id);
|
|
400
|
+
const boundsExtra = allowed(
|
|
401
|
+
value.bounds,
|
|
402
|
+
value.bounds.kind === 'aabb'
|
|
403
|
+
? ['kind', 'min', 'max']
|
|
404
|
+
: value.bounds.kind === 'sphere'
|
|
405
|
+
? ['kind', 'center', 'radius']
|
|
406
|
+
: ['kind'],
|
|
407
|
+
);
|
|
408
|
+
if (boundsExtra !== undefined) {
|
|
409
|
+
return invalid(`${path}.bounds.${boundsExtra}`, 'a supported bounds field', id);
|
|
410
|
+
}
|
|
411
|
+
const boundsOk =
|
|
412
|
+
(value.bounds.kind === 'aabb' && vector(value.bounds.min, 3) && vector(value.bounds.max, 3)) ||
|
|
413
|
+
(value.bounds.kind === 'sphere' &&
|
|
414
|
+
vector(value.bounds.center, 3) &&
|
|
415
|
+
finite(value.bounds.radius) &&
|
|
416
|
+
value.bounds.radius > 0);
|
|
417
|
+
if (!boundsOk) return invalid(`${path}.bounds`, 'valid fixed aabb or sphere bounds', id);
|
|
418
|
+
if (!record(value.schedule) || !finite(value.schedule.rate) || value.schedule.rate < 0) {
|
|
419
|
+
return invalid(`${path}.schedule`, 'a non-negative spawn schedule', id);
|
|
420
|
+
}
|
|
421
|
+
const scheduleExtra = allowed(value.schedule, ['rate', 'bursts', 'loopDuration']);
|
|
422
|
+
if (scheduleExtra !== undefined) {
|
|
423
|
+
return invalid(`${path}.schedule.${scheduleExtra}`, 'a supported schedule field', id);
|
|
424
|
+
}
|
|
425
|
+
if (
|
|
426
|
+
value.schedule.bursts !== undefined &&
|
|
427
|
+
(!Array.isArray(value.schedule.bursts) ||
|
|
428
|
+
value.schedule.bursts.some(
|
|
429
|
+
(burst) =>
|
|
430
|
+
!record(burst) || !finite(burst.time) || burst.time < 0 || !positiveInteger(burst.count),
|
|
431
|
+
))
|
|
432
|
+
) {
|
|
433
|
+
return invalid(`${path}.schedule.bursts`, 'non-negative timed positive bursts', id);
|
|
434
|
+
}
|
|
435
|
+
if (Array.isArray(value.schedule.bursts)) {
|
|
436
|
+
for (const [burstIndex, burst] of value.schedule.bursts.entries()) {
|
|
437
|
+
if (!record(burst)) continue;
|
|
438
|
+
const burstExtra = allowed(burst, ['time', 'count']);
|
|
439
|
+
if (burstExtra !== undefined) {
|
|
440
|
+
return invalid(
|
|
441
|
+
`${path}.schedule.bursts[${burstIndex}].${burstExtra}`,
|
|
442
|
+
'a supported burst field',
|
|
443
|
+
id,
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
if (
|
|
449
|
+
value.schedule.loopDuration !== undefined &&
|
|
450
|
+
(!finite(value.schedule.loopDuration) || value.schedule.loopDuration <= 0)
|
|
451
|
+
) {
|
|
452
|
+
return invalid(`${path}.schedule.loopDuration`, 'a positive finite duration', id);
|
|
453
|
+
}
|
|
454
|
+
if (!record(value.program) || !text(value.program.module)) {
|
|
455
|
+
return invalid(`${path}.program.module`, 'a WGSL module identity', id);
|
|
456
|
+
}
|
|
457
|
+
const programExtra = allowed(value.program, ['module']);
|
|
458
|
+
if (programExtra !== undefined) {
|
|
459
|
+
return invalid(`${path}.program.${programExtra}`, 'a supported program field', id);
|
|
460
|
+
}
|
|
461
|
+
if (!Array.isArray(value.renderers) || value.renderers.length === 0) {
|
|
462
|
+
return invalid(`${path}.renderers`, 'at least one renderer', id);
|
|
463
|
+
}
|
|
464
|
+
for (const [rendererIndex, renderer] of value.renderers.entries()) {
|
|
465
|
+
const rendererPath = `${path}.renderers[${rendererIndex}]`;
|
|
466
|
+
if (!record(renderer) || !text(renderer.material) || !text(renderer.kind))
|
|
467
|
+
return invalid(
|
|
468
|
+
rendererPath,
|
|
469
|
+
'a supported renderer object',
|
|
470
|
+
id,
|
|
471
|
+
'vfx-source-renderer-invalid',
|
|
472
|
+
);
|
|
473
|
+
const common = ['kind', 'material', 'enabled', 'capacity', 'overflow', 'width'] as const;
|
|
474
|
+
const rendererExtra = allowed(
|
|
475
|
+
renderer,
|
|
476
|
+
renderer.kind === 'billboard'
|
|
477
|
+
? [...common, 'blend', 'textureSheet', 'pivot', 'softParticle', 'sorting']
|
|
478
|
+
: renderer.kind === 'mesh'
|
|
479
|
+
? ['kind', 'material', 'mesh', 'submesh', 'enabled']
|
|
480
|
+
: renderer.kind === 'ribbon'
|
|
481
|
+
? [...common, 'stripKey']
|
|
482
|
+
: renderer.kind === 'trail'
|
|
483
|
+
? [...common, 'historyLength']
|
|
484
|
+
: renderer.kind === 'beam'
|
|
485
|
+
? [...common, 'endpointField']
|
|
486
|
+
: ['kind'],
|
|
487
|
+
);
|
|
488
|
+
if (rendererExtra !== undefined)
|
|
489
|
+
return invalid(
|
|
490
|
+
`${rendererPath}.${rendererExtra}`,
|
|
491
|
+
'a supported renderer field',
|
|
492
|
+
id,
|
|
493
|
+
'vfx-source-renderer-invalid',
|
|
494
|
+
);
|
|
495
|
+
if (renderer.kind === 'mesh') {
|
|
496
|
+
if (
|
|
497
|
+
!text(renderer.mesh) ||
|
|
498
|
+
(renderer.submesh !== undefined && !nonNegativeInteger(renderer.submesh))
|
|
499
|
+
)
|
|
500
|
+
return invalid(
|
|
501
|
+
rendererPath,
|
|
502
|
+
'a mesh renderer with a non-negative submesh',
|
|
503
|
+
id,
|
|
504
|
+
'vfx-source-renderer-invalid',
|
|
505
|
+
);
|
|
506
|
+
continue;
|
|
507
|
+
}
|
|
508
|
+
if (renderer.enabled !== undefined && typeof renderer.enabled !== 'boolean')
|
|
509
|
+
return invalid(
|
|
510
|
+
`${rendererPath}.enabled`,
|
|
511
|
+
'a boolean enabled flag',
|
|
512
|
+
id,
|
|
513
|
+
'vfx-source-renderer-invalid',
|
|
514
|
+
);
|
|
515
|
+
if (
|
|
516
|
+
renderer.capacity !== undefined &&
|
|
517
|
+
(!positiveInteger(renderer.capacity) || renderer.capacity > 65536)
|
|
518
|
+
)
|
|
519
|
+
return invalid(
|
|
520
|
+
`${rendererPath}.capacity`,
|
|
521
|
+
'a positive capacity no greater than 65536',
|
|
522
|
+
id,
|
|
523
|
+
'vfx-source-renderer-invalid',
|
|
524
|
+
);
|
|
525
|
+
if (
|
|
526
|
+
renderer.overflow !== undefined &&
|
|
527
|
+
renderer.overflow !== 'drop-newest' &&
|
|
528
|
+
renderer.overflow !== 'drop-oldest'
|
|
529
|
+
)
|
|
530
|
+
return invalid(
|
|
531
|
+
`${rendererPath}.overflow`,
|
|
532
|
+
'drop-newest or drop-oldest',
|
|
533
|
+
id,
|
|
534
|
+
'vfx-source-renderer-invalid',
|
|
535
|
+
);
|
|
536
|
+
if (renderer.width !== undefined && (!finite(renderer.width) || renderer.width <= 0))
|
|
537
|
+
return invalid(
|
|
538
|
+
`${rendererPath}.width`,
|
|
539
|
+
'a positive finite width',
|
|
540
|
+
id,
|
|
541
|
+
'vfx-source-renderer-invalid',
|
|
542
|
+
);
|
|
543
|
+
if (renderer.kind === 'billboard') {
|
|
544
|
+
if (
|
|
545
|
+
renderer.blend !== undefined &&
|
|
546
|
+
renderer.blend !== 'additive' &&
|
|
547
|
+
renderer.blend !== 'alpha' &&
|
|
548
|
+
renderer.blend !== 'opaque-cutout'
|
|
549
|
+
)
|
|
550
|
+
return invalid(
|
|
551
|
+
`${rendererPath}.blend`,
|
|
552
|
+
'additive, alpha, or opaque-cutout',
|
|
553
|
+
id,
|
|
554
|
+
'vfx-source-renderer-invalid',
|
|
555
|
+
);
|
|
556
|
+
if (
|
|
557
|
+
renderer.sorting !== undefined &&
|
|
558
|
+
renderer.sorting !== 'none' &&
|
|
559
|
+
renderer.sorting !== 'emitter' &&
|
|
560
|
+
renderer.sorting !== 'back-to-front'
|
|
561
|
+
)
|
|
562
|
+
return invalid(
|
|
563
|
+
`${rendererPath}.sorting`,
|
|
564
|
+
'none, emitter, or back-to-front',
|
|
565
|
+
id,
|
|
566
|
+
'vfx-source-renderer-invalid',
|
|
567
|
+
);
|
|
568
|
+
if (
|
|
569
|
+
renderer.pivot !== undefined &&
|
|
570
|
+
(!vector(renderer.pivot, 2) || renderer.pivot.some((value) => value < -1 || value > 1))
|
|
571
|
+
)
|
|
572
|
+
return invalid(
|
|
573
|
+
`${rendererPath}.pivot`,
|
|
574
|
+
'two finite values in the -1..1 range',
|
|
575
|
+
id,
|
|
576
|
+
'vfx-source-renderer-invalid',
|
|
577
|
+
);
|
|
578
|
+
if (renderer.textureSheet !== undefined && !record(renderer.textureSheet))
|
|
579
|
+
return invalid(
|
|
580
|
+
`${rendererPath}.textureSheet`,
|
|
581
|
+
'a texture sheet object',
|
|
582
|
+
id,
|
|
583
|
+
'vfx-source-renderer-invalid',
|
|
584
|
+
);
|
|
585
|
+
if (renderer.textureSheet !== undefined && record(renderer.textureSheet)) {
|
|
586
|
+
const sheet = renderer.textureSheet;
|
|
587
|
+
const sheetExtra = allowed(sheet, ['columns', 'rows', 'frameRate', 'frameCount']);
|
|
588
|
+
if (
|
|
589
|
+
sheetExtra !== undefined ||
|
|
590
|
+
!positiveInteger(sheet.columns) ||
|
|
591
|
+
!positiveInteger(sheet.rows) ||
|
|
592
|
+
sheet.columns > 64 ||
|
|
593
|
+
sheet.rows > 64 ||
|
|
594
|
+
!finite(sheet.frameRate) ||
|
|
595
|
+
sheet.frameRate < 0 ||
|
|
596
|
+
(sheet.frameCount !== undefined &&
|
|
597
|
+
(!positiveInteger(sheet.frameCount) || sheet.frameCount > sheet.columns * sheet.rows))
|
|
598
|
+
)
|
|
599
|
+
return invalid(
|
|
600
|
+
`${rendererPath}.textureSheet`,
|
|
601
|
+
'a bounded texture sheet declaration',
|
|
602
|
+
id,
|
|
603
|
+
'vfx-source-renderer-invalid',
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
if (renderer.softParticle !== undefined && !record(renderer.softParticle))
|
|
607
|
+
return invalid(
|
|
608
|
+
`${rendererPath}.softParticle`,
|
|
609
|
+
'a soft-particle object',
|
|
610
|
+
id,
|
|
611
|
+
'vfx-source-renderer-invalid',
|
|
612
|
+
);
|
|
613
|
+
if (renderer.softParticle !== undefined && record(renderer.softParticle)) {
|
|
614
|
+
const softExtra = allowed(renderer.softParticle, ['fadeDistance']);
|
|
615
|
+
if (
|
|
616
|
+
softExtra !== undefined ||
|
|
617
|
+
!finite(renderer.softParticle.fadeDistance) ||
|
|
618
|
+
renderer.softParticle.fadeDistance <= 0
|
|
619
|
+
)
|
|
620
|
+
return invalid(
|
|
621
|
+
`${rendererPath}.softParticle`,
|
|
622
|
+
'a positive scene-depth fade distance',
|
|
623
|
+
id,
|
|
624
|
+
'vfx-source-renderer-invalid',
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
} else if (renderer.kind === 'ribbon') {
|
|
628
|
+
if (renderer.stripKey !== 'alive-index' || !positiveInteger(renderer.capacity))
|
|
629
|
+
return invalid(
|
|
630
|
+
rendererPath,
|
|
631
|
+
"stripKey 'alive-index' and positive capacity",
|
|
632
|
+
id,
|
|
633
|
+
'vfx-source-renderer-invalid',
|
|
634
|
+
);
|
|
635
|
+
} else if (renderer.kind === 'trail') {
|
|
636
|
+
if (
|
|
637
|
+
!positiveInteger(renderer.historyLength) ||
|
|
638
|
+
renderer.historyLength > 256 ||
|
|
639
|
+
!positiveInteger(renderer.capacity)
|
|
640
|
+
)
|
|
641
|
+
return invalid(
|
|
642
|
+
rendererPath,
|
|
643
|
+
'a bounded trail historyLength and positive capacity',
|
|
644
|
+
id,
|
|
645
|
+
'vfx-source-renderer-invalid',
|
|
646
|
+
);
|
|
647
|
+
} else if (renderer.kind === 'beam') {
|
|
648
|
+
if (renderer.endpointField !== 'velocity' || !positiveInteger(renderer.capacity))
|
|
649
|
+
return invalid(
|
|
650
|
+
rendererPath,
|
|
651
|
+
"endpointField 'velocity' and positive capacity",
|
|
652
|
+
id,
|
|
653
|
+
'vfx-source-renderer-invalid',
|
|
654
|
+
);
|
|
655
|
+
} else {
|
|
656
|
+
return invalid(
|
|
657
|
+
rendererPath,
|
|
658
|
+
'billboard, mesh, ribbon, trail, or beam',
|
|
659
|
+
id,
|
|
660
|
+
'vfx-source-renderer-invalid',
|
|
661
|
+
);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
if (value.channels !== undefined) {
|
|
665
|
+
if (!Array.isArray(value.channels) || value.channels.length === 0) {
|
|
666
|
+
return eventInvalid(
|
|
667
|
+
'vfx-source-channel-invalid',
|
|
668
|
+
`${path}.channels`,
|
|
669
|
+
'a non-empty bounded channel list',
|
|
670
|
+
id,
|
|
671
|
+
);
|
|
672
|
+
}
|
|
673
|
+
const channelIds = new Set<string>();
|
|
674
|
+
for (const [channelIndex, channel] of value.channels.entries()) {
|
|
675
|
+
const channelPath = `${path}.channels[${channelIndex}]`;
|
|
676
|
+
if (!record(channel)) {
|
|
677
|
+
return eventInvalid('vfx-source-channel-invalid', channelPath, 'a channel object', id);
|
|
678
|
+
}
|
|
679
|
+
const channelExtra = allowed(channel, ['id', 'payload', 'capacity', 'overflow']);
|
|
680
|
+
if (channelExtra !== undefined) {
|
|
681
|
+
return eventInvalid(
|
|
682
|
+
'vfx-source-channel-invalid',
|
|
683
|
+
`${channelPath}.${channelExtra}`,
|
|
684
|
+
'a supported channel field',
|
|
685
|
+
id,
|
|
686
|
+
);
|
|
687
|
+
}
|
|
688
|
+
if (!text(channel.id) || channelIds.has(channel.id)) {
|
|
689
|
+
return eventInvalid(
|
|
690
|
+
'vfx-source-channel-invalid',
|
|
691
|
+
`${channelPath}.id`,
|
|
692
|
+
'a unique non-empty channel id',
|
|
693
|
+
id,
|
|
694
|
+
);
|
|
695
|
+
}
|
|
696
|
+
if (
|
|
697
|
+
!positiveInteger(channel.capacity) ||
|
|
698
|
+
channel.capacity > 65536 ||
|
|
699
|
+
(channel.payload !== undefined && channel.payload !== 'impact') ||
|
|
700
|
+
(channel.overflow !== 'drop-newest' && channel.overflow !== 'drop-oldest')
|
|
701
|
+
) {
|
|
702
|
+
return eventInvalid(
|
|
703
|
+
'vfx-source-channel-invalid',
|
|
704
|
+
channelPath,
|
|
705
|
+
'an impact channel with capacity 1..65536 and explicit overflow policy',
|
|
706
|
+
id,
|
|
707
|
+
);
|
|
708
|
+
}
|
|
709
|
+
channelIds.add(channel.id);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
if (value.events !== undefined) {
|
|
713
|
+
if (!Array.isArray(value.events)) {
|
|
714
|
+
return eventInvalid('vfx-source-event-invalid', `${path}.events`, 'an event list', id);
|
|
715
|
+
}
|
|
716
|
+
const eventIds = new Set<string>();
|
|
717
|
+
for (const [eventIndex, event] of value.events.entries()) {
|
|
718
|
+
const eventPath = `${path}.events[${eventIndex}]`;
|
|
719
|
+
if (!record(event)) {
|
|
720
|
+
return eventInvalid('vfx-source-event-invalid', eventPath, 'an event object', id);
|
|
721
|
+
}
|
|
722
|
+
const eventExtra = allowed(event, [
|
|
723
|
+
'id',
|
|
724
|
+
'channel',
|
|
725
|
+
'subEmitter',
|
|
726
|
+
'fanOut',
|
|
727
|
+
'recursionDepth',
|
|
728
|
+
]);
|
|
729
|
+
if (eventExtra !== undefined) {
|
|
730
|
+
return eventInvalid(
|
|
731
|
+
'vfx-source-event-invalid',
|
|
732
|
+
`${eventPath}.${eventExtra}`,
|
|
733
|
+
'a supported event field',
|
|
734
|
+
id,
|
|
735
|
+
);
|
|
736
|
+
}
|
|
737
|
+
if (!text(event.id) || eventIds.has(event.id)) {
|
|
738
|
+
return eventInvalid('vfx-source-event-invalid', `${eventPath}.id`, 'a unique event id', id);
|
|
739
|
+
}
|
|
740
|
+
if (
|
|
741
|
+
!text(event.channel) ||
|
|
742
|
+
!text(event.subEmitter) ||
|
|
743
|
+
!positiveInteger(event.fanOut) ||
|
|
744
|
+
event.fanOut > 16 ||
|
|
745
|
+
!positiveInteger(event.recursionDepth) ||
|
|
746
|
+
event.recursionDepth > 8
|
|
747
|
+
) {
|
|
748
|
+
return eventInvalid(
|
|
749
|
+
'vfx-source-event-invalid',
|
|
750
|
+
eventPath,
|
|
751
|
+
'an event with bounded fanOut 1..16 and recursionDepth 1..8',
|
|
752
|
+
id,
|
|
753
|
+
);
|
|
754
|
+
}
|
|
755
|
+
eventIds.add(event.id);
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
if (
|
|
759
|
+
value.simulationWhenCulled !== undefined &&
|
|
760
|
+
value.simulationWhenCulled !== 'continue' &&
|
|
761
|
+
value.simulationWhenCulled !== 'pause' &&
|
|
762
|
+
value.simulationWhenCulled !== 'restart-on-visible'
|
|
763
|
+
) {
|
|
764
|
+
return invalid(`${path}.simulationWhenCulled`, 'continue, pause, or restart-on-visible', id);
|
|
765
|
+
}
|
|
766
|
+
return ok(value as unknown as ParticleEmitterSourceV2);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
export function parseParticleEffectSourceV2(
|
|
770
|
+
value: unknown,
|
|
771
|
+
): Result<ParticleEffectSourceV2, ParticleCodeSourceError> {
|
|
772
|
+
if (!record(value)) return invalid('$', 'a particle effect source object');
|
|
773
|
+
if (value.schemaVersion !== 2) {
|
|
774
|
+
return err({
|
|
775
|
+
code: 'vfx-source-version-unsupported',
|
|
776
|
+
expected: 'ParticleEffectSource schemaVersion 2',
|
|
777
|
+
hint: 'migrate code behavior to WGSL and recook; v1 is not interpreted at runtime',
|
|
778
|
+
detail: { path: 'schemaVersion' },
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
const extra = allowed(value, ['schemaVersion', 'emitters']);
|
|
782
|
+
if (extra !== undefined) return invalid(extra, 'a root field: emitters');
|
|
783
|
+
if (!Array.isArray(value.emitters) || value.emitters.length === 0) {
|
|
784
|
+
return invalid('emitters', 'at least one v2 emitter');
|
|
785
|
+
}
|
|
786
|
+
const ids = new Set<string>();
|
|
787
|
+
const emitters: ParticleEmitterSourceV2[] = [];
|
|
788
|
+
for (const [index, emitter] of value.emitters.entries()) {
|
|
789
|
+
const parsed = parseEmitter(emitter, index, ids);
|
|
790
|
+
if (!parsed.ok) return parsed;
|
|
791
|
+
emitters.push(parsed.value);
|
|
792
|
+
}
|
|
793
|
+
const emitterIds = new Set(emitters.map((emitter) => emitter.id));
|
|
794
|
+
for (const emitter of emitters) {
|
|
795
|
+
const channels = new Set((emitter.channels ?? []).map((channel) => channel.id));
|
|
796
|
+
for (const event of emitter.events ?? []) {
|
|
797
|
+
if (!channels.has(event.channel)) {
|
|
798
|
+
return eventInvalid(
|
|
799
|
+
'vfx-source-event-invalid',
|
|
800
|
+
`emitters[${emitters.indexOf(emitter)}].events.${event.id}.channel`,
|
|
801
|
+
'the id of a channel declared on the same emitter',
|
|
802
|
+
emitter.id,
|
|
803
|
+
);
|
|
804
|
+
}
|
|
805
|
+
if (!emitterIds.has(event.subEmitter)) {
|
|
806
|
+
return eventInvalid(
|
|
807
|
+
'vfx-source-event-invalid',
|
|
808
|
+
`emitters[${emitters.indexOf(emitter)}].events.${event.id}.subEmitter`,
|
|
809
|
+
'the id of an emitter in the same cooked effect',
|
|
810
|
+
emitter.id,
|
|
811
|
+
);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
return ok(
|
|
816
|
+
Object.freeze({
|
|
817
|
+
schemaVersion: 2,
|
|
818
|
+
emitters: Object.freeze(emitters),
|
|
819
|
+
}),
|
|
820
|
+
);
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
export function defineParticleEffectSourceV2<T extends ParticleEffectSourceV2>(source: T): T {
|
|
824
|
+
const parsed = parseParticleEffectSourceV2(source);
|
|
825
|
+
if (!parsed.ok) throw new TypeError(`${parsed.error.code}: ${parsed.error.expected}`);
|
|
826
|
+
return source;
|
|
827
|
+
}
|