@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,119 @@
1
+ import { err, ok, type Result } from '@forgeax/engine-types';
2
+ import {
3
+ resolveVfxDataInterfaces,
4
+ type VfxDataInterfaceError,
5
+ type VfxDataInterfaceKind,
6
+ type VfxDataInterfaceProvider,
7
+ type VfxDataInterfaceRequirement,
8
+ type VfxDataInterfaceResolution,
9
+ type VfxDataInterfaceResource,
10
+ type VfxDataInterfaceToken,
11
+ } from '@forgeax/engine-vfx';
12
+
13
+ export type { VfxDataInterfaceProvider } from '@forgeax/engine-vfx';
14
+
15
+ export interface VfxDataInterfaceAvailabilitySource {
16
+ readonly available: (generation: number) => boolean;
17
+ }
18
+
19
+ export interface VfxDataInterfaceRegistry {
20
+ readonly providers: readonly VfxDataInterfaceProvider[];
21
+ readonly snapshot:
22
+ | {
23
+ readonly generation: number;
24
+ readonly result: Result<VfxDataInterfaceResolution, VfxDataInterfaceError>;
25
+ }
26
+ | undefined;
27
+ register(provider: VfxDataInterfaceProvider): Result<void, VfxDataInterfaceError>;
28
+ resolve(
29
+ requirements: readonly VfxDataInterfaceRequirement[],
30
+ generation: number,
31
+ ): Result<VfxDataInterfaceResolution, VfxDataInterfaceError>;
32
+ }
33
+
34
+ function duplicate(token: VfxDataInterfaceToken, providerId: string): VfxDataInterfaceError {
35
+ return {
36
+ code: 'vfx-data-interface-duplicate',
37
+ expected: 'at most one provider for each reflected Data Interface token',
38
+ hint: `remove provider ${providerId} or the existing provider for ${token} and retry`,
39
+ detail: { token, providerId },
40
+ };
41
+ }
42
+
43
+ function availableProvider<K extends VfxDataInterfaceKind>(
44
+ token: VfxDataInterfaceProvider<K>['token'],
45
+ kind: K,
46
+ bindingType: VfxDataInterfaceProvider<K>['bindingType'],
47
+ source: VfxDataInterfaceAvailabilitySource,
48
+ ): VfxDataInterfaceProvider<K> {
49
+ return {
50
+ id: `${token}-provider`,
51
+ token,
52
+ kind,
53
+ bindingType,
54
+ provide: (generation) => {
55
+ if (!source.available(generation)) {
56
+ return err({
57
+ code: 'vfx-data-interface-missing',
58
+ expected: `an available ${kind} provider for ${token}`,
59
+ hint: `make ${token} available for generation ${generation} and retry rendering`,
60
+ detail: { token, providerId: `${token}-provider` },
61
+ });
62
+ }
63
+ const resource: VfxDataInterfaceResource = {
64
+ token,
65
+ kind,
66
+ bindingType,
67
+ generation,
68
+ };
69
+ return ok(resource);
70
+ },
71
+ };
72
+ }
73
+
74
+ export function createCameraProvider(
75
+ source: VfxDataInterfaceAvailabilitySource,
76
+ ): VfxDataInterfaceProvider<'camera'> {
77
+ return availableProvider('vfx:camera', 'camera', 'uniform', source);
78
+ }
79
+
80
+ export function createSceneDepthProvider(
81
+ source: VfxDataInterfaceAvailabilitySource,
82
+ ): VfxDataInterfaceProvider<'scene-depth'> {
83
+ return availableProvider('vfx:scene-depth', 'scene-depth', 'sampled-depth', source);
84
+ }
85
+
86
+ export function createVfxDataInterfaceRegistry(
87
+ initialProviders: readonly VfxDataInterfaceProvider[] = [],
88
+ ): VfxDataInterfaceRegistry {
89
+ const entries = new Map<VfxDataInterfaceToken, VfxDataInterfaceProvider>();
90
+ let registrationError: VfxDataInterfaceError | undefined;
91
+ let lastSnapshot: VfxDataInterfaceRegistry['snapshot'];
92
+ const registry: VfxDataInterfaceRegistry = {
93
+ get providers() {
94
+ return Object.freeze([...entries.values()]);
95
+ },
96
+ get snapshot() {
97
+ return lastSnapshot;
98
+ },
99
+ register(provider) {
100
+ if (entries.has(provider.token)) {
101
+ const error = duplicate(provider.token, provider.id);
102
+ registrationError ??= error;
103
+ return err(error);
104
+ }
105
+ entries.set(provider.token, provider);
106
+ return ok(undefined);
107
+ },
108
+ resolve(requirements, generation) {
109
+ const result =
110
+ registrationError === undefined
111
+ ? resolveVfxDataInterfaces(requirements, [...entries.values()], generation)
112
+ : err(registrationError);
113
+ lastSnapshot = Object.freeze({ generation, result });
114
+ return result;
115
+ },
116
+ };
117
+ for (const provider of initialProviders) registry.register(provider);
118
+ return registry;
119
+ }
@@ -0,0 +1,459 @@
1
+ import type {
2
+ AssetRegistryResolver,
3
+ AssetRegistry as LegacyAssetRegistry,
4
+ RuntimeAssetRegistry,
5
+ } from '@forgeax/engine-assets-runtime';
6
+ import { getAssetRegistryResolver } from '@forgeax/engine-assets-runtime';
7
+ import { createWorldContext, type EntityHandle, type World } from '@forgeax/engine-ecs';
8
+ import type {
9
+ Asset,
10
+ AssetDecoderLease,
11
+ MaterialAsset,
12
+ MeshAsset,
13
+ Result,
14
+ } from '@forgeax/engine-types';
15
+ import { err, ok } from '@forgeax/engine-types';
16
+ import type {
17
+ VfxDataInterfaceError,
18
+ VfxDataInterfaceProvider,
19
+ VfxDataInterfaceRequirement,
20
+ VfxDataInterfaceResolution,
21
+ VfxGpuPlayerInspectSnapshot,
22
+ VfxGpuRuntimeDiagnostic,
23
+ VfxReplayInput,
24
+ VfxValueMap,
25
+ } from '@forgeax/engine-vfx';
26
+ import {
27
+ ParticleEffectPlayer,
28
+ VFX_GPU_RUNTIME_RESOURCE_KEY,
29
+ type VfxGpuRuntime,
30
+ vfxGpuEffectContribution,
31
+ vfxGpuEffectPackLoader,
32
+ vfxGpuRuntimePlugin,
33
+ } from '@forgeax/engine-vfx';
34
+ import type { ParticleRenderCameraSource } from '../feature/camera.js';
35
+ import { gpuParticleRenderFeature } from '../feature/gpu-particle-feature.js';
36
+ import {
37
+ createVfxDataInterfaceRegistry,
38
+ type VfxDataInterfaceRegistry,
39
+ } from './data-interface-providers.js';
40
+
41
+ /**
42
+ * Public authoring projection accepted by the VFX host. Editor owns the
43
+ * projection, while the Engine App still owns bytes/decoders/cache. Keeping
44
+ * this small structural seam public lets the Editor pass that projection
45
+ * without importing the runtime registry's internal module or fabricating a
46
+ * second registry identity.
47
+ */
48
+ export interface VfxAuthoringAssetRegistry {
49
+ readonly loaders: {
50
+ registerPackLoader(loader: unknown): void;
51
+ };
52
+ lookup<T extends Asset = Asset>(guid: string): T | undefined;
53
+ readonly load?: (guid: string, kind: string) => Promise<Result<unknown, unknown>>;
54
+ }
55
+
56
+ type AssetRegistry = LegacyAssetRegistry | RuntimeAssetRegistry | VfxAuthoringAssetRegistry;
57
+
58
+ export interface VfxRuntimeHostOptions {
59
+ readonly camera: ParticleRenderCameraSource;
60
+ readonly maxQueuedTicks?: number;
61
+ readonly providers?: readonly VfxDataInterfaceProvider[];
62
+ }
63
+
64
+ export interface VfxRuntimeHostError {
65
+ readonly code:
66
+ | 'vfx-host-loader-install-failed'
67
+ | 'vfx-host-world-attach-failed'
68
+ | 'vfx-host-world-detach-failed';
69
+ readonly expected: string;
70
+ readonly hint: string;
71
+ readonly detail: { readonly cause: unknown };
72
+ }
73
+
74
+ export interface VfxRuntimeHostControlError {
75
+ readonly code:
76
+ | 'vfx-host-control-world-detached'
77
+ | 'vfx-host-control-stale-generation'
78
+ | 'vfx-host-control-runtime-unavailable'
79
+ | 'vfx-host-control-player-unavailable';
80
+ readonly expected: string;
81
+ readonly hint: string;
82
+ readonly detail: {
83
+ readonly requestedGeneration?: number;
84
+ readonly currentGeneration?: number;
85
+ readonly player?: EntityHandle;
86
+ };
87
+ }
88
+
89
+ export interface VfxRuntimeHostControl {
90
+ readonly generation: number;
91
+ replay(input: {
92
+ readonly player: EntityHandle;
93
+ readonly replayInput?: VfxReplayInput<VfxValueMap>;
94
+ }): Result<{ readonly state: 'queued'; readonly generation: number }, VfxRuntimeHostControlError>;
95
+ setEmitterSessionEnabled(input: {
96
+ readonly player: EntityHandle;
97
+ readonly emitterId: string;
98
+ readonly enabled: boolean;
99
+ }): Result<
100
+ {
101
+ readonly state: 'enabled' | 'disabled';
102
+ readonly generation: number;
103
+ },
104
+ VfxRuntimeHostControlError
105
+ >;
106
+ setPlayerRenderConsumption(input: {
107
+ readonly player: EntityHandle;
108
+ readonly enabled: boolean;
109
+ }): Result<
110
+ {
111
+ readonly state: 'enabled' | 'paused';
112
+ readonly generation: number;
113
+ },
114
+ VfxRuntimeHostControlError
115
+ >;
116
+ }
117
+
118
+ export interface VfxRuntimeHost {
119
+ readonly feature: ReturnType<typeof gpuParticleRenderFeature>;
120
+ readonly dataInterfaces: VfxDataInterfaceRegistry;
121
+ inspect(world: World): VfxRuntimeHostInspectSnapshot | undefined;
122
+ acquireControl(world: World): Result<VfxRuntimeHostControl, VfxRuntimeHostControlError>;
123
+ resolveDataInterfaces(input: {
124
+ readonly requirements: readonly VfxDataInterfaceRequirement[];
125
+ readonly generation: number;
126
+ }): Result<VfxDataInterfaceResolution, VfxDataInterfaceError>;
127
+ attachWorld(input: {
128
+ readonly world: World;
129
+ readonly assets: AssetRegistry;
130
+ }): Promise<Result<{ readonly state: 'attached' | 'already-attached' }, VfxRuntimeHostError>>;
131
+ detachWorld(input: {
132
+ readonly world: World;
133
+ }): Promise<Result<{ readonly state: 'detached' | 'not-attached' }, VfxRuntimeHostError>>;
134
+ }
135
+
136
+ /** Install the VFX owner decoder into the core registry. */
137
+ export function installVfxRuntimeDecoder(registry: RuntimeAssetRegistry): AssetDecoderLease {
138
+ return registry.installDecoder(vfxGpuEffectContribution.kind, vfxGpuEffectContribution.decoder);
139
+ }
140
+
141
+ export interface VfxRuntimeHostInspectSnapshot {
142
+ readonly generation: number;
143
+ /** VFX GPU resource generation; distinct from host attachment generation. */
144
+ readonly renderGeneration: number;
145
+ readonly players: readonly VfxGpuPlayerInspectSnapshot[];
146
+ readonly diagnostics: readonly VfxGpuRuntimeDiagnostic[];
147
+ }
148
+
149
+ type PluginContext = Awaited<ReturnType<typeof createWorldContext>>;
150
+
151
+ function failure(
152
+ code: VfxRuntimeHostError['code'],
153
+ expected: string,
154
+ hint: string,
155
+ cause: unknown,
156
+ ): VfxRuntimeHostError {
157
+ return { code, expected, hint, detail: { cause } };
158
+ }
159
+
160
+ function controlFailure(
161
+ code: VfxRuntimeHostControlError['code'],
162
+ expected: string,
163
+ hint: string,
164
+ detail: VfxRuntimeHostControlError['detail'] = {},
165
+ ): VfxRuntimeHostControlError {
166
+ return { code, expected, hint, detail };
167
+ }
168
+
169
+ export function createVfxRuntimeHost(options: VfxRuntimeHostOptions): VfxRuntimeHost {
170
+ const registries = new WeakSet<AssetRegistry>();
171
+ const worlds = new WeakMap<
172
+ World,
173
+ {
174
+ readonly assets: AssetRegistry;
175
+ readonly resolver: AssetRegistryResolver | undefined;
176
+ readonly generation: number;
177
+ readonly pluginContext: PluginContext;
178
+ readonly renderAssets: Map<string, MaterialAsset | MeshAsset>;
179
+ readonly pendingRenderAssets: Map<string, Promise<void>>;
180
+ readonly unsubscribeAssets: () => void;
181
+ catalogEpoch: number;
182
+ }
183
+ >();
184
+ const pausedPlayers = new WeakMap<World, Set<EntityHandle>>();
185
+ let nextGeneration = 1;
186
+ const dataInterfaces = createVfxDataInterfaceRegistry(options.providers);
187
+ const readRenderAsset = <Kind extends 'material' | 'mesh'>(
188
+ world: World,
189
+ guid: string,
190
+ kind: Kind,
191
+ ): (Kind extends 'material' ? MaterialAsset : MeshAsset) | undefined => {
192
+ const attached = worlds.get(world);
193
+ if (attached === undefined) return undefined;
194
+ const key = `${kind}:${guid.toLowerCase()}`;
195
+ const cached = attached.renderAssets.get(key);
196
+ if (cached?.kind === kind) return cached as Kind extends 'material' ? MaterialAsset : MeshAsset;
197
+ if ('lookup' in attached.assets) {
198
+ const lookup = attached.assets.lookup as (guid: string) => Asset | undefined;
199
+ // Preserve the registry receiver: the Engine-owned legacy class uses
200
+ // `this` to read its load-state/catalogue maps. The Editor facade may
201
+ // expose an arrow function, but calling through the owner is safe for
202
+ // both shapes and avoids turning a lookup into a swallowed plan error.
203
+ const legacy = lookup.call(attached.assets, guid);
204
+ if (legacy?.kind === kind) {
205
+ attached.renderAssets.set(key, legacy);
206
+ return legacy as Kind extends 'material' ? MaterialAsset : MeshAsset;
207
+ }
208
+ }
209
+ const resolved = attached.resolver?.lookup<MaterialAsset | MeshAsset>(guid);
210
+ if (resolved?.kind === kind) {
211
+ attached.renderAssets.set(key, resolved);
212
+ return resolved as Kind extends 'material' ? MaterialAsset : MeshAsset;
213
+ }
214
+ if (
215
+ 'load' in attached.assets &&
216
+ typeof attached.assets.load === 'function' &&
217
+ !attached.pendingRenderAssets.has(key)
218
+ ) {
219
+ const epoch = attached.catalogEpoch;
220
+ const load = attached.assets.load as (
221
+ guid: string,
222
+ kind: string,
223
+ ) => Promise<Result<MaterialAsset | MeshAsset, unknown>>;
224
+ const request = load(guid, kind)
225
+ .then((result) => {
226
+ if (!result.ok || worlds.get(world) !== attached || attached.catalogEpoch !== epoch)
227
+ return;
228
+ attached.renderAssets.set(key, result.value);
229
+ })
230
+ .catch(() => undefined);
231
+ attached.pendingRenderAssets.set(key, request);
232
+ void request.finally(() => {
233
+ if (attached.pendingRenderAssets.get(key) === request) {
234
+ attached.pendingRenderAssets.delete(key);
235
+ }
236
+ });
237
+ }
238
+ return undefined;
239
+ };
240
+ const feature = gpuParticleRenderFeature({
241
+ camera: options.camera,
242
+ dataInterfaces,
243
+ material: { read: (world, guid) => readRenderAsset(world, guid, 'material') },
244
+ mesh: { read: (world, guid) => readRenderAsset(world, guid, 'mesh') },
245
+ playerConsumption: {
246
+ isEnabled: (world, player) => !pausedPlayers.get(world)?.has(player),
247
+ },
248
+ });
249
+ return {
250
+ feature,
251
+ dataInterfaces,
252
+ inspect: (world) => {
253
+ const attached = worlds.get(world);
254
+ if (attached === undefined || !world.hasResource(VFX_GPU_RUNTIME_RESOURCE_KEY))
255
+ return undefined;
256
+ const runtime = world.getResource<VfxGpuRuntime>(VFX_GPU_RUNTIME_RESOURCE_KEY);
257
+ return Object.freeze({
258
+ generation: attached.generation,
259
+ renderGeneration: runtime.renderGeneration,
260
+ players: runtime.inspectPlayers(),
261
+ diagnostics: runtime.diagnostics(),
262
+ });
263
+ },
264
+ acquireControl: (world) => {
265
+ const attached = worlds.get(world);
266
+ if (attached === undefined) {
267
+ return err(
268
+ controlFailure(
269
+ 'vfx-host-control-world-detached',
270
+ 'an attached VFX Runtime World',
271
+ 'attach the World to this VfxRuntimeHost before acquiring controls',
272
+ ),
273
+ );
274
+ }
275
+ const requestedGeneration = attached.generation;
276
+ const withRuntime = <T>(
277
+ player: EntityHandle,
278
+ action: (runtime: VfxGpuRuntime) => T,
279
+ ): Result<T, VfxRuntimeHostControlError> => {
280
+ const current = worlds.get(world);
281
+ if (current === undefined) {
282
+ return err(
283
+ controlFailure(
284
+ 'vfx-host-control-world-detached',
285
+ `VFX host generation ${requestedGeneration} to remain attached`,
286
+ 'reacquire controls after the World is attached again',
287
+ { requestedGeneration },
288
+ ),
289
+ );
290
+ }
291
+ if (current.generation !== requestedGeneration) {
292
+ return err(
293
+ controlFailure(
294
+ 'vfx-host-control-stale-generation',
295
+ `VFX host generation ${requestedGeneration}`,
296
+ 'discard this stale control lease and acquire one from the current host generation',
297
+ { requestedGeneration, currentGeneration: current.generation },
298
+ ),
299
+ );
300
+ }
301
+ if (!world.hasResource(VFX_GPU_RUNTIME_RESOURCE_KEY)) {
302
+ return err(
303
+ controlFailure(
304
+ 'vfx-host-control-runtime-unavailable',
305
+ 'the attached World to own its VFX GPU runtime resource',
306
+ 'repair the host attachment before retrying the preview command',
307
+ { requestedGeneration, currentGeneration: current.generation },
308
+ ),
309
+ );
310
+ }
311
+ if (!world.get(player, ParticleEffectPlayer).ok) {
312
+ return err(
313
+ controlFailure(
314
+ 'vfx-host-control-player-unavailable',
315
+ 'a live entity with ParticleEffectPlayer in the attached World',
316
+ 'discard the stale player handle or target a VFX player owned by this World',
317
+ { requestedGeneration, currentGeneration: current.generation, player },
318
+ ),
319
+ );
320
+ }
321
+ return ok(action(world.getResource<VfxGpuRuntime>(VFX_GPU_RUNTIME_RESOURCE_KEY)));
322
+ };
323
+ const control: VfxRuntimeHostControl = {
324
+ generation: requestedGeneration,
325
+ replay: ({ player, replayInput }) =>
326
+ withRuntime(player, (runtime) => {
327
+ runtime.replay(player, replayInput);
328
+ return Object.freeze({ state: 'queued' as const, generation: requestedGeneration });
329
+ }),
330
+ setEmitterSessionEnabled: ({ player, emitterId, enabled }) =>
331
+ withRuntime(player, (runtime) => {
332
+ runtime.setEmitterSessionEnabled(player, emitterId, enabled);
333
+ return Object.freeze({
334
+ state: enabled ? ('enabled' as const) : ('disabled' as const),
335
+ generation: requestedGeneration,
336
+ });
337
+ }),
338
+ setPlayerRenderConsumption: ({ player, enabled }) =>
339
+ withRuntime(player, () => {
340
+ let paused = pausedPlayers.get(world);
341
+ if (enabled) {
342
+ paused?.delete(player);
343
+ if (paused !== undefined && paused.size === 0) pausedPlayers.delete(world);
344
+ } else {
345
+ paused ??= new Set();
346
+ paused.add(player);
347
+ pausedPlayers.set(world, paused);
348
+ }
349
+ return Object.freeze({
350
+ state: enabled ? ('enabled' as const) : ('paused' as const),
351
+ generation: requestedGeneration,
352
+ });
353
+ }),
354
+ };
355
+ return ok(Object.freeze(control));
356
+ },
357
+ resolveDataInterfaces: ({ requirements, generation }) =>
358
+ dataInterfaces.resolve(requirements, generation),
359
+ attachWorld: async ({ world, assets }) => {
360
+ if (worlds.has(world)) return ok({ state: 'already-attached' });
361
+ if (!registries.has(assets)) {
362
+ try {
363
+ // The Editor facade deliberately exposes the legacy LoaderRegistry
364
+ // while projecting the Engine-owned App.assets instance. Prefer
365
+ // that public authoring seam before looking for the five-action
366
+ // runtime registry; otherwise a facade that happens to expose
367
+ // `load` would be misclassified as a decoder owner.
368
+ if ('loaders' in assets) {
369
+ assets.loaders.registerPackLoader(vfxGpuEffectPackLoader);
370
+ } else if ('installDecoder' in assets) {
371
+ assets.installDecoder(vfxGpuEffectContribution.kind, vfxGpuEffectContribution.decoder);
372
+ } else {
373
+ throw new TypeError('VFX assets registry exposes neither loaders nor installDecoder');
374
+ }
375
+ registries.add(assets);
376
+ } catch (cause) {
377
+ return err(
378
+ failure(
379
+ 'vfx-host-loader-install-failed',
380
+ 'the v2 VFX loader to be registered once',
381
+ 'remove a conflicting particle-effect loader and retry attachWorld',
382
+ cause,
383
+ ),
384
+ );
385
+ }
386
+ }
387
+ let pluginContext: PluginContext;
388
+ try {
389
+ pluginContext = await createWorldContext(world, [
390
+ vfxGpuRuntimePlugin(
391
+ options.maxQueuedTicks === undefined ? {} : { maxQueuedTicks: options.maxQueuedTicks },
392
+ ),
393
+ ]);
394
+ } catch (cause) {
395
+ return err(
396
+ failure(
397
+ 'vfx-host-world-attach-failed',
398
+ 'the World FixedUpdate VFX intent producer to install',
399
+ 'repair the reported World registration conflict and retry',
400
+ cause,
401
+ ),
402
+ );
403
+ }
404
+ let resolver: AssetRegistryResolver | undefined;
405
+ if (!('loaders' in assets) && 'installDecoder' in assets) {
406
+ try {
407
+ resolver = getAssetRegistryResolver(assets);
408
+ } catch {
409
+ resolver = undefined;
410
+ }
411
+ }
412
+ const attached = {
413
+ assets,
414
+ resolver,
415
+ pluginContext,
416
+ generation: nextGeneration++,
417
+ renderAssets: new Map<string, MaterialAsset | MeshAsset>(),
418
+ pendingRenderAssets: new Map<string, Promise<void>>(),
419
+ catalogEpoch:
420
+ 'snapshot' in assets && typeof assets.snapshot === 'function'
421
+ ? assets.snapshot().epoch
422
+ : 0,
423
+ unsubscribeAssets: () => {},
424
+ };
425
+ if ('subscribe' in assets && typeof assets.subscribe === 'function') {
426
+ attached.unsubscribeAssets = assets.subscribe((snapshot) => {
427
+ if (snapshot.epoch === attached.catalogEpoch) return;
428
+ attached.catalogEpoch = snapshot.epoch;
429
+ attached.renderAssets.clear();
430
+ attached.pendingRenderAssets.clear();
431
+ });
432
+ }
433
+ worlds.set(world, attached);
434
+ return ok({ state: 'attached' });
435
+ },
436
+ detachWorld: async ({ world }) => {
437
+ const attached = worlds.get(world);
438
+ if (attached === undefined) return ok({ state: 'not-attached' });
439
+ try {
440
+ await attached.pluginContext.fiber.dispose();
441
+ } catch (cause) {
442
+ return err(
443
+ failure(
444
+ 'vfx-host-world-detach-failed',
445
+ 'the VFX FixedUpdate producer to detach exactly once',
446
+ 'inspect the World schedule and retry detachWorld',
447
+ cause,
448
+ ),
449
+ );
450
+ }
451
+ pausedPlayers.delete(world);
452
+ attached.unsubscribeAssets();
453
+ attached.renderAssets.clear();
454
+ attached.pendingRenderAssets.clear();
455
+ worlds.delete(world);
456
+ return ok({ state: 'detached' });
457
+ },
458
+ };
459
+ }
package/src/index.ts ADDED
@@ -0,0 +1,57 @@
1
+ // @forgeax/engine-vfx-render - GPU particle execution and rendering.
2
+
3
+ export type { ParticleRenderCamera, ParticleRenderCameraSource } from './feature/camera.js';
4
+ export {
5
+ encodeEventInputs,
6
+ eventCapacity,
7
+ eventCounterData,
8
+ eventInputCapacity,
9
+ VFX_EVENT_BYTES,
10
+ VFX_EVENT_COUNTER_BYTES,
11
+ VFX_EVENT_INPUT_BYTES,
12
+ } from './feature/event-resources.js';
13
+ export {
14
+ createVfxRenderInspectSnapshot,
15
+ gpuParticleRenderFeature,
16
+ resolveBillboardAdvancedState,
17
+ topologyRecoveryHint,
18
+ } from './feature/gpu-particle-feature.js';
19
+ export {
20
+ createTopologyResourcePlan,
21
+ PARTICLE_SHADER_IDENTIFIERS,
22
+ topologyCapacitySnapshot,
23
+ } from './feature/particle-resources.js';
24
+ export type {
25
+ VfxStagePlanError,
26
+ VfxStagePlanObservation,
27
+ VfxStageReadiness,
28
+ VfxStageRecovery,
29
+ VfxValidatedStage,
30
+ VfxValidatedStagePlan,
31
+ } from './feature/stage-plan.js';
32
+ export {
33
+ observeStagePlan,
34
+ stageDispatches,
35
+ stageRecoveryReadiness,
36
+ validatedStagePlan,
37
+ } from './feature/stage-plan.js';
38
+ export type {
39
+ VfxDataInterfaceAvailabilitySource,
40
+ VfxDataInterfaceProvider,
41
+ VfxDataInterfaceRegistry,
42
+ } from './host/data-interface-providers.js';
43
+ export {
44
+ createCameraProvider,
45
+ createSceneDepthProvider,
46
+ createVfxDataInterfaceRegistry,
47
+ } from './host/data-interface-providers.js';
48
+ export type {
49
+ VfxAuthoringAssetRegistry,
50
+ VfxRuntimeHost,
51
+ VfxRuntimeHostControl,
52
+ VfxRuntimeHostControlError,
53
+ VfxRuntimeHostError,
54
+ VfxRuntimeHostInspectSnapshot,
55
+ VfxRuntimeHostOptions,
56
+ } from './host/vfx-runtime-host.js';
57
+ export { createVfxRuntimeHost, installVfxRuntimeDecoder } from './host/vfx-runtime-host.js';
@@ -0,0 +1,69 @@
1
+ #define_import_path forgeax::vfx-render.particles.beam
2
+ #import forgeax_view::common::{View, FogViewParams, FogRay, view}
3
+ #import forgeax_view::fog::{apply_fog}
4
+
5
+ struct BeamInput {
6
+ @location(0) start: vec3<f32>,
7
+ @location(1) endpoint: vec3<f32>,
8
+ @location(2) color: vec4<f32>,
9
+ @location(3) properties: vec2<f32>,
10
+ }
11
+
12
+ struct VertexOutput {
13
+ @builtin(position) position: vec4<f32>,
14
+ @location(0) color: vec4<f32>,
15
+ @location(1) clip_position: vec3<f32>,
16
+ }
17
+
18
+ fn fogWorldPoint(ndc: vec3<f32>) -> vec3<f32> {
19
+ let homogeneous = view.inverseViewProj * vec4<f32>(ndc, 1.0);
20
+ let divisor = select(1.0, homogeneous.w, abs(homogeneous.w) > 0.000001);
21
+ return homogeneous.xyz / divisor;
22
+ }
23
+
24
+ fn fogRayFromNdc(ndc: vec3<f32>) -> FogRay {
25
+ let worldPosition = fogWorldPoint(ndc);
26
+ let nearPosition = fogWorldPoint(vec3<f32>(ndc.xy, 0.0));
27
+ let farPosition = fogWorldPoint(vec3<f32>(ndc.xy, 1.0));
28
+ let perspective = view.temporalProjection.z < 0.5;
29
+ let perspectiveVector = worldPosition - view.cameraPos;
30
+ let orthographicVector = farPosition - nearPosition;
31
+ let direction = normalize(select(orthographicVector, perspectiveVector, perspective));
32
+ let origin = select(nearPosition, view.cameraPos, perspective);
33
+ let ray_distance = select(
34
+ max(dot(worldPosition - nearPosition, direction), 0.0),
35
+ length(perspectiveVector),
36
+ perspective,
37
+ );
38
+ return FogRay(origin, direction, ray_distance);
39
+ }
40
+
41
+ @vertex
42
+ fn vs_main(input: BeamInput, @builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
43
+ let corners = array<vec2<f32>, 6>(
44
+ vec2<f32>(0.0, -1.0), vec2<f32>(1.0, -1.0), vec2<f32>(1.0, 1.0),
45
+ vec2<f32>(0.0, -1.0), vec2<f32>(1.0, 1.0), vec2<f32>(0.0, 1.0)
46
+ );
47
+ let corner = corners[vertexIndex];
48
+ let delta = input.endpoint.xy - input.start.xy;
49
+ let normal = normalize(vec2<f32>(-delta.y, delta.x) + vec2<f32>(0.000001, 0.0));
50
+ let point = mix(input.start, input.endpoint, corner.x);
51
+ let clipPosition = vec3<f32>(point.xy + normal * corner.y * input.properties.x, point.z);
52
+ var output: VertexOutput;
53
+ output.position = vec4<f32>(clipPosition, 1.0);
54
+ output.color = input.color;
55
+ output.clip_position = clipPosition;
56
+ return output;
57
+ }
58
+
59
+ @fragment
60
+ fn fs_main(input: VertexOutput) -> @location(0) vec4<f32> {
61
+ let base = vec4<f32>(0.7, 0.2, 1.0, 1.0);
62
+ let alpha = base.a * input.color.a;
63
+ let fogged = apply_fog(
64
+ view.fog,
65
+ fogRayFromNdc(input.clip_position),
66
+ vec4<f32>(base.rgb * input.color.rgb, alpha),
67
+ );
68
+ return vec4<f32>(fogged.rgb * fogged.a, fogged.a);
69
+ }