@forgeax/engine-vfx-render 0.0.0-dev.8d955ade1c79

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 (91) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +157 -0
  3. package/dist/.tsbuildinfo +1 -0
  4. package/dist/__tests__/billboard-advanced.integration.test.d.ts +2 -0
  5. package/dist/__tests__/billboard-advanced.integration.test.d.ts.map +1 -0
  6. package/dist/__tests__/data-interface-providers.unit.test.d.ts +2 -0
  7. package/dist/__tests__/data-interface-providers.unit.test.d.ts.map +1 -0
  8. package/dist/__tests__/data-interface-public-api.test-d.d.ts +2 -0
  9. package/dist/__tests__/data-interface-public-api.test-d.d.ts.map +1 -0
  10. package/dist/__tests__/event-resources.unit.test.d.ts +2 -0
  11. package/dist/__tests__/event-resources.unit.test.d.ts.map +1 -0
  12. package/dist/__tests__/fog-producer.integration.test.d.ts +2 -0
  13. package/dist/__tests__/fog-producer.integration.test.d.ts.map +1 -0
  14. package/dist/__tests__/gpu-host.integration.test.d.ts +2 -0
  15. package/dist/__tests__/gpu-host.integration.test.d.ts.map +1 -0
  16. package/dist/__tests__/inspect-snapshot.integration.test.d.ts +2 -0
  17. package/dist/__tests__/inspect-snapshot.integration.test.d.ts.map +1 -0
  18. package/dist/__tests__/particle-resources.unit.test.d.ts +2 -0
  19. package/dist/__tests__/particle-resources.unit.test.d.ts.map +1 -0
  20. package/dist/__tests__/render-vocabulary-owner.test-d.d.ts +2 -0
  21. package/dist/__tests__/render-vocabulary-owner.test-d.d.ts.map +1 -0
  22. package/dist/__tests__/stage-plan.integration.test.d.ts +2 -0
  23. package/dist/__tests__/stage-plan.integration.test.d.ts.map +1 -0
  24. package/dist/__tests__/stage-recovery.integration.test.d.ts +2 -0
  25. package/dist/__tests__/stage-recovery.integration.test.d.ts.map +1 -0
  26. package/dist/__tests__/stage-resource-access-owner.test-d.d.ts +2 -0
  27. package/dist/__tests__/stage-resource-access-owner.test-d.d.ts.map +1 -0
  28. package/dist/__tests__/stage-resource-layout.unit.test.d.ts +2 -0
  29. package/dist/__tests__/stage-resource-layout.unit.test.d.ts.map +1 -0
  30. package/dist/__tests__/topology-capacity.unit.test.d.ts +2 -0
  31. package/dist/__tests__/topology-capacity.unit.test.d.ts.map +1 -0
  32. package/dist/__tests__/topology-resources.unit.test.d.ts +2 -0
  33. package/dist/__tests__/topology-resources.unit.test.d.ts.map +1 -0
  34. package/dist/feature/camera.d.ts +11 -0
  35. package/dist/feature/camera.d.ts.map +1 -0
  36. package/dist/feature/event-resources.d.ts +9 -0
  37. package/dist/feature/event-resources.d.ts.map +1 -0
  38. package/dist/feature/gpu-particle-feature.d.ts +90 -0
  39. package/dist/feature/gpu-particle-feature.d.ts.map +1 -0
  40. package/dist/feature/particle-resources.d.ts +63 -0
  41. package/dist/feature/particle-resources.d.ts.map +1 -0
  42. package/dist/feature/stage-plan.d.ts +54 -0
  43. package/dist/feature/stage-plan.d.ts.map +1 -0
  44. package/dist/feature/topologies/beam.d.ts +16 -0
  45. package/dist/feature/topologies/beam.d.ts.map +1 -0
  46. package/dist/feature/topologies/ribbon.d.ts +15 -0
  47. package/dist/feature/topologies/ribbon.d.ts.map +1 -0
  48. package/dist/feature/topologies/trail.d.ts +19 -0
  49. package/dist/feature/topologies/trail.d.ts.map +1 -0
  50. package/dist/host/data-interface-providers.d.ts +19 -0
  51. package/dist/host/data-interface-providers.d.ts.map +1 -0
  52. package/dist/host/vfx-runtime-host.d.ts +103 -0
  53. package/dist/host/vfx-runtime-host.d.ts.map +1 -0
  54. package/dist/index.d.ts +11 -0
  55. package/dist/index.d.ts.map +1 -0
  56. package/dist/index.mjs +1308 -0
  57. package/dist/index.mjs.map +1 -0
  58. package/package.json +63 -0
  59. package/src/__tests__/billboard-advanced.integration.test.ts +46 -0
  60. package/src/__tests__/data-interface-providers.unit.test.ts +55 -0
  61. package/src/__tests__/data-interface-public-api.test-d.ts +58 -0
  62. package/src/__tests__/event-resources.unit.test.ts +71 -0
  63. package/src/__tests__/fog-producer.integration.test.ts +60 -0
  64. package/src/__tests__/gpu-host.integration.test.ts +459 -0
  65. package/src/__tests__/inspect-snapshot.integration.test.ts +26 -0
  66. package/src/__tests__/particle-resources.unit.test.ts +66 -0
  67. package/src/__tests__/render-vocabulary-owner.test-d.ts +81 -0
  68. package/src/__tests__/stage-plan.integration.test.ts +148 -0
  69. package/src/__tests__/stage-recovery.integration.test.ts +24 -0
  70. package/src/__tests__/stage-resource-access-owner.test-d.ts +26 -0
  71. package/src/__tests__/stage-resource-layout.unit.test.ts +40 -0
  72. package/src/__tests__/topology-capacity.unit.test.ts +59 -0
  73. package/src/__tests__/topology-resources.unit.test.ts +29 -0
  74. package/src/feature/camera.ts +12 -0
  75. package/src/feature/event-resources.ts +62 -0
  76. package/src/feature/gpu-particle-feature.ts +819 -0
  77. package/src/feature/particle-resources.ts +195 -0
  78. package/src/feature/stage-plan.ts +285 -0
  79. package/src/feature/topologies/beam.ts +41 -0
  80. package/src/feature/topologies/ribbon.ts +31 -0
  81. package/src/feature/topologies/trail.ts +43 -0
  82. package/src/host/data-interface-providers.ts +119 -0
  83. package/src/host/vfx-runtime-host.ts +459 -0
  84. package/src/index.ts +57 -0
  85. package/src/shaders/beam.wgsl +69 -0
  86. package/src/shaders/billboard.wgsl +132 -0
  87. package/src/shaders/event.wgsl +11 -0
  88. package/src/shaders/mesh.wgsl +98 -0
  89. package/src/shaders/ribbon.wgsl +69 -0
  90. package/src/shaders/stage.wgsl +5 -0
  91. package/src/shaders/trail.wgsl +77 -0
