Zum Inhalt springen
DasPeTe Tutorials

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

Teil 4: Terrain aus Rauschen: Hügel, Überhänge und Höhlen

Erzeuge ein endloses Terrain aus Simplex-Rauschen mit Seed: Hügel und Bergkämme als Höhe, dann Überhänge und Tunnelhöhlen, die nur eine Dichte fassen kann. Ein Block davon, ausgeschnitten wie ein Stück Kuchen, zeigt das Innere.

Quest-Ziele

  • Rauschen erzeugen, das aus einem Seed jedes Mal gleich ist
  • Rauschen zu Hügeln schichten und zu Bergkämmen formen
  • Eine Höhe als Dichte schreiben und sie mit 3D-Rauschen zu Überhängen biegen
  • Tunnelhöhlen dort ausheben, wo zwei Rauschen beide 0 sind
  • Die Höhe einer Säule einmal ausrechnen, nicht für jeden Wert darin

Die Mesher aus Teil 2 und Teil 3 funktionieren für jede Dichte. Zeit, ihnen Terrain zu geben: eine Funktion von x, y und z, die Boden beschreibt, endlos in jede Richtung, ohne dass jemand einen Hügel von Hand setzt. Das Werkzeug dafür ist Rauschen.

Simplex-Rauschen

Rauschen in diesem Sinn ist eine glatte Zufallsfunktion: Sie liefert Werte zwischen -1 und 1, die sich von Punkt zu Punkt sanft ändern, ohne ein Muster, das auffiele. Ken Perlin hat die klassische Art 1983 erfunden und 2001 das schnellere Simplex-Rauschen. Das Paket simplex-noise hat es für zwei, drei und vier Dimensionen, ohne Abhängigkeiten. Installiere es und lösche die Testform, die dieser Teil ersetzt:

~/voxel-terrain
$ npm install simplex-noise@4.0.3added 1 package, and audited 20 packages in 421ms8 packages are looking for funding  run `npm fund` for detailsfound 0 vulnerabilities$ rm src/shapes.ts

Jedes Mal dieselbe Welt

createNoise2D() mischt vor dem Start eine Tabelle mit einer Zufallsfunktion, und die Tabelle entscheidet, wie das Rauschen aussieht. Mit Math.random gäbe jeder Seitenaufruf eine andere Welt, und ab Teil 6 machte jeder Web Worker seine eigene, was überhaupt nicht zusammenpasst. Eine Zufallsfunktion mit Seed liefert für denselben Seed dieselben Zahlen. Mulberry32 ist eine in fünf Zeilen und gut genug, um eine Tabelle zu mischen.

Schichten aus Rauschen

Rauschen in einem Maßstab sieht aus wie weiche Beulen. Echter Boden hat große Formen mit kleineren darauf, und die haben noch kleinere. Addiere mehrere Schichten Rauschen, jede doppelt so fein und halb so stark wie die davor, und es sieht so aus. Jede Schicht heißt Oktave, und die Summe hat einen feinen Namen, fraktale Brownsche Bewegung, ist aber nur diese Schleife.

Hügel und Berge

Die Höhe des Bodens hat drei Zutaten:

  • Hügel: fünf Schichten Rauschen, die größte etwa 400 m breit, bis zu etwa 40 m hinauf und hinab. Sie wogen sanft und bedecken die ganze Welt.

  • Wo die Berge sind: eine langsame Schicht, 1.500 m breit, durch ein smoothstep, das daraus 0 im Flachland und 1 im Gebirge macht, mit weicher Grenze.

  • Bergkämme: Rauschen ist entlang von Linien 0, die sich über die Karte winden. Eins minus seine Größe ist entlang dieser Linien am höchsten, das ergibt Kämme, und hoch drei macht sie steil, mit weiten Tälern dazwischen. Das hypot mit 0,15 rundet die oberste Spitze ab: Ohne es wäre der Grat eine scharfe Falte. Die Kämme ragen bis zu etwa 90 m über die Hügel, aber nur, wo das Land gebirgig ist.

