Zum Inhalt springen
DasPeTe Tutorials

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

Teil 8: Triplanare Texturen

Gib dem Terrain Gras, Erde, Fels und Schnee: Texturen, im Code in ein Textur-Array gemalt, in einem GLSL-Shader von drei Seiten projiziert und nach Hangneigung, Höhe und Tiefe unter dem Boden gewählt.

Quest-Ziele

  • Wissen, warum ein Voxel-Mesh keine Texturkoordinaten hat und wie Triplanar Mapping ohne auskommt
  • Kachelbare Texturen im Code malen und in ein Textur-Array legen
  • Einen Vertex- und einen Fragment-Shader für ein ShaderMaterial von Babylon.js schreiben
  • Das Material nach Hangneigung, Höhe und Tiefe unter dem erzeugten Boden wählen
  • Das Terrain im Shader beleuchten und vernebeln

Seit Teil 7 geht das Terrain ewig weiter, ganz in der Farbe von Ton. Eine Textur braucht Texturkoordinaten: für jeden Vertex eine Stelle im Bild. Ein Modellierer wickelt ein Modell dafür von Hand ab. Voxel-Terrain kann jede Form annehmen, Überhänge und Höhlen eingeschlossen, und ändert sich, wenn die Spielfigur gräbt: Da gibt es nichts abzuwickeln.

Triplanar Mapping

Triplanar Mapping braucht keine Koordinaten. Stell dir drei Projektoren vor, einer leuchtet von oben herab, einer entlang x und einer entlang z. Jeder wirft die Textur aufs Terrain und nutzt dafür zwei Koordinaten der Weltposition: der von oben x und z, die anderen z und y sowie x und y. Ein flaches Feld bekommt die Textur von oben, ungedehnt; eine Klippe, die entlang x schaut, bekommt sie vom x-Projektor; ein Hang irgendwo dazwischen bekommt eine Mischung.

Die Mischung kommt aus der Normale. Je mehr die Oberfläche entlang einer Achse schaut, desto mehr nimmt sie von der Projektion dieser Achse. Die Anteile der Normale vor dem Mischen hoch vier zu nehmen, verengt den Streifen, in dem sich zwei Projektionen mischen, was sonst verwaschen aussähe.

Texturen, im Code gemalt

Das Terrain braucht vier Texturen: Gras, Erde, Fels und Schnee. Statt Bilddateien malt die Seite sie beim Start. Wieder Rauschen, aber eine Art, die sich kacheln lässt: ein Gitter aus Zufallswerten, weich gemischt, dessen letzte Spalte zur ersten zurückläuft. Der rechte Rand der Textur setzt dann den linken fort, und die Textur wiederholt sich ohne Naht. Lege src/textures.ts an:

src/textures.ts
import { seededRandom } from './terrain.ts';

/** Pixels along each side of a texture. */
export const TEXTURE_SIZE = 256;
/** The textures, in the order of the layers of the texture array. */
export const LAYERS = ['grass', 'soil', 'rock', 'snow'] as const;

type Rgb = [number, number, number];
type Pattern = (u: number, v: number) => number;

const random = seededRandom(7);
const smooth = (t: number) => t * t * (3 - 2 * t);
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;

/**
 * Noise that repeats `period` times across the texture: random values on a grid,
 * blended smoothly. The grid wraps around, so the texture tiles without seams.
 */
function tilingNoise(period: number): Pattern {
  const values = Float32Array.from({ length: period * period }, () => random() * 2 - 1);
  const at = (i: number, j: number) => values[(i % period) + (j % period) * period];
  return (u, v) => {
    const x = u * period;
    const y = v * period;
    const i = Math.floor(x);
    const j = Math.floor(y);
    const fx = smooth(x - i);
    const fy = smooth(y - j);
    return lerp(lerp(at(i, j), at(i + 1, j), fx), lerp(at(i, j + 1), at(i + 1, j + 1), fx), fy);
  };
}

