There’s a pattern I’ve seen in 3D engine code many times.

You have a forward direction vector and a vector pointing toward some target. You need to know if the target falls inside a field-of-view cone. So you write something like this:

float angle = acos(dot(normalize(forward), normalize(to_target)));
if (angle < fov_half_angle) {
    // target is visible
}

It looks reasonable. It produces correct results. But it’s doing a round trip through a representation that adds nothing and takes something away: it converts the dot product into an angle, then immediately compares that angle against a threshold. If you understood what the dot product actually encodes, you’d skip the middle step entirely.


The Dot Product Is a Cosine

AB
Angle between A and B 40°
dot product · A·B = |A||B| cos θ
projection of B onto A — alignment
cross product × |A×B| = |A||B| sin θ
area of parallelogram — perpendicularity
Max at θ = 0° (parallel — full overlap)
Zero at θ = 90° (perpendicular)
Zero at θ = 0° (parallel — no area)
Max at θ = 90° (perpendicular)

For two unit vectors a and b, the dot product is:

a · b = cos(θ)

where θ is the angle between them. This isn’t a coincidence. It’s the geometric meaning of what the dot product computes.

The range tells you everything you need:

  • a · b = 1: pointing in exactly the same direction
  • a · b = 0: perpendicular
  • a · b = -1: exactly opposite

So when you write acos(dot(a, b)) to get an angle, you’re computing acos(cos(θ)). You’re spending cycles undoing the cosine that the dot product just computed, in order to get back to θ so you can compare it to a threshold.

The field-of-view check should be:

const float fov_cos = cos(fov_half_angle); // compute once at startup
if (dot(normalize(forward), normalize(to_target)) > fov_cos) {
    // target is visible
}

No acos. Just a comparison against a precomputed threshold. And once you’ve internalized this, you realize you don’t need the angle at all. The cosine threshold is the configuration value. A 45-degree half-angle FOV means a dot product threshold of 0.707. They’re the same thing, and the dot product form skips the conversion entirely.

The operations you can do directly with dot products, without ever extracting an angle:

  • Is this target in front of me? dot(forward, to_target) > 0
  • Is this surface facing the light? dot(normal, light_dir) > 0
  • How much light hits this surface? max(0.0, dot(normal, light_dir)), which is just a cosine (Lambertian diffuse)
  • How aligned are these two directions? The closer dot(a, b) is to 1, the more parallel
  • Project a vector onto an axis? dot(v, axis) * axis (for unit axis)

You don’t need acos for any of these.


The Cross Product Is a Sine

The cross product of two unit vectors a and b is:

a × b = sin(θ) * n̂

where n̂ is the unit vector perpendicular to both a and b, and θ is the angle between them.

The magnitude is sin(θ). The direction is the axis of rotation from a to b by the right-hand rule.

Together, dot and cross encode a complete rotation between two vectors:

  • dot(a, b) gives you cos(θ): how far around the rotation you are
  • cross(a, b) gives you sin(θ) * axis: the axis to rotate around, scaled by how far you’ve gone

This is exactly the information content of a quaternion. When you need to construct the quaternion that rotates a onto b, you can build it directly:

Quaternion rotation_from_to(vec3 a, vec3 b) {
    // a and b must be unit vectors
    vec3  xyz = cross(a, b);       // sin(θ) * axis
    float w   = 1.0f + dot(a, b); // 1 + cos(θ) = 2cos²(θ/2)
    // Note: when a and b are antiparallel, xyz is (0,0,0) and w is 0.
    // There's no unique rotation axis in that case; handle it separately
    // by choosing an arbitrary perpendicular axis before calling this.
    return normalize(Quaternion(xyz, w));
}

No acos. No atan2. The cross product and dot product assemble directly into the quaternion. Compare to the angle-detour version:

Quaternion rotation_from_to_naive(vec3 a, vec3 b) {
    vec3  axis  = normalize(cross(a, b));
    float angle = acosf(clamp(dot(a, b), -1.0f, 1.0f)); // unstable near 0 and π
    return Quaternion::from_axis_angle(axis, angle);
}

This discards the magnitude of the cross product (which encoded sin(θ)), calls acosf to recover the angle, then pays for two more trig operations inside from_axis_angle to produce the final quaternion. That’s a round trip through angle space for a result you could have computed directly, and less numerically stable at the boundaries.


You Already Have Both

What’s interesting is cosine and sine aren’t two independent things. They’re the two components of a unit circle. For any angle θ:

cos²(θ) + sin²(θ) = 1