Von der Höhe zur Dichte

Eine Höhe wird mit einer Subtraktion zur Dichte: height - y ist unter dem Boden positiv, darüber negativ und genau auf ihm 0. Das allein wäre eine verkleidete Heightmap. Die nächsten beiden Schritte nutzen, was eine Dichte kann und eine Heightmap nicht.

Überhänge. 3D-Rauschen, zur Dichte addiert, drückt die Oberfläche hinein und hinaus, in jede Richtung. Wo es seitlich stärker drückt, als der Hang ansteigt, neigt sich der Boden vor und bildet einen Überhang. In der Ebene ist es nur ein Meter, das hält sie glatt; im Gebirge sind es bis zu 10 m, die Klippen zu Simsen biegen. Das Rauschen ist in der Höhe etwas gestreckt, damit die Formen mehr am Hang entlang laufen als an ihm hinauf.

Höhlen. Ein 3D-Rauschen ist auf einer welligen Fläche 0, die durch die Welt läuft. Zwei verschiedene Rauschen sind beide 0, wo sich zwei solche Flächen schneiden, und zwei Flächen schneiden sich in einer Linie. Um diese Linien, wo beide Rauschen nahe 0 sind, verlaufen die Höhlen: lange, gewundene Tunnel. Math.hypot(a, b) ist, wie weit ein Punkt in Rauscheinheiten von der Linie entfernt ist, und mal 50 sind das ungefähr Meter. Innerhalb von 4 m unter der Oberfläche werden die Tunnel enger, sodass die meisten unter der Erde bleiben und nur manche als Höhleneingänge durchbrechen. Math.min() zieht die Höhlen vom Boden ab, wie in Teil 1.

Lege src/terrain.ts an:

src/terrain.ts
import { createNoise2D, createNoise3D } from 'simplex-noise';

