Skip to content
DasPeTe Tutorials

Building on Voxel Terrain in Babylon.js · Part 4 of 10 in the series

Part 4: A Chisel for Rounded Edges

Round the edges of blocks with a chisel: twelve bits per block, a shape made of eight octants that are each a box, a quarter cylinder, two crossing cylinders or an eighth of a ball, its signed distance for walking, and a tool that lights up the edge, corner or side you aim at.

Quest objectives

  • Number the twelve edges of a cube, and keep a block's rounded ones in its bits
  • Build any rounding from eight octants of four kinds, and make their curves meet
  • Keep what lies flat on a side apart, so that neighbors can still hide it
  • Work out the signed distance to a rounded block, so that the player walks on its curves
  • Aim at an edge, a corner or a side, and round or sharpen it with a click

A block is a cube, with twelve sharp edges. Round some of them and it becomes something else: round the four upright edges and you have a pillar, round all twelve and you have a ball, round the top four and the upright four and you have a dome. A builder that can round edges, one at a time, gets arches, columns, rounded wall tops and domed towers from the same blocks.

This part adds a chisel. Aim it at an edge of a block and a click rounds it; aim near a corner and it rounds the corner's three edges; aim at the middle of a side and it rounds the side's four. A right click makes them sharp again.

Twelve bits

Which edges of a block are rounded fits into a number: one bit per edge, 4,096 possible shapes. The edges need numbers. An edge runs along one axis, and sits at one end or the other of the two other axes: four edges per axis, twelve in all. Edges 0 to 3 run along x, 4 to 7 along y, 8 to 11 along z, and within an axis the two ends make the rest of the number.

A block gets a field for it. In src/blocks.ts, add to Block:

src/blocks.ts
/** A bit for each rounded edge, by the edge's number (see shapes.ts). */
cuts: number;

And a method to BlockStore, which changes the shape and tells the meshes:

src/blocks.ts
/** Rounds a block's edges, or makes them sharp again. */
reshape(block: Block, cuts: number): void {
  if (block.cuts === cuts) return;
  block.cuts = cuts;
  this.onChange(block);
}

Eight octants

4,096 shapes is too many to model by hand, and most are made of the same few pieces. Cut the cube into eight octants, one per corner, each half a block on every side. Three edges meet at a corner, and they run along the octant. Now let a rounded edge have a radius of half the block. Its curve then lies entirely inside the two octants along it, and an octant's piece of the shape depends only on how many of its own three edges are rounded:

  • None: a box. Its three outer sides lie on the block's sides.

  • One: a quarter cylinder along that edge, ending in a quarter disc on the side it runs into. The other two sides are gone: the curve starts where they would be.

  • Two: two cylinders crossing, each along its edge only as far as it lies inside the other. They meet along a curve, like the vault where two barrel roofs cross.

  • Three: an eighth of a ball.

So a pillar is eight octants with one upright edge each, and a ball is eight eighths of a ball. Each piece is easy to build, and the eight together close up, if their curves meet: the cylinder in one octant must end where the ball in the next begins. So every curve is cut the same way, into the same four segments per quarter circle, and neighboring pieces share their points exactly.

Start src/shapes.ts with the shape's parts and the edges' numbers. A shape keeps what lies flat on each of the six sides apart from the rest: a neighbor can hide a flat side, but nothing can hide a curve.

src/shapes.ts
/**
 * The shapes of blocks, in a cube from 0 to 1 on every axis: a plain cube, or one with
 * some of its twelve edges rounded. A block's `cuts` has a bit for each edge.
 */

/** Segments of a quarter circle. */
const SEGMENTS = 4;
/** The radius of a rounded edge: half the block. */
const R = 0.5;

type Vec = [number, number, number];

/** Triangles as plain lists: positions, normals and indices. */
export interface ShapeMesh {
  positions: number[];
  normals: number[];
  indices: number[];
}

export interface Shape {
  /** What lies flat on each side of the cube, by side: -x, +x, -y, +y, -z, +z. */
  sides: ShapeMesh[];
  /** The rest, which no neighbor can cover. */
  inner: ShapeMesh;
  /** A bit for each side the shape covers whole. */
  fullSides: number;
}