/** Several tiling noises added up, each finer and weaker than the one before. */
function layeredNoise(...periods: number[]): Pattern {
  const layers = periods.map(tilingNoise);
  return (u, v) => layers.reduce((sum, layer, i) => sum + layer(u, v) / 2 ** i, 0) * 0.6;
}

const hex = (color: string): Rgb => [1, 3, 5].map((i) => parseInt(color.slice(i, i + 2), 16)) as Rgb;
const mix = (a: Rgb, b: Rgb, t: number): Rgb => {
  const k = Math.min(Math.max(t, 0), 1);
  return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)];
};
const shade = (color: Rgb, amount: number): Rgb => color.map((c) => c * (1 + amount)) as Rgb;

/** How to paint each texture, from u and v between 0 and 1. */
const PAINTERS: Record<(typeof LAYERS)[number], (u: number, v: number) => Rgb> = (() => {
  const grassPatches = layeredNoise(8, 16, 32);
  const blades = layeredNoise(64, 128);
  const soilPatches = layeredNoise(8, 16, 32);
  const pebbles = tilingNoise(48);
  const rockPatches = layeredNoise(4, 8, 16, 32);
  const cracks = layeredNoise(8, 16);
  const snowPatches = layeredNoise(4, 8, 16, 64);
  return {
    grass: (u, v) => {
      const color = mix(hex('#4a7a2a'), hex('#6f9e36'), grassPatches(u, v) + 0.5);
      return shade(color, blades(u, v) * 0.5);
    },
    soil: (u, v) => {
      const color = mix(hex('#5a3b27'), hex('#795336'), soilPatches(u, v) + 0.5);
      return pebbles(u, v) > 0.55 ? mix(color, hex('#a88a6a'), 0.7) : color;
    },
    rock: (u, v) => {
      const color = mix(hex('#5f5866'), hex('#a39aa8'), rockPatches(u, v) + 0.5);
      // Cracks run where the noise crosses 0.
      return Math.abs(cracks(u, v)) < 0.025 ? shade(color, -0.45) : color;
    },
    snow: (u, v) => mix(hex('#d4dcf0'), hex('#ffffff'), snowPatches(u, v) + 0.6),
  };
})();

/** Paints every texture: RGBA bytes, one layer after the other. */
export function paintLayers(): Uint8Array {
  const pixels = TEXTURE_SIZE * TEXTURE_SIZE;
  const data = new Uint8Array(pixels * 4 * LAYERS.length);
  LAYERS.forEach((name, layer) => {
    const paint = PAINTERS[name];
    for (let y = 0; y < TEXTURE_SIZE; y += 1) {
      for (let x = 0; x < TEXTURE_SIZE; x += 1) {
        const [r, g, b] = paint(x / TEXTURE_SIZE, y / TEXTURE_SIZE);
        data.set([r, g, b, 255], (layer * pixels + y * TEXTURE_SIZE + x) * 4);
      }
    }
  });
  return data;
}
  • tilingNoise(period) hat period Zufallswerte pro Seite. i % period lässt das Gitter umlaufen, sodass das Rauschen bei u 1 das bei u 0 ist.

  • layeredNoise() addiert mehrere davon, wie die Schichten des Terrains. Jede Periode muss in die 256 Pixel der Textur aufgehen, sonst passte das Muster nicht zusammen.

  • Gras sind grüne Flecken mit feinen Sprenkeln für Halme. Erde ist braun mit helleren Kieseln, wo ein feineres Rauschen hoch ist. Fels ist grau mit Rissen, wo ein anderes Rauschen 0 kreuzt, was wie bei den Höhlen entlang von Linien passiert. Schnee ist fast weiß.

  • Die Zufallsfunktion ist seededRandom() aus dem Terrain, mit einem eigenen Seed. Die Texturen sind bei jedem Laden gleich.

Vier Texturen mit 256 × 256 Pixeln zu malen dauert beim Start einen Moment, und das Projekt kommt ohne eine einzige Bilddatei aus.

Eine Textur mit Schichten

