@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
@@ -16,7 +16,8 @@ export type ParticleBoundsSource =
16
16
  };
17
17
 
18
18
  export type ParticleRendererOverflowPolicy = 'drop-newest' | 'drop-oldest';
19
- export type ParticleRendererSorting = 'none' | 'emitter' | 'back-to-front';
19
+ /** Internal structural projection used while validating the v3 source. */
20
+ export type ParticleRendererSortingStructure = 'none' | 'emitter' | 'back-to-front';
20
21
 
21
22
  export interface ParticleTextureSheetSource {
22
23
  readonly columns: number;
@@ -29,7 +30,7 @@ export interface ParticleSoftParticleSource {
29
30
  readonly fadeDistance: number;
30
31
  }
31
32
 
32
- export type ParticleRendererSource =
33
+ export type ParticleRendererSourceStructure =
33
34
  | {
34
35
  readonly kind: 'billboard';
35
36
  readonly material: string;
@@ -40,7 +41,7 @@ export type ParticleRendererSource =
40
41
  readonly textureSheet?: ParticleTextureSheetSource;
41
42
  readonly pivot?: readonly [number, number];
42
43
  readonly softParticle?: ParticleSoftParticleSource;
43
- readonly sorting?: ParticleRendererSorting;
44
+ readonly sorting?: ParticleRendererSortingStructure;
44
45
  }
45
46
  | {
46
47
  readonly kind: 'mesh';
@@ -124,7 +125,7 @@ export interface ParticleEventSource {
124
125
  readonly recursionDepth: number;
125
126
  }
126
127
 
127
- export interface ParticleEmitterSourceV2 {
128
+ export interface ParticleEmitterSourceStructure {
128
129
  readonly id: string;
129
130
  readonly capacity: number;
130
131
  readonly backend: { readonly required: 'gpu' };
@@ -136,18 +137,18 @@ export interface ParticleEmitterSourceV2 {
136
137
  readonly loopDuration?: number;
137
138
  };
138
139
  readonly program: { readonly module: string };
139
- readonly renderers: readonly ParticleRendererSource[];
140
+ readonly renderers: readonly ParticleRendererSourceStructure[];
140
141
  readonly channels?: readonly ParticleChannelSource[];
141
142
  readonly events?: readonly ParticleEventSource[];
142
143
  readonly simulationWhenCulled?: 'continue' | 'pause' | 'restart-on-visible';
143
144
  }
144
145
 
145
- export interface ParticleEffectRootSourceV2 {
146
- readonly schemaVersion: 2;
147
- readonly emitters: readonly ParticleEmitterSourceV2[];
146
+ export interface ParticleEffectRootSourceStructure {
147
+ readonly schemaVersion: 3;
148
+ readonly emitters: readonly ParticleEmitterSourceStructure[];
148
149
  }
149
150
 
150
- export type ParticleEffectSourceV2 = ParticleEffectRootSourceV2;
151
+ export type ParticleEffectSourceStructure = ParticleEffectRootSourceStructure;
151
152
 
152
153
  export interface ParticleCodeSourceInvalidDetail {
153
154
  readonly path: string;
@@ -364,9 +365,9 @@ function parseEmitter(
364
365
  value: unknown,
365
366
  index: number,
366
367
  ids: Set<string>,
367
- ): Result<ParticleEmitterSourceV2, ParticleCodeSourceError> {
368
+ ): Result<ParticleEmitterSourceStructure, ParticleCodeSourceError> {
368
369
  const path = `emitters[${index}]`;
369
- if (!record(value)) return invalid(path, 'a v2 emitter object');
370
+ if (!record(value)) return invalid(path, 'a Program v3 emitter object');
370
371
  const extra = allowed(value, [
371
372
  'id',
372
373
  'capacity',
@@ -380,7 +381,7 @@ function parseEmitter(
380
381
  'events',
381
382
  'simulationWhenCulled',
382
383
  ]);
383
- if (extra !== undefined) return invalid(`${path}.${extra}`, 'a v2 emitter field');
384
+ if (extra !== undefined) return invalid(`${path}.${extra}`, 'a Program v3 emitter field');
384
385
  if (!text(value.id) || ids.has(value.id)) return invalid(`${path}.id`, 'a unique non-empty id');
385
386
  const id = value.id;
386
387
  ids.add(id);
@@ -763,28 +764,29 @@ function parseEmitter(
763
764
  ) {
764
765
  return invalid(`${path}.simulationWhenCulled`, 'continue, pause, or restart-on-visible', id);
765
766
  }
766
- return ok(value as unknown as ParticleEmitterSourceV2);
767
+ return ok(value as unknown as ParticleEmitterSourceStructure);
767
768
  }
768
769
 
769
- export function parseParticleEffectSourceV2(
770
+ /** Validate the fields shared by the canonical Program v3 source parser. */
771
+ export function parseParticleEffectSourceStructure(
770
772
  value: unknown,
771
- ): Result<ParticleEffectSourceV2, ParticleCodeSourceError> {
773
+ ): Result<ParticleEffectSourceStructure, ParticleCodeSourceError> {
772
774
  if (!record(value)) return invalid('$', 'a particle effect source object');
773
- if (value.schemaVersion !== 2) {
775
+ if (value.schemaVersion !== 3) {
774
776
  return err({
775
777
  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
+ expected: 'ParticleEffectSource schemaVersion 3',
779
+ hint: 'cold-cook the source with the Program v3 compiler; older versions are not executable',
778
780
  detail: { path: 'schemaVersion' },
779
781
  });
780
782
  }
781
783
  const extra = allowed(value, ['schemaVersion', 'emitters']);
782
784
  if (extra !== undefined) return invalid(extra, 'a root field: emitters');
783
785
  if (!Array.isArray(value.emitters) || value.emitters.length === 0) {
784
- return invalid('emitters', 'at least one v2 emitter');
786
+ return invalid('emitters', 'at least one Program v3 emitter');
785
787
  }
786
788
  const ids = new Set<string>();
787
- const emitters: ParticleEmitterSourceV2[] = [];
789
+ const emitters: ParticleEmitterSourceStructure[] = [];
788
790
  for (const [index, emitter] of value.emitters.entries()) {
789
791
  const parsed = parseEmitter(emitter, index, ids);
790
792
  if (!parsed.ok) return parsed;
@@ -814,14 +816,8 @@ export function parseParticleEffectSourceV2(
814
816
  }
815
817
  return ok(
816
818
  Object.freeze({
817
- schemaVersion: 2,
819
+ schemaVersion: 3,
818
820
  emitters: Object.freeze(emitters),
819
821
  }),
820
822
  );
821
823
  }
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
- }
@@ -6,11 +6,12 @@ export type VfxDataInterfaceToken = `vfx:${VfxDataInterfaceKind}`;
6
6
 
7
7
  export type VfxDataInterfaceBindingType = VfxDataInterfaceBindingTypeByKind[VfxDataInterfaceKind];
8
8
 
9
+ export type VfxDataInterfaceResourceKind = 'buffer' | 'texture-view' | 'sampler';
10
+
9
11
  type VfxDataInterfaceBindingTypeByKind = {
10
12
  readonly camera: 'uniform';
11
13
  readonly 'scene-depth': 'sampled-depth';
12
14
  readonly noise: 'sampled-float';
13
- readonly channel: 'storage-read';
14
15
  };
15
16
 
16
17
  export type VfxDataInterfaceLifetime = 'generation';
@@ -21,6 +22,8 @@ export interface VfxDataInterfaceRequirement {
21
22
  readonly binding: number;
22
23
  readonly bindingType: VfxDataInterfaceBindingType;
23
24
  readonly lifetime: VfxDataInterfaceLifetime;
25
+ /** Scene-depth composition is intentionally proven only for single-sample targets. */
26
+ readonly sampleCount?: 1;
24
27
  }
25
28
 
26
29
  export interface VfxDataInterfaceResource {
@@ -28,6 +31,18 @@ export interface VfxDataInterfaceResource {
28
31
  readonly kind: VfxDataInterfaceKind;
29
32
  readonly bindingType: VfxDataInterfaceBindingType;
30
33
  readonly generation: number;
34
+ readonly sampleCount?: number;
35
+ /**
36
+ * Provider-owned opaque resource. Render owners refine this to their typed
37
+ * prepared-resource reference; VFX never inspects a backend handle.
38
+ */
39
+ readonly resource?: {
40
+ readonly kind: 'buffer' | 'texture-view' | 'sampler';
41
+ readonly value: unknown;
42
+ /** Required when a provider supplies an external buffer to a compute bind group. */
43
+ readonly size?: number;
44
+ readonly usage?: 'uniform' | 'storage';
45
+ };
31
46
  }
32
47
 
33
48
  export interface VfxDataInterfaceErrorDetail {
@@ -37,6 +52,8 @@ export interface VfxDataInterfaceErrorDetail {
37
52
  readonly actualGeneration?: number;
38
53
  readonly expectedBindingType?: VfxDataInterfaceBindingType;
39
54
  readonly actualBindingType?: VfxDataInterfaceBindingType;
55
+ readonly expectedResourceKind?: VfxDataInterfaceResourceKind;
56
+ readonly actualResourceKind?: VfxDataInterfaceResourceKind;
40
57
  }
41
58
 
42
59
  export interface VfxDataInterfaceError {
@@ -64,6 +81,16 @@ export interface VfxDataInterfaceResolution {
64
81
  readonly resources: readonly VfxDataInterfaceResource[];
65
82
  }
66
83
 
84
+ function expectedResourceKind(kind: VfxDataInterfaceKind): VfxDataInterfaceResourceKind {
85
+ switch (kind) {
86
+ case 'camera':
87
+ return 'buffer';
88
+ case 'scene-depth':
89
+ case 'noise':
90
+ return 'texture-view';
91
+ }
92
+ }
93
+
67
94
  function failure(
68
95
  code: VfxDataInterfaceError['code'],
69
96
  requirement: VfxDataInterfaceRequirement,
@@ -160,6 +187,44 @@ export function resolveVfxDataInterfaces(
160
187
  ),
161
188
  );
162
189
  }
190
+ const expectedKind = expectedResourceKind(requirement.kind);
191
+ if (resource.resource === undefined) {
192
+ return err(
193
+ failure(
194
+ 'vfx-data-interface-missing',
195
+ requirement,
196
+ `${requirement.kind} backed by a resident ${expectedKind} resource`,
197
+ `refresh provider ${provider.id} with a generation-owned resource before dispatch`,
198
+ { providerId: provider.id, expectedResourceKind: expectedKind },
199
+ ),
200
+ );
201
+ }
202
+ if (resource.resource.kind !== expectedKind) {
203
+ return err(
204
+ failure(
205
+ 'vfx-data-interface-wrong-type',
206
+ requirement,
207
+ `${requirement.kind} backed by a ${expectedKind} resource`,
208
+ `repair provider ${provider.id} so its opaque resource matches the reflected binding kind`,
209
+ {
210
+ providerId: provider.id,
211
+ expectedResourceKind: expectedKind,
212
+ actualResourceKind: resource.resource.kind,
213
+ },
214
+ ),
215
+ );
216
+ }
217
+ if (requirement.sampleCount !== undefined && resource.sampleCount !== requirement.sampleCount) {
218
+ return err(
219
+ failure(
220
+ 'vfx-data-interface-wrong-type',
221
+ requirement,
222
+ `a scene-depth resource with sampleCount=${requirement.sampleCount}`,
223
+ `provide a single-sample scene depth target for ${provider.id}; MSAA resolve is not implicit`,
224
+ { providerId: provider.id },
225
+ ),
226
+ );
227
+ }
163
228
  if (resource.generation !== generation) {
164
229
  return err(
165
230
  failure(
@@ -1,5 +1,6 @@
1
1
  import { err, ok, type Result } from '@forgeax/engine-types';
2
2
  import type { VfxDataInterfaceRequirement } from './data-interface.js';
3
+ import type { VfxCustomLayout, VfxParticleCoreLayout } from './particle-layout.js';
3
4
 
4
5
  export type VfxValue = number | readonly number[];
5
6
  export type VfxValueMap = Readonly<Record<string, VfxValue>>;
@@ -21,9 +22,12 @@ export interface VfxReflectedStruct {
21
22
  }
22
23
 
23
24
  export interface VfxEffectReflection {
24
- readonly version: 1;
25
+ /** The single executable particle data ABI. */
26
+ readonly version: 3;
25
27
  readonly parameters: VfxReflectedStruct;
26
28
  readonly custom: VfxReflectedStruct;
29
+ readonly core: VfxParticleCoreLayout;
30
+ readonly customLayout: VfxCustomLayout;
27
31
  readonly dataInterfaces?: readonly VfxDataInterfaceRequirement[];
28
32
  readonly fingerprint: string;
29
33
  }
@@ -48,10 +52,16 @@ export interface VfxEffectContract<Values extends VfxValueMap = VfxValueMap> {
48
52
  readonly reflection: VfxEffectReflection;
49
53
  readonly fingerprint: string;
50
54
  readonly packedSize: number;
55
+ /** Bytes in the effect-instance Parameters uniform. */
56
+ readonly parameterSize: number;
57
+ /** Bytes in one persistent per-particle Custom record. */
58
+ readonly customStride: number;
51
59
  readonly defaults: Values;
52
60
  createValues(initial?: Partial<Values>): Result<Values, VfxEffectContractError>;
53
61
  validateValues(values: VfxValueMap): Result<Values, VfxEffectContractError>;
54
62
  pack(values: Values): Result<Uint8Array, VfxEffectContractError>;
63
+ packParameters(values: Values): Result<Uint8Array, VfxEffectContractError>;
64
+ packCustom(values: VfxValueMap): Result<Uint8Array, VfxEffectContractError>;
55
65
  }
56
66
 
57
67
  const VECTOR_LENGTH = {
@@ -65,8 +75,13 @@ const VECTOR_LENGTH = {
65
75
 
66
76
  export type VfxValueType = keyof typeof VECTOR_LENGTH;
67
77
 
68
- function fieldList(reflection: VfxEffectReflection): readonly VfxReflectedField[] {
69
- return [...reflection.parameters.fields, ...reflection.custom.fields];
78
+ function fieldList(
79
+ reflection: VfxEffectReflection,
80
+ includeCustom = false,
81
+ ): readonly VfxReflectedField[] {
82
+ return includeCustom
83
+ ? [...reflection.parameters.fields, ...reflection.custom.fields]
84
+ : reflection.parameters.fields;
70
85
  }
71
86
 
72
87
  function zeroValue(type: VfxValueType): VfxValue {
@@ -101,14 +116,29 @@ function fail(
101
116
 
102
117
  function validateReflection(reflection: VfxEffectReflection): Result<true, VfxEffectContractError> {
103
118
  if (
104
- reflection.version !== 1 ||
119
+ reflection.version !== 3 ||
105
120
  typeof reflection.fingerprint !== 'string' ||
106
121
  !reflection.fingerprint.startsWith('sha256:')
107
122
  ) {
108
123
  return fail(
109
124
  'vfx-reflection-invalid',
110
125
  'reflection',
111
- 'a version 1 reflection with a sha256 fingerprint',
126
+ 'a version 3 reflection with a sha256 fingerprint',
127
+ 'recook the effect with the current VFX compiler',
128
+ );
129
+ }
130
+ if (
131
+ reflection.core.name !== 'VfxParticle' ||
132
+ reflection.core.stride !== 112 ||
133
+ reflection.core.fields.length === 0 ||
134
+ reflection.customLayout.name !== 'VfxCustom' ||
135
+ reflection.customLayout.stride < 0 ||
136
+ reflection.customLayout.lanes < 0
137
+ ) {
138
+ return fail(
139
+ 'vfx-reflection-invalid',
140
+ 'reflection.core',
141
+ 'Program v3 Core and Custom layouts derived from the particle schema',
112
142
  'recook the effect with the current VFX compiler',
113
143
  );
114
144
  }
@@ -140,14 +170,21 @@ function validateMap<Values extends VfxValueMap>(
140
170
  reflection: VfxEffectReflection,
141
171
  values: VfxValueMap,
142
172
  ): Result<Values, VfxEffectContractError> {
143
- const fields = new Map(fieldList(reflection).map((field) => [field.name, field]));
173
+ return validateMapAgainstFields<Values>(fieldList(reflection), values);
174
+ }
175
+
176
+ function validateMapAgainstFields<Values extends VfxValueMap>(
177
+ reflectedFields: readonly VfxReflectedField[],
178
+ values: VfxValueMap,
179
+ ): Result<Values, VfxEffectContractError> {
180
+ const fields = new Map(reflectedFields.map((field) => [field.name, field]));
144
181
  for (const name of Object.keys(values)) {
145
182
  const field = fields.get(name);
146
183
  if (field === undefined) {
147
184
  return fail(
148
185
  'vfx-value-unknown-field',
149
186
  name,
150
- 'a field declared by VfxParameters or VfxCustom',
187
+ 'a field declared by the selected VFX data scope',
151
188
  `remove ${name} or declare it in the authored WGSL struct`,
152
189
  values[name],
153
190
  );
@@ -181,13 +218,33 @@ export function createVfxEffectContract<Values extends VfxValueMap = VfxValueMap
181
218
  const checked = validateReflection(reflection);
182
219
  if (!checked.ok) throw new TypeError(checked.error.hint);
183
220
  const defaults = defaultsFor<Values>(reflection);
184
- const fields = fieldList(reflection);
185
221
  const customBase = reflection.parameters.size;
186
- const packedSize = customBase + reflection.custom.size;
222
+ const parameterSize = reflection.parameters.size;
223
+ const customStride = reflection.customLayout.stride;
224
+ const packedSize = customBase;
225
+ const packFields = (
226
+ values: VfxValueMap,
227
+ selected: readonly VfxReflectedField[],
228
+ size: number,
229
+ baseOffset: number,
230
+ ): Result<Uint8Array, VfxEffectContractError> => {
231
+ const checkedValues = validateMapAgainstFields<Values>(selected, values);
232
+ if (!checkedValues.ok) return checkedValues;
233
+ const bytes = new Uint8Array(size);
234
+ const view = new DataView(bytes.buffer);
235
+ for (const field of selected) {
236
+ const value = checkedValues.value[field.name];
237
+ if (value === undefined) continue;
238
+ writeValue(view, baseOffset + field.offset, field.type, value);
239
+ }
240
+ return ok(bytes);
241
+ };
187
242
  return {
188
243
  reflection,
189
244
  fingerprint: reflection.fingerprint,
190
245
  packedSize,
246
+ parameterSize: reflection.parameters.size,
247
+ customStride,
191
248
  defaults,
192
249
  createValues(initial = {} as Partial<Values>) {
193
250
  const merged = { ...defaults, ...initial };
@@ -197,17 +254,13 @@ export function createVfxEffectContract<Values extends VfxValueMap = VfxValueMap
197
254
  return validateMap<Values>(reflection, values);
198
255
  },
199
256
  pack(values) {
200
- const checkedValues = validateMap<Values>(reflection, values);
201
- if (!checkedValues.ok) return checkedValues;
202
- const bytes = new Uint8Array(packedSize);
203
- const view = new DataView(bytes.buffer);
204
- for (const field of fields) {
205
- const value = checkedValues.value[field.name];
206
- if (value === undefined) continue;
207
- const base = reflection.parameters.fields.includes(field) ? 0 : customBase;
208
- writeValue(view, base + field.offset, field.type, value);
209
- }
210
- return ok(bytes);
257
+ return packFields(values, reflection.parameters.fields, parameterSize, 0);
258
+ },
259
+ packParameters(values) {
260
+ return packFields(values, reflection.parameters.fields, parameterSize, 0);
261
+ },
262
+ packCustom(values) {
263
+ return packFields(values, reflection.custom.fields, customStride, 0);
211
264
  },
212
265
  };
213
266
  }
package/src/gpu-loader.ts CHANGED
@@ -12,16 +12,17 @@ import type {
12
12
  import { err, ok } from '@forgeax/engine-types';
13
13
  import {
14
14
  VFX_GPU_PROGRAM_ARTIFACT_KEY,
15
- VFX_GPU_PROGRAM_FORMAT,
16
- type VfxGpuEffectAsset,
17
- type VfxGpuEmitterProgram,
15
+ VFX_GPU_PROGRAM_FORMAT_V3,
16
+ type VfxGpuEffectAssetAny,
17
+ type VfxGpuEmitterProgramV3,
18
18
  } from './gpu-program.js';
19
19
 
20
20
  export interface VfxGpuAssetError {
21
21
  readonly code:
22
- | 'vfx-asset-v2-invalid'
23
- | 'vfx-asset-v2-program-missing'
24
- | 'vfx-asset-v2-fingerprint-mismatch';
22
+ | 'vfx-asset-version-unsupported'
23
+ | 'vfx-asset-v3-invalid'
24
+ | 'vfx-asset-v3-program-missing'
25
+ | 'vfx-asset-v3-fingerprint-mismatch';
25
26
  readonly expected: string;
26
27
  readonly hint: string;
27
28
  readonly detail: { readonly guid: string; readonly path: string };
@@ -70,20 +71,26 @@ async function fingerprint(bytes: Uint8Array): Promise<string> {
70
71
 
71
72
  function validReflectionLayout(value: unknown): boolean {
72
73
  if (value === undefined) return true;
73
- if (!record(value) || value.version !== 1) return false;
74
+ if (!record(value) || value.version !== 3) return false;
74
75
  if (!record(value.parameters) || !Array.isArray(value.parameters.fields)) return false;
75
76
  if (!record(value.custom) || !Array.isArray(value.custom.fields)) return false;
77
+ if (value.version === 3) {
78
+ if (!record(value.core) || value.core.name !== 'VfxParticle' || value.core.stride !== 112) {
79
+ return false;
80
+ }
81
+ if (!record(value.customLayout) || value.customLayout.name !== 'VfxCustom') return false;
82
+ }
76
83
  return typeof value.fingerprint === 'string' && value.fingerprint.startsWith('sha256:');
77
84
  }
78
85
 
79
- function stableLayouts(emitters: readonly VfxGpuEmitterProgram[]): boolean {
86
+ function stableLayouts(emitters: readonly VfxGpuEmitterProgramV3[]): boolean {
80
87
  const fingerprints = emitters
81
88
  .map((emitter) => emitter.reflection.layout?.fingerprint)
82
89
  .filter((fingerprint): fingerprint is string => fingerprint !== undefined);
83
90
  return fingerprints.every((fingerprint) => fingerprint === fingerprints[0]);
84
91
  }
85
92
 
86
- function validEmitter(value: unknown): value is VfxGpuEmitterProgram {
93
+ function validEmitter(value: unknown): boolean {
87
94
  const reflection = record(value) && record(value.reflection) ? value.reflection : undefined;
88
95
  const layout = reflection !== undefined ? reflection.layout : undefined;
89
96
  return (
@@ -107,7 +114,13 @@ function validEmitter(value: unknown): value is VfxGpuEmitterProgram {
107
114
  value.reflection.entryPoints.includes('forgeax_vfx_mesh_main') &&
108
115
  value.reflection.entryPoints.includes('forgeax_vfx_ribbon_main') &&
109
116
  value.reflection.entryPoints.includes('forgeax_vfx_trail_main') &&
110
- value.reflection.entryPoints.includes('forgeax_vfx_beam_main')
117
+ value.reflection.entryPoints.includes('forgeax_vfx_beam_main') &&
118
+ layout !== undefined &&
119
+ record(layout) &&
120
+ Array.isArray(reflection?.bindings) &&
121
+ Array.isArray(reflection?.dataInterfaces) &&
122
+ Array.isArray(reflection?.renderers) &&
123
+ Array.isArray(reflection?.resources)
111
124
  );
112
125
  }
113
126
 
@@ -116,26 +129,39 @@ export const vfxGpuEffectPackLoader = {
116
129
  async load(
117
130
  input: PackLoaderInput,
118
131
  _context: LoadContext,
119
- ): Promise<Result<VfxGpuEffectAsset, VfxGpuAssetError>> {
132
+ ): Promise<Result<VfxGpuEffectAssetAny, VfxGpuAssetError>> {
133
+ const schemaVersion = input.payload.schemaVersion;
134
+ if (schemaVersion !== 3) {
135
+ return failure(
136
+ 'vfx-asset-version-unsupported',
137
+ input.guid,
138
+ 'schemaVersion',
139
+ 'schemaVersion 3 / forgeax-vfx-program-3',
140
+ 'cold-cook the older source and publish a Program v3 payload; older versions are not executable',
141
+ );
142
+ }
143
+ const invalidCode = 'vfx-asset-v3-invalid' as const;
144
+ const missingCode = 'vfx-asset-v3-program-missing' as const;
145
+ const fingerprintCode = 'vfx-asset-v3-fingerprint-mismatch' as const;
146
+ const expectedFormat = VFX_GPU_PROGRAM_FORMAT_V3;
120
147
  if (
121
148
  input.payload.kind !== 'particle-effect' ||
122
- input.payload.schemaVersion !== 2 ||
123
149
  !Array.isArray(input.payload.emitters) ||
124
150
  typeof input.payload.programFingerprint !== 'string' ||
125
151
  !record(input.payload.program)
126
152
  ) {
127
153
  return failure(
128
- 'vfx-asset-v2-invalid',
154
+ invalidCode,
129
155
  input.guid,
130
156
  'payload',
131
- 'a schemaVersion 2 particle payload with its complete program and fingerprint',
132
- 'migrate behavior to WGSL and recook; the runtime does not interpret v1',
157
+ 'a schemaVersion 3 particle payload with its complete Program v3 and fingerprint',
158
+ 'cold-cook the source with the current VFX compiler and retry the load',
133
159
  );
134
160
  }
135
161
  const artifact = input.artifacts[VFX_GPU_PROGRAM_ARTIFACT_KEY];
136
162
  if (artifact === undefined) {
137
163
  return failure(
138
- 'vfx-asset-v2-program-missing',
164
+ missingCode,
139
165
  input.guid,
140
166
  VFX_GPU_PROGRAM_ARTIFACT_KEY,
141
167
  'the asset-local cooked VFX program',
@@ -147,7 +173,7 @@ export const vfxGpuEffectPackLoader = {
147
173
  decoded = JSON.parse(new TextDecoder().decode(artifact.bytes));
148
174
  } catch {
149
175
  return failure(
150
- 'vfx-asset-v2-invalid',
176
+ invalidCode,
151
177
  input.guid,
152
178
  VFX_GPU_PROGRAM_ARTIFACT_KEY,
153
179
  'canonical JSON program bytes',
@@ -156,27 +182,27 @@ export const vfxGpuEffectPackLoader = {
156
182
  }
157
183
  if (
158
184
  !record(decoded) ||
159
- decoded.format !== VFX_GPU_PROGRAM_FORMAT ||
185
+ decoded.format !== expectedFormat ||
160
186
  !Array.isArray(decoded.emitters) ||
161
- !decoded.emitters.every(validEmitter) ||
162
- !stableLayouts(decoded.emitters as VfxGpuEmitterProgram[])
187
+ !decoded.emitters.every((emitter) => validEmitter(emitter)) ||
188
+ !stableLayouts(decoded.emitters as VfxGpuEmitterProgramV3[])
163
189
  ) {
164
190
  return failure(
165
- 'vfx-asset-v2-invalid',
191
+ invalidCode,
166
192
  input.guid,
167
193
  VFX_GPU_PROGRAM_ARTIFACT_KEY,
168
- `a ${VFX_GPU_PROGRAM_FORMAT} managed GPU program`,
194
+ `a ${expectedFormat} managed GPU program`,
169
195
  'recook with the current VFX compiler ABI',
170
196
  );
171
197
  }
172
- const decodedEmitters = decoded.emitters as VfxGpuEmitterProgram[];
198
+ const decodedEmitters = decoded.emitters as VfxGpuEmitterProgramV3[];
173
199
  if (
174
- input.payload.program.format !== VFX_GPU_PROGRAM_FORMAT ||
200
+ input.payload.program.format !== expectedFormat ||
175
201
  input.payload.program.fingerprint !== input.payload.programFingerprint ||
176
202
  !Array.isArray(input.payload.program.emitters)
177
203
  ) {
178
204
  return failure(
179
- 'vfx-asset-v2-invalid',
205
+ invalidCode,
180
206
  input.guid,
181
207
  'payload.program',
182
208
  'the complete canonical program matching payload.programFingerprint',
@@ -186,7 +212,7 @@ export const vfxGpuEffectPackLoader = {
186
212
  const actualFingerprint = await fingerprint(artifact.bytes);
187
213
  if (actualFingerprint !== input.payload.programFingerprint) {
188
214
  return failure(
189
- 'vfx-asset-v2-fingerprint-mismatch',
215
+ fingerprintCode,
190
216
  input.guid,
191
217
  'payload.programFingerprint',
192
218
  'payload and program artifact fingerprints to match',
@@ -209,7 +235,7 @@ export const vfxGpuEffectPackLoader = {
209
235
  )
210
236
  ) {
211
237
  return failure(
212
- 'vfx-asset-v2-invalid',
238
+ invalidCode,
213
239
  input.guid,
214
240
  'payload.emitters',
215
241
  'payload emitter identities and capacities to match the program',
@@ -219,26 +245,26 @@ export const vfxGpuEffectPackLoader = {
219
245
  return ok(
220
246
  Object.freeze({
221
247
  guid: input.guid,
222
- kind: 'particle-effect',
223
- schemaVersion: 2,
248
+ kind: 'particle-effect' as const,
249
+ schemaVersion: 3,
224
250
  programFingerprint: actualFingerprint,
225
251
  emitters: Object.freeze(emitters as { id: string; capacity: number }[]),
226
252
  program: Object.freeze({
227
- format: VFX_GPU_PROGRAM_FORMAT,
253
+ format: expectedFormat,
228
254
  fingerprint: actualFingerprint,
229
255
  emitters: Object.freeze(decodedEmitters),
230
256
  }),
231
- }),
257
+ }) as VfxGpuEffectAssetAny,
232
258
  );
233
259
  },
234
260
  };
235
261
 
236
262
  /** VFX owner decoder contribution for the core registry's typed map. */
237
263
  export const vfxGpuEffectContribution: AssetDecoderContribution<
238
- VfxGpuEffectAsset,
264
+ VfxGpuEffectAssetAny,
239
265
  'particle-effect'
240
266
  > = {
241
- kind: { kind: 'particle-effect' } as AssetKind<VfxGpuEffectAsset, 'particle-effect'>,
267
+ kind: { kind: 'particle-effect' } as AssetKind<VfxGpuEffectAssetAny, 'particle-effect'>,
242
268
  consumer: 'VfxGpuRuntime',
243
269
  decoder: {
244
270
  async decode({ envelope, artifacts }) {
@@ -277,7 +303,7 @@ export const vfxGpuEffectContribution: AssetDecoderContribution<
277
303
  export async function loadVfxGpuEffect(
278
304
  registry: LegacyAssetRegistry | RuntimeAssetRegistry,
279
305
  guid: string,
280
- ): Promise<Result<VfxGpuEffectAsset, unknown>> {
306
+ ): Promise<Result<VfxGpuEffectAssetAny, unknown>> {
281
307
  if ('load' in registry) {
282
308
  return registry.load(guid, vfxGpuEffectContribution.kind);
283
309
  }
@@ -287,5 +313,5 @@ export async function loadVfxGpuEffect(
287
313
  } catch (error) {
288
314
  return err(error);
289
315
  }
290
- return registry.loadByGuid<VfxGpuEffectAsset>(parsed);
316
+ return registry.loadByGuid<VfxGpuEffectAssetAny>(parsed);
291
317
  }