Orthographic Projection

A projection matrix decides what the camera sees. An orthographic projection keeps sizes independent of distance, as in technical drawings, 2D games and UI overlays. It maps an eye-space box, left to right, bottom to top, z from -near to -far, onto clip space's cube from -1 to 1:

webgl-helper.js, ninth part: an orthographic projectionJavaScript
// Section 3.6: orthographic projection of the box left..right, bottom..top, -near..-far
GLH.m4.ortho = (l, r, b, t, n, f) => new Float32Array([2 / (r - l), 0, 0, 0,
  0, 2 / (t - b), 0, 0, 0, 0, -2 / (f - n), 0,
  -(r + l) / (r - l), -(t + b) / (t - b), -(f + n) / (f - n), 1]);
The box's corners reach the cube's corners, whatever their depthJavaScript
const ortho = GLH.m4.ortho(-2, 2, -1, 1, 0.1, 10);          // a 2:1 box, 0.1 to 10 deep
for (const p of [[2, 1, -0.1], [1, 0.5, -1], [1, 0.5, -9]]) {
  console.log(p.join(', '), '->', GLH.m4.apply(ortho, p).join(', '));
}
Output
2, 1, -0.1 -> 1, 1, -1, 1
1, 0.5, -1 -> 0.5, 0.5, -0.818, 1
1, 0.5, -9 -> 0.5, 0.5, 0.798, 1

The last two points differ only in depth and land at the same x and y. Match the box's aspect to the canvas's, or shapes stretch (Transform Order used a bare scaling for that). Note that z flips sign (Clip Space).

A row of books receding in depth through an orthographic projection: every book keeps its sizeHTMLLive
<!doctype html>
<style>
  body { margin: 0; font: 11px system-ui, sans-serif; background: #f7f4ee; color: #333; }
  canvas { display: block; width: 100%; max-width: 600px; }
  pre { max-width: 600px; margin: 4px 0 0; padding: 6px 8px; background: #fff; border: 1px solid #ddd; }
</style>
<canvas id="view" width="600" height="260"></canvas>
<pre id="out"></pre>
<script>
const m4 = {
  translation: (x, y, z) => [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, x, y, z, 1],
  scaling: (x, y, z) => [x, 0, 0, 0, 0, y, 0, 0, 0, 0, z, 0, 0, 0, 0, 1],
  // Orthographic projection of the box left..right, bottom..top, -near..-far.
  ortho: (l, r, b, t, n, f) => [2 / (r - l), 0, 0, 0, 0, 2 / (t - b), 0, 0, 0, 0, -2 / (f - n), 0,
    -(r + l) / (r - l), -(t + b) / (t - b), -(f + n) / (f - n), 1],
  lookAt(eye, target, up = [0, 1, 0]) {
    const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
    const unit = (v) => v.map((c) => c / Math.hypot(...v));
    const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
    const z = unit(eye.map((c, i) => c - target[i])), x = unit(cross(up, z)), y = cross(z, x);
    return [x[0], y[0], z[0], 0, x[1], y[1], z[1], 0, x[2], y[2], z[2], 0, -dot(x, eye), -dot(y, eye), -dot(z, eye), 1];
  },
  multiply(a, b) {
    const out = new Array(16).fill(0);
    for (let col = 0; col < 4; col++)
      for (let row = 0; row < 4; row++)
        for (let k = 0; k < 4; k++) out[col * 4 + row] += a[k * 4 + row] * b[col * 4 + k];
    return out;
  },
  apply: (m, [x, y, z, w = 1]) => [0, 1, 2, 3].map((r) => +(m[r] * x + m[4 + r] * y + m[8 + r] * z + m[12 + r] * w).toFixed(3)),
};
function cube() {
  const faces = [[[0,0,1],[1,0,0],[0,1,0]], [[0,0,-1],[-1,0,0],[0,1,0]], [[1,0,0],[0,0,-1],[0,1,0]],
                 [[-1,0,0],[0,0,1],[0,1,0]], [[0,1,0],[1,0,0],[0,0,-1]], [[0,-1,0],[1,0,0],[0,0,1]]];
  const out = [];
  for (const [n, u, v] of faces)
    for (const [a, b] of [[-1,-1],[1,-1],[1,1],[-1,-1],[1,1],[-1,1]])
      out.push(...[0, 1, 2].map(i => (n[i] + a * u[i] + b * v[i]) / 2), ...n);
  return new Float32Array(out);
}

const gl = document.getElementById('view').getContext('webgl2');
const program = gl.createProgram();
for (const [type, src] of [[gl.VERTEX_SHADER, `#version 300 es
layout(location = 0) in vec3 aPosition; layout(location = 1) in vec3 aNormal;
uniform mat4 uViewProjection, uModel; out float vLight;
void main() {
  vLight = 0.5 + 0.5 * max(dot(normalize(mat3(uModel) * aNormal), normalize(vec3(0.5, 0.8, 0.4))), 0.0);
  gl_Position = uViewProjection * uModel * vec4(aPosition, 1.0);
}`], [gl.FRAGMENT_SHADER, `#version 300 es
precision mediump float; uniform vec3 uColor; in float vLight; out vec4 c;
void main() { c = vec4(uColor * vLight, 1.0); }`]]) {
  const s = gl.createShader(type);
  gl.shaderSource(s, src); gl.compileShader(s);
  if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s));
  gl.attachShader(program, s);
}
gl.linkProgram(program);
gl.useProgram(program);
const uVP = gl.getUniformLocation(program, 'uViewProjection');
const uModel = gl.getUniformLocation(program, 'uModel');
const uColor = gl.getUniformLocation(program, 'uColor');
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER, cube(), gl.STATIC_DRAW);
gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 24, 0); gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(1, 3, gl.FLOAT, false, 24, 12); gl.enableVertexAttribArray(1);
gl.enable(gl.DEPTH_TEST);

const colors = [[0.12, 0.37, 0.55], [0.36, 0.25, 0.6], [0.88, 0.6, 0.06], [0.25, 0.49, 0.23], [0.71, 0.27, 0.18], [0.16, 0.62, 0.56]];
// Six identical books, each 1.5 units further away than the last.
const books = colors.map((color, i) => ({ color,
  model: m4.multiply(m4.translation(0.6 * (i % 2 ? 1 : -1), 0.6, -i * 1.5), m4.scaling(0.8, 1.2, 0.2)) }));
const aspect = 600 / 260;
const view = m4.lookAt([3, 2, 4], [0, 0.4, -3.5]);

// The box must match the canvas's aspect (2.31 : 1) or the books stretch.
const ortho = m4.ortho(-3 * aspect, 3 * aspect, -3, 3, 0.1, 30);
gl.uniformMatrix4fv(uVP, false, m4.multiply(ortho, view));
gl.clearColor(0.97, 0.96, 0.93, 1);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
for (const { color, model } of books) {
  gl.uniformMatrix4fv(uModel, false, model);
  gl.uniform3fv(uColor, color);
  gl.drawArrays(gl.TRIANGLES, 0, 36);
}

// The page's check: box corners reach the cube's corners at any depth.
const box = m4.ortho(-2, 2, -1, 1, 0.1, 10);
document.getElementById('out').textContent = [[2, 1, -0.1], [1, 0.5, -1], [1, 0.5, -9]]
  .map(p => `ortho(-2, 2, -1, 1, 0.1, 10) * (${p.join(', ')}) -> ${m4.apply(box, p).join(', ')}`).join('\n');
</script>