Skip to content
DasPeTe Tutorials

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

Part 5: Slopes and Roofs

Give the chisel a second profile: an edge cut flat from side to side. A cube clipped by one plane per edge makes wedges, ridges, pyramids and hip corners; the player walks up them like ramps, and slate lies on a roof in level rows.

Quest objectives

  • Describe a sloped edge as a half space, and cut the cube down plane by plane
  • Clip polygons against a plane, and close each cut with a new face
  • Measure a shape's volume, and refuse a cut that would leave nothing
  • Lay a texture on a slope along a level axis, so that its rows stay level
  • Switch the chisel between round and slope

Rounded edges make columns and domes. A house wants something else on top: a roof, with flat slopes meeting at a ridge. This part gives the chisel a second profile: instead of rounding an edge, it cuts it off flat, from the far edge of one side to the far edge of the other. One sloped edge makes a wedge, half a cube, a ramp the player walks up. Two on opposite top edges make a ridge, four around the top a pyramid, and two meeting at a corner a hip, where two roofs join.

A block has one profile for all its cut edges. Chiseling it with the other profile switches the block, and keeps which edges are cut.

Half spaces

A sloped edge keeps the points of the cube whose distances from the edge's two sides add up to at least one block. On the edge itself both distances are 0; on the far edges of its two sides, one of them is 1: the cut runs straight between those. Written out, that's one inequality, dot(gradient, p) ≥ offset, a half space. A shape with several sloped edges is the cube cut by all their half spaces. Each half space is convex, and so is what's left.

Cutting a cube

The shape starts as the cube's six sides, each a square polygon. Each plane cuts every polygon: going round its corners, a corner inside stays, and where an edge crosses the plane, the crossing becomes a new corner. That is the Sutherland–Hodgman way of clipping. The crossings are kept: together they are the corners of the new face the cut leaves behind, the slope. Sorted by their angle around their middle, they make a polygon. The next plane cuts the slopes made so far like the sides.

The sides that are left still lie flat on the cube's sides, and a neighbor can still hide them, as in part 4. The slopes go with the curves, never hidden.

A texture on a slope

The blend of part 2 picks a projection by the normal raised to the 8th power. A slope at 45° faces two axes equally, and gets half of each: two textures on top of each other, a blur. So each vertex now says along which axis its texture is projected, or -1 for a blend, which only curves need. A side is projected along its own axis. A slope is projected along the level one of the two axes it leans between: a roof sloping down to the north is seen from the north, and its rows of slate run level and climb with the roof.

The shape's parts get a projection per vertex, and the shape a volume, which the chisel needs. In src/shapes.ts, replace ShapeMesh and Shape:

src/shapes.ts
/**
 * Triangles as plain lists: positions, normals and indices, and for each vertex the axis
 * along which its texture is projected, or -1 for a blend of all three, on curves.
 */
export interface ShapeMesh {
  positions: number[];
  normals: number[];
  projections: 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;
  /** How much of the cube the shape fills, from 0 to 1. */
  volume: number;
}

triangle() and quad() take the projection along. Replace them, and emptyMesh():

src/shapes.ts
function emptyMesh(): ShapeMesh {
  return { positions: [], normals: [], projections: [], 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[], projection = -1): 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.projections.push(projection);
  }
  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[], projection = -1): void {
  triangle(mesh, [points[0], points[1], points[2]], [normals[0], normals[1], normals[2]], projection);
  triangle(mesh, [points[0], points[2], points[3]], [normals[0], normals[2], normals[3]], projection);
}

In the rounded shape, the squares of a box and the quarter discs of a cylinder's end lie flat on a side, and are projected along it; the curves keep the blend. The shape starts with a volume of 0 and measures itself at the end. Replace roundShape():

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, volume: 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))), 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))),
          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;
  shape.volume = volumeOf(shape);
  return shape;
}

Now the sloped shape. Add after roundShape():

src/shapes.ts
/**
 * The half space a sloped edge keeps: the points p of the cube with dot(gradient, p) at
 * least `offset`. They are the points whose distances from the edge's two sides add up to
 * at least one block, so the cut runs straight from one side's far edge to the other's.
 */
function slopePlane(edge: number): { gradient: Vec; offset: number } {
  const axis = Math.floor(edge / 4);
  const ends = [edge & 1, (edge >> 1) & 1];
  const gradient: Vec = [0, 0, 0];
  gradient[(axis + 1) % 3] = ends[0] ? -1 : 1;
  gradient[(axis + 2) % 3] = ends[1] ? -1 : 1;
  return { gradient, offset: 1 - ends[0] - ends[1] };
}

