Building on Voxel Terrain in Babylon.js · Part 1 of 10 in the series
Part 1: Blocks on the Terrain
Put blocks on the smooth voxel terrain: a grid of half-meter cells, blocks of three sizes that fill whole cells, one mesh per region with the hidden sides left out, and a tool that places and breaks them where you look.

Quest objectives
- Know what this series adds to the smooth voxel terrain
- Keep blocks in a grid of cells, looked up by one number per cell
- Mesh the blocks of a region, leaving out the sides nobody can see
- Light the blocks with the same sun, sky and fog as the terrain
- Aim with a ray, show where a block goes, and place and break blocks
In the smooth voxel terrain series you built a world of rolling hills, overhangs and caves, which you can dig into and build up. Its ground is smooth on purpose: a density field, meshed with marching cubes or surface nets. Houses, walls and towers are another matter. They want straight edges, flat walls and right angles: blocks, as in a block building game.
This series puts blocks on that terrain, and then goes further than the usual block game. Blocks get painted materials and three sizes. A chisel rounds or slopes their edges, so that a block can become a pillar, a dome, a ramp or a roof. And blocks need something to hold them: when you break the last block that connects a part of a building to the ground, that part comes down, tumbles as one rigid body, lands as rubble, smashes what it hits and raises dust.
The series
Blocks on the terrain (this part)
Painted blocks and their light
Walking on blocks
A chisel for rounded edges
Slopes and roofs
What holds a block
Falling pieces
Rubble
Chain reactions
Dust and sound
As in the first series, every part ends with a demo you can play on the page, built from exactly the code it shows, with Babylon.js 9.33, Vite 8.3 and TypeScript 7.
What you need
This series starts where the other one ends: with the project as it is at the end of its part 12. Everything in it stays: the chunks, the workers, the textures, the cave light, the player and the far view. The terrain tool stays too, so that you can still dig, now under the blocks. If you haven't built it, its parts show all of its code.
Cells and blocks
The blocks live in a grid of their own, of cells half a meter on each side. The terrain's samples are a meter apart, and the blocks don't need to agree with them: they sit on top of the ground, or sink into it, wherever they are put.
A block fills a cube of cells, size cells along each side, from its low corner at cell (x, y, z). A size of 1 is a block of half a meter, 2 a block of a meter, 4 one of two meters. Small blocks are for details, big ones fill a wall quickly. A block always lines up with blocks of its own size: a 1 m block starts on a whole meter, a 2 m block on an even one. That keeps rows straight without any extra effort from the player.
Each cell knows which block fills it, so that "is there a block at this point?" is one lookup. The cells are kept in a Map, and the key is a number made of the three coordinates: 16 bits each, enough for 16 km in every direction, and a number holds 48 bits exactly. A number key is faster than a string like "12,3,-4", and the lookups add up, as you will see when blocks start to fall.
The blocks are also kept by region: cubes of 64 cells, as big as a chunk of the terrain and at the same places. Each region gets one mesh, so a change rebuilds only the mesh of its region, not those of all blocks.
First the materials, six of them, in the order of the number keys. For now each is a color; part 2 paints them. Create 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;Then the store of blocks. Create 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()moves each coordinate by 32,768, so that it is never below 0, and puts them one after the other, 65,536 apart.regionOf()turns a cell into its region's key, made the same way.add()takes a block only if all its cells are free. It notes the block in every cell and in its region, and callsonChange, so that the meshes can follow.remove()undoes all of that. A block knows its own cells, so it needs no search.
The sides you can't see
Drawing every block as a whole cube would work, and waste most of the triangles: in a wall, the sides between two blocks are covered on both sides. The mesh leaves out a side when blocks fill every cell right beyond it. A 2 m block next to a few half-meter blocks keeps its side, since a part of it shows; the hidden part behind the small blocks costs a little drawing, nothing else.
Each side is a quad of four corners in the unit cube, scaled to the block. The order of the corners matters: Babylon.js draws the front of a triangle (a, b, c) where cross(a − b, c − b) points, and leaves out its back. Create 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) };
}The mesher takes any blocks and a store to look up their neighbors in. It works out positions in meters from the world's origin, with a normal per side, and a material per vertex, which the shader turns into a color. It uses the MeshBuilder of the first series, whose quad() splits the four corners into two triangles.
The same light
The blocks should look as if they stood in the same world as the terrain: the same low sun, the same lilac sky, the same fog. The terrain's material sets these as uniforms; to share them, they move into a function both materials call. In src/material.ts, replace everything from the options to the end:
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;
}The blocks get shaders of their own. The vertex shader looks up the material's color in a uniform array, and turns the normal with the mesh. Region meshes never turn, but part 7 will turn blocks that fall. Create 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;
}The fragment shader lights the color the way the terrain's shader does. The cave light of the first series is left out for now: every block sees the whole sky, until part 2. Create 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);
}The material puts the shaders together. MATERIAL_COUNT is a define, because GLSL needs the length of an array when the shader is compiled. Create 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;
}One mesh per region
When a block comes or goes, its region's mesh is out of date. So are the regions of the blocks around it: a new block covers some of their sides, and a broken one lays them open. The block world notes those regions and builds their meshes once per frame, however many blocks changed. Create 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() looks at every cell around the block, one cell out on each side, and marks the regions of the blocks it finds. A region mesh carries 'blocks' as its metadata, so that the tool can tell a block from the ground when its ray hits something. Its world matrix is frozen, as the chunks' are: it never moves.
The block tool
The tool works like the terrain tool of the first series: a ray from the camera through the middle of the screen, scene.pickWithRay(), and a see-through box that shows what will happen. It has two of them: an orange one where a right click puts a block, red if it doesn't fit, and a faint white one around the block a left click breaks.
Where does a new block go? If the ray hits a block, the new block goes against the side that was hit, and lines up with its own size across that side. If it hits the ground, the new block is the one of its size around a point just above the ground, so it sits on the ground or a little in it. Either way it touches what you aimed at, which part 6 will rely on.
Holding a button repeats every 200 ms. A quick click has to count too, even when it is over before the next frame: on a slow machine, a frame can take longer than a click. So a click is kept until the next frame acts on it. Create 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()finds what the ray hits first. On a block, a point just inside the surface is in the block's cells; the side is the axis the normal points along most.placement()works out the next block.snap()lines a coordinate up with the block's size.update()shows the boxes, and acts on a click or a held button.resize()steps through the sizes, from half a meter to two.
The two tools take turns, and the one that isn't in use hides its marker. In src/tool.ts, add a method to DigTool:
hide(): void {
this.marker.isVisible = false;
}Putting it together
B picks the block tool, T the terrain tool. With blocks, the wheel and the number keys pick the material, and the comma and period keys the size; with the terrain tool, the wheel sizes the ball as before. The player starts on a meadow, walking: there is room to build there. Replace 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>Replace 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());Try it
Click to capture the mouse, look down, and right-click: a stone block appears on the meadow. Right-click on its top and the next one stacks on it; on its side, one goes next to it. Switch to half-meter blocks with the comma key and put a row of small ones along a big one: they line up on its half-meter lines. Left-click breaks a block, and the sides of its neighbors that were hidden appear again. Fly with F to build higher, and press T to dig a hole next to your blocks: they don't care yet. They float wherever you leave them, and the player walks right through them. Both change in the parts to come.