diff --git a/.changeset/rich-ideas-tease.md b/.changeset/rich-ideas-tease.md new file mode 100644 index 0000000..ba1f99c --- /dev/null +++ b/.changeset/rich-ideas-tease.md @@ -0,0 +1,5 @@ +--- +"@jolly-pixel/voxel.renderer": minor +--- + +Implement layers merging diff --git a/packages/voxel-renderer/examples/scripts/components/VoxelMap.ts b/packages/voxel-renderer/examples/scripts/components/VoxelMap.ts index 90a600d..a9bd3c1 100644 --- a/packages/voxel-renderer/examples/scripts/components/VoxelMap.ts +++ b/packages/voxel-renderer/examples/scripts/components/VoxelMap.ts @@ -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 @@ -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 + }); } } diff --git a/packages/voxel-renderer/examples/scripts/demo-physics.ts b/packages/voxel-renderer/examples/scripts/demo-physics.ts index 0d67c9a..3a145f5 100644 --- a/packages/voxel-renderer/examples/scripts/demo-physics.ts +++ b/packages/voxel-renderer/examples/scripts/demo-physics.ts @@ -12,6 +12,7 @@ import * as THREE from "three"; // Import Internal Dependencies import { VoxelRenderer, + TilesetLoader, Face, type BlockDefinition } from "../../src/index.ts"; @@ -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("*"); @@ -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 } ); @@ -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); diff --git a/packages/voxel-renderer/examples/scripts/demo-tiled.ts b/packages/voxel-renderer/examples/scripts/demo-tiled.ts index 266007f..16acdd3 100644 --- a/packages/voxel-renderer/examples/scripts/demo-tiled.ts +++ b/packages/voxel-renderer/examples/scripts/demo-tiled.ts @@ -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"; @@ -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 ──────────────────────────────────────────── diff --git a/packages/voxel-renderer/src/VoxelRenderer.ts b/packages/voxel-renderer/src/VoxelRenderer.ts index d3cb67c..1456aab 100644 --- a/packages/voxel-renderer/src/VoxelRenderer.ts +++ b/packages/voxel-renderer/src/VoxelRenderer.ts @@ -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 @@ -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 = {} @@ -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()) { @@ -851,6 +879,10 @@ export class VoxelRenderer extends ActorComponent { } this.#materials.clear(); + if (options.mergeLayers) { + this.world.mergeAllLayers(); + } + this.#rebuildAllChunks("load"); } diff --git a/packages/voxel-renderer/src/hooks.ts b/packages/voxel-renderer/src/hooks.ts index 908829b..9c7908c 100644 --- a/packages/voxel-renderer/src/hooks.ts +++ b/packages/voxel-renderer/src/hooks.ts @@ -37,6 +37,13 @@ export type VoxelLayerHookEvent = options: PartialExcept; }; } + | { + action: "merged"; + layerName: string; + metadata: { + targetLayerName: string; + }; + } | { action: "offset-updated"; layerName: string; diff --git a/packages/voxel-renderer/src/index.ts b/packages/voxel-renderer/src/index.ts index b6b751e..ec1d19b 100644 --- a/packages/voxel-renderer/src/index.ts +++ b/packages/voxel-renderer/src/index.ts @@ -3,6 +3,7 @@ export { VoxelRenderer, VoxelRotation, type VoxelRendererOptions, + type VoxelLoadOptions, type VoxelSetOptions, type VoxelRemoveOptions } from "./VoxelRenderer.ts"; diff --git a/packages/voxel-renderer/src/network/VoxelCommandApplier.ts b/packages/voxel-renderer/src/network/VoxelCommandApplier.ts index d8026c8..5a1f493 100644 --- a/packages/voxel-renderer/src/network/VoxelCommandApplier.ts +++ b/packages/voxel-renderer/src/network/VoxelCommandApplier.ts @@ -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; diff --git a/packages/voxel-renderer/src/world/VoxelLayer.ts b/packages/voxel-renderer/src/world/VoxelLayer.ts index fd71f17..7443d1e 100644 --- a/packages/voxel-renderer/src/world/VoxelLayer.ts +++ b/packages/voxel-renderer/src/world/VoxelLayer.ts @@ -369,11 +369,31 @@ export class VoxelLayer { }; } - clone(opts: Partial = {}): VoxelLayer { + clone( + opts: Partial = {} + ): 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 + ); + } + } + } } diff --git a/packages/voxel-renderer/src/world/VoxelWorld.ts b/packages/voxel-renderer/src/world/VoxelWorld.ts index 2d6eb7f..e08724b 100644 --- a/packages/voxel-renderer/src/world/VoxelWorld.ts +++ b/packages/voxel-renderer/src/world/VoxelWorld.ts @@ -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( diff --git a/packages/voxel-renderer/test/world/VoxelLayer.spec.ts b/packages/voxel-renderer/test/world/VoxelLayer.spec.ts index 080182e..1b4b489 100644 --- a/packages/voxel-renderer/test/world/VoxelLayer.spec.ts +++ b/packages/voxel-renderer/test/world/VoxelLayer.spec.ts @@ -216,3 +216,66 @@ describe("clone", () => { }); }); }); + +describe("VoxelLayer mergeFrom", () => { + it("copies voxels at correct world positions (both layers at offset {0,0,0})", () => { + const source = makeLayer({ id: "src", name: "Source" }); + const target = makeLayer({ id: "tgt", name: "Target" }); + const entry = makeEntry(5, 2); + source.setVoxelAt({ x: 2, y: 1, z: 3 }, entry); + + target.mergeFrom(source); + + assert.equal(target.getVoxelAt({ x: 2, y: 1, z: 3 }), entry); + }); + + it("applies source offset: voxel at local (1,0,0) with source offset {5,0,0} lands at world (6,0,0)", () => { + const source = makeLayer({ id: "src", name: "Source", offset: { x: 5, y: 0, z: 0 } }); + const target = makeLayer({ id: "tgt", name: "Target" }); + const entry = makeEntry(3, 0); + // Local (1,0,0) → world (6,0,0) + source.setVoxelAt({ x: 6, y: 0, z: 0 }, entry); + + target.mergeFrom(source); + + assert.equal(target.getVoxelAt({ x: 6, y: 0, z: 0 }), entry); + assert.equal(target.getVoxelAt({ x: 1, y: 0, z: 0 }), undefined); + }); + + it("applies target offset: target with offset {3,0,0} stores world (6,0,0) at local (3,0,0)", () => { + const source = makeLayer({ id: "src", name: "Source", offset: { x: 5, y: 0, z: 0 } }); + const target = makeLayer({ id: "tgt", name: "Target", offset: { x: 3, y: 0, z: 0 } }); + const entry = makeEntry(7, 1); + source.setVoxelAt({ x: 6, y: 0, z: 0 }, entry); + + target.mergeFrom(source); + + // World (6,0,0) should be in target (local (3,0,0)) + assert.equal(target.getVoxelAt({ x: 6, y: 0, z: 0 }), entry); + }); + + it("source voxel overwrites existing target voxel at same world position", () => { + const source = makeLayer({ id: "src", name: "Source" }); + const target = makeLayer({ id: "tgt", name: "Target" }); + const original = makeEntry(1, 0); + const overwrite = makeEntry(9, 3); + target.setVoxelAt({ x: 0, y: 0, z: 0 }, original); + source.setVoxelAt({ x: 0, y: 0, z: 0 }, overwrite); + + target.mergeFrom(source); + + assert.equal(target.getVoxelAt({ x: 0, y: 0, z: 0 }), overwrite); + }); + + it("source layer is not modified after merge", () => { + const source = makeLayer({ id: "src", name: "Source" }); + const target = makeLayer({ id: "tgt", name: "Target" }); + const entry = makeEntry(2, 0); + source.setVoxelAt({ x: 1, y: 1, z: 1 }, entry); + + target.mergeFrom(source); + + assert.equal(source.getVoxelAt({ x: 1, y: 1, z: 1 }), entry); + assert.equal(source.chunkCount, 1); + }); +}); diff --git a/packages/voxel-renderer/test/world/VoxelWorld.spec.ts b/packages/voxel-renderer/test/world/VoxelWorld.spec.ts index f333e51..74e0828 100644 --- a/packages/voxel-renderer/test/world/VoxelWorld.spec.ts +++ b/packages/voxel-renderer/test/world/VoxelWorld.spec.ts @@ -332,3 +332,104 @@ describe("VoxelWorld clone", () => { return rest; } }); + +describe("VoxelWorld mergeLayer", () => { + it("returns false when source layer does not exist", () => { + const world = new VoxelWorld(4); + world.addLayer("Target"); + assert.equal(world.mergeLayer("NoSuch", "Target"), false); + }); + + it("returns false when target layer does not exist", () => { + const world = new VoxelWorld(4); + world.addLayer("Source"); + assert.equal(world.mergeLayer("Source", "NoSuch"), false); + }); + + it("copies voxels from source into target and returns true", () => { + const world = new VoxelWorld(4); + world.addLayer("Source"); + world.addLayer("Target"); + const entry = makeEntry(3, 0); + world.setVoxelAt("Source", { x: 1, y: 0, z: 0 }, entry); + + const result = world.mergeLayer("Source", "Target"); + + assert.equal(result, true); + assert.equal(world.getLayer("Target")!.getVoxelAt({ x: 1, y: 0, z: 0 }), entry); + }); + + it("source layer voxels overwrite target voxels at the same position", () => { + const world = new VoxelWorld(4); + world.addLayer("Source"); + world.addLayer("Target"); + const srcEntry = makeEntry(9, 2); + const tgtEntry = makeEntry(1, 0); + world.setVoxelAt("Target", { x: 0, y: 0, z: 0 }, tgtEntry); + world.setVoxelAt("Source", { x: 0, y: 0, z: 0 }, srcEntry); + + world.mergeLayer("Source", "Target"); + + assert.equal(world.getLayer("Target")!.getVoxelAt({ x: 0, y: 0, z: 0 }), srcEntry); + }); +}); + +describe("VoxelWorld mergeAllLayers", () => { + it("returns null when there are no layers", () => { + const world = new VoxelWorld(4); + assert.equal(world.mergeAllLayers(), null); + }); + + it("returns the existing layer and is a no-op with one layer", () => { + const world = new VoxelWorld(4); + const layer = world.addLayer("Only"); + const entry = makeEntry(2, 0); + world.setVoxelAt("Only", { x: 0, y: 0, z: 0 }, entry); + + const result = world.mergeAllLayers(); + + assert.equal(result, layer); + assert.equal(world.getLayers().length, 1); + assert.equal(layer.getVoxelAt({ x: 0, y: 0, z: 0 }), entry); + }); + + it("collapses multiple layers into one", () => { + const world = new VoxelWorld(4); + world.addLayer("Base"); + world.addLayer("Top"); + world.setVoxelAt("Base", { x: 0, y: 0, z: 0 }, makeEntry(1, 0)); + world.setVoxelAt("Top", { x: 1, y: 0, z: 0 }, makeEntry(2, 0)); + + const result = world.mergeAllLayers(); + + assert.equal(world.getLayers().length, 1); + assert.ok(result !== null); + }); + + it("higher-priority layer wins at conflict (compositor order)", () => { + const world = new VoxelWorld(4); + // Base has order 0, Top has order 1 (higher priority). + world.addLayer("Base"); + world.addLayer("Top"); + const basEntry = makeEntry(1, 0); + const topEntry = makeEntry(9, 0); + world.setVoxelAt("Base", { x: 0, y: 0, z: 0 }, basEntry); + world.setVoxelAt("Top", { x: 0, y: 0, z: 0 }, topEntry); + + const result = world.mergeAllLayers()!; + + // Higher priority (Top) should win. + assert.equal(result.getVoxelAt({ x: 0, y: 0, z: 0 }), topEntry); + }); + + it("only one layer remains after mergeAllLayers", () => { + const world = new VoxelWorld(4); + world.addLayer("A"); + world.addLayer("B"); + world.addLayer("C"); + + world.mergeAllLayers(); + + assert.equal(world.getLayers().length, 1); + }); +});