Bauen auf Voxel-Terrain in Babylon.js · Teil 3 von 10 der Serie
Teil 3: Auf Blöcken gehen
Mach die Blöcke für die Spielfigur fest: der vorzeichenbehaftete Abstand zu einer Box, die nächste Oberfläche von Terrain und Blöcken zusammen, ein Weg aus einer Wand, der ins Freie führt, und keine Blöcke dort, wo die Spielfigur steht.

Quest-Ziele
- Den vorzeichenbehafteten Abstand von einem Punkt zu einem Block ausrechnen
- Die nächste Oberfläche von Terrain und Blöcken zusammen finden
- Einen Punkt nur dort aus einem Block schieben, wo kein anderer Block ist
- Die Spielfigur mit dieser Oberfläche kollidieren lassen, sonst unverändert
- Neue Blöcke aus der Spielfigur heraushalten
Die Spielfigur der Terrain-Serie geht glatt durch die Blöcke: Sie kennt nur die Dichte des Terrains. Sie braucht keine Physik-Engine, und sie braucht auch jetzt keine. Ihre zwei Kugeln, eine an den Füßen und eine am Kopf, stellen eine einzige Frage: Wie weit ist die nächste Oberfläche, und wo geht es hinaus? Beantwortet wurde sie von der Dichte geteilt durch ihren Gradienten. Blöcke können sie auch beantworten.
Der Abstand zu einer Box
Ein vorzeichenbehafteter Abstand ist der Abstand von einem Punkt zum nächsten Punkt einer Form, unter 0 in ihr. Für eine Box ist er kurz geschrieben. Miss den Punkt von der Mitte der Box aus und falte ihn mit abs() in eine Ecke: Die Box ist in allen acht gleich. Zieh dann pro Achse die halbe Größe der Box ab. Das sagt, wie weit der Punkt entlang dieser Achse außerhalb der Schicht der Box liegt, unter 0, wenn er in der Schicht liegt.
Sind manche der drei über 0, liegt der Punkt außen. Der Abstand ist die Länge dieser Teile: gerade von einer Seite weg, oder schräg von einer Kante oder Ecke.
Sind alle drei unter 0, liegt der Punkt innen. Die nächste Seite ist die, deren Zahl am nächsten an 0 ist.
Der Weg hinaus, die Normale, zeigt vom nächsten Punkt der Box zum Punkt.
Aus einer Wand heraus
Ein Fall braucht Sorgfalt: ein Punkt in einem Block, der Teil einer Wand ist. Die nächste Seite dieses Blocks kann die sein, die er mit dem nächsten Block teilt, und durch sie hinauszuschieben, schiebt nur in den Nachbarn, der zurückschiebt. Also verlässt ein Punkt einen Block nur durch eine offene Seite: eine, die kein anderer Block verdeckt. Der Mesher aus Teil 1 weiß schon, welche Seiten verdeckt sind. Exportiere in src/blockMesher.ts Side und covered(), damit andere fragen können.
Innen ist die Ebene einer Seite dir × p[axis] − half entfernt, unter 0. Die größte der Zahlen der offenen Seiten gewinnt. Ist keine offen, tief in einem großen Bau, geht es nach oben hinaus.
Alles, was fest ist
Die nächste Oberfläche des Terrains ist die, die die Spielfigur bisher genutzt hat: die Dichte geteilt durch die Steilheit des Gradienten, etwa der Abstand in Metern. Die Blöcke werden in den Zellen in Reichweite des Punkts nachgeschlagen, jeder Block einmal, und die nächste Oberfläche von allen gewinnt. Lege src/solids.ts an:
import { CELL, type Block, type BlockStore } from './blocks.ts';
import { covered, SIDES } from './blockMesher.ts';
import type { Field } from './field.ts';
/** The nearest surface to a point: how far away it is, and which way is out. */
export interface Surface {
/** Meters from the point to the surface, below 0 inside a solid. */
distance: number;
/** One meter long, pointing out of the solid. */
normal: [number, number, number];
}
/**
* The distance from a point to a block, below 0 inside it: a signed distance. Each axis
* says how far the point is outside the block's slab along it; outside the box, the
* distance is the length of those that are above 0, inside it the largest of them.
* Inside, the way out is through the nearest side for which `isOpen(side)` says yes,
* by the side's number in SIDES.
*/
export function blockDistance(
block: Block,
x: number,
y: number,
z: number,
isOpen: (side: number) => boolean = () => true,
): Surface {
const half = (block.size * CELL) / 2;
// From the block's middle.
const p = [x - block.x * CELL - half, y - block.y * CELL - half, z - block.z * CELL - half];
const q = p.map((c) => Math.abs(c) - half);
const outside = q.map((c) => Math.max(c, 0));
const length = Math.hypot(...outside);
if (length > 0) {
// Out from the nearest point of the box.
const normal = outside.map((c, axis) => (c / length) * Math.sign(p[axis]));
return { distance: length, normal: normal as Surface['normal'] };
}
// Inside: out through the nearest open side, or up (side 3) if none is open. Each
// side's plane is dir × p[axis] - half away, below 0 since the point is inside.
let side = 3;
let distance = -Infinity;
SIDES.forEach(({ axis, dir }, s) => {
const d = dir * p[axis] - half;
if (d > distance && isOpen(s)) {
side = s;
distance = d;
}
});
if (distance === -Infinity) distance = p[1] - half;
const normal: Surface['normal'] = [0, 0, 0];
normal[SIDES[side].axis] = SIDES[side].dir;
return { distance, normal };
}
/** Everything solid: the terrain and the blocks. */
export class Solids {
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 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 = this.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(this.store, block, SIDES[side]));
if (surface.distance < reach && (!nearest || surface.distance < nearest.distance)) nearest = surface;
}
}
}
return nearest;
}
}Außerhalb der Blöcke ist der kleinste ihrer Abstände genau der Abstand zu allen zusammen. Das ist eine Eigenschaft von vorzeichenbehafteten Abständen: Der Abstand zu einer Vereinigung ist der kleinste der Abstände. Innen stimmt das nicht, und deshalb braucht der Weg hinaus die offenen Seiten.
Die Spielfigur
Die Spielfigur behält alles, was sie konnte: das Tempo, den Sprung, die Schritte einen Hang hinunter. Nur ihre Frage ändert sich. Sie bekommt Solids statt des Field des Terrains und fragt nach der nächsten Oberfläche innerhalb ihres Radius. Exportiere in src/player.ts die Größe des Körpers:
/** The body: two balls of this radius, one at the feet and one at the head. */
export const RADIUS = 0.4;
export const HEIGHT = 1.8;Ersetze den Kopf der Klasse, und ersetze den Import von Field durch einen von Solids aus ./solids.ts:
/** A body that walks on the terrain and the blocks, with no physics engine. */
export class Player {
/** Where the feet are. */
readonly position: Vector3;
readonly velocity = new Vector3();
grounded = false;
private readonly solids: Solids;
constructor(solids: Solids, feet: Vector3) {
this.solids = solids;
this.position = feet.clone();
}Ersetze collide():
/** Pushes both balls out of the ground and the blocks. */
private collide(): void {
this.grounded = false;
for (let pass = 0; pass < 3; pass += 1) {
for (const height of [RADIUS, HEIGHT - RADIUS]) {
const surface = this.solids.nearest(
this.position.x,
this.position.y + height,
this.position.z,
RADIUS,
);
if (!surface) continue;
const depth = RADIUS - surface.distance;
const normal = new Vector3(...surface.normal);
if (normal.y > WALKABLE) {
// Ground: straight up, so that the player doesn't slide down a slope.
this.position.y += depth / normal.y;
this.velocity.y = Math.max(this.velocity.y, 0);
this.grounded = true;
} else {
// A wall or a ceiling: back out along the normal, and stop moving into it.
this.position.addInPlace(normal.scale(depth));
const into = Vector3.Dot(this.velocity, normal);
if (into < 0) this.velocity.subtractInPlace(normal.scale(into));
}
}
}
}Der Abstand der Oberfläche tritt an die Stelle der Dichte geteilt durch die Steilheit, ihre Normale an die Stelle des Gradienten. Die Oberseite eines Blocks ist Boden, denn ihre Normale zeigt gerade nach oben; seine Seite ist eine Wand. Nahe einer Kante neigt sich die Normale, und die Spielfigur gleitet von der Kante wie von einer gerundeten.
Nicht in die Spielfigur
Ein Block, der dort hingesetzt wird, wo die Spielfigur steht, würde sie einsperren. Das Werkzeug fragt, bevor es einen setzt. Ergänze in src/buildTool.ts ein Feld in BlockTool:
/** Whether a block may go somewhere, besides fitting between the other blocks. */
canPlace: (block: Block) => boolean = () => true;Und frag es in update(), wo das Werkzeug prüft, ob der Block passt:
const fits = this.store.fits(block.x, block.y, block.z, block.size) && this.canPlace(block);Gib der Spielfigur in src/main.ts die festen Körper. Der Import von Field bleibt, und Solids und blockDistance kommen aus ./solids.ts, RADIUS und HEIGHT aus ./player.ts:
const player = new Player(new Solids(new Field(world.edits), blocks.store), feet);Und sag dem Block-Werkzeug nach dem Erzeugen der Werkzeuge, wo die Spielfigur ist. Drei Kugeln entlang des Körpers, an den Füßen, in der Mitte und am Kopf, decken ihn gut genug ab:
// A block may not go 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 (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,
);
};Ausprobieren
Bau eine niedrige Mauer und geh hinein: Du bleibst stehen. Spring hinauf und geh oben entlang. Setz einen Block vor deine Füße: Der Geist wird rot, wenn er in dich hineinreichen würde. Bau einen Turm, tritt über seine Kante und fall neben ihn. Flieg dann mit F hinauf, setz einen Block dorthin, wo deine Füße sein werden, und lande: Die Spielfigur wird aus ihm hinausgeschoben, durch die nächste Seite, die nichts verdeckt.