Image-Based Lighting

Image-Based Lighting with an Environment Map

Image-based lighting (IBL) lights objects with a picture of their surroundings, usually a cube map: the specular part samples it in the reflection direction, blurrier for rougher surfaces, and the diffuse part samples a fully blurred version along the normal. Proper IBL prefilters each level with the GGX lobe; this demo uses the cube map's plain mipmaps, with Karis's ambientBRDF() fit as the split-sum scale and bias. The environment is a small room drawn on six sRGB canvases with a bright window in front. Roughness runs 0.1 to 0.9 across:

BookNest gold as a dielectric (top) and a metal (bottom), roughness 0.1 to 0.9HTML
<canvas id="c" width="1240" height="400" style="width:620px;height:200px"></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'), { mat4, mat3 } = glMatrix;
const { attributes, uniforms: u } = GLH.lit(gl, `#version 300 es
  precision highp float;
  in vec3 vPosition, vNormal; in vec2 vUV; out vec4 outColor;
  uniform samplerCube uEnvironment;
  uniform vec3 uAlbedo, uEye, uSun;
  uniform float uMetallic, uRoughness;
  ${GLH.brdf}
  void main() {
    vec3 n = normalize(vNormal), v = normalize(uEye - vPosition);
    vec3 albedo = pow(uAlbedo, vec3(2.2));                        // sRGB color -> linear
    vec3 color = brdf(n, v, normalize(uSun), albedo, uMetallic, uRoughness) * 3.0;
    float mip = uRoughness * 7.0;                                 // blurrier for rougher
    vec3 f0 = mix(vec3(0.04), albedo, uMetallic);
    color += textureLod(uEnvironment, reflect(-v, n), mip).rgb *
      ambientBRDF(f0, uRoughness, max(dot(n, v), 0.0));
    color += textureLod(uEnvironment, n, 7.0).rgb * albedo * (1.0 - uMetallic);
    color = color / (1.0 + color);                                // Reinhard tone mapping
    outColor = vec4(pow(color, vec3(1.0 / 2.2)), 1.0);            // linear -> sRGB
  }`);
const sphere = GLH.sphere(24, 32);
const vao = GLH.vao(gl, attributes, sphere.vertices, [['aPosition', 3], ['aNormal', 3],
  ['aUV', 2]], sphere.indices);