/** A side's number: -x, +x, -y, +y, -z, +z. */
export const sideOf = (axis: number, dir: number) => axis * 2 + (dir > 0 ? 1 : 0);

/**
 * An edge's number. The edge runs along `axis`; `ends` says at which end of the other two
 * axes it lies, 0 or 1 (the entry for `axis` itself doesn't count). Edges 0 to 3 run
 * along x, 4 to 7 along y, 8 to 11 along z.
 */
export function edgeOf(axis: number, ends: number[]): number {
  return axis * 4 + ends[(axis + 1) % 3] + 2 * ends[(axis + 2) % 3];
}

/** The edges of a block that meet at a corner, given by its ends on the three axes. */
export function cornerEdges(ends: number[]): number[] {
  return [0, 1, 2].map((axis) => edgeOf(axis, ends));
}

/** The four edges around a side. */
export function sideEdges(axis: number, dir: number): number[] {
  const edges: number[] = [];
  for (const other of [(axis + 1) % 3, (axis + 2) % 3]) {
    const along = 3 - axis - other;
    for (const end of [0, 1]) {
      const ends = [0, 0, 0];
      ends[axis] = dir > 0 ? 1 : 0;
      ends[other] = end;
      edges.push(edgeOf(along, ends));
    }
  }
  return edges;
}

cornerEdges() and sideEdges() are for the chisel: the three edges of a corner, the four around a side. Next, small vector helpers and the way to cut a curve. Points along a quarter circle are taken on the straight line between its two ends and pushed out onto the circle. That spaces them a little unevenly, which nobody sees, and it works the same for the arcs of a ball, where the line runs between three corners. Add to src/shapes.ts:

src/shapes.ts
const unit = (axis: number): Vec => [axis === 0 ? 1 : 0, axis === 1 ? 1 : 0, axis === 2 ? 1 : 0];
const add = (a: Vec, b: Vec): Vec => [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
const scale = (a: Vec, s: number): Vec => [a[0] * s, a[1] * s, a[2] * s];
const normalize = (a: Vec): Vec => scale(a, 1 / Math.hypot(...a));

/**
 * The point k / SEGMENTS of the way round a quarter circle, from `from` to `to`, both
 * one unit long: on the straight line between them, pushed out onto the circle. Every
 * curve is cut the same way, so the curves of neighboring pieces meet point for point.
 */
function arc(from: Vec, to: Vec, k: number): Vec {
  return normalize(add(scale(from, SEGMENTS - k), scale(to, k)));
}

function emptyMesh(): ShapeMesh {
  return { positions: [], normals: [], indices: [] };
}

/**
 * Adds a triangle that faces the way its normals lean. Babylon.js draws the front of a
 * triangle (a, b, c) where cross(a - b, c - b) points; the other order turns it around.
 */
function triangle(mesh: ShapeMesh, points: Vec[], normals: Vec[]): void {
  const [a, b, c] = points;
  const ab = [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
  const cb = [c[0] - b[0], c[1] - b[1], c[2] - b[2]];
  const facing = [ab[1] * cb[2] - ab[2] * cb[1], ab[2] * cb[0] - ab[0] * cb[2], ab[0] * cb[1] - ab[1] * cb[0]];
  const lean = add(add(normals[0], normals[1]), normals[2]);
  const dot = facing[0] * lean[0] + facing[1] * lean[1] + facing[2] * lean[2];
  // Two points in the same place: no triangle at all.
  if (Math.abs(dot) < 1e-12) return;
  const order = dot > 0 ? [0, 1, 2] : [0, 2, 1];
  const first = mesh.positions.length / 3;
  for (const i of order) {
    mesh.positions.push(...points[i]);
    mesh.normals.push(...normals[i]);
  }
  mesh.indices.push(first, first + 1, first + 2);
}

/** Two triangles for four points in order around them. */
function quad(mesh: ShapeMesh, points: Vec[], normals: Vec[]): void {
  triangle(mesh, [points[0], points[1], points[2]], [normals[0], normals[1], normals[2]]);
  triangle(mesh, [points[0], points[2], points[3]], [normals[0], normals[2], normals[3]]);
}

triangle() turns each triangle to face the way its normals lean, so the pieces need not care about the order of their points. A triangle whose points coincide is dropped: a cylinder clipped to nothing at its end leaves one.

Now the shape itself. Each octant works in its own frame: from the cube's middle, outward toward its corner on all three axes, so that the same code builds all eight. at() and out() turn its points and normals into the cube's. Add to src/shapes.ts:

src/shapes.ts
/**
 * The shape with the edges in `cuts` rounded. The cube is cut into eight octants, one
 * per corner, each half a block on every side. In an octant three edges meet, and its
 * piece of the shape depends only on how many of them are rounded: none, a box; one, a
 * quarter cylinder along it; two, two crossing cylinders; all three, an eighth of a ball.
 */
function roundShape(cuts: number): Shape {
  const shape: Shape = { sides: [0, 1, 2, 3, 4, 5].map(emptyMesh), inner: emptyMesh(), fullSides: 0 };
  // A side is whole while none of its octants has a rounded edge.
  let notWhole = 0;
  for (let corner = 0; corner < 8; corner += 1) {
    const ends = [corner & 1, (corner >> 1) & 1, (corner >> 2) & 1];
    const sign = ends.map((end) => (end ? 1 : -1));
    // Points and normals in the octant go out from the middle of the cube, toward the
    // corner on every axis: `at` and `out` turn them into the cube's own.
    const at = (o: Vec): Vec => [0.5 + sign[0] * o[0], 0.5 + sign[1] * o[1], 0.5 + sign[2] * o[2]];
    const out = (n: Vec): Vec => [sign[0] * n[0], sign[1] * n[1], sign[2] * n[2]];
    const rounded = [0, 1, 2].filter((axis) => cuts & (1 << edgeOf(axis, ends)));
    if (rounded.length > 0) for (let axis = 0; axis < 3; axis += 1) notWhole |= 1 << sideOf(axis, sign[axis]);
    // Two points along a curve and the same two moved along the axis by their lengths.
    const strip = (mesh: ShapeMesh, k: number, curve: (k: number) => Vec, axis: number, length: (p: Vec) => number) => {
      const [p, q] = [curve(k), curve(k + 1)];
      const [np, nq] = [normalize(p), normalize(q)];
      const points = [p, q, add(q, scale(unit(axis), length(q))), add(p, scale(unit(axis), length(p)))];
      quad(mesh, points.map(at), [np, nq, nq, np].map(out));
    };

    if (rounded.length === 0) {
      // A box: a square on each of the octant's three outer sides.
      for (let axis = 0; axis < 3; axis += 1) {
        const [b, c] = [unit((axis + 1) % 3), unit((axis + 2) % 3)];
        const base = scale(unit(axis), R);
        const points = [base, add(base, scale(b, R)), add(add(base, scale(b, R)), scale(c, R)), add(base, scale(c, R))];
        quad(shape.sides[sideOf(axis, sign[axis])], points.map(at), Array(4).fill(out(unit(axis))));
      }
    } else if (rounded.length === 1) {
      // A quarter cylinder along the rounded edge, and its end, a quarter disc on the side.
      const [axis] = rounded;
      const [b, c] = [unit((axis + 1) % 3), unit((axis + 2) % 3)];
      const curve = (k: number) => scale(arc(b, c, k), R);
      const cap = scale(unit(axis), R);
      for (let k = 0; k < SEGMENTS; k += 1) {
        strip(shape.inner, k, curve, axis, () => R);
        triangle(
          shape.sides[sideOf(axis, sign[axis])],
          [cap, add(cap, curve(k)), add(cap, curve(k + 1))].map(at),
          Array(3).fill(out(unit(axis))),
        );
      }
    } else if (rounded.length === 2) {
      // Two cylinders, each along its edge only as far as it lies inside the other. The
      // cylinder along a reaches as far along a as its curve is out along b.
      const [a, b] = rounded;
      const c = 3 - a - b;
      const curveA = (k: number) => scale(arc(unit(b), unit(c), k), R);
      const curveB = (k: number) => scale(arc(unit(a), unit(c), k), R);
      for (let k = 0; k < SEGMENTS; k += 1) {
        strip(shape.inner, k, curveA, a, (p) => p[b]);
        strip(shape.inner, k, curveB, b, (p) => p[a]);
      }
    } else {
      // An eighth of a ball: a triangle of points, each pushed out onto the ball.
      const point = (i: number, j: number) =>
        scale(normalize(add(add(scale(unit(0), i), scale(unit(1), j)), scale(unit(2), SEGMENTS - i - j))), R);
      const ball = (points: Vec[]) =>
        triangle(shape.inner, points.map(at), points.map((p) => out(normalize(p))));
      for (let i = 0; i < SEGMENTS; i += 1) {
        for (let j = 0; j < SEGMENTS - i; j += 1) {
          ball([point(i, j), point(i + 1, j), point(i, j + 1)]);
          if (i + j + 1 < SEGMENTS) ball([point(i + 1, j), point(i + 1, j + 1), point(i, j + 1)]);
        }
      }
    }
  }
  shape.fullSides = 0b111111 & ~notWhole;
  return shape;
}
  • strip() makes one segment of a cylinder: two neighboring points of its curve, and the same two moved along the cylinder's axis. The length can differ per point, which is what clips one cylinder by the other.

  • For two rounded edges a and b, the cylinder along a reaches only as far along a as its curve is out along b. Where both are as far out, the two cylinders meet.

  • The ball's eighth is a triangle of points between its three axes, each pushed out onto the ball, and on its rim the same points as the cylinders' curves.

  • A side is whole only if none of its four octants has a rounded edge. A whole side can hide a neighbor's side.

A shape is made once and kept, by its cuts. Add to src/shapes.ts:

src/shapes.ts
const shapes = new Map<number, Shape>();

/** The shape of a block with the edges in `cuts` rounded, made once and kept. */
export function shapeOf(cuts: number): Shape {
  let shape = shapes.get(cuts);
  if (!shape) {
    shape = roundShape(cuts);
    shapes.set(cuts, shape);
  }
  return shape;
}

Meshing shapes

The mesher now places each block's shape, scaled to the block, instead of six squares. The inner part, the curves, is always drawn. A side is drawn unless the cells beyond it hold blocks whose own sides there are whole: a rounded neighbor no longer hides you. Replace src/blockMesher.ts:

src/blockMesher.ts
import { CELL, type Block, type BlockStore } from './blocks.ts';
import { MeshBuilder, type MeshData } from './meshData.ts';
import { shapeOf, sideOf, type ShapeMesh } from './shapes.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. */
export 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 })));

/**
 * Whether blocks fill every cell right beyond a side of a block, each with its own side
 * there whole. Then that side can't be seen, and is left out.
 */
export 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];
  const facing = 1 << sideOf(axis, -dir);
  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;
      const other = store.at(cell[0], cell[1], cell[2]);
      if (!other || !(shapeOf(other.cuts).fullSides & facing)) return false;
    }
  }
  return true;
}

/** Meshes blocks: each block's shape, without the sides its neighbors cover. */
export function meshBlocks(blocks: Iterable<Block>, store: BlockStore): BlockMesh | null {
  const builder = new MeshBuilder();
  const materials: number[] = [];
  for (const block of blocks) {
    const shape = shapeOf(block.cuts);
    const meters = block.size * CELL;
    const parts: ShapeMesh[] = [shape.inner];
    SIDES.forEach((side, s) => {
      if (!covered(store, block, side)) parts.push(shape.sides[s]);
    });
    // The shape is a unit cube: scaled to the block and moved to its place.
    for (const { positions, normals, indices } of parts) {
      const vertices: number[] = [];
      for (let i = 0; i < positions.length; i += 3) {
        materials.push(block.material);
        vertices.push(builder.vertex(
          block.x * CELL + positions[i] * meters,
          block.y * CELL + positions[i + 1] * meters,
          block.z * CELL + positions[i + 2] * meters,
          normals[i],
          normals[i + 1],
          normals[i + 2],
        ));
      }
      for (let i = 0; i < indices.length; i += 3) {
        builder.triangle(vertices[indices[i]], vertices[indices[i + 1]], vertices[indices[i + 2]]);
      }
    }
  }
  if (materials.length === 0) return null;
  return { ...builder.build(), materials: new Float32Array(materials) };
}

Walking on curves

The player should walk over a rounded block, not over the box around it. A rounded shape is the box with some parts taken away, so its signed distance is the box's, or that of a curve, whichever is larger: a point is inside the shape only if it is inside all of them. For a point in an octant, those are the cylinders of the octant's rounded edges, or the ball if all three are. A cylinder's distance is the point's distance from its axis less the radius, and the axis runs through the cube's middle. Add the rest to src/shapes.ts:

