Per-object frustum culling with visibility. Sixty mixed primitives drift around in a wide volume; each frame their bounds — AABBs for boxes, spheres for spheres — are tested against a fixed test camera’s frustum, with the six frustum planes pre-computed once per frame via bounds({ mat4Eye }). Three return states drive the render: solid lit material for VISIBLE, amber wireframe for SEMIVISIBLE (the bounds straddle a plane), faint grey for INVISIBLE. Toggle the test camera’s projection between perspective and orthographic with the gold checkbox; drag to orbit the observer and watch the cyan wireframe stay put while objects drift through it.


Three primitive forms

visibility accepts three shapes for the bounds you’re testing:

visibility({ corner1, corner2 })       // axis-aligned box (AABB)
visibility({ center, radius })         // sphere
visibility({ center })                 // point

AABBs are conservative for irregular geometry; spheres are cheaper to test (one centre + one radius vs eight corners) and a natural fit when an object is already tracked by position + bounding radius. Points are the degenerate case — billboard centres, particle positions, anything where the “object” is a single location.

The sketch above uses two of the three: AABB for boxes (their natural extent fits an axis-aligned box exactly) and sphere for spheres. The form is chosen per object — there’s no rule that a scene must pick one or the other.


Three return states

const v = visibility({ center: m.position, radius: m.radius, bounds: _b })

if      (v === p5.Tree.VISIBLE)     // fully inside — render at full quality
else if (v === p5.Tree.SEMIVISIBLE) // straddles a plane — render anyway
else                                // fully outside — skip

VISIBLE and INVISIBLE are unambiguous. SEMIVISIBLE is the case where the bounds intersect at least one frustum plane — partly inside, partly outside. You almost always still render it (per-fragment clipping handles the boundary on the GPU), but the state is exposed so you can use it to drive level-of-detail decisions: full mesh for VISIBLE, simplified for SEMIVISIBLE, skip for INVISIBLE. The sketch leans into this for diagnostic encoding — solid lit material for VISIBLE, lower-detail wireframe for SEMIVISIBLE, very low-detail wireframe for INVISIBLE — so the three states are visually distinguishable as objects drift through the boundary.


Bounds in local space

Bounds default to world space. Pass mat4Model to interpret them in any model frame and the library transforms the bounds to world before testing:

push()
translate(...)
rotateY(...)
scale(...)

// Test the OBJECT's local AABB, not a hand-derived world AABB.
const v = visibility({
  corner1:   [-1, -1, -1],
  corner2:   [ 1,  1,  1],
  mat4Model: mat4Model(),         // current model matrix from the stack
})
pop()

For an object stored as { mesh, mat4Model } with bounds defined once in mesh-local coordinates, this is the natural form — the bounds describe the mesh forever, and mat4Model carries them into the world. AABBs are transformed conservatively (all eight corners projected, then re-fitted into a world-space AABB); spheres take their centre transformed and radius scaled by the matrix’s largest column length. Skip mat4Model and you fall back to interpreting bounds as already in world space, which is what the sketch above does (every primitive’s AABB is computed from position and extents directly).


Testing against any camera

The “current camera” is the renderer’s active one. To test against a different camera — the test camera in this sketch, a light source’s frustum, a portal’s view — you need its eye and projection to be live on the renderer when bounds() runs. The cleanest way is to set them inside an fbo.begin/end scope, which saves and restores the renderer’s matrix state. Inside the scope, the global camera() and perspective() / ortho() functions write directly to renderer state; bounds() reads that state and snapshots the six world-space planes; fbo.end() pops back to the orbit camera’s matrices.

scopeFbo.begin()
camera(0, -60, 360,   0, 0, 0,   0, 1, 0)            // test cam view
perspective(PI / 4, width / height, 80, 580)          // test cam projection
mat4Eye(_eye)                                         // → save eye for viewFrustum
mat4Proj(_proj)                                       // → save proj for viewFrustum
const b = bounds({ mat4Eye: _eye })                   // → six world-space planes
scopeFbo.end()                                        // restore orbit cam state

orbitControl()                                        // operates on the restored cam

for (const m of models) {
  const v = visibility({ center: m.position, radius: m.radius, bounds: b })
  // ...
}

Two details that matter. First, bounds({ mat4Eye }) accepts an eye override but always reads projection from the renderer’s currently active state — so the projection has to be set on the renderer before the call, which is what the global perspective() / ortho() do. Second, b is a plain object holding the six planes, not a handle to renderer state — it survives fbo.end() intact, and every subsequent visibility(..., bounds: b) reads from it directly without consulting the renderer. The scopeFbo here is 1×1 and never displayed; it exists purely as a save/restore boundary for the matrix swap. Omit mat4Eye and bounds() returns the planes for the active camera (eye and projection both).

A handful of standard uses for the arbitrary-camera form:

  • Shadow map culling — test geometry against the light’s frustum to skip casters that don’t contribute to the shadow buffer.
  • Portal / dual-camera scenes — cull against whichever virtual camera is rendering each pass, independent of the user’s view.
  • Off-screen pre-passes — cull geometry for a render target whose camera differs from the on-screen one (the test camera in this sketch is exactly this).

Four combinations

The two parameters compose freely:

Current camera (default)Arbitrary camera (bounds)
World-space bounds (default)visibility({ ... })visibility({ ..., bounds })
Local-space (mat4Model)visibility({ ..., mat4Model })visibility({ ..., mat4Model, bounds })

The defaults cover the common case — world-space bounds against the active camera. mat4Model is the lever for where the bounds live; bounds is the lever for which frustum to test against. Each adds a few characters at the call site, and the four combinations together cover everything from a quick world-space cull against the current view to dual-camera shadow-map culling with mesh-local AABBs.


References

  1. p5.tree README — Visibility testingvisibility, bounds, the four-quadrant table
  2. Viewing frustum (Wikipedia) — geometry of the frustum and the plane-vs-bounds culling test
  3. Hidden-surface determination (Wikipedia) — frustum culling sits inside the broader visibility-determination family
  4. Bounding volume hierarchy (Wikipedia) — the next step up: hierarchical culling for large scenes
  5. Real-Time Rendering — the standard textbook reference for bounding volumes, culling strategies, and acceleration structures
  6. GPU color-ID picking — the dual operation: instead of “is this object on screen?”, “what object is at this pixel?”
  7. Camera interpolation — surrounding p5.tree vocabulary used here (mat4Eye, viewFrustum, beginHUD)