@vectojs/core 1.16.2 → 1.17.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.
@@ -13,6 +13,7 @@ export interface IWebGPUParticleSystemManager {
13
13
  import { Entity } from './Entity';
14
14
  import { IRenderer } from '../renderer/IRenderer';
15
15
  import { type WasmModuleSource, type WasmTransformBackend } from '../wasm/backend';
16
+ import { type CoreWasmRuntime } from '../wasm/runtime';
16
17
  import { type HitModuleSource, type HitTestBackend } from '../wasm/hit-backend';
17
18
  import { type AnimModuleSource, type AnimBackend } from '../wasm/anim-backend';
18
19
  import { type ParticleModuleSource, type ParticleBackend } from '../wasm/particle-backend';
@@ -380,11 +381,54 @@ export declare class Scene {
380
381
  * Resolves `true` if WASM is now active, `false` if the JS path remains.
381
382
  */
382
383
  enableWasmTransforms(source: WasmModuleSource): Promise<boolean>;
384
+ /**
385
+ * The one WASM instance this Scene's accelerators share.
386
+ *
387
+ * Each `enableWasm*` used to instantiate the binary itself, so enabling all
388
+ * four compiled the same module four times and held four linear memories. The
389
+ * Rust crate already keeps transform/anim/hit/particle in separate statics, so
390
+ * one instance serves all of them without aliasing. The compiled module is
391
+ * cached globally; the instance is per-Scene, which is the isolation that
392
+ * actually matters.
393
+ */
394
+ private _wasmRuntime;
395
+ /**
396
+ * Load (or reuse) this Scene's shared WASM runtime.
397
+ *
398
+ * Returns `null` on any failure — CSP `wasm-unsafe-eval`, a 404, corrupt bytes,
399
+ * unsupported SIMD — so every caller keeps its JS path. Failure is the default
400
+ * state here, not an error path.
401
+ */
402
+ private ensureWasmRuntime;
403
+ /**
404
+ * Install a pre-built runtime, so several Scenes can share one compile while
405
+ * each keeps its own stores. Pass `null` to detach (backends already installed
406
+ * keep working; only subsequent `enableWasm*` calls re-load).
407
+ */
408
+ setWasmRuntime(runtime: CoreWasmRuntime | null): void;
409
+ /** The shared WASM runtime, if one has been loaded. */
410
+ get wasmRuntime(): CoreWasmRuntime | null;
383
411
  private _hitWasm;
384
412
  private _hitGridFrame;
385
413
  private _hitGridOk;
386
414
  private _hitSlotEntity;
387
415
  private _hitBoundless;
416
+ /** Reused buffer for the fused gather, so a pointer query allocates nothing. */
417
+ private _hitGatherBuffer;
418
+ /**
419
+ * Whether the last grid build sourced its AABBs from the WASM transform store
420
+ * rather than recomputing them in JS. Diagnostic only — both paths must
421
+ * produce the same entity for a given point.
422
+ */
423
+ private _hitFusedGather;
424
+ /**
425
+ * Whether `compute_aabbs` has run against the current frame's world matrices.
426
+ * The AABB pass is only meaningful after a `compose_*`, so the fused gather
427
+ * must not read the views before then.
428
+ */
429
+ private _wasmAabbsFresh;
430
+ /** Did the last hit-grid build use the fused (WASM-store) gather? */
431
+ get hitGatherPath(): 'fused' | 'js';
388
432
  /** Which backend answers `findEntityAt` for the main tree. */
389
433
  get hitTestBackend(): 'js' | 'wasm';
390
434
  /** Install (or clear) a WASM hit-test backend directly. Prefer
@@ -464,7 +508,51 @@ export declare class Scene {
464
508
  * own target browser mix and driver-kind distribution, or leave WASM
465
509
  * animation batching disabled entirely on a Firefox-heavy audience.
466
510
  */
467
- animDriverGateCount: number;
511
+ /**
512
+ * Back-compat alias for {@link animGate}. Reading it returns the tween gate
513
+ * (the conservative one the single knob used to represent); writing it sets all
514
+ * three, so code that tuned one number keeps behaving as before.
515
+ *
516
+ * Prefer {@link animGate} — a single threshold cannot be right for both kinds,
517
+ * which is why this exists as an alias rather than the primary control.
518
+ */
519
+ get animDriverGateCount(): number;
520
+ set animDriverGateCount(n: number);
521
+ /**
522
+ * Per-kind driver gates, in active batchable drivers.
523
+ *
524
+ * Measured on the integrated path (`benchmarks/anim-wasm-scene`, real Chrome
525
+ * 150 / Firefox 153): spring and mixed workloads are a ~1.4-2.3x win from 128
526
+ * drivers up through 16384, while pure tween is a **0.71x loss** at 128 and
527
+ * only turns net-positive near 256. One scalar threshold therefore had to be
528
+ * set for the worst kind, discarding the 128-255 spring win to avoid making a
529
+ * tween-heavy scene slower.
530
+ *
531
+ * Firefox is a net loss at every count measured up to 16384 — not an
532
+ * allocation artifact (confirmed after removing all per-frame allocation from
533
+ * gather/scatter); SpiderMonkey's wasm-boundary cost for this call shape
534
+ * appears to structurally exceed the saving at these scales. These defaults are
535
+ * Chrome-oriented; on a Firefox-heavy audience, leave
536
+ * {@link enableWasmAnimBatching} off entirely rather than tuning these.
537
+ *
538
+ * Setting {@link animDriverGateCount} overwrites all three, so existing code
539
+ * that tuned the single knob keeps working unchanged.
540
+ */
541
+ private _animBatchedLastFrame;
542
+ /**
543
+ * Whether the WASM batch path actually ran on the most recent frame.
544
+ *
545
+ * Distinct from {@link animBackend}, which reports only that a backend is
546
+ * installed — a gate below the driver count means the frame still ticked in JS.
547
+ * Conflating the two makes it easy to believe an accelerator is active when it
548
+ * never opens.
549
+ */
550
+ get animBatchedLastFrame(): boolean;
551
+ animGate: {
552
+ spring: number;
553
+ tween: number;
554
+ mixed: number;
555
+ };
468
556
  /** Which backend advances active property drivers on the current gate
469
557
  * decision. Reflects only whether a backend is installed — the per-frame
470
558
  * gate can still choose the JS path even when this reads `'wasm'`. */
@@ -542,6 +630,17 @@ export declare class Scene {
542
630
  * input view and runs the kernel. Returns `null` if there is no backend.
543
631
  */
544
632
  private _syncWasmStore;
633
+ /**
634
+ * Run the WASM world-AABB pass over the current frame's world matrices, so the
635
+ * fused hit gather can read AABBs straight out of the store.
636
+ *
637
+ * Local bounds are uploaded here rather than in the per-frame transform sync
638
+ * because `getBounds()` is a virtual call that allocates a rect on most
639
+ * entities — paying it every frame for a query that may never come would move
640
+ * cost onto the render path to save it on hover. Returns `false` if any entity
641
+ * cannot supply bounds through the store, so the caller uses the JS gather.
642
+ */
643
+ private _ensureWasmAabbs;
545
644
  /**
546
645
  * Authoritative paint order for semantic nodes discovered during the main
547
646
  * render. A node may not have a DOM projection until the following a11y
@@ -673,7 +772,19 @@ export declare class Scene {
673
772
  * without removing it from the scene graph. Components that manage dynamic
674
773
  * interactive *child* entities (e.g. a {@link Entity}'s per-link hotspots) call
675
774
  * this before discarding those children so their shadow `<a>`/controls don't
676
- * leak (the per-frame `syncA11y` only creates/updates, it never prunes).
775
+ * leak.
776
+ *
777
+ * `syncA11y` itself only creates and updates, never prunes — but it is always
778
+ * followed by `enforceA11yDomOrder`, whose prune pass removes any element
779
+ * whose entity is no longer reachable in the tree or no longer satisfies
780
+ * {@link shouldProjectA11y}. So an entity that is `remove()`d, or whose
781
+ * `interactive` flips to `false`, has its element torn down on the next synced
782
+ * frame without any explicit call.
783
+ *
784
+ * This method is for the case that pass cannot see: a child dropped from a
785
+ * component's own bookkeeping while still parented, or one discarded before
786
+ * the next sync runs. Calling it is always safe and is the right habit for
787
+ * pooled children.
677
788
  *
678
789
  * @param entity - The subtree whose shadow nodes should be removed.
679
790
  */
@@ -756,6 +867,26 @@ export declare class Scene {
756
867
  /** True when any node in the subtree has a pending animation. */
757
868
  /** True when any node in the subtree is interactive (drives a11y sync). */
758
869
  private syncOptionalAttribute;
870
+ /**
871
+ * Whether `node` should have an a11y shadow element projected for it.
872
+ *
873
+ * The single authority for that decision. It was previously inlined verbatim
874
+ * at four call sites — `syncA11y` (create/update), `enforceA11yDomOrder`
875
+ * (which ids survive pruning), `getA11yTree` (the public snapshot) and
876
+ * `render` (z-index / reading-order assignment). Four copies of one predicate
877
+ * is a standing correctness hazard: if any of them drifts, elements either
878
+ * leak (created but never marked active, so pruned every frame and rebuilt) or
879
+ * go missing from the semantic tree while still present in the DOM.
880
+ *
881
+ * A box is required because a zero-size element is unfocusable and
882
+ * unhittable; `a11yFullViewport` is the deliberate exception, since those
883
+ * nodes are boundless interaction surfaces mounted behind everything else.
884
+ *
885
+ * Keep this the only place the rule is written. A planned per-entity
886
+ * `a11yProjection` mode ('eager' | 'onDemand' | 'never') extends exactly this
887
+ * predicate, which is only tractable while it has one home.
888
+ */
889
+ private shouldProjectA11y;
759
890
  private syncA11y;
760
891
  /**
761
892
  * Mirror one entity's static text ({@link Entity.getContentProjection}) as a
@@ -9,6 +9,25 @@
9
9
  * entity: at ~12-31 ns/crossing a per-entity call at 100k would cost >1 ms/frame.
10
10
  */
11
11
  import type { TransformStore } from './soa';
12
+ /**
13
+ * Status codes returned by the crate's fallible exports, mirroring the `STATUS_*`
14
+ * constants in `crates/vectojs-core-rs/src/lib.rs`.
15
+ *
16
+ * The kernels used to trust their arguments completely — the Safety contracts
17
+ * were enforced only by this file's calling convention, and PR #136's review
18
+ * found two out-of-bounds read paths that way. Now a rejected call writes nothing
19
+ * and reports why, so a bad batch degrades to the JS path instead of rendering
20
+ * from a half-written store.
21
+ */
22
+ export declare const WASM_STATUS: {
23
+ readonly OK: 0;
24
+ /** A count exceeded what `init` allocated. */
25
+ readonly CAPACITY: 1;
26
+ /** A kernel ran before `init`. */
27
+ readonly UNINITIALIZED: 2;
28
+ /** A sibling run addressed a slot or parent outside the store. */
29
+ readonly BAD_RUN: 3;
30
+ };
12
31
  /** Which kernel to run. `simd` is the default; `scalar` exists for A/B and for
13
32
  * the (theoretical) case of a build without simd128. Both are f64 and
14
33
  * bit-identical to {@link composeJS}. */
@@ -63,10 +82,15 @@ export declare class WasmTransformBackend {
63
82
  * once at the current capacity to size the store and set the run count; call
64
83
  * {@link uploadRuns} after a topology change.
65
84
  */
66
- runKernel(kernel?: Kernel): void;
85
+ runKernel(kernel?: Kernel): number;
67
86
  /** Upload only the run table + count (topology), leaving per-entity inputs to
68
87
  * the resident views. Call when the tree structure changes, not per frame. */
69
88
  uploadRuns(store: TransformStore): void;
89
+ /**
90
+ * Status of the most recent kernel or run-table call. `WASM_STATUS.OK` unless
91
+ * the crate rejected its arguments, in which case that call was a no-op.
92
+ */
93
+ lastStatus: number;
70
94
  /** The resident wasm input views (`x,y,sx,sy,cos,sin,opacity`), valid until
71
95
  * the next capacity growth. Writing here is what makes uploads unnecessary. */
72
96
  inputView(): {
@@ -117,6 +141,18 @@ export declare class WasmTransformBackend {
117
141
  amaxx: Float64Array;
118
142
  amaxy: Float64Array;
119
143
  };
144
+ /**
145
+ * Re-create the typed-array views if the memory buffer they were built over has
146
+ * been detached.
147
+ *
148
+ * Necessary because all backends of a Scene now share one instance, and
149
+ * therefore one linear memory: another backend's allocation (notably
150
+ * `hit_init`, which allocates its own grid arrays) can grow the memory and
151
+ * detach every view built over the old buffer. A detached `Float64Array` reads
152
+ * as length 0 and silently returns `undefined` for every index, so without this
153
+ * the transform store appears empty rather than failing loudly.
154
+ */
155
+ revalidateViews(): void;
120
156
  private ensure;
121
157
  /** Rebuild typed-array views after an init() (which may have grown, and thus
122
158
  * detached, the memory buffer). */
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Build the hit-test grid's AABB set from the resident WASM transform store,
3
+ * instead of recomputing every world AABB in JavaScript.
4
+ *
5
+ * Why this exists: the WASM hit grid's *kernel* is 65-170x faster than the JS
6
+ * depth-first walk, but the integrated path was measured **slower** for an
7
+ * ordinary hover — 11.2ms versus 39us at 100k entities. All of that went to the
8
+ * JS gather in front of it: walk the tree, `getWorldTransform()`, `getBounds()`,
9
+ * transform four corners per entity, push into arrays, copy into WASM views. The
10
+ * kernel win was real and the gather ate it.
11
+ *
12
+ * When the transform backend is active, those world AABBs already exist inside
13
+ * WASM memory: `compose_*` filled the world matrices and `compute_aabbs` reduced
14
+ * them to `aminx/aminy/amaxx/amaxy`. So the gather can become a copy between two
15
+ * views of the *same* linear memory (possible only because all backends now share
16
+ * one instance) plus the index remap below.
17
+ *
18
+ * The remap is the whole subtlety. Two different index spaces are in play:
19
+ *
20
+ * - The **transform store** re-indexes entities into depth-ordered, contiguous
21
+ * sibling runs, because that is what the SIMD composer needs.
22
+ * - The **hit grid** requires strict **pre-order** indices, because its
23
+ * `idx > best` tie-break is only equivalent to `findHitRecursively`'s
24
+ * topmost-hit priority under that numbering.
25
+ *
26
+ * Those orders differ, so this walks the tree in pre-order exactly as
27
+ * {@link gatherHitAABBs} does — preserving the priority invariant — and for each
28
+ * entity reads its AABB from `entity._storeSlot`. Nothing about which entity wins
29
+ * a hit changes; only where the four numbers come from.
30
+ */
31
+ import type { Entity } from '../tree/Entity';
32
+ import type { HitGatherResult } from './hit-store';
33
+ /** The resident world-AABB views of the transform backend. */
34
+ export interface ResidentAabbs {
35
+ aminx: Float64Array;
36
+ aminy: Float64Array;
37
+ amaxx: Float64Array;
38
+ amaxy: Float64Array;
39
+ }
40
+ /**
41
+ * Pre-order walk collecting AABBs out of the transform store.
42
+ *
43
+ * Returns `null` when the store cannot answer for some entity — an unassigned or
44
+ * out-of-range `_storeSlot`, which happens legitimately when the tree changed
45
+ * after the last store rebuild. The caller then falls back to the JS gather
46
+ * rather than indexing a stale slot, because a wrong AABB would mean a wrong
47
+ * entity under the cursor, and a slower correct answer beats a fast wrong one.
48
+ *
49
+ * `slotEntity` here is the *hit* grid's pre-order mapping, unrelated to the
50
+ * transform store's slots despite the shared name in the result type.
51
+ */
52
+ export declare function gatherHitAABBsFromStore(root: Entity, aabbs: ResidentAabbs, storeSlotEntity: readonly Entity[], out: HitGatherResult): HitGatherResult | null;
53
+ /** A reusable result buffer, so the fused path allocates nothing per query. */
54
+ export declare function createHitGatherBuffer(): HitGatherResult;
@@ -0,0 +1,76 @@
1
+ /**
2
+ * A single compiled-and-instantiated `vectojs_core.wasm` shared by every WASM
3
+ * backend of one Scene.
4
+ *
5
+ * Before this, each of the four accelerators (transform, animation, hit-test,
6
+ * particle) carried its own copy of the same three-way loader and instantiated
7
+ * the binary independently. A Scene enabling all four therefore compiled the same
8
+ * module up to four times and held four separate linear memories, plus four sets
9
+ * of module-level static state.
10
+ *
11
+ * Nothing required that: the Rust crate already keeps the transform, anim, hit
12
+ * and particle stores in distinct statics, so they do not alias inside one
13
+ * instance. The separate instances bought isolation that was already there, and
14
+ * paid for it in compile time and memory.
15
+ *
16
+ * Two levels of sharing:
17
+ *
18
+ * - The compiled {@link WebAssembly.Module} is cached **globally** per source, so
19
+ * a second Scene (or a re-enable after `destroy()`) skips compilation.
20
+ * - The {@link WebAssembly.Instance} is created **per runtime**, so two Scenes
21
+ * never share mutable stores. That isolation is the part that matters.
22
+ *
23
+ * Backends stay independently gated: holding a runtime does not mean every
24
+ * accelerator is active. Each `enableWasm*` still decides whether its workload
25
+ * clears its own threshold.
26
+ */
27
+ import { WasmTransformBackend } from './backend';
28
+ import { AnimBackend } from './anim-backend';
29
+ import { HitTestBackend } from './hit-backend';
30
+ import { ParticleBackend } from './particle-backend';
31
+ /** Anything a core module can be loaded from. Matches the per-backend loaders. */
32
+ export type CoreModuleSource = BufferSource | string | URL | Response | Promise<Response>;
33
+ /**
34
+ * Compile a core module, reusing the global cache when the source is a
35
+ * URL or path. Returns `null` on any failure (CSP `wasm-unsafe-eval`, 404,
36
+ * corrupt bytes, unsupported) so callers keep the JS path.
37
+ */
38
+ export declare function loadCoreWasmModule(source: CoreModuleSource): Promise<WebAssembly.Module | null>;
39
+ /**
40
+ * One instance of the core module, exposing every backend that instance can
41
+ * serve. All four share its linear memory, which is why they must come from the
42
+ * same instantiation rather than four independent ones.
43
+ */
44
+ export declare class CoreWasmRuntime {
45
+ readonly instance: WebAssembly.Instance;
46
+ private transformBackend;
47
+ private animBackend;
48
+ private hitBackend;
49
+ private particleBackendInstance;
50
+ constructor(instance: WebAssembly.Instance);
51
+ /**
52
+ * Backends are constructed lazily and memoised: each one's constructor calls
53
+ * into the instance to size its store and build typed-array views, so building
54
+ * all four up front would pay for accelerators the Scene never enables.
55
+ */
56
+ transform(): WasmTransformBackend;
57
+ anim(): AnimBackend;
58
+ hit(): HitTestBackend;
59
+ particle(): ParticleBackend;
60
+ }
61
+ /**
62
+ * Instantiate a runtime from an already-compiled module. Separated from
63
+ * {@link loadCoreWasmModule} so several Scenes can share one compile while each
64
+ * keeps its own mutable stores.
65
+ */
66
+ export declare function createCoreWasmRuntime(module: WebAssembly.Module): CoreWasmRuntime | null;
67
+ /**
68
+ * Compile (or reuse) and instantiate in one step. Returns `null` on any failure
69
+ * so the caller keeps the JS path.
70
+ */
71
+ export declare function loadCoreWasmRuntime(source: CoreModuleSource): Promise<CoreWasmRuntime | null>;
72
+ /**
73
+ * Drop the global compiled-module cache. For tests that need to observe a fresh
74
+ * compile, and for apps that want to release the memory after teardown.
75
+ */
76
+ export declare function clearCoreWasmModuleCache(): void;
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vectojs/core",
3
- "version": "1.16.2",
3
+ "version": "1.17.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },