Zum Inhalt springen
DasPeTe Tutorials

Bauen auf Voxel-Terrain in Babylon.js · Teil 1 von 10 der Serie

Teil 1: Blöcke auf dem Terrain

Setz Blöcke auf das glatte Voxel-Terrain: ein Gitter aus halbmetergroßen Zellen, Blöcke in drei Größen, die ganze Zellen füllen, ein Mesh pro Region ohne die verdeckten Seiten, und ein Werkzeug, das Blöcke dort setzt und abbaut, wo du hinschaust.

Quest-Ziele

  • Wissen, was diese Serie dem glatten Voxel-Terrain hinzufügt
  • Blöcke in einem Gitter aus Zellen halten, nachgeschlagen über eine Zahl pro Zelle
  • Die Blöcke einer Region vernetzen und die Seiten weglassen, die niemand sieht
  • Die Blöcke mit derselben Sonne, demselben Himmel und Nebel beleuchten wie das Terrain
  • Mit einem Strahl zielen, zeigen, wohin ein Block kommt, und Blöcke setzen und abbauen

In der Serie über glattes Voxel-Terrain hast du eine Welt aus sanften Hügeln, Überhängen und Höhlen gebaut, in die du dich graben und die du aufschütten kannst. Ihr Boden ist mit Absicht glatt: ein Dichtefeld, vernetzt mit Marching Cubes oder Surface Nets. Häuser, Mauern und Türme sind etwas anderes. Sie wollen gerade Kanten, ebene Wände und rechte Winkel: Blöcke, wie in einem Block-Baukasten-Spiel.

Diese Serie setzt Blöcke auf dieses Terrain und geht dann weiter als das übliche Blockspiel. Die Blöcke bekommen gemalte Materialien und drei Größen. Ein Meißel rundet oder schrägt ihre Kanten, sodass ein Block zur Säule, zur Kuppel, zur Rampe oder zum Dach werden kann. Und Blöcke brauchen etwas, das sie hält: Wenn du den letzten Block abbaust, der einen Teil eines Gebäudes mit dem Boden verbindet, kommt dieser Teil herunter, stürzt als ein starrer Körper, landet als Schutt, zerschlägt, was er trifft, und wirbelt Staub auf.

Die Serie

  1. Blöcke auf dem Terrain (dieser Teil)

  2. Bemalte Blöcke und ihr Licht

  3. Auf Blöcken gehen

  4. Ein Meißel für runde Kanten

  5. Schrägen und Dächer

  6. Was einen Block hält

  7. Fallende Stücke

  8. Schutt

  9. Kettenreaktionen

  10. Staub und Klang

Wie in der ersten Serie endet jeder Teil mit einer Demo, die du auf der Seite spielen kannst, gebaut aus genau dem Code, den er zeigt, mit Babylon.js 9.33, Vite 8.3 und TypeScript 7.

Was du brauchst

Diese Serie beginnt, wo die andere endet: mit dem Projekt, wie es am Ende ihres Teils 12 ist. Alles darin bleibt: die Chunks, die Worker, die Texturen, das Höhlenlicht, die Spielfigur und die Fernsicht. Auch das Terrain-Werkzeug bleibt, damit du weiter graben kannst, jetzt unter den Blöcken. Falls du es nicht gebaut hast: Seine Teile zeigen seinen ganzen Code.

Zellen und Blöcke

Die Blöcke leben in einem eigenen Gitter aus Zellen, einen halben Meter auf jeder Seite. Die Samples des Terrains liegen einen Meter auseinander, und die Blöcke müssen nicht zu ihnen passen: Sie stehen auf dem Boden oder sinken in ihn ein, wo immer sie hingesetzt werden.

Ein Block füllt einen Würfel aus Zellen, size Zellen auf jeder Seite, von seiner unteren Ecke in der Zelle (x, y, z) an. Größe 1 ist ein Block von einem halben Meter, 2 einer von einem Meter, 4 einer von zwei Metern. Kleine Blöcke sind für Details, große füllen eine Wand schnell. Ein Block richtet sich immer nach Blöcken seiner eigenen Größe aus: Ein 1-m-Block beginnt auf einem ganzen Meter, ein 2-m-Block auf einem geraden. So bleiben Reihen gerade, ohne dass der Spieler sich darum kümmern muss.

Jede Zelle weiß, welcher Block sie füllt, also ist „Steht hier ein Block?“ ein einziges Nachschlagen. Die Zellen liegen in einer Map, und der Schlüssel ist eine Zahl aus den drei Koordinaten: je 16 Bit, genug für 16 km in jede Richtung, und eine Zahl hält 48 Bit genau. Ein Zahlenschlüssel ist schneller als ein String wie "12,3,-4", und die Abfragen summieren sich, wie du sehen wirst, sobald Blöcke fallen.

Die Blöcke werden außerdem nach Region geführt: Würfel aus 64 Zellen, so groß wie ein Chunk des Terrains und an denselben Stellen. Jede Region bekommt ein Mesh, also baut eine Änderung nur das Mesh ihrer Region neu, nicht das aller Blöcke.

Zuerst die Materialien, sechs Stück, in der Reihenfolge der Zifferntasten. Vorerst ist jedes eine Farbe; Teil 2 bemalt sie. Lege src/materials.ts an:

src/materials.ts
/** What blocks can be made of, in the order of the number keys. */
export const MATERIALS = [
  { name: 'Stone', color: '#9a9c8e' },
  { name: 'Bricks', color: '#b5523b' },
  { name: 'Planks', color: '#b98a52' },
  { name: 'Plaster', color: '#d6dcc6' },
  { name: 'Slate', color: '#4f4a63' },
  { name: 'Copper', color: '#cf7a3f' },
] as const;

Dann der Speicher der Blöcke. Lege src/blocks.ts an:

src/blocks.ts
import { CHUNK_SIZE } from './grid.ts';

/** Meters along each side of a cell of the block grid. */
export const CELL = 0.5;
/** Cells along each side of a region: a region is as big as a chunk of the terrain. */
export const REGION = CHUNK_SIZE / CELL;

/** A block: a box of size × size × size cells, from its low corner at cell (x, y, z). */
export interface Block {
  x: number;
  y: number;
  z: number;
  /** Cells along each side. */
  size: number;
  material: number;
}

/**
 * One number for a cell, the same for the same cell. Each coordinate gets 16 bits, which
 * is enough for 16 km in every direction, and three of them fit into a number exactly.
 */
export function cellKey(x: number, y: number, z: number): number {
  return ((x + 32768) * 65536 + (y + 32768)) * 65536 + (z + 32768);
}

/** The region a cell is in, as a key like the cells'. */
export function regionOf(x: number, y: number, z: number): number {
  return cellKey(Math.floor(x / REGION), Math.floor(y / REGION), Math.floor(z / REGION));
}

/** The blocks: which block fills each cell, and which blocks start in each region. */
export class BlockStore {
  private readonly cells = new Map<number, Block>();
  private readonly regions = new Map<number, Set<Block>>();
  /** How many blocks there are. */
  count = 0;
  /** Called with every block that is added or removed. */
  onChange: (block: Block) => void = () => {};

  /** The block that fills a cell, if any. */
  at(x: number, y: number, z: number): Block | undefined {
    return this.cells.get(cellKey(x, y, z));
  }

  /** Whether all cells of a box of size³ cells from (x, y, z) are free. */
  fits(x: number, y: number, z: number, size: number): boolean {
    for (let k = 0; k < size; k += 1) {
      for (let j = 0; j < size; j += 1) {
        for (let i = 0; i < size; i += 1) if (this.at(x + i, y + j, z + k)) return false;
      }
    }
    return true;
  }

  /** Adds a block if its cells are free, and says whether it did. */
  add(block: Block): boolean {
    if (!this.fits(block.x, block.y, block.z, block.size)) return false;
    forEachCell(block, (x, y, z) => this.cells.set(cellKey(x, y, z), block));
    const region = regionOf(block.x, block.y, block.z);
    let blocks = this.regions.get(region);
    if (!blocks) {
      blocks = new Set();
      this.regions.set(region, blocks);
    }
    blocks.add(block);
    this.count += 1;
    this.onChange(block);
    return true;
  }

  remove(block: Block): void {
    if (this.at(block.x, block.y, block.z) !== block) return;
    forEachCell(block, (x, y, z) => this.cells.delete(cellKey(x, y, z)));
    const region = regionOf(block.x, block.y, block.z);
    this.regions.get(region)?.delete(block);
    if (this.regions.get(region)?.size === 0) this.regions.delete(region);
    this.count -= 1;
    this.onChange(block);
  }

  /** The blocks whose low corner lies in a region. */
  inRegion(region: number): ReadonlySet<Block> {
    return this.regions.get(region) ?? new Set();
  }
}

/** Calls `visit` with every cell of a block. */
export function forEachCell(block: Block, visit: (x: number, y: number, z: number) => void): void {
  for (let k = 0; k < block.size; k += 1) {
    for (let j = 0; j < block.size; j += 1) {
      for (let i = 0; i < block.size; i += 1) visit(block.x + i, block.y + j, block.z + k);
    }
  }
}
  • cellKey() verschiebt jede Koordinate um 32.768, damit sie nie unter 0 liegt, und setzt sie hintereinander, 65.536 auseinander.

  • regionOf() macht aus einer Zelle den Schlüssel ihrer Region, auf dieselbe Art.

  • add() nimmt einen Block nur, wenn alle seine Zellen frei sind. Es trägt den Block in jede Zelle und in seine Region ein und ruft onChange auf, damit die Meshes folgen können.

  • remove() macht all das rückgängig. Ein Block kennt seine Zellen, also braucht es keine Suche.

Die Seiten, die man nicht sieht

Jeden Block als ganzen Würfel zu zeichnen, ginge, und verschwendete die meisten Dreiecke: In einer Mauer sind die Seiten zwischen zwei Blöcken von beiden Seiten verdeckt. Das Mesh lässt eine Seite weg, wenn Blöcke jede Zelle direkt dahinter füllen. Ein 2-m-Block neben ein paar halbmetergroßen behält seine Seite, weil ein Teil davon zu sehen ist; der verdeckte Teil hinter den kleinen Blöcken kostet ein wenig Zeichnen, sonst nichts.

Jede Seite ist ein Viereck aus vier Ecken im Einheitswürfel, auf den Block skaliert. Die Reihenfolge der Ecken zählt: Babylon.js zeichnet die Vorderseite eines Dreiecks (a, b, c) dort, wohin cross(a − b, c − b) zeigt, und lässt seine Rückseite weg. Lege src/blockMesher.ts an:

src/blockMesher.ts
import { CELL, type Block, type BlockStore } from './blocks.ts';
import { MeshBuilder, type MeshData } from './meshData.ts';

/** A mesh of blocks, with the material of every vertex. Positions are in meters. */
export interface BlockMesh extends MeshData {
  materials: Float32Array;
}

/** A side of a box: the axis it faces along, and which way, -1 or 1. */
interface Side {
  axis: number;
  dir: number;
}

/** The six sides of a box: -x, +x, -y, +y, -z, +z. */
export const SIDES: Side[] = [0, 1, 2].flatMap((axis) => [-1, 1].map((dir) => ({ axis, dir })));

type Point = [number, number, number];

/**
 * The four corners of a side of the unit cube, in the order Babylon.js wants them: it
 * draws the front of a triangle (a, b, c) where cross(a - b, c - b) points.
 */
function sideCorners({ axis, dir }: Side): Point[] {
  const u = (axis + 1) % 3;
  const v = (axis + 2) % 3;
  const corners = ([[0, 0], [1, 0], [1, 1], [0, 1]] as const).map(([a, b]) => {
    const corner: Point = [0, 0, 0];
    corner[axis] = dir > 0 ? 1 : 0;
    corner[u] = a;
    corner[v] = b;
    return corner;
  });
  // Going round u first and v second, cross(a - b, c - b) points along -axis.
  return dir > 0 ? corners.reverse() : corners;
}

const CORNERS = SIDES.map(sideCorners);

/**
 * Whether blocks fill every cell right beyond a side of a block. Then that side can't be
 * seen, and is left out.
 */
function covered(store: BlockStore, block: Block, { axis, dir }: Side): boolean {
  const cell = [block.x, block.y, block.z];
  cell[axis] = dir > 0 ? cell[axis] + block.size : cell[axis] - 1;
  const u = (axis + 1) % 3;
  const v = (axis + 2) % 3;
  const from = [...cell];
  for (let a = 0; a < block.size; a += 1) {
    for (let b = 0; b < block.size; b += 1) {
      cell[u] = from[u] + a;
      cell[v] = from[v] + b;
      if (!store.at(cell[0], cell[1], cell[2])) return false;
    }
  }
  return true;
}

/** Meshes blocks: the sides of each block that a neighbor doesn't cover. */
export function meshBlocks(blocks: Iterable<Block>, store: BlockStore): BlockMesh | null {
  const builder = new MeshBuilder();
  const materials: number[] = [];
  for (const block of blocks) {
    const meters = block.size * CELL;
    SIDES.forEach((side, s) => {
      if (covered(store, block, side)) return;
      const normal: Point = [0, 0, 0];
      normal[side.axis] = side.dir;
      const [a, b, c, d] = CORNERS[s].map(([x, y, z]) => {
        materials.push(block.material);
        return builder.vertex(
          block.x * CELL + x * meters,
          block.y * CELL + y * meters,
          block.z * CELL + z * meters,
          ...normal,
        );
      });
      builder.quad(a, b, c, d);
    });
  }
  if (materials.length === 0) return null;
  return { ...builder.build(), materials: new Float32Array(materials) };
}

