Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ Click on one of the links to access the documentation of the package:
| --- | --- | --- |
| engine | [@jolly-pixel/engine](./packages/engine) | ECS framework on top of Three.js |
| runtime | [@jolly-pixel/runtime](./packages/runtime) | Runtime for the engine / ECS |
| voxel-renderer | [@jolly-pixel/voxel.renderer](./packages/voxel-renderer) | Voxel Engine and Renderer |

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).
```bash
Expand Down
60 changes: 59 additions & 1 deletion docs/.vitepress/config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ export default defineConfig({
themeConfig: {
nav: [
{ text: "Engine", link: "/engine/README", activeMatch: "^/engine/" },
{ text: "Runtime", link: "/runtime/README", activeMatch: "^/runtime/" }
{ text: "Runtime", link: "/runtime/README", activeMatch: "^/runtime/" },
{ text: "Voxel Renderer", link: "/voxel-renderer/README", activeMatch: "^/voxel-renderer/" }
],
search: {
provider: "local"
Expand Down Expand Up @@ -190,6 +191,63 @@ export default defineConfig({
}
]
}
],
"/voxel-renderer/": [
{
items: [
{
text: "Introduction",
link: "/voxel-renderer/README"
},
{
text: "Core",
items: [
{
text: "VoxelRenderer",
link: "/voxel-renderer/VoxelRenderer"
},
{
text: "World",
link: "/voxel-renderer/World"
},
{
text: "Blocks",
link: "/voxel-renderer/Blocks"
}
]
},
{
text: "Tilesets",
items: [
{
text: "Tileset",
link: "/voxel-renderer/Tileset"
},
{
text: "Built-In Shapes",
link: "/voxel-renderer/BuiltInShapes"
},
{
text: "TiledConverter",
link: "/voxel-renderer/TiledConverter"
}
]
},
{
text: "Advanced",
items: [
{
text: "Collision",
link: "/voxel-renderer/Collision"
},
{
text: "Serialization",
link: "/voxel-renderer/Serialization"
}
]
}
]
}
]
},
socialLinks: [
Expand Down
7 changes: 6 additions & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,10 @@ export default [
}
}
},
...typescriptConfig()
...typescriptConfig({
rules: {
"@stylistic/no-mixed-operators": "off",
"max-classes-per-file": "off"
}
})
];
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@
"packages/fs-tree",
"packages/experimental",
"packages/editors/voxel-map",
"packages/editors/model"
"packages/editors/model",
"packages/voxel-renderer"
],
"repository": {
"type": "git",
Expand All @@ -48,6 +49,9 @@
"tsx": "^4.20.3",
"typescript": "^5.9.2",
"vite": "^7.0.0",
"vite-plugin-checker": "0.12.0",
"vite-plugin-glsl": "1.5.5",
"vite-plugin-wasm": "3.5.0",
"vitepress": "1.6.4"
}
}
2 changes: 1 addition & 1 deletion packages/engine/src/controls/CombinedInput.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/* eslint-disable max-classes-per-file */

// Import Internal Dependencies
import type { Input } from "./Input.class.ts";
import {
Expand Down
2 changes: 1 addition & 1 deletion packages/engine/test/Behavior.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/* eslint-disable max-classes-per-file */

// Import Node.js Dependencies
import { describe, test } from "node:test";
import assert from "node:assert/strict";
Expand Down
2 changes: 1 addition & 1 deletion packages/engine/test/Scene.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/* eslint-disable max-classes-per-file */

// Import Node.js Dependencies
import { describe, test, mock, beforeEach } from "node:test";
import assert from "node:assert/strict";
Expand Down
41 changes: 41 additions & 0 deletions packages/voxel-renderer/PROMPT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@

> 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.).
>
> Your work is split into **two phases**:
>
> ---
>
> ### Phase 1 — Audit & Refactoring Plan
>
> Analyze the codebase and produce a structured report:
>
> 1. **Testability inventory**: Go through each module/class/function and categorize it as:
> - ✅ **Easy to test** — pure functions, stateless logic, clear inputs/outputs
> - ⚠️ **Needs refactoring first** — testable in principle but tightly coupled, uses globals, mixes concerns, or has hidden side effects
> - 🚫 **Out of scope** — rendering, I/O-heavy, or explicitly excluded (e.g. `VoxelRenderer`)
>
> 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.
>
> 3. **Risk assessment**: Flag any refactoring that could introduce regressions or requires touching sensitive areas.
>
> ---
>
> ### Phase 2 — Test Implementation Plan
>
> For each item marked ✅ or ⚠️ (after refactoring):
>
> 1. **Propose a test file structure** — where test files live, naming conventions, how to run them via `node --test`.
>
> 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).
>
> 3. **Prioritize by value** — start with the logic most likely to contain bugs or be changed frequently.
>
> ---
>
> **Constraints & context:**
> - Runtime: Node.js with `node:test` only — no Jest, Mocha, Sinon, or any npm packages for testing
> - Excluded from testing: `VoxelRenderer` and any other pure rendering/display classes
> - Prefer small, focused tests over large integration tests
> - When in doubt about whether something is worth testing, err on the side of skipping it and explaining why
>
> Begin with **Phase 1** and wait for confirmation before proceeding to Phase 2.
196 changes: 196 additions & 0 deletions packages/voxel-renderer/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
<h1 align="center">
voxel.renderer
</h1>

<p align="center">
JollyPixel Voxel Engine and Renderer
</p>

## 📌 About

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.

## 💡 Features

- **`VoxelRenderer` ActorComponent** — full ECS lifecycle integration (`awake`, `update`, `destroy`) with automatic dirty-chunk detection and frame-accurate mesh rebuilds.
- **Chunked rendering** — the world is partitioned into fixed-size chunks (default 16³). Only modified chunks are rebuilt each frame; unchanged chunks are never touched.
- **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.
- **Layer controls** — toggle visibility, reorder layers, add or remove layers, and move entire layers in world space (`setLayerOffset` / `translateLayer`) at runtime.
- **Face culling** — shared faces between adjacent solid voxels are suppressed to minimize triangle count.
- **Block shapes** — eight built-in shapes (`fullCube`, `halfCubeBottom`, `halfCubeTop`, `ramp`, `cornerInner`, `cornerOuter`, `pillar`, `wedge`) plus a `BlockShape` interface for fully custom geometry.
- **Block transforms** — place any block at 90° Y-axis rotations and X/Z flips via a packed `transform` byte, without duplicating block definitions.
- **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.
- **Per-face texture overrides** — `faceTextures` on a `BlockDefinition` lets individual faces use a different tile from the block's default tile.
- **Material options** — `"lambert"` (default, fast) or `"standard"` (PBR, roughness/metalness).
- **Cutout transparency** — configurable `alphaTest` threshold (default `0.1`) for foliage and sprite-style blocks. Set `0` to disable.
- **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.
- **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.
- **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.
- **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).
- **2D Tiled Map conversion**

## 💃 Getting Started

This package is available in the Node Package Repository and can be easily installed with [npm][npm] or [yarn][yarn].

```bash
$ npm i @jolly-pixel/voxel.renderer
# or
$ yarn add @jolly-pixel/voxel.renderer
```

## 👀 Usage example

### Basic — place voxels manually

```ts
import { Runtime, loadRuntime } from "@jolly-pixel/runtime";
import { VoxelRenderer, type BlockDefinition } from "@jolly-pixel/voxel.renderer";

