Skip to content

BlockShape ​

A shape is the geometry a block draws and collides with, named by BlockDefinition.shapeId. Every VoxelView registers the built-in shapes in view.shapes; add your own with the shapes view option or view.shapes.register(). See creating custom shapes for a full example.

ts
interface BlockShape {
  readonly id: BlockShapeID;
  readonly faces: readonly FaceDefinition[];
  readonly collisionHint: BlockCollisionHint;

  occludes(face: Face): boolean;
}

type BlockShapeID = "cube" | "slabBottom" | ... | (string & {});
type BlockCollisionHint = "box" | "trimesh" | "none";

BlockShapeID lists the built-in ids but accepts any string. A block naming an unregistered shape is not drawn.

occludes(face) returns true when the shape covers that whole side of its cell, which hides the neighbour's face against it. Return false for partial coverage: a wrong true removes visible geometry from the neighbour.

collisionHint picks the collider a VoxelCollider builds: a box, a triangle mesh of the faces, or nothing.

Built-in shapes ​

Available block shapes

Shape idClassCollisionOccludesTexture slots
cubenew Cube(id?)boxallsix faces
slabBottomnew Slab("bottom", id?)boxNegYsix faces
slabTopnew Slab("top", id?)boxPosYsix faces
slabBeamnew Slab("beam", id?)boxnonesix faces
slabCornernew Slab("corner", id?)boxnonesix faces
slabNotchnew Slab("notch", id?)trimeshnoneadds left.1, back.1
wallnew Wall("straight", id?)boxnonesix faces
wallCornernew Wall("corner", id?)trimeshnoneadds right.1, front.1
wallTeenew Wall("tee", id?)trimeshnoneadds right.1, front.1, back.1
wallCrossnew Wall("cross", id?)trimeshnoneadds a .1 slot on each side
poleYnew PoleY(id?)boxnonesix faces
polenew Pole("straight", "none", id?)boxnonesix faces
poleCornernew Pole("corner", "none", id?)trimeshnoneadds right.1, front.1
poleTeenew Pole("tee", "none", id?)trimeshnoneadds right.1, front.1, back.1
poleCrossnew Pole("cross", "none", id?)trimeshnoneadds a .1 slot on each side
poleEndUpnew Pole("end", "up", id?)trimeshnoneadds top.1, front.1
poleUpnew Pole("straight", "up", id?)trimeshnoneadds top.1, front.1, back.1
poleCornerUpnew Pole("corner", "up", id?)trimeshnoneadds right.1, top.1, front.1
poleTeeUpnew Pole("tee", "up", id?)trimeshnoneadds right.1, top.1, front.1, back.1
poleCrossUpnew Pole("cross", "up", id?)trimeshnoneadds a .1 slot on each side but bottom
poleEndThroughnew Pole("end", "through", id?)trimeshnoneadds top.1, bottom.1, front.1
poleThroughnew Pole("straight", "through", id?)trimeshnoneadds top.1, bottom.1, front.1, back.1
poleCornerThroughnew Pole("corner", "through", id?)trimeshnoneadds right.1, top.1, bottom.1, front.1
poleTeeThroughnew Pole("tee", "through", id?)trimeshnoneadds a .1 slot on each side but left
poleCrossThroughnew Pole("cross", "through", id?)trimeshnoneadds a .1 slot on each side
rampnew Ramp(id?)trimeshNegY, PosZno back
rampCornerInnernew RampCornerInner(id?)trimeshNegY, PosZ, PosXsix faces
rampCornerOuternew RampCornerOuter(id?)trimeshNegYno top
rampTipnew RampTip(id?)trimeshnoneno right, front
rampValleynew RampValley(id?)trimeshPosX, PosY, NegZno bottom; adds left.1, front.1
stairnew Stair(id?)trimeshNegY, PosZadds top.1, back.1
stairCornerInnernew StairCornerInner(id?)trimeshNegY, PosZ, PosXadds left.1, top.1, back.1
stairCornerOuternew StairCornerOuter(id?)trimeshNegYadds right.1, top.1, front.1
stairCornerPeaknew StairCornerPeak(id?)trimeshnoneadds right.1, top.1, front.1

Slab takes a SlabType ("bottom" | "top" | "beam" | "corner" | "notch", default "bottom"). A beam is half a bottom slab, a corner a quarter, and a notch three quarters, the missing quarter at the -X, -Z corner. The six face slots are right, left, top, bottom, front and back; see texture slots.

Wall takes a WallType ("straight" | "corner" | "tee" | "cross", default "straight") and builds a full-height wall a quarter block thick, centered in the cell. Pole takes a PoleType with the same values, default "straight", and a PoleRise ("none" | "up" | "through", default "none") that adds an arm to +Y, or arms to both +Y and -Y. With a rise, the type may also be "end": a single horizontal arm to +Z. Walls and horizontal poles run along Z: a corner branches to +Z and +X, a tee adds a +X arm to a straight piece, and a cross runs along both axes. Every arm ends on the same section as pole or poleY, so joined blocks hide the faces they share. A rise placed with flipY: true points down.

stairCornerPeak is a full-height quarter column standing on an L-shaped lower step, with the opposite quarter left empty. The column sits where stairCornerOuter puts its upper step.

Ceiling ramps and upside-down stairs are the same shapes placed with flipY: true, see VoxelTransform.

Complements ​

Each shape below has a complement that fills the rest of its cell. Placed with the original's transform, the pair forms a full cube. A flipped complement toggles flipY; a flipped and turned one also turns rotation by two more quarter turns.