/** A flat face of a shape: its corners in order around it, and the side it lies on or -1. */
interface Polygon {
  points: Vec[];
  normal: Vec;
  side: number;
}

const dot = facing[0] * lean[0] + facing[1] * lean[1] + facing[2] * lean[2];

/**
 * Cuts the part of a polygon off that lies outside a half space, going round its corners:
 * a corner inside stays, and where an edge crosses the plane, the crossing becomes a
 * corner, and is noted in `crossings`.
 */
function clip(polygon: Polygon, gradient: Vec, offset: number, crossings: Vec[]): Polygon {
  const points: Vec[] = [];
  polygon.points.forEach((current, i) => {
    const next = polygon.points[(i + 1) % polygon.points.length];
    const a = dot(gradient, current) - offset;
    const b = dot(gradient, next) - offset;
    if (a >= -1e-9) points.push(current);
    if (a >= -1e-9 !== b >= -1e-9) {
      const t = a / (a - b);
      const crossing = add(current, scale(add(next, scale(current, -1)), t));
      points.push(crossing);
      crossings.push(crossing);
    }
  });
  return { ...polygon, points };
}

/** The new face a cut leaves: the crossings, without doubles, in order around their middle. */
function capOf(crossings: Vec[], normal: Vec): Vec[] {
  const points = crossings.filter((p, i) => crossings.findIndex((q) => Math.hypot(...add(p, scale(q, -1))) < 1e-6) === i);
  if (points.length < 3) return [];
  const middle = scale(points.reduce(add), 1 / points.length);
  // Two directions in the cap's plane, to measure each point's angle around the middle.
  const helper: Vec = Math.abs(normal[1]) < 0.9 ? [0, 1, 0] : [1, 0, 0];
  const across: Vec = [
    normal[1] * helper[2] - normal[2] * helper[1],
    normal[2] * helper[0] - normal[0] * helper[2],
    normal[0] * helper[1] - normal[1] * helper[0],
  ];
  const up: Vec = [
    normal[1] * across[2] - normal[2] * across[1],
    normal[2] * across[0] - normal[0] * across[2],
    normal[0] * across[1] - normal[1] * across[0],
  ];
  const angle = (p: Vec) => {
    const d = add(p, scale(middle, -1));
    return Math.atan2(dot(d, up), dot(d, across));
  };
  return points.sort((p, q) => angle(p) - angle(q));
}

/** Twice a polygon's area. */
function area(points: Vec[]): number {
  let sum: Vec = [0, 0, 0];
  points.forEach((p, i) => {
    const q = points[(i + 1) % points.length];
    sum = add(sum, [p[1] * q[2] - p[2] * q[1], p[2] * q[0] - p[0] * q[2], p[0] * q[1] - p[1] * q[0]]);
  });
  return Math.hypot(...sum);
}

/**
 * The shape with the edges in `cuts` sloped: the cube, cut flat by one plane per edge.
 * The cube's six sides are cut down one plane after another, and each plane leaves a new
 * face where it cut, made of the points where it crossed the faces.
 */
function slopeShape(cuts: number): Shape {
  let polygons: Polygon[] = [];
  for (let side = 0; side < 6; side += 1) {
    const axis = Math.floor(side / 2);
    const [b, c] = [unit((axis + 1) % 3), unit((axis + 2) % 3)];
    const base = side % 2 ? unit(axis) : ([0, 0, 0] as Vec);
    const points = [base, add(base, b), add(add(base, b), c), add(base, c)];
    polygons.push({ points, normal: scale(unit(axis), side % 2 ? 1 : -1), side });
  }
  for (let edge = 0; edge < 12; edge += 1) {
    if (!(cuts & (1 << edge))) continue;
    const { gradient, offset } = slopePlane(edge);
    const crossings: Vec[] = [];
    polygons = polygons
      .map((polygon) => clip(polygon, gradient, offset, crossings))
      .filter((polygon) => polygon.points.length >= 3 && area(polygon.points) > 1e-9);
    // The cut's face looks out of the shape, against the gradient.
    const normal = normalize(scale(gradient, -1));
    const points = capOf(crossings, normal);
    if (points.length >= 3) polygons.push({ points, normal, side: -1 });
  }

  const shape: Shape = { sides: [0, 1, 2, 3, 4, 5].map(emptyMesh), inner: emptyMesh(), fullSides: 0, volume: 0 };
  for (const { points, normal, side } of polygons) {
    // A side's texture is projected along its axis. A slope's is too, along the
    // level one of the two axes it leans between, so its rows run level and go up.
    const leaning = [0, 2, 1].filter((axis) => normal[axis] !== 0);
    const projection = side >= 0 ? Math.floor(side / 2) : leaning.find((axis) => axis !== 1)!;
    const mesh = side >= 0 ? shape.sides[side] : shape.inner;
    for (let i = 1; i + 1 < points.length; i += 1) {
      triangle(mesh, [points[0], points[i], points[i + 1]], [normal, normal, normal], projection);
    }
    if (side >= 0 && area(points) > 2 - 1e-6) shape.fullSides |= 1 << side;
  }
  shape.volume = volumeOf(shape);
  return shape;
}

