The camera from the previous chapter changed our coordinate system, but distance did not change the size of the picture. Now put two identical cards at different depths. We want the farther one to occupy less of the screen, while both keep their proportions.
Start with two equal cards
Each card is 1.2 units wide and 1.6 units tall. Their world centers are (−0.9,0,0) and (0.9,0,−3). The camera sits at (0,0,3) and looks along −Z, so their forward depths are 3 and 6. In Perspective mode, the farther card is half as tall and half as wide.
Choose Orthographic. Both cards now have equal projected sizes. Their world geometry did not change; we changed the rule that converts view coordinates into a screen picture. That distinction separates the camera from its projection.
Divide by forward depth
Imagine an image plane one unit in front of the camera. A ray from the eye through a view-space point crosses that plane. Similar triangles give the intersection: horizontal displacement divided by forward depth, and the same for vertical displacement.
float depth = -view.z; // Positive for points in front.
vec2 projected = view.xy / depth;
A point at (1,0,−2) lands at horizontal coordinate 0.5. At (1,0,−4), it lands at 0.25. Doubling depth halves the projected displacement. This formula requires positive depth; a point at the eye plane would divide by zero.
Depth is a component along the camera's forward direction, not the Euclidean distance from eye to point. Two points with equal view Z share the same scale even if one is farther to the side.
Choose how much fits vertically
The vertical field of view, or FOV, is the angle between the top and bottom of the visible cone. At depth 1, its upper edge lies at tan(FOV/2). To map that edge to normalized screen coordinate +1, divide by this extent:
float focal = 1.0 / tan(0.5 * radians(verticalFovDegrees));
vec2 ndcXY = focal * view.xy / -view.z;
ndcXY.x /= aspect; // viewport width / viewport height
A narrower FOV gives a larger focal factor and a larger picture. At 60°, focal=sqrt(3). At 90°, focal=1, so every projected size becomes 1/sqrt(3), about 0.57735, of its former size. Halving the angle does not generally double the scale: the tangent is nonlinear.
The aspect correction makes one unit occupy the same number of pixels horizontally and vertically. X maps across the viewport width, while Y maps across its height; dividing X by width/height cancels that difference. Resize the viewport or change Aspect and the card keeps its shape. A narrow viewport may cut off its sides, which is clipping rather than stretching.
Prepare the division with a mat4
We add one more coordinate again, as in chapter 16. A 3D position therefore needs four components. This time the fourth coordinate will carry depth. The projection matrix produces clip coordinates, with clip.w=−view.z. WebGL performs the division later.
Let nearDistance and farDistance be positive distances, with nearDistance smaller than farDistance. Our lab fixes them at 0.5 and 12. The matrix below uses GLSL's column constructor:
float n = nearDistance, f = farDistance;
float k = 1.0 / tan(0.5 * verticalFovRadians);
mat4 projection = mat4(
vec4(k / aspect, 0.0, 0.0, 0.0),
vec4(0.0, k, 0.0, 0.0),
vec4(0.0, 0.0, (f+n)/(n-f), -1.0),
vec4(0.0, 0.0, 2.0*f*n/(n-f), 0.0)
);
vec4 clip = projection * vec4(view, 1.0);
The first two rows produce k*view.x/aspect and k*view.y. The last row produces −view.z. Thus dividing clip.xy by clip.w gives exactly our similar-triangle formula. The mat4 multiplication itself is linear in four-dimensional homogeneous coordinates; the subsequent division is nonlinear.
The Z coefficients have a separate job. Write clip.z=A*view.z+B. Requiring the near plane to become NDC Z=−1 and the far plane to become +1 gives A=(f+n)/(n−f) and B=2fn/(n−f). Depth between them maps nonlinearly. At our depths 3 and 6, NDC Z is approximately 0.73913 and 0.91304. These are not view Z or the default depth-buffer values, which lie in [0,1].
Clip first, divide afterward
For a point to lie inside the clip volume, all three clip components must lie between −w and +w:
-clip.w <= clip.x && clip.x <= clip.w
-clip.w <= clip.y && clip.y <= clip.w
-clip.w <= clip.z && clip.z <= clip.w
WebGL clips triangles against these six planes before dividing by w. A triangle can cross a boundary: the GPU keeps the inside portion and creates vertices along the intersection. One outside corner does not mean the whole card disappears.
After clipping, xyz/w produces normalized device coordinates, or NDC. Their visible range is [−1,1] on each axis. Viewport mapping then turns XY into pixel positions. Keep the spaces in order: local → world → view → clip → NDC → viewport.
Do not predivide gl_Position and replace its w with 1. That changes the homogeneous clipping information and how values are interpolated across triangles. Supply the original clip vector and let the pipeline finish the projection.
Give WebGL four vertices
Earlier labs drew a fullscreen triangle and calculated a diagram inside its fragment shader. This one supplies a real card mesh: four local positions, four UV coordinates, and two indexed triangles.
// position.xyz, uv.xy: interleaved vertex attributes
const vertices = new Float32Array([
-0.6, -0.8, 0, 0, 0,
0.6, -0.8, 0, 1, 0,
0.6, 0.8, 0, 1, 1,
-0.6, 0.8, 0, 0, 1
]);
const indices = new Uint16Array([0, 1, 2, 0, 2, 3]);
The host uploads these arrays to buffers. A vertex array object records how to read them: location 0 has three floats for position, location 1 has two for UV, with a five-float stride. Each draw uses six indices. The same mesh is drawn twice with different model matrices.
The model is translation(center)*rotationY(tilt), so rotation happens around each card's own origin before placement. The view matrix translates by (0,0,−cameraDistance). The host computes these matrices and the projection when controls or viewport dimensions change, then supplies them as uniforms.
The vertex shader runs for vertices and writes gl_Position. Rasterization finds covered pixels. The fragment shader colors those pixels using interpolated UVs. The editor exposes both stages; the complete WebGL host source includes the buffers, attribute setup, matrices, draw calls, and restoration code.
Keep the pattern on the card
Assign aUV to an output named vUV in the vertex shader, then declare the matching input in the fragment shader. These per-vertex values, often called varyings, are interpolated across the triangle.
GLSL ES 3.00 uses perspective-correct smooth interpolation by default. If a tilted card's vertices have different w values, straight interpolation in screen coordinates would distort its local pattern. The pipeline corrects for those depths.
// For screen-space triangle weights lambda0 + lambda1 + lambda2 = 1:
value = (lambda0 * value0 / w0 + lambda1 * value1 / w1
+ lambda2 * value2 / w2)
/ (lambda0 / w0 + lambda1 / w1 + lambda2 / w2);
You do not implement this expression in the fragment shader. It explains why keeping the original clip.w matters. The checker pattern is computed from the interpolated local UV, so it stays attached as the card tilts.
Separate distance from FOV
At zero tilt, increase camera distance from 3 to 6. The near card's depth doubles, so its projected height halves. The far card's depth changes from 6 to 9, so its height becomes two thirds. Moving the camera changes their relative sizes.
Changing FOV instead multiplies both sizes by the same focal-factor ratio, without moving any world or view position. Orthographic mode uses a fixed vertical half-height of 2: XY is divided by a fixed scale, w stays 1, and depth does not shrink objects. FOV is retained but unused in that mode.