Der Mesher nimmt beliebige Blöcke und einen Speicher, in dem er ihre Nachbarn nachschlägt. Er rechnet Positionen in Metern vom Ursprung der Welt aus, mit einer Normalen pro Seite und einem Material pro Vertex, das der Shader zu einer Farbe macht. Er nutzt den MeshBuilder der ersten Serie, dessen quad() die vier Ecken in zwei Dreiecke teilt.

Dasselbe Licht

Die Blöcke sollen aussehen, als stünden sie in derselben Welt wie das Terrain: dieselbe tiefe Sonne, derselbe fliederfarbene Himmel, derselbe Nebel. Das Material des Terrains setzt sie als Uniforms; um sie zu teilen, wandern sie in eine Funktion, die beide Materialien aufrufen. Ersetze in src/material.ts alles von den Optionen bis zum Ende:

src/material.ts
export interface FogOptions {
  fogStart: number;
  fogEnd: number;
}

interface TerrainOptions extends FogOptions {
  /** For the far view: the distance from the camera within which it isn't drawn. */
  nearRadius?: number;
}

/** The uniforms of the light, which the terrain and the blocks share. */
export const LIGHT_UNIFORMS = [
  'cameraPosition', 'sunDirection', 'sunColor', 'skyColor', 'groundColor', 'caveColor',
  'lampColor', 'fogColor', 'fogRange',
];

/** Sets the light's uniforms: the same sun, sky and fog for everything. */
export function setLight(material: ShaderMaterial, options: FogOptions): void {
  // 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));
}

/** 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', ...LIGHT_UNIFORMS, 'nearRadius'],
      samplers: ['layers'],
      defines: far ? ['#define FAR'] : [],
    },
  );
  material.setTexture('layers', textures);
  setLight(material, options);
  if (far) material.setFloat('nearRadius', options.nearRadius!);
  return material;
}

Die Blöcke bekommen eigene Shader. Der Vertex-Shader schlägt die Farbe des Materials in einem Uniform-Array nach und dreht die Normale mit dem Mesh. Region-Meshes drehen sich nie, aber Teil 7 wird Blöcke drehen, die fallen. Lege src/block.vertex.glsl an:

src/block.vertex.glsl
precision highp float;

attribute vec3 position;
attribute vec3 normal;
attribute float material;

uniform mat4 world;
uniform mat4 viewProjection;
uniform vec3 colors[MATERIAL_COUNT];

varying vec3 vPosition;
varying vec3 vNormal;
varying vec3 vColor;

void main() {
  vec4 worldPosition = world * vec4(position, 1.0);
  vPosition = worldPosition.xyz;
  // Blocks may be turned, but never scaled: turning the normal is enough.
  vNormal = mat3(world) * normal;
  vColor = colors[int(material + 0.5)];
  gl_Position = viewProjection * worldPosition;
}

Der Fragment-Shader beleuchtet die Farbe so, wie es der Shader des Terrains tut. Das Höhlenlicht der ersten Serie fehlt vorerst: Jeder Block sieht den ganzen Himmel, bis Teil 2. Lege src/block.fragment.glsl an:

src/block.fragment.glsl
precision highp float;

varying vec3 vPosition;
varying vec3 vNormal;
varying vec3 vColor;

uniform vec3 cameraPosition;
uniform vec3 sunDirection;
uniform vec3 sunColor;
uniform vec3 skyColor;
uniform vec3 groundColor;
uniform vec3 caveColor;
uniform vec3 lampColor;
uniform vec3 fogColor;
uniform vec2 fogRange;

// Meters the lamp at the camera reaches.
const float LAMP_RANGE = 24.0;

void main() {
  vec3 normal = normalize(vNormal);

  // The same light as the terrain's. For now, every block sees the whole sky.
  float sky = 1.0;
  float sun = max(dot(normal, -sunDirection), 0.0) * smoothstep(0.6, 1.0, sky);
  vec3 ambient = mix(groundColor, skyColor, normal.y * 0.5 + 0.5) * pow(sky, 1.5) + caveColor;

  vec3 toCamera = cameraPosition - vPosition;
  float distanceToCamera = length(toCamera);
  float lamp = max(dot(normal, toCamera / distanceToCamera), 0.0)
    * pow(1.0 - min(distanceToCamera / LAMP_RANGE, 1.0), 2.0) * (1.0 - sky);

  vec3 lit = vColor * (ambient + sunColor * sun + lampColor * lamp);

  float fog = smoothstep(fogRange.x, fogRange.y, distance(vPosition, cameraPosition));
  gl_FragColor = vec4(mix(lit, fogColor, fog), 1.0);
}

Das Material setzt die Shader zusammen. MATERIAL_COUNT ist ein Define, weil GLSL die Länge eines Arrays braucht, wenn der Shader kompiliert wird. Lege src/blockMaterial.ts an:

src/blockMaterial.ts
import { Color3, ShaderMaterial, type Scene } from '@babylonjs/core/pure';
import fragmentSource from './block.fragment.glsl?raw';
import vertexSource from './block.vertex.glsl?raw';
import { LIGHT_UNIFORMS, setLight, type FogOptions } from './material.ts';
import { MATERIALS } from './materials.ts';

/** The blocks' material: a color for each material, lit like the terrain. */
export function createBlockMaterial(scene: Scene, options: FogOptions): ShaderMaterial {
  const material = new ShaderMaterial(
    'blocks',
    scene,
    { vertexSource, fragmentSource },
    {
      attributes: ['position', 'normal', 'material'],
      uniforms: ['world', 'viewProjection', 'colors', ...LIGHT_UNIFORMS],
      defines: [`#define MATERIAL_COUNT ${MATERIALS.length}`],
    },
  );
  material.setColor3Array('colors', MATERIALS.map(({ color }) => Color3.FromHexString(color)));
  setLight(material, options);
  return material;
}

