@vectojs/core 1.22.0 → 1.23.0

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.
package/dist/index.mjs CHANGED
@@ -307,12 +307,16 @@ var ComputeParticleEntity = class extends Entity {
307
307
  * The backend holds one resident SoA store, so a Scene with multiple particle
308
308
  * entities reuses it sequentially — origin is therefore re-gathered each call
309
309
  * (not upload-once), a couple of extra f32 reads per particle.
310
+ *
311
+ * Returns `false` if the kernel declined the call and {@link updateCPU} ran
312
+ * instead, so the Scene can report which path actually simulated this frame
313
+ * rather than assuming an installed backend did the work.
310
314
  */
311
315
  stepWithBackend(backend, dt, mouseX, mouseY, width, height) {
312
316
  const count = this.maxParticles;
313
317
  backend.ensure(count);
314
318
  backend.gather(this.particleData, count, true);
315
- this._wasmPending = backend.step(count, {
319
+ const pending = backend.step(count, {
316
320
  dt,
317
321
  mouseX,
318
322
  mouseY,
@@ -324,8 +328,14 @@ var ComputeParticleEntity = class extends Entity {
324
328
  maxVelocity: this.maxVelocity,
325
329
  explosion: this.pendingExplosion
326
330
  });
331
+ if (pending === null) {
332
+ this.updateCPU(dt, mouseX, mouseY, width, height);
333
+ return false;
334
+ }
335
+ this._wasmPending = pending;
327
336
  backend.scatter(this.particleData, count);
328
337
  this.pendingExplosion = null;
338
+ return true;
329
339
  }
330
340
  destroy() {
331
341
  this.destroyGPUResources();
@@ -469,148 +479,6 @@ function buildTreeStore(root) {
469
479
  return { store, indexOf };
470
480
  }
471
481
 
472
- // src/wasm/hit-store.ts
473
- function gatherHitAABBs(root, currentFrame) {
474
- const slotEntity = [];
475
- const boundless = [];
476
- const minxs = [];
477
- const minys = [];
478
- const maxxs = [];
479
- const maxys = [];
480
- const scratch = { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 };
481
- const visit = (node) => {
482
- const index = slotEntity.length;
483
- slotEntity.push(node);
484
- const bounds = node.getBounds();
485
- if (bounds === null) {
486
- boundless.push({ entity: node, index });
487
- minxs.push(0);
488
- minys.push(0);
489
- maxxs.push(0);
490
- maxys.push(0);
491
- } else {
492
- if (!node._readWorldCache(currentFrame, scratch)) {
493
- const t = node.getWorldTransform();
494
- scratch.a = t.a;
495
- scratch.b = t.b;
496
- scratch.c = t.c;
497
- scratch.d = t.d;
498
- scratch.e = t.e;
499
- scratch.f = t.f;
500
- }
501
- const { a, b, c, d, e, f } = scratch;
502
- let minX = Infinity;
503
- let minY = Infinity;
504
- let maxX = -Infinity;
505
- let maxY = -Infinity;
506
- for (let i = 0; i < 4; i++) {
507
- const lx = i & 1 ? bounds.x + bounds.width : bounds.x;
508
- const ly = i & 2 ? bounds.y + bounds.height : bounds.y;
509
- const wx = a * lx + c * ly + e;
510
- const wy = b * lx + d * ly + f;
511
- if (wx < minX) minX = wx;
512
- if (wx > maxX) maxX = wx;
513
- if (wy < minY) minY = wy;
514
- if (wy > maxY) maxY = wy;
515
- }
516
- minxs.push(minX);
517
- minys.push(minY);
518
- maxxs.push(maxX);
519
- maxys.push(maxY);
520
- }
521
- const kids = node.children;
522
- for (let i = 0; i < kids.length; i++) visit(kids[i]);
523
- };
524
- visit(root);
525
- return {
526
- count: slotEntity.length,
527
- slotEntity,
528
- boundless,
529
- minx: Float64Array.from(minxs),
530
- miny: Float64Array.from(minys),
531
- maxx: Float64Array.from(maxxs),
532
- maxy: Float64Array.from(maxys)
533
- };
534
- }
535
-
536
- // src/wasm/hit-store-fused.ts
537
- function gatherHitAABBsFromStore(root, aabbs, storeSlotEntity, out) {
538
- const slotEntity = out.slotEntity;
539
- const boundless = out.boundless;
540
- slotEntity.length = 0;
541
- boundless.length = 0;
542
- let count = 0;
543
- let capacity = out.minx.length;
544
- let minx = out.minx;
545
- let miny = out.miny;
546
- let maxx = out.maxx;
547
- let maxy = out.maxy;
548
- const grow = (needed) => {
549
- if (needed <= capacity) return;
550
- let next = capacity || 256;
551
- while (next < needed) next *= 2;
552
- const nminx = new Float64Array(next);
553
- const nminy = new Float64Array(next);
554
- const nmaxx = new Float64Array(next);
555
- const nmaxy = new Float64Array(next);
556
- nminx.set(minx);
557
- nminy.set(miny);
558
- nmaxx.set(maxx);
559
- nmaxy.set(maxy);
560
- minx = nminx;
561
- miny = nminy;
562
- maxx = nmaxx;
563
- maxy = nmaxy;
564
- capacity = next;
565
- };
566
- let bailed = false;
567
- const visit = (node) => {
568
- if (bailed) return;
569
- const index = count;
570
- slotEntity.push(node);
571
- count++;
572
- grow(count);
573
- if (node.getBounds() === null) {
574
- boundless.push({ entity: node, index });
575
- minx[index] = 0;
576
- miny[index] = 0;
577
- maxx[index] = 0;
578
- maxy[index] = 0;
579
- } else {
580
- const slot = node._storeSlot;
581
- if (slot < 0 || slot >= aabbs.aminx.length || storeSlotEntity[slot] !== node) {
582
- bailed = true;
583
- return;
584
- }
585
- minx[index] = aabbs.aminx[slot];
586
- miny[index] = aabbs.aminy[slot];
587
- maxx[index] = aabbs.amaxx[slot];
588
- maxy[index] = aabbs.amaxy[slot];
589
- }
590
- const children = node.children;
591
- for (let i = 0; i < children.length; i++) visit(children[i]);
592
- };
593
- visit(root);
594
- if (bailed) return null;
595
- out.count = count;
596
- out.minx = minx;
597
- out.miny = miny;
598
- out.maxx = maxx;
599
- out.maxy = maxy;
600
- return out;
601
- }
602
- function createHitGatherBuffer() {
603
- return {
604
- count: 0,
605
- slotEntity: [],
606
- boundless: [],
607
- minx: new Float64Array(256),
608
- miny: new Float64Array(256),
609
- maxx: new Float64Array(256),
610
- maxy: new Float64Array(256)
611
- };
612
- }
613
-
614
482
  // src/wasm/backend.ts
615
483
  var WASM_STATUS = {
616
484
  OK: 0,
@@ -703,7 +571,10 @@ var WasmTransformBackend = class {
703
571
  return status;
704
572
  }
705
573
  /** Upload only the run table + count (topology), leaving per-entity inputs to
706
- * the resident views. Call when the tree structure changes, not per frame. */
574
+ * the resident views. Call when the tree structure changes, not per frame.
575
+ * Returns `false` if the crate rejected the run count, in which case the
576
+ * PREVIOUS topology is still published and any kernel run against it would
577
+ * compose the wrong tree — the caller must not proceed. */
707
578
  uploadRuns(store) {
708
579
  this.ensure(store.count, store.runCount);
709
580
  const rc = store.runCount;
@@ -711,6 +582,7 @@ var WasmTransformBackend = class {
711
582
  this.vrs.set(store.runStart.subarray(0, rc));
712
583
  this.vrl.set(store.runLen.subarray(0, rc));
713
584
  this.lastStatus = this.ex.set_run_count(rc);
585
+ return this.lastStatus === WASM_STATUS.OK;
714
586
  }
715
587
  /**
716
588
  * Status of the most recent kernel or run-table call. `WASM_STATUS.OK` unless
@@ -759,7 +631,8 @@ var WasmTransformBackend = class {
759
631
  this.vby.set(store.by.subarray(0, n));
760
632
  this.vbw.set(store.bw.subarray(0, n));
761
633
  this.vbh.set(store.bh.subarray(0, n));
762
- this.ex.compute_aabbs(n);
634
+ this.lastStatus = this.ex.compute_aabbs(n);
635
+ if (this.lastStatus !== WASM_STATUS.OK) return;
763
636
  store.aminx.set(this.vaminx.subarray(0, n));
764
637
  store.aminy.set(this.vaminy.subarray(0, n));
765
638
  store.amaxx.set(this.vamaxx.subarray(0, n));
@@ -767,9 +640,12 @@ var WasmTransformBackend = class {
767
640
  }
768
641
  /** Run the AABB pass only, over `count` entities already resident in wasm
769
642
  * memory (bounds written via {@link boundsView}, world matrices already
770
- * composed). No upload/readback — the per-frame resident path. */
643
+ * composed). No upload/readback — the per-frame resident path. Returns
644
+ * `false` if the kernel rejected `count` (beyond capacity, or uninitialized),
645
+ * in which case {@link aabbView} still holds the previous frame's bounds. */
771
646
  runAabbs(count) {
772
- this.ex.compute_aabbs(count);
647
+ this.lastStatus = this.ex.compute_aabbs(count);
648
+ return this.lastStatus === WASM_STATUS.OK;
773
649
  }
774
650
  /** Resident wasm local-bounds input views (`bx,by,bw,bh`) for the AABB pass. */
775
651
  boundsView() {
@@ -843,6 +719,148 @@ var WasmTransformBackend = class {
843
719
  }
844
720
  };
845
721
 
722
+ // src/wasm/hit-store.ts
723
+ function gatherHitAABBs(root, currentFrame) {
724
+ const slotEntity = [];
725
+ const boundless = [];
726
+ const minxs = [];
727
+ const minys = [];
728
+ const maxxs = [];
729
+ const maxys = [];
730
+ const scratch = { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 };
731
+ const visit = (node) => {
732
+ const index = slotEntity.length;
733
+ slotEntity.push(node);
734
+ const bounds = node.getBounds();
735
+ if (bounds === null) {
736
+ boundless.push({ entity: node, index });
737
+ minxs.push(0);
738
+ minys.push(0);
739
+ maxxs.push(0);
740
+ maxys.push(0);
741
+ } else {
742
+ if (!node._readWorldCache(currentFrame, scratch)) {
743
+ const t = node.getWorldTransform();
744
+ scratch.a = t.a;
745
+ scratch.b = t.b;
746
+ scratch.c = t.c;
747
+ scratch.d = t.d;
748
+ scratch.e = t.e;
749
+ scratch.f = t.f;
750
+ }
751
+ const { a, b, c, d, e, f } = scratch;
752
+ let minX = Infinity;
753
+ let minY = Infinity;
754
+ let maxX = -Infinity;
755
+ let maxY = -Infinity;
756
+ for (let i = 0; i < 4; i++) {
757
+ const lx = i & 1 ? bounds.x + bounds.width : bounds.x;
758
+ const ly = i & 2 ? bounds.y + bounds.height : bounds.y;
759
+ const wx = a * lx + c * ly + e;
760
+ const wy = b * lx + d * ly + f;
761
+ if (wx < minX) minX = wx;
762
+ if (wx > maxX) maxX = wx;
763
+ if (wy < minY) minY = wy;
764
+ if (wy > maxY) maxY = wy;
765
+ }
766
+ minxs.push(minX);
767
+ minys.push(minY);
768
+ maxxs.push(maxX);
769
+ maxys.push(maxY);
770
+ }
771
+ const kids = node.children;
772
+ for (let i = 0; i < kids.length; i++) visit(kids[i]);
773
+ };
774
+ visit(root);
775
+ return {
776
+ count: slotEntity.length,
777
+ slotEntity,
778
+ boundless,
779
+ minx: Float64Array.from(minxs),
780
+ miny: Float64Array.from(minys),
781
+ maxx: Float64Array.from(maxxs),
782
+ maxy: Float64Array.from(maxys)
783
+ };
784
+ }
785
+
786
+ // src/wasm/hit-store-fused.ts
787
+ function gatherHitAABBsFromStore(root, aabbs, storeSlotEntity, out) {
788
+ const slotEntity = out.slotEntity;
789
+ const boundless = out.boundless;
790
+ slotEntity.length = 0;
791
+ boundless.length = 0;
792
+ let count = 0;
793
+ let capacity = out.minx.length;
794
+ let minx = out.minx;
795
+ let miny = out.miny;
796
+ let maxx = out.maxx;
797
+ let maxy = out.maxy;
798
+ const grow = (needed) => {
799
+ if (needed <= capacity) return;
800
+ let next = capacity || 256;
801
+ while (next < needed) next *= 2;
802
+ const nminx = new Float64Array(next);
803
+ const nminy = new Float64Array(next);
804
+ const nmaxx = new Float64Array(next);
805
+ const nmaxy = new Float64Array(next);
806
+ nminx.set(minx);
807
+ nminy.set(miny);
808
+ nmaxx.set(maxx);
809
+ nmaxy.set(maxy);
810
+ minx = nminx;
811
+ miny = nminy;
812
+ maxx = nmaxx;
813
+ maxy = nmaxy;
814
+ capacity = next;
815
+ };
816
+ let bailed = false;
817
+ const visit = (node) => {
818
+ if (bailed) return;
819
+ const index = count;
820
+ slotEntity.push(node);
821
+ count++;
822
+ grow(count);
823
+ if (node.getBounds() === null) {
824
+ boundless.push({ entity: node, index });
825
+ minx[index] = 0;
826
+ miny[index] = 0;
827
+ maxx[index] = 0;
828
+ maxy[index] = 0;
829
+ } else {
830
+ const slot = node._storeSlot;
831
+ if (slot < 0 || slot >= aabbs.aminx.length || storeSlotEntity[slot] !== node) {
832
+ bailed = true;
833
+ return;
834
+ }
835
+ minx[index] = aabbs.aminx[slot];
836
+ miny[index] = aabbs.aminy[slot];
837
+ maxx[index] = aabbs.amaxx[slot];
838
+ maxy[index] = aabbs.amaxy[slot];
839
+ }
840
+ const children = node.children;
841
+ for (let i = 0; i < children.length; i++) visit(children[i]);
842
+ };
843
+ visit(root);
844
+ if (bailed) return null;
845
+ out.count = count;
846
+ out.minx = minx;
847
+ out.miny = miny;
848
+ out.maxx = maxx;
849
+ out.maxy = maxy;
850
+ return out;
851
+ }
852
+ function createHitGatherBuffer() {
853
+ return {
854
+ count: 0,
855
+ slotEntity: [],
856
+ boundless: [],
857
+ minx: new Float64Array(256),
858
+ miny: new Float64Array(256),
859
+ maxx: new Float64Array(256),
860
+ maxy: new Float64Array(256)
861
+ };
862
+ }
863
+
846
864
  // src/wasm/anim-backend.ts
847
865
  var PAD3 = 8;
848
866
  var AnimBackend = class {
@@ -877,14 +895,33 @@ var AnimBackend = class {
877
895
  this.ex.anim_init(this.springCap, this.tweenCap);
878
896
  this.refreshViews();
879
897
  }
880
- /** Advance `count` springs (from index 0) by `dtMs` milliseconds, in place. */
898
+ /**
899
+ * Advance `count` springs (from index 0) by `dtMs` milliseconds, in place.
900
+ * Returns `true` when the kernel ran. `false` means it rejected the call
901
+ * (count beyond the capacity {@link ensure} allocated, or no `anim_init` yet)
902
+ * and wrote nothing, so the caller must tick those drivers in JS instead of
903
+ * scattering back a pack the kernel never touched. See {@link lastStatus}.
904
+ */
881
905
  stepSprings(dtMs, count) {
882
- this.ex.spring_step(dtMs / 1e3, count);
906
+ this.lastStatus = this.ex.spring_step(dtMs / 1e3, count);
907
+ return this.lastStatus === WASM_STATUS.OK;
883
908
  }
884
- /** Advance `count` tweens (from index 0) by `dtMs` milliseconds, writing `val`. */
909
+ /**
910
+ * Advance `count` tweens (from index 0) by `dtMs` milliseconds, writing `val`.
911
+ * Returns `true` when the kernel ran; `false` means it rejected the call and
912
+ * wrote nothing (see {@link stepSprings}). `elapsed` is kernel-side state, so
913
+ * a rejected tween pack must not be read back — it is unadvanced, not
914
+ * partially advanced.
915
+ */
885
916
  stepTweens(dtMs, count) {
886
- this.ex.tween_step(dtMs, count);
917
+ this.lastStatus = this.ex.tween_step(dtMs, count);
918
+ return this.lastStatus === WASM_STATUS.OK;
887
919
  }
920
+ /**
921
+ * Status of the most recent kernel call — `WASM_STATUS.OK` unless the kernel
922
+ * declined it. Mirrors {@link TransformBackend.lastStatus}.
923
+ */
924
+ lastStatus = WASM_STATUS.OK;
888
925
  refreshViews() {
889
926
  const buf = this.ex.memory.buffer;
890
927
  const sCap = this.springCap;
@@ -1035,6 +1072,12 @@ var ParticleBackend = class {
1035
1072
  * Advance `count` particles one step in place. Returns `true` when at least
1036
1073
  * one live particle is still moving or off-origin beyond epsilon (the fused
1037
1074
  * `hasPendingAnimations` flag), so the caller need not re-scan the buffer.
1075
+ *
1076
+ * Returns `null` when the kernel REJECTED the call — `count` beyond the
1077
+ * capacity {@link ensure} allocated, or no `particle_init` yet. Nothing was
1078
+ * written, so the caller must NOT {@link scatter} (that would write the
1079
+ * gathered pre-step values back and freeze the simulation) and should fall
1080
+ * back to the JS `updateCPU` path for this frame. See {@link lastStatus}.
1038
1081
  */
1039
1082
  step(count, p) {
1040
1083
  const e = p.explosion;
@@ -1054,8 +1097,18 @@ var ParticleBackend = class {
1054
1097
  e ? e.force : 0,
1055
1098
  count
1056
1099
  );
1100
+ if (flag < 0) {
1101
+ this.lastStatus = -flag;
1102
+ return null;
1103
+ }
1104
+ this.lastStatus = WASM_STATUS.OK;
1057
1105
  return flag !== 0;
1058
1106
  }
1107
+ /**
1108
+ * Status of the most recent {@link step} — `WASM_STATUS.OK` unless the kernel
1109
+ * declined it. Mirrors {@link TransformBackend.lastStatus}.
1110
+ */
1111
+ lastStatus = WASM_STATUS.OK;
1059
1112
  /** Transpose the AoS stride-8 buffer into the SoA views (position/velocity/
1060
1113
  * life every frame; origin upload-once when `withOrigin`). */
1061
1114
  gather(data, count, withOrigin) {
@@ -1866,10 +1919,13 @@ var Scene = class _Scene {
1866
1919
  return true;
1867
1920
  }
1868
1921
  // ── WASM hit-test backend (invisible accelerator, G3) ───────────────────────
1869
- // A separate WASM module instance from the transform backend (each crate
1870
- // export lives in independent linear memory per instance, so there is no
1871
- // shared-state hazard in running both) that indexes the main tree's world
1872
- // AABBs into a dense viewport grid for findEntityAt. The JS depth-first walk
1922
+ // Served by the same instance as every other accelerator (see
1923
+ // `_wasmRuntime`): the crate keeps transform/anim/hit/particle in distinct
1924
+ // statics, so one instance runs them all without aliasing. It indexes the
1925
+ // main tree's world AABBs into a dense viewport grid for findEntityAt. Note
1926
+ // that sharing one linear memory means an allocation here can grow it and
1927
+ // detach views built over the old buffer, so each backend re-checks buffer
1928
+ // identity (`revalidateViews`) before use. The JS depth-first walk
1873
1929
  // (findHitRecursively) is the permanent fallback: a null backend, a build
1874
1930
  // that overflows its item budget, or the overlay tree (never indexed — small
1875
1931
  // and rare, not worth accelerating) all fall through to it, so WASM can only
@@ -1942,6 +1998,70 @@ var Scene = class _Scene {
1942
1998
  get hitGatherPath() {
1943
1999
  return this._hitFusedGather ? "fused" : "js";
1944
2000
  }
2001
+ /**
2002
+ * Why the transform accelerator did or did not run on the most recent frame.
2003
+ * Written by the render walk and `_syncWasmStore`.
2004
+ */
2005
+ _transformReason = "not-installed";
2006
+ /** Why the batched-driver accelerator did or did not run. */
2007
+ _animReason = "not-installed";
2008
+ /**
2009
+ * Why the hit-test accelerator did or did not serve the last pointer query.
2010
+ * The grid is built lazily on demand, not every frame, so this describes the
2011
+ * most recent BUILD. Starts at `'not-installed'` because that is the truth
2012
+ * before a backend exists; `_ensureHitGrid` moves it to `'not-applicable'`
2013
+ * once one is installed but nothing has queried yet.
2014
+ */
2015
+ _hitReason = "not-installed";
2016
+ /** Why the particle accelerator did or did not run. */
2017
+ _particleReason = "not-applicable";
2018
+ /** Which particle implementation actually simulated the most recent frame. */
2019
+ _particlePath = "none";
2020
+ /**
2021
+ * Per-frame status of every invisible accelerator: whether each is installed,
2022
+ * whether it actually ran on the most recent frame, and why.
2023
+ *
2024
+ * This exists because the older per-accelerator getters
2025
+ * ({@link transformBackend}, {@link animBackend}, {@link hitTestBackend},
2026
+ * {@link particleBackend}) report only that a backend is INSTALLED. Reading
2027
+ * `'wasm'` from one of those and concluding the accelerator is doing work is
2028
+ * wrong whenever a gate never opens, a kernel rejects its arguments, or a
2029
+ * faster backend takes the pass instead. Read {@link AcceleratorStatus.reason}
2030
+ * for which of those happened.
2031
+ *
2032
+ * Reflects the most recent main-renderer frame; a secondary renderer (SVG
2033
+ * export, offscreen snapshot) does not overwrite it.
2034
+ */
2035
+ get accelerators() {
2036
+ return {
2037
+ transform: {
2038
+ available: this._wasm !== null && this._transformBackend === "wasm",
2039
+ activeThisFrame: this._transformReason === "active",
2040
+ reason: this._transformReason,
2041
+ path: this._transformReason === "active" ? "wasm" : "js"
2042
+ },
2043
+ animation: {
2044
+ available: this._animWasm !== null,
2045
+ activeThisFrame: this._animBatchedLastFrame,
2046
+ reason: this._animReason,
2047
+ path: this._animBatchedLastFrame ? "wasm" : "js"
2048
+ },
2049
+ hitTest: {
2050
+ available: this._hitWasm !== null,
2051
+ // The grid is built lazily on a pointer query, not every frame, so this
2052
+ // describes the last BUILD rather than the last frame.
2053
+ activeThisFrame: this._hitReason === "active",
2054
+ reason: this._hitReason,
2055
+ path: this._hitReason !== "active" ? "js" : this._hitFusedGather ? "wasm-fused" : "wasm"
2056
+ },
2057
+ particle: {
2058
+ available: this._particleWasm !== null || this.webgpuActive,
2059
+ activeThisFrame: this._particleReason === "active",
2060
+ reason: this._particleReason,
2061
+ path: this._particlePath
2062
+ }
2063
+ };
2064
+ }
1945
2065
  /** Which backend answers `findEntityAt` for the main tree. */
1946
2066
  get hitTestBackend() {
1947
2067
  return this._hitWasm ? "wasm" : "js";
@@ -1951,6 +2071,7 @@ var Scene = class _Scene {
1951
2071
  setHitTestBackend(backend) {
1952
2072
  this._hitWasm = backend;
1953
2073
  this._hitGridFrame = -1;
2074
+ this._hitReason = backend ? "not-applicable" : "not-installed";
1954
2075
  }
1955
2076
  /**
1956
2077
  * Asynchronously instantiate the WASM hit-test core and, on success, hot-swap
@@ -1977,7 +2098,10 @@ var Scene = class _Scene {
1977
2098
  */
1978
2099
  _ensureHitGrid() {
1979
2100
  const backend = this._hitWasm;
1980
- if (!backend) return false;
2101
+ if (!backend) {
2102
+ this._hitReason = "not-installed";
2103
+ return false;
2104
+ }
1981
2105
  if (this._hitGridFrame === this.currentFrame) return this._hitGridOk;
1982
2106
  let gathered = null;
1983
2107
  if (this._wasm && this._ensureWasmAabbs()) {
@@ -2006,6 +2130,7 @@ var Scene = class _Scene {
2006
2130
  this._hitBoundless = gathered.boundless;
2007
2131
  this._hitGridFrame = this.currentFrame;
2008
2132
  this._hitGridOk = ok;
2133
+ this._hitReason = ok ? "active" : "rejected";
2009
2134
  return ok;
2010
2135
  }
2011
2136
  /**
@@ -2271,7 +2396,10 @@ var Scene = class _Scene {
2271
2396
  * pre-pass.
2272
2397
  */
2273
2398
  _tickBatchedDrivers(dt) {
2274
- if (this._activeDriverEntities.size === 0) return;
2399
+ if (this._activeDriverEntities.size === 0) {
2400
+ this._animReason = "not-applicable";
2401
+ return;
2402
+ }
2275
2403
  let springBatchable = 0;
2276
2404
  let tweenBatchable = 0;
2277
2405
  for (const entity of this._activeDriverEntities) {
@@ -2288,10 +2416,17 @@ var Scene = class _Scene {
2288
2416
  const batchable = springBatchable + tweenBatchable;
2289
2417
  const backend = this._animWasm;
2290
2418
  this._animBatchedLastFrame = false;
2291
- if (!backend) return;
2419
+ if (!backend) {
2420
+ this._animReason = "not-installed";
2421
+ return;
2422
+ }
2292
2423
  const gate = springBatchable > 0 && tweenBatchable > 0 ? this.animGate.mixed : tweenBatchable > 0 ? this.animGate.tween : this.animGate.spring;
2293
- if (batchable < gate) return;
2424
+ if (batchable < gate) {
2425
+ this._animReason = "below-gate";
2426
+ return;
2427
+ }
2294
2428
  this._animBatchedLastFrame = true;
2429
+ this._animReason = "active";
2295
2430
  const sE = this._springEntities;
2296
2431
  const sP = this._springProps;
2297
2432
  const sD = this._springDrivers;
@@ -2338,8 +2473,13 @@ var Scene = class _Scene {
2338
2473
  sv.damp[i] = phys.damping;
2339
2474
  sv.mass[i] = phys.mass;
2340
2475
  }
2341
- backend.stepSprings(dt, springCount);
2342
- for (let i = 0; i < springCount; i++) sD[i].syncExternal(sv.val[i], sv.vel[i]);
2476
+ if (backend.stepSprings(dt, springCount)) {
2477
+ for (let i = 0; i < springCount; i++) sD[i].syncExternal(sv.val[i], sv.vel[i]);
2478
+ } else {
2479
+ for (let i = 0; i < springCount; i++) sD[i].tick(dt);
2480
+ this._animReason = "rejected";
2481
+ this._animBatchedLastFrame = false;
2482
+ }
2343
2483
  }
2344
2484
  if (tweenCount > 0) {
2345
2485
  const tv = backend.tweenView();
@@ -2352,8 +2492,13 @@ var Scene = class _Scene {
2352
2492
  tv.delay[i] = d.delayMs;
2353
2493
  tv.ease[i] = d.wasmEasingId;
2354
2494
  }
2355
- backend.stepTweens(dt, tweenCount);
2356
- for (let i = 0; i < tweenCount; i++) tD[i].syncExternal(tv.val[i], tv.elapsed[i]);
2495
+ if (backend.stepTweens(dt, tweenCount)) {
2496
+ for (let i = 0; i < tweenCount; i++) tD[i].syncExternal(tv.val[i], tv.elapsed[i]);
2497
+ } else {
2498
+ for (let i = 0; i < tweenCount; i++) tD[i].tick(dt);
2499
+ this._animReason = "rejected";
2500
+ this._animBatchedLastFrame = false;
2501
+ }
2357
2502
  }
2358
2503
  for (let i = 0; i < springCount; i++) sE[i]._applyDriverTick(sP[i], sD[i]);
2359
2504
  for (let i = 0; i < tweenCount; i++) tE[i]._applyDriverTick(tP[i], tD[i]);
@@ -2375,7 +2520,10 @@ var Scene = class _Scene {
2375
2520
  slotEntity2[slot] = entity;
2376
2521
  entity._storeSlot = slot;
2377
2522
  }
2378
- backend.uploadRuns(built.store);
2523
+ if (!backend.uploadRuns(built.store)) {
2524
+ this._transformReason = "rejected";
2525
+ return null;
2526
+ }
2379
2527
  this._treeStore = built.store;
2380
2528
  this._slotEntity = slotEntity2;
2381
2529
  this._wasmInputs = backend.inputView();
@@ -2400,8 +2548,12 @@ var Scene = class _Scene {
2400
2548
  inp.sin[slot] = trig.sin;
2401
2549
  inp.opacity[slot] = e.opacity;
2402
2550
  }
2403
- backend.runKernel("simd");
2551
+ if (backend.runKernel("simd") !== WASM_STATUS.OK) {
2552
+ this._transformReason = "rejected";
2553
+ return null;
2554
+ }
2404
2555
  this._wasmAabbsFresh = false;
2556
+ this._transformReason = "active";
2405
2557
  return this._wasmWorld;
2406
2558
  }
2407
2559
  /**
@@ -2431,7 +2583,7 @@ var Scene = class _Scene {
2431
2583
  bounds.bw[slot] = b ? b.width : 0;
2432
2584
  bounds.bh[slot] = b ? b.height : 0;
2433
2585
  }
2434
- backend.runAabbs(slotEntity.length);
2586
+ if (!backend.runAabbs(slotEntity.length)) return false;
2435
2587
  this._wasmAabbsFresh = true;
2436
2588
  return true;
2437
2589
  }
@@ -4705,6 +4857,10 @@ var Scene = class _Scene {
4705
4857
  const computeEntities = this._computeEntitiesFor(this._structureVersion);
4706
4858
  if (computeEntities.length > 0) {
4707
4859
  const isMainRenderPath = renderer === this.renderer;
4860
+ if (isMainRenderPath) {
4861
+ this._particleReason = this._particleWasm ? "active" : "not-installed";
4862
+ this._particlePath = this._particleWasm ? "wasm" : "js";
4863
+ }
4708
4864
  if (isMainRenderPath && !this.device && !this.webgpuDisabled && !this.initializingWebGPU && !this.deviceLost) {
4709
4865
  this.initializingWebGPU = true;
4710
4866
  this.initWebGPUContext(computeEntities).then((newDevice) => {
@@ -4780,6 +4936,8 @@ var Scene = class _Scene {
4780
4936
  }
4781
4937
  this.device.queue.submit([commandEncoder.finish()]);
4782
4938
  if (this.gpuContext) this.gpuHasContent = true;
4939
+ this._particleReason = "active";
4940
+ this._particlePath = "webgpu";
4783
4941
  } catch (e) {
4784
4942
  console.error("WebGPU frame execution failed. Falling back.", e);
4785
4943
  this.deviceLost = true;
@@ -4801,13 +4959,25 @@ var Scene = class _Scene {
4801
4959
  }
4802
4960
  }
4803
4961
  if (this._particleWasm) {
4804
- entity.stepWithBackend(this._particleWasm, dt / 1e3, mx, my, this.width, this.height);
4962
+ if (!entity.stepWithBackend(
4963
+ this._particleWasm,
4964
+ dt / 1e3,
4965
+ mx,
4966
+ my,
4967
+ this.width,
4968
+ this.height
4969
+ )) {
4970
+ this._particleReason = "rejected";
4971
+ this._particlePath = "js";
4972
+ }
4805
4973
  } else {
4806
4974
  entity.updateCPU(dt / 1e3, mx, my, this.width, this.height);
4807
4975
  }
4808
4976
  }
4809
4977
  }
4810
4978
  } else if (isMainRenderer) {
4979
+ this._particleReason = "not-applicable";
4980
+ this._particlePath = "none";
4811
4981
  this.clearGPUCanvasIfStale();
4812
4982
  }
4813
4983
  renderer.clear();
@@ -4836,6 +5006,9 @@ var Scene = class _Scene {
4836
5006
  }
4837
5007
  };
4838
5008
  const wasmMain = isMainRenderer && this._wasm !== null && this._transformBackend === "wasm";
5009
+ if (isMainRenderer) {
5010
+ this._transformReason = wasmMain ? "active" : "not-installed";
5011
+ }
4839
5012
  if (wasmMain) {
4840
5013
  const updateWalk = (node) => {
4841
5014
  runUpdate(node);