top of page

How to implement multi-threaded frustum culling in Unity C#

In 3D rendering, "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

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?


Learning resources

You can download the example project we use in this tutorial with this link.

Frustum culling Unity example

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 tutorial


Frustum culling algorithm: frustum planes vs. AABB

Frustum culling in 2D

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 result

Attach this code to a game object and move it (or the camera) around to see the algorithm result:


The code is included in the learning resources link, if you've downloaded it, open the Demo_FrustumCulling scene:

Frustum culling Unity example

Multi-threaded frustum culling with Jobs

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:

Frustum culling Unity example

Wrap up

And that's how we do 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 the next Polaris 3.

In the next post, we will learn how to tackle that with cell-based/quadtree culling.

Curious about Polaris? Take a look at the current version:



833 views0 comments

Comments


bottom of page