Zum Inhalt springen
DasPeTe Tutorials

Glattes Voxel-Terrain in Babylon.js · Teil 5 von 12 der Serie

Teil 5: Chunks und Nähte

Zerleg die Welt in Chunks von 32 m, vernetze jeden für sich und lass sie ohne Naht aneinanderstoßen, für beide Mesher, indem jeder Chunk an seinen Rändern dieselben Werte bekommt.

Quest-Ziele

  • Chunks über ihre Stelle in einem Gitter aus Chunks ansprechen
  • Jeden Chunk für sich vernetzen, mit Positionen ab seiner Ecke
  • Wissen, warum benachbarte Chunks ohne Nähen zusammenpassen, für beide Mesher
  • Chunks ohne Oberfläche überspringen
  • Mit einer Kamera von Babylon.js über das Terrain fliegen

Der Block aus Teil 4 war ein Chunk. Eine Welt sind viele davon, nebeneinander und übereinander, und jeder wird für sich abgetastet und vernetzt. Das hält die Arbeit klein: Wenn sich etwas ändert, werden nur die Chunks drumherum neu gebaut, und später lassen sich Chunks im Hintergrund bauen und rund um die Spielfigur laden, während sie sich bewegt.

Ein Gitter aus Chunks

Chunks liegen in einem eigenen Gitter. Chunk (1, 0, 2) ist der Chunk einen Schritt entlang x und zwei entlang z: Sein erster Wert liegt in der Welt bei x 32, y 0, z 64. Lege src/chunks.ts an:

src/chunks.ts
import { CHUNK_SIZE, DensityGrid } from './grid.ts';
import type { MeshData } from './meshData.ts';
import { MESHERS, type MesherName } from './meshers.ts';
import { density } from './terrain.ts';

/** A chunk's place in the grid of chunks: chunk (1, 0, 2) starts at x 32, y 0, z 64. */
export interface ChunkPosition {
  x: number;
  y: number;
  z: number;
}

export const chunkKey = ({ x, y, z }: ChunkPosition) => `${x},${y},${z}`;

/** Samples the terrain in a chunk and meshes it, or null when it holds no surface. */
export function buildChunk(chunk: ChunkPosition, mesher: MesherName): MeshData | null {
  const origin = { x: chunk.x * CHUNK_SIZE, y: chunk.y * CHUNK_SIZE, z: chunk.z * CHUNK_SIZE };
  const grid = new DensityGrid().fill(density, origin);
  if (!grid.hasSurface()) return null;
  return MESHERS[mesher].mesh(grid);
}

chunkKey() macht aus einer Position einen String, damit Chunks in einer Map liegen können. buildChunk() tastet das Terrain an der Stelle des Chunks ab und vernetzt es. Die Positionen des Meshes werden von der Ecke des Chunks aus gemessen, nicht vom Ursprung der Welt, und stattdessen wird das Mesh von Babylon.js an die Ecke verschoben. Große Zahlen verlieren in einem Float32Array an Genauigkeit, und so bleiben die Positionen klein, wie weit die Welt auch reicht.

Die meisten Chunks enthalten gar keine Oberfläche: Die hoch in der Luft sind ganz Luft, tiefe ohne Höhle ganz Fels. Sie brauchen kein Mesh. Ergänze in DensityGrid in src/grid.ts eine Methode:

src/grid.ts
/** Whether the grid has solid and air samples, and so a surface between them. */
hasSurface(): boolean {
  const solid = this.values[0] > 0;
  return this.values.some((value) => value > 0 !== solid);
}

Warum die Nähte schließen

Zwei benachbarte Chunks teilen eine Seite, eine Wand aus 33 × 33 Werten. Beide tasten diese Punkte ab, jeder für sein eigenes Gitter, und beide bekommen genau dieselben Zahlen, weil die Dichte eine Funktion der Weltposition ist und von nichts sonst. Daraus folgt alles:

  • Marching Cubes setzt seine Vertices auf die Kanten des Gitters. Die Kanten in der geteilten Seite werden in beiden Chunks aus denselben zwei Werten berechnet, also landen die Vertices an derselben Stelle: Die Meshes treffen sich genau, ganz ohne Nähen.

  • Die Normalen kommen aus dem Gradienten, der die Werte auf beiden Seiten der Wand braucht. Dafür ist der Rand aus Teil 2 da: Jeder Chunk tastet auch ein bisschen von seinem Nachbarn ab, sodass der Gradient an der Wand in beiden gleich ist und das Licht an der Naht nicht springt.

  • Surface Nets setzen ihre Vertices in die Zellen, also braucht ein Viereck am Rand Zellen beider Chunks. Das ist die Regel aus Teil 3: Ein Chunk legt Vertices in einer Zelle mehr an, als er hat, und nimmt nur die Kanten, die ihm gehören. Die Zellen, die er mit seinem Nachbarn teilt, kommen in beiden gleich heraus, und jede Kante bekommt ein Viereck, von einem Chunk.

Alles hängt daran, dass jeder Chunk dieselbe Funktion fragt. Teil 9 lässt die Spielfigur das Terrain verändern, und die Änderungen müssen dieses Versprechen halten.

Das Gebiet

Die Blöcke werden nicht mehr gebraucht:

~/voxel-terrain
$ rm src/blocks.ts

Die Hilfe nennt neue Tasten. Ersetze index.html:

index.html
<!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">WASD fly, Q/E down/up, drag to look · M mesher · C chunks · L wireframe</div>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

Ersetze src/main.ts. Es baut 8 × 8 Säulen aus Chunks um den Ursprung, sechs Chunks hoch, von 32 m unter 0 bis 160 m hinauf, alle auf einmal, und lässt eine Kamera darüber fliegen:

