Skip to content
Perspective to NDC

Perspective to NDC

The sketch makes a normally invisible step in the rendering pipeline tangible: the perspective projection that maps the view frustum into the NDC cube. Drag the d slider — or leave animate on — and watch the scene geometry deform continuously between world space and NDC space. The frustum deforms with it.

The Perspective Transform

The GPU projects every vertex through the camera’s projection matrix PP:

pclip=PVMv\mathbf{p}_{\text{clip}} = P \cdot V \cdot M \cdot \mathbf{v}

After the perspective divide pndc=pclip/w\mathbf{p}_{\text{ndc}} = \mathbf{p}_{\text{clip}} / w, all visible geometry maps into the unit cube [1,1]3[-1, 1]^3 — Normalized Device Coordinates. The transform is rational and non-linear in depth; see Foundations / Perspective for the full derivation. The morph is constructed in frustum space rather than clip space precisely because frustum space keeps the transform invertible.

The Morph

The morph operates in the frustum camera’s coordinate space. Each vertex passes through four transformations:

localMworldVFfrustummorph(d)frustumVF1world\text{local} \xrightarrow{M} \text{world} \xrightarrow{V_F} \text{frustum} \xrightarrow{\text{morph}(d)} \text{frustum} \xrightarrow{V_F^{-1}} \text{world}

In frustum space, the perspective projection maps (x,y,z)(x, y, z) to a point on the near plane:

x=xz(1+ndc)ny=yz(1+ndc)nx' = -\frac{x}{z} \cdot (1 + \texttt{ndc}) \cdot n \qquad y' = -\frac{y}{z} \cdot (1 + \texttt{ndc}) \cdot n

The parameter ndc[0,1]\texttt{ndc} \in [0, 1] scales the projected position relative to the near plane: at ndc=0\texttt{ndc} = 0 the projection lands on the near plane itself; at ndc=1\texttt{ndc} = 1 it reaches the far plane’s footprint. The morph parameter dd interpolates linearly between the original and the projected position.

The far plane distance follows from consistency:

f=n(1+2tanϕ2(1+ndc))f = n \cdot \left(1 + 2\tan\frac{\phi}{2}(1 + \texttt{ndc})\right)

where ϕ\phi is the frustum’s vertical field of view.

View and Eye Matrices

Two matrices drive the morph — a matched pair:

VF=frustumCam.vMatrix()VF1=frustumCam.eMatrix()V_F = \texttt{frustumCam.vMatrix()} \qquad V_F^{-1} = \texttt{frustumCam.eMatrix()}

VFV_F maps world space into the frustum camera’s eye space; VF1V_F^{-1} is its exact inverse, mapping back. Both are extracted from a secondary camera that never renders to screen — it exists solely as a mathematical instrument.

function updateFrustum() {
  frustumCam.camera(0, 0, Z, ...p5.Tree.k, ...p5.Tree.j)
  frustumCam.vMatrix(vBuf)   // V_F   — world → frustum
  frustumCam.eMatrix(eBuf)   // V_F⁻¹ — frustum → world
  const far = N * (1 + 2 * tan(FOVY / 2) * (1 + NDC))
  frustumProj.perspective(FOVY, 1, N, far)
}

vBuf and eBuf are Float32Array(16) allocated once in setup — no allocation per frame. frustumProj is a p5.Matrix(4) passed to viewFrustum to draw the near and far planes correctly.

The Vertex Hook

The morph is injected into p5’s base shaders through buildColorShader and buildStrokeShader — no shader files needed. The hook runs per vertex in world space, after the model matrix has been applied:

// getWorldInputs — world space, per vertex
vec4 fPos       = uViewFrustumMatrix * vec4(inputs.position, 1.0); // world → frustum
vec2 xy         = -(fPos.xy / fPos.z) * (1.0 + ndc) * n;          // perspective divide
fPos.xy         = mix(fPos.xy, xy, d);                             // morph
inputs.position = (uEyeFrustumMatrix * fPos).xyz;                  // frustum → world
return inputs;

The same body applies to fills and strokes. The only difference is the vertex struct type: Vertex for buildColorShader, StrokeVertex for buildStrokeShader — a distinction imposed by p5’s internal shader architecture.

Uniforms are declared as callbacks and updated automatically each frame:

uniforms: {
  'mat4 uViewFrustumMatrix': () => vBuf,
  'mat4 uEyeFrustumMatrix':  () => eBuf,
  'float d':   () => panel.d.value(),
  'float ndc': () => NDC,
  'float n':   () => N,
}

The Axes Exception

The frustum axes are drawn with resetShader() inside the viewer callback:

viewer: () => {
  push()
  resetShader()
  axes({ size: 50, bits: p5.Tree.X | p5.Tree._Y | p5.Tree._Z })
  pop()
}

The axes sit at the frustum camera’s origin, which in frustum eye space is z=0z = 0. The morph divides by zz — the axes would produce a division by zero and disappear. resetShader() bypasses the morph for the axes only.

Column-Major Storage

The matrices vBuf and eBuf are column-major Float32Array(16) — the same layout GLSL mat4 uses internally. Reading the flat array left-to-right reads columns, not rows. See Foundations / Matrices for the full derivation and the cognitive trap this creates when writing matrices by hand.

p5.tree API

FunctionRole
pvMatrix(out)Capture P·V into Float32Array(16)
vMatrix(out)Frustum view matrix VFV_F
eMatrix(out)Frustum eye matrix VF1V_F^{-1}
viewFrustum(opts)Draw near/far planes
axes() / grid()Scene-space reference

Exercises

1 — Non-linear morph Replace mix(fPos.xy, xy, d) with a smooth-step or Bézier easing. How does the visual character of the deformation change? What does this imply about the relationship between the parameter space and perceptual space?

2 — Perspective light projection The frustum camera currently uses ortho. Replace it with perspective and observe how the morph changes. Why does the depth distribution look different? Relate your answer to Foundations / Perspective.

3 — Multiple frustums Add a second frustum camera with a different position and field of view. Morph the scene toward both simultaneously using two independent d parameters. Where does the geometry go when both d values are 1?

References

  1. Santell, J. 3D Projection. Interactive guide with perspective visualization.
  2. Ahn, S. H. OpenGL Projection Matrix. Derivation from first principles.
  3. Shirley, P. & Marschner, S. Fundamentals of Computer Graphics. CRC Press. Chapter 7.