VoxelEngine
Voxel world engine: manages layers, blocks, tilesets, and hooks, and builds chunked Three.js meshes. Use it directly, or through VoxelRenderer, which exposes it as vr.engine.
const loader = new TilesetLoader();
await loader.fromTileDefinition({
id: "default",
src: "tileset.png",
tileSize: 16
});
const engine = new VoxelEngine({
tilesetLoader: loader,
layers: ["Ground"],
blocks: [
{
id: 1,
name: "Grass",
shapeId: "cube",
collidable: true,
faceTextures: {},
defaultTexture: {
col: 0,
row: 0
}
}
]
});
engine.setVoxel("Ground", {
position: { x: 0, y: 0, z: 0 },
blockId: 1
});
engine.setVoxel("Ground", {
position: { x: 1, y: 0, z: 0 },
blockId: 1,
rotation: VoxelRotation.CW90,
flipX: false,
flipZ: false
});
engine.setVoxel("Ground", {
position: { x: 2, y: 0, z: 0 },
blockId: 1,
flipY: true
});
const entry = engine.getVoxel({
x: 0, y: 0, z: 0
});
// Move an entire layer in world space
// e.g. snap a prefab layer to a new grid position
engine.setLayerOffset("Ground", {
x: 8, y: 0, z: 0
});
// Shift a layer incrementally
engine.translateLayer("Ground", {
x: 0, y: 1, z: 0
});When wrapped by VoxelRenderer, call the same methods via vr.engine.<method>(...).
VoxelEngineOptions (a.k.a. VoxelRendererOptions)
type MaterialCustomizerFn = (
material: THREE.MeshLambertMaterial | THREE.MeshStandardMaterial,
tilesetId: string
) => void;
interface VoxelEngineOptions {
/**
* Must be a power of two — every world-to-chunk conversion is a shift and a
* mask. Anything else throws a RangeError.
* @default 16
*/
chunkSize?: number;
/**
* Milliseconds tick() may spend rebuilding dirty chunks before deferring the
* rest to the next frame. 0 rebuilds everything in the same tick.
* @default 8
*/
rebuildBudgetMs?: number;
/**
* Enables collision when provided, disabled by default so no physics backend
* is required. Called once during construction with the registries.
* See plugins/rapier for the bundled Rapier3D implementation.
*/
collider?: VoxelColliderFactory;
/**
* @default "lambert"
* The type of material to use for rendering chunks. "standard" supports
* roughness and metalness maps but is more expensive to render; "lambert"
* is faster but only supports a simple diffuse map.
*/
material?: "lambert" | "standard";
/**
* Optional callback to customize each material after it is created.
* Called with the material instance and the tileset ID it corresponds to
*/
materialCustomizer?: MaterialCustomizerFn;
/**
* Optional list of layer names to create on initialization.
*/
layers?: string[];
/** Optional initial block definitions to register. */
blocks?: BlockDefinition[];
/**
* Optional block shapes to register in addition to the default
* shapes provided by BlockShapeRegistry.createDefault().
*/
shapes?: BlockShape[];
/**
* Alpha value below which fragments are discarded (cutout transparency).
* Set to 0 to disable alpha testing entirely (useful when your tileset tiles
* have no transparency, or during debugging to confirm geometry is present).
* @default 0.1
*/
alphaTest?: number;
/**
* Optional logger instance for debug output. Structural type (`child()` +
* `debug()`) so `Systems.Logger` satisfies it without an import.
* Defaults to a no-op logger.
*/
logger?: VoxelLogger;
/**
* Optional callback that is called whenever a layer is
* - added
* - removed
* - updated.
* Useful for synchronizing external systems with changes to the voxel world.
*/
onLayerUpdated?: VoxelLayerHookListener;
/**
* Initial state of the debug inspector (`engine.debug`). Mesh counters are
* always collected; this only decides whether the wireframe is drawn from
* the start. See [Debug](./Debug.md).
*/
debug?: VoxelDebuggerOptions;
/**
* Texels of edge-replicated gutter added around every tile of an atlas before
* it is bound to a material. Prevents distant geometry from sampling
* neighbouring tiles. See [Tileset](./Tileset.md#atlas-padding).
* Set to 0 to render atlases untouched.
* @default half the tile size, clamped to 2..8
*/
tilesetPadding?: number;
/**
* Merge coplanar identical block faces into the largest quads possible
* instead of one quad per voxel face. Roughly 3x fewer triangles on terrain.
* See [Greedy meshing](#greedy-meshing).
* @default false
*/
greedy?: boolean;
/**
* Optional pre-loaded tileset collection. All tilesets in the loader are
* registered synchronously during construction. Use `TilesetLoader.fromTileDefinition()`
* or `TilesetLoader.fromWorld()` to populate it before constructing `VoxelEngine`.
*/
tilesetLoader?: TilesetLoader;
}Properties
class VoxelEngine {
readonly root: THREE.Group; // container for all chunk meshes
readonly world: VoxelWorld;
readonly blockRegistry: BlockRegistry;
readonly shapeRegistry: BlockShapeRegistry;
readonly tilesetManager: TilesetManager;
readonly serializer: VoxelSerializer;
readonly debug: VoxelDebugger; // mesh statistics + wireframe, see ./Debug.md
greedy: boolean; // read/write; assigning rebuilds every chunk
}Lifecycle
init(): void; // builds meshes for any voxels already present (e.g. after deserialize)
tick(deltaTime: number): void; // rebuilds dirty chunks within a time budget; call once per frame
flush(): void; // rebuilds every pending chunk now, ignoring the budget
dispose(): void; // disposes chunk meshes, materials, and tileset texturesWhen wrapped by VoxelRenderer, these are called automatically from its awake()/update()/destroy(). Call them yourself when using VoxelEngine standalone.
Rebuild budget
tick() spends at most rebuildBudgetMs (default 8 ms) per frame and defers the rest. Set to 0 to rebuild everything synchronously. init() and load() always rebuild the whole world synchronously regardless. Use flush() when meshes must be ready before the next line runs.
const engine = new VoxelEngine({ rebuildBudgetMs: 8 });
engine.rebuildFocus = camera.position; // prioritize chunks near the camera
engine.pendingRebuilds; // 0 once the world is up to dateChunk geometry layout
One THREE.Mesh per tileset per chunk, parented to root. 19 bytes per vertex:
| Attribute | Type | Items | Bytes | Notes |
|---|---|---|---|---|
position | float32 | 3 | 12 | absolute world space |
normal | int8 normalized | 3 | 3 | non-axis-aligned for ramps/corners |
uv | uint16 normalized | 2 | 4 | atlas coordinates |
Vertices are not shared between faces (a cube = 24 vertices). position stays float32; it is read verbatim by raycasting and mergeChunkGeometries(). Layer opacity lives on the material, not on vertices; materials are cached in 32 opacity buckets so an opacity slider never mints unbounded instances.
Greedy meshing
With greedy: true adjacent identical faces are merged into the largest rectangle possible — roughly 3x fewer triangles on flat terrain. Merging only happens within the same chunk and only for full flat faces (cubes, slabs); slopes, poles, and rotated voxels are left as-is.
What it changes
Greedy mode adds two extra vertex attributes (+20 bytes/vertex) and enables tile-repeating in the shader:
| Attribute | Type | Items | Bytes | Notes |
|---|---|---|---|---|
uv | float32 | 2 | 8 | tile space (0..span), not atlas space |
tileRegion | uint16 normalized | 4 | 8 | atlas rect: offsetU, offsetV, scaleU, scaleV |
tileRepeat | uint16 | 2 | 4 | tile repeat count per axis |
A
materialCustomizerthat overridesonBeforeCompileor remapsmapUVs will conflict with the tile-wrapping shader.
When not to use it
Avoid combining greedy meshing with large chunk sizes. The scratch grid scales with chunkSize³: fine at 16–64, but at chunkSize: 256 it uses 64 MB and meshes ~35% slower. It is also incompatible with per-vertex lighting or ambient occlusion.
const engine = new VoxelEngine({ chunkSize: 32, greedy: true });
// Toggling at runtime rebuilds every chunk and swaps the materials.
engine.greedy = false;Methods
getLayer(name: string): VoxelLayer
Find a layer or null if none is found with name.
addLayer(name: string, options?: VoxelLayerConfigurableOptions): VoxelLayer
Creates and returns a new named layer.
options is described by the following interface:
interface VoxelLayerConfigurableOptions {
visible?: boolean;
/**
* Rendered translucency, from `0` (fully transparent) to `1` (fully opaque).
* @default 1
*/
opacity?: number;
properties?: Record<string, any>;
}A layer with
opacity < 1renders with real alpha blending and stops occluding neighbouring faces (like glass);opacity === 0behaves exactly likevisible: false. See Layer for the full semantics. Partial opacity does not affect collision — see Collision.
updateLayer(name: string, options?: Partial< VoxelLayerConfigurableOptions >): boolean
Update a layer that already exists. Return false if no layer is found with the given name and true when updated.
removeLayer(name: string): VoxelLayer
Remove and returns a boolean confirming layer deletion.
setLayerOffset(name: string, offset: VoxelCoord): void
Sets the world-space translation of a layer. All voxels in the layer shift to localPosition + offset. Triggers a full dirty-chunk pass so cross-layer face culling is re-evaluated on the next frame. No-op if the layer is not found.
translateLayer(name: string, delta: VoxelCoord): void
Adds delta to the layer's current offset. Equivalent to setLayerOffset with layer.offset + delta. No-op if the layer is not found.
moveLayer(name: string, direction: "up" | "down"): void
Swaps order with the neighbouring layer in the given direction.
getLayerCenter(name: string): Vector3 | null
Returns the world-space center of all voxels in the given layer
setVoxel(layerName: string, options: VoxelSetOptions): void
Places a voxel at a world-space position.
interface VoxelSetOptions {
position: THREE.Vector3Like;
blockId: number;
/** Y-axis rotation in 90° steps. Default: `VoxelRotation.None`. */
rotation?: VoxelRotation;
/** Mirror the block on the X axis. Default: `false`. */
flipX?: boolean;
/** Mirror the block on the Z axis. Default: `false`. */
flipZ?: boolean;
/** Mirror the block geometry around y = 0.5 (upside-down). */
flipY?: boolean;
}Y-axis rotation applied to a placed voxel, in 90° steps.
const VoxelRotation = {
None: 0, // 0°
CCW90: 1, // 90° counter-clockwise
Deg180: 2, // 180°
CW90: 3, // 90° clockwise
} as const;
type VoxelRotation = typeof VoxelRotation[keyof typeof VoxelRotation];removeVoxel(layerName: string, options: VoxelRemoveOptions): void
Removes the voxel at a world-space position.
interface VoxelRemoveOptions {
position: THREE.Vector3Like;
}setVoxelBulk(layerName: string, entries: VoxelSetOptions[]): void
Places multiple voxels in the specified layer in a single batch call.
engine.setVoxelBulk("Ground", [
{ position: { x: 0, y: 0, z: 0 }, blockId: 1 },
{ position: { x: 1, y: 0, z: 0 }, blockId: 2, rotation: VoxelRotation.CW90 },
]);Each item in entries accepts the same fields as VoxelSetOptions.
removeVoxelBulk(layerName: string, entries: VoxelRemoveOptions[]): void
Removes multiple voxels from the specified layer in a single batch call.
engine.removeVoxelBulk("Ground", [
{ position: { x: 0, y: 0, z: 0 } },
{ position: { x: 1, y: 0, z: 0 } },
]);getVoxel overloads
getVoxel(position: VoxelCoord): VoxelEntry | undefined
getVoxel(layerName: string, position: VoxelCoord): VoxelEntry | undefinedComposited read (first overload) or layer-specific read (second overload). Returns undefined for air.
getVoxelNeighbour overloads
getVoxelNeighbour(position: VoxelCoord, face: Face): VoxelEntry | undefined
getVoxelNeighbour(layerName: string, position: VoxelCoord, face: Face): VoxelEntry | undefinedReturns the voxel immediately adjacent to position in the given face direction. Composited (first overload) or restricted to a specific layer (second overload).
loadTileset(def: TilesetDefinition, texture: THREE.Texture<HTMLImageElement>): void
Registers an already-loaded texture for a tileset definition. The first registered tileset becomes the default for tile references with no explicit tilesetId. Prefer passing a TilesetLoader via VoxelEngineOptions.tilesetLoader for pre-loading; use this method only when adding a tileset after construction.
save(): VoxelWorldJSON
Serialises the full world state (layers, voxels, tileset metadata) to a plain JSON object.
load(data: VoxelWorldJSON): void
Clears the current world and restores state from a JSON snapshot. All tilesets referenced by the snapshot must have been pre-loaded via TilesetLoader before this call — if a tileset is missing, an error is thrown. Already-registered tilesets are skipped.
markAllChunksDirty(source?: string): void
Mark all the chunks as dirty and rebuild them in the next frame
Object Layer API
Object layers hold placed objects (spawn points, trigger zones, etc.) rather than voxel data. Each mutating method fires a VoxelLayerHookEvent so external systems stay in sync.
addObjectLayer(name: string, options?: { visible?: boolean; order?: number }): VoxelObjectLayerJSON
Creates a new object layer in the world and fires "object-layer-added". Returns the new layer descriptor.
removeObjectLayer(name: string): boolean
Removes an object layer from the world. Fires "object-layer-removed" on success. Returns false if not found.
getObjectLayer(name: string): VoxelObjectLayerJSON | undefined
Returns the layer descriptor for name, or undefined if it does not exist.
getObjectLayers(): readonly VoxelObjectLayerJSON[]
Returns a snapshot array of all object layers in insertion order.
updateObjectLayer(name: string, patch: { visible?: boolean }): boolean
Applies a partial patch to a named object layer and fires "object-layer-updated". Returns false if not found.
addObject(layerName: string, object: VoxelObjectJSON): boolean
Appends an object to the named layer and fires "object-added". Returns false if the layer does not exist.
removeObject(layerName: string, objectId: string): boolean
Removes the object with the given id from the layer and fires "object-removed". Returns false if the layer or object is not found.
updateObject(layerName: string, objectId: string, patch: Partial<VoxelObjectJSON>): boolean
Merges patch into the matching object and fires "object-updated". Returns false if the layer or object is not found.
Hooks
See Hooks for more information