src/main.ts
import {
  Color3,
  Color4,
  DirectionalLight,
  Engine,
  HemisphericLight,
  RegisterStandardEngineExtensions,
  RegisterStandardMaterial,
  Scene,
  StandardMaterial,
  UniversalCamera,
  Vector3,
  type Mesh,
} from '@babylonjs/core/pure';
import { buildChunk, chunkKey, type ChunkPosition } from './chunks.ts';
import { CHUNK_SIZE } from './grid.ts';
import { createMesh } from './meshData.ts';
import { MESHERS, otherMesher, type MesherName } from './meshers.ts';
import { surfaceHeight } from './terrain.ts';
import './style.css';

RegisterStandardEngineExtensions();
RegisterStandardMaterial();

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');

/** Two materials, so that every other chunk can be told apart. */
const materials = ['#d9a066', '#a0d966'].map((color, i) => {
  const material = new StandardMaterial(`ground-${i}`, scene);
  material.diffuseColor = Color3.FromHexString(color);
  material.specularColor = Color3.Black();
  return material;
});
let tinted = false;

// The chunks around the origin: 8 × 8 columns, from 32 m down to 160 m up.
const area: ChunkPosition[] = [];
for (let z = -4; z < 4; z += 1) {
  for (let x = -4; x < 4; x += 1) {
    for (let y = -1; y < 5; y += 1) area.push({ x, y, z });
  }
}

let mesher: MesherName = 'marching-cubes';
const meshes = new Map<string, Mesh>();

function materialFor(chunk: ChunkPosition): StandardMaterial {
  return materials[tinted ? (chunk.x + chunk.y + chunk.z) & 1 : 0];
}

/** Builds every chunk of the area, one after the other. */
function buildArea(): void {
  for (const mesh of meshes.values()) mesh.dispose();
  meshes.clear();
  const started = performance.now();
  let triangles = 0;
  for (const chunk of area) {
    const data = buildChunk(chunk, mesher);
    if (!data) continue;
    const mesh = createMesh(`chunk ${chunkKey(chunk)}`, data, scene);
    mesh.position.set(chunk.x * CHUNK_SIZE, chunk.y * CHUNK_SIZE, chunk.z * CHUNK_SIZE);
    mesh.material = materialFor(chunk);
    // The chunk never moves: Babylon.js can stop working out its world matrix.
    mesh.freezeWorldMatrix();
    mesh.metadata = chunk;
    meshes.set(chunkKey(chunk), mesh);
    triangles += data.indices.length / 3;
  }
  const time = performance.now() - started;
  hud.textContent =
    `${MESHERS[mesher].label}: ${area.length} chunks in ${(time / 1000).toFixed(1)} s\n` +
    `${meshes.size} with a surface, ${triangles} triangles`;
}
buildArea();

window.addEventListener('keydown', (event) => {
  if (event.code === 'KeyL') for (const material of materials) material.wireframe = !material.wireframe;
  if (event.code === 'KeyC') {
    tinted = !tinted;
    for (const mesh of meshes.values()) mesh.material = materialFor(mesh.metadata);
  }
  if (event.code === 'KeyM') {
    mesher = otherMesher(mesher);
    buildArea();
  }
});

// A flying camera: WASD to move, Q and E for down and up, drag the mouse to look.
const start = new Vector3(40, surfaceHeight(40, 170) + 60, 170);
const camera = new UniversalCamera('camera', start, scene);
camera.setTarget(new Vector3(0, surfaceHeight(0, -60), -60));
camera.keysUp = [87];
camera.keysDown = [83];
camera.keysLeft = [65];
camera.keysRight = [68];
camera.keysDownward = [81];
camera.keysUpward = [69];
camera.speed = 1;
camera.minZ = 0.1;
camera.attachControl();

engine.runRenderLoop(() => scene.render());
window.addEventListener('resize', () => engine.resize());
  • freezeWorldMatrix() sagt Babylon.js, dass sich der Chunk nie bewegt, also rechnet es seine Lage nicht mehr in jedem Frame aus. Bei Hunderten Chunks macht das etwas aus.

  • Zwei Materialien, eines in der alten Farbe und eines grün, färben jeden zweiten Chunk, wenn du C drückst: Chunks, deren x, y und z zusammen eine ungerade Zahl ergeben, bekommen das grüne.

  • Die UniversalCamera fliegt: Ihre Tasten sind auf W, A, S, D zum Bewegen und Q und E für runter und hoch gesetzt, und Ziehen mit der Maus dreht sie. Die Zahlen sind Tastencodes: 87 ist W.

Ausprobieren

Nach einer Pause erscheint das Terrain, etwa 250 m breit, mit Rändern, wo das Gebiet endet. Von den 384 Chunks hat nur etwa die Hälfte eine Oberfläche. Flieg herum und such die Grenzen der Chunks: Du findest sie nicht. Drück C, und ein Schachbrett erscheint: Jedes Feld ist ein Chunk, und die Oberfläche und ihr Licht laufen glatt über die Grenzen, mit beiden Meshern.

Das Projekt am Ende von Teil 5: 384 Chunks, auf einmal gebaut. WASD und Q/E fliegen, Ziehen schaut, C färbt jeden zweiten Chunk.
In neuem Tab öffnen

Im nächsten Teil zieht die Arbeit von der Seite in Web Worker um, und die Seite bleibt bedienbar, während die Chunks gebaut werden.

Kommentare

Die Kommentare sind GitHub Discussions: Zum Schreiben mit GitHub anmelden.

Die Kommentare kommen über giscus von GitHub. Beim Laden gehen deine IP-Adresse an giscus.app und GitHub.