Der Shader braucht alle vier Texturen. Ein Textur-Array hält sie als Schichten einer Textur, im Shader per Nummer gewählt. WebGL 2 hat sie, und Babylon.js macht eines mit RawTexture2DArray, direkt aus den gemalten Bytes.

Gras, Erde, Fels oder Schnee

Welche Textur kommt wohin? Der Shader entscheidet pro Pixel, aus drei Dingen:

  • Der Hangneigung: dem y der Normale. Gras wächst auf sanften Hängen; steile sind Fels.

  • Der Höhe: Schnee liegt über 105 m, auf Hängen, die sanft genug sind, ihn zu halten.

  • Der Tiefe unter dem Boden: wie weit eine Stelle unter der erzeugten Oberfläche liegt. Gras wächst nur ganz oben; ein, zwei Meter tiefer ist Erde, und ab etwa 4 m Tiefe Fels. Klippen, wo die Überhänge den Boden hineingedrückt haben, Höhlenwände und später jedes Loch, das die Spielfigur gräbt, zeigen, was darunter liegt.

Die Tiefe wird pro Vertex im Worker ausgerechnet, mit surfaceHeight() aus Teil 4: die Höhe des Bodens vor Überhängen und Höhlen minus die Höhe des Vertex. Ergänze in src/chunks.ts einen Typ für das Mesh eines Chunks und ersetze buildChunk():

src/chunks.ts
/** A chunk's mesh, with how far below the ground each vertex lies. */
export interface ChunkMesh extends MeshData {
  depths: Float32Array;
}

/** Samples the terrain in a chunk and meshes it, or null when it holds no surface. */
export function buildChunk(chunk: ChunkPosition, mesher: MesherName): ChunkMesh | 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;
  const mesh = MESHERS[mesher].mesh(grid);

  // Meters below the generated ground, for the material: soil first, then rock.
  const { positions } = mesh;
  const depths = new Float32Array(positions.length / 3);
  for (let i = 0; i < depths.length; i += 1) {
    const x = origin.x + positions[i * 3];
    const y = origin.y + positions[i * 3 + 1];
    const z = origin.z + positions[i * 3 + 2];
    depths[i] = surfaceHeight(x, z) - y;
  }
  return { ...mesh, depths };
}

Der Worker schickt die Tiefen mit, übertragen wie die anderen Arrays. Ersetze src/chunkWorker.ts:

src/chunkWorker.ts
import { buildChunk, type ChunkMesh, type ChunkPosition } from './chunks.ts';
import type { MesherName } from './meshers.ts';

export interface ChunkJob {
  chunk: ChunkPosition;
  mesher: MesherName;
}

export interface ChunkResult {
  /** null when the chunk holds no surface. */
  mesh: ChunkMesh | null;
  /** Milliseconds the worker spent on the chunk. */
  time: number;
}

self.onmessage = (event: MessageEvent<ChunkJob>) => {
  const started = performance.now();
  const mesh = buildChunk(event.data.chunk, event.data.mesher);
  const result: ChunkResult = { mesh, time: performance.now() - started };
  // Hand the arrays' memory over to the page instead of copying it.
  const transfer = mesh
    ? [mesh.positions.buffer, mesh.normals.buffer, mesh.indices.buffer, mesh.depths.buffer]
    : [];
  self.postMessage(result, { transfer });
};

Und die Welt gibt sie dem Mesh als eigenes Attribut, eine Zahl pro Vertex. Importiere in src/world.ts ChunkMesh statt MeshData, nutze es für die Daten von Finished und ersetze createChunkMesh():

src/world.ts
private createChunkMesh(position: ChunkPosition, data: ChunkMesh): Mesh {
  const mesh = createMesh(`chunk ${chunkKey(position)}`, data, this.scene);
  mesh.setVerticesData('depth', data.depths, false, 1);
  mesh.position.set(position.x * CHUNK_SIZE, position.y * CHUNK_SIZE, position.z * CHUNK_SIZE);
  mesh.material = this.material;
  // The chunk never moves: Babylon.js can stop working out its world matrix.
  mesh.freezeWorldMatrix();
  return mesh;
}

