voxolith

Formats

Reading and writing MagicaVoxel .vox (base models, the extended scene graph, materials and animation frames) and loading Minecraft Anvil .mca regions.

The renderer reads and writes MagicaVoxel .vox and reads Minecraft Anvil .mca regions. The readers are pure (no fs, no GPU): they take bytes and return data, so they run in the browser, in workers and in bun tools. Import them from @voxolith/renderer/vox or @voxolith/renderer/core in tools; browser code may use the main barrel.

For .vox files as entities (a model with an anchor and named colour roles), use the engine's entityFromVox and entityToVox, which sit on top of these.

MagicaVoxel .vox

The format is a RIFF-like tree of chunks under a root MAIN. MagicaVoxel is Z-up; the renderer's grid is Y-up, so a model's z becomes the grid's y.

One model

parseVox(buffer) reads the base format: the first model's SIZE and XYZI chunks and the optional RGBA palette (the MagicaVoxel default palette when there is none). It returns a VoxModel:

  • size, in MagicaVoxel axes;
  • voxels, a list of { x, y, z, c } with a 1-based colour index;
  • palette, 256 RGBA bytes indexed by c (file entry i is palette index i + 1; entry 0 is unused).
import { parseVox } from "@voxolith/renderer/vox";

const vox = parseVox(await (await fetch("oak.vox")).arrayBuffer());
const size = { x: vox.size.x, y: vox.size.z, z: vox.size.y }; // Z-up to Y-up
const data = new Uint8Array(size.x * size.y * size.z);
for (const v of vox.voxels) data[v.x + v.z * size.x + v.y * size.x * size.y] = v.c;

Scenes, materials and animation

parseVoxScene(buffer) reads the full extended format into a VoxScene: every model, the nTRN / nGRP / nSHP scene graph with its rotations and translations, MATL materials, LAYR layers and keyframes.

memberis
modelsevery model in the file
palettethe shared RGBA palette
materialsper palette index: diffuse, metal, glass or emit with their parameters, or null
layersid, name and whether hidden
frameCountthe number of animation frames (at least 1)
sample(frame)the model placements at a frame: model, rotation matrix, translation, layer
bounds()the world box over all frames, for sizing a stable playback grid

Rotations in the file are packed bytes; decodeVoxRotation(r) turns one into a row-major signed permutation matrix (4 is the identity).

voxSceneAnimator(scene) turns a scene into something a renderer can play: a Y-up grid sized from bounds() so it never resizes mid-playback, the palette as floats, and frame(i), which composes that frame's placements into the grid (a cached buffer). Re-upload it each tick with updateVoxels. packMaterials(scene) packs the MATL materials into the renderer's material buffer, or returns undefined when the file has none, which keeps the flat-palette path.

import { createRenderer, parseVoxScene, voxSceneAnimator, packMaterials } from "@voxolith/renderer";

const scene = parseVoxScene(bytes);
const anim = voxSceneAnimator(scene);
const renderer = await createRenderer(gpu, {
  size: anim.size,
  data: anim.frame(0),
  palette: anim.palette,
  materials: packMaterials(scene),
});

let frame = 0;
setInterval(() => {
  frame = (frame + 1) % anim.frameCount;
  renderer.updateVoxels(anim.frame(frame));
  loop.invalidate();
}, 100);

Writing

writeVox(size, voxels, colorFor) serialises one model (SIZE, XYZI, RGBA) to an ArrayBuffer. voxels are [x, y, z, colourIndex] in MagicaVoxel axes with a 1-based index, and colorFor(index) returns an RGB triple or null for an unused entry. It is pure: the caller writes the bytes wherever it likes.

import { writeVox } from "@voxolith/renderer/vox";

const bytes = writeVox({ x: 2, y: 1, z: 1 }, [[0, 0, 0, 1], [1, 0, 0, 2]], (i) =>
  i === 1 ? [200, 60, 40] : i === 2 ? [40, 60, 200] : null,
);
await Bun.write("pair.vox", bytes);

Minecraft .mca

buildMinecraftRegion(bytes, options?) reads an Anvil region file (an 8 KiB header of chunk locations, then zlib or gzip compressed NBT chunks in 4 KiB sectors), decodes the chunks overlapping a crop box in parallel and maps blocks to a palette built on the fly. It returns a MinecraftScene with size, data, palette and materials, which is a RenderScene as it stands, plus meta (chunks, colours, voxels, the crop used and whether it was clamped).

It decodes all three eras of chunk encoding, chosen by structure and DataVersion:

eralayout
before 1.13Level.Sections[] with numeric Blocks (plus Add and Data nibbles)
1.13 to 1.17Level.Sections[] with a Palette and packed BlockStates (indices span longs before DataVersion 2529)
1.18 and latertop-level sections[] with block_states = { palette, data } and negative section Y

Blocks get flat colours from two curated tables (legacy numeric ids and modern names), with no textures; a few map to materials (emissive, glass, metal). Minecraft is Y-up, so no axes swap.

Prop

Type

import { buildMinecraftRegion, createRenderer } from "@voxolith/renderer";

const bytes = new Uint8Array(await file.arrayBuffer());
const scene = await buildMinecraftRegion(bytes, { x0: 128, x1: 383, z0: 128, z1: 383, downsample: 2 });
const renderer = await createRenderer(gpu, scene);

Decompression uses the web DecompressionStream, available in browsers, bun and Node 18 and later. The viewer opens both formats by drag and drop.

On this page