/** How much of the unit cube a closed mesh fills: the pyramids its triangles make with the origin, added up. */
function volumeOf(shape: Shape): number {
  let volume = 0;
  for (const { positions: p, indices } of [shape.inner, ...shape.sides]) {
    for (let i = 0; i < indices.length; i += 3) {
      const [a, b, c] = [0, 1, 2].map((k) => p.slice(indices[i + k] * 3, indices[i + k] * 3 + 3)) as Vec[];
      // a · (b × c) / 6 for each triangle, turned the way Babylon.js sees its front.
      volume -= dot(a, [b[1] * c[2] - b[2] * c[1], b[2] * c[0] - b[0] * c[2], b[0] * c[1] - b[1] * c[0]]) / 6;
    }
  }
  return volume;
}
  • slopePlane() turns an edge into its half space. The gradient points away from the edge's two sides.

  • clip() is Sutherland–Hodgman for one polygon and one plane. A tiny tolerance keeps corners that lie on the plane.

  • capOf() drops crossings that are found twice, where two polygons share an edge, and sorts the rest around their middle, in the plane of the cut.

  • slopeShape() cuts plane after plane, drops polygons that shrank to nothing, fans each polygon into triangles, and notes the sides that are still whole: their area is that of the full square.

  • volumeOf() adds up the pyramids the triangles make with the origin. For a closed mesh, the parts outside cancel, and what's left is its volume.

Some combinations leave nothing: two slopes on opposite edges along the same axis, or four slopes around an upright edge, each taking half the cube. Their volume is 0.

The shapes are kept by cuts and profile. Replace the end of the shapes' cache:

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

/** The shape of a block with the edges in `cuts` rounded or sloped, made once and kept. */
export function shapeOf(cuts: number, slope: boolean): Shape {
  const key = cuts + (slope ? 4096 : 0);
  let shape = shapes.get(key);
  if (!shape) {
    shape = slope ? slopeShape(cuts) : roundShape(cuts);
    shapes.set(key, shape);
  }
  return shape;
}

The signed distance of a sloped block is the box's or a plane's, whichever is larger, as with the curves: the plane's distance is how far short of its offset the point is, over the gradient's length, √2. Replace shapeDistance(), which takes the profile:

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 sloped edge its plane's, and each rounded edge of the point's octant 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,
  slope: boolean,
  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;

  if (slope) {
    for (let edge = 0; edge < 12; edge += 1) {
      if (!(cuts & (1 << edge))) continue;
      // The plane's distance: how far short of the offset the point is, over the
      // gradient's length, √2.
      const { gradient, offset } = slopePlane(edge);
      const d = (offset - dot(gradient, [x, y, z])) / Math.SQRT2;
      if (d > nearest.distance) nearest = { distance: d, normal: scale(gradient, -Math.SQRT1_2) };
    }
    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 profile of a block

In src/blocks.ts, a block gets its profile:

src/blocks.ts
/** A bit for each rounded or sloped edge, by the edge's number (see shapes.ts). */
cuts: number;
/** Whether the cut edges are sloped rather than rounded. */
slope: boolean;

And reshape() sets both:

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

New blocks in src/buildTool.ts start with slope: false, next to cuts: 0, in both places of placement(). In src/solids.ts, blockDistance() hands block.slope to shapeDistance(), after the cuts.

The projection in the mesh

The mesher passes each vertex's projection on, and looks shapes up by their profile. 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 and the axis its texture is
 * projected along. Positions are in meters.
 */
export interface BlockMesh extends MeshData {
  materials: Float32Array;
  projections: 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, other.slope).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[] = [];
  const projections: number[] = [];
  for (const block of blocks) {
    const shape = shapeOf(block.cuts, block.slope);
    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 part of parts) {
      const { positions, normals, indices } = part;
      const vertices: number[] = [];
      for (let i = 0; i < positions.length; i += 3) {
        materials.push(block.material);
        projections.push(part.projections[i / 3]);
        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), projections: new Float32Array(projections) };
}