Die Shader

Das Standardmaterial von Babylon.js kann nichts davon, also bekommt das Terrain eigene Shader, in GLSL, der Sprache von WebGL. Der Vertex-Shader läuft einmal pro Vertex und entscheidet, wo er auf dem Bildschirm landet. Lege src/terrain.vertex.glsl an:

src/terrain.vertex.glsl
precision highp float;

attribute vec3 position;
attribute vec3 normal;
attribute float depth;

uniform mat4 world;
uniform mat4 viewProjection;

varying vec3 vPosition;
varying vec3 vNormal;
varying float vDepth;

void main() {
  vec4 worldPosition = world * vec4(position, 1.0);
  vPosition = worldPosition.xyz;
  // Chunks are only moved, never turned or scaled, so the normal stays as it is.
  vNormal = normal;
  vDepth = depth;
  gl_Position = viewProjection * worldPosition;
}

world verschiebt die lokalen Positionen des Chunks an seine Ecke in der Welt, und viewProjection bringt sie auf den Bildschirm. Die Weltposition, die Normale und die Tiefe gehen als Varyings weiter zum Fragment-Shader: Werte, die die Grafikkarte zwischen den Vertices jedes Dreiecks mischt.

Der Fragment-Shader läuft einmal pro Pixel und entscheidet über seine Farbe. Lege src/terrain.fragment.glsl an:

src/terrain.fragment.glsl
precision highp float;
precision highp sampler2DArray;

varying vec3 vPosition;
varying vec3 vNormal;
varying float vDepth;

uniform sampler2DArray layers;
uniform vec3 cameraPosition;
uniform vec3 sunDirection;
uniform vec3 sunColor;
uniform vec3 skyColor;
uniform vec3 groundColor;
uniform vec3 fogColor;
uniform vec2 fogRange;

// The layers of the texture array.
const float GRASS = 0.0;
const float SOIL = 1.0;
const float ROCK = 2.0;
const float SNOW = 3.0;
// Meters one texture covers, and a second, much larger size against visible repeats.
const float TILE = 4.0;
const float LARGE_TILE = 27.0;

// A texture at both sizes, half and half.
vec3 sampleLayer(vec2 p, float layer) {
  return (texture(layers, vec3(p / TILE, layer)).rgb + texture(layers, vec3(p / LARGE_TILE, layer)).rgb) * 0.5;
}

// A layer seen from three sides: the texture projected along x, y and z, blended by how
// much the surface faces each way.
vec3 triplanar(float layer, vec3 blend) {
  return sampleLayer(vPosition.zy, layer) * blend.x
    + sampleLayer(vPosition.xz, layer) * blend.y
    + sampleLayer(vPosition.xy, layer) * blend.z;
}

