Summary
A shell tile is drawn through one geometry.group per contiguous run of samples that are visible at the GEOMETRY level: the worker splits the tile's visibility state into runs (MultiBufferData.get → visibilityData.position/size) and the main thread turns each run into a group:
// LODManager.processMesh
for (let i = 0; i < visibilityData.position.length; ++i) {
...
geometry.addGroup(position, value, 0);
}
three.js issues one draw call per group. Samples are appended to a tile in file order (generate() walks 0..N), while the per-sample LOD decision (fetchLodLevel) depends on screen size, so a tile ends up with dozens of interleaved runs of geometry / wires / invisible samples — and the count changes with every camera move.
Measurements
38 MB .frag architecture model converted from IFC (1.85 M triangles), exterior view, postproduction off:
- 260 shell meshes carrying 3 670
geometry.groups, plus 829 LOD wire meshes at one call each — 5 619 draw calls per frame in total
- with seven small interior models added: 6 783 draw calls, 21 FPS (Radeon 780M, 1920×1080); on a Radeon 8060S at 2341×1130: 9 448 calls, 14 FPS
- frame time is linear in draw calls (~7 µs per call there); hiding every shell mesh leaves the 1 438 wire calls and the frame rate at the 50 FPS cap
- camera distance ×4 / ×0.25: 2 618 / 10 392 groups — the fragmentation is the LOD cut, not user visibility
Two independent fixes (rendered result pixel-identical to today's)
- Fill tiles in size-sorted sample order.
generate() already has _samplesDimensions (sorted big → small, the order the cull sweep uses); feeding tiles in that order makes the LOD cut through a tile one or two runs instead of dozens. One line. −27 … −40 % draw calls. perf/tile-sample-order
- Compact the visible ranges into one draw call. Keep the tile's Uint16 index on the CPU; when a tile has ≥3 visible runs, copy the visible ranges into the GPU index buffer in place and draw a single group bounded by
drawRange. The buffer never changes size, so nothing is reallocated or leaked. Highlighted tiles (their highlight groups address the full index) and tiles with fewer runs keep the group path, restoring the full index first. −72 … −80 % draw calls; the cost is one extra index copy per shell tile (~11 MB on this model). perf/compact-index
|
today |
(1) |
(2) |
(1) + (2) |
| draw calls, 1 model, idle |
5 619 |
4 101 |
1 101 |
1 113 |
| draw calls, 8 models, idle |
6 783 |
4 408 |
1 868 |
1 817 |
| FPS, 8 models, idle (780M, cap 50) |
21.6 |
23.5 |
37.2 |
50 |
| FPS, 8 models, orbit |
22.7 |
28.2 |
37.3 |
50 |
Screenshots of the same camera pose diff 0.000 % between (2) and today; (1) changes tile draw order, which flips one z-fighting façade strip (0.08 % of pixels).
The remaining calls are the LOD wire tiles (one mesh per wire tile). Enlarging the wire tile cells (same tileSizeMultiplier as geometry tiles) cuts those meshes by 76 % but draws more off-screen wire geometry and costs ~15 % render time, so I'm not proposing that.
Both branches are single commits on top of main (3.4.7), built and measured with a headless A/B harness on real project models. Happy to open them as two PRs ((2) first) if this direction is fine. One note for getItemDrawChunks consumers: with (2) the chunk positions refer to the full index, kept in geometry.userData.fullIndex.
Summary
A shell tile is drawn through one
geometry.groupper contiguous run of samples that are visible at the GEOMETRY level: the worker splits the tile's visibility state into runs (MultiBufferData.get→visibilityData.position/size) and the main thread turns each run into a group:three.js issues one draw call per group. Samples are appended to a tile in file order (
generate()walks0..N), while the per-sample LOD decision (fetchLodLevel) depends on screen size, so a tile ends up with dozens of interleaved runs of geometry / wires / invisible samples — and the count changes with every camera move.Measurements
38 MB
.fragarchitecture model converted from IFC (1.85 M triangles), exterior view, postproduction off:geometry.groups, plus 829 LOD wire meshes at one call each — 5 619 draw calls per frame in totalTwo independent fixes (rendered result pixel-identical to today's)
generate()already has_samplesDimensions(sorted big → small, the order the cull sweep uses); feeding tiles in that order makes the LOD cut through a tile one or two runs instead of dozens. One line. −27 … −40 % draw calls.perf/tile-sample-orderdrawRange. The buffer never changes size, so nothing is reallocated or leaked. Highlighted tiles (their highlight groups address the full index) and tiles with fewer runs keep the group path, restoring the full index first. −72 … −80 % draw calls; the cost is one extra index copy per shell tile (~11 MB on this model).perf/compact-indexScreenshots of the same camera pose diff 0.000 % between (2) and today; (1) changes tile draw order, which flips one z-fighting façade strip (0.08 % of pixels).
The remaining calls are the LOD wire tiles (one mesh per wire tile). Enlarging the wire tile cells (same
tileSizeMultiplieras geometry tiles) cuts those meshes by 76 % but draws more off-screen wire geometry and costs ~15 % render time, so I'm not proposing that.Both branches are single commits on top of
main(3.4.7), built and measured with a headless A/B harness on real project models. Happy to open them as two PRs ((2) first) if this direction is fine. One note forgetItemDrawChunksconsumers: with (2) the chunk positions refer to the full index, kept ingeometry.userData.fullIndex.