@forgeax/engine-vfx-render 0.1.28 → 0.1.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +55 -10
  2. package/dist/__tests__/persistent-ticks.integration.test.d.ts +2 -0
  3. package/dist/__tests__/persistent-ticks.integration.test.d.ts.map +1 -0
  4. package/dist/feature/event-resources.d.ts +10 -3
  5. package/dist/feature/event-resources.d.ts.map +1 -1
  6. package/dist/feature/gpu-particle-feature.d.ts +18 -5
  7. package/dist/feature/gpu-particle-feature.d.ts.map +1 -1
  8. package/dist/feature/particle-resources.d.ts +43 -6
  9. package/dist/feature/particle-resources.d.ts.map +1 -1
  10. package/dist/host/data-interface-providers.d.ts +4 -1
  11. package/dist/host/data-interface-providers.d.ts.map +1 -1
  12. package/dist/host/vfx-runtime-host.d.ts +21 -2
  13. package/dist/host/vfx-runtime-host.d.ts.map +1 -1
  14. package/dist/index.d.ts +4 -3
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.mjs +1027 -307
  17. package/dist/index.mjs.map +1 -1
  18. package/package.json +11 -9
  19. package/src/__tests__/billboard-advanced.integration.test.ts +1 -1
  20. package/src/__tests__/data-interface-providers.unit.test.ts +4 -0
  21. package/src/__tests__/data-interface-public-api.test-d.ts +1 -0
  22. package/src/__tests__/gpu-host.integration.test.ts +1471 -59
  23. package/src/__tests__/particle-resources.unit.test.ts +75 -0
  24. package/src/__tests__/persistent-ticks.integration.test.ts +162 -0
  25. package/src/__tests__/render-vocabulary-owner.test-d.ts +7 -7
  26. package/src/feature/event-resources.ts +18 -4
  27. package/src/feature/gpu-particle-feature.ts +1153 -376
  28. package/src/feature/particle-resources.ts +223 -11
  29. package/src/host/data-interface-providers.ts +19 -0
  30. package/src/host/vfx-runtime-host.ts +82 -2
  31. package/src/index.ts +8 -0
  32. package/src/shaders/beam-inputs.wgsl +40 -0
  33. package/src/shaders/beam.wgsl +4 -3
  34. package/src/shaders/billboard-inputs.wgsl +85 -0
  35. package/src/shaders/mesh-inputs.wgsl +95 -0
  36. package/src/shaders/mesh-shadow.wgsl +18 -0
  37. package/src/shaders/mesh.wgsl +64 -40
  38. package/src/shaders/ribbon-inputs.wgsl +39 -0
  39. package/src/shaders/trail-inputs.wgsl +45 -0
  40. package/src/shaders/trail.wgsl +9 -3
@@ -2,10 +2,12 @@ import type { MaterialAsset, MeshAsset } from '@forgeax/engine-types';
2
2
  import { describe, expect, it } from 'vitest';
3
3
  import {
4
4
  canonicalMeshVertices,
5
+ canonicalMeshVerticesCached,
5
6
  particleMaterialPass,
6
7
  particleMaterialSceneDepthBinding,
7
8
  particleMaterialUsesBindings,
8
9
  particleRendererRenderState,
10
+ prepareParticleMaterialInputs,
9
11
  } from '../feature/particle-resources.js';
10
12
 
