My renderer attempts always got stuck on the "should implement clipping" phase too, until I finally bit the bullet and managed to write a working one without much effort, independently "rediscovering" the Sutherland–Hodgman algorithm [1] as I found out later (googling it beforehand would've been cheating, of course).
The algorithm itself is fairly straightforward and intuitive, I think the biggest mental block is the weirdness of the projective space and working with homogeneous coordinates (actually the only frustum plane that you have to clip against in P₃(ℝ) is the front plane, the rest could be clipped after the perspective division, but no reason not to do it all at the same time while you're at it). The plane equations in the clip space are super simple, basically the six equations of the form ax + by + cz = w simplify to
x = ±w
y = ±w
z = ±w.
Meaning, for example, that if the x coordinate of your vertex is greater than the w coordinate, that vertex is outside the right clipping plane. The Sutherland–Hodgman itself goes something like this: # Returns true if point is inside the half-space defined by plane
def point_inside_plane(point, plane) -> bool:
# single dot product, can be further simplified
# Returns t such that the edge (p1, p2) intersects plane at lerp(t, p1, p2)
def edge_intersect_plane(edge: (Point, Point), plane) -> float:
# single dot product, can be further simplified
# Given the vertices of a simple polygon and a plane,
# returns the part of the polygon fully inside the plane
def clip_against_plane(poly: [Vertex], plane):
let result: [Vertex] = []
let [(v_1, v_2), (v_2, v_3), ..., (v_n, v_1)] = poly.edges()
for each (v_i, v_j) of the edges:
let i_inside = point_inside_plane(v_i, plane)
let j_inside = point_inside_plane(n_j, plane)
if i_inside and j_inside:
# v_j will be pushed on the next iteration!
result.push(v_i)
else if not i_inside and not j_inside:
pass # Nothing to do!
else:
# One is inside, the other is not, we have to clip
let t = edge_intersect_plane((v_i.pos, v_j.pos), plane)
# Synthetize a new vertex straddling the plane
let v_new = Vertex(
pos = lerp(t, v_i.pos, v_j.pos),
# For each vertex attribute
attrib = lerp(t, v_i.attrib, v_j.attrib)
)
if i_inside:
result.push(v_i); result.push(v_new) # discard v_j
else:
result.push(v_new); result.push(v_j) # discard v_i
return result
Then you just call this for all the planes so that the output of one call becomes the input for the next call! The end result of this process is a convex polygon (of at most nine vertices for a triangle against six planes), which can be trivially triangulated. You can make the whole process faster by precomputing so-called outcodes which allow you to avoid clipping triangles known to be entirely outside at last one plane, or entirely inside every plane.[1]: I. Sutherland and G. Hodgman. 1974. "Reentrant polygon clipping." Communications of the ACM, Volume 17, Issue. Available: https://dl.acm.org/doi/10.1145/360767.360802
This is a great comment. As someone who is familiar with R^3 geometry but not projection or clipping, I feel like I could almost implement it from your comment alone.
Can you explain more about clipping before/after the divide by w operation? Is this about avoiding coordinates with w = 0? Otherwise all the geometric ideas seem to make sense in both the world/view-space frustum and the ±1 box.