Glattes Voxel-Terrain in Babylon.js · Teil 12 von 12 der Serie
Teil 12: Fernsicht bis zum Horizont
Zeig den Boden jenseits der Chunks bis 4,5 km weit, als billiges Höhenfeld aus Ringen um die Kamera, die mit der Entfernung breiter werden, gebaut in einem eigenen Worker und mit dem Voxel-Terrain über eine Überlappung verbunden, die der Shader versteckt.

Quest-Ziele
- Wissen, warum ferner Boden keine Voxel braucht
- Ein Höhenfeld aus Ringen bauen, dessen Detail mit der Entfernung abnimmt
- Vierecke entlang der kürzeren Diagonale teilen, damit Grate Grate bleiben
- Den fernen Boden dort weglassen, wo die Chunks sind, und die Naht verstecken
- Einen Shader für beides nutzen, mit einem Define
Alles seit Teil 7 endet 250 m entfernt im Nebel. Von einem Hügel aus gesehen ist die Welt eine fliederfarbene Wand.
Chunks bis zum Horizont zu laden, ist keine Lösung: Ihre Zahl wächst mit dem Quadrat der Entfernung, und 4 km wären Zehntausende. Aber in der Ferne haben Voxel nichts zu zeigen. Eine Höhle oder ein Überhang 2 km entfernt sind ein paar Pixel, wenn überhaupt. Was den Horizont formt, ist die Höhe des Bodens, und das ist surfaceHeight(), die billige 2D-Funktion aus Teil 4. Ein Höhenfeld daraus, ein Gitter aus Dreiecken mit einer Höhe pro Punkt, kann für den Preis von ein paar Chunks bis zum Horizont reichen.
Ringe
Das Detail, das das Auge sehen kann, nimmt mit der Entfernung ab: Eine 10 m hohe Beule 3 km entfernt ist weniger als ein Pixel. Deshalb ist die Fernsicht kein quadratisches Gitter, sondern eine Scheibe aus Ringen um die Kamera: 128 Ringe von 150 m bis 4,5 km, jeder etwas breiter als der davor, mit 256 Punkten auf jedem. Nahe der Kamera liegen die Punkte ein paar Meter auseinander, am Horizont etwa hundert.
Weil jeder Ring dieselbe Zahl von Punkten hat, schließen die Ringe ohne Lücken aneinander, und die ganze Fernsicht ist ein Mesh, ohne Nähte zwischen Detailstufen. Die Breite jedes Rings ist die des vorigen mal denselben Faktor, also behalten die Dreiecke ihre Form von nah bis fern.
Der Worker
128 × 256 Höhen dauern einen Moment, also wird die Fernsicht in einem eigenen Worker gebaut, neben den Chunk-Workern. Lege src/farWorker.ts an:
import type { MeshData } from './meshData.ts';
import { surfaceHeight } from './terrain.ts';
/** Rings from INNER to OUTER meters around the center, each further one wider. */
export const INNER = 150;
export const OUTER = 4500;
const RINGS = 128;
const SEGMENTS = 256;
/** The far ground lies this much lower, so that the chunks win where both are drawn. */
const SINK = 1;
export interface FarJob {
x: number;
z: number;
}
/** A heightfield of rings around (x, z), positions from that point. */
self.onmessage = (event: MessageEvent<FarJob>) => {
const { x, z } = event.data;
const growth = (OUTER / INNER) ** (1 / (RINGS - 1));
const positions = new Float32Array(RINGS * SEGMENTS * 3);
for (let ring = 0; ring < RINGS; ring += 1) {
const radius = INNER * growth ** ring;
for (let segment = 0; segment < SEGMENTS; segment += 1) {
const angle = (segment / SEGMENTS) * Math.PI * 2;
const px = Math.cos(angle) * radius;
const pz = Math.sin(angle) * radius;
positions.set([px, surfaceHeight(x + px, z + pz) - SINK, pz], (ring * SEGMENTS + segment) * 3);
}
}
// Normals from the neighbors: across the ring outwards, and along it.
const at = (ring: number, segment: number, axis: number) =>
positions[(Math.min(Math.max(ring, 0), RINGS - 1) * SEGMENTS + ((segment + SEGMENTS) % SEGMENTS)) * 3 + axis];
const normals = new Float32Array(positions.length);
for (let ring = 0; ring < RINGS; ring += 1) {
for (let segment = 0; segment < SEGMENTS; segment += 1) {
const out = [0, 1, 2].map((axis) => at(ring + 1, segment, axis) - at(ring - 1, segment, axis));
const along = [0, 1, 2].map((axis) => at(ring, segment + 1, axis) - at(ring, segment - 1, axis));
// The cross product of the two, turned to point up.
let nx = out[1] * along[2] - out[2] * along[1];
let ny = out[2] * along[0] - out[0] * along[2];
let nz = out[0] * along[1] - out[1] * along[0];
if (ny < 0) [nx, ny, nz] = [-nx, -ny, -nz];
const length = Math.hypot(nx, ny, nz);
normals.set([nx / length, ny / length, nz / length], (ring * SEGMENTS + segment) * 3);
}
}
// Two triangles between each ring and the next, segment by segment, split along the
// shorter diagonal as in the surface nets: the triangles then follow the ridges.
const distance = (a: number, b: number) =>
Math.hypot(
positions[a * 3] - positions[b * 3],
positions[a * 3 + 1] - positions[b * 3 + 1],
positions[a * 3 + 2] - positions[b * 3 + 2],
);
const indices = new Uint32Array((RINGS - 1) * SEGMENTS * 6);
let i = 0;
for (let ring = 0; ring < RINGS - 1; ring += 1) {
for (let segment = 0; segment < SEGMENTS; segment += 1) {
const a = ring * SEGMENTS + segment;
const b = ring * SEGMENTS + ((segment + 1) % SEGMENTS);
const c = a + SEGMENTS;
const d = b + SEGMENTS;
if (distance(a, d) <= distance(b, c)) indices.set([a, d, b, a, c, d], i);
else indices.set([a, c, b, b, c, d], i);
i += 6;
}
}
const mesh: MeshData = { positions, normals, indices };
self.postMessage(mesh, { transfer: [positions.buffer, normals.buffer, indices.buffer] });
};growthist der Faktor von einem Ring zum nächsten: 128 Ringe von 150 m bis 4,5 km wachsen um das 30-Fache, also ist jeder Ring etwa 2,7 % breiter als der letzte.Die Normalen kommen von den Nachbarn: das Kreuzprodukt des Wegs zum nächsten Ring und des Wegs entlang des Rings, nach oben gedreht. Weitere Höhen braucht es nicht.
Die Vierecke zwischen den Ringen werden entlang der kürzeren Diagonale geteilt, wie bei den Surface Nets aus Teil 3. Auf einem Grat, der schräg über die Ringe läuft, schnitte eine feste Diagonale den Kamm zum Sägeblatt; die kürzere folgt ihm.
Der ferne Boden liegt einen Meter tiefer, als die Höhe sagt. Wo er und die Chunks sich überlappen, gewinnen dann die Chunks: Sonst stäche die Fernsicht in Mulden durch den Boden.
Mitziehen
Die Ringe liegen um einen Punkt nahe der Kamera, aber nicht genau auf ihr: Ihre Mitte rastet auf einem Gitter von 64 m ein, und die Fernsicht wird neu gebaut, wenn die Kamera eine neue Stufe erreicht. Sie in jedem Frame mit der Kamera zu verschieben, ließe den fernen Boden schwimmen, weil seine Punkte über die Hügel glitten. Lege src/farView.ts an:
import type { Material, Mesh, Scene, Vector3 } from '@babylonjs/core/pure';
import type { FarJob } from './farWorker.ts';
import { createMesh, type MeshData } from './meshData.ts';
/** The far view moves along in steps of this many meters, and is built again each time. */
const STEP = 64;
/** The ground beyond the chunks, to the horizon: a heightfield of the generated heights. */
export class FarView {
private mesh: Mesh | null = null;
private center: FarJob | null = null;
private building: FarJob | null = null;
private readonly worker = new Worker(new URL('./farWorker.ts', import.meta.url), { type: 'module' });
private readonly scene: Scene;
private readonly material: Material;
constructor(scene: Scene, material: Material) {
this.scene = scene;
this.material = material;
this.worker.onmessage = (event: MessageEvent<MeshData>) => this.show(event.data);
}
get vertices(): number {
return this.mesh?.getTotalVertices() ?? 0;
}
/** Once a frame: builds the far view again when the camera has moved a step. */
update(focus: Vector3): void {
const x = Math.round(focus.x / STEP) * STEP;
const z = Math.round(focus.z / STEP) * STEP;
if (this.building || (this.center?.x === x && this.center.z === z)) return;
this.building = { x, z };
this.worker.postMessage(this.building);
}
private show(data: MeshData): void {
this.mesh?.dispose();
const mesh = createMesh('far view', data, this.scene);
// The far ground is all surface, under the open sky.
const count = data.positions.length / 3;
mesh.setVerticesData('depth', new Float32Array(count), false, 1);
mesh.setVerticesData('light', new Float32Array(count).fill(1), false, 1);
mesh.position.set(this.building!.x, 0, this.building!.z);
mesh.material = this.material;
mesh.isPickable = false;
mesh.freezeWorldMatrix();
this.mesh = mesh;
this.center = this.building;
this.building = null;
}
}Ein Bau zur Zeit: Während der Worker baut, wartet eine neue Stufe, und das nächste update() nach dem Ergebnis beginnt den Bau dort, wo die Kamera dann ist. Die Fernsicht nutzt das Terrainmaterial, also braucht sie eine Tiefe, überall 0, und ein Licht, überall 1: Sie ist ganz Oberfläche, ganz unter offenem Himmel.
Die Naht
Nahe der Kamera zeigen die Chunks den Boden, mit ihren Höhlen und Edits. Die Fernsicht darf sie nicht verdecken, also lässt der Shader sie innerhalb von 200 m um die Kamera weg. Die Chunks reichen sicher in jede Richtung etwas weiter, also überlappen sich beide in einem Streifen, und es gibt in keinem Winkel einen Spalt. In der Überlappung gewinnen dank des versenkten Meters die Chunks, und dahinter übernimmt die Fernsicht in einer Entfernung, in der der Unterschied nicht zu sehen ist.
Die Fernsicht nutzt denselben Shader wie die Chunks, mit einem Define: FAR schaltet am Anfang des Fragment-Shaders ein paar Zeilen ein. Ergänze in src/terrain.fragment.glsl nach den anderen ein Uniform und am Anfang von main() den Test:
#ifdef FAR
uniform float nearRadius;
#endif
void main() {
#ifdef FAR
// Near the camera, the chunks show the ground.
if (distance(vPosition.xz, cameraPosition.xz) < nearRadius) discard;
#endif
vec3 normal = normalize(vNormal);
// …discard verwirft das Pixel. Ein Define macht aus dem Shader ein eigenes Programm, also enthält das Material der Chunks den Test gar nicht.
Zwei Materialien, ein Satz Texturen
Die Fernsicht braucht ein eigenes Material, für das Define und den Radius, aber dieselben Texturen: Sie zweimal zu malen, verschwendete Zeit und Speicher. Ersetze 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 painted textures, as the layers of one texture array. */
export function createTextures(scene: Scene): RawTexture2DArray {
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;
return textures;
}
interface TerrainOptions {
fogStart: number;
fogEnd: number;
/** For the far view: the distance from the camera within which it isn't drawn. */
nearRadius?: number;
}
/** The terrain's material: painted textures, projected from three sides, and lit. */
export function createTerrainMaterial(
scene: Scene,
textures: RawTexture2DArray,
options: TerrainOptions,
): ShaderMaterial {
const far = options.nearRadius !== undefined;
const material = new ShaderMaterial(
far ? 'far terrain' : 'terrain',
scene,
{ vertexSource, fragmentSource },
{
attributes: ['position', 'normal', 'depth', 'light'],
uniforms: [
'world', 'viewProjection', 'cameraPosition', 'sunDirection', 'sunColor', 'skyColor',
'groundColor', 'caveColor', 'lampColor', 'fogColor', 'fogRange', 'nearRadius',
],
samplers: ['layers'],
defines: far ? ['#define FAR'] : [],
},
);
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));
// In caves: a faint violet glow, and a warm lamp.
material.setColor3('caveColor', Color3.FromHexString('#5b21b6').scale(0.3));
material.setColor3('lampColor', Color3.FromHexString('#fdba74').scale(1.2));
material.setColor3('fogColor', SKY);
material.setVector2('fogRange', new Vector2(options.fogStart, options.fogEnd));
if (far) material.setFloat('nearRadius', options.nearRadius!);
return material;
}createTextures() malt die Texturen einmal, und createTerrainMaterial() nimmt sie. Ein nearRadius macht das ferne Material: Es ergänzt das Define und setzt den Radius.
Nebel bis zum Horizont
Beide Materialien bekommen denselben Nebel, und er rückt hinaus: von 1,1 km bis zum Rand der Fernsicht. Der Rand der Chunks braucht keinen Nebel mehr, denn die Fernsicht geht dahinter weiter. Ersetze src/main.ts. Die Demo beginnt hoch in der Luft, damit du sie siehst:
import {
Engine,
RegisterEnginesExtensionsEngineRawTexture,
RegisterRay,
RegisterShaderMaterial,
RegisterStandardEngineExtensions,
RegisterStandardMaterial,
Scene,
UniversalCamera,
Vector3,
} from '@babylonjs/core/pure';
import { FarView } from './farView.ts';
import { OUTER } from './farWorker.ts';
import { Field } from './field.ts';
import { CHUNK_SIZE } from './grid.ts';
import { Input } from './input.ts';
import { createTerrainMaterial, createTextures, SKY } from './material.ts';
import { MESHERS, otherMesher } from './meshers.ts';
import { EYES, Player } from './player.ts';
import { surfaceHeight } from './terrain.ts';
import { DigTool } from './tool.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();
// For the tool: the marker's material, and the ray that finds the ground.
RegisterStandardMaterial();
RegisterRay();
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();
// The chunks reach VIEW_RADIUS chunks around the camera, the far view to the horizon.
// The fog is the same for both, and only hides the far view's edge.
const textures = createTextures(scene);
const fog = { fogStart: OUTER * 0.25, fogEnd: OUTER * 0.95 };
const material = createTerrainMaterial(scene, textures, fog);
// The far view leaves out the ground near the camera, which the chunks show. They are
// sure to cover a little less than VIEW_RADIUS chunks.
const nearRadius = (VIEW_RADIUS - 1.75) * CHUNK_SIZE;
const farView = new FarView(scene, createTerrainMaterial(scene, textures, { ...fog, nearRadius }));
const world = new World(scene, material, new ChunkWorkers());
// The camera only looks around: the player and the flying below move it.
const feet = new Vector3(0, surfaceHeight(0, 40) + 1, 40);
const camera = new UniversalCamera('camera', feet.add(new Vector3(0, EYES, 0)), scene);
camera.setTarget(new Vector3(0, surfaceHeight(0, -200) + 20, -200));
camera.inputs.removeByType('FreeCameraKeyboardMoveInput');
camera.minZ = 0.1;
camera.maxZ = OUTER * 1.1;
camera.attachControl();
const input = new Input();
const player = new Player(new Field(world.edits), feet);
// This part starts high in the air, with the far view in sight; F lands.
let mode: 'walk' | 'fly' = 'fly';
camera.position.set(0, 160, 120);
camera.setTarget(new Vector3(0, 110, -100));
/** Flies the camera: WASD along the view, Q and E down and up, Shift faster. */
function fly(dt: number): void {
const speed = input.pressed('ShiftLeft') ? 60 : 15;
const forward = camera.getDirection(Vector3.Forward()).scale(input.axis('KeyS', 'KeyW'));
const right = camera.getDirection(Vector3.Right()).scale(input.axis('KeyA', 'KeyD'));
camera.position.addInPlace(forward.add(right).scale(speed * dt));
camera.position.y += input.axis('KeyQ', 'KeyE') * speed * dt;
}
const tool = new DigTool(scene, camera, world);
// The mouse wheel sizes the tool instead of moving the camera.
camera.inputs.removeByType('FreeCameraMouseWheelInput');
// A click captures the mouse, so that it turns the camera without a button held down.
// While it is captured, the left button digs and the right one builds.
canvas.addEventListener('pointerdown', (event) => {
if (document.pointerLockElement !== canvas) {
canvas.requestPointerLock();
return;
}
if (event.button === 0) tool.start('dig');
if (event.button === 2) tool.start('build');
});
window.addEventListener('pointerup', () => tool.stop());
canvas.addEventListener('contextmenu', (event) => event.preventDefault());
canvas.addEventListener('wheel', (event) => tool.resize(-Math.sign(event.deltaY)), { passive: true });
window.addEventListener('keydown', (event) => {
if (event.code === 'KeyL') material.wireframe = !material.wireframe;
if (event.code === 'KeyM') world.setMesher(otherMesher(world.mesher));
if (event.code === 'KeyF') {
mode = mode === 'walk' ? 'fly' : 'walk';
// Landing where the camera is: the feet go below the eyes.
if (mode === 'walk') {
player.position.copyFrom(camera.position).y -= EYES;
player.velocity.setAll(0);
}
}
});
scene.onBeforeRenderObservable.add(() => {
// After a stall, such as a tab in the background, one huge step would be no good.
const dt = Math.min(engine.getDeltaTime() / 1000, 0.05);
if (mode === 'walk') {
player.update(dt, input, camera.rotation.y);
camera.position.copyFrom(player.position).y += EYES;
} else {
fly(dt);
}
world.update(camera.position);
farView.update(camera.position);
tool.update(performance.now());
const { loaded, waiting } = world.counts;
const { x, y, z } = camera.position;
hud.textContent =
`${MESHERS[world.mesher].label}: ${loaded} chunks, ${waiting} waiting\n` +
`${mode === 'walk' ? (player.grounded ? 'Walking' : 'In the air') : 'Flying'}\n` +
`x ${x.toFixed(0)}, y ${y.toFixed(0)}, z ${z.toFixed(0)}\n` +
`Tool ${tool.radius.toFixed(1)} m, far view ${farView.vertices} vertices\n` +
`${engine.getFps().toFixed(0)} fps`;
});
engine.runRenderLoop(() => scene.render());
window.addEventListener('resize', () => engine.resize());Ausprobieren
Hügel und Bergketten laufen bis zum Horizont und verblassen im Dunst, mit Schnee auf den höchsten Graten. Flieg auf die Berge zu: Wenn du näher kommst, übernehmen die Chunks von der Fernsicht, und der Boden bekommt seine Überhänge und Höhlen. Drück F, um zu landen und zu gehen: Der Horizont bleibt. Die Fernsicht hat etwa 33.000 Vertices, etwa so viele wie acht Chunks zerklüfteter Boden.
Wie es weitergehen kann
Das war die Serie: ein Dichtefeld und zwei Wege, es zu vernetzen, endloses Terrain mit Überhängen und Höhlen, Chunks, in Workern gebaut und rund um dich gestreamt, Texturen ohne Texturkoordinaten, Graben und Bauen, Gehen, Höhlenlicht und eine Fernsicht. Ein paar Ideen, wie es weitergehen kann:
Speichern: Behalte die Edits in IndexedDB, wie in Teil 9 beschrieben, damit die Löcher morgen noch da sind.
Gröbere Chunks in der Ferne: Chunks aus 2-m-Voxeln zwischen den nahen Chunks und der Fernsicht hielten Höhlen und Überhänge weiter hinaus sichtbar. Der Haken ist die Naht zwischen Chunks verschiedener Größe, und genau die löst der Transvoxel-Algorithmus.
Tempo: Abtasten und Vernetzen sind reine Rechnerei auf Arrays. In Rust oder C geschrieben und nach WebAssembly übersetzt, kann derselbe Code auf der Seite und in den Workern laufen, oft schneller.
Eine Welt zum Leben: Wasser in den Senken, Pflanzen, die auf der Grasschicht wachsen, und Dinge, die fallen, wenn du den Boden unter ihnen weggräbst.
Der Code jedes Teils steckt in den Demos, die hier in der Serie direkt spielbar sind. Viel Spaß beim Graben.
