Walkthrough Camera's walk camera uses ellipsoid collisions, which ignore Havok 516,349 bodies. A PhysicsCharacterController is a capsule moved by Havok queries: each frame checkSupport() finds the ground, calculateMovement() steers the velocity toward the one you want, and integrate() moves the capsule, sliding along whatever it touches. maxSlopeCosine (60 degrees) and maxStepHeight (off) limit what it climbs:
new BABYLON.PhysicsAggregate(floor, BABYLON.PhysicsShapeType.BOX, { mass: 0 }, scene);
const left = BookNestStore.createBookcase(scene, 'bookcaseLeft', -1.8);
new BABYLON.PhysicsAggregate(left, BABYLON.PhysicsShapeType.MESH, { mass: 0 }, scene);
const shopper = new BABYLON.PhysicsCharacterController(new BABYLON.Vector3(-0.6, 0.8, -1),
{ capsuleHeight: 1.6, capsuleRadius: 0.3 }, scene); // center 0.8 m: feet on the floor
const [dt, up, gravity] = [1 / 60, BABYLON.Vector3.Up(), new BABYLON.Vector3(0, -9.81, 0)];
const forward = new BABYLON.Vector3(-0.3, 0, 0.95).normalize();
for (let frame = 1; frame <= 300; frame++) {
const support = shopper.checkSupport(dt, gravity.normalizeToNew());
const current = shopper.getVelocity();
shopper.setVelocity(support.supportedState === BABYLON.CharacterSupportedState.SUPPORTED
? shopper.calculateMovement(dt, forward, support.averageSurfaceNormal, current,
support.averageSurfaceVelocity, forward.scale(1.4), up) // walk at 1.4 m/s
: current.add(gravity.scale(dt))); // in the air: fall
shopper.integrate(dt, support, gravity);
const where = shopper.getPosition().asArray().map((v) => v.toFixed(3));
if (frame % 100 === 0) console.log(frame, where);
}Output
100 [-1.163, 0.800, 0.783] 200 [-1.865, 0.800, 2.271] 300 [-2.567, 0.800, 2.271]
By frame 200 the capsule has met the bookcase (z 2.575 less its radius) and slides left at 0.42 m/s. In an app, run the loop in scene.onAfterPhysicsObservable and copy getPosition() to the model and camera.
<!doctype html>
<style>
body { margin: 0; background: #f7f3ec; font: 13px system-ui, sans-serif; color: #333; }
canvas { width: 100%; max-width: 600px; height: 320px; 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>
// ---- 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 ----
/*
Havok's PhysicsCharacterController (checkSupport, calculateMovement, integrate) needs the
Havok WebAssembly module, which this sandbox cannot download. The same loop is written out:
find support, steer the velocity toward the wanted one, move, and slide along what you touch.
*/
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);
placeBooks(scene, left);
const camera = new BABYLON.ArcRotateCamera('cam', -2.3, 0.85, 7.5, new BABYLON.Vector3(-1, 0.5, 0.8), scene);
camera.attachControl(canvas, true);
const radius = 0.3, height = 1.6;
const capsule = BABYLON.MeshBuilder.CreateCapsule('shopper', { height, radius }, scene);
capsule.material = paint(scene, 'shopper', '#2a9d8f');
// The bookcase's footprint as seen from above: x -3.3 .. -0.3, z 2.575 .. 2.925
const obstacle = { minX: -3.32, maxX: -0.28, minZ: 2.575, maxZ: 2.925 };
const wanted = new BABYLON.Vector3(-0.3, 0, 0.95).normalize().scale(1.4); // walk at 1.4 m/s
const velocity = BABYLON.Vector3.Zero();
const trail = [];
let frame = 0, trailLine = null;
function reset() {
capsule.position.set(-0.6, height / 2, -1); // center 0.8 m: feet on the floor
velocity.setAll(0);
frame = 0;
trail.length = 0;
}
reset();
scene.onBeforeRenderObservable.add(() => {
const dt = 1 / 60;
// supported by the floor: steer toward the wanted velocity (calculateMovement)
velocity.addInPlace(wanted.subtract(velocity).scale(Math.min(dt * 8, 1)));
const next = capsule.position.add(velocity.scale(dt));
// slide: the circle of radius r must stay outside the rectangle; remove the blocked component
const cx = Math.max(obstacle.minX, Math.min(next.x, obstacle.maxX));
const cz = Math.max(obstacle.minZ, Math.min(next.z, obstacle.maxZ));
const away = new BABYLON.Vector3(next.x - cx, 0, next.z - cz);
const gap = away.length();
if (gap < radius) { // touching: push out and cancel the inward speed
const normal = gap > 1e-6 ? away.scale(1 / gap) : new BABYLON.Vector3(0, 0, -1);
next.addInPlace(normal.scale(radius - gap));
const into = BABYLON.Vector3.Dot(velocity, normal);
if (into < 0) velocity.subtractInPlace(normal.scale(into));
}
capsule.position.copyFrom(next); // integrate
if (frame % 6 === 0) {
trail.push(capsule.position.clone().set(capsule.position.x, 0.02, capsule.position.z));
if (trail.length > 1) {
trailLine?.dispose();
trailLine = BABYLON.MeshBuilder.CreateLines('trail', { points: trail }, scene);
trailLine.color = BABYLON.Color3.FromHexString('#b5452f');
}
}
frame++;
document.getElementById('info').textContent = `frame ${frame}: position (${capsule.position.x.toFixed(2)}, ` +
`${capsule.position.y.toFixed(2)}, ${capsule.position.z.toFixed(2)}), speed ${velocity.length().toFixed(2)} m/s`;
if (frame === 300) reset();
});
engine.runRenderLoop(() => scene.render());
addEventListener('resize', () => engine.resize());
</script>