const canvas = document.querySelector("canvas") as HTMLCanvasElement;
const runtime = new Runtime(canvas);
const { world } = runtime;

const blocks: BlockDefinition[] = [
{
id: 1,
name: "Dirt",
shapeId: "fullCube",
collidable: true,
faceTextures: {},
defaultTexture: { tilesetId: "default", col: 2, row: 0 }
}
];

const voxelMap = world.createActor("map")
.addComponentAndGet(VoxelRenderer, {
chunkSize: 16,
layers: ["Ground"],
blocks
});

// Load the tileset — resolves before awake() runs thanks to the runtime splash delay
voxelMap.loadTileset({
id: "default",
src: "tileset/Tileset001.png",
tileSize: 32,
cols: 9,
rows: 4
}).catch(console.error);

// Place a flat 8×8 ground plane
for (let x = 0; x < 8; x++) {
for (let z = 0; z < 8; z++) {
voxelMap.setVoxel("Ground", { position: { x, y: 0, z }, blockId: 1 });
}
}

loadRuntime(runtime).catch(console.error);
```

### Tiled import — convert a `.tmj` map

```ts
import { VoxelRenderer, TiledConverter, type TiledMap } from "@jolly-pixel/voxel.renderer";

// No blocks or layers needed here — load() restores them from the JSON snapshot
const voxelMap = world.createActor("map")
.addComponentAndGet(VoxelRenderer, { alphaTest: 0.1, material: "lambert" });

