A shape is collision geometry in the body's local space; the simpler, the faster. Havok 516,349 offers PhysicsShapeSphere, ...Capsule, ...Cylinder and ...Box; a PhysicsShapeConvexHull shrink-wrapped around a mesh's vertices, snug and fast for props; a PhysicsShapeMesh of every triangle for static scenery; a PhysicsShapeHeightField for terrain; and containers (Constraints). A hull fills every hollow, which matters for the bookcase's open compartments. Here the case gets three shapes in turn, and a book is placed on its second shelf:
const bookcase = BookNestStore.createBookcase(scene, 'bookcaseLeft', -1.8);
new BABYLON.PhysicsAggregate(floor, BABYLON.PhysicsShapeType.BOX, { mass: 0 }, scene);
const { BOX, CONVEX_HULL, MESH } = BABYLON.PhysicsShapeType;
for (const type of [BOX, CONVEX_HULL, MESH]) {
const caseBody = new BABYLON.PhysicsAggregate(bookcase, type, { mass: 0 }, scene);
const book = BABYLON.MeshBuilder.CreateBox('book', { width: 0.1, height: 0.3, depth: 0.22 });
book.position.set(-1.8, 0.95, 2.72); // inside the second compartment
const bookBody = new BABYLON.PhysicsAggregate(book, BOX, { mass: 0.6 }, scene);
for (let frame = 0; frame < 90; frame++) scene.render();
const { x, y, z } = book.position;
console.log(`${BABYLON.PhysicsShapeType[type]}: book at`,
`(${x.toFixed(2)}, ${y.toFixed(2)}, ${z.toFixed(2)})`);
[caseBody, bookBody, book].forEach((item) => item.dispose());
}BOX: book at (-1.80, 0.15, 2.47) CONVEX_HULL: book at (-1.80, 0.15, 2.47) MESH: book at (-1.80, 0.77, 2.72)
With a box or a hull the case is solid: Havok pushes the book out through the front (z 2.47, just clear of the case at 2.575) onto the floor. Only the mesh shape lets it rest on the shelf at y 0.77. Keep mesh shapes for static bodies; two moving meshes must test triangles against triangles.
<!doctype html>
<style>
body { margin: 0; background: #f7f3ec; font: 13px system-ui, sans-serif; color: #333; }
canvas { width: 100%; max-width: 600px; height: 330px; display: block; touch-action: none; outline: none; }
#info { max-width: 600px; padding: 4px 8px; font-family: monospace; }
</style>
<canvas id="view"></canvas>
<div id="info"></div>
<script src="https://cdn.jsdelivr.net/npm/babylonjs@9.28.0/babylon.js"></script>
<script src="https://cdn.jsdelivr.net/npm/babylonjs-gui@9.28.0/babylon.gui.min.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 ----
const canvas = document.getElementById('view');
const engine = new BABYLON.Engine(canvas, true, {}, true);
const scene = createScene(engine);
const floor = BABYLON.MeshBuilder.CreateGround('floor', { width: 10, height: 4 }, scene);
floor.material = paint(scene, 'oak', '#b08d6a');
const camera = new BABYLON.ArcRotateCamera('cam', -Math.PI / 2 - 0.3, 1.25, 7, new BABYLON.Vector3(0, 0.7, 0), scene);
camera.attachControl(canvas, true);
const ui = BABYLON.GUI.AdvancedDynamicTexture.CreateFullscreenUI('ui');
const wire = new BABYLON.StandardMaterial('wire', scene);
wire.wireframe = true;
wire.emissiveColor = BABYLON.Color3.FromHexString('#b5452f');
wire.disableLighting = true;
// A small bookcase: sides, back and three boards: the parts a triangle-mesh shape follows.
// A box shape is one solid block; a convex hull shrink-wraps the vertices, the same block here.
const PARTS = [[0.04, 1.3, 0.35, -0.5, 0.65, 0], [0.04, 1.3, 0.35, 0.5, 0.65, 0], [1, 1.3, 0.04, 0, 0.65, 0.155],
[1, 0.04, 0.35, 0, 0.05, 0], [1, 0.04, 0.35, 0, 0.6, 0], [1, 0.04, 0.35, 0, 1.28, 0]];
const SOLID = [[1.04, 1.3, 0.35, 0, 0.65, 0]];
const CASES = [['BOX', -2.4, SOLID], ['CONVEX_HULL', 0, SOLID], ['MESH', 2.4, PARTS]];
const books = CASES.map(([type, x, shapes], i) => {
const bookcase = BABYLON.Mesh.MergeMeshes(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, pz);
return part;
}), true);
bookcase.material = paint(scene, 'walnut', '#6d4c33');
for (const [w, h, d, px, py, pz] of shapes) { // draw the shapes as the engine sees them
const outline = BABYLON.MeshBuilder.CreateBox('shape', { width: w + 0.01, height: h + 0.01, depth: d + 0.01 }, scene);
outline.position.set(x + px, py, pz);
outline.material = wire;
}
const label = new BABYLON.GUI.TextBlock('', `PhysicsShapeType.${type}`);
Object.assign(label, { color: '#333', fontSize: 12, resizeToFit: true });
ui.addControl(label);
const anchor = new BABYLON.TransformNode('anchor' + i, scene); // merged vertices sit in world space,
anchor.position.x = x; // so anchor the label at the case's foot
label.linkWithMesh(anchor);
label.linkOffsetY = 22;
const book = BABYLON.MeshBuilder.CreateBox('book' + type, { width: 0.1, height: 0.3, depth: 0.22 }, scene);
book.material = paint(scene, 'cover' + i, BOOKS[i * 2].color);
return book;
});
let world, frame = 0;
function reset() {
world = new MiniWorld();
world.addStaticBox(new BABYLON.Vector3(0, -0.05, 0), new BABYLON.Vector3(5, 0.05, 2)); // the floor
CASES.forEach(([, x, shapes], i) => {
for (const [w, h, d, px, py, pz] of shapes) { // mass 0: static shapes
world.addStaticBox(new BABYLON.Vector3(x + px, py, pz), new BABYLON.Vector3(w / 2, h / 2, d / 2));
}
books[i].position.set(x, 0.95, -0.04); // inside the second compartment
world.addBox(books[i], { mass: 0.6 });
});
frame = 0;
}
reset();
const info = document.getElementById('info');
scene.onBeforeRenderObservable.add(() => {
if (frame < 90) world.step(1 / 60);
if (++frame === 90) info.textContent = books.map((b, i) => `${CASES[i][0]}: y ${b.position.y.toFixed(2)}, z ${b.position.z.toFixed(2)}`).join(' ');
if (frame === 240) reset();
});
engine.runRenderLoop(() => scene.render());
addEventListener('resize', () => engine.resize());
</script>