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