Skip to content

Commit 5ca894b

Browse files
authored
feat: implement v1.0 of VoxelRenderer (#189)
1 parent 176d26f commit 5ca894b

File tree

84 files changed

+10897
-6
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

84 files changed

+10897
-6
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ Click on one of the links to access the documentation of the package:
3737
| --- | --- | --- |
3838
| engine | [@jolly-pixel/engine](./packages/engine) | ECS framework on top of Three.js |
3939
| runtime | [@jolly-pixel/runtime](./packages/runtime) | Runtime for the engine / ECS |
40+
| voxel-renderer | [@jolly-pixel/voxel.renderer](./packages/voxel-renderer) | Voxel Engine and Renderer |
4041

4142
These packages are available in the Node Package Repository and can be easily installed with [npm](https://docs.npmjs.com/getting-started/what-is-npm) or [yarn](https://yarnpkg.com).
4243
```bash

docs/.vitepress/config.mts

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ export default defineConfig({
88
themeConfig: {
99
nav: [
1010
{ text: "Engine", link: "/engine/README", activeMatch: "^/engine/" },
11-
{ text: "Runtime", link: "/runtime/README", activeMatch: "^/runtime/" }
11+
{ text: "Runtime", link: "/runtime/README", activeMatch: "^/runtime/" },
12+
{ text: "Voxel Renderer", link: "/voxel-renderer/README", activeMatch: "^/voxel-renderer/" }
1213
],
1314
search: {
1415
provider: "local"
@@ -190,6 +191,63 @@ export default defineConfig({
190191
}
191192
]
192193
}
194+
],
195+
"/voxel-renderer/": [
196+
{
197+
items: [
198+
{
199+
text: "Introduction",
200+
link: "/voxel-renderer/README"
201+
},
202+
{
203+
text: "Core",
204+
items: [
205+
{
206+
text: "VoxelRenderer",
207+
link: "/voxel-renderer/VoxelRenderer"
208+
},
209+
{
210+
text: "World",
211+
link: "/voxel-renderer/World"
212+
},
213+
{
214+
text: "Blocks",
215+
link: "/voxel-renderer/Blocks"
216+
}
217+
]
218+
},
219+
{
220+
text: "Tilesets",
221+
items: [
222+
{
223+
text: "Tileset",
224+
link: "/voxel-renderer/Tileset"
225+
},
226+
{
227+
text: "Built-In Shapes",
228+
link: "/voxel-renderer/BuiltInShapes"
229+
},
230+
{
231+
text: "TiledConverter",
232+
link: "/voxel-renderer/TiledConverter"
233+
}
234+
]
235+
},
236+
{
237+
text: "Advanced",
238+
items: [
239+
{
240+
text: "Collision",
241+
link: "/voxel-renderer/Collision"
242+
},
243+
{
244+
text: "Serialization",
245+
link: "/voxel-renderer/Serialization"
246+
}
247+
]
248+
}
249+
]
250+
}
193251
]
194252
},
195253
socialLinks: [

eslint.config.mjs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,10 @@ export default [
1212
}
1313
}
1414
},
15-
...typescriptConfig()
15+
...typescriptConfig({
16+
rules: {
17+
"@stylistic/no-mixed-operators": "off",
18+
"max-classes-per-file": "off"
19+
}
20+
})
1621
];

package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@
2121
"packages/fs-tree",
2222
"packages/experimental",
2323
"packages/editors/voxel-map",
24-
"packages/editors/model"
24+
"packages/editors/model",
25+
"packages/voxel-renderer"
2526
],
2627
"repository": {
2728
"type": "git",
@@ -48,6 +49,9 @@
4849
"tsx": "^4.20.3",
4950
"typescript": "^5.9.2",
5051
"vite": "^7.0.0",
52+
"vite-plugin-checker": "0.12.0",
53+
"vite-plugin-glsl": "1.5.5",
54+
"vite-plugin-wasm": "3.5.0",
5155
"vitepress": "1.6.4"
5256
}
5357
}

packages/engine/src/controls/CombinedInput.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
/* eslint-disable max-classes-per-file */
1+
22
// Import Internal Dependencies
33
import type { Input } from "./Input.class.ts";
44
import {

packages/engine/test/Behavior.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
/* eslint-disable max-classes-per-file */
1+
22
// Import Node.js Dependencies
33
import { describe, test } from "node:test";
44
import assert from "node:assert/strict";

packages/engine/test/Scene.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
/* eslint-disable max-classes-per-file */
1+
22
// Import Node.js Dependencies
33
import { describe, test, mock, beforeEach } from "node:test";
44
import assert from "node:assert/strict";

packages/voxel-renderer/PROMPT.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
2+
> You are a senior software engineer tasked with introducing tests to an existing Node.js codebase using **only `node:test`** (no additional dependencies like Jest, Mocha, Sinon, etc.).
3+
>
4+
> Your work is split into **two phases**:
5+
>
6+
> ---
7+
>
8+
> ### Phase 1 — Audit & Refactoring Plan
9+
>
10+
> Analyze the codebase and produce a structured report:
11+
>
12+
> 1. **Testability inventory**: Go through each module/class/function and categorize it as:
13+
> -**Easy to test** — pure functions, stateless logic, clear inputs/outputs
14+
> - ⚠️ **Needs refactoring first** — testable in principle but tightly coupled, uses globals, mixes concerns, or has hidden side effects
15+
> - 🚫 **Out of scope** — rendering, I/O-heavy, or explicitly excluded (e.g. `VoxelRenderer`)
16+
>
17+
> 2. **Refactoring recommendations**: For each "Needs refactoring" item, describe *what* should change and *why* — e.g. extract pure logic from a class, inject dependencies instead of hardcoding them, separate side effects from computation.
18+
>
19+
> 3. **Risk assessment**: Flag any refactoring that could introduce regressions or requires touching sensitive areas.
20+
>
21+
> ---
22+
>
23+
> ### Phase 2 — Test Implementation Plan
24+
>
25+
> For each item marked ✅ or ⚠️ (after refactoring):
26+
>
27+
> 1. **Propose a test file structure** — where test files live, naming conventions, how to run them via `node --test`.
28+
>
29+
> 2. **Write the tests** using only `node:test` and `node:assert`. Use `describe`/`it` blocks, `before`/`after` hooks where relevant. For async code use `async/await`. For dependencies that can't be avoided, use manual stubs/mocks (plain JS objects — no libraries).
30+
>
31+
> 3. **Prioritize by value** — start with the logic most likely to contain bugs or be changed frequently.
32+
>
33+
> ---
34+
>
35+
> **Constraints & context:**
36+
> - Runtime: Node.js with `node:test` only — no Jest, Mocha, Sinon, or any npm packages for testing
37+
> - Excluded from testing: `VoxelRenderer` and any other pure rendering/display classes
38+
> - Prefer small, focused tests over large integration tests
39+
> - When in doubt about whether something is worth testing, err on the side of skipping it and explaining why
40+
>
41+
> Begin with **Phase 1** and wait for confirmation before proceeding to Phase 2.

packages/voxel-renderer/README.md

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
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

Comments
 (0)