@forgeax/engine-vfx-render 0.1.27 → 0.1.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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
@@ -1,11 +1,21 @@
1
1
  import type { EntityHandle, World } from '@forgeax/engine-ecs';
2
2
  import { frustum } from '@forgeax/engine-math';
3
- import type { RenderFeature, RenderFeaturePlan } from '@forgeax/engine-render';
3
+ import type {
4
+ RenderFeature,
5
+ RenderFeaturePlan,
6
+ RenderFeaturePlanContext,
7
+ } from '@forgeax/engine-render';
4
8
  import { GlobalTransform } from '@forgeax/engine-scene';
5
9
  import { err, type MaterialAsset, type MeshAsset, ok } from '@forgeax/engine-types';
6
- import type { ParticleRendererSource } from '@forgeax/engine-vfx';
10
+ import type {
11
+ ParticleRendererSourceV3,
12
+ VfxDataInterfaceRequirement,
13
+ VfxDataInterfaceResource,
14
+ } from '@forgeax/engine-vfx';
7
15
  import {
8
16
  VFX_GPU_RUNTIME_RESOURCE_KEY,
17
+ VFX_PARTICLE_CORE_STRIDE,
18
+ type VfxGpuEmitterSource,
9
19
  type VfxGpuRuntime,
10
20
  type VfxGpuTickIntent,
11
21
  } from '@forgeax/engine-vfx';
@@ -14,7 +24,7 @@ import { RENDER_FEATURE_VERTEX_LAYOUTS } from '../../../render/src/features/prep
14
24
  import type { VfxDataInterfaceRegistry } from '../host/data-interface-providers.js';
15
25
  import type { ParticleRenderCamera } from './camera.js';
16
26
  import {
17
- encodeEventInputs,
27
+ encodeEventBuffer,
18
28
  eventCapacity,
19
29
  eventInputCapacity,
20
30
  VFX_EVENT_BYTES,
@@ -23,26 +33,28 @@ import {
23
33
  import {
24
34
  canonicalMeshVertices,
25
35
  createTopologyResourcePlan,
36
+ PARTICLE_INPUT_SHADER_IDENTIFIERS,
26
37
  PARTICLE_SHADER_IDENTIFIERS,
27
38
  particleMaterialPass,
28
39
  particleMaterialSceneDepthBinding,
29
40
  particleMaterialUsesBindings,
30
41
  particleRendererRenderState,
42
+ prepareParticleMaterialInputs,
31
43
  } from './particle-resources.js';
32
44
  import type { VfxStagePlanObservation, VfxValidatedStagePlan } from './stage-plan.js';
33
45
  import { validatedStagePlan } from './stage-plan.js';
34
46
 
35
47
  const IDENTITY = 'forgeax.vfx-render.gpu-particles';
36
48
  const WORKGROUP_SIZE = 256;
37
- const PARTICLE_BYTES = 80;
38
49
  const BILLBOARD_INSTANCE_BYTES = 31 * 4;
39
- const MESH_INSTANCE_BYTES = 28 * 4;
50
+ const MESH_INSTANCE_BYTES = 18 * 4;
40
51
  const COUNTERS_BYTES = 24;
41
- const RUNTIME_BYTES = 72 * 4;
52
+ const RUNTIME_BYTES = 76 * 4;
42
53
  const IDENTITY_MATRIX = new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]);
43
54
 
44
- type ParticleRendererKind = ParticleRendererSource['kind'];
45
- type ParticleTopologyRenderer = Extract<ParticleRendererSource, { readonly capacity: number }>;
55
+ type ParticleRendererKind = ParticleRendererSourceV3['kind'];
56
+ type ParticleRenderer = ParticleRendererSourceV3;
57
+ type ParticleTopologyRenderer = Extract<ParticleRendererSourceV3, { readonly capacity: number }>;
46
58
  type ParticleTopologyKind = ParticleTopologyRenderer['kind'];
47
59
  type VfxStageOutput = VfxStagePlanObservation['stageOutput'];
48
60
 
@@ -95,7 +107,7 @@ export interface BillboardAdvancedSample {
95
107
  }
96
108
 
97
109
  export function resolveBillboardAdvancedState(
98
- renderer: Extract<ParticleRendererSource, { readonly kind: 'billboard' }>,
110
+ renderer: Extract<ParticleRendererSourceV3, { readonly kind: 'billboard' }>,
99
111
  sample: BillboardAdvancedSample,
100
112
  ) {
101
113
  if (renderer.softParticle !== undefined && !sample.depthAvailable) {
@@ -129,7 +141,10 @@ export function resolveBillboardAdvancedState(
129
141
  frameIndex,
130
142
  pivot: renderer.pivot ?? ([0, 0] as const),
131
143
  softParticleFade,
132
- sortingKey: renderer.sorting === 'back-to-front' ? sample.particleDepth : 0,
144
+ sortingKey:
145
+ renderer.sorting === 'view-depth' || renderer.sorting === 'view-distance'
146
+ ? sample.particleDepth
147
+ : 0,
133
148
  });
134
149
  }
135
150
 
@@ -161,6 +176,20 @@ interface ExtractedWorld {
161
176
  readonly runtime: VfxGpuRuntime;
162
177
  readonly camera: ParticleRenderCamera;
163
178
  readonly intents: readonly VfxGpuTickIntent[];
179
+ /**
180
+ * Last submitted emitter state kept drawable between fixed ticks. The
181
+ * queue above is simulation work only; renderHz is allowed to exceed
182
+ * fixedHz, and a live emitter must keep its projection/raster lease warm.
183
+ */
184
+ readonly retained: readonly RetainedEmitter[];
185
+ }
186
+
187
+ interface RetainedEmitter {
188
+ readonly player: VfxGpuEmitterSource['player'];
189
+ readonly emitter: VfxGpuEmitterSource['emitter'];
190
+ readonly intent: VfxGpuTickIntent;
191
+ readonly localToWorld: Float32Array;
192
+ readonly visible: boolean;
164
193
  }
165
194
 
