Normals And Binormals
Knowing the Tangent (the Forward direction) of a curve is not enough to build 3D geometry. Imagine sweeping a flat 2D rectangle along a roller coaster track. The Tangent tells the rectangle which way to face, but it doesn't tell it how much to "roll" or "bank" side-to-side!
Degrees of Freedom:
1# Extruding a Shape Along a Curve2def sweep_profile(profile_polygon, curve):3 """How to make a pipe or ribbon."""45 mesh = Mesh()67 for t in range(0, 1.0, 0.05):8 # 1. We need a coordinate system at every step9 frame = get_reference_frame(curve, t)1011 # 2. We align our 2D shape to that coordinate system12 aligned_profile = align_to_frame(profile_polygon, frame)1314 # 3. Add to mesh and connect to the previous step...15 mesh.add_loop(aligned_profile)1617 return mesh
We found the Tangent (Velocity) by taking the First Derivative. To find the Normal (Up), we take the Second Derivative. In physics, the derivative of Velocity is Acceleration.
The Pull of the Curve:
1# Calculating the Second Derivative (Acceleration)2def get_acceleration(p0, p1, p2, t):3 """Calculates the physical acceleration (pull) of the curve."""45 # 1. Take the mathematical derivative of the First Derivative formula!6 # For a Quadratic Bezier, the 2nd derivative formula is CONSTANT:7 # 2 * (p2 - 2*p1 + p0)89 accel_vector = 2 * (p2 - (2 * p1) + p0)1011 return accel_vector
By combining the First Derivative (Velocity) and the Second Derivative (Acceleration), we finally have enough information to build a complete 3D coordinate system! This is the legendary Frenet-Serret Frame.
Cross Product Magic:
1# Calculating the Frenet-Serret Frame2def get_frenet_frame(curve, t):3 """The standard math formula for a moving coordinate system."""45 # 1. Forward Vector (Tangent)6 vel = curve.get_velocity(t)7 T = vel.normalize()89 # 2. Up Vector (Normal)10 accel = curve.get_acceleration(t)11 N = accel.normalize()1213 # 3. Right Vector (Binormal)14 # The Cross Product finds a 3rd vector 90 degrees to both T and N.15 B = T.cross(N).normalize()1617 return ReferenceFrame(T, N, B)
The Frenet-Serret frame is mathematically perfect, but for 3D modeling, it is a disaster! If you try to sweep a rectangle using a pure Frenet frame, the resulting ribbon will often snap and twist violently.
The Flipping Normal:
1# The Inflection Point Problem2def sweep_frenet(curve):3 """Why pure math fails in 3D modeling."""45 mesh = Mesh()67 for t in range(0, 1.0, 0.01):8 # 1. Get the pure mathematical Frenet frame9 frame = get_frenet_frame(curve, t)1011 # 2. What happens if the curve bends Left, and then bends Right?12 # The Acceleration vector (Up axis) instantly flips 180 degrees13 # at the inflection point between the two curves!1415 # 3. If we sweep a shape along this frame, the shape will16 # violently snap upside-down exactly at that point.17 mesh.add_profile(frame)1819 return mesh
To fix the flipping problem of the Frenet frame, the graphics industry uses the Parallel Transport Frame (also called the Bishop Frame).
Twist-Free Propagation:
t = 0. 3. As we move slightly forward, the Tangent bends by a certain angle. 4. We simply take our old Normal and Binormal vectors, and rotate them by that exact same angle! 5. Because we ignore the Second Derivative (Acceleration) entirely, inflection points are completely ignored. The frame glides smoothly without ever flipping!1# Parallel Transport Frame (Bishop Frame)2def get_bishop_frame(curve, t_steps):3 """A twist-free frame for 3D modeling."""45 frames = []67 # 1. Calculate the initial frame manually at t=08 current_frame = get_initial_frame(curve)9 frames.append(current_frame)1011 for t in t_steps[1:]:12 # 2. Get the new Forward direction (Tangent)13 new_tangent = curve.evaluate_derivative(t).normalize()1415 # 3. Calculate the angle and rotation axis between the16 # old Tangent and the new Tangent17 axis = current_frame.tangent.cross(new_tangent).normalize()18 angle = math.acos(current_frame.tangent.dot(new_tangent))1920 # 4. Rotate the old Normal and Binormal by that exact amount!21 # This completely ignores Acceleration, preventing flipping.22 new_normal = current_frame.normal.rotate(axis, angle)23 new_binormal = current_frame.binormal.rotate(axis, angle)2425 current_frame = ReferenceFrame(new_tangent, new_normal, new_binormal)26 frames.append(current_frame)2728 return frames
The Bishop frame is great because it has zero twisting. But what if we want our ribbon to twist? What if we want the start of a roller coaster track to be flat, but the end of the track to be banked 90 degrees?
Guided Sweeps:
t), we smoothly interpolate that twist and apply it on top of the Bishop frame.1# Interpolating User Up-Vectors2def sweep_with_guides(curve, start_up, end_up):3 """Controlling twist manually using quaternions."""45 # 1. Get the Bishop Frame (Zero Twist)6 frames = get_bishop_frame(curve)78 for i in range(len(frames)):9 t = i / (len(frames) - 1)10 frame = frames[i]1112 # 2. How much should we twist at this percentage?13 # We blend smoothly between the User's Start and End rotations14 current_twist = slerp(start_up, end_up, t)1516 # 3. Apply the twist to the Bishop frame17 frame.normal = frame.normal.rotate(frame.tangent, current_twist)18 frame.binormal = frame.binormal.rotate(frame.tangent, current_twist)1920 return frames
To understand why Differential Geometry is essential for 3D engine programmers, we can look at the two methods side-by-side.
Frenet vs Bishop:
1# Comparing Ribbon Sweeps2def generate_ribbons(curve):3 """Why we care about this math."""45 # 1. Frenet Sweep (Mathematically pure, visually broken)6 frenet_ribbon = sweep_profile(profile, get_frenet_frames(curve))78 # 2. Bishop Sweep (Visually perfect, mathematically relaxed)9 bishop_ribbon = sweep_profile(profile, get_bishop_frames(curve))1011 return frenet_ribbon, bishop_ribbon