If dot(a, b) = cos(θ), then length(cross(a, b)) = sin(θ) for unit vectors (where 0 ≤ θ ≤ π). You can reconstruct the angle at any time: atan2(length(cross(a, b)), dot(a, b)). But you already had cos(θ) and sin(θ). The angle itself is usually not what you need. It’s just a way of expressing the same information in a different form.

Lighting is the clearest example of this. Standard Blinn-Phong shading:

vec3  H        = normalize(L + V);
float diffuse  = max(0.0f, dot(N, L));
float specular = powf(max(0.0f, dot(N, H)), shininess);
vec3  color    = albedo * diffuse + vec3(specular);

No angles anywhere. The physical model of light reflection involves cosines of incidence and reflection angles, but you don’t extract those angles. The dot product is the cosine. GPUs evaluate millions of these per frame, all in dot products, none of them going through acos.


What Goes Wrong When You Work in Angles

Going through angle space isn’t just inefficient. It introduces failure modes that staying in vector space avoids entirely.

Numerical precision near the limits. acos is poorly conditioned near 0 and π. For nearly-parallel unit vectors, dot(a, b) is close to 1.0, and floating point has good precision there. But acos(0.9999998f) produces a small angle with large relative error. Any downstream arithmetic inherits that error. The dot product was fine. Extracting the angle broke it.

Wrapping. Angles wrap at 2π. Once you’re accumulating or comparing angles, you need to normalize them constantly. Is -0.1 radians close to 2π - 0.1 radians? Numerically no. Geometrically yes. Vectors don’t have this problem. The relationship between two vectors doesn’t have a concept of accumulated full rotations.

Euler angles and gimbal lock. Representing 3D orientation as three sequential rotations is convenient for human input but unreliable as an internal representation. When two rotation axes align, you lose a degree of freedom and rotations become degenerate. This isn’t a rare edge case: it happens at predictable, common configurations (looking straight up, full roll). Quaternions avoid it entirely because they encode rotation as a single rotation around a single axis, which is exactly what cross and dot give you.


Operations That Stay in Vector Space

A few operations I’ve seen done the angle-detour way, and the direct versions:

Which side is the target on? (2D)

// Angle version: atan2 is expensive and wrapping is fragile
float angle_to_target = atan2f(to_target.y, to_target.x);
float angle_forward   = atan2f(forward.y, forward.x);
float diff = angle_to_target - angle_forward;
if (diff >  M_PI) diff -= 2.0f * M_PI;
if (diff < -M_PI) diff += 2.0f * M_PI;

// Vector version: cross product z-component is the signed sine
// (right-handed coordinate system, y pointing up)
float side = forward.x * to_target.y - forward.y * to_target.x;
// positive: target is to the left, negative: to the right
// no wrapping, no atan2

Reflecting a vector:

vec3 reflect(vec3 v, vec3 n) {         // n must be unit length
    return v - 2.0f * dot(v, n) * n;
}

Just a dot product. dot(v, n) is the projection of v onto n, so 2 * dot(v, n) * n is twice the normal component. No angle involved.

Checking approximate alignment:

// Angle version: acos is slow and fragile near parallel
bool nearly_facing(vec3 a, vec3 b, float max_angle) {
    return acosf(dot(a, b)) < max_angle;
}

// Vector version: compare cosines directly
// Note: the parameter type changed — think in cosine thresholds from the start
bool nearly_facing(vec3 a, vec3 b, float cos_threshold) {
    return dot(a, b) > cos_threshold;
}

When You Actually Need an Angle

There are legitimate uses for explicit angles. Knowing where they belong keeps you from over-correcting.

Display to a user. “Your aim is 12° off target” requires converting to degrees. Do it at the last moment, on the way out to the UI layer. Don’t store degrees internally.

Physics with angular velocity. Rigid body simulation accumulates angular velocity in radians per second and integrates it through time. This is genuinely angle arithmetic, and it’s the right tool for the job.

Procedural content with designer-specified parameters. A joint that should rotate “45 degrees over 10 frames” is specified by a human in angle terms. Accept that at the boundary, convert to quaternions immediately, and work in quaternions from there.

The pattern is consistent: angles at system boundaries where humans set values or where outputs need to be human-readable. Vectors and quaternions internally.


Dot and Cross Products Are All You Need

Everything you need for orientation work in 3D is already encoded in the vectors. The dot product gives you cos(θ). The cross product gives you sin(θ) and the rotation axis. That’s the complete description of the angular relationship between two directions.

Extracting an explicit angle doesn’t give you more information. It gives you a less stable representation of the same information, in a form that wraps, that’s sensitive to floating point at the boundaries, and that most downstream operations immediately convert back into trig values anyway.