Vertex vs Fragment Shading

Per-Vertex Versus Per-Fragment Shading Quality

Per-vertex lighting blends vertex colors across each triangle, which fails whenever lighting changes faster than the mesh: a highlight smaller than a triangle falls between the vertices and vanishes. Per-fragment lighting interpolates position and normal and evaluates the equation per pixel. The listing shares one GLSL function between the stages and lights BookNest's globe bookend (a 12 x 8 sphere) both ways:

The same lighting per vertex (left) and per fragment (right)HTML
<canvas id="c" width="1240" height="340" style="width:620px;height:170px"></canvas>
<script src="https://cdn.jsdelivr.net/npm/gl-matrix@3.4.4/gl-matrix-min.js"></script>
<script src="webgl-helper.js"></script>
<script>
const light = `uniform vec3 uColor, uLight, uEye;
  vec3 shade(vec3 p, vec3 n) {                          // ambient, diffuse and specular
    vec3 l = normalize(uLight - p), r = reflect(-l, n), v = normalize(uEye - p);
    float fade = 1.0 / (1.0 + 0.2 * dot(uLight - p, uLight - p));
    float diffuse = max(dot(n, l), 0.0), specular = pow(max(dot(r, v), 0.0), 30.0);
    return uColor * 0.15 + fade * (uColor * diffuse + specular);
  }`;
const gl = GLH.context('c'), { attributes, uniforms } = GLH.lit(gl, `#version 300 es
  precision highp float; in vec3 vPosition, vNormal, vColor; uniform bool uPerVertex;
  out vec4 c; ${light}
  void main() { c = vec4(uPerVertex ? vColor : shade(vPosition, normalize(vNormal)), 1.0); }`,
GLH.litVS.replace('out vec2 vUV;', `out vec2 vUV; out vec3 vColor; ${light}`)
  .replace('vUV = aUV;', 'vUV = aUV; vColor = shade(vPosition, normalize(vNormal));'));
const { mat4, mat3 } = glMatrix, eye = [0.1, 0.0, 1.7], globe = GLH.sphere(8, 12);
const camera = GLH.shelf(gl, attributes).camera;       // the shelf's lookAt and perspective
gl.bindVertexArray(GLH.vao(gl, attributes, globe.vertices,
  [['aPosition', 3], ['aNormal', 3], ['aUV', 2]], globe.indices));
const model = mat4.fromTranslation(mat4.create(), [0.1, -0.2, 0]);   // the camera's target
uniforms.uModel(mat4.scale(model, model, [0.5, 0.5, 0.5]));
uniforms.uNormalMatrix(mat3.normalFromMat4(mat3.create(), model));
uniforms.uColor(GLH.rgb(GLH.covers[0]));
uniforms.uLight([0.7, 0.5, 0.8]);
uniforms.uEye(eye);
gl.enable(gl.DEPTH_TEST);
GLH.panes(gl, 2, (pane, aspect) => {
  uniforms.uViewProjection(camera(aspect, eye));
  uniforms.uPerVertex(pane === 0 ? 1 : 0);             // bool uniforms take 0 or 1
  gl.drawElements(gl.TRIANGLES, globe.indices.length, gl.UNSIGNED_SHORT, 0);
});
window.__done = true;
</script>
Browser output of Listing 3.71
Browser output of 71

The per-vertex globe has lost its highlight, because no vertex sits where the reflection points at the eye; the per-fragment globe, with the same 96 quads, looks glossy. Both keep the polygonal outline, which no shading hides. Per-fragment lighting costs an evaluation per pixel, the right trade on any recent GPU.

A low-poly globe bookend lit per vertex (left) and per fragment (right) with the same shade() functionHTMLLive
<!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; font-family: monospace; }
  .names div { flex: 1; padding: 4px 2px; }
</style>
<canvas id="c" width="1200" height="340"></canvas>
<div class="names"><div>per vertex: highlight lost</div><div>per fragment: same 96 quads, glossy</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) };
}
// One GLSL function, pasted into both stages.
const light = `uniform vec3 uColor, uLight, uEye;
  vec3 shade(vec3 p, vec3 n) {                          // ambient, diffuse and specular
    vec3 l = normalize(uLight - p), r = reflect(-l, n), v = normalize(uEye - p);
    float fade = 1.0 / (1.0 + 0.2 * dot(uLight - p, uLight - p));
    float diffuse = max(dot(n, l), 0.0), specular = pow(max(dot(r, v), 0.0), 30.0);
    return uColor * 0.15 + fade * (uColor * diffuse + specular);
  }`;
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; uniform float uScale;
  out vec3 vPosition, vNormal, vColor;
  ${light}
  void main() {
    vPosition = aPosition * uScale; vNormal = aPosition;          // a sphere's normal is its position
    vColor = shade(vPosition, normalize(vNormal));                // Gouraud: once per vertex
    gl_Position = uViewProjection * vec4(vPosition, 1.0);
  }`], [gl.FRAGMENT_SHADER, `#version 300 es
  precision highp float; in vec3 vPosition, vNormal, vColor; uniform bool uPerVertex;
  out vec4 c; ${light}
  void main() { c = vec4(uPerVertex ? vColor : shade(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(8, 12);                                        // 8 rings x 12 segments = 96 quads
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.2, 2.2];
gl.uniform3fv(u('uEye'), eye);
gl.uniform3f(u('uColor'), 0.12, 0.37, 0.55);
gl.uniform1f(u('uScale'), 0.6);
gl.uniformMatrix4fv(u('uViewProjection'), false, m4.multiply(m4.perspective(40, 600 / 340, 0.1, 10), m4.lookAt(eye, [0, 0, 0])));
function frame(ms) {
  const a = ms * 0.0007;
  gl.uniform3f(u('uLight'), 0.8 * Math.cos(a), 0.5, 0.9 + 0.3 * Math.sin(a));   // a lamp circling in front
  for (let pane = 0; pane < 2; pane++) {
    gl.viewport(pane * 600, 0, 600, 340); gl.scissor(pane * 600 + 3, 0, 594, 340);
    gl.clearColor(0.93, 0.91, 0.87, 1);
    gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
    gl.uniform1i(u('uPerVertex'), pane === 0 ? 1 : 0);             // bool uniforms take 0 or 1
    gl.drawElements(gl.TRIANGLES, globe.indices.length, gl.UNSIGNED_SHORT, 0);
  }
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script>