Jim Blinn's 1977 paper "Models of Light Reflection for Computer Synthesized Pictures" replaced Phong's R . V with N . H, where H, the half vector, lies halfway between L and V. The highlight is brightest when the surface normal points exactly between the light and the eye, which is the same condition in a cheaper form:
vec3 h = normalize(l + v); // halfway between light and eye
float blinn = pow(max(dot(n, h), 0.0), uShininess);For a directional light and a distant viewer, H is the same for every fragment and can be computed once on the CPU, which is why fixed-function OpenGL and Direct3D used Blinn-Phong. It also behaves better at grazing angles: R . V drops to zero abruptly once the angle between R and V passes 90 degrees, cutting highlights off with a hard edge, while N . H fades smoothly and stretches highlights along the surface, as real ones are. The angle between N and H is about half that between R and V, so Blinn-Phong needs roughly four times the exponent for the same highlight size. GLH.shelfFS uses Blinn-Phong.
<!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="380"></canvas>
<div class="names"><div>Phong: pow(max(dot(r, v), 0), 16)</div><div>Blinn-Phong: pow(max(dot(n, h), 0), 64)</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;
},
};
// A sphere (position = normal) followed by a large floor quad facing up.
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)]; };
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 = ring(i + di, j + dj); verts.push(...p, ...p); }
const sphereCount = verts.length / 6;
for (const [x, z] of [[-1, 1], [1, 1], [1, -1], [-1, 1], [1, -1], [-1, -1]]) verts.push(x * 4, 0, z * 4, 0, 1, 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; layout(location = 1) in vec3 aNormal;
uniform mat4 uViewProjection; uniform vec4 uPlace; // xyz offset, w scale
out vec3 vPosition, vNormal;
void main() { vNormal = aNormal; vPosition = uPlace.xyz + aPosition * uPlace.w;
gl_Position = uViewProjection * vec4(vPosition, 1.0); }`], [gl.FRAGMENT_SHADER, `#version 300 es
precision highp float;
in vec3 vPosition, vNormal;
uniform vec3 uEye, uLight, uColor; uniform bool uBlinn;
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), spec;
if (uBlinn) {
vec3 h = normalize(l + v); // halfway between light and eye
spec = pow(max(dot(n, h), 0.0), 64.0); // about 4x Phong's exponent for the same size
} else {
spec = pow(max(dot(reflect(-l, n), v), 0.0), 16.0);
}
if (lambert == 0.0) spec = 0.0;
outColor = vec4(uColor * (0.12 + 0.8 * lambert) + vec3(1.0, 0.95, 0.8) * spec, 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, 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); gl.enable(gl.SCISSOR_TEST);
const eye = [0, 0.9, 3.4];
gl.uniform3fv(u('uEye'), eye);
gl.uniformMatrix4fv(u('uViewProjection'), false, m4.multiply(m4.perspective(45, 600 / 380, 0.1, 20), m4.lookAt(eye, [0, 0.3, 0])));
function frame(ms) {
// A lamp low behind the scene: the desk is lit at a grazing angle.
gl.uniform3f(u('uLight'), Math.sin(ms * 0.0005) * 1.5, 0.6, -3.0);
for (let pane = 0; pane < 2; pane++) {
gl.viewport(pane * 600, 0, 600, 380); gl.scissor(pane * 600 + 3, 0, 594, 380);
gl.clearColor(0.93, 0.91, 0.87, 1);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
gl.uniform1i(u('uBlinn'), pane);
gl.uniform4f(u('uPlace'), 0, 0, 0, 1); gl.uniform3f(u('uColor'), 0.36, 0.25, 0.2); // polished desk
gl.drawArrays(gl.TRIANGLES, sphereCount, 6);
gl.uniform4f(u('uPlace'), 0, 0.45, 0, 0.45); gl.uniform3f(u('uColor'), 0.12, 0.37, 0.55); // globe
gl.drawArrays(gl.TRIANGLES, 0, sphereCount);
}
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script>