In the previous chapter we rotated a plane whose axes were already known. This time, start with two arbitrary edges. We need a third direction that is perpendicular to both, even when the edges are not perpendicular to each other.
Start with two edges
Let two vectors begin at the same origin:
vec3 edgeA = vec3(1.0, 0.0, 0.0);
vec3 edgeB = vec3(0.4, 0.8, 0.4);
The points 0, edgeA, edgeA+edgeB, and edgeB form a parallelogram. Moving along either edge stays in its plane. A perpendicular direction is called a normal. It is useful when choosing a surface orientation, constructing a camera, or calculating light later.
There are two opposite perpendicular directions. The edges alone do not choose a preferred side, but their order can. We keep the right-handed world from chapter 17: X, then Y, selects positive Z.
Build the perpendicular
GLSL provides the cross product for this job:
vec3 rawNormal = cross(edgeA, edgeB);
Unlike dot, which returns a scalar, cross returns a vec3. Its components are:
vec3 rawNormal = vec3(
edgeA.y*edgeB.z - edgeA.z*edgeB.y,
edgeA.z*edgeB.x - edgeA.x*edgeB.z,
edgeA.x*edgeB.y - edgeA.y*edgeB.x
);
Each component resembles the 2D determinant from chapter 15. The three components describe signed areas in the YZ, ZX, and XY coordinate planes, with those axis orders. Together they give an oriented perpendicular in space.
Why is it perpendicular? Substitute this formula into dot(rawNormal,edgeA). Every product has an equal term with the opposite sign, so they cancel. The same happens with edgeB. This is the algebraic reason both dot products are zero in exact arithmetic.
For our edges, rawNormal=(0,−0.4,0.8). Its dot product with A is zero because its X component is zero. Its dot product with B is −0.4×0.8+0.8×0.4=0. The 0.4 component of B along A adds no new direction to the plane.
Read its length and sign
The raw cross product is generally not a unit vector:
float area = length(cross(edgeA, edgeB));
// area = length(edgeA) * length(edgeB) * sin(theta)
Here theta is the angle between the edges, from 0 to pi. The parallelogram has base length(A) and perpendicular height length(B)*sin(theta), so this is its area. Equivalently, expanding the component formula gives |A×B|²=|A|²|B|²−dot(A,B)². Using the dot-product angle formula produces the same result.
In our example the area is sqrt(0.8), approximately 0.89443. With perpendicular edges (0.5,0,0) and (0,0.5,0), the area is 0.25. The raw normal shrinks with the area, but a unit normal still has length 1.
Swap the arguments and every subtraction changes sign:
cross(edgeB, edgeA) == -cross(edgeA, edgeB); // Exact math.
The plane and its area stay the same. The normal points to the opposite side. Follow the right-hand rule: curl from the first edge toward the second through the smaller angle; your thumb gives the cross-product direction. For X and Y, the result is Z; for Y and X, it is −Z.
Decide when to normalize
Parallel or opposite edges have zero cross product. So does any pair containing a zero edge. Such inputs do not determine a unique plane normal. Do not call normalize on their cross product.
Nearly parallel edges are different: they can define a mathematical plane, but a tiny cross product amplifies rounding when normalized. The lab uses two explicit checks for its bounded inputs:
const float EPS = 0.0001;
float lenA = length(edgeA), lenB = length(edgeB);
float area = length(rawNormal);
float areaLimit = EPS * (lenA * lenB);
bool usable = lenA > EPS && lenB > EPS && area > areaLimit;
if (usable) {
vec3 normal = rawNormal / area;
// Build and draw the frame here.
}
The first checks reject very short edges in the lab's coordinate units. The relative area check rejects a small sine of the angle, independently of the edge lengths. These are demonstration policies, not a universal guarantee of numerical accuracy. Rejection does not mean every input is mathematically singular.
For A=(1,0,0), B=(1,0.00005,0), the area is positive, but the angular check pauses the frame. Increase B.y to 0.001 and it resumes. With B.y=0, the exact parallel pair has no normal. Rounded calculations can also produce zero through cancellation; a zero readout alone is not proof of exact collinearity.
The lab keeps the edges, raw cross product, and area available when the frame is paused. The normal and frame readouts become unavailable, rather than pretending a zero vector is a valid normal. Near the cutoff, accepted results may still show larger perpendicularity errors.
Complete a unit frame
A normal gives us one axis. Normalize A to get an in-plane tangent T. Then take another cross product:
// Only after the usable check:
vec3 N = rawNormal / area;
vec3 T = edgeA / lenA;
vec3 Bframe = cross(N, T);
mat3 frame = mat3(T, Bframe, N);
N is perpendicular to the plane, so it is perpendicular to T. Their cross product lies in the plane and is perpendicular to T. Because both inputs have length 1 and their angle is 90°, Bframe also has length 1 in exact arithmetic. These three unit, mutually perpendicular vectors form an orthonormal frame.
Simply normalizing edgeB would not work: it can still lean toward T. Our default frame is T=(1,0,0), N≈(0,−0.44721,0.89443), and Bframe≈(0,0.89443,0.44721). The new bitangent follows the part of B perpendicular to A, with the orientation chosen by the normal.
The column order (T,Bframe,N) is right-handed: cross(T,Bframe)=N. When we reverse only the normal's cross order, T stays fixed and both N and Bframe reverse. The input edges and the plane do not move.
Check the result with dot products and lengths. dot(N,A) and dot(N,B) should be close to zero; the three frame pairs should also be perpendicular, with lengths close to 1. The host readouts are float32 references for the built-in formulas, not GPU measurements. Editing the shader changes the drawing but not those references.