src/shapes.ts
/** How far a point is from a shape, and which way is out. */
export interface ShapeDistance {
  /** Below 0 inside. */
  distance: number;
  normal: Vec;
}

/**
 * The signed distance from a point of the cube to a shape. The box gives one distance,
 * each rounded edge of the point's octant another: the cylinder's or the ball's. The
 * shape is inside all of them, so the largest one counts. Inside the box, the way out
 * is through the nearest side for which `isOpen(side)` says yes, or up.
 */
export function shapeDistance(
  cuts: number,
  x: number,
  y: number,
  z: number,
  isOpen: (side: number) => boolean,
): ShapeDistance {
  // From the middle of the cube.
  const p: Vec = [x - 0.5, y - 0.5, z - 0.5];
  const q = p.map((c) => Math.abs(c) - R);
  const outside = q.map((c) => Math.max(c, 0));
  const length = Math.hypot(...outside);
  let nearest: ShapeDistance;
  if (length > 0) {
    // Out of the box from its nearest point.
    nearest = { distance: length, normal: outside.map((c, axis) => (c / length) * Math.sign(p[axis])) as Vec };
  } else {
    // Inside the box: each side's plane is dir × p[axis] - R away, below 0.
    let side = 3;
    let distance = -Infinity;
    for (let s = 0; s < 6; s += 1) {
      const d = (s % 2 ? 1 : -1) * p[Math.floor(s / 2)] - R;
      if (d > distance && isOpen(s)) {
        side = s;
        distance = d;
      }
    }
    if (distance === -Infinity) distance = p[1] - R;
    const normal: Vec = [0, 0, 0];
    normal[Math.floor(side / 2)] = side % 2 ? 1 : -1;
    nearest = { distance, normal };
  }
  if (cuts === 0) return nearest;

  const ends = p.map((c) => (c > 0 ? 1 : 0));
  const sign = ends.map((end) => (end ? 1 : -1));
  const a = p.map(Math.abs);
  const rounded = [0, 1, 2].filter((axis) => cuts & (1 << edgeOf(axis, ends)));
  // Each rounded edge's surface: the ball's when all three are, else a cylinder's.
  const curves = rounded.length === 3 ? [-1] : rounded;
  for (const axis of curves) {
    const along = [0, 1, 2].map((other) => (other === axis ? 0 : a[other]));
    const l = Math.hypot(...along);
    if (l === 0 || l - R <= nearest.distance) continue;
    nearest = { distance: l - R, normal: along.map((c, other) => (c / l) * sign[other]) as Vec };
  }
  return nearest;
}

The box part is the one from part 3, measured in the unit cube. The largest of several signed distances isn't the exact distance to their intersection everywhere, only near its surfaces. That's all the player needs. In src/solids.ts, replace blockDistance(), which now scales the shape's distance to the block, and import shapeDistance from ./shapes.ts:

src/solids.ts
/**
 * The distance from a point to a block, below 0 inside it: the distance to its shape,
 * scaled from the unit cube to the block. Inside, the way out is through the nearest side
 * for which `isOpen(side)` says yes, by the side's number in SIDES.
 */
export function blockDistance(
  block: Block,
  x: number,
  y: number,
  z: number,
  isOpen: (side: number) => boolean = () => true,
): Surface {
  const meters = block.size * CELL;
  const { distance, normal } = shapeDistance(
    block.cuts,
    (x - block.x * CELL) / meters,
    (y - block.y * CELL) / meters,
    (z - block.z * CELL) / meters,
    isOpen,
  );
  return { distance: distance * meters, normal };
}

Aiming at a block

Two tools aim at blocks now, so aiming moves into a file of its own. On a rounded block the normal says little about the side: on the curve it leans between two. So the side is the axis along which the hit point lies furthest from the block's middle. On a flat side that's the same as before. Create src/aim.ts:

src/aim.ts
import { Vector3, type Camera, type Scene } from '@babylonjs/core/pure';
import { CELL, type Block, type BlockStore } from './blocks.ts';

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