11
13
  describe('particle mesh resources', () => {
@@ -22,6 +24,37 @@ describe('particle mesh resources', () => {
22
24
 
23
25
  expect(canonicalMeshVertices(mesh)).toBe(vertices);
24
26
  });
27
+
28
+ it('reuses derived vertices only for frozen asset publications', () => {
29
+ const mesh = Object.freeze({
30
+ kind: 'mesh' as const,
31
+ vertices: new Float32Array(),
32
+ attributes: { position: new Float32Array([0, 1, 2]) },
33
+ indices: new Uint16Array([0]),
34
+ submeshes: [],
35
+ materialSlots: [],
36
+ }) as unknown as MeshAsset;
37
+ const first = canonicalMeshVerticesCached(mesh);
38
+ const second = canonicalMeshVerticesCached(mesh);
39
+ expect(second).toBe(first);
40
+ expect(Array.from(second)).toEqual([0, 1, 2, 0, 0, 1, 0, 0, 1, 0, 0, 1]);
41
+ });
42
+
43
+ it('does not hide mutations on unfrozen authoring meshes', () => {
44
+ const mesh = {
45
+ kind: 'mesh' as const,
46
+ vertices: new Float32Array(),
47
+ attributes: { position: new Float32Array([0, 1, 2]) },
48
+ indices: new Uint16Array([0]),
49
+ submeshes: [],
50
+ materialSlots: [],
51
+ } as unknown as MeshAsset;
52
+ const first = canonicalMeshVerticesCached(mesh);
53
+ (mesh.attributes.position as Float32Array)[0] = 7;
54
+ const second = canonicalMeshVerticesCached(mesh);
55
+ expect(second).not.toBe(first);
56
+ expect(second[0]).toBe(7);
57
+ });
25
58
  });
26
59
 
27
60
  describe('particle material pass', () => {
@@ -36,6 +69,7 @@ describe('particle material pass', () => {
36
69
  }),
37
70
  ).toBe(true);
38
71
  });
72
+
39
73
  it('uses the renderer-specific authored shader and render state', () => {
40
74
  const material: MaterialAsset = {
41
75
  kind: 'material',
@@ -73,6 +107,47 @@ describe('particle material pass', () => {
73
107
  shader: 'forgeax::vfx-render.particles.mesh',
74
108
  });
75
109
  });
110
+
111
+ it('returns a structured missing-input error instead of dereferencing an absent declaration', () => {
112
+ expect(
113
+ prepareParticleMaterialInputs(
114
+ {
115
+ kind: 'billboard',
116
+ material: '019e9c00-0000-7000-8000-000000000003',
117
+ materialInputs: ['heat'],
118
+ },
119
+ { kind: 'material', particleInputs: [] },
120
+ [],
121
+ ),
122
+ ).toMatchObject({
123
+ ok: false,
124
+ error: { code: 'vfx-material-input-missing', detail: { name: 'heat' } },
125
+ });
126
+ });
127
+ });
128
+
129
+ describe('VFX Mesh world-space projection', () => {
130
+ it('adapts Standard geometry without discarding the ordinary material render state', () => {
131
+ const renderState = {
132
+ depthCompare: 'less-equal',
133
+ depthWriteEnabled: true,
134
+ queue: 2000,
135
+ } as const;
136
+ const material = {
137
+ kind: 'material',
138
+ passes: [
139
+ {
140
+ name: 'forward',
141
+ program: { module: 'forgeax::default-standard-pbr' },
142
+ renderState,
143
+ },
144
+ ],
145
+ } as MaterialAsset;
146
+ expect(particleMaterialPass('mesh', material)).toEqual({
147
+ shader: 'forgeax::vfx-render.particles.mesh',
148
+ renderState,
149
+ });
150
+ });
76
151
  });
77
152
 
