2D HUD

Overlaying a 2D Heads-Up Display

A heads-up display (HUD) is flat information over a 3D view. WebGL draws no text, so the classic HUD is a 2D canvas stacked on the WebGL canvas in a relative container, transparent where nothing is drawn, with pointer-events: none so clicks still reach WebGL's picking. Size it in device pixels, scale(devicePixelRatio, devicePixelRatio) once, and redraw it only when its content changes. To see the page through the 3D view, clear WebGL to (0, 0, 0, 0): premultiplied alpha requires a transparent color to be black.

A 2D canvas HUD stacked over the turning library: pointer-events none, device-pixel sized, redrawn only when its numbers changeHTMLLive
<!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; }
  .names { display: flex; max-width: 600px; text-align: center; font: 11px monospace; }
  .names div { flex: 1; padding: 4px 2px; }
</style>
<div class="wrap" style="background:linear-gradient(#dfe9f5,#f7f4ee)">
  <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();
// The HUD canvas has the same drawing-buffer size as the WebGL canvas; scale once to work in CSS-sized units.
const hudCanvas = document.getElementById('hud'), hud = hudCanvas.getContext('2d');
hud.scale(hudCanvas.width / hudCanvas.clientWidth, hudCanvas.height / hudCanvas.clientHeight);
let frames = 0, lastHud = 0, shown = '';
function drawHud(fps, yaw) {
  const text = `${fps} fps | camera ${yaw} deg`;
  if (text === shown) return;                                       // redraw only when the content changes
  shown = text;
  hud.clearRect(0, 0, hudCanvas.width, hudCanvas.height);           // transparent where nothing is drawn
  hud.fillStyle = 'rgba(20, 24, 30, 0.75)'; hud.fillRect(10, 10, 190, 52);
  hud.fillStyle = '#e09a10'; hud.font = 'bold 13px Georgia'; hud.fillText('BookNest library', 20, 30);
  hud.fillStyle = '#fff'; hud.font = '11px monospace'; hud.fillText(text, 20, 50);
  hud.strokeStyle = 'rgba(255,255,255,0.7)'; hud.lineWidth = 1;     // a crosshair in the middle
  const cx = hudCanvas.clientWidth / 2, cy = hudCanvas.clientHeight / 2;
  hud.beginPath(); hud.moveTo(cx - 8, cy); hud.lineTo(cx + 8, cy); hud.moveTo(cx, cy - 8); hud.lineTo(cx, cy + 8); hud.stroke();
}
function frame(ms) {
  const yaw = 0.6 * Math.sin(ms * 0.0004);
  const eye = [Math.sin(yaw) * 4.6, 1.3, Math.cos(yaw) * 4.6];
  gl.clearColor(0, 0, 0, 0);                                        // transparent: the page's gradient shows through
  gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
  drawGraph(scene.root, m4.multiply(m4.perspective(42, 1200 / 520, 0.1, 20), m4.lookAt(eye, [0, 0.95, 0])));
  frames++;
  if (ms - lastHud > 500) {                                         // the numbers change twice a second
    drawHud(Math.round(frames * 1000 / (ms - lastHud)), (yaw * 180 / Math.PI).toFixed(0));
    frames = 0; lastHud = ms;
  }
  requestAnimationFrame(frame);
}
drawHud(0, 0);
requestAnimationFrame(frame);
</script>