scene.pick (Picking Rays and scene.pick) tests rendered triangles; the physics engine tests collision shapes and returns the body. physicsEngine.raycastToRef(from, to, result, query) fills a reusable PhysicsRaycastResult, and the query's collideWith mask skips shapes by their filterMembershipMask. Havok 516,349 also sweeps shapes: hk.shapeCast() reports the fraction of a move at which a shape first touches something, and pointProximity() and shapeProximity() find the nearest body. After Falling Books's pile settles:
for (let frame = 0; frame < 150; frame++) scene.render();
const physics = scene.getPhysicsEngine();
const hit = new BABYLON.PhysicsRaycastResult(); // reuse one result object
const [top, bottom] = [new BABYLON.Vector3(-1.75, 2, 1.7), new BABYLON.Vector3(-1.75, 0, 1.7)];
physics.raycastToRef(top, bottom, hit);
const node = hit.body.transformNode;
console.log(`ray hits ${node.name} at y ${hit.hitPointWorld.y.toFixed(3)}:`,
node.metadata.title);
const probe = new BABYLON.PhysicsShapeBox(BABYLON.Vector3.Zero(),
BABYLON.Quaternion.Identity(), new BABYLON.Vector3(0.3, 0.1, 0.22), scene); // a book, flat
const [local, world] = [new BABYLON.ShapeCastResult(), new BABYLON.ShapeCastResult()];
const [start, end] = [new BABYLON.Vector3(-1.6, 2, 1.7), new BABYLON.Vector3(-1.6, 0, 1.7)];
hk.shapeCast({ shape: probe, rotation: BABYLON.Quaternion.Identity(),
startPosition: start, endPosition: end, shouldHitTriggers: false }, local, world);
const restY = BABYLON.Vector3.Lerp(start, end, world.hitFraction).y;
console.log(`cast hits ${world.body.transformNode.name} at fraction`,
`${world.hitFraction.toFixed(3)}: a new book would stop at y ${restY.toFixed(3)}`);ray hits new1 at y 0.848: The Quiet Harbor cast hits new2 at fraction 0.450: a new book would stop at y 1.100
Clones keep the original's metadata, so a hit leads to the book record. At x -1.6 a ray reaches the bare tabletop, but the 30 cm box stops on the upright Patterns of the Deep Web: a placement preview a ray gets wrong.
<!doctype html>
<style>
body { margin: 0; background: #f7f3ec; font: 13px system-ui, sans-serif; color: #333; }
canvas { width: 100%; max-width: 600px; height: 310px; display: block; touch-action: none; outline: none; }
#info { max-width: 600px; margin: 0; padding: 4px 8px; font-family: monospace; white-space: pre-wrap; }
</style>
<canvas id="view"></canvas>
<pre id="info">Letting the pile settle…</pre>
<script src="https://cdn.jsdelivr.net/npm/babylonjs@9.28.0/babylon.js"></script>
<script>
// ---- BookNest's bookstore: the helpers the book keeps in bookstore.js ----
const ROOM = { width: 8, depth: 6, height: 3, wall: 0.15 };
const BOOKS = [
{ id: 1, title: 'The Quiet Harbor', author: 'Mara Ellison', color: '#1f5f8b', pages: 312, price: 14.99, rating: 4.6, inStock: true },
{ id: 2, title: 'Patterns of the Deep Web', author: 'Tomas Reyes', color: '#5b3f99', pages: 428, price: 32.5, rating: 4.4, inStock: true },
{ id: 3, title: 'Salt and Saffron', author: 'Priya Nair', color: '#e09a10', pages: 256, price: 24, rating: 4.8, inStock: false },
{ id: 4, title: 'Small Steps to Big Summits', author: 'Dana Whitfield', color: '#3f7d3a', pages: 198, price: 18.5, rating: 4.3, inStock: true },
{ id: 5, title: "The Clockmaker's Paradox", author: 'Julian Voss', color: '#b5452f', pages: 344, price: 16.75, rating: 4.7, inStock: true },
{ id: 6, title: 'Gardens in Glass', author: 'Lena Hart', color: '#2a9d8f', pages: 176, price: 21.3, rating: 4.5, inStock: true },
];
function createScene(engine) {
const scene = new BABYLON.Scene(engine);
scene.clearColor = BABYLON.Color4.FromHexString('#cfd8dcff');
const sky = new BABYLON.HemisphericLight('sky', new BABYLON.Vector3(0.3, 1, -0.4), scene);
sky.groundColor = new BABYLON.Color3(0.35, 0.32, 0.3);
return scene;
}
function paint(scene, name, hex) { // a matte color
const material = new BABYLON.StandardMaterial(name, scene);
material.diffuseColor = BABYLON.Color3.FromHexString(hex);
material.specularColor = BABYLON.Color3.Black();
return material;
}
function createRoom(scene) { // floor and three walls, front open
const room = new BABYLON.TransformNode('room', scene);
const floor = BABYLON.MeshBuilder.CreateGround('floor', { width: ROOM.width, height: ROOM.depth }, scene);
floor.material = paint(scene, 'oak', '#b08d6a');
floor.parent = room;
const plaster = paint(scene, 'plaster', '#fff3e0');
for (const [name, width, x, z] of [['backWall', 8, 0, 3], ['leftWall', 6, -4, 0], ['rightWall', 6, 4, 0]]) {
const wall = BABYLON.MeshBuilder.CreateBox(name, { width, height: ROOM.height, depth: ROOM.wall }, scene);
wall.position.set(x, ROOM.height / 2, z);
wall.rotation.y = x ? Math.PI / 2 : 0;
wall.material = plaster;
wall.parent = room;
}
return room;
}
function createBookcase(scene, name, x) { // 8 boxes merged into 1 mesh
const [w, h, d, t] = [3, 2.2, 0.35, 0.04];
const parts = [[t, h, d, -w / 2, h / 2, 0], [t, h, d, w / 2, h / 2, 0], [w, h, t, 0, h / 2, d / 2 - t / 2]];
for (let i = 0; i < 5; i++) parts.push([w, t, d, 0, 0.1 + i * 0.5, 0]);
const bookcase = BABYLON.Mesh.MergeMeshes(parts.map(([width, height, depth, px, py, pz]) => {
const part = BABYLON.MeshBuilder.CreateBox('part', { width, height, depth }, scene);
part.position.set(px, py, pz);
return part;
}), true);
bookcase.name = name;
bookcase.material = paint(scene, 'walnut', '#6d4c33');
bookcase.position.set(x, 0, ROOM.depth / 2 - ROOM.wall / 2 - d / 2); // against the back wall
return bookcase;
}
function drawCover(scene, book) { // cover art painted on a canvas
const tex = new BABYLON.DynamicTexture('cover' + book.id, { width: 256, height: 384 }, scene);
const c = tex.getContext();
c.fillStyle = book.color; c.fillRect(0, 0, 256, 384);
c.fillStyle = 'rgba(0,0,0,0.4)'; c.fillRect(0, 0, 18, 384); // the dark left edge
c.strokeStyle = '#fff8e1'; c.lineWidth = 3; c.strokeRect(34, 24, 202, 336);
c.fillStyle = '#fff8e1'; c.textAlign = 'center'; c.font = 'bold 30px Georgia';
const lines = [''];
for (const word of book.title.split(' ')) {
const test = (lines[lines.length - 1] + ' ' + word).trim();
if (c.measureText(test).width > 180) lines.push(word); else lines[lines.length - 1] = test;
}
lines.forEach((line, i) => c.fillText(line, 135, 110 + i * 38));
c.font = '20px Georgia'; c.fillText(book.author, 135, 330);
tex.update();
return tex;
}
function placeBooks(scene, bookcase, shelf = 2) { // six books, spines out, covers on +x
const strip = new BABYLON.Vector4(0, 0, 0.07, 1);
const faceUV = [strip, strip, new BABYLON.Vector4(0, 0, 1, 1), strip, strip, strip];
let x = -0.35; // left end of the row, local to the bookcase
return BOOKS.map((record) => {
const width = record.pages / 4000 + 0.02; // spine thickness from page count
const book = BABYLON.MeshBuilder.CreateBox(`book${record.id}`, { width, height: 0.3, depth: 0.22, faceUV, wrap: true }, scene);
book.parent = bookcase;
book.position.set(x + width / 2, 0.12 + shelf * 0.5 + 0.15, -0.02);
x += width + 0.012;
book.material = new BABYLON.StandardMaterial(record.title, scene);
book.material.diffuseTexture = drawCover(scene, record);
book.material.specularColor = new BABYLON.Color3(0.15, 0.15, 0.15);
book.metadata = record; // the record, for picking
return book;
});
}
// The book cuts the storefront with CSG2 (Manifold WebAssembly); this sandbox cannot download
// the .wasm, so the older pure-JavaScript BABYLON.CSG makes the same cuts here.
function createFrontWall(scene) {
const box = (width, height, depth, x, y) => {
const mesh = BABYLON.MeshBuilder.CreateBox('cut', { width, height, depth }, scene);
mesh.position.set(x, y, -ROOM.depth / 2);
return mesh;
};
const solid = box(ROOM.width, ROOM.height, ROOM.wall, 0, ROOM.height / 2);
const windowHole = box(3.2, 1.6, 1, -1.4, 1.6), doorHole = box(1.1, 2.2, 1, 2.3, 1.1);
const wall = BABYLON.CSG.FromMesh(solid).subtract(BABYLON.CSG.FromMesh(windowHole))
.subtract(BABYLON.CSG.FromMesh(doorHole)).toMesh('frontWall', scene.getMaterialByName('plaster'), scene);
[solid, windowHole, doorHole].forEach((mesh) => mesh.dispose());
return wall;
}
// ---- end of the bookstore helpers ----
// ---- A tiny stand-in for Havok ----
// Havok's WebAssembly module has to be downloaded, which this sandboxed preview cannot do, so
// these pages simulate with a few lines of their own: axis-aligned boxes that fall, bounce,
// slide with friction, stack and sleep (no rotation), stepped at a fixed 1/60 s like
// new BABYLON.HavokPlugin(false, havok). Comments name the Havok call each part stands for.
class MiniWorld {
constructor(gravity = -9.81) {
this.gravity = gravity;
this.bodies = [];
this.contacts = new Set(); // pairs touching in the last step
this.onCollision = null; // ({ a, b, impulse }) on first contact
this.onTrigger = null; // ({ trigger, body }) when a body enters
}
// Like new PhysicsAggregate(mesh, PhysicsShapeType.BOX, { mass, restitution, friction })
addBox(mesh, { mass = 0, restitution = 0.2, friction = 0.6, isTrigger = false, half = null, center = null } = {}) {
mesh.computeWorldMatrix(true);
const box = mesh.getBoundingInfo().boundingBox;
const body = { mesh, mass, restitution, friction, isTrigger, sleeping: false, still: 0,
half: half ?? box.extendSizeWorld.clone(), center: (center ?? box.centerWorld).clone(),
velocity: BABYLON.Vector3.Zero() };
body.offset = mesh.getAbsolutePosition().subtract(body.center);
this.bodies.push(body);
return body;
}
// A static collider with no mesh of its own, like one child shape of a PhysicsShapeContainer
addStaticBox(center, half) {
const body = { mesh: { uniqueId: `static${this.bodies.length}`, parent: true }, mass: 0, restitution: 0.2,
friction: 0.6, isTrigger: false, sleeping: false, half, center, velocity: BABYLON.Vector3.Zero() };
this.bodies.push(body);
return body;
}
step(dt = 1 / 60) {
for (const b of this.bodies) { // integrate: gravity, then velocity
if (!b.mass || b.sleeping) continue;
b.velocity.y += this.gravity * dt;
b.center.addInPlace(b.velocity.scale(dt));
}
const touching = new Set();
for (let iteration = 0; iteration < 6; iteration++) { // relax the contacts a few times
for (let i = 0; i < this.bodies.length; i++) for (let j = i + 1; j < this.bodies.length; j++) {
const pair = this.resolve(this.bodies[i], this.bodies[j], iteration === 0);
if (pair) touching.add(pair);
}
}
this.contacts = touching;
for (const b of this.bodies) { // sleep bodies that have come to rest
if (!b.mass) continue;
b.still = b.velocity.length() < 0.05 ? b.still + 1 : 0;
if (b.still > 20) { b.sleeping = true; b.velocity.setAll(0); }
if (!b.mesh.parent) b.mesh.position.copyFrom(b.center.add(b.offset));
}
}
resolve(a, b, firstPass) {
if ((!a.mass || a.sleeping) && (!b.mass || b.sleeping)) return null;
const d = a.center.subtract(b.center);
const ox = a.half.x + b.half.x - Math.abs(d.x), oy = a.half.y + b.half.y - Math.abs(d.y),
oz = a.half.z + b.half.z - Math.abs(d.z);
if (ox <= 0 || oy <= 0 || oz <= 0) return null; // no overlap on some axis: no contact
const key = a.mesh.uniqueId + ':' + b.mesh.uniqueId;
if (a.isTrigger || b.isTrigger) { // triggers report, never push
if (firstPass && !this.contacts.has(key) && this.onTrigger) {
this.onTrigger(a.isTrigger ? { trigger: a, body: b } : { trigger: b, body: a });
}
return key;
}
const n = new BABYLON.Vector3(); // push apart along the shallowest axis
const depth = Math.min(ox, oy, oz);
if (depth === oy) n.y = Math.sign(d.y) || 1; else if (depth === ox) n.x = Math.sign(d.x) || 1; else n.z = Math.sign(d.z) || 1;
const ia = a.mass && !a.sleeping ? 1 / a.mass : 0, ib = b.mass && !b.sleeping ? 1 / b.mass : 0;
a.center.addInPlace(n.scale(depth * ia / (ia + ib)));
b.center.subtractInPlace(n.scale(depth * ib / (ia + ib)));
const vn = BABYLON.Vector3.Dot(a.velocity.subtract(b.velocity), n);
if (vn < 0) { // approaching: bounce with restitution
const e = vn < -0.5 ? Math.min(a.restitution, b.restitution) : 0;
const impulse = -(1 + e) * vn / (ia + ib);
a.velocity.addInPlace(n.scale(impulse * ia));
b.velocity.subtractInPlace(n.scale(impulse * ib));
const mu = Math.sqrt(a.friction * b.friction); // friction slows the sliding part
for (const body of [a, b]) {
const tangent = body.velocity.subtract(n.scale(BABYLON.Vector3.Dot(body.velocity, n)));
body.velocity.subtractInPlace(tangent.scale(Math.min(mu * 0.5, 1)));
}
for (const body of [a, b]) { // a hard hit wakes a sleeping body
if (impulse > 0.5 && body.mass && body.sleeping) { body.sleeping = false; body.still = 0; }
}
if (firstPass && !this.contacts.has(key) && this.onCollision) this.onCollision({ a, b, impulse });
}
return key;
}
// Like physicsEngine.raycastToRef(from, to, result): the nearest box a segment enters
raycast(from, to) {
const ray = BABYLON.Ray.CreateNewFromTo(from, to);
let best = null;
for (const body of this.bodies) {
if (body.isTrigger) continue;
const min = body.center.subtract(body.half), max = body.center.add(body.half);
if (!ray.intersectsBoxMinMax(min, max)) continue;
let distance = 0; // slab test: where the ray enters the box
for (const axis of ['x', 'y', 'z']) {
const inv = 1 / (ray.direction[axis] || 1e-9);
const t1 = (min[axis] - ray.origin[axis]) * inv, t2 = (max[axis] - ray.origin[axis]) * inv;
distance = Math.max(distance, Math.min(t1, t2));
}
if (distance <= ray.length && (!best || distance < best.distance)) {
best = { body, distance, point: from.add(ray.direction.scale(distance)) };
}
}
return best;
}
}
// ---- end of the stand-in ----
function createDisplayTable(scene, world, x, z) { // a top and four legs, one render mesh
const parts = [[1.2, 0.05, 0.8, 0, 0.725, 0]]; // width, height, depth, offset
for (const [lx, lz] of [[-0.55, -0.35], [0.55, -0.35], [-0.55, 0.35], [0.55, 0.35]]) parts.push([0.06, 0.7, 0.06, lx, 0.35, lz]);
const meshes = parts.map(([w, h, d, px, py, pz]) => {
const part = BABYLON.MeshBuilder.CreateBox('part', { width: w, height: h, depth: d }, scene);
part.position.set(x + px, py, z + pz);
return part;
});
// one static collider per part, like the child shapes of a PhysicsShapeContainer
const colliders = meshes.map((part) => world.addBox(part, { mass: 0 }));
const table = BABYLON.Mesh.MergeMeshes(meshes, true);
table.name = 'displayTable';
table.material = paint(scene, 'tableWood', '#8d6e4f');
colliders.forEach((body) => { body.mesh = table; }); // the merged mesh stands for them all
return { table, colliders };
}
function dropBooks(scene, world, shelved, x, z) { // copies of the shelved books, tipped in
return shelved.map((original, i) => {
const book = original.clone(original.name + 'Copy'); // keeps metadata and material
book.parent = null;
book.position.set(x - 0.25 + (i % 3) * 0.22, 1 + i * 0.2, z - 0.1 + (i % 2) * 0.2);
if (i % 3 !== 1) book.rotation.z = Math.PI / 2; // most land flat, some stand up
if (i % 2) book.rotation.y = Math.PI / 2;
book.computeWorldMatrix(true);
const pages = book.metadata.pages;
world.addBox(book, { mass: pages * 0.002, friction: 0.6, restitution: 0.1 }); // 2 g a page
return book;
});
}
const canvas = document.getElementById('view');
const engine = new BABYLON.Engine(canvas, true, {}, true);
const scene = createScene(engine);
createRoom(scene);
const left = createBookcase(scene, 'bookcaseLeft', -1.8);
const shelved = placeBooks(scene, left);
const camera = new BABYLON.ArcRotateCamera('browse', -2.1, 1.15, 2.6, new BABYLON.Vector3(-1.6, 1.1, 1.7), scene);
camera.minZ = 0.05;
camera.attachControl(canvas, true);
const world = new MiniWorld();
world.addStaticBox(new BABYLON.Vector3(0, -0.05, 0), new BABYLON.Vector3(4, 0.05, 3));
createDisplayTable(scene, world, -1.6, 1.7);
dropBooks(scene, world, shelved, -1.6, 1.7);
for (let frame = 0; frame < 150; frame++) world.step(1 / 60); // let the pile settle first
const red = paint(scene, 'red', '#b5452f');
red.emissiveColor = BABYLON.Color3.FromHexString('#b5452f');
// 1. Raycast: the first collision shape a segment enters, and so its body and record
const [rayTop, rayBottom] = [new BABYLON.Vector3(-1.75, 2, 1.7), new BABYLON.Vector3(-1.75, 0, 1.7)];
const hit = world.raycast(rayTop, rayBottom); // like physics.raycastToRef(top, bottom, result)
BABYLON.MeshBuilder.CreateLines('ray', { points: [rayTop, hit.point], colors: [new BABYLON.Color4(0.7, 0.2, 0.1, 1), new BABYLON.Color4(0.7, 0.2, 0.1, 1)] }, scene);
const dot = BABYLON.MeshBuilder.CreateSphere('hit', { diameter: 0.04 }, scene);
dot.position.copyFrom(hit.point);
dot.material = red;
// 2. Shape cast: sweep a flat book-sized box down and find the fraction of the move where it
// first touches something (Havok: hk.shapeCast(...) fills a ShapeCastResult.hitFraction)
const probeHalf = new BABYLON.Vector3(0.15, 0.05, 0.11);
const [start, end] = [new BABYLON.Vector3(-1.45, 2, 1.7), new BABYLON.Vector3(-1.45, 0, 1.7)];
let fraction = 1, hitBody = null;
for (let f = 0; f <= 1; f += 0.002) { // march the box down in 4 mm steps
const center = BABYLON.Vector3.Lerp(start, end, f);
hitBody = world.bodies.find((b) => !b.isTrigger && Math.abs(b.center.x - center.x) < b.half.x + probeHalf.x &&
Math.abs(b.center.y - center.y) < b.half.y + probeHalf.y && Math.abs(b.center.z - center.z) < b.half.z + probeHalf.z);
if (hitBody) { fraction = f; break; }
}
const rest = BABYLON.Vector3.Lerp(start, end, fraction);
const ghost = BABYLON.MeshBuilder.CreateBox('ghost', { width: 0.3, height: 0.1, depth: 0.22 }, scene);
ghost.position.copyFrom(rest);
ghost.material = new BABYLON.StandardMaterial('ghostMat', scene);
ghost.material.diffuseColor = BABYLON.Color3.FromHexString('#2a9d8f');
ghost.material.alpha = 0.5;
BABYLON.MeshBuilder.CreateDashedLines('sweep', { points: [start, rest], dashNb: 30 }, scene).color = BABYLON.Color3.FromHexString('#2a9d8f');
const name = (body) => body.mesh.metadata?.title ?? 'the table';
document.getElementById('info').textContent =
`ray hits ${name(hit.body)} at y ${hit.point.y.toFixed(3)}\n` +
`cast hits ${name(hitBody)} at fraction ${fraction.toFixed(3)}: a new book would stop at y ${rest.y.toFixed(3)}`;
engine.runRenderLoop(() => scene.render());
addEventListener('resize', () => engine.resize());
</script>