Glattes Voxel-Terrain in Babylon.js · Teil 2 von 12 der Serie
Teil 2: Marching Cubes mit einer erzeugten Tabelle
Mach aus dem Dichtegitter mit Marching Cubes ein glattes Mesh, mit einer Falltabelle, die der Code aus dem Würfel selbst ausrechnet, sodass benachbarte Würfel immer schließen.

Quest-Ziele
- Wissen, wie Marching Cubes ein Gitter Zelle für Zelle abläuft
- Die 256 Fälle der Falltabelle ausrechnen, statt sie abzutippen
- Die mehrdeutigen Seiten eines Würfels immer gleich entscheiden, damit das Mesh keine Löcher hat
- Jeden Vertex dorthin setzen, wo die Dichte 0 kreuzt, und ihn zwischen Würfeln teilen
- Glatte Normalen aus dem Gradienten der Dichte gewinnen
Die Blöcke aus Teil 1 wissen nur, ob ein Wert fest ist. Dieser Teil nutzt die Zahlen. Marching Cubes, 1987 von William Lorensen und Harvey Cline veröffentlicht, baut eine Oberfläche aus Dreiecken, Zelle für Zelle des Gitters.
Ein Würfel nach dem anderen
Jede Zelle ist ein Würfel mit einem Wert an jeder seiner acht Ecken. Sind alle acht fest oder alle Luft, geht die Oberfläche nicht durch ihn hindurch. Sonst kreuzt sie jede Kante, die an einem Ende eine feste Ecke hat und am anderen Luft, und die Dreiecke des Würfels verbinden Punkte auf diesen Kanten.
Welche Kanten gekreuzt werden, hängt nur davon ab, welche Ecken fest sind. Acht Ecken, jede fest oder nicht, ergeben 28 = 256 Fälle, und eine Tabelle hält die Dreiecke für jeden. Der Algorithmus ist dann kurz: Für jeden Würfel die Fallnummer bilden, seine Dreiecke nachschlagen und jede Ecke eines Dreiecks auf ihre Kante setzen, dorthin, wo die Dichte 0 kreuzt. Zwischen einem Wert von 0,25 und einem von -0,75 ist das ein Viertel des Wegs, t = a / (a - b).
Nummern für Ecken und Kanten
Eine Ecke des Würfels ist danach nummeriert, wo sie liegt: Bit 0 ist ein Schritt entlang x, Bit 1 entlang y, Bit 2 entlang z. Ecke 0 ist die untere Ecke, Ecke 7 die gegenüber, und Ecke 5, binär 101, liegt einen Schritt entlang x und einen entlang z. Eine Fallnummer hat dann ein Bit pro Ecke: Fall 1 heißt, dass nur Ecke 0 fest ist.
Die 12 Kanten sind nach ihrer Achse nummeriert: Die Kanten 0 bis 3 laufen entlang x, 4 bis 7 entlang y und 8 bis 11 entlang z. Innerhalb einer Achse hält die Nummer die Lage der Kante auf den beiden anderen Achsen, als zwei weitere Bits.
Eine ausgerechnete Tabelle, keine abgetippte
Der meiste Marching-Cubes-Code enthält eine Tabelle aus 256 Zeilen voller Zahlen, kopiert aus denselben wenigen Quellen. Das funktioniert, ist aber ein Block magischer Zahlen, der nichts über das Warum verrät, und dahinter steckt eine knifflige Frage. Eine Würfelseite mit zwei festen Ecken auf der einen Diagonale und zwei Luft-Ecken auf der anderen lässt sich auf zwei Arten schneiden: Die beiden festen Ecken können über die Seite hinweg verbunden oder getrennt bleiben. Verbindet sie ein Würfel und hält sie sein Nachbar, der diese Seite teilt, getrennt, hat die Oberfläche ein Loch. Der ursprüngliche Artikel baute seine Fälle aus 15 Grundfällen, und bald zeigte sich, dass genau das damit passieren kann. Tabellen seitdem beantworten die Frage auf verschiedene Weise.
Diese Serie rechnet die Tabelle aus dem Würfel selbst aus und entscheidet jede solche Seite gleich: Die festen Ecken bleiben immer getrennt. Zwei Würfel mit einer gemeinsamen Seite sehen dieselben vier Ecken, entscheiden gleich, und ihre Oberflächen treffen sich.
Die Idee: Wo die Oberfläche einen Würfel durchquert, schneidet sie seine Seiten in Linien, und diese Linien bilden geschlossene Schleifen um jedes Stück Oberfläche.
Geh jede Seite Ecke für Ecke ab. Wo eine Reihe fester Ecken endet, beginnt auf dieser Kante eine Linie, und sie läuft über die Seite zu der Kante, an der die Reihe begann. Sie trennt die festen Ecken vom Rest der Seite ab. Auf einer diagonalen Seite ist jede feste Ecke eine eigene Reihe und bekommt eine eigene Linie: getrennt.
Alle Seiten werden in derselben Richtung abgegangen, von außerhalb des Würfels gesehen. Zwei Seiten, die sich treffen, gehen ihre gemeinsame Kante dann in entgegengesetzter Richtung ab, und so ist jede gekreuzte Kante die Stelle, an der die Linie einer Seite endet und die einer anderen beginnt. Wer den Linien von Kante zu Kante folgt, schließt immer eine Schleife.
Jede Schleife ist ein annähernd ebenes Vieleck. Zerleg sie in einen Fächer aus Dreiecken von einer ihrer Ecken aus. Ein Detail: Eine innere Seite des Fächers zwischen zwei Kanten derselben Würfelseite läge flach in dieser Seite, wo auch die Oberfläche des Nachbarn liegen kann. Der Fächer beginnt an einer Ecke, die keine solche Seite hat.
Lege src/caseTable.ts an:
/**
* The marching cubes case table, worked out from the cube rather than typed in.
*
* Corners are numbered by where they are: bit 0 is x, bit 1 is y and bit 2 is z, so
* corner 5 (binary 101) sits at x = 1, y = 0, z = 1. Edges are numbered axis × 4 + i:
* edges 0 to 3 run along x, 4 to 7 along y and 8 to 11 along z, and i holds the
* edge's place on the two other axes.
*/
/** The axis each edge runs along. */
export const EDGE_AXIS: number[] = [];
/** The corner each edge starts at; it ends one step further along its axis. */
export const EDGE_START: number[] = [];
for (let axis = 0; axis < 3; axis += 1) {
const [a, b] = otherAxes(axis);
for (let i = 0; i < 4; i += 1) {
EDGE_AXIS.push(axis);
EDGE_START.push(((i & 1) << a) | ((i >> 1) << b));
}
}
function otherAxes(axis: number): [number, number] {
return axis === 0 ? [1, 2] : axis === 1 ? [0, 2] : [0, 1];
}
/** The edge between two corners next to each other. */
function edgeBetween(c1: number, c2: number): number {
const axis = Math.log2(c1 ^ c2);
const start = c1 & c2;
const [a, b] = otherAxes(axis);
return axis * 4 + ((start >> a) & 1) + ((start >> b) & 1) * 2;
}
/**
* The six faces of the cube, each as its four corners in order around it. Seen from
* outside the cube, all of them go around the same way, so two faces that meet walk
* their shared edge in opposite directions.
*/
const FACES: number[][] = [];
for (let axis = 0; axis < 3; axis += 1) {
const u = 1 << ((axis + 1) % 3);
const v = 1 << ((axis + 2) % 3);
const high = 1 << axis;
FACES.push([0, v, u | v, u]);
FACES.push([high, high | u, high | u | v, high | v]);
}
/**
* Where the surface meets the cube's faces: closed loops of edges, around each patch of
* surface in the cube. `corners` has a bit set for every solid corner.
*/
function outlinesOf(corners: number): number[][] {
const solid = (corner: number) => ((corners >> corner) & 1) === 1;
// On every face, a line runs from the edge where a row of solid corners ends to the edge
// where it starts, cutting those corners off. `next` links each line to the next one.
const next = new Map<number, number>();
for (const face of FACES) {
for (let i = 0; i < 4; i += 1) {
const corner = face[i];
const after = face[(i + 1) % 4];
if (!solid(corner) || solid(after)) continue;
let first = i;
while (solid(face[(first + 3) % 4])) first = (first + 3) % 4;
next.set(edgeBetween(corner, after), edgeBetween(face[(first + 3) % 4], face[first]));
}
}
// Every edge ends one line and starts another, so following the links closes loops.
const loops: number[][] = [];
const done = new Set<number>();
for (const start of next.keys()) {
if (done.has(start)) continue;
const loop: number[] = [];
for (let edge = start; !done.has(edge); edge = next.get(edge)!) {
done.add(edge);
loop.push(edge);
}
loops.push(loop);
}
return loops;
}
/** Whether two edges lie on the same face of the cube. */
function shareFace(e1: number, e2: number): boolean {
for (let axis = 0; axis < 3; axis += 1) {
if (axis === EDGE_AXIS[e1] || axis === EDGE_AXIS[e2]) continue;
if (((EDGE_START[e1] >> axis) & 1) === ((EDGE_START[e2] >> axis) & 1)) return true;
}
return false;
}
/**
* Splits a loop into a fan of triangles around one of its edges. The loop runs so that
* Babylon.js shows the triangles' front to the air. A side between two edges of the same
* face would lie flat in that face, where the next cube's surface may lie too, so the fan
* starts at an edge that has no such side.
*/
function triangulate(loop: number[]): number[] {
const n = loop.length;
for (let apex = 0; apex < n; apex += 1) {
const at = (i: number) => loop[(apex + i) % n];
let fits = true;
for (let i = 2; i < n - 1; i += 1) if (shareFace(at(0), at(i))) fits = false;
if (!fits) continue;
const triangles: number[] = [];
for (let i = 1; i < n - 1; i += 1) triangles.push(at(0), at(i), at(i + 1));
return triangles;
}
throw new Error(`No fan fits the loop ${loop.join(', ')}`);
}
/** For every case, the edges its triangles' corners lie on, three per triangle. */
export const CASES: number[][] = [];
for (let corners = 0; corners < 256; corners += 1) {
CASES.push(outlinesOf(corners).flatMap(triangulate));
}EDGE_STARTundEDGE_AXISbeschreiben die 12 Kanten: wo jede beginnt und in welche Richtung sie läuft. Der Marching-Cubes-Code macht damit aus einer Kantennummer einen Wert des Gitters.FACESlistet die Ecken jeder Seite in der Reihenfolge des Abgehens. Eine Seite auf der oberen Seite einer Achse läuft in die eine Richtung, die auf der unteren in die andere, und das ist von außen gesehen dieselbe Richtung.outlinesOf()trägt die Linien auf jeder Seite innextein, von der Kante, an der eine Linie beginnt, zur Kante, an der sie endet, und folgt ihnen dann zu Schleifen.triangulate()probiert jede Ecke als Mitte des Fächers, bis eine passt. Für alle 256 Fälle passt eine.
Die Schleifen laufen in der Richtung, in der Babylon.js die Vorderseite jedes Dreiecks zur Luft zeigt. Die Fälle ergeben zusammen 820 Dreiecke, höchstens fünf in einem Würfel. Aus vielen zufälligen Gittern gebaut, ist das Mesh geschlossen: Jede Kante jedes Dreiecks grenzt an genau ein weiteres Dreieck.
Normalen aus der Dichte
Die Beleuchtung braucht an jedem Vertex eine Normale: die Richtung, in die die Oberfläche zeigt. Die Dichte kennt sie. Ihr Gradient, die Richtung, in der sie am schnellsten steigt, zeigt geradewegs ins Feste, also ist die Normale der umgedrehte Gradient. Auf dem Gitter ist der Gradient an einem Wert die Differenz seiner beiden Nachbarn auf jeder Achse, halbiert: eine zentrale Differenz.
Ein Wert am äußeren Rand des Gitters hat nur auf einer Seite einen Nachbarn. Deshalb bekommt das Gitter einen Rand: Werte einen Schritt jenseits der Zellen auf jeder Seite. Ersetze src/grid.ts:
/** Cells along each side of a chunk. A side has one sample more than it has cells. */
export const CHUNK_SIZE = 32;
/** Extra samples around the cells on every side, for the normals at the border. */
export const MARGIN = 1;
/** The density of the world at a point: above 0 is solid, below 0 is air. */
export type Density = (x: number, y: number, z: number) => number;
/**
* The density sampled at the corners of a cube of CHUNK_SIZE³ cells, one meter apart.
* Samples go from -MARGIN to CHUNK_SIZE + MARGIN on each axis.
*/
export class DensityGrid {
/** Samples along each side. */
readonly side = CHUNK_SIZE + 1 + 2 * MARGIN;
readonly values = new Float32Array(this.side ** 3);
index(x: number, y: number, z: number): number {
return x + MARGIN + this.side * (y + MARGIN + this.side * (z + MARGIN));
}
get(x: number, y: number, z: number): number {
return this.values[this.index(x, y, z)];
}
/** Samples `density` at every corner. The corner (0, 0, 0) is at `origin` in the world. */
fill(density: Density, origin = { x: 0, y: 0, z: 0 }): this {
const last = CHUNK_SIZE + MARGIN;
for (let z = -MARGIN; z <= last; z += 1) {
for (let x = -MARGIN; x <= last; x += 1) {
for (let y = -MARGIN; y <= last; y += 1) {
this.values[this.index(x, y, z)] = density(origin.x + x, origin.y + y, origin.z + z);
}
}
}
return this;
}
/**
* The gradient at a sample: the direction in which the density rises fastest, which
* points into the solid. Central differences need the samples on both sides.
*/
gradient(x: number, y: number, z: number): [number, number, number] {
return [
(this.get(x + 1, y, z) - this.get(x - 1, y, z)) / 2,
(this.get(x, y + 1, z) - this.get(x, y - 1, z)) / 2,
(this.get(x, y, z + 1) - this.get(x, y, z - 1)) / 2,
];
}
}Die Werte gehen jetzt von -1 bis 33 auf jeder Achse, 35 Stück, und index() verschiebt die Koordinaten um den Rand. Der übrige Code zählt weiter ab 0.
Das Mesh
Beide Mesher dieser Serie liefern dasselbe: Positionen, Normalen und Indizes, drei Zahlen pro Vertex für die ersten beiden und drei Vertexnummern pro Dreieck. Lege src/meshData.ts an:
import { Mesh, VertexData, type Scene } from '@babylonjs/core/pure';
/** A mesh as typed arrays: what a mesher makes and Babylon.js draws. */
export interface MeshData {
positions: Float32Array;
normals: Float32Array;
indices: Uint32Array;
}
/** Collects vertices and triangles in growing arrays and hands them out as typed ones. */
export class MeshBuilder {
private readonly positions: number[] = [];
private readonly normals: number[] = [];
private readonly indices: number[] = [];
/** Adds a vertex and returns its number. The normal is made one meter long. */
vertex(x: number, y: number, z: number, nx: number, ny: number, nz: number): number {
const length = Math.hypot(nx, ny, nz) || 1;
this.positions.push(x, y, z);
this.normals.push(nx / length, ny / length, nz / length);
return this.positions.length / 3 - 1;
}
triangle(a: number, b: number, c: number): void {
this.indices.push(a, b, c);
}
build(): MeshData {
return {
positions: new Float32Array(this.positions),
normals: new Float32Array(this.normals),
indices: new Uint32Array(this.indices),
};
}
}
export function createMesh(name: string, data: MeshData, scene: Scene): Mesh {
const mesh = new Mesh(name, scene);
const vertexData = new VertexData();
vertexData.positions = data.positions;
vertexData.normals = data.normals;
vertexData.indices = data.indices;
vertexData.applyToMesh(mesh);
return mesh;
}MeshBuilder sammelt sie in normalen Arrays, die wachsen können, und gibt sie als typisierte Arrays heraus, die Babylon.js so nimmt, wie sie sind. Er macht jede Normale einen Meter lang. createMesh() macht daraus ein Mesh von Babylon.js.
Marching Cubes
Lege src/marchingCubes.ts an:
import { CASES, EDGE_AXIS, EDGE_START } from './caseTable.ts';
import { CHUNK_SIZE, type DensityGrid } from './grid.ts';
import { MeshBuilder, type MeshData } from './meshData.ts';
/** One step along each axis. */
const STEP = [
[1, 0, 0],
[0, 1, 0],
[0, 0, 1],
];
/** A surface for the density in the grid's cells, with its vertices on the cells' edges. */
export function marchingCubes(grid: DensityGrid): MeshData {
const mesh = new MeshBuilder();
// The vertex on each edge of the grid once it is made, by the edge's start sample and
// axis. Cubes next to each other share it.
const vertices = new Map<number, number>();
const vertexOn = (x: number, y: number, z: number, axis: number): number => {
const key = grid.index(x, y, z) * 3 + axis;
const known = vertices.get(key);
if (known !== undefined) return known;
const [dx, dy, dz] = STEP[axis];
const a = grid.get(x, y, z);
const b = grid.get(x + dx, y + dy, z + dz);
// How far along the edge the density crosses 0.
const t = a / (a - b);
// The normal points out of the solid, against the gradient.
const ga = grid.gradient(x, y, z);
const gb = grid.gradient(x + dx, y + dy, z + dz);
const vertex = mesh.vertex(
x + dx * t,
y + dy * t,
z + dz * t,
-(ga[0] + (gb[0] - ga[0]) * t),
-(ga[1] + (gb[1] - ga[1]) * t),
-(ga[2] + (gb[2] - ga[2]) * t),
);
vertices.set(key, vertex);
return vertex;
};
for (let z = 0; z < CHUNK_SIZE; z += 1) {
for (let y = 0; y < CHUNK_SIZE; y += 1) {
for (let x = 0; x < CHUNK_SIZE; x += 1) {
// One bit per solid corner: the case number.
let corners = 0;
for (let corner = 0; corner < 8; corner += 1) {
if (grid.get(x + (corner & 1), y + ((corner >> 1) & 1), z + (corner >> 2)) > 0) {
corners |= 1 << corner;
}
}
const edges = CASES[corners];
for (let i = 0; i < edges.length; i += 3) {
const [a, b, c] = [edges[i], edges[i + 1], edges[i + 2]].map((edge) => {
const start = EDGE_START[edge];
return vertexOn(x + (start & 1), y + ((start >> 1) & 1), z + (start >> 2), EDGE_AXIS[edge]);
});
mesh.triangle(a, b, c);
}
}
}
}
return mesh.build();
}Die Schleifen gehen über die Zellen, 0 bis 31 auf jeder Achse. Die Ecken einer Zelle sind die Werte an ihren Koordinaten plus 0 oder 1.
Eine Kante des Gitters gehört zu bis zu vier Würfeln.
vertexOn()merkt sich ihren Vertex unter dem Startwert und der Achse der Kante, sodass alle vier Würfel denselben Vertex nutzen, statt jeder einen eigenen zu machen. Das macht das Mesh kleiner, und ein geteilter Vertex mit einer Normale sorgt dafür, dass die Schattierung über die Dreiecke hinweg glatt ist.Die Normale ist der Gradient an beiden Enden der Kante, gemischt nach
t.
Die Seite
Die Blöcke bleiben zum Vergleich, ausgeschaltet. Die Seite bekommt ein HUD in der oberen Ecke, und die Hilfe nennt zwei Tasten:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Voxel terrain</title>
</head>
<body>
<canvas id="game"></canvas>
<div id="hud"></div>
<div id="help">Drag to turn, scroll to zoom · B blocks · L wireframe</div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>Ergänze den Stil des HUDs in src/style.css:
#hud {
position: fixed;
left: 12px;
top: 12px;
padding: 6px 10px;
border-radius: 6px;
background: rgb(29 18 51 / 0.7);
font: 13px/1.5 ui-monospace, monospace;
white-space: pre;
pointer-events: none;
}Und ersetze src/main.ts. Es vernetzt das Gitter, zeigt das Mesh mit einem Material in der Farbe der Blöcke und lässt B die Blöcke und L die Dreiecke zeigen:
import {
ArcRotateCamera,
Color3,
Color4,
DirectionalLight,
Engine,
HemisphericLight,
RegisterStandardEngineExtensions,
RegisterStandardMaterial,
RegisterThinInstanceMesh,
Scene,
StandardMaterial,
Vector3,
} from '@babylonjs/core/pure';
import { showBlocks } from './blocks.ts';
import { CHUNK_SIZE, DensityGrid } from './grid.ts';
import { marchingCubes } from './marchingCubes.ts';
import { createMesh } from './meshData.ts';
import { testShape } from './shapes.ts';
import './style.css';
RegisterStandardEngineExtensions();
RegisterStandardMaterial();
RegisterThinInstanceMesh();
const canvas = document.querySelector<HTMLCanvasElement>('#game')!;
const hud = document.querySelector<HTMLElement>('#hud')!;
const engine = new Engine(canvas, true, {}, true);
const scene = new Scene(engine);
scene.clearColor = Color4.FromHexString('#2a1150ff');
// A cool light from the sky and a warm, low sun.
const sky = new HemisphericLight('sky', new Vector3(0, 1, 0), scene);
sky.intensity = 0.6;
sky.diffuse = Color3.FromHexString('#c4b5fd');
sky.groundColor = Color3.FromHexString('#3b1676');
const sun = new DirectionalLight('sun', new Vector3(0.5, -0.65, -0.55), scene);
sun.intensity = 1.1;
sun.diffuse = Color3.FromHexString('#ffd8a8');
const grid = new DensityGrid().fill(testShape);
const data = marchingCubes(grid);
const material = new StandardMaterial('ground', scene);
material.diffuseColor = Color3.FromHexString('#d9a066');
material.specularColor = Color3.Black();
const mesh = createMesh('shape', data, scene);
mesh.material = material;
const blocks = showBlocks(scene, grid);
blocks.setEnabled(false);
hud.textContent =
`Marching cubes\n${data.indices.length / 3} triangles, ${data.positions.length / 3} vertices`;
window.addEventListener('keydown', (event) => {
if (event.code === 'KeyB') blocks.setEnabled(!blocks.isEnabled());
if (event.code === 'KeyL') material.wireframe = !material.wireframe;
});
// Orbit around the middle of the grid.
const middle = new Vector3(CHUNK_SIZE / 2, CHUNK_SIZE / 3, CHUNK_SIZE / 2);
const camera = new ArcRotateCamera('camera', 2.2, 1.1, 60, middle, scene);
camera.attachControl();
camera.lowerRadiusLimit = 10;
camera.upperRadiusLimit = 120;
camera.wheelPrecision = 5;
engine.runRenderLoop(() => scene.render());
window.addEventListener('resize', () => engine.resize());Ausprobieren
Die Kugel ist jetzt rund, und die Platte hat glatte, gerundete Kanten. Drück L, um die Dreiecke zu sehen: Sie liegen in einem regelmäßigen Muster, das das Gitter vorgibt, und werden klein und schmal, wo die Oberfläche nah an einem Wert vorbeiläuft.
Drück B, um die Blöcke darüberzulegen: Die glatte Oberfläche läuft durch die Blöcke, mal in ihnen, mal außerhalb. Schau dir die Öffnung des Tunnels an. Wo der Zylinder auf die Kugel trifft, hat die Form eine scharfe Kante, und das Mesh kaut sie ab. Marching Cubes kann Vertices nur auf die Kanten des Gitters setzen, also wird eine Ecke, die dazwischen liegt, abgeschnitten. Andere Verfahren, etwa Dual Contouring, halten scharfe Kanten, aber Terrain hat wenige davon, und die Surface Nets des nächsten Teils halten sie auch nicht.
Im nächsten Teil kommt ein zweiter Mesher dazu: Surface Nets, die einen Vertex in jede Zelle setzen statt auf ihre Kanten.
