The finished scene places four copies of the shelf in a row receding into the shop, lit by a lamp near the front and another deeper inside, with glossy foil titles and exponential-squared fog in the canvas's own color. Each copy reuses the shelf's items with an extra translation in front of their model matrices; translations leave the normal matrices unchanged:
<canvas id="c" width="1240" height="340" style="width:620px;height:170px"></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'), { attributes, uniforms } = GLH.lit(gl, GLH.shelfFS);
const shelf = GLH.shelf(gl, attributes), { mat4 } = glMatrix, eye = [-0.4, 1.0, 3.2];
const rows = [0, 1, 2, 3].flatMap((k) => shelf.items.map((item) => ({ ...item,
model: mat4.multiply(mat4.create(), // each shelf 2 right and 2.4 back
mat4.fromTranslation(mat4.create(), [2 * k, 0, -2.4 * k]), item.model) })));
uniforms.uAmbient([0.2, 0.21, 0.25]);
uniforms.uEye(eye);
uniforms.uFogColor([0.93, 0.91, 0.87]); // the canvas's clear color
const lamps = [[[0.2, 1.0, 1.5], [2.6, 2.3, 1.9]], [[-2.5, 1.0, -3.0], [2.0, 1.9, 1.7]]];
lamps.forEach(([p, c], i) => {
uniforms[`uLights[${i}].position`](p);
uniforms[`uLights[${i}].color`](c);
});
uniforms.uShininess(60);
uniforms.uFoil(1.0); // glossy title bands
gl.enable(gl.DEPTH_TEST);
gl.enable(gl.CULL_FACE);
GLH.panes(gl, 2, (pane, aspect) => {
uniforms.uFogDensity(pane === 0 ? 0 : 0.12); // left: no fog
uniforms.uViewProjection(shelf.camera(aspect, eye));
shelf.draw(uniforms, rows);
});
window.__done = true;
</script>
Without fog, the far shelves are dark blocks, out of the lamps' reach, and the eye reads them as holes; with fog they fade into the room's color and the depth of the shop becomes obvious, while the nearest shelf keeps nearly full contrast. The foil bands catch the lamp in a glossy line across the covers' upper halves. Later sections build their shadows and textures on this lit shelf and GLH.shelfFS.
<!doctype html>
<style>
body { margin: 0; background: #f7f4ee; }
canvas { display: block; width: 100%; max-width: 600px; }
</style>
<canvas id="c" width="1240" height="340"></canvas>
<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;
},
model(x, y, z, sx, sy, sz, turn = 0) { // T * Ry(turn) * S
const c = Math.cos(turn), s = Math.sin(turn);
return [c * sx, 0, -s * sx, 0, 0, sy, 0, 0, s * sz, 0, c * sz, 0, x, y, z, 1];
},
normal(m) {
const [a, b, c, , d, e, f, , g, h, i] = m;
const n = [e * i - f * h, f * g - d * i, d * h - e * g, c * h - b * i, a * i - c * g,
b * g - a * h, b * f - c * e, c * d - a * f, a * e - b * d];
const det = a * n[0] + b * n[1] + c * n[2];
return n.map((v) => v / det);
},
};
function cube() { // position, normal, uv
const faces = [[[0,0,1],[1,0,0],[0,1,0]], [[0,0,-1],[-1,0,0],[0,1,0]], [[1,0,0],[0,0,-1],[0,1,0]],
[[-1,0,0],[0,0,1],[0,1,0]], [[0,1,0],[1,0,0],[0,0,-1]], [[0,-1,0],[1,0,0],[0,0,1]]];
const out = [];
for (const [n, u, v] of faces) for (const [a, b] of [[-1,-1],[1,-1],[1,1],[-1,-1],[1,1],[-1,1]])
out.push(...[0, 1, 2].map(i => (n[i] + a * u[i] + b * v[i]) / 2), ...n, (a + 1) / 2, (b + 1) / 2);
return new Float32Array(out);
}
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; layout(location = 2) in vec2 aUV;
uniform mat4 uViewProjection, uModel; uniform mat3 uNormalMatrix;
out vec3 vPosition, vNormal; out vec2 vUV;
void main() { vec4 w = uModel * vec4(aPosition, 1.0); vPosition = w.xyz; vNormal = uNormalMatrix * aNormal; vUV = aUV;
gl_Position = uViewProjection * w; }`], [gl.FRAGMENT_SHADER, `#version 300 es
precision highp float;
struct Light { vec3 position, color; };
uniform Light uLights[2];
uniform vec3 uColor, uAmbient, uEye, uFogColor;
uniform float uShininess, uFoil, uFogDensity;
in vec3 vPosition, vNormal; in vec2 vUV; out vec4 outColor;
void main() {
vec3 n = normalize(vNormal), v = normalize(uEye - vPosition);
float band = step(abs(vUV.y - 0.78), 0.05); // a foil title band near the top
vec3 diffuse = uAmbient, specular = vec3(0.0);
for (int i = 0; i < 2; i++) {
vec3 toLight = uLights[i].position - vPosition;
float d = length(toLight); vec3 l = toLight / d;
float fade = clamp(1.0 - pow(d / 6.0, 4.0), 0.0, 1.0) / (d * d);
float lambert = max(dot(n, l), 0.0);
diffuse += uLights[i].color * lambert * fade;
if (lambert > 0.0) specular += uLights[i].color * pow(max(dot(n, normalize(l + v)), 0.0), uShininess) * fade;
}
vec3 color = uColor * diffuse + specular * uFoil * band;
float fog = exp(-pow(uFogDensity * distance(uEye, vPosition), 2.0)); // exponential squared
outColor = vec4(mix(uFogColor, color, fog), 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, cube(), gl.STATIC_DRAW);
gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 32, 0); gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(1, 3, gl.FLOAT, false, 32, 12); gl.enableVertexAttribArray(1);
gl.vertexAttribPointer(2, 2, gl.FLOAT, false, 32, 24); gl.enableVertexAttribArray(2);
// One shelf, then four copies, each 2 right and 2.4 back (a translation in front of each model).
const covers = ['#1f5f8b', '#5b3f99', '#e09a10', '#3f7d3a', '#b5452f', '#2a9d8f'];
const rgb = (hex) => [1, 3, 5].map((k) => parseInt(hex.slice(k, k + 2), 16) / 255);
const shelf = covers.map((hex, i) => ({ color: rgb(hex), place: [-1.3 + i * 0.52, 0.5, 0], size: [0.42, 0.98, 0.12], turn: (30 - i * 12) * Math.PI / 180 }));
shelf.push({ color: rgb('#8a6240'), place: [0, -0.03, 0], size: [3.4, 0.06, 0.7], turn: 0 });
const rows = [0, 1, 2, 3].flatMap((k) => shelf.map(({ color, place, size, turn }) =>
({ color, model: m4.model(place[0] + 2 * k, place[1], place[2] - 2.4 * k, ...size, turn) })));
const eye = [1.0, 1.5, 4.4];
gl.uniform3f(u('uAmbient'), 0.2, 0.21, 0.25);
gl.uniform3fv(u('uEye'), eye);
gl.uniform3f(u('uFogColor'), 0.93, 0.91, 0.87); // the canvas's clear colour
[[[0.2, 1.0, 1.5], [2.6, 2.3, 1.9]], [[-2.5, 1.0, -3.0], [2.0, 1.9, 1.7]]].forEach(([p, c], i) => {
gl.uniform3fv(u(`uLights[${i}].position`), p);
gl.uniform3fv(u(`uLights[${i}].color`), c);
});
gl.uniform1f(u('uShininess'), 60);
gl.uniform1f(u('uFoil'), 1.0); // glossy title bands
gl.enable(gl.DEPTH_TEST); gl.enable(gl.CULL_FACE); gl.enable(gl.SCISSOR_TEST);
gl.uniformMatrix4fv(u('uViewProjection'), false, m4.multiply(m4.perspective(40, 620 / 340, 0.1, 40), m4.lookAt(eye, [2.4, 0.3, -3.0])));
for (let pane = 0; pane < 2; pane++) {
gl.viewport(pane * 620, 0, 620, 340); gl.scissor(pane * 620 + 3, 0, 614, 340);
gl.clearColor(0.93, 0.91, 0.87, 1);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
gl.uniform1f(u('uFogDensity'), pane === 0 ? 0 : 0.12); // left: no fog
for (const { color, model } of rows) {
gl.uniformMatrix4fv(u('uModel'), false, model);
gl.uniformMatrix3fv(u('uNormalMatrix'), false, m4.normal(model));
gl.uniform3fv(u('uColor'), color);
gl.drawArrays(gl.TRIANGLES, 0, 36);
}
}
</script>