Skip to content

Tiles are drawn with one draw call per visibility run — thousands of geometry.groups on real models #278

Description

@rihokirss

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.getvisibilityData.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)

  1. 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
  2. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions