Window Culling - A Utility for Portal-Based Visibility

Standard frustum culling checks if something is on screen. This doesn’t.

This utility answers a different question: can this object be seen through this specific window? Not “is it in the camera’s view?” - “is it geometrically possible to see it through that rectangular hole in the wall?”

I have a moving train with windows. I wanted to skip updating objects that the player has no line of sight to through any window. But the same utility applies to any portal, doorframe, gap, or opening - if you can model it as a rectangle, you can cull against it.


How it works

A window is a BasePart. Its CFrame and Size give four world-space corners. From the camera position, those four corners define a pyramid - five half-space planes:

  • 4 side planes - each passes through the camera and two adjacent corners
  • 1 near plane - the window surface itself, to reject objects on the camera’s side of the wall

An object is visible through the window only if its bounding sphere intersects all five half-spaces. Fail any one → culled.

        tl -------- tr
Camera   |  window  |   [ Object? ]
         bl -------- br

4 planes from camera→corners + 1 plane at the window surface

Building the frustum

local tl = position + (-rightHalf) + upHalf
local tr = position +   rightHalf  + upHalf
local bl = position + (-rightHalf) - upHalf
local br = position +   rightHalf  - upHalf

-- Midpoint between camera and window center.
-- Always inside the volume - used to orient all normals correctly.
local insidePoint = (cameraPos + position) * 0.5

local p1n, p1o = buildPlane(cameraPos, tl, bl, insidePoint)  -- LEFT
local p2n, p2o = buildPlane(cameraPos, br, tr, insidePoint)  -- RIGHT
local p3n, p3o = buildPlane(cameraPos, tr, tl, insidePoint)  -- TOP
local p4n, p4o = buildPlane(cameraPos, bl, br, insidePoint)  -- BOTTOM
-- p5 = near plane, normal = (cameraPos - windowPos).Unit

buildPlane takes three points, computes the cross product normal, then checks insidePoint to flip if needed - handles any window orientation automatically.

The near plane rejects anything on the camera’s side of the window. Without it, objects behind the wall behind the camera would incorrectly pass.


Sphere test

Planes stored with outward normals. A sphere at center with radius is outside the frustum if:

plane.normal:Dot(center - plane.origin) > radius

Five checks, early-out on the first failure. In practice, objects clearly outside the window fail on plane 1 or 2, so the common case is very cheap.

The frustum struct uses flat fields instead of a table of tables:

export type WindowFrustum = {
    p1n: Vector3, p1o: Vector3,
    p2n: Vector3, p2o: Vector3,
    p3n: Vector3, p3o: Vector3,
    p4n: Vector3, p4o: Vector3,
    p5n: Vector3, p5o: Vector3,
    cameraPos: Vector3,
}

One less table dereference per plane per object per frame.


Debug visualizer

Getting the plane orientations right took a few iterations. I built a companion debug module that draws the frustum live each frame using pooled adornments - LineHandleAdornment for edges and normal arrows, SphereHandleAdornment for object tests, BillboardGui on invisible Part anchors for labels. Everything pre-warmed at require-time, zero allocation per frame.

Labels attach to actual Part anchors rather than StudsOffsetWorldSpace on Terrain - the latter causes flickering.


Where to get it?

Although I’ve managed to get rid of a lot of geometry issues and optimize the code so it runs in 0.165ms under a load of 6 windows and 78 objects, it’ll still take some time to polish out all the issues.

But you can test it out here Window Culling | Play on Roblox

4 Likes