Perspective projection is a fundamental concept in 3D graphics. The transformation maps a view frustum — a truncated pyramid — into a cube of Normalized Device Coordinates (NDC), producing the foreshortening effect that makes 3D scenes look convincing. The sketch below morphs a set of
cajas(boxes) continuously from world space into NDC space, rendered from a third-person camera that also displays the frustum being transformed.
Shaders
The morph lives in a single GLSL hook injected into p5’s base shaders through buildColorShader and buildStrokeShader — no shader files needed.
// getWorldInputs hook — runs per vertex in world space, after model matrix
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;
Four steps in four lines:
- World → frustum space via
uViewFrustumMatrix— the frustum camera’s view matrix. - Perspective divide — the classic
xy / zthat collapses depth onto the near plane, scaled bynandndc. - Morph —
d = 0leaves vertices untouched;d = 1fully projects them onto the NDC surface; values between animate the deformation. - Frustum → world space via
uEyeFrustumMatrix— the inverse, so the main camera can render the result normally.
The same hook body applies to fills (Vertex) and strokes (StrokeVertex), so both morph in a single scene pass.
Sketch
Three concerns are cleanly separated: the frustum state, the scene geometry, and the shader declaration.
Frustum state
Each frame, the frustum camera is positioned and its matrices extracted into pre-allocated buffers:
function updateFrustum() {
frustumCam.camera(0, 0, Z, ...p5.Tree.k, ...p5.Tree.j)
frustumCam.mat4View(vBuf) // world → frustum, written into Float32Array(16)
frustumCam.mat4Eye(eBuf) // frustum → world, written into Float32Array(16)
const far = N * (1 + 2 * tan(FOVY / 2) * (1 + NDC))
frustumProj.perspective(FOVY, 1, N, far)
}
mat4View and mat4Eye are a matched pair — inverse of each other. The shader needs both: mat4View to enter frustum space, mat4Eye to leave it. frustumProj tracks the frustum’s projection for viewFrustum to draw the near/far planes correctly.
Shader uniforms
Uniforms are declared as callbacks in the hooks object and updated automatically each frame — no manual setUniform calls needed:
uniforms: {
'mat4 uViewFrustumMatrix': () => vBuf,
'mat4 uEyeFrustumMatrix': () => eBuf,
'float d': () => panel.d.value(),
'float ndc': () => NDC,
'float n': () => N,
}
Single scene pass
Because shader() and strokeShader() are set independently in p5 v2, fills and strokes morph together in one pass — the frustum gizmo included:
shader(fillMorph)
strokeShader(outlineMorph)
scene()
viewFrustum({ mat4Eye: eBuf, mat4Proj: frustumProj, ... })
The frustum axes are exempt from the morph via resetShader() inside the viewer callback — the axes sit at the frustum origin where z = 0, which would cause a division by zero in the perspective divide.
p5.tree API used
grid()viewFrustum()axes()mat4View(out)mat4Eye(out)createMatrix()- Constants:
p5.Tree.k(= [0, 0, 1])andp5.Tree.j(= [0, 1, 0])
References
- Jordan Santell’s 3D Projection: A clear guide to 3D projection with a perspective visualization.
- Song Ho Ahn’s Projection Matrix: A rigorous mathematical derivation of the OpenGL projection matrix.