/** What the camera's ray hits first within `reach` meters. */
export function aimAt(scene: Scene, camera: Camera, store: BlockStore, reach: number): Aim | null {
  const hit = scene.pickWithRay(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 surface that was hit is in the block's cells.
    const inside = point.subtract(normal.scale(0.01));
    const block = store.at(Math.floor(inside.x / CELL), Math.floor(inside.y / CELL), Math.floor(inside.z / CELL));
    if (block) {
      // The side of the box the point is nearest to, also on a rounded surface: the
      // axis along which it lies furthest from the block's middle.
      const meters = block.size * CELL;
      const local = [point.x - block.x * CELL, point.y - block.y * CELL, point.z - block.z * CELL].map(
        (c) => c / meters - 0.5,
      );
      const axis = [0, 1, 2].reduce((best, a) => (Math.abs(local[a]) > Math.abs(local[best]) ? a : best));
      return { kind: 'block', block, axis, dir: Math.sign(local[axis]) || 1, point };
    }
  }
  return { kind: 'ground', point, normal };
}

The block tool uses it, and exports its marker and its reach for the chisel. New blocks start sharp. Replace src/buildTool.ts:

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

/** How far the player reaches with blocks, in meters. */
export 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;

/** A see-through box that marks a place, never in the way of the camera's ray. */
export 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;
  /** Whether a block may go somewhere, besides fitting between the other blocks. */
  canPlace: (block: Block) => boolean = () => true;
  /** 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 = aimAt(this.scene, this.camera, this.store, REACH);
    if (!aim) {
      this.clicked = null;
      return;
    }
    const block = this.placement(aim);
    const fits = this.store.fits(block.x, block.y, block.z, block.size) && this.canPlace(block);
    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);
  }

  /** 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, cuts: 0 };
    }
    // 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, cuts: 0 };
  }
}

The chisel

Where on a block does the chisel aim? On the side that was hit, look where the point lies across it, from 0 to 1 on its two axes. Within a quarter of a border on both axes, it is near a corner; on one, near an edge; on neither, in the middle. The mark follows: a small cube on a corner, a thin bar along an edge, a thin plate on a side. Create src/chiselTool.ts:

src/chiselTool.ts
import { Color3, type Camera, type Mesh, type Scene, type StandardMaterial } from '@babylonjs/core/pure';
import { aimAt } from './aim.ts';
import { CELL, type Block, type BlockStore } from './blocks.ts';
import { createMarker, REACH } from './buildTool.ts';
import { cornerEdges, edgeOf, sideEdges } from './shapes.ts';

/** How near to a side's border, as a share of the side, the aim picks an edge. */
const BORDER = 0.25;
/** Meters across the marks of an edge and a corner. */
const THIN = 0.06;
const CORNER = 0.16;

/** What a click of the chisel changes: some edges of a block, and where to mark them. */
interface Target {
  block: Block;
  edges: number[];
  /** The mark: its middle and its size along each axis, in meters. */
  middle: number[];
  extent: number[];
}

/**
 * Where on a block the chisel aims. On the side that was hit, near a corner it takes the
 * corner's three edges, near a border the edge there, and in the middle the side's four.
 */
function targetOf(block: Block, axis: number, dir: number, point: number[]): Target {
  const meters = block.size * CELL;
  const low = [block.x, block.y, block.z].map((c) => c * CELL);
  // Where on the block the point lies, from 0 to 1 on each axis.
  const local = point.map((c, a) => (c - low[a]) / meters);
  const u = (axis + 1) % 3;
  const v = (axis + 2) % 3;
  const near = (t: number) => (t < BORDER ? 0 : t > 1 - BORDER ? 1 : -1);
  const ends = [0, 0, 0];
  ends[axis] = dir > 0 ? 1 : 0;
  ends[u] = near(local[u]);
  ends[v] = near(local[v]);

  // A corner, an edge or the side: the mark sits there, thin where it isn't long.
  const middle = ends.map((end, a) => low[a] + (end < 0 ? 0.5 : end) * meters);
  const extent = ends.map((end) => (end < 0 ? meters : THIN));
  let edges: number[];
  if (ends[u] >= 0 && ends[v] >= 0) {
    edges = cornerEdges(ends);
    extent.fill(CORNER);
  } else if (ends[u] >= 0) {
    edges = [edgeOf(v, ends)];
  } else if (ends[v] >= 0) {
    edges = [edgeOf(u, ends)];
  } else {
    edges = sideEdges(axis, dir);
    extent[axis] = THIN;
    extent[u] = extent[v] = meters * 0.9;
  }
  return { block, edges, middle, extent };
}

