Instancing needs identical meshes. Batching merges different static meshes into one vertex buffer so one call draws them all, and it stops at the first thing that differs between them, usually the texture. A texture atlas removes that break: pack the images into one texture and give each mesh the UVs of its cell. Here a 2D canvas packs the six BookNest covers into a 3 x 2 atlas, and the shader maps each cover's UVs into its cell:
<div style="display:flex;gap:10px;align-items:center">
<canvas id="atlas" width="768" height="768" style="width:120px"></canvas>
<canvas id="c" width="1000" height="238" style="width:500px"></canvas></div>
<script src="webgl-helper.js"></script>
<script type="module">
const images = await Promise.all([1, 2, 3, 4, 5, 6].map((n) =>
GLH.image(`booknest-cover-${n}.png`)));
const atlas = document.getElementById('atlas'), ctx = atlas.getContext('2d');
images.forEach((image, i) => ctx.drawImage(image, (i % 3) * 256, Math.floor(i / 3) * 384));
const gl = GLH.context('c'), wall = GLH.coverWall();
GLH.texture(gl, atlas, { mipmaps: false }); // one texture, on unit 0
const { attributes } = GLH.load(gl, `#version 300 es
in vec2 aPosition, aUV; in float aBook; out vec2 vUV;
void main() {
float cell = aBook; // column cell % 3, row cell / 3
vec2 corner = vec2(mod(cell, 3.0), 1.0 - floor(cell / 3.0));
vUV = (corner + vec2(aUV.x, 1.0 - aUV.y)) / vec2(3.0, 2.0); // v = 1 is the top
gl_Position = vec4(aPosition, 0, 1); }`,
`#version 300 es
precision mediump float; uniform sampler2D uAtlas; in vec2 vUV; out vec4 c;
void main() { c = texture(uAtlas, vUV); }`);
gl.bindVertexArray(GLH.vao(gl, attributes, wall.vertices,
[['aPosition', 2], ['aUV', 2], ['aBook', 1]], wall.indices));
gl.drawElements(gl.TRIANGLES, 36, gl.UNSIGNED_SHORT, 0); // all six at once
window.__done = true;
</script>
The atlas (left) is the only texture, and one drawElements() drew all six covers. Mipmaps blur neighboring cells into each other, so pad the cells or skip mipmaps as here; MAX_TEXTURE_SIZE (16384 here, 8192 on SwiftShader 2,546 ) caps the atlas. Same-sized images can use a TEXTURE_2D_ARRAY (Multi-Texturing) instead.
<!doctype html>
<style>
body { margin: 0; font: 11px system-ui, sans-serif; background: #f7f4ee; color: #333; }
.row { display: flex; gap: 10px; align-items: center; max-width: 600px; }
#atlas { width: 110px; flex: none; border: 1px solid #ccc; }
#c { flex: 1; min-width: 0; }
</style>
<div class="row">
<canvas id="atlas" width="768" height="768"></canvas>
<canvas id="c" width="960" height="228"></canvas>
</div>
<script>
// Pack six 256 x 384 covers into a 768 x 768 atlas: three columns, two rows.
const titles = [['#1f5f8b', 'The Quiet', 'Harbor'], ['#5b3f99', "Clockmaker's", 'Paradox'], ['#e09a10', 'Salt and', 'Saffron'],
['#3f7d3a', 'Small Steps', 'Big Summits'], ['#b5452f', 'Paper', 'Lanterns'], ['#2a9d8f', 'Gardens', 'in Glass']];
const atlas = document.getElementById('atlas'), ctx = atlas.getContext('2d');
titles.forEach(([color, a, b], i) => {
const x = (i % 3) * 256, y = Math.floor(i / 3) * 384;
ctx.fillStyle = color; ctx.fillRect(x, y, 256, 384);
ctx.strokeStyle = '#f2ead8'; ctx.lineWidth = 8; ctx.strokeRect(x + 16, y + 16, 224, 352);
ctx.fillStyle = 'rgba(255,255,255,0.25)'; ctx.beginPath(); ctx.arc(x + 128, y + 140, 60, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#fff'; ctx.font = 'bold 34px Georgia'; ctx.textAlign = 'center';
ctx.fillText(a, x + 128, y + 270); ctx.fillText(b, x + 128, y + 312);
});
const gl = document.getElementById('c').getContext('webgl2');
gl.bindTexture(gl.TEXTURE_2D, gl.createTexture()); // the only texture, on unit 0
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, gl.RGBA, gl.UNSIGNED_BYTE, atlas);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); // no mipmaps: they would bleed across cells
const program = gl.createProgram();
for (const [type, src] of [[gl.VERTEX_SHADER, `#version 300 es
layout(location = 0) in vec2 aPosition; layout(location = 1) in vec2 aUV; layout(location = 2) in float aBook;
out vec2 vUV;
void main() {
float cell = aBook; // column cell % 3, row cell / 3
vec2 corner = vec2(mod(cell, 3.0), floor(cell / 3.0));
vUV = (corner + vec2(aUV.x, 1.0 - aUV.y)) / vec2(3.0, 2.0); // canvas rows run top to bottom
gl_Position = vec4(aPosition, 0, 1);
}`], [gl.FRAGMENT_SHADER, `#version 300 es
precision mediump float; uniform sampler2D uAtlas; in vec2 vUV; out vec4 c; void main() { c = texture(uAtlas, vUV); }`]]) {
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);
// The cover wall merged into one buffer: x, y, u, v, book for 4 corners per cover, 6 indices each.
const vertices = [], indices = [];
titles.forEach((_, i) => {
for (const [u, v] of [[0, 0], [1, 0], [1, 1], [0, 1]]) vertices.push(-0.94 + i * 0.32 + u * 0.26, -0.82 + v * 1.64, u, v, i);
indices.push(i * 4, i * 4 + 1, i * 4 + 2, i * 4, i * 4 + 2, i * 4 + 3);
});
gl.bindVertexArray(gl.createVertexArray());
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices), gl.STATIC_DRAW);
[[0, 2, 0], [1, 2, 8], [2, 1, 16]].forEach(([loc, size, offset]) => { gl.vertexAttribPointer(loc, size, gl.FLOAT, false, 20, offset); gl.enableVertexAttribArray(loc); });
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(indices), gl.STATIC_DRAW);
gl.clearColor(0.93, 0.91, 0.87, 1); gl.clear(gl.COLOR_BUFFER_BIT);
gl.drawElements(gl.TRIANGLES, 36, gl.UNSIGNED_SHORT, 0); // all six at once
</script>