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:
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.
<!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>