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:
- 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.
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 ✅
Summary
Tile updates are processed incrementally across update ticks, and the
FINISHnotification is emitted only once a whole pass has completed. But every view refresh resets the pass's progress counter to zero. SinceFragmentsModels.update()refreshes the view of every model, anything that callsupdate()repeatedly restarts the pass before it can finish — on a model large enough that a pass spans more than one tick,FINISHis then never emitted at all.Two consequences:
FINISHnever receive them, so no geometry appears while the camera is moving. Stop moving and the first uninterrupted pass completes and everything appears at once.forceUpdateFinish()never resolves, soawait 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
FINISHrequires a completed pass.src/FragmentsModels/src/virtual-model/virtual-controllers/tile-controller.ts(names from the sourcemap)A pass is incremental and time-budgeted, so on a model with many samples it spans several ticks:
Every view refresh throws that progress away, and drops a
FINISHthat was queued but not yet posted:And
update()refreshes every model's view on every call:So the loop is: refresh the view → progress reset to 0 → partial pass → refresh again → reset again.
_changedSamplesnever reaches_sampleAmount,notifyUpdateFinishedalways returns early, and noFINISHis ever produced.The rate limit in
update()caps this at one refresh persettings.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
In an application: orbit continuously while a large model loads. No geometry appears until the camera is released.
Expected
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 fromsetupViewon 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
FINISHprogress-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.tilesUpdatedalready 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
FINISHwas emitted and force one when that exceeds some multiple ofmaxUpdateRate, 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 behindawait 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 theforceargument does not lift it — it only decides whether a call that gets through awaitsforceUpdateFinish(). A caller that needs an update to actually happen has to liftsettings.maxUpdateRatearound the call.Used Package Manager 📦
npm
@thatopen/fragments@3.4.7Error Trace/Logs 📃
No response
Validations ✅