@forgeax/engine-vfx-render 0.1.28 → 0.1.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +55 -10
  2. package/dist/__tests__/persistent-ticks.integration.test.d.ts +2 -0
  3. package/dist/__tests__/persistent-ticks.integration.test.d.ts.map +1 -0
  4. package/dist/feature/event-resources.d.ts +10 -3
  5. package/dist/feature/event-resources.d.ts.map +1 -1
  6. package/dist/feature/gpu-particle-feature.d.ts +18 -5
  7. package/dist/feature/gpu-particle-feature.d.ts.map +1 -1
  8. package/dist/feature/particle-resources.d.ts +43 -6
  9. package/dist/feature/particle-resources.d.ts.map +1 -1
  10. package/dist/host/data-interface-providers.d.ts +4 -1
  11. package/dist/host/data-interface-providers.d.ts.map +1 -1
  12. package/dist/host/vfx-runtime-host.d.ts +21 -2
  13. package/dist/host/vfx-runtime-host.d.ts.map +1 -1
  14. package/dist/index.d.ts +4 -3
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.mjs +1027 -307
  17. package/dist/index.mjs.map +1 -1
  18. package/package.json +11 -9
  19. package/src/__tests__/billboard-advanced.integration.test.ts +1 -1
  20. package/src/__tests__/data-interface-providers.unit.test.ts +4 -0
  21. package/src/__tests__/data-interface-public-api.test-d.ts +1 -0
  22. package/src/__tests__/gpu-host.integration.test.ts +1471 -59
  23. package/src/__tests__/particle-resources.unit.test.ts +75 -0
  24. package/src/__tests__/persistent-ticks.integration.test.ts +162 -0
  25. package/src/__tests__/render-vocabulary-owner.test-d.ts +7 -7
  26. package/src/feature/event-resources.ts +18 -4
  27. package/src/feature/gpu-particle-feature.ts +1153 -376
  28. package/src/feature/particle-resources.ts +223 -11
  29. package/src/host/data-interface-providers.ts +19 -0
  30. package/src/host/vfx-runtime-host.ts +82 -2
  31. package/src/index.ts +8 -0
  32. package/src/shaders/beam-inputs.wgsl +40 -0
  33. package/src/shaders/beam.wgsl +4 -3
  34. package/src/shaders/billboard-inputs.wgsl +85 -0
  35. package/src/shaders/mesh-inputs.wgsl +95 -0
  36. package/src/shaders/mesh-shadow.wgsl +18 -0
  37. package/src/shaders/mesh.wgsl +64 -40
  38. package/src/shaders/ribbon-inputs.wgsl +39 -0
  39. package/src/shaders/trail-inputs.wgsl +45 -0
  40. package/src/shaders/trail.wgsl +9 -3
package/dist/index.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { frustum } from '@forgeax/engine-math';
2
2
  import { GlobalTransform } from '@forgeax/engine-scene';
3
3
  import { err, ok } from '@forgeax/engine-types';
4
- import { VFX_GPU_RUNTIME_RESOURCE_KEY, vfxGpuEffectContribution, vfxGpuEffectPackLoader, vfxGpuRuntimePlugin, resolveVfxDataInterfaces, ParticleEffectPlayer } from '@forgeax/engine-vfx';
4
+ import { vfxGpuEffectContribution, vfxGpuEffectPackLoader, vfxGpuRuntimePlugin, VFX_GPU_RUNTIME_RESOURCE_KEY, resolveVfxDataInterfaces, VFX_PARTICLE_CORE_STRIDE, ParticleEffectPlayer } from '@forgeax/engine-vfx';
5
5
  import { getAssetRegistryResolver } from '@forgeax/engine-assets-runtime';
6
6
  import { createWorldContext } from '@forgeax/engine-ecs';
7
7
 
@@ -55,6 +55,13 @@ function encodeEventInputs(intent) {
55
55
  }
56
56
  return bytes;
57
57
  }
58
+ function encodeEventBuffer(intent) {
59
+ const inputBytes = encodeEventInputs(intent);
60
+ const outputBytes = eventCapacity(intent.emitter) * VFX_EVENT_BYTES;
61
+ const data = new Uint8Array(inputBytes.byteLength + outputBytes);
62
+ data.set(inputBytes);
63
+ return data;
64
+ }
58
65
  function eventCounterData() {
59
66
  return new Uint8Array(VFX_EVENT_COUNTER_BYTES);
60
67
  }
