Skip to content

With several models loaded an idle camera keeps re-culling every model; forced updates are dropped or unthrottled; tile-cache budget is N× per worker #279

Description

@rihokirss

Summary

Three related things in FragmentsModels that only show up with several models loaded:

  1. Idle re-cull loop. update() sends REFRESH_VIEW to every model unconditionally; worker-side setupView() calls restart(), so every worker re-culls every sample of every model; the resulting FINISH schedules the next update() (newUpdateEvent, maxUpdateRate + 1 ms). With a static camera and 8 models that is 112 REFRESH_VIEW and 106 tile batches per 5 s doing nothing (13 models: ~2 cores busy at idle).
  2. Forced updates. update(true) inside maxUpdateRate is silently dropped, so the awaited "fence" never happened. Letting it through unthrottled is not the answer either: @thatopen/components forces an update on every camera-controls rest, which fires nearly every frame of a programmatic orbit — each forced refresh is a full re-cull plus an unbounded drain on the main thread (orbit FPS −10 %, long tasks ×3 when I tried exactly that).
  3. Tile-cache budget and updater fairness. VirtualTilesController._graphicMemoryConsumed is a per-worker static, but the graphicThreshold sent from main is the global GPU estimate (w × h × dpr² × 200, ~6.6 GB on a 4K hidpi display), so N workers allow N× the budget and invisible tiles are effectively never evicted. VirtualMemoryController.setCapacity(view.meshThreshold) is dead code — meshThreshold is never set, main sends graphicThreshold. ThreadUpdater.updateAllModels() always starts at the first model and breaks after 16 ms, so one model's long pass starves the others on the same worker.

Proposed fixes (single-purpose branches on main, measured with an interleaved A/B on 8 real models)

  • Skip unchanged views, coalesce forced updates. ViewManager fingerprints the outgoing view (frustum, camera position in model space, clipping planes, viewport size, quality, placement) and skips REFRESH_VIEW when nothing changed; worker setupView() compares too and answers a FINISH directly when its pass is already complete (so update(true) fences still settle); update() reschedules itself with a light timer instead of the FINISH-driven loop. Forced calls inside the rate window are merged into one trailing forced update whose settle resolves the awaiting callers — fence semantics kept, nothing dropped, nothing unthrottled. perf/c4a-skip-unchanged-view-refresh — idle REFRESH_VIEW 112 → 0, tile batches 106 → 2, settle after orbit up to −31 %, orbit ±1 %, heap −14 %.
  • Per-worker budget. Divide the threshold by the active worker count and cap the estimate at 1 GB (it only bounds the invisible-tile cache; visible geometry is never evicted). perf/c4b-per-worker-tile-budget — 13 models after 10 s of orbiting: tile meshes 3 872 → 1 595, GPU geometries 1 838 → 876, JS heap 148 → 47 MB. Depends on the first fix: on its own, eviction plus the idle re-cull loop re-create tiles continuously.
  • ThreadUpdater rotation. Resume the per-tick sweep where the previous tick stopped, and don't report a partial sweep as fully updated. perf/c4c-thread-updater-rotation

Everything renders identically (screenshot diff 0.000 %); visibility/highlight changes, camera moves, clipping-plane changes and update(true) fences verified. I'd open these as three PRs, the budget one after the first is merged.

Activity

  1. rihokirss commented on Sep 4, 2026

    @rihokirss
    ContributorAuthor

    PRs for the parts that are ready, each one branch on top of main:

    PR Covers Effect
    #283 points 1 and 2 — skip the refresh when the view is unchanged, coalesce forced updates instead of dropping them or letting them bypass maxUpdateRate idle REFRESH_VIEW 112 → 0, idle tile batches 109 → 2, settle after orbit −31 %, heap −14 %
    #284 the ThreadUpdater fairness part neutral in my scenes, fairness/correctness fix

    The remaining part — dividing the invisible-tile cache budget by the active worker count and capping the screen-size estimate — is written and measured but not submitted yet: on its own it fights the idle re-cull loop (eviction and re-cull churn against each other), so it only makes sense after #283. I'll open it once that one is in, or add it there as a third commit if you prefer. No closing keyword on either PR for the same reason.

  2. rihokirss commented on Sep 4, 2026

    @rihokirss
    ContributorAuthor

    Correction to my earlier comment: the per-worker tile-cache budget is now up as #286.

    I said it only made sense after #283 and that on its own the eviction would churn against the idle re-cull loop. That was based on a measurement I should not have trusted — every candidate in that run was measured 30+ minutes into a chain on a laptop-class APU that throttles. Re-running it with an unpatched baseline immediately before it, and a second baseline at the end as a control, gives a different picture:

    • the control: the same unpatched build 35 minutes apart reports +39 % render time and −28 % FPS on the 16-model scene, while the tile counts are identical to within 0.5 %;
    • the budget change measured against its adjacent baseline: frame time neutral (±2.4 % on both scenes, both directions), tile meshes and GPU geometries alive after orbiting 1 874 → 1 704 on 8 models and 2 550 → 1 970 on 16 models;
    • on top of perf(FragmentsModels): skip view refresh when the view is unchanged and coalesce forced updates #283, the same −9.9 % / −23.0 %, with one real cost: settle after a large camera move on the 16-model scene goes 740 → 962 ms, since evicted tiles are rebuilt when the camera returns.

    So the three parts of this issue are independent and can be reviewed in any order. Sorry for the noise on the dependency claim.

  3. rihokirss commented on Sep 9, 2026

    @rihokirss
    ContributorAuthor

    All three parts are on main now — #283 (ab89f90, skip unchanged view refresh + coalesce forced updates), #284 (4ed4d47, ThreadUpdater rotation) and #286 (217b9b0, per-worker tile-cache budget) — and are listed in the 3.5.0 release PR (#189). Closing.

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