2D and DOM Overlays

Mixing 2D Canvas or DOM Overlays with WebGL

Ways to put 2D content on a WebGL scene
Overlay Strengths Weaknesses
2D canvas on top fast, pixel control, leader lines not accessible, manual text layout
DOM elements on top CSS, selectable text, screen readers layout cost if moved every frame
Canvas texture in WebGL depth-tested inside the scene fixed resolution (Canvas Textures)
CSS 3D transforms DOM placed in 3D two renderers kept in step

Use the DOM for text a reader may select or tab to (Clickable Covers's card) and a 2D canvas for many small marks pinned to projected 3D points.

Three kinds of 2D over 3D: a DOM card pinned to a book, 2D-canvas markers on projected points, and a canvas texture price tag inside the sceneHTMLLive
<!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">
  <canvas id="c" width="1200" height="520"></canvas>
  <canvas id="hud" width="1200" height="520"></canvas>
  <div id="card" style="position:absolute;padding:4px 8px;background:#fffdf7;border:1px solid #c9b99a;border-radius:6px;font:12px Georgia;transform:translate(-50%,-110%);white-space:nowrap"></div>
</div>
<div class="names"><div>DOM: selectable card</div><div>2D canvas: markers</div><div>canvas texture: depth-tested tag</div></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();
// 3. A canvas texture inside the scene: a price tag hung in front of the catalog shelf, depth-tested like any mesh.
const tag = Object.assign(document.createElement('canvas'), { width: 256, height: 128 }), tg = tag.getContext('2d');
tg.fillStyle = '#c62828'; tg.fillRect(0, 0, 256, 128);
tg.fillStyle = '#fff'; tg.font = 'bold 44px Arial'; tg.textAlign = 'center'; tg.fillText('SALE', 128, 60);
tg.font = '28px Arial'; tg.fillText('3 for 2', 128, 104);
const tagTexture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, tagTexture);
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, gl.RGBA, gl.UNSIGNED_BYTE, tag); gl.generateMipmap(gl.TEXTURE_2D);
scene.shelves[1].children.push(node(m4.multiply(m4.translation(0.9, 0.66, 0.26), m4.scaling(1.2, 0.26, 0.2)),   // a flattened book mesh:
  { mesh: 'book', index: -1, color: [0.78, 0.16, 0.16], cover: tagTexture }));    // its cover group shows the texture
const hudCanvas = document.getElementById('hud'), hud = hudCanvas.getContext('2d');
hud.scale(2, 2);
const card = document.getElementById('card');
const project = (camera, world, point) => {
  const w = m4.apply(world, point);
  const c = [0, 1, 3].map(r => camera[r] * w[0] + camera[4 + r] * w[1] + camera[8 + r] * w[2] + camera[12 + r]);
  return [(c[0] / c[2] + 1) / 2, (1 - c[1] / c[2]) / 2];            // 0..1 across and down
};
function frame(ms) {
  const yaw = 0.5 * Math.sin(ms * 0.0004);
  const camera = m4.multiply(m4.perspective(40, 1200 / 520, 0.1, 20), m4.lookAt([Math.sin(yaw) * 4.2, 1.3, Math.cos(yaw) * 4.2], [0, 0.95, 0]));
  gl.clearColor(0.93, 0.91, 0.87, 1); gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
  drawGraph(scene.root, camera);
  // 2. The 2D canvas: numbered markers pinned to the tops of the catalog books.
  hud.clearRect(0, 0, 600, 260);
  const catalogNodes = scene.shelves[1].children.filter((n) => n.index >= 0);
  catalogNodes.forEach((n) => {
    const [x, y] = project(camera, n.world, [0, 0.55, 0.06]);
    hud.fillStyle = catalog[n.index].color; hud.beginPath(); hud.arc(x * 600, y * 260, 8, 0, Math.PI * 2); hud.fill();
    hud.fillStyle = '#fff'; hud.font = 'bold 10px Arial'; hud.textAlign = 'center'; hud.fillText(n.index + 1, x * 600, y * 260 + 3.5);
  });
  // 1. The DOM card: real, selectable text, moved with CSS every frame.
  const saffron = catalogNodes[2], [cx, cy] = project(camera, saffron.world, [0, 0.5, 0.06]);
  card.style.left = cx * 100 + '%'; card.style.top = cy * 100 + '%';
  card.textContent = `${catalog[2].title}, $${catalog[2].price.toFixed(2)}`;
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script>