Building on Voxel Terrain in Babylon.js · Part 8 of 10 in the series
Part 8: Rubble
Turn pieces at rest into rubble: solid for the player and for the pieces still falling, at any angle, not to build on, broken up block by block, falling apart into pieces where it splits, and falling again when what it lies on goes.

Quest objectives
- Ask a turned piece for its nearest surface, in its own frame
- Make rubble solid for the player and for falling pieces
- Aim at a block of rubble, and break it off
- Split rubble into the groups that still touch
- Wake rubble when the ground or the blocks under it go
A piece that came to rest in part 7 is a ghost: you can see it, and walk through it. This part makes it rubble. Rubble is solid, for you and for every piece that lands on it, tilted however it came down. It isn't part of the grid any more, so nothing can be built on it, but it can be broken up, block by block. And rubble is a body at rest, not one that stopped for good: dig the ground away under it, and it falls again.
A turned solid
Rubble lies at any angle, but its blocks are still the grid blocks they were, in the piece's own frame. So to ask a piece for the surface nearest to a point, turn the point into that frame: subtract the body's position, turn it back by the conjugate of the orientation, and add the middle. There, the blocks are a grid again, and the search of part 3 works unchanged, in the piece's own store. Its normal is turned back out into the world.
The search of the grid moves into a function of its own, for the world's store and for each piece's. Solids gets a list of other solids, asked for each time, since rubble comes and goes. In src/solids.ts, replace everything after blockDistance():
/** The nearest surface of the blocks in a store, if one is within `reach` meters. */
export function nearestBlock(store: BlockStore, x: number, y: number, z: number, reach: number): Surface | null {
let nearest: Surface | null = null;
// The blocks with a cell within reach, each once.
const seen = new Set<Block>();
const from = (c: number) => Math.floor((c - reach) / CELL);
const to = (c: number) => Math.floor((c + reach) / CELL);
for (let k = from(z); k <= to(z); k += 1) {
for (let j = from(y); j <= to(y); j += 1) {
for (let i = from(x); i <= to(x); i += 1) {
const block = store.at(i, j, k);
if (!block || seen.has(block)) continue;
seen.add(block);
// Out of a block only where no other block fills the cells beyond.
const surface = blockDistance(block, x, y, z, (side) => !covered(store, block, SIDES[side]));
if (surface.distance < reach && (!nearest || surface.distance < nearest.distance)) nearest = surface;
}
}
}
return nearest;
}
/** Something else that is solid, such as rubble: it finds its own nearest surface. */
export interface Solid {
nearest(x: number, y: number, z: number, reach: number): Surface | null;
}
/** Everything solid: the terrain, the blocks, and whatever `others` hands out. */
export class Solids {
/** More solids, asked for each time, as they come and go: the rubble. */
others: () => Iterable<Solid> = () => [];
private readonly field: Field;
private readonly store: BlockStore;
constructor(field: Field, store: BlockStore) {
this.field = field;
this.store = store;
}
/** The nearest surface to a point, if one is within `reach` meters. */
nearest(x: number, y: number, z: number, reach: number): Surface | null {
let nearest: Surface | null = null;
// The terrain: its density over how fast the density changes is about the distance
// to its surface, as the player worked it out so far.
const { density, gradient } = this.field.probe(x, y, z);
const steepness = Math.hypot(...gradient);
if (steepness > 0 && -density / steepness < reach) {
const normal = gradient.map((g) => -g / steepness) as Surface['normal'];
nearest = { distance: -density / steepness, normal };
}
// The blocks, and the other solids.
const surfaces = [nearestBlock(this.store, x, y, z, reach)];
for (const solid of this.others()) surfaces.push(solid.nearest(x, y, z, reach));
for (const surface of surfaces) {
if (surface && (!nearest || surface.distance < nearest.distance)) nearest = surface;
}
return nearest;
}
}A piece is asked for every point of every falling piece and for the player, so it first checks its bounding ball: if the point is further from the middle than the piece's farthest contact point plus the reach, there is nothing to find.
The piece as rubble
The piece keeps its own store now, for the searches, and its mesh data, to light it again where it comes to rest: the light of part 2, read at the vertices where they are in the world now. At rest it can be aimed at. A piece can also start in the pose of another body, which breaking rubble apart needs. In src/pieces.ts, update the imports:
import { TransformNode, Vector3, type Material, type Mesh, type Scene } from '@babylonjs/core/pure';
import { BlockStore, CELL, cellKey, type Block } from './blocks.ts';
import { meshBlocks, type BlockMesh } from './blockMesher.ts';
import { Body, type BodyPart } from './body.ts';
import { CHUNK_SIZE } from './grid.ts';
import { MATERIALS } from './materials.ts';
import { createMesh } from './meshData.ts';
import { shapeDistance, shapeOf } from './shapes.ts';
import { lightAt } from './skyLight.ts';
import { nearestBlock, type Solids, type Surface } from './solids.ts';
import { looseGroups } from './support.ts';
import type { World } from './world.ts';Replace the class Piece:
/**
* A group of blocks that broke off. Its blocks keep the places they had in the grid: that
* is the piece's own frame. A body moves and turns it, and the mesh hangs under a node
* that follows the body. At rest, it is rubble: solid, but not to build on.
*/
export class Piece {
readonly blocks: Block[];
/** The piece's own store, in its own frame. */
readonly own = new BlockStore();
readonly body: Body;
readonly node: TransformNode;
/** How far its points reach from its middle. */
readonly radius: number;
private readonly mesh: Mesh;
private readonly data: BlockMesh;
private readonly world: World;
/** With `pose`, the piece starts where that body is, turned as it is. */
constructor(blocks: Block[], scene: Scene, material: Material, world: World, pose?: Body) {
this.blocks = blocks;
this.world = world;
for (const block of blocks) this.own.add(block);
this.body = new Body(blocks.map(partOf), contactPoints(blocks, this.own));
if (pose) {
this.body.orientation.copyFrom(pose.orientation);
this.body.position.copyFrom(pose.toWorld(this.body.center));
}
this.radius = Math.max(...this.body.points.map((point) => Vector3.Distance(point, this.body.center)));
// The sides between its blocks are left out.
this.data = meshBlocks(blocks, this.own)!;
this.mesh = createMesh('piece', this.data, scene);
this.mesh.setVerticesData('material', this.data.materials, false, 1);
this.mesh.setVerticesData('projection', this.data.projections, false, 1);
this.mesh.material = material;
this.mesh.metadata = { piece: this };
this.mesh.isPickable = false;
this.light();
// The mesh is in the grid's meters: moved back by the middle, the node turns it
// about the middle.
this.node = new TransformNode('piece', scene);
this.mesh.parent = this.node;
this.mesh.position = this.body.center.negate();
this.node.rotationQuaternion = this.body.orientation.clone();
this.follow();
}
/** Puts the node where the body is. */
follow(): void {
this.node.position.copyFrom(this.body.position);
this.node.rotationQuaternion!.copyFrom(this.body.orientation);
}
/** A point of the world in the piece's own frame. */
toLocal(point: Vector3): Vector3 {
return point
.subtract(this.body.position)
.applyRotationQuaternion(this.body.orientation.conjugate())
.addInPlace(this.body.center);
}
/** The nearest surface of the piece to a point of the world, if within reach. */
nearest(x: number, y: number, z: number, reach: number): Surface | null {
const point = new Vector3(x, y, z);
if (Vector3.Distance(point, this.body.position) > this.radius + reach) return null;
const local = this.toLocal(point);
const surface = nearestBlock(this.own, local.x, local.y, local.z, reach);
if (!surface) return null;
const normal = Vector3.FromArray(surface.normal).applyRotationQuaternion(this.body.orientation);
return { distance: surface.distance, normal: normal.asArray() as Surface['normal'] };
}
/** The block under a point of its surface, where `normal` points out. */
blockAt(point: Vector3, normal: Vector3): Block | undefined {
const inside = this.toLocal(point.subtract(normal.scale(0.01)));
return this.own.at(Math.floor(inside.x / CELL), Math.floor(inside.y / CELL), Math.floor(inside.z / CELL));
}
/** At rest it is rubble: it can be aimed at, and it gets the light where it lies. */
rest(): void {
this.mesh.isPickable = true;
this.light();
}
/** Sets it falling again. */
wake(): void {
this.body.wake();
this.mesh.isPickable = false;
}
dispose(): void {
this.node.dispose();
}
/** The light of the place where the piece is now, from the grid of its middle's chunk. */
private light(): void {
const count = this.data.positions.length / 3;
const positions = new Float32Array(count * 3);
const normals = new Float32Array(count * 3);
for (let i = 0; i < count; i += 1) {
const local = Vector3.FromArray(this.data.positions, i * 3);
const normal = Vector3.FromArray(this.data.normals, i * 3).applyRotationQuaternion(this.body.orientation);
this.body.toWorld(local).toArray(positions, i * 3);
normal.toArray(normals, i * 3);
}
const [x, y, z] = this.body.position.asArray().map((c) => Math.floor(c / CHUNK_SIZE));
const grid = this.world.lightGrid({ x, y, z });
const light = grid ? lightAt(grid, { x: 0, y: 0, z: 0 }, positions, normals) : new Float32Array(count).fill(1);
this.mesh.setVerticesData('light', light, false, 1);
}
}toLocal()istoWorld()backward.nearest()is the solid's question: the ball test, then the store.blockAt()finds the block under a point of the surface, as the aiming of part 4 does in the grid.light()turns every vertex and normal into the world, and reads the grid of the chunk the middle is in.
Breaking rubble up
Rubble isn't built on, but a left click breaks a block off it. What's left may come apart: break the middle of a beam of rubble and it is two halves. They are found as part 6 finds loose groups, in the piece's own store, with nothing holding anything: each group that still touches becomes a piece of its own, in the old piece's pose. All of them wake up and settle again: the half that lay on the broken block tips over.
Waking rubble
Rubble rests on something. When a block is taken away, or the ground is dug, the rubble near that place is woken, and falls if it has to. If nothing changed under it, it rests again within half a second. That's cheaper than working out what each piece of rubble lies on.
Replace the class Pieces. It hands its rubble to the solids, and keeps the list once per frame, since the solids ask for it many times:
/** The pieces that broke off: falling, or at rest as rubble. */
export class Pieces {
readonly all: Piece[] = [];
/** The pieces at rest, as of the last update. */
rubble: Piece[] = [];
private readonly scene: Scene;
private readonly material: Material;
private readonly world: World;
private readonly solids: Solids;
constructor(scene: Scene, material: Material, world: World, solids: Solids) {
this.scene = scene;
this.material = material;
this.world = world;
this.solids = solids;
// Rubble is solid, for the player and for the pieces still falling.
solids.others = () => this.rubble;
}
/** How many pieces are falling. */
get falling(): number {
return this.all.length - this.rubble.length;
}
/** A loose group leaves the grid and falls as one piece. */
breakOff(group: Block[], store: BlockStore): void {
for (const block of group) store.remove(block);
this.all.push(new Piece(group, this.scene, this.material, this.world));
}
/**
* Breaks a block off a piece. What is left falls again, in as many pieces as it falls
* apart into: the groups of blocks that still touch, found as the support finds loose
* groups, with nothing holding them.
*/
breakBlock(piece: Piece, block: Block): void {
this.remove(piece);
piece.own.remove(block);
const left = piece.blocks.filter((other) => other !== block);
for (const group of looseGroups(piece.own, () => false, left)) {
this.all.push(new Piece(group, this.scene, this.material, this.world, piece.body));
}
}
/** Wakes the rubble near a box of the world, whose ground or blocks went away. */
wakeNear(low: Vector3, high: Vector3): void {
for (const piece of this.rubble) {
const { position } = piece.body;
const nearest = Vector3.Clamp(position, low, high);
if (Vector3.Distance(position, nearest) <= piece.radius + 0.5) piece.wake();
}
}
/** Whether a block would go into rubble: whether the middles of its cells would. */
overlaps(block: Block): boolean {
for (const piece of this.rubble) {
for (let k = 0; k < block.size; k += 1) {
for (let j = 0; j < block.size; j += 1) {
for (let i = 0; i < block.size; i += 1) {
const [x, y, z] = [block.x + i + 0.5, block.y + j + 0.5, block.z + k + 0.5].map((c) => c * CELL);
const surface = piece.nearest(x, y, z, CELL);
if (surface && surface.distance < 0) return true;
}
}
}
}
return false;
}
/** Once a frame: moves the falling pieces. */
update(dt: number): void {
for (const piece of [...this.all]) {
if (piece.body.resting) continue;
piece.body.update(dt, this.solids);
piece.follow();
if (piece.body.resting) piece.rest();
if (piece.body.position.y < BOTTOM) this.remove(piece);
}
this.rubble = this.all.filter((piece) => piece.body.resting);
}
private remove(piece: Piece): void {
piece.dispose();
this.all.splice(this.all.indexOf(piece), 1);
this.rubble = this.rubble.filter((other) => other !== piece);
}
}overlaps() keeps new blocks out of rubble: a block may not go where the middle of one of its cells lies inside a piece.
Aiming at rubble
A ray can now hit rubble. Its mesh carries the piece in its metadata, and the piece finds the block under the hit point. Replace src/aim.ts:
import { Vector3, type Camera, type Scene } from '@babylonjs/core/pure';
import { CELL, type Block, type BlockStore } from './blocks.ts';
import type { Piece } from './pieces.ts';
/** Where the camera looks: at a side of a block's box, at a block of rubble, or at the ground. */
export type Aim =
| { kind: 'block'; block: Block; axis: number; dir: number; point: Vector3 }
| { kind: 'rubble'; piece: Piece; block: Block; point: Vector3 }
| { kind: 'ground'; point: Vector3; normal: Vector3 };
/** What the camera's ray hits first within `reach` meters. */
export function aimAt(scene: Scene, camera: Camera, store: BlockStore, reach: number): Aim | null {
const hit = scene.pickWithRay(camera.getForwardRay(reach));
if (!hit?.pickedPoint || !hit.pickedMesh) return null;
const point = hit.pickedPoint;
const normal = hit.getNormal(true) ?? Vector3.Up();
const piece = (hit.pickedMesh.metadata as { piece?: Piece } | null)?.piece;
if (piece) {
const block = piece.blockAt(point, normal);
if (block) return { kind: 'rubble', piece, block, point };
}
if (hit.pickedMesh.metadata === 'blocks') {
// A point just inside the surface that was hit is in the block's cells.
const inside = point.subtract(normal.scale(0.01));
const block = store.at(Math.floor(inside.x / CELL), Math.floor(inside.y / CELL), Math.floor(inside.z / CELL));
if (block) {
// The side of the box the point is nearest to, also on a rounded surface: the
// axis along which it lies furthest from the block's middle.
const meters = block.size * CELL;
const local = [point.x - block.x * CELL, point.y - block.y * CELL, point.z - block.z * CELL].map(
(c) => c / meters - 0.5,
);
const axis = [0, 1, 2].reduce((best, a) => (Math.abs(local[a]) > Math.abs(local[best]) ? a : best));
return { kind: 'block', block, axis, dir: Math.sign(local[axis]) || 1, point };
}
}
return { kind: 'ground', point, normal };
}The block tool handles rubble on its own: no ghost, a marker turned the way the rubble lies, and only the left button does something. In src/buildTool.ts, add to the fields of BlockTool:
/** Breaks a block off rubble. */
breakRubble: (piece: Piece, block: Block) => void = () => {};
/** Whether the camera looks at rubble. */
atRubble = false;In update(), rubble is handled before the placement is worked out. Replace the lines from if (!aim) to the one that works out the placement, both included:
this.atRubble = aim?.kind === 'rubble';
if (!aim) {
this.clicked = null;
return;
}
if (aim.kind === 'rubble') {
// Rubble isn't built on: a left click only breaks blocks off it.
this.markRubble(aim.piece, aim.block);
const action = this.clicked ?? (now - this.lastEdit >= REPEAT ? this.held : null);
this.clicked = null;
if (action !== 'break') return;
this.lastEdit = now;
this.breakRubble(aim.piece, aim.block);
return;
}
const block = this.placement(aim);The marker for a grid block is straight again: after placeMarker() for the target, add this.target.rotationQuaternion = null;. The marker for rubble sits at the block's middle, turned as the piece is:
/** Marks a block of rubble, turned as the rubble lies. */
private markRubble(piece: Piece, block: Block): void {
const half = (block.size * CELL) / 2;
const middle = new Vector3(block.x * CELL + half, block.y * CELL + half, block.z * CELL + half);
this.target.position.copyFrom(piece.body.toWorld(middle));
this.target.rotationQuaternion = piece.body.orientation.clone();
this.target.scaling.setAll(half * 2 + 0.02);
this.target.isVisible = true;
}placement() only takes blocks and the ground now: private placement(aim: Exclude<Aim, { kind: 'rubble' }>): Block. Import Vector3 from Babylon.js, and the type Piece.
In src/main.ts, the store and the world wake rubble. Replace from the comment on what holds the blocks to the edit's handler:
// What holds the blocks: after a block is broken or the ground dug, the blocks around
// are checked, and the groups nothing holds any more break off and fall. Rubble near
// the change falls again too: it may have lain on what went.
const support = new Support(blocks.store, field);
// Something to try the rules on, in front of the player.
buildSample(blocks.store, field, 96, 158);
const pieces = new Pieces(scene, blockMaterial, world, solids);
blocks.store.onRemove = (block) => {
support.removed(block);
const low = new Vector3(block.x, block.y, block.z).scaleInPlace(CELL);
pieces.wakeNear(low, low.add(new Vector3(1, 1, 1).scaleInPlace(block.size * CELL)));
};
world.onEdit = (box) => {
field.forget();
support.groundChanged(box);
pieces.wakeNear(Vector3.FromArray(box.low), Vector3.FromArray(box.high));
};After the tools are made, rubble breaks up, and nothing goes into it:
// Aiming at a block shows what would fall with it. Rubble breaks apart.
blockTool.wouldFall = (block) => support.wouldFall(block);
blockTool.breakRubble = (piece, block) => pieces.breakBlock(piece, block);
// A block may not go into rubble, nor where the player stands: balls at the feet, the
// middle and the head must stay clear of it. Flying, the camera goes through anything.
blockTool.canPlace = (block) => {
if (pieces.overlaps(block)) return false;
if (mode === 'fly') return true;
const { x, y, z } = player.position;
return [RADIUS, HEIGHT / 2, HEIGHT - RADIUS].every(
(height) => blockDistance(block, x, y + height, z).distance >= RADIUS,
);
};The HUD says what a left click does to rubble, and counts it:
blocks:
`Blocks: ${MATERIALS[blockTool.material].name}, ${blockTool.size * CELL} m, ${blocks.store.count} placed` +
(blockTool.falling > 0 ? `\nBreaking this drops ${blockTool.falling} more` : '') +
(blockTool.atRubble ? '\nRubble: left click breaks it up' : ''),
(pieces.all.length > 0 ? `${pieces.falling} falling, ${pieces.rubble.length} rubble\n` : '') +Try it
Knock out the pillar's lowest block above the ground, and let it come down. Walk up to the rubble and climb onto it: it holds you at the angle it lies. Try to build on it: no ghost, only a white marker turned like the block. Left-click through it block by block; where the pillar splits in two, each half settles on its own. Then dig a hole under a piece of rubble with the terrain tool, and it drops into it.