void main() {
  vec3 normal = normalize(vNormal);
  // Raised to the 4th power, so that the blend between the sides stays narrow.
  vec3 blend = pow(abs(normal), vec3(4.0));
  blend /= blend.x + blend.y + blend.z;

  // Grass on gentle slopes at the surface, rock on steep ones and deeper down, soil
  // between them, and snow high up.
  float flat_ = normal.y;
  float grass = smoothstep(0.7, 0.85, flat_) * (1.0 - smoothstep(0.5, 2.0, vDepth));
  float rock = max(1.0 - smoothstep(0.45, 0.7, flat_), smoothstep(3.0, 6.0, vDepth));
  float snow = smoothstep(105.0, 120.0, vPosition.y) * smoothstep(0.55, 0.75, flat_);

  vec3 color = triplanar(SOIL, blend);
  if (grass > 0.0) color = mix(color, triplanar(GRASS, blend), grass);
  if (rock > 0.0) color = mix(color, triplanar(ROCK, blend), rock);
  if (snow > 0.0) color = mix(color, triplanar(SNOW, blend), snow);

  // Light from the sun, and from the sky: more from above than from below.
  float sun = max(dot(normal, -sunDirection), 0.0);
  vec3 ambient = mix(groundColor, skyColor, normal.y * 0.5 + 0.5);
  vec3 lit = color * (ambient + sunColor * sun);

  float fog = smoothstep(fogRange.x, fogRange.y, distance(vPosition, cameraPosition));
  gl_FragColor = vec4(mix(lit, fogColor, fog), 1.0);
}
  • triplanar() liest eine Schicht dreimal, einmal pro Projektion, und mischt die drei nach blend.

  • sampleLayer() nimmt jede Textur in zwei Größen, 4 m und 27 m, und mittelt sie. Eine Textur, die sich alle 4 m wiederholt, ergibt ein regelmäßiges Muster, das dem Auge von Weitem auffällt; die große Kopie, in einer Größe, die nicht glatt in 4 aufgeht, bricht es auf.

  • Die Anteile von Gras, Fels und Schnee sind je ein smoothstep zwischen zwei Werten, sodass die Materialien ineinander übergehen, statt an einer Linie zu wechseln. Die ifs sparen die Lesezugriffe einer Schicht, die nicht da ist.

  • Das Licht ist dasselbe wie beim Standardmaterial: die Sonne, danach, wie sehr die Oberfläche zu ihr zeigt, und der Himmel, mehr von oben als von unten. Der Nebel verblasst nach dem Abstand zur Kamera in der Farbe des Himmels, denn ein ShaderMaterial bekommt den Nebel der Szene nicht.

Das Material

Lege src/material.ts an:

src/material.ts
import {
  Color3,
  Constants,
  RawTexture2DArray,
  ShaderMaterial,
  Texture,
  Vector2,
  Vector3,
  type Scene,
} from '@babylonjs/core/pure';
import fragmentSource from './terrain.fragment.glsl?raw';
import vertexSource from './terrain.vertex.glsl?raw';
import { LAYERS, paintLayers, TEXTURE_SIZE } from './textures.ts';

/** The sky's color, a hazy lilac, which the fog fades to. */
export const SKY = Color3.FromHexString('#b8a4e0');

/** The terrain's material: painted textures, projected from three sides, and lit. */
export function createTerrainMaterial(scene: Scene, fogStart: number, fogEnd: number): ShaderMaterial {
  const material = new ShaderMaterial(
    'terrain',
    scene,
    { vertexSource, fragmentSource },
    {
      attributes: ['position', 'normal', 'depth'],
      uniforms: [
        'world', 'viewProjection', 'cameraPosition', 'sunDirection',
        'sunColor', 'skyColor', 'groundColor', 'fogColor', 'fogRange',
      ],
      samplers: ['layers'],
    },
  );

  const textures = new RawTexture2DArray(
    paintLayers(),
    TEXTURE_SIZE,
    TEXTURE_SIZE,
    LAYERS.length,
    Constants.TEXTUREFORMAT_RGBA,
    scene,
    true,
    false,
    Texture.TRILINEAR_SAMPLINGMODE,
  );
  textures.wrapU = Texture.WRAP_ADDRESSMODE;
  textures.wrapV = Texture.WRAP_ADDRESSMODE;
  // Sharper textures on ground seen at a flat angle.
  textures.anisotropicFilteringLevel = 8;
  material.setTexture('layers', textures);

  // A warm, low sun, a cool sky and a dark violet below.
  material.setVector3('sunDirection', new Vector3(0.5, -0.65, -0.55).normalize());
  material.setColor3('sunColor', Color3.FromHexString('#ffd8a8').scale(1.1));
  material.setColor3('skyColor', Color3.FromHexString('#c4b5fd').scale(0.6));
  material.setColor3('groundColor', Color3.FromHexString('#3b1676').scale(0.6));
  material.setColor3('fogColor', SKY);
  material.setVector2('fogRange', new Vector2(fogStart, fogEnd));
  return material;
}
  • Das ?raw am Ende eines Imports ist der Weg von Vite, eine Datei als String zu laden. Die Shader bleiben in .glsl-Dateien, wo ein Editor sie hervorheben kann.

  • attributes, uniforms und samplers sagen Babylon.js, was die Shader nutzen. world, viewProjection und cameraPosition füllt Babylon.js selbst; den Rest setzt der Code hier.

  • Der Himmel ist jetzt ein dunstiges Flieder: Das alte Dunkelviolett passte zum Ton, aber nicht zu einer sonnigen Welt.

