Shininess and Specular Maps

The exponent controls the size of the highlight: low values give broad sheens like satin or skin, high values the tight glints of lacquer and glass. The figure lights BookNest's globe with GLH.shelfFS at four exponents:

Blinn-Phong shininess 4, 16, 64 and 256HTML
<canvas id="c" width="1240" height="300" style="width:620px;height:150px"></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 gl = GLH.context('c'), { attributes, uniforms } = GLH.lit(gl, GLH.shelfFS);
const camera = GLH.shelf(gl, attributes).camera, globe = GLH.sphere(32, 48);
gl.bindVertexArray(GLH.vao(gl, attributes, globe.vertices,
  [['aPosition', 3], ['aNormal', 3], ['aUV', 2]], globe.indices));
const { mat4, mat3 } = glMatrix, eye = [0.1, -0.1, 1.6];
const model = mat4.fromTranslation(mat4.create(), [0.1, -0.2, 0]);
uniforms.uModel(mat4.scale(model, model, [0.4, 0.4, 0.4]));
uniforms.uNormalMatrix(mat3.normalFromMat4(mat3.create(), model));
uniforms.uColor(GLH.rgb(GLH.covers[0]));
uniforms.uAmbient([0.15, 0.15, 0.18]);
uniforms.uEye(eye);
uniforms['uLights[0].position']([1.2, 1.0, 1.6]);
uniforms['uLights[0].color']([4, 4, 4]);
uniforms.uGloss(1.0);
uniforms.uAlpha(1.0);                              // no GLH.shelf().draw() to set it
gl.enable(gl.DEPTH_TEST);
GLH.panes(gl, 4, (pane, aspect) => {
  uniforms.uShininess([4, 16, 64, 256][pane]);
  uniforms.uViewProjection(camera(aspect, eye));
  gl.drawElements(gl.TRIANGLES, globe.indices.length, gl.UNSIGNED_SHORT, 0);
});
window.__done = true;
</script>
Browser output of Listing 3.81
Browser output of 81

Real objects are rarely glossy all over. A specular map is a texture whose value scales the specular term texel by texel, so one draw call can show matte paper and a shiny foil title; Textures loads such textures. GLH.shelfFS fakes one with a procedural mask: step(abs(vUV.y - 0.78), 0.05) is 1 in a band near the top of each face, and uGloss + uFoil * band makes that band glossy. Uniforms default to 0, so set uShininess (pow(x, 0.0) is 1, a glossy wash) and, when drawing without GLH.shelf(), uAlpha.

Blinn-Phong shininess 4, 16, 64 and 256 on BookNest's globe, and a book whose foil title band is glossyHTMLLive
<!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="300"></canvas>
<div class="names"><div>4</div><div>16</div><div>64</div><div>256</div><div>specular mask: foil band</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;
  },
};
// Sphere (position = normal, uv from latitude/longitude), then a unit box with uv per face.
const verts = [];
const ring = (i, j) => { const t = i / 32 * Math.PI, p = j / 48 * 2 * Math.PI;
  return [[Math.sin(t) * Math.cos(p), Math.cos(t), -Math.sin(t) * Math.sin(p)], [j / 48, 1 - i / 32]]; };
for (let i = 0; i < 32; i++) for (let j = 0; j < 48; j++)
  for (const [di, dj] of [[0, 0], [1, 0], [0, 1], [0, 1], [1, 0], [1, 1]]) { const [p, uv] = ring(i + di, j + dj); verts.push(...p, ...p, ...uv); }
const sphereCount = verts.length / 8;
const faces = [[[0,0,1],[1,0,0],[0,1,0]], [[0,0,-1],[-1,0,0],[0,1,0]], [[1,0,0],[0,0,-1],[0,1,0]],
               [[-1,0,0],[0,0,1],[0,1,0]], [[0,1,0],[1,0,0],[0,0,-1]], [[0,-1,0],[1,0,0],[0,0,1]]];
for (const [n, u, v] of faces) for (const [a, b] of [[-1,-1],[1,-1],[1,1],[-1,-1],[1,1],[-1,1]])
  verts.push(...[0, 1, 2].map(i => (n[i] + a * u[i] + b * v[i]) / 2), ...n, (a + 1) / 2, (b + 1) / 2);

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; layout(location = 1) in vec3 aNormal; layout(location = 2) in vec2 aUV;
uniform mat4 uViewProjection; uniform vec3 uScale; uniform float uTurn;
out vec3 vPosition, vNormal; out vec2 vUV;
void main() {
  float c = cos(uTurn), s = sin(uTurn);
  mat3 turn = mat3(c, 0.0, -s, 0.0, 1.0, 0.0, s, 0.0, c);
  vPosition = turn * (aPosition * uScale); vNormal = turn * (aNormal / uScale); vUV = aUV;
  gl_Position = uViewProjection * vec4(vPosition, 1.0);
}`], [gl.FRAGMENT_SHADER, `#version 300 es
precision highp float;
in vec3 vPosition, vNormal; in vec2 vUV;
uniform vec3 uEye, uLight, uColor; uniform float uShininess, uGloss, uFoil;
out vec4 outColor;
void main() {
  vec3 n = normalize(vNormal), l = normalize(uLight - vPosition), v = normalize(uEye - vPosition);
  float lambert = max(dot(n, l), 0.0);
  float spec = lambert > 0.0 ? pow(max(dot(n, normalize(l + v)), 0.0), uShininess) : 0.0;
  float band = step(abs(vUV.y - 0.78), 0.05);          // a procedural specular mask
  float gloss = uGloss + uFoil * band;                  // paper matte, foil shiny
  vec3 base = mix(uColor, vec3(0.85, 0.7, 0.3), uFoil * band);
  outColor = vec4(base * (0.15 + 0.85 * lambert) + vec3(1.0) * spec * gloss, 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);
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(verts), gl.STATIC_DRAW);
gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 32, 0); gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(1, 3, gl.FLOAT, false, 32, 12); gl.enableVertexAttribArray(1);
gl.vertexAttribPointer(2, 2, gl.FLOAT, false, 32, 24); gl.enableVertexAttribArray(2);
gl.enable(gl.DEPTH_TEST); gl.enable(gl.CULL_FACE); gl.enable(gl.SCISSOR_TEST);
const eye = [0, 0, 2.6];
gl.uniform3fv(u('uEye'), eye);
gl.uniform3f(u('uLight'), 1.2, 1.0, 1.6);
gl.uniformMatrix4fv(u('uViewProjection'), false, m4.multiply(m4.perspective(40, 240 / 300, 0.1, 20), m4.lookAt(eye, [0, 0, 0])));
function frame(ms) {
  for (let pane = 0; pane < 5; pane++) {
    gl.viewport(pane * 240, 0, 240, 300); gl.scissor(pane * 240 + 2, 0, 236, 300);
    gl.clearColor(0.93, 0.91, 0.87, 1);
    gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
    if (pane < 4) {
      gl.uniform1f(u('uShininess'), [4, 16, 64, 256][pane]);
      gl.uniform1f(u('uGloss'), 1); gl.uniform1f(u('uFoil'), 0);
      gl.uniform3f(u('uScale'), 0.5, 0.5, 0.5); gl.uniform1f(u('uTurn'), 0);
      gl.uniform3f(u('uColor'), 0.12, 0.37, 0.55);
      gl.drawArrays(gl.TRIANGLES, 0, sphereCount);
    } else {                                            // a matte cover with a glossy foil title
      gl.uniform1f(u('uShininess'), 60);
      gl.uniform1f(u('uGloss'), 0.05); gl.uniform1f(u('uFoil'), 1.5);
      gl.uniform3f(u('uScale'), 0.75, 1.1, 0.15); gl.uniform1f(u('uTurn'), 0.6 * Math.sin(ms * 0.0008));
      gl.uniform3f(u('uColor'), 0.36, 0.25, 0.6);
      gl.drawArrays(gl.TRIANGLES, sphereCount, 36);
    }
  }
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script>