<style> .reveal-up, .reveal-left, .reveal-right, .reveal-stagger > * { opacity: 1 !important; transform: none !important; } </style>
  1. Learn
  2. BIM Math
  3. Master level
All lessons in this course

Lesson 41 of 44 16 minMaster

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.

Every 3D view in a BIM program, from the default 3D view to a rendered walkthrough, is the same pipeline of transforms from the matrices module. Each model point goes world → camera → clip space → screen pixels. The camera is just another coordinate system: put it anywhere, express every point in its axes, then flatten the result onto the screen.

Interactive

From camera to picture

eye
eye(7.50, −5.79, 5.70)
forward(−0.45, 0.78, −0.42)
right(0.87, 0.50, 0.00)
up(−0.21, 0.37, 0.91)
Perspective divides x and y by the depth, so the far columns come out smaller and parallel edges meet towards vanishing points. Widen the field of view and more fits in, but everything shrinks.

The view matrix: the world as the camera sees it

A camera is described by an eye position, a target it looks at, and which way is up. From those come three axes at right angles: forward f = unit(target − eye), right r = unit(f × up), and the camera’s own up u = r × f. The view matrix is the world-to-local transform for that frame: subtract the eye, then dot with each axis. By the convention of OpenGL, WebGL and three.js, the camera looks down its own −z axis, so the third row is −f.

LookAt.cs
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)})";}
Output
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

The target lands on the negative z axis at exactly its distance from the eye, and the eye itself goes to the origin. Points to the camera’s left get a negative x, points above its line of sight a positive y. Orbiting, panning and zooming in a BIM viewer only ever change eye and target; the matrix is rebuilt from them every frame.

Perspective: divide by the depth

In a perspective view, a thing twice as far away looks half as big. The projection matrix arranges this with homogeneous coordinates: it copies the depth into the fourth coordinate w, and the graphics card divides x, y and z by w afterwards (the perspective divide). With f = 1 / tan(fov / 2):

xyzw
row 1f / aspect000
row 20f00
row 300(n + f) / (n − f)2·n·f / (n − f)
row 400−10

Here fov is the vertical field of view, aspect is width / height, and n and f are the near and far clipping distances (the f in rows 3 and 4 is the far distance, not the focal factor). After the divide, everything visible has x, y and z between −1 and 1: the normalised device coordinates (NDC), which the viewport then scales to pixels.

Perspective.cs
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)})";}
Output
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.00000

Double the distance and the column’s height on screen halves, and its x moves towards the centre of the screen at 960 px: that is perspective. The second table shows something less obvious. The depth value is not proportional to distance: it climbs past 0.98 within the first 10 m, leaving a tiny slice of the range for everything further away. Two faces 1 cm apart, 300 m from the camera, can get the same depth value and flicker through each other (z-fighting). Keep the near plane as far out as you can, or use a reversed-Z depth buffer as modern engines do.

Orthographic: no divide

Plans, sections and elevations must not shrink with distance: a 3 m wall is 3 m long whether it is near or far. An orthographic projection simply scales a box of space (left, right, bottom, top, near, far) to the −1…1 cube, and leaves w = 1, so there is no divide. In the widget, switch to orthographic and change the distance: the picture does not change, only the size of the view box does. BIM programs often let you toggle the 3D view between the two for exactly this reason.

Section boxes and clipping

A section box is an axis-aligned box (or an oriented one) that hides everything outside it, and it is how BIM users look inside a model. On a graphics card it is six clipping planes; in code that has to know what was cut, such as cutting a pipe to the box or listing the elements a box touches, it is the Liang–Barsky algorithm. Write the segment as P(t) = A + t·(B − A). Each of the six faces cuts off the part of the t range that lies outside it; whatever survives all six is inside.

SectionBox.cs
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)})";}
Output
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

Picking runs the pipeline backwards. A click at pixel (px, py) becomes NDC coordinates, those give a direction in camera space (scaled by the field of view), and the camera’s axes turn that direction into a world ray from the eye. The ray–triangle lesson then finds what it hits. Here the ray is intersected with the floor plane z = 0 to find where on the floor the user clicked.

PickRay.cs
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)})";}
Output
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)

The centre pixel gives exactly the forward direction, so its ray passes through the target (3, 2, 1.5) and meets the floor beyond it. In a real viewer the same ray goes to the spatial index of the next lessons but one, to find which elements it might hit before testing their triangles.

Quick check

In a perspective view, a duct 40 m from the camera shows z-fighting with a ceiling 1 cm above it, but the problem goes away when the near plane moves from 0.01 m to 0.5 m. Why?

Quick check

Why do plans and sections use orthographic rather than perspective projection?

Practice

Zoom to extents: the model’s bounding box runs from (0, 0, 0) to (60, 40, 20). With a vertical field of view of 50° and a 16:9 view, find the camera distance from the box centre at which the box’s bounding sphere just fits, then the eye position if the camera looks from the direction (1, −1, 0.6). Print the sphere radius, the horizontal field of view, the distance and the eye.

Show a hint

The horizontal fov is 2·atan(tan(fovY / 2) · aspect). A sphere of radius r fits a cone of half-angle a at distance r / sin(a); use the smaller of the two fields of view.

Show the solution
Solution.cs
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)})";}
Output
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.
0 of 44 lessons completed