Chapter 3 · the prototype's tests
What a Meshlet test is
Key idea. A drawn Instance's Meshlet can be dropped whole when its box lies outside the view frustum, when in phase two the depth pyramid hides its box, or when its normal cone shows the camera sees all of its triangles from behind. The cone test needs only a sphere that holds the triangles, so the sphere round the Meshlet's box will do.
The prototype tests each Meshlet of an Instance that has already passed the Instance tests. A Meshlet is kept or dropped whole, never triangle by triangle: its record stays, and its indices are written or not.
The box tests, again
The frustum and occlusion tests are the Instance's, given a smaller box. clip_space_bounds takes the box's corners instead of a Mesh, and in_view_frustum and occluded run unchanged (Instance Culling Explained, chapter 4; Depth Pyramid Explained). The frustum has five planes, four sides and the near one, since with reverse-Z and no far plane there is no sixth (ADR 0015). Phase one has no depth pyramid yet, so it keeps Meshlets that are hidden behind others. The prototype's GPU test shows it: an Instance entering view in its second frame has phase two drop a Meshlet behind a nearer quad, and in its third, phase one keeps it.
The normal cone
The third test asks whether the camera sees every triangle of the Meshlet from behind, as the rasterizer would cull each of them. A normal cone answers it without visiting the triangles: an axis a, and a half-angle α within which every triangle's normal lies. meshoptimizer 0.25's meshopt_computeMeshletBounds computes it, which the Mesh pool calls through the meshopt crate. It sets the cone's axis from the triangles' normals and finds the smallest dot product of any normal with it, cos α. Its cutoff is s = √(1 − cos² α) = sin α.
- Wide cones accept everything. Where cos α ≤ 0.1, so the cone is about 168° wide or more, meshoptimizer calls it degenerate and sets the cutoff to 1, which culls nothing.
- Eight bits, rounded up. The axis and cutoff are also given as Snorm8, each x/127. meshoptimizer adds the quantised axis's error to the cutoff and rounds up, so the 8-bit test never culls a Meshlet the exact cone keeps. The prototype stores those four bytes, and tests
cone.w < 1.0 first: a cutoff of 127 culls nothing.
The test without an apex
meshoptimizer's header gives two perspective tests. One needs the cone's apex. The other, which the prototype uses, needs only a bounding sphere, centre c and radius r, and the camera e: the Meshlet faces away where
dot(c − e, a) ≥ s · |c − e| + r
The header cites Real-Time Rendering, 4th edition, section 19.3, for its derivation. The prototype uses the sphere round the Meshlet's box, centred on the box with half its diagonal as radius, since the box is there already. meshoptimizer's own sphere would be tighter, but the test holds for any sphere that holds the triangles. Here is why, in this page's words. Write L = |c − e| and u = cos θ for the angle θ between c − e and the axis:
the test says u ≥ s + r/L
any normal n is within α of a, so the angle from c − e to n is at most θ + α
any triangle point p is within r of c, so
dot(p − e, n) = dot(c − e, n) + dot(p − c, n) ≥ L·cos(θ + α) − r
cos(θ + α) = u·√(1 − s²) − s·√(1 − u²) = f(u), and f(s) = 0
f′(u) = √(1 − s²) + s·u/√(1 − u²) ≥ 1/√(1 − s²) ≥ 1 for u ≥ s
so L·cos(θ + α) ≥ L·(u − s) ≥ r, and dot(p − e, n) ≥ 0
The last line says the camera lies behind the plane of every triangle the cone covers, which is what the rasterizer would cull. The sphere enters only through dot(p − c, n) ≥ −r, which every sphere that holds p gives. A larger sphere is still sound; it only culls less.
In mesh space
The cone is computed from the Mesh's own vertices, so the prototype tests it in mesh space. It moves the camera there instead of the cone: by the inverse of the Instance's rotation-scale, computed as its cofactors' transpose over its determinant. That is sound because whether a point lies behind a triangle's plane is the sign of a determinant of the triangle's corners and the point, and an affine map multiplies that by its own determinant. Where the determinant is not positive, a mirroring transform that flips winding, the cone test is skipped. It is also skipped unless the camera is a perspective one at the camera-relative origin, which the view-projection maps to clip x = y = w = 0; tests that draw with an identity view-projection are orthographic and keep every Meshlet.
Where the bounds come from
Each Meshlet's box and cone are computed when the Mesh pool loads a Mesh, not cooked into its Blob. SciFiHelmet's Mesh Blob reads 13.822 bytes per triangle against its 13.83 ceiling, which leaves no room for them. A Mesh the GPU bends, as crowd's figures are, gets its whole Mesh's box for every Meshlet and the no-cull cone, since its cooked triangles bound none of the shapes it is bent into.