Recipe: Vector Algebra
The problem
You are doing 3D work (graphics, physics, a game) and need the four
staples: dot product, length, normalization, and cross product. Written as
scalar loops they are noisy and easy to get subtly wrong. Zig’s @Vector
lets you write them with ordinary operators, so the code reads like the math
it implements and the compiler emits SIMD.
Two steps trip people up: collapsing a vector back to a single number, and pushing a single number back out into a vector.
The plan
- Pick a vector type.
@Vector(3, f32)is threef32lanes. - Dot product is
@reduce(.Add, a * b): multiply lane-by-lane, then add the lanes together. - Length is
sqrt(dot(v, v)), which reuses the dot you already wrote. - Normalize divides by the length.
@splat(len)turns the scalar length into a vector so the division is one operation. - Cross is the exception: it references specific lanes, so it cannot be written width-generic like the others.
const std = @import("std");
const Vec3 = @Vector(3, f32);
// Element-wise multiply, then a horizontal add. Works for any vector width,
// so `dot` is written once and reused by `length` below.
fn dot(a: Vec3, b: Vec3) f32 {
return @reduce(.Add, a * b);
}
fn length(v: Vec3) f32 {
return std.math.sqrt(dot(v, v));
}
// Divide every lane by the magnitude. `@splat` broadcasts the scalar length
// into a vector so the division is one SIMD op, not a loop.
fn normalize(v: Vec3) Vec3 {
const len = length(v);
if (len < 1e-6) return v; // a zero vector has no direction to keep
const len_vec: Vec3 = @splat(len);
return v / len_vec;
}
// Cross is the one operation that is genuinely 3D: it reads specific lanes,
// so it cannot be written width-generic like dot.
fn cross(a: Vec3, b: Vec3) Vec3 {
return .{
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
};
}
fn printVec(out: anytype, v: Vec3) !void {
try out.print("({d:.3}, {d:.3}, {d:.3})", .{ v[0], v[1], v[2] });
}
pub fn main(init: std.process.Init) !void {
var buf: [1024]u8 = undefined;
var file_writer = std.Io.File.stdout().writerStreaming(init.io, &buf);
const out = &file_writer.interface;
const a = Vec3{ 1.0, 0.0, 0.0 };
const b = Vec3{ 0.5, 0.5, 0.0 };
try out.print("dot = {d:.3}\n", .{dot(a, b)});
const v = Vec3{ 0.0, 3.0, 4.0 };
try out.print("length = {d:.3}\n", .{length(v)});
const n = normalize(v);
try out.writeAll("normal = ");
try printVec(out, n);
try out.print(" (len {d:.3})\n", .{length(n)});
// Right cross Up is Forward in a right-handed frame.
const right = Vec3{ 1.0, 0.0, 0.0 };
const up = Vec3{ 0.0, 1.0, 0.0 };
try out.writeAll("cross = ");
try printVec(out, cross(right, up));
try out.writeAll("\n");
try out.flush();
}@reduce collapses lanes to a scalar
a * b multiplies element-wise and stays a vector. To get a dot product you
still need the horizontal step: add lane 0 plus lane 1 plus lane 2 into one
number. That is @reduce(.Add, product). The same builtin does .Min,
.Max, .Mul, and the bitwise reductions, so it is the general way to turn
a vector into a scalar. Because dot only uses @reduce and *, it works
for any width, and length gets it for free.
@splat goes the other way
Normalization needs to divide every lane by one scalar, the length. You
cannot divide a vector by a scalar directly. @splat(len) broadcasts the
scalar into a vector whose lanes are all len, and then v / len_vec is a
single lane-wise division. The guard on a near-zero length matters: a zero
vector has no direction, and dividing by its length would produce nan
lanes that then poison every later computation silently.
Cross is the odd one out
Dot, length, and normalize never care which lane is which, so they generalize
to any size. Cross does: it is defined only for 3D and each output lane reads
two specific input lanes. That is why it takes @Vector(3, f32) explicitly
and indexes with a[1], a[2], and so on. Right × Up = Forward in a
right-handed frame, which the snippet prints as (0, 0, 1).
Why the output is rounded
The result prints normal = (0.000, 0.600, 0.800) (len 1.000). The length
reads as exactly one only because the output is rounded to three places.
0.6 and 0.8 are not exactly representable in f32, so the true squared
sum is a hair above one and its square root a hair below. This is normal
float behavior, not a bug in the vector path. When you compare vector
lengths, compare against a tolerance rather than testing equality with 1.0.
Variations
- Other widths: the same
dot,length, andnormalizework on@Vector(2, f32)or@Vector(4, f32)unchanged; onlycrossis pinned to three lanes. - Doubles: swap
f32forf64when you need the precision and can spend the lanes. - Building on it: reflection, projection, and lerp are all a dot, a
@splat, and arithmetic away, so they follow the same shape. - Bulk arithmetic instead of geometry:
the SIMD dot product recipe uses the same
@reduceover long arrays, with a scalar tail, when the vectors are data rather than points in space.