Clip Space

Homogeneous Coordinates and Clip Space

Positions carry four coordinates: w = 1 makes a point, which translations move, and w = 0 a direction, which they cannot. gl_Position is in clip space: the GPU keeps what lies within -w to w on each axis, divides by w to get normalized device coordinates (NDC, -1 to 1), then maps x and y through the viewport and z onto depths 0 to 1. NDC is left-handed, larger z being farther, so with no matrices the viewer looks along +z (not -z, as older tutorials say) and z = -0.5 hides z = 0.5:

Which z is nearer in NDC, with no matrices at allJavaScript
const { uniforms } = GLH.load(gl, `#version 300 es
  uniform float uZ; void main() {             // a triangle covering the canvas at depth uZ
    gl_Position = vec4(vec2(gl_VertexID & 1, gl_VertexID >> 1) * 4.0 - 1.0, uZ, 1.0); }`,
`#version 300 es
  precision lowp float; uniform vec3 uRGB; out vec4 c; void main() { c = vec4(uRGB, 1); }`);
gl.enable(gl.DEPTH_TEST);
for (const [z, color] of [[-0.5, [1, 0, 0]], [0.5, [0, 0, 1]]]) {   // red first, then blue
  uniforms.uZ(z); uniforms.uRGB(color); gl.drawArrays(gl.TRIANGLES, 0, 3);
}
const pixel = new Uint8Array(4);
gl.readPixels(0, 0, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, pixel);
console.log('visible:', pixel[0] ? 'red, z = -0.5' : 'blue, z = 0.5');
Output
visible: red, z = -0.5

Blue, drawn second, failed the depth test: at depth 0.75 it lies behind red's 0.25. Projection matrices flip z so that eye space's -z becomes increasing NDC depth.

With no matrices, z = -0.5 hides z = 0.5; and the same square shrinks as w growsHTMLLive
<!doctype html>
<style>
  body { margin: 0; font: 11px system-ui, sans-serif; background: #f7f4ee; color: #333; }
  canvas { display: block; width: 100%; max-width: 600px; }
  .names { display: flex; max-width: 600px; text-align: center; }
  .names div { flex: 1; padding: 4px 2px; }
</style>
<canvas id="view" width="600" height="240"></canvas>
<div class="names">
  <div id="depth">red (z = -0.5) drawn first, blue (z = 0.5) second</div>
  <div>gl_Position = vec4(x, y, 0, w): the GPU divides by w (w = 1, 1.5, 2, 3)</div>
</div>
<script>
const gl = document.getElementById('view').getContext('webgl2');
const program = gl.createProgram();
for (const [type, src] of [[gl.VERTEX_SHADER, `#version 300 es
uniform vec2 uCenter; uniform float uZ, uW, uSize;
void main() {                               // a square straight in clip space: no matrices
  vec2 corner = vec2[6](vec2(-1), vec2(1, -1), vec2(1), vec2(-1), vec2(1), vec2(-1, 1))[gl_VertexID];
  vec2 xy = uCenter + corner * uSize;
  gl_Position = vec4(xy, uZ, uW);            // clip space: the GPU divides x, y and z by w
}`], [gl.FRAGMENT_SHADER, `#version 300 es
precision lowp float; uniform vec3 uRGB; out vec4 c; void main() { c = vec4(uRGB, 1); }`]]) {
  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);
gl.bindVertexArray(gl.createVertexArray());
const u = (n) => gl.getUniformLocation(program, n);
function square(center, size, z, w, rgb) {
  gl.uniform2fv(u('uCenter'), center); gl.uniform1f(u('uSize'), size);
  gl.uniform1f(u('uZ'), z); gl.uniform1f(u('uW'), w); gl.uniform3fv(u('uRGB'), rgb);
  gl.drawArrays(gl.TRIANGLES, 0, 6);
}
gl.clearColor(0.97, 0.96, 0.93, 1);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);

// Left: NDC is left-handed, larger z is farther. Blue fails the depth test where they overlap.
gl.enable(gl.DEPTH_TEST);
gl.viewport(0, 0, 300, 240);
square([-0.2, 0.1], 0.5, -0.5, 1, [0.71, 0.27, 0.18]);   // red first, z = -0.5 (depth 0.25)
square([0.2, -0.1], 0.5, 0.5, 1, [0.12, 0.37, 0.55]);    // blue second, z = 0.5 (depth 0.75)
const pixel = new Uint8Array(4);
gl.readPixels(150, 120, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, pixel);   // the overlap
document.getElementById('depth').textContent += ` -> visible at the overlap: ${pixel[0] > pixel[2] ? 'red' : 'blue'}`;

// Right: the same clip-space square (x, y up to 0.8) with growing w: NDC = 0.8 / w.
gl.disable(gl.DEPTH_TEST);
gl.viewport(300, 0, 300, 240);
[[1, [0.88, 0.6, 0.06]], [1.5, [0.25, 0.49, 0.23]], [2, [0.36, 0.25, 0.6]], [3, [0.16, 0.62, 0.56]]]
  .forEach(([w, rgb]) => square([0, 0], 0.8, 0, w, rgb));
</script>