In src/blockWorld.ts, set it on the mesh after the materials: mesh.setVerticesData('projection', data.projections, false, 1);. And in src/blockMaterial.ts, add 'projection' to the attributes, after 'material'. The vertex shader passes it on as a varying. Replace src/block.vertex.glsl:

src/block.vertex.glsl
precision highp float;

attribute vec3 position;
attribute vec3 normal;
attribute float material;
attribute float projection;
attribute float light;

uniform mat4 world;
uniform mat4 viewProjection;

varying vec3 vPosition;
varying vec3 vNormal;
varying vec3 vPattern;
varying vec3 vPatternNormal;
varying float vMaterial;
varying float vProjection;
varying float vLight;

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;
  // The pattern stays where it is on the block, however the block is turned.
  vPattern = position;
  vPatternNormal = normal;
  vMaterial = material;
  vProjection = projection;
  vLight = light;
  gl_Position = viewProjection * worldPosition;
}

In src/block.fragment.glsl, declare varying float vProjection; with the others, and replace the texture's lines at the start of main(). A projection of 0, 1 or 2 makes the blend all of that one axis:

src/block.fragment.glsl
// The material's texture, one per meter, projected along x, y and z. Flat faces say
// along which axis; rounded ones blend all three.
float layer = floor(vMaterial + 0.5);
vec3 blend = pow(abs(normalize(vPatternNormal)), vec3(8.0));
blend /= blend.x + blend.y + blend.z;
if (vProjection > -0.5) blend = vec3(step(abs(vProjection - vec3(0.0, 1.0, 2.0)), vec3(0.5)));
vec3 color = texture(layers, vec3(vPattern.zy, layer)).rgb * blend.x
  + texture(layers, vec3(vPattern.xz, layer)).rgb * blend.y
  + texture(layers, vec3(vPattern.xy, layer)).rgb * blend.z;

A chisel with two profiles

The chisel cuts in its own profile, and gives it to the whole block. A cut that would leave less than a tenth of the block is refused, and its mark turns red. A right click takes the cut back in either profile. Replace 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, shapeOf, 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;
/** A cut may not leave less of a block than this share of its cube. */
const LEAST_VOLUME = 0.1;

/** 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 or slopes the edges of blocks, or makes them sharp again. */
export class ChiselTool {
  /** Whether the chisel slopes edges rather than rounding them. */
  slope = false;
  /** 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;

    // A cut takes the chisel's profile for the whole block, and keeps its other cuts.
    // One that would leave (almost) nothing is not made: the mark turns red.
    const mask = target.edges.reduce((bits, edge) => bits | (1 << edge), 0);
    const { block } = target;
    const cut = { cuts: block.cuts | mask, slope: this.slope };
    const possible = shapeOf(cut.cuts, cut.slope).volume >= LEAST_VOLUME;
    (this.mark.material as StandardMaterial).emissiveColor = Color3.FromHexString(possible ? '#f97316' : '#ef4444');

    if (this.clicked === 'cut' && possible) this.store.reshape(block, cut.cuts, cut.slope);
    if (this.clicked === 'sharpen') this.store.reshape(block, block.cuts & ~mask, block.slope);
    this.clicked = null;
  }
}

In src/main.ts, R switches the profile: if (event.code === 'KeyR') chiselTool.slope = !chiselTool.slope;, next to the other keys. The HUD says which one 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: ${chiselTool.slope ? 'slopes' : 'rounds'} edges`,
}[tool];

And the help says it too. Replace 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 cut, right sharp, R round or slope ·
      T terrain: left dig, right build, wheel size · M mesher
    </div>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

Try it

Build four walls, then a row of blocks along the top of each long wall, one layer out. Press C, then R for slopes, and aim at the outer top edge of each block of the rows: they become the eaves. One layer up and one block in, do it again, until the two sides meet at the ridge. In slate, the rows of tiles run level and climb the roof. Then put a sloped block in front of a 1 m block and walk up: no jump needed. It's the ramp the walking part promised.

A house of plaster with a slate roof from sloped blocks, a copper pyramid, and a stone ramp.
Slopes and roofs. Click, then: C is the chisel, R switches between round and slope, left click cuts, right click makes 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.