Skip to content

Commit ae78d50

Browse files
committed
feat(voxel-renderer): implement layers merging
1 parent 8989a96 commit ae78d50

File tree

12 files changed

+348
-37
lines changed

12 files changed

+348
-37
lines changed

.changeset/rich-ideas-tease.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@jolly-pixel/voxel.renderer": minor
3+
---
4+
5+
Implement layers merging

packages/voxel-renderer/examples/scripts/components/VoxelMap.ts

Lines changed: 43 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,39 @@ import {
33
Actor,
44
ActorComponent
55
} from "@jolly-pixel/engine";
6+
import * as THREE from "three";
67

78
// Import Internal Dependencies
89
import {
910
loadVoxelTiledMap,
10-
VoxelRenderer
11+
TilesetLoader,
12+
VoxelRenderer,
13+
type VoxelWorldJSON
1114
} from "../../../src/index.ts";
1215

1316
export class VoxelBehavior extends ActorComponent {
14-
world = loadVoxelTiledMap(this.actor.world.assetManager, "tilemap/brackeys-level.tmj", {
15-
layerMode: "stacked"
16-
});
17-
// @ts-ignore
18-
voxelRenderer: VoxelRenderer;
17+
tilesetLoader = new TilesetLoader();
18+
19+
world: VoxelWorldJSON | undefined;
20+
21+
async initialize({ assetManager }) {
22+
console.log("initialize VoxelBehavior");
23+
24+
this.tilesetLoader = new TilesetLoader({
25+
manager: assetManager.context.manager
26+
});
27+
const mapLoader = loadVoxelTiledMap(
28+
this.actor.world.assetManager,
29+
"tilemap/brackeys-level.tmj",
30+
{
31+
layerMode: "stacked"
32+
}
33+
);
34+
35+
this.world = await mapLoader.getAsync();
36+
console.log(this.world);
37+
await this.tilesetLoader.fromWorld(this.world);
38+
}
1939

2040
constructor(
2141
actor: Actor
@@ -27,15 +47,23 @@ export class VoxelBehavior extends ActorComponent {
2747
}
2848

2949
awake() {
30-
const world = this.world.get();
31-
32-
const voxelRenderer = this.actor.getComponent(VoxelRenderer);
33-
if (!voxelRenderer) {
34-
throw new Error("VoxelRenderer component not found on actor");
50+
if (!this.world) {
51+
throw new Error("world is not initilized");
3552
}
36-
this.voxelRenderer = voxelRenderer;
37-
voxelRenderer
38-
.load(world)
39-
.catch(console.error);
53+
54+
const vr = this.actor.addComponentAndGet(VoxelRenderer, {
55+
material: "lambert",
56+
materialCustomizer: (material) => {
57+
if (material instanceof THREE.MeshStandardMaterial) {
58+
material.metalness = 0;
59+
material.roughness = 0.85;
60+
}
61+
},
62+
tilesetLoader: this.tilesetLoader
63+
});
64+
65+
vr.load(this.world, {
66+
mergeLayers: true
67+
});
4068
}
4169
}

packages/voxel-renderer/examples/scripts/demo-physics.ts

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import * as THREE from "three";
1212
// Import Internal Dependencies
1313
import {
1414
VoxelRenderer,
15+
TilesetLoader,
1516
Face,
1617
type BlockDefinition
1718
} from "../../src/index.ts";
@@ -40,6 +41,15 @@ const runtime = new Runtime(canvas, {
4041
includePerformanceStats: true
4142
});
4243

44+
const tileDef = {
45+
tileSize: 32,
46+
src: "tileset/UV_cube.png",
47+
id: "default"
48+
};
49+
50+
const tilesetLoader = new TilesetLoader();
51+
await tilesetLoader.fromTileDefinition(tileDef);
52+
4353
const { world } = runtime;
4454
world.logger.setLevel("debug");
4555
world.logger.enableNamespace("*");
@@ -120,7 +130,8 @@ const voxelMap = world.createActor("map")
120130
// Rapier namespace / World instance satisfy them without any cast.
121131
api: RAPIER as never,
122132
world: rapierWorld as never
123-
}
133+
},
134+
tilesetLoader
124135
}
125136
);
126137

