Each book's front face now samples its cover on unit 0, and Salt and Saffron gets the canvas-drawn sale sticker on unit 1. The fragment shader finds the front face by comparing the normal with the model's +z axis, passed through the same uNormalMatrix the vertex shader uses:
<canvas id="c" width="1240" height="380" style="width:620px;height:190px"></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 type="module">
const gl = GLH.context('c');
const { attributes, uniforms: u } = GLH.lit(gl, `#version 300 es
precision highp float;
in vec3 vPosition, vNormal; in vec2 vUV; out vec4 outColor;
uniform sampler2D uCover, uSticker; // texture units 0 and 1
uniform mat3 uNormalMatrix;
uniform vec3 uColor, uLamp;
uniform float uTextured, uSale;
void main() {
vec3 n = normalize(vNormal), l = normalize(uLamp - vPosition);
float front = step(0.99, dot(n, normalize(uNormalMatrix * vec3(0, 0, 1))));
vec3 base = mix(uColor, texture(uCover, vUV).rgb, front * uTextured);
vec4 sticker = texture(uSticker, vUV);
base = mix(base, sticker.rgb, sticker.a * front * uSale);
outColor = vec4(base * (0.35 + 0.8 * max(dot(n, l), 0.0)), 1.0); // ambient + diffuse
}`);
const shelf = GLH.shelf(gl, attributes), eye = [0.3, 0.35, 2.3];
const covers = await Promise.all([1, 2, 3, 4, 5, 6].map((n) =>
GLH.image(`booknest-cover-${n}.png`).then((image) => GLH.texture(gl, image))));
const badge = Object.assign(document.createElement('canvas'), { width: 256, height: 384 });
const g = badge.getContext('2d');
g.fillStyle = '#c62828'; g.beginPath(); g.arc(190, 250, 50, 0, 2 * Math.PI); g.fill();
g.fillStyle = 'white'; g.font = 'bold 34px Arial'; g.textAlign = 'center';
g.fillText('20%', 190, 248); g.font = 'bold 24px Arial'; g.fillText('OFF', 190, 276);
const sticker = GLH.texture(gl, badge);
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, sticker); gl.activeTexture(gl.TEXTURE0);
u.uSticker(1); u.uLamp([-0.6, 1.2, 2.0]); // uCover stays on unit 0
u.uViewProjection(shelf.camera(1240 / 380, eye));
gl.enable(gl.DEPTH_TEST); gl.enable(gl.CULL_FACE); // a new canvas starts cleared
shelf.items.forEach((item, i) => {
gl.bindTexture(gl.TEXTURE_2D, covers[i] ?? null); // the plank has no cover
u.uTextured(i < 6 ? 1 : 0); u.uSale(i === 2 ? 1 : 0);
shelf.draw(u, [item]);
});
window.__done = true;
</script>
Titles stay sharp thanks to mipmaps and anisotropy. The books (0.42 x 0.98) are narrower than the 2:3 art, so it is squeezed sideways. One bind and draw per book suits six; hundreds call for instancing (Post-Processing and Particles).
<!doctype html>
<style>
body { margin: 0; background: #f7f4ee; }
canvas { display: block; width: 100%; max-width: 600px; }
</style>
<canvas id="c" width="1240" height="380"></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) {
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);
}
// Cover art drawn with Canvas 2D (standing in for downloaded images).
const books = [['#1f5f8b', 'The Quiet', 'Harbor'], ['#5b3f99', "Clockmaker's", 'Paradox'], ['#b5452f', 'Salt and', 'Saffron'],
['#3f7d3a', 'Small Steps', 'Big Summits'], ['#e09a10', 'Paper', 'Lanterns'], ['#2a9d8f', 'Tide', 'Tables']];
function coverArt([color, line1, line2], i) {
const c = Object.assign(document.createElement('canvas'), { width: 256, height: 384 });
const g = c.getContext('2d');
g.fillStyle = color; g.fillRect(0, 0, 256, 384);
g.strokeStyle = 'rgba(255,255,255,0.6)'; g.lineWidth = 6; g.strokeRect(18, 18, 220, 348);
g.fillStyle = 'rgba(255,255,255,0.25)'; g.beginPath(); g.arc(128, 150, 60 + i * 4, 0, Math.PI * 2); g.fill();
g.fillStyle = '#fff'; g.textAlign = 'center'; g.font = 'bold 30px Georgia';
g.fillText(line1, 128, 280); g.fillText(line2, 128, 318);
return c;
}
const badge = Object.assign(document.createElement('canvas'), { width: 256, height: 384 });
const bg = badge.getContext('2d');
bg.fillStyle = '#c62828'; bg.beginPath(); bg.arc(190, 90, 50, 0, 2 * Math.PI); bg.fill();
bg.fillStyle = 'white'; bg.font = 'bold 34px Arial'; bg.textAlign = 'center';
bg.fillText('20%', 190, 88); bg.font = 'bold 24px Arial'; bg.fillText('OFF', 190, 116);
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 = vec2(aUV.x, 1.0 - aUV.y); gl_Position = uViewProjection * w; }`], [gl.FRAGMENT_SHADER, `#version 300 es
precision highp float;
in vec3 vPosition, vNormal; in vec2 vUV; out vec4 outColor;
uniform sampler2D uCover, uSticker; // texture units 0 and 1
uniform mat3 uNormalMatrix; uniform vec3 uColor, uLamp; uniform float uTextured, uSale;
void main() {
vec3 n = normalize(vNormal), l = normalize(uLamp - vPosition);
float front = step(0.99, dot(n, normalize(uNormalMatrix * vec3(0, 0, 1)))); // the cover face
vec3 base = mix(uColor, texture(uCover, vUV).rgb, front * uTextured);
vec4 sticker = texture(uSticker, vUV);
base = mix(base, sticker.rgb, sticker.a * front * uSale);
outColor = vec4(base * (0.35 + 0.8 * max(dot(n, l), 0.0)), 1.0); // ambient + diffuse
}`]]) {
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);
const ext = gl.getExtension('EXT_texture_filter_anisotropic');
function texture(source) {
const t = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, t);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, gl.RGBA, gl.UNSIGNED_BYTE, source);
gl.generateMipmap(gl.TEXTURE_2D);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
if (ext) gl.texParameterf(gl.TEXTURE_2D, ext.TEXTURE_MAX_ANISOTROPY_EXT, 8);
return t;
}
const covers = books.map(coverArt).map(texture);
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, texture(badge));
gl.activeTexture(gl.TEXTURE0); // covers are bound to unit 0 below
gl.uniform1i(u('uSticker'), 1); // uCover stays on unit 0
gl.uniform3f(u('uLamp'), -0.6, 1.2, 2.0);
gl.uniformMatrix4fv(u('uViewProjection'), false, m4.multiply(m4.perspective(40, 1240 / 380, 0.1, 20), m4.lookAt([0.3, 0.55, 2.4], [0.1, 0.4, 0])));
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);
const items = books.map(([color], i) => ({ color: [1, 3, 5].map(k => parseInt(color.slice(k, k + 2), 16) / 255),
model: m4.model(-1.3 + i * 0.52, 0.5, 0, 0.42, 0.98, 0.12, (30 - i * 12) * Math.PI / 180) }));
items.push({ color: [0.54, 0.38, 0.25], model: m4.model(0, -0.03, 0, 3.4, 0.06, 0.7) });
items.forEach(({ color, model }, i) => {
gl.bindTexture(gl.TEXTURE_2D, covers[i] ?? null); // the plank has no cover
gl.uniform1f(u('uTextured'), i < 6 ? 1 : 0);
gl.uniform1f(u('uSale'), i === 2 ? 1 : 0); // Salt and Saffron is on sale
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>