Zum Inhalt springen
DasPeTe Tutorials

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

Teil 3: Surface Nets

Ergänze einen zweiten Mesher, der keine Tabelle braucht: ein Vertex in jeder Zelle, durch die die Oberfläche läuft, und ein Viereck über jeder Kante, an der die Dichte ihr Vorzeichen wechselt. Die Taste M schaltet zwischen beiden um.

Quest-Ziele

  • Wissen, wie sich Surface Nets von Marching Cubes unterscheiden
  • Einen Vertex pro Zelle setzen, in den Mittelwert der Punkte, an denen ihre Kanten die Oberfläche kreuzen
  • Die Vertices mit einem Viereck über jeder Kante verbinden, die das Vorzeichen wechselt
  • Eine Zelle mehr vernetzen, als der Chunk hat, und nur die Kanten nehmen, die ihm gehören
  • Beide Mesher hinter eine Schnittstelle stellen und zwischen ihnen umschalten

Marching Cubes aus Teil 2 setzt seine Vertices auf die Kanten des Gitters und braucht eine Tabelle, um sie zu verbinden. Surface Nets drehen das um: ein Vertex in jeder Zelle, durch die die Oberfläche läuft, und die Flächen dazwischen ergeben sich direkt aus dem Gitter, ganz ohne Tabelle. Sarah Gibson hat sie 1998 beschrieben; Spiele nutzen eine einfache Form davon, und die baut dieser Teil.

Die Idee

Jede Zelle mit festen und luftigen Ecken bekommt einen Vertex. Wohin in der Zelle? Die Kanten der Zelle, die das Vorzeichen wechseln, kreuzen die Oberfläche je an einem Punkt, ausgerechnet wie bei Marching Cubes. Der Vertex kommt in den Mittelwert dieser Punkte. Das liegt nicht genau auf der Oberfläche, aber nah daran, und es wandert gleichmäßig von Zelle zu Zelle.

Dann die Flächen. Nimm eine Kante des Gitters, deren beide Werte verschiedene Vorzeichen haben: Die Oberfläche geht durch sie hindurch. Vier Zellen teilen sich diese Kante, die um sie herum, und jede hat einen Vertex, weil jede diese Kante mit ihrem Vorzeichenwechsel hat. Die vier Vertices bilden ein Viereck, und das Viereck kreuzt die Kante. Ein Viereck pro gekreuzter Kante, und die Oberfläche ist vollständig.

In welche Richtung das Viereck zeigt, folgt daraus, welches Ende der Kante fest ist: Seine Vorderseite zeigt zur Luft.

Eine Zelle mehr

Die vier Zellen um eine Kante am Rand des Chunks liegen teils außerhalb des Chunks. Teil 5 zerlegt das Terrain in viele Chunks, und die müssen ohne Spalt aneinanderstoßen, deshalb vernetzt dieser Teil schon so, wie es dort funktioniert:

  • Der Chunk legt Vertices in den Zellen 0 bis 32 auf jeder Achse an: eine Zelle mehr, als er hat, bis in den nächsten Chunk hinein.

  • Von den Kanten entlang einer Achse nimmt der Chunk die, die entlang dieser Achse bei 0 bis 31 beginnen und auf den beiden anderen Achsen bei 1 bis 32. Dann liegen die vier Zellen um jede seiner Kanten unter seinen 33 Zellen. Die Kanten bei 0 auf den anderen Achsen gehören dem Chunk darunter, der sie als seine 32 nimmt.

Jede Kante der Welt gehört dann genau einem Chunk, und die Vertices in den geteilten Zellen kommen in beiden Chunks gleich heraus, weil sie aus denselben Werten berechnet werden.

Zelle 32 hat Ecken bei 33, und ihre Normale, siehe unten, braucht dort den Gradienten, der den Wert bei 34 braucht. Also wächst der Rand auf zwei Werte. Ändere in src/grid.ts den MARGIN:

src/grid.ts
/**
 * Extra samples around the cells on every side. The normals need one sample beyond the
 * edge, and the last cells of surface nets reach one sample further.
 */
export const MARGIN = 2;

Vierecke

Ein Viereck wird als zwei Dreiecke gezeichnet, geteilt entlang einer seiner Diagonalen. Die vier Vertices liegen selten in einer Ebene, und die kürzere Diagonale ergibt das bessere Paar: Die längere würde einen Knick machen, wo die Oberfläche gebogen ist. Ergänze in MeshBuilder in src/meshData.ts unter triangle() eine Methode:

src/meshData.ts
/** Two triangles for the four corners, split along the shorter diagonal. */
quad(a: number, b: number, c: number, d: number): void {
  if (this.distance(a, c) <= this.distance(b, d)) {
    this.triangle(a, b, c);
    this.triangle(a, c, d);
  } else {
    this.triangle(b, c, d);
    this.triangle(b, d, a);
  }
}

