Glattes Voxel-Terrain in Babylon.js · Teil 7 von 12 der Serie
Teil 7: Eine endlose Welt streamen
Lade die Chunks in einem Umkreis um die Kamera, die nächsten zuerst, lass die zurückgelassenen fallen, überspring den Himmel über dem höchsten Boden und verteil die Arbeit, Meshes anzuzeigen, über die Frames.

Quest-Ziele
- Die Chunks rund um die Kamera geladen halten, wohin sie auch geht
- Die nächsten Chunks zuerst bauen und einen neuen beginnen, sobald ein Worker frei ist
- Wissen, wie hoch der Boden sein kann, ohne ihn abzutasten, und die Luft darüber nie bauen
- Einen Chunk neu bauen, während er sein altes Mesh weiter zeigt
- Meshes mit einem Zeitbudget pro Frame an die Grafikkarte geben
Das Gebiet aus Teil 6 war 256 m breit, und dann endete die Welt. Die Terrainfunktion geht ewig weiter; nur die Chunks hören auf. Dieser Teil lädt Chunks dort, wo die Kamera ist: Jeder Chunk innerhalb von 8 Chunks, etwa 256 m, wird gebaut, die nächsten zuerst, und Chunks, die die Kamera zurücklässt, fallen weg. Die Welt bekommt keinen Rand, und der Speicher bleibt gleich, wohin du auch fliegst.
Die Welt
Eine Klasse behält die Chunks im Blick: welche geladen sind, welche gerade gebaut werden, welche ein Mesh zum Anzeigen haben und welche weg können. Lege src/world.ts an:
import type { Material, Mesh, Scene, Vector3 } from '@babylonjs/core/pure';
import { chunkKey, type ChunkPosition } from './chunks.ts';
import { CHUNK_SIZE } from './grid.ts';
import { createMesh, type MeshData } from './meshData.ts';
import type { MesherName } from './meshers.ts';
import { highestGround } from './terrain.ts';
import type { ChunkWorkers } from './workers.ts';
/** How far around the camera chunks are loaded, in chunks. */
export const VIEW_RADIUS = 8;
/** The lowest layer of chunks: below it the world is solid. */
const LOWEST_LAYER = -2;
/** Milliseconds per frame for handing new meshes to the graphics card. */
const SHOW_BUDGET = 4;
interface Chunk {
position: ChunkPosition;
mesh: Mesh | null;
/** Counts up whenever the chunk has to be built again. */
version: number;
/** The version the chunk's mesh shows; -1 before it has one. */
shown: number;
/** The version a worker builds, or that waits to be shown; -1 when there is none. */
pending: number;
}
interface Finished {
chunk: Chunk;
version: number;
data: MeshData | null;
}
/** The chunks around the camera: which to load, build, show and drop. */
export class World {
mesher: MesherName = 'marching-cubes';
private readonly chunks = new Map<string, Chunk>();
/** The highest layer of chunks that can hold ground, per column. */
private readonly tops = new Map<string, number>();
private readonly finished: Finished[] = [];
private focus = { x: 0, y: 0, z: 0 };
private readonly scene: Scene;
private readonly material: Material;
private readonly workers: ChunkWorkers;
constructor(scene: Scene, material: Material, workers: ChunkWorkers) {
this.scene = scene;
this.material = material;
this.workers = workers;
}
/** Switches the mesher. Every chunk is built again and keeps its mesh until then. */
setMesher(mesher: MesherName): void {
this.mesher = mesher;
for (const chunk of this.chunks.values()) chunk.version += 1;
}
/** Once a frame: follows the camera at `focus`. */
update(focus: Vector3): void {
this.focus = { x: focus.x, y: focus.y, z: focus.z };
this.dropFar();
this.startJobs();
this.showFinished();
}
/** How many chunks are loaded, and how many of them wait for a mesh. */
get counts(): { loaded: number; waiting: number } {
let waiting = 0;
for (const chunk of this.chunks.values()) if (chunk.shown !== chunk.version) waiting += 1;
return { loaded: this.chunks.size, waiting };
}
/** The column of chunks the camera is in. */
private get center(): { x: number; z: number } {
return { x: Math.floor(this.focus.x / CHUNK_SIZE), z: Math.floor(this.focus.z / CHUNK_SIZE) };
}
private dropFar(): void {
const center = this.center;
// A little beyond the view radius, so that chunks at the edge are not dropped and
// loaded again while the camera moves back and forth.
const limit = (VIEW_RADIUS + 1.5) ** 2;
for (const [key, chunk] of this.chunks) {
if ((chunk.position.x - center.x) ** 2 + (chunk.position.z - center.z) ** 2 <= limit) continue;
chunk.mesh?.dispose();
this.chunks.delete(key);
}
for (const key of this.tops.keys()) {
const [x, z] = key.split(',').map(Number);
if ((x - center.x) ** 2 + (z - center.z) ** 2 > limit) this.tops.delete(key);
}
}
/** Gives every free worker the nearest chunk that needs building. */
private startJobs(): void {
if (this.workers.free === 0) return;
const center = this.center;
const wanted: { position: ChunkPosition; distance: number }[] = [];
for (let dz = -VIEW_RADIUS; dz <= VIEW_RADIUS; dz += 1) {
for (let dx = -VIEW_RADIUS; dx <= VIEW_RADIUS; dx += 1) {
if (dx * dx + dz * dz > VIEW_RADIUS * VIEW_RADIUS) continue;
const x = center.x + dx;
const z = center.z + dz;
for (let y = LOWEST_LAYER; y <= this.top(x, z); y += 1) {
const chunk = this.chunks.get(chunkKey({ x, y, z }));
if (chunk && (chunk.pending >= 0 || chunk.shown === chunk.version)) continue;
const distance = Math.hypot(
(x + 0.5) * CHUNK_SIZE - this.focus.x,
(y + 0.5) * CHUNK_SIZE - this.focus.y,
(z + 0.5) * CHUNK_SIZE - this.focus.z,
);
wanted.push({ position: { x, y, z }, distance });
}
}
}
wanted.sort((a, b) => a.distance - b.distance);
for (const { position } of wanted.slice(0, this.workers.free)) {
const key = chunkKey(position);
let chunk = this.chunks.get(key);
if (!chunk) {
chunk = { position, mesh: null, version: 0, shown: -1, pending: -1 };
this.chunks.set(key, chunk);
}
const job = chunk;
const version = chunk.version;
job.pending = version;
this.workers.run({ chunk: position, mesher: this.mesher }).then((result) => {
this.finished.push({ chunk: job, version, data: result.mesh });
// The worker is free again: no need to wait for the next frame.
this.startJobs();
});
}
}
/** The highest layer of chunks in a column that can hold ground. */
private top(x: number, z: number): number {
const key = `${x},${z}`;
let top = this.tops.get(key);
if (top === undefined) {
top = Math.floor(highestGround(x * CHUNK_SIZE, z * CHUNK_SIZE, CHUNK_SIZE) / CHUNK_SIZE);
this.tops.set(key, top);
}
return top;
}
/** Shows the meshes the workers made, as many as fit into the frame's budget. */
private showFinished(): void {
const started = performance.now();
while (this.finished.length > 0 && performance.now() - started < SHOW_BUDGET) {
const { chunk, version, data } = this.finished.shift()!;
chunk.pending = -1;
// Dropped while it was being built: the chunk is gone.
if (this.chunks.get(chunkKey(chunk.position)) !== chunk) continue;
chunk.mesh?.dispose();
chunk.mesh = data ? this.createChunkMesh(chunk.position, data) : null;
chunk.shown = version;
}
}
private createChunkMesh(position: ChunkPosition, data: MeshData): Mesh {
const mesh = createMesh(`chunk ${chunkKey(position)}`, data, this.scene);
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;
}
}In jedem Frame macht update() drei Schritte: Chunks, die zu weit weg sind, fallen lassen, den Bau der fehlenden beginnen und die fertigen Meshes anzeigen.
Versionen
Ein Chunk hat drei Zahlen, die zusammen sagen, was er braucht:
versionzählt hoch, wann immer der Chunk neu gebaut werden muss. Ein Wechsel des Meshers zählt jeden Chunk hoch; das Graben aus Teil 9 wird die Chunks hochzählen, die es ändert.shownist die Version, die sein Mesh zeigt. Ist sie gleichversion, ist der Chunk aktuell.pendingist die Version, die ein Worker baut oder die gebaut ist und darauf wartet, angezeigt zu werden.
Gebaut wird ein Chunk, wenn er veraltet ist und für ihn nichts aussteht. Bis das neue Mesh kommt, bleibt das alte: Ein Wechsel des Meshers reißt nie ein Loch. Und pending hält, bis das Mesh angezeigt ist, nicht nur, bis der Worker fertig ist. Sonst sähe ein Chunk, dessen Mesh in der Warteschlange liegt, veraltet aus, ginge an den nächsten freien Worker und würde wieder und wieder gebaut.
Die nächsten zuerst
startJobs() listet jeden Chunk, der gebaut werden will: die Säulen aus Chunks im Sichtradius um die Säule der Kamera und in jeder die Schichten von 64 m unter 0 bis zur höchsten, die Boden enthalten kann. Es sortiert sie nach dem Abstand zur Kamera, und die freien Worker nehmen die nächsten. Boden in deiner Nähe erscheint zuerst, und wenn du weiterfliegst, wird die Liste neu gemacht, und die Chunks voraus kommen zuerst.
Ist ein Worker fertig, läuft startJobs() sofort wieder, statt auf den nächsten Frame zu warten. Ein Frame dauert 16 ms, und viele Chunks brauchen weniger, also würde ein Worker sonst die halbe Zeit warten.
Die Luft über dem Boden
Wie hoch reicht eine Säule aus Chunks? Hoch genug für den höchsten Berg, wäre sie meist Luft, denn der meiste Boden liegt viel tiefer, und jeder Chunk über dem Boden ist Luft. Die zu bauen kostete einen Worker ein volles Abtasten des Chunks, nur um nichts zu finden. Das Terrain kann sagen, wie hoch sein Boden reichen kann, ohne einen ganzen Chunk abzutasten: Die Höhenfunktion ist 2D und billig. Ergänze src/terrain.ts:
/**
* A height that no ground in the square from (x, z) to (x + size, z + size) rises above:
* the highest of 5 × 5 heights in it, plus the overhangs and some room for a peak
* between the samples.
*/
export function highestGround(x: number, z: number, size: number): number {
let highest = -Infinity;
for (let i = 0; i <= 4; i += 1) {
for (let j = 0; j <= 4; j += 1) {
highest = Math.max(highest, surfaceHeight(x + (i * size) / 4, z + (j * size) / 4));
}
}
return highest + MOST_BEND + 16;
}Die Höhen an 25 Punkten im Quadrat des Chunks, die höchste davon, plus das Meiste, um das die Überhänge hinaufdrücken, plus Platz für einen Gipfel, der zwischen den Punkten liegt. Die Welt rechnet das in top() einmal pro Säule aus und behält es. Die 16 m Platz sind mit Absicht großzügig: Ein übersehenes Stück Boden wäre ein Loch in einem Berggipfel. Über 1.640 Säulen, Meter für Meter abgetastet, ragte kein Boden darüber.
Tiefe Chunks werden gebaut: Sie können Höhlen enthalten. Die Welt hört 64 m unter 0 auf, was das Terrain mit seiner Oberfläche nie erreicht.
Chunks fallen lassen
dropFar() lässt Chunks fallen, die weiter als 9,5 Chunks entfernt sind, etwas mehr als die 8, in denen sie geladen werden. Wären beide gleich, würde ein Chunk genau am Rand jedes Mal fallen gelassen und wieder geladen, wenn die Kamera vor und zurück über eine Chunkgrenze fliegt.
Ein Budget fürs Anzeigen
Die Worker haben die Arrays gemacht, aber daraus ein Mesh von Babylon.js zu machen, passiert auf der Seite: Die Arrays gehen als Puffer an die Grafikkarte. Das dauert pro Chunk einen Moment, und wenn ein Dutzend im selben Frame ankommt, ist der Frame zu spät, und die Kamera ruckelt. showFinished() zeigt höchstens 4 ms pro Frame lang Meshes an und lässt den Rest für den nächsten.
Nebel
Die geladenen Chunks enden in einem ausgefransten Kreis 256 m entfernt. Nebel verbirgt ihn: Ab der halben Strecke verblasst das Terrain in der Farbe des Himmels, und kurz vor dem Rand ist es verschwunden. Ersetze src/main.ts:
import {
Color3,
Color4,
DirectionalLight,
Engine,
HemisphericLight,
RegisterStandardEngineExtensions,
RegisterStandardMaterial,
Scene,
StandardMaterial,
UniversalCamera,
Vector3,
} from '@babylonjs/core/pure';
import { CHUNK_SIZE } from './grid.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();
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');
// Fog hides the edge of the loaded chunks: it ends a little before the view radius.
const viewDistance = VIEW_RADIUS * CHUNK_SIZE;
scene.fogMode = Scene.FOGMODE_LINEAR;
scene.fogColor = Color3.FromHexString('#2a1150');
scene.fogStart = viewDistance * 0.5;
scene.fogEnd = viewDistance * 0.95;
// 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();
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());Der Szenennebel von Babylon.js ist hier linear, von
fogStartbisfogEnd, und das Standardmaterial wendet ihn an. Seine Farbe ist die Hintergrundfarbe, damit das Terrain im Himmel verblasst.Die Kamera beginnt etwas über dem Boden und schaut nach Norden, auf die Hügel und die Berge dahinter. Shift macht sie viermal so schnell.
Das HUD zeigt die geladenen Chunks, wie viele auf ein Mesh warten und wo die Kamera ist.
In index.html bekommt die Hilfe Shift faster und verliert C chunks, das dieser Teil weglässt.
Ausprobieren
Der Boden nahe der Kamera erscheint zuerst, dann füllt sich der Rest drumherum. Halt W und Shift und flieg in eine beliebige Richtung: Die Chunks vor dir erscheinen, bevor du dort bist, und die Zahl der wartenden im HUD steigt und fällt. Dreh dich nach einer Weile um: Der Boden hinter dir ist weggefallen und wird neu gebaut. Die Zahl der geladenen Chunks bleibt etwa gleich, wie weit du auch fliegst.
Das Terrain hat noch die Farbe von Ton. Im nächsten Teil bekommt es Gras, Erde, Fels und Schnee, im Code gemalt und von drei Seiten projiziert.