Building on Voxel Terrain in Babylon.js · Part 7 of 10 in the series
Part 7: Falling Pieces
Let loose groups fall as rigid bodies, with no physics engine: mass, middle and inertia from the blocks, a quaternion that turns, contacts at the corners of the cells against the terrain and the blocks, pushes that keep them out, friction that makes them stop, and rest when they lie still.

Quest objectives
- Work out a piece's mass, its middle and its inertia from its blocks
- Move and turn a body in fixed steps, with a quaternion for its orientation
- Find contacts at points of the piece's surface, against the terrain and the blocks
- Solve contacts with impulses: out of the surface, a little bounce, and friction
- Show a falling piece as a mesh under a node that follows its body
In part 6, a group that loses its hold vanishes. Now it falls: as one piece, a rigid body that keeps its shape, drops, spins as it goes, hits the ground or a wall with a corner, tips over, slides down a slope and comes to rest.
Babylon.js comes with Havok, a full physics engine, and the first-person controller series uses it. Here a body of our own is the better fit. Its collision shapes would have to be the terrain's chunks, rebuilt into mesh shapes after every dig, and all the blocks; and a hundred lines of our own can ask our own question for every point, the one the player asks since part 3: how far is the nearest surface, and which way is out?
A body
A rigid body has four things that change: where its middle is, how it is turned, how fast it moves, and how fast it spins. The orientation is a quaternion, four numbers that describe a turn, and the spin a vector: its direction is the axis, its length the speed in radians per second.
Three things don't change. The mass, in kilograms: each block's volume times its material's density. The middle of the mass, the average of the blocks' middles weighted by their masses: a body turns about that. And the inertia, how hard the body is to set spinning about each axis: a long beam spins easily about its length and hardly end over end. For a cube it is m s² / 6 about every axis. A block away from the middle adds its mass times its distance squared, the parallel axis theorem. The inertia of a whole piece is a 3 × 3 matrix; turning a body's push into spin needs its inverse.
The materials get their weights. Copper is heavy, planks are light. Replace src/materials.ts:
/**
* What blocks can be made of, in the order of the number keys and the texture layers,
* with how heavy each is, in kilograms per cubic meter.
*/
export const MATERIALS = [
{ name: 'Stone', color: '#9a9c8e', density: 2600 },
{ name: 'Bricks', color: '#b5523b', density: 1900 },
{ name: 'Planks', color: '#b98a52', density: 600 },
{ name: 'Plaster', color: '#d6dcc6', density: 1500 },
{ name: 'Slate', color: '#4f4a63', density: 2800 },
{ name: 'Copper', color: '#cf7a3f', density: 8900 },
] as const;A rounded or sloped block is lighter than its cube, and its weight pulls from somewhere else than the cube's middle. The shapes can say where: the volume of part 5 and its middle come from the same pyramids. In src/shapes.ts, add to Shape:
/** How much of the cube the shape fills, from 0 to 1. */
volume: number;
/** The middle of its volume: where its weight pulls. */
middle: Vec;Both shapes start with middle: [0.5, 0.5, 0.5] next to volume: 0, and call measure(shape) where they called volumeOf(), which it replaces:
/**
* Works out a shape's volume and its middle from its closed mesh: each triangle and the
* origin make a pyramid, with a third of the triangle's area times its height as volume
* and its middle at a quarter of the way from the origin to the triangle's corners.
*/
function measure(shape: Shape): void {
let volume = 0;
let middle: Vec = [0, 0, 0];
for (const { positions: p, indices } of [shape.inner, ...shape.sides]) {
for (let i = 0; i < indices.length; i += 3) {
const [a, b, c] = [0, 1, 2].map((k) => p.slice(indices[i + k] * 3, indices[i + k] * 3 + 3)) as Vec[];
// a · (b × c) / 6, turned the way Babylon.js sees a triangle's front.
const v = -dot(a, [b[1] * c[2] - b[2] * c[1], b[2] * c[0] - b[0] * c[2], b[0] * c[1] - b[1] * c[0]]) / 6;
volume += v;
middle = add(middle, scale(add(add(a, b), c), v / 4));
}
}
shape.volume = volume;
if (volume > 1e-9) shape.middle = scale(middle, 1 / volume);
}Steps
The body moves in fixed steps of 1/120 s, as many per frame as the frame's time holds. Steps of the same length make the motion the same however fast the page runs, and short ones keep a fast body from moving through a thin wall between two looks. A step does four things:
Gravity adds its share to the velocity.
Contacts: every point of the piece's surface that touches a solid, or has sunk into one, gives a contact, with the surface's normal.
Solving: pushes at the contacts change the velocity and the spin until no contact point moves into its surface, and friction slows them along it.
Moving: the velocity moves the middle, the spin turns the orientation.
Pushes
A contact is solved with an impulse, a push that changes the velocity at once. A push J along the normal at a point changes the velocity by J / m, and the spin by the inverse inertia times the point's lever crossed with the push: a push off the middle turns the body. How fast the point then moves along the normal follows from both. The push that makes that speed exactly what we want is the wanted change over the effective mass, how heavy the body feels at that point in that direction: the whole mass at its middle, less at the end of a long lever.
Each contact gives three rows: one along the normal, two along the surface. The normal row only pushes, never pulls. Its target speed is 0 for a point that rests, a little outward for a point that sank in, and a share of the speed it hit with, for a bounce. The two friction rows aim at 0, so that the point stops sliding, but may push at most 0.6 times as hard as the normal row: a body on a steep enough slope slides.
Pushing at one contact changes the speed at the others. So the rows are worked through eight times per step, each correcting what is left. That is called sequential impulses, and it keeps a body that rests on many points steady. Each row remembers the push it has given in this step, and the limits apply to that sum, not to each correction: a correction may take back some of the push, as long as the sum doesn't pull.
Rest
A body that touches something and has been slow for 0.4 s comes to rest: it stops, and is left alone until something wakes it. A body still falling after 20 s rests wherever it is. A ball rolling down a long slope is the only thing that takes that long.
The body
Create src/body.ts. First the numbers that tune it:
import { Quaternion, Vector3 } from '@babylonjs/core/pure';
import type { Solids } from './solids.ts';
/** Meters per second², down. */
const GRAVITY = 9.81;
/** Seconds per step: bodies move in small steps of the same length, whatever the frame. */
const STEP = 1 / 120;
/** How often the contacts of a step are worked through. */
const ITERATIONS = 8;
/** How near a surface a point counts as touching it, in meters. */
const MARGIN = 0.02;
/** How deep a point may sink in unnoticed, and how much of the rest a step pushes out. */
const SLOP = 0.01;
const PUSH = 0.2;
/** The fastest a body is pushed out of a surface, in meters per second. */
const MOST_PUSH = 2;
/** A hit faster than BOUNCE_SPEED bounces back with this share of its speed. */
const BOUNCE = 0.15;
const BOUNCE_SPEED = 1.5;
/** How hard rubbing along a surface may be, as a share of the push into it. */
const FRICTION = 0.6;
/** How much of its spin a touching body loses per second. */
const SPIN_LOSS = 2;
/** A touching body this slow, for this long, comes to rest. */
const STILL_SPEED = 0.15;
const STILL_SPIN = 0.3;
const STILL_TIME = 0.4;
/** Seconds after which a body rests, wherever it is. */
const LONGEST_FALL = 20;Some arithmetic with 3 × 3 matrices, kept as nine numbers in a list: a matrix times a vector, two matrices multiplied, the transpose, the inverse, and the matrix that turns a vector as a quaternion does. And two directions along a surface, for friction:
/** A 3 × 3 matrix, row after row. */
type Matrix3 = number[];
function times(m: Matrix3, v: Vector3): Vector3 {
return new Vector3(
m[0] * v.x + m[1] * v.y + m[2] * v.z,
m[3] * v.x + m[4] * v.y + m[5] * v.z,
m[6] * v.x + m[7] * v.y + m[8] * v.z,
);
}
function product(a: Matrix3, b: Matrix3): Matrix3 {
const m: Matrix3 = [];
for (let i = 0; i < 3; i += 1) {
for (let j = 0; j < 3; j += 1) m.push(a[i * 3] * b[j] + a[i * 3 + 1] * b[3 + j] + a[i * 3 + 2] * b[6 + j]);
}
return m;
}
const transpose = (m: Matrix3): Matrix3 => [m[0], m[3], m[6], m[1], m[4], m[7], m[2], m[5], m[8]];
/** The inverse, from the cofactors over the determinant. */
function inverse(m: Matrix3): Matrix3 {
const [a, b, c, d, e, f, g, h, i] = m;
const cofactors = [e * i - f * h, c * h - b * i, b * f - c * e, f * g - d * i, a * i - c * g, c * d - a * f, d * h - e * g, b * g - a * h, a * e - b * d];
const determinant = a * cofactors[0] + b * cofactors[3] + c * cofactors[6];
return cofactors.map((value) => value / determinant);
}
/** The matrix that turns a vector as the quaternion does. */
function rotation({ x, y, z, w }: Quaternion): Matrix3 {
return [
1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w),
2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w),
2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y),
];
}
/** Two directions along a surface, square to its normal and to each other. */
function tangents(normal: Vector3): [Vector3, Vector3] {
const helper = Math.abs(normal.y) < 0.9 ? Vector3.Up() : Vector3.Right();
const first = Vector3.Cross(normal, helper).normalize();
return [first, Vector3.Cross(normal, first)];
}A part of a body, and a row:
/** A part of a body: its mass and middle, and its inertia about that middle. */
export interface BodyPart {
mass: number;
center: Vector3;
/** kg m², about the part's own middle. */
inertia: Matrix3;
}
/**
* A row of a contact: a push along `linear` at a point, and how the body answers it. Each
* contact gives three: one out of the surface, two along it for friction.
*/
interface Row {
linear: Vector3;
/** The point's lever times the push: how a push along the row turns the body. */
angular: Vector3;
/** How the spin changes per unit of push. */
turn: Vector3;
/** How much push changes the speed along the row by one meter per second. */
mass: number;
/** The speed along the row the step should end with. */
target: number;
/** The push given so far in this step. */
impulse: number;
/** For friction: the row out of the surface, whose push limits the rubbing. */
normal: Row | null;
}The body's own frame is that of its parts: for a piece, the grid where its blocks were. toWorld() takes a point of that frame, measures it from the middle, turns it, and adds the middle's place in the world. wake() is for part 8, which sets resting bodies moving again:
/**
* A rigid body: it falls, spins, hits the solids, slides and bounces off them, and comes
* to rest. Its points are tested for contacts.
*/
export class Body {
/** Kilograms. */
readonly mass: number;
/** The middle of its mass, in the body's own frame. */
readonly center: Vector3;
/** Where that middle is in the world. */
readonly position: Vector3;
readonly orientation = Quaternion.Identity();
readonly velocity = new Vector3();
/** Turning speed: around this axis of the world, its length in radians per second. */
readonly spin = new Vector3();
/** The points tested for contacts, in the body's own frame. */
readonly points: Vector3[];
resting = false;
/** How many points touched something in the last step. */
touching = 0;
/** The inverse of the inertia about the middle, in the body's own frame. */
private readonly inverseInertia: Matrix3;
private still = 0;
private age = 0;
/** Seconds left over from the last frame, less than a step. */
private time = 0;
constructor(parts: BodyPart[], points: Vector3[]) {
this.mass = parts.reduce((sum, part) => sum + part.mass, 0);
this.center = parts.reduce((sum, part) => sum.addInPlace(part.center.scale(part.mass / this.mass)), new Vector3());
// Each part's inertia about the body's middle: its own, and its mass at its distance
// (the parallel axis theorem).
const inertia: Matrix3 = [0, 0, 0, 0, 0, 0, 0, 0, 0];
for (const part of parts) {
const d = part.center.subtract(this.center).asArray();
const lengthSquared = d[0] ** 2 + d[1] ** 2 + d[2] ** 2;
for (let i = 0; i < 3; i += 1) {
for (let j = 0; j < 3; j += 1) {
inertia[i * 3 + j] += part.inertia[i * 3 + j] + part.mass * ((i === j ? lengthSquared : 0) - d[i] * d[j]);
}
}
}
this.inverseInertia = inverse(inertia);
this.position = this.center.clone();
this.points = points;
}
/** Where a point of the body's own frame is in the world. */
toWorld(local: Vector3): Vector3 {
return local.subtract(this.center).applyRotationQuaternion(this.orientation).addInPlace(this.position);
}
/** Moves the body on by `dt` seconds, in steps, against the solids. */
update(dt: number, solids: Solids): void {
this.time += dt;
while (this.time >= STEP && !this.resting) {
this.time -= STEP;
this.step(solids);
}
}
/** Sets the body moving again from where it rests. */
wake(): void {
this.resting = false;
this.still = 0;
this.age = 0;
}A step. The inertia is known in the body's own frame; in the world's, it is R I⁻¹ Rᵀ, with R the body's turn. The orientation changes by half the spin, written as a quaternion, times itself, which is the derivative of a quaternion. After adding it, the quaternion is made one unit long again, or it would slowly grow and stretch the body:
private step(solids: Solids): void {
this.velocity.y -= GRAVITY * STEP;
// The inertia turns with the body: R I⁻¹ Rᵀ is the inverse in the world's frame.
const turned = rotation(this.orientation);
const inverseInertia = product(product(turned, this.inverseInertia), transpose(turned));
const rows = this.contacts(solids, inverseInertia);
for (let i = 0; i < ITERATIONS; i += 1) for (const row of rows) this.solve(row);
this.position.addInPlace(this.velocity.scale(STEP));
// Turning: the orientation changes by half the spin, as a quaternion, times itself.
const change = new Quaternion(this.spin.x, this.spin.y, this.spin.z, 0).multiply(this.orientation);
this.orientation.x += change.x * STEP * 0.5;
this.orientation.y += change.y * STEP * 0.5;
this.orientation.z += change.z * STEP * 0.5;
this.orientation.w += change.w * STEP * 0.5;
this.orientation.normalize();
// A body on the ground slows its spin, and rests once it has been still for a while.
this.touching = rows.length / 3;
if (this.touching > 0) this.spin.scaleInPlace(Math.max(1 - SPIN_LOSS * STEP, 0));
const still = this.touching > 0 && this.velocity.length() < STILL_SPEED && this.spin.length() < STILL_SPIN;
this.still = still ? this.still + STEP : 0;
this.age += STEP;
if (this.still >= STILL_TIME || this.age >= LONGEST_FALL) {
this.resting = true;
this.velocity.setAll(0);
this.spin.setAll(0);
}
}The contacts, the rows and the solving. A point counts as touching within 2 cm of a surface, so that a resting body keeps its contacts and doesn't drop a hair's breadth into the ground and bounce out every step:
/** The rows of every point that touches a solid. */
private contacts(solids: Solids, inverseInertia: Matrix3): Row[] {
const rows: Row[] = [];
for (const local of this.points) {
const point = this.toWorld(local);
const surface = solids.nearest(point.x, point.y, point.z, MARGIN);
if (!surface) continue;
const normal = Vector3.FromArray(surface.normal);
const lever = point.subtract(this.position);
// A fast hit bounces back a little. A point that sank in is pushed out, a share
// of its depth per step.
const velocity = this.velocity.add(Vector3.Cross(this.spin, lever));
const closing = -Vector3.Dot(velocity, normal);
const bounce = closing > BOUNCE_SPEED ? closing * BOUNCE : 0;
const push = Math.min((Math.max(-surface.distance - SLOP, 0) * PUSH) / STEP, MOST_PUSH);
const out = this.row(normal, lever, inverseInertia, Math.max(bounce, push), null);
const [first, second] = tangents(normal);
rows.push(out, this.row(first, lever, inverseInertia, 0, out), this.row(second, lever, inverseInertia, 0, out));
}
return rows;
}
private row(linear: Vector3, lever: Vector3, inverseInertia: Matrix3, target: number, normal: Row | null): Row {
const angular = Vector3.Cross(lever, linear);
const turn = times(inverseInertia, angular);
const mass = 1 / (1 / this.mass + Vector3.Dot(angular, turn));
return { linear, angular, turn, mass, target, impulse: 0, normal };
}
/**
* Pushes along a row so that the speed along it reaches its target: out of a surface
* only pushes, never pulls, and friction rubs at most FRICTION times the push. The
* push changes the velocity and the spin.
*/
private solve(row: Row): void {
const speed = Vector3.Dot(row.linear, this.velocity) + Vector3.Dot(row.angular, this.spin);
let impulse = row.impulse + (row.target - speed) * row.mass;
if (row.normal) {
const most = FRICTION * row.normal.impulse;
impulse = Math.min(Math.max(impulse, -most), most);
} else {
impulse = Math.max(impulse, 0);
}
const change = impulse - row.impulse;
row.impulse = impulse;
row.linear.scaleAndAddToRef(change / this.mass, this.velocity);
row.turn.scaleAndAddToRef(change, this.spin);
}The push-out target is a fifth of the depth per step, and at most 2 m/s, so that a point that sank in comes out gently instead of jumping.
A bounce only comes above 1.5 m/s. Below that, a resting body would keep hopping on its own small corrections.
scaleAndAddToRef()adds a scaled vector to another without making a new one: the rows are solved hundreds of times a frame.
Where a piece touches
Which points of a piece are tested? A box needs only its corners on flat ground, but a piece lands on blocks, ridges and the edges of other things, which can poke between corners far apart. So the points are the corners of the piece's cells, half a meter apart, on its outside: a corner whose eight cells around it all belong to the piece lies inside, and is left out. On a rounded or sloped block, a corner of its box may lie in the air, beside the shape. It is pulled in toward the shape's middle until it lies on the shape: halving the way there and back, sixteen times. The shapes are convex around their middles, so the line from the middle crosses the surface once.
Create src/pieces.ts, with a block's part of a body:
import { TransformNode, Vector3, type Material, type Mesh, type Scene } from '@babylonjs/core/pure';
import { BlockStore, CELL, cellKey, type Block } from './blocks.ts';
import { meshBlocks } 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 type { Solids } from './solids.ts';
import type { World } from './world.ts';
/** Below this height, a falling piece is gone for good. */
const BOTTOM = -500;
/** A block's weight, its middle and its inertia, as a part of a body. */
export function partOf(block: Block): BodyPart {
const meters = block.size * CELL;
const shape = shapeOf(block.cuts, block.slope);
const mass = MATERIALS[block.material].density * shape.volume * meters ** 3;
const low = new Vector3(block.x, block.y, block.z).scaleInPlace(CELL);
const center = low.add(Vector3.FromArray(shape.middle).scaleInPlace(meters));
// A cube's inertia about its middle, the same about every axis: m s² / 6.
const inertia = (mass * meters * meters) / 6;
return { mass, center, inertia: [inertia, 0, 0, 0, inertia, 0, 0, 0, inertia] };
}And the points:
/**
* Pulls a corner of a cut block in toward the middle of its shape until it lies on the
* shape. The shapes are convex around their middles, so halving the way there and back
* finds the surface.
*/
function pullOnto(block: Block, corner: number[]): number[] {
const { middle } = shapeOf(block.cuts, block.slope);
const at = (t: number) => middle.map((m, axis) => m + (corner[axis] - m) * t);
const outside = (t: number) => {
const [x, y, z] = at(t);
return shapeDistance(block.cuts, block.slope, x, y, z, () => true).distance > 0;
};
let inside = 0;
let out = 1;
if (!outside(1)) return corner;
for (let i = 0; i < 16; i += 1) {
const t = (inside + out) / 2;
if (outside(t)) out = t;
else inside = t;
}
return at(inside);
}
/**
* The points a piece touches things with: the corners of its cells on its outside, a
* point every half meter. A corner with all eight cells around it in the piece is inside
* it. On a cut block, a corner is pulled in onto the shape.
*/
export function contactPoints(blocks: Block[], own: BlockStore): Vector3[] {
const points = new Map<number, Vector3>();
for (const block of blocks) {
const { x, y, z, size } = block;
for (let k = 0; k <= size; k += 1) {
for (let j = 0; j <= size; j += 1) {
for (let i = 0; i <= size; i += 1) {
const [cx, cy, cz] = [x + i, y + j, z + k];
let cells = 0;
for (let c = 0; c < 8; c += 1) {
if (own.at(cx - 1 + (c & 1), cy - 1 + ((c >> 1) & 1), cz - 1 + (c >> 2))) cells += 1;
}
if (cells === 8) continue;
// A plain block's corner is a real corner; a cut one only if no plain block has it.
const key = cellKey(cx, cy, cz);
if (block.cuts !== 0 && points.has(key)) continue;
const [u, v, w] = block.cuts === 0 ? [i / size, j / size, k / size] : pullOnto(block, [i / size, j / size, k / size]);
const meters = size * CELL;
points.set(key, new Vector3(x * CELL + u * meters, y * CELL + v * meters, z * CELL + w * meters));
}
}
}
}
return [...points.values()];
}A lone 1 m block has 26 points: its 27 cell corners, less the one in its middle. A piece of 64 blocks, 4 m on each side, has 386.
A piece
A piece is a group of blocks, its body and its mesh. The blocks keep their places in the grid: that is the piece's own frame, and the mesh is built there, by the same mesher, with a store of the piece's own blocks so that the sides between them are left out. The mesh hangs under a TransformNode, moved back by the middle of the mass, and the node follows the body. So the mesh turns about the middle, and its vertices keep their grid positions: the pattern of part 2 stays where it is on the blocks however they turn. The light is the one where the piece broke off, from the grid of the chunk its middle is in. Add to src/pieces.ts:
/**
* 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.
*/
export class Piece {
readonly blocks: Block[];
readonly body: Body;
readonly node: TransformNode;
readonly mesh: Mesh;
constructor(blocks: Block[], scene: Scene, material: Material, world: World) {
this.blocks = blocks;
// The piece's own store, so that the sides between its blocks are left out.
const own = new BlockStore();
for (const block of blocks) own.add(block);
this.body = new Body(blocks.map(partOf), contactPoints(blocks, own));
const data = meshBlocks(blocks, own)!;
this.mesh = createMesh('piece', data, scene);
this.mesh.setVerticesData('material', data.materials, false, 1);
this.mesh.setVerticesData('projection', data.projections, false, 1);
// The light where the piece broke off, from the grid of the chunk its middle is in.
// It keeps that light while it falls.
const [x, y, z] = this.body.center.asArray().map((c) => Math.floor(c / CHUNK_SIZE));
const grid = world.lightGrid({ x, y, z });
const light = grid
? lightAt(grid, { x: 0, y: 0, z: 0 }, data.positions, data.normals)
: new Float32Array(data.positions.length / 3).fill(1);
this.mesh.setVerticesData('light', light, false, 1);
this.mesh.material = material;
// 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.node.position.copyFrom(this.body.position);
}
/** Puts the node where the body is. */
follow(): void {
this.node.position.copyFrom(this.body.position);
this.node.rotationQuaternion!.copyFrom(this.body.orientation);
}
dispose(): void {
this.node.dispose();
}
}And the pieces: a loose group leaves the store and becomes one, and every frame the falling ones move on. A piece that falls into a deep hole and out of the world is dropped. Add:
/** The pieces that broke off, falling or at rest. */
export class Pieces {
readonly all: 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;
}
/** How many pieces are falling. */
get falling(): number {
return this.all.filter((piece) => !piece.body.resting).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));
}
/** 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.position.y < BOTTOM) {
piece.dispose();
this.all.splice(this.all.indexOf(piece), 1);
}
}
}
}Samples, once
A falling piece asks for the nearest surface at each of its points, every step. The terrain's part of that is the trilinear blend of eight samples, and a sample of unedited ground is three 3D noises. A few hundred points, two or more steps per frame, eight samples each: that's a lot of noise. But neighboring points share samples, and a piece moves only centimeters per step. So the field keeps every sample it worked out, until the ground changes. Replace src/field.ts:
import { cellKey } from './blocks.ts';
import type { EditStore } from './edits.ts';
/** The density at a point and its gradient. */
export interface Probe {
density: number;
gradient: [number, number, number];
}
/**
* The terrain's density anywhere, as the meshes see it: blended from the eight samples
* around the point, edited or generated. It needs no chunk to be loaded.
*/
export class Field {
private readonly edits: EditStore;
/** Samples worked out before, kept until the ground changes. */
private readonly samples = new Map<number, number>();
constructor(edits: EditStore) {
this.edits = edits;
}
/** Forgets the kept samples: the ground changed. */
forget(): void {
this.samples.clear();
}
probe(x: number, y: number, z: number): Probe {
const x0 = Math.floor(x);
const y0 = Math.floor(y);
const z0 = Math.floor(z);
const fx = x - x0;
const fy = y - y0;
const fz = z - z0;
const d = (i: number, j: number, k: number) => this.sample(x0 + i, y0 + j, z0 + k);
const [d000, d100, d010, d110] = [d(0, 0, 0), d(1, 0, 0), d(0, 1, 0), d(1, 1, 0)];
const [d001, d101, d011, d111] = [d(0, 0, 1), d(1, 0, 1), d(0, 1, 1), d(1, 1, 1)];
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
// The same trilinear blend and gradient as the surface nets' normals.
const density = lerp(
lerp(lerp(d000, d100, fx), lerp(d010, d110, fx), fy),
lerp(lerp(d001, d101, fx), lerp(d011, d111, fx), fy),
fz,
);
const gradient: [number, number, number] = [
lerp(lerp(d100 - d000, d110 - d010, fy), lerp(d101 - d001, d111 - d011, fy), fz),
lerp(lerp(d010 - d000, d110 - d100, fx), lerp(d011 - d001, d111 - d101, fx), fz),
lerp(lerp(d001 - d000, d101 - d100, fx), lerp(d011 - d010, d111 - d110, fx), fy),
];
return { density, gradient };
}
/** A sample of the terrain, worked out once: falling blocks ask for the same ones often. */
private sample(x: number, y: number, z: number): number {
const key = cellKey(x, y, z);
let value = this.samples.get(key);
if (value === undefined) {
value = this.edits.sample(x, y, z);
// Not without end: a long walk would fill the memory.
if (this.samples.size > 200_000) this.samples.clear();
this.samples.set(key, value);
}
return value;
}
}In src/main.ts, the solids get a name, the pieces are made, and an edit makes the field forget. Replace the part from the field to the edit's handler:
const field = new Field(world.edits);
const solids = new Solids(field, blocks.store);
const player = new Player(solids, feet);
// 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.
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);
world.onEdit = (box) => {
field.forget();
support.groundChanged(box);
};In the frame, the loose groups break off, and the pieces fall:
for (const group of support.update()) pieces.breakOff(group, blocks.store);
pieces.update(dt);The HUD counts them, after the tool's line:
(pieces.falling > 0 ? `${pieces.falling} falling\n` : '') +Import Pieces from ./pieces.ts.
Try it
Break the beam next to the pillar. The rest of the beam and the copper ball tip off and fall onto the table, as one piece: the heavy ball pulls its end down first. Knock out the pillar's lowest block above the ground, and the whole pillar drops, tilts and falls over. Build a tall, thin tower on a slope and dig out its foot with the terrain tool. For now, a piece that has come to rest stays where it is, but nothing else knows it is there: the player walks through it, and the next piece falls through it. Part 8 makes it rubble.
