Ray Casting

Picking with Ray Casting Instead of Color Coding

Ray casting needs no second render: unprojecting the pointer at the near (z = -1) and far (z = 1) planes gives a ray, tested against each object in its model space, where a book is a unit box (the slab method):

webgl-helper.js, thirty-first part: a ray under the pointer and a ray-box testJavaScript
// Section 3.17: the world-space ray under a clip-space point: origin on the near plane
GLH.ray = function (viewProjection, [x, y]) {
  const { mat4, vec3, vec4 } = glMatrix, inverse = mat4.invert([], viewProjection);
  const unproject = (z) => {
    const p = vec4.transformMat4([], [x, y, z, 1], inverse);
    return [p[0] / p[3], p[1] / p[3], p[2] / p[3]];   // undo the perspective divide
  };
  const origin = unproject(-1);
  return { origin, direction: vec3.normalize([], vec3.sub([], unproject(1), origin)) };
};
// Section 3.17: where a ray first enters the unit box of a GLH.box() model (slab test in
// model space): { t, face } with t the world distance and face 0-5 as in GLH.box, or null
GLH.hitBox = function ({ origin, direction }, model) {
  const { mat4, vec4 } = glMatrix, inverse = mat4.invert([], model);
  const o = vec4.transformMat4([], [...origin, 1], inverse);
  const d = vec4.transformMat4([], [...direction, 0], inverse);   // w = 0: no translation
  let near = -Infinity, far = Infinity, face = -1;
  for (let a = 0; a < 3; a++) {                       // x, y and z slabs
    const t1 = (-0.5 - o[a]) / d[a], t2 = (0.5 - o[a]) / d[a];
    if (Math.min(t1, t2) > near) [near, face] = [Math.min(t1, t2), 2 * a + (t1 < t2 ? 1 : 0)];
    far = Math.min(far, Math.max(t1, t2));
  }
  return near <= far && near >= 0 ? { t: near, face } : null;
};

Through Color Picking's four probe pixels, the rays hit the same objects and faces (t = 3.264, 2.868, 3.556, 2.899). Rays cost CPU time per object, so they suit bounding volumes; the ID buffer is exact for any shape.

Ray picking: the pointer unprojected at the near and far planes, tested against each book's unit box; a top view shows the rayHTMLLive
<!doctype html>
<style>
  body { margin: 0; font: 12px Georgia, serif; background: #f7f4ee; color: #333; }
  .wrap { position: relative; width: 100%; max-width: 600px; }
  canvas { display: block; width: 100%; cursor: grab; }
  .names { display: flex; max-width: 600px; text-align: center; font: 11px monospace; }
  .names div { flex: 1; padding: 4px 2px; }
  #card { position: absolute; right: 10px; bottom: 10px; padding: 6px 10px; background: #fffdf7; border: 1px solid #c9b99a; border-radius: 6px; }
  #log { position: absolute; left: 10px; top: 8px; font: 11px monospace; }
</style>
<div class="wrap">
  <canvas id="c" width="1200" height="480"></canvas>
  <code id="log"></code>
</div>
<div class="names"><div style="flex:2">camera view: move the pointer over the books</div><div>top view: the ray and its first hit</div></div>
<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) {        // T * Ry(turn) * S
    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 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);

// A box whose faces come in book order (6 vertices each, so face = gl_VertexID / 6):
// +x fore-edge, -x spine, +y top, -y bottom, +z front cover, -z back cover.
const FACES = ['fore-edge', 'spine', 'top', 'bottom', 'front cover', 'back cover'];
const cube = [];
for (const [n, a1, b1] of [[[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]], [[0,0,1],[1,0,0],[0,1,0]], [[0,0,-1],[-1,0,0],[0,1,0]]])
  for (const [a, b] of [[-1,-1],[1,-1],[1,1],[-1,-1],[1,1],[-1,1]])
    cube.push(...[0, 1, 2].map(i => (n[i] + a * a1[i] + b * b1[i]) / 2), ...n, (a + 1) / 2, (b + 1) / 2);
const sceneVAO = gl.createVertexArray();
gl.bindVertexArray(sceneVAO);
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(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);

// BookNest's catalog, its covers (drawn on 2D canvases) and the shelf.
const books = [['The Quiet Harbor', 'Mara Ellison', 14.99, 4.6, '#1f5f8b'], ['Patterns of the Deep Web', 'Tomas Reyes', 39.5, 4.3, '#5b3f99'],
  ['Salt and Saffron', 'Priya Nair', 24, 4.8, '#e09a10'], ['Small Steps to Big Summits', 'Jonas Berg', 18.75, 4.1, '#3f7d3a'],
  ["The Clockmaker's Paradox", 'Elena Sokolova', 16.2, 4.5, '#b5452f'], ['Gardens in Glass', 'Aiko Tanaka', 21.3, 4.4, '#2a9d8f']]
  .map(([title, author, price, rating, color]) => ({ title, author, price, rating, color }));
const rgb = (hex) => [1, 3, 5].map((k) => parseInt(hex.slice(k, k + 2), 16) / 255);
const covers = books.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 15px Georgia'; g.textAlign = 'center';
  title.split(' ').reduce((lines, w) => { const l = lines[lines.length - 1]; if ((l + ' ' + w).length > 11) lines.push(w); else lines[lines.length - 1] = (l + ' ' + w).trim(); return lines; }, [''])
    .forEach((line, i) => g.fillText(line, 64, 90 + i * 20));
  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;
});
const items = books.map(({ color }, i) => ({ color: rgb(color), 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: rgb('#8a6240'), model: m4.model(0, -0.03, 0, 3.4, 0.06, 0.7) });   // the plank: object 7

// The visible pass, with the selection as uniforms: brighten the book, frame the face in gold.
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; flat out int vFace;     // integers must be flat
void main() { vec4 w = uModel * vec4(aPosition, 1.0); vPosition = w.xyz; vNormal = uNormalMatrix * aNormal;
  vUV = aUV; vFace = gl_VertexID / 6; gl_Position = uViewProjection * w; }`, `#version 300 es
precision highp float;
in vec3 vPosition, vNormal; in vec2 vUV; flat in int vFace; out vec4 outColor;
uniform sampler2D uCover; uniform vec3 uColor, uLamp;
uniform int uPart; uniform float uTextured, uSelected;          // selected face; 1 if this book is selected
void main() {
  float light = 0.4 + 0.75 * max(dot(normalize(vNormal), normalize(uLamp - vPosition)), 0.0);
  vec3 base = vFace == 4 && uTextured > 0.5 ? texture(uCover, vUV).rgb : uColor;
  vec2 edge = min(vUV, 1.0 - vUV);
  bool frame = uSelected > 0.5 && vFace == uPart && min(edge.x, edge.y) < 0.04;
  outColor = vec4(frame ? vec3(1.0, 0.75, 0.15) : base * light * (1.0 + 0.3 * uSelected), 1.0);
}`);
function drawScene(viewProjection, selected = 0, part = -1) {
  gl.enable(gl.DEPTH_TEST); gl.enable(gl.CULL_FACE);
  gl.useProgram(lit);
  gl.bindVertexArray(sceneVAO);
  gl.uniformMatrix4fv(u(lit, 'uViewProjection'), false, viewProjection);
  gl.uniform3f(u(lit, 'uLamp'), -0.6, 1.8, 2.0);
  gl.uniform1i(u(lit, 'uPart'), part);
  items.forEach(({ color, model }, i) => {
    gl.bindTexture(gl.TEXTURE_2D, covers[i] || null);
    gl.uniform1f(u(lit, 'uTextured'), i < 6 ? 1 : 0);
    gl.uniform1f(u(lit, 'uSelected'), i + 1 === selected ? 1 : 0);
    gl.uniformMatrix4fv(u(lit, 'uModel'), false, model);
    gl.uniformMatrix3fv(u(lit, 'uNormalMatrix'), false, m4.normal(model));
    gl.uniform3fv(u(lit, 'uColor'), color);
    gl.drawArrays(gl.TRIANGLES, 0, 36);
  });
}

// Pointer input: drags turn an orbit camera (with inertia), a press and release that barely moved is a click.
function locate(e) {                                            // CSS pixels -> buffer pixels (y up) and clip space
  const r = canvas.getBoundingClientRect();
  const x = (e.clientX - r.left) * canvas.width / r.width, y = (e.clientY - r.top) * canvas.height / r.height;
  return { pixel: [Math.floor(x), canvas.height - 1 - Math.floor(y)], clip: [2 * x / canvas.width - 1, 1 - 2 * y / canvas.height] };
}
function makeOrbit({ target = [0, 0.4, 0], distance = 3.4, yaw = 15, pitch = 20, click = () => {} } = {}) {
  const orbit = { yaw, pitch, vx: 0, vy: 0, dx: 0, dy: 0, dragging: false, moved: 0, x: 0, y: 0 };
  canvas.style.touchAction = 'none';                          // no scrolling on touch drags
  canvas.addEventListener('pointerdown', (e) => {
    if (e.isTrusted) canvas.setPointerCapture(e.pointerId);    // keep the moves that leave the canvas
    Object.assign(orbit, { dragging: true, moved: 0, x: e.clientX, y: e.clientY });
  });
  canvas.addEventListener('pointermove', (e) => {
    if (!orbit.dragging) return;
    const dx = e.clientX - orbit.x, dy = e.clientY - orbit.y;
    orbit.moved += Math.hypot(dx, dy); orbit.x = e.clientX; orbit.y = e.clientY;
    orbit.dx += dx; orbit.dy += dy;
  });
  canvas.addEventListener('pointerup', (e) => {
    orbit.dragging = false;
    if (orbit.moved < 4) click(locate(e));                    // a drag is not a click
  });
  orbit.step = () => {                                          // once per frame
    if (orbit.dragging) [orbit.vx, orbit.vy] = [orbit.dx * 0.4, orbit.dy * 0.4];
    else [orbit.vx, orbit.vy] = [orbit.vx * 0.92, orbit.vy * 0.92];   // inertia: 8% less per frame
    orbit.yaw -= orbit.vx;
    orbit.pitch = Math.min(80, Math.max(-80, orbit.pitch + orbit.vy));  // never flip over
    orbit.dx = orbit.dy = 0;
  };
  orbit.eye = () => {
    const y = orbit.yaw * Math.PI / 180, p = orbit.pitch * Math.PI / 180;
    return [target[0] + distance * Math.cos(p) * Math.sin(y), target[1] + distance * Math.sin(p), target[2] + distance * Math.cos(p) * Math.cos(y)];
  };
  orbit.camera = (aspect) => m4.multiply(m4.perspective(40, aspect, 0.1, 20), m4.lookAt(orbit.eye(), target));
  return orbit;
}
// Scripted input for the preview: synthetic PointerEvents play the user.
const fire = (type, x, y) => {
  const r = canvas.getBoundingClientRect();
  canvas.dispatchEvent(new PointerEvent(type, { clientX: r.left + x * r.width, clientY: r.top + y * r.height, pointerId: 1, bubbles: true }));
};


// A general 4x4 inverse (cofactors), needed to unproject and to move rays into model space.
function invert(m) {
  const [a00, a01, a02, a03, a10, a11, a12, a13, a20, a21, a22, a23, a30, a31, a32, a33] = m;
  const b = [a00 * a11 - a01 * a10, a00 * a12 - a02 * a10, a00 * a13 - a03 * a10, a01 * a12 - a02 * a11, a01 * a13 - a03 * a11, a02 * a13 - a03 * a12,
             a20 * a31 - a21 * a30, a20 * a32 - a22 * a30, a20 * a33 - a23 * a30, a21 * a32 - a22 * a31, a21 * a33 - a23 * a31, a22 * a33 - a23 * a32];
  const det = b[0] * b[11] - b[1] * b[10] + b[2] * b[9] + b[3] * b[8] - b[4] * b[7] + b[5] * b[6];
  return [a11 * b[11] - a12 * b[10] + a13 * b[9], a02 * b[10] - a01 * b[11] - a03 * b[9], a31 * b[5] - a32 * b[4] + a33 * b[3], a22 * b[4] - a21 * b[5] - a23 * b[3],
          a12 * b[8] - a10 * b[11] - a13 * b[7], a00 * b[11] - a02 * b[8] + a03 * b[7], a32 * b[2] - a30 * b[5] - a33 * b[1], a20 * b[5] - a22 * b[2] + a23 * b[1],
          a10 * b[10] - a11 * b[8] + a13 * b[6], a01 * b[8] - a00 * b[10] - a03 * b[6], a30 * b[4] - a31 * b[2] + a33 * b[0], a21 * b[2] - a20 * b[4] - a23 * b[0],
          a11 * b[7] - a10 * b[9] - a12 * b[6], a00 * b[9] - a01 * b[7] + a02 * b[6], a31 * b[1] - a30 * b[3] - a32 * b[0], a20 * b[3] - a21 * b[1] + a22 * b[0]].map(v => v / det);
}
const transform = (m, [x, y, z, w]) => [0, 1, 2, 3].map(r => m[r] * x + m[4 + r] * y + m[8 + r] * z + m[12 + r] * w);
// The world-space ray under a clip-space point: unproject at the near (z = -1) and far (z = 1) planes.
function ray(viewProjection, [x, y]) {
  const inverse = invert(viewProjection);
  const unproject = (z) => { const p = transform(inverse, [x, y, z, 1]); return [p[0] / p[3], p[1] / p[3], p[2] / p[3]]; };
  const origin = unproject(-1), far = unproject(1), d = far.map((c, i) => c - origin[i]), len = Math.hypot(...d);
  return { origin, direction: d.map(c => c / len) };
}
// Where a ray first enters the unit box of a model (slab test in model space): { t, face } or null.
function hitBox({ origin, direction }, model) {
  const inverse = invert(model);
  const o = transform(inverse, [...origin, 1]), d = transform(inverse, [...direction, 0]);   // w = 0: no translation
  let near = -Infinity, far = Infinity, face = -1;
  for (let a = 0; a < 3; a++) {
    const t1 = (-0.5 - o[a]) / d[a], t2 = (0.5 - o[a]) / d[a];
    if (Math.min(t1, t2) > near) [near, face] = [Math.min(t1, t2), 2 * a + (t1 < t2 ? 1 : 0)];
    far = Math.min(far, Math.max(t1, t2));
  }
  return near <= far && near >= 0 ? { t: near, face } : null;   // face 2a + 1 = the -side of axis a
}

// Lines for the top view.
const lines = program(`#version 300 es
layout(location = 0) in vec3 aPosition; uniform mat4 uViewProjection;
void main() { gl_Position = uViewProjection * vec4(aPosition, 1.0); gl_PointSize = 9.0; }`,
`#version 300 es
precision mediump float; uniform vec3 uColor; out vec4 c; void main() { c = vec4(uColor, 1.0); }`);
const lineVAO = gl.createVertexArray();
gl.bindVertexArray(lineVAO);
const lineBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, lineBuffer);
gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 0, 0); gl.enableVertexAttribArray(0);
gl.bindVertexArray(null);

const SW = 800, H = 480, eye = [1.0, 1.5, 3.4];
const camera = m4.multiply(m4.perspective(40, SW / H, 0.1, 20), m4.lookAt(eye, [0, 0.4, 0]));
const topView = m4.multiply(m4.perspective(40, 400 / H, 0.1, 30), m4.lookAt([0.2, 6, 1.9], [0.2, 0, 1.5], [0, 0, -1]));
let pointerClip = null;
canvas.addEventListener('pointermove', (e) => {
  const { clip } = locate(e);
  pointerClip = [(clip[0] + 1) * 1200 / SW - 1, clip[1]];        // clip space of the left pane
});
canvas.addEventListener('pointerleave', () => { pointerClip = null; });
function frame(ms) {
  const clip = pointerClip || [0.6 * Math.sin(ms * 0.0005), 0.1 + 0.15 * Math.sin(ms * 0.0011)];   // demo sweep
  const r = ray(camera, clip);
  let best = null;
  items.forEach(({ model }, i) => { const hit = hitBox(r, model); if (hit && (!best || hit.t < best.t)) best = { ...hit, object: i + 1 }; });
  const selectable = best && best.object <= 6;
  gl.viewport(0, 0, canvas.width, canvas.height);
  gl.clearColor(0.93, 0.91, 0.87, 1); gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
  gl.viewport(0, 0, SW, H);
  drawScene(camera, selectable ? best.object : 0, selectable ? best.face : -1);
  gl.viewport(SW, 0, 400, H);
  gl.enable(gl.SCISSOR_TEST); gl.scissor(SW + 3, 0, 397, H);
  gl.clearColor(0.88, 0.86, 0.8, 1); gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
  drawScene(topView, selectable ? best.object : 0, selectable ? best.face : -1);
  const end = r.origin.map((c, i) => c + r.direction[i] * (best ? best.t : 8));
  gl.useProgram(lines); gl.bindVertexArray(lineVAO); gl.disable(gl.DEPTH_TEST);
  gl.uniformMatrix4fv(u(lines, 'uViewProjection'), false, topView);
  gl.bindBuffer(gl.ARRAY_BUFFER, lineBuffer);
  gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([...r.origin, ...end, ...end]), gl.DYNAMIC_DRAW);
  gl.uniform3f(u(lines, 'uColor'), 0.85, 0.2, 0.1);
  gl.drawArrays(gl.LINES, 0, 2); gl.drawArrays(gl.POINTS, 2, 1);
  gl.bindVertexArray(null); gl.disable(gl.SCISSOR_TEST);
  document.getElementById('log').textContent = best
    ? `hit object ${best.object}${selectable ? ` (${books[best.object - 1].title}), ${FACES[best.face]}` : ' (the plank)'} at t = ${best.t.toFixed(3)}`
    : 'the ray hits nothing';
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script>