@forgeax/engine-render 0.1.32 → 0.1.33

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 (250) hide show
  1. package/README.md +379 -33
  2. package/dist/assembly/analytic-fog-registration.d.ts +3 -0
  3. package/dist/assembly/analytic-fog-registration.d.ts.map +1 -0
  4. package/dist/assembly/fxaa-registration.d.ts +4 -0
  5. package/dist/assembly/fxaa-registration.d.ts.map +1 -0
  6. package/dist/assembly/lod-observation.d.ts +4 -0
  7. package/dist/assembly/lod-observation.d.ts.map +1 -0
  8. package/dist/assembly/motion-blur-registration.d.ts +3 -0
  9. package/dist/assembly/motion-blur-registration.d.ts.map +1 -0
  10. package/dist/assembly/renderer-facade.d.ts.map +1 -1
  11. package/dist/assembly/temporal-post-process-prewarm.d.ts +13 -0
  12. package/dist/assembly/temporal-post-process-prewarm.d.ts.map +1 -0
  13. package/dist/assembly/webgpu-ready-contract.d.ts +4 -3
  14. package/dist/assembly/webgpu-ready-contract.d.ts.map +1 -1
  15. package/dist/assembly/webgpu-ready.d.ts +1 -1
  16. package/dist/assembly/webgpu-ready.d.ts.map +1 -1
  17. package/dist/assembly/webgpu-renderer.d.ts.map +1 -1
  18. package/dist/bloom-admission.d.ts +13 -0
  19. package/dist/bloom-admission.d.ts.map +1 -0
  20. package/dist/{chunk-DRR7D3KX.mjs → chunk-3RTCJTH4.mjs} +12 -9
  21. package/dist/chunk-3RTCJTH4.mjs.map +1 -0
  22. package/dist/{chunk-S665NJNF.mjs → chunk-LEAXOMYJ.mjs} +3 -3
  23. package/dist/{chunk-S665NJNF.mjs.map → chunk-LEAXOMYJ.mjs.map} +1 -1
  24. package/dist/{chunk-6TFBXLRJ.mjs → chunk-NB2S7ZRX.mjs} +155 -11
  25. package/dist/chunk-NB2S7ZRX.mjs.map +1 -0
  26. package/dist/{chunk-LULCA3OF.mjs → chunk-NNQL7CB3.mjs} +4 -4
  27. package/dist/{chunk-LULCA3OF.mjs.map → chunk-NNQL7CB3.mjs.map} +1 -1
  28. package/dist/{chunk-4NNZQCL3.mjs → chunk-OO3DRYK7.mjs} +66 -200
  29. package/dist/chunk-OO3DRYK7.mjs.map +1 -0
  30. package/dist/{chunk-GWCEMDVR.mjs → chunk-PLAXOOX5.mjs} +232 -7
  31. package/dist/chunk-PLAXOOX5.mjs.map +1 -0
  32. package/dist/{chunk-JRIPCMYC.mjs → chunk-VOZWDM3L.mjs} +283 -193
  33. package/dist/chunk-VOZWDM3L.mjs.map +1 -0
  34. package/dist/{chunk-JCIRYQ4V.mjs → chunk-ZDQWGQW7.mjs} +10 -4
  35. package/dist/chunk-ZDQWGQW7.mjs.map +1 -0
  36. package/dist/components/atmosphere.d.ts +2 -0
  37. package/dist/components/atmosphere.d.ts.map +1 -1
  38. package/dist/components/camera.d.ts +20 -4
  39. package/dist/components/camera.d.ts.map +1 -1
  40. package/dist/components/mesh-filter.d.ts +6 -5
  41. package/dist/components/mesh-filter.d.ts.map +1 -1
  42. package/dist/components/motion-blur.d.ts +1 -0
  43. package/dist/components/motion-blur.d.ts.map +1 -1
  44. package/dist/construct-renderer.mjs +2865 -526
  45. package/dist/construct-renderer.mjs.map +1 -1
  46. package/dist/device/gpu-residency.d.ts +6 -0
  47. package/dist/device/gpu-residency.d.ts.map +1 -1
  48. package/dist/environment/analytic-fog.d.ts +6 -0
  49. package/dist/environment/analytic-fog.d.ts.map +1 -0
  50. package/dist/errors/render.d.ts +6 -2
  51. package/dist/errors/render.d.ts.map +1 -1
  52. package/dist/extract/camera.d.ts.map +1 -1
  53. package/dist/extract/contracts.d.ts +7 -0
  54. package/dist/extract/contracts.d.ts.map +1 -1
  55. package/dist/extract/environment.d.ts +2 -0
  56. package/dist/extract/environment.d.ts.map +1 -1
  57. package/dist/features/error-reporting.d.ts +13 -0
  58. package/dist/features/error-reporting.d.ts.map +1 -0
  59. package/dist/features/host.d.ts +5 -0
  60. package/dist/features/host.d.ts.map +1 -1
  61. package/dist/features/motion-blur/motion-blur-feature.d.ts +20 -2
  62. package/dist/features/motion-blur/motion-blur-feature.d.ts.map +1 -1
  63. package/dist/features/motion-blur/motion-blur-params.d.ts +21 -0
  64. package/dist/features/motion-blur/motion-blur-params.d.ts.map +1 -1
  65. package/dist/features/motion-blur/motion-blur-runtime.d.ts +54 -0
  66. package/dist/features/motion-blur/motion-blur-runtime.d.ts.map +1 -0
  67. package/dist/features/plan.d.ts +7 -4
  68. package/dist/features/plan.d.ts.map +1 -1
  69. package/dist/features/prepared-gpu-work.d.ts +9 -5
  70. package/dist/features/prepared-gpu-work.d.ts.map +1 -1
  71. package/dist/features/render-graph-compute.d.ts.map +1 -1
  72. package/dist/features/render-graph-contribution.d.ts.map +1 -1
  73. package/dist/gpu-driven/material-artifacts.d.ts.map +1 -1
  74. package/dist/gpu-driven/prepared-draw.d.ts +7 -0
  75. package/dist/gpu-driven/prepared-draw.d.ts.map +1 -1
  76. package/dist/gpu-driven/production-raster.d.ts +3 -1
  77. package/dist/gpu-driven/production-raster.d.ts.map +1 -1
  78. package/dist/gpu-driven/view-gpu.d.ts +9 -0
  79. package/dist/gpu-driven/view-gpu.d.ts.map +1 -1
  80. package/dist/gpu-scene.d.ts +6 -1
  81. package/dist/gpu-scene.d.ts.map +1 -1
  82. package/dist/index.d.ts +5 -2
  83. package/dist/index.d.ts.map +1 -1
  84. package/dist/index.mjs +5 -5
  85. package/dist/inspection-types.d.ts +43 -2
  86. package/dist/inspection-types.d.ts.map +1 -1
  87. package/dist/instances.d.ts +8 -1
  88. package/dist/instances.d.ts.map +1 -1
  89. package/dist/internal.mjs +6 -6
  90. package/dist/pipeline/standard-post.d.ts +28 -19
  91. package/dist/pipeline/standard-post.d.ts.map +1 -1
  92. package/dist/pipeline-spec.d.ts +2 -2
  93. package/dist/record/frame-snapshot.d.ts +3 -0
  94. package/dist/record/frame-snapshot.d.ts.map +1 -1
  95. package/dist/record/frame-targets.d.ts.map +1 -1
  96. package/dist/record/frame.d.ts +5 -2
  97. package/dist/record/frame.d.ts.map +1 -1
  98. package/dist/record/main-pass-geometry.d.ts.map +1 -1
  99. package/dist/record/mesh-ssbo.d.ts +6 -5
  100. package/dist/record/mesh-ssbo.d.ts.map +1 -1
  101. package/dist/record/render-context.d.ts +12 -28
  102. package/dist/record/render-context.d.ts.map +1 -1
  103. package/dist/record/shadow-pass.d.ts.map +1 -1
  104. package/dist/record/skybox-post-pass.d.ts +5 -3
  105. package/dist/record/skybox-post-pass.d.ts.map +1 -1
  106. package/dist/record/typed-frame-graph.d.ts +1 -1
  107. package/dist/record/typed-frame-graph.d.ts.map +1 -1
  108. package/dist/recovery/render-system-candidate.d.ts.map +1 -1
  109. package/dist/render-contract.d.ts +24 -4
  110. package/dist/render-contract.d.ts.map +1 -1
  111. package/dist/render-pipeline.d.ts +3 -1
  112. package/dist/render-pipeline.d.ts.map +1 -1
  113. package/dist/render-system-extract-tail.d.ts.map +1 -1
  114. package/dist/render-system-extract.d.ts +8 -1
  115. package/dist/render-system-extract.d.ts.map +1 -1
  116. package/dist/render-system.d.ts +7 -1
  117. package/dist/render-system.d.ts.map +1 -1
  118. package/dist/scene/render-scene.d.ts +2 -0
  119. package/dist/scene/render-scene.d.ts.map +1 -1
  120. package/dist/temporal/frame-coordinator.d.ts.map +1 -1
  121. package/dist/temporal/frame.d.ts +7 -0
  122. package/dist/temporal/frame.d.ts.map +1 -1
  123. package/dist/temporal/index.mjs +5 -5
  124. package/dist/temporal/view.d.ts +6 -1
  125. package/dist/temporal/view.d.ts.map +1 -1
  126. package/dist/typed-render-graph-primitives.d.ts +10 -7
  127. package/dist/typed-render-graph-primitives.d.ts.map +1 -1
  128. package/dist/volume/component.d.ts +0 -1
  129. package/dist/volume/component.d.ts.map +1 -1
  130. package/dist/volume/passes.d.ts.map +1 -1
  131. package/dist/volume/temporal.d.ts +3 -1
  132. package/dist/volume/temporal.d.ts.map +1 -1
  133. package/package.json +21 -21
  134. package/src/__tests__/analytic-fog-graph.unit.test.ts +199 -0
  135. package/src/__tests__/analytic-fog-optics.unit.test.ts +38 -0
  136. package/src/__tests__/bloom-exact-zero.unit.test.ts +112 -18
  137. package/src/__tests__/bloom-lifecycle.integration.test.ts +17 -4
  138. package/src/__tests__/dynamic-resolution-plugin.integration.test.ts +20 -0
  139. package/src/__tests__/environment-fog-lanes.integration.test.ts +14 -0
  140. package/src/__tests__/environment-selection.unit.test.ts +12 -0
  141. package/src/__tests__/factory-contract.integration.test.ts +331 -68
  142. package/src/__tests__/frame-camera-selection.unit.test.ts +39 -1
  143. package/src/__tests__/frame-plan-contract.unit.test.ts +2 -0
  144. package/src/__tests__/gpu-driven-indirect-raster-evidence.ts +2 -1
  145. package/src/__tests__/gpu-driven-shadow-views.dawn.test.ts +1 -1
  146. package/src/__tests__/gpu-driven-shadow-views.integration.test.ts +1 -1
  147. package/src/__tests__/gpu-driven-view-gpu-evidence.ts +11 -0
  148. package/src/__tests__/gpu-resource-store-stride.unit.test.ts +12 -0
  149. package/src/__tests__/gpu-scene.unit.test.ts +91 -0
  150. package/src/__tests__/instances-store.unit.test.ts +75 -0
  151. package/src/__tests__/mesh-submission-lifetime.unit.test.ts +31 -0
  152. package/src/__tests__/motion-blur-compute.dawn.test.ts +583 -0
  153. package/src/__tests__/prepared-gpu-driven-pbr.integration.test.ts +21 -0
  154. package/src/__tests__/prepared-graphics-lifecycle.unit.test.ts +86 -0
  155. package/src/__tests__/public-surface.test-d.ts +8 -0
  156. package/src/__tests__/render-error-code-owner.test-d.ts +2 -0
  157. package/src/__tests__/render-feature-error-reporting.unit.test.ts +24 -0
  158. package/src/__tests__/render-feature-stage-fault-recovery.unit.test.ts +23 -0
  159. package/src/__tests__/render-scene-temporal-retry.unit.test.ts +31 -0
  160. package/src/__tests__/renderer-frame-transaction.integration.test.ts +7 -1
  161. package/src/__tests__/shader-manifest-fixture.ts +4 -4
  162. package/src/__tests__/skinned-shadow-caster.test.ts +2 -0
  163. package/src/__tests__/standard-lighting-write.unit.test.ts +2 -1
  164. package/src/__tests__/standard-pipeline.dawn.test.ts +1 -1
  165. package/src/__tests__/standard-pipeline.integration.test.ts +18 -8
  166. package/src/__tests__/temporal-reset-matrix.unit.test.ts +10 -0
  167. package/src/__tests__/temporal-view.unit.test.ts +2 -1
  168. package/src/__tests__/typed-pipeline-topology.unit.test.ts +20 -4
  169. package/src/__tests__/volume-optics-oracle.unit.test.ts +3 -1
  170. package/src/__tests__/volumetric-fog-budget-recovery.integration.test.ts +177 -13
  171. package/src/__tests__/volumetric-fog-stage-readback.dawn.test.ts +447 -61
  172. package/src/__tests__/volumetric-fog-temporal.unit.test.ts +27 -0
  173. package/src/__tests__/volumetric-fog-topology.unit.test.ts +19 -3
  174. package/src/assembly/analytic-fog-registration.ts +14 -0
  175. package/src/assembly/fxaa-registration.ts +17 -0
  176. package/src/assembly/lod-observation.ts +12 -0
  177. package/src/assembly/motion-blur-registration.ts +17 -0
  178. package/src/assembly/renderer-facade.ts +2 -0
  179. package/src/assembly/temporal-post-process-prewarm.ts +40 -0
  180. package/src/assembly/webgpu-ready-contract.ts +8 -3
  181. package/src/assembly/webgpu-ready.ts +181 -245
  182. package/src/assembly/webgpu-renderer.ts +16 -31
  183. package/src/bloom-admission.ts +21 -0
  184. package/src/components/__tests__/motion-blur.unit.test.ts +1 -0
  185. package/src/components/atmosphere.ts +4 -0
  186. package/src/components/camera.ts +66 -10
  187. package/src/components/mesh-filter.ts +11 -9
  188. package/src/components/motion-blur.ts +1 -0
  189. package/src/device/gpu-residency.ts +56 -12
  190. package/src/environment/analytic-fog.ts +44 -0
  191. package/src/environment/background.ts +2 -2
  192. package/src/errors/render.ts +14 -1
  193. package/src/extract/camera.ts +20 -7
  194. package/src/extract/contracts.ts +7 -0
  195. package/src/extract/environment.ts +4 -0
  196. package/src/features/error-reporting.ts +78 -0
  197. package/src/features/host.ts +25 -12
  198. package/src/features/motion-blur/__tests__/motion-blur-feature.unit.test.ts +111 -1
  199. package/src/features/motion-blur/__tests__/motion-blur-params.unit.test.ts +58 -3
  200. package/src/features/motion-blur/motion-blur-feature.ts +1519 -15
  201. package/src/features/motion-blur/motion-blur-params.ts +70 -2
  202. package/src/features/motion-blur/motion-blur-runtime.ts +176 -0
  203. package/src/features/plan.ts +25 -8
  204. package/src/features/prepared-gpu-work.ts +86 -35
  205. package/src/features/render-graph-compute.ts +13 -5
  206. package/src/features/render-graph-contribution.ts +2 -0
  207. package/src/gpu-driven/material-artifacts.ts +2 -7
  208. package/src/gpu-driven/prepared-draw.ts +15 -10
  209. package/src/gpu-driven/production-raster.ts +83 -7
  210. package/src/gpu-driven/view-gpu.ts +17 -0
  211. package/src/gpu-scene.ts +188 -21
  212. package/src/index.ts +32 -0
  213. package/src/inspection-types.ts +60 -2
  214. package/src/instances.ts +80 -1
  215. package/src/pipeline/standard-post.ts +307 -78
  216. package/src/pipeline-spec.ts +5 -5
  217. package/src/points-lines/__tests__/view-ubo.unit.test.ts +2 -1
  218. package/src/record/__tests__/temporal-payload.unit.test.ts +2 -1
  219. package/src/record/frame-snapshot.ts +5 -1
  220. package/src/record/frame-targets.ts +10 -6
  221. package/src/record/frame.ts +163 -22
  222. package/src/record/main-pass-geometry.ts +30 -1
  223. package/src/record/main-pass-sprite-draws.ts +6 -1
  224. package/src/record/mesh-ssbo.ts +43 -6
  225. package/src/record/render-context.ts +12 -28
  226. package/src/record/shadow-pass.ts +6 -1
  227. package/src/record/skybox-post-pass.ts +122 -196
  228. package/src/record/typed-frame-graph.ts +153 -9
  229. package/src/recovery/render-system-candidate.ts +22 -10
  230. package/src/render-contract.ts +23 -4
  231. package/src/render-pipeline.ts +6 -1
  232. package/src/render-system-extract-tail.ts +16 -0
  233. package/src/render-system-extract.ts +45 -13
  234. package/src/render-system.ts +255 -120
  235. package/src/scene/render-scene.ts +85 -4
  236. package/src/temporal/__tests__/temporal-frame-transaction.unit.test.ts +36 -0
  237. package/src/temporal/frame-coordinator.ts +8 -1
  238. package/src/temporal/frame.ts +20 -0
  239. package/src/temporal/view.ts +13 -2
  240. package/src/typed-render-graph-primitives.ts +121 -48
  241. package/src/volume/component.ts +0 -1
  242. package/src/volume/passes.ts +8 -4
  243. package/src/volume/resources.ts +1 -1
  244. package/src/volume/temporal.ts +11 -0
  245. package/dist/chunk-4NNZQCL3.mjs.map +0 -1
  246. package/dist/chunk-6TFBXLRJ.mjs.map +0 -1
  247. package/dist/chunk-DRR7D3KX.mjs.map +0 -1
  248. package/dist/chunk-GWCEMDVR.mjs.map +0 -1
  249. package/dist/chunk-JCIRYQ4V.mjs.map +0 -1
  250. package/dist/chunk-JRIPCMYC.mjs.map +0 -1