@@ -83,8 +90,11 @@ var RenderFeatureStageFailedError = class extends Error {
83
90
  var RENDER_FEATURE_VERTEX_LAYOUTS = Object.freeze({
84
91
  positionSizeColorInstance: "position-size-color-instance",
85
92
  billboardMaterialInstance: "billboard-material-instance",
93
+ billboardMaterialInputInstance: "billboard-material-input-instance",
86
94
  topologySegmentInstance: "topology-segment-instance",
87
- meshGeometryMaterialInstance: "mesh-geometry-material-instance"
95
+ topologySegmentMaterialInputInstance: "topology-segment-material-input-instance",
96
+ meshGeometryMaterialInstance: "mesh-geometry-material-instance",
97
+ meshGeometryMaterialInputInstance: "mesh-geometry-material-input-instance"
88
98
  });
89
99
  var PARTICLE_SHADER_IDENTIFIERS = Object.freeze({
90
100
  billboard: "forgeax::vfx-render.particles.billboard",
@@ -93,6 +103,13 @@ var PARTICLE_SHADER_IDENTIFIERS = Object.freeze({
93
103
  trail: "forgeax::vfx-render.particles.trail",
94
104
  beam: "forgeax::vfx-render.particles.beam"
95
105
  });
106
+ var PARTICLE_INPUT_SHADER_IDENTIFIERS = Object.freeze({
107
+ billboard: "forgeax::vfx-render.particles.billboard-inputs",
108
+ mesh: "forgeax::vfx-render.particles.mesh-inputs",
109
+ ribbon: "forgeax::vfx-render.particles.ribbon-inputs",
110
+ trail: "forgeax::vfx-render.particles.trail-inputs",
111
+ beam: "forgeax::vfx-render.particles.beam-inputs"
112
+ });
96
113
  function createTopologyResourcePlan(renderer) {
97
114
  if (renderer === null || typeof renderer !== "object" || Array.isArray(renderer))
98
115
  return err({
@@ -172,13 +189,121 @@ var PARTICLE_ADDITIVE_BLEND = {
172
189
  color: { srcFactor: "one", dstFactor: "one", operation: "add" },
173
190
  alpha: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" }
174
191
  };
175
- function particleMaterialPass(kind, material) {
192
+ function particleMaterialPass(kind, material, hasParticleInputs = false) {
176
193
  const pass = material?.passes?.find((candidate) => candidate.name === `particle-${kind}`);
194
+ const renderState = pass?.renderState ?? (kind === "mesh" ? material?.passes?.find((candidate) => {
195
+ const tags = candidate.renderState?.tags;
196
+ return candidate.name === "forward" || typeof tags === "object" && tags !== null && "LightMode" in tags && tags.LightMode === "Forward";
197
+ })?.renderState : void 0);
177
198
  return {
178
- shader: pass?.program.module ?? PARTICLE_SHADER_IDENTIFIERS[kind],
179
- ...pass?.renderState === void 0 ? {} : { renderState: pass.renderState }
199
+ shader: pass?.program.module ?? (hasParticleInputs ? PARTICLE_INPUT_SHADER_IDENTIFIERS[kind] : PARTICLE_SHADER_IDENTIFIERS[kind]),
200
+ ...renderState === void 0 ? {} : { renderState }
180
201
  };
181
202
  }
203
+ var EMPTY_PARTICLE_MATERIAL_INPUTS = Object.freeze({
204
+ definitions: Object.freeze([]),
205
+ lanes: 0,
206
+ stride: 0
207
+ });
208
+ function particleInputFailure(code, expected, hint, detail) {
209
+ return err({ code, expected, hint, detail });
210
+ }
211
+ function isParticleInputType(value) {
212
+ return value === "f32" || value === "vec2<f32>" || value === "vec3<f32>" || value === "vec4<f32>";
213
+ }
214
+ function isParticleInputVisibility(value) {
215
+ return value === "vertex" || value === "fragment" || value === "vertex-fragment";
216
+ }
217
+ function validParticleInput(value) {
218
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
219
+ const input = value;
220
+ return typeof input.name === "string" && /^[A-Za-z_][A-Za-z0-9_]*$/.test(input.name) && isParticleInputType(input.type) && isParticleInputVisibility(input.visibility) && typeof input.lane === "number" && Number.isInteger(input.lane) && input.lane >= 0 && input.lane < 4;
221
+ }
222
+ function sameParticleInput(left, right) {
223
+ return left.name === right.name && left.type === right.type && left.visibility === right.visibility && left.lane === right.lane;
224
+ }
225
+ function prepareParticleMaterialInputs(renderer, material, reflected) {
226
+ const requested = renderer.materialInputs ?? [];
227
+ if (requested.length === 0) return ok(EMPTY_PARTICLE_MATERIAL_INPUTS);
228
+ if (new Set(requested).size !== requested.length) {
229
+ return particleInputFailure(
230
+ "vfx-material-input-duplicate",
231
+ "unique particle input names per renderer",
232
+ "remove the duplicate renderer material input and recook the effect",
233
+ { material: renderer.material, path: "renderer.materialInputs" }
234
+ );
235
+ }
236
+ const definitions = material?.particleInputs;
237
+ if (definitions === void 0) {
238
+ return particleInputFailure(
239
+ "vfx-material-input-missing",
240
+ `material ${renderer.material} to declare particleInputs`,
241
+ "add the requested typed input to MaterialAsset and recook the material before the VFX effect",
242
+ { material: renderer.material, path: "material.particleInputs" }
243
+ );
244
+ }
245
+ const names = /* @__PURE__ */ new Set();
246
+ const lanes = /* @__PURE__ */ new Set();
247
+ for (const [index, candidate] of definitions.entries()) {
248
+ if (!validParticleInput(candidate)) {
249
+ return particleInputFailure(
250
+ "vfx-material-input-wrong-type",
251
+ "particleInputs entries with a supported type, visibility, and lane",
252
+ "repair the material particleInputs declaration and recook it",
253
+ { material: renderer.material, path: `material.particleInputs[${index}]` }
254
+ );
255
+ }
256
+ if (names.has(candidate.name) || lanes.has(candidate.lane)) {
257
+ return particleInputFailure(
258
+ "vfx-material-input-duplicate",
259
+ "unique particle input names and lanes",
260
+ "assign one lane to one input name and recook the material",
261
+ { material: renderer.material, name: candidate.name, lane: candidate.lane }
262
+ );
263
+ }
264
+ names.add(candidate.name);
265
+ lanes.add(candidate.lane);
266
+ }
267
+ const selectedDefinitions = [];
268
+ for (const name of requested) {
269
+ const input = definitions.find((candidate) => candidate.name === name);
270
+ if (input === void 0) {
271
+ return particleInputFailure(
272
+ "vfx-material-input-missing",
273
+ `material ${renderer.material} to declare particle input ${name}`,
274
+ "add the requested input to MaterialAsset.particleInputs and recook both assets",
275
+ { material: renderer.material, name, path: "renderer.materialInputs" }
276
+ );
277
+ }
278
+ selectedDefinitions.push(input);
279
+ }
280
+ if (reflected === void 0) {
281
+ return particleInputFailure(
282
+ "vfx-material-input-stale",
283
+ "cooked renderer reflection to carry the material input declarations",
284
+ "recook the VFX effect with the current material artifact catalog",
285
+ { material: renderer.material, path: "effect.reflection.renderers.materialInputDefinitions" }
286
+ );
287
+ }
288
+ for (const input of selectedDefinitions) {
289
+ const cooked = reflected.find((candidate) => candidate.name === input.name);
290
+ if (cooked === void 0 || !sameParticleInput(input, cooked)) {
291
+ return particleInputFailure(
292
+ "vfx-material-input-stale",
293
+ `the cooked declaration for material input ${input.name} to match MaterialAsset`,
294
+ "recook the VFX effect and material together so names, types, visibility, and lanes agree",
295
+ { material: renderer.material, name: input.name, lane: input.lane }
296
+ );
297
+ }
298
+ }
299
+ const lanesUsed = selectedDefinitions.map((input) => input.lane);
300
+ const lanesCount = Math.max(...lanesUsed, -1) + 1;
301
+ return ok({
302
+ definitions: Object.freeze([...selectedDefinitions]),
303
+ lanes: lanesCount,
304
+ stride: lanesCount * 16
305
+ });
306
+ }
182
307
  function particleRendererRenderState(kind, blend, authored) {
183
308
  if (authored !== void 0) return authored;
184
309
  const isTopology = kind === "ribbon" || kind === "trail" || kind === "beam";
@@ -202,8 +327,9 @@ function particleMaterialUsesBindings(material) {
202
327
  return (material?.parameters?.length ?? 0) > 0;
203
328
  }
204
329
  function particleMaterialSceneDepthBinding(contract) {
205
- if (contract === "group-0-resource") return 0;
206
- if (contract === "view-and-scene-depth") return 1;
330
+ if (contract === "group-0-resource" || contract === "render-material-with-scene-depth") return 0;
331
+ if (contract === "view-and-scene-depth" || contract === "render-material-and-scene-depth")
332
+ return 1;
207
333
  return void 0;
208
334
  }
209
335
  function floatAttribute(value) {
@@ -425,11 +551,10 @@ function stageRecoveryReadiness(plan, generation, recovery) {
425
551
  // src/feature/gpu-particle-feature.ts
426
552
  var IDENTITY = "forgeax.vfx-render.gpu-particles";
427
553
  var WORKGROUP_SIZE = 256;
428
- var PARTICLE_BYTES = 80;
429
554
  var BILLBOARD_INSTANCE_BYTES = 31 * 4;
430
- var MESH_INSTANCE_BYTES = 28 * 4;
555
+ var MESH_INSTANCE_BYTES = 18 * 4;
431
556
  var COUNTERS_BYTES = 24;
432
- var RUNTIME_BYTES = 72 * 4;
557
+ var RUNTIME_BYTES = 76 * 4;
433
558
  var IDENTITY_MATRIX = new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]);
434
559
  function createVfxRenderInspectSnapshot(input) {
435
560
  return {
@@ -467,7 +592,7 @@ function resolveBillboardAdvancedState(renderer, sample) {
467
592
  frameIndex,
468
593
  pivot: renderer.pivot ?? [0, 0],
469
594
  softParticleFade,
470
- sortingKey: renderer.sorting === "back-to-front" ? sample.particleDepth : 0
595
+ sortingKey: renderer.sorting === "view-depth" || renderer.sorting === "view-distance" ? sample.particleDepth : 0
471
596
  });
472
597
  }
473
598
  function topologyRecoveryHint(topology, reason) {
@@ -482,10 +607,25 @@ function topologyRecoveryHint(topology, reason) {
482
607
  function finite(value, fallback) {
483
608
  return typeof value === "number" && Number.isFinite(value) ? value : fallback;
484
609
  }
610
+ function billboardSortingMode(renderer) {
611
+ if (renderer?.kind !== "billboard") return 0;
612
+ switch (renderer.sorting) {
613
+ case "view-depth":
614
+ return 2;
615
+ case "view-distance":
616
+ return 5;
617
+ case "custom-ascending":
618
+ return 3;
619
+ case "custom-descending":
620
+ return 4;
621
+ default:
622
+ return 0;
623
+ }
624
+ }
485
625
  function vector(value, fallback, size) {
486
626
  return Array.isArray(value) ? Array.from({ length: size }, (_, index) => finite(value[index], fallback[index] ?? 0)) : fallback;
487
627
  }
488
- function runtimeData(intent, camera, material, localToWorld, renderer, rendererIndex = 0) {
628
+ function runtimeData(intent, camera, material, localToWorld, renderer, rendererIndex = 0, particleInputLanes = renderer?.materialInputs?.length ?? 0, meshDraw) {
489
629
  const storage = new ArrayBuffer(RUNTIME_BYTES);
490
630
  const floats = new Float32Array(storage);
491
631
  const words = new Uint32Array(storage);
@@ -500,6 +640,7 @@ function runtimeData(intent, camera, material, localToWorld, renderer, rendererI
500
640
  floats.set(camera.viewProjection, 8);
501
641
  floats.set(camera.right, 24);
502
642
  floats.set(camera.up, 28);
643
+ floats.set(camera.position, 72);
503
644
  const values = material?.values ?? {};
504
645
  floats.set(vector(values.baseColor, [1, 1, 1, 1], 4), 32);
505
646
  const emissive = vector(values.emissive, [0, 0, 0], 3);
@@ -511,12 +652,13 @@ function runtimeData(intent, camera, material, localToWorld, renderer, rendererI
511
652
  floats[43] = finite(values.clearcoatRoughness, 0.5);
512
653
  floats.set(localToWorld, 44);
513
654
  words[60] = rendererIndex;
514
- words[61] = renderer?.kind === "trail" ? renderer.historyLength : 0;
655
+ words[61] = renderer === void 0 && ((intent.emitter.reflection.resources ?? []).includes("eventBuffer") || (intent.emitter.reflection.entryPoints ?? []).includes("forgeax_vfx_event_main")) ? eventInputCapacity(intent.emitter) : renderer?.kind === "trail" ? renderer.historyLength : meshDraw?.count ?? 0;
515
656
  words[62] = renderer?.kind === "ribbon" || renderer?.kind === "trail" || renderer?.kind === "beam" ? renderer.capacity : intent.emitter.capacity;
516
- words[63] = renderer?.kind === "billboard" && renderer.sorting === "back-to-front" ? 2 : renderer?.kind === "billboard" && renderer.sorting === "emitter" ? 1 : 0;
657
+ words[63] = meshDraw?.firstIndex ?? billboardSortingMode(renderer);
517
658
  floats[64] = renderer?.kind === "billboard" ? renderer.pivot?.[0] ?? 0 : renderer?.kind === "ribbon" || renderer?.kind === "trail" || renderer?.kind === "beam" ? renderer.width ?? 0.1 : 0.1;
518
659
  floats[65] = renderer?.kind === "billboard" ? renderer.pivot?.[1] ?? 0 : 0;
519
660
  floats[66] = renderer?.kind === "billboard" ? renderer.softParticle?.fadeDistance ?? 0 : 0;
661
+ floats[67] = particleInputLanes;
520
662
  const sheet = renderer?.kind === "billboard" ? renderer.textureSheet : void 0;
521
663
  floats[68] = sheet?.columns ?? 1;
522
664
  floats[69] = sheet?.rows ?? 1;
@@ -529,8 +671,8 @@ function emitterTransform(world, intent) {
529
671
  const transform = world.get(intent.player, GlobalTransform);
530
672
  return transform.ok ? transform.value.world : IDENTITY_MATRIX;
531
673
  }
532
- function emitterVisible(intent, camera, localToWorld) {
533
- const bounds = intent.emitter.bounds;
674
+ function emitterVisible(emitter, camera, localToWorld) {
675
+ const bounds = emitter.bounds;
534
676
  const center = bounds.kind === "sphere" ? bounds.center : [
535
677
  (bounds.min[0] + bounds.max[0]) * 0.5,
536
678
  (bounds.min[1] + bounds.max[1]) * 0.5,
@@ -563,6 +705,36 @@ function requiresSceneDepth(intent) {
563
705
  (requirement) => requirement.kind === "scene-depth"
564
706
  );
565
707
  }
708
+ function dataInterfacesExecutable(intent, registry) {
709
+ const requirements = intent.emitter.reflection.dataInterfaces ?? [];
710
+ if (requirements.length === 0) return true;
711
+ const resolved = registry?.resolve(requirements, intent.instanceGeneration);
712
+ if (resolved === void 0) return true;
713
+ if (!resolved.ok) {
714
+ const error = resolved.error;
715
+ if (error === void 0) return false;
716
+ return error.code === "vfx-data-interface-missing" && (error.detail.providerId === void 0 || error.expected.includes("resident"));
717
+ }
718
+ if (resolved.value === void 0) return true;
719
+ return resolved.value.resources.every((resource) => resource.resource !== void 0);
720
+ }
721
+ function preparedDataInterfaceResources(requirements, generation, prepared) {
722
+ if (prepared === void 0) return void 0;
723
+ const resources = [];
724
+ for (const requirement of requirements) {
725
+ const value = prepared[requirement.kind];
726
+ if (value === void 0) return void 0;
727
+ resources.push({
728
+ token: requirement.token,
729
+ kind: requirement.kind,
730
+ bindingType: requirement.bindingType,
731
+ generation,
732
+ ...requirement.sampleCount === void 0 ? {} : { sampleCount: requirement.sampleCount },
733
+ resource: value
734
+ });
735
+ }
736
+ return resources;
737
+ }
566
738
  function planFailure() {
567
739
  return new RenderFeatureStageFailedError(IDENTITY, -1, "plan", "next-frame");
568
740
  }
@@ -572,7 +744,9 @@ function planName(value, maxLength = 24) {
572
744
  }
573
745
  function computeBindingEntries(intent, resources) {
574
746
  const declared = new Set(
575
- (intent.emitter.reflection.bindings[0]?.entries ?? []).filter((entry) => entry.buffer !== void 0).map((entry) => entry.binding)
747
+ (intent.emitter.reflection.bindings[0]?.entries ?? []).filter(
748
+ (entry) => entry.buffer !== void 0 || entry.texture !== void 0 || entry.sampler !== void 0 || entry.storageTexture !== void 0
749
+ ).map((entry) => entry.binding)
576
750
  );
577
751
  return Object.entries(resources).flatMap(
578
752
  ([binding, resource]) => declared.has(Number(binding)) ? [{ binding: Number(binding), resource }] : []
@@ -580,6 +754,12 @@ function computeBindingEntries(intent, resources) {
580
754
  }
581
755
  function simulationDispatches(intent, stages) {
582
756
  const groups = Math.max(1, Math.ceil(intent.emitter.capacity / WORKGROUP_SIZE));
757
+ const compact = [
758
+ { kind: "direct", entryPoint: "forgeax_vfx_scan_blocks_main", workgroups: [groups] },
759
+ { kind: "direct", entryPoint: "forgeax_vfx_scan_block_offsets_main", workgroups: [1] },
760
+ { kind: "direct", entryPoint: "forgeax_vfx_add_offsets_main", workgroups: [groups] },
761
+ { kind: "direct", entryPoint: "forgeax_vfx_compact_main", workgroups: [groups] }
762
+ ];
583
763
  return [
584
764
  { kind: "direct", entryPoint: "forgeax_vfx_spawn_main", workgroups: [groups] },
585
765
  { kind: "direct", entryPoint: "forgeax_vfx_update_main", workgroups: [groups] },
@@ -588,19 +768,40 @@ function simulationDispatches(intent, stages) {
588
768
  entryPoint: stage.entryPoint,
589
769
  workgroups: [groups]
590
770
  })),
591
- { kind: "direct", entryPoint: "forgeax_vfx_scan_blocks_main", workgroups: [groups] },
592
- { kind: "direct", entryPoint: "forgeax_vfx_scan_block_offsets_main", workgroups: [1] },
593
- { kind: "direct", entryPoint: "forgeax_vfx_add_offsets_main", workgroups: [groups] },
594
- { kind: "direct", entryPoint: "forgeax_vfx_compact_main", workgroups: [groups] },
595
- {
596
- kind: "direct",
597
- entryPoint: "forgeax_vfx_event_main",
598
- workgroups: [Math.max(1, Math.ceil(eventInputCapacity(intent.emitter) / 64))]
599
- }
771
+ ...compact,
772
+ ...intent.emitter.reflection.entryPoints.includes("forgeax_vfx_event_main") ? [
773
+ {
774
+ kind: "direct",
775
+ entryPoint: "forgeax_vfx_event_main",
776
+ workgroups: [1]
777
+ },
778
+ ...compact
779
+ ] : []
600
780
  ];
601
781
  }
602
782
  function gpuParticleRenderFeature(options) {
603
- return {
783
+ const pendingFrames = /* @__PURE__ */ new Map();
784
+ const worldIds = /* @__PURE__ */ new WeakMap();
785
+ const runtimeIds = /* @__PURE__ */ new WeakMap();
786
+ const resetEpochByEmitter = /* @__PURE__ */ new WeakMap();
787
+ const resetEpochByIntent = /* @__PURE__ */ new WeakMap();
788
+ let nextWorldId = 0;
789
+ let nextRuntimeId = 0;
790
+ const worldId = (world) => {
791
+ const prior = worldIds.get(world);
792
+ if (prior !== void 0) return prior;
793
+ const assigned = nextWorldId++;
794
+ worldIds.set(world, assigned);
795
+ return assigned;
796
+ };
797
+ const runtimeId = (runtime) => {
798
+ const prior = runtimeIds.get(runtime);
799
+ if (prior !== void 0) return prior;
800
+ const assigned = nextRuntimeId++;
801
+ runtimeIds.set(runtime, assigned);
802
+ return assigned;
803
+ };
804
+ const feature = {
604
805
  identity: IDENTITY,
605
806
  requiredCapabilities: ["compute", "indirectDrawing"],
606
807
  // Generated emitter programs are first-use assets. They must hand a
@@ -608,372 +809,806 @@ function gpuParticleRenderFeature(options) {
608
809
  // diagnostic-only getCompilationInfo() round trip; pipeline creation still
609
810
  // validates the module before the pass is submitted.
610
811
  shaderModuleMode: "immediate",
611
- requiredMaterialShaders: Object.values(PARTICLE_SHADER_IDENTIFIERS),
812
+ requiredMaterialShaders: Object.freeze([
813
+ ...Object.values(PARTICLE_SHADER_IDENTIFIERS),
814
+ ...Object.values(PARTICLE_INPUT_SHADER_IDENTIFIERS)
815
+ ]),
612
816
  extract: (context) => {
817
+ for (const frameNumber of pendingFrames.keys()) {
818
+ if (frameNumber + 4 < context.frameNumber) pendingFrames.delete(frameNumber);
819
+ }
613
820
  const extracted = [];
614
821
  for (const world of context.worlds) {
615
822
  if (!world.hasResource(VFX_GPU_RUNTIME_RESOURCE_KEY)) continue;
616
823
  const camera = options.camera.read(world);
617
824
  if (camera === void 0) continue;
618
825
  const runtime = world.getResource(VFX_GPU_RUNTIME_RESOURCE_KEY);
619
- const intents = runtime.snapshot().filter((intent) => {
620
- if (options.playerConsumption?.isEnabled(world, intent.player) === false) return false;
621
- const requirements = intent.emitter.reflection.dataInterfaces ?? [];
622
- return requirements.length === 0 || options.dataInterfaces?.resolve(requirements, intent.instanceGeneration).ok === true;
826
+ const retained = [];
827
+ runtime.forEachEmitterSource(({ player, emitter }) => {
828
+ const sourceIntent = runtime.lastCommittedEmitter(player, emitter.id);
829
+ const localToWorld = sourceIntent === void 0 ? emitter.space === "world" ? IDENTITY_MATRIX : (() => {
830
+ const transform = world.get(player, GlobalTransform);
831
+ return transform.ok ? transform.value.world : IDENTITY_MATRIX;
832
+ })() : emitterTransform(world, sourceIntent);
833
+ const visible = emitterVisible(emitter, camera, localToWorld);
834
+ runtime.setEmitterCameraVisibility(player, emitter.id, visible);
835
+ const intent = runtime.lastCommittedEmitter(player, emitter.id);
836
+ if (intent !== void 0) {
837
+ retained.push({ player, emitter, intent, localToWorld, visible });
838
+ }
623
839
  });
624
- extracted.push({ world, runtime, camera, intents });
840
+ const intents = runtime.snapshot();
841
+ extracted.push({ world, runtime, camera, intents, retained });
625
842
  }
626
843
  return ok({ worlds: extracted, frameNumber: context.frameNumber });
627
844
  },
628
845
  plan: (frame, context) => {
629
846
  const resources = [];
630
847
  const passes = [];
631
- const dispatchedIntents = /* @__PURE__ */ new Set();
632
848
  const colorTarget = context.targets.find((candidate) => candidate.kind === "color") ?? context.targets.find((candidate) => candidate.kind === "swapchain");
633
849
  const depthTarget = context.targets.find((candidate) => candidate.kind === "depth");
850
+ const groups = /* @__PURE__ */ new Map();
851
+ const outcomesByEntry = /* @__PURE__ */ new Map();
634
852
  for (const [worldIndex, entry] of frame.worlds.entries()) {
635
- for (const [intentIndex, intent] of entry.intents.entries()) {
636
- if (!entry.runtime.isEmitterSessionEnabled(intent.player, intent.emitter.id)) continue;
853
+ const outcomes = [];
854
+ outcomesByEntry.set(entry, outcomes);
855
+ const effectiveEpochByEmitter = /* @__PURE__ */ new Map();
856
+ const renderGeneration = entry.runtime.renderGeneration ?? 0;
857
+ const attachmentId = runtimeId(entry.runtime);
858
+ let emitterEpochs = resetEpochByEmitter.get(entry.runtime);
859
+ if (emitterEpochs === void 0) {
860
+ emitterEpochs = /* @__PURE__ */ new Map();
861
+ resetEpochByEmitter.set(entry.runtime, emitterEpochs);
862
+ }
863
+ let reservations = resetEpochByIntent.get(entry.runtime);
864
+ if (reservations === void 0) {
865
+ reservations = /* @__PURE__ */ new Map();
866
+ resetEpochByIntent.set(entry.runtime, reservations);
867
+ }
868
+ const liveResetSequences = new Set(
869
+ (entry.runtime.snapshot?.() ?? entry.intents).filter((intent) => intent.reset).map((intent) => intent.sequence)
870
+ );
871
+ for (const [sequence, reservation] of reservations) {
872
+ if (reservation.generation !== renderGeneration || !liveResetSequences.has(sequence)) {
873
+ reservations.delete(sequence);
874
+ }
875
+ }
876
+ const blockedEmitters = /* @__PURE__ */ new Set();
877
+ for (const intent of entry.intents) {
878
+ const baseKey = `${worldId(entry.world)}:${renderGeneration}:${Number(intent.player)}:${intent.emitter.id}`;
879
+ let priorReservedEpoch;
880
+ let priorReservedSequence = -1;
881
+ let exactReservation;
882
+ for (const [sequence, reservation] of reservations) {
883
+ if (reservation.generation === renderGeneration && reservation.emitterKey === baseKey) {
884
+ if (sequence === intent.sequence) {
885
+ exactReservation = reservation;
886
+ } else if (sequence < intent.sequence && sequence > priorReservedSequence) {
887
+ priorReservedEpoch = reservation.epoch;
888
+ priorReservedSequence = sequence;
889
+ }
890
+ }
891
+ }
892
+ const committedEpoch = effectiveEpochByEmitter.get(baseKey) ?? Math.max(emitterEpochs.get(baseKey) ?? 0, priorReservedEpoch ?? 0);
893
+ let resetEpoch = committedEpoch;
894
+ if (intent.reset) {
895
+ resetEpoch = exactReservation?.generation === renderGeneration && exactReservation.emitterKey === baseKey ? exactReservation.epoch : committedEpoch + 1;
896
+ reservations.set(intent.sequence, {
897
+ generation: renderGeneration,
898
+ emitterKey: baseKey,
899
+ epoch: resetEpoch
900
+ });
901
+ }
902
+ if (blockedEmitters.has(baseKey)) {
903
+ outcomes.push({
904
+ intent,
905
+ state: "deferred",
906
+ ...intent.reset ? { resetEpoch } : {}
907
+ });
908
+ continue;
909
+ }
910
+ if (options.playerConsumption?.isEnabled(entry.world, intent.player) === false || !entry.runtime.isEmitterSessionEnabled(intent.player, intent.emitter.id)) {
911
+ const state = intent.reset ? "deferred" : "skipped";
912
+ outcomes.push({
913
+ intent,
914
+ state,
915
+ ...intent.reset ? { resetEpoch } : {}
916
+ });
917
+ if (intent.reset) blockedEmitters.add(baseKey);
918
+ continue;
919
+ }
637
920
  const localToWorld = emitterTransform(entry.world, intent);
638
- const visible = emitterVisible(intent, entry.camera, localToWorld);
921
+ const visible = emitterVisible(intent.emitter, entry.camera, localToWorld);
639
922
  entry.runtime.setEmitterCameraVisibility(intent.player, intent.emitter.id, visible);
640
- if (!visible) continue;
641
- if (requiresSceneDepth(intent) && depthTarget === void 0) continue;
923
+ if (!visible && intent.emitter.simulationWhenCulled !== "continue") {
924
+ const state = intent.reset ? "deferred" : "skipped";
925
+ outcomes.push({
926
+ intent,
927
+ state,
928
+ ...intent.reset ? { resetEpoch } : {}
929
+ });
930
+ if (intent.reset) blockedEmitters.add(baseKey);
931
+ continue;
932
+ }
933
+ if (visible && requiresSceneDepth(intent) && (depthTarget === void 0 || depthTarget.sampleCount !== 1)) {
934
+ outcomes.push({
935
+ intent,
936
+ state: "deferred",
937
+ ...intent.reset ? { resetEpoch } : {}
938
+ });
939
+ blockedEmitters.add(baseKey);
940
+ continue;
941
+ }
942
+ const requirements = intent.emitter.reflection.dataInterfaces ?? [];
943
+ if (requirements.length > 0 && !dataInterfacesExecutable(intent, options.dataInterfaces)) {
944
+ outcomes.push({
945
+ intent,
946
+ state: "deferred",
947
+ ...intent.reset ? { resetEpoch } : {}
948
+ });
949
+ blockedEmitters.add(baseKey);
950
+ continue;
951
+ }
642
952
  const stagePlan = validatedStagePlan(
643
953
  intent.emitter.reflection.stages,
644
954
  intent.instanceGeneration
645
955
  );
646
956
  if (!stagePlan.ok) return err(planFailure());
647
- const prefix = `vfx.w-${worldIndex}.i-${intentIndex}.${planName(intent.emitter.id)}`;
648
- const program = `${prefix}.compute-program`;
649
- const particles = `${prefix}.particles`;
650
- const runtime = `${prefix}.runtime`;
651
- const aliveIndices = `${prefix}.alive-indices`;
652
- const counters = `${prefix}.counters`;
653
- const indirect = `${prefix}.indirect`;
654
- const scratch = `${prefix}.scratch`;
655
- const sharedInstances = `${prefix}.shared-instances`;
656
- const eventInputs = `${prefix}.event-inputs`;
657
- const events = `${prefix}.events`;
658
- const bindings = `${prefix}.simulation-bindings`;
659
- const capacity = intent.emitter.capacity;
660
- const renderers = intent.emitter.renderers;
661
- const meshes = renderers.map(
662
- (renderer) => renderer.kind === "mesh" ? options.mesh?.read(entry.world, renderer.mesh) : void 0
957
+ const entryPoints = new Set(intent.emitter.reflection.entryPoints);
958
+ const dispatches = simulationDispatches(intent, stagePlan.value).filter(
959
+ (dispatch) => entryPoints.has(dispatch.entryPoint)
663
960
  );
664
- const indirectWords = new Uint32Array(Math.max(1, renderers.length) * 5);
665
- for (const [rendererIndex, renderer] of renderers.entries()) {
666
- const mesh = meshes[rendererIndex];
667
- const submesh = renderer.kind === "mesh" ? mesh?.submeshes[renderer.submesh ?? 0] : void 0;
668
- if (renderer.kind === "mesh" && submesh === void 0) return err(planFailure());
669
- if ((renderer.kind === "ribbon" || renderer.kind === "trail" || renderer.kind === "beam") && !createTopologyResourcePlan(renderer).ok) {
670
- return err(planFailure());
671
- }
672
- indirectWords[rendererIndex * 5] = renderer.kind === "mesh" ? mesh?.indices === void 0 ? submesh?.vertexCount ?? 0 : submesh?.indexCount ?? 0 : 6;
673
- indirectWords[rendererIndex * 5 + 2] = renderer.kind === "mesh" && mesh?.indices !== void 0 ? submesh?.indexOffset ?? 0 : 0;
961
+ if (dispatches.length === 0 && intent.emitter.renderers.length === 0) {
962
+ outcomes.push({
963
+ intent,
964
+ state: "skipped",
965
+ ...intent.reset ? { resetEpoch } : {}
966
+ });
967
+ continue;
674
968
  }
675
- const scratchBytes = (capacity * 2 + Math.ceil(capacity / WORKGROUP_SIZE)) * 4;
676
- const eventInputBytes = Math.max(
677
- 4,
678
- eventInputCapacity(intent.emitter) * VFX_EVENT_INPUT_BYTES
969
+ effectiveEpochByEmitter.set(baseKey, resetEpoch);
970
+ const key = `vfx.w-${planName(worldId(entry.world).toString(36), 8)}.a-${planName(attachmentId.toString(36), 8)}.r-${entry.runtime.renderGeneration}.p-${planName(Number(intent.player).toString(36), 16)}.${planName(intent.emitter.id)}.g-${resetEpoch}`;
971
+ let group = groups.get(key);
972
+ if (group === void 0) {
973
+ group = {
974
+ key,
975
+ worldIndex,
976
+ entry,
977
+ intents: [],
978
+ // Reset data is a per-frame boundary, not a property of the
979
+ // epoch. Once a reset-created group is warm, later ticks in
980
+ // the same epoch must retain its particle state.
981
+ initialReset: intent.reset
982
+ };
983
+ groups.set(key, group);
984
+ } else if (group.entry !== entry || group.intents[0]?.intent.emitter.wgsl !== intent.emitter.wgsl || group.intents[0]?.intent.emitter.capacity !== intent.emitter.capacity) {
985
+ return err(planFailure());
986
+ }
987
+ group.intents.push({
988
+ intent,
989
+ localToWorld,
990
+ stagePlan: stagePlan.value,
991
+ visible,
992
+ retained: false
993
+ });
994
+ outcomes.push({
995
+ intent,
996
+ state: dispatches.length > 0 ? "dispatched" : "skipped",
997
+ ...intent.reset ? { resetEpoch } : {}
998
+ });
999
+ if (dispatches.length === 0) effectiveEpochByEmitter.delete(baseKey);
1000
+ }
1001
+ }
1002
+ for (const [worldIndex, entry] of frame.worlds.entries()) {
1003
+ const renderGeneration = entry.runtime.renderGeneration ?? 0;
1004
+ const attachmentId = runtimeId(entry.runtime);
1005
+ let emitterEpochs = resetEpochByEmitter.get(entry.runtime);
1006
+ if (emitterEpochs === void 0) {
1007
+ emitterEpochs = /* @__PURE__ */ new Map();
1008
+ resetEpochByEmitter.set(entry.runtime, emitterEpochs);
1009
+ }
1010
+ for (const retained of entry.retained ?? []) {
1011
+ const hasAdmittedGroup = [...groups.values()].some(
1012
+ (group) => group.worldIndex === worldIndex && group.entry === entry && group.intents.some(
1013
+ (planned) => planned.intent.player === retained.player && planned.intent.emitter.id === retained.emitter.id
1014
+ )
679
1015
  );
680
- const eventBytes = Math.max(4, eventCapacity(intent.emitter) * VFX_EVENT_BYTES);
681
- resources.push(
682
- {
683
- kind: "compute-program",
684
- name: program,
685
- program: {
686
- wgsl: intent.emitter.wgsl,
687
- entryPoints: intent.emitter.reflection.entryPoints,
688
- bindings: intent.emitter.reflection.bindings
1016
+ if (hasAdmittedGroup) continue;
1017
+ const stagePlan = validatedStagePlan(
1018
+ retained.intent.emitter.reflection.stages,
1019
+ retained.intent.instanceGeneration
1020
+ );
1021
+ if (!stagePlan.ok) return err(planFailure());
1022
+ const baseKey = `${worldId(entry.world)}:${renderGeneration}:${Number(retained.player)}:${retained.emitter.id}`;
1023
+ const resetEpoch = emitterEpochs.get(baseKey) ?? 0;
1024
+ const key = `vfx.w-${planName(worldId(entry.world).toString(36), 8)}.a-${planName(attachmentId.toString(36), 8)}.r-${renderGeneration}.p-${planName(Number(retained.player).toString(36), 16)}.${planName(retained.emitter.id)}.g-${resetEpoch}`;
1025
+ const visible = retained.visible && (options.playerConsumption?.isEnabled(entry.world, retained.player) ?? true) && entry.runtime.isEmitterSessionEnabled(retained.player, retained.emitter.id);
1026
+ groups.set(key, {
1027
+ key,
1028
+ worldIndex,
1029
+ entry,
1030
+ intents: [
1031
+ {
1032
+ intent: retained.intent,
1033
+ localToWorld: retained.localToWorld,
1034
+ stagePlan: stagePlan.value,
1035
+ visible,
1036
+ retained: true
689
1037
  }
690
- },
691
- {
692
- kind: "buffer",
693
- name: particles,
694
- size: capacity * PARTICLE_BYTES,
695
- usage: ["storage"],
696
- ...intent.reset ? { data: resetData(capacity * PARTICLE_BYTES) } : {}
697
- },
698
- { kind: "buffer", name: aliveIndices, size: capacity * 4, usage: ["storage"] },
699
- {
700
- kind: "buffer",
701
- name: counters,
702
- size: COUNTERS_BYTES,
703
- usage: ["storage"],
704
- ...intent.reset ? { data: resetData(COUNTERS_BYTES) } : {}
705
- },
706
- {
707
- kind: "buffer",
708
- name: indirect,
709
- size: indirectWords.byteLength,
710
- usage: ["storage", "indirect"],
711
- data: indirectWords
712
- },
713
- {
714
- kind: "buffer",
715
- name: scratch,
716
- size: scratchBytes,
717
- usage: ["storage"],
718
- ...intent.reset ? { data: resetData(scratchBytes) } : {}
719
- },
1038
+ ],
1039
+ initialReset: false
1040
+ });
1041
+ }
1042
+ }
1043
+ for (const group of groups.values()) {
1044
+ const first = group.intents[0];
1045
+ if (first === void 0) continue;
1046
+ const firstIntent = first.intent;
1047
+ const prefix = group.key;
1048
+ const program = `${prefix}.compute-program`;
1049
+ const particles = `${prefix}.particles`;
1050
+ const aliveIndices = `${prefix}.alive-indices`;
1051
+ const counters = `${prefix}.counters`;
1052
+ const indirect = `${prefix}.indirect`;
1053
+ const scratch = `${prefix}.scratch`;
1054
+ const sharedInstances = `${prefix}.shared-instances`;
1055
+ const projectionEventInputs = `${prefix}.projection-event-inputs`;
1056
+ const capacity = firstIntent.emitter.capacity;
1057
+ const renderers = firstIntent.emitter.renderers;
1058
+ const layout = firstIntent.emitter.reflection.layout;
1059
+ const parameterBytes = layout?.parameters.size ?? 0;
1060
+ const customStride = layout?.customLayout?.stride ?? 0;
1061
+ const hasEvents = (firstIntent.emitter.reflection.resources ?? []).includes("eventBuffer") || (firstIntent.emitter.reflection.entryPoints ?? []).includes("forgeax_vfx_event_main") || (firstIntent.emitter.reflection.bindings[0]?.entries ?? []).some(
1062
+ (entry) => entry.binding === 8
1063
+ );
1064
+ const diBindings = {};
1065
+ const diResources = [];
1066
+ const reflectedBindings = new Set(
1067
+ firstIntent.emitter.reflection.bindings.flatMap(
1068
+ (group2) => group2.entries.map((entry) => entry.binding)
1069
+ )
1070
+ );
1071
+ const requirements = (firstIntent.emitter.reflection.dataInterfaces ?? []).filter(
1072
+ (requirement) => reflectedBindings.has(requirement.binding)
1073
+ );
1074
+ if (requirements.length > 0) {
1075
+ let resolved = options.dataInterfaces?.resolve(
1076
+ requirements,
1077
+ firstIntent.instanceGeneration
1078
+ );
1079
+ if ((resolved === void 0 || !resolved.ok && resolved.error.code === "vfx-data-interface-missing" && (resolved.error.detail.providerId === void 0 || resolved.error.expected.includes("resident"))) && context.preparedDataInterfaces !== void 0) {
1080
+ const fallback = preparedDataInterfaceResources(
1081
+ requirements,
1082
+ firstIntent.instanceGeneration,
1083
+ context.preparedDataInterfaces
1084
+ );
1085
+ if (fallback !== void 0) {
1086
+ resolved = ok({
1087
+ generation: firstIntent.instanceGeneration,
1088
+ readiness: "ready",
1089
+ resources: fallback
1090
+ });
1091
+ }
1092
+ }
1093
+ if (resolved === void 0 || !resolved.ok) return err(planFailure());
1094
+ for (const requirement of requirements) {
1095
+ const resource = resolved.value.resources.find(
1096
+ (candidate) => candidate.token === requirement.token
1097
+ );
1098
+ const prepared = resource?.resource;
1099
+ if (resource === void 0 || prepared === void 0) return err(planFailure());
1100
+ const name = `${prefix}.di-${planName(requirement.kind)}`;
1101
+ if (prepared.kind === "buffer") {
1102
+ if (prepared.size === void 0 || prepared.size <= 0) return err(planFailure());
1103
+ diResources.push({
1104
+ kind: "prepared-gpu-resource",
1105
+ name,
1106
+ resource: {
1107
+ kind: "buffer",
1108
+ value: prepared.value,
1109
+ size: prepared.size,
1110
+ usage: prepared.usage === "storage" ? ["storage"] : ["uniform"]
1111
+ }
1112
+ });
1113
+ } else {
1114
+ const external = prepared.kind === "texture-view" ? { kind: "texture-view", value: prepared.value } : { kind: "sampler", value: prepared.value };
1115
+ diResources.push({
1116
+ kind: "prepared-gpu-resource",
1117
+ name,
1118
+ resource: external,
1119
+ ...requirement.kind === "scene-depth" && depthTarget !== void 0 ? { logicalTarget: depthTarget.name } : {}
1120
+ });
1121
+ }
1122
+ diBindings[requirement.binding] = name;
1123
+ }
1124
+ }
1125
+ const meshes = renderers.map(
1126
+ (renderer) => renderer.kind === "mesh" ? options.mesh?.read(group.entry.world, renderer.mesh) : void 0
1127
+ );
1128
+ const indirectWords = new Uint32Array(Math.max(1, renderers.length) * 5);
1129
+ for (const [rendererIndex, renderer] of renderers.entries()) {
1130
+ const mesh = meshes[rendererIndex];
1131
+ const submesh = renderer.kind === "mesh" ? mesh?.submeshes[renderer.submesh ?? 0] : void 0;
1132
+ if (renderer.kind === "mesh" && submesh === void 0) return err(planFailure());
1133
+ if ((renderer.kind === "ribbon" || renderer.kind === "trail" || renderer.kind === "beam") && !createTopologyResourcePlan(renderer).ok) {
1134
+ return err(planFailure());
1135
+ }
1136
+ indirectWords[rendererIndex * 5] = renderer.kind === "mesh" ? mesh?.indices === void 0 ? submesh?.vertexCount ?? 0 : submesh?.indexCount ?? 0 : 6;
1137
+ indirectWords[rendererIndex * 5 + 2] = renderer.kind === "mesh" && mesh?.indices !== void 0 ? submesh?.indexOffset ?? 0 : 0;
1138
+ }
1139
+ const scratchBytes = (capacity * 2 + Math.ceil(capacity / WORKGROUP_SIZE)) * 4;
1140
+ const eventBufferBytes = Math.max(
1141
+ 4,
1142
+ eventInputCapacity(firstIntent.emitter) * VFX_EVENT_INPUT_BYTES + eventCapacity(firstIntent.emitter) * VFX_EVENT_BYTES
1143
+ );
1144
+ const particleBytes = VFX_PARTICLE_CORE_STRIDE;
1145
+ const maxParticleInputBytes = Math.max(
1146
+ 0,
1147
+ ...renderers.map((renderer) => (renderer.materialInputs?.length ?? 0) * 16)
1148
+ );
1149
+ resources.push(
1150
+ ...diResources,
1151
+ {
1152
+ kind: "compute-program",
1153
+ name: program,
1154
+ program: {
1155
+ wgsl: firstIntent.emitter.wgsl,
1156
+ entryPoints: firstIntent.emitter.reflection.entryPoints,
1157
+ bindings: firstIntent.emitter.reflection.bindings
1158
+ }
1159
+ },
1160
+ {
1161
+ kind: "buffer",
1162
+ name: particles,
1163
+ size: capacity * particleBytes,
1164
+ usage: ["storage"],
1165
+ ...group.initialReset ? { data: resetData(capacity * particleBytes) } : {}
1166
+ },
1167
+ ...parameterBytes === 0 ? [] : [
720
1168
  {
721
1169
  kind: "buffer",
722
- name: sharedInstances,
723
- size: capacity * Math.max(BILLBOARD_INSTANCE_BYTES, MESH_INSTANCE_BYTES),
724
- usage: ["storage", "vertex"]
725
- },
1170
+ name: `${prefix}.parameters`,
1171
+ size: parameterBytes,
1172
+ usage: ["uniform"],
1173
+ data: firstIntent.parameterBlock
1174
+ }
1175
+ ],
1176
+ ...customStride === 0 ? [] : [
726
1177
  {
727
1178
  kind: "buffer",
728
- name: eventInputs,
729
- size: eventInputBytes,
1179
+ name: `${prefix}.custom`,
1180
+ size: capacity * customStride,
730
1181
  usage: ["storage"],
731
- data: encodeEventInputs(intent)
732
- },
1182
+ ...group.initialReset ? { data: resetData(capacity * customStride) } : {}
1183
+ }
1184
+ ],
1185
+ { kind: "buffer", name: aliveIndices, size: capacity * 4, usage: ["storage"] },
1186
+ {
1187
+ kind: "buffer",
1188
+ name: counters,
1189
+ size: COUNTERS_BYTES,
1190
+ usage: ["storage"],
1191
+ ...group.initialReset ? { data: resetData(COUNTERS_BYTES) } : {}
1192
+ },
1193
+ {
1194
+ kind: "buffer",
1195
+ name: indirect,
1196
+ size: indirectWords.byteLength,
1197
+ usage: ["storage", "indirect"],
1198
+ ...group.initialReset ? { data: indirectWords } : {}
1199
+ },
1200
+ {
1201
+ kind: "buffer",
1202
+ name: scratch,
1203
+ size: scratchBytes,
1204
+ usage: ["storage"],
1205
+ ...group.initialReset ? { data: resetData(scratchBytes) } : {}
1206
+ },
1207
+ {
1208
+ kind: "buffer",
1209
+ name: sharedInstances,
1210
+ size: capacity * (Math.max(BILLBOARD_INSTANCE_BYTES, MESH_INSTANCE_BYTES) + maxParticleInputBytes),
1211
+ usage: ["storage", "vertex"]
1212
+ },
1213
+ ...hasEvents ? [
733
1214
  {
734
1215
  kind: "buffer",
735
- name: events,
736
- size: eventBytes,
1216
+ name: projectionEventInputs,
1217
+ size: eventBufferBytes,
737
1218
  usage: ["storage"],
738
- ...intent.reset ? { data: resetData(eventBytes) } : {}
739
- },
1219
+ data: encodeEventBuffer(firstIntent)
1220
+ }
1221
+ ] : []
1222
+ );
1223
+ const entryPoints = new Set(firstIntent.emitter.reflection.entryPoints);
1224
+ for (const [tickIndex, planned] of group.intents.entries()) {
1225
+ if (planned.retained) continue;
1226
+ const tickPrefix = `${prefix}.tick-${tickIndex}`;
1227
+ const tickRuntime = `${tickPrefix}.runtime`;
1228
+ const tickEventInputs = `${tickPrefix}.event-inputs`;
1229
+ const tickBindings = `${tickPrefix}.simulation-bindings`;
1230
+ resources.push(
740
1231
  {
741
1232
  kind: "buffer",
742
- name: runtime,
1233
+ name: tickRuntime,
743
1234
  size: RUNTIME_BYTES,
744
1235
  usage: ["uniform"],
745
- data: runtimeData(intent, entry.camera, void 0, localToWorld)
1236
+ data: runtimeData(
1237
+ planned.intent,
1238
+ group.entry.camera,
1239
+ void 0,
1240
+ planned.localToWorld
1241
+ )
746
1242
  },
1243
+ ...hasEvents ? [
1244
+ {
1245
+ kind: "buffer",
1246
+ name: tickEventInputs,
1247
+ size: eventBufferBytes,
1248
+ usage: ["storage"],
1249
+ data: encodeEventBuffer(planned.intent)
1250
+ }
1251
+ ] : [],
1252
+ ...parameterBytes === 0 ? [] : [
1253
+ {
1254
+ kind: "buffer",
1255
+ name: `${tickPrefix}.parameters`,
1256
+ size: parameterBytes,
1257
+ usage: ["uniform"],
1258
+ data: planned.intent.parameterBlock
1259
+ }
1260
+ ],
747
1261
  {
748
1262
  kind: "compute-bindings",
749
- name: bindings,
1263
+ name: tickBindings,
750
1264
  program,
751
- entries: computeBindingEntries(intent, {
1265
+ entries: computeBindingEntries(planned.intent, {
752
1266
  0: particles,
753
- 1: runtime,
1267
+ 1: tickRuntime,
754
1268
  2: aliveIndices,
755
1269
  3: counters,
756
1270
  4: indirect,
757
1271
  5: scratch,
758
1272
  6: sharedInstances,
759
- 8: eventInputs,
760
- 9: events
1273
+ ...hasEvents ? { 8: tickEventInputs } : {},
1274
+ ...parameterBytes === 0 ? {} : { 10: `${tickPrefix}.parameters` },
1275
+ ...customStride === 0 ? {} : { 11: `${prefix}.custom` },
1276
+ ...diBindings
761
1277
  })
762
1278
  }
763
1279
  );
764
- const entryPoints = new Set(intent.emitter.reflection.entryPoints);
765
- const dispatches = simulationDispatches(intent, stagePlan.value).filter(
1280
+ const dispatches = simulationDispatches(planned.intent, planned.stagePlan).filter(
766
1281
  (dispatch) => entryPoints.has(dispatch.entryPoint)
767
1282
  );
768
- if (dispatches.length > 0) {
769
- passes.push({
770
- kind: "compute",
771
- name: `${prefix}.simulate`,
772
- program,
773
- bindings,
774
- dispatches
775
- });
776
- dispatchedIntents.add(intent);
777
- }
1283
+ if (dispatches.length === 0) continue;
1284
+ passes.push({
1285
+ kind: "compute",
1286
+ name: `${tickPrefix}.simulate`,
1287
+ program,
1288
+ bindings: tickBindings,
1289
+ dispatches
1290
+ });
778
1291
  for (const [rendererIndex, renderer] of renderers.entries()) {
1292
+ if (renderer.enabled === false) continue;
1293
+ if (renderer.kind !== "trail") continue;
1294
+ if (!entryPoints.has("forgeax_vfx_trail_history_main")) continue;
779
1295
  const rendererPrefix = `${prefix}.renderer-${rendererIndex}`;
780
- const isBillboard = renderer.kind === "billboard";
781
- const isTopology = renderer.kind === "ribbon" || renderer.kind === "trail" || renderer.kind === "beam";
782
- const topologyPlan = isTopology ? createTopologyResourcePlan(renderer) : void 0;
783
- if (topologyPlan !== void 0 && !topologyPlan.ok) return err(planFailure());
784
- const material = options.material?.read(entry.world, renderer.material);
785
- const materialPass = particleMaterialPass(renderer.kind, material);
786
- const mesh = meshes[rendererIndex];
787
- const submesh = renderer.kind === "mesh" ? mesh?.submeshes[renderer.submesh ?? 0] : void 0;
788
- const indexFormat = mesh?.indices instanceof Uint32Array ? "uint32" : "uint16";
789
- const sceneDepthBinding = isBillboard ? particleMaterialSceneDepthBinding(
790
- context.materialShaderBindingContract?.(materialPass.shader) ?? (materialPass.shader === PARTICLE_SHADER_IDENTIFIERS.billboard ? "view-and-scene-depth" : void 0)
791
- ) : void 0;
792
- const instances = `${rendererPrefix}.instances`;
793
1296
  const history = `${rendererPrefix}.history`;
794
- const projectionRuntime = `${rendererPrefix}.runtime`;
795
- const projectionBindings = `${rendererPrefix}.compute-bindings`;
796
- const vertexLayout = isBillboard ? RENDER_FEATURE_VERTEX_LAYOUTS.billboardMaterialInstance : isTopology ? RENDER_FEATURE_VERTEX_LAYOUTS.topologySegmentInstance : RENDER_FEATURE_VERTEX_LAYOUTS.meshGeometryMaterialInstance;
797
- const instanceBytes = isTopology ? topologyPlan?.value.vertexBytes ?? 16 : capacity * (isBillboard ? BILLBOARD_INSTANCE_BYTES : MESH_INSTANCE_BYTES);
798
- const historyBytes = renderer.kind === "trail" ? Math.max(16, renderer.capacity * renderer.historyLength * 16) : 16;
1297
+ const historyRuntime = `${tickPrefix}.renderer-${rendererIndex}.history-runtime`;
1298
+ const historyBindings = `${tickPrefix}.renderer-${rendererIndex}.history-bindings`;
799
1299
  resources.push(
800
1300
  {
801
1301
  kind: "buffer",
802
- name: instances,
803
- size: instanceBytes,
804
- usage: ["storage", "vertex"]
805
- },
806
- {
807
- kind: "buffer",
808
- name: history,
809
- size: historyBytes,
810
- usage: ["storage"],
811
- ...intent.reset ? { data: resetData(historyBytes) } : {}
812
- },
813
- {
814
- kind: "buffer",
815
- name: projectionRuntime,
1302
+ name: historyRuntime,
816
1303
  size: RUNTIME_BYTES,
817
1304
  usage: ["uniform"],
818
1305
  data: runtimeData(
819
- { ...intent, fixedDelta: 0, spawnCount: 0 },
820
- entry.camera,
821
- material,
822
- localToWorld,
1306
+ planned.intent,
1307
+ group.entry.camera,
1308
+ void 0,
1309
+ planned.localToWorld,
823
1310
  renderer,
824
1311
  rendererIndex
825
1312
  )
826
1313
  },
827
1314
  {
828
1315
  kind: "compute-bindings",
829
- name: projectionBindings,
1316
+ name: historyBindings,
830
1317
  program,
831
- entries: computeBindingEntries(intent, {
1318
+ entries: computeBindingEntries(planned.intent, {
832
1319
  0: particles,
833
- 1: projectionRuntime,
1320
+ 1: historyRuntime,
834
1321
  2: aliveIndices,
835
1322
  3: counters,
836
1323
  4: indirect,
837
1324
  5: history,
838
- 6: instances,
839
- 8: eventInputs,
840
- 9: events
1325
+ 6: sharedInstances,
1326
+ ...hasEvents ? { 8: tickEventInputs } : {},
1327
+ ...parameterBytes === 0 ? {} : { 10: `${tickPrefix}.parameters` },
1328
+ ...customStride === 0 ? {} : { 11: `${prefix}.custom` },
1329
+ ...diBindings
841
1330
  })
842
1331
  }
843
1332
  );
844
- const projectionDispatches = [];
845
- const pushProjection = (entryPoint, workgroups) => {
846
- if (!entryPoints.has(entryPoint)) return;
847
- projectionDispatches.push({
848
- kind: "direct",
849
- entryPoint,
850
- workgroups: [Math.max(1, workgroups)]
851
- });
852
- };
853
- if (isBillboard && renderer.sorting === "back-to-front") {
854
- pushProjection("forgeax_vfx_sort_main", 1);
1333
+ passes.push({
1334
+ kind: "compute",
1335
+ name: `${historyBindings}.write`,
1336
+ program,
1337
+ bindings: historyBindings,
1338
+ dispatches: [
1339
+ {
1340
+ kind: "direct",
1341
+ entryPoint: "forgeax_vfx_trail_history_main",
1342
+ workgroups: [Math.max(1, Math.ceil(renderer.capacity / WORKGROUP_SIZE))]
1343
+ }
1344
+ ]
1345
+ });
1346
+ }
1347
+ }
1348
+ const latest = group.intents.at(-1) ?? first;
1349
+ for (const [rendererIndex, renderer] of renderers.entries()) {
1350
+ if (renderer.enabled === false) continue;
1351
+ const rendererPrefix = `${prefix}.renderer-${rendererIndex}`;
1352
+ const isBillboard = renderer.kind === "billboard";
1353
+ const isTopology = renderer.kind === "ribbon" || renderer.kind === "trail" || renderer.kind === "beam";
1354
+ const topologyPlan = isTopology ? createTopologyResourcePlan(renderer) : void 0;
1355
+ if (topologyPlan !== void 0 && !topologyPlan.ok) return err(planFailure());
1356
+ const material = options.material?.read(group.entry.world, renderer.material);
1357
+ const reflectedRenderer = firstIntent.emitter.reflection.renderers?.[rendererIndex];
1358
+ const preparedInputs = prepareParticleMaterialInputs(
1359
+ renderer,
1360
+ material,
1361
+ reflectedRenderer?.materialInputDefinitions
1362
+ );
1363
+ if (!preparedInputs.ok) return err(planFailure());
1364
+ const hasParticleInputs = preparedInputs.value.lanes > 0;
1365
+ const materialPass = particleMaterialPass(renderer.kind, material, hasParticleInputs);
1366
+ const mesh = meshes[rendererIndex];
1367
+ const submesh = renderer.kind === "mesh" ? mesh?.submeshes[renderer.submesh ?? 0] : void 0;
1368
+ const indexFormat = mesh?.indices instanceof Uint32Array ? "uint32" : "uint16";
1369
+ const sceneDepthBinding = particleMaterialSceneDepthBinding(
1370
+ context.materialShaderBindingContract?.(materialPass.shader) ?? (materialPass.shader === PARTICLE_SHADER_IDENTIFIERS.billboard ? "view-and-scene-depth" : void 0)
1371
+ );
1372
+ const instances = `${rendererPrefix}.instances`;
1373
+ const history = `${rendererPrefix}.history`;
1374
+ const projectionRuntime = `${rendererPrefix}.runtime`;
1375
+ const projectionBindings = `${rendererPrefix}.compute-bindings`;
1376
+ const vertexLayout = isBillboard ? hasParticleInputs ? RENDER_FEATURE_VERTEX_LAYOUTS.billboardMaterialInputInstance : RENDER_FEATURE_VERTEX_LAYOUTS.billboardMaterialInstance : isTopology ? hasParticleInputs ? RENDER_FEATURE_VERTEX_LAYOUTS.topologySegmentMaterialInputInstance : RENDER_FEATURE_VERTEX_LAYOUTS.topologySegmentInstance : hasParticleInputs ? RENDER_FEATURE_VERTEX_LAYOUTS.meshGeometryMaterialInputInstance : RENDER_FEATURE_VERTEX_LAYOUTS.meshGeometryMaterialInstance;
1377
+ const inputBytes = preparedInputs.value.stride;
1378
+ const instanceBytes = isTopology ? (() => {
1379
+ const vertexCount = Math.max(
1380
+ 1,
1381
+ Math.floor((topologyPlan?.value.vertexBytes ?? 16) / 48)
1382
+ );
1383
+ return vertexCount * (48 + inputBytes);
1384
+ })() : capacity * ((isBillboard ? BILLBOARD_INSTANCE_BYTES : MESH_INSTANCE_BYTES) + inputBytes);
1385
+ const historyBytes = renderer.kind === "trail" ? capacity * (Math.max(2, renderer.historyLength) + 1) * 16 : 16;
1386
+ resources.push(
1387
+ {
1388
+ kind: "buffer",
1389
+ name: instances,
1390
+ size: instanceBytes,
1391
+ usage: ["storage", "vertex"]
1392
+ },
1393
+ {
1394
+ kind: "buffer",
1395
+ name: history,
1396
+ size: historyBytes,
1397
+ usage: ["storage"],
1398
+ ...group.initialReset ? { data: resetData(historyBytes) } : {}
855
1399
  }
856
- if (renderer.kind === "trail") {
857
- pushProjection(
858
- "forgeax_vfx_trail_history_main",
859
- Math.ceil(renderer.capacity / WORKGROUP_SIZE)
860
- );
1400
+ );
1401
+ const castsShadow = renderer.kind === "mesh" && renderer.castShadows;
1402
+ if (!latest.visible && !castsShadow) continue;
1403
+ resources.push(
1404
+ {
1405
+ kind: "buffer",
1406
+ name: projectionRuntime,
1407
+ size: RUNTIME_BYTES,
1408
+ usage: ["uniform"],
1409
+ data: runtimeData(
1410
+ { ...latest.intent, fixedDelta: 0, spawnCount: 0 },
1411
+ group.entry.camera,
1412
+ material,
1413
+ latest.localToWorld,
1414
+ renderer,
1415
+ rendererIndex,
1416
+ preparedInputs.value.lanes,
1417
+ renderer.kind === "mesh" && submesh !== void 0 ? {
1418
+ count: mesh?.indices === void 0 ? submesh.vertexCount : submesh.indexCount,
1419
+ firstIndex: mesh?.indices === void 0 ? 0 : submesh.indexOffset
1420
+ } : void 0
1421
+ )
1422
+ },
1423
+ {
1424
+ kind: "compute-bindings",
1425
+ name: projectionBindings,
1426
+ program,
1427
+ entries: computeBindingEntries(latest.intent, {
1428
+ 0: particles,
1429
+ 1: projectionRuntime,
1430
+ 2: aliveIndices,
1431
+ 3: counters,
1432
+ 4: indirect,
1433
+ 5: history,
1434
+ 6: instances,
1435
+ ...hasEvents ? { 8: projectionEventInputs } : {},
1436
+ ...parameterBytes === 0 ? {} : { 10: `${prefix}.parameters` },
1437
+ ...customStride === 0 ? {} : { 11: `${prefix}.custom` },
1438
+ ...diBindings
1439
+ })
861
1440
  }
862
- const projectionCount = renderer.kind === "trail" ? renderer.capacity * Math.max(1, renderer.historyLength - 1) : isTopology ? renderer.capacity : capacity;
863
- pushProjection(
864
- renderer.kind === "billboard" ? "forgeax_vfx_billboard_main" : renderer.kind === "mesh" ? "forgeax_vfx_mesh_main" : `forgeax_vfx_${renderer.kind}_main`,
865
- Math.ceil(projectionCount / WORKGROUP_SIZE)
1441
+ );
1442
+ const projectionDispatches = [];
1443
+ const pushProjection = (entryPoint, workgroups) => {
1444
+ if (!entryPoints.has(entryPoint)) return;
1445
+ projectionDispatches.push({
1446
+ kind: "direct",
1447
+ entryPoint,
1448
+ workgroups: [Math.max(1, workgroups)]
1449
+ });
1450
+ };
1451
+ if (isBillboard && (renderer.sorting === "view-depth" || renderer.sorting === "view-distance" || renderer.sorting === "custom-ascending" || renderer.sorting === "custom-descending")) {
1452
+ pushProjection("forgeax_vfx_sort_main", 1);
1453
+ }
1454
+ if (renderer.kind === "trail") pushProjection("forgeax_vfx_trail_offsets_main", 1);
1455
+ const projectionCount = renderer.kind === "trail" ? renderer.capacity * Math.max(1, renderer.historyLength - 1) : isTopology ? renderer.capacity : capacity;
1456
+ pushProjection(
1457
+ renderer.kind === "billboard" ? "forgeax_vfx_billboard_main" : renderer.kind === "mesh" ? "forgeax_vfx_mesh_main" : `forgeax_vfx_${renderer.kind}_main`,
1458
+ Math.ceil(projectionCount / WORKGROUP_SIZE)
1459
+ );
1460
+ if (projectionDispatches.length > 0) {
1461
+ passes.push({
1462
+ kind: "compute",
1463
+ name: `${rendererPrefix}.project`,
1464
+ program,
1465
+ bindings: projectionBindings,
1466
+ dispatches: projectionDispatches
1467
+ });
1468
+ }
1469
+ const graphicsProgram = `${rendererPrefix}.graphics-program`;
1470
+ const graphicsBindings = `${rendererPrefix}.graphics-bindings`;
1471
+ const vertexData = `${rendererPrefix}.vertex-data`;
1472
+ const defaultRenderState = particleRendererRenderState(
1473
+ renderer.kind,
1474
+ renderer.kind === "billboard" ? renderer.blend : void 0,
1475
+ materialPass.renderState
1476
+ );
1477
+ const renderState = isBillboard && depthTarget !== void 0 ? { ...defaultRenderState ?? {}, depthWriteEnabled: false } : defaultRenderState;
1478
+ resources.push(
1479
+ {
1480
+ kind: "graphics-program",
1481
+ name: graphicsProgram,
1482
+ program: {
1483
+ shader: materialPass.shader,
1484
+ vertexLayout,
1485
+ ...hasParticleInputs ? { particleInputLanes: preparedInputs.value.lanes } : {},
1486
+ colorFormats: [colorTarget?.format ?? "rgba8unorm-srgb"],
1487
+ ...depthTarget === void 0 ? {} : { depthFormat: depthTarget.format },
1488
+ sampleCount: colorTarget?.sampleCount ?? 1,
1489
+ topology: submesh?.topology ?? "triangle-list",
1490
+ ...mesh?.indices === void 0 ? {} : { indexFormat },
1491
+ ...renderState === void 0 ? {} : { renderState }
1492
+ }
1493
+ },
1494
+ {
1495
+ kind: "graphics-bindings",
1496
+ name: graphicsBindings,
1497
+ program: graphicsProgram,
1498
+ values: {
1499
+ group: 0,
1500
+ runtime: projectionRuntime,
1501
+ instances,
1502
+ ...sceneDepthBinding === void 0 ? {} : { sceneDepthBinding }
1503
+ },
1504
+ ...sceneDepthBinding !== void 0 && depthTarget !== void 0 ? { logicalTargets: { sceneDepth: depthTarget.name } } : {}
1505
+ },
1506
+ { kind: "vertex-data", name: vertexData, layout: vertexLayout, buffer: instances }
1507
+ );
1508
+ const drawBindings = [graphicsBindings];
1509
+ if (particleMaterialUsesBindings(material) || materialPass.shader === PARTICLE_SHADER_IDENTIFIERS.mesh || materialPass.shader === PARTICLE_INPUT_SHADER_IDENTIFIERS.mesh) {
1510
+ const materialBindings = `${rendererPrefix}.material-bindings.w-${group.worldIndex}`;
1511
+ resources.push({
1512
+ kind: "graphics-bindings",
1513
+ name: materialBindings,
1514
+ program: graphicsProgram,
1515
+ values: {
1516
+ group: 1,
1517
+ material: { world: group.worldIndex, guid: renderer.material }
1518
+ }
1519
+ });
1520
+ drawBindings.push(materialBindings);
1521
+ }
1522
+ const vertexBindings = [];
1523
+ let indexData;
1524
+ if (renderer.kind === "mesh") {
1525
+ if (mesh === void 0) return err(planFailure());
1526
+ const geometryBuffer = `${rendererPrefix}.geometry-buffer`;
1527
+ const geometry = `${rendererPrefix}.geometry`;
1528
+ const geometryData = canonicalMeshVertices(mesh);
1529
+ resources.push(
1530
+ {
1531
+ kind: "buffer",
1532
+ name: geometryBuffer,
1533
+ size: geometryData.byteLength,
1534
+ usage: ["vertex"],
1535
+ data: geometryData
1536
+ },
1537
+ {
1538
+ kind: "vertex-data",
1539
+ name: geometry,
1540
+ layout: vertexLayout,
1541
+ buffer: geometryBuffer
1542
+ }
866
1543
  );
867
- if (projectionDispatches.length > 0) {
868
- passes.push({
869
- kind: "compute",
870
- name: `${rendererPrefix}.project`,
871
- program,
872
- bindings: projectionBindings,
873
- dispatches: projectionDispatches
874
- });
1544
+ vertexBindings.push({ slot: 0, resource: geometry }, { slot: 1, resource: vertexData });
1545
+ if (mesh.indices !== void 0) {
1546
+ const indexBuffer = `${rendererPrefix}.index-buffer`;
1547
+ const indices = `${rendererPrefix}.indices`;
1548
+ resources.push(
1549
+ {
1550
+ kind: "buffer",
1551
+ name: indexBuffer,
1552
+ size: mesh.indices.byteLength,
1553
+ usage: ["index"],
1554
+ data: mesh.indices
1555
+ },
1556
+ {
1557
+ kind: "index-data",
1558
+ name: indices,
1559
+ format: indexFormat,
1560
+ buffer: indexBuffer
1561
+ }
1562
+ );
1563
+ indexData = { resource: indices, format: indexFormat };
875
1564
  }
876
- const graphicsProgram = `${rendererPrefix}.graphics-program`;
877
- const graphicsBindings = `${rendererPrefix}.graphics-bindings`;
878
- const vertexData = `${rendererPrefix}.vertex-data`;
879
- const defaultRenderState = particleRendererRenderState(
880
- renderer.kind,
881
- renderer.kind === "billboard" ? renderer.blend : void 0,
882
- materialPass.renderState
883
- );
884
- const renderState = isBillboard && depthTarget !== void 0 ? { ...defaultRenderState ?? {}, depthWriteEnabled: false } : defaultRenderState;
1565
+ } else {
1566
+ vertexBindings.push({ slot: 0, resource: vertexData });
1567
+ }
1568
+ if (castsShadow) {
1569
+ const shadowProgram = `${rendererPrefix}.shadow-program`;
1570
+ const shadowBindings = `${rendererPrefix}.shadow-bindings`;
885
1571
  resources.push(
886
1572
  {
887
1573
  kind: "graphics-program",
888
- name: graphicsProgram,
1574
+ name: shadowProgram,
889
1575
  program: {
890
- shader: materialPass.shader,
1576
+ shader: "forgeax::vfx-render.particles.mesh-shadow",
891
1577
  vertexLayout,
892
- colorFormats: [colorTarget?.format ?? "rgba8unorm-srgb"],
893
- ...depthTarget === void 0 ? {} : { depthFormat: depthTarget.format },
894
- sampleCount: colorTarget?.sampleCount ?? 1,
1578
+ ...hasParticleInputs ? { particleInputLanes: preparedInputs.value.lanes } : {},
1579
+ colorFormats: [],
1580
+ depthFormat: "depth32float",
895
1581
  topology: submesh?.topology ?? "triangle-list",
896
1582
  ...mesh?.indices === void 0 ? {} : { indexFormat },
897
- ...renderState === void 0 ? {} : { renderState }
1583
+ renderState: { depthWriteEnabled: true, cullMode: "none" }
898
1584
  }
899
1585
  },
900
1586
  {
901
1587
  kind: "graphics-bindings",
902
- name: graphicsBindings,
903
- program: graphicsProgram,
904
- values: {
905
- group: 0,
906
- runtime: projectionRuntime,
907
- instances,
908
- ...sceneDepthBinding === void 0 ? {} : { sceneDepthBinding }
909
- },
910
- ...sceneDepthBinding !== void 0 && depthTarget !== void 0 ? { logicalTargets: { sceneDepth: depthTarget.name } } : {}
911
- },
912
- { kind: "vertex-data", name: vertexData, layout: vertexLayout, buffer: instances }
1588
+ name: shadowBindings,
1589
+ program: shadowProgram,
1590
+ values: { group: 0, runtime: projectionRuntime, instances }
1591
+ }
913
1592
  );
914
- const drawBindings = [graphicsBindings];
915
- if (particleMaterialUsesBindings(material)) {
916
- const materialBindings = `${rendererPrefix}.material-bindings`;
917
- resources.push({
918
- kind: "graphics-bindings",
919
- name: materialBindings,
920
- program: graphicsProgram,
921
- values: {
922
- group: 1,
923
- material: { world: worldIndex, guid: renderer.material }
924
- }
925
- });
926
- drawBindings.push(materialBindings);
927
- }
928
- const vertexBindings = [];
929
- let indexData;
930
- if (renderer.kind === "mesh") {
931
- if (mesh === void 0) return err(planFailure());
932
- const geometryBuffer = `${rendererPrefix}.geometry-buffer`;
933
- const geometry = `${rendererPrefix}.geometry`;
934
- const geometryData = canonicalMeshVertices(mesh);
935
- resources.push(
936
- {
937
- kind: "buffer",
938
- name: geometryBuffer,
939
- size: geometryData.byteLength,
940
- usage: ["vertex"],
941
- data: geometryData
942
- },
1593
+ passes.push({
1594
+ kind: "shadow-caster",
1595
+ name: `${rendererPrefix}.shadow`,
1596
+ draws: [
943
1597
  {
944
- kind: "vertex-data",
945
- name: geometry,
946
- layout: vertexLayout,
947
- buffer: geometryBuffer
948
- }
949
- );
950
- vertexBindings.push(
951
- { slot: 0, resource: geometry },
952
- { slot: 1, resource: vertexData }
953
- );
954
- if (mesh.indices !== void 0) {
955
- const indexBuffer = `${rendererPrefix}.index-buffer`;
956
- const indices = `${rendererPrefix}.indices`;
957
- resources.push(
958
- {
959
- kind: "buffer",
960
- name: indexBuffer,
961
- size: mesh.indices.byteLength,
962
- usage: ["index"],
963
- data: mesh.indices
964
- },
965
- {
966
- kind: "index-data",
967
- name: indices,
968
- format: indexFormat,
969
- buffer: indexBuffer
1598
+ program: shadowProgram,
1599
+ bindings: [shadowBindings],
1600
+ vertexData: vertexBindings,
1601
+ ...indexData === void 0 ? {} : { indexData },
1602
+ draw: {
1603
+ kind: indexData === void 0 ? "draw-indirect" : "draw-indexed-indirect",
1604
+ resource: indirect,
1605
+ offset: rendererIndex * 20
970
1606
  }
971
- );
972
- indexData = { resource: indices, format: indexFormat };
973
- }
974
- } else {
975
- vertexBindings.push({ slot: 0, resource: vertexData });
976
- }
1607
+ }
1608
+ ]
1609
+ });
1610
+ }
1611
+ if (latest.visible)
977
1612
  passes.push({
978
1613
  kind: "raster",
979
1614
  name: `${rendererPrefix}.raster`,
@@ -1006,21 +1641,50 @@ function gpuParticleRenderFeature(options) {
1006
1641
  }
1007
1642
  ]
1008
1643
  });
1009
- }
1010
1644
  }
1011
1645
  }
1012
- for (const entry of frame.worlds) {
1013
- for (const intent of entry.intents) {
1014
- if (dispatchedIntents.has(intent)) {
1015
- entry.runtime.markEventDispatched(intent.player, intent.eventCounters);
1646
+ pendingFrames.set(
1647
+ frame.frameNumber,
1648
+ frame.worlds.map((entry) => ({
1649
+ world: entry.world,
1650
+ runtime: entry.runtime,
1651
+ outcomes: outcomesByEntry.get(entry) ?? entry.intents.map((intent) => ({ intent, state: "deferred" }))
1652
+ }))
1653
+ );
1654
+ return ok({ resources, passes });
1655
+ },
1656
+ onFrameSubmitted: (frame) => {
1657
+ const pending = pendingFrames.get(frame.frameNumber);
1658
+ if (pending === void 0) return;
1659
+ pendingFrames.delete(frame.frameNumber);
1660
+ for (const entry of pending) {
1661
+ const acknowledgedSequences = [];
1662
+ const publishedSequences = [];
1663
+ for (const outcome of entry.outcomes) {
1664
+ if (outcome.state === "deferred") continue;
1665
+ acknowledgedSequences.push(outcome.intent.sequence);
1666
+ if (outcome.state === "dispatched") {
1667
+ publishedSequences.push(outcome.intent.sequence);
1668
+ entry.runtime.markEventDispatched(outcome.intent.player, outcome.intent.eventCounters);
1669
+ if (outcome.resetEpoch !== void 0) {
1670
+ const baseKey = `${worldId(entry.world)}:${entry.runtime.renderGeneration ?? 0}:${Number(outcome.intent.player)}:${outcome.intent.emitter.id}`;
1671
+ resetEpochByEmitter.get(entry.runtime)?.set(baseKey, outcome.resetEpoch);
1672
+ resetEpochByIntent.get(entry.runtime)?.delete(outcome.intent.sequence);
1673
+ }
1674
+ } else if (outcome.state === "skipped" && outcome.resetEpoch !== void 0) {
1675
+ resetEpochByIntent.get(entry.runtime)?.delete(outcome.intent.sequence);
1016
1676
  }
1017
1677
  }
1018
- const lastIntent = entry.intents.at(-1);
1019
- if (lastIntent !== void 0) entry.runtime.commit(lastIntent.sequence);
1678
+ if (acknowledgedSequences.length > 0) {
1679
+ entry.runtime.commit(acknowledgedSequences, publishedSequences);
1680
+ }
1020
1681
  }
1021
- return ok({ resources, passes });
1682
+ },
1683
+ onFrameAborted: (frame) => {
1684
+ pendingFrames.delete(frame.frameNumber);
1022
1685
  }
1023
1686
  };
1687
+ return feature;
1024
1688
  }
1025
1689
  function duplicate(token, providerId) {
1026
1690
  return {
@@ -1045,11 +1709,22 @@ function availableProvider(token, kind, bindingType, source) {
1045
1709
  detail: { token, providerId: `${token}-provider` }
1046
1710
  });
1047
1711
  }
1712
+ const prepared = source.resource?.(generation);
1713
+ if (prepared === void 0) {
1714
+ return err({
1715
+ code: "vfx-data-interface-missing",
1716
+ expected: `a resident ${kind} resource for ${token}`,
1717
+ hint: `provide the generation-owned ${kind} resource before rendering`,
1718
+ detail: { token, providerId: `${token}-provider` }
1719
+ });
1720
+ }
1048
1721
  const resource = {
1049
1722
  token,
1050
1723
  kind,
1051
1724
  bindingType,
1052
- generation
1725
+ generation,
1726
+ ...source.sampleCount === void 0 ? {} : { sampleCount: source.sampleCount },
1727
+ resource: prepared
1053
1728
  };
1054
1729
  return ok(resource);
1055
1730
  }
@@ -1061,6 +1736,9 @@ function createCameraProvider(source) {
1061
1736
  function createSceneDepthProvider(source) {
1062
1737
  return availableProvider("vfx:scene-depth", "scene-depth", "sampled-depth", source);
1063
1738
  }
1739
+ function createNoiseProvider(source) {
1740
+ return availableProvider("vfx:noise", "noise", "sampled-float", source);
1741
+ }
1064
1742
  function createVfxDataInterfaceRegistry(initialProviders = []) {
1065
1743
  const entries = /* @__PURE__ */ new Map();
1066
1744
  let registrationError;
@@ -1221,6 +1899,33 @@ function createVfxRuntimeHost(options) {
1221
1899
  }
1222
1900
  return ok(action(world.getResource(VFX_GPU_RUNTIME_RESOURCE_KEY)));
1223
1901
  };
1902
+ const withInstance = (player, action) => {
1903
+ const result = withRuntime(player, (runtime) => {
1904
+ const instance = runtime.getInstance(player);
1905
+ if (instance === void 0) {
1906
+ return { kind: "error", causeCode: "vfx-instance-unavailable" };
1907
+ }
1908
+ const outcome = action(instance);
1909
+ return outcome.ok ? { kind: "value", value: outcome.value } : { kind: "error", causeCode: outcome.error.code };
1910
+ });
1911
+ if (!result.ok) return result;
1912
+ if (result.value.kind === "error") {
1913
+ return err(
1914
+ controlFailure(
1915
+ "vfx-host-control-instance-rejected",
1916
+ "the live typed VFX instance to accept this control input",
1917
+ "run one fixed tick or repair the reflected instance contract before retrying",
1918
+ {
1919
+ requestedGeneration,
1920
+ currentGeneration: requestedGeneration,
1921
+ player,
1922
+ causeCode: result.value.causeCode
1923
+ }
1924
+ )
1925
+ );
1926
+ }
1927
+ return ok(result.value.value);
1928
+ };
1224
1929
  const control = {
1225
1930
  generation: requestedGeneration,
1226
1931
  replay: ({ player, replayInput }) => withRuntime(player, (runtime) => {
@@ -1248,6 +1953,21 @@ function createVfxRuntimeHost(options) {
1248
1953
  state: enabled ? "enabled" : "paused",
1249
1954
  generation: requestedGeneration
1250
1955
  });
1956
+ }),
1957
+ patchPlayerParameters: ({ player, values }) => withInstance(player, (instance) => {
1958
+ const patched = instance.patch(values);
1959
+ if (!patched.ok) return patched;
1960
+ return ok({
1961
+ state: "queued",
1962
+ generation: requestedGeneration,
1963
+ parameterGeneration: instance.generation,
1964
+ pendingPatchCount: instance.pendingPatchCount
1965
+ });
1966
+ }),
1967
+ submitChannel: ({ player, channel, payload, sequence }) => withInstance(player, (instance) => {
1968
+ const submitted = instance.submit({ channel, payload, sequence });
1969
+ if (!submitted.ok) return submitted;
1970
+ return ok({ state: "queued", generation: requestedGeneration });
1251
1971
  })
1252
1972
  };
1253
1973
  return ok(Object.freeze(control));
@@ -1269,7 +1989,7 @@ function createVfxRuntimeHost(options) {
1269
1989
  return err(
1270
1990
  failure2(
1271
1991
  "vfx-host-loader-install-failed",
1272
- "the v2 VFX loader to be registered once",
1992
+ "the Program v3 VFX loader to be registered once",
1273
1993
  "remove a conflicting particle-effect loader and retry attachWorld",
1274
1994
  cause
1275
1995
  )
@@ -1348,6 +2068,6 @@ function createVfxRuntimeHost(options) {
1348
2068
  };
1349
2069
  }
1350
2070
 
1351
- export { PARTICLE_SHADER_IDENTIFIERS, VFX_EVENT_BYTES, VFX_EVENT_COUNTER_BYTES, VFX_EVENT_INPUT_BYTES, createCameraProvider, createSceneDepthProvider, createTopologyResourcePlan, createVfxDataInterfaceRegistry, createVfxRenderInspectSnapshot, createVfxRuntimeHost, encodeEventInputs, eventCapacity, eventCounterData, eventInputCapacity, gpuParticleRenderFeature, installVfxRuntimeDecoder, observeStagePlan, resolveBillboardAdvancedState, stageDispatches, stageRecoveryReadiness, topologyCapacitySnapshot, topologyRecoveryHint, validatedStagePlan };
2071
+ export { PARTICLE_INPUT_SHADER_IDENTIFIERS, PARTICLE_SHADER_IDENTIFIERS, VFX_EVENT_BYTES, VFX_EVENT_COUNTER_BYTES, VFX_EVENT_INPUT_BYTES, createCameraProvider, createNoiseProvider, createSceneDepthProvider, createTopologyResourcePlan, createVfxDataInterfaceRegistry, createVfxRenderInspectSnapshot, createVfxRuntimeHost, encodeEventBuffer, encodeEventInputs, eventCapacity, eventCounterData, eventInputCapacity, gpuParticleRenderFeature, installVfxRuntimeDecoder, observeStagePlan, prepareParticleMaterialInputs, resolveBillboardAdvancedState, stageDispatches, stageRecoveryReadiness, topologyCapacitySnapshot, topologyRecoveryHint, validatedStagePlan };
1352
2072
  //# sourceMappingURL=index.mjs.map
1353
2073
  //# sourceMappingURL=index.mjs.map