private distance(a: number, b: number): number {
  const p = this.positions;
  return Math.hypot(p[a * 3] - p[b * 3], p[a * 3 + 1] - p[b * 3 + 1], p[a * 3 + 2] - p[b * 3 + 2]);
}

Surface Nets

Lege src/surfaceNets.ts an:

src/surfaceNets.ts
import { CHUNK_SIZE, type DensityGrid } from './grid.ts';
import { MeshBuilder, type MeshData } from './meshData.ts';

/** The eight corners of a cell, numbered like the marching cubes corners. */
const CORNERS = [
  [0, 0, 0], [1, 0, 0], [0, 1, 0], [1, 1, 0],
  [0, 0, 1], [1, 0, 1], [0, 1, 1], [1, 1, 1],
];
/** The twelve edges of a cell, as the corners they join. */
const EDGES = [
  [0, 1], [2, 3], [4, 5], [6, 7],
  [0, 2], [1, 3], [4, 6], [5, 7],
  [0, 4], [1, 5], [2, 6], [3, 7],
];

const lerp = (a: number, b: number, t: number) => a + (b - a) * t;

/**
 * A surface for the density with one vertex in each cell the surface passes through, and
 * a quad across each edge of the grid where the density changes sign. The quad joins the
 * vertices of the four cells around that edge.
 */
export function surfaceNets(grid: DensityGrid): MeshData {
  const mesh = new MeshBuilder();
  // Cells 0 to CHUNK_SIZE: one more on each axis than the chunk has, see below.
  const cells = CHUNK_SIZE + 1;
  const vertexIn = new Int32Array(cells ** 3).fill(-1);
  const step = [1, cells, cells * cells];
  const d = new Float32Array(8);

  for (let z = 0; z < cells; z += 1) {
    for (let y = 0; y < cells; y += 1) {
      for (let x = 0; x < cells; x += 1) {
        let solid = 0;
        for (let c = 0; c < 8; c += 1) {
          d[c] = grid.get(x + CORNERS[c][0], y + CORNERS[c][1], z + CORNERS[c][2]);
          if (d[c] > 0) solid += 1;
        }
        if (solid === 0 || solid === 8) continue;

        // The vertex sits at the average of the points where the edges cross the surface.
        let px = 0;
        let py = 0;
        let pz = 0;
        let crossings = 0;
        for (const [a, b] of EDGES) {
          if (d[a] > 0 === d[b] > 0) continue;
          const t = d[a] / (d[a] - d[b]);
          px += lerp(CORNERS[a][0], CORNERS[b][0], t);
          py += lerp(CORNERS[a][1], CORNERS[b][1], t);
          pz += lerp(CORNERS[a][2], CORNERS[b][2], t);
          crossings += 1;
        }
        px /= crossings;
        py /= crossings;
        pz /= crossings;

        // The normal: the corners' gradients, blended by how close the vertex is to each.
        let nx = 0;
        let ny = 0;
        let nz = 0;
        for (let c = 0; c < 8; c += 1) {
          const [cx, cy, cz] = CORNERS[c];
          const weight = (cx ? px : 1 - px) * (cy ? py : 1 - py) * (cz ? pz : 1 - pz);
          const [gx, gy, gz] = grid.gradient(x + cx, y + cy, z + cz);
          nx -= gx * weight;
          ny -= gy * weight;
          nz -= gz * weight;
        }
        vertexIn[x + y * step[1] + z * step[2]] = mesh.vertex(x + px, y + py, z + pz, nx, ny, nz);
      }
    }
  }

  // A quad for every edge with a sign change. Of the edges along an axis, the chunk takes
  // those 0 to CHUNK_SIZE - 1 along it and 1 to CHUNK_SIZE on the other two axes, so that
  // the four cells around each edge are among its cells. The neighbors take the rest.
  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) {
        const p = [x, y, z];
        const here = grid.get(x, y, z) > 0;
        const cell = x + y * step[1] + z * step[2];
        for (let axis = 0; axis < 3; axis += 1) {
          const u = (axis + 1) % 3;
          const v = (axis + 2) % 3;
          if (p[axis] === CHUNK_SIZE || p[u] === 0 || p[v] === 0) continue;
          const there = grid.get(x + (axis === 0 ? 1 : 0), y + (axis === 1 ? 1 : 0), z + (axis === 2 ? 1 : 0)) > 0;
          if (here === there) continue;

          // The four cells around the edge, in order around it.
          const a = vertexIn[cell - step[u] - step[v]];
          const b = vertexIn[cell - step[v]];
          const c = vertexIn[cell];
          const e = vertexIn[cell - step[u]];
          // Which way the quad faces depends on which end of the edge is solid.
          if (here) mesh.quad(a, e, c, b);
          else mesh.quad(a, b, c, e);
        }
      }
    }
  }
  return mesh.build();
}
  • Der erste Durchgang legt die Vertices an. vertexIn merkt sich den Vertex jeder Zelle, -1 wo keiner ist, in einem flachen Array aus 333 Zellen.

  • Die Normale mischt die Gradienten an den acht Ecken der Zelle. Das Gewicht jeder Ecke ist, wie nah der Vertex an ihr liegt, dieselben Gewichte, die eine trilineare Mischung nutzt: Ein Vertex in der Mitte der Zelle nimmt alle acht gleich, einer direkt neben einer Ecke vor allem deren.

  • Der zweite Durchgang geht über die Werte und ihre drei Kanten zu höherem x, y und z. Das continue behält die Kanten, die dem Chunk gehören, und der Vorzeichentest die, die die Oberfläche kreuzen.

  • step sagt, wie weit die Nummer einer Zelle bei einem Schritt auf jeder Achse springt, also sind die vier Zellen um eine Kante die aktuelle Zelle und drei Nachbarn einen Schritt zurück.

