@ifc-lite/renderer 1.35.2 → 1.37.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 (50) 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 +94 -0
  6. package/dist/contribution-cull.d.ts.map +1 -0
  7. package/dist/contribution-cull.js +111 -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 +233 -14
  15. package/dist/index.js.map +1 -1
  16. package/dist/instanced-render.d.ts +31 -0
  17. package/dist/instanced-render.d.ts.map +1 -1
  18. package/dist/instanced-render.js +45 -0
  19. package/dist/instanced-render.js.map +1 -1
  20. package/dist/lod-simplify.d.ts +36 -0
  21. package/dist/lod-simplify.d.ts.map +1 -0
  22. package/dist/lod-simplify.js +101 -0
  23. package/dist/lod-simplify.js.map +1 -0
  24. package/dist/pipeline.d.ts +13 -0
  25. package/dist/pipeline.d.ts.map +1 -1
  26. package/dist/pipeline.js +63 -2
  27. package/dist/pipeline.js.map +1 -1
  28. package/dist/quantize.d.ts +49 -0
  29. package/dist/quantize.d.ts.map +1 -0
  30. package/dist/quantize.js +137 -0
  31. package/dist/quantize.js.map +1 -0
  32. package/dist/render-stats.d.ts +95 -0
  33. package/dist/render-stats.d.ts.map +1 -0
  34. package/dist/render-stats.js +31 -0
  35. package/dist/render-stats.js.map +1 -0
  36. package/dist/residency.d.ts +52 -0
  37. package/dist/residency.d.ts.map +1 -0
  38. package/dist/residency.js +28 -0
  39. package/dist/residency.js.map +1 -0
  40. package/dist/scene.d.ts +190 -1
  41. package/dist/scene.d.ts.map +1 -1
  42. package/dist/scene.js +799 -42
  43. package/dist/scene.js.map +1 -1
  44. package/dist/shaders/main.wgsl.d.ts +1 -1
  45. package/dist/shaders/main.wgsl.d.ts.map +1 -1
  46. package/dist/shaders/main.wgsl.js +53 -0
  47. package/dist/shaders/main.wgsl.js.map +1 -1
  48. package/dist/types.d.ts +47 -0
  49. package/dist/types.d.ts.map +1 -1
  50. package/package.json +3 -3
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, projectedInstancedRadiusPx } 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,24 @@ 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
+ let frameInstancedDrawn = 0;
937
+ let frameInstancedFrustumCulled = 0;
938
+ let frameInstancedContributionCulled = 0;
939
+ // Residency ages are measured in RENDERED frames (idle never ages out).
940
+ this.scene.beginResidencyFrame();
941
+ // Capture renders restore evicted batches SYNCHRONOUSLY so isolation
942
+ // snapshots are complete in this very frame (see RenderOptions doc).
943
+ if (options.restoreEvictedForCapture && this.pipeline) {
944
+ this.scene.restoreAllEvicted(device, this.pipeline);
945
+ }
883
946
  const visualEnhancement = this.resolveVisualEnhancement(options.visualEnhancement);
884
947
  // Post effects during interaction (orbit/pan/zoom) are governed
885
948
  // adaptively: they stay on while the interactive frame cadence holds
