@uptimizr/playcanvas 0.1.2 → 0.2.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.
@@ -18,6 +18,14 @@ export interface PlayCanvasCaptureOptions {
18
18
  perf?: boolean;
19
19
  /** WebGL `webglcontextlost` / `webglcontextrestored` transitions on the canvas. */
20
20
  contextLoss?: boolean;
21
+ /**
22
+ * Asset / resource load timing (`asset_load`, #14). **On by default**: it hooks
23
+ * the PlayCanvas `app.assets` registry load lifecycle (`load:start` → `load` /
24
+ * `error`) and emits one `asset_load` per observed load with its duration and
25
+ * (when known) byte size. Privacy (ADR 0003): only the asset's app-defined
26
+ * **name** is recorded — never the file URL.
27
+ */
28
+ assetLoad?: boolean;
21
29
  /**
22
30
  * Per-object dwell / attention capture (`mesh_visibility`, #37). **Opt-in, off
23
31
  * by default** (privacy, ADR 0003). Each window the connector emits one
@@ -1 +1 @@
1
- {"version":3,"file":"collector.d.ts","sourceRoot":"","sources":["../src/collector.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"collector.d.ts","sourceRoot":"","sources":["../src/collector.ts"],"names":[],"mappings":"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"}
package/dist/collector.js CHANGED
@@ -1,4 +1,5 @@
1
- import { classifyCameraGesture, resolveCadence, toCanonicalDirection, toCanonicalPosition, toCanonicalQuat, } from "@uptimizr/sdk-core";
1
+ import { poseUnchanged, resolveCadence, toCanonicalDirection, toCanonicalPosition, toCanonicalQuat, } from "@uptimizr/sdk-core";
2
+ import { LIMITS } from "@uptimizr/schema";
2
3
  import { clamp01, toVec3 } from "./vec.js";
3
4
  import { createGazeRaycaster, createSceneRaycaster } from "./raycast.js";
4
5
  /**
@@ -24,30 +25,14 @@ function isPointerLocked(getCanvas) {
24
25
  const locked = document.pointerLockElement;
25
26
  return locked != null && locked === getCanvas();
26
27
  }
27
- function vec3Close(a, b, eps) {
28
- return (Math.abs(a[0] - b[0]) <= eps && Math.abs(a[1] - b[1]) <= eps && Math.abs(a[2] - b[2]) <= eps);
29
- }
30
- /** True when two poses are equal within `eps`. */
31
- function poseUnchanged(a, b, eps) {
32
- if (!vec3Close(a.position, b.position, eps))
33
- return false;
34
- if (!vec3Close(a.direction, b.direction, eps))
35
- return false;
36
- if ((a.fov === undefined) !== (b.fov === undefined))
37
- return false;
38
- if (a.fov !== undefined && b.fov !== undefined && Math.abs(a.fov - b.fov) > eps)
39
- return false;
40
- return true;
41
- }
28
+ /** Max stored `asset_load.name` length (schema bound, ADR 0003). */
29
+ const ASSET_NAME_MAX = LIMITS.maxAssetNameLength;
42
30
  function sub3(a, b) {
43
31
  return [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
44
32
  }
45
33
  function dot3(a, b) {
46
34
  return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
47
35
  }
48
- function len3(a) {
49
- return Math.hypot(a[0], a[1], a[2]);
50
- }
51
36
  /**
52
37
  * Derive the per-tick capture rate from a `sampling.nodes` entry: either a bare
53
38
  * {@link SampleRate} (root-only, ADR 0027) or a {@link NodeSamplingConfig} whose
@@ -138,22 +123,6 @@ function readNodeTransform(node, scaleEps) {
138
123
  }
139
124
  return sample;
140
125
  }
141
- /** True when two node samples are equal within `eps` (scale presence must also match). */
142
- function nodeSampleUnchanged(a, b, eps) {
143
- if (!vec3Close(a.position, b.position, eps))
144
- return false;
145
- if (Math.abs(a.rotation[0] - b.rotation[0]) > eps ||
146
- Math.abs(a.rotation[1] - b.rotation[1]) > eps ||
147
- Math.abs(a.rotation[2] - b.rotation[2]) > eps ||
148
- Math.abs(a.rotation[3] - b.rotation[3]) > eps) {
149
- return false;
150
- }
151
- if ((a.scale === undefined) !== (b.scale === undefined))
152
- return false;
153
- if (a.scale && b.scale && !vec3Close(a.scale, b.scale, eps))
154
- return false;
155
- return true;
156
- }
157
126
  /**
158
127
  * Resolve a declared {@link PlayCanvasActor} to a live node, or `null` when it is
159
128
  * not (yet) in the scene. A function is called each time (robust to load order
@@ -240,17 +209,6 @@ function readPlayCanvasBoneTransform(bone, scaleEps) {
240
209
  }
241
210
  return sample;
242
211
  }
243
- /** Round an AABB to mm precision so tiny float jitter doesn't re-send the box. */
244
- function roundAabb(b) {
245
- return b.map((v) => Math.round(v * 1000) / 1000);
246
- }
247
- /** True when two AABBs match within `eps` on every axis. */
248
- function aabbClose(a, b, eps) {
249
- for (let i = 0; i < 6; i++)
250
- if (Math.abs((a[i] ?? 0) - (b[i] ?? 0)) > eps)
251
- return false;
252
- return true;
253
- }
254
212
  function meshInstancesOf(node) {
255
213
  return node.render?.meshInstances ?? node.model?.meshInstances ?? [];
256
214
  }
@@ -344,6 +302,8 @@ export function playcanvasCollector(options) {
344
302
  meshPicks: capture.meshPicks ?? true,
345
303
  perf: (capture.perf ?? true) && perfCadence.mode !== "off",
346
304
  contextLoss: capture.contextLoss ?? true,
305
+ // Asset load timing is on by default (#14): startup perf, name-only (no URL).
306
+ assetLoad: capture.assetLoad ?? true,
347
307
  // Opt-in, off by default (privacy, ADR 0003).
348
308
  meshVisibility: capture.meshVisibility ?? false,
349
309
  hoverDwell: capture.hoverDwell ?? false,
@@ -417,6 +377,17 @@ export function playcanvasCollector(options) {
417
377
  name: "playcanvas",
418
378
  start(ctx) {
419
379
  const appView = app;
380
+ // The engine-agnostic aggregator (#10): the connector reads live engine
381
+ // state into plain-number snapshot DTOs and hands them here; the
382
+ // offload-eligible math (matrix-free decompose idle-diffs, visibility
383
+ // bucketing, gesture classification) runs main-thread by default or inside
384
+ // the offload worker, behind the client `offload` flag.
385
+ const aggregatorConfig = {
386
+ perf: { suppressIdle: suppressIdlePerfSamples, fpsThreshold: perfFpsThreshold },
387
+ node: { suppressIdle: suppressIdleSamples },
388
+ visibility: { centeredCos: visCenteredCos, boundingBox: visBoundingBox, boundsEps: 1e-3 },
389
+ };
390
+ const snapshot = ctx.createAggregation(aggregatorConfig);
420
391
  const timers = [];
421
392
  const domListeners = [];
422
393
  const frameCallbacks = [];
@@ -426,7 +397,6 @@ export function playcanvasCollector(options) {
426
397
  let disposed = false;
427
398
  let lastPointerMove = 0;
428
399
  let lastPose;
429
- let lastFps;
430
400
  // camera_gesture (ADR 0025) bracket state: the camera snapshot at
431
401
  // pointer-down, diffed against pointer-up to classify the navigation.
432
402
  let gestureStart = null;
@@ -476,8 +446,8 @@ export function playcanvasCollector(options) {
476
446
  ? toCanonicalPosition(gazeHit.point, "right")
477
447
  : undefined;
478
448
  const hitMesh = gazeHit && gazeHit.name ? gazeHit.name : undefined;
479
- ctx.emit({
480
- type: "camera_sample",
449
+ snapshot({
450
+ channel: "camera",
481
451
  position: pose.position,
482
452
  direction: pose.direction,
483
453
  ...(pose.fov !== undefined ? { fov: pose.fov } : {}),
@@ -508,13 +478,11 @@ export function playcanvasCollector(options) {
508
478
  const fps = readFps();
509
479
  if (fps <= 0)
510
480
  return;
511
- if (suppressIdlePerfSamples &&
512
- lastFps !== undefined &&
513
- Math.abs(fps - lastFps) <= perfFpsThreshold) {
514
- return;
515
- }
516
- lastFps = fps;
517
- ctx.emit({ type: "frame_perf", fps });
481
+ // Hand the engine FPS read to the aggregator; it owns the idle-diff (and,
482
+ // for engines that supply a frame-time window, the percentiles/longFrames).
483
+ // PlayCanvas exposes only an averaged FPS, so the window stays empty and the
484
+ // aggregator emits `frame_perf` with `fps` alone — byte-for-byte unchanged.
485
+ snapshot({ channel: "perf", frameTimes: new Float32Array(0), fps, jankFrameMs: 0 });
518
486
  };
519
487
  // Drive a continuous channel either on a timer (fixed interval) or once per
520
488
  // engine frame ("frame"). PlayCanvas owns its render loop, so the connector
@@ -553,10 +521,12 @@ export function playcanvasCollector(options) {
553
521
  // actor gets its own cadence-driven sampler: resolve the node (lazily —
554
522
  // resolvers handle load order), refuse cameras (the visitor camera is
555
523
  // already `camera_sample`; "events live once"), read the WORLD transform,
556
- // convert it to the canonical frame, and emit. Idle suppression skips
557
- // samples where the transform is unchanged so a static actor costs nothing.
524
+ // convert it to the canonical frame, and hand the decomposed sample to the
525
+ // aggregator. PlayCanvas applies a right-handed→canonical conversion (ADR
526
+ // 0018), so we pass the already-decomposed sample (never the raw matrix
527
+ // path); the aggregator owns the idle-diff so a static actor stays off the
528
+ // wire (#10).
558
529
  const root = appView.root;
559
- const lastNodeSample = new Map();
560
530
  const refusedCamera = new Set();
561
531
  for (const id of actorIds) {
562
532
  const actor = actorMap[id];
@@ -580,40 +550,24 @@ export function playcanvasCollector(options) {
580
550
  }
581
551
  return;
582
552
  }
583
- const sample = readNodeTransform(node, cameraEpsilon);
584
- const prev = lastNodeSample.get(id);
585
- if (!(suppressIdleSamples && prev && nodeSampleUnchanged(prev, sample, cameraEpsilon))) {
586
- lastNodeSample.set(id, sample);
587
- ctx.emit({
588
- type: "node_transform",
589
- nodeId: id,
590
- position: sample.position,
591
- rotation: sample.rotation,
592
- ...(sample.scale ? { scale: sample.scale } : {}),
593
- });
594
- }
595
- // Subtree descendants (ADR 0033): walk the bounded hierarchy and emit
596
- // each kept node's WORLD transform with its `childPath`. Idle
597
- // suppression is keyed per (actor, childPath) so a static part costs
598
- // nothing on the wire.
553
+ snapshot({
554
+ channel: "node",
555
+ nodeId: id,
556
+ decomposed: readNodeTransform(node, cameraEpsilon),
557
+ scaleEps: cameraEpsilon,
558
+ });
559
+ // Subtree descendants (ADR 0033): walk the bounded hierarchy and hand
560
+ // each kept node's WORLD transform with its `childPath` to the
561
+ // aggregator, which idle-diffs per (actor, childPath) so a static part
562
+ // costs nothing on the wire.
599
563
  if (subtree) {
600
564
  for (const { childPath, node: child } of collectSubtree(node, subtree)) {
601
- const childSample = readNodeTransform(child, cameraEpsilon);
602
- const key = `${id}\u0000${childPath}`;
603
- const childPrev = lastNodeSample.get(key);
604
- if (suppressIdleSamples &&
605
- childPrev &&
606
- nodeSampleUnchanged(childPrev, childSample, cameraEpsilon)) {
607
- continue;
608
- }
609
- lastNodeSample.set(key, childSample);
610
- ctx.emit({
611
- type: "node_transform",
565
+ snapshot({
566
+ channel: "node",
612
567
  nodeId: id,
613
568
  childPath,
614
- position: childSample.position,
615
- rotation: childSample.rotation,
616
- ...(childSample.scale ? { scale: childSample.scale } : {}),
569
+ decomposed: readNodeTransform(child, cameraEpsilon),
570
+ scaleEps: cameraEpsilon,
617
571
  });
618
572
  }
619
573
  }
@@ -626,11 +580,13 @@ export function playcanvasCollector(options) {
626
580
  // Skeleton bone capture (`node_transform` + `boneId`, ADR 0027 Tier 2).
627
581
  // For each declared skinned actor, resolve its skeleton bones (by the
628
582
  // configured allowlist or "*"), then sample each bone's skeleton-LOCAL
629
- // pose at the actor's cadence. Per-bone idle suppression keeps a still
630
- // rig free. The local frame is parent-relative, so motion replays onto a
631
- // differently-placed instance of the same rig (ADR 0027).
583
+ // pose at the actor's cadence and hand the decomposed sample to the
584
+ // aggregator (which idle-diffs per (actor, bone), #10). PlayCanvas reads
585
+ // the local TRS via `getLocal*` and applies the canonical conversion (ADR
586
+ // 0018), so — like nodes — we pass the decomposed sample, never the raw
587
+ // matrix path. The local frame is parent-relative, so motion replays onto
588
+ // a differently-placed instance of the same rig (ADR 0027).
632
589
  const root = appView.root;
633
- const lastBoneSample = new Map();
634
590
  const warnedNoSkeleton = new Set();
635
591
  for (const id of boneActorIds) {
636
592
  const actor = actorMap[id];
@@ -658,19 +614,12 @@ export function playcanvasCollector(options) {
658
614
  const sample = readPlayCanvasBoneTransform(bone, cameraEpsilon);
659
615
  if (!sample)
660
616
  continue;
661
- const key = `${id}\u0000${boneName}`;
662
- const prev = lastBoneSample.get(key);
663
- if (suppressIdleSamples && prev && nodeSampleUnchanged(prev, sample, cameraEpsilon)) {
664
- continue;
665
- }
666
- lastBoneSample.set(key, sample);
667
- ctx.emit({
668
- type: "node_transform",
617
+ snapshot({
618
+ channel: "node",
669
619
  nodeId: id,
670
620
  boneId: boneName,
671
- position: sample.position,
672
- rotation: sample.rotation,
673
- ...(sample.scale ? { scale: sample.scale } : {}),
621
+ decomposed: sample,
622
+ scaleEps: cameraEpsilon,
674
623
  });
675
624
  }
676
625
  };
@@ -679,14 +628,12 @@ export function playcanvasCollector(options) {
679
628
  }
680
629
  }
681
630
  // --- Per-object dwell (`mesh_visibility`, #37) ---
682
- // Accumulate on-screen / near-centre time per object every engine frame
683
- // (frameend pauses when the tab is hidden like Babylon's onBeforeRender),
684
- // then flush one bucketed summary per object per window (ADR 0012).
631
+ // Each engine frame we read which tracked objects are on-screen (the engine
632
+ // bounds/frustum reads must stay main-thread) and hand the raw per-tick
633
+ // observations to the aggregator; it owns the dwell/centred/screen-fraction
634
+ // bucketing and the per-window flush (ADR 0012, #10). `frameend` pauses when
635
+ // the tab is hidden — like Babylon's onBeforeRender.
685
636
  if (want.meshVisibility) {
686
- const accum = new Map();
687
- // Last AABB sent per mesh, so a static box is sent once then suppressed.
688
- const sentBounds = new Map();
689
- const boundsEps = 1e-3;
690
637
  let lastVisTime = ctx.now();
691
638
  const sampleVisibility = () => {
692
639
  const now = ctx.now();
@@ -699,11 +646,12 @@ export function playcanvasCollector(options) {
699
646
  const cf = camView.forward;
700
647
  const camPos = [cp.x, cp.y, cp.z];
701
648
  const forward = [cf.x, cf.y, cf.z];
702
- const fwdLen = len3(forward) || 1;
703
- // Half vertical FOV in radians; fall back for ortho / missing fov.
704
- const vfov = verticalFovRad(camView.camera);
705
- const halfFov = vfov !== undefined ? vfov / 2 : 0.4;
649
+ // Vertical FOV in radians; `0` lets the aggregator apply its `0.8`
650
+ // default (→ half-FOV `0.4`), matching the previous ortho/missing-fov
651
+ // fallback.
652
+ const fov = verticalFovRad(camView.camera) ?? 0;
706
653
  let tracked = 0;
654
+ const observations = [];
707
655
  appView.root?.forEach?.((raw) => {
708
656
  const node = raw;
709
657
  if (meshInstancesOf(node).length === 0)
@@ -728,54 +676,41 @@ export function playcanvasCollector(options) {
728
676
  if (!sphereInFront(bounds.center, bounds.radius, camPos, forward)) {
729
677
  return;
730
678
  }
731
- const toCenter = sub3(bounds.center, camPos);
732
- const dist = len3(toCenter) || 1e-6;
733
- const cosAngle = dot3(toCenter, forward) / (dist * fwdLen);
734
- const screenFraction = clamp01(Math.atan2(bounds.radius, dist) / (halfFov || 1e-6));
735
- let entry = accum.get(name);
736
- if (!entry) {
737
- entry = { visibleMs: 0, centeredMs: 0, maxScreenFraction: 0 };
738
- accum.set(name, entry);
739
- }
740
- entry.visibleMs += stepMs;
741
- if (cosAngle >= visCenteredCos)
742
- entry.centeredMs += stepMs;
743
- if (screenFraction > entry.maxScreenFraction)
744
- entry.maxScreenFraction = screenFraction;
745
- if (visBoundingBox) {
746
- // Canonical frame negates Z (right-handed PlayCanvas → canonical),
747
- // which swaps the Z min/max — match hitPoint / camera_sample (ADR 0018).
748
- const [aMinX, aMinY, aMinZ, aMaxX, aMaxY, aMaxZ] = bounds.aabb;
749
- entry.bounds = [aMinX, aMinY, -aMaxZ, aMaxX, aMaxY, -aMinZ];
750
- }
751
- });
752
- };
753
- const flushVisibility = () => {
754
- for (const [mesh, entry] of accum) {
755
- if (entry.visibleMs <= 0)
756
- continue;
757
- let bounds;
758
- if (entry.bounds) {
759
- const rounded = roundAabb(entry.bounds);
760
- const prev = sentBounds.get(mesh);
761
- if (!prev || !aabbClose(prev, rounded, boundsEps)) {
762
- bounds = rounded;
763
- sentBounds.set(mesh, rounded);
764
- }
765
- }
766
- ctx.emit({
767
- type: "mesh_visibility",
768
- mesh,
769
- visibleMs: Math.round(entry.visibleMs),
770
- ...(entry.centeredMs > 0 ? { centeredMs: Math.round(entry.centeredMs) } : {}),
771
- ...(entry.maxScreenFraction > 0
772
- ? { maxScreenFraction: entry.maxScreenFraction }
679
+ observations.push({
680
+ mesh: name,
681
+ center: bounds.center,
682
+ radius: bounds.radius,
683
+ // Ride the world AABB along only when bounds capture is on (#53). The
684
+ // canonical frame negates Z (right-handed PlayCanvas → canonical),
685
+ // swapping the Z min/max match hitPoint / camera_sample (ADR 0018);
686
+ // the aggregator dedupes/rounds it across the window.
687
+ ...(visBoundingBox
688
+ ? {
689
+ aabb: [
690
+ bounds.aabb[0],
691
+ bounds.aabb[1],
692
+ -bounds.aabb[5],
693
+ bounds.aabb[3],
694
+ bounds.aabb[4],
695
+ -bounds.aabb[2],
696
+ ],
697
+ }
773
698
  : {}),
774
- ...(bounds ? { bounds } : {}),
775
699
  });
776
- }
777
- accum.clear();
700
+ });
701
+ if (observations.length === 0)
702
+ return;
703
+ snapshot({
704
+ channel: "visibilityTick",
705
+ stepMs,
706
+ camPos,
707
+ // Pass the raw (un-normalized) forward; the aggregator normalizes.
708
+ forward,
709
+ fov,
710
+ meshes: observations,
711
+ });
778
712
  };
713
+ const flushVisibility = () => snapshot({ channel: "visibilityFlush" });
779
714
  // Sample every frame; flush on the window timer + once on stop (trailing).
780
715
  frameCallbacks.push(sampleVisibility);
781
716
  ensureFrameendBound();
@@ -840,8 +775,8 @@ export function playcanvasCollector(options) {
840
775
  if (hoverMesh !== undefined && !hoverActed) {
841
776
  const dwellMs = now - hoverStartMs;
842
777
  if (dwellMs >= hoverMinDwellMs) {
843
- ctx.emit({
844
- type: "hover_dwell",
778
+ snapshot({
779
+ channel: "hover",
845
780
  mesh: hoverMesh,
846
781
  dwellMs,
847
782
  ...(hoverSource ? { source: hoverSource } : {}),
@@ -977,19 +912,14 @@ export function playcanvasCollector(options) {
977
912
  gestureStart = null;
978
913
  if (!opened)
979
914
  return;
980
- const classified = classifyCameraGesture(opened.sample, readGestureSample(), {
981
- sensitivity: cameraGestureSensitivity,
982
- });
983
- if (!classified)
984
- return;
985
- ctx.emit({
986
- type: "camera_gesture",
987
- kind: classified.kind,
915
+ // Hand the start→end bracket to the aggregator; the (pure) classification
916
+ // math runs there, main-thread or in the worker (#10).
917
+ snapshot({
918
+ channel: "gesture",
919
+ start: opened.sample,
920
+ end: readGestureSample(),
988
921
  durationMs: Math.max(0, Math.round(ctx.now() - opened.ts)),
989
- ...(classified.orbitDeg !== undefined ? { orbitDeg: classified.orbitDeg } : {}),
990
- ...(classified.rollDeg !== undefined ? { rollDeg: classified.rollDeg } : {}),
991
- ...(classified.zoomRatio !== undefined ? { zoomRatio: classified.zoomRatio } : {}),
992
- ...(classified.panDist !== undefined ? { panDist: classified.panDist } : {}),
922
+ options: { sensitivity: cameraGestureSensitivity },
993
923
  ...(opened.source ? { source: opened.source } : {}),
994
924
  });
995
925
  };
@@ -1006,6 +936,54 @@ export function playcanvasCollector(options) {
1006
936
  addListener("webglcontextlost", () => ctx.emit({ type: "context_lost" }));
1007
937
  addListener("webglcontextrestored", () => ctx.emit({ type: "context_restored" }));
1008
938
  }
939
+ // Asset / resource load timing (`asset_load`, #14). PlayCanvas' `app.assets`
940
+ // registry (an EventHandler) exposes the load lifecycle: `load:start` fires
941
+ // when an asset begins network loading, `load` on success, `error` on
942
+ // failure. We bracket start→load to report a duration; assets whose load we
943
+ // never observed from the start (already-loaded, or added before tracking)
944
+ // are skipped so `loadMs` is never fabricated. Privacy (ADR 0003): only the
945
+ // asset's app-defined `name` is recorded — never the file URL.
946
+ const assets = appView.assets;
947
+ if (want.assetLoad && assets) {
948
+ const startTimes = new Map();
949
+ const keyOf = (asset) => asset.id ?? asset;
950
+ const onLoadStart = (raw) => {
951
+ startTimes.set(keyOf(raw), ctx.now());
952
+ };
953
+ const onLoad = (raw) => {
954
+ const asset = raw;
955
+ const key = keyOf(asset);
956
+ const start = startTimes.get(key);
957
+ if (start === undefined)
958
+ return; // load not observed from the start
959
+ startTimes.delete(key);
960
+ const event = {
961
+ type: "asset_load",
962
+ name: (asset.name ?? "asset").slice(0, ASSET_NAME_MAX),
963
+ loadMs: Math.max(0, Math.round(ctx.now() - start)),
964
+ };
965
+ const bytes = asset.file?.size;
966
+ if (typeof bytes === "number" && bytes > 0)
967
+ event.bytes = bytes;
968
+ ctx.emit(event);
969
+ };
970
+ const onError = (...args) => {
971
+ // Registry fires `error(err, asset)`; drop the pending start (no emit —
972
+ // `asset_load` is a success-timing signal with no error field).
973
+ const asset = args[1];
974
+ if (asset)
975
+ startTimes.delete(keyOf(asset));
976
+ };
977
+ assets.on("load:start", onLoadStart);
978
+ assets.on("load", onLoad);
979
+ assets.on("error", onError);
980
+ stopCallbacks.push(() => {
981
+ assets.off("load:start", onLoadStart);
982
+ assets.off("load", onLoad);
983
+ assets.off("error", onError);
984
+ startTimes.clear();
985
+ });
986
+ }
1009
987
  // GPU / memory footprint (`resource_sample`, #44). A low-rate timer samples
1010
988
  // the triangles PlayCanvas submitted last frame (`app.stats.frame.triangles`)
1011
989
  // and the JS heap. PlayCanvas exposes no per-frame vertex count or resident