Per-Fragment Lighting

Per-Fragment Lighting in the Fragment Shader

Moving lighting into the fragment shader takes two changes. The vertex shader outputs the world-space position and normal instead of a color, as GLH.litVS does, and the fragment shader renormalizes the normal, because interpolating between unit vectors shortens them. This fragment shader lights a surface with one point light and the windowed inverse-square falloff of Point Lights and Attenuation:

A fragment shader for one point light, for use with GLH.lit()JavaScript
#version 300 es
precision highp float;                       // world positions need highp on mobile GPUs
in vec3 vPosition, vNormal;
uniform vec3 uColor, uAmbient, uLightPosition, uLightColor;
out vec4 outColor;
void main() {
  vec3 normal = normalize(vNormal);          // interpolation shortens normals
  vec3 toLight = uLightPosition - vPosition;
  float d = length(toLight);
  float diffuse = max(dot(normal, toLight / d), 0.0);
  float fade = clamp(1.0 - pow(d / 6.0, 4.0), 0.0, 1.0) / (d * d);   // range 6
  outColor = vec4(uColor * (uAmbient + uLightColor * diffuse * fade), 1.0);
}

Pass it to GLH.lit(gl, source) and set uLightPosition, uLightColor (an intensity such as (2, 1.8, 1.5)) and uAmbient. Three details decide whether it looks right: all vectors in world space (so uLightPosition needs no view matrix), highp precision (mediump guarantees only about three significant digits), and the normal matrix for normals, since the books are scaled unevenly.

A reading lamp moving along the shelf, lit per fragment with windowed inverse-square falloffHTMLLive
<!doctype html>
<style>
  body { margin: 0; background: #f7f4ee; }
  canvas { display: block; width: 100%; max-width: 600px; }
</style>
<canvas id="c" width="1200" height="400"></canvas>
<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;
  },
  model(x, y, z, sx, sy, sz, turn = 0) {
    const c = Math.cos(turn), s = Math.sin(turn);
    return [c * sx, 0, -s * sx, 0, 0, sy, 0, 0, s * sz, 0, c * sz, 0, x, y, z, 1];
  },
  normal(m) {
    const [a, b, c, , d, e, f, , g, h, i] = m;
    const n = [e * i - f * h, f * g - d * i, d * h - e * g, c * h - b * i, a * i - c * g,
      b * g - a * h, b * f - c * e, c * d - a * f, a * e - b * d];
    const det = a * n[0] + b * n[1] + c * n[2];
    return n.map((v) => v / det);
  },
};
function cube() {
  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]]];
  const out = [];
  for (const [n, u, v] of faces) for (const [a, b] of [[-1,-1],[1,-1],[1,1],[-1,-1],[1,1],[-1,1]])
    out.push(...[0, 1, 2].map(i => (n[i] + a * u[i] + b * v[i]) / 2), ...n);
  return new Float32Array(out);
}
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;
uniform mat4 uViewProjection, uModel; uniform mat3 uNormalMatrix;
out vec3 vPosition, vNormal;                 // world space, interpolated per fragment
void main() {
  vec4 world = uModel * vec4(aPosition, 1.0);
  vPosition = world.xyz;
  vNormal = uNormalMatrix * aNormal;
  gl_Position = uViewProjection * world;
}`], [gl.FRAGMENT_SHADER, `#version 300 es
precision highp float;                       // world positions need highp on mobile GPUs
in vec3 vPosition, vNormal;
uniform vec3 uColor, uAmbient, uLightPosition, uLightColor;
uniform bool uEmissive;
out vec4 outColor;
void main() {
  if (uEmissive) { outColor = vec4(1.0, 0.95, 0.7, 1.0); return; }   // the lamp bulb itself
  vec3 normal = normalize(vNormal);          // interpolation shortens normals
  vec3 toLight = uLightPosition - vPosition;
  float d = length(toLight);
  float diffuse = max(dot(normal, toLight / d), 0.0);
  float fade = clamp(1.0 - pow(d / 6.0, 4.0), 0.0, 1.0) / (d * d);   // range 6
  outColor = vec4(uColor * (uAmbient + uLightColor * diffuse * fade), 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, cube(), gl.STATIC_DRAW);
gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 24, 0); gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(1, 3, gl.FLOAT, false, 24, 12); gl.enableVertexAttribArray(1);
gl.enable(gl.DEPTH_TEST); gl.enable(gl.CULL_FACE);

const colors = [[0.12, 0.37, 0.55], [0.36, 0.25, 0.6], [0.88, 0.6, 0.06], [0.25, 0.49, 0.23], [0.71, 0.27, 0.18], [0.16, 0.62, 0.56]];
const items = colors.map((color, i) => ({ color,
  model: m4.model(-1.3 + i * 0.52, 0.5, 0, 0.42, 0.98, 0.12, (30 - i * 12) * Math.PI / 180) }));
items.push({ color: [0.54, 0.38, 0.25], model: m4.model(0, -0.03, 0, 3.4, 0.06, 0.7) });
items.push({ color: [0.85, 0.82, 0.76], model: m4.model(0, 0.8, -0.4, 4.4, 2.2, 0.05) });   // back wall

gl.uniform3f(u('uAmbient'), 0.12, 0.13, 0.16);
gl.uniform3f(u('uLightColor'), 2.0, 1.8, 1.5);              // an intensity, not a 0..1 colour
gl.uniformMatrix4fv(u('uViewProjection'), false, m4.multiply(m4.perspective(38, 3, 0.1, 20), m4.lookAt([1.1, 1.3, 3.4], [0.1, 0.35, 0])));
function frame(ms) {
  const lamp = [Math.sin(ms * 0.0007) * 1.6, 0.9, 0.7];
  gl.uniform3fv(u('uLightPosition'), lamp);                 // world space: no view matrix needed
  gl.clearColor(0.93, 0.91, 0.87, 1);
  gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
  gl.uniform1i(u('uEmissive'), 0);
  for (const { color, model } of items) {
    gl.uniformMatrix4fv(u('uModel'), false, model);
    gl.uniformMatrix3fv(u('uNormalMatrix'), false, m4.normal(model));
    gl.uniform3fv(u('uColor'), color);
    gl.drawArrays(gl.TRIANGLES, 0, 36);
  }
  gl.uniform1i(u('uEmissive'), 1);
  gl.uniformMatrix4fv(u('uModel'), false, m4.model(...lamp, 0.07, 0.07, 0.07));
  gl.drawArrays(gl.TRIANGLES, 0, 36);
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script>