@@ -0,0 +1,26 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { createVfxRenderInspectSnapshot } from '../feature/gpu-particle-feature.js';
3
+
4
+ describe('VFX render inspect projection', () => {
5
+ it('reports topology counters, stage readiness, provider state, and timing', () => {
6
+ const snapshot = createVfxRenderInspectSnapshot({
7
+ topology: 'beam',
8
+ capacity: 12,
9
+ produced: 6,
10
+ dropped: 1,
11
+ stageReadiness: [{ id: 'turbulence', state: 'ready', generation: 3 }],
12
+ providerReadiness: { readiness: 'ready', generation: 3 },
13
+ gpuTiming: { frameMs: 0.8, samples: 8 },
14
+ });
15
+
16
+ expect(snapshot).toEqual(
17
+ expect.objectContaining({
18
+ topology: 'beam',
19
+ counters: { capacity: 12, produced: 6, dropped: 1 },
20
+ stageReadiness: expect.arrayContaining([expect.objectContaining({ id: 'turbulence' })]),
21
+ providerReadiness: { readiness: 'ready', generation: 3 },
22
+ gpuTiming: { frameMs: 0.8, samples: 8 },
23
+ }),
24
+ );
25
+ });
26
+ });
@@ -0,0 +1,66 @@
1
+ import type { MaterialAsset, MeshAsset } from '@forgeax/engine-types';
2
+ import { describe, expect, it } from 'vitest';
3
+ import {
4
+ canonicalMeshVertices,
5
+ particleMaterialPass,
6
+ particleMaterialUsesBindings,
7
+ } from '../feature/particle-resources.js';
8
+
9
+ describe('particle mesh resources', () => {
10
+ it('preserves authored canonical vertices when the attribute map is empty', () => {
11
+ const vertices = new Float32Array([
12
+ 0, 1, 0, 0, 1, 0, 0.5, 1, 1, 0, 0, 1, 0, -1, 0, 0, -1, 0, 0.5, 0, 1, 0, 0, 1,
13
+ ]);
14
+ const mesh = {
15
+ vertices,
16
+ indices: new Uint16Array([0, 1, 0]),
17
+ attributes: {},
18
+ submeshes: [],
19
+ } as unknown as MeshAsset;
20
+
21
+ expect(canonicalMeshVertices(mesh)).toBe(vertices);
22
+ });
23
+ });
24
+
25
+ describe('particle material pass', () => {
26
+ it('only requests the shared material bind group for a declared parameter contract', () => {
27
+ expect(particleMaterialUsesBindings(undefined)).toBe(false);
28
+ expect(particleMaterialUsesBindings({ kind: 'material', values: {} })).toBe(false);
29
+ expect(
30
+ particleMaterialUsesBindings({
31
+ kind: 'material',
32
+ parameters: [{ name: 'maskTexture', type: 'texture' }],
33
+ values: {},
34
+ }),
35
+ ).toBe(true);
36
+ });
37
+ it('uses the renderer-specific authored shader and render state', () => {
38
+ const material: MaterialAsset = {
39
+ kind: 'material',
40
+ passes: [
41
+ { name: 'Forward', program: { module: 'game::standard' } },
42
+ {
43
+ name: 'particle-billboard',
44
+ program: { module: 'game::hex-sigil' },
45
+ renderState: { depthWriteEnabled: false, cullMode: 'none' },
46
+ },
47
+ ],
48
+ };
49
+
50
+ expect(particleMaterialPass('billboard', material)).toEqual({
51
+ shader: 'game::hex-sigil',
52
+ renderState: { depthWriteEnabled: false, cullMode: 'none' },
53
+ });
54
+ });
55
+
56
+ it('does not mistake an ordinary Forward pass for a particle shader', () => {
57
+ const material: MaterialAsset = {
58
+ kind: 'material',
59
+ passes: [{ name: 'Forward', program: { module: 'game::standard' } }],
60
+ };
61
+
62
+ expect(particleMaterialPass('mesh', material)).toEqual({
63
+ shader: 'forgeax::vfx-render.particles.mesh',
64
+ });
65
+ });
66
+ });
@@ -0,0 +1,81 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import type { ParticleRendererSource } from '@forgeax/engine-vfx';
3
+ import { describe, expect, expectTypeOf, it } from 'vitest';
4
+ import type {
5
+ createVfxRenderInspectSnapshot,
6
+ topologyRecoveryHint,
7
+ VfxRenderInspectInput,
8
+ } from '../feature/gpu-particle-feature.js';
9
+ import type {
10
+ ParticleMaterialPass,
11
+ particleMaterialPass,
12
+ TopologyResourcePlan,
13
+ } from '../feature/particle-resources.js';
14
+ import type { VfxStagePlanObservation } from '../feature/stage-plan.js';
15
+
16
+ type RendererKind = ParticleRendererSource['kind'];
17
+ type TopologyRenderer = Extract<ParticleRendererSource, { readonly capacity: number }>;
18
+ type TopologyKind = TopologyRenderer['kind'];
19
+ type StageOutput = VfxStagePlanObservation['stageOutput'];
20
+
21
+ const gpuFeatureSource = readFileSync(
22
+ new URL('../feature/gpu-particle-feature.ts', import.meta.url),
23
+ 'utf8',
24
+ );
25
+ const particleResourcesSource = readFileSync(
26
+ new URL('../feature/particle-resources.ts', import.meta.url),
27
+ 'utf8',
28
+ );
29
+ const normalizedGpuFeatureSource = gpuFeatureSource.replace(/\s+/g, ' ');
30
+ const normalizedParticleResourcesSource = particleResourcesSource.replace(/\s+/g, ' ');
31
+
32
+ describe('VFX render vocabulary owners', () => {
33
+ it('keeps the full renderer vocabulary exact and bilateral', () => {
34
+ expectTypeOf<RendererKind>().toEqualTypeOf<
35
+ 'billboard' | 'mesh' | 'ribbon' | 'trail' | 'beam'
36
+ >();
37
+ expectTypeOf<RendererKind>().toEqualTypeOf<VfxRenderInspectInput['topology']>();
38
+ expectTypeOf<VfxRenderInspectInput['topology']>().toEqualTypeOf<RendererKind>();
39
+ expectTypeOf<
40
+ Parameters<typeof createVfxRenderInspectSnapshot>[0]['topology']
41
+ >().toEqualTypeOf<RendererKind>();
42
+ expectTypeOf<Parameters<typeof particleMaterialPass>[0]>().toEqualTypeOf<RendererKind>();
43
+ expectTypeOf<ParticleMaterialPass>().toEqualTypeOf<ReturnType<typeof particleMaterialPass>>();
44
+ });
45
+
46
+ it('derives topology-only views from required-capacity renderers', () => {
47
+ expectTypeOf<TopologyKind>().toEqualTypeOf<'ribbon' | 'trail' | 'beam'>();
48
+ expectTypeOf<TopologyKind>().toEqualTypeOf<Parameters<typeof topologyRecoveryHint>[0]>();
49
+ expectTypeOf<Parameters<typeof topologyRecoveryHint>[0]>().toEqualTypeOf<TopologyKind>();
50
+ expectTypeOf<TopologyResourcePlan['topology']>().toEqualTypeOf<TopologyKind>();
51
+ expectTypeOf<TopologyKind>().toEqualTypeOf<TopologyResourcePlan['topology']>();
52
+ });
53
+
54
+ it('derives stage output and keeps the private emitter state projection', () => {
55
+ expectTypeOf<StageOutput>().toEqualTypeOf<'active' | 'last-known-good' | 'empty'>();
56
+ expect(normalizedGpuFeatureSource).toContain(
57
+ "type VfxStageOutput = VfxStagePlanObservation['stageOutput'];",
58
+ );
59
+ expect(gpuFeatureSource).toContain('stageOutput: VfxStageOutput;');
60
+ expect(gpuFeatureSource).not.toContain("stageOutput: 'active' | 'last-known-good' | 'empty';");
61
+ });
62
+
63
+ it('keeps both production projections derived from the source owner', () => {
64
+ expect(normalizedGpuFeatureSource).toContain(
65
+ "type ParticleRendererKind = ParticleRendererSource['kind'];",
66
+ );
67
+ expect(normalizedParticleResourcesSource).toContain(
68
+ "type ParticleRendererKind = ParticleRendererSource['kind'];",
69
+ );
70
+ expect(normalizedGpuFeatureSource).toContain(
71
+ 'type ParticleTopologyRenderer = Extract<ParticleRendererSource, { readonly capacity: number }>;',
72
+ );
73
+ expect(normalizedParticleResourcesSource).toContain(
74
+ 'type ParticleTopologyRenderer = Extract<ParticleRendererSource, { readonly capacity: number }>;',
75
+ );
76
+ expect(gpuFeatureSource).toContain('readonly topology: ParticleRendererKind;');
77
+ expect(gpuFeatureSource).toContain('topology: ParticleTopologyKind,');
78
+ expect(particleResourcesSource).toContain('readonly topology: ParticleTopologyKind;');
79
+ expect(particleResourcesSource).toContain('kind: ParticleRendererKind,');
80
+ });
81
+ });
@@ -0,0 +1,148 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import type { RenderFeaturePlan } from '@forgeax/engine-render';
4
+ import type { VfxGpuTickIntent } from '@forgeax/engine-vfx';
5
+ import { describe, expect, it } from 'vitest';
6
+ import { freezeRenderFeaturePlan } from '../../../render/src/features/plan';
7
+ import { gpuParticleRenderFeature } from '../feature/gpu-particle-feature.js';
8
+
9
+ const stagePlanPath = resolve(import.meta.dirname, '../feature/stage-plan.ts');
10
+
11
+ describe('VFX managed stage execution seam', () => {
12
+ it('owns readiness and validated-plan execution in the RenderFeature package', () => {
13
+ const source = readFileSync(stagePlanPath, 'utf8');
14
+ expect(source).toContain('validatedStagePlan');
15
+ expect(source).toContain('stageReadiness');
16
+ expect(source).toContain('stageOutput');
17
+ expect(source).toContain('forgeax_vfx_stage_');
18
+ expect(source).not.toContain('authorRawDispatch');
19
+ });
20
+
21
+ it('projects a VFX stage into the closed render feature plan surface', () => {
22
+ const source = readFileSync(
23
+ resolve(import.meta.dirname, '../feature/gpu-particle-feature.ts'),
24
+ 'utf8',
25
+ );
26
+ const plan: RenderFeaturePlan = { resources: [], passes: [] };
27
+ expect(source).toContain('RenderFeaturePlan');
28
+ expect(source).not.toMatch(/GPUCommandEncoder|queue\.submit|encoder\.finish/);
29
+ expect(plan.passes).toEqual([]);
30
+ });
31
+
32
+ it('declares storage-produced indirect work and dependent raster descriptors', () => {
33
+ const intent = {
34
+ sequence: 1,
35
+ player: 1,
36
+ emitter: {
37
+ id: 'spark',
38
+ capacity: 4,
39
+ space: 'world',
40
+ bounds: { kind: 'sphere', center: [0, 0, 0], radius: 1 },
41
+ wgsl: '// cooked VFX program',
42
+ reflection: {
43
+ entryPoints: ['forgeax_vfx_spawn_main', 'forgeax_vfx_billboard_main'],
44
+ bindings: [
45
+ {
46
+ entries: [
47
+ { binding: 0, visibility: 4, buffer: { type: 'storage' } },
48
+ { binding: 1, visibility: 4, buffer: { type: 'uniform' } },
49
+ { binding: 4, visibility: 4, buffer: { type: 'storage' } },
50
+ { binding: 6, visibility: 4, buffer: { type: 'storage' } },
51
+ ],
52
+ },
53
+ ],
54
+ stages: [],
55
+ dataInterfaces: [],
56
+ },
57
+ renderers: [
58
+ {
59
+ kind: 'billboard',
60
+ material: 'material-guid',
61
+ blend: 'additive',
62
+ sorting: 'none',
63
+ },
64
+ ],
65
+ },
66
+ programFingerprint: 'spark-program',
67
+ reset: true,
68
+ fixedDelta: 1 / 60,
69
+ phaseTick: 0,
70
+ tick: 0,
71
+ seed: 1,
72
+ playCycle: 0,
73
+ spawnCount: 1,
74
+ firstParticleId: 0,
75
+ instanceGeneration: 1,
76
+ channelInputs: [],
77
+ eventCounters: {},
78
+ } as unknown as VfxGpuTickIntent;
79
+ const runtime = {
80
+ isEmitterSessionEnabled: () => true,
81
+ setEmitterCameraVisibility: () => {},
82
+ markEventDispatched: () => {},
83
+ commit: () => {},
84
+ };
85
+ const feature = gpuParticleRenderFeature({ camera: { read: () => undefined } });
86
+ const planned = feature.plan(
87
+ {
88
+ worlds: [
89
+ {
90
+ world: {},
91
+ runtime,
92
+ camera: {
93
+ position: new Float32Array(3),
94
+ right: new Float32Array([1, 0, 0]),
95
+ up: new Float32Array([0, 1, 0]),
96
+ viewProjection: new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]),
97
+ },
98
+ intents: [intent],
99
+ },
100
+ ],
101
+ frameNumber: 1,
102
+ } as never,
103
+ {
104
+ caps: {} as never,
105
+ frame: { frameNumber: 1 },
106
+ generation: 1,
107
+ targets: [
108
+ { name: 'scene-color', kind: 'color', format: 'rgba16float', sampleCount: 1 },
109
+ { name: 'scene-depth', kind: 'depth', format: 'depth24plus', sampleCount: 1 },
110
+ ],
111
+ },
112
+ );
113
+
114
+ expect(planned.ok).toBe(true);
115
+ if (!planned.ok) return;
116
+ expect(
117
+ freezeRenderFeaturePlan(feature.identity, planned.value, [
118
+ { name: 'scene-color', kind: 'color', format: 'rgba16float', sampleCount: 1 },
119
+ { name: 'scene-depth', kind: 'depth', format: 'depth24plus', sampleCount: 1 },
120
+ ]).ok,
121
+ ).toBe(true);
122
+ expect(planned.value.resources).toEqual(
123
+ expect.arrayContaining([
124
+ expect.objectContaining({
125
+ kind: 'buffer',
126
+ name: expect.stringContaining('.indirect'),
127
+ usage: ['storage', 'indirect'],
128
+ }),
129
+ expect.objectContaining({ kind: 'graphics-program' }),
130
+ expect.objectContaining({ kind: 'graphics-bindings' }),
131
+ expect.objectContaining({ kind: 'vertex-data' }),
132
+ ]),
133
+ );
134
+ expect(planned.value.passes).toEqual(
135
+ expect.arrayContaining([
136
+ expect.objectContaining({ kind: 'compute' }),
137
+ expect.objectContaining({
138
+ kind: 'raster',
139
+ draws: [
140
+ expect.objectContaining({
141
+ draw: expect.objectContaining({ kind: 'draw-indirect' }),
142
+ }),
143
+ ],
144
+ }),
145
+ ]),
146
+ );
147
+ });
148
+ });
@@ -0,0 +1,24 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import { describe, expect, it } from 'vitest';
4
+
5
+ const stagePlanPath = resolve(import.meta.dirname, '../feature/stage-plan.ts');
6
+
7
+ describe('VFX stage generation recovery', () => {
8
+ it('keeps candidate failure and device recovery observable without stale resources', () => {
9
+ const source = readFileSync(stagePlanPath, 'utf8');
10
+ expect(source).toContain('candidate');
11
+ expect(source).toContain('lastKnownGood');
12
+ expect(source).toContain('generation');
13
+ expect(source).toContain('retryable');
14
+ expect(source).toContain('device-loss');
15
+ expect(source).toContain('stale');
16
+ });
17
+
18
+ it('does not introduce a second VFX RPC or a CPU fallback recovery path', () => {
19
+ const source = readFileSync(stagePlanPath, 'utf8');
20
+ expect(source).not.toContain('readback');
21
+ expect(source).not.toContain('cpuFallback');
22
+ expect(source).not.toContain('new VFX RPC');
23
+ });
24
+ });
@@ -0,0 +1,26 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import type { ParticleStageResourceAccess, VfxGpuStageReflection } from '@forgeax/engine-vfx';
3
+ import { describe, expect, expectTypeOf, it } from 'vitest';
4
+ import type { VfxValidatedStage } from '../index.js';
5
+
6
+ type ReflectedAccess = VfxGpuStageReflection['resources'][number]['access'];
7
+ type ValidatedAccess = VfxValidatedStage['resources'][number]['access'];
8
+
9
+ const stagePlanSource = readFileSync(new URL('../feature/stage-plan.ts', import.meta.url), 'utf8');
10
+
11
+ describe('VFX validated stage resource access owner', () => {
12
+ it('keeps reflected and final validated access equal to the authored owner', () => {
13
+ expectTypeOf<ReflectedAccess>().toEqualTypeOf<ParticleStageResourceAccess>();
14
+ expectTypeOf<ParticleStageResourceAccess>().toEqualTypeOf<ReflectedAccess>();
15
+ expectTypeOf<ValidatedAccess>().toEqualTypeOf<ParticleStageResourceAccess>();
16
+ expectTypeOf<ParticleStageResourceAccess>().toEqualTypeOf<ValidatedAccess>();
17
+ });
18
+
19
+ it('keeps the final projection derived from reflection without a second vocabulary', () => {
20
+ expect(stagePlanSource).toContain(
21
+ "readonly access: VfxGpuStageReflection['resources'][number]['access'];",
22
+ );
23
+ expect(stagePlanSource).not.toContain("readonly access: 'read' | 'write' | 'read-write';");
24
+ expectTypeOf<'unknown-access'>().not.toExtend<ValidatedAccess>();
25
+ });
26
+ });
@@ -0,0 +1,40 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { stageDispatches, validatedStagePlan } from '../feature/stage-plan.js';
3
+
4
+ const reflection = {
5
+ id: 'turbulence',
6
+ entry: 'vfx_turbulence',
7
+ entryPoint: 'forgeax_vfx_stage_turbulence_main',
8
+ domain: 'particle' as const,
9
+ resources: [
10
+ { name: 'particles', access: 'read-write' as const },
11
+ { name: 'runtime', access: 'read' as const },
12
+ ],
13
+ dependsOn: ['update'],
14
+ iterationBudget: 4,
15
+ };
16
+
17
+ describe('managed stage resource layout', () => {
18
+ it('preserves compiler reflection fields and normalizes particle dispatch', () => {
19
+ const result = validatedStagePlan([reflection], 7);
20
+
21
+ expect(result.ok).toBe(true);
22
+ if (!result.ok) return;
23
+ expect(result.value).toMatchObject({
24
+ generation: 7,
25
+ stages: [reflection],
26
+ });
27
+ expect(result.value.stages[0]?.domain).toBe('particle');
28
+ expect(result.value.stages[0]?.resources).toEqual(reflection.resources);
29
+ expect(result.value.stages[0]?.iterationBudget).toBe(4);
30
+
31
+ const dispatches = stageDispatches(result.value, 3, undefined as never);
32
+ expect(dispatches).toEqual([
33
+ {
34
+ entryPoint: 'forgeax_vfx_stage_turbulence_main',
35
+ workgroups: [3],
36
+ bindings: undefined,
37
+ },
38
+ ]);
39
+ });
40
+ });
@@ -0,0 +1,59 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ createTopologyResourcePlan,
4
+ topologyCapacitySnapshot,
5
+ } from '../feature/particle-resources.js';
6
+
7
+ describe('topology capacity and degeneracy contract', () => {
8
+ it('reports ribbon overflow without changing its topology identity', () => {
9
+ const plan = createTopologyResourcePlan({
10
+ kind: 'ribbon',
11
+ material: 'vfx',
12
+ stripKey: 'alive-index',
13
+ capacity: 2,
14
+ });
15
+
16
+ expect(plan.ok).toBe(true);
17
+ if (plan.ok) {
18
+ expect(topologyCapacitySnapshot(plan.value, { requested: 3, produced: 2 })).toEqual({
19
+ topology: 'ribbon',
20
+ capacity: 2,
21
+ produced: 2,
22
+ dropped: 1,
23
+ overflow: 1,
24
+ degenerate: 0,
25
+ });
26
+ }
27
+ });
28
+
29
+ it('distinguishes a broken trail and a degenerate beam from overflow', () => {
30
+ const trail = createTopologyResourcePlan({
31
+ kind: 'trail',
32
+ material: 'vfx',
33
+ historyLength: 4,
34
+ capacity: 8,
35
+ });
36
+ const beam = createTopologyResourcePlan({
37
+ kind: 'beam',
38
+ material: 'vfx',
39
+ endpointField: 'velocity',
40
+ capacity: 8,
41
+ });
42
+
43
+ expect(
44
+ trail.ok &&
45
+ topologyCapacitySnapshot(trail.value, { requested: 1, produced: 0, degenerate: 1 }),
46
+ ).toMatchObject({
47
+ topology: 'trail',
48
+ degenerate: 1,
49
+ overflow: 0,
50
+ });
51
+ expect(
52
+ beam.ok && topologyCapacitySnapshot(beam.value, { requested: 1, produced: 0, degenerate: 1 }),
53
+ ).toMatchObject({
54
+ topology: 'beam',
55
+ degenerate: 1,
56
+ overflow: 0,
57
+ });
58
+ });
59
+ });
@@ -0,0 +1,29 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { createTopologyResourcePlan } from '../feature/particle-resources.js';
3
+
4
+ describe('independent topology resources', () => {
5
+ it.each([
6
+ ['ribbon', { kind: 'ribbon', material: 'vfx', stripKey: 'alive-index', capacity: 4 }],
7
+ ['trail', { kind: 'trail', material: 'vfx', historyLength: 3, capacity: 5 }],
8
+ ['beam', { kind: 'beam', material: 'vfx', endpointField: 'velocity', capacity: 2 }],
9
+ ])('allocates a distinct %s vertex/index/indirect plan', (_kind, renderer) => {
10
+ const result = createTopologyResourcePlan(renderer);
11
+
12
+ expect(result.ok).toBe(true);
13
+ if (result.ok) {
14
+ expect(result.value.vertexBytes).toBeGreaterThan(0);
15
+ expect(result.value.indexBytes).toBe(0);
16
+ expect(result.value.indirectBytes).toBeGreaterThan(0);
17
+ expect(result.value.resourceKey).not.toMatch(/billboard|mesh|particle/);
18
+ }
19
+ });
20
+
21
+ it('rejects topology field names that the managed WGSL shell cannot execute', () => {
22
+ expect(
23
+ createTopologyResourcePlan({ kind: 'ribbon', stripKey: 'custom', capacity: 4 }),
24
+ ).toMatchObject({ ok: false, error: { detail: { path: 'renderer.stripKey' } } });
25
+ expect(
26
+ createTopologyResourcePlan({ kind: 'beam', endpointField: 'custom', capacity: 4 }),
27
+ ).toMatchObject({ ok: false, error: { detail: { path: 'renderer.endpointField' } } });
28
+ });
29
+ });
@@ -0,0 +1,12 @@
1
+ import type { World } from '@forgeax/engine-ecs';
2
+
3
+ export interface ParticleRenderCamera {
4
+ readonly position: Float32Array;
5
+ readonly right: Float32Array;
6
+ readonly up: Float32Array;
7
+ readonly viewProjection: Float32Array;
8
+ }
9
+
10
+ export interface ParticleRenderCameraSource {
11
+ readonly read: (world: World) => ParticleRenderCamera | undefined;
12
+ }
@@ -0,0 +1,62 @@
1
+ import type { VfxGpuEmitterProgram, VfxGpuTickIntent } from '@forgeax/engine-vfx';
2
+
3
+ export const VFX_EVENT_INPUT_BYTES = 32;
4
+ export const VFX_EVENT_BYTES = 32;
5
+ export const VFX_EVENT_COUNTER_BYTES = 16;
6
+
7
+ function channelFanOut(emitter: VfxGpuEmitterProgram, channel: string): number {
8
+ return Math.max(
9
+ 1,
10
+ ...(emitter.events ?? [])
11
+ .filter((event) => event.channel === channel)
12
+ .map((event) => event.fanOut),
13
+ );
14
+ }
15
+
16
+ export function eventInputCapacity(emitter: VfxGpuEmitterProgram): number {
17
+ return Math.max(
18
+ 1,
19
+ (emitter.channels ?? []).reduce((total, channel) => total + channel.capacity, 0),
20
+ );
21
+ }
22
+
23
+ export function eventCapacity(emitter: VfxGpuEmitterProgram): number {
24
+ const capacity = (emitter.channels ?? []).reduce(
25
+ (total, channel) => total + channel.capacity * channelFanOut(emitter, channel.id),
26
+ 0,
27
+ );
28
+ return Math.max(1, Math.min(emitter.capacity, capacity));
29
+ }
30
+
31
+ function channelKey(channel: string): number {
32
+ let hash = 2166136261;
33
+ for (const code of channel) {
34
+ hash ^= code.codePointAt(0) ?? 0;
35
+ hash = Math.imul(hash, 16777619);
36
+ }
37
+ return hash >>> 0;
38
+ }
39
+
40
+ export function encodeEventInputs(intent: VfxGpuTickIntent): Uint8Array {
41
+ const capacity = eventInputCapacity(intent.emitter);
42
+ const data = new ArrayBuffer(capacity * VFX_EVENT_INPUT_BYTES);
43
+ const bytes = new Uint8Array(data);
44
+ bytes.fill(0xff);
45
+ const view = new DataView(data);
46
+ for (const [index, input] of intent.channelInputs.entries()) {
47
+ if (index >= capacity) break;
48
+ const offset = index * VFX_EVENT_INPUT_BYTES;
49
+ view.setFloat32(offset, input.payload.position[0], true);
50
+ view.setFloat32(offset + 4, input.payload.position[1], true);
51
+ view.setFloat32(offset + 8, input.payload.position[2], true);
52
+ view.setFloat32(offset + 16, input.payload.strength, true);
53
+ view.setUint32(offset + 20, input.sequence, true);
54
+ view.setUint32(offset + 24, channelKey(input.channel), true);
55
+ view.setUint32(offset + 28, channelFanOut(intent.emitter, input.channel), true);
56
+ }
57
+ return bytes;
58
+ }
59
+
60
+ export function eventCounterData(): Uint8Array {
61
+ return new Uint8Array(VFX_EVENT_COUNTER_BYTES);
62
+ }