Describe the bug 📝
Summary
If ViewManager.useCamera() has never been called, VirtualTilesController culls every item whose bounding box lies entirely at x < 0. The geometry is loaded, present in the index buffer, and reported by every data-side API — it is simply never drawn, with no error or warning.
Root cause
ViewManager._updateCameraFrustumEvent is initialised to a no-op:
private _updateCameraFrustumEvent: (frustum: THREE.Frustum) => void = () => {};
It is only replaced by a real implementation inside setCameraFrustum(), which runs from useCamera(). So if useCamera() is never called, setup() leaves this._tempFrustum as a default-constructed THREE.Frustum — six new THREE.Plane(), each normal=(1,0,0) constant=0.
That frustum is shipped to the worker and consumed verbatim by VirtualTilesController.setupViewPlanes():
private setupViewPlanes() {
this._virtualPlanes = [];
for (const plane of this._virtualView.cameraFrustum.planes) {
this._virtualPlanes.push(plane); // six copies of the plane x = 0
}
...
}
fetchLodLevel() then culls against it:
const notClipped = CameraUtils.collides(item, this._virtualPlanes);
if (!notClipped) return CurrentLod.INVISIBLE;
and PlanesUtils.collides rejects a box if it falls behind any plane. With six copies of x = 0, normal +X, that means every item lying entirely at x < 0 is discarded.
Because culling is expressed by omitting the item's index range from geometry.groups (rather than by hiding a mesh), the result is invisible to every other observable:
- vertices remain in the index buffer
getLocalIds / getItemsData / getMergedBox / getItemsGeometry all report the item present
- the batched mesh still reports
visible === true, a full drawRange, and a bbox spanning the culled geometry
There is no signal anywhere except the draw-group table.
Evidence
Instrumenting all four paths to CurrentLod.INVISIBLE in the worker and loading a 245-element building:
LODPROBE first-cull-branch: CLIPPING-PLANE
LODPROBE tally: CLIPPING-PLANE=40
100% of culls come from the clipping branch; the screen-size LOD cutoff never fires. Dumping _virtualPlanes at the first cull:
_virtualPlanes.length=6
plane[0] normal=(1.000,0.000,0.000) constant=0.000
plane[1] normal=(1.000,0.000,0.000) constant=0.000
plane[2] normal=(1.000,0.000,0.000) constant=0.000
plane[3] normal=(1.000,0.000,0.000) constant=0.000
plane[4] normal=(1.000,0.000,0.000) constant=0.000
plane[5] normal=(1.000,0.000,0.000) constant=0.000
cameraPosition=(0.00,0.00,0.00) graphicQuality=2
Every culled box has max.x < 0 (highest observed: -0.57); nothing at x >= 0 is ever culled:
CLIPPING-PLANE x[-1.39,-1.29] CLIPPING-PLANE x[-1.87,-1.07]
CLIPPING-PLANE x[-1.26,-1.07] CLIPPING-PLANE x[-0.97,-0.57]
CLIPPING-PLANE x[-1.07,-0.89] CLIPPING-PLANE x[-0.99,-0.94]
Per-batch draw-group coverage for that model — 852 of 26719 indices (284 triangles) never drawn:
batch (walls) 2328 idx, 108 SKIPPED x[-0.97,-0.57] x[-1.07,-0.89]
batch (facade) 144 idx, 36 SKIPPED x[-1.39,-1.29]
batch (terrain) 816 idx, 204 SKIPPED x[-1.87,-1.07]
batch (cladding) 237 idx, 48 SKIPPED x[-1.26,-1.07]
batch (rafters) 10671 idx, 456 SKIPPED x[-0.99,-0.94] x[-1.03,-0.90]
Setting model.setLodMode(LodMode.ALL_VISIBLE) takes this to 0 skipped indices across all 12 batches and the missing geometry renders correctly — consistent with the diagnosis, since ALL_VISIBLE bypasses the clipping branch.
Reproduction
- Load a
.frag model that extends into negative X, via FragmentsManager / FragmentsModels.load().
- Do not call
useCamera().
await core.update(true) and render.
Everything lying entirely at x < 0 is missing. Severity scales with placement: our model spanned x[-1.87, 16.73], so it lost a 1.87 m sliver at one end. A model centred on the origin would lose half its geometry.
Why this is worth guarding rather than documenting
useCamera() being required for correct rendering is reasonable. What is surprising is that skipping it silently deletes geometry rather than disabling culling — and that the deletion is undetectable through the public API surface. Two consumers hit this without ever touching the LOD system: a browser viewer and a headless renderer.
Candidate patch
Cull against the camera frustum only once a camera has actually been applied. Clipping planes are unaffected. Written defensively (!== false) so an older worker paired with a newer main thread keeps the current behaviour.
--- a/packages/fragments/src/FragmentsModels/src/model/view-manager.ts
+++ b/packages/fragments/src/FragmentsModels/src/model/view-manager.ts
@@ -93,6 +93,7 @@ export class ViewManager {
view.graphicQuality = model.graphicsQuality * -1.5 + 2;
view.clippingPlanes = this.getPlanes();
view.modelPlacement = model.object.matrixWorld;
+ view.cameraApplied = this.currentCamera !== null;
return view;
}
--- a/packages/fragments/src/FragmentsModels/src/virtual-model/virtual-controllers/virtual-tiles-controller.ts
+++ b/packages/fragments/src/FragmentsModels/src/virtual-model/virtual-controllers/virtual-tiles-controller.ts
@@ -461,8 +461,16 @@ export class VirtualTilesController {
private setupViewPlanes() {
this._virtualPlanes = [];
- for (const plane of this._virtualView.cameraFrustum.planes) {
- this._virtualPlanes.push(plane);
+ // Only cull against the camera frustum once a camera has actually been
+ // applied. Until `ViewManager.useCamera` runs, `_updateCameraFrustumEvent`
+ // is a no-op, so `cameraFrustum` is a default-constructed THREE.Frustum:
+ // six identical planes at normal (1,0,0), constant 0. Culling against that
+ // silently discards every item whose box lies entirely at x < 0.
+ if (this._virtualView.cameraApplied !== false) {
+ for (const plane of this._virtualView.cameraFrustum.planes) {
+ this._virtualPlanes.push(plane);
+ }
}
if (this._virtualView.clippingPlanes) {
for (const plane of this._virtualView.clippingPlanes) {
An alternative, if you would rather keep setupViewPlanes untouched: have ViewManager.refreshView skip sending a frustum until currentCamera !== null. A one-time console.warn when culling runs without a camera would also have saved us the investigation entirely.
Happy to open a PR with whichever shape you prefer, plus a regression test.
Reproduction ▶️
No response
Steps to reproduce 🔢
No response
System Info 💻
**Version:** `@thatopen/fragments` 3.4.7 (also reproduces on 3.4.6)
Used Package Manager 📦
npm
Error Trace/Logs 📃
No response
Validations ✅
Describe the bug 📝
Summary
If
ViewManager.useCamera()has never been called,VirtualTilesControllerculls every item whose bounding box lies entirely at x < 0. The geometry is loaded, present in the index buffer, and reported by every data-side API — it is simply never drawn, with no error or warning.Root cause
ViewManager._updateCameraFrustumEventis initialised to a no-op:It is only replaced by a real implementation inside
setCameraFrustum(), which runs fromuseCamera(). So ifuseCamera()is never called,setup()leavesthis._tempFrustumas a default-constructedTHREE.Frustum— sixnew THREE.Plane(), eachnormal=(1,0,0) constant=0.That frustum is shipped to the worker and consumed verbatim by
VirtualTilesController.setupViewPlanes():fetchLodLevel()then culls against it:and
PlanesUtils.collidesrejects a box if it falls behind any plane. With six copies ofx = 0, normal +X, that means every item lying entirely at x < 0 is discarded.Because culling is expressed by omitting the item's index range from
geometry.groups(rather than by hiding a mesh), the result is invisible to every other observable:getLocalIds/getItemsData/getMergedBox/getItemsGeometryall report the item presentvisible === true, a fulldrawRange, and a bbox spanning the culled geometryThere is no signal anywhere except the draw-group table.
Evidence
Instrumenting all four paths to
CurrentLod.INVISIBLEin the worker and loading a 245-element building:100% of culls come from the clipping branch; the screen-size LOD cutoff never fires. Dumping
_virtualPlanesat the first cull:Every culled box has
max.x < 0(highest observed:-0.57); nothing atx >= 0is ever culled:Per-batch draw-group coverage for that model — 852 of 26719 indices (284 triangles) never drawn:
Setting
model.setLodMode(LodMode.ALL_VISIBLE)takes this to 0 skipped indices across all 12 batches and the missing geometry renders correctly — consistent with the diagnosis, sinceALL_VISIBLEbypasses the clipping branch.Reproduction
.fragmodel that extends into negative X, viaFragmentsManager/FragmentsModels.load().useCamera().await core.update(true)and render.Everything lying entirely at x < 0 is missing. Severity scales with placement: our model spanned
x[-1.87, 16.73], so it lost a 1.87 m sliver at one end. A model centred on the origin would lose half its geometry.Why this is worth guarding rather than documenting
useCamera()being required for correct rendering is reasonable. What is surprising is that skipping it silently deletes geometry rather than disabling culling — and that the deletion is undetectable through the public API surface. Two consumers hit this without ever touching the LOD system: a browser viewer and a headless renderer.Candidate patch
Cull against the camera frustum only once a camera has actually been applied. Clipping planes are unaffected. Written defensively (
!== false) so an older worker paired with a newer main thread keeps the current behaviour.An alternative, if you would rather keep
setupViewPlanesuntouched: haveViewManager.refreshViewskip sending a frustum untilcurrentCamera !== null. A one-timeconsole.warnwhen culling runs without a camera would also have saved us the investigation entirely.Happy to open a PR with whichever shape you prefer, plus a regression test.
Reproduction▶️
No response
Steps to reproduce 🔢
No response
System Info 💻
Used Package Manager 📦
npm
Error Trace/Logs 📃
No response
Validations ✅