Cook-Torrance BRDF

The Cook-Torrance BRDF in a Fragment Shader

A BRDF says how much light from direction l leaves toward the viewer v. Cook-Torrance treats a surface as tiny mirrors, with specular D G F / (4 (n.l)(n.v)): D, how many face the half vector (GGX, Walter et al. 2007); G, how many are unshadowed (Smith-Schlick, k = (r + 1)^2 / 8 per Karis); F, Schlick's Fresnel. The twentieth part of webgl-helper.js holds it and the ambient fit that Image-Based Lighting uses:

webgl-helper.js, twentieth part: the Cook-Torrance BRDF and an ambient fitJavaScript
// Section 3.14: GLSL for metallic-roughness PBR: the Cook-Torrance BRDF for one light (GGX
// distribution, Smith-Schlick geometry, Schlick Fresnel) and Karis's fit for image-based light
GLH.brdf = `
  const float PI = 3.14159265;
  vec3 brdf(vec3 n, vec3 v, vec3 l, vec3 albedo, float metallic, float roughness) {
    vec3 h = normalize(v + l);
    float nl = max(dot(n, l), 0.0), nv = max(dot(n, v), 1e-4), nh = max(dot(n, h), 0.0);
    float a2 = pow(roughness, 4.0);                              // alpha = roughness squared
    float d = a2 / (PI * pow(nh * nh * (a2 - 1.0) + 1.0, 2.0));    // GGX microfacet normals
    float k = (roughness + 1.0) * (roughness + 1.0) / 8.0;
    float g = nl / (nl * (1.0 - k) + k) * nv / (nv * (1.0 - k) + k); // Smith shadowing
    vec3 f0 = mix(vec3(0.04), albedo, metallic);                  // reflectance head-on
    vec3 f = f0 + (1.0 - f0) * pow(1.0 - max(dot(h, v), 0.0), 5.0);   // Schlick Fresnel
    vec3 specular = d * g * f / (4.0 * nv * max(nl, 1e-4));
    vec3 diffuse = (1.0 - f) * (1.0 - metallic) * albedo / PI;   // metals have none
    return (diffuse + specular) * nl;
  }
  vec3 ambientBRDF(vec3 f0, float roughness, float nv) {        // split-sum fit, Karis 2014
    vec4 r = roughness * vec4(-1, -0.0275, -0.572, 0.022) + vec4(1, 0.0425, 1.04, -0.04);
    float a004 = min(r.x * r.x, exp2(-9.28 * nv)) * r.x + r.y;
    vec2 ab = vec2(-1.04, 1.04) * a004 + r.zw;
    return f0 * ab.x + ab.y;
  }`;

Multiply by each light's intensity (over distance squared for point lights) and sum the lights.

The Cook-Torrance terms on a sphere with sweeping roughness: GGX distribution D, Smith shadowing G, Schlick Fresnel F, and the full BRDFHTMLLive
<!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; font-size: 10px; }
  .names div { flex: 1; padding: 2px; }
</style>
<canvas id="c" width="600" height="160"></canvas>
<div class="names"><div>D: microfacets facing h</div><div>G: unshadowed microfacets</div><div>F: Fresnel (gold)</div><div id="full">full BRDF</div></div>
<script>
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 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; out vec3 vNormal;
void main() { vNormal = aPosition; gl_Position = vec4(aPosition.xy * 0.85, -aPosition.z * 0.5, 1.0); }`],
  [gl.FRAGMENT_SHADER, `#version 300 es
precision highp float;
in vec3 vNormal; uniform float uRoughness; uniform int uTerm; out vec4 c;
const float PI = 3.14159265;
void main() {
  vec3 n = normalize(vNormal), v = vec3(0.0, 0.0, 1.0), l = normalize(vec3(0.6, 0.5, 0.6));
  vec3 albedo = pow(vec3(0.88, 0.6, 0.06), vec3(2.2));
  float metallic = 1.0;
  vec3 h = normalize(v + l);
  float nl = max(dot(n, l), 0.0), nv = max(dot(n, v), 1e-4), nh = max(dot(n, h), 0.0);
  float a2 = pow(uRoughness, 4.0);                                  // alpha = roughness squared
  float d = a2 / (PI * pow(nh * nh * (a2 - 1.0) + 1.0, 2.0));        // GGX microfacet normals
  float k = (uRoughness + 1.0) * (uRoughness + 1.0) / 8.0;
  float g = nl / (nl * (1.0 - k) + k) * nv / (nv * (1.0 - k) + k);  // Smith-Schlick shadowing
  vec3 f0 = mix(vec3(0.04), albedo, metallic);
  vec3 f = f0 + (1.0 - f0) * pow(1.0 - max(dot(h, v), 0.0), 5.0);   // Schlick Fresnel
  vec3 specular = d * g * f / (4.0 * nv * max(nl, 1e-4));
  vec3 terms[4] = vec3[](vec3(d / (1.0 + d)), vec3(g), f, specular * nl * 3.0 + albedo * 0.05);
  vec3 color = terms[uTerm];
  if (uTerm == 3) color = pow(color / (1.0 + color), vec3(1.0 / 2.2));
  c = vec4(color, 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 ball = sphere(32, 48);
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER, ball.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, ball.indices, gl.STATIC_DRAW);
gl.enable(gl.DEPTH_TEST); gl.enable(gl.CULL_FACE);
gl.clearColor(0.97, 0.96, 0.93, 1);
function frame(ms) {
  const roughness = 0.45 + 0.35 * Math.sin(ms * 0.0008);
  gl.uniform1f(gl.getUniformLocation(program, 'uRoughness'), roughness);
  gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
  for (let i = 0; i < 4; i++) {
    gl.uniform1i(gl.getUniformLocation(program, 'uTerm'), i);
    gl.viewport(i * 150 + 5, 5, 150, 150);
    gl.drawElements(gl.TRIANGLES, ball.indices.length, gl.UNSIGNED_SHORT, 0);
  }
  document.getElementById('full').textContent = `D G F / (4 n.l n.v), roughness ${roughness.toFixed(2)}`;
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script>