166
195
  interface ExtractedFrame {
@@ -172,6 +201,22 @@ function finite(value: unknown, fallback: number): number {
172
201
  return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
173
202
  }
174
203
 
204
+ function billboardSortingMode(renderer: ParticleRenderer | undefined): 0 | 1 | 2 | 3 | 4 | 5 {
205
+ if (renderer?.kind !== 'billboard') return 0;
206
+ switch (renderer.sorting) {
207
+ case 'view-depth':
208
+ return 2;
209
+ case 'view-distance':
210
+ return 5;
211
+ case 'custom-ascending':
212
+ return 3;
213
+ case 'custom-descending':
214
+ return 4;
215
+ default:
216
+ return 0;
217
+ }
218
+ }
219
+
175
220
  function vector(value: unknown, fallback: readonly number[], size: number): readonly number[] {
176
221
  return Array.isArray(value)
177
222
  ? Array.from({ length: size }, (_, index) => finite(value[index], fallback[index] ?? 0))
@@ -183,8 +228,10 @@ function runtimeData(
183
228
  camera: ParticleRenderCamera,
184
229
  material: MaterialAsset | undefined,
185
230
  localToWorld: Float32Array,
186
- renderer?: ParticleRendererSource,
231
+ renderer?: ParticleRenderer,
187
232
  rendererIndex = 0,
233
+ particleInputLanes = renderer?.materialInputs?.length ?? 0,
234
+ meshDraw?: { readonly count: number; readonly firstIndex: number },
188
235
  ): Uint8Array {
189
236
  const storage = new ArrayBuffer(RUNTIME_BYTES);
190
237
  const floats = new Float32Array(storage);
@@ -200,6 +247,7 @@ function runtimeData(
200
247
  floats.set(camera.viewProjection, 8);
201
248
  floats.set(camera.right, 24);
202
249
  floats.set(camera.up, 28);
250
+ floats.set(camera.position, 72);
203
251
  const values = material?.values ?? {};
204
252
  floats.set(vector(values.baseColor, [1, 1, 1, 1], 4), 32);
205
253
  const emissive = vector(values.emissive, [0, 0, 0], 3);
@@ -211,17 +259,19 @@ function runtimeData(
211
259
  floats[43] = finite(values.clearcoatRoughness, 0.5);
212
260
  floats.set(localToWorld, 44);
213
261
  words[60] = rendererIndex;
214
- words[61] = renderer?.kind === 'trail' ? renderer.historyLength : 0;
262
+ words[61] =
263
+ renderer === undefined &&
264
+ ((intent.emitter.reflection.resources ?? []).includes('eventBuffer') ||
265
+ (intent.emitter.reflection.entryPoints ?? []).includes('forgeax_vfx_event_main'))
266
+ ? eventInputCapacity(intent.emitter)
267
+ : renderer?.kind === 'trail'
268
+ ? renderer.historyLength
269
+ : (meshDraw?.count ?? 0);
215
270
  words[62] =
216
271
  renderer?.kind === 'ribbon' || renderer?.kind === 'trail' || renderer?.kind === 'beam'
217
272
  ? renderer.capacity
218
273
  : intent.emitter.capacity;
219
- words[63] =
220
- renderer?.kind === 'billboard' && renderer.sorting === 'back-to-front'
221
- ? 2
222
- : renderer?.kind === 'billboard' && renderer.sorting === 'emitter'
223
- ? 1
224
- : 0;
274
+ words[63] = meshDraw?.firstIndex ?? billboardSortingMode(renderer);
225
275
  floats[64] =
226
276
  renderer?.kind === 'billboard'
227
277
  ? (renderer.pivot?.[0] ?? 0)
@@ -230,6 +280,9 @@ function runtimeData(
230
280
  : 0.1;
231
281
  floats[65] = renderer?.kind === 'billboard' ? (renderer.pivot?.[1] ?? 0) : 0;
232
282
  floats[66] = renderer?.kind === 'billboard' ? (renderer.softParticle?.fadeDistance ?? 0) : 0;
283
+ // The projection shader derives its instance stride from this reflected
284
+ // material-input lane count; undeclared inputs therefore allocate nothing.
285
+ floats[67] = particleInputLanes;
233
286
  const sheet = renderer?.kind === 'billboard' ? renderer.textureSheet : undefined;
234
287
  floats[68] = sheet?.columns ?? 1;
235
288
  floats[69] = sheet?.rows ?? 1;
@@ -245,11 +298,11 @@ function emitterTransform(world: World, intent: VfxGpuTickIntent): Float32Array
245
298
  }
246
299
 
247
300
  function emitterVisible(
248
- intent: VfxGpuTickIntent,
301
+ emitter: VfxGpuTickIntent['emitter'],
249
302
  camera: ParticleRenderCamera,
250
303
  localToWorld: Float32Array,
251
304
  ): boolean {
252
- const bounds = intent.emitter.bounds;
305
+ const bounds = emitter.bounds;
253
306
  const center =
254
307
  bounds.kind === 'sphere'
255
308
  ? bounds.center
@@ -291,6 +344,53 @@ function requiresSceneDepth(intent: VfxGpuTickIntent): boolean {
291
344
  );
292
345
  }
293
346
 
347
+ function dataInterfacesExecutable(
348
+ intent: VfxGpuTickIntent,
349
+ registry: Pick<VfxDataInterfaceRegistry, 'resolve'> | undefined,
350
+ ): boolean {
351
+ const requirements = intent.emitter.reflection.dataInterfaces ?? [];
352
+ if (requirements.length === 0) return true;
353
+ const resolved = registry?.resolve(requirements, intent.instanceGeneration);
354
+ // Render can provide its prepared fallback resources. An unavailable
355
+ // provider is therefore deferred in plan, while an explicit hard failure
356
+ // remains fail-closed and observable.
357
+ if (resolved === undefined) return true;
358
+ if (!resolved.ok) {
359
+ const error = resolved.error;
360
+ if (error === undefined) return false;
361
+ return (
362
+ error.code === 'vfx-data-interface-missing' &&
363
+ (error.detail.providerId === undefined || error.expected.includes('resident'))
364
+ );
365
+ }
366
+ // Synthetic availability-only providers used by host probes intentionally
367
+ // omit the payload; concrete plan resources are still checked below.
368
+ if (resolved.value === undefined) return true;
369
+ return resolved.value.resources.every((resource) => resource.resource !== undefined);
370
+ }
371
+
372
+ function preparedDataInterfaceResources(
373
+ requirements: readonly VfxDataInterfaceRequirement[],
374
+ generation: number,
375
+ prepared: RenderFeaturePlanContext['preparedDataInterfaces'],
376
+ ): readonly VfxDataInterfaceResource[] | undefined {
377
+ if (prepared === undefined) return undefined;
378
+ const resources: VfxDataInterfaceResource[] = [];
379
+ for (const requirement of requirements) {
380
+ const value = prepared[requirement.kind];
381
+ if (value === undefined) return undefined;
382
+ resources.push({
383
+ token: requirement.token,
384
+ kind: requirement.kind,
385
+ bindingType: requirement.bindingType,
386
+ generation,
387
+ ...(requirement.sampleCount === undefined ? {} : { sampleCount: requirement.sampleCount }),
388
+ resource: value,
389
+ });
390
+ }
391
+ return resources;
392
+ }
393
+
294
394
  function planFailure(): RenderFeatureStageFailedError {
295
395
  return new RenderFeatureStageFailedError(IDENTITY, -1, 'plan', 'next-frame');
296
396
  }
@@ -309,7 +409,13 @@ function computeBindingEntries(
309
409
  ): readonly { readonly binding: number; readonly resource: string }[] {
310
410
  const declared = new Set(
311
411
  (intent.emitter.reflection.bindings[0]?.entries ?? [])
312
- .filter((entry) => entry.buffer !== undefined)
412
+ .filter(
413
+ (entry) =>
414
+ entry.buffer !== undefined ||
415
+ entry.texture !== undefined ||
416
+ entry.sampler !== undefined ||
417
+ entry.storageTexture !== undefined,
418
+ )
313
419
  .map((entry) => entry.binding),
314
420
  );
315
421
  return Object.entries(resources).flatMap(([binding, resource]) =>
@@ -322,6 +428,12 @@ function simulationDispatches(
322
428
  stages: VfxValidatedStagePlan,
323
429
  ): Extract<PlanPass, { readonly kind: 'compute' }>['dispatches'] {
324
430
  const groups = Math.max(1, Math.ceil(intent.emitter.capacity / WORKGROUP_SIZE));
431
+ const compact: Extract<PlanPass, { readonly kind: 'compute' }>['dispatches'] = [
432
+ { kind: 'direct', entryPoint: 'forgeax_vfx_scan_blocks_main', workgroups: [groups] },
433
+ { kind: 'direct', entryPoint: 'forgeax_vfx_scan_block_offsets_main', workgroups: [1] },
434
+ { kind: 'direct', entryPoint: 'forgeax_vfx_add_offsets_main', workgroups: [groups] },
435
+ { kind: 'direct', entryPoint: 'forgeax_vfx_compact_main', workgroups: [groups] },
436
+ ];
325
437
  return [
326
438
  { kind: 'direct', entryPoint: 'forgeax_vfx_spawn_main', workgroups: [groups] },
327
439
  { kind: 'direct', entryPoint: 'forgeax_vfx_update_main', workgroups: [groups] },
@@ -330,22 +442,83 @@ function simulationDispatches(
330
442
  entryPoint: stage.entryPoint,
331
443
  workgroups: [groups] as const,
332
444
  })),
333
- { kind: 'direct', entryPoint: 'forgeax_vfx_scan_blocks_main', workgroups: [groups] },
334
- { kind: 'direct', entryPoint: 'forgeax_vfx_scan_block_offsets_main', workgroups: [1] },
335
- { kind: 'direct', entryPoint: 'forgeax_vfx_add_offsets_main', workgroups: [groups] },
336
- { kind: 'direct', entryPoint: 'forgeax_vfx_compact_main', workgroups: [groups] },
337
- {
338
- kind: 'direct',
339
- entryPoint: 'forgeax_vfx_event_main',
340
- workgroups: [Math.max(1, Math.ceil(eventInputCapacity(intent.emitter) / 64))],
341
- },
445
+ ...compact,
446
+ ...(intent.emitter.reflection.entryPoints.includes('forgeax_vfx_event_main')
447
+ ? [
448
+ {
449
+ kind: 'direct' as const,
450
+ entryPoint: 'forgeax_vfx_event_main',
451
+ workgroups: [1] as const,
452
+ },
453
+ ...compact,
454
+ ]
455
+ : []),
342
456
  ];
343
457
  }
344
458
 
345
459
  export function gpuParticleRenderFeature(
346
460
  options: GpuParticleFeatureOptions,
347
461
  ): RenderFeature<ExtractedFrame> {
348
- return {
462
+ type IntentOutcomeState = 'dispatched' | 'skipped' | 'deferred';
463
+ interface PlannedIntent {
464
+ readonly intent: VfxGpuTickIntent;
465
+ readonly localToWorld: Float32Array;
466
+ readonly stagePlan: VfxValidatedStagePlan;
467
+ /** Camera projection is optional for `simulationWhenCulled: continue`. */
468
+ readonly visible: boolean;
469
+ /** Retained state has no fixed-tick simulation/history work this frame. */
470
+ readonly retained: boolean;
471
+ }
472
+ interface EmitterGroup {
473
+ readonly key: string;
474
+ readonly worldIndex: number;
475
+ readonly entry: ExtractedWorld;
476
+ readonly intents: PlannedIntent[];
477
+ readonly initialReset: boolean;
478
+ }
479
+ interface IntentOutcome {
480
+ readonly intent: VfxGpuTickIntent;
481
+ readonly state: IntentOutcomeState;
482
+ /** Stable reset epoch assigned once per intent, including retry plans. */
483
+ readonly resetEpoch?: number;
484
+ }
485
+ interface PendingEntry {
486
+ readonly world: World;
487
+ readonly runtime: VfxGpuRuntime;
488
+ readonly outcomes: readonly IntentOutcome[];
489
+ }
490
+ const pendingFrames = new Map<number, readonly PendingEntry[]>();
491
+ const worldIds = new WeakMap<World, number>();
492
+ const runtimeIds = new WeakMap<VfxGpuRuntime, number>();
493
+ const resetEpochByEmitter = new WeakMap<VfxGpuRuntime, Map<string, number>>();
494
+ // Sequences are local to a VfxGpuRuntime. Scope reset reservations by the
495
+ // runtime instance so detach/reattach on the same World cannot borrow old
496
+ // epochs. Reservations survive an aborted frame, but are only promoted to
497
+ // the emitter's committed epoch on submission.
498
+ const resetEpochByIntent = new WeakMap<
499
+ VfxGpuRuntime,
500
+ Map<
501
+ number,
502
+ { readonly generation: number; readonly emitterKey: string; readonly epoch: number }
503
+ >
504
+ >();
505
+ let nextWorldId = 0;
506
+ let nextRuntimeId = 0;
507
+ const worldId = (world: World): number => {
508
+ const prior = worldIds.get(world);
509
+ if (prior !== undefined) return prior;
510
+ const assigned = nextWorldId++;
511
+ worldIds.set(world, assigned);
512
+ return assigned;
513
+ };
514
+ const runtimeId = (runtime: VfxGpuRuntime): number => {
515
+ const prior = runtimeIds.get(runtime);
516
+ if (prior !== undefined) return prior;
517
+ const assigned = nextRuntimeId++;
518
+ runtimeIds.set(runtime, assigned);
519
+ return assigned;
520
+ };
521
+ const feature: RenderFeature<ExtractedFrame> = {
349
522
  identity: IDENTITY,
350
523
  requiredCapabilities: ['compute', 'indirectDrawing'],
351
524
  // Generated emitter programs are first-use assets. They must hand a
@@ -353,438 +526,1000 @@ export function gpuParticleRenderFeature(
353
526
  // diagnostic-only getCompilationInfo() round trip; pipeline creation still
354
527
  // validates the module before the pass is submitted.
355
528
  shaderModuleMode: 'immediate',
356
- requiredMaterialShaders: Object.values(PARTICLE_SHADER_IDENTIFIERS),
529
+ requiredMaterialShaders: Object.freeze([
530
+ ...Object.values(PARTICLE_SHADER_IDENTIFIERS),
531
+ ...Object.values(PARTICLE_INPUT_SHADER_IDENTIFIERS),
532
+ ]),
357
533
  extract: (context) => {
534
+ // A rejected recovery candidate can outlive the frame that produced it.
535
+ // Keep the producer-owned submission ledger bounded even when a host
536
+ // disappears before it can call onFrameAborted.
537
+ for (const frameNumber of pendingFrames.keys()) {
538
+ if (frameNumber + 4 < context.frameNumber) pendingFrames.delete(frameNumber);
539
+ }
358
540
  const extracted: ExtractedWorld[] = [];
359
541
  for (const world of context.worlds) {
360
542
  if (!world.hasResource(VFX_GPU_RUNTIME_RESOURCE_KEY)) continue;
361
543
  const camera = options.camera.read(world);
362
544
  if (camera === undefined) continue;
363
545
  const runtime = world.getResource<VfxGpuRuntime>(VFX_GPU_RUNTIME_RESOURCE_KEY);
364
- const intents = runtime.snapshot().filter((intent) => {
365
- if (options.playerConsumption?.isEnabled(world, intent.player) === false) return false;
366
- const requirements = intent.emitter.reflection.dataInterfaces ?? [];
367
- return (
368
- requirements.length === 0 ||
369
- options.dataInterfaces?.resolve(requirements, intent.instanceGeneration).ok === true
370
- );
546
+ const retained: RetainedEmitter[] = [];
547
+ // Refresh frustum state from live emitter sources even when a paused
548
+ // or restart-on-visible emitter has no queued tick. Otherwise a
549
+ // camera move back into its bounds can never produce the next intent.
550
+ runtime.forEachEmitterSource(({ player, emitter }) => {
551
+ const sourceIntent = runtime.lastCommittedEmitter(player, emitter.id);
552
+ const localToWorld =
553
+ sourceIntent === undefined
554
+ ? emitter.space === 'world'
555
+ ? IDENTITY_MATRIX
556
+ : (() => {
557
+ const transform = world.get(player, GlobalTransform);
558
+ return transform.ok ? transform.value.world : IDENTITY_MATRIX;
559
+ })()
560
+ : emitterTransform(world, sourceIntent);
561
+ const visible = emitterVisible(emitter, camera, localToWorld);
562
+ runtime.setEmitterCameraVisibility(player, emitter.id, visible);
563
+ const intent = runtime.lastCommittedEmitter(player, emitter.id);
564
+ if (intent !== undefined) {
565
+ retained.push({ player, emitter, intent, localToWorld, visible });
566
+ }
371
567
  });
372
- extracted.push({ world, runtime, camera, intents });
568
+ // Keep every queued intent in the extracted frame. Plan assigns each
569
+ // intent an explicit terminal state (dispatched/skipped/deferred), so a
570
+ // filtered head can never block an acknowledged tail or be silently
571
+ // removed by a later global commit.
572
+ const intents = runtime.snapshot();
573
+ extracted.push({ world, runtime, camera, intents, retained });
373
574
  }
374
575
  return ok({ worlds: extracted, frameNumber: context.frameNumber });
375
576
  },
376
577
  plan: (frame, context) => {
377
578
  const resources: PlanResource[] = [];
378
579
  const passes: PlanPass[] = [];
379
- const dispatchedIntents = new Set<VfxGpuTickIntent>();
380
580
  const colorTarget =
381
581
  context.targets.find((candidate) => candidate.kind === 'color') ??
382
582
  context.targets.find((candidate) => candidate.kind === 'swapchain');
383
583
  const depthTarget = context.targets.find((candidate) => candidate.kind === 'depth');
584
+ const groups = new Map<string, EmitterGroup>();
585
+ const outcomesByEntry = new Map<ExtractedWorld, IntentOutcome[]>();
384
586
 
587
+ // First classify every queued intent. A deferred intent is an ordered
588
+ // barrier: later intents in the same runtime cannot be dispatched until
589
+ // the provider/target is ready. Session masks and paused consumption
590
+ // skip ordinary ticks, but a reset remains deferred so a new authored
591
+ // timeline cannot be mixed into the previous GPU state.
592
+ // The key is the stable player/emitter identity plus a reset epoch; queued
593
+ // ticks in one epoch deliberately share their particle/counter/history
594
+ // storage.
385
595
  for (const [worldIndex, entry] of frame.worlds.entries()) {
386
- for (const [intentIndex, intent] of entry.intents.entries()) {
387
- if (!entry.runtime.isEmitterSessionEnabled(intent.player, intent.emitter.id)) continue;
596
+ const outcomes: IntentOutcome[] = [];
597
+ outcomesByEntry.set(entry, outcomes);
598
+ const effectiveEpochByEmitter = new Map<string, number>();
599
+ const renderGeneration = entry.runtime.renderGeneration ?? 0;
600
+ const attachmentId = runtimeId(entry.runtime);
601
+ let emitterEpochs = resetEpochByEmitter.get(entry.runtime);
602
+ if (emitterEpochs === undefined) {
603
+ emitterEpochs = new Map();
604
+ resetEpochByEmitter.set(entry.runtime, emitterEpochs);
605
+ }
606
+ let reservations = resetEpochByIntent.get(entry.runtime);
607
+ if (reservations === undefined) {
608
+ reservations = new Map();
609
+ resetEpochByIntent.set(entry.runtime, reservations);
610
+ }
611
+ const liveResetSequences = new Set(
612
+ (entry.runtime.snapshot?.() ?? entry.intents)
613
+ .filter((intent) => intent.reset)
614
+ .map((intent) => intent.sequence),
615
+ );
616
+ for (const [sequence, reservation] of reservations) {
617
+ if (reservation.generation !== renderGeneration || !liveResetSequences.has(sequence)) {
618
+ reservations.delete(sequence);
619
+ }
620
+ }
621
+ // Ordering barriers belong to one player/emitter stream. A paused
622
+ // reset or unavailable provider must not head-of-line block unrelated
623
+ // players that share the same VfxGpuRuntime/World.
624
+ const blockedEmitters = new Set<string>();
625
+ for (const intent of entry.intents) {
626
+ const baseKey =
627
+ `${worldId(entry.world)}:${renderGeneration}:` +
628
+ `${Number(intent.player)}:${intent.emitter.id}`;
629
+ let priorReservedEpoch: number | undefined;
630
+ let priorReservedSequence = -1;
631
+ let exactReservation:
632
+ | { readonly generation: number; readonly emitterKey: string; readonly epoch: number }
633
+ | undefined;
634
+ for (const [sequence, reservation] of reservations) {
635
+ if (reservation.generation === renderGeneration && reservation.emitterKey === baseKey) {
636
+ if (sequence === intent.sequence) {
637
+ exactReservation = reservation;
638
+ } else if (sequence < intent.sequence && sequence > priorReservedSequence) {
639
+ priorReservedEpoch = reservation.epoch;
640
+ priorReservedSequence = sequence;
641
+ }
642
+ }
643
+ }
644
+ const committedEpoch =
645
+ effectiveEpochByEmitter.get(baseKey) ??
646
+ Math.max(emitterEpochs.get(baseKey) ?? 0, priorReservedEpoch ?? 0);
647
+ let resetEpoch = committedEpoch;
648
+ if (intent.reset) {
649
+ resetEpoch =
650
+ exactReservation?.generation === renderGeneration &&
651
+ exactReservation.emitterKey === baseKey
652
+ ? exactReservation.epoch
653
+ : committedEpoch + 1;
654
+ reservations.set(intent.sequence, {
655
+ generation: renderGeneration,
656
+ emitterKey: baseKey,
657
+ epoch: resetEpoch,
658
+ });
659
+ }
660
+ if (blockedEmitters.has(baseKey)) {
661
+ outcomes.push({
662
+ intent,
663
+ state: 'deferred',
664
+ ...(intent.reset ? { resetEpoch } : {}),
665
+ });
666
+ continue;
667
+ }
668
+ if (
669
+ options.playerConsumption?.isEnabled(entry.world, intent.player) === false ||
670
+ !entry.runtime.isEmitterSessionEnabled(intent.player, intent.emitter.id)
671
+ ) {
672
+ const state = intent.reset ? ('deferred' as const) : ('skipped' as const);
673
+ outcomes.push({
674
+ intent,
675
+ state,
676
+ ...(intent.reset ? { resetEpoch } : {}),
677
+ });
678
+ if (intent.reset) blockedEmitters.add(baseKey);
679
+ continue;
680
+ }
388
681
  const localToWorld = emitterTransform(entry.world, intent);
389
- const visible = emitterVisible(intent, entry.camera, localToWorld);
682
+ const visible = emitterVisible(intent.emitter, entry.camera, localToWorld);
390
683
  entry.runtime.setEmitterCameraVisibility(intent.player, intent.emitter.id, visible);
391
- if (!visible) continue;
392
- if (requiresSceneDepth(intent) && depthTarget === undefined) continue;
393
-
684
+ if (!visible && intent.emitter.simulationWhenCulled !== 'continue') {
685
+ const state = intent.reset ? ('deferred' as const) : ('skipped' as const);
686
+ outcomes.push({
687
+ intent,
688
+ state,
689
+ ...(intent.reset ? { resetEpoch } : {}),
690
+ });
691
+ if (intent.reset) blockedEmitters.add(baseKey);
692
+ continue;
693
+ }
694
+ if (
695
+ visible &&
696
+ requiresSceneDepth(intent) &&
697
+ (depthTarget === undefined || depthTarget.sampleCount !== 1)
698
+ ) {
699
+ outcomes.push({
700
+ intent,
701
+ state: 'deferred',
702
+ ...(intent.reset ? { resetEpoch } : {}),
703
+ });
704
+ blockedEmitters.add(baseKey);
705
+ continue;
706
+ }
707
+ const requirements = intent.emitter.reflection.dataInterfaces ?? [];
708
+ if (
709
+ requirements.length > 0 &&
710
+ !dataInterfacesExecutable(intent, options.dataInterfaces)
711
+ ) {
712
+ outcomes.push({
713
+ intent,
714
+ state: 'deferred',
715
+ ...(intent.reset ? { resetEpoch } : {}),
716
+ });
717
+ blockedEmitters.add(baseKey);
718
+ continue;
719
+ }
394
720
  const stagePlan = validatedStagePlan(
395
721
  intent.emitter.reflection.stages,
396
722
  intent.instanceGeneration,
397
723
  );
398
724
  if (!stagePlan.ok) return err(planFailure());
725
+ const entryPoints = new Set(intent.emitter.reflection.entryPoints);
726
+ const dispatches = simulationDispatches(intent, stagePlan.value).filter((dispatch) =>
727
+ entryPoints.has(dispatch.entryPoint),
728
+ );
729
+ if (dispatches.length === 0 && intent.emitter.renderers.length === 0) {
730
+ outcomes.push({
731
+ intent,
732
+ state: 'skipped',
733
+ ...(intent.reset ? { resetEpoch } : {}),
734
+ });
735
+ continue;
736
+ }
737
+ effectiveEpochByEmitter.set(baseKey, resetEpoch);
738
+ const key =
739
+ `vfx.w-${planName(worldId(entry.world).toString(36), 8)}.` +
740
+ `a-${planName(attachmentId.toString(36), 8)}.` +
741
+ `r-${entry.runtime.renderGeneration}.p-${planName(Number(intent.player).toString(36), 16)}.` +
742
+ `${planName(intent.emitter.id)}.g-${resetEpoch}`;
743
+ let group = groups.get(key);
744
+ if (group === undefined) {
745
+ group = {
746
+ key,
747
+ worldIndex,
748
+ entry,
749
+ intents: [],
750
+ // Reset data is a per-frame boundary, not a property of the
751
+ // epoch. Once a reset-created group is warm, later ticks in
752
+ // the same epoch must retain its particle state.
753
+ initialReset: intent.reset,
754
+ };
755
+ groups.set(key, group);
756
+ } else if (
757
+ group.entry !== entry ||
758
+ group.intents[0]?.intent.emitter.wgsl !== intent.emitter.wgsl ||
759
+ group.intents[0]?.intent.emitter.capacity !== intent.emitter.capacity
760
+ ) {
761
+ return err(planFailure());
762
+ }
763
+ group.intents.push({
764
+ intent,
765
+ localToWorld,
766
+ stagePlan: stagePlan.value,
767
+ visible,
768
+ retained: false,
769
+ });
770
+ outcomes.push({
771
+ intent,
772
+ state: dispatches.length > 0 ? 'dispatched' : 'skipped',
773
+ ...(intent.reset ? { resetEpoch } : {}),
774
+ });
775
+ if (dispatches.length === 0) effectiveEpochByEmitter.delete(baseKey);
776
+ }
777
+ }
399
778
 
400
- const prefix = `vfx.w-${worldIndex}.i-${intentIndex}.${planName(intent.emitter.id)}`;
401
- const program = `${prefix}.compute-program`;
402
- const particles = `${prefix}.particles`;
403
- const runtime = `${prefix}.runtime`;
404
- const aliveIndices = `${prefix}.alive-indices`;
405
- const counters = `${prefix}.counters`;
406
- const indirect = `${prefix}.indirect`;
407
- const scratch = `${prefix}.scratch`;
408
- const sharedInstances = `${prefix}.shared-instances`;
409
- const eventInputs = `${prefix}.event-inputs`;
410
- const events = `${prefix}.events`;
411
- const bindings = `${prefix}.simulation-bindings`;
412
- const capacity = intent.emitter.capacity;
413
- const renderers = intent.emitter.renderers;
414
- const meshes = renderers.map((renderer) =>
415
- renderer.kind === 'mesh' ? options.mesh?.read(entry.world, renderer.mesh) : undefined,
779
+ // The fixed-tick queue is intentionally empty on most render frames.
780
+ // Keep the last submitted emitter state as a render-only group so its
781
+ // persistent particle/history buffers, projection and raster passes stay
782
+ // alive until the player is reset. A queued intent admitted above owns
783
+ // the frame; deferred/skipped work falls back to this retained lease.
784
+ for (const [worldIndex, entry] of frame.worlds.entries()) {
785
+ const renderGeneration = entry.runtime.renderGeneration ?? 0;
786
+ const attachmentId = runtimeId(entry.runtime);
787
+ let emitterEpochs = resetEpochByEmitter.get(entry.runtime);
788
+ if (emitterEpochs === undefined) {
789
+ emitterEpochs = new Map();
790
+ resetEpochByEmitter.set(entry.runtime, emitterEpochs);
791
+ }
792
+ for (const retained of entry.retained ?? []) {
793
+ const hasAdmittedGroup = [...groups.values()].some(
794
+ (group) =>
795
+ group.worldIndex === worldIndex &&
796
+ group.entry === entry &&
797
+ group.intents.some(
798
+ (planned) =>
799
+ planned.intent.player === retained.player &&
800
+ planned.intent.emitter.id === retained.emitter.id,
801
+ ),
802
+ );
803
+ if (hasAdmittedGroup) continue;
804
+ const stagePlan = validatedStagePlan(
805
+ retained.intent.emitter.reflection.stages,
806
+ retained.intent.instanceGeneration,
416
807
  );
417
- const indirectWords = new Uint32Array(Math.max(1, renderers.length) * 5);
808
+ if (!stagePlan.ok) return err(planFailure());
809
+ const baseKey =
810
+ `${worldId(entry.world)}:${renderGeneration}:` +
811
+ `${Number(retained.player)}:${retained.emitter.id}`;
812
+ const resetEpoch = emitterEpochs.get(baseKey) ?? 0;
813
+ const key =
814
+ `vfx.w-${planName(worldId(entry.world).toString(36), 8)}.` +
815
+ `a-${planName(attachmentId.toString(36), 8)}.` +
816
+ `r-${renderGeneration}.p-${planName(Number(retained.player).toString(36), 16)}.` +
817
+ `${planName(retained.emitter.id)}.g-${resetEpoch}`;
818
+ const visible =
819
+ retained.visible &&
820
+ (options.playerConsumption?.isEnabled(entry.world, retained.player) ?? true) &&
821
+ entry.runtime.isEmitterSessionEnabled(retained.player, retained.emitter.id);
822
+ groups.set(key, {
823
+ key,
824
+ worldIndex,
825
+ entry,
826
+ intents: [
827
+ {
828
+ intent: retained.intent,
829
+ localToWorld: retained.localToWorld,
830
+ stagePlan: stagePlan.value,
831
+ visible,
832
+ retained: true,
833
+ },
834
+ ],
835
+ initialReset: false,
836
+ });
837
+ }
838
+ }
418
839
 
419
- for (const [rendererIndex, renderer] of renderers.entries()) {
420
- const mesh = meshes[rendererIndex];
421
- const submesh =
422
- renderer.kind === 'mesh' ? mesh?.submeshes[renderer.submesh ?? 0] : undefined;
423
- if (renderer.kind === 'mesh' && submesh === undefined) return err(planFailure());
424
- if (
425
- (renderer.kind === 'ribbon' ||
426
- renderer.kind === 'trail' ||
427
- renderer.kind === 'beam') &&
428
- !createTopologyResourcePlan(renderer).ok
429
- ) {
430
- return err(planFailure());
840
+ for (const group of groups.values()) {
841
+ const first = group.intents[0];
842
+ if (first === undefined) continue;
843
+ const firstIntent = first.intent;
844
+ const prefix = group.key;
845
+ const program = `${prefix}.compute-program`;
846
+ const particles = `${prefix}.particles`;
847
+ const aliveIndices = `${prefix}.alive-indices`;
848
+ const counters = `${prefix}.counters`;
849
+ const indirect = `${prefix}.indirect`;
850
+ const scratch = `${prefix}.scratch`;
851
+ const sharedInstances = `${prefix}.shared-instances`;
852
+ // Binding 8 is the cooked event ABI: input and output records share one
853
+ // storage buffer. Keep the stable projection name for bind-group reuse.
854
+ const projectionEventInputs = `${prefix}.projection-event-inputs`;
855
+ const capacity = firstIntent.emitter.capacity;
856
+ const renderers = firstIntent.emitter.renderers;
857
+ const layout = firstIntent.emitter.reflection.layout;
858
+ const parameterBytes = layout?.parameters.size ?? 0;
859
+ const customStride = layout?.customLayout?.stride ?? 0;
860
+ const hasEvents =
861
+ (firstIntent.emitter.reflection.resources ?? []).includes('eventBuffer') ||
862
+ (firstIntent.emitter.reflection.entryPoints ?? []).includes('forgeax_vfx_event_main') ||
863
+ (firstIntent.emitter.reflection.bindings[0]?.entries ?? []).some(
864
+ (entry) => entry.binding === 8,
865
+ );
866
+ const diBindings: Record<number, string> = {};
867
+ const diResources: PlanResource[] = [];
868
+ const reflectedBindings = new Set(
869
+ firstIntent.emitter.reflection.bindings.flatMap((group) =>
870
+ group.entries.map((entry) => entry.binding),
871
+ ),
872
+ );
873
+ const requirements = (firstIntent.emitter.reflection.dataInterfaces ?? []).filter(
874
+ (requirement) => reflectedBindings.has(requirement.binding),
875
+ );
876
+ if (requirements.length > 0) {
877
+ let resolved = options.dataInterfaces?.resolve(
878
+ requirements,
879
+ firstIntent.instanceGeneration,
880
+ );
881
+ if (
882
+ (resolved === undefined ||
883
+ (!resolved.ok &&
884
+ resolved.error.code === 'vfx-data-interface-missing' &&
885
+ (resolved.error.detail.providerId === undefined ||
886
+ resolved.error.expected.includes('resident')))) &&
887
+ context.preparedDataInterfaces !== undefined
888
+ ) {
889
+ const fallback = preparedDataInterfaceResources(
890
+ requirements,
891
+ firstIntent.instanceGeneration,
892
+ context.preparedDataInterfaces,
893
+ );
894
+ if (fallback !== undefined) {
895
+ resolved = ok({
896
+ generation: firstIntent.instanceGeneration,
897
+ readiness: 'ready' as const,
898
+ resources: fallback,
899
+ });
431
900
  }
432
- indirectWords[rendererIndex * 5] =
433
- renderer.kind === 'mesh'
434
- ? mesh?.indices === undefined
435
- ? (submesh?.vertexCount ?? 0)
436
- : (submesh?.indexCount ?? 0)
437
- : 6;
438
- indirectWords[rendererIndex * 5 + 2] =
439
- renderer.kind === 'mesh' && mesh?.indices !== undefined
440
- ? (submesh?.indexOffset ?? 0)
441
- : 0;
442
901
  }
902
+ if (resolved === undefined || !resolved.ok) return err(planFailure());
903
+ for (const requirement of requirements) {
904
+ const resource = resolved.value.resources.find(
905
+ (candidate) => candidate.token === requirement.token,
906
+ );
907
+ const prepared = resource?.resource;
908
+ if (resource === undefined || prepared === undefined) return err(planFailure());
909
+ const name = `${prefix}.di-${planName(requirement.kind)}`;
910
+ if (prepared.kind === 'buffer') {
911
+ if (prepared.size === undefined || prepared.size <= 0) return err(planFailure());
912
+ diResources.push({
913
+ kind: 'prepared-gpu-resource',
914
+ name,
915
+ resource: {
916
+ kind: 'buffer',
917
+ value: prepared.value,
918
+ size: prepared.size,
919
+ usage: prepared.usage === 'storage' ? ['storage'] : ['uniform'],
920
+ },
921
+ });
922
+ } else {
923
+ const external =
924
+ prepared.kind === 'texture-view'
925
+ ? { kind: 'texture-view' as const, value: prepared.value }
926
+ : { kind: 'sampler' as const, value: prepared.value };
927
+ diResources.push({
928
+ kind: 'prepared-gpu-resource',
929
+ name,
930
+ resource: external,
931
+ ...(requirement.kind === 'scene-depth' && depthTarget !== undefined
932
+ ? { logicalTarget: depthTarget.name }
933
+ : {}),
934
+ });
935
+ }
936
+ diBindings[requirement.binding] = name;
937
+ }
938
+ }
939
+ const meshes = renderers.map((renderer) =>
940
+ renderer.kind === 'mesh'
941
+ ? options.mesh?.read(group.entry.world, renderer.mesh)
942
+ : undefined,
943
+ );
944
+ const indirectWords = new Uint32Array(Math.max(1, renderers.length) * 5);
945
+ for (const [rendererIndex, renderer] of renderers.entries()) {
946
+ const mesh = meshes[rendererIndex];
947
+ const submesh =
948
+ renderer.kind === 'mesh' ? mesh?.submeshes[renderer.submesh ?? 0] : undefined;
949
+ if (renderer.kind === 'mesh' && submesh === undefined) return err(planFailure());
950
+ if (
951
+ (renderer.kind === 'ribbon' || renderer.kind === 'trail' || renderer.kind === 'beam') &&
952
+ !createTopologyResourcePlan(renderer).ok
953
+ ) {
954
+ return err(planFailure());
955
+ }
956
+ indirectWords[rendererIndex * 5] =
957
+ renderer.kind === 'mesh'
958
+ ? mesh?.indices === undefined
959
+ ? (submesh?.vertexCount ?? 0)
960
+ : (submesh?.indexCount ?? 0)
961
+ : 6;
962
+ indirectWords[rendererIndex * 5 + 2] =
963
+ renderer.kind === 'mesh' && mesh?.indices !== undefined
964
+ ? (submesh?.indexOffset ?? 0)
965
+ : 0;
966
+ }
967
+ const scratchBytes = (capacity * 2 + Math.ceil(capacity / WORKGROUP_SIZE)) * 4;
968
+ const eventBufferBytes = Math.max(
969
+ 4,
970
+ eventInputCapacity(firstIntent.emitter) * VFX_EVENT_INPUT_BYTES +
971
+ eventCapacity(firstIntent.emitter) * VFX_EVENT_BYTES,
972
+ );
973
+ const particleBytes = VFX_PARTICLE_CORE_STRIDE;
974
+ const maxParticleInputBytes = Math.max(
975
+ 0,
976
+ ...renderers.map((renderer) => (renderer.materialInputs?.length ?? 0) * 16),
977
+ );
978
+ resources.push(
979
+ ...diResources,
980
+ {
981
+ kind: 'compute-program',
982
+ name: program,
983
+ program: {
984
+ wgsl: firstIntent.emitter.wgsl,
985
+ entryPoints: firstIntent.emitter.reflection.entryPoints,
986
+ bindings: firstIntent.emitter.reflection.bindings,
987
+ },
988
+ },
989
+ {
990
+ kind: 'buffer',
991
+ name: particles,
992
+ size: capacity * particleBytes,
993
+ usage: ['storage'],
994
+ ...(group.initialReset ? { data: resetData(capacity * particleBytes) } : {}),
995
+ },
996
+ ...(parameterBytes === 0
997
+ ? []
998
+ : [
999
+ {
1000
+ kind: 'buffer' as const,
1001
+ name: `${prefix}.parameters`,
1002
+ size: parameterBytes,
1003
+ usage: ['uniform'] as const,
1004
+ data: firstIntent.parameterBlock,
1005
+ },
1006
+ ]),
1007
+ ...(customStride === 0
1008
+ ? []
1009
+ : [
1010
+ {
1011
+ kind: 'buffer' as const,
1012
+ name: `${prefix}.custom`,
1013
+ size: capacity * customStride,
1014
+ usage: ['storage'] as const,
1015
+ ...(group.initialReset ? { data: resetData(capacity * customStride) } : {}),
1016
+ },
1017
+ ]),
1018
+ { kind: 'buffer', name: aliveIndices, size: capacity * 4, usage: ['storage'] },
1019
+ {
1020
+ kind: 'buffer',
1021
+ name: counters,
1022
+ size: COUNTERS_BYTES,
1023
+ usage: ['storage'],
1024
+ ...(group.initialReset ? { data: resetData(COUNTERS_BYTES) } : {}),
1025
+ },
1026
+ {
1027
+ kind: 'buffer',
1028
+ name: indirect,
1029
+ size: indirectWords.byteLength,
1030
+ usage: ['storage', 'indirect'],
1031
+ ...(group.initialReset ? { data: indirectWords } : {}),
1032
+ },
1033
+ {
1034
+ kind: 'buffer',
1035
+ name: scratch,
1036
+ size: scratchBytes,
1037
+ usage: ['storage'],
1038
+ ...(group.initialReset ? { data: resetData(scratchBytes) } : {}),
1039
+ },
1040
+ {
1041
+ kind: 'buffer',
1042
+ name: sharedInstances,
1043
+ size:
1044
+ capacity *
1045
+ (Math.max(BILLBOARD_INSTANCE_BYTES, MESH_INSTANCE_BYTES) + maxParticleInputBytes),
1046
+ usage: ['storage', 'vertex'],
1047
+ },
1048
+ ...(hasEvents
1049
+ ? [
1050
+ {
1051
+ kind: 'buffer' as const,
1052
+ name: projectionEventInputs,
1053
+ size: eventBufferBytes,
1054
+ usage: ['storage'] as const,
1055
+ data: encodeEventBuffer(firstIntent),
1056
+ },
1057
+ ]
1058
+ : []),
1059
+ );
1060
+ const entryPoints = new Set(firstIntent.emitter.reflection.entryPoints);
443
1061
 
444
- const scratchBytes = (capacity * 2 + Math.ceil(capacity / WORKGROUP_SIZE)) * 4;
445
- const eventInputBytes = Math.max(
446
- 4,
447
- eventInputCapacity(intent.emitter) * VFX_EVENT_INPUT_BYTES,
448
- );
449
- const eventBytes = Math.max(4, eventCapacity(intent.emitter) * VFX_EVENT_BYTES);
1062
+ // Each fixed tick gets only its own immutable runtime/event input and
1063
+ // bindings. The particle/counter/scratch/event/history resources are
1064
+ // shared, so dispatch order advances one emitter state rather than
1065
+ // simulating independent q-0/q-1 copies.
1066
+ for (const [tickIndex, planned] of group.intents.entries()) {
1067
+ if (planned.retained) continue;
1068
+ const tickPrefix = `${prefix}.tick-${tickIndex}`;
1069
+ const tickRuntime = `${tickPrefix}.runtime`;
1070
+ const tickEventInputs = `${tickPrefix}.event-inputs`;
1071
+ const tickBindings = `${tickPrefix}.simulation-bindings`;
450
1072
  resources.push(
451
- {
452
- kind: 'compute-program',
453
- name: program,
454
- program: {
455
- wgsl: intent.emitter.wgsl,
456
- entryPoints: intent.emitter.reflection.entryPoints,
457
- bindings: intent.emitter.reflection.bindings,
458
- },
459
- },
460
1073
  {
461
1074
  kind: 'buffer',
462
- name: particles,
463
- size: capacity * PARTICLE_BYTES,
464
- usage: ['storage'],
465
- ...(intent.reset ? { data: resetData(capacity * PARTICLE_BYTES) } : {}),
466
- },
467
- { kind: 'buffer', name: aliveIndices, size: capacity * 4, usage: ['storage'] },
468
- {
469
- kind: 'buffer',
470
- name: counters,
471
- size: COUNTERS_BYTES,
472
- usage: ['storage'],
473
- ...(intent.reset ? { data: resetData(COUNTERS_BYTES) } : {}),
474
- },
475
- {
476
- kind: 'buffer',
477
- name: indirect,
478
- size: indirectWords.byteLength,
479
- usage: ['storage', 'indirect'],
480
- data: indirectWords,
481
- },
482
- {
483
- kind: 'buffer',
484
- name: scratch,
485
- size: scratchBytes,
486
- usage: ['storage'],
487
- ...(intent.reset ? { data: resetData(scratchBytes) } : {}),
488
- },
489
- {
490
- kind: 'buffer',
491
- name: sharedInstances,
492
- size: capacity * Math.max(BILLBOARD_INSTANCE_BYTES, MESH_INSTANCE_BYTES),
493
- usage: ['storage', 'vertex'],
494
- },
495
- {
496
- kind: 'buffer',
497
- name: eventInputs,
498
- size: eventInputBytes,
499
- usage: ['storage'],
500
- data: encodeEventInputs(intent),
501
- },
502
- {
503
- kind: 'buffer',
504
- name: events,
505
- size: eventBytes,
506
- usage: ['storage'],
507
- ...(intent.reset ? { data: resetData(eventBytes) } : {}),
508
- },
509
- {
510
- kind: 'buffer',
511
- name: runtime,
1075
+ name: tickRuntime,
512
1076
  size: RUNTIME_BYTES,
513
1077
  usage: ['uniform'],
514
- data: runtimeData(intent, entry.camera, undefined, localToWorld),
1078
+ data: runtimeData(
1079
+ planned.intent,
1080
+ group.entry.camera,
1081
+ undefined,
1082
+ planned.localToWorld,
1083
+ ),
515
1084
  },
1085
+ ...(hasEvents
1086
+ ? [
1087
+ {
1088
+ kind: 'buffer' as const,
1089
+ name: tickEventInputs,
1090
+ size: eventBufferBytes,
1091
+ usage: ['storage'] as const,
1092
+ data: encodeEventBuffer(planned.intent),
1093
+ },
1094
+ ]
1095
+ : []),
1096
+ ...(parameterBytes === 0
1097
+ ? []
1098
+ : [
1099
+ {
1100
+ kind: 'buffer' as const,
1101
+ name: `${tickPrefix}.parameters`,
1102
+ size: parameterBytes,
1103
+ usage: ['uniform'] as const,
1104
+ data: planned.intent.parameterBlock,
1105
+ },
1106
+ ]),
516
1107
  {
517
1108
  kind: 'compute-bindings',
518
- name: bindings,
1109
+ name: tickBindings,
519
1110
  program,
520
- entries: computeBindingEntries(intent, {
1111
+ entries: computeBindingEntries(planned.intent, {
521
1112
  0: particles,
522
- 1: runtime,
1113
+ 1: tickRuntime,
523
1114
  2: aliveIndices,
524
1115
  3: counters,
525
1116
  4: indirect,
526
1117
  5: scratch,
527
1118
  6: sharedInstances,
528
- 8: eventInputs,
529
- 9: events,
1119
+ ...(hasEvents ? { 8: tickEventInputs } : {}),
1120
+ ...(parameterBytes === 0 ? {} : { 10: `${tickPrefix}.parameters` }),
1121
+ ...(customStride === 0 ? {} : { 11: `${prefix}.custom` }),
1122
+ ...diBindings,
530
1123
  }),
531
1124
  },
532
1125
  );
533
-
534
- const entryPoints = new Set(intent.emitter.reflection.entryPoints);
535
- const dispatches = simulationDispatches(intent, stagePlan.value).filter((dispatch) =>
536
- entryPoints.has(dispatch.entryPoint),
1126
+ const dispatches = simulationDispatches(planned.intent, planned.stagePlan).filter(
1127
+ (dispatch) => entryPoints.has(dispatch.entryPoint),
537
1128
  );
538
- if (dispatches.length > 0) {
539
- passes.push({
540
- kind: 'compute',
541
- name: `${prefix}.simulate`,
542
- program,
543
- bindings,
544
- dispatches,
545
- });
546
- dispatchedIntents.add(intent);
547
- }
1129
+ if (dispatches.length === 0) continue;
1130
+ passes.push({
1131
+ kind: 'compute',
1132
+ name: `${tickPrefix}.simulate`,
1133
+ program,
1134
+ bindings: tickBindings,
1135
+ dispatches,
1136
+ });
548
1137
 
1138
+ // Trail history is part of the fixed-step simulation state. It must
1139
+ // be written immediately after each tick, before the next tick can
1140
+ // mutate particles; projecting history once after the whole batch
1141
+ // would overwrite only the latest ring slot.
549
1142
  for (const [rendererIndex, renderer] of renderers.entries()) {
1143
+ if (renderer.enabled === false) continue;
1144
+ if (renderer.kind !== 'trail') continue;
1145
+ if (!entryPoints.has('forgeax_vfx_trail_history_main')) continue;
550
1146
  const rendererPrefix = `${prefix}.renderer-${rendererIndex}`;
551
- const isBillboard = renderer.kind === 'billboard';
552
- const isTopology =
553
- renderer.kind === 'ribbon' || renderer.kind === 'trail' || renderer.kind === 'beam';
554
- const topologyPlan = isTopology ? createTopologyResourcePlan(renderer) : undefined;
555
- if (topologyPlan !== undefined && !topologyPlan.ok) return err(planFailure());
556
- const material = options.material?.read(entry.world, renderer.material);
557
- const materialPass = particleMaterialPass(renderer.kind, material);
558
- const mesh = meshes[rendererIndex];
559
- const submesh =
560
- renderer.kind === 'mesh' ? mesh?.submeshes[renderer.submesh ?? 0] : undefined;
561
- const indexFormat = mesh?.indices instanceof Uint32Array ? 'uint32' : 'uint16';
562
- const sceneDepthBinding = isBillboard
563
- ? particleMaterialSceneDepthBinding(
564
- context.materialShaderBindingContract?.(materialPass.shader) ??
565
- (materialPass.shader === PARTICLE_SHADER_IDENTIFIERS.billboard
566
- ? 'view-and-scene-depth'
567
- : undefined),
568
- )
569
- : undefined;
570
- const instances = `${rendererPrefix}.instances`;
571
1147
  const history = `${rendererPrefix}.history`;
572
- const projectionRuntime = `${rendererPrefix}.runtime`;
573
- const projectionBindings = `${rendererPrefix}.compute-bindings`;
574
- const vertexLayout = isBillboard
575
- ? RENDER_FEATURE_VERTEX_LAYOUTS.billboardMaterialInstance
576
- : isTopology
577
- ? RENDER_FEATURE_VERTEX_LAYOUTS.topologySegmentInstance
578
- : RENDER_FEATURE_VERTEX_LAYOUTS.meshGeometryMaterialInstance;
579
- const instanceBytes = isTopology
580
- ? (topologyPlan?.value.vertexBytes ?? 16)
581
- : capacity * (isBillboard ? BILLBOARD_INSTANCE_BYTES : MESH_INSTANCE_BYTES);
582
- const historyBytes =
583
- renderer.kind === 'trail'
584
- ? Math.max(16, renderer.capacity * renderer.historyLength * 16)
585
- : 16;
586
-
1148
+ const historyRuntime = `${tickPrefix}.renderer-${rendererIndex}.history-runtime`;
1149
+ const historyBindings = `${tickPrefix}.renderer-${rendererIndex}.history-bindings`;
587
1150
  resources.push(
588
1151
  {
589
1152
  kind: 'buffer',
590
- name: instances,
591
- size: instanceBytes,
592
- usage: ['storage', 'vertex'],
593
- },
594
- {
595
- kind: 'buffer',
596
- name: history,
597
- size: historyBytes,
598
- usage: ['storage'],
599
- ...(intent.reset ? { data: resetData(historyBytes) } : {}),
600
- },
601
- {
602
- kind: 'buffer',
603
- name: projectionRuntime,
1153
+ name: historyRuntime,
604
1154
  size: RUNTIME_BYTES,
605
1155
  usage: ['uniform'],
606
1156
  data: runtimeData(
607
- { ...intent, fixedDelta: 0, spawnCount: 0 },
608
- entry.camera,
609
- material,
610
- localToWorld,
1157
+ planned.intent,
1158
+ group.entry.camera,
1159
+ undefined,
1160
+ planned.localToWorld,
611
1161
  renderer,
612
1162
  rendererIndex,
613
1163
  ),
614
1164
  },
615
1165
  {
616
1166
  kind: 'compute-bindings',
617
- name: projectionBindings,
1167
+ name: historyBindings,
618
1168
  program,
619
- entries: computeBindingEntries(intent, {
1169
+ entries: computeBindingEntries(planned.intent, {
620
1170
  0: particles,
621
- 1: projectionRuntime,
1171
+ 1: historyRuntime,
622
1172
  2: aliveIndices,
623
1173
  3: counters,
624
1174
  4: indirect,
625
1175
  5: history,
626
- 6: instances,
627
- 8: eventInputs,
628
- 9: events,
1176
+ 6: sharedInstances,
1177
+ ...(hasEvents ? { 8: tickEventInputs } : {}),
1178
+ ...(parameterBytes === 0 ? {} : { 10: `${tickPrefix}.parameters` }),
1179
+ ...(customStride === 0 ? {} : { 11: `${prefix}.custom` }),
1180
+ ...diBindings,
629
1181
  }),
630
1182
  },
631
1183
  );
1184
+ passes.push({
1185
+ kind: 'compute',
1186
+ name: `${historyBindings}.write`,
1187
+ program,
1188
+ bindings: historyBindings,
1189
+ dispatches: [
1190
+ {
1191
+ kind: 'direct',
1192
+ entryPoint: 'forgeax_vfx_trail_history_main',
1193
+ workgroups: [Math.max(1, Math.ceil(renderer.capacity / WORKGROUP_SIZE))],
1194
+ },
1195
+ ],
1196
+ });
1197
+ }
1198
+ }
632
1199
 
633
- const projectionDispatches: Extract<
634
- PlanPass,
635
- { readonly kind: 'compute' }
636
- >['dispatches'][number][] = [];
637
- const pushProjection = (entryPoint: string, workgroups: number): void => {
638
- if (!entryPoints.has(entryPoint)) return;
639
- projectionDispatches.push({
640
- kind: 'direct',
641
- entryPoint,
642
- workgroups: [Math.max(1, workgroups)],
643
- });
644
- };
645
- if (isBillboard && renderer.sorting === 'back-to-front') {
646
- pushProjection('forgeax_vfx_sort_main', 1);
647
- }
648
- if (renderer.kind === 'trail') {
649
- pushProjection(
650
- 'forgeax_vfx_trail_history_main',
651
- Math.ceil(renderer.capacity / WORKGROUP_SIZE),
652
- );
653
- }
654
- const projectionCount =
655
- renderer.kind === 'trail'
656
- ? renderer.capacity * Math.max(1, renderer.historyLength - 1)
657
- : isTopology
658
- ? renderer.capacity
659
- : capacity;
660
- pushProjection(
661
- renderer.kind === 'billboard'
662
- ? 'forgeax_vfx_billboard_main'
663
- : renderer.kind === 'mesh'
664
- ? 'forgeax_vfx_mesh_main'
665
- : `forgeax_vfx_${renderer.kind}_main`,
666
- Math.ceil(projectionCount / WORKGROUP_SIZE),
667
- );
668
- if (projectionDispatches.length > 0) {
669
- passes.push({
670
- kind: 'compute',
671
- name: `${rendererPrefix}.project`,
672
- program,
673
- bindings: projectionBindings,
674
- dispatches: projectionDispatches,
675
- });
676
- }
1200
+ const latest = group.intents.at(-1) ?? first;
1201
+ for (const [rendererIndex, renderer] of renderers.entries()) {
1202
+ // Disabled entries remain in the cooked renderer index space but do
1203
+ // not acquire projection, graphics, or shadow resources.
1204
+ if (renderer.enabled === false) continue;
1205
+ const rendererPrefix = `${prefix}.renderer-${rendererIndex}`;
1206
+ const isBillboard = renderer.kind === 'billboard';
1207
+ const isTopology =
1208
+ renderer.kind === 'ribbon' || renderer.kind === 'trail' || renderer.kind === 'beam';
1209
+ const topologyPlan = isTopology ? createTopologyResourcePlan(renderer) : undefined;
1210
+ if (topologyPlan !== undefined && !topologyPlan.ok) return err(planFailure());
1211
+ const material = options.material?.read(group.entry.world, renderer.material);
1212
+ const reflectedRenderer = firstIntent.emitter.reflection.renderers?.[rendererIndex];
1213
+ const preparedInputs = prepareParticleMaterialInputs(
1214
+ renderer,
1215
+ material,
1216
+ reflectedRenderer?.materialInputDefinitions,
1217
+ );
1218
+ if (!preparedInputs.ok) return err(planFailure());
1219
+ const hasParticleInputs = preparedInputs.value.lanes > 0;
1220
+ const materialPass = particleMaterialPass(renderer.kind, material, hasParticleInputs);
1221
+ const mesh = meshes[rendererIndex];
1222
+ const submesh =
1223
+ renderer.kind === 'mesh' ? mesh?.submeshes[renderer.submesh ?? 0] : undefined;
1224
+ const indexFormat = mesh?.indices instanceof Uint32Array ? 'uint32' : 'uint16';
1225
+ const sceneDepthBinding = particleMaterialSceneDepthBinding(
1226
+ context.materialShaderBindingContract?.(materialPass.shader) ??
1227
+ (materialPass.shader === PARTICLE_SHADER_IDENTIFIERS.billboard
1228
+ ? 'view-and-scene-depth'
1229
+ : undefined),
1230
+ );
1231
+ const instances = `${rendererPrefix}.instances`;
1232
+ const history = `${rendererPrefix}.history`;
1233
+ const projectionRuntime = `${rendererPrefix}.runtime`;
1234
+ const projectionBindings = `${rendererPrefix}.compute-bindings`;
1235
+ const vertexLayout = isBillboard
1236
+ ? hasParticleInputs
1237
+ ? RENDER_FEATURE_VERTEX_LAYOUTS.billboardMaterialInputInstance
1238
+ : RENDER_FEATURE_VERTEX_LAYOUTS.billboardMaterialInstance
1239
+ : isTopology
1240
+ ? hasParticleInputs
1241
+ ? RENDER_FEATURE_VERTEX_LAYOUTS.topologySegmentMaterialInputInstance
1242
+ : RENDER_FEATURE_VERTEX_LAYOUTS.topologySegmentInstance
1243
+ : hasParticleInputs
1244
+ ? RENDER_FEATURE_VERTEX_LAYOUTS.meshGeometryMaterialInputInstance
1245
+ : RENDER_FEATURE_VERTEX_LAYOUTS.meshGeometryMaterialInstance;
1246
+ const inputBytes = preparedInputs.value.stride;
1247
+ const instanceBytes = isTopology
1248
+ ? (() => {
1249
+ const vertexCount = Math.max(
1250
+ 1,
1251
+ Math.floor((topologyPlan?.value.vertexBytes ?? 16) / 48),
1252
+ );
1253
+ return vertexCount * (48 + inputBytes);
1254
+ })()
1255
+ : capacity *
1256
+ ((isBillboard ? BILLBOARD_INSTANCE_BYTES : MESH_INSTANCE_BYTES) + inputBytes);
1257
+ const historyBytes =
1258
+ renderer.kind === 'trail'
1259
+ ? capacity * (Math.max(2, renderer.historyLength) + 1) * 16
1260
+ : 16;
1261
+ resources.push(
1262
+ {
1263
+ kind: 'buffer',
1264
+ name: instances,
1265
+ size: instanceBytes,
1266
+ usage: ['storage', 'vertex'],
1267
+ },
1268
+ {
1269
+ kind: 'buffer',
1270
+ name: history,
1271
+ size: historyBytes,
1272
+ usage: ['storage'],
1273
+ ...(group.initialReset ? { data: resetData(historyBytes) } : {}),
1274
+ },
1275
+ );
1276
+ // `simulationWhenCulled: continue` still owns persistent simulation
1277
+ // and (for trails) history writes, but it must not project or draw
1278
+ // hidden state. Keep the two persistent storage declarations above;
1279
+ // the remaining projection/raster resources are draw-only.
1280
+ const castsShadow = renderer.kind === 'mesh' && renderer.castShadows;
1281
+ if (!latest.visible && !castsShadow) continue;
1282
+ resources.push(
1283
+ {
1284
+ kind: 'buffer',
1285
+ name: projectionRuntime,
1286
+ size: RUNTIME_BYTES,
1287
+ usage: ['uniform'],
1288
+ data: runtimeData(
1289
+ { ...latest.intent, fixedDelta: 0, spawnCount: 0 },
1290
+ group.entry.camera,
1291
+ material,
1292
+ latest.localToWorld,
1293
+ renderer,
1294
+ rendererIndex,
1295
+ preparedInputs.value.lanes,
1296
+ renderer.kind === 'mesh' && submesh !== undefined
1297
+ ? {
1298
+ count: mesh?.indices === undefined ? submesh.vertexCount : submesh.indexCount,
1299
+ firstIndex: mesh?.indices === undefined ? 0 : submesh.indexOffset,
1300
+ }
1301
+ : undefined,
1302
+ ),
1303
+ },
1304
+ {
1305
+ kind: 'compute-bindings',
1306
+ name: projectionBindings,
1307
+ program,
1308
+ entries: computeBindingEntries(latest.intent, {
1309
+ 0: particles,
1310
+ 1: projectionRuntime,
1311
+ 2: aliveIndices,
1312
+ 3: counters,
1313
+ 4: indirect,
1314
+ 5: history,
1315
+ 6: instances,
1316
+ ...(hasEvents ? { 8: projectionEventInputs } : {}),
1317
+ ...(parameterBytes === 0 ? {} : { 10: `${prefix}.parameters` }),
1318
+ ...(customStride === 0 ? {} : { 11: `${prefix}.custom` }),
1319
+ ...diBindings,
1320
+ }),
1321
+ },
1322
+ );
1323
+ const projectionDispatches: Extract<
1324
+ PlanPass,
1325
+ { readonly kind: 'compute' }
1326
+ >['dispatches'][number][] = [];
1327
+ const pushProjection = (entryPoint: string, workgroups: number): void => {
1328
+ if (!entryPoints.has(entryPoint)) return;
1329
+ projectionDispatches.push({
1330
+ kind: 'direct',
1331
+ entryPoint,
1332
+ workgroups: [Math.max(1, workgroups)],
1333
+ });
1334
+ };
1335
+ if (
1336
+ isBillboard &&
1337
+ (renderer.sorting === 'view-depth' ||
1338
+ renderer.sorting === 'view-distance' ||
1339
+ renderer.sorting === 'custom-ascending' ||
1340
+ renderer.sorting === 'custom-descending')
1341
+ ) {
1342
+ pushProjection('forgeax_vfx_sort_main', 1);
1343
+ }
1344
+ if (renderer.kind === 'trail') pushProjection('forgeax_vfx_trail_offsets_main', 1);
1345
+ const projectionCount =
1346
+ renderer.kind === 'trail'
1347
+ ? renderer.capacity * Math.max(1, renderer.historyLength - 1)
1348
+ : isTopology
1349
+ ? renderer.capacity
1350
+ : capacity;
1351
+ pushProjection(
1352
+ renderer.kind === 'billboard'
1353
+ ? 'forgeax_vfx_billboard_main'
1354
+ : renderer.kind === 'mesh'
1355
+ ? 'forgeax_vfx_mesh_main'
1356
+ : `forgeax_vfx_${renderer.kind}_main`,
1357
+ Math.ceil(projectionCount / WORKGROUP_SIZE),
1358
+ );
1359
+ if (projectionDispatches.length > 0) {
1360
+ passes.push({
1361
+ kind: 'compute',
1362
+ name: `${rendererPrefix}.project`,
1363
+ program,
1364
+ bindings: projectionBindings,
1365
+ dispatches: projectionDispatches,
1366
+ });
1367
+ }
677
1368
 
678
- const graphicsProgram = `${rendererPrefix}.graphics-program`;
679
- const graphicsBindings = `${rendererPrefix}.graphics-bindings`;
680
- const vertexData = `${rendererPrefix}.vertex-data`;
681
- const defaultRenderState = particleRendererRenderState(
682
- renderer.kind,
683
- renderer.kind === 'billboard' ? renderer.blend : undefined,
684
- materialPass.renderState,
1369
+ const graphicsProgram = `${rendererPrefix}.graphics-program`;
1370
+ const graphicsBindings = `${rendererPrefix}.graphics-bindings`;
1371
+ const vertexData = `${rendererPrefix}.vertex-data`;
1372
+ const defaultRenderState = particleRendererRenderState(
1373
+ renderer.kind,
1374
+ renderer.kind === 'billboard' ? renderer.blend : undefined,
1375
+ materialPass.renderState,
1376
+ );
1377
+ const renderState =
1378
+ isBillboard && depthTarget !== undefined
1379
+ ? { ...(defaultRenderState ?? {}), depthWriteEnabled: false }
1380
+ : defaultRenderState;
1381
+ resources.push(
1382
+ {
1383
+ kind: 'graphics-program',
1384
+ name: graphicsProgram,
1385
+ program: {
1386
+ shader: materialPass.shader,
1387
+ vertexLayout,
1388
+ ...(hasParticleInputs ? { particleInputLanes: preparedInputs.value.lanes } : {}),
1389
+ colorFormats: [colorTarget?.format ?? 'rgba8unorm-srgb'],
1390
+ ...(depthTarget === undefined ? {} : { depthFormat: depthTarget.format }),
1391
+ sampleCount: colorTarget?.sampleCount ?? 1,
1392
+ topology: submesh?.topology ?? 'triangle-list',
1393
+ ...(mesh?.indices === undefined ? {} : { indexFormat }),
1394
+ ...(renderState === undefined ? {} : { renderState }),
1395
+ },
1396
+ },
1397
+ {
1398
+ kind: 'graphics-bindings',
1399
+ name: graphicsBindings,
1400
+ program: graphicsProgram,
1401
+ values: {
1402
+ group: 0,
1403
+ runtime: projectionRuntime,
1404
+ instances,
1405
+ ...(sceneDepthBinding === undefined ? {} : { sceneDepthBinding }),
1406
+ },
1407
+ ...(sceneDepthBinding !== undefined && depthTarget !== undefined
1408
+ ? { logicalTargets: { sceneDepth: depthTarget.name } }
1409
+ : {}),
1410
+ },
1411
+ { kind: 'vertex-data', name: vertexData, layout: vertexLayout, buffer: instances },
1412
+ );
1413
+ const drawBindings = [graphicsBindings];
1414
+ if (
1415
+ particleMaterialUsesBindings(material) ||
1416
+ materialPass.shader === PARTICLE_SHADER_IDENTIFIERS.mesh ||
1417
+ materialPass.shader === PARTICLE_INPUT_SHADER_IDENTIFIERS.mesh
1418
+ ) {
1419
+ const materialBindings = `${rendererPrefix}.material-bindings.w-${group.worldIndex}`;
1420
+ resources.push({
1421
+ kind: 'graphics-bindings',
1422
+ name: materialBindings,
1423
+ program: graphicsProgram,
1424
+ values: {
1425
+ group: 1,
1426
+ material: { world: group.worldIndex, guid: renderer.material },
1427
+ },
1428
+ });
1429
+ drawBindings.push(materialBindings);
1430
+ }
1431
+ const vertexBindings: { readonly slot: number; readonly resource: string }[] = [];
1432
+ let indexData:
1433
+ | { readonly resource: string; readonly format: 'uint16' | 'uint32' }
1434
+ | undefined;
1435
+ if (renderer.kind === 'mesh') {
1436
+ if (mesh === undefined) return err(planFailure());
1437
+ const geometryBuffer = `${rendererPrefix}.geometry-buffer`;
1438
+ const geometry = `${rendererPrefix}.geometry`;
1439
+ const geometryData = canonicalMeshVertices(mesh);
1440
+ resources.push(
1441
+ {
1442
+ kind: 'buffer',
1443
+ name: geometryBuffer,
1444
+ size: geometryData.byteLength,
1445
+ usage: ['vertex'],
1446
+ data: geometryData,
1447
+ },
1448
+ {
1449
+ kind: 'vertex-data',
1450
+ name: geometry,
1451
+ layout: vertexLayout,
1452
+ buffer: geometryBuffer,
1453
+ },
685
1454
  );
686
- const renderState =
687
- isBillboard && depthTarget !== undefined
688
- ? { ...(defaultRenderState ?? {}), depthWriteEnabled: false }
689
- : defaultRenderState;
1455
+ vertexBindings.push({ slot: 0, resource: geometry }, { slot: 1, resource: vertexData });
1456
+ if (mesh.indices !== undefined) {
1457
+ const indexBuffer = `${rendererPrefix}.index-buffer`;
1458
+ const indices = `${rendererPrefix}.indices`;
1459
+ resources.push(
1460
+ {
1461
+ kind: 'buffer',
1462
+ name: indexBuffer,
1463
+ size: mesh.indices.byteLength,
1464
+ usage: ['index'],
1465
+ data: mesh.indices,
1466
+ },
1467
+ {
1468
+ kind: 'index-data',
1469
+ name: indices,
1470
+ format: indexFormat,
1471
+ buffer: indexBuffer,
1472
+ },
1473
+ );
1474
+ indexData = { resource: indices, format: indexFormat };
1475
+ }
1476
+ } else {
1477
+ vertexBindings.push({ slot: 0, resource: vertexData });
1478
+ }
1479
+ if (castsShadow) {
1480
+ const shadowProgram = `${rendererPrefix}.shadow-program`;
1481
+ const shadowBindings = `${rendererPrefix}.shadow-bindings`;
690
1482
  resources.push(
691
1483
  {
692
1484
  kind: 'graphics-program',
693
- name: graphicsProgram,
1485
+ name: shadowProgram,
694
1486
  program: {
695
- shader: materialPass.shader,
1487
+ shader: 'forgeax::vfx-render.particles.mesh-shadow',
696
1488
  vertexLayout,
697
- colorFormats: [colorTarget?.format ?? 'rgba8unorm-srgb'],
698
- ...(depthTarget === undefined ? {} : { depthFormat: depthTarget.format }),
699
- sampleCount: colorTarget?.sampleCount ?? 1,
1489
+ ...(hasParticleInputs ? { particleInputLanes: preparedInputs.value.lanes } : {}),
1490
+ colorFormats: [],
1491
+ depthFormat: 'depth32float',
700
1492
  topology: submesh?.topology ?? 'triangle-list',
701
1493
  ...(mesh?.indices === undefined ? {} : { indexFormat }),
702
- ...(renderState === undefined ? {} : { renderState }),
1494
+ renderState: { depthWriteEnabled: true, cullMode: 'none' },
703
1495
  },
704
1496
  },
705
1497
  {
706
1498
  kind: 'graphics-bindings',
707
- name: graphicsBindings,
708
- program: graphicsProgram,
709
- values: {
710
- group: 0,
711
- runtime: projectionRuntime,
712
- instances,
713
- ...(sceneDepthBinding === undefined ? {} : { sceneDepthBinding }),
714
- },
715
- ...(sceneDepthBinding !== undefined && depthTarget !== undefined
716
- ? { logicalTargets: { sceneDepth: depthTarget.name } }
717
- : {}),
1499
+ name: shadowBindings,
1500
+ program: shadowProgram,
1501
+ values: { group: 0, runtime: projectionRuntime, instances },
718
1502
  },
719
- { kind: 'vertex-data', name: vertexData, layout: vertexLayout, buffer: instances },
720
1503
  );
721
- const drawBindings = [graphicsBindings];
722
- if (particleMaterialUsesBindings(material)) {
723
- const materialBindings = `${rendererPrefix}.material-bindings`;
724
- resources.push({
725
- kind: 'graphics-bindings',
726
- name: materialBindings,
727
- program: graphicsProgram,
728
- values: {
729
- group: 1,
730
- material: { world: worldIndex, guid: renderer.material },
731
- },
732
- });
733
- drawBindings.push(materialBindings);
734
- }
735
-
736
- const vertexBindings: { readonly slot: number; readonly resource: string }[] = [];
737
- let indexData:
738
- | { readonly resource: string; readonly format: 'uint16' | 'uint32' }
739
- | undefined;
740
- if (renderer.kind === 'mesh') {
741
- if (mesh === undefined) return err(planFailure());
742
- const geometryBuffer = `${rendererPrefix}.geometry-buffer`;
743
- const geometry = `${rendererPrefix}.geometry`;
744
- const geometryData = canonicalMeshVertices(mesh);
745
- resources.push(
746
- {
747
- kind: 'buffer',
748
- name: geometryBuffer,
749
- size: geometryData.byteLength,
750
- usage: ['vertex'],
751
- data: geometryData,
752
- },
1504
+ passes.push({
1505
+ kind: 'shadow-caster',
1506
+ name: `${rendererPrefix}.shadow`,
1507
+ draws: [
753
1508
  {
754
- kind: 'vertex-data',
755
- name: geometry,
756
- layout: vertexLayout,
757
- buffer: geometryBuffer,
758
- },
759
- );
760
- vertexBindings.push(
761
- { slot: 0, resource: geometry },
762
- { slot: 1, resource: vertexData },
763
- );
764
- if (mesh.indices !== undefined) {
765
- const indexBuffer = `${rendererPrefix}.index-buffer`;
766
- const indices = `${rendererPrefix}.indices`;
767
- resources.push(
768
- {
769
- kind: 'buffer',
770
- name: indexBuffer,
771
- size: mesh.indices.byteLength,
772
- usage: ['index'],
773
- data: mesh.indices,
774
- },
775
- {
776
- kind: 'index-data',
777
- name: indices,
778
- format: indexFormat,
779
- buffer: indexBuffer,
1509
+ program: shadowProgram,
1510
+ bindings: [shadowBindings],
1511
+ vertexData: vertexBindings,
1512
+ ...(indexData === undefined ? {} : { indexData }),
1513
+ draw: {
1514
+ kind: indexData === undefined ? 'draw-indirect' : 'draw-indexed-indirect',
1515
+ resource: indirect,
1516
+ offset: rendererIndex * 20,
780
1517
  },
781
- );
782
- indexData = { resource: indices, format: indexFormat };
783
- }
784
- } else {
785
- vertexBindings.push({ slot: 0, resource: vertexData });
786
- }
787
-
1518
+ },
1519
+ ],
1520
+ });
1521
+ }
1522
+ if (latest.visible)
788
1523
  passes.push({
789
1524
  kind: 'raster',
790
1525
  name: `${rendererPrefix}.raster`,
@@ -821,19 +1556,61 @@ export function gpuParticleRenderFeature(
821
1556
  },
822
1557
  ],
823
1558
  });
824
- }
825
1559
  }
826
1560
  }
827
- for (const entry of frame.worlds) {
828
- for (const intent of entry.intents) {
829
- if (dispatchedIntents.has(intent)) {
830
- entry.runtime.markEventDispatched(intent.player, intent.eventCounters);
1561
+
1562
+ pendingFrames.set(
1563
+ frame.frameNumber,
1564
+ frame.worlds.map((entry) => ({
1565
+ world: entry.world,
1566
+ runtime: entry.runtime,
1567
+ outcomes:
1568
+ outcomesByEntry.get(entry) ??
1569
+ entry.intents.map((intent) => ({ intent, state: 'deferred' as const })),
1570
+ })),
1571
+ );
1572
+ return ok<RenderFeaturePlan>({ resources, passes });
1573
+ },
1574
+ onFrameSubmitted: (frame) => {
1575
+ const pending = pendingFrames.get(frame.frameNumber);
1576
+ if (pending === undefined) return;
1577
+ pendingFrames.delete(frame.frameNumber);
1578
+ for (const entry of pending) {
1579
+ // A deferred provider or target is an ordered barrier for its own
1580
+ // player/emitter stream. Other streams may still have terminal work in
1581
+ // this frame, so acknowledge those exact sequences without dropping
1582
+ // the deferred intents that must retry later.
1583
+ const acknowledgedSequences: number[] = [];
1584
+ const publishedSequences: number[] = [];
1585
+ for (const outcome of entry.outcomes) {
1586
+ if (outcome.state === 'deferred') continue;
1587
+ acknowledgedSequences.push(outcome.intent.sequence);
1588
+ if (outcome.state === 'dispatched') {
1589
+ publishedSequences.push(outcome.intent.sequence);
1590
+ entry.runtime.markEventDispatched(outcome.intent.player, outcome.intent.eventCounters);
1591
+ if (outcome.resetEpoch !== undefined) {
1592
+ const baseKey =
1593
+ `${worldId(entry.world)}:` +
1594
+ `${entry.runtime.renderGeneration ?? 0}:` +
1595
+ `${Number(outcome.intent.player)}:${outcome.intent.emitter.id}`;
1596
+ resetEpochByEmitter.get(entry.runtime)?.set(baseKey, outcome.resetEpoch);
1597
+ resetEpochByIntent.get(entry.runtime)?.delete(outcome.intent.sequence);
1598
+ }
1599
+ } else if (outcome.state === 'skipped' && outcome.resetEpoch !== undefined) {
1600
+ // A skipped reset never touched GPU state. Drop only its retry
1601
+ // reservation; a future dispatched reset must allocate a new
1602
+ // epoch from the last committed emitter state.
1603
+ resetEpochByIntent.get(entry.runtime)?.delete(outcome.intent.sequence);
831
1604
  }
832
1605
  }
833
- const lastIntent = entry.intents.at(-1);
834
- if (lastIntent !== undefined) entry.runtime.commit(lastIntent.sequence);
1606
+ if (acknowledgedSequences.length > 0) {
1607
+ entry.runtime.commit(acknowledgedSequences, publishedSequences);
1608
+ }
835
1609
  }
836
- return ok<RenderFeaturePlan>({ resources, passes });
1610
+ },
1611
+ onFrameAborted: (frame) => {
1612
+ pendingFrames.delete(frame.frameNumber);
837
1613
  },
838
1614
  };
1615
+ return feature;
839
1616
  }