Ein Mesh pro Region

Wenn ein Block kommt oder geht, ist das Mesh seiner Region veraltet. Ebenso die Regionen der Blöcke um ihn herum: Ein neuer Block verdeckt einige ihrer Seiten, und ein abgebauter legt sie frei. Die Blockwelt merkt sich diese Regionen und baut ihre Meshes einmal pro Frame, egal wie viele Blöcke sich geändert haben. Lege src/blockWorld.ts an:

src/blockWorld.ts
import type { Material, Mesh, Scene } from '@babylonjs/core/pure';
import { BlockStore, regionOf, type Block } from './blocks.ts';
import { meshBlocks } from './blockMesher.ts';
import { createMesh } from './meshData.ts';

/** The blocks in the scene: one mesh per region, built again when its blocks change. */
export class BlockWorld {
  readonly store = new BlockStore();
  private readonly meshes = new Map<number, Mesh>();
  /** The regions whose mesh is out of date. */
  private readonly dirty = new Set<number>();
  private readonly scene: Scene;
  private readonly material: Material;

  constructor(scene: Scene, material: Material) {
    this.scene = scene;
    this.material = material;
    this.store.onChange = (block) => this.touch(block);
  }

  /** Once a frame: builds the meshes of the regions that changed. */
  update(): void {
    for (const region of this.dirty) this.build(region);
    this.dirty.clear();
  }

  /**
   * A block came or went. Its region is out of date, and so are the regions of the blocks
   * around it, whose sides it covers or uncovers.
   */
  private touch(block: Block): void {
    this.dirty.add(regionOf(block.x, block.y, block.z));
    const { x, y, z, size } = block;
    for (let k = z - 1; k <= z + size; k += 1) {
      for (let j = y - 1; j <= y + size; j += 1) {
        for (let i = x - 1; i <= x + size; i += 1) {
          const other = this.store.at(i, j, k);
          if (other && other !== block) this.dirty.add(regionOf(other.x, other.y, other.z));
        }
      }
    }
  }

  private build(region: number): void {
    this.meshes.get(region)?.dispose();
    this.meshes.delete(region);
    const blocks = this.store.inRegion(region);
    const data = meshBlocks(blocks, this.store);
    if (!data) return;
    const mesh = createMesh(`blocks ${region}`, data, this.scene);
    mesh.setVerticesData('material', data.materials, false, 1);
    mesh.material = this.material;
    // Tells the tools that a ray hit blocks, not the ground.
    mesh.metadata = 'blocks';
    mesh.freezeWorldMatrix();
    this.meshes.set(region, mesh);
  }
}

touch() schaut jede Zelle rund um den Block an, eine Zelle hinaus auf jeder Seite, und markiert die Regionen der Blöcke, die es findet. Ein Region-Mesh trägt 'blocks' als Metadaten, damit das Werkzeug einen Block vom Boden unterscheiden kann, wenn sein Strahl etwas trifft. Seine Weltmatrix ist eingefroren, wie die der Chunks: Es bewegt sich nie.

Das Block-Werkzeug

