Frustum culling is a process of determining which objects can be seen by a camera, defined by the camera's frustum planes. By excluding objects that are "out of view", we can reduce the workload on the GPU thus greatly improve performance.
Most 3D engines do this process under the hood already, but for some special cases, such as implementing a custom foliage renderer as we do in our next product Polaris 3, we need to do this manually with our own strategy.
Do you want to know how to do it? Let's dive in!
The scopes of this Unity frustum culling tutorial
In this tutorial, we will:
Create utility functions that quickly determine if an object is visible, partially visible or cannot be seen at all.
The object is represent by its axis aligned bounding box (AABB).
The functions are flexible enough to be used in single-threaded (managed code) and multi-threaded (Jobs System) context.
Why would we bother? Isn't that Unity already provided a function for this?
Unity frustum culling learning resources
You can download the example project we use in this tutorial with this link.
Calculate the camera's frustum planes
The camera's view frustum is formed by 6 planes defined by its projection (Perspective or Orthographic), field of view, near & far viewing range. These planes has their normal vector points to the "inner volume" of the frustum. In other words, these plane has their "front side" faces toward the inner volume of the frustum.
Lucky for us, there is a built in function to calculate the planes in the GeometryUtility class:
public static void CalculateFrustumPlanes(Camera camera, Plane[] planes);
Calculate the object's bounds
It's up to you do decide how to calculate its bounds. For rendering, it usually the renderer's bounds multiply with object scale. Sometime object rotation should be taken into consider.
For simplicity, we only use the object world position and scale for this Unity frustum culling tutorial
Unity frustum culling algorithm: frustum planes vs. AABB
Look at the simplified to 2D model of a frustum vs AABB test, where green box is "visible", yellow boxes are "partially visible" and red box is "culled", we observed that:
An AABB is culled if all of its vertices lie in the back of a particular plane.
An AABB is fully visible if all of its vertices lie in the front of ALL planes.
An AABB is partially visible otherwise.
Remember that the planes front side face toward the inner volume of the frustum.
In other words, we can say that:
The AABB is culled if all of its vertices lie in the back of a particular plane. Otherwise,
The AABB is partially visible if at least 1 vertex falls out side the frustum. Otherwise,
The AABB is fully visible.
We come up with the code above. After calculating the 8 corners of our AABB, we test each corner in a cascade fashion so we can break sooner. Don't bother about the IsBehindPlaneAABB and IsPointInsideFrustum just yet, we'll deal with them very soon.
The function signature looks very inconvenient, why don't we just pass a single array of planes?
What are those "ref"?
To check if an AABB is behind a plane, simply check if all of its vertices are behind that plane, using the Plane.GetSide() function:
To check if a point falls inside the view frustum, just check if it is on the front face of all planes:
Visualizing the frustum culling result
Attach this code to a game object and move it (or the camera) around to see the Unity frustum culling algorithm result:
The code is included in the learning resources link, if you've downloaded it, open the Demo_FrustumCulling scene:
Unity frustum culling in multi-threaded C# with Jobs System
The culling process usually deals with lots of object at a time. Instead of testing one by one, we can do the job in parallel. It's quite simple to do.
First, we have a struct that implement IJobParallelFor interface that invoke our TestFrustumAABB() function on each AABB:
Once again, we pass the frustum planes as 6 seperated argument, instead of using a NativeArray<Plane>, so we don't have to deal with the native array life cycle.
In each invocation, we read the AABB value at index i, do the test, then write the result to the designated array.
Below is an example component for scheduling the job:
Because we will run the job every frame, the native arrays that contain our AABBs and cull result need to be declared as class fields, and initialized with Persistent allocator, that way we can keep using them during the app lifetime.
Native containers are initialized/disposed in the OnEnable/OnDisable message. Make sure to add the [ExecuteInEditMode] attribute to the class.
In the Update() function, we copy native data to managed side after the previously scheduled job has been completed, then schedule a new job for the next frame.
Culling result are visualized in the OnDrawGizmos() function by reading the managed arrays of AABBs and cull results.
In the sample project, open the Demo_FrustumCulling_Multithreaded scene, move the camera around in the Scene view to see it in action:
Wrap up
And that's how we do Unity frustum culling using planes vs. AABB test. However, this strategy is still not enough to handle scenarios where there are lots of object packed statically in a fixed space, such as foliage renderer as we implemented in Polaris 3.
In the next post, we will learn how to tackle that with cell-based/quadtree culling.
Curious about Polaris? It's a fully featured terrain editor for low poly style, packed with an arsenal of tools such as painter, spline, stamper, erosion simulator, custom foliage renderer, etc. Take a look:
What if the object is not in the view of the camera, but it's shadow is....and what if you have many lights...it becomes very complicated very quickly.
Very useful, thank you!