const face = Object.assign(document.createElement('canvas'), { width: 128, height: 128 });
const g = face.getContext('2d'), environment = gl.createTexture();
gl.bindTexture(gl.TEXTURE_CUBE_MAP, environment);
for (let i = 0; i < 6; i++) {               // +x -x +y -y +z -z: a room with a bright window
  const wall = g.createLinearGradient(0, 0, 0, 128);
  wall.addColorStop(0, i === 3 ? '#6b4a2e' : '#dfe9f5');         // floor (-y) or sky
  wall.addColorStop(1, i === 2 ? '#dfe9f5' : '#6b4a2e');         // ceiling (+y) or floor
  g.fillStyle = wall; g.fillRect(0, 0, 128, 128);
  if (i === 4) { g.fillStyle = '#ffffff'; g.fillRect(24, 16, 80, 56); }   // the window
  gl.texImage2D(gl.TEXTURE_CUBE_MAP_POSITIVE_X + i, 0, gl.SRGB8_ALPHA8, gl.RGBA,
    gl.UNSIGNED_BYTE, face);
}
gl.generateMipmap(gl.TEXTURE_CUBE_MAP);          // 128 down to 1: levels 0 to 7
gl.texParameteri(gl.TEXTURE_CUBE_MAP, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
u.uViewProjection(mat4.ortho([], -3.1, 3.1, -1, 1, -2, 2));   // a flat chart, 3.1:1
u.uEye([0, 0, 40]); u.uSun([-0.5, 0.7, 0.8]); u.uAlbedo(GLH.rgb('#e09a10'));
gl.enable(gl.DEPTH_TEST); gl.enable(gl.CULL_FACE);
gl.bindVertexArray(vao);
for (let row = 0; row < 2; row++) {
  for (let col = 0; col < 5; col++) {
    const model = mat4.fromTranslation([], [(col - 2) * 1.2, 0.5 - row, 0]);
    mat4.scale(model, model, [0.47, 0.47, 0.47]);
    u.uModel(model); u.uNormalMatrix(mat3.normalFromMat4([], model));
    u.uMetallic(row); u.uRoughness(0.1 + col * 0.2);       // dielectric row, then metal
    gl.drawElements(gl.TRIANGLES, sphere.indices.length, gl.UNSIGNED_SHORT, 0);
  }
}
window.__done = true;
</script>
Browser output of Listing 3.109
Browser output of 109

Left to right, the sun's highlight spreads and dims and the window's reflection blurs away. The dielectric row keeps its diffuse yellow under a white highlight; the metal row has no diffuse light, only gold-tinted reflections of the room. Production IBL uses prefiltered HDR environments, such as Poly Haven 82,537 's CC0 HDRIs.

BookNest gold as a dielectric (top) and a metal (bottom), roughness 0.1 to 0.9, lit by a canvas-drawn cube-map roomHTMLLive
<!doctype html>
<style>
  body { margin: 0; background: #f7f4ee; }
  canvas { display: block; width: 100%; max-width: 600px; }
</style>
<canvas id="c" width="1240" height="400"></canvas>
<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 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);
    float d = a2 / (PI * pow(nh * nh * (a2 - 1.0) + 1.0, 2.0));
    float k = (roughness + 1.0) * (roughness + 1.0) / 8.0;
    float g = nl / (nl * (1.0 - k) + k) * nv / (nv * (1.0 - k) + k);
    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);
    vec3 specular = d * g * f / (4.0 * nv * max(nl, 1e-4));
    vec3 diffuse = (1.0 - f) * (1.0 - metallic) * albedo / PI;
    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;
  }`;
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; uniform vec3 uCenter; out vec3 vPosition, vNormal;
void main() {
  vNormal = aPosition; vPosition = uCenter + aPosition * 0.47;
  gl_Position = vec4(vPosition.x / 3.1, vPosition.y, -vPosition.z * 0.3, 1.0);   // a flat chart, 3.1 : 1
}`], [gl.FRAGMENT_SHADER, `#version 300 es
precision highp float;
in vec3 vPosition, vNormal; out vec4 outColor;
uniform samplerCube uEnvironment;
uniform vec3 uAlbedo, uEye, uSun;
uniform float uMetallic, uRoughness;
${brdf}
void main() {
  vec3 n = normalize(vNormal), v = normalize(uEye - vPosition);
  vec3 albedo = pow(uAlbedo, vec3(2.2));                        // sRGB colour -> linear
  vec3 color = brdf(n, v, normalize(uSun), albedo, uMetallic, uRoughness) * 3.0;
  float mip = uRoughness * 7.0;                                 // blurrier for rougher
  vec3 f0 = mix(vec3(0.04), albedo, uMetallic);
  color += textureLod(uEnvironment, reflect(-v, n), mip).rgb * ambientBRDF(f0, uRoughness, max(dot(n, v), 0.0));
  color += textureLod(uEnvironment, n, 7.0).rgb * albedo * (1.0 - uMetallic);
  color = color / (1.0 + color);                                // Reinhard tone mapping
  outColor = vec4(pow(color, vec3(1.0 / 2.2)), 1.0);            // linear -> sRGB
}`]]) {
  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(24, 32);
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);

// The environment: six sRGB canvases, a room with a bright window in front (+z).
const face = Object.assign(document.createElement('canvas'), { width: 128, height: 128 });
const g = face.getContext('2d');
gl.bindTexture(gl.TEXTURE_CUBE_MAP, gl.createTexture());
for (let i = 0; i < 6; i++) {                                   // +x -x +y -y +z -z
  const wall = g.createLinearGradient(0, 0, 0, 128);
  wall.addColorStop(0, i === 3 ? '#6b4a2e' : '#dfe9f5');        // floor (-y) or sky
  wall.addColorStop(1, i === 2 ? '#dfe9f5' : '#6b4a2e');        // ceiling (+y) or floor
  g.fillStyle = wall; g.fillRect(0, 0, 128, 128);
  if (i === 4) { g.fillStyle = '#ffffff'; g.fillRect(24, 16, 80, 56); }   // the window
  gl.texImage2D(gl.TEXTURE_CUBE_MAP_POSITIVE_X + i, 0, gl.SRGB8_ALPHA8, gl.RGBA, gl.UNSIGNED_BYTE, face);
}
gl.generateMipmap(gl.TEXTURE_CUBE_MAP);                         // 128 down to 1: levels 0 to 7
gl.texParameteri(gl.TEXTURE_CUBE_MAP, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);

const u = (n) => gl.getUniformLocation(program, n);
gl.uniform3f(u('uEye'), 0, 0, 40); gl.uniform3f(u('uSun'), -0.5, 0.7, 0.8);
gl.uniform3f(u('uAlbedo'), 0.88, 0.6, 0.06);                    // BookNest gold
gl.enable(gl.DEPTH_TEST); gl.enable(gl.CULL_FACE);
gl.clearColor(0.93, 0.91, 0.87, 1);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
for (let row = 0; row < 2; row++) {
  for (let col = 0; col < 5; col++) {
    gl.uniform3f(u('uCenter'), (col - 2) * 1.2, 0.5 - row, 0);
    gl.uniform1f(u('uMetallic'), row);                          // dielectric row, then metal
    gl.uniform1f(u('uRoughness'), 0.1 + col * 0.2);
    gl.drawElements(gl.TRIANGLES, ball.indices.length, gl.UNSIGNED_SHORT, 0);
  }
}
</script>