From e73a7e7bfc580d1f9225ad9ff7d2c753c320aabd Mon Sep 17 00:00:00 2001 From: tandede <1090179959@qq.com> Date: Thu, 27 Aug 2026 06:32:44 -0700 Subject: [PATCH] Fix coordinate indexing in frustum face culling (#2044) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: Fixes the coordinate indexing used by frustum face culling, and keeps the now-live culling from deleting faces that straddle the camera plane. `face_verts` has shape `[F, 3, 3]`, where the last two dimensions are the vertex and the xyz coordinate. `_get_culled_faces` indexed it as `face_verts[:, axis]`, which picks one whole vertex out of every face rather than one coordinate out of all three vertices, so `verts_clipped.sum(1) == 3` was asking whether a single vertex was outside the plane on all three axes at once. Every other access in the file agrees with the documented layout — `clip_faces` reads z as `face_verts_unclipped[:, :, 2]`. The fix selects the coordinate with `face_verts[:, :, axis]` and reduces with `all(dim=1)`, which states the documented condition directly: a face is culled only when all three of its vertices lie outside the same plane. Because the old indexing almost never fired, this is the first time frustum culling does real work, and that exposes a second problem. `_get_culled_faces` runs on the unclipped face verts, and `rasterize_meshes` passes `left`/`right`/`top`/`bottom` in NDC while z stays in world space. The perspective divide mirrors vertices behind the camera through the origin, so a triangle straddling the camera plane can have all 3 projected vertices outside the same xy plane while the part of it in front of the camera still crosses the frustum. At fov 90, for instance, the vertices `(-1.1, 0, 1)`, `(100, 0, -1)` and `(100, 0.1, -1)` all project to x < -1, yet the edge from the first to the second passes through x = 0 while still at z > 0. Culled faces are classified as case 2 and dropped outright, before z clipping could have salvaged them, so that triangle would vanish from the render. xy culling is therefore now applied only to faces lying entirely in front of the clipping plane (`z >= z_clip_value`, or `z > 0` when no clip value is set), and only when `perspective_correct` is set; straddling faces are left to the z clipping step. Orthographic projections keep usable xy coordinates behind the camera and are unaffected, as is culling on the z axis, which uses world coordinates throughout. The `frustum.cull` check also moves out of the per-plane loop into an early return. For blast radius: `RasterizationSettings.cull_to_frustum` defaults to `False`, but `rasterize_meshes_python` defaults it to `True`. Fixes https://github.com/facebookresearch/pytorch3d/issues/1936. Pull Request resolved: https://github.com/facebookresearch/pytorch3d/pull/2044 Test Plan: `buck2 test fbcode//vision/fair/pytorch3d:tests`, filtered to the culling and rendering tests (`--regex 'test_render_meshes_clipped|test_render_meshes\b|clip'`): 34 passed, 0 failed, 8 skipped. The skips are the OpenGL-only tests. Two new unit tests over `_get_culled_faces`: - `test_cull_faces_uses_coordinate_axis` — for each of the 6 planes, a face whose 3 vertices all lie outside it is culled; and a face intersecting the left plane stays visible for 3 different vertex orders. Fails on the old `face_verts[:, axis]` indexing. - `test_cull_faces_straddling_perspective_camera` — a face with one vertex in front of the camera and two behind it, all 3 projecting outside the left plane, is not culled under a perspective projection (with and without a `z_clip_value`) but is culled under an orthographic one; and z-axis culling still fires for a face lying entirely behind `znear`. Negative control: with the xy gating disabled and nothing else changed, this test fails on 2 assertions, so it pins the new behaviour rather than restating it. `arc lint` on `pytorch3d/renderer/mesh/clip.py` and `tests/test_render_meshes_clipped.py`: no issues. Reviewed By: MichaelRamamonjisoa Differential Revision: D117200371 Pulled By: bottler fbshipit-source-id: 99422fc69f4f5725bd82f6af205a0a333a4aef40 --- pytorch3d/renderer/mesh/clip.py | 29 ++++++++++-- tests/test_render_meshes_clipped.py | 72 +++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 4 deletions(-) diff --git a/pytorch3d/renderer/mesh/clip.py b/pytorch3d/renderer/mesh/clip.py index b0e41cb5..c6db01b0 100644 --- a/pytorch3d/renderer/mesh/clip.py +++ b/pytorch3d/renderer/mesh/clip.py @@ -182,17 +182,38 @@ def _get_culled_faces(face_verts: torch.Tensor, frustum: ClipFrustum) -> torch.T faces_culled = torch.zeros( [face_verts.shape[0]], dtype=torch.bool, device=face_verts.device ) + if not frustum.cull: + return faces_culled + + # With a perspective camera the xy coordinates are in NDC space, where the + # perspective divide mirrors vertices behind the camera through the origin. + # A face with such a vertex can therefore have all 3 xy coordinates outside + # the same plane while the part of it in front of the camera still crosses + # the frustum, so only faces lying entirely in front of the clipping plane + # may be culled on the x and y axes. The z axis uses world coordinates and + # is unaffected. + xy_cullable = None + if frustum.perspective_correct: + if frustum.z_clip_value is not None: + verts_in_front = face_verts[:, :, 2] >= frustum.z_clip_value + else: + verts_in_front = face_verts[:, :, 2] > 0 + xy_cullable = verts_in_front.all(dim=1) + for plane in clipping_planes: clip_value, axis, op = plane # If clip_value is None then don't clip along that plane - if frustum.cull and clip_value is not None: + if clip_value is not None: if op == "<": - verts_clipped = face_verts[:, axis] < clip_value + verts_clipped = face_verts[:, :, axis] < clip_value else: - verts_clipped = face_verts[:, axis] > clip_value + verts_clipped = face_verts[:, :, axis] > clip_value # If all verts are clipped then face is outside the frustum - faces_culled |= verts_clipped.sum(1) == 3 + plane_culled = verts_clipped.all(dim=1) + if axis != 2 and xy_cullable is not None: + plane_culled &= xy_cullable + faces_culled |= plane_culled return faces_culled diff --git a/tests/test_render_meshes_clipped.py b/tests/test_render_meshes_clipped.py index b47eef56..e48f44c8 100644 --- a/tests/test_render_meshes_clipped.py +++ b/tests/test_render_meshes_clipped.py @@ -31,6 +31,7 @@ from pytorch3d.renderer.mesh import ( convert_clipped_rasterization_to_original_faces, TexturesUV, ) +from pytorch3d.renderer.mesh.clip import _get_culled_faces from pytorch3d.renderer.mesh.rasterize_meshes import _RasterizeFaceVerts from pytorch3d.renderer.mesh.rasterizer import MeshRasterizer, RasterizationSettings from pytorch3d.renderer.mesh.renderer import MeshRenderer @@ -194,6 +195,77 @@ class TestRenderMeshesClipping(TestCaseMixin, unittest.TestCase): ) return clipped_faces + def test_cull_faces_uses_coordinate_axis(self): + """ + A face is culled when all 3 of its vertices are outside the same + clipping plane, not when one vertex is outside it on all 3 axes. + """ + planes_and_faces = ( + ({"left": -1.0}, [-2.0, 0.0, 1.0]), + ({"right": 1.0}, [2.0, 0.0, 1.0]), + ({"top": -1.0}, [0.0, -2.0, 1.0]), + ({"bottom": 1.0}, [0.0, 2.0, 1.0]), + ({"znear": 0.0}, [0.0, 0.0, -1.0]), + ({"zfar": 2.0}, [0.0, 0.0, 3.0]), + ) + for frustum_kwargs, vertex in planes_and_faces: + with self.subTest(frustum_kwargs=frustum_kwargs): + face_verts = torch.tensor([[vertex, vertex, vertex]]) + faces_culled = _get_culled_faces( + face_verts, ClipFrustum(**frustum_kwargs) + ) + self.assertTrue(faces_culled.item()) + + # A face intersecting the left plane must remain visible regardless of + # which of its vertices is first in the face. + face_verts = torch.tensor( + [[[-2.0, -2.0, -2.0], [0.0, 0.0, 1.0], [0.0, 0.0, 1.0]]] + ) + for order in ([0, 1, 2], [1, 2, 0], [2, 0, 1]): + with self.subTest(order=order): + faces_culled = _get_culled_faces( + face_verts[:, order], ClipFrustum(left=-1.0) + ) + self.assertFalse(faces_culled.item()) + + def test_cull_faces_straddling_perspective_camera(self): + """ + The perspective divide mirrors vertices behind the camera through the + origin, so a face straddling the camera plane can have all 3 of its + projected vertices outside the same plane while the part of it in front + of the camera still crosses the frustum. Such faces must not be culled + on the x and y axes. + """ + # xy are in NDC and z is in world coordinates. Only the first vertex is + # in front of the camera, and the edges joining it to the other two + # cross the frustum before reaching z = 0. + straddling = torch.tensor( + [[[-1.1, 0.0, 1.0], [-100.0, 0.0, -1.0], [-100.0, 0.1, -1.0]]] + ) + # The same projected face with every vertex in front of the camera is + # genuinely outside the left plane. + in_front = straddling.clone() + in_front[:, :, 2] = 1.0 + + for frustum_kwargs in ({}, {"z_clip_value": 1e-2}): + with self.subTest(frustum_kwargs=frustum_kwargs): + frustum = ClipFrustum( + left=-1.0, perspective_correct=True, **frustum_kwargs + ) + self.assertFalse(_get_culled_faces(straddling, frustum).item()) + self.assertTrue(_get_culled_faces(in_front, frustum).item()) + + # An orthographic projection leaves the xy coordinates of vertices + # behind the camera usable, so there the face is culled. + frustum = ClipFrustum(left=-1.0, perspective_correct=False) + self.assertTrue(_get_culled_faces(straddling, frustum).item()) + + # Culling on the z axis uses world coordinates and is unaffected. + behind = straddling.clone() + behind[:, :, 2] = -1.0 + frustum = ClipFrustum(znear=0.0, perspective_correct=True) + self.assertTrue(_get_culled_faces(behind, frustum).item()) + def test_grad(self): """ Check that gradient flow is unaffected when the camera is inside the mesh