@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/index.js CHANGED
@@ -59,6 +59,7 @@ import { PickingManager } from './picking-manager.js';
59
59
  import { RaycastEngine } from './raycast-engine.js';
60
60
  import { PostProcessor } from './post-processor.js';
61
61
  import { InteractionEffectsGovernor } from './interaction-effects-governor.js';
62
+ import { VisibilityEpochTracker } from './visibility-epoch.js';
62
63
  import { resolveContributionThresholdPx, projectedAabbRadiusPx, projectedInstancedRadiusPx } from './contribution-cull.js';
63
64
  import { EdlPass } from './edl-pass.js';
64
65
  import { SkyPass } from './sky-pass.js';
@@ -125,6 +126,15 @@ export class Renderer {
125
126
  /** Set true at the end of `init()`; gates `whenReady()`. */
126
127
  ready = false;
127
128
  readyWaiters = [];
129
+ /**
130
+ * Set once the GPU device is lost for a non-intentional reason (driver
131
+ * reset / VRAM exhaustion — see `WebGPUDevice`). Every GPU resource is then
132
+ * dead, so `render()` becomes a no-op (it would only spew validation errors)
133
+ * until the host re-initialises the renderer. Consumers learn of this via
134
+ * `onDeviceLost` and typically respond by reloading the model.
135
+ */
136
+ deviceLost = false;
137
+ deviceLostListeners = new Set();
128
138
  deviationPipeline = null;
129
139
  /**
130
140
  * Cache of which mesh-set the BVH was built from. We rebuild on
@@ -145,8 +155,11 @@ export class Renderer {
145
155
  // Composition: delegate to extracted managers
146
156
  pickingManager;
147
157
  raycastEngine;
148
- // Error rate limiting (log at most once per second)
149
- lastRenderErrorTime = 0;
158
+ // Error rate limiting (log at most once per second). -Infinity, not 0:
159
+ // performance.now() is below 1000 during the first second of page life, so
160
+ // a 0 start would silently suppress the FIRST render/device-loss error —
161
+ // exactly the evidence worth keeping.
162
+ lastRenderErrorTime = -Infinity;
150
163
  RENDER_ERROR_THROTTLE_MS = 1000;
151
164
  // Diagnostic counters for mobile debugging
152
165
  _renderCallCount = 0;
@@ -158,6 +171,35 @@ export class Renderer {
158
171
  // Dirty flag: set by requestRender(), consumed by the animation loop.
159
172
  // Centralises all render scheduling — callers never call render() directly.
160
173
  _renderRequested = false;
174
+ // ─── Visibility-change bookkeeping (per-frame perf + leak fixes) ─────────
175
+ // Hide/isolate changes are detected by CONTENT (snapshot compare in the
176
+ // tracker), so callers may either mutate the same Set in place or pass a
177
+ // fresh Set per frame — see the RenderOptions.hiddenIds contract.
178
+ // `_visibilityVersion` drives the per-batch visibility cache;
179
+ // `_partialBatchEpoch` additionally folds colour-override changes so the
180
+ // partial sub-batch cache fast path stays correct.
181
+ _visibilityEpochs = new VisibilityEpochTracker();
182
+ _visibilityVersion = 0;
183
+ _partialBatchEpoch = 0;
184
+ _lastColorOverrideGen = -1;
185
+ _lastHadVisibilityFiltering = false;
186
+ // Cached per-batch visibility, valid only while `_batchVisibilityEpoch`
187
+ // matches `_visibilityVersion`. Avoids the O(total element count) recompute
188
+ // (+ per-batch visible-id Set allocation) every frame while hide/isolate
189
+ // holds. Keyed by batch object (immutable expressIds per instance); a
190
+ // rebuilt batch is a new object → recomputed lazily. WeakMap, not Map:
191
+ // residency eviction/restore churn while ONE filter epoch holds (e.g. a
192
+ // schedule animation) would otherwise pin every dead batch object until
193
+ // the next visibility change.
194
+ _batchVisibilityEpoch = -1;
195
+ _batchVisibilityCache = new WeakMap();
196
+ // Selection snapshot from the previous frame — a change triggers disposal of
197
+ // now-unselected hydrated meshes (leak + double-draw fix). The model index
198
+ // is part of the snapshot: federated models can share express ids, so a
199
+ // same-id selection in ANOTHER model is still a change that must free the
200
+ // old model's hydrated mesh.
201
+ _prevHydratedSelection = new Set();
202
+ _prevHydratedSelectionModelIndex = undefined;
161
203
  // One-shot log guard — prints Y-up clip bounds on first section-enable so
162
204
  // users can confirm the slider is operating on the intended range.
163
205
  _loggedSectionBounds = false;
@@ -186,6 +228,13 @@ export class Renderer {
186
228
  * Initialize renderer
187
229
  */
188
230
  async init() {
231
+ // Clear the lost flag so a re-init (destroy()+init() on the same instance)
232
+ // resumes rendering instead of staying a permanent no-op from an earlier loss.
233
+ this.deviceLost = false;
234
+ // Subscribe before the device exists so a loss during the first frames
235
+ // is never missed — the handler is only invoked when `device.lost`
236
+ // actually resolves (a real fault), long after init in practice.
237
+ this.device.onDeviceLost((info) => this.handleDeviceLost(info));
189
238
  await this.device.init(this.canvas);
190
239
  // Get canvas dimensions (use pixel dimensions if set, otherwise use CSS dimensions)
191
240
  // and clamp to the GPU's max 2D texture dimension so the initial pipeline allocations
@@ -276,6 +325,39 @@ export class Renderer {
276
325
  for (const w of waiters)
277
326
  w();
278
327
  }
328
+ /**
329
+ * Subscribe to non-intentional GPU device loss (driver reset / VRAM
330
+ * exhaustion — NOT an intentional `destroy()`). Fired at most once per
331
+ * device. After it fires, `render()` is a no-op until the renderer is
332
+ * re-initialised, so the typical response is to dispose this renderer and
333
+ * reload the model. Returns an unsubscribe function.
334
+ *
335
+ * Camera and model state live on the CPU (JS) and survive device loss, so a
336
+ * reload restores the model at its current orientation — the loss is a GPU
337
+ * event, not a data loss.
338
+ */
339
+ onDeviceLost(listener) {
340
+ this.deviceLostListeners.add(listener);
341
+ return () => this.deviceLostListeners.delete(listener);
342
+ }
343
+ /** True once the GPU device has been lost for a non-intentional reason. */
344
+ isDeviceLost() {
345
+ return this.deviceLost;
346
+ }
347
+ handleDeviceLost(info) {
348
+ if (this.deviceLost)
349
+ return;
350
+ this.deviceLost = true;
351
+ console.warn('[Renderer] GPU device lost — halting rendering until re-init:', info.message);
352
+ for (const listener of this.deviceLostListeners) {
353
+ try {
354
+ listener(info);
355
+ }
356
+ catch (e) {
357
+ console.error('[Renderer] onDeviceLost listener threw:', e);
358
+ }
359
+ }
360
+ }
279
361
  /**
280
362
  * Replace all loaded point clouds with `assets`.
281
363
  *
@@ -797,7 +879,10 @@ export class Renderer {
797
879
  usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
798
880
  });
799
881
  device.queue.writeBuffer(indexBuffer, 0, meshData.indices);
800
- // Add to scene with identity transform (positions already in world space)
882
+ // Add to scene with identity transform (positions already in world space).
883
+ // Flagged `hydrated` so it can be freed when its entity leaves the
884
+ // selection — these duplicate geometry already drawn by a batch and would
885
+ // otherwise accumulate + double-draw (see Scene.disposeHydratedMeshesExcept).
801
886
  this.scene.addMesh({
802
887
  expressId: meshData.expressId,
803
888
  modelIndex: meshData.modelIndex, // Preserve modelIndex for multi-model selection
@@ -806,6 +891,70 @@ export class Renderer {
806
891
  indexCount: meshData.indices.length,
807
892
  transform: MathUtils.identity(),
808
893
  color: meshData.color,
894
+ hydrated: true,
895
+ });
896
+ }
897
+ /**
898
+ * On a selection change, free the hydrated (pick/selection-highlight)
899
+ * individual meshes whose entity is no longer selected. These duplicate
900
+ * geometry already drawn by a batch; without this they accumulate in
901
+ * scene.meshes (VRAM grows with selection history) and — for transparent
902
+ * entities — re-draw every frame on top of their still-drawn batch copy
903
+ * (double alpha-blend darkens glass). Currently-selected entities keep their
904
+ * hydrated meshes so the highlight pass doesn't rebuild them each change.
905
+ * No-op when the selection set is unchanged to avoid per-frame buffer churn.
906
+ * Selection identity is the (modelIndex, expressId) PAIR: federated models
907
+ * can share express ids, so re-selecting the same id in a different model
908
+ * must still free the previous model's hydrated mesh (it would otherwise
909
+ * stay resident and keep drawing unhighlighted).
910
+ */
911
+ syncHydratedSelectionMeshes(selected, selectedModelIndex) {
912
+ const prev = this._prevHydratedSelection;
913
+ let changed = selected.size !== prev.size
914
+ || selectedModelIndex !== this._prevHydratedSelectionModelIndex;
915
+ if (!changed) {
916
+ for (const id of selected) {
917
+ if (!prev.has(id)) {
918
+ changed = true;
919
+ break;
920
+ }
921
+ }
922
+ }
923
+ if (!changed)
924
+ return;
925
+ this._prevHydratedSelection = new Set(selected);
926
+ this._prevHydratedSelectionModelIndex = selectedModelIndex;
927
+ this.scene.disposeHydratedMeshesExcept(selected, selectedModelIndex);
928
+ }
929
+ /**
930
+ * Pop the frame's validation error scope, recording any captured validation
931
+ * error into the device diagnostics (getDiagnostics().gpuErrors). The pop
932
+ * itself REJECTS when the GPU device is lost while the scope is pending —
933
+ * often the only evidence of the loss — so the rejection is logged
934
+ * (throttled) and the context invalidated, never swallowed silently. Used
935
+ * by every render() exit path that pushed a scope, so push/pop stay
936
+ * balanced even on skipped or throwing frames.
937
+ */
938
+ drainErrorScope(device) {
939
+ device.popErrorScope().then((error) => {
940
+ if (error) {
941
+ const msg = error.message || String(error);
942
+ console.error('[WebGPU] Validation error in render pass:', msg);
943
+ this.device._lastUncapturedError = `VALIDATION: ${msg}`;
944
+ this.device._uncapturedErrorCount++;
945
+ }
946
+ }).catch((error) => {
947
+ // popErrorScope() rejects (e.g. "Instance dropped in popErrorScope")
948
+ // when the GPU device is lost while the scope is still pending. This
949
+ // escapes the surrounding synchronous try/catch and would otherwise
950
+ // surface as an unhandled rejection. Treat it like any other device
951
+ // loss: invalidate the context so it reconfigures next frame.
952
+ this.device.invalidateContext();
953
+ const now = performance.now();
954
+ if (now - this.lastRenderErrorTime > this.RENDER_ERROR_THROTTLE_MS) {
955
+ this.lastRenderErrorTime = now;
956
+ console.warn('[WebGPU] popErrorScope rejected (device likely lost):', error);
957
+ }
809
958
  });
810
959
  }
811
960
  resolveVisualEnhancement(options) {
@@ -881,6 +1030,12 @@ export class Renderer {
881
1030
  }
882
1031
  render(options = {}) {
883
1032
  this._renderCallCount++;
1033
+ // A lost device leaves every pipeline/buffer dead; rendering would only
1034
+ // emit a stream of validation errors. Stay quiet until re-init.
1035
+ if (this.deviceLost) {
1036
+ this._renderSkipCount++;
1037
+ return;
1038
+ }
884
1039
  if (!this.device.isInitialized() || !this.pipeline) {
885
1040
  this._renderSkipCount++;
886
1041
  return;
@@ -969,11 +1124,35 @@ export class Renderer {
969
1124
  && visualEnhancement.separationLines.enabled
970
1125
  && visualEnhancement.separationLines.quality !== 'off';
971
1126
  const needsObjectIdPass = contactEnabled || separationEnabled;
972
- let meshes = this.scene.getMeshes();
973
1127
  // Check if visibility filtering is active
974
1128
  const hasHiddenFilter = options.hiddenIds && options.hiddenIds.size > 0;
975
1129
  const hasIsolatedFilter = options.isolatedIds !== null && options.isolatedIds !== undefined;
976
1130
  const hasVisibilityFiltering = hasHiddenFilter || hasIsolatedFilter;
1131
+ // ─── Visibility / override epoch bookkeeping ────────────────────────
1132
+ // The tracker compares hide/isolate CONTENT against a snapshot, so both
1133
+ // in-place mutation of the caller's Set and a fresh identical Set per
1134
+ // frame behave correctly (see RenderOptions.hiddenIds). Bumping
1135
+ // `_visibilityVersion` invalidates the per-batch visibility cache; the
1136
+ // partial sub-batch cache additionally depends on colour-override
1137
+ // promotion, so its epoch bumps on either.
1138
+ const newVisibilityVersion = this._visibilityEpochs.update(options.hiddenIds, options.isolatedIds);
1139
+ const visibilityChanged = newVisibilityVersion !== this._visibilityVersion;
1140
+ this._visibilityVersion = newVisibilityVersion;
1141
+ const colorOverrideGen = this.scene.getColorOverrideGeneration();
1142
+ if (visibilityChanged || colorOverrideGen !== this._lastColorOverrideGen) {
1143
+ this._lastColorOverrideGen = colorOverrideGen;
1144
+ this._partialBatchEpoch++;
1145
+ }
1146
+ // When hide/isolate turns fully OFF (back to all-visible), release the
1147
+ // partial sub-batch clones built while filtering. They are excluded from
1148
+ // the GPU residency budget and are otherwise only freed on clear()/
1149
+ // finalize/evict — never here — so ~model-sized clone VRAM would stay
1150
+ // pinned until the next model reload. Any override-promotion sub-batches
1151
+ // dropped alongside are rebuilt on demand next frame (cache miss).
1152
+ if (this._lastHadVisibilityFiltering && !hasVisibilityFiltering) {
1153
+ this.scene.dropAllPartialCaches();
1154
+ }
1155
+ this._lastHadVisibilityFiltering = hasVisibilityFiltering;
977
1156
  // Build the selected-id set once per frame so the X-Ray override paths
978
1157
  // can keep highlighted entities at full alpha without per-site checks.
979
1158
  const selectedId = options.selectedId;
@@ -989,6 +1168,13 @@ export class Renderer {
989
1168
  }
990
1169
  }
991
1170
  const hasSelected = selectedExpressIds.size > 0;
1171
+ // Free hydrated (pick/selection) individual meshes whose entity is no
1172
+ // longer selected BEFORE we snapshot the mesh list, so stale glass
1173
+ // doesn't double-draw over its batch copy or accumulate until clear().
1174
+ // Only acts on a selection change (avoids per-frame buffer churn) and
1175
+ // never touches authored (non-hydrated) or batch geometry.
1176
+ this.syncHydratedSelectionMeshes(selectedExpressIds, selectedModelIndex);
1177
+ let meshes = this.scene.getMeshes();
992
1178
  // Keep the GPU-instanced occurrences' per-instance selected flag in sync.
993
1179
  // The Scene diff makes this a no-op (no writeBuffer) when the set is
994
1180
  // unchanged, so calling it every frame is cheap; it no-ops entirely when
@@ -1103,14 +1289,26 @@ export class Renderer {
1103
1289
  this.pipeline.resize(this.canvas.width, this.canvas.height);
1104
1290
  }
1105
1291
  // Push a validation error scope to capture the EXACT error (for mobile debugging)
1106
- // Only do this for the first few renders to avoid performance overhead
1292
+ // Only do this for the first few renders to avoid performance overhead.
1293
+ // Tracked with a flag (not just captureGpuError) so EVERY exit path below
1294
+ // pops it exactly once — an unpopped scope silently swallows all later
1295
+ // validation errors and blinds getDiagnostics().gpuErrors.
1107
1296
  const captureGpuError = this._renderCallCount <= 5;
1297
+ let errorScopePushed = false;
1108
1298
  if (captureGpuError) {
1109
1299
  device.pushErrorScope('validation');
1300
+ errorScopePushed = true;
1110
1301
  }
1111
1302
  // Get current texture safely - may return null if context needs reconfiguration
1112
1303
  const currentTexture = this.device.getCurrentTexture();
1113
1304
  if (!currentTexture) {
1305
+ // Balance the pushed scope before bailing so it doesn't leak into
1306
+ // the next frame; drainErrorScope logs a rejection (device loss)
1307
+ // instead of swallowing the evidence.
1308
+ if (errorScopePushed) {
1309
+ errorScopePushed = false;
1310
+ this.drainErrorScope(device);
1311
+ }
1114
1312
  return; // Skip this frame, context will be reconfigured next frame
1115
1313
  }
1116
1314
  try {
@@ -1538,27 +1736,41 @@ export class Renderer {
1538
1736
  viewportHeightPx: this.canvas.height,
1539
1737
  };
1540
1738
  }
1541
- // Pre-compute visibility for each batch (only when filtering is active)
1542
- // A batch is visible if ANY of its elements are visible
1543
- // A batch is fully visible if ALL of its elements are visible
1544
- const batchVisibility = new Map();
1545
- if (hasVisibilityFiltering) {
1546
- for (const batch of allBatchedMeshes) {
1547
- let visibleCount = 0;
1548
- const total = batch.expressIds.length;
1549
- for (const expressId of batch.expressIds) {
1550
- const isHidden = options.hiddenIds?.has(expressId) ?? false;
1551
- const isIsolated = !hasIsolatedFilter || options.isolatedIds.has(expressId);
1552
- if (!isHidden && isIsolated) {
1553
- visibleCount++;
1554
- }
1555
- }
1556
- batchVisibility.set(batch, {
1557
- visible: visibleCount > 0,
1558
- fullyVisible: visibleCount === total,
1559
- });
1560
- }
1739
+ // Per-batch visibility (only meaningful while filtering is active).
1740
+ // A batch is visible if ANY of its elements are visible, fully
1741
+ // visible if ALL are. Cached across frames keyed by the visibility
1742
+ // version so the O(total element count) scan + per-batch visible-id
1743
+ // Set allocation happens once per visibility change, not per frame.
1744
+ // The cache map is keyed by batch object (immutable expressIds per
1745
+ // instance); a rebuilt batch is a new object → recomputed lazily.
1746
+ if (this._batchVisibilityEpoch !== this._visibilityVersion) {
1747
+ this._batchVisibilityCache = new WeakMap();
1748
+ this._batchVisibilityEpoch = this._visibilityVersion;
1561
1749
  }
1750
+ const batchVisibilityCache = this._batchVisibilityCache;
1751
+ const getBatchVisibility = (batch) => {
1752
+ let vis = batchVisibilityCache.get(batch);
1753
+ if (vis)
1754
+ return vis;
1755
+ const total = batch.expressIds.length;
1756
+ // Build the visible-id set in one pass; drop it for fully-visible
1757
+ // batches (they draw from their own buffers, no subset needed).
1758
+ const visibleIds = new Set();
1759
+ for (const expressId of batch.expressIds) {
1760
+ const isHidden = options.hiddenIds?.has(expressId) ?? false;
1761
+ const isIsolated = !hasIsolatedFilter || options.isolatedIds.has(expressId);
1762
+ if (!isHidden && isIsolated)
1763
+ visibleIds.add(expressId);
1764
+ }
1765
+ const fullyVisible = visibleIds.size === total;
1766
+ vis = {
1767
+ visible: visibleIds.size > 0,
1768
+ fullyVisible,
1769
+ visibleIds: fullyVisible ? undefined : visibleIds,
1770
+ };
1771
+ batchVisibilityCache.set(batch, vis);
1772
+ return vis;
1773
+ };
1562
1774
  // Separate batches into opaque and transparent, filtering by visibility
1563
1775
  // IMPORTANT: Only render FULLY visible batches - partially visible batches
1564
1776
  // need individual mesh rendering to show only the visible elements
@@ -1640,19 +1852,15 @@ export class Renderer {
1640
1852
  const nativelyTransparent = alpha < 0.99;
1641
1853
  // Check visibility
1642
1854
  if (hasVisibilityFiltering) {
1643
- const vis = batchVisibility.get(batch);
1644
- if (!vis || !vis.visible)
1855
+ const vis = getBatchVisibility(batch);
1856
+ if (!vis.visible)
1645
1857
  continue; // Skip completely hidden batches
1646
1858
  // Handle partially visible batches - create sub-batches instead of individual meshes
1647
1859
  if (!vis.fullyVisible) {
1648
- const visibleIds = new Set();
1649
- for (const expressId of batch.expressIds) {
1650
- const isHidden = options.hiddenIds?.has(expressId) ?? false;
1651
- const isIsolated = !hasIsolatedFilter || options.isolatedIds.has(expressId);
1652
- if (!isHidden && isIsolated)
1653
- visibleIds.add(expressId);
1654
- }
1655
- if (visibleIds.size > 0) {
1860
+ // The visible subset was computed once for this
1861
+ // visibility epoch (cached) reuse it, don't rebuild.
1862
+ const visibleIds = vis.visibleIds;
1863
+ if (visibleIds && visibleIds.size > 0) {
1656
1864
  pushVisibleAsPartial(batch, visibleIds, nativelyTransparent);
1657
1865
  }
1658
1866
  // A COLD parent has no CPU meshData, so the partial
@@ -1949,7 +2157,7 @@ export class Renderer {
1949
2157
  if (partiallyVisibleBatches.length > 0) {
1950
2158
  for (const { sourceBatchKey, colorKey, visibleIds, color } of partiallyVisibleBatches) {
1951
2159
  // Get or create a cached sub-batch for this visibility state
1952
- const subBatch = this.scene.getOrCreatePartialBatch(sourceBatchKey, colorKey, visibleIds, device, this.pipeline);
2160
+ const subBatch = this.scene.getOrCreatePartialBatch(sourceBatchKey, colorKey, visibleIds, device, this.pipeline, this._partialBatchEpoch);
1953
2161
  if (subBatch) {
1954
2162
  // Use opaque or transparent pipeline based on resolved alpha
1955
2163
  // (not the parent batch's color[3] — that ignores transparencyOverrides).
@@ -2404,30 +2612,20 @@ export class Renderer {
2404
2612
  // buffers is deferred past in-flight work by WebGPU.
2405
2613
  this.scene.enforceGpuBudget();
2406
2614
  // Pop validation error scope and capture the exact error
2407
- if (captureGpuError) {
2408
- device.popErrorScope().then((error) => {
2409
- if (error) {
2410
- const msg = error.message || String(error);
2411
- console.error('[WebGPU] Validation error in render pass:', msg);
2412
- this.device._lastUncapturedError = `VALIDATION: ${msg}`;
2413
- this.device._uncapturedErrorCount++;
2414
- }
2415
- }).catch((error) => {
2416
- // popErrorScope() rejects (e.g. "Instance dropped in popErrorScope")
2417
- // when the GPU device is lost while the scope is still pending. This
2418
- // escapes the surrounding synchronous try/catch and would otherwise
2419
- // surface as an unhandled rejection. Treat it like any other device
2420
- // loss: invalidate the context so it reconfigures next frame.
2421
- this.device.invalidateContext();
2422
- const now = performance.now();
2423
- if (now - this.lastRenderErrorTime > this.RENDER_ERROR_THROTTLE_MS) {
2424
- this.lastRenderErrorTime = now;
2425
- console.warn('[WebGPU] popErrorScope rejected (device likely lost):', error);
2426
- }
2427
- });
2615
+ if (errorScopePushed) {
2616
+ errorScopePushed = false;
2617
+ this.drainErrorScope(device);
2428
2618
  }
2429
2619
  }
2430
2620
  catch (error) {
2621
+ // Balance the validation scope if we threw before popping it above —
2622
+ // an unpopped scope would capture every later frame's errors silently.
2623
+ // drainErrorScope logs a pop rejection (device loss) rather than
2624
+ // swallowing it.
2625
+ if (errorScopePushed) {
2626
+ errorScopePushed = false;
2627
+ this.drainErrorScope(device);
2628
+ }
2431
2629
  this._renderErrorCount++;
2432
2630
  this._lastRenderError = error instanceof Error ? error.message : String(error);
2433
2631
  // Handle WebGPU errors (e.g., device lost, invalid state)
@@ -2943,6 +3141,13 @@ export class Renderer {
2943
3141
  this.deviationBvhFingerprint = null;
2944
3142
  // Snap detector geometry cache
2945
3143
  this.raycastEngine.clearCaches();
3144
+ // Finally, release the GPU device itself. Every buffer/pipeline/texture
3145
+ // above was created from it and has already been destroyed, so nothing
3146
+ // will touch the device after this. Without it, an app that spins up a
3147
+ // renderer per model keeps N live devices (and their VRAM) alive. The
3148
+ // lost-handler special-cases reason 'destroyed' so this is not reported
3149
+ // as a fault. render() early-returns while the device is uninitialised.
3150
+ this.device.destroy();
2946
3151
  }
2947
3152
  /**
2948
3153
  * Get the canvas element