Zwei Mesher, eine Schnittstelle

Beide Mesher nehmen ein Gitter und liefern MeshData, also muss der übrige Code nicht wissen, welchen er hat. Lege src/meshers.ts an:

src/meshers.ts
import type { DensityGrid } from './grid.ts';
import { marchingCubes } from './marchingCubes.ts';
import type { MeshData } from './meshData.ts';
import { surfaceNets } from './surfaceNets.ts';

/** Both ways to turn a grid into a mesh, by name. */
export const MESHERS = {
  'marching-cubes': { label: 'Marching cubes', mesh: marchingCubes },
  'surface-nets': { label: 'Surface nets', mesh: surfaceNets },
} satisfies Record<string, { label: string; mesh: (grid: DensityGrid) => MeshData }>;

export type MesherName = keyof typeof MESHERS;

/** The other mesher. */
export function otherMesher(name: MesherName): MesherName {
  return name === 'marching-cubes' ? 'surface-nets' : 'marching-cubes';
}

satisfies prüft das Objekt gegen den Typ, ohne es zu verändern: MesherName sind weiterhin genau die zwei Namen. Spätere Teile schicken den Namen an Web Worker, die keine Funktionen empfangen können.

Ersetze src/main.ts. Es vernetzt mit dem aktuellen Mesher, misst die Zeit und baut bei M neu. Weil es in diesem Teil um Surface Nets geht, kommen sie zuerst:

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 { 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 material = new StandardMaterial('ground', scene);
material.diffuseColor = Color3.FromHexString('#d9a066');
material.specularColor = Color3.Black();

// This part is about surface nets, so they come first; M switches to marching cubes.
let mesher: MesherName = 'surface-nets';
let mesh: Mesh | null = null;

/** 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('shape', data, scene);
  mesh.material = material;
  hud.textContent =
    `${MESHERS[mesher].label} in ${time.toFixed(1)} ms\n` +
    `${data.indices.length / 3} triangles, ${data.positions.length / 3} vertices`;
}
remesh();

const blocks = showBlocks(scene, grid);
blocks.setEnabled(false);

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();
  }
});

// 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());

Ergänze in index.html in der Hilfe M mesher vor B blocks.

Ausprobieren

Auf den ersten Blick sieht die Form aus wie in Teil 2. Drück L: Die Dreiecke sind gleichmäßiger. Marching Cubes macht schmale Splitter, wo die Oberfläche nah an einem Wert vorbeiläuft; Surface Nets machen Paare etwa gleich großer Dreiecke, die dem Gitter in Reihen folgen.

Die Testform mit Surface Nets und L: Vierecke, jedes in zwei Dreiecke geteilt.

Drück ein paarmal M und vergleiche. Bei dieser Form haben beide Meshes 7.840 Dreiecke, aber gleich sind sie nicht:

  • Die Vertices von Marching Cubes liegen genau dort, wo die Dichte entlang einer Kante 0 kreuzt. Die von Surface Nets sind Mittelwerte, deshalb werden Rundungen etwas runder und der Rand des Tunnels etwas weicher. Keiner hält die scharfe Kante.

  • Surface Nets haben keine Tabelle und keine Fälle, und ihre Dreiecke sind besser geformt. Marching Cubes hält dafür jeden Vertex auf der Oberfläche, die die Werte beschreiben.

  • Hier brauchen Surface Nets länger, weil jeder Vertex acht Gradienten mischt, wo Marching Cubes zwei mischt. Die Zeit im HUD ändert sich von Mal zu Mal: Der Browser übersetzt den Code nach den ersten Durchläufen in schnelleren.

Beide sind gute Wahlen für Terrain, deshalb behält die Serie beide. Auf echtem Terrain siehst du sie im nächsten Teil.

Das Projekt am Ende von Teil 3: zuerst Surface Nets, M schaltet zu Marching Cubes. L zeigt die Dreiecke.
In neuem Tab öffnen

Im nächsten Teil macht die Testform Platz für Terrain: Hügel, Berge, Überhänge und Höhlen aus Rauschen.

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.