@@ -26,6 +26,7 @@ import {
26
26
  } from '../components';
27
27
  import type { DeviceScope, LifecycleResourceSpec } from '../device/device-scope';
28
28
  import { gpuDrivenShadowDrawKey } from '../extract/gpu-driven';
29
+ import { motionBlurTemporalDemand } from '../features/motion-blur/motion-blur-params';
29
30
  import {
30
31
  BatchTopology,
31
32
  type BatchTopologyInspection,
@@ -115,6 +116,41 @@ interface WorldTransformSpan {
115
116
 
116
117
  type GlobalTransformChangeQuery = Query<readonly [typeof GlobalTransform]>;
117
118
 
119
+ function sameInstanceIdentitySet(
120
+ current: Uint32Array | undefined,
121
+ previous: Uint32Array | undefined,
122
+ ): boolean {
123
+ // An instanced producer without generation tokens cannot prove that a
124
+ // compacted/reordered row is the same logical object. Keep that legacy
125
+ // snapshot conservative: the record stage will seed its previous matrix
126
+ // instead of borrowing a velocity from an unknown ordinal.
127
+ if (current === undefined || previous === undefined) return false;
128
+ const currentSet = new Set(current);
129
+ const previousSet = new Set(previous);
130
+ if (
131
+ currentSet.size !== current.length ||
132
+ previousSet.size !== previous.length ||
133
+ current.some((identity) => identity === 0) ||
134
+ previous.some((identity) => identity === 0)
135
+ ) {
136
+ return false;
137
+ }
138
+ if (current.length === previous.length) {
139
+ let sameOrder = true;
140
+ for (let index = 0; index < current.length; index += 1) {
141
+ if (current[index] !== previous[index]) {
142
+ sameOrder = false;
143
+ break;
144
+ }
145
+ }
146
+ if (sameOrder) return true;
147
+ }
148
+ // Reorder and retirement are valid when every current token is unique and
149
+ // survives. The set is built only on the topology-change path; steady pose
150
+ // updates stay on the linear same-order check and do not allocate.
151
+ return current.every((identity) => previousSet.has(identity));
152
+ }
153
+
118
154
  /** The normal extract request used by the persistent scene owner. */
119
155
  export type PersistentRenderCandidateRequest =
120
156
  | 'full'
@@ -202,6 +238,9 @@ function ownSnapshot(snapshot: RenderableSnapshot): RenderableSnapshot {
202
238
  instances: {
203
239
  ...snapshot.instances,
204
240
  transforms: new Float32Array(snapshot.instances.transforms),
241
+ ...(snapshot.instances.generations === undefined
242
+ ? {}
243
+ : { generations: new Uint32Array(snapshot.instances.generations) }),
205
244
  },
206
245
  }),
207
246
  ...(snapshot.spriteInstances === undefined
@@ -442,6 +481,9 @@ export class RenderScene {
442
481
  instances: {
443
482
  ...operation.instances,
444
483
  transforms: new Float32Array(operation.instances.transforms),
484
+ ...(operation.instances.generations === undefined
485
+ ? {}
486
+ : { generations: new Uint32Array(operation.instances.generations) }),
445
487
  },
446
488
  };
447
489
  pending.instanceChanged = true;
@@ -922,16 +964,36 @@ export class RenderScene {
922
964
  reasons.includes('material-revision');
923
965
  const previous = seedCurrent || submitted === undefined ? slot.snapshot : submitted.snapshot;
924
966
  const previousWorld = this.previousWorldBySlot[slot.slot];
967
+ const currentInstances = slot.snapshot.instances;
968
+ const submittedInstances = submitted?.snapshot.instances;
969
+ const instancesCompatible =
970
+ currentInstances === undefined && submittedInstances === undefined
971
+ ? true
972
+ : currentInstances !== undefined &&
973
+ submittedInstances !== undefined &&
974
+ (currentInstances.collectionId === undefined ||
975
+ submittedInstances.collectionId === undefined ||
976
+ currentInstances.collectionId === submittedInstances.collectionId) &&
977
+ currentInstances.transforms.length >= currentInstances.instanceCount * 16 &&
978
+ submittedInstances.transforms.length >= submittedInstances.instanceCount * 16 &&
979
+ sameInstanceIdentitySet(currentInstances.generations, submittedInstances.generations);
980
+ const motionValid = !seedCurrent && previousWorld !== undefined && instancesCompatible;
925
981
  return {
926
982
  previousEpoch: seedCurrent ? undefined : this.submittedEpoch,
927
983
  previousSource: seedCurrent ? 'current-seed' : 'last-submitted',
984
+ motionValid,
928
985
  reactive: reasons.length > 0,
929
986
  reactiveReasons: reasons,
930
987
  previousTransform:
931
988
  seedCurrent || previousWorld === undefined
932
989
  ? previous.transform
933
990
  : { ...previous.transform, world: previousWorld },
934
- previousInstances: previous.instances,
991
+ // A collection identity/count break is a temporal cut for that
992
+ // renderable. The record stage receives the current snapshot as its
993
+ // baseline, while stable collections retain the submitted column and
994
+ // use identity generations to map reordered entries and seed replaced
995
+ // entries.
996
+ previousInstances: seedCurrent ? slot.snapshot.instances : previous.instances,
935
997
  previousSkin: previous.skin,
936
998
  previousMorphWeights: previous.morph?.weights,
937
999
  };
@@ -1273,7 +1335,17 @@ function topologyDeltaForAvailable(
1273
1335
 
1274
1336
  function frameDemandsTemporal(frame: ExtractedFrame): boolean {
1275
1337
  const camera = frame.cameras[0];
1276
- return camera?.antialias === 'taa' || (camera?.motionBlur?.shutterAngle ?? 0) > 0;
1338
+ const motionBlur = camera?.motionBlur;
1339
+ return (
1340
+ camera?.antialias === 'taa' ||
1341
+ (motionBlur !== undefined &&
1342
+ motionBlurTemporalDemand({
1343
+ shutterAngle: motionBlur.shutterAngle,
1344
+ maxRadiusPixels: motionBlur.maxRadiusPixels,
1345
+ sampleCount: motionBlur.sampleCount,
1346
+ targetFps: motionBlur.targetFps ?? 60,
1347
+ }))
1348
+ );
1277
1349
  }
1278
1350
 
1279
1351
  function cameraFrusta(cameras: readonly CameraSnapshot[]): readonly Float32Array[] {
@@ -2510,13 +2582,17 @@ export class PersistentRenderScene {
2510
2582
  // Previous transforms are consumed only by TAA and motion blur. Ordinary
2511
2583
  // frames must not copy and upload the complete GPU transform table.
2512
2584
  const gpuCommit = this.gpuScene?.commitTemporalFrame(this.temporalCapture !== undefined);
2513
- if (gpuCommit !== undefined && !gpuCommit.ok) return gpuCommit;
2585
+ if (gpuCommit !== undefined && !gpuCommit.ok) {
2586
+ this.temporalCapture = undefined;
2587
+ return gpuCommit;
2588
+ }
2514
2589
  const temporalCapture = this.temporalCapture;
2515
2590
  if (temporalCapture !== undefined) {
2516
2591
  try {
2517
2592
  temporalCapture.projection.commitSubmission(temporalCapture.capture);
2518
2593
  } catch (cause) {
2519
2594
  void cause;
2595
+ this.temporalCapture = undefined;
2520
2596
  return err(
2521
2597
  new RhiError({
2522
2598
  code: 'internal-error',
@@ -2530,6 +2606,11 @@ export class PersistentRenderScene {
2530
2606
  return ok(undefined);
2531
2607
  }
2532
2608
 
2609
+ /** Drop a staged temporal capture when the owning frame did not submit. */
2610
+ discardTemporalFrame(): void {
2611
+ this.temporalCapture = undefined;
2612
+ }
2613
+
2533
2614
  /** Capture current CPU scene facts; commitTemporalFrame publishes them only after submit. */
2534
2615
  prepareTemporalFrame(
2535
2616
  visible: readonly Pick<RenderableSnapshot, 'worldId' | 'entityKey'>[],
@@ -2912,7 +2993,7 @@ export class PersistentRenderScene {
2912
2993
  recreatedSlots: [],
2913
2994
  resynced: 0,
2914
2995
  })
2915
- : acquired.scene.sync(delta);
2996
+ : acquired.scene.sync(delta, (slot) => projection.temporalSnapshotBySlot(slot.slot));
2916
2997
  if (!result.ok) {
2917
2998
  this.sceneTableUploadBytes = 0;
2918
2999
  this.failGpu(result.error);
@@ -0,0 +1,36 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { createTemporalFrameTransaction } from '../frame';
3
+
4
+ function frame(frameId: number, resetReason?: 'camera-cut') {
5
+ return {
6
+ frameId,
7
+ currentViewProjection: new Float32Array(16),
8
+ jitter: [0, 0] as [number, number],
9
+ viewport: { width: 64, height: 64 },
10
+ cameraPosition: [frameId, 0, 0] as [number, number, number],
11
+ ...(resetReason === undefined ? {} : { resetReason }),
12
+ };
13
+ }
14
+
15
+ describe('renderer temporal candidate lifecycle', () => {
16
+ it('projects a rejected submit while retaining the accepted baseline', () => {
17
+ const transaction = createTemporalFrameTransaction({ deviceEpoch: 4 });
18
+ transaction.stage(frame(1));
19
+ expect(transaction.commit({ accepted: true }).ok).toBe(true);
20
+ const baseline = transaction.snapshot();
21
+
22
+ transaction.stage(frame(2));
23
+ expect(transaction.commit({ accepted: false, reason: 'queue-submit-failed' }).ok).toBe(false);
24
+ expect(transaction.snapshot()).toEqual(baseline);
25
+ expect(transaction.inspect()).toEqual({
26
+ accepted: true,
27
+ staged: false,
28
+ lastFailure: 'queue-submit-failed',
29
+ });
30
+
31
+ transaction.stage(frame(3));
32
+ expect(transaction.commit({ accepted: true }).ok).toBe(true);
33
+ expect(transaction.inspect()).toEqual({ accepted: true, staged: false });
34
+ expect(transaction.snapshot().frameId).toBe(3);
35
+ });
36
+ });
@@ -1,4 +1,5 @@
1
1
  import { err, ok, type Result } from '@forgeax/engine-types';
2
+ import { motionBlurTemporalDemand } from '../features/motion-blur/motion-blur-params';
2
3
  import type { CameraSnapshot } from '../render-contract';
3
4
  import { classifySceneDataCoverage, type SceneDataContributor } from './coverage';
4
5
  import { type TaaHistoryAttempt, TaaHistoryStore } from './taa-history-store';
@@ -301,7 +302,13 @@ export class TemporalFrameCoordinator<T = unknown> {
301
302
  ): TemporalView | undefined {
302
303
  const demanded =
303
304
  camera.antialias === 'taa' ||
304
- (camera.motionBlur?.shutterAngle !== undefined && camera.motionBlur.shutterAngle > 0);
305
+ (camera.motionBlur !== undefined &&
306
+ motionBlurTemporalDemand({
307
+ shutterAngle: camera.motionBlur.shutterAngle,
308
+ maxRadiusPixels: camera.motionBlur.maxRadiusPixels,
309
+ sampleCount: camera.motionBlur.sampleCount,
310
+ targetFps: camera.motionBlur.targetFps ?? 60,
311
+ }));
305
312
  if (!demanded) return undefined;
306
313
  return projectTemporalView({
307
314
  camera,
@@ -40,10 +40,18 @@ export interface TemporalSubmitResult {
40
40
  readonly resetReason?: Exclude<TemporalResetReason, 'none' | 'queue-submit-failed'>;
41
41
  }
42
42
 
43
+ /** Detached candidate/lifecycle facts consumed by Renderer inspection. */
44
+ export interface TemporalFrameTransactionInspection {
45
+ readonly accepted: boolean;
46
+ readonly staged: boolean;
47
+ readonly lastFailure?: 'queue-submit-failed';
48
+ }
49
+
43
50
  export interface TemporalFrameTransaction {
44
51
  stage(input: TemporalFrameInput): void;
45
52
  commit(submit: TemporalSubmitResult): Result<TemporalFrame, TemporalFrameSubmitError>;
46
53
  snapshot(): TemporalFrame;
54
+ inspect(): TemporalFrameTransactionInspection;
47
55
  /** Drop accepted/staged history after a producer or device generation cut. */
48
56
  reset(reason?: Extract<TemporalResetReason, 'signature-change' | 'device-recovery'>): void;
49
57
  }
@@ -85,6 +93,7 @@ export function createTemporalFrameTransaction(options: {
85
93
  let staged: TemporalFrameInput | undefined;
86
94
  let historyEpoch = 0;
87
95
  let pendingResetReason: Exclude<TemporalResetReason, 'none' | 'queue-submit-failed'> | undefined;
96
+ let lastFailure: TemporalFrameTransactionInspection['lastFailure'];
88
97
 
89
98
  return {
90
99
  stage(input) {
@@ -93,9 +102,11 @@ export function createTemporalFrameTransaction(options: {
93
102
  commit(submit) {
94
103
  if (!submit.accepted) {
95
104
  staged = undefined;
105
+ lastFailure = submit.reason ?? 'queue-submit-failed';
96
106
  return err(new TemporalFrameSubmitError(submit.reason ?? 'queue-submit-failed'));
97
107
  }
98
108
  if (staged === undefined) {
109
+ lastFailure = 'queue-submit-failed';
99
110
  return err(new TemporalFrameSubmitError('queue-submit-failed'));
100
111
  }
101
112
 
@@ -121,6 +132,7 @@ export function createTemporalFrameTransaction(options: {
121
132
  accepted = next;
122
133
  staged = undefined;
123
134
  pendingResetReason = undefined;
135
+ lastFailure = undefined;
124
136
  return ok(copyFrame(next));
125
137
  },
126
138
  snapshot() {
@@ -128,6 +140,13 @@ export function createTemporalFrameTransaction(options: {
128
140
  throw new Error('TemporalFrame snapshot is unavailable before commit');
129
141
  return copyFrame(accepted);
130
142
  },
143
+ inspect() {
144
+ return {
145
+ accepted: accepted !== undefined,
146
+ staged: staged !== undefined,
147
+ ...(lastFailure === undefined ? {} : { lastFailure }),
148
+ };
149
+ },
131
150
  reset(reason = 'signature-change') {
132
151
  accepted = undefined;
133
152
  staged = undefined;
@@ -136,6 +155,7 @@ export function createTemporalFrameTransaction(options: {
136
155
  // committed frame prevents stale motion vectors from crossing the
137
156
  // producer/device boundary.
138
157
  pendingResetReason = reason;
158
+ lastFailure = undefined;
139
159
  },
140
160
  };
141
161
  }
@@ -21,6 +21,7 @@ export type TemporalResetReason =
21
21
  | 'view-switch'
22
22
  | 'environment-change'
23
23
  | 'fog-change'
24
+ | 'time-discontinuity'
24
25
  | 'device-recover'
25
26
  | 'submit-failure';
26
27
 
@@ -43,6 +44,8 @@ export interface TemporalViewInput {
43
44
  readonly previousUnjitteredViewProjection?: ArrayLike<number> | undefined;
44
45
  readonly currentCameraPosition?: readonly [number, number, number] | undefined;
45
46
  readonly previousCameraPosition?: readonly [number, number, number] | undefined;
47
+ /** Monotonic accepted sample time used to bridge failed render attempts. */
48
+ readonly sampleTimeSeconds?: number | undefined;
46
49
  readonly resetReason?: TemporalResetReason | undefined;
47
50
  }
48
51
 
@@ -65,6 +68,7 @@ interface TemporalViewSnapshot {
65
68
  readonly previousUnjitteredViewProjection: ArrayLike<number> | undefined;
66
69
  readonly currentCameraPosition: readonly [number, number, number] | undefined;
67
70
  readonly previousCameraPosition: readonly [number, number, number] | undefined;
71
+ readonly sampleTimeSeconds: number | undefined;
68
72
  readonly resetReason: TemporalResetReason | undefined;
69
73
  }
70
74
 
@@ -83,6 +87,8 @@ export interface TemporalView {
83
87
  /** Camera origins follow the same accepted-submit boundary as the matrices. */
84
88
  readonly currentCameraPosition: readonly [number, number, number] | undefined;
85
89
  readonly previousCameraPosition: readonly [number, number, number] | undefined;
90
+ /** Monotonic accepted sample time used to bridge failed render attempts. */
91
+ readonly sampleTimeSeconds: number | undefined;
86
92
  readonly currentProjection: 'jittered' | 'unjittered';
87
93
  readonly previousProjection: 'unjittered';
88
94
  readonly historyRequired: boolean;
@@ -131,6 +137,7 @@ export function createTemporalView(input: TemporalViewInput): TemporalView {
131
137
  previousUnjitteredViewProjection: input.previousUnjitteredViewProjection,
132
138
  currentCameraPosition: input.currentCameraPosition,
133
139
  previousCameraPosition: input.previousCameraPosition,
140
+ sampleTimeSeconds: input.sampleTimeSeconds,
134
141
  resetReason: input.resetReason,
135
142
  });
136
143
  const taa = input.antialias === 'taa';
@@ -156,6 +163,7 @@ export function createTemporalView(input: TemporalViewInput): TemporalView {
156
163
  previousUnjitteredViewProjection: input.previousUnjitteredViewProjection,
157
164
  currentCameraPosition: input.currentCameraPosition,
158
165
  previousCameraPosition: input.previousCameraPosition,
166
+ sampleTimeSeconds: input.sampleTimeSeconds,
159
167
  currentProjection: taa ? 'jittered' : 'unjittered',
160
168
  previousProjection: 'unjittered',
161
169
  historyRequired: taa,
@@ -167,8 +175,11 @@ export function resolveTemporalReset(
167
175
  previous: TemporalView | undefined,
168
176
  next: TemporalView,
169
177
  ): TemporalResetReason | undefined {
170
- if (next.mode !== 'taa') return undefined;
171
- if (previous === undefined || previous.mode !== 'taa') return 'first-frame';
178
+ // Motion Blur uses the unjittered `off` temporal mode as well as TAA. Keep
179
+ // the reset matrix shared so a camera switch, resize, or recovery cannot
180
+ // leak a previous motion vector merely because antialiasing is disabled.
181
+ if (previous === undefined) return 'first-frame';
182
+ if (next.mode === 'taa' && previous.mode !== 'taa') return 'first-frame';
172
183
  const before = previous.input;
173
184
  const after = next.input;
174
185
  if (before.width !== after.width || before.height !== after.height) return 'resize';
@@ -1,10 +1,10 @@
1
- import type {
2
- GraphAccess,
3
- GraphResourceResolver,
4
- GraphTextureView,
5
- RenderGraphBuilder,
1
+ import {
2
+ type GraphAccess,
3
+ type GraphResourceResolver,
4
+ type GraphTextureView,
5
+ type RenderGraphBuilder,
6
6
  RenderGraphError,
7
- ResolveContext,
7
+ type ResolveContext,
8
8
  } from '@forgeax/engine-render-graph';
9
9
  import {
10
10
  type Buffer,
@@ -13,7 +13,7 @@ import {
13
13
  type TextureFormat,
14
14
  type TextureView,
15
15
  } from '@forgeax/engine-rhi';
16
- import { ok, type PassKind, type PassSelector, type Result } from '@forgeax/engine-types';
16
+ import { err, ok, type PassKind, type PassSelector, type Result } from '@forgeax/engine-types';
17
17
  import {
18
18
  GPU_TEXTURE_USAGE_COPY_SRC,
19
19
  GPU_TEXTURE_USAGE_RENDER_ATTACHMENT,
@@ -28,10 +28,9 @@ import type {
28
28
  } from './record/render-context';
29
29
  import {
30
30
  encodeSkyboxPass,
31
- recordBloomBlurHPass,
32
- recordBloomBlurVPass,
33
- recordBloomBrightPass,
34
31
  recordBloomCompositePass,
32
+ recordBloomDownsamplePass,
33
+ recordBloomUpsamplePass,
35
34
  } from './record/skybox-post-pass';
36
35
  import { STANDARD_OUTPUT_TRANSFORM_FEATURE_ID } from './render-contract';
37
36
  import {
@@ -502,57 +501,57 @@ export function addReflectionFallbackObservationPass(
502
501
  export interface TypedBloomTargets {
503
502
  readonly scene: RenderPipelineTarget;
504
503
  readonly composited: RenderPipelineTarget;
505
- readonly bright: RenderPipelineTarget;
506
- readonly blurH: RenderPipelineTarget;
507
- readonly blurV: RenderPipelineTarget;
504
+ readonly downsample: readonly RenderPipelineTarget[];
505
+ readonly upsample: readonly RenderPipelineTarget[];
506
+ readonly levelDimensions: readonly { readonly width: number; readonly height: number }[];
508
507
  }
509
508
 
510
509
  /** The semantic Bloom pass owner; graph inspection consumes these same identities. */
511
510
  export const TYPED_BLOOM_PASS_NAMES = {
512
- bright: 'bloom-bright',
513
- blurH: 'bloom-blur-h',
514
- blurV: 'bloom-blur-v',
511
+ downsample: 'bloom-downsample',
512
+ upsample: 'bloom-upsample',
515
513
  composite: 'bloom-composite',
516
514
  } as const;
517
515
 
516
+ export function typedBloomLevelPassName(kind: 'downsample' | 'upsample', level: number): string {
517
+ return `${TYPED_BLOOM_PASS_NAMES[kind]}-${level}`;
518
+ }
519
+
518
520
  export function addTypedBloomPasses(
519
521
  graph: RenderGraphBuilder<RenderPipelineFrame>,
520
522
  targets: TypedBloomTargets,
521
523
  ): Result<void, RenderGraphError> {
522
- const named = {
524
+ const named: Record<string, RenderPipelineTarget> = {
523
525
  hdrColor: targets.scene,
524
526
  hdrComposited: targets.composited,
525
- bloomBright: targets.bright,
526
- bloomBlurH: targets.blurH,
527
- bloomBlurV: targets.blurV,
528
527
  };
529
- const descriptors = [
530
- {
531
- name: TYPED_BLOOM_PASS_NAMES.bright,
532
- reads: [targets.scene],
533
- write: targets.bright,
534
- encode: recordBloomBrightPass,
535
- },
536
- {
537
- name: TYPED_BLOOM_PASS_NAMES.blurH,
538
- reads: [targets.bright],
539
- write: targets.blurH,
540
- encode: recordBloomBlurHPass,
541
- },
542
- {
543
- name: TYPED_BLOOM_PASS_NAMES.blurV,
544
- reads: [targets.blurH],
545
- write: targets.blurV,
546
- encode: recordBloomBlurVPass,
547
- },
548
- {
549
- name: TYPED_BLOOM_PASS_NAMES.composite,
550
- reads: [targets.scene, targets.blurV],
551
- write: targets.composited,
552
- encode: recordBloomCompositePass,
553
- },
554
- ] as const;
555
- for (const descriptor of descriptors) {
528
+ targets.downsample.forEach((target, level) => {
529
+ named[`bloomDownsample${level}`] = target;
530
+ });
531
+ targets.upsample.forEach((target, level) => {
532
+ named[`bloomUpsample${level}`] = target;
533
+ });
534
+
535
+ const add = (
536
+ name: string,
537
+ reads: readonly RenderPipelineTarget[],
538
+ write: RenderPipelineTarget,
539
+ encode: (
540
+ frame: _InternalRenderPipelineContext,
541
+ resolve: ResolveContext,
542
+ pass: import('@forgeax/engine-rhi').RhiRenderPassEncoder,
543
+ level?: number,
544
+ size?: { readonly width: number; readonly height: number },
545
+ ) => void,
546
+ level?: number,
547
+ ): Result<void, RenderGraphError> => {
548
+ const descriptor = {
549
+ name,
550
+ reads,
551
+ write,
552
+ encode,
553
+ level,
554
+ };
556
555
  const added = graph.addRasterPass(descriptor.name, {
557
556
  accesses: [
558
557
  ...descriptor.reads.map((target) => ({
@@ -574,11 +573,85 @@ export function addTypedBloomPasses(
574
573
  frame as _InternalRenderPipelineContext,
575
574
  legacyResolver(resources, named),
576
575
  pass,
576
+ descriptor.level,
577
+ descriptor.level === undefined ? undefined : targets.levelDimensions[descriptor.level],
577
578
  ),
578
579
  });
580
+ return added.ok ? ok(undefined) : added;
581
+ };
582
+
583
+ for (const [level, write] of targets.downsample.entries()) {
584
+ const source = level === 0 ? targets.scene : targets.downsample[level - 1];
585
+ if (source === undefined) {
586
+ return err(
587
+ new RenderGraphError({
588
+ code: 'resource-resolution-failed',
589
+ expected: 'Bloom downsample levels form one adjacent pyramid',
590
+ hint: 'rebuild the Standard Bloom target declaration from one level list',
591
+ detail: { resourceLabel: `bloom-level-${level}` },
592
+ }),
593
+ );
594
+ }
595
+ const added = add(
596
+ typedBloomLevelPassName('downsample', level),
597
+ [source],
598
+ write,
599
+ (frame, resolve, pass, passLevel) =>
600
+ recordBloomDownsamplePass(
601
+ frame,
602
+ resolve,
603
+ pass,
604
+ passLevel ?? level,
605
+ targets.levelDimensions[passLevel ?? level],
606
+ ),
607
+ level,
608
+ );
579
609
  if (!added.ok) return added;
580
610
  }
581
- return ok(undefined);
611
+ for (let level = targets.upsample.length - 1; level >= 0; level -= 1) {
612
+ const current = targets.downsample[level];
613
+ const coarse =
614
+ level === targets.downsample.length - 2
615
+ ? targets.downsample[level + 1]
616
+ : targets.upsample[level + 1];
617
+ const write = targets.upsample[level];
618
+ if (current === undefined || coarse === undefined || write === undefined) {
619
+ return err(
620
+ new RenderGraphError({
621
+ code: 'resource-resolution-failed',
622
+ expected: 'Bloom downsample and upsample levels form one adjacent pyramid',
623
+ hint: 'rebuild the Standard Bloom target declaration from one level list',
624
+ detail: { resourceLabel: `bloom-level-${level}` },
625
+ }),
626
+ );
627
+ }
628
+ const added = add(
629
+ typedBloomLevelPassName('upsample', level),
630
+ [current, coarse],
631
+ write,
632
+ (frame, resolve, pass, passLevel) =>
633
+ recordBloomUpsamplePass(frame, resolve, pass, passLevel ?? level),
634
+ level,
635
+ );
636
+ if (!added.ok) return added;
637
+ }
638
+ const finest = targets.upsample[0] ?? targets.downsample[0];
639
+ if (finest === undefined) {
640
+ return err(
641
+ new RenderGraphError({
642
+ code: 'resource-resolution-failed',
643
+ expected: 'Bloom has at least one downsample level',
644
+ hint: 'derive Bloom levels from a positive output extent',
645
+ detail: { resourceLabel: 'bloom-downsample-0' },
646
+ }),
647
+ );
648
+ }
649
+ return add(
650
+ TYPED_BLOOM_PASS_NAMES.composite,
651
+ [targets.scene, finest],
652
+ targets.composited,
653
+ (frame, resolve, pass) => recordBloomCompositePass(frame, resolve, pass),
654
+ );
582
655
  }
583
656
 
584
657
  export interface TypedSsaoTargets {
@@ -35,7 +35,6 @@ export interface VolumetricFogAuthoring {
35
35
  readonly light: EntityHandle;
36
36
  /** The optional same-World SpotLight paired with a PointLight. */
37
37
  readonly spotLight?: EntityHandle;
38
- /** Linear scalar density over bounds; the renderer adds no noise or animation. */
39
38
  readonly density: VolumeDensityBinding;
40
39
  readonly bounds: VolumeBounds;
41
40
  readonly extinction: readonly [number, number, number];
@@ -32,6 +32,7 @@ import {
32
32
  VOLUME_HISTORY_FORMAT,
33
33
  VOLUME_PACKING_Z,
34
34
  VOLUME_RESOLVED_FORMAT,
35
+ VOLUMETRIC_FOG_PARAMS_BYTES,
35
36
  } from './resources';
36
37
  import type { VolumeProjectorTuple } from './temporal';
37
38
 
@@ -214,7 +215,10 @@ function importVolumeInputs(
214
215
  if (!density.ok) return density;
215
216
  const params = graph.importBuffer(
216
217
  'volume-params',
217
- { size: 128, usage: GPU_BUFFER_USAGE_UNIFORM | GPU_BUFFER_USAGE_COPY_DST },
218
+ {
219
+ size: VOLUMETRIC_FOG_PARAMS_BYTES,
220
+ usage: GPU_BUFFER_USAGE_UNIFORM | GPU_BUFFER_USAGE_COPY_DST,
221
+ },
218
222
  (frame) => {
219
223
  const context = frame.volumetricFog as VolumetricFogFrameContext | undefined;
220
224
  if (context === undefined) throw pipelineError('parameter residency');
@@ -481,9 +485,9 @@ export function addVolumetricFogPasses(
481
485
  const densitySampler = frame.runtime.device.createSampler({
482
486
  minFilter: 'linear',
483
487
  magFilter: 'linear',
484
- addressModeU: 'clamp-to-edge',
485
- addressModeV: 'clamp-to-edge',
486
- addressModeW: 'clamp-to-edge',
488
+ addressModeU: 'repeat',
489
+ addressModeV: 'repeat',
490
+ addressModeW: 'repeat',
487
491
  });
488
492
  if (!densitySampler.ok) throw densitySampler.error;
489
493
  const bindings = volumeBindGroup(frame, integrate.layout, [
@@ -15,7 +15,7 @@ export const VOLUME_RESOLVED_FORMAT = 'rgba16float' as const;
15
15
  export const VOLUME_DEPTH_FORMAT = 'r32float' as const;
16
16
  export const VOLUME_PACKING_Z = 4;
17
17
  /** Renderer-owned parameter allocation shared by staging and inspection. */
18
- export const VOLUMETRIC_FOG_PARAMS_BYTES = 8 * 16;
18
+ export const VOLUMETRIC_FOG_PARAMS_BYTES = 9 * 16;
19
19
 
20
20
  export interface VolumetricFogExtent {
21
21
  readonly width: number;
@@ -5,6 +5,8 @@ export interface VolumeTemporalSignature {
5
5
  readonly densityGeneration: number;
6
6
  readonly width: number;
7
7
  readonly height: number;
8
+ /** World-owned authored-medium time; render frame identity never advances the medium. */
9
+ readonly worldTimeSeconds?: number;
8
10
  readonly lightKind?: 'directional' | 'point' | 'spot';
9
11
  readonly lightEntity?: number;
10
12
  readonly pointLightEntity?: number;
@@ -28,6 +30,7 @@ export type VolumeTemporalResetReason =
28
30
  | 'fog-revision'
29
31
  | 'light-revision'
30
32
  | 'density-generation'
33
+ | 'simulation-time'
31
34
  | 'resize'
32
35
  | 'reprojection-out-of-screen'
33
36
  | 'reprojection-depth-discontinuity';
@@ -92,6 +95,14 @@ export function resolveVolumeTemporalReset(
92
95
  if (previous.lightShadowRevision !== next.lightShadowRevision) {
93
96
  return { reset: true, reason: 'light-revision' };
94
97
  }
98
+ if (previous.worldTimeSeconds !== next.worldTimeSeconds) {
99
+ const delta = (next.worldTimeSeconds ?? NaN) - (previous.worldTimeSeconds ?? NaN);
100
+ // Small monotonic ticks animate the same medium. Reset only on a clock
101
+ // discontinuity; rejecting every tick prevents temporal reuse in live play.
102
+ if (!Number.isFinite(delta) || delta < 0 || delta > 0.1) {
103
+ return { reset: true, reason: 'simulation-time' };
104
+ }
105
+ }
95
106
  if (previous.densityGeneration !== next.densityGeneration) {
96
107
  return { reset: true, reason: 'density-generation' };
97
108
  }