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
5 changes: 5 additions & 0 deletions .changeset/rich-ideas-tease.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@jolly-pixel/voxel.renderer": minor
---

Implement layers merging
58 changes: 43 additions & 15 deletions packages/voxel-renderer/examples/scripts/components/VoxelMap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,39 @@ import {
Actor,
ActorComponent
} from "@jolly-pixel/engine";
import * as THREE from "three";

// Import Internal Dependencies
import {
loadVoxelTiledMap,
VoxelRenderer
TilesetLoader,
VoxelRenderer,
type VoxelWorldJSON
} from "../../../src/index.ts";

export class VoxelBehavior extends ActorComponent {
world = loadVoxelTiledMap(this.actor.world.assetManager, "tilemap/brackeys-level.tmj", {
layerMode: "stacked"
});
// @ts-ignore
voxelRenderer: VoxelRenderer;
tilesetLoader = new TilesetLoader();

world: VoxelWorldJSON | undefined;

async initialize({ assetManager }) {
console.log("initialize VoxelBehavior");

this.tilesetLoader = new TilesetLoader({
manager: assetManager.context.manager
});
const mapLoader = loadVoxelTiledMap(
this.actor.world.assetManager,
"tilemap/brackeys-level.tmj",
{
layerMode: "stacked"
}
);

this.world = await mapLoader.getAsync();
console.log(this.world);
await this.tilesetLoader.fromWorld(this.world);
}

constructor(
actor: Actor
Expand All @@ -27,15 +47,23 @@ export class VoxelBehavior extends ActorComponent {
}

awake() {
const world = this.world.get();

const voxelRenderer = this.actor.getComponent(VoxelRenderer);
if (!voxelRenderer) {
throw new Error("VoxelRenderer component not found on actor");
if (!this.world) {
throw new Error("world is not initilized");
}
this.voxelRenderer = voxelRenderer;
voxelRenderer
.load(world)
.catch(console.error);

const vr = this.actor.addComponentAndGet(VoxelRenderer, {
material: "lambert",
materialCustomizer: (material) => {
if (material instanceof THREE.MeshStandardMaterial) {
material.metalness = 0;
material.roughness = 0.85;
}
},
tilesetLoader: this.tilesetLoader
});

vr.load(this.world, {
mergeLayers: true
});
}
}
20 changes: 12 additions & 8 deletions packages/voxel-renderer/examples/scripts/demo-physics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import * as THREE from "three";
// Import Internal Dependencies
import {
VoxelRenderer,
TilesetLoader,
Face,
type BlockDefinition
} from "../../src/index.ts";
Expand Down Expand Up @@ -40,6 +41,15 @@ const runtime = new Runtime(canvas, {
includePerformanceStats: true
});

const tileDef = {
tileSize: 32,
src: "tileset/UV_cube.png",
id: "default"
};

const tilesetLoader = new TilesetLoader();
await tilesetLoader.fromTileDefinition(tileDef);

const { world } = runtime;
world.logger.setLevel("debug");
world.logger.enableNamespace("*");
Expand Down Expand Up @@ -120,7 +130,8 @@ const voxelMap = world.createActor("map")
// Rapier namespace / World instance satisfy them without any cast.
api: RAPIER as never,
world: rapierWorld as never
}
},
tilesetLoader
}
);

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

// ── Tileset + runtime start ───────────────────────────────────────────────────
voxelMap.loadTileset({
tileSize: 32,
src: "tileset/UV_cube.png",
id: "default"
}).catch(console.error);

createExamplesMenu();
loadRuntime(runtime).catch(console.error);
12 changes: 0 additions & 12 deletions packages/voxel-renderer/examples/scripts/demo-tiled.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,6 @@ import {
import * as THREE from "three";

// Import Internal Dependencies
import {
VoxelRenderer
} from "../../src/index.ts";
import { VoxelBehavior } from "./components/VoxelMap.ts";
import { createExamplesMenu } from "./utils/menu.ts";

Expand Down Expand Up @@ -49,15 +46,6 @@ world.createActor("camera")
// ── VoxelRenderer ─────────────────────────────────────────────────────────────
// No blocks or layers supplied here — load() will register them from the JSON.
world.createActor("map")
.addComponent(VoxelRenderer, {
material: "lambert",
materialCustomizer: (material) => {
if (material instanceof THREE.MeshStandardMaterial) {
material.metalness = 0;
material.roughness = 0.85;
}
}
})
.addComponent(VoxelBehavior);

// ── Load runtime ────────────────────────────────────────────
Expand Down
34 changes: 33 additions & 1 deletion packages/voxel-renderer/src/VoxelRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,15 @@ import type { VoxelSetOptions, VoxelRemoveOptions, PartialExcept } from "./types

export type { VoxelSetOptions, VoxelRemoveOptions };

export interface VoxelLoadOptions {
/**
* When true, all voxel layers are collapsed into one before rendering.
* Higher-priority layers overwrite lower ones at the same world position.
* Use this for runtime loading when multi-layer editing is not needed.
*/
mergeLayers?: boolean;
}

type MaterialCustomizerFn = (
material: THREE.MeshLambertMaterial | THREE.MeshStandardMaterial,
tilesetId: string
Expand Down Expand Up @@ -577,6 +586,24 @@ export class VoxelRenderer extends ActorComponent {
return clone;
}

mergeLayer(
sourceLayerName: string,
targetLayerName: string
): boolean {
const merged = this.world.mergeLayer(sourceLayerName, targetLayerName);
if (!merged) {
return false;
}

this.#emitHook({
action: "merged",
layerName: sourceLayerName,
metadata: { targetLayerName }
});

return true;
}