78
153
  describe('particle renderer blend defaults', () => {
@@ -0,0 +1,162 @@
1
+ import { createWorldContext, World } from '@forgeax/engine-ecs';
2
+ import type { ParticleEffectAsset } from '@forgeax/engine-types';
3
+ import {
4
+ ParticleEffectPlayer,
5
+ VFX_GPU_RUNTIME_RESOURCE_KEY,
6
+ type VfxGpuRuntime,
7
+ vfxGpuRuntimePlugin,
8
+ } from '@forgeax/engine-vfx';
9
+ import { cookParticleCodeProgram } from '@forgeax/engine-vfx-compiler';
10
+ import { expect, it } from 'vitest';
11
+ import { freezeRenderFeaturePlan } from '../../../render/src/features/plan.js';
12
+ import { gpuParticleRenderFeature } from '../feature/gpu-particle-feature.js';
13
+
14
+ it.each([
15
+ false,
16
+ true,
17
+ ])('retains particles and acknowledges ordered work only after submission (events=%s)', async (events) => {
18
+ const result = await cookParticleCodeProgram(
19
+ {
20
+ schemaVersion: 3,
21
+ emitters: [
22
+ {
23
+ id: 'persistent',
24
+ capacity: 4,
25
+ backend: { required: 'gpu' },
26
+ space: 'world',
27
+ bounds: { kind: 'sphere', center: [0, 0, 0], radius: 10 },
28
+ schedule: { rate: 0, bursts: [{ time: 0, count: 1 }] },
29
+ program: { module: 'persistent.wgsl' },
30
+ renderers: [{ kind: 'billboard', material: 'material' }],
31
+ ...(events
32
+ ? {
33
+ channels: [{ id: 'hit', capacity: 2, overflow: 'drop-newest' }],
34
+ events: [
35
+ {
36
+ id: 'hit',
37
+ channel: 'hit',
38
+ subEmitter: 'persistent',
39
+ fanOut: 2,
40
+ recursionDepth: 1,
41
+ },
42
+ ],
43
+ }
44
+ : {}),
45
+ },
46
+ ],
47
+ },
48
+ {
49
+ 'persistent.wgsl': {
50
+ entry: `#import forgeax_vfx::prelude::{VfxParticle, VfxSpawnContext, VfxUpdateContext}
51
+ fn vfx_spawn(ctx: VfxSpawnContext, particle: ptr<function, VfxParticle>) { (*particle).lifetime = 86400.0; }
52
+ fn vfx_update(ctx: VfxUpdateContext, particle: ptr<function, VfxParticle>) { (*particle).position.x += ctx.delta; }`,
53
+ },
54
+ },
55
+ );
56
+ if (!result.ok) throw result.error;
57
+ const asset: ParticleEffectAsset = {
58
+ kind: 'particle-effect',
59
+ schemaVersion: 3,
60
+ programFingerprint: result.value.fingerprint,
61
+ emitters: [{ id: 'persistent', capacity: 4 }],
62
+ program: { ...result.value.program, fingerprint: result.value.fingerprint },
63
+ };
64
+ const world = new World();
65
+ const handle = world.allocSharedRef('ParticleEffectAsset', { ...asset, guid: 'effect' });
66
+ const player = world
67
+ .spawn({
68
+ component: ParticleEffectPlayer,
69
+ data: { effect: handle, playing: true, seed: 1, timeScale: 1 },
70
+ })
71
+ .unwrap();
72
+ const context = await createWorldContext(world, [vfxGpuRuntimePlugin()]);
73
+ const runtime = world.getResource<VfxGpuRuntime>(VFX_GPU_RUNTIME_RESOURCE_KEY);
74
+ const camera = {
75
+ position: new Float32Array(3),
76
+ right: new Float32Array([1, 0, 0]),
77
+ up: new Float32Array([0, 1, 0]),
78
+ viewProjection: new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]),
79
+ };
80
+ const feature = gpuParticleRenderFeature({ camera: { read: () => camera } });
81
+ const targets = [
82
+ {
83
+ name: 'color',
84
+ kind: 'color' as const,
85
+ format: 'rgba8unorm' as const,
86
+ sampleCount: 1 as const,
87
+ },
88
+ ];
89
+ let frameNumber = 0;
90
+ const frame = (worlds = [world]) => {
91
+ const extracted = feature.extract({ worlds, owner: 0, frameNumber: ++frameNumber }).unwrap();
92
+ const plan = feature
93
+ .plan(extracted, {
94
+ targets,
95
+ caps: {} as never,
96
+ frame: { frameNumber },
97
+ generation: 0,
98
+ sceneData: {} as never,
99
+ })
100
+ .unwrap();
101
+ expect(freezeRenderFeaturePlan(feature.identity, plan, targets).ok).toBe(true);
102
+ return { extracted, plan };
103
+ };
104
+ const particleName = (plan: ReturnType<typeof frame>['plan']) =>
105
+ plan.resources.find((resource) => resource.name.endsWith('.particles'))?.name;
106
+ try {
107
+ world.update(1 / 60).unwrap();
108
+ const first = frame();
109
+ const entries = first.plan.passes.flatMap((pass) =>
110
+ pass.kind === 'compute' ? pass.dispatches.map((dispatch) => dispatch.entryPoint) : [],
111
+ );
112
+ const eventIndex = entries.indexOf('forgeax_vfx_event_main');
113
+ if (events) {
114
+ expect(eventIndex).toBeGreaterThan(0);
115
+ expect(entries.slice(eventIndex + 1, eventIndex + 5)).toEqual([
116
+ 'forgeax_vfx_scan_blocks_main',
117
+ 'forgeax_vfx_scan_block_offsets_main',
118
+ 'forgeax_vfx_add_offsets_main',
119
+ 'forgeax_vfx_compact_main',
120
+ ]);
121
+ } else expect(eventIndex).toBe(-1);
122
+ expect(runtime.snapshot()).toHaveLength(1);
123
+ const retry = frame();
124
+ expect(particleName(retry.plan)).toBe(particleName(first.plan));
125
+ feature.onFrameSubmitted?.(retry.extracted);
126
+ expect(runtime.snapshot()).toHaveLength(0);
127
+ const presentation = frame([new World(), world]);
128
+ expect(particleName(presentation.plan)).toBe(particleName(first.plan));
129
+ expect(presentation.plan.passes.filter((pass) => pass.kind === 'raster')).toHaveLength(1);
130
+ expect(
131
+ presentation.plan.passes
132
+ .flatMap((pass) => (pass.kind === 'compute' ? pass.dispatches : []))
133
+ .some((dispatch) => /spawn|update|history/.test(dispatch.entryPoint)),
134
+ ).toBe(false);
135
+ expect(
136
+ presentation.plan.resources.find(
137
+ (resource) => resource.kind === 'buffer' && resource.name.endsWith('.indirect'),
138
+ ),
139
+ ).not.toHaveProperty('data');
140
+ world.update(1 / 60).unwrap();
141
+ world.update(1 / 60).unwrap();
142
+ const catchup = frame();
143
+ expect(runtime.snapshot()).toHaveLength(2);
144
+ expect(catchup.plan.passes.filter((pass) => pass.name.endsWith('.simulate'))).toHaveLength(2);
145
+ expect(catchup.plan.passes.filter((pass) => pass.kind === 'raster')).toHaveLength(1);
146
+ expect(particleName(catchup.plan)).toBe(particleName(first.plan));
147
+ expect(
148
+ catchup.plan.resources.find((resource) => resource.name.endsWith('.indirect')),
149
+ ).not.toHaveProperty('data');
150
+ feature.onFrameSubmitted?.(catchup.extracted);
151
+ expect(runtime.snapshot()).toHaveLength(0);
152
+ runtime.replay(player);
153
+ world.update(1 / 60).unwrap();
154
+ const replay = frame();
155
+ expect(particleName(replay.plan)).not.toBe(particleName(first.plan));
156
+ feature.onFrameSubmitted?.(replay.extracted);
157
+ runtime.reset(player);
158
+ expect(frame().plan.resources).toEqual([]);
159
+ } finally {
160
+ await context.fiber.dispose();
161
+ }
162
+ });
@@ -1,5 +1,5 @@
1
1
  import { readFileSync } from 'node:fs';
