Cameras: view matrices, perspective and section boxes
How a 3D view is made: the look-at view matrix, perspective and orthographic projection, the depth buffer, section boxes that clip the model, and turning a mouse click back into a ray.
The view matrix: the world as the camera sees it
var eye = new V3(12, -8, 7);var target = new V3(3, 2, 1.5);var view = LookAt(eye, target, up: new V3(0, 0, 1)); foreach (var (name, p) in new (string, V3)[] { ("target", target), ("eye", eye), ("column top", new(0, 0, 3)), ("far corner", new(6, 4, 0)) }) Console.WriteLine($"{name,-11} world {p,-14} camera {view.Point(p)}");Console.WriteLine($"distance to target: {(target - eye).Length:0.###}"); static M4 LookAt(V3 eye, V3 target, V3 up){ var f = (target - eye).Normalized(); var r = V3.Cross(f, up).Normalized(); var u = V3.Cross(r, f); var m = M4.Identity(); V3[] rows = { r, u, f * -1 }; // camera x = right, y = up, looks along -z for (int i = 0; i < 3; i++) { m.A[i, 0] = rows[i].X; m.A[i, 1] = rows[i].Y; m.A[i, 2] = rows[i].Z; m.A[i, 3] = -V3.Dot(rows[i], eye); // move the eye to the origin } return m;} sealed class M4 // 4x4, column vectors: p' = M * p{ public readonly double[,] A = new double[4, 4]; public static M4 Identity() { var m = new M4(); for (int i = 0; i < 4; i++) m.A[i, i] = 1; return m; } public static M4 Move(double x, double y, double z) { var m = Identity(); m.A[0, 3] = x; m.A[1, 3] = y; m.A[2, 3] = z; return m; } public static M4 Scale(double x, double y, double z) { var m = Identity(); m.A[0, 0] = x; m.A[1, 1] = y; m.A[2, 2] = z; return m; } public static M4 RotateZ(double degrees) { double t = degrees * Math.PI / 180, c = Math.Cos(t), s = Math.Sin(t); var m = Identity(); m.A[0, 0] = c; m.A[0, 1] = -s; m.A[1, 0] = s; m.A[1, 1] = c; return m; } public static M4 operator *(M4 p, M4 q) { var m = new M4(); for (int i = 0; i < 4; i++) for (int j = 0; j < 4; j++) for (int k = 0; k < 4; k++) m.A[i, j] += p.A[i, k] * q.A[k, j]; return m; } public V3 Point(V3 p) => Apply(p, 1); // w = 1: moved public V3 Direction(V3 v) => Apply(v, 0); // w = 0: only turned and scaled V3 Apply(V3 v, double w) => new( A[0, 0] * v.X + A[0, 1] * v.Y + A[0, 2] * v.Z + A[0, 3] * w, A[1, 0] * v.X + A[1, 1] * v.Y + A[1, 2] * v.Z + A[1, 3] * w, A[2, 0] * v.X + A[2, 1] * v.Y + A[2, 2] * v.Z + A[2, 3] * w);} readonly record struct V3(double X, double Y, double Z){ public static V3 operator +(V3 a, V3 b) => new(a.X + b.X, a.Y + b.Y, a.Z + b.Z); public static V3 operator -(V3 a, V3 b) => new(a.X - b.X, a.Y - b.Y, a.Z - b.Z); public static V3 operator *(V3 v, double k) => new(v.X * k, v.Y * k, v.Z * k); public static double Dot(V3 a, V3 b) => a.X * b.X + a.Y * b.Y + a.Z * b.Z; public static V3 Cross(V3 a, V3 b) => new(a.Y * b.Z - a.Z * b.Y, a.Z * b.X - a.X * b.Z, a.X * b.Y - a.Y * b.X); public double Length => Math.Sqrt(Dot(this, this)); public V3 Normalized() => this * (1 / Length); static string F(double v) => Math.Abs(v) < 5e-4 ? "0" : v.ToString("0.###"); public override string ToString() => $"({F(X)}, {F(Y)}, {F(Z)})";}target world (3, 2, 1.5) camera (0, 0, -14.534) eye world (12, -8, 7) camera (0, 0, 0) column top world (0, 0, 3) camera (-3.568, 1.585, -14.448) far corner world (6, 4, 0) camera (3.568, -1.585, -14.62) distance to target: 14.534
Perspective: divide by the depth
| x | y | z | w | |
|---|---|---|---|---|
var proj = Perspective(fovYDeg: 50, aspect: 16.0 / 9, near: 0.1, far: 1000); foreach (double depth in new[] { 5.0, 10, 20 }){ var top = ToPixels(proj, new V3(1, 1.5, -depth), 1920, 1080); var bottom = ToPixels(proj, new V3(1, -1.5, -depth), 1920, 1080); Console.WriteLine($"3 m column {depth,2} m away: x = {top.X:0} px, {bottom.Y - top.Y:0} px tall");} Console.WriteLine("depth buffer value against distance:");foreach (double d in new[] { 0.1, 1, 10, 100, 1000 }) Console.WriteLine($" {d,6} m -> z = {Ndc(proj, new V3(0, 0, -d)).Z:0.00000}"); static M4 Perspective(double fovYDeg, double aspect, double near, double far){ double f = 1 / Math.Tan(fovYDeg * Math.PI / 360); var m = new M4(); m.A[0, 0] = f / aspect; m.A[1, 1] = f; m.A[2, 2] = (near + far) / (near - far); m.A[2, 3] = 2 * near * far / (near - far); m.A[3, 2] = -1; // w = -z: the depth in front of the camera return m;} static V3 Ndc(M4 m, V3 p){ double[] c = new double[4]; for (int i = 0; i < 4; i++) c[i] = m.A[i, 0] * p.X + m.A[i, 1] * p.Y + m.A[i, 2] * p.Z + m.A[i, 3]; return new V3(c[0] / c[3], c[1] / c[3], c[2] / c[3]); // the perspective divide} static V3 ToPixels(M4 m, V3 p, int w, int h){ var n = Ndc(m, p); return new V3((n.X + 1) / 2 * w, (1 - n.Y) / 2 * h, n.Z); // pixel y grows downwards} sealed class M4 // 4x4, column vectors: p' = M * p{ public readonly double[,] A = new double[4, 4]; public static M4 Identity() { var m = new M4(); for (int i = 0; i < 4; i++) m.A[i, i] = 1; return m; } public static M4 Move(double x, double y, double z) { var m = Identity(); m.A[0, 3] = x; m.A[1, 3] = y; m.A[2, 3] = z; return m; } public static M4 Scale(double x, double y, double z) { var m = Identity(); m.A[0, 0] = x; m.A[1, 1] = y; m.A[2, 2] = z; return m; } public static M4 RotateZ(double degrees) { double t = degrees * Math.PI / 180, c = Math.Cos(t), s = Math.Sin(t); var m = Identity(); m.A[0, 0] = c; m.A[0, 1] = -s; m.A[1, 0] = s; m.A[1, 1] = c; return m; } public static M4 operator *(M4 p, M4 q) { var m = new M4(); for (int i = 0; i < 4; i++) for (int j = 0; j < 4; j++) for (int k = 0; k < 4; k++) m.A[i, j] += p.A[i, k] * q.A[k, j]; return m; } public V3 Point(V3 p) => Apply(p, 1); // w = 1: moved public V3 Direction(V3 v) => Apply(v, 0); // w = 0: only turned and scaled V3 Apply(V3 v, double w) => new( A[0, 0] * v.X + A[0, 1] * v.Y + A[0, 2] * v.Z + A[0, 3] * w, A[1, 0] * v.X + A[1, 1] * v.Y + A[1, 2] * v.Z + A[1, 3] * w, A[2, 0] * v.X + A[2, 1] * v.Y + A[2, 2] * v.Z + A[2, 3] * w);} readonly record struct V3(double X, double Y, double Z){ public static V3 operator +(V3 a, V3 b) => new(a.X + b.X, a.Y + b.Y, a.Z + b.Z); public static V3 operator -(V3 a, V3 b) => new(a.X - b.X, a.Y - b.Y, a.Z - b.Z); public static V3 operator *(V3 v, double k) => new(v.X * k, v.Y * k, v.Z * k); public static double Dot(V3 a, V3 b) => a.X * b.X + a.Y * b.Y + a.Z * b.Z; public static V3 Cross(V3 a, V3 b) => new(a.Y * b.Z - a.Z * b.Y, a.Z * b.X - a.X * b.Z, a.X * b.Y - a.Y * b.X); public double Length => Math.Sqrt(Dot(this, this)); public V3 Normalized() => this * (1 / Length); static string F(double v) => Math.Abs(v) < 5e-4 ? "0" : v.ToString("0.###"); public override string ToString() => $"({F(X)}, {F(Y)}, {F(Z)})";}3 m column 5 m away: x = 1192 px, 695 px tall
3 m column 10 m away: x = 1076 px, 347 px tall
3 m column 20 m away: x = 1018 px, 174 px tall
depth buffer value against distance:
0.1 m -> z = -1.00000
1 m -> z = 0.80018
10 m -> z = 0.98020
100 m -> z = 0.99820
1000 m -> z = 1.00000Orthographic: no divide
Section boxes and clipping
var box = new Aabb(new V3(0, 0, 0), new V3(6, 4, 1.6)); // section box around the ground floor var segments = new (string Name, V3 A, V3 B)[]{ ("pipe through the box", new(-2, 1, 1.2), new(8, 3, 1.2)), ("riser from below", new(5, 3, -1), new(5, 3, 4)), ("duct above the box", new(0, 2, 2.5), new(6, 2, 2.5)),};foreach (var (name, a, b) in segments){ var c = Clip(a, b, box); Console.WriteLine(c is { } k ? $"{name,-20} kept {k.Item1} to {k.Item2}" : $"{name,-20} hidden");} // Liang-Barsky: each face trims the range of t in which A + t(B - A) is insidestatic (V3, V3)? Clip(V3 a, V3 b, Aabb box){ double t0 = 0, t1 = 1; var d = b - a; double[] start = { a.X, a.Y, a.Z }, dir = { d.X, d.Y, d.Z }; double[] lo = { box.Min.X, box.Min.Y, box.Min.Z }, hi = { box.Max.X, box.Max.Y, box.Max.Z }; for (int k = 0; k < 3; k++) { if (Math.Abs(dir[k]) < 1e-12) { if (start[k] < lo[k] || start[k] > hi[k]) return null; // parallel and outside continue; } double ta = (lo[k] - start[k]) / dir[k], tb = (hi[k] - start[k]) / dir[k]; t0 = Math.Max(t0, Math.Min(ta, tb)); t1 = Math.Min(t1, Math.Max(ta, tb)); if (t0 > t1) return null; } return (a + d * t0, a + d * t1);} readonly record struct Aabb(V3 Min, V3 Max); readonly record struct V3(double X, double Y, double Z){ public static V3 operator +(V3 a, V3 b) => new(a.X + b.X, a.Y + b.Y, a.Z + b.Z); public static V3 operator -(V3 a, V3 b) => new(a.X - b.X, a.Y - b.Y, a.Z - b.Z); public static V3 operator *(V3 v, double k) => new(v.X * k, v.Y * k, v.Z * k); public static double Dot(V3 a, V3 b) => a.X * b.X + a.Y * b.Y + a.Z * b.Z; public static V3 Cross(V3 a, V3 b) => new(a.Y * b.Z - a.Z * b.Y, a.Z * b.X - a.X * b.Z, a.X * b.Y - a.Y * b.X); public double Length => Math.Sqrt(Dot(this, this)); public V3 Normalized() => this * (1 / Length); static string F(double v) => Math.Abs(v) < 5e-4 ? "0" : v.ToString("0.###"); public override string ToString() => $"({F(X)}, {F(Y)}, {F(Z)})";}pipe through the box kept (0, 1.4, 1.2) to (6, 2.6, 1.2) riser from below kept (5, 3, 0) to (5, 3, 1.6) duct above the box hidden
From a mouse click back to the model
V3 eye = new(12, -8, 7), target = new(3, 2, 1.5), up = new(0, 0, 1);double fovY = 50 * Math.PI / 180, aspect = 1920.0 / 1080; var f = (target - eye).Normalized();var r = V3.Cross(f, up).Normalized();var u = V3.Cross(r, f); foreach (var (px, py) in new[] { (960.0, 540.0), (1400.0, 700.0), (300.0, 900.0) }){ double nx = px / 1920 * 2 - 1, ny = 1 - py / 1080 * 2; // pixels to NDC double tan = Math.Tan(fovY / 2); var dir = (f + r * (nx * tan * aspect) + u * (ny * tan)).Normalized(); // camera space to world double t = -eye.Z / dir.Z; // where the ray meets z = 0 var hit = eye + dir * t; Console.WriteLine($"pixel ({px}, {py}): ray {dir}, floor hit {hit}");} readonly record struct V3(double X, double Y, double Z){ public static V3 operator +(V3 a, V3 b) => new(a.X + b.X, a.Y + b.Y, a.Z + b.Z); public static V3 operator -(V3 a, V3 b) => new(a.X - b.X, a.Y - b.Y, a.Z - b.Z); public static V3 operator *(V3 v, double k) => new(v.X * k, v.Y * k, v.Z * k); public static double Dot(V3 a, V3 b) => a.X * b.X + a.Y * b.Y + a.Z * b.Z; public static V3 Cross(V3 a, V3 b) => new(a.Y * b.Z - a.Z * b.Y, a.Z * b.X - a.X * b.Z, a.X * b.Y - a.Y * b.X); public double Length => Math.Sqrt(Dot(this, this)); public V3 Normalized() => this * (1 / Length); static string F(double v) => Math.Abs(v) < 5e-4 ? "0" : v.ToString("0.###"); public override string ToString() => $"({F(X)}, {F(Y)}, {F(Z)})";}pixel (960, 540): ray (-0.619, 0.688, -0.378), floor hit (0.545, 4.727, 0) pixel (1400, 700): ray (-0.28, 0.837, -0.469), floor hit (7.827, 4.489, 0) pixel (300, 900): ray (-0.809, 0.184, -0.559), floor hit (1.869, -5.695, 0)
Show a hint
Show the solution
V3 lo = new(0, 0, 0), hi = new(60, 40, 20);var centre = (lo + hi) * 0.5;double radius = (hi - lo).Length / 2; double fovY = 50 * Math.PI / 180, aspect = 16.0 / 9;double fovX = 2 * Math.Atan(Math.Tan(fovY / 2) * aspect);double half = Math.Min(fovX, fovY) / 2;double distance = radius / Math.Sin(half); var eye = centre + new V3(1, -1, 0.6).Normalized() * distance;Console.WriteLine($"sphere radius {radius:0.000} m, horizontal fov {fovX * 180 / Math.PI:0.00} deg");Console.WriteLine($"distance {distance:0.000} m, eye at {eye}"); readonly record struct V3(double X, double Y, double Z){ public static V3 operator +(V3 a, V3 b) => new(a.X + b.X, a.Y + b.Y, a.Z + b.Z); public static V3 operator -(V3 a, V3 b) => new(a.X - b.X, a.Y - b.Y, a.Z - b.Z); public static V3 operator *(V3 v, double k) => new(v.X * k, v.Y * k, v.Z * k); public static double Dot(V3 a, V3 b) => a.X * b.X + a.Y * b.Y + a.Z * b.Z; public static V3 Cross(V3 a, V3 b) => new(a.Y * b.Z - a.Z * b.Y, a.Z * b.X - a.X * b.Z, a.X * b.Y - a.Y * b.X); public double Length => Math.Sqrt(Dot(this, this)); public V3 Normalized() => this * (1 / Length); static string F(double v) => Math.Abs(v) < 5e-4 ? "0" : v.ToString("0.###"); public override string ToString() => $"({F(X)}, {F(Y)}, {F(Z)})";}sphere radius 37.417 m, horizontal fov 79.32 deg distance 88.535 m, eye at (87.631, -37.631, 44.579)
Remember
A 3D view is world → camera (view matrix) → clip space (projection) → divide by w → pixels. The look-at matrix has rows right, up and −forward, plus a move that puts the eye at the origin. Perspective copies depth into w so x and y are divided by distance; orthographic leaves w = 1. Depth precision is uneven: keep the near plane as far out as possible, or use reversed-Z. Section boxes clip with six planes (Liang–Barsky for segments); picking runs the pipeline backwards to get a world ray.