|
| 1 | +<h1 align="center"> |
| 2 | + voxel.renderer |
| 3 | +</h1> |
| 4 | + |
| 5 | +<p align="center"> |
| 6 | + JollyPixel Voxel Engine and Renderer |
| 7 | +</p> |
| 8 | + |
| 9 | +## 📌 About |
| 10 | + |
| 11 | +Chunked voxel engine and renderer built for Three.js and the JollyPixel ECS engine. Drop in the `VoxelRenderer` ActorComponent to add multi-layer voxel worlds — with tileset textures, face culling, block transforms, JSON save/load, and optional Rapier3D physics — to any JollyPixel scene. |
| 12 | + |
| 13 | +## 💡 Features |
| 14 | + |
| 15 | +- **`VoxelRenderer` ActorComponent** — full ECS lifecycle integration (`awake`, `update`, `destroy`) with automatic dirty-chunk detection and frame-accurate mesh rebuilds. |
| 16 | +- **Chunked rendering** — the world is partitioned into fixed-size chunks (default 16³). Only modified chunks are rebuilt each frame; unchanged chunks are never touched. |
| 17 | +- **Multiple named layers** — add any number of named layers (`"Ground"`, `"Decoration"`, …). Layers composite from highest priority to lowest, so decorative layers override base terrain non-destructively without Z-fighting. |
| 18 | +- **Layer controls** — toggle visibility, reorder layers, add or remove layers, and move entire layers in world space (`setLayerOffset` / `translateLayer`) at runtime. |
| 19 | +- **Face culling** — shared faces between adjacent solid voxels are suppressed to minimize triangle count. |
| 20 | +- **Block shapes** — eight built-in shapes (`fullCube`, `halfCubeBottom`, `halfCubeTop`, `ramp`, `cornerInner`, `cornerOuter`, `pillar`, `wedge`) plus a `BlockShape` interface for fully custom geometry. |
| 21 | +- **Block transforms** — place any block at 90° Y-axis rotations and X/Z flips via a packed `transform` byte, without duplicating block definitions. |
| 22 | +- **Multi-tileset texturing** — load multiple tileset PNGs at different resolutions. Blocks reference tiles by `{ tilesetId, col, row }` coordinates. GPU textures are shared across chunk meshes. |
| 23 | +- **Per-face texture overrides** — `faceTextures` on a `BlockDefinition` lets individual faces use a different tile from the block's default tile. |
| 24 | +- **Material options** — `"lambert"` (default, fast) or `"standard"` (PBR, roughness/metalness). |
| 25 | +- **Cutout transparency** — configurable `alphaTest` threshold (default `0.1`) for foliage and sprite-style blocks. Set `0` to disable. |
| 26 | +- **JSON serialization** — `save()` / `load()` round-trip the entire world state (layers, voxels, tileset metadata) as a plain JSON object. Compatible with `localStorage`, file I/O, and network APIs. |
| 27 | +- **Tiled map import** — `TiledConverter` converts Tiled `.tmj` exports to `VoxelWorldJSON`, mapping Tiled layers and tilesets to VoxelRenderer layers and tilesets. Supports `"stacked"` and `"flat"` layer modes. |
| 28 | +- **Optional Rapier3D physics** — pass a `{ api, world }` object to enable automatic collider generation per chunk. Colliders are rebuilt only when a chunk is dirty. No hard Rapier dependency — omit the option to keep physics out of the bundle entirely. |
| 29 | +- **Collision strategies** — `"box"` shapes generate compound cuboid colliders (best performance for full-cube terrain); `"trimesh"` shapes generate a mesh collider per chunk (best accuracy for ramps and diagonals). |
| 30 | +- **2D Tiled Map conversion** |
| 31 | + |
| 32 | +## 💃 Getting Started |
| 33 | + |
| 34 | +This package is available in the Node Package Repository and can be easily installed with [npm][npm] or [yarn][yarn]. |
| 35 | + |
| 36 | +```bash |
| 37 | +$ npm i @jolly-pixel/voxel.renderer |
| 38 | +# or |
| 39 | +$ yarn add @jolly-pixel/voxel.renderer |
| 40 | +``` |
| 41 | + |
| 42 | +## 👀 Usage example |
| 43 | + |
| 44 | +### Basic — place voxels manually |
| 45 | + |
| 46 | +```ts |
| 47 | +import { Runtime, loadRuntime } from "@jolly-pixel/runtime"; |
| 48 | +import { VoxelRenderer, type BlockDefinition } from "@jolly-pixel/voxel.renderer"; |
| 49 | + |
| 50 | +const canvas = document.querySelector("canvas") as HTMLCanvasElement; |
| 51 | +const runtime = new Runtime(canvas); |
| 52 | +const { world } = runtime; |
| 53 | + |
| 54 | +const blocks: BlockDefinition[] = [ |
| 55 | + { |
| 56 | + id: 1, |
| 57 | + name: "Dirt", |
| 58 | + shapeId: "fullCube", |
| 59 | + collidable: true, |
| 60 | + faceTextures: {}, |
| 61 | + defaultTexture: { tilesetId: "default", col: 2, row: 0 } |
| 62 | + } |
| 63 | +]; |
| 64 | + |
| 65 | +const voxelMap = world.createActor("map") |
| 66 | + .addComponentAndGet(VoxelRenderer, { |
| 67 | + chunkSize: 16, |
| 68 | + layers: ["Ground"], |
| 69 | + blocks |
| 70 | + }); |
| 71 | + |
| 72 | +// Load the tileset — resolves before awake() runs thanks to the runtime splash delay |
| 73 | +voxelMap.loadTileset({ |
| 74 | + id: "default", |
| 75 | + src: "tileset/Tileset001.png", |
| 76 | + tileSize: 32, |
| 77 | + cols: 9, |
| 78 | + rows: 4 |
| 79 | +}).catch(console.error); |
| 80 | + |
| 81 | +// Place a flat 8×8 ground plane |
| 82 | +for (let x = 0; x < 8; x++) { |
| 83 | + for (let z = 0; z < 8; z++) { |
| 84 | + voxelMap.setVoxel("Ground", { position: { x, y: 0, z }, blockId: 1 }); |
| 85 | + } |
| 86 | +} |
| 87 | + |
| 88 | +loadRuntime(runtime).catch(console.error); |
| 89 | +``` |
| 90 | + |
| 91 | +### Tiled import — convert a `.tmj` map |
| 92 | + |
| 93 | +```ts |
| 94 | +import { VoxelRenderer, TiledConverter, type TiledMap } from "@jolly-pixel/voxel.renderer"; |
| 95 | + |
| 96 | +// No blocks or layers needed here — load() restores them from the JSON snapshot |
| 97 | +const voxelMap = world.createActor("map") |
| 98 | + .addComponentAndGet(VoxelRenderer, { alphaTest: 0.1, material: "lambert" }); |
| 99 | + |
| 100 | +const tiledMap = await fetch("tilemap/map.tmj").then((r) => r.json()) as TiledMap; |
| 101 | + |
| 102 | +const worldJson = new TiledConverter().convert(tiledMap, { |
| 103 | + // Map Tiled .tsx source references to the PNG files served by your dev server |
| 104 | + resolveTilesetSrc: (src) => "tilemap/" + src.replace(/\.tsx$/, ".png"), |
| 105 | + layerMode: "stacked" |
| 106 | +}); |
| 107 | + |
| 108 | +// Kick off deserialization concurrently with the runtime splash (~850 ms) |
| 109 | +// so textures are ready before awake() runs |
| 110 | +voxelMap.load(worldJson).catch(console.error); |
| 111 | +await loadRuntime(runtime); |
| 112 | +``` |
| 113 | + |
| 114 | +### Save and restore world state |
| 115 | + |
| 116 | +```ts |
| 117 | +// Save to localStorage |
| 118 | +const json = voxelMap.save(); |
| 119 | +localStorage.setItem("map", JSON.stringify(json)); |
| 120 | + |
| 121 | +// Restore from localStorage |
| 122 | +const data = JSON.parse(localStorage.getItem("map")!) as VoxelWorldJSON; |
| 123 | +await voxelMap.load(data); |
| 124 | +``` |
| 125 | + |
| 126 | +### Layer positioning — move a layer in world space |
| 127 | + |
| 128 | +```ts |
| 129 | +// Place a prefab layer at local origin |
| 130 | +const prefab = voxelMap.addLayer("Prefab"); |
| 131 | +voxelMap.setVoxel("Prefab", { position: { x: 0, y: 0, z: 0 }, blockId: 2 }); |
| 132 | + |
| 133 | +// Snap the whole layer to world position {8, 0, 0} |
| 134 | +voxelMap.setLayerOffset("Prefab", { x: 8, y: 0, z: 0 }); |
| 135 | + |
| 136 | +// Nudge it one unit up on the next tick |
| 137 | +voxelMap.translateLayer("Prefab", { x: 0, y: 1, z: 0 }); |
| 138 | +// The layer now renders at {8, 1, 0} |
| 139 | +``` |
| 140 | + |
| 141 | +Offsets are stored in the JSON snapshot and restored automatically on `load()`. |
| 142 | + |
| 143 | +### Rapier3D physics |
| 144 | + |
| 145 | +```ts |
| 146 | +import Rapier from "@dimforge/rapier3d-compat"; |
| 147 | + |
| 148 | +await Rapier.init(); |
| 149 | +const rapierWorld = new Rapier.World({ x: 0, y: -9.81, z: 0 }); |
| 150 | + |
| 151 | +// Step physics once per fixed tick, before the scene update |
| 152 | +world.on("beforeFixedUpdate", () => rapierWorld.step()); |
| 153 | + |
| 154 | +const voxelMap = world.createActor("map") |
| 155 | + .addComponentAndGet(VoxelRenderer, { |
| 156 | + chunkSize: 16, |
| 157 | + layers: ["Ground"], |
| 158 | + blocks, |
| 159 | + rapier: { api: Rapier, world: rapierWorld } |
| 160 | + }); |
| 161 | +``` |
| 162 | + |
| 163 | +## 📚 API |
| 164 | + |
| 165 | +- [VoxelRenderer](docs/VoxelRenderer.md) - Main `ActorComponent` — options, voxel placement, tileset loading, save/load. |
| 166 | +- [World](docs/World.md) - `VoxelWorld`, `VoxelLayer`, `VoxelChunk`, and related types. |
| 167 | +- [Blocks](docs/Blocks.md) - `BlockDefinition`, `BlockShape`, `BlockRegistry`, `BlockShapeRegistry`, and `Face`. |
| 168 | +- [Tileset](docs/Tileset.md) - `TilesetManager`, `TilesetDefinition`, `TileRef`, UV regions. |
| 169 | +- [Serialization](docs/Serialization.md) - `VoxelSerializer` and JSON snapshot types. |
| 170 | +- [Collision](docs/Collision.md) - Rapier3D integration, `VoxelColliderBuilder`, and physics interfaces. |
| 171 | +- [Built-In Shapes](docs/BuiltInShapes.md) - All built-in block shapes and custom shape authoring. |
| 172 | +- [TiledConverter](docs/TiledConverter.md) - Converting Tiled `.tmj` exports to `VoxelWorldJSON`. |
| 173 | + |
| 174 | +## Contributors guide |
| 175 | + |
| 176 | +If you are a developer **looking to contribute** to the project, you must first read the [CONTRIBUTING][contributing] guide. |
| 177 | + |
| 178 | +Once you have finished your development, check that the tests (and linter) are still good by running the following script: |
| 179 | + |
| 180 | +```bash |
| 181 | +$ npm run test |
| 182 | +$ npm run lint |
| 183 | +``` |
| 184 | + |
| 185 | +> [!CAUTION] |
| 186 | +> In case you introduce a new feature or fix a bug, make sure to include tests for it as well. |
| 187 | +
|
| 188 | +## License |
| 189 | + |
| 190 | +MIT |
| 191 | + |
| 192 | +<!-- Reference-style links for DRYness --> |
| 193 | + |
| 194 | +[npm]: https://docs.npmjs.com/getting-started/what-is-npm |
| 195 | +[yarn]: https://yarnpkg.com |
| 196 | +[contributing]: ../../CONTRIBUTING.md |
0 commit comments