@ifc-lite/renderer 1.35.2 → 1.36.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.
Files changed (46) hide show
  1. package/dist/chunk-grid.d.ts +60 -0
  2. package/dist/chunk-grid.d.ts.map +1 -0
  3. package/dist/chunk-grid.js +47 -0
  4. package/dist/chunk-grid.js.map +1 -0
  5. package/dist/contribution-cull.d.ts +75 -0
  6. package/dist/contribution-cull.d.ts.map +1 -0
  7. package/dist/contribution-cull.js +63 -0
  8. package/dist/contribution-cull.js.map +1 -0
  9. package/dist/device.d.ts.map +1 -1
  10. package/dist/device.js +24 -1
  11. package/dist/device.js.map +1 -1
  12. package/dist/index.d.ts +28 -0
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +190 -10
  15. package/dist/index.js.map +1 -1
  16. package/dist/lod-simplify.d.ts +36 -0
  17. package/dist/lod-simplify.d.ts.map +1 -0
  18. package/dist/lod-simplify.js +101 -0
  19. package/dist/lod-simplify.js.map +1 -0
  20. package/dist/pipeline.d.ts +13 -0
  21. package/dist/pipeline.d.ts.map +1 -1
  22. package/dist/pipeline.js +63 -2
  23. package/dist/pipeline.js.map +1 -1
  24. package/dist/quantize.d.ts +49 -0
  25. package/dist/quantize.d.ts.map +1 -0
  26. package/dist/quantize.js +137 -0
  27. package/dist/quantize.js.map +1 -0
  28. package/dist/render-stats.d.ts +85 -0
  29. package/dist/render-stats.d.ts.map +1 -0
  30. package/dist/render-stats.js +31 -0
  31. package/dist/render-stats.js.map +1 -0
  32. package/dist/residency.d.ts +52 -0
  33. package/dist/residency.d.ts.map +1 -0
  34. package/dist/residency.js +28 -0
  35. package/dist/residency.js.map +1 -0
  36. package/dist/scene.d.ts +165 -0
  37. package/dist/scene.d.ts.map +1 -1
  38. package/dist/scene.js +725 -34
  39. package/dist/scene.js.map +1 -1
  40. package/dist/shaders/main.wgsl.d.ts +1 -1
  41. package/dist/shaders/main.wgsl.d.ts.map +1 -1
  42. package/dist/shaders/main.wgsl.js +53 -0
  43. package/dist/shaders/main.wgsl.js.map +1 -1
  44. package/dist/types.d.ts +47 -0
  45. package/dist/types.d.ts.map +1 -1
  46. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -32,6 +32,12 @@ export * from './types.js';
32
32
  export { resolveEnvironment, deriveSkyGradient, packEnvironmentUniforms, ENVIRONMENT_UNIFORM_SIZE, } from './environment.js';
33
33
  // Extracted manager classes
34
34
  export { PickingManager } from './picking-manager.js';
35
+ export { resolveContributionThresholdPx, projectedAabbRadiusPx } from './contribution-cull.js';
36
+ export { chunkCellKey, bucketBaseKeyFor, DEFAULT_CHUNK_CELL_SIZE } from './chunk-grid.js';
37
+ export { selectEvictions, MIN_EVICTION_AGE_FRAMES } from './residency.js';
38
+ export { simplifyIndicesByClustering, lodCellSizeForBounds, LOD_MIN_TRIANGLES, LOD_CELL_FRACTION } from './lod-simplify.js';
39
+ export { quantizeInterleaved, octEncode, octDecode, QUANT_STEP, MAX_QUANT_EXTENT, QUANT_BYTES_PER_VERTEX } from './quantize.js';
40
+ export { sumResidentGpuBytes } from './render-stats.js';
35
41
  export { RaycastEngine } from './raycast-engine.js';
36
42
  export { PointPicker, decodePickSample } from './point-picker.js';