addLayer(
name: string,
options: VoxelLayerConfigurableOptions = {}
Expand Down Expand Up @@ -807,7 +834,8 @@ export class VoxelRenderer extends ActorComponent {
}

load(
data: VoxelWorldJSON
data: VoxelWorldJSON,
options: VoxelLoadOptions = {}
): void {
// Clear existing meshes before replacing world data.
for (const mesh of this.#chunkMeshes.values()) {
Expand Down Expand Up @@ -851,6 +879,10 @@ export class VoxelRenderer extends ActorComponent {
}
this.#materials.clear();

if (options.mergeLayers) {
this.world.mergeAllLayers();
}

this.#rebuildAllChunks("load");
}

Expand Down
7 changes: 7 additions & 0 deletions packages/voxel-renderer/src/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@ export type VoxelLayerHookEvent =
options: PartialExcept<VoxelLayerOptions, "name">;
};
}
| {
action: "merged";
layerName: string;
metadata: {
targetLayerName: string;
};
}
| {
action: "offset-updated";
layerName: string;
Expand Down
1 change: 1 addition & 0 deletions packages/voxel-renderer/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export {
VoxelRenderer,
VoxelRotation,
type VoxelRendererOptions,
type VoxelLoadOptions,
type VoxelSetOptions,
type VoxelRemoveOptions
} from "./VoxelRenderer.ts";
Expand Down
4 changes: 4 additions & 0 deletions packages/voxel-renderer/src/network/VoxelCommandApplier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,10 @@ export function applyCommandToWorld(
world.moveLayer(cmd.layerName, cmd.metadata.direction);
break;

case "merged":
world.mergeLayer(cmd.layerName, cmd.metadata.targetLayerName);
break;

case "object-layer-added":
world.addObjectLayer(cmd.layerName);
break;
Expand Down
22 changes: 21 additions & 1 deletion packages/voxel-renderer/src/world/VoxelLayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -369,11 +369,31 @@ export class VoxelLayer {
};
}

clone(opts: Partial<VoxelLayerOptions> = {}): VoxelLayer {
clone(
opts: Partial<VoxelLayerOptions> = {}
): VoxelLayer {
return new VoxelLayer({
chunkSize: this.#chunkSize,
...this.toJSON(),
...opts
});
}

mergeFrom(
source: VoxelLayer
): void {
for (const chunk of source.getChunks()) {
const wx0 = chunk.cx * chunk.size + source.offset.x;
const wy0 = chunk.cy * chunk.size + source.offset.y;
const wz0 = chunk.cz * chunk.size + source.offset.z;

for (const [idx, entry] of chunk.entries()) {
const { lx, ly, lz } = chunk.fromLinearIndex(idx);
this.setVoxelAt(
{ x: wx0 + lx, y: wy0 + ly, z: wz0 + lz },
entry
);
}
}
}
}
58 changes: 58 additions & 0 deletions packages/voxel-renderer/src/world/VoxelWorld.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,64 @@ export class VoxelWorld {
return clone;
}

/**
* Merges all voxels from `sourceName` into `targetName`.
* Source voxels overwrite target voxels at the same world position.
* Returns false if either layer does not exist.
*/
mergeLayer(
sourceName: string,
targetName: string
): boolean {
const source = this.getLayer(sourceName);
const target = this.getLayer(targetName);
if (!source || !target) {
return false;
}

target.mergeFrom(source);
this.#markLayerDirty(target);

return true;
}

/**
* Collapses all voxel layers into a single layer using compositor order
* (lowest-priority voxels are overwritten by higher-priority ones).
* All layers except the base (lowest order) are removed from the world.
* Returns null when there are no layers; returns the existing layer when
* there is only one.
*/
mergeAllLayers(): VoxelLayer | null {
if (this.#layers.length === 0) {
return null;
}
if (this.#layers.length === 1) {
return this.#layers[0];
}

// Sort ascending so we merge from lowest to highest priority.
const sorted = [...this.#layers].sort((a, b) => a.order - b.order);
const target = sorted[0];

for (let i = 1; i < sorted.length; i++) {
target.mergeFrom(sorted[i]);
}

// Remove all but the target layer.
for (let i = 1; i < sorted.length; i++) {
const idx = this.#layers.findIndex((l) => l === sorted[i]);
if (idx !== -1) {
this.#layersToRemove.push(this.#layers[idx]);
this.#layers.splice(idx, 1);
}
}

this.#markLayerDirty(target);

return target;
}

// --- Object layer management --- //

addObjectLayer(
Expand Down
Loading
Loading