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