@ifc-lite/renderer 1.37.0 → 1.38.1

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/scene.js CHANGED
@@ -8,6 +8,7 @@ import { sumResidentGpuBytes } from './render-stats.js';
8
8
  import { simplifyIndicesByClustering, lodCellSizeForBounds, LOD_MIN_TRIANGLES } from './lod-simplify.js';
9
9
  import { quantizeInterleaved } from './quantize.js';
10
10
  import { bucketBaseKeyFor } from './chunk-grid.js';
11
+ import { VisibilityEpochTracker } from './visibility-epoch.js';
11
12
  import { selectEvictions } from './residency.js';
12
13
  import { OPAQUE_ALPHA_CUTOFF } from './overlay-routing.js';
13
14
  import { prepareInstancedRender, foldOccurrenceWorldBox, INSTANCE_STRIDE_BYTES, INSTANCE_COLOR_OFFSET, INSTANCE_FLAGS_OFFSET, INSTANCE_FLAG_SELECTED, INSTANCE_FLAG_HIDDEN, } from './instanced-render.js';
@@ -39,8 +40,11 @@ export class Scene {
39
40
  instancedHidden = new Set(); // currently hidden instanced express_ids (hide/isolate)
40
41
  instancedOverridden = new Set(); // currently colour-overridden instanced express_ids
41
42
  instancedHasTransparent = false; // an override made some instanced occurrence translucent
42
- lastInstancedHiddenIds = null; // ref-equality guard for setInstancedVisibility
43
- lastInstancedIsolatedIds = null;
43
+ // Content-based change guard for setInstancedVisibility — same contract as
44
+ // RenderOptions.hiddenIds (in-place mutation and fresh identical Sets both
45
+ // behave), keeping the instanced path in lockstep with the batched path.
46
+ instancedVisibilityEpochs = new VisibilityEpochTracker();
47
+ lastInstancedVisibilityVersion = -1;
44
48
  instancedVisibilityDirty = false; // set when a new shard adds occurrences → re-apply visibility
45
49
  // Buffer-size-aware bucket splitting: when a single color group's geometry
46
50
  // would exceed the GPU maxBufferSize, overflow is directed to a new
@@ -96,6 +100,11 @@ export class Scene {
96
100
  // This allows rendering partially visible batches as single draw calls instead of 10,000+ individual draws
97
101
  partialBatchCache = new Map();
98
102
  partialBatchCacheKeys = new Map(); // sourceBatchKey -> current cache key (for invalidation)
103
+ // sourceBatchKey -> visibility/override epoch its cached partial batch was
104
+ // built for. Lets getOrCreatePartialBatch return the cached clone WITHOUT
105
+ // re-sorting + re-hashing every visible id each frame while the epoch holds
106
+ // (issue: O(elements) per-frame work under hide/isolate). See render loop.
107
+ partialBatchCacheVersions = new Map();
99
108
  // Color overlay system for lens coloring — NEVER modifies original batches.
100
109
  // Overlay batches render on top using depthCompare 'equal', so they only
101
110
  // paint where original geometry already wrote depth. Clearing is instant.
@@ -103,6 +112,11 @@ export class Scene {
103
112
  // Defensively-typed: the renderer is the sole writer (via setColorOverrides),
104
113
  // external readers go through getColorOverrides() and get a ReadonlyMap.
105
114
  colorOverrides = null;
115
+ // Bumped whenever the colour-override set changes. The partial sub-batch's
116
+ // visible subset depends on override promotion (splitVisibleIdsByPromotion),
117
+ // so the render loop folds this into the partial-batch cache epoch to keep the
118
+ // per-frame fast path correct when overrides change with no visibility change.
119
+ colorOverrideGeneration = 0;
106
120
  // Streaming optimization: track pending batch rebuilds
107
121
  pendingBatchKeys = new Set();
108
122
  // Temporary fragment batches created during streaming for immediate rendering.
@@ -571,7 +585,58 @@ export class Scene {
571
585
  this.partialBatchCache.delete(cacheKey);
572
586
  }
573
587
  this.partialBatchCacheKeys.delete(sourceBatchKey);
588
+ this.partialBatchCacheVersions.delete(sourceBatchKey);
589
+ }
590
+ }
591
+ /** Destroy + drop EVERY cached partial sub-batch. The clones built during
592
+ * hide/isolate are deliberately excluded from the GPU residency budget and
593
+ * are otherwise only freed on clear()/finalize/evict — never when filtering
594
+ * ends. The render loop calls this on the transition back to fully-visible so
595
+ * the ~model-sized clone VRAM is not pinned until the next model reload. Uses
596
+ * the same destroy-then-clear idiom as clear(); safe to call between frames
597
+ * because the previous frame is already submitted (WebGPU defers the free
598
+ * past in-flight work). */
599
+ dropAllPartialCaches() {
600
+ if (this.partialBatchCache.size === 0
601
+ && this.partialBatchCacheKeys.size === 0
602
+ && this.partialBatchCacheVersions.size === 0) {
603
+ return;
574
604
  }
605
+ for (const batch of this.partialBatchCache.values())
606
+ destroyGpuResources(batch);
607
+ this.partialBatchCache.clear();
608
+ this.partialBatchCacheKeys.clear();
609
+ this.partialBatchCacheVersions.clear();
610
+ }
611
+ /** Free the hydrated (pick / selection-highlight) individual meshes that are
612
+ * no longer selected, destroying their GPU buffers and dropping them from
613
+ * `this.meshes`. A mesh is kept iff its expressId is in `keep` AND it
614
+ * matches `keepModelIndex` (undefined = any model) — the same predicate the
615
+ * render loop uses to draw selection highlights, so disposal is its exact
616
+ * complement. The model scoping matters for federation: models can share
617
+ * express ids, and an id-only check would strand the OTHER model's hydrated
618
+ * mesh resident and drawing when selection moves across models. Only meshes
619
+ * flagged `hydrated` are touched — authored geometry added via addMesh()
620
+ * and batch geometry are left untouched. Returns how many were freed. */
621
+ disposeHydratedMeshesExcept(keep, keepModelIndex) {
622
+ if (this.meshes.length === 0)
623
+ return 0;
624
+ const kept = [];
625
+ let disposed = 0;
626
+ for (const mesh of this.meshes) {
627
+ const keepMesh = keep.has(mesh.expressId)
628
+ && (keepModelIndex === undefined || mesh.modelIndex === keepModelIndex);
629
+ if (mesh.hydrated && !keepMesh) {
630
+ destroyGpuResources(mesh);
631
+ disposed++;
632
+ }
633
+ else {
634
+ kept.push(mesh);
635
+ }
636
+ }
637
+ if (disposed > 0)
638
+ this.meshes = kept;
639
+ return disposed;
575
640
  }
576
641
  /**
577
642
  * Bucket BASE key for a mesh: colour key, prefixed with the mesh's grid
@@ -1718,10 +1783,7 @@ export class Scene {
1718
1783
  this.residencyRestoreQueue.clear();
1719
1784
  this.pendingBatchKeys.clear();
1720
1785
  // Destroy cached partial batches — their colorKeys are now stale
1721
- for (const batch of this.partialBatchCache.values())
1722
- destroyGpuResources(batch);
1723
- this.partialBatchCache.clear();
1724
- this.partialBatchCacheKeys.clear();
1786
+ this.dropAllPartialCaches();
1725
1787
  // Re-seat the carried cold shells in the fresh bucket map (their GPU
1726
1788
  // shells re-enter the flat array via rebuildPendingBatches below).
1727
1789
  for (const [key, bucket] of carriedCold)
@@ -1799,10 +1861,7 @@ export class Scene {
1799
1861
  this.lastDrawnFrame.clear();
1800
1862
  this.residencyRestoreQueue.clear();
1801
1863
  this.pendingBatchKeys.clear();
1802
- for (const batch of this.partialBatchCache.values())
1803
- destroyGpuResources(batch);
1804
- this.partialBatchCache.clear();
1805
- this.partialBatchCacheKeys.clear();
1864
+ this.dropAllPartialCaches();
1806
1865
  // Re-seat the carried cold shells in the fresh bucket map.
1807
1866
  for (const [key, bucket] of carriedCold)
1808
1867
  this.buckets.set(key, bucket);
@@ -1924,10 +1983,7 @@ export class Scene {
1924
1983
  this.coldBuckets.clear();
1925
1984
  this.dirtyBuckets.clear();
1926
1985
  this.pendingBatchKeys.clear();
1927
- for (const batch of this.partialBatchCache.values())
1928
- destroyGpuResources(batch);
1929
- this.partialBatchCache.clear();
1930
- this.partialBatchCacheKeys.clear();
1986
+ this.dropAllPartialCaches();
1931
1987
  this.geometryReleased = true;
1932
1988
  this.ephemeralStreamingMode = false;
1933
1989
  }
@@ -2012,10 +2068,7 @@ export class Scene {
2012
2068
  this.coldBuckets.clear();
2013
2069
  this.dirtyBuckets.clear();
2014
2070
  // 3. Clear partial batch cache (would need mesh data to rebuild)
2015
- for (const batch of this.partialBatchCache.values())
2016
- destroyGpuResources(batch);
2017
- this.partialBatchCache.clear();
2018
- this.partialBatchCacheKeys.clear();
2071
+ this.dropAllPartialCaches();
2019
2072
  this.geometryReleased = true;
2020
2073
  console.log(`[Scene] Released JS geometry data. ${this.boundingBoxes.size} bounding boxes cached. ` +
2021
2074
  `${this.batchedMeshes.length} GPU batches retained.`);
@@ -2321,10 +2374,25 @@ export class Scene {
2321
2374
  * @param pipeline - Rendering pipeline
2322
2375
  * @returns BatchedMesh containing only visible elements, or undefined if no visible elements
2323
2376
  */
2324
- getOrCreatePartialBatch(sourceBatchKey, colorKey, visibleIds, device, pipeline) {
2377
+ getOrCreatePartialBatch(sourceBatchKey, colorKey, visibleIds, device, pipeline, visibilityEpoch) {
2325
2378
  // Cannot create partial batches after geometry data has been released
2326
2379
  if (this.geometryReleased)
2327
2380
  return undefined;
2381
+ // Fast path (PERF): while the visibility + colour-override epoch is
2382
+ // unchanged, the visible subset for this sourceBatch is provably identical
2383
+ // to what we cached (the source batch is immutable per id and both hide/
2384
+ // isolate and override promotion are folded into the epoch). Return the
2385
+ // cached clone WITHOUT the O(n) sort + FNV hash below. A rebuilt/evicted
2386
+ // source batch gets a new id → new sourceBatchKey → cache miss here.
2387
+ if (visibilityEpoch !== undefined &&
2388
+ this.partialBatchCacheVersions.get(sourceBatchKey) === visibilityEpoch) {
2389
+ const key = this.partialBatchCacheKeys.get(sourceBatchKey);
2390
+ if (key !== undefined) {
2391
+ const cached = this.partialBatchCache.get(key);
2392
+ if (cached)
2393
+ return cached;
2394
+ }
2395
+ }
2328
2396
  // Create cache key from colorKey + deterministic hash of all visible IDs
2329
2397
  // Using a proper hash over all IDs to avoid collisions when middle IDs differ
2330
2398
  const sortedIds = Array.from(visibleIds).sort((a, b) => a - b);
@@ -2341,8 +2409,13 @@ export class Scene {
2341
2409
  const currentCacheKey = this.partialBatchCacheKeys.get(sourceBatchKey);
2342
2410
  if (currentCacheKey === cacheKey) {
2343
2411
  const cached = this.partialBatchCache.get(cacheKey);
2344
- if (cached)
2412
+ if (cached) {
2413
+ // Record the epoch so subsequent frames take the sort-free fast path.
2414
+ if (visibilityEpoch !== undefined) {
2415
+ this.partialBatchCacheVersions.set(sourceBatchKey, visibilityEpoch);
2416
+ }
2345
2417
  return cached;
2418
+ }
2346
2419
  }
2347
2420
  // Invalidate old cache for this colorKey if visibility changed
2348
2421
  if (currentCacheKey && currentCacheKey !== cacheKey) {
@@ -2381,6 +2454,9 @@ export class Scene {
2381
2454
  // Cache it
2382
2455
  this.partialBatchCache.set(cacheKey, partialBatch);
2383
2456
  this.partialBatchCacheKeys.set(sourceBatchKey, cacheKey);
2457
+ if (visibilityEpoch !== undefined) {
2458
+ this.partialBatchCacheVersions.set(sourceBatchKey, visibilityEpoch);
2459
+ }
2384
2460
  return partialBatch;
2385
2461
  }
2386
2462
  // ─── Color overlay system ────────────────────────────────────────────
@@ -2399,6 +2475,9 @@ export class Scene {
2399
2475
  setColorOverrides(overrides, device, pipeline) {
2400
2476
  // Destroy previous overlay batches
2401
2477
  this.destroyOverrideBatches();
2478
+ // The override set is changing — invalidate the partial-batch cache epoch so
2479
+ // the render loop rebuilds any promotion-split sub-batches (see render loop).
2480
+ this.colorOverrideGeneration++;
2402
2481
  if (this.geometryReleased) {
2403
2482
  console.warn('[Scene] setColorOverrides called after geometry data was released — skipping.');
2404
2483
  this.colorOverrides = null;
@@ -2452,9 +2531,16 @@ export class Scene {
2452
2531
  */
2453
2532
  clearColorOverrides() {
2454
2533
  this.destroyOverrideBatches();
2534
+ this.colorOverrideGeneration++;
2455
2535
  this.colorOverrides = null;
2456
2536
  this.setInstancedColorOverrides(null);
2457
2537
  }
2538
+ /** Monotonic counter that changes whenever the colour-override set changes.
2539
+ * The render loop folds it into the partial sub-batch cache epoch so the
2540
+ * per-frame fast path stays correct across override changes. */
2541
+ getColorOverrideGeneration() {
2542
+ return this.colorOverrideGeneration;
2543
+ }
2458
2544
  /** Get overlay batches for rendering */
2459
2545
  getOverrideBatches() {
2460
2546
  return this.overrideBatches;
@@ -2891,19 +2977,20 @@ export class Scene {
2891
2977
  const device = this.instancedDevice;
2892
2978
  if (!device || this.instancedTemplates.length === 0)
2893
2979
  return;
2894
- // Called every render frame. The viewer passes stable Set references that only
2895
- // change when visibility changes, so a reference-equality guard skips the O(N)
2896
- // set rebuild + allocation during orbit (the common, unchanged case). The dirty
2897
- // flag forces a recompute after a new shard adds occurrences mid-stream, so an
2980
+ // Called every render frame. Change detection is by CONTENT (the tracker
2981
+ // snapshot-compares), matching the RenderOptions.hiddenIds contract: an
2982
+ // in-place mutation of the caller's Set is seen, a fresh identical Set is
2983
+ // not treated as a change, and the O(occurrences) rebuild below still only
2984
+ // runs on a real visibility change (orbit stays cheap). The dirty flag
2985
+ // forces a recompute after a new shard adds occurrences mid-stream, so an
2898
2986
  // active isolate/hide also applies to geometry that streams in afterwards.
2987
+ const visibilityVersion = this.instancedVisibilityEpochs.update(hiddenIds, isolatedIds);
2899
2988
  if (!this.instancedVisibilityDirty &&
2900
- hiddenIds === this.lastInstancedHiddenIds &&
2901
- isolatedIds === this.lastInstancedIsolatedIds) {
2989
+ visibilityVersion === this.lastInstancedVisibilityVersion) {
2902
2990
  return;
2903
2991
  }
2904
2992
  this.instancedVisibilityDirty = false;
2905
- this.lastInstancedHiddenIds = hiddenIds ?? null;
2906
- this.lastInstancedIsolatedIds = isolatedIds ?? null;
2993
+ this.lastInstancedVisibilityVersion = visibilityVersion;
2907
2994
  const isHidden = (eid) => (hiddenIds != null && hiddenIds.has(eid)) ||
2908
2995
  (isolatedIds != null && !isolatedIds.has(eid));
2909
2996
  // Recompute the effective hidden set over all instanced occurrences and diff vs
@@ -3121,13 +3208,13 @@ export class Scene {
3121
3208
  this.instancedHidden.clear();
3122
3209
  this.instancedOverridden.clear();
3123
3210
  this.instancedHasTransparent = false;
3124
- this.lastInstancedHiddenIds = null;
3125
- this.lastInstancedIsolatedIds = null;
3211
+ // Force the next setInstancedVisibility to recompute against fresh state.
3212
+ this.lastInstancedVisibilityVersion = -1;
3126
3213
  this.instancedVisibilityDirty = false;
3127
3214
  this.instancedDevice = undefined;
3128
- // Clear partial batch cache
3129
- for (const batch of this.partialBatchCache.values())
3130
- destroyGpuResources(batch);
3215
+ // Clear partial batch cache (destroys buffers + drops all cache maps)
3216
+ this.dropAllPartialCaches();
3217
+ this.colorOverrideGeneration++;
3131
3218
  // Destroy streaming fragments (already included in batchedMeshes, but tracked separately)
3132
3219
  this.streamingFragments = [];
3133
3220
  this.destroyOverrideBatches();
@@ -3147,8 +3234,6 @@ export class Scene {
3147
3234
  this.dirtyBuckets.clear();
3148
3235
  this.cachedMaxBufferSize = 0;
3149
3236
  this.pendingBatchKeys.clear();
3150
- this.partialBatchCache.clear();
3151
- this.partialBatchCacheKeys.clear();
3152
3237
  this.meshQueue = [];
3153
3238
  this.meshQueueReadIndex = 0;
3154
3239
  this.geometryReleased = false;