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:
// 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.
<!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>