Skip to main content

Triangle Occlusion Culling

Tools: Houdini, VEX

I built the Triangle Occlusion Culling HDA to support the Immersive Environments for visionOS production. It shipped as part of a suite of optimization HDAs and became a key part of my WWDC25 session, where I showed how we removed hidden geometry while protecting cinematic detail.

Backface Visibility Culling

The first pass strips away any surface that never faces a viewer sample. We seed the HDA with a configurable rig of viewpoints and evaluate triangle orientation per viewpoint. Anything that never faces the viewer is culled before later ray based runs.

// Sample a viewer position (input 1) and compare it to the primitive normal
vector view_pos = point(1, "P", i@view_index);
vector face_normal = prim_normal(0, @primnum, set(0.5, 0.5, 0.0));
vector to_view = normalize(view_pos - primuv(0, "P", @primnum, set(0.5, 0.5, 0.0)));

float alignment = dot(face_normal, to_view);
if (alignment >= 0.0) {
  setprimgroup(0, "visible_backface", @primnum, 1, "set");
}

Triangles that never satisfy the alignment check are pruned.

Geometry after backface culling pass Geometry after backface culling pass

Raycast Occlusion Culling

With the backfaces gone, dense raycasts are fired from each viewpoint to confirm whether the remaining surfaces are ever visible. Hits are logged into a primitive array, and only surfaces that receive at least one unobstructed ray survive.

Traditional occlusion passes ray-cast mesh vertices back toward the camera. That approach falls short because a “keyhole” occluder could hide every vertex on a triangle while the triangle’s interior is still visible. To avoid that, the HDA scatters an independent point cloud from each sample position outward. Even if the triangle’s corners are occluded, the surface samples still report visibility and keep the face alive.

vector view_pos   = point(1, "P", i@view_index);
vector sample_pos = point(2, "P", i@sample_index); // Dense surface samples carry i@sourceprim
vector dir        = normalize(sample_pos - view_pos);

vector hit_pos, hit_n;
int hit_prim = intersect(0, view_pos, dir, hit_pos, hit_n);
int source_prim = point(2, "sourceprim", i@sample_index);

if (hit_prim == source_prim) {
  setprimgroup(0, "visible_ray", hit_prim, 1, "set");
}

Example of sample positions used to check for visibility. Raycast occlusion pass highlighting surviving geometry

Each sample point fires millions of rays to test visibility:

Results

Combined, the two passes deliver an average 50% triangle reduction on Vision Pro environments with no visible popping. Cook times stay manageable despite tens of millions of ray checks. The HDA feeds into the broader Vision Pro LOD bake pipeline, so downstream UV atlases pack tighter and require less texture data.

Houdini Download

These occlusion-culling HDAs are available to download and plug directly into your Houdini projects, along with the other tools from the Vision Pro pipeline.

Download Houdini HDAs →