@@ -180,12 +191,5 @@ world.on("beforeFixedUpdate", (_dt) => {
180191
world.createActor("sphere")
181192
.addComponent(SphereBehavior, { body: sphereBody, mesh: sphereMesh });
182193

183-
// ── Tileset + runtime start ───────────────────────────────────────────────────
184-
voxelMap.loadTileset({
185-
tileSize: 32,
186-
src: "tileset/UV_cube.png",
187-
id: "default"
188-
}).catch(console.error);
189-
190194
createExamplesMenu();
191195
loadRuntime(runtime).catch(console.error);

packages/voxel-renderer/examples/scripts/demo-tiled.ts

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,6 @@ import {
77
import * as THREE from "three";
88

99
// Import Internal Dependencies
10-
import {
11-
VoxelRenderer
12-
} from "../../src/index.ts";
1310
import { VoxelBehavior } from "./components/VoxelMap.ts";
1411
import { createExamplesMenu } from "./utils/menu.ts";
1512

@@ -49,15 +46,6 @@ world.createActor("camera")
4946
// ── VoxelRenderer ─────────────────────────────────────────────────────────────
5047
// No blocks or layers supplied here — load() will register them from the JSON.
5148
world.createActor("map")
52-
.addComponent(VoxelRenderer, {
53-
material: "lambert",
54-
materialCustomizer: (material) => {
55-
if (material instanceof THREE.MeshStandardMaterial) {
56-
material.metalness = 0;
57-
material.roughness = 0.85;
58-
}
59-
}
60-
})
6149
.addComponent(VoxelBehavior);
6250

6351
// ── Load runtime ────────────────────────────────────────────

packages/voxel-renderer/src/VoxelRenderer.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,15 @@ import type { VoxelSetOptions, VoxelRemoveOptions, PartialExcept } from "./types
5656

5757
export type { VoxelSetOptions, VoxelRemoveOptions };
5858

59+
export interface VoxelLoadOptions {
60+
/**
61+
* When true, all voxel layers are collapsed into one before rendering.
62+
* Higher-priority layers overwrite lower ones at the same world position.
63+
* Use this for runtime loading when multi-layer editing is not needed.
64+
*/
65+
mergeLayers?: boolean;
66+
}
67+
5968
type MaterialCustomizerFn = (
6069
material: THREE.MeshLambertMaterial | THREE.MeshStandardMaterial,
6170
tilesetId: string
@@ -577,6 +586,24 @@ export class VoxelRenderer extends ActorComponent {
577586
return clone;
578587
}
579588

589+
mergeLayer(
590+
sourceLayerName: string,
591+
targetLayerName: string
592+
): boolean {
593+
const merged = this.world.mergeLayer(sourceLayerName, targetLayerName);
594+
if (!merged) {
595+
return false;
596+
}
597+
598+
this.#emitHook({
599+
action: "merged",
600+
layerName: sourceLayerName,
601+
metadata: { targetLayerName }
602+
});
603+
604+
return true;
605+
}
606+
580607
addLayer(
581608
name: string,
582609
options: VoxelLayerConfigurableOptions = {}
@@ -807,7 +834,8 @@ export class VoxelRenderer extends ActorComponent {
807834
}
808835

809836
load(
810-
data: VoxelWorldJSON
837+
data: VoxelWorldJSON,
838+
options: VoxelLoadOptions = {}
811839
): void {
812840
// Clear existing meshes before replacing world data.
813841
for (const mesh of this.#chunkMeshes.values()) {
@@ -851,6 +879,10 @@ export class VoxelRenderer extends ActorComponent {
851879
}
852880
this.#materials.clear();
853881

882+
if (options.mergeLayers) {
883+
this.world.mergeAllLayers();
884+
}
885+
854886
this.#rebuildAllChunks("load");
855887
}
856888

packages/voxel-renderer/src/hooks.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,13 @@ export type VoxelLayerHookEvent =
3737
options: PartialExcept<VoxelLayerOptions, "name">;
3838
};
3939
}
40+
| {
41+
action: "merged";
42+
layerName: string;
43+
metadata: {
44+
targetLayerName: string;
45+
};
46+
}
4047
| {
4148
action: "offset-updated";
4249
layerName: string;

packages/voxel-renderer/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export {
33
VoxelRenderer,
44
VoxelRotation,
55
type VoxelRendererOptions,
6+
type VoxelLoadOptions,
67
type VoxelSetOptions,
78
type VoxelRemoveOptions
89
} from "./VoxelRenderer.ts";

packages/voxel-renderer/src/network/VoxelCommandApplier.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,10 @@ export function applyCommandToWorld(
8585
world.moveLayer(cmd.layerName, cmd.metadata.direction);
8686
break;
8787

88+
case "merged":
89+
world.mergeLayer(cmd.layerName, cmd.metadata.targetLayerName);
90+
break;
91+
8892
case "object-layer-added":
8993
world.addObjectLayer(cmd.layerName);
9094
break;

packages/voxel-renderer/src/world/VoxelLayer.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -369,11 +369,31 @@ export class VoxelLayer {
369369
};
370370
}
371371

372-
clone(opts: Partial<VoxelLayerOptions> = {}): VoxelLayer {
372+
clone(
373+
opts: Partial<VoxelLayerOptions> = {}
374+
): VoxelLayer {
373375
return new VoxelLayer({
374376
chunkSize: this.#chunkSize,
375377
...this.toJSON(),
376378
...opts
377379
});
378380
}
381+
382+
mergeFrom(
383+
source: VoxelLayer
384+
): void {
385+
for (const chunk of source.getChunks()) {
386+
const wx0 = chunk.cx * chunk.size + source.offset.x;
387+
const wy0 = chunk.cy * chunk.size + source.offset.y;
388+
const wz0 = chunk.cz * chunk.size + source.offset.z;
389+
390+
for (const [idx, entry] of chunk.entries()) {
391+
const { lx, ly, lz } = chunk.fromLinearIndex(idx);
392+
this.setVoxelAt(
393+
{ x: wx0 + lx, y: wy0 + ly, z: wz0 + lz },
394+
entry
395+
);
396+
}
397+
}
398+
}
379399
}

packages/voxel-renderer/src/world/VoxelWorld.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,64 @@ export class VoxelWorld {
199199
return clone;
200200
}
201201

202+
/**
203+
* Merges all voxels from `sourceName` into `targetName`.
204+
* Source voxels overwrite target voxels at the same world position.
205+
* Returns false if either layer does not exist.
206+
*/
207+
mergeLayer(
208+
sourceName: string,
209+
targetName: string
210+
): boolean {
211+
const source = this.getLayer(sourceName);
212+
const target = this.getLayer(targetName);
213+
if (!source || !target) {
214+
return false;
215+
}
216+
217+
target.mergeFrom(source);
218+
this.#markLayerDirty(target);
219+
220+
return true;
221+
}
222+
223+
/**
224+
* Collapses all voxel layers into a single layer using compositor order
225+
* (lowest-priority voxels are overwritten by higher-priority ones).
226+
* All layers except the base (lowest order) are removed from the world.
227+
* Returns null when there are no layers; returns the existing layer when
228+
* there is only one.
229+
*/
230+
mergeAllLayers(): VoxelLayer | null {
231+
if (this.#layers.length === 0) {
232+
return null;
233+
}
234+
if (this.#layers.length === 1) {
235+
return this.#layers[0];
236+
}
237+
238+
// Sort ascending so we merge from lowest to highest priority.
239+
const sorted = [...this.#layers].sort((a, b) => a.order - b.order);
240+
const target = sorted[0];
241+
242+
for (let i = 1; i < sorted.length; i++) {
243+
target.mergeFrom(sorted[i]);
244+
}
245+
246+
// Remove all but the target layer.
247+
for (let i = 1; i < sorted.length; i++) {
248+
const idx = this.#layers.findIndex((l) => l === sorted[i]);
249+
if (idx !== -1) {
250+
this.#layersToRemove.push(this.#layers[idx]);
251+
this.#layers.splice(idx, 1);
252+
}
253+
}
254+
255+
this.#markLayerDirty(target);
256+
257+
return target;
258+
}
259+
202260
// --- Object layer management --- //
203261

204262
addObjectLayer(

0 commit comments

Comments
 (0)