Catalog HUD

Displaying BookNest's Catalog Data as a HUD

The finished page loads the OBJ and MTL files, draws the library, and lays a 2D canvas over it with GLH.books: title, price, rating, a color key and a leader line to each book, from its projected world matrix:

The library scene graph with a 2D canvas HUD listing BookNest's catalogHTML
<div style="position:relative;width:620px">
  <canvas id="c" width="1240" height="520" style="width:620px;height:260px"></canvas>
  <canvas id="hud" width="1240" height="520" style="position:absolute;left:0;top:0;
    width:620px;height:260px;pointer-events:none"></canvas>
</div>
<script src="https://cdn.jsdelivr.net/npm/gl-matrix@3.4.4/gl-matrix-min.js"></script>
<script src="webgl-helper.js"></script>
<script src="webgl-library-scene.js"></script>
<script type="module">
const gl = GLH.context('c'), { mat4, vec3 } = 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 sampler2D uCover; uniform mat3 uNormalMatrix; uniform vec3 uColor, uLamp;
  uniform float uTextured;
  void main() {                                      // cover art on +z faces, as in 3.12.10
    vec3 n = normalize(vNormal), m = normalize(uNormalMatrix * vec3(0, 0, 1));
    vec3 base = mix(uColor, texture(uCover, vUV).rgb, step(0.99, dot(n, m)) * uTextured);
    outColor = vec4(base * (0.4 + 0.7 * max(dot(n, normalize(uLamp - vPosition)), 0.0)), 1);
  }`);
const [obj, mtl] = await Promise.all(['booknest-book.obj', 'booknest-book.mtl']
  .map((file) => fetch(file).then((response) => response.text())));
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 scene = libraryScene(gl, attributes, GLH.obj(obj, mtl), covers);
const camera = mat4.multiply([], mat4.perspective([], 0.75, 1240 / 520, 0.1, 20),
  mat4.lookAt([], [1.1, 1.3, 4.4], [1.05, 1.15, 0], [0, 1, 0]));
u.uViewProjection(camera); u.uLamp([-1, 2.5, 3]);
gl.enable(gl.DEPTH_TEST); gl.enable(gl.CULL_FACE);
scene.draw(u);
const hud = document.getElementById('hud').getContext('2d');   // the HUD, in CSS pixels
hud.scale(2, 2);
hud.fillStyle = 'rgba(255, 253, 247, 0.92)'; hud.fillRect(404, 10, 206, 124);
hud.fillStyle = '#222'; hud.font = 'bold 14px Georgia';
hud.fillText('BookNest catalog', 416, 30);
GLH.walk(scene.root, (node) => {
  const i = node.index, y = 50 + 16 * i, { title, price, rating } = GLH.books[i] ?? {};
  if (!title) return;                                // only the six catalog books
  const top = vec3.transformMat4([], [0, 0.5, 0.06], node.world);   // world, then clip
  const [cx, cy] = vec3.transformMat4([], top, camera);
  hud.strokeStyle = hud.fillStyle = GLH.covers[i];   // a leader line and a color key
  hud.beginPath(); hud.moveTo((cx + 1) * 310, (1 - cy) * 130); hud.lineTo(412, y - 4);
  hud.stroke(); hud.fillRect(412, y - 9, 8, 9);
  hud.fillStyle = '#222'; hud.font = '11px Arial';
  hud.fillText(title.length > 19 ? title.slice(0, 18) + '...' : title, 424, y);
  hud.fillText(`$${price.toFixed(2)}  ${rating}`, 548, y);
});
window.__done = true;
</script>
Browser output of Listing 3.136
Browser output of 136

The HUD is drawn once, and with pointer-events: none it lets Rotation and Picking's picking work through it.

The library scene graph (OBJ books on three shelves) with a 2D canvas HUD listing BookNest's catalog, leader lines to each bookHTMLLive
<!doctype html>
<style>
  body { margin: 0; font: 11px system-ui, sans-serif; background: #f7f4ee; color: #333; }
  .wrap { position: relative; width: 100%; max-width: 600px; }
  .wrap canvas { display: block; width: 100%; }
  #hud { position: absolute; left: 0; top: 0; pointer-events: none; }
  pre { max-width: 600px; margin: 4px 0; font: 11px monospace; }
</style>
<div class="wrap">
  <canvas id="c" width="1200" height="520"></canvas>
  <canvas id="hud" width="1200" height="520"></canvas>
</div>
<script>
// --- 4x4 matrices (column-major) ---
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];
  },
  identity: () => [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 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;
  },
  translation: (x, y, z) => [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, x, y, z, 1],
  scaling: (x, y, z) => [x, 0, 0, 0, 0, y, 0, 0, 0, 0, z, 0, 0, 0, 0, 1],
  rotationY(a) { const c = Math.cos(a), s = Math.sin(a); return [c, 0, -s, 0, 0, 1, 0, 0, s, 0, c, 0, 0, 0, 0, 1]; },
  chain: (...ms) => ms.reduce((a, b) => m4.multiply(a, b)),
  apply: (m, [x, y, z]) => [0, 1, 2].map((r) => m[r] * x + m[4 + r] * y + m[8 + r] * z + m[12 + r]),
  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 program(vs, fs) {
  const p = gl.createProgram();
  for (const [type, src] of [[gl.VERTEX_SHADER, vs], [gl.FRAGMENT_SHADER, fs]]) {
    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(p, s);
  }
  gl.linkProgram(p);
  return p;
}
const canvas = document.getElementById('c');
const gl = canvas.getContext('webgl2');
const u = (p, n) => gl.getUniformLocation(p, n);

// --- The OBJ and MTL files, inline (a page may not fetch files) ---
const BOOK_OBJ = `# BookNest book, 0.42 x 0.98 x 0.12: cover facing +z, spine on -x, y up
mtllib booknest-book.mtl
o Book
v -0.21 -0.49 0.06
v 0.21 -0.49 0.06
v 0.21 0.49 0.06
v -0.21 0.49 0.06
v -0.21 -0.49 -0.06
v 0.21 -0.49 -0.06
v 0.21 0.49 -0.06
v -0.21 0.49 -0.06
vt 0 0
vt 1 0
vt 1 1
vt 0 1
vn 0 0 1
vn 0 0 -1
vn -1 0 0
vn 1 0 0
vn 0 1 0
vn 0 -1 0
usemtl cover
s off
f 1/1/1 2/2/1 3/3/1 4/4/1
f 6/1/2 5/2/2 8/3/2 7/4/2
f 5/1/3 1/2/3 4/3/3 8/4/3
usemtl pages
f 2/1/4 6/2/4 7/3/4 3/4/4
f 4/1/5 3/2/5 7/3/5 8/4/5
f 5/1/6 6/2/6 2/3/6 1/4/6`;
const BOOK_MTL = `newmtl cover
Kd 0.122 0.373 0.545
Ks 0.25 0.25 0.25
Ns 60
illum 2
map_Kd booknest-cover-1.png
newmtl pages
Kd 0.95 0.93 0.86
illum 1`;
// A minimal OBJ + MTL parser: v, vt, vn, polygon faces (fanned into triangles), usemtl groups.
// Vertices come out as x y z, nx ny nz, u v.
function parseOBJ(objText, mtlText = '') {
  const lines = (text) => text.split('\n').map((line) => line.trim().split(/\s+/)).filter(([key]) => key && !key.startsWith('#'));
  const materials = {}, gray = [0.8, 0.8, 0.8];
  let material;
  for (const [key, ...args] of lines(mtlText)) {
    if (key === 'newmtl') material = materials[args[0]] = { name: args[0], Kd: gray };
    else if (/^K[adse]$/.test(key)) material[key] = args.map(Number);   // colours
    else if (/^(Ns|d|Ni|illum)$/.test(key)) material[key] = Number(args[0]);
    else if (key.startsWith('map_')) material[key] = args.at(-1);       // the file name after any options
  }
  const data = { v: [], vt: [], vn: [] }, vertices = [], indices = [], groups = [];
  const seen = new Map();                                    // "v/vt/vn" -> vertex number
  const pick = (list, i) => list[i < 0 ? list.length + i : i - 1];   // 1-based, or relative if negative
  for (const [key, ...args] of lines(objText)) {
    if (key in data) data[key].push(args.map(Number));
    else if (key === 'usemtl') groups.push({ material: materials[args[0]] ?? { name: args[0], Kd: gray }, start: indices.length });
    else if (key === 'f') {
      const corners = args.map((corner) => {
        const [v, vt, vn] = corner.split('/').map(Number);  // 'v//vn' gives vt = 0
        const id = `${pick(data.v, v)}/${vt && pick(data.vt, vt)}/${vn && pick(data.vn, vn)}`;
        if (!seen.has(id)) {                                // one vertex per distinct combination
          seen.set(id, vertices.length / 8);
          vertices.push(...pick(data.v, v), ...(vn ? pick(data.vn, vn) : [0, 0, 1]), ...(vt ? pick(data.vt, vt).slice(0, 2) : [0, 0]));
        }
        return seen.get(id);
      });
      for (let k = 1; k < corners.length - 1; k++) indices.push(corners[0], corners[k], corners[k + 1]);   // a fan
    }
  }
  if (!groups.length) groups.push({ material: { name: 'default', Kd: gray }, start: 0 });
  groups.forEach((g, i) => { g.count = (groups[i + 1]?.start ?? indices.length) - g.start; });
  return { vertices: new Float32Array(vertices), indices: new Uint16Array(indices), groups };
}

// --- Meshes: a unit box (for the wood) and the parsed book, each in a VAO ---
function meshVAO(vertices, indices) {
  const vao = gl.createVertexArray();
  gl.bindVertexArray(vao);
  gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
  gl.bufferData(gl.ARRAY_BUFFER, vertices, 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);
  gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, gl.createBuffer());
  gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.STATIC_DRAW);
  gl.bindVertexArray(null);
  return vao;
}
const box = (() => {
  const v = [], idx = [];
  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]]];
  faces.forEach(([n, a1, b1], f) => {
    for (const [a, b] of [[-1,-1],[1,-1],[1,1],[-1,1]]) v.push(...[0, 1, 2].map(i => (n[i] + a * a1[i] + b * b1[i]) / 2), ...n, (a + 1) / 2, (b + 1) / 2);
    idx.push(f * 4, f * 4 + 1, f * 4 + 2, f * 4, f * 4 + 2, f * 4 + 3);
  });
  return { vertices: new Float32Array(v), indices: new Uint16Array(idx), groups: [{ start: 0, count: 36 }] };
})();
const book = parseOBJ(BOOK_OBJ, BOOK_MTL);
const boxVAO = meshVAO(box.vertices, box.indices), bookVAO = meshVAO(book.vertices, book.indices);

// --- Covers (2D canvases) and the catalog ---
const catalog = [['The Quiet Harbor', 14.99, 4.6, '#1f5f8b'], ['Patterns of the Deep Web', 39.5, 4.3, '#5b3f99'],
  ['Salt and Saffron', 24, 4.8, '#e09a10'], ['Small Steps to Big Summits', 18.75, 4.1, '#3f7d3a'],
  ["The Clockmaker's Paradox", 16.2, 4.5, '#b5452f'], ['Gardens in Glass', 21.3, 4.4, '#2a9d8f']]
  .map(([title, price, rating, color]) => ({ title, price, rating, color }));
const rgb = (hex) => [1, 3, 5].map((k) => parseInt(hex.slice(k, k + 2), 16) / 255);
const covers = catalog.map(({ title, color }) => {
  const c = Object.assign(document.createElement('canvas'), { width: 128, height: 192 }), g = c.getContext('2d');
  g.fillStyle = color; g.fillRect(0, 0, 128, 192);
  g.strokeStyle = '#f2ead8'; g.lineWidth = 3; g.strokeRect(8, 8, 112, 176);
  g.fillStyle = '#f2ead8'; g.font = 'bold 16px Georgia'; g.textAlign = 'center';
  g.fillText(title.split(' ').slice(0, 2).join(' '), 64, 90); g.fillText(title.split(' ').slice(2).join(' '), 64, 112);
  const t = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, t);
  gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, gl.RGBA, gl.UNSIGNED_BYTE, c); gl.generateMipmap(gl.TEXTURE_2D);
  return t;
});

// --- The scene graph: nodes with a local matrix and children; a walk computes world = parent x local ---
const node = (local, options = {}) => ({ local, children: [], ...options });
function walk(n, visit, parent = m4.identity()) {
  const world = m4.multiply(parent, n.local);
  visit(n, world);
  for (const child of n.children) walk(child, visit, world);   // the recursion replaces push and pop
}
function libraryScene() {
  const T = m4.translation;
  const wood = (x, y, z, [sx, sy, sz]) => node(m4.multiply(T(x, y, z), m4.scaling(sx, sy, sz)), { mesh: 'box', color: rgb('#8a6240') });
  const root = node(m4.identity()), side = [0.06, 2.5, 0.5];
  root.children.push(wood(-1.45, 0.95, 0, side), wood(1.45, 0.95, 0, side),             // sides,
    wood(0, 0.95, -0.24, [2.96, 2.5, 0.02]), wood(0, 2.17, 0, [2.96, 0.06, 0.5]));        // back and top
  const shelves = [];
  for (let k = 0; k < 3; k++) {
    const shelf = node(T(0, -0.25 + 0.8 * k, 0));                  // a shelf carries its plank and books
    shelf.children.push(wood(0, -0.03, 0, [2.84, 0.06, 0.48]));
    for (let i = 0; i < (k === 1 ? 6 : 16); i++) {
      const isCatalog = k === 1, height = isCatalog ? 1 : 0.62 + 0.12 * ((i * 7 + k) % 4);
      const local = isCatalog ? T(-1.1 + 0.44 * i, 0.49 * 0.7, 0.05)                   // covers out
        : m4.multiply(T(-1.3 + 0.15 * i, 0.49 * 0.7 * height, 0), m4.rotationY(Math.PI / 2));   // spines out
      shelf.children.push(node(m4.multiply(local, m4.scaling(0.7, 0.7 * height, 0.7)), { mesh: 'book',
        cover: isCatalog ? covers[i] : null, index: isCatalog ? i : -1, color: rgb(catalog[(i + k) % 6].color) }));
    }
    root.children.push(shelf); shelves.push(shelf);
  }
  return { root, shelves };
}
const lit = program(`#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; }`, `#version 300 es
precision highp float;
in vec3 vPosition, vNormal; in vec2 vUV; out vec4 outColor;
uniform sampler2D uCover; uniform mat3 uNormalMatrix; uniform vec3 uColor, uLamp; uniform float uTextured;
void main() {                                                    // cover art on the +z faces
  vec3 n = normalize(vNormal), m = normalize(uNormalMatrix * vec3(0, 0, 1));
  vec3 base = mix(uColor, texture(uCover, vUV).rgb, step(0.99, dot(n, m)) * uTextured);
  outColor = vec4(base * (0.4 + 0.7 * max(dot(n, normalize(uLamp - vPosition)), 0.0)), 1.0);
}`);
function drawGraph(root, viewProjection) {
  gl.enable(gl.DEPTH_TEST); gl.enable(gl.CULL_FACE);
  gl.useProgram(lit);
  gl.uniformMatrix4fv(u(lit, 'uViewProjection'), false, viewProjection);
  gl.uniform3f(u(lit, 'uLamp'), -1, 2.5, 3);
  walk(root, (n, world) => {
    n.world = world;                                               // kept for the HUD
    if (!n.mesh) return;
    gl.uniformMatrix4fv(u(lit, 'uModel'), false, world);
    gl.uniformMatrix3fv(u(lit, 'uNormalMatrix'), false, m4.normal(world));
    const mesh = n.mesh === 'box' ? { vao: boxVAO, groups: box.groups } : { vao: bookVAO, groups: book.groups };
    gl.bindVertexArray(mesh.vao);
    for (const { material, start, count } of mesh.groups) {       // one draw per material group
      const cover = material?.name === 'cover';
      gl.uniform3fv(u(lit, 'uColor'), cover || !material ? n.color : material.Kd);
      gl.uniform1f(u(lit, 'uTextured'), cover && n.cover ? 1 : 0);
      gl.bindTexture(gl.TEXTURE_2D, n.cover || null);
      gl.drawElements(gl.TRIANGLES, count, gl.UNSIGNED_SHORT, start * 2);
    }
  });
  gl.bindVertexArray(null);
}

const scene = libraryScene();
const camera = m4.multiply(m4.perspective(43, 1200 / 520, 0.1, 20), m4.lookAt([1.1, 1.3, 4.4], [1.05, 1.15, 0]));
gl.clearColor(0.93, 0.91, 0.87, 1); gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
drawGraph(scene.root, camera);
// The HUD: a 2D canvas on top, drawn once, in CSS-sized units (the canvas is twice the CSS size).
const hud = document.getElementById('hud').getContext('2d');
hud.scale(2, 2);
hud.fillStyle = 'rgba(255, 253, 247, 0.92)'; hud.fillRect(394, 10, 200, 124);
hud.fillStyle = '#222'; hud.font = 'bold 14px Georgia'; hud.fillText('BookNest catalog', 406, 30);
walk(scene.root, (n) => {
  const i = n.index, entry = catalog[i];
  if (!entry) return;                                              // only the six catalog books
  const y = 50 + 16 * i;
  const top = m4.apply(n.world, [0, 0.5, 0.06]);                   // world, then clip
  const c = [0, 1, 3].map(r => camera[r] * top[0] + camera[4 + r] * top[1] + camera[8 + r] * top[2] + camera[12 + r]);
  const [sx, sy] = [(c[0] / c[2] + 1) * 300, (1 - c[1] / c[2]) * 130];
  hud.strokeStyle = hud.fillStyle = entry.color;                   // a leader line and a colour key
  hud.beginPath(); hud.moveTo(sx, sy); hud.lineTo(402, y - 4); hud.stroke();
  hud.fillRect(402, y - 9, 8, 9);
  hud.fillStyle = '#222'; hud.font = '11px Arial';
  hud.fillText(entry.title.length > 19 ? entry.title.slice(0, 18) + '...' : entry.title, 414, y);
  hud.fillText(`$${entry.price.toFixed(2)}  ${entry.rating}`, 534, y);
});
</script>