Skip to content

Geometry silently culled when no camera has been set: default THREE.Frustum discards the entire negative-X half-space #255

Description

@arbirk

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

  1. Load a .frag model that extends into negative X, via FragmentsManager / FragmentsModels.load().
  2. Do not call useCamera().
  3. 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 ✅

  • 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