Skip to content

Continuous view refreshes starve the tile FINISH notification: geometry never appears, forceUpdateFinish never resolves #300

Description

@ShaMan123

Summary

Tile updates are processed incrementally across update ticks, and the FINISH notification is emitted only once a whole pass has completed. But every view refresh resets the pass's progress counter to zero. Since FragmentsModels.update() refreshes the view of every model, anything that calls update() repeatedly restarts the pass before it can finish — on a model large enough that a pass spans more than one tick, FINISH is then never emitted at all.

Two consequences:

  1. Consumers that apply tiles on FINISH never receive them, so no geometry appears while the camera is moving. Stop moving and the first uninterrupted pass completes and everything appears at once.
  2. forceUpdateFinish() never resolves, so await update(true) hangs for as long as the view keeps being refreshed. Any load or mutation sequenced behind that await stalls indefinitely.

Moving the camera is the ordinary way to trigger this, because camera motion is exactly what drives repeated update() calls.

Root cause

FINISH requires a completed pass.

src/FragmentsModels/src/virtual-model/virtual-controllers/tile-controller.ts (names from the sourcemap)

notifyUpdateFinished() {
  const noficationNotSentYet = !this.tilesUpdated;
  const samplesUpdated = this._changedSamples >= this._sampleAmount;
  const updateFinished = samplesUpdated && noficationNotSentYet;
  if (!updateFinished) {
    return;
  }
  this._meshConnection.process({
    tileRequestClass: TileRequestClass.FINISH,
    modelId: this._modelId,
    seq: thread.lastSeenSeq,
  });
  this.tilesUpdated = true;
}

A pass is incremental and time-budgeted, so on a model with many samples it spans several ticks:

updateTiles(time) {
  const needsUpdate = this._changedSamples < this._sampleAmount;
  // ...
  let keepUpdating = true;
  let updatingSampleId = 0;
  while (keepUpdating) {
    const meshId = this._samplesDimensions[this._currentSample];
    this.updateMesh(meshId);
    this.updateCurrentSample();        // ← _changedSamples++
    updatingSampleId++;
    keepUpdating = this.getKeepUpdating(updatingSampleId, time);
  }
}

Every view refresh throws that progress away, and drops a FINISH that was queued but not yet posted:

setupView(view) {
  this._virtualView = view;
  VirtualMemoryController.setCapacity(view.meshThreshold);
  this.restart();                      // ← here
  this.updateOrientationIfNeeded();
  this.updatePositionIfNeeded();
  this.setupViewPlanes();
}

restart() {
  this.resetUpdateProcess();           // _changedSamples = 0; tilesUpdated = false
  this._meshConnection.clean();        // cleanRequests() drops FINISH requests
}

And update() refreshes every model's view on every call:

async update(force = false) {
  // ...
  for (const model of this.models.list.values()) {
    modelUpdates.push(model._refreshView());   // → refreshView → tiles.setupView → restart()
  }
  await Promise.all(modelUpdates);
  if (force) {
    await this.models.forceUpdateFinish();     // waits for a FINISH stamped seq ≥ ours
  } else {
    this.models.update();
  }
}

So the loop is: refresh the view → progress reset to 0 → partial pass → refresh again → reset again. _changedSamples never reaches _sampleAmount, notifyUpdateFinished always returns early, and no FINISH is ever produced.

The rate limit in update() caps this at one refresh per settings.maxUpdateRate (default 100 ms), which sets the threshold: any model whose pass needs more than ~100 ms of budgeted work cannot finish while the view is being refreshed. Small sample counts finish inside one tick and never show the problem, which is presumably why it has not been noticed.

Reproduction

const fragments = new FRAGS.FragmentsModels(workerUrl);
const model = await fragments.load(buffer, { modelId: "m" });  // a large model

// Simulate camera motion: refresh the view faster than a pass can complete.
const moving = setInterval(() => void fragments.update(), 100);

// Never resolves while the interval runs.
await fragments.update(true);
clearInterval(moving);

In an application: orbit continuously while a large model loads. No geometry appears until the camera is released.

Expected

  • A pass's progress should survive a view refresh, or the refresh should be coalesced so that repeated refreshes cannot indefinitely prevent one from completing.
  • forceUpdateFinish() should not be indefinitely blockable by unrelated view refreshes.

Suggested fixes

Don't reset progress for a view change that doesn't invalidate it. restart() is called from setupView on every refresh, including refreshes where only the camera pose changed. A pose change alters which LOD each sample resolves to, but the pass does not have to begin again — the already-updated samples were updated against a view that is still approximately current, and the next pass will correct them.

Or make FINISH progress-independent: emit it when a pass ends or when a refresh supersedes one, so a consumer is told "here is what there is" rather than waiting for a completion that may never come. tilesUpdated already exists to prevent duplicate notifications, so the guard against spamming is in place.

Or, minimally, bound the starvation: track how long it has been since a FINISH was emitted and force one when that exceeds some multiple of maxUpdateRate, so continuous refreshing degrades the frame rate instead of stopping delivery entirely.

Notes for anyone hitting this

The hang in await update(true) is the more damaging half, because it is invisible: there is no error and no timeout, just a promise that does not settle. Sequencing work behind await update(true) — waiting for it before adding geometry to a scene, for example — makes a stalled load indistinguishable from a slow one.

Related, and separate: update()'s rate limit silently drops any call inside its window, and the force argument does not lift it — it only decides whether a call that gets through awaits forceUpdateFinish(). A caller that needs an update to actually happen has to lift settings.maxUpdateRate around the call.

Used Package Manager 📦

npm @thatopen/fragments@3.4.7

Error Trace/Logs 📃

No response

Validations ✅

  • Read the docs.
  • Check that there isn't already an issue that reports the same bug to avoid creating a duplicate.
  • Make sure this is a repository issue and not a framework-specific issue. For example, if it's a THREE.js related bug, it should likely be reported to mrdoob/threejs instead.
  • Check that this is a concrete bug. For Q&A join our Community.
  • The provided reproduction is a minimal reproducible example of the bug.

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

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions