Two classic papers name the interpolation choices of Vertex vs Fragment Shading. Henri Gouraud's "Continuous Shading of Curved Surfaces" (1971) computed colors at vertices and interpolated them; Bui Tuong Phong's "Illumination for Computer Generated Pictures" (1975) interpolated normals and shaded every pixel, and introduced the specular term still called the Phong reflection model. "Phong shading" (normal interpolation) and the "Phong model" (the equation) are separate ideas: you can evaluate the Phong model per vertex, which is Gouraud shading.
| Shading | Lighting computed | Interpolated | Look | Cost |
|---|---|---|---|---|
| Flat | Once per triangle | Nothing | Faceted | Lowest |
| Gouraud | Per vertex | Colors | Smooth, lost highlights | Per vertex |
| Phong | Per fragment | Normals, positions | Smooth, sharp highlights | Per pixel |
For a deliberate low-poly look, declare the normal output flat, or derive the face normal from screen-space derivatives, normalize(cross(dFdx(vPosition), dFdy(vPosition))), which also sidesteps Chrome 154 1 's Direct3D 11 bug with flat outputs in programs that draw both lines and triangles (Drawing Modes).
<!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; }
.names b { display: block; font-size: 12px; }
</style>
<canvas id="c" width="600" height="200"></canvas>
<div class="names">
<div><b>Flat</b>face normal from dFdx / dFdy</div>
<div><b>Gouraud (1971)</b>colours per vertex, interpolated</div>
<div><b>Phong (1975)</b>normals interpolated, lit per pixel</div>
</div>
<script>
const m4 = {
perspective(fovy, aspect, near, far) {
const f = 1 / Math.tan(fovy * Math.PI / 360), d = near - far;
return [f / aspect, 0, 0, 0, 0, f, 0, 0, 0, 0, (far + near) / d, -1, 0, 0, 2 * far * near / d, 0];
},
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 c = 0; c < 4; c++) for (let r = 0; r < 4; r++) for (let k = 0; k < 4; k++) out[c * 4 + r] += a[k * 4 + r] * b[c * 4 + k];
return out;
},
};
function sphere(rings, segments) {
const vertices = [], indices = [];
for (let i = 0; i <= rings; i++) for (let j = 0; j <= segments; j++) {
const t = i / rings * Math.PI, p = j / segments * 2 * Math.PI;
vertices.push(Math.sin(t) * Math.cos(p), Math.cos(t), -Math.sin(t) * Math.sin(p));
}
for (let i = 0; i < rings; i++) for (let j = 0; j < segments; j++) {
const a = i * (segments + 1) + j, b = a + segments + 1;
indices.push(a, b, a + 1, a + 1, b, b + 1);
}
return { vertices: new Float32Array(vertices), indices: new Uint16Array(indices) };
}
const phongModel = `uniform vec3 uLight, uEye;
vec3 phong(vec3 p, vec3 n) { // the Phong reflection model (the equation)
vec3 l = normalize(uLight - p), v = normalize(uEye - p), r = reflect(-l, n);
vec3 base = vec3(0.71, 0.27, 0.18);
return base * 0.2 + base * max(dot(n, l), 0.0) + vec3(0.8) * pow(max(dot(r, v), 0.0), 24.0);
}`;
const gl = document.getElementById('c').getContext('webgl2');
const program = gl.createProgram();
for (const [type, src] of [[gl.VERTEX_SHADER, `#version 300 es
layout(location = 0) in vec3 aPosition;
uniform mat4 uViewProjection, uModel; out vec3 vPosition, vNormal, vColor;
${phongModel}
void main() {
vPosition = (uModel * vec4(aPosition, 1.0)).xyz;
vNormal = mat3(uModel) * aPosition;
vColor = phong(vPosition, normalize(vNormal)); // Gouraud: evaluated at the vertices
gl_Position = uViewProjection * vec4(vPosition, 1.0);
}`], [gl.FRAGMENT_SHADER, `#version 300 es
precision highp float; in vec3 vPosition, vNormal, vColor; uniform int uMode; out vec4 c;
${phongModel}
void main() {
if (uMode == 0) {
vec3 faceNormal = normalize(cross(dFdx(vPosition), dFdy(vPosition))); // flat: one normal per triangle
c = vec4(phong(vPosition, faceNormal), 1.0);
} else if (uMode == 1) c = vec4(vColor, 1.0);
else c = vec4(phong(vPosition, normalize(vNormal)), 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 u = (n) => gl.getUniformLocation(program, n);
const globe = sphere(9, 14);
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER, globe.vertices, gl.STATIC_DRAW);
gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 0, 0); gl.enableVertexAttribArray(0);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, globe.indices, gl.STATIC_DRAW);
gl.enable(gl.DEPTH_TEST); gl.enable(gl.CULL_FACE); gl.enable(gl.SCISSOR_TEST);
const eye = [0, 0.3, 3];
gl.uniform3fv(u('uEye'), eye);
gl.uniform3f(u('uLight'), 1.5, 1.8, 2.5);
gl.uniformMatrix4fv(u('uViewProjection'), false, m4.multiply(m4.perspective(40, 1, 0.1, 10), m4.lookAt(eye, [0, 0, 0])));
function frame(ms) {
const a = ms * 0.0005, c = Math.cos(a), s = Math.sin(a);
gl.uniformMatrix4fv(u('uModel'), false, [c, 0, -s, 0, 0, 1, 0, 0, s, 0, c, 0, 0, 0, 0, 1]); // slow spin
for (let mode = 0; mode < 3; mode++) {
gl.viewport(mode * 200, 0, 200, 200); gl.scissor(mode * 200 + 2, 0, 196, 200);
gl.clearColor(0.93, 0.91, 0.87, 1);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
gl.uniform1i(u('uMode'), mode);
gl.drawElements(gl.TRIANGLES, globe.indices.length, gl.UNSIGNED_SHORT, 0);
}
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script>