/** Numbers that look random but are the same for the same seed (mulberry32). */
export function seededRandom(seed: number): () => number {
  return () => {
    seed = (seed + 0x6d2b79f5) | 0;
    let t = Math.imul(seed ^ (seed >>> 15), seed | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

const random = seededRandom(20261114);
const noise2 = createNoise2D(random);
const noise3 = createNoise3D(random);

/** Layers of noise, each twice as fine and half as strong as the one before. */
function layered2(x: number, z: number, layers: number): number {
  let sum = 0;
  let strength = 0.5;
  for (let layer = 0; layer < layers; layer += 1) {
    sum += noise2(x, z) * strength;
    x *= 2;
    z *= 2;
    strength /= 2;
  }
  return sum;
}

const smoothstep = (from: number, to: number, value: number) => {
  const t = Math.min(Math.max((value - from) / (to - from), 0), 1);
  return t * t * (3 - 2 * t);
};

/** How mountainous the land is at (x, z), from 0 to 1. */
function mountains(x: number, z: number): number {
  return smoothstep(-0.1, 0.4, noise2(x / 1500 + 40, z / 1500 - 70));
}

// The grid asks for a whole column before the next one, so the last column's
// numbers are kept.
let columnX = Number.NaN;
let columnZ = Number.NaN;
let columnHeight = 0;
let columnMountains = 0;

function column(x: number, z: number): void {
  if (x === columnX && z === columnZ) return;
  columnX = x;
  columnZ = z;
  columnMountains = mountains(x, z);
  // Rolling hills everywhere, and ridges where the land is mountainous: high where the
  // noise is near 0. The small number in the hypot rounds the crest off.
  const hills = layered2(x / 400, z / 400, 5) * 40;
  const ridge = Math.max(1 - Math.hypot(layered2(x / 500 - 20, z / 500 + 90, 4) * 1.6, 0.15), 0);
  columnHeight = 12 + hills + columnMountains * ridge ** 3 * 150;
}

/** The height of the ground at (x, z), before overhangs and caves. */
export function surfaceHeight(x: number, z: number): number {
  column(x, z);
  return columnHeight;
}

/** How far the overhangs push the ground out at most, in meters. */
const MOST_BEND = 10;

/** The density of the terrain: above 0 in rock and soil, below 0 in the air. */
export function density(x: number, y: number, z: number): number {
  column(x, z);
  // How far below the ground the point is: the same 0 as the height, but in 3D.
  let d = columnHeight - y;

  // Overhangs: 3D noise pushes the ground in and out, more in the mountains.
  const bend = 1 + (MOST_BEND - 1) * columnMountains;
  d += noise3(x / 50, y / 35, z / 50) * bend;

  // Caves: tunnels along the lines where two noises are both 0. Near the surface they
  // narrow, so that only some of them break through.
  const a = noise3(x / 100, y / 50, z / 100);
  const b = noise3(x / 100 + 43.7, y / 50 - 11.3, z / 100 + 7.9);
  const cave = (Math.hypot(a, b) - 0.09) * 50 + Math.max(4 - d, 0) * 0.6;
  return Math.min(d, cave);
}
  • Das zweite Rauschen für die Höhlen ist dasselbe Rauschen an einer anderen Stelle: Die Koordinaten um ein paar krumme Zahlen zu verschieben, ergibt ein Muster, das mit dem ersten nichts zu tun hat.

  • MOST_BEND ist das Höchste, um das die Überhänge drücken. Teil 7 braucht diese Zahl, um zu wissen, wie hoch der Boden reichen kann.

  • surfaceHeight() ist die Höhe vor Überhängen und Höhlen. Teil 8 nutzt sie, um zu sagen, wie tief ein Punkt unter dem Boden liegt.

Ein Stück Terrain

Ein Chunk ist 32 m lang, breit und hoch, ein kleines Stück der Welt. Um zu sehen, was die Dichte innen enthält, schneidet dieser Teil das Stück aus wie ein Stück Kuchen: das Terrain, geschnitten mit einer Kiste, die etwas kleiner ist als der Chunk, sodass die Schnittflächen es schließen und Höhlen und Überhänge im Schnitt zeigen. Ersetze src/main.ts:

src/main.ts
import {
  ArcRotateCamera,
  Color3,
  Color4,
  DirectionalLight,
  Engine,
  HemisphericLight,
  RegisterStandardEngineExtensions,
  RegisterStandardMaterial,
  RegisterThinInstanceMesh,
  Scene,
  StandardMaterial,
  Vector3,
  type Mesh,
} from '@babylonjs/core/pure';
import { showBlocks } from './blocks.ts';
import { CHUNK_SIZE, DensityGrid } from './grid.ts';
import { createMesh } from './meshData.ts';
import { MESHERS, otherMesher, type MesherName } from './meshers.ts';
import { density, surfaceHeight } from './terrain.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 material = new StandardMaterial('ground', scene);
material.diffuseColor = Color3.FromHexString('#d9a066');
material.specularColor = Color3.Black();

/** Solid inside the box from 0.5 to CHUNK_SIZE - 0.5, so the cut faces close the block. */
function insideBlock(x: number, y: number, z: number): number {
  const far = CHUNK_SIZE - 0.5;
  return Math.min(x - 0.5, far - x, y - 0.5, far - y, z - 0.5, far - z);
}

// The part of the world shown: the low corner of the block. Here a shelf of rock hangs
// over a cave; the arrow keys move on.
const place = { x: 304, z: -64 };
const grid = new DensityGrid();
let mesher: MesherName = 'marching-cubes';
let mesh: Mesh | null = null;
let blocks: Mesh | null = null;
let sampleTime = 0;

/** Samples the world at `place`, cut to a block with the ground 20 m up. */
function sample(): void {
  const origin = {
    x: place.x,
    y: Math.round(surfaceHeight(place.x + CHUNK_SIZE / 2, place.z + CHUNK_SIZE / 2)) - 20,
    z: place.z,
  };
  const started = performance.now();
  grid.fill((x, y, z) => Math.min(density(x, y, z), insideBlock(x - origin.x, y - origin.y, z - origin.z)), origin);
  sampleTime = performance.now() - started;
  const wasShown = blocks?.isEnabled() ?? false;
  blocks?.dispose();
  blocks = showBlocks(scene, grid);
  blocks.setEnabled(wasShown);
  remesh();
}

/** Meshes the grid with the current mesher, in place of the last mesh. */
function remesh(): void {
  const started = performance.now();
  const data = MESHERS[mesher].mesh(grid);
  const time = performance.now() - started;
  mesh?.dispose();
  mesh = createMesh('terrain', data, scene);
  mesh.material = material;
  hud.textContent =
    `x ${place.x}, z ${place.z}, sampled in ${sampleTime.toFixed(1)} ms\n` +
    `${MESHERS[mesher].label} in ${time.toFixed(1)} ms\n` +
    `${data.indices.length / 3} triangles, ${data.positions.length / 3} vertices`;
}
sample();

// Steps of x and z for the arrow keys, as the camera looks at the block at the start:
// up is away from it, along -z.
const MOVES: Record<string, [number, number]> = {
  ArrowLeft: [1, 0],
  ArrowRight: [-1, 0],
  ArrowUp: [0, -1],
  ArrowDown: [0, 1],
};

window.addEventListener('keydown', (event) => {
  if (event.code === 'KeyB') blocks?.setEnabled(!blocks.isEnabled());
  if (event.code === 'KeyL') material.wireframe = !material.wireframe;
  if (event.code === 'KeyM') {
    mesher = otherMesher(mesher);
    remesh();
  }
  const move = MOVES[event.code];
  if (move) {
    event.preventDefault();
    place.x += move[0] * (CHUNK_SIZE / 2);
    place.z += move[1] * (CHUNK_SIZE / 2);
    sample();
  }
});

// Orbit around the middle of the block.
const middle = new Vector3(CHUNK_SIZE / 2, CHUNK_SIZE / 2, CHUNK_SIZE / 2);
const camera = new ArcRotateCamera('camera', 2.2, 1.1, 70, middle, scene);
camera.attachControl();
camera.lowerRadiusLimit = 10;
camera.upperRadiusLimit = 120;
camera.wheelPrecision = 5;

engine.runRenderLoop(() => scene.render());
window.addEventListener('resize', () => engine.resize());
  • insideBlock() ist die Dichte der Kiste, der Schnitt von sechs Halbräumen wie die Platte in Teil 1, auf jeder Seite einen halben Meter im Chunk.

  • Die Höhe des Blocks folgt dem Boden: origin.y legt die Oberfläche in der Mitte des Blocks etwa 20 m nach oben, wo auch immer er ist.

  • Die Pfeiltasten verschieben die Stelle in Schritten von 16 m, einem halben Block, sodass du den Block durch die Welt wandern lassen und sehen kannst, dass sie weitergeht. Das HUD zeigt, wie lange Abtasten und Vernetzen dauern.

  • Er beginnt an einer Stelle, an der ein Felssims über eine Höhle ragt, gefunden durch Ausprobieren.

Ergänze in index.html in der Hilfe arrows move.

Ausprobieren

Zu Beginn ragt ein Felssims über eine Mulde, und darunter läuft die Höhle in den Block. Eine Heightmap kann das nicht fassen: Sie hat eine Höhe für jede Stelle. Drück die Pfeiltasten und reise: Ebenen, Hänge, die steilen Seiten der Kämme und ab und zu ein Tunnel, aufgeschnitten in der Seite des Blocks. Probier M auf echtem Terrain aus und B, um die Werte wieder als Blöcke zu sehen.

Das Projekt am Ende von Teil 4: ein Block Terrain. Die Pfeiltasten bewegen ihn durch die Welt, M schaltet den Mesher um, B zeigt Blöcke.
In neuem Tab öffnen

Ein Block ist klein. Im nächsten Teil wird die Welt zu vielen Chunks nebeneinander, und ihre Meshes müssen sich ohne Naht treffen.

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.