Das Werkzeug funktioniert wie das Terrain-Werkzeug der ersten Serie: ein Strahl von der Kamera durch die Bildmitte, scene.pickWithRay(), und eine durchscheinende Box, die zeigt, was passieren wird. Es hat zwei davon: eine orange, wo ein Rechtsklick einen Block hinsetzt, rot, wenn er nicht passt, und eine blasse weiße um den Block, den ein Linksklick abbaut.

Wohin kommt ein neuer Block? Trifft der Strahl einen Block, kommt der neue Block an die getroffene Seite und richtet sich quer dazu nach seiner eigenen Größe aus. Trifft er den Boden, ist der neue Block der seiner Größe rund um einen Punkt knapp über dem Boden, er steht also auf dem Boden oder ein wenig darin. So oder so berührt er, worauf du gezielt hast, und darauf wird sich Teil 6 verlassen.

Eine gehaltene Taste wiederholt alle 200 ms. Ein kurzer Klick muss auch zählen, selbst wenn er vor dem nächsten Frame vorbei ist: Auf einem langsamen Rechner kann ein Frame länger dauern als ein Klick. Also wird ein Klick aufgehoben, bis der nächste Frame ihn ausführt. Lege src/buildTool.ts an:

src/buildTool.ts
import {
  Color3,
  CreateBox,
  StandardMaterial,
  Vector3,
  type Camera,
  type Mesh,
  type Scene,
} from '@babylonjs/core/pure';
import { CELL, type Block, type BlockStore } from './blocks.ts';

/** How far the player reaches with blocks, in meters. */
const REACH = 12;
/** The sizes of blocks, in cells: 0.5, 1 and 2 m. */
export const SIZES = [1, 2, 4];
/** Milliseconds between blocks while a mouse button is held down. */
const REPEAT = 200;

/** Where the camera looks: at a side of a block, or at the ground. */
type Aim =
  | { kind: 'block'; block: Block; axis: number; dir: number; point: Vector3 }
  | { kind: 'ground'; point: Vector3; normal: Vector3 };

/** A see-through box that marks a place, never in the way of the camera's ray. */
function createMarker(name: string, scene: Scene): Mesh {
  const marker = CreateBox(name, { size: 1 }, scene);
  const material = new StandardMaterial(name, scene);
  material.disableLighting = true;
  material.alpha = 0.3;
  marker.material = material;
  marker.isPickable = false;
  marker.isVisible = false;
  return marker;
}

/** Places a marker over a box of cells. `grow` makes it a little bigger than the box. */
function placeMarker(marker: Mesh, x: number, y: number, z: number, size: number, grow = 0): void {
  const meters = size * CELL;
  marker.position.set((x + size / 2) * CELL, (y + size / 2) * CELL, (z + size / 2) * CELL);
  marker.scaling.setAll(meters + grow);
  marker.isVisible = true;
}

/** Places blocks where the camera looks and breaks them. */
export class BlockTool {
  /** The material of the next block, by its number. */
  material = 0;
  /** The size of the next block, in cells. */
  size = 2;
  /** What the button held down does, again and again. */
  private held: 'break' | 'place' | null = null;
  /** A click not acted on yet: even a short one between two frames counts. */
  private clicked: 'break' | 'place' | null = null;
  private lastEdit = 0;
  /** Where a right click puts a block: orange, or red where it doesn't fit. */
  private readonly ghost: Mesh;
  /** The block a left click breaks. */
  private readonly target: Mesh;
  private readonly scene: Scene;
  private readonly camera: Camera;
  private readonly store: BlockStore;

  constructor(scene: Scene, camera: Camera, store: BlockStore) {
    this.scene = scene;
    this.camera = camera;
    this.store = store;
    this.ghost = createMarker('ghost', scene);
    this.target = createMarker('target', scene);
    (this.target.material as StandardMaterial).emissiveColor = Color3.FromHexString('#fff7ed');
    (this.target.material as StandardMaterial).alpha = 0.15;
  }

  start(action: 'break' | 'place'): void {
    this.held = action;
    this.clicked = action;
  }

  stop(): void {
    this.held = null;
  }

  /** Makes the next block bigger or smaller by `steps` sizes. */
  resize(steps: number): void {
    const index = Math.min(Math.max(SIZES.indexOf(this.size) + steps, 0), SIZES.length - 1);
    this.size = SIZES[index];
  }

  hide(): void {
    this.ghost.isVisible = false;
    this.target.isVisible = false;
  }