/** Rounds the edges of blocks, or makes them sharp again. */
export class ChiselTool {
  /** A click not acted on yet. */
  private clicked: 'cut' | 'sharpen' | null = null;
  private readonly mark: 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.mark = createMarker('chisel', scene);
    const material = this.mark.material as StandardMaterial;
    material.emissiveColor = Color3.FromHexString('#f97316');
    material.alpha = 0.7;
  }

  start(action: 'cut' | 'sharpen'): void {
    this.clicked = action;
  }

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

  update(): void {
    this.hide();
    const aim = aimAt(this.scene, this.camera, this.store, REACH);
    if (aim?.kind !== 'block') {
      this.clicked = null;
      return;
    }
    const { point } = aim;
    const target = targetOf(aim.block, aim.axis, aim.dir, [point.x, point.y, point.z]);
    this.mark.position.set(target.middle[0], target.middle[1], target.middle[2]);
    this.mark.scaling.set(target.extent[0], target.extent[1], target.extent[2]);
    this.mark.isVisible = true;

    if (!this.clicked) return;
    const mask = target.edges.reduce((bits, edge) => bits | (1 << edge), 0);
    const { block } = target;
    this.store.reshape(block, this.clicked === 'cut' ? block.cuts | mask : block.cuts & ~mask);
    this.clicked = null;
  }
}

A left click sets the bits of the edges it aims at, a right click clears them. Rounding an edge that already is round changes nothing, and the store doesn't even rebuild the mesh.

Three tools

In src/main.ts, add the chisel to the tools:

src/main.ts
// Three tools: one digs and builds the terrain, one places and breaks blocks, and one
// rounds their edges.
const digTool = new DigTool(scene, camera, world);
const blockTool = new BlockTool(scene, camera, blocks.store);
const chiselTool = new ChiselTool(scene, camera, blocks.store);
let tool: 'terrain' | 'blocks' | 'chisel' = 'blocks';

Give it the mouse buttons:

src/main.ts
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 (tool === 'blocks') {
    if (event.button === 0) blockTool.start('break');
    if (event.button === 2) blockTool.start('place');
  } else {
    if (event.button === 0) chiselTool.start('cut');
    if (event.button === 2) chiselTool.start('sharpen');
  }
});

The wheel only changes the material when the block tool is in hand: if (tool === 'blocks') pickMaterial(-steps);. Add if (event.code === 'KeyC') tool = 'chisel'; to the keys. In the frame, each tool updates or hides:

src/main.ts
if (tool === 'terrain') digTool.update(now);
else digTool.hide();
if (tool === 'blocks') blockTool.update(now);
else blockTool.hide();
if (tool === 'chisel') chiselTool.update();
else chiselTool.hide();

And the HUD says which tool is in hand:

src/main.ts
const toolLine = {
  terrain: `Terrain tool, ${digTool.radius.toFixed(1)} m`,
  blocks: `Blocks: ${MATERIALS[blockTool.material].name}, ${blockTool.size * CELL} m, ${blocks.store.count} placed`,
  chisel: 'Chisel: rounds edges',
}[tool];

Add the chisel to the help in 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="bar"></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 · C chisel: left round, right sharp ·
      T terrain: left dig, right build, wheel size · M mesher
    </div>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

Try it

Put down a block, press C, and aim at its four upright edges one after the other: four clicks, and it is a round pillar. Aim at the middle of a side and all four of its edges round at once. Near a corner of another block, a click rounds the corner; round all four top corners and it becomes a dome, all eight and it is a ball. Walk over the ball and feel the player roll off it.

One block, many shapes: rounded top edges, a pillar, a dome, a ball, a single rounded edge, and small balls between cubes.
Rounded edges. Click, then: C is the chisel, left click rounds what lights up, right click makes it sharp. B builds blocks, T digs, F flies.
Open in a new tab

Comments

The comments are GitHub Discussions: sign in with GitHub to write one.

The comments come from GitHub, through giscus. Loading them sends your IP address to giscus.app and GitHub.