Moving WebGL into Three Dimensions
This continues a series on WebGL fundamentals, following the introduction to 2D matrices where translation, rotation, scaling, and pixel-to-clip-space projection were all combined into a single matrix operation. Extending that approach to 3D requires 3D points (x, y, z) and a 4x4 matrix instead of the 2D versions.
The vertex shader becomes simpler since the w component of the position is no longer hardcoded:
// Updated vertex shader for 3D
attribute vec4 a_position;
uniform mat4 u_matrix;
void main() {
gl_Position = u_matrix * a_position;
}
Geometry data is updated to supply z values for each vertex. All matrix functions must then be rewritten to operate on 4x4 matrices. The 2D rotation function is replaced by three separate functions for rotation around the Z, Y, and X axes. Their expanded forms simplify just like the 2D case:
- Z rotation:
c = cos(angle); s = sin(angle), with matrix entries[c, s, 0, 0], [-s, c, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1] - Y rotation:
[c, 0, -s, 0], [0, 1, 0, 0], [s, 0, c, 0], [0, 0, 0, 1] - X rotation:
[1, 0, 0, 0], [0, c, s, 0], [0, -s, c, 0], [0, 0, 0, 1]
Projection for 3D Space
The pixel-to-clip-space projection needs a depth equivalent. A common approach is to make Z use pixel-like units with a supplied depth value defining the range from -depth / 2 to +depth / 2, similar to how width and height define the X and Y ranges:
function make3DProjection(width, height, depth) {
return [
2 / width, 0, 0, 0,
0, -2 / height, 0, 0,
0, 0, 2 / depth, 0,
-1, 1, 0, 1,
];
}
Matrix computation is updated to combine the projection with the translation, rotation, and scaling matrices for each frame.
Making the Geometry Actually 3D
A flat F-shaped polygon is insufficient for visualizing depth. The geometry must be expanded into a true three-dimensional object. This requires creating front, back, and side faces—16 rectangles total for an F, each composed of two triangles. Drawing this shape requires 96 vertices.
Even with the geometry defined, a monochrome shape makes 3D orientation difficult to discern. Coloring each face distinctly clarifies which side is being viewed. This calls for a second attribute in the vertex shader to carry per-vertex color data:
attribute vec4 a_color;
varying vec4 v_color;
void main() {
gl_Position = u_matrix * a_position;
v_color = a_color;
}
The fragment shader then uses that varying:
precision mediump float;
varying vec4 v_color;
void main() {
gl_FragColor = v_color;
}
A second buffer and attribute setup feed the color data to the shader.
Triangle Culling
Drawing geometry in naive buffer order produces incorrect occlusion—back-facing surfaces can be painted over front-facing ones. WebGL distinguishes front-facing (clockwise in clip space) from back-facing (counter-clockwise) triangles after vertex shader math is applied.
Enabling culling stops back-facing triangles from being drawn:
gl.enable(gl.CULL_FACE);
With culling active, some faces vanish because their winding order is reversed. Fixing this means checking individual triangle definitions and swapping any two vertices to reverse the winding. Note that operations like negative X scaling or rotation around X/Y can flip a triangle from clockwise to counter-clockwise, so correct winding order consistently matters as transformations are applied.
Depth Testing
Culling alone cannot prevent depth conflicts where a farther triangle is drawn on top of a nearer one. The depth buffer (Z-buffer) solves this. WebGL uses a per-pixel depth value derived from clip-space Z (mapped to 0 to 1). Before writing a color pixel, it compares the incoming depth against the existing depth buffer value; if the new value is greater, the fragment is discarded. Otherwise both color and depth pixels are updated.
Enable it in the WebGL context setup:
gl.enable(gl.DEPTH_TEST);
With the depth buffer active, it must be cleared each frame alongside the color buffer:
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
A Note on the Vector Size
Both the position and color attributes are declared as vec4 in the vertex shader, yet the vertex attribute pointer is given a size of 3:
gl.vertexAttribPointer(positionLocation, 3, gl.FLOAT, false, 0, 0);
gl.vertexAttribPointer(colorLocation, 3, gl.FLOAT, false, 0, 0);
This is valid because WebGL supplies default values for unsupplied attribute components: 0, 0, 0, then 1 for the fourth component. The earlier 2D examples required an explicit 1 for Z because the default Z is 0. In 3D, the matrix math needs a w of 1, and the default supplies it. When the size is 3, the fourth component of the declared vec4 is taken as the default 1.



