@forgeax/engine-vfx-render 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +163 -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 +96 -0
  39. package/dist/feature/gpu-particle-feature.d.ts.map +1 -0
  40. package/dist/feature/particle-resources.d.ts +66 -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 +87 -0
  53. package/dist/host/vfx-runtime-host.d.ts.map +1 -0
  54. package/dist/index.d.ts +12 -0
  55. package/dist/index.d.ts.map +1 -0
  56. package/dist/index.mjs +1572 -0
  57. package/dist/index.mjs.map +1 -0
  58. package/package.json +63 -0
  59. package/src/__tests__/billboard-advanced.integration.test.ts +69 -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 +59 -0
  64. package/src/__tests__/gpu-host.integration.test.ts +441 -0
  65. package/src/__tests__/inspect-snapshot.integration.test.ts +26 -0
  66. package/src/__tests__/particle-resources.unit.test.ts +75 -0
  67. package/src/__tests__/render-vocabulary-owner.test-d.ts +81 -0
  68. package/src/__tests__/stage-plan.integration.test.ts +16 -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 +1225 -0
  77. package/src/feature/particle-resources.ts +203 -0
  78. package/src/feature/stage-plan.ts +282 -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 +402 -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,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,16 @@
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 managed stage execution seam', () => {
8
+ it('owns readiness and validated-plan execution in the RenderFeature package', () => {
9
+ const source = readFileSync(stagePlanPath, 'utf8');
10
+ expect(source).toContain('validatedStagePlan');
11
+ expect(source).toContain('stageReadiness');
12
+ expect(source).toContain('stageOutput');
13
+ expect(source).toContain('forgeax_vfx_stage_');
14
+ expect(source).not.toContain('authorRawDispatch');
15
+ });
16
+ });
@@ -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
+ }