ShapeComplement
slabBottomslabTop
stairslabBeam, flipped
stairCornerInnerslabCorner, flipped
stairCornerOuterslabNotch, flipped
stairCornerPeakstairCornerPeak, flipped and turned
rampramp, flipped and turned
rampCornerInnerrampTip
rampCornerOuterrampValley

slabBeam, slabCorner and slabNotch rest on the floor like slabBottom, so their stair complement is the flipped piece.

A shape and its complement can share one cell, see merged cells. Complements are found from the geometry, so custom shapes pair up without declaring anything:

ts
class ShapeOccupancy {
  static fromShape(shape: BlockShape, transform?: VoxelTransform): ShapeOccupancy;
  complements(other: ShapeOccupancy): boolean;
  contains(point: Vec3): boolean;
}

class BlockComplements {
  constructor(options: { blocks: BlockRegistry; shapes: BlockShapeRegistry });
  complements(a: PackedVoxel, b: PackedVoxel): boolean;
  resolveOccupancy(packed: PackedVoxel): ShapeOccupancy | null;
}

ShapeOccupancy.fromShape() samples which parts of the cell a placed shape fills and caches the result per shape and transform. Two occupancies complement each other when they never overlap, leave no gap, and together cover all six sides of the cell. BlockComplements answers the same question for two placed blocks; an unknown block or shape is never a complement.

contains() tests one point exactly, in cell space (0..1 on each axis), against the placed faces rather than the samples. A point on a face may go either way. resolveOccupancy() returns the occupancy of a placed block, or null when its block or shape is unknown.

BlockShapeBase ​

ts
abstract class BlockShapeBase implements BlockShape {
  abstract readonly id: BlockShapeID;
  abstract readonly faces: readonly FaceDefinition[];
  abstract readonly collisionHint: BlockCollisionHint;

  occludes(face: Face): boolean;
}

function computeOcclusionMask(faces: readonly FaceDefinition[]): number;
function computeSideCoverage(faces: Iterable<FacePlacement>): Float64Array;

Derives occludes() from faces: a side occludes when the faces lying on its boundary plane cover the whole unit square. Faces sharing a side must not overlap, or their areas add up and the side reports covered when it is not. Override occludes() when the geometry does not tell the truth, for example a full quad drawn through an alpha mask. computeOcclusionMask() returns the same answer as a bitmask indexed by Face, and computeSideCoverage() the covered area of each side, indexed by Face. Every built-in shape extends this class.

BlockShapeRegistry ​

ts
class BlockShapeRegistry implements Iterable<BlockShape> {
  readonly version: number;

  static createDefault(): BlockShapeRegistry;
  register(shape: BlockShape): this;
  registerMany(shapes: Iterable<BlockShape>): this;
  get(id: BlockShapeID): BlockShape | undefined;
  has(id: BlockShapeID): boolean;
  getAll(): IterableIterator<BlockShape>;
  ids(): IterableIterator<BlockShapeID>;
}

register() replaces a shape with the same id. Iteration follows registration order. version increases on each registration. createDefault() returns a registry holding the built-in shapes.

Faces ​

ts
interface FaceDescriptor {
  face: Face;
  normal: [number, number, number];
  vertices: readonly [number, number, number][];
  uvs?: readonly [number, number][];
  cull?: Face | null;
  slot?: string;
}

interface FaceDefinition {
  readonly face: Face;
  readonly normal: [number, number, number];
  readonly vertices: readonly [number, number, number][];
  readonly uvs: readonly [number, number][];
  readonly cull: Face | null;
  readonly slot?: string | null;
  readonly span?: Readonly<TileSpan>;
}

function defineFace(descriptor: FaceDescriptor): FaceDefinition;
ts
defineFace({
  face: Face.PosZ,
  normal: [0, 0, 1],
  vertices: [[0, 0, 1], [1, 0, 1], [1, 1, 1], [0, 1, 1]]
});

A face has three or four vertices in block space, 0 to 1 on each axis. face is the side it belongs to, which picks its texture slot and its default culling. defineFace() fills in uvs, cull and span. slot pins the face to a named texture slot, see pinning a slot.

ts
const Face = {
  PosX: 0, NegX: 1, PosY: 2, NegY: 3, PosZ: 4, NegZ: 5
} as const;

Default culling ​

ts
interface FacePlacement {
  face: Face;
  vertices: readonly [number, number, number][];
}

function isBoundaryFace(placement: FacePlacement): boolean;
function defaultCullFace(placement: FacePlacement): Face | null;

When cull is omitted, a face whose vertices all lie on its side's boundary plane is culled against that side, and any other face is never culled. defaultCullFace() returns that value. Pass cull: null to never cull a face, or another side to cull against it instead.

Face UV convention ​

When uvs is omitted, each vertex is projected onto its face, seen from outside the block, with u to the right and v up:

Faceuv
PosX1 - zy
NegXzy
PosY1 - xz
NegY1 - x1 - z
PosZxy
NegZ1 - xy

A face samples only the part of the tile it covers: a pole side spans u0.375 to 0.625, a slabBottom side spans v 0 to 0.5. One texture therefore continues across neighbouring blocks of different shapes. projectFaceUv(face, vertex) and faceUvs(face, vertices) compute the projection. Pass explicit uvs to repeat or rotate a tile on purpose.

Slanted faces ​

ts
function faceUvSpan(
  face: Face,
  normal: [number, number, number]
): Readonly<TileSpan>;

A ramp slope is √2 blocks long but its UVs span 0 to 1. faceUvSpan() returns the tiles the face really covers, { u: 1, v: √2 } for a ramp slope, and defineFace() stores it as span. A face given explicit uvs, or leaning along both axes, keeps { u: 1, v: 1 }.