  update(now: number): void {
    this.hide();
    const aim = this.aim();
    if (!aim) {
      this.clicked = null;
      return;
    }
    const block = this.placement(aim);
    const fits = this.store.fits(block.x, block.y, block.z, block.size);
    placeMarker(this.ghost, block.x, block.y, block.z, block.size);
    (this.ghost.material as StandardMaterial).emissiveColor = Color3.FromHexString(fits ? '#f97316' : '#ef4444');
    if (aim.kind === 'block') {
      const { x, y, z, size } = aim.block;
      placeMarker(this.target, x, y, z, size, 0.02);
    }

    const action = this.clicked ?? (now - this.lastEdit >= REPEAT ? this.held : null);
    this.clicked = null;
    if (!action) return;
    this.lastEdit = now;
    if (action === 'break' && aim.kind === 'block') this.store.remove(aim.block);
    if (action === 'place' && fits) this.store.add(block);
  }

  /** What the camera's ray hits first within reach. */
  private aim(): Aim | null {
    const hit = this.scene.pickWithRay(this.camera.getForwardRay(REACH));
    if (!hit?.pickedPoint || !hit.pickedMesh) return null;
    const point = hit.pickedPoint;
    const normal = hit.getNormal(true) ?? Vector3.Up();
    if (hit.pickedMesh.metadata === 'blocks') {
      // A point just inside the side that was hit is in the block's cells.
      const inside = point.subtract(normal.scale(0.01));
      const block = this.store.at(
        Math.floor(inside.x / CELL),
        Math.floor(inside.y / CELL),
        Math.floor(inside.z / CELL),
      );
      if (block) {
        // The side is the one the normal points out of most.
        const n = [normal.x, normal.y, normal.z];
        const axis = [0, 1, 2].reduce((best, a) => (Math.abs(n[a]) > Math.abs(n[best]) ? a : best));
        return { kind: 'block', block, axis, dir: Math.sign(n[axis]), point };
      }
    }
    return { kind: 'ground', point, normal };
  }

  /** The block a right click would put down. */
  private placement(aim: Aim): Block {
    const size = this.size;
    // A block lines up with the blocks of its own size: a 1 m block starts at whole meters.
    const snap = (meters: number) => Math.floor(meters / CELL / size) * size;
    const material = this.material;
    if (aim.kind === 'ground') {
      // The block around a point just above the ground.
      const p = aim.point.add(aim.normal.scale(0.05));
      return { x: snap(p.x), y: snap(p.y), z: snap(p.z), size, material };
    }
    // Against the side that was hit, and across it where the ray hit it.
    const { block, axis, dir, point } = aim;
    const low = [snap(point.x), snap(point.y), snap(point.z)];
    const from = [block.x, block.y, block.z];
    low[axis] = dir > 0 ? from[axis] + block.size : from[axis] - size;
    return { x: low[0], y: low[1], z: low[2], size, material };
  }
}
  • aim() findet, was der Strahl zuerst trifft. Auf einem Block liegt ein Punkt knapp innerhalb der Oberfläche in den Zellen des Blocks; die Seite ist die Achse, entlang der die Normale am meisten zeigt.

  • placement() rechnet den nächsten Block aus. snap() richtet eine Koordinate nach der Größe des Blocks aus.

  • update() zeigt die Boxen und führt einen Klick oder eine gehaltene Taste aus.

  • resize() geht durch die Größen, von einem halben Meter bis zu zweien.

Die beiden Werkzeuge wechseln sich ab, und das gerade unbenutzte versteckt seine Markierung. Ergänze in src/tool.ts eine Methode in DigTool:

src/tool.ts
hide(): void {
  this.marker.isVisible = false;
}

Alles zusammen

B wählt das Block-Werkzeug, T das Terrain-Werkzeug. Mit Blöcken wählen das Mausrad und die Zifferntasten das Material, Komma und Punkt die Größe; mit dem Terrain-Werkzeug bestimmt das Rad wie bisher die Größe der Kugel. Die Spielfigur beginnt gehend auf einer Wiese: Dort ist Platz zum Bauen. Ersetze index.html:

index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Voxel building</title>
  </head>
  <body>
    <canvas id="game"></canvas>
    <div id="crosshair"></div>
    <div id="hud"></div>
    <div id="help">
      Click to look around · WASD walk, Shift run, Space jump · F fly (Q/E down/up) · B blocks:
      left break, right place, wheel or 1–6 material, , . size · T terrain: left dig, right build, wheel
      size · M mesher
    </div>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

Ersetze src/main.ts:

src/main.ts
import {
  Engine,
  RegisterEnginesExtensionsEngineRawTexture,
  RegisterRay,
  RegisterShaderMaterial,
  RegisterStandardEngineExtensions,
  RegisterStandardMaterial,
  Scene,
  UniversalCamera,
  Vector3,
} from '@babylonjs/core/pure';
import { createBlockMaterial } from './blockMaterial.ts';
import { CELL } from './blocks.ts';
import { BlockWorld } from './blockWorld.ts';
import { BlockTool } from './buildTool.ts';
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 { MATERIALS } from './materials.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 tools: the markers' 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());
const blocks = new BlockWorld(scene, createBlockMaterial(scene, fog));

// The camera only looks around: the player and the flying below move it. The player
// starts on a meadow, with room to build and mountains behind it.
const feet = new Vector3(96, surfaceHeight(96, 176) + 1, 176);
const camera = new UniversalCamera('camera', feet.add(new Vector3(0, EYES, 0)), scene);
camera.setTarget(new Vector3(96, surfaceHeight(96, 166) + 2.5, 166));
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);
let mode: 'walk' | 'fly' = 'walk';

/** 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;
}

// Two tools: one digs and builds the terrain, the other places and breaks blocks.
const digTool = new DigTool(scene, camera, world);
const blockTool = new BlockTool(scene, camera, blocks.store);
let tool: 'terrain' | 'blocks' = 'blocks';
// The mouse wheel changes the tool instead of moving the camera.
camera.inputs.removeByType('FreeCameraMouseWheelInput');

/** Switches the material by `steps`, round the list. */
function pickMaterial(steps: number): void {
  blockTool.material = (blockTool.material + steps + MATERIALS.length) % MATERIALS.length;
}

// A click captures the mouse, so that it turns the camera without a button held down.
// While it is captured, the left button takes away and the right one adds.
canvas.addEventListener('pointerdown', (event) => {
  if (document.pointerLockElement !== canvas) {
    canvas.requestPointerLock();
    return;
  }
  if (tool === 'terrain') {
    if (event.button === 0) digTool.start('dig');
    if (event.button === 2) digTool.start('build');
  } else {
    if (event.button === 0) blockTool.start('break');
    if (event.button === 2) blockTool.start('place');
  }
});
window.addEventListener('pointerup', () => {
  digTool.stop();
  blockTool.stop();
});
canvas.addEventListener('contextmenu', (event) => event.preventDefault());
canvas.addEventListener(
  'wheel',
  (event) => {
    const steps = -Math.sign(event.deltaY);
    if (tool === 'terrain') digTool.resize(steps);
    else pickMaterial(-steps);
  },
  { 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 === 'KeyT') tool = 'terrain';
  if (event.code === 'KeyB') tool = 'blocks';
  if (event.code === 'Comma') blockTool.resize(-1);
  if (event.code === 'Period') blockTool.resize(1);
  // The number keys pick a material: Digit1 is the first.
  const digit = Number(event.code.replace('Digit', ''));
  if (digit >= 1 && digit <= MATERIALS.length) blockTool.material = digit - 1;
  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);
  const now = performance.now();
  if (tool === 'terrain') {
    blockTool.hide();
    digTool.update(now);
  } else {
    digTool.hide();
    blockTool.update(now);
  }
  blocks.update();
  const { loaded, waiting } = world.counts;
  const { x, y, z } = camera.position;
  const toolLine = tool === 'terrain'
    ? `Terrain tool, ${digTool.radius.toFixed(1)} m`
    : `Blocks: ${MATERIALS[blockTool.material].name}, ${blockTool.size * CELL} m, ${blocks.store.count} placed`;
  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` +
    `${toolLine}\n` +
    `${engine.getFps().toFixed(0)} fps`;
});

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

Ausprobieren

Klick, um die Maus einzufangen, schau nach unten und klick rechts: Ein Steinblock erscheint auf der Wiese. Ein Rechtsklick auf seine Oberseite stapelt den nächsten darauf; auf seine Seite kommt einer daneben. Schalte mit der Komma-Taste auf halbmetergroße Blöcke und setz eine Reihe kleiner Blöcke an einen großen: Sie liegen auf seinen Halbmeter-Linien. Ein Linksklick baut einen Block ab, und die verdeckten Seiten seiner Nachbarn erscheinen wieder. Flieg mit F, um höher zu bauen, und drück T, um neben deinen Blöcken ein Loch zu graben: Noch kümmert sie das nicht. Sie schweben, wo du sie lässt, und die Spielfigur geht einfach durch sie hindurch. Beides ändert sich in den nächsten Teilen.

Blöcke auf dem Terrain. Klick, dann: Rechtsklick setzt, Linksklick baut ab, Rad oder 1–6 wählt das Material, Komma und Punkt die Größe. T ist das Terrain-Werkzeug, F fliegt.
In neuem Tab öffnen

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.