37
43
  // Point cloud rendering (Phase 0: IFCx inline; Phase 1+: streaming LAS/LAZ)
@@ -53,6 +59,7 @@ import { PickingManager } from './picking-manager.js';
53
59
  import { RaycastEngine } from './raycast-engine.js';
54
60
  import { PostProcessor } from './post-processor.js';
55
61
  import { InteractionEffectsGovernor } from './interaction-effects-governor.js';
62
+ import { resolveContributionThresholdPx, projectedAabbRadiusPx } from './contribution-cull.js';
56
63
  import { EdlPass } from './edl-pass.js';
57
64
  import { SkyPass } from './sky-pass.js';
58
65
  import { skyShaderSource } from './shaders/sky.wgsl.js';
@@ -144,6 +151,8 @@ export class Renderer {
144
151
  // Diagnostic counters for mobile debugging
145
152
  _renderCallCount = 0;
146
153
  _renderSkipCount = 0;
154
+ /** Snapshot of the last completed render() — see getFrameStats(). */
155
+ _lastFrameStats = null;
147
156
  _renderErrorCount = 0;
148
157
  _lastRenderError = '';
149
158
  // Dirty flag: set by requestRender(), consumed by the animation loop.
@@ -155,7 +164,9 @@ export class Renderer {
155
164
  // Pooled per-frame buffers to avoid GC pressure from per-batch Float32Array allocations
156
165
  // A single 224-byte uniform buffer (56 floats) is reused for all batches/meshes within a frame
157
166
  // (48 floats viewProj…flags + 8 floats clipBoxMin/clipBoxMax)
158
- uniformScratch = new Float32Array(56);
167
+ // 60 floats = the WGSL Uniforms struct incl. quantParams (see
168
+ // pipeline.getUniformBufferSize).
169
+ uniformScratch = new Float32Array(60);
159
170
  uniformScratchU32 = new Uint32Array(this.uniformScratch.buffer, 176, 4);
160
171
  // What the last render() actually clipped, so the GPU picker can mirror it and
161
172
  // section/crop-clipped geometry stays unpickable, not just invisible. Updated
@@ -739,13 +750,24 @@ export class Renderer {
739
750
  const fr = Math.fround;
740
751
  const sox = so ? fr(so[0]) : null, soy = so ? fr(so[1]) : 0, soz = so ? fr(so[2]) : 0;
741
752
  const dx = so ? (ox - so[0]) : ox, dy = so ? (oy - so[1]) : oy, dz = so ? (oz - so[2]) : oz;
753
+ // Quantized batches (issue #1682 phase 6) render lattice-snapped
754
+ // positions: the shader's quantMin + q*step is exactly the lattice
755
+ // node nearest the batch's stored f32 rel coordinate. Reproduce it by
756
+ // snapping the SAME rel coordinate here (round(s*1024)/1024 in f64
757
+ // yields the identical exact-f32 lattice value — see quantize.ts), so
758
+ // the highlight/picker mesh stays BIT-coincident with its quantized
759
+ // source surface, exactly as the two-step fold above achieves for the
760
+ // f32 path. Meshes whose batch fell back to f32 must not snap.
761
+ const snap = this.scene.isMeshQuantized(meshData)
762
+ ? (v) => Math.round(v * 1024) / 1024
763
+ : (v) => v;
742
764
  const p = meshData.positions;
743
765
  for (let i = 0; i < vertexCount; i++) {
744
766
  const base = i * 7;
745
767
  const posBase = i * 3;
746
- interleaved[base] = so ? fr(sox + fr(p[posBase] + dx)) : (p[posBase] + dx);
747
- interleaved[base + 1] = so ? fr(soy + fr(p[posBase + 1] + dy)) : (p[posBase + 1] + dy);
748
- interleaved[base + 2] = so ? fr(soz + fr(p[posBase + 2] + dz)) : (p[posBase + 2] + dz);
768
+ interleaved[base] = so ? fr(sox + snap(fr(p[posBase] + dx))) : snap(p[posBase] + dx);
769
+ interleaved[base + 1] = so ? fr(soy + snap(fr(p[posBase + 1] + dy))) : snap(p[posBase + 1] + dy);
770
+ interleaved[base + 2] = so ? fr(soz + snap(fr(p[posBase + 2] + dz))) : snap(p[posBase + 2] + dz);
749
771
  const hasNormals = meshData.normals.length > 0;
750
772
  interleaved[base + 3] = hasNormals ? meshData.normals[posBase] : 0;
751
773
  interleaved[base + 4] = hasNormals ? meshData.normals[posBase + 1] : 0;
@@ -834,6 +856,29 @@ export class Renderer {
834
856
  bounds: b ? `${b.min.x.toFixed(0)}..${b.max.x.toFixed(0)} ${b.min.y.toFixed(0)}..${b.max.y.toFixed(0)} ${b.min.z.toFixed(0)}..${b.max.z.toFixed(0)}` : 'none',
835
857
  };
836
858
  }
859
+ /**
860
+ * Statistics of the last COMPLETED render() call (draw calls issued,
861
+ * batches drawn / frustum-culled / contribution-culled), or null before
862
+ * the first frame. Pair with `getScene().getResidentGpuBytes()` for the
863
+ * load-complete telemetry snapshot (issue #1682 observability).
864
+ */
865
+ getFrameStats() {
866
+ return this._lastFrameStats;
867
+ }
868
+ /**
869
+ * Probe the quantized pipeline variants and, when all exist, enable
870
+ * 12-byte quantized batch vertices (issue #1682 phase 6). Returns
871
+ * whether quantization is active — on failure (e.g. a fragile backend
872
+ * rejecting pipeline creation) batches stay on the f32 path.
873
+ */
874
+ async enableQuantizedBatches() {
875
+ if (!this.pipeline)
876
+ return false;
877
+ const ok = await this.pipeline.ensureQuantizedPipelines();
878
+ if (ok)
879
+ this.scene.setQuantizedBatches(true);
880
+ return ok;
881
+ }
837
882
  render(options = {}) {
838
883
  this._renderCallCount++;
839
884
  if (!this.device.isInitialized() || !this.pipeline) {
@@ -880,6 +925,21 @@ export class Renderer {
880
925
  }
881
926
  const device = this.device.getDevice();
882
927
  const viewProj = this.camera.getViewProjMatrix().m;
928
+ // Frame stats (issue #1682): geometry draw calls + per-frame cull
929
+ // outcomes, snapshotted into _lastFrameStats before queue.submit.
930
+ let frameDrawCalls = 0;
931
+ let frameBatchesDrawn = 0;
932
+ let frameBatchesFrustumCulled = 0;
933
+ let frameBatchesContributionCulled = 0;
934
+ let frameBatchesNotResident = 0;
935
+ let frameBatchesAtLod1 = 0;
936
+ // Residency ages are measured in RENDERED frames (idle never ages out).
937
+ this.scene.beginResidencyFrame();
938
+ // Capture renders restore evicted batches SYNCHRONOUSLY so isolation
939
+ // snapshots are complete in this very frame (see RenderOptions doc).
940
+ if (options.restoreEvictedForCapture && this.pipeline) {
941
+ this.scene.restoreAllEvicted(device, this.pipeline);
942
+ }
883
943
  const visualEnhancement = this.resolveVisualEnhancement(options.visualEnhancement);
884
944
  // Post effects during interaction (orbit/pan/zoom) are governed
885
945
  // adaptively: they stay on while the interactive frame cadence holds
@@ -1446,6 +1506,35 @@ export class Renderer {
1446
1506
  // Frustum culling for batched meshes - skip entire batches outside the camera view
1447
1507
  // This is the primary performance optimization for large models (200K+ meshes)
1448
1508
  const frustum = FrustumUtils.fromViewProjMatrix(viewProj);
1509
+ // Contribution culling (issue #1682): skip batches whose world
1510
+ // AABB projects below a pixel threshold. Disabled unless the
1511
+ // caller opts in via options.contributionCull; the threshold is
1512
+ // raised while interacting (quality matters least mid-gesture).
1513
+ const contribThresholdPx = resolveContributionThresholdPx(options.contributionCull, interacting);
1514
+ // LOD1 selection (issue #1682 phase 5): batches projecting below
1515
+ // this draw their simplified index range. Shares the projection
1516
+ // camera with contribution culling. Precedence is intentional:
1517
+ // a batch below the CULL threshold is skipped entirely, so a
1518
+ // lod threshold at or below the cull threshold never fires.
1519
+ const lodScreenPx = options.lod && options.lod.screenPx > 0 ? options.lod.screenPx : 0;
1520
+ const lodBatches = new Set();
1521
+ let cullCam = null;
1522
+ if (contribThresholdPx > 0 || lodScreenPx > 0) {
1523
+ const eye = this.camera.getPosition();
1524
+ const tgt = this.camera.getTarget();
1525
+ const dx = tgt.x - eye.x, dy = tgt.y - eye.y, dz = tgt.z - eye.z;
1526
+ const len = Math.sqrt(dx * dx + dy * dy + dz * dz);
1527
+ cullCam = {
1528
+ eye,
1529
+ // Degenerate (eye == target) stays zero-length — the
1530
+ // projection helper fails open (never culls) on it.
1531
+ viewDir: len > 0 ? { x: dx / len, y: dy / len, z: dz / len } : { x: 0, y: 0, z: 0 },
1532
+ mode: this.camera.getProjectionMode(),
1533
+ fovYRadians: this.camera.getFOV(),
1534
+ orthoHalfHeight: this.camera.getOrthoSize(),
1535
+ viewportHeightPx: this.canvas.height,
1536
+ };
1537
+ }
1449
1538
  // Pre-compute visibility for each batch (only when filtering is active)
1450
1539
  // A batch is visible if ANY of its elements are visible
1451
1540
  // A batch is fully visible if ALL of its elements are visible
@@ -1524,8 +1613,25 @@ export class Renderer {
1524
1613
  if (batch.bounds) {
1525
1614
  const batchAABB = { min: batch.bounds.min, max: batch.bounds.max };
1526
1615
  if (!FrustumUtils.isAABBVisible(frustum, batchAABB)) {
1616
+ frameBatchesFrustumCulled++;
1527
1617
  continue; // Entire batch is off-screen
1528
1618
  }
1619
+ if (cullCam) {
1620
+ const px = projectedAabbRadiusPx(batch.bounds.min, batch.bounds.max, cullCam);
1621
+ // Contribution cull: the whole batch projects below the
1622
+ // pixel threshold — drawing it could change at most a
1623
+ // (sub-)pixel. Selected entities still highlight: the
1624
+ // selection pass draws per-mesh, independent of batches.
1625
+ if (contribThresholdPx > 0 && px < contribThresholdPx) {
1626
+ frameBatchesContributionCulled++;
1627
+ continue;
1628
+ }
1629
+ // LOD1: small-but-visible batches draw the simplified
1630
+ // index range over the same vertices.
1631
+ if (lodScreenPx > 0 && px < lodScreenPx && batch.lod1IndexBuffer) {
1632
+ lodBatches.add(batch.id);
1633
+ }
1634
+ }
1529
1635
  }
1530
1636
  const alpha = alphaForBatch(batch, batch.color[3]);
1531
1637
  const nativelyTransparent = alpha < 0.99;
@@ -1546,10 +1652,28 @@ export class Renderer {
1546
1652
  if (visibleIds.size > 0) {
1547
1653
  pushVisibleAsPartial(batch, visibleIds, nativelyTransparent);
1548
1654
  }
1655
+ // A COLD parent has no CPU meshData, so the partial
1656
+ // sub-batch above comes back empty — queue the
1657
+ // residency restore or the visible subset would
1658
+ // stay missing under hide/isolate forever.
1659
+ if (batch.gpuResident === false) {
1660
+ this.scene.requestBatchResidency(batch);
1661
+ }
1549
1662
  continue; // Don't add batch to render list
1550
1663
  }
1551
1664
  }
1552
- // Fully visible (or no filtering). Transparent batches with mixed
1665
+ // Fully visible (or no filtering) this batch draws from its OWN
1666
+ // GPU buffers. An evicted batch (residency budget, #1682 phase 3a)
1667
+ // is skipped for a frame while its rebuild is queued; the partial
1668
+ // path above is unaffected (sub-batches own separate buffers built
1669
+ // from CPU meshData).
1670
+ if (batch.gpuResident === false) {
1671
+ this.scene.requestBatchResidency(batch);
1672
+ frameBatchesNotResident++;
1673
+ continue;
1674
+ }
1675
+ this.scene.recordBatchDrawn(batch);
1676
+ // Transparent batches with mixed
1553
1677
  // override membership must be split so non-overridden batchmates
1554
1678
  // stay transparent — see splitVisibleIdsByPromotion / issue #677.
1555
1679
  if (nativelyTransparent) {
@@ -1643,12 +1767,44 @@ export class Renderer {
1643
1767
  tpl[28] = o ? o[0] : 0;
1644
1768
  tpl[29] = o ? o[1] : 0;
1645
1769
  tpl[30] = o ? o[2] : 0;
1770
+ // Quantized dequantization params (issue #1682 phase 6);
1771
+ // zeroed for f32 batches (their pipelines ignore them).
1772
+ const qz = batch.quantized;
1773
+ tpl[56] = qz ? qz.min[0] : 0;
1774
+ tpl[57] = qz ? qz.min[1] : 0;
1775
+ tpl[58] = qz ? qz.min[2] : 0;
1776
+ tpl[59] = qz ? qz.step : 0;
1646
1777
  device.queue.writeBuffer(batch.uniformBuffer, 0, tpl);
1647
- // Single draw call for entire batch!
1778
+ // Single draw call for entire batch! LOD1-selected batches
1779
+ // bind their simplified index range over the same vertices.
1780
+ const useLod1 = batch.lod1IndexBuffer && batch.lod1IndexCount && lodBatches.has(batch.id);
1648
1781
  pass.setBindGroup(0, batch.bindGroup);
1649
1782
  pass.setVertexBuffer(0, batch.vertexBuffer);
1650
- pass.setIndexBuffer(batch.indexBuffer, 'uint32');
1651
- pass.drawIndexed(batch.indexCount);
1783
+ if (useLod1) {
1784
+ pass.setIndexBuffer(batch.lod1IndexBuffer, 'uint32');
1785
+ pass.drawIndexed(batch.lod1IndexCount);
1786
+ frameBatchesAtLod1++;
1787
+ }
1788
+ else {
1789
+ pass.setIndexBuffer(batch.indexBuffer, 'uint32');
1790
+ pass.drawIndexed(batch.indexCount);
1791
+ }
1792
+ frameDrawCalls++;
1793
+ frameBatchesDrawn++;
1794
+ };
1795
+ // Quantized batches (issue #1682 phase 6) draw through the
1796
+ // quantized pipeline variants; scene only quantizes after the
1797
+ // probe (enableQuantizedBatches) verified they exist, so the
1798
+ // base fallback here is type-safety, never taken.
1799
+ const pipeFor = (batch, kind) => {
1800
+ const base = kind === 'opaque'
1801
+ ? this.pipeline.getPipeline()
1802
+ : kind === 'transparent'
1803
+ ? this.pipeline.getTransparentPipeline()
1804
+ : this.pipeline.getOverlayPipeline();
1805
+ if (!batch.quantized)
1806
+ return base;
1807
+ return this.pipeline.getQuantizedPipelineVariant(kind) ?? base;
1652
1808
  };
1653
1809
  // Render opaque batches with the opaque (double-sided) pipeline.
1654
1810
  // Material-layer slices render double-sided like all other IFC
@@ -1661,8 +1817,10 @@ export class Renderer {
1661
1817
  // Double-siding draws every face of the watertight skin ⇒ solid.
1662
1818
  pass.setPipeline(this.pipeline.getPipeline());
1663
1819
  for (const batch of opaqueBatches) {
1820
+ pass.setPipeline(pipeFor(batch, 'opaque'));
1664
1821
  renderBatch(batch);
1665
1822
  }
1823
+ pass.setPipeline(this.pipeline.getPipeline());
1666
1824
  // GPU-instancing pass — repeated geometry collated by the producer
1667
1825
  // into one template + a per-occurrence instance buffer (mat4 +
1668
1826
  // entityId + rgba), drawn with the instanced pipeline as
@@ -1686,6 +1844,7 @@ export class Renderer {
1686
1844
  pass.setVertexBuffer(1, it.instanceBuffer);
1687
1845
  pass.setIndexBuffer(it.indexBuffer, 'uint32');
1688
1846
  pass.drawIndexed(it.indexCount, it.instanceCount);
1847
+ frameDrawCalls++;
1689
1848
  }
1690
1849
  pass.setPipeline(this.pipeline.getPipeline());
1691
1850
  // The TRANSPARENT instanced sub-pass is drawn later, alongside the
@@ -1738,6 +1897,7 @@ export class Renderer {
1738
1897
  pass.setVertexBuffer(0, tm.vertexBuffer);
1739
1898
  pass.setIndexBuffer(tm.indexBuffer, 'uint32');
1740
1899
  pass.drawIndexed(tm.indexCount);
1900
+ frameDrawCalls++;
1741
1901
  }
1742
1902
  // Restore the opaque pipeline for the passes that follow.
1743
1903
  pass.setPipeline(this.pipeline.getPipeline());
@@ -1760,7 +1920,7 @@ export class Renderer {
1760
1920
  // lens/Pset colour override, so the overlay paint pass finds depth.
1761
1921
  const isTransparent = shouldRouteBatchTransparent(alphaForBatch(subBatch, color[3]), subBatch.expressIds, colorOverrides);
1762
1922
  if (isTransparent) {
1763
- pass.setPipeline(this.pipeline.getTransparentPipeline());
1923
+ pass.setPipeline(pipeFor(subBatch, 'transparent'));
1764
1924
  }
1765
1925
  else {
1766
1926
  // Opaque (incl. material-layer slices): double-sided.
@@ -1768,7 +1928,7 @@ export class Renderer {
1768
1928
  // open watertight-skin bands with unreliable winding,
1769
1929
  // so culling punched holes (wall read hollow). See the
1770
1930
  // full-batch path above.
1771
- pass.setPipeline(this.pipeline.getPipeline());
1931
+ pass.setPipeline(pipeFor(subBatch, 'opaque'));
1772
1932
  opaqueSubBatches.push(subBatch);
1773
1933
  }
1774
1934
  // Render the sub-batch as a single draw call
@@ -1794,6 +1954,7 @@ export class Renderer {
1794
1954
  // bit 1 = overlay; bit 5 (32) = emphasize (pop) — see shader.
1795
1955
  tplFlags[0] = options.emphasizeOverrides ? (2 | 32) : 2;
1796
1956
  for (const batch of overrideBatches) {
1957
+ pass.setPipeline(pipeFor(batch, 'overlay'));
1797
1958
  renderBatch(batch);
1798
1959
  }
1799
1960
  tplFlags[0] = 0; // restore for any downstream use of the template
@@ -1872,6 +2033,7 @@ export class Renderer {
1872
2033
  pass.setVertexBuffer(1, it.instanceBuffer);
1873
2034
  pass.setIndexBuffer(it.indexBuffer, 'uint32');
1874
2035
  pass.drawIndexed(it.indexCount, it.instanceCount);
2036
+ frameDrawCalls++;
1875
2037
  }
1876
2038
  pass.setPipeline(this.pipeline.getPipeline());
1877
2039
  }
@@ -1879,6 +2041,7 @@ export class Renderer {
1879
2041
  if (transparentBatches.length > 0) {
1880
2042
  pass.setPipeline(this.pipeline.getTransparentPipeline());
1881
2043
  for (const batch of transparentBatches) {
2044
+ pass.setPipeline(pipeFor(batch, 'transparent'));
1882
2045
  renderBatch(batch);
1883
2046
  }
1884
2047
  }
@@ -1923,6 +2086,7 @@ export class Renderer {
1923
2086
  pass.setVertexBuffer(0, mesh.vertexBuffer);
1924
2087
  pass.setIndexBuffer(mesh.indexBuffer, 'uint32');
1925
2088
  pass.drawIndexed(mesh.indexCount, 1, 0, 0, 0);
2089
+ frameDrawCalls++;
1926
2090
  }
1927
2091
  }
1928
2092
  // Ensure selected meshes have uniform buffers and bind groups
@@ -1983,6 +2147,7 @@ export class Renderer {
1983
2147
  pass.setVertexBuffer(0, mesh.vertexBuffer);
1984
2148
  pass.setIndexBuffer(mesh.indexBuffer, 'uint32');
1985
2149
  pass.drawIndexed(mesh.indexCount, 1, 0, 0, 0);
2150
+ frameDrawCalls++;
1986
2151
  }
1987
2152
  }
1988
2153
  else {
@@ -1998,6 +2163,7 @@ export class Renderer {
1998
2163
  pass.setVertexBuffer(0, mesh.vertexBuffer);
1999
2164
  pass.setIndexBuffer(mesh.indexBuffer, 'uint32');
2000
2165
  pass.drawIndexed(mesh.indexCount, 1, 0, 0, 0);
2166
+ frameDrawCalls++;
2001
2167
  }
2002
2168
  // Render transparent meshes with transparent pipeline (alpha blending)
2003
2169
  if (transparentMeshes.length > 0) {
@@ -2012,6 +2178,7 @@ export class Renderer {
2012
2178
  pass.setVertexBuffer(0, mesh.vertexBuffer);
2013
2179
  pass.setIndexBuffer(mesh.indexBuffer, 'uint32');
2014
2180
  pass.drawIndexed(mesh.indexCount, 1, 0, 0, 0);
2181
+ frameDrawCalls++;
2015
2182
  }
2016
2183
  }
2017
2184
  }
@@ -2184,6 +2351,19 @@ export class Renderer {
2184
2351
  });
2185
2352
  }
2186
2353
  device.queue.submit([encoder.finish()]);
2354
+ this._lastFrameStats = {
2355
+ drawCalls: frameDrawCalls,
2356
+ batchesDrawn: frameBatchesDrawn,
2357
+ batchesFrustumCulled: frameBatchesFrustumCulled,
2358
+ batchesContributionCulled: frameBatchesContributionCulled,
2359
+ batchesNotResident: frameBatchesNotResident,
2360
+ batchesAtLod1: frameBatchesAtLod1,
2361
+ timestamp: performance.now(),
2362
+ };
2363
+ // GPU residency budget (issue #1682 phase 3a): evict least-recently
2364
+ // drawn bucket batches after submit — destruction of just-submitted
2365
+ // buffers is deferred past in-flight work by WebGPU.
2366
+ this.scene.enforceGpuBudget();
2187
2367
  // Pop validation error scope and capture the exact error
2188
2368
  if (captureGpuError) {
2189
2369
  device.popErrorScope().then((error) => {