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 :
After the perspective divide , all visible geometry maps into the unit cube — 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:
In frustum space, the perspective projection maps to a point on the near plane:
The parameter scales the projected position relative to the near plane: at the projection lands on the near plane itself; at it reaches the far plane’s footprint. The morph parameter interpolates linearly between the original and the projected position.
The far plane distance follows from consistency:
where is the frustum’s vertical field of view.
View and Eye Matrices
Two matrices drive the morph — a matched pair:
maps world space into the frustum camera’s eye space; 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 . The morph divides by — 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
| Function | Role |
|---|---|
pvMatrix(out) | Capture P·V into Float32Array(16) |
vMatrix(out) | Frustum view matrix |
eMatrix(out) | Frustum eye matrix |
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
- Santell, J. 3D Projection. Interactive guide with perspective visualization.
- Ahn, S. H. OpenGL Projection Matrix. Derivation from first principles.
- Shirley, P. & Marschner, S. Fundamentals of Computer Graphics. CRC Press. Chapter 7.