Skip to content

perf(FragmentsModels): draw fragmented shell tiles with one compacted index instead of a group per visibility run - #281

Open
rihokirss wants to merge 1 commit into
ThatOpen:mainfrom
rihokirss:perf/compact-index
Open

perf(FragmentsModels): draw fragmented shell tiles with one compacted index instead of a group per visibility run#281
rihokirss wants to merge 1 commit into
ThatOpen:mainfrom
rihokirss:perf/compact-index

Conversation

@rihokirss

@rihokirss rihokirss commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Part of #278.

A shell tile is drawn through one geometry.group per contiguous run of samples visible at the GEOMETRY level, so a tile whose samples are partly culled or at a different LOD costs one draw call per run. On real models the runs are heavily interleaved (samples are appended in file order, the LOD decision is per sample and screen-size based), which is what makes the frame time scale with model count.

This keeps the tile's index on the CPU and, when a tile has three or more visible runs, copies the visible ranges into the existing GPU index buffer in place and draws them as a single group bounded by setDrawRange. The buffer never changes size, so no reallocation and nothing to leak; needsUpdate re-uploads the same-sized array.

Kept on the group path (with the full index restored first, so switching back and forth is safe):

  • highlighted tiles — MaterialManager.createHighlights adds groups with a materialIndex addressing the full index;
  • LOD/wire tiles — LODManager owns their groups;
  • tiles with fewer than three runs, where the copy is not worth it.

Measurements

Headless Chromium, Radeon 780M, 1920×1080, postproduction off, 50 FPS cap, medians of 3 runs. "1 model" is a 38 MB .frag architecture model converted from IFC, "8 models" adds seven interior models of the same building.

before after
draw calls, 1 model, idle / orbit 5 619 / 4 691 1 101 / 1 097
geometry.groups, 1 model 3 670 235
render CPU ms, 1 model, idle / orbit 30.9 / 26.0 6.4 / 7.3
FPS, 1 model, idle / orbit 31.4 / 37.1 50 / 50.1 (cap)
draw calls, 8 models, idle / orbit 6 783 / 6 456 1 868 / 1 862
geometry.groups, 8 models 4 527 438
FPS, 8 models, idle / orbit 21.6 / 22.7 37.2 / 37.3
JS heap after load, 8 models 631 MB 368 MB

Screenshots of six fixed camera poses are pixel-identical (0.000 % differing pixels) to the current build. Group-load wall time −12 %, update(true) settle after an orbit unchanged.

Memory cost: one extra Uint16Array index copy per shell tile — ~11 MB on that model. (The heap number above drops anyway, because far fewer tile geometries are kept alive.)

Note for getItemDrawChunks consumers

The chunk positions returned by getItemDrawChunks refer to the full index. With this change a compacted tile's geometry.index holds only the visible ranges, so a renderer cloning the tile mesh should take the index from geometry.userData.fullIndex. Happy to add that to the JSDoc if you want it worded differently.

I also have a small worker-side change that reduces the run count at the source (fill tiles in size-sorted sample order) — separate PR, they compose: together the 8-model scene is 1 817 calls at the 50 FPS cap.

I can add a unit test around the compaction (visible ranges in, compacted index + single group out) if you'd like one; it needs a tiny seam in MeshManager to be testable in isolation, so I left it out of this diff.


The companion PR for #278 is #282 (worker-side sample order). They are independent — either can go in alone — and compose.

🤖 Generated with Claude Code

https://claude.ai/code/session_013rtNQqhSQRM2t6E98DESNE

… index instead of a group per visibility run

A shell tile whose samples are partly culled or at a different LOD was
drawn as one geometry.group per visible run — each its own draw call. On a
large model with many small items this is 10–40 draw calls per tile
(measured 289 shell meshes → 4 739 draw calls on a 38 MB IFC). The tile
index is now kept on the CPU and, when a tile has three or more visible
runs, the visible ranges are copied into the GPU index buffer in place and
drawn as a single group (drawRange bounds the count). Highlighted tiles and
tiles with fewer runs keep the group path, restoring the full index first.
The GL buffer is never reallocated, so nothing leaks across updates.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant