@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
@@ -0,0 +1,62 @@
1
+ import type { VfxReflectedStruct, VfxValueType } from './effect-contract.js';
2
+ /**
3
+ * The particle core is a storage-buffer ABI, not a convenient object shape.
4
+ * Keep its fields in one ordered schema so WGSL, host-side allocation and
5
+ * renderer projections all consume the same offsets.
6
+ */
7
+ export type VfxParticleCoreAttribute = 'position' | 'age' | 'velocity' | 'lifetime' | 'color' | 'sprite_size' | 'sprite_rotation' | 'sub_image' | 'mesh_orientation' | 'mesh_scale' | 'material_random' | 'id' | 'alive';
8
+ export interface VfxParticleCoreField {
9
+ readonly name: VfxParticleCoreAttribute;
10
+ readonly type: VfxValueType | 'u32';
11
+ readonly offset: number;
12
+ readonly size: number;
13
+ readonly alignment: number;
14
+ }
15
+ export interface VfxParticleCoreLayout {
16
+ readonly name: 'VfxParticle';
17
+ readonly fields: readonly VfxParticleCoreField[];
18
+ readonly size: number;
19
+ readonly alignment: 16;
20
+ readonly stride: number;
21
+ readonly fingerprint: string;
22
+ }
23
+ /** Program v3 core layout. The 112-byte stride includes WGSL's final 16-byte alignment. */
24
+ export declare const VFX_PARTICLE_CORE_LAYOUT: VfxParticleCoreLayout;
25
+ export declare const VFX_PARTICLE_CORE_STRIDE: number;
26
+ export declare function vfxParticleCoreField(name: VfxParticleCoreAttribute): VfxParticleCoreField;
27
+ /** WGSL declaration generated from the core schema. */
28
+ export declare function vfxParticleCoreWgsl(): string;
29
+ /** Effect-level data is intentionally separate from per-particle custom data. */
30
+ export interface VfxParametersLayout extends VfxReflectedStruct {
31
+ readonly name: 'VfxParameters';
32
+ }
33
+ export interface VfxCustomLayout extends VfxReflectedStruct {
34
+ readonly name: 'VfxCustom';
35
+ /** Array stride for `array<VfxCustom>` in the persistent GPU storage buffer. */
36
+ readonly stride: number;
37
+ /** Number of vec4-equivalent lanes consumed by the custom record. */
38
+ readonly lanes: number;
39
+ }
40
+ /** Re-derive custom offsets from reflected fields and reject the bounded renderer budget. */
41
+ export declare function deriveVfxCustomLayout(struct: VfxReflectedStruct, maxLanes?: number): VfxCustomLayout;
42
+ export type VfxParticleCoreValue = readonly [number, number, number] | readonly [number, number, number, number] | readonly [number, number] | number;
43
+ export interface VfxParticleCoreValues {
44
+ readonly position: readonly [number, number, number];
45
+ readonly age: number;
46
+ readonly velocity: readonly [number, number, number];
47
+ readonly lifetime: number;
48
+ readonly color: readonly [number, number, number, number];
49
+ readonly sprite_size: readonly [number, number];
50
+ readonly sprite_rotation: number;
51
+ readonly sub_image: number;
52
+ readonly mesh_orientation: readonly [number, number, number, number];
53
+ readonly mesh_scale: readonly [number, number, number];
54
+ readonly material_random: number;
55
+ readonly id: number;
56
+ readonly alive: number;
57
+ }
58
+ /** Pack a core record using schema offsets. This is for inspection/replay fixtures, not a particle mirror. */
59
+ export declare function encodeVfxParticleCore(value: VfxParticleCoreValues): Uint8Array;
60
+ /** Return the normalized age used by all renderer semantic projections. */
61
+ export declare function normalizedVfxParticleAge(age: number, lifetime: number): number;
62
+ //# sourceMappingURL=particle-layout.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"particle-layout.d.ts","sourceRoot":"","sources":["../src/particle-layout.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forgeax/engine-vfx",
3
- "version": "0.1.27",
3
+ "version": "0.1.29",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -29,10 +29,10 @@
29
29
  "LICENSE"
30
30
  ],
31
31
  "dependencies": {
32
- "@forgeax/engine-assets-runtime": "0.1.27",
33
- "@forgeax/engine-ecs": "0.1.27",
34
- "@forgeax/engine-plugin": "0.1.27",
35
- "@forgeax/engine-types": "0.1.27"
32
+ "@forgeax/engine-assets-runtime": "0.1.29",
33
+ "@forgeax/engine-ecs": "0.1.29",
34
+ "@forgeax/engine-plugin": "0.1.29",
35
+ "@forgeax/engine-types": "0.1.29"
36
36
  },
37
37
  "forgeax": {
38
38
  "metrics": {
@@ -2,15 +2,16 @@ import { describe, expect, it } from 'vitest';
2
2
  import { describeVfxGpuEffect, isVfxGpuEffectAsset } from '../authoring-descriptor.js';
3
3
  import type { VfxGpuEffectAsset } from '../gpu-program.js';
4
4
  import { VFX_GPU_PROGRAM_ARTIFACT_KEY, VFX_GPU_PROGRAM_FORMAT } from '../gpu-program.js';
5
+ import { VFX_PARTICLE_CORE_LAYOUT } from '../particle-layout.js';
5
6
 
6
- const effect: VfxGpuEffectAsset = {
7
+ const effect = {
7
8
  guid: 'effect-guid',
8
9
  kind: 'particle-effect',
9
- schemaVersion: 2,
10
+ schemaVersion: 3,
10
11
  programFingerprint: 'sha256:effect',
11
12
  emitters: [{ id: 'sparks', capacity: 128 }],
12
13
  program: {
13
- format: 'forgeax-vfx-program-2',
14
+ format: 'forgeax-vfx-program-3',
14
15
  fingerprint: 'sha256:effect',
15
16
  emitters: [
16
17
  {
@@ -22,7 +23,7 @@ const effect: VfxGpuEffectAsset = {
22
23
  schedule: { rate: 8, bursts: [{ time: 0, count: 4 }], loopDuration: 2 },
23
24
  bounds: { kind: 'sphere', center: [0, 1, 0], radius: 4 },
24
25
  renderers: [
25
- { kind: 'billboard', material: 'material-guid', sorting: 'back-to-front' },
26
+ { kind: 'billboard', material: 'material-guid', sorting: 'view-depth' },
26
27
  { kind: 'beam', material: 'beam-material-guid', endpointField: 'velocity', capacity: 32 },
27
28
  ],
28
29
  channels: [{ id: 'impact', payload: 'impact', capacity: 8, overflow: 'drop-newest' }],
@@ -44,7 +45,7 @@ const effect: VfxGpuEffectAsset = {
44
45
  entryPoints: ['forgeax_vfx_spawn_main'],
45
46
  bindings: [],
46
47
  layout: {
47
- version: 1,
48
+ version: 3,
48
49
  parameters: {
49
50
  name: 'VfxParameters',
50
51
  fields: [
@@ -61,6 +62,15 @@ const effect: VfxGpuEffectAsset = {
61
62
  alignment: 16,
62
63
  },
63
64
  custom: { name: 'VfxCustom', fields: [], size: 0, alignment: 1 },
65
+ core: VFX_PARTICLE_CORE_LAYOUT,
66
+ customLayout: {
67
+ name: 'VfxCustom',
68
+ fields: [],
69
+ size: 0,
70
+ alignment: 1,
71
+ stride: 0,
72
+ lanes: 0,
73
+ },
64
74
  fingerprint: 'sha256:layout',
65
75
  },
66
76
  stages: [
@@ -78,11 +88,11 @@ const effect: VfxGpuEffectAsset = {
78
88
  },
79
89
  ],
80
90
  },
81
- };
91
+ } as unknown as VfxGpuEffectAsset;
82
92
 
83
93
  describe('VFX authoring descriptor', () => {
84
94
  it('keeps the complete program format and asset-local artifact key canonical', () => {
85
- expect(VFX_GPU_PROGRAM_FORMAT).toBe('forgeax-vfx-program-2');
95
+ expect(VFX_GPU_PROGRAM_FORMAT).toBe('forgeax-vfx-program-3');
86
96
  expect(VFX_GPU_PROGRAM_ARTIFACT_KEY).toBe('particle-effect/program.json');
87
97
  expect(effect.program.format).toBe(VFX_GPU_PROGRAM_FORMAT);
88
98
  expect(effect.programFingerprint).toMatch(/^sha256:/);
@@ -129,7 +139,7 @@ describe('VFX authoring descriptor', () => {
129
139
  );
130
140
  expect(descriptor.capabilities).toEqual(
131
141
  expect.arrayContaining([
132
- expect.objectContaining({ id: 'runtime-parameters', state: 'partial' }),
142
+ expect.objectContaining({ id: 'runtime-parameters', state: 'executable' }),
133
143
  expect.objectContaining({ id: 'deterministic-replay', state: 'executable' }),
134
144
  ]),
135
145
  );
@@ -1,11 +1,14 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import { createVfxEffectContract } from '../effect-contract.js';
3
3
  import { ParticleEffectInstance, type VfxChannelInput } from '../instance.js';
4
+ import { VFX_PARTICLE_CORE_LAYOUT } from '../particle-layout.js';
4
5
 
5
6
  const reflection = {
6
- version: 1,
7
+ version: 3,
7
8
  parameters: { name: 'VfxParameters', fields: [], size: 0, alignment: 1 },
8
9
  custom: { name: 'VfxCustom', fields: [], size: 0, alignment: 1 },
10
+ core: VFX_PARTICLE_CORE_LAYOUT,
11
+ customLayout: { name: 'VfxCustom', fields: [], size: 0, alignment: 1, stride: 0, lanes: 0 },
9
12
  fingerprint: 'sha256:channel-input',
10
13
  } as const;
11
14
 
@@ -4,14 +4,14 @@ import type {
4
4
  ParticleChannelOverflowPolicy,
5
5
  ParticleChannelSource,
6
6
  ParticleRendererOverflowPolicy,
7
- ParticleRendererSource,
8
7
  } from '../code-source.js';
8
+ import type { ParticleRendererSourceV3 } from '../code-source-v3.js';
9
9
  import type {
10
10
  ParticleChannelOverflowPolicy as PublicParticleChannelOverflowPolicy,
11
11
  ParticleRendererOverflowPolicy as PublicParticleRendererOverflowPolicy,
12
12
  } from '../index.js';
13
13
 
14
- type BillboardRenderer = Extract<ParticleRendererSource, { readonly kind: 'billboard' }>;
14
+ type BillboardRenderer = Extract<ParticleRendererSourceV3, { readonly kind: 'billboard' }>;
15
15
  type RendererOverflow = BillboardRenderer['overflow'];
16
16
  type ChannelOverflow = ParticleChannelSource['overflow'];
17
17
  type RendererOverflowSlot = Pick<BillboardRenderer, 'overflow'>;
@@ -1,8 +1,9 @@
1
1
  import { describe, expect, it } from 'vitest';
2
- import { parseParticleEffectSourceV2, parseVfxStageDeclarations } from '../code-source.js';
2
+ import { parseVfxStageDeclarations } from '../code-source.js';
3
+ import { parseParticleEffectSourceV3 } from '../code-source-v3.js';
3
4
 
4
5
  const source = {
5
- schemaVersion: 2,
6
+ schemaVersion: 3,
6
7
  emitters: [
7
8
  {
8
9
  id: 'sparks',
@@ -17,9 +18,9 @@ const source = {
17
18
  ],
18
19
  };
19
20
 
20
- describe('ParticleCodeEffectSource v2', () => {
21
+ describe('ParticleCodeEffectSource v3', () => {
21
22
  it('accepts typed bounded channels and same-effect visual events', () => {
22
- const result = parseParticleEffectSourceV2({
23
+ const result = parseParticleEffectSourceV3({
23
24
  ...source,
24
25
  emitters: [
25
26
  {
@@ -41,7 +42,7 @@ describe('ParticleCodeEffectSource v2', () => {
41
42
  });
42
43
 
43
44
  it('accepts the code-first source without an operator stack', () => {
44
- const result = parseParticleEffectSourceV2(source);
45
+ const result = parseParticleEffectSourceV3(source);
45
46
  expect(result.ok).toBe(true);
46
47
  if (result.ok) {
47
48
  expect(result.value.emitters[0]?.program.module).toBe('sparks.vfx.wgsl');
@@ -56,12 +57,12 @@ describe('ParticleCodeEffectSource v2', () => {
56
57
  });
57
58
 
58
59
  it('rejects v1 with an executable recook hint', () => {
59
- const result = parseParticleEffectSourceV2({ schemaVersion: 1, emitters: [] });
60
+ const result = parseParticleEffectSourceV3({ schemaVersion: 1, emitters: [] });
60
61
  expect(result.ok).toBe(false);
61
62
  if (!result.ok) {
62
63
  expect(result.error.code).toBe('vfx-source-version-unsupported');
63
- expect(result.error.hint).toContain('WGSL');
64
- expect(result.error.hint).toContain('recook');
64
+ expect(result.error.hint).toContain('Program v3');
65
+ expect(result.error.hint).toContain('cold-cook');
65
66
  }
66
67
  });
67
68
 
@@ -73,10 +74,10 @@ describe('ParticleCodeEffectSource v2', () => {
73
74
  expect(emitter).toBeDefined();
74
75
  if (emitter === undefined) return;
75
76
  emitter.backend = { required: 'cpu' };
76
- expect(parseParticleEffectSourceV2(backend).ok).toBe(false);
77
+ expect(parseParticleEffectSourceV3(backend).ok).toBe(false);
77
78
  const derived = structuredClone(source) as typeof source & { backendPlan: string };
78
79
  derived.backendPlan = 'gpu';
79
- expect(parseParticleEffectSourceV2(derived).ok).toBe(false);
80
+ expect(parseParticleEffectSourceV3(derived).ok).toBe(false);
80
81
  });
81
82
 
82
83
  it.each([
@@ -88,7 +89,7 @@ describe('ParticleCodeEffectSource v2', () => {
88
89
  ['custom data', { customData: { value: 1 } }],
89
90
  ])('rejects unknown nested %s fields', (_name, override) => {
90
91
  const emitter = { ...source.emitters[0], ...override };
91
- expect(parseParticleEffectSourceV2({ ...source, emitters: [emitter] }).ok).toBe(false);
92
+ expect(parseParticleEffectSourceV3({ ...source, emitters: [emitter] }).ok).toBe(false);
92
93
  });
93
94
 
94
95
  it('keeps Data Interface requirements out of the source-side roster', () => {
@@ -96,7 +97,7 @@ describe('ParticleCodeEffectSource v2', () => {
96
97
  ...source.emitters[0],
97
98
  dataInterfaces: [{ token: 'vfx:camera' }],
98
99
  };
99
- expect(parseParticleEffectSourceV2({ ...source, emitters: [emitter] }).ok).toBe(false);
100
+ expect(parseParticleEffectSourceV3({ ...source, emitters: [emitter] }).ok).toBe(false);
100
101
  });
101
102
 
102
103
  it('accepts an explicit mesh submesh and rejects ambiguous indices', () => {
@@ -111,11 +112,11 @@ describe('ParticleCodeEffectSource v2', () => {
111
112
  { kind: 'mesh', material: 'material-guid', mesh: 'mesh-guid', submesh: 2 },
112
113
  ];
113
114
  emitter.renderers = renderers;
114
- expect(parseParticleEffectSourceV2(mesh).ok).toBe(true);
115
+ expect(parseParticleEffectSourceV3(mesh).ok).toBe(true);
115
116
  const renderer = renderers[0];
116
117
  expect(renderer).toBeDefined();
117
118
  if (renderer === undefined) return;
118
119
  renderer.submesh = -1;
119
- expect(parseParticleEffectSourceV2(mesh).ok).toBe(false);
120
+ expect(parseParticleEffectSourceV3(mesh).ok).toBe(false);
120
121
  });
121
122
  });
@@ -42,7 +42,15 @@ describe('VFX Data Interface contract', () => {
42
42
  ],
43
43
  [
44
44
  'stale generation',
45
- [provider({ token: 'vfx:camera', kind: 'camera', bindingType: 'uniform', generation: 2 })],
45
+ [
46
+ provider({
47
+ token: 'vfx:camera',
48
+ kind: 'camera',
49
+ bindingType: 'uniform',
50
+ generation: 2,
51
+ resource: { kind: 'buffer', value: {}, size: 16, usage: 'uniform' },
52
+ }),
53
+ ],
46
54
  'vfx-data-interface-stale',
47
55
  ],
48
56
  ])('%s is a structured failure', (_name, providers, code) => {
@@ -63,12 +71,43 @@ describe('VFX Data Interface contract', () => {
63
71
  it('returns generation-scoped readiness for a valid provider', () => {
64
72
  const result = resolveVfxDataInterfaces(
65
73
  [camera],
66
- [provider({ token: 'vfx:camera', kind: 'camera', bindingType: 'uniform' })],
74
+ [
75
+ provider({
76
+ token: 'vfx:camera',
77
+ kind: 'camera',
78
+ bindingType: 'uniform',
79
+ resource: { kind: 'buffer', value: {}, size: 16, usage: 'uniform' },
80
+ }),
81
+ ],
67
82
  7,
68
83
  );
69
84
  expect(result).toMatchObject({ ok: true, value: { generation: 7, readiness: 'ready' } });
70
85
  });
71
86
 
87
+ it('rejects an opaque resource whose kind cannot satisfy the reflected binding', () => {
88
+ const result = resolveVfxDataInterfaces(
89
+ [camera],
90
+ [
91
+ provider({
92
+ token: 'vfx:camera',
93
+ kind: 'camera',
94
+ bindingType: 'uniform',
95
+ resource: { kind: 'texture-view', value: {} },
96
+ }),
97
+ ],
98
+ 7,
99
+ );
100
+ expect(result.ok).toBe(false);
101
+ if (!result.ok) {
102
+ expect(result.error.code).toBe('vfx-data-interface-wrong-type');
103
+ expect(result.error.detail).toMatchObject({
104
+ token: 'vfx:camera',
105
+ expectedResourceKind: 'buffer',
106
+ actualResourceKind: 'texture-view',
107
+ });
108
+ }
109
+ });
110
+
72
111
  it('keeps provider failures exhaustive without parsing messages', () => {
73
112
  const result: Result<unknown, VfxDataInterfaceError> = resolveVfxDataInterfaces(
74
113
  [camera],
@@ -1,9 +1,10 @@
1
1
  import type { ParticleEffectAsset } from '@forgeax/engine-types';
2
2
  import { describe, expect, it } from 'vitest';
3
3
  import { createVfxEffectContract } from '../effect-contract.js';
4
+ import { VFX_PARTICLE_CORE_LAYOUT } from '../particle-layout.js';
4
5
 
5
6
  const reflection = {
6
- version: 1,
7
+ version: 3,
7
8
  parameters: {
8
9
  name: 'VfxParameters',
9
10
  fields: [
@@ -14,6 +15,8 @@ const reflection = {
14
15
  alignment: 16,
15
16
  },
16
17
  custom: { name: 'VfxCustom', fields: [], size: 0, alignment: 1 },
18
+ core: VFX_PARTICLE_CORE_LAYOUT,
19
+ customLayout: { name: 'VfxCustom', fields: [], size: 0, alignment: 1, stride: 0, lanes: 0 },
17
20
  fingerprint: 'sha256:0000000000000000000000000000000000000000000000000000000000000000',
18
21
  } as const;
19
22
 
@@ -21,11 +24,11 @@ describe('VfxEffectContract', () => {
21
24
  it('models the executable program on the ordinary particle asset', () => {
22
25
  const asset: ParticleEffectAsset = {
23
26
  kind: 'particle-effect',
24
- schemaVersion: 2,
27
+ schemaVersion: 3,
25
28
  programFingerprint: 'sha256:program',
26
29
  emitters: [{ id: 'sparks', capacity: 8 }],
27
30
  program: {
28
- format: 'forgeax-vfx-program-2',
31
+ format: 'forgeax-vfx-program-3',
29
32
  fingerprint: 'sha256:program',
30
33
  emitters: [],
31
34
  },
@@ -1,6 +1,6 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { describe, expect, expectTypeOf, it } from 'vitest';
3
- import type { ParticleEmitterSourceV2 } from '../code-source.js';
3
+ import type { ParticleEmitterSourceV3 } from '../code-source-v3.js';
4
4
  import type { VfxGpuEmitterProgram } from '../gpu-program.js';
5
5
  import type {
6
6
  VfxGpuEmitterProgram as PublicVfxGpuEmitterProgram,
@@ -8,7 +8,7 @@ import type {
8
8
  VfxGpuProgram,
9
9
  } from '../index.js';
10
10
 
11
- type SourceSimulationWhenCulled = ParticleEmitterSourceV2['simulationWhenCulled'];
11
+ type SourceSimulationWhenCulled = ParticleEmitterSourceV3['simulationWhenCulled'];
12
12
  type CookedSimulationWhenCulled = VfxGpuEmitterProgram['simulationWhenCulled'];
13
13
  type RequiredSimulationWhenCulled = Pick<VfxGpuEmitterProgram, 'simulationWhenCulled'>;
14
14
  type PublicProgramEmitter = VfxGpuProgram['emitters'][number];
@@ -19,13 +19,13 @@ const gpuProgramSource = readFileSync(new URL('../gpu-program.ts', import.meta.u
19
19
  describe('VFX GPU program simulation culling owner', () => {
20
20
  it('derives the cooked policy from the authored source while keeping it required', () => {
21
21
  expectTypeOf<CookedSimulationWhenCulled>().toEqualTypeOf<
22
- NonNullable<ParticleEmitterSourceV2['simulationWhenCulled']>
22
+ NonNullable<ParticleEmitterSourceV3['simulationWhenCulled']>
23
23
  >();
24
24
  expectTypeOf<SourceSimulationWhenCulled>().toEqualTypeOf<
25
- NonNullable<ParticleEmitterSourceV2['simulationWhenCulled']> | undefined
25
+ NonNullable<ParticleEmitterSourceV3['simulationWhenCulled']> | undefined
26
26
  >();
27
27
  expectTypeOf<RequiredSimulationWhenCulled>().toEqualTypeOf<{
28
- readonly simulationWhenCulled: NonNullable<ParticleEmitterSourceV2['simulationWhenCulled']>;
28
+ readonly simulationWhenCulled: NonNullable<ParticleEmitterSourceV3['simulationWhenCulled']>;
29
29
  }>();
30
30
  });
31
31
 
@@ -49,7 +49,7 @@ describe('VFX GPU program simulation culling owner', () => {
49
49
 
50
50
  it('keeps the source-derived owner explicit in the declaration', () => {
51
51
  expect(gpuProgramSource).toContain(
52
- "readonly simulationWhenCulled: NonNullable<ParticleEmitterSourceV2['simulationWhenCulled']>;",
52
+ "readonly simulationWhenCulled: NonNullable<ParticleEmitterSourceV3['simulationWhenCulled']>;",
53
53
  );
54
54
  expect(gpuProgramSource).not.toContain(
55
55
  "readonly simulationWhenCulled: 'continue' | 'pause' | 'restart-on-visible';",
@@ -1,10 +1,10 @@
1
1
  import { describe, expectTypeOf, it } from 'vitest';
2
2
  import type {
3
3
  ParticleRendererOverflowPolicy,
4
- ParticleRendererSorting,
5
4
  ParticleStageDomain,
6
5
  ParticleStageResourceAccess,
7
6
  } from '../code-source.js';
7
+ import type { ParticleRendererSortingV3 } from '../code-source-v3.js';
8
8
  import type { VfxGpuRendererReflection, VfxGpuStageReflection } from '../gpu-program.js';
9
9
 
10
10
  describe('VFX GPU reflection vocabulary owner', () => {
@@ -14,7 +14,7 @@ describe('VFX GPU reflection vocabulary owner', () => {
14
14
  >().toEqualTypeOf<ParticleRendererOverflowPolicy>();
15
15
  expectTypeOf<
16
16
  NonNullable<VfxGpuRendererReflection['sorting']>
17
- >().toEqualTypeOf<ParticleRendererSorting>();
17
+ >().toEqualTypeOf<ParticleRendererSortingV3>();
18
18
  expectTypeOf<VfxGpuStageReflection['domain']>().toEqualTypeOf<ParticleStageDomain>();
19
19
  expectTypeOf<
20
20
  VfxGpuStageReflection['resources'][number]['access']