@forgeax/engine-vfx 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 (53) hide show
  1. package/README.md +62 -14
  2. package/dist/assets/particle-effect-decoder.d.ts.map +1 -1
  3. package/dist/authoring-descriptor.d.ts +4 -4
  4. package/dist/authoring-descriptor.d.ts.map +1 -1
  5. package/dist/code-source-v3.d.ts +101 -0
  6. package/dist/code-source-v3.d.ts.map +1 -0
  7. package/dist/code-source.d.ts +12 -11
  8. package/dist/code-source.d.ts.map +1 -1
  9. package/dist/data-interface.d.ts +17 -1
  10. package/dist/data-interface.d.ts.map +1 -1
  11. package/dist/effect-contract.d.ts +11 -1
  12. package/dist/effect-contract.d.ts.map +1 -1
  13. package/dist/gpu-loader.d.ts +5 -5
  14. package/dist/gpu-loader.d.ts.map +1 -1
  15. package/dist/gpu-program.d.ts +52 -27
  16. package/dist/gpu-program.d.ts.map +1 -1
  17. package/dist/gpu-runtime.d.ts +32 -7
  18. package/dist/gpu-runtime.d.ts.map +1 -1
  19. package/dist/index.d.ts +10 -6
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.mjs +647 -117
  22. package/dist/index.mjs.map +1 -1
  23. package/dist/particle-layout.d.ts +62 -0
  24. package/dist/particle-layout.d.ts.map +1 -0
  25. package/package.json +5 -5
  26. package/src/__tests__/authoring-descriptor.unit.test.ts +18 -8
  27. package/src/__tests__/channel-input.unit.test.ts +4 -1
  28. package/src/__tests__/channel-overflow-policy-owner.test-d.ts +2 -2
  29. package/src/__tests__/code-source-v2.unit.test.ts +15 -14
  30. package/src/__tests__/data-interface-contract.unit.test.ts +41 -2
  31. package/src/__tests__/effect-contract.unit.test.ts +6 -3
  32. package/src/__tests__/gpu-program-simulation-culling-owner.test-d.ts +6 -6
  33. package/src/__tests__/gpu-reflection-vocabulary-owner.test-d.ts +2 -2
  34. package/src/__tests__/gpu-runtime.integration.test.ts +244 -11
  35. package/src/__tests__/instance-public-api.unit.test.ts +4 -1
  36. package/src/__tests__/instance.unit.test.ts +4 -1
  37. package/src/__tests__/loader-v1-rejection.unit.test.ts +14 -14
  38. package/src/__tests__/player-reflection.unit.test.ts +2 -2
  39. package/src/__tests__/player-type.test-d.ts +4 -1
  40. package/src/__tests__/renderer-source.unit.test.ts +6 -6
  41. package/src/__tests__/replay-canonical.unit.test.ts +4 -1
  42. package/src/__tests__/vfx-data-interface-vocabulary-owner.test-d.ts +3 -8
  43. package/src/assets/particle-effect-decoder.ts +4 -3
  44. package/src/authoring-descriptor.ts +26 -28
  45. package/src/code-source-v3.ts +460 -0
  46. package/src/code-source.ts +23 -27
  47. package/src/data-interface.ts +66 -1
  48. package/src/effect-contract.ts +73 -20
  49. package/src/gpu-loader.ts +61 -35
  50. package/src/gpu-program.ts +57 -35
  51. package/src/gpu-runtime.ts +115 -42
  52. package/src/index.ts +53 -9
  53. package/src/particle-layout.ts +214 -0
