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:

  1. World → frustum space via uViewFrustumMatrix — the frustum camera’s view matrix.
  2. Perspective divide — the classic xy / z that collapses depth onto the near plane, scaled by n and ndc.
  3. Morphd = 0 leaves vertices untouched; d = 1 fully projects them onto the NDC surface; values between animate the deformation.
  4. 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

References

  1. Jordan Santell’s 3D Projection: A clear guide to 3D projection with a perspective visualization.
  2. Song Ho Ahn’s Projection Matrix: A rigorous mathematical derivation of the OpenGL projection matrix.