moonmath the arithmetic of space

the workshop

One matrix, four dials

A perspective camera, a unit cube and the depth it produces. The numbers are computed in this page by the library itself, compiled to JavaScript; the drawing is only the ink that follows from them.

drawn from the matrix the library returned

Drag a dial. The cube is the same cube throughout; only the camera changes.

the view-projection matrix, column by column


near plane → · far plane →

The red row is the depth row: the only one that mentions the near and far planes, and the only one that changes when you move far.

§ 1

What the page calls

Everything on this page comes from one call. The page passes five numbers — the field of view in degrees, the eye's distance along z, the two planes and the aspect ratio of the sheet — and the library answers with the matrix and the projected corners.

the whole of it
let eye = @moonmath.Vec3::new(1.6, 1.4, distance)
let view = @moonmath.Mat4::look_at(eye, @moonmath.Vec3::zero(), @moonmath.Vec3::unit_y())
let projection = @moonmath.Mat4::perspective(
  @moonmath.to_radians(field_of_view),
  aspect,
  near,
  far,
)
let view_projection = projection * view

// Eight corners of a cube from -1 to 1, projected one at a time.
let ndc = view_projection.transform_point(corner)

The bridge that carries the answer across is a dozen lines in src/examples/web/: it writes the sixteen numbers and the eight points into a string the page can read. Nothing is reimplemented on the other side — the ink is the only thing the page adds.

watched, not assumed

Move the far dial and the cube does not move. The first two rows of the projection do not mention \(f\): it takes part only in depth and in clipping. The curve, which is all depth, moves a great deal.

§ 2

Why the curve bends

Depth is a projection of a projection. The clip-space \(z\) is affine in the distance, and then it is divided by \(w\), which is the distance itself — so what arrives is a hyperbola:

$$ z_{\text{ndc}}(d) = \frac{f}{f-n} - \frac{fn}{f-n}\cdot\frac{1}{d} $$

At the near plane \(d = n\) the two terms cancel and the depth is zero; at the far plane \(d = f\) the second term is \(n/(f-n)\) and what is left is one. Between them, the curve is already at nine tenths of its height when the distance is ten times the near plane, whatever the far plane is:

$$ \frac{f}{f-n} - \frac{fn}{f-n}\cdot\frac{1}{10n} = \frac{f}{f-n}\left(1 - \frac{1}{10}\right) = 0.9\cdot\frac{f}{f-n} $$

This is the reason a depth buffer spends its precision where the eye can tell the difference, and the reason a near plane of 0.01 costs more than it looks: it buys a hundredth of a metre and charges the whole buffer for it.

§ 3

A special function, drawn from the library

The error function erf(x) and its complement erfc(x), sampled by moonmath/special compiled to JavaScript and drawn here as ink. The page plots the numbers the library returned, over -3..3.

The solid line is erf, from -1 to 1; the faint line is erfc, its mirror about one half.

The two lowest modes of a circular drum, J_0 and J_1, over 0..20. They are the same call into the same package, and the curve crosses the point where the series gives way to the asymptotic expansion without a seam — which is the whole reason for choosing between them by argument rather than by taste.

J_0 is the solid line, J_1 the faint one; the zeros are where a struck drum has no motion at that radius.