package/dist/index.mjs CHANGED
@@ -8,13 +8,13 @@ var particleEffectContribution = {
8
8
  decoder: {
9
9
  async decode({ envelope }) {
10
10
  const payload = envelope.payload;
11
- if (payload.kind === "particle-effect" && payload.schemaVersion === 2 && payload.emitters.length === payload.program.emitters.length && payload.programFingerprint === payload.program.fingerprint) {
11
+ if (payload.kind === "particle-effect" && payload.schemaVersion === 3 && payload.program.format === "forgeax-vfx-program-3" && payload.emitters.length === payload.program.emitters.length && payload.programFingerprint === payload.program.fingerprint) {
12
12
  return ok(payload);
13
13
  }
14
14
  return err({
15
15
  code: "asset-package-invalid",
16
- expected: "a schema v2 particle payload matching its cooked program",
17
- hint: "recook the particle effect atomically with its GPU program",
16
+ expected: "a schemaVersion 3 particle payload matching its cooked Program v3",
17
+ hint: "cold-cook the legacy payload with the Program v3 compiler and publish atomically",
18
18
  detail: { guid: envelope.guid, reason: "particle owner validation failed" }
19
19
  });
20
20
  }
@@ -22,31 +22,26 @@ var particleEffectContribution = {
22
22
  };
23
23
 
24
24
  // src/authoring-descriptor.ts
25
- var CAPABILITIES = Object.freeze([
25
+ var V3_CAPABILITIES = Object.freeze([
26
26
  Object.freeze({ id: "wgsl-behavior", state: "executable" }),
27
27
  Object.freeze({ id: "multi-emitter", state: "executable" }),
28
28
  Object.freeze({ id: "deterministic-replay", state: "executable" }),
29
29
  Object.freeze({ id: "emitter-visibility", state: "executable" }),
30
- Object.freeze({
31
- id: "runtime-parameters",
32
- state: "partial",
33
- reason: "reflected and packed, but not yet bound to managed author WGSL"
34
- }),
35
- Object.freeze({
36
- id: "custom-attributes",
37
- state: "partial",
38
- reason: "reflected without executable per-particle custom storage"
39
- }),
30
+ Object.freeze({ id: "runtime-parameters", state: "executable" }),
31
+ Object.freeze({ id: "custom-attributes", state: "executable" }),
32
+ Object.freeze({ id: "renderer-semantics", state: "executable" }),
33
+ Object.freeze({ id: "material-particle-inputs", state: "executable" }),
40
34
  Object.freeze({
41
35
  id: "data-interfaces",
42
36
  state: "partial",
43
- reason: "requirements resolve provider readiness without author resource bindings"
37
+ reason: "camera, single-sample scene depth, and noise require generation-owned providers"
44
38
  })
45
39
  ]);
46
40
  function isVfxGpuEffectAsset(value) {
47
41
  if (typeof value !== "object" || value === null) return false;
48
42
  const candidate = value;
49
- return candidate.kind === "particle-effect" && candidate.schemaVersion === 2 && typeof candidate.guid === "string" && candidate.guid.length > 0 && typeof candidate.program === "object" && candidate.program !== null && candidate.program.format === "forgeax-vfx-program-2" && typeof candidate.program.fingerprint === "string" && Array.isArray(candidate.program.emitters);
43
+ const program = candidate.program;
44
+ return candidate.kind === "particle-effect" && candidate.schemaVersion === 3 && typeof candidate.guid === "string" && candidate.guid.length > 0 && typeof program === "object" && program !== null && program.format === "forgeax-vfx-program-3" && typeof program.fingerprint === "string" && Array.isArray(program.emitters);
50
45
  }
51
46
  function label(path) {
52
47
  const leaf = path.split(".").at(-1) ?? path;
@@ -243,7 +238,7 @@ function describeVfxGpuEffect(effect) {
243
238
  return Object.freeze({
244
239
  version: 1,
245
240
  assetGuid: effect.guid,
246
- schemaVersion: 2,
241
+ schemaVersion: effect.schemaVersion,
247
242
  artifactFingerprint: effect.program.fingerprint,
248
243
  emitters: Object.freeze(effect.program.emitters.map(emitterNode)),
249
244
  timeline: Object.freeze(
@@ -257,7 +252,7 @@ function describeVfxGpuEffect(effect) {
257
252
  )
258
253
  ),
259
254
  dependencies: dependencies(effect),
260
- capabilities: CAPABILITIES
255
+ capabilities: V3_CAPABILITIES
261
256
  });
262
257
  }
263
258
  var PARTICLE_CODE_DEFAULT_MODULE_ID = "forgeax_vfx::default";
@@ -326,8 +321,8 @@ function parseStageResources(value, path, stageId) {
326
321
  const resources = [];
327
322
  const names = /* @__PURE__ */ new Set();
328
323
  for (const item of value.split(",")) {
329
- const [name, access, extra] = item.split(":");
330
- if (name === void 0 || access === void 0 || extra !== void 0 || !STAGE_RESOURCE_NAMES.has(name) || access !== "read" && access !== "write" && access !== "read-write" || names.has(name)) {
324
+ const [name, access, extra2] = item.split(":");
325
+ if (name === void 0 || access === void 0 || extra2 !== void 0 || !STAGE_RESOURCE_NAMES.has(name) || access !== "read" && access !== "write" && access !== "read-write" || names.has(name)) {
331
326
  return stageInvalid(
332
327
  path,
333
328
  "known resources with unique read, write, or read-write access",
@@ -408,8 +403,8 @@ function parseVfxStageDeclarations(source) {
408
403
  }
409
404
  function parseEmitter(value, index, ids) {
410
405
  const path = `emitters[${index}]`;
411
- if (!record(value)) return invalid(path, "a v2 emitter object");
412
- const extra = allowed(value, [
406
+ if (!record(value)) return invalid(path, "a Program v3 emitter object");
407
+ const extra2 = allowed(value, [
413
408
  "id",
414
409
  "capacity",
415
410
  "backend",
@@ -422,7 +417,7 @@ function parseEmitter(value, index, ids) {
422
417
  "events",
423
418
  "simulationWhenCulled"
424
419
  ]);
425
- if (extra !== void 0) return invalid(`${path}.${extra}`, "a v2 emitter field");
420
+ if (extra2 !== void 0) return invalid(`${path}.${extra2}`, "a Program v3 emitter field");
426
421
  if (!text(value.id) || ids.has(value.id)) return invalid(`${path}.id`, "a unique non-empty id");
427
422
  const id = value.id;
428
423
  ids.add(id);
@@ -722,20 +717,12 @@ function parseEmitter(value, index, ids) {
722
717
  }
723
718
  return ok(value);
724
719
  }
725
- function parseParticleEffectSourceV2(value) {
720
+ function parseParticleEffectSourceStructure(value) {
726
721
  if (!record(value)) return invalid("$", "a particle effect source object");
727
- if (value.schemaVersion !== 2) {
728
- return err({
729
- code: "vfx-source-version-unsupported",
730
- expected: "ParticleEffectSource schemaVersion 2",
731
- hint: "migrate code behavior to WGSL and recook; v1 is not interpreted at runtime",
732
- detail: { path: "schemaVersion" }
733
- });
734
- }
735
- const extra = allowed(value, ["schemaVersion", "emitters"]);
736
- if (extra !== void 0) return invalid(extra, "a root field: emitters");
722
+ const extra2 = allowed(value, ["schemaVersion", "emitters"]);
723
+ if (extra2 !== void 0) return invalid(extra2, "a root field: emitters");
737
724
  if (!Array.isArray(value.emitters) || value.emitters.length === 0) {
738
- return invalid("emitters", "at least one v2 emitter");
725
+ return invalid("emitters", "at least one Program v3 emitter");
739
726
  }
740
727
  const ids = /* @__PURE__ */ new Set();
741
728
  const emitters = [];
@@ -768,16 +755,297 @@ function parseParticleEffectSourceV2(value) {
768
755
  }
769
756
  return ok(
770
757
  Object.freeze({
771
- schemaVersion: 2,
758
+ schemaVersion: 3,
772
759
  emitters: Object.freeze(emitters)
773
760
  })
774
761
  );
775
762
  }
776
- function defineParticleEffectSourceV2(source) {
777
- const parsed = parseParticleEffectSourceV2(source);
763
+ var PARTICLE_RENDERER_SEMANTICS = Object.freeze({
764
+ billboard: Object.freeze([
765
+ "position",
766
+ "color",
767
+ "size",
768
+ "rotation",
769
+ "age",
770
+ "subImage",
771
+ "sort",
772
+ "visibility"
773
+ ]),
774
+ mesh: Object.freeze(["position", "color", "orientation", "scale", "visibility"]),
775
+ ribbon: Object.freeze(["position", "color", "width"]),
776
+ trail: Object.freeze(["position", "color", "width", "taper"]),
777
+ beam: Object.freeze(["position", "endpoint", "color", "width"])
778
+ });
779
+ function coreAttribute(name) {
780
+ return { source: "core", name };
781
+ }
782
+ function defaultParticleRendererAttributes(kind) {
783
+ switch (kind) {
784
+ case "billboard":
785
+ return {
786
+ position: coreAttribute("position"),
787
+ color: coreAttribute("color"),
788
+ size: coreAttribute("sprite_size"),
789
+ rotation: coreAttribute("sprite_rotation"),
790
+ age: coreAttribute("age"),
791
+ visibility: coreAttribute("alive")
792
+ };
793
+ case "mesh":
794
+ return {
795
+ position: coreAttribute("position"),
796
+ color: coreAttribute("color"),
797
+ orientation: coreAttribute("mesh_orientation"),
798
+ scale: coreAttribute("mesh_scale"),
799
+ visibility: coreAttribute("alive")
800
+ };
801
+ case "ribbon":
802
+ return {
803
+ position: coreAttribute("position"),
804
+ color: coreAttribute("color")
805
+ };
806
+ case "trail":
807
+ return {
808
+ position: coreAttribute("position"),
809
+ color: coreAttribute("color")
810
+ };
811
+ case "beam":
812
+ return {
813
+ position: coreAttribute("position"),
814
+ endpoint: coreAttribute("velocity"),
815
+ color: coreAttribute("color")
816
+ };
817
+ }
818
+ }
819
+ function record2(value) {
820
+ return typeof value === "object" && value !== null && !Array.isArray(value);
821
+ }
822
+ function fail(path, expected) {
823
+ return err({
824
+ code: "vfx-source-invalid",
825
+ expected,
826
+ hint: `repair ${path} and recook the Program v3 particle effect`,
827
+ detail: { path }
828
+ });
829
+ }
830
+ function extra(value, allowed2) {
831
+ const keys = new Set(allowed2);
832
+ return Object.keys(value).find((key) => !keys.has(key));
833
+ }
834
+ var CORE_NAMES = /* @__PURE__ */ new Set([
835
+ "position",
836
+ "age",
837
+ "velocity",
838
+ "lifetime",
839
+ "color",
840
+ "sprite_size",
841
+ "sprite_rotation",
842
+ "sub_image",
843
+ "mesh_orientation",
844
+ "mesh_scale",
845
+ "material_random",
846
+ "id",
847
+ "alive"
848
+ ]);
849
+ function validateAttributes(value, path) {
850
+ if (value === void 0) return ok(void 0);
851
+ if (!record2(value)) return fail(path, "a semantic-to-attribute object");
852
+ for (const [semantic, reference] of Object.entries(value)) {
853
+ if (!record2(reference) || reference.source !== "core" && reference.source !== "custom") {
854
+ return fail(`${path}.${semantic}`, "{ source: 'core' | 'custom', name: string }");
855
+ }
856
+ if (typeof reference.name !== "string" || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(reference.name)) {
857
+ return fail(`${path}.${semantic}.name`, "a non-empty identifier");
858
+ }
859
+ if (reference.source === "core" && !CORE_NAMES.has(reference.name)) {
860
+ return fail(`${path}.${semantic}.name`, "a VfxParticle core attribute");
861
+ }
862
+ const referenceExtra = extra(reference, ["source", "name"]);
863
+ if (referenceExtra !== void 0)
864
+ return fail(`${path}.${semantic}.${referenceExtra}`, "source and name only");
865
+ }
866
+ return ok(value);
867
+ }
868
+ function validateMaterialInputs(value, path) {
869
+ if (value === void 0) return ok(void 0);
870
+ if (!Array.isArray(value) || value.length > 4 || value.some((entry) => typeof entry !== "string" || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(entry))) {
871
+ return fail(path, "at most four unique particle input names");
872
+ }
873
+ if (new Set(value).size !== value.length) return fail(path, "unique particle input names");
874
+ return ok(Object.freeze([...value]));
875
+ }
876
+ function rendererAllowed(kind) {
877
+ const common = [
878
+ "kind",
879
+ "material",
880
+ "enabled",
881
+ "capacity",
882
+ "overflow",
883
+ "width",
884
+ "attributes",
885
+ "materialInputs"
886
+ ];
887
+ switch (kind) {
888
+ case "billboard":
889
+ return [...common, "blend", "textureSheet", "pivot", "softParticle", "sorting"];
890
+ case "mesh":
891
+ return [
892
+ "kind",
893
+ "material",
894
+ "mesh",
895
+ "submesh",
896
+ "enabled",
897
+ "attributes",
898
+ "materialInputs",
899
+ "lighting",
900
+ "castShadows",
901
+ "receiveShadows"
902
+ ];
903
+ case "ribbon":
904
+ return [...common, "stripKey", "twist", "facing"];
905
+ case "trail":
906
+ return [...common, "historyLength", "taper"];
907
+ case "beam":
908
+ return [...common, "endpointField", "taper"];
909
+ default:
910
+ return ["kind"];
911
+ }
912
+ }
913
+ function asStructureRenderer(value) {
914
+ const common = ["kind", "material", "enabled", "capacity", "overflow", "width"];
915
+ const result = {};
916
+ for (const key of common) if (value[key] !== void 0) result[key] = value[key];
917
+ switch (value.kind) {
918
+ case "billboard":
919
+ for (const key of ["blend", "textureSheet", "pivot", "softParticle"])
920
+ if (value[key] !== void 0) result[key] = value[key];
921
+ if (value.sorting === "view-depth" || value.sorting === "view-distance")
922
+ result.sorting = "back-to-front";
923
+ else if (value.sorting === "custom-ascending" || value.sorting === "custom-descending")
924
+ result.sorting = "emitter";
925
+ else if (value.sorting !== void 0) result.sorting = value.sorting;
926
+ break;
927
+ case "mesh":
928
+ for (const key of ["mesh", "submesh"])
929
+ if (value[key] !== void 0) result[key] = value[key];
930
+ break;
931
+ case "ribbon":
932
+ result.stripKey = value.stripKey;
933
+ break;
934
+ case "trail":
935
+ result.historyLength = value.historyLength;
936
+ break;
937
+ case "beam":
938
+ result.endpointField = value.endpointField;
939
+ break;
940
+ }
941
+ return result;
942
+ }
943
+ function validateRenderer(value, emitterIndex, index) {
944
+ const path = `emitters[${emitterIndex}].renderers[${index}]`;
945
+ if (!record2(value) || typeof value.kind !== "string" || typeof value.material !== "string") {
946
+ return fail(path, "a Program v3 renderer object");
947
+ }
948
+ const unknown = extra(value, rendererAllowed(value.kind));
949
+ if (unknown !== void 0)
950
+ return fail(`${path}.${unknown}`, "a supported Program v3 renderer field");
951
+ if (value.lighting !== void 0 && value.lighting !== "unlit" && value.lighting !== "standard")
952
+ return fail(`${path}.lighting`, "unlit or standard");
953
+ for (const field2 of ["castShadows", "receiveShadows"]) {
954
+ if (value[field2] !== void 0 && typeof value[field2] !== "boolean")
955
+ return fail(`${path}.${field2}`, "a boolean");
956
+ }
957
+ for (const field2 of ["twist", "taper"]) {
958
+ if (value[field2] !== void 0 && (typeof value[field2] !== "number" || !Number.isFinite(value[field2])))
959
+ return fail(`${path}.${field2}`, "a finite number");
960
+ }
961
+ if (value.facing !== void 0 && value.facing !== "camera" && value.facing !== "velocity" && value.facing !== "custom")
962
+ return fail(`${path}.facing`, "camera, velocity, or custom");
963
+ if (value.sorting !== void 0 && value.kind === "billboard" && !["none", "view-depth", "view-distance", "custom-ascending", "custom-descending"].includes(
964
+ value.sorting
965
+ ))
966
+ return fail(`${path}.sorting`, "a Program v3 sorting mode");
967
+ const attrs = validateAttributes(value.attributes, `${path}.attributes`);
968
+ if (!attrs.ok) return attrs;
969
+ if (attrs.value !== void 0) {
970
+ for (const semantic of Object.keys(attrs.value)) {
971
+ if (!PARTICLE_RENDERER_SEMANTICS[value.kind]?.includes(
972
+ semantic
973
+ )) {
974
+ return fail(
975
+ `${path}.attributes.${semantic}`,
976
+ "a semantic supported by this renderer topology"
977
+ );
978
+ }
979
+ }
980
+ }
981
+ const inputs = validateMaterialInputs(value.materialInputs, `${path}.materialInputs`);
982
+ if (!inputs.ok) return inputs;
983
+ return ok({
984
+ ...value,
985
+ ...attrs.value === void 0 ? {} : { attributes: attrs.value },
986
+ ...inputs.value === void 0 ? {} : { materialInputs: inputs.value }
987
+ });
988
+ }
989
+ function parseParticleEffectSourceV3(value) {
990
+ if (!record2(value)) return fail("$", "a Program v3 particle effect source object");
991
+ if (value.schemaVersion !== 3) {
992
+ return err({
993
+ code: "vfx-source-version-unsupported",
994
+ expected: "ParticleEffectSource schemaVersion 3",
995
+ hint: "cold-cook the source with the Program v3 compiler; older versions are not executable",
996
+ detail: { path: "schemaVersion" }
997
+ });
998
+ }
999
+ const rootExtra = extra(value, ["schemaVersion", "emitters"]);
1000
+ if (rootExtra !== void 0) return fail(rootExtra, "schemaVersion and emitters only");
1001
+ if (!Array.isArray(value.emitters) || value.emitters.length === 0)
1002
+ return fail("emitters", "at least one Program v3 emitter");
1003
+ const renderers = /* @__PURE__ */ new Map();
1004
+ for (const [emitterIndex, emitter] of value.emitters.entries()) {
1005
+ if (record2(emitter) && Array.isArray(emitter.renderers)) {
1006
+ const parsed2 = [];
1007
+ for (const [index, renderer] of emitter.renderers.entries()) {
1008
+ const result = validateRenderer(renderer, emitterIndex, index);
1009
+ if (!result.ok) return result;
1010
+ parsed2.push(result.value);
1011
+ }
1012
+ renderers.set(typeof emitter.id === "string" ? emitter.id : String(renderers.size), parsed2);
1013
+ }
1014
+ }
1015
+ const structuralInput = {
1016
+ schemaVersion: 3,
1017
+ emitters: value.emitters.map((emitter) => {
1018
+ if (!record2(emitter)) return emitter;
1019
+ return {
1020
+ ...emitter,
1021
+ renderers: Array.isArray(emitter.renderers) ? emitter.renderers.map(
1022
+ (renderer) => record2(renderer) ? asStructureRenderer(renderer) : renderer
1023
+ ) : emitter.renderers
1024
+ };
1025
+ })
1026
+ };
1027
+ const parsed = parseParticleEffectSourceStructure(structuralInput);
1028
+ if (!parsed.ok) return parsed;
1029
+ const emitters = parsed.value.emitters.map((emitter) => ({
1030
+ ...emitter,
1031
+ renderers: renderers.get(emitter.id) ?? []
1032
+ }));
1033
+ return ok(Object.freeze({ schemaVersion: 3, emitters: Object.freeze(emitters) }));
1034
+ }
1035
+ function defineParticleEffectSourceV3(source) {
1036
+ const parsed = parseParticleEffectSourceV3(source);
778
1037
  if (!parsed.ok) throw new TypeError(`${parsed.error.code}: ${parsed.error.expected}`);
779
1038
  return source;
780
1039
  }
1040
+ function expectedResourceKind(kind) {
1041
+ switch (kind) {
1042
+ case "camera":
1043
+ return "buffer";
1044
+ case "scene-depth":
1045
+ case "noise":
1046
+ return "texture-view";
1047
+ }
1048
+ }
781
1049
  function failure(code, requirement, expected, hint, detail = {}) {
782
1050
  return { code, expected, hint, detail: { token: requirement.token, ...detail } };
783
1051
  }
@@ -859,6 +1127,44 @@ function resolveVfxDataInterfaces(requirements, providers, generation) {
859
1127
  )
860
1128
  );
861
1129
  }
1130
+ const expectedKind = expectedResourceKind(requirement.kind);
1131
+ if (resource.resource === void 0) {
1132
+ return err(
1133
+ failure(
1134
+ "vfx-data-interface-missing",
1135
+ requirement,
1136
+ `${requirement.kind} backed by a resident ${expectedKind} resource`,
1137
+ `refresh provider ${provider.id} with a generation-owned resource before dispatch`,
1138
+ { providerId: provider.id, expectedResourceKind: expectedKind }
1139
+ )
1140
+ );
1141
+ }
1142
+ if (resource.resource.kind !== expectedKind) {
1143
+ return err(
1144
+ failure(
1145
+ "vfx-data-interface-wrong-type",
1146
+ requirement,
1147
+ `${requirement.kind} backed by a ${expectedKind} resource`,
1148
+ `repair provider ${provider.id} so its opaque resource matches the reflected binding kind`,
1149
+ {
1150
+ providerId: provider.id,
1151
+ expectedResourceKind: expectedKind,
1152
+ actualResourceKind: resource.resource.kind
1153
+ }
1154
+ )
1155
+ );
1156
+ }
1157
+ if (requirement.sampleCount !== void 0 && resource.sampleCount !== requirement.sampleCount) {
1158
+ return err(
1159
+ failure(
1160
+ "vfx-data-interface-wrong-type",
1161
+ requirement,
1162
+ `a scene-depth resource with sampleCount=${requirement.sampleCount}`,
1163
+ `provide a single-sample scene depth target for ${provider.id}; MSAA resolve is not implicit`,
1164
+ { providerId: provider.id }
1165
+ )
1166
+ );
1167
+ }
862
1168
  if (resource.generation !== generation) {
863
1169
  return err(
864
1170
  failure(
@@ -886,8 +1192,8 @@ var VECTOR_LENGTH = {
886
1192
  "vec3<f32>": 3,
887
1193
  "vec4<f32>": 4
888
1194
  };
889
- function fieldList(reflection) {
890
- return [...reflection.parameters.fields, ...reflection.custom.fields];
1195
+ function fieldList(reflection, includeCustom = false) {
1196
+ return includeCustom ? [...reflection.parameters.fields, ...reflection.custom.fields] : reflection.parameters.fields;
891
1197
  }
892
1198
  function zeroValue(type) {
893
1199
  const length = VECTOR_LENGTH[type];
@@ -898,7 +1204,7 @@ function valueMatches(type, value) {
898
1204
  if (length === 1) return typeof value === "number" && Number.isFinite(value);
899
1205
  return Array.isArray(value) && value.length === length && value.every((component) => typeof component === "number" && Number.isFinite(component));
900
1206
  }
901
- function fail(code, path, expected, hint, actual) {
1207
+ function fail2(code, path, expected, hint, actual) {
902
1208
  return err({
903
1209
  code,
904
1210
  expected,
@@ -907,18 +1213,26 @@ function fail(code, path, expected, hint, actual) {
907
1213
  });
908
1214
  }
909
1215
  function validateReflection(reflection) {
910
- if (reflection.version !== 1 || typeof reflection.fingerprint !== "string" || !reflection.fingerprint.startsWith("sha256:")) {
911
- return fail(
1216
+ if (reflection.version !== 3 || typeof reflection.fingerprint !== "string" || !reflection.fingerprint.startsWith("sha256:")) {
1217
+ return fail2(
912
1218
  "vfx-reflection-invalid",
913
1219
  "reflection",
914
- "a version 1 reflection with a sha256 fingerprint",
1220
+ "a version 3 reflection with a sha256 fingerprint",
1221
+ "recook the effect with the current VFX compiler"
1222
+ );
1223
+ }
1224
+ if (reflection.core.name !== "VfxParticle" || reflection.core.stride !== 112 || reflection.core.fields.length === 0 || reflection.customLayout.name !== "VfxCustom" || reflection.customLayout.stride < 0 || reflection.customLayout.lanes < 0) {
1225
+ return fail2(
1226
+ "vfx-reflection-invalid",
1227
+ "reflection.core",
1228
+ "Program v3 Core and Custom layouts derived from the particle schema",
915
1229
  "recook the effect with the current VFX compiler"
916
1230
  );
917
1231
  }
918
1232
  const names = /* @__PURE__ */ new Set();
919
1233
  for (const field2 of fieldList(reflection)) {
920
1234
  if (names.has(field2.name)) {
921
- return fail(
1235
+ return fail2(
922
1236
  "vfx-reflection-invalid",
923
1237
  field2.name,
924
1238
  "unique field names across parameters and custom data",
@@ -937,20 +1251,23 @@ function defaultsFor(reflection) {
937
1251
  return Object.freeze(defaults);
938
1252
  }
939
1253
  function validateMap(reflection, values) {
940
- const fields2 = new Map(fieldList(reflection).map((field2) => [field2.name, field2]));
1254
+ return validateMapAgainstFields(fieldList(reflection), values);
1255
+ }
1256
+ function validateMapAgainstFields(reflectedFields, values) {
1257
+ const fields2 = new Map(reflectedFields.map((field2) => [field2.name, field2]));
941
1258
  for (const name of Object.keys(values)) {
942
1259
  const field2 = fields2.get(name);
943
1260
  if (field2 === void 0) {
944
- return fail(
1261
+ return fail2(
945
1262
  "vfx-value-unknown-field",
946
1263
  name,
947
- "a field declared by VfxParameters or VfxCustom",
1264
+ "a field declared by the selected VFX data scope",
948
1265
  `remove ${name} or declare it in the authored WGSL struct`,
949
1266
  values[name]
950
1267
  );
951
1268
  }
952
1269
  if (!valueMatches(field2.type, values[name])) {
953
- return fail(
1270
+ return fail2(
954
1271
  "vfx-value-type-mismatch",
955
1272
  name,
956
1273
  field2.type,
@@ -974,13 +1291,28 @@ function createVfxEffectContract(reflection) {
974
1291
  const checked = validateReflection(reflection);
975
1292
  if (!checked.ok) throw new TypeError(checked.error.hint);
976
1293
  const defaults = defaultsFor(reflection);
977
- const fields2 = fieldList(reflection);
978
1294
  const customBase = reflection.parameters.size;
979
- const packedSize = customBase + reflection.custom.size;
1295
+ const parameterSize = reflection.parameters.size;
1296
+ const customStride = reflection.customLayout.stride;
1297
+ const packedSize = customBase;
1298
+ const packFields = (values, selected, size, baseOffset) => {
1299
+ const checkedValues = validateMapAgainstFields(selected, values);
1300
+ if (!checkedValues.ok) return checkedValues;
1301
+ const bytes = new Uint8Array(size);
1302
+ const view = new DataView(bytes.buffer);
1303
+ for (const field2 of selected) {
1304
+ const value = checkedValues.value[field2.name];
1305
+ if (value === void 0) continue;
1306
+ writeValue(view, baseOffset + field2.offset, field2.type, value);
1307
+ }
1308
+ return ok(bytes);
1309
+ };
980
1310
  return {
981
1311
  reflection,
982
1312
  fingerprint: reflection.fingerprint,
983
1313
  packedSize,
1314
+ parameterSize: reflection.parameters.size,
1315
+ customStride,
984
1316
  defaults,
985
1317
  createValues(initial = {}) {
986
1318
  const merged = { ...defaults, ...initial };
@@ -990,17 +1322,13 @@ function createVfxEffectContract(reflection) {
990
1322
  return validateMap(reflection, values);
991
1323
  },
992
1324
  pack(values) {
993
- const checkedValues = validateMap(reflection, values);
994
- if (!checkedValues.ok) return checkedValues;
995
- const bytes = new Uint8Array(packedSize);
996
- const view = new DataView(bytes.buffer);
997
- for (const field2 of fields2) {
998
- const value = checkedValues.value[field2.name];
999
- if (value === void 0) continue;
1000
- const base = reflection.parameters.fields.includes(field2) ? 0 : customBase;
1001
- writeValue(view, base + field2.offset, field2.type, value);
1002
- }
1003
- return ok(bytes);
1325
+ return packFields(values, reflection.parameters.fields, parameterSize, 0);
1326
+ },
1327
+ packParameters(values) {
1328
+ return packFields(values, reflection.parameters.fields, parameterSize, 0);
1329
+ },
1330
+ packCustom(values) {
1331
+ return packFields(values, reflection.custom.fields, customStride, 0);
1004
1332
  }
1005
1333
  };
1006
1334
  }
@@ -1009,14 +1337,15 @@ function validateVfxEffectValues(reflection, values) {
1009
1337
  }
1010
1338
 
1011
1339
  // src/gpu-program.ts
1012
- var VFX_GPU_PROGRAM_FORMAT = "forgeax-vfx-program-2";
1340
+ var VFX_GPU_PROGRAM_FORMAT = "forgeax-vfx-program-3";
1341
+ var VFX_GPU_PROGRAM_FORMAT_V3 = VFX_GPU_PROGRAM_FORMAT;
1013
1342
  var VFX_GPU_PROGRAM_ARTIFACT_KEY = "particle-effect/program.json";
1014
1343
 
1015
1344
  // src/gpu-loader.ts
1016
1345
  function failure2(code, guid, path, expected, hint) {
1017
1346
  return err({ code, expected, hint, detail: { guid, path } });
1018
1347
  }
1019
- function record2(value) {
1348
+ function record3(value) {
1020
1349
  return value !== null && typeof value === "object" && !Array.isArray(value);
1021
1350
  }
1022
1351
  function hex(bytes) {
@@ -1031,9 +1360,15 @@ async function fingerprint(bytes) {
1031
1360
  }
1032
1361
  function validReflectionLayout(value) {
1033
1362
  if (value === void 0) return true;
1034
- if (!record2(value) || value.version !== 1) return false;
1035
- if (!record2(value.parameters) || !Array.isArray(value.parameters.fields)) return false;
1036
- if (!record2(value.custom) || !Array.isArray(value.custom.fields)) return false;
1363
+ if (!record3(value) || value.version !== 3) return false;
1364
+ if (!record3(value.parameters) || !Array.isArray(value.parameters.fields)) return false;
1365
+ if (!record3(value.custom) || !Array.isArray(value.custom.fields)) return false;
1366
+ if (value.version === 3) {
1367
+ if (!record3(value.core) || value.core.name !== "VfxParticle" || value.core.stride !== 112) {
1368
+ return false;
1369
+ }
1370
+ if (!record3(value.customLayout) || value.customLayout.name !== "VfxCustom") return false;
1371
+ }
1037
1372
  return typeof value.fingerprint === "string" && value.fingerprint.startsWith("sha256:");
1038
1373
  }
1039
1374
  function stableLayouts(emitters) {
@@ -1041,26 +1376,40 @@ function stableLayouts(emitters) {
1041
1376
  return fingerprints.every((fingerprint2) => fingerprint2 === fingerprints[0]);
1042
1377
  }
1043
1378
  function validEmitter(value) {
1044
- const reflection = record2(value) && record2(value.reflection) ? value.reflection : void 0;
1379
+ const reflection = record3(value) && record3(value.reflection) ? value.reflection : void 0;
1045
1380
  const layout = reflection !== void 0 ? reflection.layout : void 0;
1046
- return record2(value) && typeof value.id === "string" && typeof value.module === "string" && value.module.length > 0 && Number.isInteger(value.capacity) && value.capacity > 0 && record2(value.backend) && value.backend.required === "gpu" && Object.keys(value.backend).length === 1 && typeof value.wgsl === "string" && value.wgsl.length > 0 && record2(value.reflection) && validReflectionLayout(layout) && Array.isArray(value.reflection.entryPoints) && value.reflection.entryPoints.includes("forgeax_vfx_spawn_main") && value.reflection.entryPoints.includes("forgeax_vfx_compact_main") && value.reflection.entryPoints.includes("forgeax_vfx_billboard_main") && value.reflection.entryPoints.includes("forgeax_vfx_mesh_main") && value.reflection.entryPoints.includes("forgeax_vfx_ribbon_main") && value.reflection.entryPoints.includes("forgeax_vfx_trail_main") && value.reflection.entryPoints.includes("forgeax_vfx_beam_main");
1381
+ return record3(value) && typeof value.id === "string" && typeof value.module === "string" && value.module.length > 0 && Number.isInteger(value.capacity) && value.capacity > 0 && record3(value.backend) && value.backend.required === "gpu" && Object.keys(value.backend).length === 1 && typeof value.wgsl === "string" && value.wgsl.length > 0 && record3(value.reflection) && validReflectionLayout(layout) && Array.isArray(value.reflection.entryPoints) && value.reflection.entryPoints.includes("forgeax_vfx_spawn_main") && value.reflection.entryPoints.includes("forgeax_vfx_compact_main") && value.reflection.entryPoints.includes("forgeax_vfx_billboard_main") && value.reflection.entryPoints.includes("forgeax_vfx_mesh_main") && value.reflection.entryPoints.includes("forgeax_vfx_ribbon_main") && value.reflection.entryPoints.includes("forgeax_vfx_trail_main") && value.reflection.entryPoints.includes("forgeax_vfx_beam_main") && layout !== void 0 && record3(layout) && Array.isArray(reflection?.bindings) && Array.isArray(reflection?.dataInterfaces) && Array.isArray(reflection?.renderers) && Array.isArray(reflection?.resources);
1047
1382
  }
1048
1383
  var vfxGpuEffectPackLoader = {
1049
1384
  kind: "particle-effect",
1050
1385
  async load(input, _context) {
1051
- if (input.payload.kind !== "particle-effect" || input.payload.schemaVersion !== 2 || !Array.isArray(input.payload.emitters) || typeof input.payload.programFingerprint !== "string" || !record2(input.payload.program)) {
1386
+ const schemaVersion = input.payload.schemaVersion;
1387
+ if (schemaVersion !== 3) {
1052
1388
  return failure2(
1053
- "vfx-asset-v2-invalid",
1389
+ "vfx-asset-version-unsupported",
1390
+ input.guid,
1391
+ "schemaVersion",
1392
+ "schemaVersion 3 / forgeax-vfx-program-3",
1393
+ "cold-cook the older source and publish a Program v3 payload; older versions are not executable"
1394
+ );
1395
+ }
1396
+ const invalidCode = "vfx-asset-v3-invalid";
1397
+ const missingCode = "vfx-asset-v3-program-missing";
1398
+ const fingerprintCode = "vfx-asset-v3-fingerprint-mismatch";
1399
+ const expectedFormat = VFX_GPU_PROGRAM_FORMAT_V3;
1400
+ if (input.payload.kind !== "particle-effect" || !Array.isArray(input.payload.emitters) || typeof input.payload.programFingerprint !== "string" || !record3(input.payload.program)) {
1401
+ return failure2(
1402
+ invalidCode,
1054
1403
  input.guid,
1055
1404
  "payload",
1056
- "a schemaVersion 2 particle payload with its complete program and fingerprint",
1057
- "migrate behavior to WGSL and recook; the runtime does not interpret v1"
1405
+ "a schemaVersion 3 particle payload with its complete Program v3 and fingerprint",
1406
+ "cold-cook the source with the current VFX compiler and retry the load"
1058
1407
  );
1059
1408
  }
1060
1409
  const artifact = input.artifacts[VFX_GPU_PROGRAM_ARTIFACT_KEY];
1061
1410
  if (artifact === void 0) {
1062
1411
  return failure2(
1063
- "vfx-asset-v2-program-missing",
1412
+ missingCode,
1064
1413
  input.guid,
1065
1414
  VFX_GPU_PROGRAM_ARTIFACT_KEY,
1066
1415
  "the asset-local cooked VFX program",
@@ -1072,26 +1421,26 @@ var vfxGpuEffectPackLoader = {
1072
1421
  decoded = JSON.parse(new TextDecoder().decode(artifact.bytes));
1073
1422
  } catch {
1074
1423
  return failure2(
1075
- "vfx-asset-v2-invalid",
1424
+ invalidCode,
1076
1425
  input.guid,
1077
1426
  VFX_GPU_PROGRAM_ARTIFACT_KEY,
1078
1427
  "canonical JSON program bytes",
1079
1428
  "recook the particle effect and retry the load"
1080
1429
  );
1081
1430
  }
1082
- if (!record2(decoded) || decoded.format !== VFX_GPU_PROGRAM_FORMAT || !Array.isArray(decoded.emitters) || !decoded.emitters.every(validEmitter) || !stableLayouts(decoded.emitters)) {
1431
+ if (!record3(decoded) || decoded.format !== expectedFormat || !Array.isArray(decoded.emitters) || !decoded.emitters.every((emitter) => validEmitter(emitter)) || !stableLayouts(decoded.emitters)) {
1083
1432
  return failure2(
1084
- "vfx-asset-v2-invalid",
1433
+ invalidCode,
1085
1434
  input.guid,
1086
1435
  VFX_GPU_PROGRAM_ARTIFACT_KEY,
1087
- `a ${VFX_GPU_PROGRAM_FORMAT} managed GPU program`,
1436
+ `a ${expectedFormat} managed GPU program`,
1088
1437
  "recook with the current VFX compiler ABI"
1089
1438
  );
1090
1439
  }
1091
1440
  const decodedEmitters = decoded.emitters;
1092
- if (input.payload.program.format !== VFX_GPU_PROGRAM_FORMAT || input.payload.program.fingerprint !== input.payload.programFingerprint || !Array.isArray(input.payload.program.emitters)) {
1441
+ if (input.payload.program.format !== expectedFormat || input.payload.program.fingerprint !== input.payload.programFingerprint || !Array.isArray(input.payload.program.emitters)) {
1093
1442
  return failure2(
1094
- "vfx-asset-v2-invalid",
1443
+ invalidCode,
1095
1444
  input.guid,
1096
1445
  "payload.program",
1097
1446
  "the complete canonical program matching payload.programFingerprint",
@@ -1101,7 +1450,7 @@ var vfxGpuEffectPackLoader = {
1101
1450
  const actualFingerprint = await fingerprint(artifact.bytes);
1102
1451
  if (actualFingerprint !== input.payload.programFingerprint) {
1103
1452
  return failure2(
1104
- "vfx-asset-v2-fingerprint-mismatch",
1453
+ fingerprintCode,
1105
1454
  input.guid,
1106
1455
  "payload.programFingerprint",
1107
1456
  "payload and program artifact fingerprints to match",
@@ -1109,7 +1458,7 @@ var vfxGpuEffectPackLoader = {
1109
1458
  );
1110
1459
  }
1111
1460
  const emitters = input.payload.emitters.map((value) => {
1112
- if (!record2(value) || typeof value.id !== "string" || !Number.isInteger(value.capacity)) {
1461
+ if (!record3(value) || typeof value.id !== "string" || !Number.isInteger(value.capacity)) {
1113
1462
  return void 0;
1114
1463
  }
1115
1464
  return { id: value.id, capacity: value.capacity };
@@ -1118,7 +1467,7 @@ var vfxGpuEffectPackLoader = {
1118
1467
  (value, index) => value?.id !== decodedEmitters[index]?.id || value?.capacity !== decodedEmitters[index]?.capacity
1119
1468
  )) {
1120
1469
  return failure2(
1121
- "vfx-asset-v2-invalid",
1470
+ invalidCode,
1122
1471
  input.guid,
1123
1472
  "payload.emitters",
1124
1473
  "payload emitter identities and capacities to match the program",
@@ -1129,11 +1478,11 @@ var vfxGpuEffectPackLoader = {
1129
1478
  Object.freeze({
1130
1479
  guid: input.guid,
1131
1480
  kind: "particle-effect",
1132
- schemaVersion: 2,
1481
+ schemaVersion: 3,
1133
1482
  programFingerprint: actualFingerprint,
1134
1483
  emitters: Object.freeze(emitters),
1135
1484
  program: Object.freeze({
1136
- format: VFX_GPU_PROGRAM_FORMAT,
1485
+ format: expectedFormat,
1137
1486
  fingerprint: actualFingerprint,
1138
1487
  emitters: Object.freeze(decodedEmitters)
1139
1488
  })
@@ -1489,9 +1838,27 @@ var VfxGpuRuntime = class {
1489
1838
  diagnostics() {
1490
1839
  return this.#diagnostics;
1491
1840
  }
1841
+ /**
1842
+ * Visit every live emitter source. The renderer uses this on render frames
1843
+ * where pause/restart culling has no pending simulation intent, so a camera
1844
+ * move back into the bounds can refresh visibility before the next tick.
1845
+ */
1846
+ forEachEmitterSource(visitor) {
1847
+ for (const [player, state] of this.#players) {
1848
+ for (const emitter of state.emitters) visitor({ player, emitter });
1849
+ }
1850
+ }
1492
1851
  lastCommitted(player) {
1493
1852
  return this.#lastCommitted.get(player);
1494
1853
  }
1854
+ /**
1855
+ * Return the last submitted fixed-tick state for one emitter. Renderers
1856
+ * retain this projection between fixed ticks; simulation intents are a
1857
+ * queue, while a drawable emitter remains live until the player is reset.
1858
+ */
1859
+ lastCommittedEmitter(player, emitterId) {
1860
+ return this.#lastCommittedByEmitter.get(player)?.get(emitterId);
1861
+ }
1495
1862
  inspectPlayers() {
1496
1863
  return Object.freeze(
1497
1864
  [...this.#players.keys()].sort((left, right) => Number(left) - Number(right)).flatMap((player) => {
@@ -1646,24 +2013,46 @@ var VfxGpuRuntime = class {
1646
2013
  }
1647
2014
  this.#replayRequests.add(player);
1648
2015
  }
1649
- commit(sequence) {
2016
+ /**
2017
+ * Acknowledge queued intents. A scalar acknowledges the contiguous prefix;
2018
+ * an array acknowledges only those terminal sequences, which lets unrelated
2019
+ * player/emitter streams progress while another stream is deferred. When the
2020
+ * renderer supplies `publishedSequences`, only intents that reached GPU
2021
+ * submission update the retained last-committed projection; skipped intents
2022
+ * are queue acknowledgements, not new GPU state.
2023
+ */
2024
+ commit(sequence, publishedSequences) {
2025
+ const prefix = typeof sequence === "number" ? sequence : void 0;
2026
+ const acknowledged = typeof sequence === "number" ? void 0 : new Set(sequence);
2027
+ const published = publishedSequences === void 0 ? void 0 : new Set(publishedSequences);
1650
2028
  const committedPlayers = /* @__PURE__ */ new Set();
2029
+ const retained = [];
1651
2030
  for (const intent of this.#intents) {
1652
- if (intent.sequence > sequence) break;
1653
- const state = this.#players.get(intent.player);
1654
- if (state !== void 0) state.hasCommitted = true;
1655
- this.#lastCommitted.set(intent.player, intent);
1656
- let emitters = this.#lastCommittedByEmitter.get(intent.player);
1657
- if (emitters === void 0) {
1658
- emitters = /* @__PURE__ */ new Map();
1659
- this.#lastCommittedByEmitter.set(intent.player, emitters);
2031
+ const shouldAcknowledge = prefix === void 0 ? acknowledged?.has(intent.sequence) === true : intent.sequence <= prefix;
2032
+ if (!shouldAcknowledge) {
2033
+ retained.push(intent);
2034
+ continue;
1660
2035
  }
1661
- emitters.set(intent.emitter.id, intent);
2036
+ const state = this.#players.get(intent.player);
1662
2037
  committedPlayers.add(intent.player);
2038
+ if (published === void 0 || published.has(intent.sequence)) {
2039
+ if (state !== void 0) state.hasCommitted = true;
2040
+ const priorCommitted = this.#lastCommitted.get(intent.player);
2041
+ if (priorCommitted === void 0 || priorCommitted.sequence < intent.sequence) {
2042
+ this.#lastCommitted.set(intent.player, intent);
2043
+ }
2044
+ let emitters = this.#lastCommittedByEmitter.get(intent.player);
2045
+ if (emitters === void 0) {
2046
+ emitters = /* @__PURE__ */ new Map();
2047
+ this.#lastCommittedByEmitter.set(intent.player, emitters);
2048
+ }
2049
+ const priorEmitter = emitters.get(intent.emitter.id);
2050
+ if (priorEmitter === void 0 || priorEmitter.sequence < intent.sequence) {
2051
+ emitters.set(intent.emitter.id, intent);
2052
+ }
2053
+ }
1663
2054
  }
1664
- const retained = this.#intents.findIndex((intent) => intent.sequence > sequence);
1665
- if (retained < 0) this.#intents.length = 0;
1666
- else if (retained > 0) this.#intents.splice(0, retained);
2055
+ this.#intents.splice(0, this.#intents.length, ...retained);
1667
2056
  for (const player of committedPlayers) {
1668
2057
  this.#clearDiagnostics(player, "vfx-intent-queue-overflow");
1669
2058
  }
@@ -1749,11 +2138,11 @@ var VfxGpuRuntime = class {
1749
2138
  const resolved = world.sharedRefs.resolve(
1750
2139
  input.effect
1751
2140
  );
1752
- if (!resolved.ok || resolved.value.schemaVersion !== 2) {
2141
+ if (!resolved.ok || resolved.value.schemaVersion !== 3) {
1753
2142
  this.#report({
1754
2143
  code: "vfx-effect-unavailable",
1755
- expected: "a loaded schemaVersion 2 GPU particle effect",
1756
- hint: "load and recook the effect before the first FixedUpdate",
2144
+ expected: "a loaded schemaVersion 3 GPU particle effect",
2145
+ hint: "cold-cook the legacy effect as Program v3 before the first FixedUpdate",
1757
2146
  detail: { player: input.player }
1758
2147
  });
1759
2148
  continue;
@@ -1761,8 +2150,9 @@ var VfxGpuRuntime = class {
1761
2150
  this.#clearDiagnostics(input.player, "vfx-effect-unavailable");
1762
2151
  const previous = this.#players.get(input.player);
1763
2152
  const replayRequested = this.#replayRequests.delete(input.player);
1764
- const restart = previous === void 0 || previous.effect !== input.effect || previous.seed !== input.seed || replayRequested || !previous.playing && input.playing;
1765
- const state = restart ? {
2153
+ const restartRequested = previous === void 0 || previous.effect !== input.effect || previous.seed !== input.seed || replayRequested || !previous.playing && input.playing;
2154
+ const restart = restartRequested;
2155
+ const state = restartRequested ? {
1766
2156
  effect: input.effect,
1767
2157
  assetGuid: resolved.value.guid,
1768
2158
  programFingerprint: resolved.value.program.fingerprint,
@@ -1776,16 +2166,21 @@ var VfxGpuRuntime = class {
1776
2166
  playCycles: resolved.value.program.emitters.map(() => (previous?.playCycle ?? -1) + 1),
1777
2167
  cameraVisible: resolved.value.program.emitters.map(() => true),
1778
2168
  phaseTicks: resolved.value.program.emitters.map(() => 0),
1779
- hasCommitted: false
2169
+ hasCommitted: false,
2170
+ pendingResets: resolved.value.program.emitters.map(() => false)
1780
2171
  } : previous;
1781
2172
  this.#players.set(input.player, state);
1782
2173
  state.playing = input.playing;
1783
- if (!input.playing && !replayRequested) continue;
2174
+ if (!input.playing && !replayRequested) {
2175
+ if (restart) state.pendingResets.fill(true);
2176
+ continue;
2177
+ }
1784
2178
  const queuedForPlayer = this.#intents.reduce(
1785
2179
  (count, intent) => count + (intent.player === input.player ? 1 : 0),
1786
2180
  0
1787
2181
  );
1788
2182
  if (queuedForPlayer >= this.#maxQueuedTicks * Math.max(1, resolved.value.program.emitters.length)) {
2183
+ if (restart) state.pendingResets.fill(true);
1789
2184
  if (state.hasCommitted) {
1790
2185
  this.#report({
1791
2186
  code: "vfx-intent-queue-overflow",
@@ -1797,13 +2192,17 @@ var VfxGpuRuntime = class {
1797
2192
  continue;
1798
2193
  }
1799
2194
  const instance = this.#instances.get(input.player) ?? this.#createInstance(input.player, resolved.value);
1800
- if (instance === void 0) continue;
2195
+ if (instance === void 0) {
2196
+ if (restart) state.pendingResets.fill(true);
2197
+ continue;
2198
+ }
1801
2199
  const hasActiveEmitter = resolved.value.program.emitters.some((emitter) => {
1802
2200
  if (!this.isEmitterSessionEnabled(input.player, emitter.id)) return false;
1803
2201
  const cameraVisible = this.#cameraVisibility.get(`${input.player}:${emitter.id}`) ?? true;
1804
2202
  return cameraVisible || emitter.simulationWhenCulled === "continue";
1805
2203
  });
1806
2204
  if (!hasActiveEmitter) {
2205
+ if (restart) state.pendingResets.fill(true);
1807
2206
  for (const [index, emitter] of resolved.value.program.emitters.entries()) {
1808
2207
  state.cameraVisible[index] = this.#cameraVisibility.get(`${input.player}:${emitter.id}`) ?? true;
1809
2208
  }
@@ -1813,6 +2212,7 @@ var VfxGpuRuntime = class {
1813
2212
  this.#replayInputs.delete(input.player);
1814
2213
  const committed = replayInput === void 0 ? instance.commit({ seed: input.seed, tick }) : instance.replay(replayInput);
1815
2214
  if (!committed.ok) {
2215
+ if (restart) state.pendingResets.fill(true);
1816
2216
  this.#report({
1817
2217
  code: "vfx-instance-commit-failed",
1818
2218
  expected: "the current typed instance values to pack into the reflected GPU block",
@@ -1827,8 +2227,15 @@ var VfxGpuRuntime = class {
1827
2227
  const cameraVisible = this.#cameraVisibility.get(`${input.player}:${emitter.id}`) ?? true;
1828
2228
  const becameVisible = cameraVisible && state.cameraVisible[index] === false;
1829
2229
  state.cameraVisible[index] = cameraVisible;
1830
- if (!this.isEmitterSessionEnabled(input.player, emitter.id)) continue;
1831
- if (!cameraVisible && emitter.simulationWhenCulled !== "continue") continue;
2230
+ const emitterRestart = restart || state.pendingResets[index] === true;
2231
+ if (!this.isEmitterSessionEnabled(input.player, emitter.id)) {
2232
+ if (emitterRestart) state.pendingResets[index] = true;
2233
+ continue;
2234
+ }
2235
+ if (!cameraVisible && emitter.simulationWhenCulled !== "continue") {
2236
+ if (emitterRestart) state.pendingResets[index] = true;
2237
+ continue;
2238
+ }
1832
2239
  const visibilityRestart = becameVisible && emitter.simulationWhenCulled === "restart-on-visible";
1833
2240
  if (visibilityRestart) {
1834
2241
  state.elapsed[index] = 0;
@@ -1842,7 +2249,7 @@ var VfxGpuRuntime = class {
1842
2249
  emitter,
1843
2250
  previousElapsed,
1844
2251
  previousElapsed + delta,
1845
- restart || visibilityRestart,
2252
+ emitterRestart || visibilityRestart,
1846
2253
  state.rateRemainders[index] ?? 0
1847
2254
  );
1848
2255
  state.rateRemainders[index] = scheduled.remainder;
@@ -1862,7 +2269,7 @@ var VfxGpuRuntime = class {
1862
2269
  player: input.player,
1863
2270
  emitter,
1864
2271
  programFingerprint: resolved.value.program.fingerprint,
1865
- reset: restart || visibilityRestart,
2272
+ reset: emitterRestart || visibilityRestart,
1866
2273
  fixedDelta: delta,
1867
2274
  phaseTick,
1868
2275
  tick,
@@ -1885,6 +2292,7 @@ var VfxGpuRuntime = class {
1885
2292
  );
1886
2293
  state.elapsed[index] = previousElapsed + delta;
1887
2294
  state.phaseTicks[index] = phaseTick + 1;
2295
+ state.pendingResets[index] = false;
1888
2296
  }
1889
2297
  }
1890
2298
  for (const player of this.#players.keys()) {
@@ -1895,12 +2303,16 @@ var VfxGpuRuntime = class {
1895
2303
  const layout = effect.program.emitters.find(
1896
2304
  (emitter) => emitter.reflection.layout !== void 0
1897
2305
  )?.reflection.layout;
1898
- const reflection = layout ?? {
1899
- version: 1,
1900
- parameters: { name: "VfxParameters", fields: [], size: 0, alignment: 1 },
1901
- custom: { name: "VfxCustom", fields: [], size: 0, alignment: 1 },
1902
- fingerprint: effect.program.fingerprint.startsWith("sha256:") ? effect.program.fingerprint : `sha256:${effect.program.fingerprint}`
1903
- };
2306
+ if (layout === void 0) {
2307
+ this.#report({
2308
+ code: "vfx-effect-unavailable",
2309
+ expected: "a Program v3 emitter reflection layout",
2310
+ hint: "recook the effect with Program v3 reflection before starting the player",
2311
+ detail: { player }
2312
+ });
2313
+ return void 0;
2314
+ }
2315
+ const reflection = layout;
1904
2316
  try {
1905
2317
  const instance = new ParticleEffectInstance(createVfxEffectContract(reflection), {
1906
2318
  channels: effect.program.emitters.flatMap((emitter) => emitter.channels ?? [])
@@ -1990,6 +2402,124 @@ function vfxGpuRuntimePlugin(options = {}) {
1990
2402
  };
1991
2403
  }
1992
2404
 
1993
- export { PARTICLE_CODE_DEFAULT_MODULE_ID, PARTICLE_STAGE_RESOURCE_NAMES, ParticleEffectInstance, ParticleEffectPlayer, VFX_GPU_PROGRAM_ARTIFACT_KEY, VFX_GPU_PROGRAM_FORMAT, VFX_GPU_RUNTIME_RESOURCE_KEY, VfxGpuRuntime, createParticleEffectInstance, createVfxEffectContract, createVfxInspectSnapshot, defineParticleEffectSourceV2, describeVfxGpuEffect, isVfxGpuEffectAsset, loadVfxGpuEffect, parseParticleEffectSourceV2, parseVfxStageDeclarations, particleEffectContribution, resolveVfxDataInterfaces, validateVfxEffectValues, vfxGpuEffectContribution, vfxGpuEffectPackLoader, vfxGpuRuntimePlugin };
2405
+ // src/particle-layout.ts
2406
+ var CORE_FIELDS = Object.freeze([
2407
+ { name: "position", type: "vec3<f32>", offset: 0, size: 12, alignment: 16 },
2408
+ { name: "age", type: "f32", offset: 12, size: 4, alignment: 4 },
2409
+ { name: "velocity", type: "vec3<f32>", offset: 16, size: 12, alignment: 16 },
2410
+ { name: "lifetime", type: "f32", offset: 28, size: 4, alignment: 4 },
2411
+ { name: "color", type: "vec4<f32>", offset: 32, size: 16, alignment: 16 },
2412
+ { name: "sprite_size", type: "vec2<f32>", offset: 48, size: 8, alignment: 8 },
2413
+ { name: "sprite_rotation", type: "f32", offset: 56, size: 4, alignment: 4 },
2414
+ { name: "sub_image", type: "f32", offset: 60, size: 4, alignment: 4 },
2415
+ { name: "mesh_orientation", type: "vec4<f32>", offset: 64, size: 16, alignment: 16 },
2416
+ { name: "mesh_scale", type: "vec3<f32>", offset: 80, size: 12, alignment: 16 },
2417
+ { name: "material_random", type: "f32", offset: 92, size: 4, alignment: 4 },
2418
+ { name: "id", type: "u32", offset: 96, size: 4, alignment: 4 },
2419
+ { name: "alive", type: "u32", offset: 100, size: 4, alignment: 4 }
2420
+ ]);
2421
+ var VFX_PARTICLE_CORE_LAYOUT = Object.freeze({
2422
+ name: "VfxParticle",
2423
+ fields: CORE_FIELDS,
2424
+ size: 112,
2425
+ alignment: 16,
2426
+ stride: 112,
2427
+ fingerprint: "sha256:9e3c1566f6d0600cedb94bd3c4b76c4e7c456fd563b37065220d4e33cf83c777"
2428
+ });
2429
+ var VFX_PARTICLE_CORE_STRIDE = VFX_PARTICLE_CORE_LAYOUT.stride;
2430
+ var CORE_FIELD_BY_NAME = new Map(CORE_FIELDS.map((field2) => [field2.name, field2]));
2431
+ function vfxParticleCoreField(name) {
2432
+ const field2 = CORE_FIELD_BY_NAME.get(name);
2433
+ if (field2 === void 0) throw new Error(`unknown VFX particle core field ${name}`);
2434
+ return field2;
2435
+ }
2436
+ function vfxParticleCoreWgsl() {
2437
+ return [
2438
+ "struct VfxParticle {",
2439
+ ...CORE_FIELDS.map((field2) => ` ${field2.name}: ${field2.type},`),
2440
+ "}"
2441
+ ].join("\n");
2442
+ }
2443
+ var VALUE_LAYOUT = {
2444
+ f32: { alignment: 4, size: 4 },
2445
+ i32: { alignment: 4, size: 4 },
2446
+ u32: { alignment: 4, size: 4 },
2447
+ "vec2<f32>": { alignment: 8, size: 8 },
2448
+ "vec3<f32>": { alignment: 16, size: 12 },
2449
+ "vec4<f32>": { alignment: 16, size: 16 }
2450
+ };
2451
+ function alignUp(value, alignment) {
2452
+ return Math.ceil(value / alignment) * alignment;
2453
+ }
2454
+ function deriveVfxCustomLayout(struct, maxLanes = 4) {
2455
+ if (struct.name !== "VfxCustom") {
2456
+ throw new TypeError(`expected VfxCustom reflection, received ${struct.name}`);
2457
+ }
2458
+ const fields2 = [];
2459
+ let offset = 0;
2460
+ let alignment = 1;
2461
+ for (const field2 of struct.fields) {
2462
+ const layout = VALUE_LAYOUT[field2.type];
2463
+ offset = alignUp(offset, layout.alignment);
2464
+ fields2.push({ ...field2, offset, size: layout.size, alignment: layout.alignment });
2465
+ offset += layout.size;
2466
+ alignment = Math.max(alignment, layout.alignment);
2467
+ }
2468
+ const size = fields2.length === 0 ? 0 : alignUp(offset, alignment);
2469
+ const stride = size;
2470
+ const lanes = Math.ceil(stride / 16);
2471
+ if (lanes > maxLanes) {
2472
+ throw new RangeError(`VfxCustom consumes ${lanes} vec4 lanes; maximum is ${maxLanes}`);
2473
+ }
2474
+ return Object.freeze({
2475
+ name: "VfxCustom",
2476
+ fields: Object.freeze(fields2),
2477
+ size,
2478
+ alignment,
2479
+ stride,
2480
+ lanes
2481
+ });
2482
+ }
2483
+ function finite2(value) {
2484
+ return Number.isFinite(value);
2485
+ }
2486
+ function encodeVfxParticleCore(value) {
2487
+ const bytes = new Uint8Array(VFX_PARTICLE_CORE_STRIDE);
2488
+ const view = new DataView(bytes.buffer);
2489
+ const write = (name, values) => {
2490
+ const field2 = vfxParticleCoreField(name);
2491
+ for (const component of values) {
2492
+ if (!finite2(component)) throw new TypeError(`non-finite VFX particle field ${name}`);
2493
+ if (field2.type === "u32" && (!Number.isInteger(component) || component < 0 || component > 4294967295)) {
2494
+ throw new RangeError(`u32 VFX particle field ${name} is outside its valid range`);
2495
+ }
2496
+ }
2497
+ for (let index = 0; index < values.length; index += 1) {
2498
+ const component = values[index] ?? 0;
2499
+ if (field2.type === "u32") view.setUint32(field2.offset + index * 4, component, true);
2500
+ else view.setFloat32(field2.offset + index * 4, component, true);
2501
+ }
2502
+ };
2503
+ write("position", value.position);
2504
+ write("age", [value.age]);
2505
+ write("velocity", value.velocity);
2506
+ write("lifetime", [value.lifetime]);
2507
+ write("color", value.color);
2508
+ write("sprite_size", value.sprite_size);
2509
+ write("sprite_rotation", [value.sprite_rotation]);
2510
+ write("sub_image", [value.sub_image]);
2511
+ write("mesh_orientation", value.mesh_orientation);
2512
+ write("mesh_scale", value.mesh_scale);
2513
+ write("material_random", [value.material_random]);
2514
+ write("id", [value.id]);
2515
+ write("alive", [value.alive]);
2516
+ return bytes;
2517
+ }
2518
+ function normalizedVfxParticleAge(age, lifetime) {
2519
+ if (!finite2(age) || !finite2(lifetime) || lifetime <= 0) return 0;
2520
+ return Math.min(1, Math.max(0, age / lifetime));
2521
+ }
2522
+
2523
+ export { PARTICLE_CODE_DEFAULT_MODULE_ID, PARTICLE_RENDERER_SEMANTICS, PARTICLE_STAGE_RESOURCE_NAMES, ParticleEffectInstance, ParticleEffectPlayer, VFX_GPU_PROGRAM_ARTIFACT_KEY, VFX_GPU_PROGRAM_FORMAT, VFX_GPU_PROGRAM_FORMAT_V3, VFX_GPU_RUNTIME_RESOURCE_KEY, VFX_PARTICLE_CORE_LAYOUT, VFX_PARTICLE_CORE_STRIDE, VfxGpuRuntime, createParticleEffectInstance, createVfxEffectContract, createVfxInspectSnapshot, defaultParticleRendererAttributes, defineParticleEffectSourceV3, deriveVfxCustomLayout, describeVfxGpuEffect, encodeVfxParticleCore, isVfxGpuEffectAsset, loadVfxGpuEffect, normalizedVfxParticleAge, parseParticleEffectSourceV3, parseVfxStageDeclarations, particleEffectContribution, resolveVfxDataInterfaces, validateVfxEffectValues, vfxGpuEffectContribution, vfxGpuEffectPackLoader, vfxGpuRuntimePlugin, vfxParticleCoreField, vfxParticleCoreWgsl };
1994
2524
  //# sourceMappingURL=index.mjs.map
1995
2525
  //# sourceMappingURL=index.mjs.map