@@ -1446,6 +1509,35 @@ export class Renderer {
1446
1509
  // Frustum culling for batched meshes - skip entire batches outside the camera view
1447
1510
  // This is the primary performance optimization for large models (200K+ meshes)
1448
1511
  const frustum = FrustumUtils.fromViewProjMatrix(viewProj);
1512
+ // Contribution culling (issue #1682): skip batches whose world
1513
+ // AABB projects below a pixel threshold. Disabled unless the
1514
+ // caller opts in via options.contributionCull; the threshold is
1515
+ // raised while interacting (quality matters least mid-gesture).
1516
+ const contribThresholdPx = resolveContributionThresholdPx(options.contributionCull, interacting);
1517
+ // LOD1 selection (issue #1682 phase 5): batches projecting below
1518
+ // this draw their simplified index range. Shares the projection
1519
+ // camera with contribution culling. Precedence is intentional:
1520
+ // a batch below the CULL threshold is skipped entirely, so a
1521
+ // lod threshold at or below the cull threshold never fires.
1522
+ const lodScreenPx = options.lod && options.lod.screenPx > 0 ? options.lod.screenPx : 0;
1523
+ const lodBatches = new Set();
1524
+ let cullCam = null;
1525
+ if (contribThresholdPx > 0 || lodScreenPx > 0) {
1526
+ const eye = this.camera.getPosition();
1527
+ const tgt = this.camera.getTarget();
1528
+ const dx = tgt.x - eye.x, dy = tgt.y - eye.y, dz = tgt.z - eye.z;
1529
+ const len = Math.sqrt(dx * dx + dy * dy + dz * dz);
1530
+ cullCam = {
1531
+ eye,
1532
+ // Degenerate (eye == target) stays zero-length — the
1533
+ // projection helper fails open (never culls) on it.
1534
+ viewDir: len > 0 ? { x: dx / len, y: dy / len, z: dz / len } : { x: 0, y: 0, z: 0 },
1535
+ mode: this.camera.getProjectionMode(),
1536
+ fovYRadians: this.camera.getFOV(),
1537
+ orthoHalfHeight: this.camera.getOrthoSize(),
1538
+ viewportHeightPx: this.canvas.height,
1539
+ };
1540
+ }
1449
1541
  // Pre-compute visibility for each batch (only when filtering is active)
1450
1542
  // A batch is visible if ANY of its elements are visible
1451
1543
  // A batch is fully visible if ALL of its elements are visible
@@ -1524,8 +1616,25 @@ export class Renderer {
1524
1616
  if (batch.bounds) {
1525
1617
  const batchAABB = { min: batch.bounds.min, max: batch.bounds.max };
1526
1618
  if (!FrustumUtils.isAABBVisible(frustum, batchAABB)) {
1619
+ frameBatchesFrustumCulled++;
1527
1620
  continue; // Entire batch is off-screen
1528
1621
  }
1622
+ if (cullCam) {
1623
+ const px = projectedAabbRadiusPx(batch.bounds.min, batch.bounds.max, cullCam);
1624
+ // Contribution cull: the whole batch projects below the
1625
+ // pixel threshold — drawing it could change at most a
1626
+ // (sub-)pixel. Selected entities still highlight: the
1627
+ // selection pass draws per-mesh, independent of batches.
1628
+ if (contribThresholdPx > 0 && px < contribThresholdPx) {
1629
+ frameBatchesContributionCulled++;
1630
+ continue;
1631
+ }
1632
+ // LOD1: small-but-visible batches draw the simplified
1633
+ // index range over the same vertices.
1634
+ if (lodScreenPx > 0 && px < lodScreenPx && batch.lod1IndexBuffer) {
1635
+ lodBatches.add(batch.id);
1636
+ }
1637
+ }
1529
1638
  }
1530
1639
  const alpha = alphaForBatch(batch, batch.color[3]);
1531
1640
  const nativelyTransparent = alpha < 0.99;
@@ -1546,10 +1655,28 @@ export class Renderer {
1546
1655
  if (visibleIds.size > 0) {
1547
1656
  pushVisibleAsPartial(batch, visibleIds, nativelyTransparent);
1548
1657
  }
1658
+ // A COLD parent has no CPU meshData, so the partial
1659
+ // sub-batch above comes back empty — queue the
1660
+ // residency restore or the visible subset would
1661
+ // stay missing under hide/isolate forever.
1662
+ if (batch.gpuResident === false) {
1663
+ this.scene.requestBatchResidency(batch);
1664
+ }
1549
1665
  continue; // Don't add batch to render list
1550
1666
  }
1551
1667
  }
1552
- // Fully visible (or no filtering). Transparent batches with mixed
1668
+ // Fully visible (or no filtering) this batch draws from its OWN
1669
+ // GPU buffers. An evicted batch (residency budget, #1682 phase 3a)
1670
+ // is skipped for a frame while its rebuild is queued; the partial
1671
+ // path above is unaffected (sub-batches own separate buffers built
1672
+ // from CPU meshData).
1673
+ if (batch.gpuResident === false) {
1674
+ this.scene.requestBatchResidency(batch);
1675
+ frameBatchesNotResident++;
1676
+ continue;
1677
+ }
1678
+ this.scene.recordBatchDrawn(batch);
1679
+ // Transparent batches with mixed
1553
1680
  // override membership must be split so non-overridden batchmates
1554
1681
  // stay transparent — see splitVisibleIdsByPromotion / issue #677.
1555
1682
  if (nativelyTransparent) {
@@ -1643,12 +1770,44 @@ export class Renderer {
1643
1770
  tpl[28] = o ? o[0] : 0;
1644
1771
  tpl[29] = o ? o[1] : 0;
1645
1772
  tpl[30] = o ? o[2] : 0;
1773
+ // Quantized dequantization params (issue #1682 phase 6);
1774
+ // zeroed for f32 batches (their pipelines ignore them).
1775
+ const qz = batch.quantized;
1776
+ tpl[56] = qz ? qz.min[0] : 0;
1777
+ tpl[57] = qz ? qz.min[1] : 0;
1778
+ tpl[58] = qz ? qz.min[2] : 0;
1779
+ tpl[59] = qz ? qz.step : 0;
1646
1780
  device.queue.writeBuffer(batch.uniformBuffer, 0, tpl);
1647
- // Single draw call for entire batch!
1781
+ // Single draw call for entire batch! LOD1-selected batches
1782
+ // bind their simplified index range over the same vertices.
1783
+ const useLod1 = batch.lod1IndexBuffer && batch.lod1IndexCount && lodBatches.has(batch.id);
1648
1784
  pass.setBindGroup(0, batch.bindGroup);
1649
1785
  pass.setVertexBuffer(0, batch.vertexBuffer);
1650
- pass.setIndexBuffer(batch.indexBuffer, 'uint32');
1651
- pass.drawIndexed(batch.indexCount);
1786
+ if (useLod1) {
1787
+ pass.setIndexBuffer(batch.lod1IndexBuffer, 'uint32');
1788
+ pass.drawIndexed(batch.lod1IndexCount);
1789
+ frameBatchesAtLod1++;
1790
+ }
1791
+ else {
1792
+ pass.setIndexBuffer(batch.indexBuffer, 'uint32');
1793
+ pass.drawIndexed(batch.indexCount);
1794
+ }
1795
+ frameDrawCalls++;
1796
+ frameBatchesDrawn++;
1797
+ };
1798
+ // Quantized batches (issue #1682 phase 6) draw through the
1799
+ // quantized pipeline variants; scene only quantizes after the
1800
+ // probe (enableQuantizedBatches) verified they exist, so the
1801
+ // base fallback here is type-safety, never taken.
1802
+ const pipeFor = (batch, kind) => {
1803
+ const base = kind === 'opaque'
1804
+ ? this.pipeline.getPipeline()
1805
+ : kind === 'transparent'
1806
+ ? this.pipeline.getTransparentPipeline()
1807
+ : this.pipeline.getOverlayPipeline();
1808
+ if (!batch.quantized)
1809
+ return base;
1810
+ return this.pipeline.getQuantizedPipelineVariant(kind) ?? base;
1652
1811
  };
1653
1812
  // Render opaque batches with the opaque (double-sided) pipeline.
1654
1813
  // Material-layer slices render double-sided like all other IFC
@@ -1661,8 +1820,10 @@ export class Renderer {
1661
1820
  // Double-siding draws every face of the watertight skin ⇒ solid.
1662
1821
  pass.setPipeline(this.pipeline.getPipeline());
1663
1822
  for (const batch of opaqueBatches) {
1823
+ pass.setPipeline(pipeFor(batch, 'opaque'));
1664
1824
  renderBatch(batch);
1665
1825
  }
1826
+ pass.setPipeline(this.pipeline.getPipeline());
1666
1827
  // GPU-instancing pass — repeated geometry collated by the producer
1667
1828
  // into one template + a per-occurrence instance buffer (mat4 +
1668
1829
  // entityId + rgba), drawn with the instanced pipeline as
@@ -1672,7 +1833,40 @@ export class Renderer {
1672
1833
  // IFC Z-up→WebGL Y-up swap, so the uniform's model is unused here;
1673
1834
  // we reuse the frame's viewProj + section + flags from `tpl`.
1674
1835
  const instancedTemplates = this.scene.getInstancedTemplates();
1836
+ // Cull templates ONCE per frame; the transparent instanced
1837
+ // sub-pass below reuses this list. Frustum: the union of the
1838
+ // occurrences' world AABBs off-screen ⇒ every occurrence is.
1839
+ // Contribution: the LARGEST occurrence projected at the union
1840
+ // box's nearest view depth is an upper bound for any single
1841
+ // occurrence — bolts-scattered-everywhere templates cull as
1842
+ // soon as no bolt can exceed the pixel threshold, even though
1843
+ // the union box itself is model-sized. Templates with a
1844
+ // selected occurrence are exempt so the highlight can't vanish.
1845
+ // CATIA-class models put ~97% of draw calls in this pass, so
1846
+ // this is where interactive frame cost lives (issue #1682).
1847
+ let visibleInstanced = instancedTemplates;
1675
1848
  if (instancedTemplates.length > 0) {
1849
+ const kept = [];
1850
+ for (const it of instancedTemplates) {
1851
+ if (it.bounds) {
1852
+ if (!FrustumUtils.isAABBVisible(frustum, it.bounds)) {
1853
+ frameInstancedFrustumCulled++;
1854
+ continue;
1855
+ }
1856
+ if (contribThresholdPx > 0 &&
1857
+ cullCam &&
1858
+ it.selectedCount === 0 &&
1859
+ projectedInstancedRadiusPx(it.bounds.min, it.bounds.max, it.maxOccRadius, cullCam) <
1860
+ contribThresholdPx) {
1861
+ frameInstancedContributionCulled++;
1862
+ continue;
1863
+ }
1864
+ }
1865
+ kept.push(it);
1866
+ }
1867
+ visibleInstanced = kept;
1868
+ }
1869
+ if (visibleInstanced.length > 0) {
1676
1870
  // Opaque instanced pass. flags.x bit 2 marks "instanced pass" so the
1677
1871
  // shader routes per-instance opacity: opaque (or selected) occurrences
1678
1872
  // draw here; translucent ones (lens/x-ray/compare overrides) are
@@ -1681,11 +1875,13 @@ export class Renderer {
1681
1875
  pass.setPipeline(this.pipeline.getInstancedPipeline());
1682
1876
  pass.setBindGroup(0, this.pipeline.getBindGroup());
1683
1877
  pass.setBindGroup(1, this.pipeline.getEnvironmentBindGroup());
1684
- for (const it of instancedTemplates) {
1878
+ for (const it of visibleInstanced) {
1685
1879
  pass.setVertexBuffer(0, it.vertexBuffer);
1686
1880
  pass.setVertexBuffer(1, it.instanceBuffer);
1687
1881
  pass.setIndexBuffer(it.indexBuffer, 'uint32');
1688
1882
  pass.drawIndexed(it.indexCount, it.instanceCount);
1883
+ frameDrawCalls++;
1884
+ frameInstancedDrawn++;
1689
1885
  }
1690
1886
  pass.setPipeline(this.pipeline.getPipeline());
1691
1887
  // The TRANSPARENT instanced sub-pass is drawn later, alongside the
@@ -1738,6 +1934,7 @@ export class Renderer {
1738
1934
  pass.setVertexBuffer(0, tm.vertexBuffer);
1739
1935
  pass.setIndexBuffer(tm.indexBuffer, 'uint32');
1740
1936
  pass.drawIndexed(tm.indexCount);
1937
+ frameDrawCalls++;
1741
1938
  }
1742
1939
  // Restore the opaque pipeline for the passes that follow.
1743
1940
  pass.setPipeline(this.pipeline.getPipeline());
@@ -1760,7 +1957,7 @@ export class Renderer {
1760
1957
  // lens/Pset colour override, so the overlay paint pass finds depth.
1761
1958
  const isTransparent = shouldRouteBatchTransparent(alphaForBatch(subBatch, color[3]), subBatch.expressIds, colorOverrides);
1762
1959
  if (isTransparent) {
1763
- pass.setPipeline(this.pipeline.getTransparentPipeline());
1960
+ pass.setPipeline(pipeFor(subBatch, 'transparent'));
1764
1961
  }
1765
1962
  else {
1766
1963
  // Opaque (incl. material-layer slices): double-sided.
@@ -1768,7 +1965,7 @@ export class Renderer {
1768
1965
  // open watertight-skin bands with unreliable winding,
1769
1966
  // so culling punched holes (wall read hollow). See the
1770
1967
  // full-batch path above.
1771
- pass.setPipeline(this.pipeline.getPipeline());
1968
+ pass.setPipeline(pipeFor(subBatch, 'opaque'));
1772
1969
  opaqueSubBatches.push(subBatch);
1773
1970
  }
1774
1971
  // Render the sub-batch as a single draw call
@@ -1794,6 +1991,7 @@ export class Renderer {
1794
1991
  // bit 1 = overlay; bit 5 (32) = emphasize (pop) — see shader.
1795
1992
  tplFlags[0] = options.emphasizeOverrides ? (2 | 32) : 2;
1796
1993
  for (const batch of overrideBatches) {
1994
+ pass.setPipeline(pipeFor(batch, 'overlay'));
1797
1995
  renderBatch(batch);
1798
1996
  }
1799
1997
  tplFlags[0] = 0; // restore for any downstream use of the template
@@ -1858,20 +2056,20 @@ export class Renderer {
1858
2056
  // a complete depth buffer. Only runs when an override actually made some
1859
2057
  // occurrence translucent (otherwise zero cost). flags.x bit 3 flips the
1860
2058
  // shader's opacity routing so only translucent occurrences draw here.
1861
- const instancedTransparent = this.scene.getInstancedTemplates();
1862
2059
  const instancedTransparentPipeline = this.pipeline.getInstancedTransparentPipeline();
1863
- if (instancedTransparent.length > 0 &&
2060
+ if (visibleInstanced.length > 0 &&
1864
2061
  this.scene.hasTransparentInstances() &&
1865
2062
  instancedTransparentPipeline !== null) {
1866
2063
  this.pipeline.writeRawUniforms(tpl, 0x4 | 0x8);
1867
2064
  pass.setPipeline(instancedTransparentPipeline);
1868
2065
  pass.setBindGroup(0, this.pipeline.getBindGroup());
1869
2066
  pass.setBindGroup(1, this.pipeline.getEnvironmentBindGroup());
1870
- for (const it of instancedTransparent) {
2067
+ for (const it of visibleInstanced) {
1871
2068
  pass.setVertexBuffer(0, it.vertexBuffer);
1872
2069
  pass.setVertexBuffer(1, it.instanceBuffer);
1873
2070
  pass.setIndexBuffer(it.indexBuffer, 'uint32');
1874
2071
  pass.drawIndexed(it.indexCount, it.instanceCount);
2072
+ frameDrawCalls++;
1875
2073
  }
1876
2074
  pass.setPipeline(this.pipeline.getPipeline());
1877
2075
  }
@@ -1879,6 +2077,7 @@ export class Renderer {
1879
2077
  if (transparentBatches.length > 0) {
1880
2078
  pass.setPipeline(this.pipeline.getTransparentPipeline());
1881
2079
  for (const batch of transparentBatches) {
2080
+ pass.setPipeline(pipeFor(batch, 'transparent'));
1882
2081
  renderBatch(batch);
1883
2082
  }
1884
2083
  }
@@ -1923,6 +2122,7 @@ export class Renderer {
1923
2122
  pass.setVertexBuffer(0, mesh.vertexBuffer);
1924
2123
  pass.setIndexBuffer(mesh.indexBuffer, 'uint32');
1925
2124
  pass.drawIndexed(mesh.indexCount, 1, 0, 0, 0);
2125
+ frameDrawCalls++;
1926
2126
  }
1927
2127
  }
1928
2128
  // Ensure selected meshes have uniform buffers and bind groups
@@ -1983,6 +2183,7 @@ export class Renderer {
1983
2183
  pass.setVertexBuffer(0, mesh.vertexBuffer);
1984
2184
  pass.setIndexBuffer(mesh.indexBuffer, 'uint32');
1985
2185
  pass.drawIndexed(mesh.indexCount, 1, 0, 0, 0);
2186
+ frameDrawCalls++;
1986
2187
  }
1987
2188
  }
1988
2189
  else {
@@ -1998,6 +2199,7 @@ export class Renderer {
1998
2199
  pass.setVertexBuffer(0, mesh.vertexBuffer);
1999
2200
  pass.setIndexBuffer(mesh.indexBuffer, 'uint32');
2000
2201
  pass.drawIndexed(mesh.indexCount, 1, 0, 0, 0);
2202
+ frameDrawCalls++;
2001
2203
  }
2002
2204
  // Render transparent meshes with transparent pipeline (alpha blending)
2003
2205
  if (transparentMeshes.length > 0) {
@@ -2012,6 +2214,7 @@ export class Renderer {
2012
2214
  pass.setVertexBuffer(0, mesh.vertexBuffer);
2013
2215
  pass.setIndexBuffer(mesh.indexBuffer, 'uint32');
2014
2216
  pass.drawIndexed(mesh.indexCount, 1, 0, 0, 0);
2217
+ frameDrawCalls++;
2015
2218
  }
2016
2219
  }
2017
2220
  }
@@ -2184,6 +2387,22 @@ export class Renderer {
2184
2387
  });
2185
2388
  }
2186
2389
  device.queue.submit([encoder.finish()]);
2390
+ this._lastFrameStats = {
2391
+ drawCalls: frameDrawCalls,
2392
+ batchesDrawn: frameBatchesDrawn,
2393
+ batchesFrustumCulled: frameBatchesFrustumCulled,
2394
+ batchesContributionCulled: frameBatchesContributionCulled,
2395
+ batchesNotResident: frameBatchesNotResident,
2396
+ batchesAtLod1: frameBatchesAtLod1,
2397
+ instancedDrawn: frameInstancedDrawn,
2398
+ instancedFrustumCulled: frameInstancedFrustumCulled,
2399
+ instancedContributionCulled: frameInstancedContributionCulled,
2400
+ timestamp: performance.now(),
2401
+ };
2402
+ // GPU residency budget (issue #1682 phase 3a): evict least-recently
2403
+ // drawn bucket batches after submit — destruction of just-submitted
2404
+ // buffers is deferred past in-flight work by WebGPU.
2405
+ this.scene.enforceGpuBudget();
2187
2406
  // Pop validation error scope and capture the exact error
2188
2407
  if (captureGpuError) {
2189
2408
  device.popErrorScope().then((error) => {