Ersetze src/main.ts. Die Lichter und das Standardmaterial gehen, das Terrainmaterial kommt:

src/main.ts
import {
  Engine,
  RegisterEnginesExtensionsEngineRawTexture,
  RegisterShaderMaterial,
  RegisterStandardEngineExtensions,
  Scene,
  UniversalCamera,
  Vector3,
} from '@babylonjs/core/pure';
import { CHUNK_SIZE } from './grid.ts';
import { createTerrainMaterial, SKY } from './material.ts';
import { MESHERS, otherMesher } from './meshers.ts';
import { surfaceHeight } from './terrain.ts';
import { ChunkWorkers } from './workers.ts';
import { VIEW_RADIUS, World } from './world.ts';
import './style.css';

RegisterStandardEngineExtensions();
// For the texture array of the terrain.
RegisterEnginesExtensionsEngineRawTexture();
RegisterShaderMaterial();

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 = SKY.toColor4();

// Fog hides the edge of the loaded chunks: it ends a little before the view radius.
const viewDistance = VIEW_RADIUS * CHUNK_SIZE;
const material = createTerrainMaterial(scene, viewDistance * 0.5, viewDistance * 0.95);

const world = new World(scene, material, new ChunkWorkers());

// A flying camera: WASD to move, Q and E for down and up, drag the mouse to look.
const start = new Vector3(0, surfaceHeight(0, 40) + 25, 40);
const camera = new UniversalCamera('camera', start, scene);
camera.setTarget(new Vector3(0, surfaceHeight(0, -200) + 20, -200));
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.maxZ = viewDistance * 1.2;
camera.attachControl();

window.addEventListener('keydown', (event) => {
  if (event.code === 'KeyL') material.wireframe = !material.wireframe;
  if (event.code === 'KeyM') world.setMesher(otherMesher(world.mesher));
  // Shift flies faster.
  if (event.key === 'Shift') camera.speed = 4;
});
window.addEventListener('keyup', (event) => {
  if (event.key === 'Shift') camera.speed = 1;
});

scene.onBeforeRenderObservable.add(() => {
  world.update(camera.position);
  const { loaded, waiting } = world.counts;
  const { x, y, z } = camera.position;
  hud.textContent =
    `${MESHERS[world.mesher].label}: ${loaded} chunks, ${waiting} waiting\n` +
    `x ${x.toFixed(0)}, y ${y.toFixed(0)}, z ${z.toFixed(0)}\n` +
    `${engine.getFps().toFixed(0)} fps`;
});

engine.runRenderLoop(() => scene.render());
window.addEventListener('resize', () => engine.resize());

Ausprobieren

Gras in den Ebenen, Erde an den Hängen, Fels an den Klippen und Bergflanken, ein Hauch Schnee auf den höchsten Graten. Flieg nah an eine Klippe und dann weit weg: Aus der Nähe zeigen sich die Risse im Fels, und von Weitem hält die große Kopie der Texturen den Boden davon ab, wie ein gefliester Fußboden auszusehen. Die Höhleneingänge sind innen Fels, weil sie tief unter dem Boden liegen.

Das Projekt am Ende von Teil 8: Gras, Erde, Fels und Schnee, von drei Seiten projiziert. WASD und Q/E fliegen, Shift schneller, M schaltet den Mesher um.
In neuem Tab öffnen

Im nächsten Teil bekommst du ein Werkzeug: zielen, ein Loch graben und den Boden wieder aufbauen.

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.