2
- import type { ParticleRendererSource } from '@forgeax/engine-vfx';
2
+ import type { ParticleRendererSourceV3 } from '@forgeax/engine-vfx';
3
3
  import { describe, expect, expectTypeOf, it } from 'vitest';
4
4
  import type {
5
5
  createVfxRenderInspectSnapshot,
@@ -13,8 +13,8 @@ import type {
13
13
  } from '../feature/particle-resources.js';
14
14
  import type { VfxStagePlanObservation } from '../feature/stage-plan.js';
15
15
 
16
- type RendererKind = ParticleRendererSource['kind'];
17
- type TopologyRenderer = Extract<ParticleRendererSource, { readonly capacity: number }>;
16
+ type RendererKind = ParticleRendererSourceV3['kind'];
17
+ type TopologyRenderer = Extract<ParticleRendererSourceV3, { readonly capacity: number }>;
18
18
  type TopologyKind = TopologyRenderer['kind'];
19
19
  type StageOutput = VfxStagePlanObservation['stageOutput'];
20
20
 
@@ -62,16 +62,16 @@ describe('VFX render vocabulary owners', () => {
62
62
 
63
63
  it('keeps both production projections derived from the source owner', () => {
64
64
  expect(normalizedGpuFeatureSource).toContain(
65
- "type ParticleRendererKind = ParticleRendererSource['kind'];",
65
+ "type ParticleRendererKind = ParticleRendererSourceV3['kind'];",
66
66
  );
67
67
  expect(normalizedParticleResourcesSource).toContain(
68
- "type ParticleRendererKind = ParticleRendererSource['kind'];",
68
+ "type ParticleRendererKind = ParticleRendererSourceV3['kind'];",
69
69
  );
70
70
  expect(normalizedGpuFeatureSource).toContain(
71
- 'type ParticleTopologyRenderer = Extract<ParticleRendererSource, { readonly capacity: number }>;',
71
+ 'type ParticleTopologyRenderer = Extract<ParticleRendererSourceV3, { readonly capacity: number }>;',
72
72
  );
73
73
  expect(normalizedParticleResourcesSource).toContain(
74
- 'type ParticleTopologyRenderer = Extract<ParticleRendererSource, { readonly capacity: number }>;',
74
+ 'type ParticleTopologyRenderer = Extract<ParticleRendererSourceV3, { readonly capacity: number }>;',
75
75
  );
76
76
  expect(gpuFeatureSource).toContain('readonly topology: ParticleRendererKind;');
77
77
  expect(gpuFeatureSource).toContain('topology: ParticleTopologyKind,');
@@ -1,10 +1,10 @@
1
- import type { VfxGpuEmitterProgram, VfxGpuTickIntent } from '@forgeax/engine-vfx';
1
+ import type { VfxGpuEmitterProgramAny, VfxGpuTickIntent } from '@forgeax/engine-vfx';
2
2
 
3
3
  export const VFX_EVENT_INPUT_BYTES = 32;
4
4
  export const VFX_EVENT_BYTES = 32;
5
5
  export const VFX_EVENT_COUNTER_BYTES = 16;
6
6
 
7
- function channelFanOut(emitter: VfxGpuEmitterProgram, channel: string): number {
7
+ function channelFanOut(emitter: VfxGpuEmitterProgramAny, channel: string): number {
8
8
  return Math.max(
9
9
  1,
10
10
  ...(emitter.events ?? [])
@@ -13,14 +13,14 @@ function channelFanOut(emitter: VfxGpuEmitterProgram, channel: string): number {
13
13
  );
14
14
  }
15
15
 
16
- export function eventInputCapacity(emitter: VfxGpuEmitterProgram): number {
16
+ export function eventInputCapacity(emitter: VfxGpuEmitterProgramAny): number {
17
17
  return Math.max(
18
18
  1,
19
19
  (emitter.channels ?? []).reduce((total, channel) => total + channel.capacity, 0),
20
20
  );
21
21
  }
22
22
 
23
- export function eventCapacity(emitter: VfxGpuEmitterProgram): number {
23
+ export function eventCapacity(emitter: VfxGpuEmitterProgramAny): number {
24
24
  const capacity = (emitter.channels ?? []).reduce(
25
25
  (total, channel) => total + channel.capacity * channelFanOut(emitter, channel.id),
26
26
  0,
@@ -57,6 +57,20 @@ export function encodeEventInputs(intent: VfxGpuTickIntent): Uint8Array {
57
57
  return bytes;
58
58
  }
59
59
 
60
+ /**
61
+ * Pack the fixed-tick inputs and GPU-produced events into one typed storage
62
+ * resource. The managed event kernel uses the reflected input capacity as the
63
+ * split point; this keeps channel/sub-emitter semantics while leaving one
64
+ * storage binding available for Custom-enabled emitters.
65
+ */
66
+ export function encodeEventBuffer(intent: VfxGpuTickIntent): Uint8Array {
67
+ const inputBytes = encodeEventInputs(intent);
68
+ const outputBytes = eventCapacity(intent.emitter) * VFX_EVENT_BYTES;
69
+ const data = new Uint8Array(inputBytes.byteLength + outputBytes);
70
+ data.set(inputBytes);
71
+ return data;
72
+ }
73
+
60
74
  export function eventCounterData(): Uint8Array {
61
75
  return new Uint8Array(VFX_EVENT_COUNTER_BYTES);
62
76
  }