Zum Inhalt springen
DasPeTe Tutorials

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

Teil 5: Schrägen und Dächer

Gib dem Meißel ein zweites Profil: eine Kante, flach von Seite zu Seite abgeschnitten. Ein Würfel, von einer Ebene pro Kante beschnitten, ergibt Keile, Firste, Pyramiden und Walmecken; die Spielfigur geht sie wie Rampen hinauf, und Schiefer liegt auf einem Dach in waagrechten Reihen.

Quest-Ziele

  • Eine geschrägte Kante als Halbraum beschreiben und den Würfel Ebene für Ebene beschneiden
  • Polygone an einer Ebene beschneiden und jeden Schnitt mit einer neuen Fläche schließen
  • Das Volumen einer Form messen und einen Schnitt ablehnen, der nichts übrig ließe
  • Eine Textur auf einer Schräge entlang einer waagrechten Achse auflegen, damit ihre Reihen waagrecht bleiben
  • Den Meißel zwischen rund und schräg umschalten

Gerundete Kanten ergeben Säulen und Kuppeln. Ein Haus will oben etwas anderes: ein Dach, mit ebenen Schrägen, die sich an einem First treffen. Dieser Teil gibt dem Meißel ein zweites Profil: Statt eine Kante zu runden, schneidet er sie flach ab, von der fernen Kante der einen Seite zur fernen Kante der anderen. Eine geschrägte Kante ergibt einen Keil, einen halben Würfel, eine Rampe, die die Spielfigur hinaufgeht. Zwei an gegenüberliegenden oberen Kanten ergeben einen First, vier um die Oberseite eine Pyramide, und zwei, die sich an einer Ecke treffen, einen Walm, wo zwei Dächer zusammenstoßen.

Ein Block hat ein Profil für alle seine geschnittenen Kanten. Ihn mit dem anderen Profil zu meißeln, schaltet den Block um und behält, welche Kanten geschnitten sind.

Halbräume

Eine geschrägte Kante behält die Punkte des Würfels, deren Abstände von den beiden Seiten der Kante zusammen mindestens einen Block ergeben. Auf der Kante selbst sind beide Abstände 0; an den fernen Kanten ihrer beiden Seiten ist einer davon 1: Der Schnitt läuft gerade zwischen ihnen. Ausgeschrieben ist das eine Ungleichung, dot(gradient, p) ≥ offset, ein Halbraum. Eine Form mit mehreren geschrägten Kanten ist der Würfel, beschnitten von all ihren Halbräumen. Jeder Halbraum ist konvex, und so ist es auch, was übrig bleibt.

Einen Würfel beschneiden

Die Form beginnt als die sechs Seiten des Würfels, jede ein quadratisches Polygon. Jede Ebene beschneidet jedes Polygon: Rund um seine Ecken bleibt eine Ecke, die innen liegt, und wo eine Kante die Ebene kreuzt, wird die Kreuzung zur neuen Ecke. Das ist der Sutherland-Hodgman-Weg des Beschneidens. Die Kreuzungen werden behalten: Zusammen sind sie die Ecken der neuen Fläche, die der Schnitt hinterlässt, der Schräge. Nach ihrem Winkel um ihre Mitte sortiert, ergeben sie ein Polygon. Die nächste Ebene beschneidet die bisher entstandenen Schrägen wie die Seiten.

Die Seiten, die übrig bleiben, liegen weiter flach auf den Seiten des Würfels, und ein Nachbar kann sie weiter verdecken, wie in Teil 4. Die Schrägen gehen zu den Kurven, nie verdeckt.

Eine Textur auf einer Schräge

Die Mischung aus Teil 2 wählt eine Projektion nach der Normalen hoch 8. Eine Schräge mit 45° schaut zwei Achsen gleich an und bekommt von jeder die Hälfte: zwei Texturen übereinander, ein Verschwimmen. Also sagt jeder Vertex jetzt, entlang welcher Achse seine Textur projiziert wird, oder -1 für eine Mischung, die nur Kurven brauchen. Eine Seite wird entlang ihrer eigenen Achse projiziert. Eine Schräge wird entlang der waagrechten der beiden Achsen projiziert, zwischen denen sie sich neigt: Ein Dach, das nach Norden abfällt, wird von Norden gesehen, und seine Schieferreihen laufen waagrecht und steigen mit dem Dach.

Die Teile der Form bekommen eine Projektion pro Vertex und die Form ein Volumen, das der Meißel braucht. Ersetze in src/shapes.ts ShapeMesh und 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() und quad() nehmen die Projektion mit. Ersetze sie und 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 der gerundeten Form liegen die Quadrate einer Box und die Viertelscheiben am Ende eines Zylinders flach auf einer Seite und werden entlang ihrer projiziert; die Kurven behalten die Mischung. Die Form beginnt mit einem Volumen von 0 und misst sich am Ende. Ersetze 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;
}

Jetzt die geschrägte Form. Ergänze nach 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() macht aus einer Kante ihren Halbraum. Der Gradient zeigt von den beiden Seiten der Kante weg.

  • clip() ist Sutherland-Hodgman für ein Polygon und eine Ebene. Eine winzige Toleranz behält Ecken, die auf der Ebene liegen.

  • capOf() verwirft Kreuzungen, die doppelt gefunden werden, wo zwei Polygone sich eine Kante teilen, und sortiert den Rest um seine Mitte, in der Ebene des Schnitts.

  • slopeShape() schneidet Ebene um Ebene, verwirft Polygone, die zu nichts geschrumpft sind, fächert jedes Polygon in Dreiecke und merkt sich die Seiten, die noch ganz sind: Ihre Fläche ist die des vollen Quadrats.

  • volumeOf() zählt die Pyramiden zusammen, die die Dreiecke mit dem Ursprung bilden. Bei einem geschlossenen Mesh heben sich die Teile außerhalb auf, und was bleibt, ist sein Volumen.

Manche Kombinationen lassen nichts übrig: zwei Schrägen an gegenüberliegenden Kanten entlang derselben Achse, oder vier Schrägen um eine aufrechte Kante, jede nimmt den halben Würfel. Ihr Volumen ist 0.

Die Formen werden nach Schnitten und Profil behalten. Ersetze das Ende des Caches der Formen:

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

Der vorzeichenbehaftete Abstand eines geschrägten Blocks ist der der Box oder einer Ebene, je nachdem, welcher größer ist, wie bei den Kurven: Der Abstand der Ebene ist, wie weit der Punkt hinter ihrem Offset zurückbleibt, geteilt durch die Länge des Gradienten, √2. Ersetze shapeDistance(), das das Profil nimmt:

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

Das Profil eines Blocks

In src/blocks.ts bekommt ein Block sein Profil:

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;

Und reshape() setzt beides:

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);
}

Neue Blöcke in src/buildTool.ts beginnen mit slope: false, neben cuts: 0, an beiden Stellen von placement(). In src/solids.ts gibt blockDistance() nach den Schnitten block.slope an shapeDistance() weiter.

Die Projektion im Mesh

Der Mesher gibt die Projektion jedes Vertex weiter und schlägt Formen nach ihrem Profil nach. Ersetze 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) };
}

Setz sie in src/blockWorld.ts nach den Materialien auf das Mesh: mesh.setVerticesData('projection', data.projections, false, 1);. Und ergänze in src/blockMaterial.ts 'projection' bei den Attributen, nach 'material'. Der Vertex-Shader gibt sie als Varying weiter. Ersetze 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;
}

Deklariere in src/block.fragment.glsl varying float vProjection; bei den anderen und ersetze die Zeilen der Textur am Anfang von main(). Eine Projektion von 0, 1 oder 2 macht die Mischung ganz zu dieser einen Achse:

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;

Ein Meißel mit zwei Profilen

Der Meißel schneidet in seinem eigenen Profil und gibt es dem ganzen Block. Ein Schnitt, der weniger als ein Zehntel des Blocks übrig ließe, wird abgelehnt, und seine Markierung wird rot. Ein Rechtsklick nimmt den Schnitt in beiden Profilen zurück. Ersetze 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 schaltet R das Profil um: if (event.code === 'KeyR') chiselTool.slope = !chiselTool.slope;, bei den anderen Tasten. Das HUD sagt, welches in der Hand ist:

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];

Und die Hilfe sagt es auch. Ersetze index.html:

index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Voxel building</title>
  </head>
  <body>
    <canvas id="game"></canvas>
    <div id="crosshair"></div>
    <div id="hud"></div>
    <div id="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>

Ausprobieren

Bau vier Wände, dann eine Reihe Blöcke entlang der Oberkante jeder langen Wand, eine Lage hinaus. Drück C, dann R für Schrägen, und ziel auf die äußere obere Kante jedes Blocks der Reihen: Sie werden die Traufe. Eine Lage höher und einen Block weiter innen tust du es wieder, bis sich beide Seiten am First treffen. In Schiefer laufen die Plattenreihen waagrecht und steigen das Dach hinauf. Setz dann einen geschrägten Block vor einen 1-m-Block und geh hinauf: kein Sprung nötig. Das ist die Rampe, die der Teil übers Gehen versprochen hat.

Ein Haus aus Putz mit einem Schieferdach aus geschrägten Blöcken, eine Kupferpyramide und eine Steinrampe.
Schrägen und Dächer. Klick, dann: C ist der Meißel, R schaltet zwischen rund und schräg, ein Linksklick schneidet, ein Rechtsklick macht scharf. B baut Blöcke, T gräbt, F fliegt.
In neuem Tab öffnen

Kommentare

Die Kommentare sind GitHub Discussions: Zum Schreiben mit GitHub anmelden.

Die Kommentare kommen über giscus von GitHub. Beim Laden gehen deine IP-Adresse an giscus.app und GitHub.