const tiledMap = await fetch("tilemap/map.tmj").then((r) => r.json()) as TiledMap;

const worldJson = new TiledConverter().convert(tiledMap, {
// Map Tiled .tsx source references to the PNG files served by your dev server
resolveTilesetSrc: (src) => "tilemap/" + src.replace(/\.tsx$/, ".png"),
layerMode: "stacked"
});

// Kick off deserialization concurrently with the runtime splash (~850 ms)
// so textures are ready before awake() runs
voxelMap.load(worldJson).catch(console.error);
await loadRuntime(runtime);
```

### Save and restore world state

```ts
// Save to localStorage
const json = voxelMap.save();
localStorage.setItem("map", JSON.stringify(json));

// Restore from localStorage
const data = JSON.parse(localStorage.getItem("map")!) as VoxelWorldJSON;
await voxelMap.load(data);
```

### Layer positioning — move a layer in world space

```ts
// Place a prefab layer at local origin
const prefab = voxelMap.addLayer("Prefab");
voxelMap.setVoxel("Prefab", { position: { x: 0, y: 0, z: 0 }, blockId: 2 });

// Snap the whole layer to world position {8, 0, 0}
voxelMap.setLayerOffset("Prefab", { x: 8, y: 0, z: 0 });

// Nudge it one unit up on the next tick
voxelMap.translateLayer("Prefab", { x: 0, y: 1, z: 0 });
// The layer now renders at {8, 1, 0}
```

Offsets are stored in the JSON snapshot and restored automatically on `load()`.

### Rapier3D physics

```ts
import Rapier from "@dimforge/rapier3d-compat";

await Rapier.init();
const rapierWorld = new Rapier.World({ x: 0, y: -9.81, z: 0 });

// Step physics once per fixed tick, before the scene update
world.on("beforeFixedUpdate", () => rapierWorld.step());

const voxelMap = world.createActor("map")
.addComponentAndGet(VoxelRenderer, {
chunkSize: 16,
layers: ["Ground"],
blocks,
rapier: { api: Rapier, world: rapierWorld }
});
```

## 📚 API

- [VoxelRenderer](docs/VoxelRenderer.md) - Main `ActorComponent` — options, voxel placement, tileset loading, save/load.
- [World](docs/World.md) - `VoxelWorld`, `VoxelLayer`, `VoxelChunk`, and related types.
- [Blocks](docs/Blocks.md) - `BlockDefinition`, `BlockShape`, `BlockRegistry`, `BlockShapeRegistry`, and `Face`.
- [Tileset](docs/Tileset.md) - `TilesetManager`, `TilesetDefinition`, `TileRef`, UV regions.
- [Serialization](docs/Serialization.md) - `VoxelSerializer` and JSON snapshot types.
- [Collision](docs/Collision.md) - Rapier3D integration, `VoxelColliderBuilder`, and physics interfaces.
- [Built-In Shapes](docs/BuiltInShapes.md) - All built-in block shapes and custom shape authoring.
- [TiledConverter](docs/TiledConverter.md) - Converting Tiled `.tmj` exports to `VoxelWorldJSON`.

## Contributors guide

If you are a developer **looking to contribute** to the project, you must first read the [CONTRIBUTING][contributing] guide.

Once you have finished your development, check that the tests (and linter) are still good by running the following script:

```bash
$ npm run test
$ npm run lint
```

> [!CAUTION]
> In case you introduce a new feature or fix a bug, make sure to include tests for it as well.

## License

MIT

<!-- Reference-style links for DRYness -->

[npm]: https://docs.npmjs.com/getting-started/what-is-npm
[yarn]: https://yarnpkg.com
[contributing]: ../../CONTRIBUTING.md
Loading
Loading