BookNest's visitors should browse as in a real shop: at eye height, on foot, unable to walk through walls. ArcRotateCamera floats and FollowCamera needs a target, so the choice is a UniversalCamera with collisions, gravity and WASD keys, added to bookstore.js as BookNestStore.createWalkCamera(scene, canvas):
function createWalkCamera(scene, canvas) { // Section 7.3.5: walk at eye height
const start = new BABYLON.Vector3(0, 1.6, -2.2); // eye height, near the open front
const camera = new BABYLON.UniversalCamera('walk', start, scene);
camera.setTarget(new BABYLON.Vector3(0, 1.4, 3));
Object.assign(camera, {
minZ: 0.05, // the default 1 m clips nearby shelves
speed: 0.05, angularSensibility: 4000, // about 1.6 m/s, slower mouse look
ellipsoid: new BABYLON.Vector3(0.3, 0.8, 0.3), // a visitor 1.6 m tall, 0.6 m wide
checkCollisions: true, applyGravity: true,
needMoveForGravity: true, // fall even while no key is held
});
camera.keysUp.push(87); camera.keysDown.push(83); // W and S beside the arrows
camera.keysLeft.push(65); camera.keysRight.push(68); // A and D
scene.gravity = new BABYLON.Vector3(0, -9.81 / 60, 0); // units per frame, not m/s^2
scene.collisionsEnabled = true;
const size = { width: ROOM.width, height: ROOM.height, depth: ROOM.wall };
const glass = BABYLON.MeshBuilder.CreateBox('frontCollider', size, scene);
glass.position.set(0, ROOM.height / 2, -ROOM.depth / 2);
glass.isVisible = false; // the open front still stops you
for (const name of ['floor', 'backWall', 'leftWall', 'rightWall', 'frontCollider']) {
scene.getMeshByName(name).checkCollisions = true;
}
if (canvas) camera.attachControl(canvas, true);
scene.activeCamera = camera;
return camera;
}Speed 0.05 moved the camera 1.6 m in one second of holding the up arrow, a walking pace. The invisible frontCollider closes the open side until Shelves and Walls builds a real front wall. With the camera attached, the page becomes a first-person view you can walk with the keyboard and turn with the mouse:
<canvas id="view" style="width: 620px; height: 210px"></canvas>
<script src="https://cdn.jsdelivr.net/npm/babylonjs@9.28.0/babylon.js"></script>
<script src="bookstore.js"></script>
<script>
{
const engine = new BABYLON.Engine(view, true, {}, true);
const scene = BookNestStore.createScene(engine);
BookNestStore.createRoom(scene);
BookNestStore.createWalkCamera(scene, view);
BookNestStore.renderWhenReady(engine, scene, 30); // let gravity settle the visitor
}
</script>
The eye is 1.6 m above the floor, facing the back wall where shelves will stand. In virtual reality, WebXR swaps in the WebXR camera, and the headset supplies the eye height.
<!doctype html>
<style>
body { margin: 0; background: #f7f3ec; font: 13px system-ui, sans-serif; color: #333; }
canvas { width: 100%; max-width: 600px; height: 340px; display: block; touch-action: none; outline: none; }
p { max-width: 600px; margin: 4px 8px; }
</style>
<canvas id="view" tabindex="0"></canvas>
<p id="info">Click the view, then walk with WASD or the arrow keys and look around by dragging.</p>
<script src="https://cdn.jsdelivr.net/npm/babylonjs@9.28.0/babylon.js"></script>
<script>
const ROOM = { width: 8, depth: 6, height: 3, wall: 0.15 };
const canvas = document.getElementById('view');
const engine = new BABYLON.Engine(canvas, true, {}, true);
const scene = new BABYLON.Scene(engine);
scene.clearColor = BABYLON.Color4.FromHexString('#cfd8dcff');
new BABYLON.HemisphericLight('sky', new BABYLON.Vector3(0.3, 1, -0.4), scene)
.groundColor = new BABYLON.Color3(0.35, 0.32, 0.3);
const paint = (mesh, hex) => {
mesh.material = new BABYLON.StandardMaterial(hex, scene);
mesh.material.diffuseColor = BABYLON.Color3.FromHexString(hex);
mesh.material.specularColor = BABYLON.Color3.Black();
return mesh;
};
// The room: floor and three walls, as in the first render
paint(BABYLON.MeshBuilder.CreateGround('floor', { width: ROOM.width, height: ROOM.depth }, scene), '#b08d6a');
for (const [name, width, x, z] of [['backWall', 8, 0, 3], ['leftWall', 6, -4, 0], ['rightWall', 6, 4, 0]]) {
const wall = paint(BABYLON.MeshBuilder.CreateBox(name, { width, height: 3, depth: ROOM.wall }, scene), '#fff3e0');
wall.position.set(x, 1.5, z);
wall.rotation.y = x ? Math.PI / 2 : 0;
}
// A table in the middle of the room to bump into
const table = paint(BABYLON.MeshBuilder.CreateBox('table', { width: 1.6, height: 0.8, depth: 0.9 }, scene), '#6d4c33');
table.position.set(0, 0.4, 1);
table.checkCollisions = true;
['#1f5f8b', '#e09a10', '#b5452f'].forEach((hex, i) => {
const book = paint(BABYLON.MeshBuilder.CreateBox('book', { width: 0.3, height: 0.05, depth: 0.42 }, scene), hex);
book.position.set(-0.4 + i * 0.4, 0.83, 1);
});
function createWalkCamera(scene, canvas) { // walk at eye height
const start = new BABYLON.Vector3(0, 1.6, -2.2); // eye height, near the open front
const camera = new BABYLON.UniversalCamera('walk', start, scene);
camera.setTarget(new BABYLON.Vector3(0, 1.4, 3));
Object.assign(camera, {
minZ: 0.05, // the default 1 m clips nearby shelves
speed: 0.05, angularSensibility: 4000, // about 1.6 m/s, slower mouse look
ellipsoid: new BABYLON.Vector3(0.3, 0.8, 0.3), // a visitor 1.6 m tall, 0.6 m wide
checkCollisions: true, applyGravity: true,
needMoveForGravity: true, // fall even while no key is held
});
camera.keysUp.push(87); camera.keysDown.push(83); // W and S beside the arrows
camera.keysLeft.push(65); camera.keysRight.push(68); // A and D
scene.gravity = new BABYLON.Vector3(0, -9.81 / 60, 0); // units per frame, not m/s^2
scene.collisionsEnabled = true;
const size = { width: ROOM.width, height: ROOM.height, depth: ROOM.wall };
const glass = BABYLON.MeshBuilder.CreateBox('frontCollider', size, scene);
glass.position.set(0, ROOM.height / 2, -ROOM.depth / 2);
glass.isVisible = false; // the open front still stops you
for (const name of ['floor', 'backWall', 'leftWall', 'rightWall', 'frontCollider']) {
scene.getMeshByName(name).checkCollisions = true;
}
if (canvas) camera.attachControl(canvas, true);
scene.activeCamera = camera;
return camera;
}
const camera = createWalkCamera(scene, canvas);
const info = document.getElementById('info');
scene.onAfterRenderObservable.add(() => {
const p = camera.position;
info.textContent = `Click the view, then walk with WASD or the arrows, drag to look. Eye at ` +
`(${p.x.toFixed(2)}, ${p.y.toFixed(2)}, ${p.z.toFixed(2)}): walls, the table and the open front stop you.`;
});
engine.runRenderLoop(() => scene.render());
addEventListener('resize', () => engine.resize());
</script>