From 893b77b7b7787a85f5a151387bb9856405ecb2de Mon Sep 17 00:00:00 2001 From: GENTILHOMME Thomas Date: Thu, 24 Sep 2026 22:29:18 +0200 Subject: [PATCH 1/2] refactor(voxel-map): chunkSize is no longer a forced value --- .changeset/voxel-document-any-chunk-size.md | 5 +++ packages/assets/voxel-map/README.md | 4 +- .../voxel-map/src/asset/voxelMapAssetKind.ts | 7 +-- .../test/asset/voxelMapAssetKind.spec.ts | 34 +++++++++----- packages/editors/host/docs/mountStandalone.md | 2 +- .../voxel-map/src/boot/VoxelMapEditor.ts | 1 - .../voxel-map/src/boot/worldProject.ts | 10 +++-- .../editors/voxel-map/test/e2e/fixtures.ts | 8 ++-- packages/studio/src/seed.ts | 10 +++-- .../docs/api/serialization/serialization.md | 7 +-- .../docs/guides/saving-and-loading-worlds.md | 3 ++ .../src/document/VoxelDocument.ts | 3 +- .../voxel-renderer/src/serialization/world.ts | 8 ---- .../test/serialization/world.spec.ts | 44 +++++++++++++++---- 14 files changed, 95 insertions(+), 51 deletions(-) create mode 100644 .changeset/voxel-document-any-chunk-size.md diff --git a/.changeset/voxel-document-any-chunk-size.md b/.changeset/voxel-document-any-chunk-size.md new file mode 100644 index 000000000..ded8d330b --- /dev/null +++ b/.changeset/voxel-document-any-chunk-size.md @@ -0,0 +1,5 @@ +--- +"@jolly-pixel/voxel.renderer": minor +--- + +`deserializeVoxelWorld` no longer rejects a document saved with another `chunkSize`: its voxels are re-partitioned into the target world's chunks. diff --git a/packages/assets/voxel-map/README.md b/packages/assets/voxel-map/README.md index 7afcd5e74..07ec16531 100644 --- a/packages/assets/voxel-map/README.md +++ b/packages/assets/voxel-map/README.md @@ -26,11 +26,11 @@ using eventStore = await EventStore.persistence.sqlite( await createAssetBackend({ source: new FilesystemAssetSource("./assets"), eventStore, - handlers: [voxelMapAssetKind({ chunkSize: 16 })] + handlers: [voxelMapAssetKind()] }); ``` -The default `chunkSize` is 16. The handler waits 5 seconds after edits before writing a snapshot, with a 60-second maximum delay while edits continue. Pass `snapshot` to change this policy. +The default `chunkSize` is 16. A map saved with another chunk size still loads, and is saved back with the handler's. The handler waits 5 seconds after edits before writing a snapshot, with a 60-second maximum delay while edits continue. Pass `snapshot` to change this policy. ### Connect a world diff --git a/packages/assets/voxel-map/src/asset/voxelMapAssetKind.ts b/packages/assets/voxel-map/src/asset/voxelMapAssetKind.ts index 841ff2e10..b4eda3383 100644 --- a/packages/assets/voxel-map/src/asset/voxelMapAssetKind.ts +++ b/packages/assets/voxel-map/src/asset/voxelMapAssetKind.ts @@ -5,6 +5,7 @@ import type { SnapshotPolicy } from "@jolly-pixel/asset-server/kinds"; import { + DEFAULT_CHUNK_SIZE, decodeVoxelDocument, encodeVoxelDocument } from "@jolly-pixel/voxel.renderer"; @@ -24,7 +25,6 @@ import { VoxelCommandArbiter } from "../network/VoxelCommandArbiter.ts"; import type { VoxelNetworkCommand } from "../network/types.ts"; // CONSTANTS -const kDefaultChunkSize = 16; /** * Uses a slower snapshot cadence for bursty, expensive terrain serialization. */ @@ -35,7 +35,8 @@ const kDefaultSnapshot: SnapshotPolicy = { export interface VoxelMapAssetKindOptions { /** - * Chunk size used when no document exists. + * Chunk size of the server-side world. A document saved with another size + * is re-partitioned on load and saved back with this one. * @default 16 */ chunkSize?: number; @@ -50,7 +51,7 @@ export function voxelMapAssetKind( options: VoxelMapAssetKindOptions = {} ): AssetKindHandler { const { - chunkSize = kDefaultChunkSize, + chunkSize = DEFAULT_CHUNK_SIZE, snapshot = kDefaultSnapshot, conflictResolver } = options; diff --git a/packages/assets/voxel-map/test/asset/voxelMapAssetKind.spec.ts b/packages/assets/voxel-map/test/asset/voxelMapAssetKind.spec.ts index 9032771d6..0bee0aa45 100644 --- a/packages/assets/voxel-map/test/asset/voxelMapAssetKind.spec.ts +++ b/packages/assets/voxel-map/test/asset/voxelMapAssetKind.spec.ts @@ -307,19 +307,31 @@ describe("voxelMapAssetKind", () => { }); }); - test("a document with a mismatched chunk size is refused", () => { - const handler = voxelMapAssetKind({ chunkSize: 16 }); + test("a document saved with another chunk size loads", async() => { + const source = new VoxelMapState(32); + source.world.addLayer("Ground"); + source.world.setVoxelAt( + "Ground", + { + x: 20, + y: 0, + z: -3 + }, + { + blockId: 4, + transform: 0 + } + ); + const handler = voxelMapAssetKind(); const state = handler.create("asset-1"); - state.world.addLayer("Ground"); - assert.throws( - () => foldAssetEvent(handler, state, documentEvent(new VoxelMapState(8))), - /chunkSize 8 does not match/ - ); - assert.deepEqual( - state.world.getLayers().map((layer) => layer.name), - ["Ground"] - ); + foldAssetEvent(handler, state, documentEvent(source)); + + assert.equal(state.world.chunkSize, 16); + assert.equal(state.world.getVoxelAt({ x: 20, y: 0, z: -3 })?.blockId, 4); + const saved = decodeVoxelDocument(await handler.serialize(state)); + assert.equal(saved.chunkSize, 16); + assert.deepEqual(Object.keys(saved.layers[0].voxels), ["20,0,-3"]); }); test("serialize round-trips through apply", async() => { diff --git a/packages/editors/host/docs/mountStandalone.md b/packages/editors/host/docs/mountStandalone.md index 1d5051e76..1aba7d3e3 100644 --- a/packages/editors/host/docs/mountStandalone.md +++ b/packages/editors/host/docs/mountStandalone.md @@ -192,7 +192,7 @@ import { mountStandalone } from "@jolly-pixel/editor.host"; const { openSharedTabWorkspace } = await import("@jolly-pixel/editor.host/offline"); const workspace = await openSharedTabWorkspace({ - handlers: [voxelMapAssetKind({ chunkSize: 16 })], + handlers: [voxelMapAssetKind()], seed: { "maps/scratch.voxelmap.json": { id: crypto.randomUUID(), diff --git a/packages/editors/voxel-map/src/boot/VoxelMapEditor.ts b/packages/editors/voxel-map/src/boot/VoxelMapEditor.ts index b6afb454f..e8a7c7c1a 100644 --- a/packages/editors/voxel-map/src/boot/VoxelMapEditor.ts +++ b/packages/editors/voxel-map/src/boot/VoxelMapEditor.ts @@ -32,7 +32,6 @@ const kPerformanceStorageKey = "voxel-map:performance-hud"; const kPerformancePaneKey = "performance"; const kPerformanceToggleKey = "F3"; const kMapKind = voxelMapDocumentKind({ - chunkSize: 16, history: { enabled: true } diff --git a/packages/editors/voxel-map/src/boot/worldProject.ts b/packages/editors/voxel-map/src/boot/worldProject.ts index 24fb72d33..ad1f69e2e 100644 --- a/packages/editors/voxel-map/src/boot/worldProject.ts +++ b/packages/editors/voxel-map/src/boot/worldProject.ts @@ -16,10 +16,12 @@ import { voxelMapAssetKind, type TilesetDocument } from "@jolly-pixel/asset.voxel-map"; -import { DEFAULT_TILE_SIZE } from "@jolly-pixel/voxel.renderer"; +import { + DEFAULT_CHUNK_SIZE, + DEFAULT_TILE_SIZE +} from "@jolly-pixel/voxel.renderer"; // CONSTANTS -export const CHUNK_SIZE = 16; export const DEFAULT_TILESET_ID = "default"; const kTilesetUrl = "textures/tileset.png"; @@ -48,7 +50,7 @@ export async function createWorldProject( return { handlers: [ pixelArtAssetKind({ defaultSize: tileset.size }), - voxelMapAssetKind({ chunkSize: CHUNK_SIZE }), + voxelMapAssetKind(), textureAssetKind() ], seed: { @@ -61,7 +63,7 @@ export async function createWorldProject( id: crypto.randomUUID(), kind: VOXEL_MAP_KIND, content: () => createVoxelMapDocument({ - chunkSize: CHUNK_SIZE, + chunkSize: DEFAULT_CHUNK_SIZE, tileset: tileset.definition }) } diff --git a/packages/editors/voxel-map/test/e2e/fixtures.ts b/packages/editors/voxel-map/test/e2e/fixtures.ts index e88346ba1..a27a5ae20 100644 --- a/packages/editors/voxel-map/test/e2e/fixtures.ts +++ b/packages/editors/voxel-map/test/e2e/fixtures.ts @@ -17,12 +17,10 @@ import { VOXEL_MAP_KIND, tilesetAsset } from "@jolly-pixel/asset.voxel-map"; +import { DEFAULT_CHUNK_SIZE } from "@jolly-pixel/voxel.renderer"; // Import Internal Dependencies -import { - CHUNK_SIZE, - createDefaultTileset -} from "../../src/boot/worldProject.ts"; +import { createDefaultTileset } from "../../src/boot/worldProject.ts"; // CONSTANTS const kTilesetFile = path.join( @@ -63,7 +61,7 @@ export const test = editorFixture({ const id = await catalog.create( `${folder}/world.voxelmap.json`, createVoxelMapDocument({ - chunkSize: CHUNK_SIZE, + chunkSize: DEFAULT_CHUNK_SIZE, tileset: { ...tileset.definition, asset: tilesetAsset(tilesetId) diff --git a/packages/studio/src/seed.ts b/packages/studio/src/seed.ts index 140472702..cc9892717 100644 --- a/packages/studio/src/seed.ts +++ b/packages/studio/src/seed.ts @@ -22,7 +22,10 @@ import { VOXEL_MODEL_KIND, voxelModelAssetKind } from "@jolly-pixel/asset.voxel-model"; -import { DEFAULT_TILE_SIZE } from "@jolly-pixel/voxel.renderer"; +import { + DEFAULT_CHUNK_SIZE, + DEFAULT_TILE_SIZE +} from "@jolly-pixel/voxel.renderer"; // CONSTANTS const kTilesetUrl = "textures/tileset.png"; @@ -30,7 +33,6 @@ const kTilesetAssetId = "tileset-default"; const kMapAssetId = "map-overworld"; const kModelTextureAssetId = "model-texture"; const kModelAssetId = "model-default"; -const kChunkSize = 16; const kModelTextureSize = { x: 64, y: 64 @@ -54,7 +56,7 @@ export async function createStudioProject( return { handlers: [ pixelArtAssetKind({ defaultSize: tileset.size }), - voxelMapAssetKind({ chunkSize: kChunkSize }), + voxelMapAssetKind(), voxelModelAssetKind(), textureAssetKind() ], @@ -68,7 +70,7 @@ export async function createStudioProject( id: kMapAssetId, kind: VOXEL_MAP_KIND, content: () => createVoxelMapDocument({ - chunkSize: kChunkSize, + chunkSize: DEFAULT_CHUNK_SIZE, tileset: tileset.definition }) }, diff --git a/packages/voxel-renderer/docs/api/serialization/serialization.md b/packages/voxel-renderer/docs/api/serialization/serialization.md index 8e71116d7..a7107bdd2 100644 --- a/packages/voxel-renderer/docs/api/serialization/serialization.md +++ b/packages/voxel-renderer/docs/api/serialization/serialization.md @@ -85,9 +85,10 @@ function deserializeVoxelWorld( ``` The function validates `data`, then replaces the world's voxel and object -layers. It throws `InvalidVoxelDocumentError` when the document is malformed or -its chunk size differs from the target world. The target is left unchanged on -those failures. +layers. It throws `InvalidVoxelDocumentError` when the document is malformed, +and leaves the target unchanged. Voxel keys are layer coordinates, so a document +saved with another `chunkSize` loads into the world's own chunks; serializing +the world again writes the world's `chunkSize`. `options.tilesets` is replaced with the document's tilesets and `defaultTileSize`. When both `blocks` and `tilesets` are supplied, tile diff --git a/packages/voxel-renderer/docs/guides/saving-and-loading-worlds.md b/packages/voxel-renderer/docs/guides/saving-and-loading-worlds.md index 162fae088..7b51cb2df 100644 --- a/packages/voxel-renderer/docs/guides/saving-and-loading-worlds.md +++ b/packages/voxel-renderer/docs/guides/saving-and-loading-worlds.md @@ -27,6 +27,9 @@ const engine = new VoxelEngine({ engine.load(document); ``` +Passing `document.chunkSize` keeps the saved chunk layout. An engine with +another chunk size loads the document too, and re-partitions its voxels. + Every referenced tileset must be registered by the time `load()` applies the document. A document carrying block definitions replaces the registry with them, so a saved shape survives the load. A document without any leaves the diff --git a/packages/voxel-renderer/src/document/VoxelDocument.ts b/packages/voxel-renderer/src/document/VoxelDocument.ts index 230c6ebd5..fa25e406e 100644 --- a/packages/voxel-renderer/src/document/VoxelDocument.ts +++ b/packages/voxel-renderer/src/document/VoxelDocument.ts @@ -29,6 +29,7 @@ import { TilesetList } from "../tileset/TilesetList.ts"; import type { TilesetDefinition } from "../tileset/types.ts"; import { NOOP_LOGGER, type VoxelLogger } from "../utils/logger.ts"; import { VoxelWorld } from "../world/VoxelWorld.ts"; +import { DEFAULT_CHUNK_SIZE } from "../world/VoxelChunk.ts"; import type { VoxelApplyOptions, VoxelDocumentEvents, @@ -54,7 +55,7 @@ export class VoxelDocument extends Emitter { options: VoxelDocumentOptions = {} ) { const { - chunkSize = 16, + chunkSize = DEFAULT_CHUNK_SIZE, layers = [], blocks = [], tilesets = [], diff --git a/packages/voxel-renderer/src/serialization/world.ts b/packages/voxel-renderer/src/serialization/world.ts index d3ace184c..d8873de6d 100644 --- a/packages/voxel-renderer/src/serialization/world.ts +++ b/packages/voxel-renderer/src/serialization/world.ts @@ -1,8 +1,5 @@ // Import Internal Dependencies import { parseVoxelDocument } from "./document.ts"; -import { - InvalidVoxelDocumentError -} from "./errors/InvalidVoxelDocumentError.ts"; import { BlockTextures } from "../blocks/BlockTextures.ts"; import type { VoxelWorldJSON } from "./types.ts"; import type { VoxelWorld } from "../world/VoxelWorld.ts"; @@ -59,11 +56,6 @@ export function deserializeVoxelWorld( const { blocks, tilesets } = options; const document = parseVoxelDocument(data); - if (document.chunkSize !== world.chunkSize) { - throw new InvalidVoxelDocumentError( - `chunkSize ${document.chunkSize} does not match the world's ${world.chunkSize}` - ); - } tilesets?.replace(document.tilesets, document.defaultTileSize); if (blocks && document.blocks) { diff --git a/packages/voxel-renderer/test/serialization/world.spec.ts b/packages/voxel-renderer/test/serialization/world.spec.ts index 960cab31c..348695980 100644 --- a/packages/voxel-renderer/test/serialization/world.spec.ts +++ b/packages/voxel-renderer/test/serialization/world.spec.ts @@ -217,15 +217,43 @@ describe("deserializeVoxelWorld", () => { ); }); - it("throws when the chunk size differs from the world", () => { + it("re-partitions a document saved with another chunk size", () => { + const voxels = { + "0,0,0": { block: 1, transform: 0 }, + "7,0,0": { block: 2, transform: 0 }, + "8,0,0": { block: 3, transform: 0 }, + "15,9,-1": { block: 4, transform: 0 }, + "16,0,0": { block: 5, transform: 0 } + }; const world = new VoxelWorld(16); - assert.throws( - () => deserializeVoxelWorld( - { version: 1, chunkSize: 8, tilesets: [], layers: [] }, - world - ), - /chunkSize 8 does not match the world's 16/ + deserializeVoxelWorld( + untrusted({ + version: 1, + chunkSize: 8, + tilesets: [], + layers: [{ + id: "l1", + name: "Ground", + visible: true, + order: 0, + voxels + }] + }), + world + ); + + const layer = world.getLayer("Ground"); + assert.ok(layer !== undefined); + assert.equal(layer.voxelCount, 5); + assert.equal(layer.chunkCount, 3); + assert.equal(world.getVoxelAt({ x: 15, y: 9, z: -1 })?.blockId, 4); + + const json = serializeVoxelWorld(world); + assert.equal(json.chunkSize, 16); + assert.deepEqual( + Object.keys(json.layers[0].voxels).sort(), + Object.keys(voxels).sort() ); }); @@ -345,7 +373,7 @@ describe("deserializeVoxelWorld", () => { assert.throws(() => deserializeVoxelWorld( { version: 1, - chunkSize: 8, + chunkSize: 0, tilesets: [], blocks: [resolveBlockDefinition(makeBlockDef(7, "cube"))], layers: [] From 11177cc63b6a122878b5a1f08b08792c2de9cd37 Mon Sep 17 00:00:00 2001 From: GENTILHOMME Thomas Date: Thu, 24 Sep 2026 23:23:14 +0200 Subject: [PATCH 2/2] feat(voxel-map): implement materialGroups and better environment --- .changeset/voxel-material-group-finish.md | 5 + .../voxel-map/src/asset/VoxelMapState.ts | 9 +- .../src/network/VoxelCommand.schema.ts | 37 ++++ .../src/network/VoxelCommandArbiter.ts | 4 + .../test/asset/voxelMapAssetKind.spec.ts | 40 +++- .../test/network/VoxelCommand.schema.spec.ts | 28 +++ .../test/network/VoxelCommandArbiter.spec.ts | 21 +++ packages/editors/voxel-map/README.md | 4 + .../voxel-map/src/document/MapDocument.ts | 6 + .../src/features/blocks/BlockEditorDialog.ts | 41 +++++ .../src/features/blocks/BlockLibrary.ts | 1 + .../features/blocks/BlockLibraryRenderer.ts | 30 ++- .../features/blocks/BlockLibraryViewport.ts | 1 + .../features/blocks/BlockMaterialFinish.ts | 174 ++++++++++++++++++ .../features/blocks/BlockPreviewRenderer.ts | 16 +- .../src/features/blocks/BlockShapePreview.ts | 3 +- .../src/features/blocks/blockPreviewMesh.ts | 39 +++- .../features/blocks/materialGroupSources.ts | 69 +++++++ .../src/features/map-config/MapConfigPanel.ts | 78 ++++++-- .../voxel-map/src/scene/EditorScene.ts | 24 ++- .../voxel-map/src/scene/SceneEnvironment.ts | 140 ++++++++++++++ .../voxel-map/src/scene/SceneLighting.ts | 113 ++++++++---- .../voxel-map/src/scene/skyBackground.ts | 48 +++++ .../voxel-map/src/state/EditorState.ts | 19 +- .../editors/voxel-map/src/state/ViewStore.ts | 122 ++++++++++++ packages/editors/voxel-map/src/state/index.ts | 1 + .../test/document/MapDocument.spec.ts | 10 +- .../features/blocks/blockPreviewMesh.spec.ts | 20 ++ .../blocks/materialGroupSources.spec.ts | 114 ++++++++++++ .../test/scene/SceneLighting.spec.ts | 44 ++++- .../voxel-map/test/state/ViewStore.spec.ts | 70 +++++++ .../docs/api/blocks/BlockSurface.md | 17 +- .../docs/api/core/VoxelDocument.md | 16 +- .../docs/api/core/VoxelEngine.md | 14 +- .../voxel-renderer/docs/api/core/commands.md | 33 +++- .../docs/api/materials/MaterialGroup.md | 116 ++++++++++++ .../docs/api/serialization/serialization.md | 14 +- packages/voxel-renderer/src/VoxelEngine.ts | 23 +++ .../voxel-renderer/src/applyVoxelCommand.ts | 9 + packages/voxel-renderer/src/commands.ts | 34 +++- .../src/document/VoxelDocument.ts | 59 +++++- .../src/document/VoxelDocument.types.ts | 3 + packages/voxel-renderer/src/index.ts | 1 + .../src/materials/MaterialGroup.ts | 156 ++++++++++++++++ .../src/materials/MaterialGroupList.ts | 92 +++++++++ .../materials/applyMaterialGroupCommand.ts | 14 ++ .../voxel-renderer/src/materials/index.ts | 3 + .../src/render/ChunkMaterialCache.ts | 91 +++++++-- .../src/serialization/document.ts | 4 + .../voxel-renderer/src/serialization/types.ts | 2 + .../voxel-renderer/src/serialization/world.ts | 14 +- packages/voxel-renderer/src/view/VoxelView.ts | 10 + .../test/VoxelEngine.materials.spec.ts | 63 +++++++ .../test/applyVoxelCommand.spec.ts | 22 ++- packages/voxel-renderer/test/commands.spec.ts | 9 +- .../test/document/VoxelDocument.spec.ts | 48 +++++ .../test/materials/MaterialGroup.spec.ts | 130 +++++++++++++ .../test/render/ChunkMaterialCache.spec.ts | 80 ++++++++ .../test/serialization/world.spec.ts | 40 ++++ 59 files changed, 2330 insertions(+), 118 deletions(-) create mode 100644 .changeset/voxel-material-group-finish.md create mode 100644 packages/editors/voxel-map/src/features/blocks/BlockMaterialFinish.ts create mode 100644 packages/editors/voxel-map/src/features/blocks/materialGroupSources.ts create mode 100644 packages/editors/voxel-map/src/scene/SceneEnvironment.ts create mode 100644 packages/editors/voxel-map/src/scene/skyBackground.ts create mode 100644 packages/editors/voxel-map/src/state/ViewStore.ts create mode 100644 packages/editors/voxel-map/test/features/blocks/materialGroupSources.spec.ts create mode 100644 packages/editors/voxel-map/test/state/ViewStore.spec.ts create mode 100644 packages/voxel-renderer/docs/api/materials/MaterialGroup.md create mode 100644 packages/voxel-renderer/src/materials/MaterialGroup.ts create mode 100644 packages/voxel-renderer/src/materials/MaterialGroupList.ts create mode 100644 packages/voxel-renderer/src/materials/applyMaterialGroupCommand.ts create mode 100644 packages/voxel-renderer/src/materials/index.ts create mode 100644 packages/voxel-renderer/test/materials/MaterialGroup.spec.ts diff --git a/.changeset/voxel-material-group-finish.md b/.changeset/voxel-material-group-finish.md new file mode 100644 index 000000000..76f30c142 --- /dev/null +++ b/.changeset/voxel-material-group-finish.md @@ -0,0 +1,5 @@ +--- +"@jolly-pixel/voxel.renderer": minor +--- + +Store material group finishes (roughness, metalness, emissive) in the document through `materialGroups` and the new `material-group-*` commands. A defined group renders with a standard material even in a Lambert view. diff --git a/packages/assets/voxel-map/src/asset/VoxelMapState.ts b/packages/assets/voxel-map/src/asset/VoxelMapState.ts index 6c819b470..9bd70db0e 100644 --- a/packages/assets/voxel-map/src/asset/VoxelMapState.ts +++ b/packages/assets/voxel-map/src/asset/VoxelMapState.ts @@ -3,6 +3,7 @@ import { applyVoxelCommand, BlockRegistry, deserializeVoxelWorld, + MaterialGroupList, parseVoxelDocument, serializeVoxelWorld, TilesetList, @@ -19,6 +20,7 @@ export class VoxelMapState implements VoxelCommandTarget { readonly world: VoxelWorld; readonly blocks = new BlockRegistry(); readonly tilesets = new TilesetList(); + readonly materialGroups = new MaterialGroupList(); constructor( chunkSize: number @@ -30,7 +32,8 @@ export class VoxelMapState implements VoxelCommandTarget { return serializeVoxelWorld(this.world, { tilesets: this.tilesets, defaultTileSize: this.tilesets.defaultTileSize, - blocks: this.blocks + blocks: this.blocks, + materialGroups: this.materialGroups }); } @@ -39,7 +42,8 @@ export class VoxelMapState implements VoxelCommandTarget { ): void { deserializeVoxelWorld(document, this.world, { blocks: this.blocks, - tilesets: this.tilesets + tilesets: this.tilesets, + materialGroups: this.materialGroups }); } @@ -67,5 +71,6 @@ export class VoxelMapState implements VoxelCommandTarget { this.world.clear(); this.blocks.clear(); this.tilesets.clear(); + this.materialGroups.clear(); } } diff --git a/packages/assets/voxel-map/src/network/VoxelCommand.schema.ts b/packages/assets/voxel-map/src/network/VoxelCommand.schema.ts index cea9d3a9d..a360d71a2 100644 --- a/packages/assets/voxel-map/src/network/VoxelCommand.schema.ts +++ b/packages/assets/voxel-map/src/network/VoxelCommand.schema.ts @@ -10,6 +10,7 @@ import { MAX_TILE_SIZE, type VoxelBlockCommandAction, type VoxelLayerCommandAction, + type VoxelMaterialGroupCommandAction, type VoxelTilesetCommandAction } from "@jolly-pixel/voxel.renderer"; @@ -217,6 +218,35 @@ const kTilesetCommandProperties: Record< } }; +const kUnitSchema: JSONSchema = { + type: "number", + minimum: 0, + maximum: 1 +}; + +const kMaterialGroupSchema = objectSchema( + { + id: { type: "string", minLength: 1 }, + roughness: kUnitSchema, + metalness: kUnitSchema, + emissive: { type: "string", pattern: "^#[0-9a-fA-F]{6}$" }, + emissiveIntensity: { type: "number", minimum: 0 } + }, + ["id"] +); + +const kMaterialGroupCommandProperties: Record< + VoxelMaterialGroupCommandAction, + Record +> = { + "material-group-defined": { + group: kMaterialGroupSchema + }, + "material-group-removed": { + groupId: { type: "string", minLength: 1 } + } +}; + export const voxelWorldSchema: JSONSchema = { type: "object", properties: { @@ -225,6 +255,10 @@ export const voxelWorldSchema: JSONSchema = { tilesets: { type: "array" }, defaultTileSize: { type: "number" }, blocks: { type: "array" }, + materialGroups: { + type: "array", + items: kMaterialGroupSchema + }, layers: { type: "array" }, objectLayers: { type: "array" } }, @@ -270,6 +304,9 @@ export const voxelCommandProtocol: MessageProtocol = defineMessageProtocol({ ...Object.entries(kTilesetCommandProperties).map( ([action, properties]) => commandVariant(action, properties) ), + ...Object.entries(kMaterialGroupCommandProperties).map( + ([action, properties]) => commandVariant(action, properties) + ), commandVariant("world-replace", { data: voxelWorldSchema }) diff --git a/packages/assets/voxel-map/src/network/VoxelCommandArbiter.ts b/packages/assets/voxel-map/src/network/VoxelCommandArbiter.ts index dd3664253..87330e043 100644 --- a/packages/assets/voxel-map/src/network/VoxelCommandArbiter.ts +++ b/packages/assets/voxel-map/src/network/VoxelCommandArbiter.ts @@ -92,6 +92,10 @@ export class VoxelCommandArbiter { return `tileset:${command.tilesetId}`; case "default-tile-size-updated": return "default-tile-size"; + case "material-group-defined": + return `material-group:${command.group.id}`; + case "material-group-removed": + return `material-group:${command.groupId}`; default: return null; } diff --git a/packages/assets/voxel-map/test/asset/voxelMapAssetKind.spec.ts b/packages/assets/voxel-map/test/asset/voxelMapAssetKind.spec.ts index 0bee0aa45..492aaedb9 100644 --- a/packages/assets/voxel-map/test/asset/voxelMapAssetKind.spec.ts +++ b/packages/assets/voxel-map/test/asset/voxelMapAssetKind.spec.ts @@ -19,7 +19,8 @@ import { resolveBlockDefinition, VOXEL_BLOCK_COMMAND_ACTIONS, VOXEL_TILESET_COMMAND_ACTIONS, - VOXEL_LAYER_COMMAND_ACTIONS + VOXEL_LAYER_COMMAND_ACTIONS, + VOXEL_MATERIAL_GROUP_COMMAND_ACTIONS } from "@jolly-pixel/voxel.renderer"; // Import Internal Dependencies @@ -369,6 +370,7 @@ describe("voxelMapAssetKind", () => { ...VOXEL_LAYER_COMMAND_ACTIONS, ...VOXEL_BLOCK_COMMAND_ACTIONS, ...VOXEL_TILESET_COMMAND_ACTIONS, + ...VOXEL_MATERIAL_GROUP_COMMAND_ACTIONS, "world-replace" ].toSorted()); }); @@ -580,6 +582,42 @@ describe("voxelMapAssetKind — block definitions", () => { }); }); +describe("voxelMapAssetKind — material groups", () => { + test("a material group command survives serialization", async() => { + const handler = voxelMapAssetKind({ chunkSize: 16 }); + const state = handler.create("asset-1"); + + foldAssetEvent(handler, state, documentEvent(new VoxelMapState(16))); + foldAssetEvent(handler, state, event(VOXEL_MAP_COMMAND, { + action: "material-group-defined", + group: { id: "gold", metalness: 1 }, + clientId: "client-A", + seq: 1, + timestamp: 1000 + })); + + const document = decodeVoxelDocument(await handler.serialize(state)); + + assert.deepEqual( + document.materialGroups?.map((group) => [group.id, group.metalness]), + [["gold", 1]] + ); + }); + + test("a delete clears the material groups", () => { + const handler = voxelMapAssetKind({ chunkSize: 16 }); + const state = handler.create("asset-1"); + state.materialGroups.define({ id: "gold" }); + + foldAssetEvent(handler, state, event(ASSET_DELETED, { + path: "world.voxelmap.json", + kind: VOXEL_MAP_KIND + })); + + assert.equal(state.materialGroups.size, 0); + }); +}); + describe("voxelMapAssetKind — block order", () => { function seed( handler: ReturnType, diff --git a/packages/assets/voxel-map/test/network/VoxelCommand.schema.spec.ts b/packages/assets/voxel-map/test/network/VoxelCommand.schema.spec.ts index c35fa1cec..5fd1dc350 100644 --- a/packages/assets/voxel-map/test/network/VoxelCommand.schema.spec.ts +++ b/packages/assets/voxel-map/test/network/VoxelCommand.schema.spec.ts @@ -85,6 +85,34 @@ describe("voxelCommandProtocol", () => { }), true); }); + test("accepts material group commands and rejects an invalid finish", () => { + assert.strictEqual(accepts({ + ...kHeader, + action: "material-group-defined", + group: { id: "gold", roughness: 0.3, metalness: 1, emissive: "#FFaa00" } + }), true); + assert.strictEqual(accepts({ + ...kHeader, + action: "material-group-removed", + groupId: "gold" + }), true); + assert.strictEqual(accepts({ + ...kHeader, + action: "material-group-defined", + group: { id: "gold", metalness: 2 } + }), false); + assert.strictEqual(accepts({ + ...kHeader, + action: "material-group-defined", + group: { id: "gold", emissive: "gold" } + }), false); + assert.strictEqual(accepts({ + ...kHeader, + action: "material-group-defined", + group: { id: "" } + }), false); + }); + test("rejects an invalid tile size or tileset id", () => { assert.strictEqual(accepts({ ...kHeader, diff --git a/packages/assets/voxel-map/test/network/VoxelCommandArbiter.spec.ts b/packages/assets/voxel-map/test/network/VoxelCommandArbiter.spec.ts index c5916c8c7..e013d8e1f 100644 --- a/packages/assets/voxel-map/test/network/VoxelCommandArbiter.spec.ts +++ b/packages/assets/voxel-map/test/network/VoxelCommandArbiter.spec.ts @@ -521,3 +521,24 @@ describe("VoxelCommandArbiter — tileset commands", () => { }), "default-tile-size"); }); }); + +describe("VoxelCommandArbiter — material group commands", () => { + const kHeader = { + clientId: "client-A", + seq: 1, + timestamp: 1000 + }; + + test("keys a material group command by its group id", () => { + assert.strictEqual(VoxelCommandArbiter.key({ + ...kHeader, + action: "material-group-defined", + group: { id: "gold" } + }), "material-group:gold"); + assert.strictEqual(VoxelCommandArbiter.key({ + ...kHeader, + action: "material-group-removed", + groupId: "gold" + }), "material-group:gold"); + }); +}); diff --git a/packages/editors/voxel-map/README.md b/packages/editors/voxel-map/README.md index c79918a3b..27c601d43 100644 --- a/packages/editors/voxel-map/README.md +++ b/packages/editors/voxel-map/README.md @@ -30,6 +30,10 @@ offline without contacting an asset server. The Map Config folder of the General tab exports the map with its tilesets as one `.zip` and imports such an archive, offline or on a server. When the archive holds assets the workspace already has, a dialog offers to replace them, keep them, or import the archive as a copy, once for the whole archive, and names the assets outside the archive that a replace would affect. The editor then reloads onto the imported map. Offline, **Reset workspace** deletes what the browser stored and seeds a new map. +Its View section sets how this browser draws the map; nothing there is saved in the map. **Lighting** picks Flat, Studio (the default) or Daylight, which adds a sky and a warm sun. **Reflections** lights material groups with a studio environment, which metal needs to show. **Occlusion** darkens corners and rebuilds every chunk. **Shadows** casts sun shadows around the camera. All three are off by default. + +The block editor's Material section names a block's material group. **Finish** saves roughness, metalness and glow for the whole group in the map. Block thumbnails show the finish. + ## 🧩 Bootstrap `src/index.ts` hands the editor class to `bootStandalone()` from diff --git a/packages/editors/voxel-map/src/document/MapDocument.ts b/packages/editors/voxel-map/src/document/MapDocument.ts index febcdb38c..15beacd65 100644 --- a/packages/editors/voxel-map/src/document/MapDocument.ts +++ b/packages/editors/voxel-map/src/document/MapDocument.ts @@ -2,6 +2,7 @@ import { isVoxelBlockCommand, isVoxelLayerCommand, + isVoxelMaterialGroupCommand, type VoxelCommand, type VoxelCommandListener, type VoxelLayerCommand, @@ -18,6 +19,7 @@ export type MapDocumentEvents = { ) => void; blockRegistryChanged: () => void; tilesetsChanged: () => void; + materialGroupsChanged: () => void; reset: () => void; }; @@ -49,6 +51,9 @@ export class MapDocument extends Emitter { else if (isVoxelBlockCommand(command)) { this.emit("blockRegistryChanged"); } + else if (isVoxelMaterialGroupCommand(command)) { + this.emit("materialGroupsChanged"); + } else { this.emit("tilesetsChanged"); } @@ -57,6 +62,7 @@ export class MapDocument extends Emitter { #onSourceReset = (): void => { this.emit("tilesetsChanged"); this.emit("blockRegistryChanged"); + this.emit("materialGroupsChanged"); this.emit("reset"); }; diff --git a/packages/editors/voxel-map/src/features/blocks/BlockEditorDialog.ts b/packages/editors/voxel-map/src/features/blocks/BlockEditorDialog.ts index 0ed33e6dc..8c3c8ca14 100644 --- a/packages/editors/voxel-map/src/features/blocks/BlockEditorDialog.ts +++ b/packages/editors/voxel-map/src/features/blocks/BlockEditorDialog.ts @@ -29,6 +29,7 @@ import { } from "@jolly-pixel/ui"; // Import Internal Dependencies +import type { MapDocumentSignals } from "../../document/index.ts"; import type { BlockUsageStore, BrushStore, @@ -57,7 +58,9 @@ import { DEFAULT_BLOCK_NAME, type BlockDraft } from "./blockDraft.ts"; +import { materialGroupNameOf } from "./materialGroupSources.ts"; import "./BlockShapePreview.ts"; +import "./BlockMaterialFinish.ts"; // CONSTANTS const kMissingTileset = "Missing tileset"; @@ -127,6 +130,9 @@ export class BlockEditorDialog extends LitElement { @property({ attribute: false }) declare usage: BlockUsageStore; + @property({ attribute: false }) + declare mapDocument: MapDocumentSignals; + @state() private declare _mode: BlockEditorMode; @@ -276,6 +282,7 @@ export class BlockEditorDialog extends LitElement { @jolly-change=${this.#onShapeChange} > ${creating ? nothing : this.#renderTransparency()} + ${creating ? nothing : this.#renderMaterial()} ${creating ? nothing : this.#renderUsage()}
@@ -438,6 +445,40 @@ export class BlockEditorDialog extends LitElement { `; } + #renderMaterial() { + const { block } = this; + if (!block) { + return nothing; + } + + return html` + + + ${block.materialGroup === undefined ? nothing : html` + + `} + `; + } + + #onMaterialGroupChange( + event: CustomEvent> + ): void { + const materialGroup = materialGroupNameOf(event.detail.value); + if (materialGroup !== this.block?.materialGroup) { + this.#applyEdit({ materialGroup }); + } + } + #onAlphaModeChange( event: CustomEvent> ): void { diff --git a/packages/editors/voxel-map/src/features/blocks/BlockLibrary.ts b/packages/editors/voxel-map/src/features/blocks/BlockLibrary.ts index d88786ab3..9cd2b5de1 100644 --- a/packages/editors/voxel-map/src/features/blocks/BlockLibrary.ts +++ b/packages/editors/voxel-map/src/features/blocks/BlockLibrary.ts @@ -309,6 +309,7 @@ export class BlockLibrary extends LitElement { .brush=${this.brush} .tilesets=${this.tilesets} .usage=${this.usage} + .mapDocument=${this.mapDocument} .block=${this._selectedBlock} > `; diff --git a/packages/editors/voxel-map/src/features/blocks/BlockLibraryRenderer.ts b/packages/editors/voxel-map/src/features/blocks/BlockLibraryRenderer.ts index 06afeeab2..40c032c15 100644 --- a/packages/editors/voxel-map/src/features/blocks/BlockLibraryRenderer.ts +++ b/packages/editors/voxel-map/src/features/blocks/BlockLibraryRenderer.ts @@ -4,6 +4,7 @@ import { disposeObject3D } from "@jolly-pixel/engine"; import type { ResolvedBlockDefinition, BlockShapeRegistry, + MaterialGroupList, TilesetManager } from "@jolly-pixel/voxel.renderer"; @@ -42,6 +43,7 @@ export interface CellEntry { export interface BlockLibraryRendererOptions { shapeRegistry: BlockShapeRegistry; tilesetManager: TilesetManager; + materialGroups?: MaterialGroupList; blocks?: ResolvedBlockDefinition[]; } @@ -71,6 +73,7 @@ export class BlockLibraryRenderer { #stableFrames = 0; #layoutDirty = true; #tilesetVersion: number; + #materialGroupsVersion: number; #container: HTMLElement; #resizeObserver: ResizeObserver; @@ -81,8 +84,10 @@ export class BlockLibraryRenderer { this.#sources = { shapeRegistry: options.shapeRegistry, tilesetManager: options.tilesetManager, - tileOpacity: new TileOpacityProbe(options.tilesetManager) + tileOpacity: new TileOpacityProbe(options.tilesetManager), + materialGroups: options.materialGroups }; + this.#materialGroupsVersion = options.materialGroups?.version ?? -1; this.#tilesetManager = options.tilesetManager; this.#tilesetVersion = options.tilesetManager.version; this.#container = container; @@ -103,7 +108,7 @@ export class BlockLibraryRenderer { this.canvas.style.display = "block"; container.appendChild(this.canvas); - const stage = createBlockPreviewStage(); + const stage = createBlockPreviewStage(this.#renderer); this.#scene = stage.scene; this.#camera = stage.camera; @@ -261,9 +266,14 @@ export class BlockLibraryRenderer { #render( time: number ): void { + const groupsVersion = this.#sources.materialGroups?.version ?? -1; if (this.#tilesetVersion !== this.#tilesetManager.version) { this.#rebuildCells(); } + else if (groupsVersion !== this.#materialGroupsVersion) { + this.#materialGroupsVersion = groupsVersion; + this.#rebuildGroupedCells(); + } else if (time - this.#opacityCheckAt >= kOpacityCheckIntervalMs) { this.#opacityCheckAt = time; this.#refreshEmptyCells(); @@ -327,6 +337,22 @@ export class BlockLibraryRenderer { this.setBlocks(blocks); } + #rebuildGroupedCells(): void { + for (let index = 0; index < this.#cells.length; index++) { + const cell = this.#cells[index]; + if (cell.block.materialGroup === undefined) { + continue; + } + + this.#removeCell(cell); + this.#cells[index] = { + ...this.#createCell(cell.block), + x: cell.x, + y: cell.y + }; + } + } + #createCell( block: ResolvedBlockDefinition ): CellEntry { diff --git a/packages/editors/voxel-map/src/features/blocks/BlockLibraryViewport.ts b/packages/editors/voxel-map/src/features/blocks/BlockLibraryViewport.ts index 2960507ec..b72023790 100644 --- a/packages/editors/voxel-map/src/features/blocks/BlockLibraryViewport.ts +++ b/packages/editors/voxel-map/src/features/blocks/BlockLibraryViewport.ts @@ -444,6 +444,7 @@ export class BlockLibraryViewport extends LitElement { this.#renderer = new BlockLibraryRenderer(this._scroller, { shapeRegistry: this.engine.shapeRegistry, tilesetManager: this.engine.tilesetManager, + materialGroups: this.engine.materialGroups, blocks: this.blocks }); this.#renderer.onLayoutChange = () => this.#syncGrid(); diff --git a/packages/editors/voxel-map/src/features/blocks/BlockMaterialFinish.ts b/packages/editors/voxel-map/src/features/blocks/BlockMaterialFinish.ts new file mode 100644 index 000000000..8b2b364d5 --- /dev/null +++ b/packages/editors/voxel-map/src/features/blocks/BlockMaterialFinish.ts @@ -0,0 +1,174 @@ +// Import Third-party Dependencies +import { + LitElement, + html, + css, + nothing +} from "lit"; +import { + customElement, + property +} from "lit/decorators.js"; +import type { VoxelEngine } from "@jolly-pixel/voxel.renderer"; +import { FieldBinding } from "@jolly-pixel/ui"; + +// Import Internal Dependencies +import type { MapDocumentSignals } from "../../document/index.ts"; +import { + customFinishSource, + materialFinishSource, + type MaterialGroupPort +} from "./materialGroupSources.ts"; + +// CONSTANTS +const kMetalHint = "Turn on Reflections in General to see metal"; + +@customElement("block-material-finish") +export class BlockMaterialFinish extends LitElement { + static override styles = css` + :host { + display: flex; + flex-direction: column; + gap: var(--jolly-row-gap, 4px); + } + `; + + @property({ attribute: false }) + declare engine: VoxelEngine; + + @property({ attribute: false }) + declare mapDocument: MapDocumentSignals; + + @property({ attribute: false }) + declare groupId: string | undefined; + + #unwatch: (() => void) | null = null; + + #port: MaterialGroupPort = { + groupId: () => this.groupId, + group: () => ( + this.groupId === undefined ? + undefined : + this.engine.materialGroups.get(this.groupId) + ), + define: (group) => { + this.engine.defineMaterialGroup(group); + }, + remove: (groupId) => { + this.engine.removeMaterialGroup(groupId); + } + }; + + #custom = new FieldBinding(this, customFinishSource(this.#port)); + #roughness = new FieldBinding( + this, + materialFinishSource(this.#port, "roughness") + ); + #metalness = new FieldBinding( + this, + materialFinishSource(this.#port, "metalness") + ); + #emissive = new FieldBinding( + this, + materialFinishSource(this.#port, "emissive") + ); + #emissiveIntensity = new FieldBinding( + this, + materialFinishSource(this.#port, "emissiveIntensity") + ); + + readonly #onMaterialGroupsChanged = (): void => { + this.requestUpdate(); + }; + + override connectedCallback(): void { + super.connectedCallback(); + this.#watch(); + } + + override updated( + changed: Map + ): void { + if (changed.has("mapDocument")) { + this.#watch(); + } + } + + override disconnectedCallback(): void { + super.disconnectedCallback(); + this.#unwatch?.(); + this.#unwatch = null; + } + + override render() { + if (!this.engine || this.groupId === undefined) { + return nothing; + } + + return html` + + ${this.#custom.value ? this.#renderFinish() : nothing} + `; + } + + #watch(): void { + this.#unwatch?.(); + this.#unwatch = this.isConnected && this.mapDocument ? + this.mapDocument.subscribe( + "materialGroupsChanged", + this.#onMaterialGroupsChanged + ) : + null; + } + + #renderFinish() { + return html` + + 0 ? kMetalHint : ""} + min="0" + max="1" + step="0.05" + .value=${this.#metalness.value} + @jolly-input=${this.#metalness.input} + @jolly-change=${this.#metalness.commit} + > + + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "block-material-finish": BlockMaterialFinish; + } +} diff --git a/packages/editors/voxel-map/src/features/blocks/BlockPreviewRenderer.ts b/packages/editors/voxel-map/src/features/blocks/BlockPreviewRenderer.ts index 1fdd1d7a9..f59a12f04 100644 --- a/packages/editors/voxel-map/src/features/blocks/BlockPreviewRenderer.ts +++ b/packages/editors/voxel-map/src/features/blocks/BlockPreviewRenderer.ts @@ -3,6 +3,7 @@ import * as THREE from "three"; import { disposeObject3D } from "@jolly-pixel/engine"; import type { BlockShapeRegistry, + MaterialGroupList, ResolvedBlockDefinition, TilesetManager } from "@jolly-pixel/voxel.renderer"; @@ -27,6 +28,7 @@ const kOpacityCheckIntervalMs = 250; export interface BlockPreviewRendererOptions { shapeRegistry: BlockShapeRegistry; tilesetManager: TilesetManager; + materialGroups?: MaterialGroupList; } export class BlockPreviewRenderer { @@ -48,6 +50,7 @@ export class BlockPreviewRenderer { #rot = 0; #size = 0; #sizeDirty = true; + #materialGroupsVersion = -1; constructor( container: HTMLElement, @@ -57,8 +60,10 @@ export class BlockPreviewRenderer { this.#sources = { shapeRegistry: options.shapeRegistry, tilesetManager: options.tilesetManager, - tileOpacity: new TileOpacityProbe(options.tilesetManager) + tileOpacity: new TileOpacityProbe(options.tilesetManager), + materialGroups: options.materialGroups }; + this.#materialGroupsVersion = options.materialGroups?.version ?? -1; this.#renderer = new THREE.WebGLRenderer({ antialias: true, @@ -75,7 +80,7 @@ export class BlockPreviewRenderer { this.canvas.style.display = "block"; container.appendChild(this.canvas); - const stage = createBlockPreviewStage(); + const stage = createBlockPreviewStage(this.#renderer); this.#scene = stage.scene; this.#camera = stage.camera; @@ -173,7 +178,12 @@ export class BlockPreviewRenderer { if (this.#sizeDirty) { this.#syncSize(); } - if (time - this.#opacityCheckAt >= kOpacityCheckIntervalMs) { + const groupsVersion = this.#sources.materialGroups?.version ?? -1; + if (groupsVersion !== this.#materialGroupsVersion) { + this.#materialGroupsVersion = groupsVersion; + this.#buildMesh(); + } + else if (time - this.#opacityCheckAt >= kOpacityCheckIntervalMs) { this.#opacityCheckAt = time; this.#refreshEmptySlots(); } diff --git a/packages/editors/voxel-map/src/features/blocks/BlockShapePreview.ts b/packages/editors/voxel-map/src/features/blocks/BlockShapePreview.ts index fe7f37b93..c58420a39 100644 --- a/packages/editors/voxel-map/src/features/blocks/BlockShapePreview.ts +++ b/packages/editors/voxel-map/src/features/blocks/BlockShapePreview.ts @@ -82,7 +82,8 @@ export class BlockShapePreview extends LitElement { this.#renderer = new BlockPreviewRenderer(this._well, { shapeRegistry: this.engine.shapeRegistry, - tilesetManager: this.engine.tilesetManager + tilesetManager: this.engine.tilesetManager, + materialGroups: this.engine.materialGroups }); this.#renderer.block = this.block; this.#renderer.onContextLost = () => this.#build(); diff --git a/packages/editors/voxel-map/src/features/blocks/blockPreviewMesh.ts b/packages/editors/voxel-map/src/features/blocks/blockPreviewMesh.ts index e7579bef1..f5fb3a1b9 100644 --- a/packages/editors/voxel-map/src/features/blocks/blockPreviewMesh.ts +++ b/packages/editors/voxel-map/src/features/blocks/blockPreviewMesh.ts @@ -1,5 +1,6 @@ // Import Third-party Dependencies import * as THREE from "three"; +import { RoomEnvironment } from "three/addons/environments/RoomEnvironment.js"; import { buildShapeGeometry, shapeSlots, @@ -9,6 +10,7 @@ import { type FaceDefinition, type ResolvedBlockDefinition, type BlockShapeRegistry, + type MaterialGroupList, type TilesetManager } from "@jolly-pixel/voxel.renderer"; @@ -27,6 +29,8 @@ const kCheckerDark = [0x45, 0x4b, 0x51]; const kOutlineColor = 0xd0d6dc; const kOutlineOpacity = 0.35; const kOutlineThresholdAngle = 20; +const kEnvironmentBlur = 0.04; +const kEnvironmentIntensity = 0.6; export const BLOCK_TEXTURED_MATERIAL = 0; export const BLOCK_EMPTY_MATERIAL = 1; @@ -41,6 +45,7 @@ export interface BlockPreviewSources { shapeRegistry: BlockShapeRegistry; tilesetManager: TilesetManager; tileOpacity: TileOpacityProbe; + materialGroups?: MaterialGroupList; } export interface BlockPreviewStage { @@ -48,9 +53,19 @@ export interface BlockPreviewStage { camera: THREE.PerspectiveCamera; } -export function createBlockPreviewStage(): BlockPreviewStage { +export function createBlockPreviewStage( + renderer?: THREE.WebGLRenderer +): BlockPreviewStage { const scene = new THREE.Scene(); scene.add(...new SceneLighting().lights); + if (renderer) { + const room = new RoomEnvironment(); + const generator = new THREE.PMREMGenerator(renderer); + scene.environment = generator.fromScene(room, kEnvironmentBlur).texture; + scene.environmentIntensity = kEnvironmentIntensity; + generator.dispose(); + room.dispose(); + } const camera = new THREE.PerspectiveCamera(kCameraFov, 1, 0.1, 20); camera.position.set(0, 0, kCameraZ); @@ -114,14 +129,22 @@ export function buildBlockPreviewMesh( const texture = textureOf(block, sources); const surface = new BlockSurface(block); const side = surface.side === "double" ? THREE.DoubleSide : THREE.FrontSide; + const surfaceOptions = { + map: texture, + side, + alphaTest: surface.alphaCutoff, + transparent: surface.alphaMode === "blend", + depthWrite: surface.alphaMode !== "blend" + }; + const group = surface.materialGroup === undefined ? + undefined : + sources.materialGroups?.get(surface.materialGroup); + const textured = group === undefined ? + new THREE.MeshLambertMaterial(surfaceOptions) : + new THREE.MeshStandardMaterial(surfaceOptions); + group?.applyTo(textured); const materials = [ - new THREE.MeshLambertMaterial({ - map: texture, - side, - alphaTest: surface.alphaCutoff, - transparent: surface.alphaMode === "blend", - depthWrite: surface.alphaMode !== "blend" - }), + textured, new THREE.MeshLambertMaterial({ map: createCheckerTexture(), side, diff --git a/packages/editors/voxel-map/src/features/blocks/materialGroupSources.ts b/packages/editors/voxel-map/src/features/blocks/materialGroupSources.ts new file mode 100644 index 000000000..b6687823d --- /dev/null +++ b/packages/editors/voxel-map/src/features/blocks/materialGroupSources.ts @@ -0,0 +1,69 @@ +// Import Third-party Dependencies +import type { FieldSource } from "@jolly-pixel/ui"; +import { + MaterialGroup, + type MaterialGroupFinish +} from "@jolly-pixel/voxel.renderer"; + +export interface MaterialGroupPort { + groupId(): string | undefined; + group(): MaterialGroup | undefined; + define(group: MaterialGroup): void; + remove(groupId: string): void; +} + +export function materialGroupNameOf( + value: string +): string | undefined { + const name = value.trim(); + + return name === "" ? undefined : name; +} + +export function customFinishSource( + port: MaterialGroupPort +): FieldSource { + return { + read: () => port.group() !== undefined, + write: (value) => { + const groupId = port.groupId(); + if (groupId === undefined || value === (port.group() !== undefined)) { + return; + } + + if (value) { + port.define(new MaterialGroup({ id: groupId })); + } + else { + port.remove(groupId); + } + } + }; +} + +export function materialFinishSource< + TField extends keyof MaterialGroupFinish +>( + port: MaterialGroupPort, + field: TField +): FieldSource { + return { + read: () => (port.group() ?? MaterialGroup.defaults)[field], + write: (value) => { + const group = port.group(); + if (group === undefined) { + return; + } + + const finish: Partial = {}; + finish[field] = value; + const next = MaterialGroup.parse({ + ...group.toJSON(), + ...finish + }); + if (next !== null && !next.equals(group)) { + port.define(next); + } + } + }; +} diff --git a/packages/editors/voxel-map/src/features/map-config/MapConfigPanel.ts b/packages/editors/voxel-map/src/features/map-config/MapConfigPanel.ts index f39dbdbf3..6544d2ca9 100644 --- a/packages/editors/voxel-map/src/features/map-config/MapConfigPanel.ts +++ b/packages/editors/voxel-map/src/features/map-config/MapConfigPanel.ts @@ -14,7 +14,8 @@ import { import { FieldBinding, showChoice, - showConfirm + showConfirm, + type JollyOption } from "@jolly-pixel/ui"; import type { ImportConflictPolicy, @@ -24,6 +25,17 @@ import type { // Import Internal Dependencies import type { VoxelMapWorkspace } from "../../scene/EditorScene.ts"; import type { EventInput } from "../../shared/domEvents.ts"; +import type { + LightingMode, + ViewSettings +} from "../../state/index.ts"; + +// CONSTANTS +const kLightingOptions: JollyOption[] = [ + { label: "Flat", value: "flat" }, + { label: "Studio", value: "studio" }, + { label: "Daylight", value: "daylight" } +]; @customElement("map-config-panel") export class MapConfigPanel extends LitElement { @@ -76,12 +88,10 @@ export class MapConfigPanel extends LitElement { write: (value) => this.workspace.gridRenderer.setVisible(value) }); - #flatLighting = new FieldBinding(this, { - read: () => this.workspace.lighting.mode === "flat", - write: (value) => { - this.workspace.lighting.mode = value ? "flat" : "lit"; - } - }); + #lighting = this.#viewBinding("lighting"); + #reflections = this.#viewBinding("reflections"); + #ambientOcclusion = this.#viewBinding("ambientOcclusion"); + #shadows = this.#viewBinding("shadows"); #skyRadius = new FieldBinding(this, { read: () => this.workspace.localBrush.skyRadius, @@ -105,13 +115,6 @@ export class MapConfigPanel extends LitElement { @jolly-change=${this.#gridVisible.commit} > - - + ${this.#renderView()} ${this.#renderArchives()} `; } + #renderView() { + return html` + + + + + + `; + } + + #viewBinding( + key: TKey + ): FieldBinding { + return new FieldBinding(this, { + read: () => this.workspace.state.view.settings[key], + write: (value) => { + const patch: Partial = {}; + patch[key] = value; + this.workspace.state.view.update(patch); + } + }); + } + #renderArchives() { const { archives } = this.workspace; diff --git a/packages/editors/voxel-map/src/scene/EditorScene.ts b/packages/editors/voxel-map/src/scene/EditorScene.ts index 30d21fe98..d6ce32beb 100644 --- a/packages/editors/voxel-map/src/scene/EditorScene.ts +++ b/packages/editors/voxel-map/src/scene/EditorScene.ts @@ -10,7 +10,6 @@ import type { VoxelEngine, VoxelWorldJSON } from "@jolly-pixel/voxel.renderer"; -import * as THREE from "three"; import type { PeerIdentity } from "@jolly-pixel/ui"; import type { AssetLeases } from "@jolly-pixel/editor.host"; import type { @@ -54,6 +53,7 @@ import { } from "../features/tilesets/TilesetTextures.ts"; import { GridRenderer } from "./GridRenderer.ts"; import { SceneLighting } from "./SceneLighting.ts"; +import { SceneEnvironment } from "./SceneEnvironment.ts"; import { installTransparency } from "./installTransparency.ts"; import { spawnPose } from "./spawnPose.ts"; import { @@ -106,6 +106,7 @@ export class EditorScene extends Systems.Scene { #workspace = Promise.withResolvers(); #disposables: Array<() => void> = []; #camera: OrbitFlyCamera | undefined; + #environment: SceneEnvironment | undefined; #orbiting = false; #spawnPending = true; @@ -135,7 +136,6 @@ export class EditorScene extends Systems.Scene { const world = this.world; const scene = world.sceneManager.getSource(); - scene.background = new THREE.Color("#262627"); const lighting = new SceneLighting(world.renderer.getSource()); scene.add(...lighting.lights); this.#disposables.push( @@ -165,6 +165,15 @@ export class EditorScene extends Systems.Scene { tilesets: [] }); + const environment = new SceneEnvironment({ + renderer: world.renderer.getSource(), + scene, + lighting, + chunks: engine + }); + environment.apply(state.view.settings); + this.#environment = environment; + const mapDocument = new MapDocument({ commands: engine.document, source: new LeasedWorldSource({ @@ -279,6 +288,10 @@ export class EditorScene extends Systems.Scene { this.#selectFallbackLayer(engine); this.#spawnCamera(engine); }), + state.view.subscribe("change", (settings) => { + environment.apply(settings); + }), + () => environment.dispose(), () => shortcuts.dispose(), () => historyShortcuts.dispose(), () => collaboration.dispose(), @@ -321,11 +334,18 @@ export class EditorScene extends Systems.Scene { }); } + override update(): void { + if (this.#camera !== undefined) { + this.#environment?.follow(this.#camera.camera); + } + } + override destroy(): void { for (const dispose of this.#disposables.splice(0)) { dispose(); } this.#camera = undefined; + this.#environment = undefined; this.#workspace.reject( new Error("The editor scene was destroyed before it awoke.") ); diff --git a/packages/editors/voxel-map/src/scene/SceneEnvironment.ts b/packages/editors/voxel-map/src/scene/SceneEnvironment.ts new file mode 100644 index 000000000..c5c837afe --- /dev/null +++ b/packages/editors/voxel-map/src/scene/SceneEnvironment.ts @@ -0,0 +1,140 @@ +// Import Third-party Dependencies +import * as THREE from "three/webgpu"; +import { RoomEnvironment } from "three/addons/environments/RoomEnvironment.js"; + +// Import Internal Dependencies +import type { SceneLighting } from "./SceneLighting.ts"; +import { createSkyBackground } from "./skyBackground.ts"; +import type { ViewSettings } from "../state/ViewStore.ts"; + +// CONSTANTS +const kBackground = "#262627"; +const kAmbientOcclusion = 0.75; +const kEnvironmentBlur = 0.04; +const kEnvironmentIntensity = 0.6; +const kShadowRadius = 48; +const kShadowMapSize = 2048; +const kShadowSnap = 2; + +export interface ChunkRendering { + ambientOcclusion: number; + castShadow: boolean; + receiveShadow: boolean; +} + +export interface SceneEnvironmentOptions { + renderer: THREE.WebGPURenderer; + scene: THREE.Scene; + lighting: SceneLighting; + chunks: ChunkRendering; +} + +export class SceneEnvironment { + #renderer: THREE.WebGPURenderer; + #scene: THREE.Scene; + #lighting: SceneLighting; + #chunks: ChunkRendering; + #environment: THREE.RenderTarget | null = null; + #shadows = false; + #focus = new THREE.Vector3(); + #forward = new THREE.Vector3(); + + constructor( + options: SceneEnvironmentOptions + ) { + this.#renderer = options.renderer; + this.#scene = options.scene; + this.#lighting = options.lighting; + this.#chunks = options.chunks; + + const { shadow } = this.#lighting.directional; + shadow.mapSize.set(kShadowMapSize, kShadowMapSize); + shadow.bias = -0.0002; + shadow.normalBias = 0.02; + shadow.camera.left = -kShadowRadius; + shadow.camera.right = kShadowRadius; + shadow.camera.top = kShadowRadius; + shadow.camera.bottom = -kShadowRadius; + shadow.camera.near = 0.5; + shadow.camera.far = kShadowRadius * 4; + } + + apply( + settings: Readonly + ): void { + this.#lighting.mode = settings.lighting; + this.#applyBackground(settings.lighting === "daylight"); + + this.#scene.environment = settings.reflections ? + this.#environmentMap() : + null; + this.#scene.environmentIntensity = kEnvironmentIntensity; + + this.#chunks.ambientOcclusion = settings.ambientOcclusion ? + kAmbientOcclusion : + 0; + + this.#shadows = settings.shadows; + if (settings.shadows) { + this.#renderer.shadowMap.enabled = true; + } + else { + this.#lighting.aim(); + } + this.#lighting.directional.castShadow = settings.shadows; + this.#chunks.castShadow = settings.shadows; + this.#chunks.receiveShadow = settings.shadows; + } + + follow( + camera: THREE.Camera + ): void { + if (!this.#shadows) { + return; + } + + camera.getWorldDirection(this.#forward); + this.#focus + .copy(camera.position) + .addScaledVector(this.#forward, kShadowRadius * 0.5); + this.#focus.set( + snap(this.#focus.x), + snap(this.#focus.y), + snap(this.#focus.z) + ); + this.#lighting.aim(this.#focus, kShadowRadius * 2); + } + + dispose(): void { + this.#scene.environment = null; + this.#environment?.dispose(); + this.#environment = null; + } + + #applyBackground( + sky: boolean + ): void { + this.#scene.background = sky ? null : new THREE.Color(kBackground); + this.#scene.backgroundNode = sky ? + createSkyBackground(this.#lighting.sunDirection) : + null; + } + + #environmentMap(): THREE.Texture { + if (this.#environment === null) { + const room = new RoomEnvironment(); + const generator = new THREE.PMREMGenerator(this.#renderer); + this.#environment = generator.fromScene(room, kEnvironmentBlur); + generator.dispose(); + room.dispose(); + } + + return this.#environment.texture; + } +} + +function snap( + value: number +): number { + return Math.round(value / kShadowSnap) * kShadowSnap; +} diff --git a/packages/editors/voxel-map/src/scene/SceneLighting.ts b/packages/editors/voxel-map/src/scene/SceneLighting.ts index 5fc068f3a..c51056297 100644 --- a/packages/editors/voxel-map/src/scene/SceneLighting.ts +++ b/packages/editors/voxel-map/src/scene/SceneLighting.ts @@ -1,18 +1,54 @@ // Import Third-party Dependencies import * as THREE from "three"; +// Import Internal Dependencies +import type { LightingMode } from "../state/ViewStore.ts"; + // CONSTANTS -const kAmbientIntensity = 1.3; -const kDirectionalIntensity = 2.25; -const kFlatAmbientIntensity = Math.PI; -const kLightPosition = { - x: 10, - y: 20, - z: 10 +const kSunDistance = 24; +const kOrigin = new THREE.Vector3(); + +interface LightingRig { + ambient: number; + hemisphere: number; + sun: number; + sunColor: THREE.ColorRepresentation; + sunDirection: THREE.Vector3; + toneMapping: THREE.ToneMapping | null; + exposure: number; +} + +const kRigs: Record = { + studio: { + ambient: 1.3, + hemisphere: 0, + sun: 2.25, + sunColor: "#ffffff", + sunDirection: new THREE.Vector3(10, 20, 10).normalize(), + toneMapping: null, + exposure: 1 + }, + flat: { + ambient: Math.PI, + hemisphere: 0, + sun: 0, + sunColor: "#ffffff", + sunDirection: new THREE.Vector3(10, 20, 10).normalize(), + toneMapping: THREE.NoToneMapping, + exposure: 1 + }, + daylight: { + ambient: 0, + hemisphere: 1.15, + sun: 2.7, + sunColor: "#ffd9a3", + sunDirection: new THREE.Vector3(-0.62, 0.55, 0.56).normalize(), + toneMapping: THREE.ACESFilmicToneMapping, + exposure: 1.05 + } }; -const kExposure = 1; -export type LightingMode = "lit" | "flat"; +export type { LightingMode }; export interface SceneLightingOutput { toneMapping: THREE.ToneMapping; @@ -20,33 +56,34 @@ export interface SceneLightingOutput { } export class SceneLighting { - readonly ambient = new THREE.AmbientLight(0xffffff, kAmbientIntensity); - readonly directional = new THREE.DirectionalLight( - 0xffffff, - kDirectionalIntensity - ); + readonly ambient = new THREE.AmbientLight(0xffffff); + readonly hemisphere = new THREE.HemisphereLight("#bcd6f2", "#8a7454", 0); + readonly directional = new THREE.DirectionalLight(0xffffff); - #mode: LightingMode = "lit"; + #mode: LightingMode = "studio"; #output: SceneLightingOutput | undefined; #toneMapping: THREE.ToneMapping; + #center = new THREE.Vector3(); constructor( output?: SceneLightingOutput ) { - this.directional.position.set( - kLightPosition.x, - kLightPosition.y, - kLightPosition.z - ); this.#output = output; this.#toneMapping = output?.toneMapping ?? THREE.NoToneMapping; - if (output) { - output.toneMappingExposure = kExposure; - } + this.mode = "studio"; + } + + get lights(): THREE.Object3D[] { + return [ + this.ambient, + this.hemisphere, + this.directional, + this.directional.target + ]; } - get lights(): [THREE.AmbientLight, THREE.DirectionalLight] { - return [this.ambient, this.directional]; + get sunDirection(): THREE.Vector3 { + return kRigs[this.#mode].sunDirection.clone(); } get mode(): LightingMode { @@ -58,13 +95,27 @@ export class SceneLighting { ) { this.#mode = mode; - const flat = mode === "flat"; - this.ambient.intensity = flat ? kFlatAmbientIntensity : kAmbientIntensity; - this.directional.intensity = flat ? 0 : kDirectionalIntensity; + const rig = kRigs[mode]; + this.ambient.intensity = rig.ambient; + this.hemisphere.intensity = rig.hemisphere; + this.directional.intensity = rig.sun; + this.directional.color.set(rig.sunColor); + this.aim(this.#center); if (this.#output) { - this.#output.toneMapping = flat ? - THREE.NoToneMapping : - this.#toneMapping; + this.#output.toneMapping = rig.toneMapping ?? this.#toneMapping; + this.#output.toneMappingExposure = rig.exposure; } } + + aim( + center: THREE.Vector3Like = kOrigin, + distance = kSunDistance + ): void { + this.#center.copy(center); + this.directional.target.position.copy(center); + this.directional.position + .copy(kRigs[this.#mode].sunDirection) + .multiplyScalar(distance) + .add(center); + } } diff --git a/packages/editors/voxel-map/src/scene/skyBackground.ts b/packages/editors/voxel-map/src/scene/skyBackground.ts new file mode 100644 index 000000000..40fa1683f --- /dev/null +++ b/packages/editors/voxel-map/src/scene/skyBackground.ts @@ -0,0 +1,48 @@ +// Import Third-party Dependencies +import type * as THREE from "three/webgpu"; +import { + color, + dot, + float, + max, + mix, + normalize, + positionLocal, + pow, + smoothstep, + vec3 +} from "three/tsl"; + +// CONSTANTS +const kHorizon = "#f4dcc0"; +const kBlue = "#8db4dc"; +const kZenith = "#3a6aad"; +const kHaze = "#bfcadb"; +const kGlowSharpness = 12; +const kGlowStrength = 0.55; + +export function createSkyBackground( + sunDirection: THREE.Vector3 +): THREE.Node { + const direction = normalize(positionLocal); + const up = direction.y; + const glow = pow(max(dot(direction, vec3(sunDirection)), 0), float(kGlowSharpness)) + .mul(kGlowStrength); + const lower = mix( + color(kHorizon), + color(kBlue), + smoothstep(float(0), float(0.18), up) + ); + const gradient = mix( + lower, + color(kZenith), + smoothstep(float(0.18), float(0.7), up) + ); + const below = mix( + gradient, + color(kHaze), + smoothstep(float(0), float(-0.25), up) + ); + + return below.add(vec3(1, 0.85, 0.6).mul(glow)); +} diff --git a/packages/editors/voxel-map/src/state/EditorState.ts b/packages/editors/voxel-map/src/state/EditorState.ts index 9669a633c..1284aa5ab 100644 --- a/packages/editors/voxel-map/src/state/EditorState.ts +++ b/packages/editors/voxel-map/src/state/EditorState.ts @@ -1,11 +1,20 @@ // Import Third-party Dependencies -import { LogQueue } from "@jolly-pixel/ui"; +import { + LocalStorageAdapter, + LogQueue, + type StorageAdapter +} from "@jolly-pixel/ui"; // Import Internal Dependencies import { BrushStore } from "./BrushStore.ts"; import { PresenceStore } from "./PresenceStore.ts"; import { SelectionStore } from "./SelectionStore.ts"; import { TilesetStore } from "./TilesetStore.ts"; +import { ViewStore } from "./ViewStore.ts"; + +export interface EditorStateOptions { + storage?: StorageAdapter; +} export class EditorState { readonly selection = new SelectionStore(); @@ -13,4 +22,12 @@ export class EditorState { readonly presence = new PresenceStore(); readonly tilesets = new TilesetStore(); readonly log = new LogQueue(); + readonly view: ViewStore; + + constructor( + options: EditorStateOptions = {} + ) { + const { storage = new LocalStorageAdapter() } = options; + this.view = new ViewStore(storage); + } } diff --git a/packages/editors/voxel-map/src/state/ViewStore.ts b/packages/editors/voxel-map/src/state/ViewStore.ts new file mode 100644 index 000000000..ebba1e2d0 --- /dev/null +++ b/packages/editors/voxel-map/src/state/ViewStore.ts @@ -0,0 +1,122 @@ +// Import Third-party Dependencies +import { + MemoryStorageAdapter, + type StorageAdapter +} from "@jolly-pixel/ui"; +import { Emitter } from "@openally/emitt"; + +// CONSTANTS +const kStorageKey = "voxel-map:view"; +const kLightingModes = ["flat", "studio", "daylight"] as const; + +export type LightingMode = typeof kLightingModes[number]; + +export interface ViewSettings { + lighting: LightingMode; + reflections: boolean; + ambientOcclusion: boolean; + shadows: boolean; +} + +export const DEFAULT_VIEW_SETTINGS: Readonly = Object.freeze({ + lighting: "studio", + reflections: false, + ambientOcclusion: false, + shadows: false +}); + +export type ViewStoreEvents = { + change: (settings: Readonly) => void; +}; + +export class ViewStore extends Emitter { + #storage: StorageAdapter; + #settings: Readonly; + + constructor( + storage: StorageAdapter = new MemoryStorageAdapter() + ) { + super(); + this.#storage = storage; + this.#settings = Object.freeze( + settingsFrom(parseJson(storage.get(kStorageKey))) + ); + } + + get settings(): Readonly { + return this.#settings; + } + + update( + patch: Partial + ): void { + const next = settingsFrom(patch, this.#settings); + if (sameSettings(next, this.#settings)) { + return; + } + + this.#settings = Object.freeze(next); + this.#storage.set(kStorageKey, JSON.stringify(next)); + this.emit("change", this.#settings); + } +} + +function parseJson( + raw: string | null +): unknown { + if (raw === null) { + return null; + } + + try { + return JSON.parse(raw); + } + catch { + return null; + } +} + +function settingsFrom( + value: unknown, + fallback: Readonly = DEFAULT_VIEW_SETTINGS +): ViewSettings { + const fields: Map = typeof value === "object" && + value !== null ? + new Map(Object.entries(value)) : + new Map(); + const lighting = fields.get("lighting"); + + return { + lighting: kLightingModes.find((mode) => mode === lighting) ?? + fallback.lighting, + reflections: booleanOr( + fields.get("reflections"), + fallback.reflections + ), + ambientOcclusion: booleanOr( + fields.get("ambientOcclusion"), + fallback.ambientOcclusion + ), + shadows: booleanOr( + fields.get("shadows"), + fallback.shadows + ) + }; +} + +function booleanOr( + value: unknown, + fallback: boolean +): boolean { + return typeof value === "boolean" ? value : fallback; +} + +function sameSettings( + left: ViewSettings, + right: ViewSettings +): boolean { + return left.lighting === right.lighting && + left.reflections === right.reflections && + left.ambientOcclusion === right.ambientOcclusion && + left.shadows === right.shadows; +} diff --git a/packages/editors/voxel-map/src/state/index.ts b/packages/editors/voxel-map/src/state/index.ts index f893b74a5..7f263adfa 100644 --- a/packages/editors/voxel-map/src/state/index.ts +++ b/packages/editors/voxel-map/src/state/index.ts @@ -5,3 +5,4 @@ export * from "./SelectionStore.ts"; export * from "./TilesetStore.ts"; export * from "./BlockUsageStore.ts"; export * from "./tilesetEntry.ts"; +export * from "./ViewStore.ts"; diff --git a/packages/editors/voxel-map/test/document/MapDocument.spec.ts b/packages/editors/voxel-map/test/document/MapDocument.spec.ts index 09f3ad536..6605b3b31 100644 --- a/packages/editors/voxel-map/test/document/MapDocument.spec.ts +++ b/packages/editors/voxel-map/test/document/MapDocument.spec.ts @@ -23,6 +23,7 @@ const kEvents: Array = [ "layerUpdated", "blockRegistryChanged", "tilesetsChanged", + "materialGroupsChanged", "reset" ]; @@ -69,15 +70,19 @@ describe("MapDocument", () => { engine.emit("command", command("added"), { origin: "local" }); engine.emit("command", command("block-defined"), { origin: "remote" }); engine.emit("command", command("tileset-added"), { origin: "local" }); + engine.emit("command", command("material-group-removed"), { + origin: "remote" + }); assert.deepEqual(seen, [ "layerUpdated", "blockRegistryChanged", - "tilesetsChanged" + "tilesetsChanged", + "materialGroupsChanged" ]); }); - it("announces tilesets and blocks before the reset of a source", () => { + it("announces tilesets, blocks and material groups before the reset of a source", () => { const { source, seen } = setup(); source.emit("reset"); @@ -85,6 +90,7 @@ describe("MapDocument", () => { assert.deepEqual(seen, [ "tilesetsChanged", "blockRegistryChanged", + "materialGroupsChanged", "reset" ]); }); diff --git a/packages/editors/voxel-map/test/features/blocks/blockPreviewMesh.spec.ts b/packages/editors/voxel-map/test/features/blocks/blockPreviewMesh.spec.ts index 4c165becd..f4f000b0a 100644 --- a/packages/editors/voxel-map/test/features/blocks/blockPreviewMesh.spec.ts +++ b/packages/editors/voxel-map/test/features/blocks/blockPreviewMesh.spec.ts @@ -6,6 +6,7 @@ import assert from "node:assert/strict"; import * as THREE from "three"; import { BlockShapeRegistry, + MaterialGroupList, TilesetList, TilesetManager, VoxelTransform, @@ -155,6 +156,25 @@ describe("buildBlockPreviewMesh", () => { assertFitted(mesh.geometry); }); + it("draws a block of a defined material group with its finish", () => { + const sources = { + ...kSources, + materialGroups: new MaterialGroupList([ + { id: "gold", roughness: 0.3, metalness: 1 } + ]) + }; + + const gold = buildBlockPreviewMesh(blockOf({ materialGroup: "gold" }), sources); + const [textured] = gold.material as THREE.Material[]; + assert.ok(textured instanceof THREE.MeshStandardMaterial); + assert.equal(textured.metalness, 1); + assert.equal(textured.roughness, 0.3); + + const plain = buildBlockPreviewMesh(blockOf({ materialGroup: "silver" }), sources); + const [lambert] = plain.material as THREE.Material[]; + assert.ok(lambert instanceof THREE.MeshLambertMaterial); + }); + it("covers the whole index buffer with its groups", () => { for (const shapeId of kSources.shapeRegistry.ids()) { const mesh = buildBlockPreviewMesh(blockOf({ shapeId }), kSources); diff --git a/packages/editors/voxel-map/test/features/blocks/materialGroupSources.spec.ts b/packages/editors/voxel-map/test/features/blocks/materialGroupSources.spec.ts new file mode 100644 index 000000000..24af71e65 --- /dev/null +++ b/packages/editors/voxel-map/test/features/blocks/materialGroupSources.spec.ts @@ -0,0 +1,114 @@ +// Import Node.js Dependencies +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +// Import Third-party Dependencies +import { + MaterialGroup, + MaterialGroupList +} from "@jolly-pixel/voxel.renderer"; + +// Import Internal Dependencies +import { + customFinishSource, + materialFinishSource, + materialGroupNameOf, + type MaterialGroupPort +} from "../../../src/features/blocks/materialGroupSources.ts"; + +interface FakePort extends MaterialGroupPort { + groups: MaterialGroupList; + writes: number; +} + +function makePort( + groupId: string | undefined, + groups = new MaterialGroupList() +): FakePort { + const port: FakePort = { + groups, + writes: 0, + groupId: () => groupId, + group: () => (groupId === undefined ? undefined : groups.get(groupId)), + define: (group) => { + port.writes++; + groups.define(group); + }, + remove: (id) => { + port.writes++; + groups.remove(id); + } + }; + + return port; +} + +describe("materialGroupNameOf", () => { + it("trims the name and reads blank as no group", () => { + assert.equal(materialGroupNameOf(" gold "), "gold"); + assert.equal(materialGroupNameOf(" "), undefined); + }); +}); + +describe("customFinishSource", () => { + it("defines the block group with the default finish, then removes it", () => { + const port = makePort("gold"); + const source = customFinishSource(port); + + assert.equal(source.read(), false); + source.write(true, true); + assert.equal(source.read(), true); + assert.ok(port.groups.get("gold")?.equals(new MaterialGroup({ id: "gold" }))); + + source.write(false, true); + assert.equal(port.groups.has("gold"), false); + }); + + it("writes nothing without a group name or when the state already matches", () => { + const ungrouped = makePort(undefined); + customFinishSource(ungrouped).write(true, true); + assert.equal(ungrouped.writes, 0); + + const port = makePort("gold", new MaterialGroupList([{ id: "gold" }])); + customFinishSource(port).write(true, true); + assert.equal(port.writes, 0); + }); +}); + +describe("materialFinishSource", () => { + it("reads the defaults for a group the map does not define", () => { + const port = makePort("gold"); + + assert.equal(materialFinishSource(port, "roughness").read(), 1); + assert.equal(materialFinishSource(port, "emissive").read(), "#000000"); + + materialFinishSource(port, "metalness").write(1, true); + assert.equal(port.writes, 0); + }); + + it("redefines the group with the edited field only", () => { + const port = makePort("gold", new MaterialGroupList([ + { id: "gold", roughness: 0.4 } + ])); + + materialFinishSource(port, "metalness").write(1, false); + materialFinishSource(port, "emissive").write("#FF0000", true); + + assert.deepEqual(port.groups.get("gold")?.toJSON(), { + id: "gold", + roughness: 0.4, + metalness: 1, + emissive: "#ff0000", + emissiveIntensity: 1 + }); + }); + + it("drops an out of range or unchanged value", () => { + const port = makePort("gold", new MaterialGroupList([{ id: "gold" }])); + + materialFinishSource(port, "metalness").write(2, true); + materialFinishSource(port, "roughness").write(1, true); + + assert.equal(port.writes, 0); + }); +}); diff --git a/packages/editors/voxel-map/test/scene/SceneLighting.spec.ts b/packages/editors/voxel-map/test/scene/SceneLighting.spec.ts index f4e011ff8..72ee39fa2 100644 --- a/packages/editors/voxel-map/test/scene/SceneLighting.spec.ts +++ b/packages/editors/voxel-map/test/scene/SceneLighting.spec.ts @@ -35,14 +35,14 @@ function createOutput(): SceneLightingOutput { } describe("SceneLighting", () => { - test("lit mode shows the brightest face at its authored colour", () => { + test("studio mode shows the brightest face at its authored colour", () => { const lighting = new SceneLighting(); const top = lambertFactor(lighting, new THREE.Vector3(0, 1, 0)); const side = lambertFactor(lighting, new THREE.Vector3(0, 0, 1)); const shadowed = lambertFactor(lighting, new THREE.Vector3(0, 0, -1)); - assert.equal(lighting.mode, "lit"); + assert.equal(lighting.mode, "studio"); assert.ok(Math.abs(top - 1) < kTolerance, `top face factor ${top}`); assert.ok(side < top && side > shadowed); assert.ok(shadowed > 0.35, `shadowed face factor ${shadowed}`); @@ -61,13 +61,13 @@ describe("SceneLighting", () => { } }); - test("returning to lit mode restores the rig", () => { + test("returning to studio mode restores the rig", () => { const lighting = new SceneLighting(); const ambient = lighting.ambient.intensity; const directional = lighting.directional.intensity; lighting.mode = "flat"; - lighting.mode = "lit"; + lighting.mode = "studio"; assert.equal(lighting.ambient.intensity, ambient); assert.equal(lighting.directional.intensity, directional); @@ -83,18 +83,48 @@ describe("SceneLighting", () => { lighting.mode = "flat"; assert.equal(output.toneMapping, THREE.NoToneMapping); - lighting.mode = "lit"; + lighting.mode = "studio"; assert.equal(output.toneMapping, THREE.NeutralToneMapping); }); - test("exposes both lights for a scene", () => { + test("daylight swaps the ambient fill for a sky and a warm sun", () => { + const output = createOutput(); + const lighting = new SceneLighting(output); + + lighting.mode = "daylight"; + + assert.equal(lighting.ambient.intensity, 0); + assert.ok(lighting.hemisphere.intensity > 0); + assert.notEqual(lighting.directional.color.getHexString(), "ffffff"); + assert.equal(output.toneMapping, THREE.ACESFilmicToneMapping); + assert.ok( + lighting.directional.position.clone().normalize() + .distanceTo(lighting.sunDirection) < 1e-6 + ); + }); + + test("aims the sun at a point along its direction", () => { + const lighting = new SceneLighting(); + const center = new THREE.Vector3(8, 2, -4); + + lighting.aim(center, 10); + + assert.deepEqual(lighting.directional.target.position, center); + const offset = lighting.directional.position.clone().sub(center); + assert.ok(Math.abs(offset.length() - 10) < 1e-6); + assert.ok(offset.normalize().distanceTo(lighting.sunDirection) < 1e-6); + }); + + test("exposes the lights and the sun target for a scene", () => { const lighting = new SceneLighting(); const scene = new THREE.Scene(); scene.add(...lighting.lights); assert.deepEqual(scene.children, [ lighting.ambient, - lighting.directional + lighting.hemisphere, + lighting.directional, + lighting.directional.target ]); }); }); diff --git a/packages/editors/voxel-map/test/state/ViewStore.spec.ts b/packages/editors/voxel-map/test/state/ViewStore.spec.ts new file mode 100644 index 000000000..b904c10fd --- /dev/null +++ b/packages/editors/voxel-map/test/state/ViewStore.spec.ts @@ -0,0 +1,70 @@ +// Import Node.js Dependencies +import assert from "node:assert/strict"; +import { describe, test } from "node:test"; + +// Import Third-party Dependencies +import { MemoryStorageAdapter } from "@jolly-pixel/ui"; + +// Import Internal Dependencies +import { + DEFAULT_VIEW_SETTINGS, + ViewStore, + type ViewSettings +} from "../../src/state/ViewStore.ts"; + +// CONSTANTS +const kKey = "voxel-map:view"; + +describe("ViewStore", () => { + test("starts from the defaults with every effect off", () => { + const store = new ViewStore(); + + assert.deepEqual(store.settings, DEFAULT_VIEW_SETTINGS); + assert.deepEqual(store.settings, { + lighting: "studio", + reflections: false, + ambientOcclusion: false, + shadows: false + }); + }); + + test("persists an update and emits the new settings once", () => { + const storage = new MemoryStorageAdapter(); + const store = new ViewStore(storage); + const seen: ViewSettings[] = []; + store.on("change", (settings) => seen.push({ ...settings })); + + store.update({ reflections: true }); + store.update({ reflections: true }); + + assert.equal(seen.length, 1); + assert.equal(seen[0].reflections, true); + assert.equal(new ViewStore(storage).settings.reflections, true); + }); + + test("falls back to the defaults for invalid stored values", () => { + const storage = new MemoryStorageAdapter(); + storage.set(kKey, JSON.stringify({ + lighting: "sunset", + shadows: "yes", + ambientOcclusion: true + })); + + assert.deepEqual(new ViewStore(storage).settings, { + ...DEFAULT_VIEW_SETTINGS, + ambientOcclusion: true + }); + + storage.set(kKey, "{not json"); + assert.deepEqual(new ViewStore(storage).settings, DEFAULT_VIEW_SETTINGS); + }); + + test("ignores an invalid lighting mode in an update", () => { + const store = new ViewStore(); + store.update({ lighting: "daylight" }); + + store.update(JSON.parse("{\"lighting\":\"night\"}")); + + assert.equal(store.settings.lighting, "daylight"); + }); +}); diff --git a/packages/voxel-renderer/docs/api/blocks/BlockSurface.md b/packages/voxel-renderer/docs/api/blocks/BlockSurface.md index fc99e1ac2..cc65b5b69 100644 --- a/packages/voxel-renderer/docs/api/blocks/BlockSurface.md +++ b/packages/voxel-renderer/docs/api/blocks/BlockSurface.md @@ -45,16 +45,27 @@ These settings apply to the whole block. Texture slots select tiles but do not override the surface policy. [`cullCoveredFaces`](./BlockDefinition.md) independently controls the faces a neighbour covers. -A material group lets one atlas carry materials tuned apart. The -`materialCustomizer` receives the surface, so it can read the group: +A material group lets one atlas carry materials tuned apart. Declaring the +group's finish in the document makes it travel with the map: ```ts const engine = new VoxelEngine({ - material: "standard", blocks: [ { id: 1, name: "Sandstone", shapeId: "cube", defaultTexture }, { id: 2, name: "Gold", shapeId: "cube", defaultTexture, materialGroup: "gold" } ], + materialGroups: [ + { id: "gold", roughness: 0.35, metalness: 1 } + ] +}); +``` + +See [MaterialGroup](../materials/MaterialGroup.md). The `materialCustomizer` +also receives the surface, so host code can still read the group: + +```ts +const engine = new VoxelEngine({ + material: "standard", materialCustomizer(material, _tilesetId, surface) { if ( material instanceof THREE.MeshStandardMaterial && diff --git a/packages/voxel-renderer/docs/api/core/VoxelDocument.md b/packages/voxel-renderer/docs/api/core/VoxelDocument.md index 4220454ca..330e9f492 100644 --- a/packages/voxel-renderer/docs/api/core/VoxelDocument.md +++ b/packages/voxel-renderer/docs/api/core/VoxelDocument.md @@ -46,6 +46,8 @@ interface VoxelDocumentOptions { blocks?: BlockDefinition[]; /** Tileset definitions declared before any texture is registered. */ tilesets?: Iterable; + /** Finishes of the named material groups; invalid entries are skipped. */ + materialGroups?: Iterable; /** Undo/redo of voxel edits; disabled by default. */ history?: VoxelHistoryOptions; /** Debug logger; defaults to a no-op implementation. */ @@ -62,6 +64,7 @@ class VoxelDocument extends Emitter { readonly world: VoxelWorld; readonly blocks: BlockRegistry; readonly tilesets: TilesetList; // declarations only, no atlases + readonly materialGroups: MaterialGroupList; readonly history: VoxelHistory; // see VoxelHistory.md readonly chunkSize: number; defaultTileSize: number | undefined; @@ -72,6 +75,9 @@ class VoxelDocument extends Emitter { declarations belong to the view's [`TilesetManager`](../tilesets/TilesetManager.md). +`materialGroups` holds the [surface finishes](../materials/MaterialGroup.md) +that travel with the map. + ## Events ```ts @@ -107,6 +113,9 @@ removeTileset(tilesetId: string): boolean; resizeTileset(tilesetId: string, tileSize: number): boolean; registerTileset(def: TilesetDefinition): boolean; +defineMaterialGroup(group: MaterialGroup | MaterialGroupJSON): boolean; +removeMaterialGroup(groupId: string): boolean; + save(): VoxelWorldJSON; load(data: VoxelWorldJSON, options?: VoxelLoadOptions): void; dispose(): void; @@ -120,8 +129,13 @@ in that case. A rejected command is never broadcast. through an edit, which is what [`VoxelView.loadTileset()`](./VoxelView.md) calls. +`defineMaterialGroup()` adds or replaces a group and broadcasts it with every +field filled in. It returns `false` for an invalid finish or one equal to the +current definition. Neither group method emits `invalidated`; the view +decides whether a chunk needs rebuilding. + `load()` replaces the world, drops the undo history, and emits `loaded`. The -snapshot replaces the tileset list wholesale, so `options.tilesets` +snapshot replaces the tileset list and the material groups wholesale, so `options.tilesets` declarations are applied after it: ```ts diff --git a/packages/voxel-renderer/docs/api/core/VoxelEngine.md b/packages/voxel-renderer/docs/api/core/VoxelEngine.md index 1403195a3..16c43601d 100644 --- a/packages/voxel-renderer/docs/api/core/VoxelEngine.md +++ b/packages/voxel-renderer/docs/api/core/VoxelEngine.md @@ -271,6 +271,7 @@ class VoxelEngine extends Emitter { viewDistancePolicy: "hide" | "unload"; readonly pendingRebuilds: number; readonly tilesets: TilesetList; + readonly materialGroups: MaterialGroupList; defaultTileSize: number | undefined; } ``` @@ -466,6 +467,15 @@ nothing for an unknown ID or a move that changes nothing. The order is a document concern only, so no chunk is marked dirty. See [`BlockRegistry` ordering](../blocks/BlockRegistry.md#ordering). +#### `defineMaterialGroup(group)`, `removeMaterialGroup(groupId)` + +Shorthands for `apply()` with `material-group-defined` and +`material-group-removed`. Each returns whether the list changed. A defined +group draws its blocks with a `MeshStandardMaterial` carrying its finish, even +when `material` is `"lambert"`. Editing the finish of a group updates its +materials in place; defining or removing one rebuilds every chunk. See +[MaterialGroup](../materials/MaterialGroup.md). + ### Commands #### `apply(command: VoxelCommand, options?: VoxelApplyOptions): boolean` @@ -493,6 +503,8 @@ emits nothing. The material customizer receives the resolved [BlockSurface](../blocks/BlockSurface.md) for each draw group. It can distinguish masked and blended geometry without inferring the policy from the material opacity, and reads -`surface.materialGroup` to tune grouped blocks apart on a shared atlas. To composite overlapping +`surface.materialGroup` to tune grouped blocks apart on a shared atlas. It runs +after the document finish of a [material group](../materials/MaterialGroup.md) +is applied, so it can override it on a new material. To composite overlapping blended chunks, install [VoxelTransparencyRenderer](./VoxelTransparencyRenderer.md) in the application render loop. diff --git a/packages/voxel-renderer/docs/api/core/commands.md b/packages/voxel-renderer/docs/api/core/commands.md index 9d6472275..f6d072108 100644 --- a/packages/voxel-renderer/docs/api/core/commands.md +++ b/packages/voxel-renderer/docs/api/core/commands.md @@ -2,7 +2,8 @@ Every change to a voxel document is a `VoxelCommand`. The engine emits each one on its `"command"` event and replays one with `apply()`, so a single listener -and a single entry point cover layers, voxels, objects, blocks and tilesets. +and a single entry point cover layers, voxels, objects, blocks, tilesets and +material groups. ```ts import { @@ -28,7 +29,8 @@ engine.off("command", onCommand); type VoxelCommand = | VoxelLayerCommand | VoxelBlockCommand - | VoxelTilesetCommand; + | VoxelTilesetCommand + | VoxelMaterialGroupCommand; type VoxelCommandListener = ( command: VoxelCommand, @@ -44,11 +46,12 @@ interface VoxelCommandContext { replayed with `engine.apply(command, { origin: "remote" })`. A network adapter sends only local commands; UI listeners usually ignore the origin. -`isVoxelLayerCommand()`, `isVoxelBlockCommand()` and `isVoxelTilesetCommand()` -narrow a command (or any `{ action: string }`) to one category. -`VOXEL_COMMAND_ACTIONS` lists every action; `VOXEL_LAYER_COMMAND_ACTIONS`, -`VOXEL_BLOCK_COMMAND_ACTIONS` and `VOXEL_TILESET_COMMAND_ACTIONS` list each -category. `VoxelCommandAction` and the per-category `*CommandAction` types are +`isVoxelLayerCommand()`, `isVoxelBlockCommand()`, `isVoxelTilesetCommand()` and +`isVoxelMaterialGroupCommand()` narrow a command (or any `{ action: string }`) +to one category. `VOXEL_COMMAND_ACTIONS` lists every action; +`VOXEL_LAYER_COMMAND_ACTIONS`, `VOXEL_BLOCK_COMMAND_ACTIONS`, +`VOXEL_TILESET_COMMAND_ACTIONS` and `VOXEL_MATERIAL_GROUP_COMMAND_ACTIONS` list +each category. `VoxelCommandAction` and the per-category `*CommandAction` types are the matching unions. ## Applying commands @@ -64,10 +67,12 @@ interface VoxelCommandTarget { readonly world: VoxelWorld; readonly blocks: BlockRegistry; readonly tilesets: TilesetList; + readonly materialGroups: MaterialGroupList; } ``` -Routes a command to `world.apply()`, `applyBlockCommand()` or +Routes a command to `world.apply()`, `applyBlockCommand()`, +[`applyMaterialGroupCommand()`](../materials/MaterialGroup.md#commands) or [`applyTilesetCommand()`](../tilesets/tilesets.md#tileset-commands) and returns whether it changed anything. Layer commands always return `true`. It does not emit, rebuild meshes or rescale atlases; use it on a headless document such as @@ -145,3 +150,15 @@ type VoxelTilesetCommand = `engine.apply()` and its shorthands emit them only when the command changed the list. `engine.load()`, `engine.loadTileset()` and direct `engine.tilesets` mutations do not. + +## Material group commands + +```ts +type VoxelMaterialGroupCommand = + | { action: "material-group-defined"; group: MaterialGroupJSON; } + | { action: "material-group-removed"; groupId: string; }; +``` + +`engine.defineMaterialGroup()`, `removeMaterialGroup()` and `apply()` emit +them when the list changed. The emitted definition has every finish field +filled in. See [MaterialGroup](../materials/MaterialGroup.md). diff --git a/packages/voxel-renderer/docs/api/materials/MaterialGroup.md b/packages/voxel-renderer/docs/api/materials/MaterialGroup.md new file mode 100644 index 000000000..6d020ee56 --- /dev/null +++ b/packages/voxel-renderer/docs/api/materials/MaterialGroup.md @@ -0,0 +1,116 @@ +# MaterialGroup + +A material group is the surface finish shared by every block whose +[`BlockSurface`](../blocks/BlockSurface.md) names it in `materialGroup`. The +finish is stored in the document, next to `blocks`, so it is saved, loaded +and synced with the map. + +```ts +import { VoxelEngine } from "@jolly-pixel/voxel.renderer"; + +const engine = new VoxelEngine({ + blocks: [ + { id: 1, name: "Gold", shapeId: "cube", defaultTexture, materialGroup: "gold" } + ], + materialGroups: [ + { id: "gold", roughness: 0.35, metalness: 1 } + ] +}); + +engine.defineMaterialGroup({ id: "gold", roughness: 0.2, metalness: 1 }); +``` + +```ts +interface MaterialGroupJSON { + id: string; + roughness?: number; + metalness?: number; + emissive?: string; + emissiveIntensity?: number; +} + +type MaterialGroupFinish = Required>; + +class MaterialGroup { + static readonly defaults: Readonly; + static parse(value: unknown): MaterialGroup | null; + + constructor(json: MaterialGroupJSON); + readonly id: string; + readonly roughness: number; + readonly metalness: number; + readonly emissive: string; + readonly emissiveIntensity: number; + + with(finish: Partial): MaterialGroup; + applyTo(material: THREE.MeshLambertMaterial | THREE.MeshStandardMaterial): void; + equals(other: MaterialGroup): boolean; + toJSON(): Required; +} +``` + +| Field | Default | Bounds | +|---|---|---| +| `id` | Required | Non-empty string, matching `BlockSurface.materialGroup`. | +| `roughness` | `1` | `0` to `1`. | +| `metalness` | `0` | `0` to `1`. | +| `emissive` | `"#000000"` | A `#rrggbb` colour, stored in lower case. | +| `emissiveIntensity` | `1` | `0` or more. | + +The instance is frozen. The constructor throws `RangeError` for a value out +of bounds; `parse()` returns `null` instead. `with()` returns a new group. + +## Rendering + +A block whose group is defined is drawn with a `MeshStandardMaterial` carrying +the finish, even when the view's `material` is `"lambert"`. Blocks without a +group, or naming a group the document does not define, keep the view's +material. `applyTo()` writes only the emissive fields on a Lambert material. + +The `materialCustomizer` runs after the finish is applied, so host code can +still override it when a material is created. Editing a finish afterwards +updates the existing materials in place, without the customizer. Defining or +removing a group rebuilds every chunk. + +A metallic finish reflects its environment. With no `scene.environment`, a +metalness near `1` renders dark. + +## MaterialGroupList + +`VoxelDocument.materialGroups` and `VoxelEngine.materialGroups` hold the +groups of a document. + +```ts +class MaterialGroupList implements Iterable { + constructor(groups?: Iterable); + readonly version: number; + readonly size: number; + + ids(): Set; + has(groupId: string): boolean; + get(groupId: string): MaterialGroup | undefined; + define(group: MaterialGroup | MaterialGroupJSON): boolean; + remove(groupId: string): boolean; + replace(groups: Iterable): void; + clear(): void; + toJSON(): MaterialGroupJSON[]; +} +``` + +`define()` returns `false` for an invalid group or one equal to the current +definition. `replace()` and the constructor skip invalid entries and keep the +first of duplicate IDs. Mutating the list directly emits no command; use +`engine.defineMaterialGroup()` for an edit that should sync. + +## Commands + +```ts +function applyMaterialGroupCommand( + groups: MaterialGroupList, + command: VoxelMaterialGroupCommand +): boolean; +``` + +Applies a `material-group-defined` or `material-group-removed` +[command](../core/commands.md#material-group-commands) to a list and returns +whether it changed. diff --git a/packages/voxel-renderer/docs/api/serialization/serialization.md b/packages/voxel-renderer/docs/api/serialization/serialization.md index a7107bdd2..22961cec0 100644 --- a/packages/voxel-renderer/docs/api/serialization/serialization.md +++ b/packages/voxel-renderer/docs/api/serialization/serialization.md @@ -34,6 +34,7 @@ interface VoxelWorldJSON { defaultTileSize?: number; layers: VoxelLayerJSON[]; blocks?: ResolvedBlockDefinition[]; + materialGroups?: MaterialGroupJSON[]; objectLayers?: VoxelObjectLayerJSON[]; } ``` @@ -47,6 +48,8 @@ Migrate legacy `transparent: true` to `alphaMode: "blend"` before loading old content; the renderer no longer reads the legacy property. `blocks` contains definitions embedded by `VoxelEngine.save()` or a converter. +`materialGroups` stores the [finish](../materials/MaterialGroup.md) of each +group named by a block's `materialGroup`. `objectLayers` stores placed objects such as spawn points and trigger zones. `defaultTileSize` is the tile size pre-selected for a new tileset. The mesher never reads it, and a value that fails `isTileSize()` is dropped on parse. @@ -58,6 +61,7 @@ interface VoxelSerializeOptions { tilesets?: Iterable; defaultTileSize?: number; blocks?: Iterable; + materialGroups?: Iterable; } function serializeVoxelWorld( @@ -67,7 +71,8 @@ function serializeVoxelWorld( ``` The world does not own loaded tileset metadata or the block registry, so callers -pass those collections explicitly. `blocks` is omitted when it is not supplied. +pass those collections explicitly. `blocks` is omitted when it is not supplied, +and `materialGroups` when it is empty. ## Deserializing a world @@ -75,6 +80,7 @@ pass those collections explicitly. `blocks` is omitted when it is not supplied. interface VoxelDeserializeOptions { blocks?: BlockRegistry; tilesets?: TilesetList; + materialGroups?: MaterialGroupList; } function deserializeVoxelWorld( @@ -94,6 +100,9 @@ the world again writes the world's `chunkSize`. `defaultTileSize`. When both `blocks` and `tilesets` are supplied, tile references without `tilesetId` are assigned the first declared tileset. +`options.materialGroups` is replaced with the document's groups, and emptied +for a document without any. Invalid or duplicate groups are skipped. + Embedded block definitions are registered when `options.blocks` is supplied. A document carrying a `blocks` array is authoritative: the registry is emptied and refilled from it, after the document has parsed, so a rejected document @@ -134,7 +143,8 @@ class InvalidVoxelDocumentError extends Error { `parseVoxelDocument()` requires version `1`, a positive integer `chunkSize`, and a `layers` array. A missing or malformed `tilesets` value becomes an empty -array. Malformed `blocks` and `objectLayers` values are omitted. Unknown +array. Malformed `blocks`, `materialGroups` and `objectLayers` values are +omitted. Unknown top-level keys are discarded. The parser validates the top-level document shape. Collection elements are diff --git a/packages/voxel-renderer/src/VoxelEngine.ts b/packages/voxel-renderer/src/VoxelEngine.ts index 5fc5be59d..5f16ac3bb 100644 --- a/packages/voxel-renderer/src/VoxelEngine.ts +++ b/packages/voxel-renderer/src/VoxelEngine.ts @@ -14,6 +14,11 @@ import type { VoxelCommand } from "./commands.ts"; import { VoxelDocument } from "./document/VoxelDocument.ts"; import type { VoxelHistory } from "./history/VoxelHistory.ts"; import type { VoxelInspector } from "./inspector/index.ts"; +import type { + MaterialGroup, + MaterialGroupJSON +} from "./materials/MaterialGroup.ts"; +import type { MaterialGroupList } from "./materials/MaterialGroupList.ts"; import type { VoxelWorldJSON } from "./serialization/types.ts"; import type { TilesetList } from "./tileset/TilesetList.ts"; import type { TilesetManager } from "./tileset/TilesetManager.ts"; @@ -59,6 +64,7 @@ export class VoxelEngine extends Emitter { chunkSize, layers, blocks, + materialGroups, history, onCommand, logger, @@ -75,6 +81,7 @@ export class VoxelEngine extends Emitter { chunkSize, layers, blocks, + materialGroups, history, logger, tilesets: Array.from(tilesets ?? [], (source) => source.def) @@ -111,6 +118,10 @@ export class VoxelEngine extends Emitter { return this.document.tilesets; } + get materialGroups(): MaterialGroupList { + return this.document.materialGroups; + } + get shapeRegistry(): BlockShapeRegistry { return this.view.shapes; } @@ -269,6 +280,18 @@ export class VoxelEngine extends Emitter { return this.document.moveBlock(blockId, toIndex); } + defineMaterialGroup( + group: MaterialGroup | MaterialGroupJSON + ): boolean { + return this.document.defineMaterialGroup(group); + } + + removeMaterialGroup( + groupId: string + ): boolean { + return this.document.removeMaterialGroup(groupId); + } + addTileset( tileset: TilesetDefinition ): boolean { diff --git a/packages/voxel-renderer/src/applyVoxelCommand.ts b/packages/voxel-renderer/src/applyVoxelCommand.ts index bf2860ebe..e9ed05e3f 100644 --- a/packages/voxel-renderer/src/applyVoxelCommand.ts +++ b/packages/voxel-renderer/src/applyVoxelCommand.ts @@ -1,5 +1,9 @@ // Import Internal Dependencies import { applyBlockCommand } from "./blocks/applyBlockCommand.ts"; +import { + applyMaterialGroupCommand +} from "./materials/applyMaterialGroupCommand.ts"; +import type { MaterialGroupList } from "./materials/MaterialGroupList.ts"; import { applyTilesetCommand, type TilesetDocument @@ -7,6 +11,7 @@ import { import { isVoxelBlockCommand, isVoxelLayerCommand, + isVoxelMaterialGroupCommand, type VoxelCommand } from "./commands.ts"; import type { VoxelWorld } from "./world/VoxelWorld.ts"; @@ -14,6 +19,7 @@ import type { VoxelLogger } from "./utils/logger.ts"; export interface VoxelCommandTarget extends TilesetDocument { readonly world: VoxelWorld; + readonly materialGroups: MaterialGroupList; } export function applyVoxelCommand( @@ -29,6 +35,9 @@ export function applyVoxelCommand( if (isVoxelBlockCommand(command)) { return applyBlockCommand(target.blocks, command); } + if (isVoxelMaterialGroupCommand(command)) { + return applyMaterialGroupCommand(target.materialGroups, command); + } return applyTilesetCommand(target, command); } diff --git a/packages/voxel-renderer/src/commands.ts b/packages/voxel-renderer/src/commands.ts index 9c5178ff7..692ebc38d 100644 --- a/packages/voxel-renderer/src/commands.ts +++ b/packages/voxel-renderer/src/commands.ts @@ -12,6 +12,7 @@ import type { ResolvedBlockDefinition } from "./blocks/BlockDefinition.ts"; import type { TilesetDefinition } from "./tileset/types.ts"; +import type { MaterialGroupJSON } from "./materials/MaterialGroup.ts"; import type { VoxelObjectLayerJSON, VoxelObjectJSON @@ -246,17 +247,38 @@ export const VOXEL_TILESET_COMMAND_ACTIONS: readonly VoxelTilesetCommandAction[] "default-tile-size-updated" ]; +export type VoxelMaterialGroupCommand = + | { + action: "material-group-defined"; + group: MaterialGroupJSON; + } + | { + action: "material-group-removed"; + groupId: string; + }; + +export type VoxelMaterialGroupCommandAction = + VoxelMaterialGroupCommand["action"]; + +export const VOXEL_MATERIAL_GROUP_COMMAND_ACTIONS: +readonly VoxelMaterialGroupCommandAction[] = [ + "material-group-defined", + "material-group-removed" +]; + export type VoxelCommand = | VoxelLayerCommand | VoxelBlockCommand - | VoxelTilesetCommand; + | VoxelTilesetCommand + | VoxelMaterialGroupCommand; export type VoxelCommandAction = VoxelCommand["action"]; export const VOXEL_COMMAND_ACTIONS: readonly VoxelCommandAction[] = [ ...VOXEL_LAYER_COMMAND_ACTIONS, ...VOXEL_BLOCK_COMMAND_ACTIONS, - ...VOXEL_TILESET_COMMAND_ACTIONS + ...VOXEL_TILESET_COMMAND_ACTIONS, + ...VOXEL_MATERIAL_GROUP_COMMAND_ACTIONS ]; export type VoxelCommandOrigin = "local" | "remote"; @@ -297,3 +319,11 @@ export function isVoxelTilesetCommand( (action) => action === command.action ); } + +export function isVoxelMaterialGroupCommand( + command: { action: string; } +): command is VoxelMaterialGroupCommand { + return VOXEL_MATERIAL_GROUP_COMMAND_ACTIONS.some( + (action) => action === command.action + ); +} diff --git a/packages/voxel-renderer/src/document/VoxelDocument.ts b/packages/voxel-renderer/src/document/VoxelDocument.ts index fa25e406e..7dc380b93 100644 --- a/packages/voxel-renderer/src/document/VoxelDocument.ts +++ b/packages/voxel-renderer/src/document/VoxelDocument.ts @@ -20,6 +20,11 @@ import { type VoxelCommandOrigin } from "../commands.ts"; import { VoxelHistory } from "../history/VoxelHistory.ts"; +import { + MaterialGroup, + type MaterialGroupJSON +} from "../materials/MaterialGroup.ts"; +import { MaterialGroupList } from "../materials/MaterialGroupList.ts"; import { deserializeVoxelWorld, serializeVoxelWorld @@ -47,6 +52,7 @@ export class VoxelDocument extends Emitter { readonly world: VoxelWorld; readonly blocks: BlockRegistry; readonly tilesets: TilesetList; + readonly materialGroups: MaterialGroupList; readonly history: VoxelHistory; #logger: VoxelLogger; @@ -59,6 +65,7 @@ export class VoxelDocument extends Emitter { layers = [], blocks = [], tilesets = [], + materialGroups = [], history, logger = NOOP_LOGGER, onCommand @@ -86,6 +93,7 @@ export class VoxelDocument extends Emitter { for (const tileset of tilesets) { this.tilesets.add(tileset); } + this.materialGroups = new MaterialGroupList(materialGroups); } get chunkSize(): number { @@ -98,14 +106,13 @@ export class VoxelDocument extends Emitter { ): boolean { const { origin = "local" } = options; - const resolved = command.action === "block-defined" ? - this.#blockDefined(command.block) : - command; - const applied = applyVoxelCommand( + const resolved = this.#resolve(command); + const applied = resolved !== null && applyVoxelCommand( { world: this.world, blocks: this.blocks, - tilesets: this.tilesets + tilesets: this.tilesets, + materialGroups: this.materialGroups }, resolved, this.#logger @@ -197,6 +204,24 @@ export class VoxelDocument extends Emitter { }); } + defineMaterialGroup( + group: MaterialGroup | MaterialGroupJSON + ): boolean { + return this.apply({ + action: "material-group-defined", + group: group instanceof MaterialGroup ? group.toJSON() : group + }); + } + + removeMaterialGroup( + groupId: string + ): boolean { + return this.apply({ + action: "material-group-removed", + groupId + }); + } + get defaultTileSize(): number | undefined { return this.tilesets.defaultTileSize; } @@ -256,7 +281,8 @@ export class VoxelDocument extends Emitter { return serializeVoxelWorld(this.world, { tilesets: this.tilesets, defaultTileSize: this.tilesets.defaultTileSize, - blocks: this.blocks + blocks: this.blocks, + materialGroups: this.materialGroups }); } @@ -267,7 +293,8 @@ export class VoxelDocument extends Emitter { this.world.silently( () => deserializeVoxelWorld(data, this.world, { blocks: this.blocks, - tilesets: this.tilesets + tilesets: this.tilesets, + materialGroups: this.materialGroups }) ); @@ -291,6 +318,24 @@ export class VoxelDocument extends Emitter { this.removeAllListeners(); } + #resolve( + command: VoxelCommand + ): VoxelCommand | null { + if (command.action === "block-defined") { + return this.#blockDefined(command.block); + } + if (command.action === "material-group-defined") { + const group = MaterialGroup.parse(command.group); + + return group && { + action: command.action, + group: group.toJSON() + }; + } + + return command; + } + #blockDefined( block: BlockDefinition ): BlockDefinedCommand { diff --git a/packages/voxel-renderer/src/document/VoxelDocument.types.ts b/packages/voxel-renderer/src/document/VoxelDocument.types.ts index 7e1df62d0..7348dbee1 100644 --- a/packages/voxel-renderer/src/document/VoxelDocument.types.ts +++ b/packages/voxel-renderer/src/document/VoxelDocument.types.ts @@ -1,6 +1,7 @@ // Import Internal Dependencies import type { BlockDefinition } from "../blocks/BlockDefinition.ts"; import type { VoxelHistoryOptions } from "../history/VoxelHistory.ts"; +import type { MaterialGroupJSON } from "../materials/MaterialGroup.ts"; import type { TilesetDefinition } from "../tileset/types.ts"; import type { VoxelLogger } from "../utils/logger.ts"; import type { @@ -55,6 +56,8 @@ export interface VoxelDocumentOptions { */ tilesets?: Iterable; + materialGroups?: Iterable; + /** * Undo/redo of voxel edits made through `VoxelWorld`; disabled by default. */ diff --git a/packages/voxel-renderer/src/index.ts b/packages/voxel-renderer/src/index.ts index 63fa5f0e7..9a25f81a2 100644 --- a/packages/voxel-renderer/src/index.ts +++ b/packages/voxel-renderer/src/index.ts @@ -13,6 +13,7 @@ export * from "./applyTilesetCommand.ts"; export * from "./blocks/index.ts"; export * from "./collision/index.ts"; +export * from "./materials/index.ts"; export * from "./serialization/index.ts"; export * from "./tileset/index.ts"; export * from "./world/index.ts"; diff --git a/packages/voxel-renderer/src/materials/MaterialGroup.ts b/packages/voxel-renderer/src/materials/MaterialGroup.ts new file mode 100644 index 000000000..853392111 --- /dev/null +++ b/packages/voxel-renderer/src/materials/MaterialGroup.ts @@ -0,0 +1,156 @@ +// Import Third-party Dependencies +import * as THREE from "three"; + +// CONSTANTS +const kHexColor = /^#[0-9a-f]{6}$/i; + +export interface MaterialGroupJSON { + id: string; + roughness?: number; + metalness?: number; + emissive?: string; + emissiveIntensity?: number; +} + +export type MaterialGroupFinish = Required>; + +export type FinishableMaterial = + | THREE.MeshLambertMaterial + | THREE.MeshStandardMaterial; + +export class MaterialGroup { + static readonly defaults: Readonly = Object.freeze({ + roughness: 1, + metalness: 0, + emissive: "#000000", + emissiveIntensity: 1 + }); + + readonly id: string; + readonly roughness: number; + readonly metalness: number; + readonly emissive: string; + readonly emissiveIntensity: number; + + static parse( + value: unknown + ): MaterialGroup | null { + return isMaterialGroupJSON(value) ? new MaterialGroup(value) : null; + } + + constructor( + json: MaterialGroupJSON + ) { + const problem = problemOf(json); + if (problem !== null) { + throw new RangeError(problem); + } + + const { defaults } = MaterialGroup; + this.id = json.id; + this.roughness = json.roughness ?? defaults.roughness; + this.metalness = json.metalness ?? defaults.metalness; + this.emissive = (json.emissive ?? defaults.emissive).toLowerCase(); + this.emissiveIntensity = json.emissiveIntensity ?? + defaults.emissiveIntensity; + Object.freeze(this); + } + + with( + finish: Partial + ): MaterialGroup { + return new MaterialGroup({ + ...this.toJSON(), + ...finish, + id: this.id + }); + } + + applyTo( + material: FinishableMaterial + ): void { + material.emissive.set(this.emissive); + material.emissiveIntensity = this.emissiveIntensity; + if (material instanceof THREE.MeshStandardMaterial) { + material.roughness = this.roughness; + material.metalness = this.metalness; + } + } + + equals( + other: MaterialGroup + ): boolean { + return this.id === other.id && + this.roughness === other.roughness && + this.metalness === other.metalness && + this.emissive === other.emissive && + this.emissiveIntensity === other.emissiveIntensity; + } + + toJSON(): Required { + return { + id: this.id, + roughness: this.roughness, + metalness: this.metalness, + emissive: this.emissive, + emissiveIntensity: this.emissiveIntensity + }; + } +} + +function isMaterialGroupJSON( + value: unknown +): value is MaterialGroupJSON { + return problemOf(value) === null; +} + +function problemOf( + value: unknown +): string | null { + if (typeof value !== "object" || value === null) { + return "Material group must be an object."; + } + + const fields: Map = new Map(Object.entries(value)); + const id = fields.get("id"); + const emissive = fields.get("emissive"); + const emissiveIntensity = fields.get("emissiveIntensity"); + + if (typeof id !== "string" || id === "") { + return "Material group id must be a non-empty string."; + } + if (!isUnitOrUndefined(fields.get("roughness"))) { + return "Roughness must be between 0 and 1."; + } + if (!isUnitOrUndefined(fields.get("metalness"))) { + return "Metalness must be between 0 and 1."; + } + if ( + emissive !== undefined && + (typeof emissive !== "string" || !kHexColor.test(emissive)) + ) { + return "Emissive must be a #rrggbb colour."; + } + if ( + emissiveIntensity !== undefined && + ( + typeof emissiveIntensity !== "number" || + !Number.isFinite(emissiveIntensity) || + emissiveIntensity < 0 + ) + ) { + return "Emissive intensity must be a finite number of 0 or more."; + } + + return null; +} + +function isUnitOrUndefined( + value: unknown +): boolean { + return value === undefined || ( + typeof value === "number" && + value >= 0 && + value <= 1 + ); +} diff --git a/packages/voxel-renderer/src/materials/MaterialGroupList.ts b/packages/voxel-renderer/src/materials/MaterialGroupList.ts new file mode 100644 index 000000000..8a6d1bc0c --- /dev/null +++ b/packages/voxel-renderer/src/materials/MaterialGroupList.ts @@ -0,0 +1,92 @@ +// Import Internal Dependencies +import { + MaterialGroup, + type MaterialGroupJSON +} from "./MaterialGroup.ts"; + +export class MaterialGroupList implements Iterable { + #groups = new Map(); + #version = 0; + + constructor( + groups: Iterable = [] + ) { + this.replace(groups); + } + + get version(): number { + return this.#version; + } + + get size(): number { + return this.#groups.size; + } + + [Symbol.iterator](): IterableIterator { + return this.#groups.values(); + } + + ids(): Set { + return new Set(this.#groups.keys()); + } + + has( + groupId: string + ): boolean { + return this.#groups.has(groupId); + } + + get( + groupId: string + ): MaterialGroup | undefined { + return this.#groups.get(groupId); + } + + define( + group: MaterialGroup | MaterialGroupJSON + ): boolean { + const next = group instanceof MaterialGroup ? + group : + MaterialGroup.parse(group); + if (next === null || this.#groups.get(next.id)?.equals(next)) { + return false; + } + + this.#groups.set(next.id, next); + this.#version++; + + return true; + } + + remove( + groupId: string + ): boolean { + if (!this.#groups.delete(groupId)) { + return false; + } + this.#version++; + + return true; + } + + replace( + groups: Iterable + ): void { + this.#groups.clear(); + for (const value of groups) { + const group = MaterialGroup.parse(value); + if (group !== null && !this.#groups.has(group.id)) { + this.#groups.set(group.id, group); + } + } + this.#version++; + } + + clear(): void { + this.replace([]); + } + + toJSON(): MaterialGroupJSON[] { + return Array.from(this.#groups.values(), (group) => group.toJSON()); + } +} diff --git a/packages/voxel-renderer/src/materials/applyMaterialGroupCommand.ts b/packages/voxel-renderer/src/materials/applyMaterialGroupCommand.ts new file mode 100644 index 000000000..10b968532 --- /dev/null +++ b/packages/voxel-renderer/src/materials/applyMaterialGroupCommand.ts @@ -0,0 +1,14 @@ +// Import Internal Dependencies +import type { MaterialGroupList } from "./MaterialGroupList.ts"; +import type { VoxelMaterialGroupCommand } from "../commands.ts"; + +export function applyMaterialGroupCommand( + groups: MaterialGroupList, + command: VoxelMaterialGroupCommand +): boolean { + if (command.action === "material-group-removed") { + return groups.remove(command.groupId); + } + + return groups.define(command.group); +} diff --git a/packages/voxel-renderer/src/materials/index.ts b/packages/voxel-renderer/src/materials/index.ts new file mode 100644 index 000000000..0d38c09e4 --- /dev/null +++ b/packages/voxel-renderer/src/materials/index.ts @@ -0,0 +1,3 @@ +export * from "./MaterialGroup.ts"; +export * from "./MaterialGroupList.ts"; +export * from "./applyMaterialGroupCommand.ts"; diff --git a/packages/voxel-renderer/src/render/ChunkMaterialCache.ts b/packages/voxel-renderer/src/render/ChunkMaterialCache.ts index 4a93efe16..a50e36466 100644 --- a/packages/voxel-renderer/src/render/ChunkMaterialCache.ts +++ b/packages/voxel-renderer/src/render/ChunkMaterialCache.ts @@ -12,6 +12,8 @@ import type { MaterialCustomizerFn } from "../VoxelEngine.types.ts"; import { BlockSurface } from "../blocks/BlockSurface.ts"; import { ChunkGeometryKey } from "../mesh/ChunkGeometryKey.ts"; import { createAoStrength } from "../mesh/ambientOcclusion.ts"; +import type { MaterialGroup } from "../materials/MaterialGroup.ts"; +import type { MaterialGroupList } from "../materials/MaterialGroupList.ts"; // CONSTANTS const kCoveredFaceOffset = -1; @@ -20,8 +22,16 @@ export type ChunkMaterial = | THREE.MeshLambertMaterial | THREE.MeshStandardMaterial; +interface ChunkMaterialEntry { + key: string; + material: ChunkMaterial; + tilesetId: string; + surface: BlockSurface; +} + export interface ChunkMaterialCacheOptions { tilesetManager: TilesetManager; + materialGroups?: MaterialGroupList; /** * @default "lambert" */ @@ -54,9 +64,10 @@ export class ChunkMaterialCache { readonly aoStrength: ReturnType; #materials = new Map(); - #keys = new Map(); + #entries = new Map(); #references = new Map(); #tilesetManager: TilesetManager; + #materialGroups: MaterialGroupList | undefined; #type: "lambert" | "standard"; #customizer?: MaterialCustomizerFn; @@ -65,6 +76,7 @@ export class ChunkMaterialCache { ) { const { tilesetManager, + materialGroups, type = "lambert", customizer, tileWrapping = false, @@ -73,6 +85,7 @@ export class ChunkMaterialCache { } = options; this.#tilesetManager = tilesetManager; + this.#materialGroups = materialGroups; this.#type = type; this.#customizer = customizer; this.tileWrapping = tileWrapping; @@ -104,7 +117,12 @@ export class ChunkMaterialCache { resolved ); this.#materials.set(key, material); - this.#keys.set(material, key); + this.#entries.set(material, { + key, + material, + tilesetId, + surface: resolved + }); return material; } @@ -127,15 +145,38 @@ export class ChunkMaterialCache { return; } - const key = this.#keys.get(material); - if (key !== undefined) { - this.#materials.delete(key); - this.#keys.delete(material); - material.dispose(); + if (this.#entries.has(material)) { + this.#evict(material); + + return; } this.#references.delete(material); } + refreshGroup( + groupId: string + ): boolean { + const group = this.#materialGroups?.get(groupId); + let evicted = false; + + for (const { material, surface } of this.#entries.values()) { + if (surface.materialGroup !== groupId) { + continue; + } + + const standard = material instanceof THREE.MeshStandardMaterial; + if (group !== undefined && standard === this.#usesStandard(group)) { + group.applyTo(material); + continue; + } + + this.#evict(material); + evicted = true; + } + + return evicted; + } + #create( tilesetId: string, opacity: number, @@ -158,7 +199,10 @@ export class ChunkMaterialCache { polygonOffsetUnits: surface.occludes ? 0 : kCoveredFaceOffset }; - const material = this.#type === "standard" ? + const group = surface.materialGroup === undefined ? + undefined : + this.#materialGroups?.get(surface.materialGroup); + const material = this.#usesStandard(group) ? new THREE.MeshStandardMaterial(options) : new THREE.MeshLambertMaterial(options); @@ -174,6 +218,7 @@ export class ChunkMaterialCache { this.aoStrength, averages ); + group?.applyTo(material); this.#customizer?.( material, tilesetId, @@ -192,13 +237,9 @@ export class ChunkMaterialCache { return; } - const prefix = `${tilesetId}:`; - for (const [key, material] of this.#materials) { - if (key.startsWith(prefix)) { - material.dispose(); - this.#keys.delete(material); - this.#references.delete(material); - this.#materials.delete(key); + for (const { material, tilesetId: id } of this.#entries.values()) { + if (id === tilesetId) { + this.#evict(material); } } } @@ -209,7 +250,25 @@ export class ChunkMaterialCache { } this.#materials.clear(); - this.#keys.clear(); + this.#entries.clear(); this.#references.clear(); } + + #usesStandard( + group: MaterialGroup | undefined + ): boolean { + return this.#type === "standard" || group !== undefined; + } + + #evict( + material: THREE.Material + ): void { + const entry = this.#entries.get(material); + if (entry !== undefined) { + this.#materials.delete(entry.key); + this.#entries.delete(material); + } + this.#references.delete(material); + material.dispose(); + } } diff --git a/packages/voxel-renderer/src/serialization/document.ts b/packages/voxel-renderer/src/serialization/document.ts index 8e513045f..ae5c54d4c 100644 --- a/packages/voxel-renderer/src/serialization/document.ts +++ b/packages/voxel-renderer/src/serialization/document.ts @@ -20,6 +20,7 @@ export function parseVoxelDocument( const objectLayers = fields.get("objectLayers"); const tilesets = fields.get("tilesets"); const defaultTileSize = fields.get("defaultTileSize"); + const materialGroups = fields.get("materialGroups"); if (version !== 1) { throw new InvalidVoxelDocumentError( @@ -51,6 +52,9 @@ export function parseVoxelDocument( if (Array.isArray(blocks)) { document.blocks = blocks; } + if (Array.isArray(materialGroups)) { + document.materialGroups = materialGroups; + } if (Array.isArray(objectLayers)) { document.objectLayers = objectLayers; } diff --git a/packages/voxel-renderer/src/serialization/types.ts b/packages/voxel-renderer/src/serialization/types.ts index 43b9337cf..35bf5cad9 100644 --- a/packages/voxel-renderer/src/serialization/types.ts +++ b/packages/voxel-renderer/src/serialization/types.ts @@ -2,6 +2,7 @@ import type { VoxelLayerJSON } from "../world/VoxelLayer.ts"; import type { TilesetDefinition } from "../tileset/types.ts"; import type { ResolvedBlockDefinition } from "../blocks/BlockDefinition.ts"; +import type { MaterialGroupJSON } from "../materials/MaterialGroup.ts"; export type VoxelObjectProperties = Record< string, @@ -38,6 +39,7 @@ export interface VoxelWorldJSON { tilesets: TilesetDefinition[]; defaultTileSize?: number; blocks?: ResolvedBlockDefinition[]; + materialGroups?: MaterialGroupJSON[]; layers: VoxelLayerJSON[]; objectLayers?: VoxelObjectLayerJSON[]; } diff --git a/packages/voxel-renderer/src/serialization/world.ts b/packages/voxel-renderer/src/serialization/world.ts index d8873de6d..c0746bfa9 100644 --- a/packages/voxel-renderer/src/serialization/world.ts +++ b/packages/voxel-renderer/src/serialization/world.ts @@ -11,16 +11,20 @@ import { import type { BlockRegistry } from "../blocks/BlockRegistry.ts"; import type { ResolvedBlockDefinition } from "../blocks/BlockDefinition.ts"; import type { TilesetList } from "../tileset/TilesetList.ts"; +import type { MaterialGroup } from "../materials/MaterialGroup.ts"; +import type { MaterialGroupList } from "../materials/MaterialGroupList.ts"; export interface VoxelSerializeOptions { tilesets?: Iterable; defaultTileSize?: number; blocks?: Iterable; + materialGroups?: Iterable; } export interface VoxelDeserializeOptions { blocks?: BlockRegistry; tilesets?: TilesetList; + materialGroups?: MaterialGroupList; } export function serializeVoxelWorld( @@ -44,6 +48,13 @@ export function serializeVoxelWorld( if (options.blocks) { document.blocks = [...options.blocks]; } + const materialGroups = Array.from( + options.materialGroups ?? [], + (group) => group.toJSON() + ); + if (materialGroups.length > 0) { + document.materialGroups = materialGroups; + } return document; } @@ -53,11 +64,12 @@ export function deserializeVoxelWorld( world: VoxelWorld, options: VoxelDeserializeOptions = {} ): void { - const { blocks, tilesets } = options; + const { blocks, tilesets, materialGroups } = options; const document = parseVoxelDocument(data); tilesets?.replace(document.tilesets, document.defaultTileSize); + materialGroups?.replace(document.materialGroups ?? []); if (blocks && document.blocks) { blocks.clear(); blocks.registerMany(document.blocks); diff --git a/packages/voxel-renderer/src/view/VoxelView.ts b/packages/voxel-renderer/src/view/VoxelView.ts index 90dc3d62c..092f0acc3 100644 --- a/packages/voxel-renderer/src/view/VoxelView.ts +++ b/packages/voxel-renderer/src/view/VoxelView.ts @@ -5,6 +5,7 @@ import * as THREE from "three"; import { BlockShapeRegistry } from "../blocks/shape/BlockShapeRegistry.ts"; import type { VoxelCollider } from "../collision/VoxelCollider.ts"; import { + isVoxelMaterialGroupCommand, isVoxelTilesetCommand, type VoxelCommand } from "../commands.ts"; @@ -61,6 +62,14 @@ export class VoxelView { if (isVoxelTilesetCommand(command)) { this.#syncAtlases(); } + else if (isVoxelMaterialGroupCommand(command)) { + const groupId = command.action === "material-group-defined" ? + command.group.id : + command.groupId; + if (this.#materials.refreshGroup(groupId)) { + this.markAllChunksDirty(command.action); + } + } }; #onInvalidated = ( @@ -158,6 +167,7 @@ export class VoxelView { this.#materials = new ChunkMaterialCache({ tilesetManager: this.tilesets, + materialGroups: document.materialGroups, type: material, customizer: materialCustomizer, tileWrapping: greedy, diff --git a/packages/voxel-renderer/test/VoxelEngine.materials.spec.ts b/packages/voxel-renderer/test/VoxelEngine.materials.spec.ts index 975504335..3405f9500 100644 --- a/packages/voxel-renderer/test/VoxelEngine.materials.spec.ts +++ b/packages/voxel-renderer/test/VoxelEngine.materials.spec.ts @@ -156,6 +156,69 @@ describe("VoxelEngine - material groups", () => { }); }); +describe("VoxelEngine - document material group finishes", () => { + const kGoldId = 5; + + function lambertGold(): VoxelEngine { + const engine = meshedGround({}, { + blocks: [ + makeBlockDef(kCubeId, "cube"), + makeBlockDef(kGoldId, "cube", { materialGroup: "gold" }) + ] + }); + placeCube(engine, "Ground", { x: 2, y: 0, z: 0 }, kGoldId); + engine.flush(); + + return engine; + } + + function goldMaterial( + engine: VoxelEngine + ): ChunkMaterial | undefined { + return materialsOf(engine).find( + (material) => material instanceof THREE.MeshStandardMaterial + ); + } + + it("rebuilds a lambert view with a standard material for a defined group", () => { + const engine = lambertGold(); + assert.equal(goldMaterial(engine), undefined); + + engine.defineMaterialGroup({ id: "gold", roughness: 0.3, metalness: 1 }); + engine.flush(); + + const gold = goldMaterial(engine); + assert.ok(gold instanceof THREE.MeshStandardMaterial); + assert.equal(gold.metalness, 1); + assert.equal(gold.roughness, 0.3); + assert.equal(materialsOf(engine).length, 2); + }); + + it("edits the finish of the drawn material in place", () => { + const engine = lambertGold(); + engine.defineMaterialGroup({ id: "gold" }); + engine.flush(); + const gold = goldMaterial(engine); + + engine.defineMaterialGroup({ id: "gold", emissive: "#ff0000" }); + + assert.equal(engine.view.pendingRebuilds, 0); + assert.equal(goldMaterial(engine), gold); + assert.equal(gold?.emissive.getHexString(), "ff0000"); + }); + + it("goes back to the view material when the group is removed", () => { + const engine = lambertGold(); + engine.defineMaterialGroup({ id: "gold", metalness: 1 }); + engine.flush(); + + engine.removeMaterialGroup("gold"); + engine.flush(); + + assert.equal(goldMaterial(engine), undefined); + }); +}); + describe("VoxelEngine - tile minification", () => { it("fades distant tiles to their average colour by default", () => { assert.equal(meshedGround().tileMinification, "average"); diff --git a/packages/voxel-renderer/test/applyVoxelCommand.spec.ts b/packages/voxel-renderer/test/applyVoxelCommand.spec.ts index d86ef0831..eea7416c8 100644 --- a/packages/voxel-renderer/test/applyVoxelCommand.spec.ts +++ b/packages/voxel-renderer/test/applyVoxelCommand.spec.ts @@ -8,6 +8,7 @@ import { type VoxelCommandTarget } from "../src/applyVoxelCommand.ts"; import { BlockRegistry } from "../src/blocks/index.ts"; +import { MaterialGroupList } from "../src/materials/index.ts"; import { TilesetList } from "../src/tileset/index.ts"; import { VoxelWorld } from "../src/world/index.ts"; import { @@ -19,7 +20,8 @@ function makeTarget(): VoxelCommandTarget { return { world: new VoxelWorld(4), blocks: new BlockRegistry(), - tilesets: new TilesetList() + tilesets: new TilesetList(), + materialGroups: new MaterialGroupList() }; } @@ -55,4 +57,22 @@ describe("applyVoxelCommand", () => { assert.equal(target.tilesets.get("a")?.tileSize, 16); }); + + it("routes a material group command to the group list", () => { + const target = makeTarget(); + const command = { + action: "material-group-defined", + group: { id: "gold", metalness: 1 } + } as const; + + assert.equal(applyVoxelCommand(target, command), true); + assert.equal(applyVoxelCommand(target, command), false); + assert.equal(target.materialGroups.get("gold")?.metalness, 1); + + assert.equal(applyVoxelCommand(target, { + action: "material-group-removed", + groupId: "gold" + }), true); + assert.equal(target.materialGroups.has("gold"), false); + }); }); diff --git a/packages/voxel-renderer/test/commands.spec.ts b/packages/voxel-renderer/test/commands.spec.ts index 2636663c7..c0ed7f38d 100644 --- a/packages/voxel-renderer/test/commands.spec.ts +++ b/packages/voxel-renderer/test/commands.spec.ts @@ -6,10 +6,12 @@ import assert from "node:assert/strict"; import { isVoxelBlockCommand, isVoxelLayerCommand, + isVoxelMaterialGroupCommand, isVoxelTilesetCommand, VOXEL_BLOCK_COMMAND_ACTIONS, VOXEL_COMMAND_ACTIONS, VOXEL_LAYER_COMMAND_ACTIONS, + VOXEL_MATERIAL_GROUP_COMMAND_ACTIONS, VOXEL_TILESET_COMMAND_ACTIONS } from "../src/commands.ts"; @@ -20,7 +22,8 @@ describe("command guards", () => { const matches = [ isVoxelLayerCommand(command), isVoxelBlockCommand(command), - isVoxelTilesetCommand(command) + isVoxelTilesetCommand(command), + isVoxelMaterialGroupCommand(command) ].filter(Boolean); assert.equal(matches.length, 1, action); @@ -32,7 +35,8 @@ describe("command guards", () => { new Set(VOXEL_COMMAND_ACTIONS).size, VOXEL_LAYER_COMMAND_ACTIONS.length + VOXEL_BLOCK_COMMAND_ACTIONS.length + - VOXEL_TILESET_COMMAND_ACTIONS.length + VOXEL_TILESET_COMMAND_ACTIONS.length + + VOXEL_MATERIAL_GROUP_COMMAND_ACTIONS.length ); }); @@ -42,5 +46,6 @@ describe("command guards", () => { assert.equal(isVoxelLayerCommand(command), false); assert.equal(isVoxelBlockCommand(command), false); assert.equal(isVoxelTilesetCommand(command), false); + assert.equal(isVoxelMaterialGroupCommand(command), false); }); }); diff --git a/packages/voxel-renderer/test/document/VoxelDocument.spec.ts b/packages/voxel-renderer/test/document/VoxelDocument.spec.ts index be072f313..31a7ce8de 100644 --- a/packages/voxel-renderer/test/document/VoxelDocument.spec.ts +++ b/packages/voxel-renderer/test/document/VoxelDocument.spec.ts @@ -151,6 +151,54 @@ describe("VoxelDocument.apply", () => { }); }); +describe("VoxelDocument - material groups", () => { + it("emits the normalized group without invalidating chunks", () => { + const document = makeDocument(); + const emitted: VoxelCommand[] = []; + document.on("command", (command) => emitted.push(command)); + const events = trace(document); + + assert.equal(document.defineMaterialGroup({ id: "gold", metalness: 1 }), true); + + assert.deepEqual(events, [ + { event: "command", action: "material-group-defined", origin: "local" } + ]); + assert.deepEqual(emitted, [{ + action: "material-group-defined", + group: { + id: "gold", + roughness: 1, + metalness: 1, + emissive: "#000000", + emissiveIntensity: 1 + } + }]); + }); + + it("rejects an invalid group without emitting", () => { + const document = makeDocument(); + const events = trace(document); + + assert.equal(document.defineMaterialGroup({ id: "gold", metalness: 3 }), false); + assert.equal(document.removeMaterialGroup("gold"), false); + assert.deepEqual(events, []); + }); + + it("saves and restores the groups", () => { + const document = makeDocument(); + document.defineMaterialGroup({ id: "gold", roughness: 0.3, metalness: 1 }); + const saved = document.save(); + + const restored = makeDocument(); + restored.load(saved); + + assert.deepEqual( + restored.materialGroups.toJSON(), + document.materialGroups.toJSON() + ); + }); +}); + describe("VoxelDocument.registerTileset", () => { it("invalidates without emitting a command", () => { const document = makeDocument(); diff --git a/packages/voxel-renderer/test/materials/MaterialGroup.spec.ts b/packages/voxel-renderer/test/materials/MaterialGroup.spec.ts new file mode 100644 index 000000000..2116aa1e8 --- /dev/null +++ b/packages/voxel-renderer/test/materials/MaterialGroup.spec.ts @@ -0,0 +1,130 @@ +// Import Node.js Dependencies +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +// Import Third-party Dependencies +import * as THREE from "three"; + +// Import Internal Dependencies +import { + MaterialGroup, + MaterialGroupList +} from "../../src/materials/index.ts"; + +describe("MaterialGroup", () => { + it("fills the finish defaults and freezes itself", () => { + const group = new MaterialGroup({ id: "gold" }); + + assert.deepEqual(group.toJSON(), { + id: "gold", + roughness: 1, + metalness: 0, + emissive: "#000000", + emissiveIntensity: 1 + }); + assert.ok(Object.isFrozen(group)); + }); + + it("normalizes the emissive colour to lower case", () => { + assert.equal( + new MaterialGroup({ id: "lava", emissive: "#FF8800" }).emissive, + "#ff8800" + ); + }); + + it("rejects out of range values", () => { + const invalid = [ + { id: "" }, + { id: "a", roughness: 1.5 }, + { id: "a", metalness: -0.1 }, + { id: "a", metalness: NaN }, + { id: "a", emissive: "red" }, + { id: "a", emissiveIntensity: -1 }, + { id: "a", emissiveIntensity: Infinity } + ]; + for (const json of invalid) { + assert.throws(() => new MaterialGroup(json), RangeError); + assert.equal(MaterialGroup.parse(json), null); + } + assert.equal(MaterialGroup.parse("gold"), null); + assert.equal(MaterialGroup.parse(null), null); + }); + + it("derives a copy with a changed finish", () => { + const group = new MaterialGroup({ id: "gold", roughness: 0.3 }); + const metal = group.with({ metalness: 1 }); + + assert.equal(metal.id, "gold"); + assert.equal(metal.roughness, 0.3); + assert.equal(metal.metalness, 1); + assert.equal(group.metalness, 0); + assert.ok(!group.equals(metal)); + assert.ok(metal.equals(new MaterialGroup(metal.toJSON()))); + }); + + it("applies every field to a standard material", () => { + const material = new THREE.MeshStandardMaterial(); + new MaterialGroup({ + id: "gold", + roughness: 0.25, + metalness: 1, + emissive: "#ff0000", + emissiveIntensity: 2 + }).applyTo(material); + + assert.equal(material.roughness, 0.25); + assert.equal(material.metalness, 1); + assert.equal(material.emissive.getHexString(), "ff0000"); + assert.equal(material.emissiveIntensity, 2); + }); + + it("applies the emissive fields to a lambert material", () => { + const material = new THREE.MeshLambertMaterial(); + new MaterialGroup({ + id: "lava", + emissive: "#00ff00", + emissiveIntensity: 0.5 + }).applyTo(material); + + assert.equal(material.emissive.getHexString(), "00ff00"); + assert.equal(material.emissiveIntensity, 0.5); + }); +}); + +describe("MaterialGroupList", () => { + it("reports whether a definition changed the list", () => { + const list = new MaterialGroupList(); + const version = list.version; + + assert.equal(list.define({ id: "gold", metalness: 1 }), true); + assert.equal(list.define({ id: "gold", metalness: 1 }), false); + assert.equal(list.define({ id: "gold", metalness: 2 }), false); + assert.equal(list.version, version + 1); + assert.equal(list.define(new MaterialGroup({ id: "gold" })), true); + assert.equal(list.get("gold")?.metalness, 0); + }); + + it("removes a group once", () => { + const list = new MaterialGroupList([{ id: "gold" }]); + + assert.equal(list.remove("gold"), true); + assert.equal(list.remove("gold"), false); + assert.equal(list.size, 0); + }); + + it("skips invalid and duplicate entries on replace", () => { + const list = new MaterialGroupList([ + { id: "gold", metalness: 1 }, + { id: "gold", metalness: 0.5 }, + { id: "bad", roughness: 4 }, + 42 + ]); + + assert.deepEqual([...list.ids()], ["gold"]); + assert.equal(list.get("gold")?.metalness, 1); + assert.deepEqual(list.toJSON(), [new MaterialGroup({ + id: "gold", + metalness: 1 + }).toJSON()]); + }); +}); diff --git a/packages/voxel-renderer/test/render/ChunkMaterialCache.spec.ts b/packages/voxel-renderer/test/render/ChunkMaterialCache.spec.ts index 994ce51a9..d934fd809 100644 --- a/packages/voxel-renderer/test/render/ChunkMaterialCache.spec.ts +++ b/packages/voxel-renderer/test/render/ChunkMaterialCache.spec.ts @@ -7,6 +7,8 @@ import * as THREE from "three"; // Import Internal Dependencies import { ChunkMaterialCache } from "../../src/render/index.ts"; +import { BlockSurface } from "../../src/blocks/index.ts"; +import { MaterialGroupList } from "../../src/materials/index.ts"; import { AtlasAverages, TilesetManager @@ -107,6 +109,84 @@ describe("ChunkMaterialCache — resolve", () => { }); }); +describe("ChunkMaterialCache — material groups", () => { + const kGold = new BlockSurface({ materialGroup: "gold" }); + + it("gives a defined group a standard material carrying its finish", () => { + const materialGroups = new MaterialGroupList([ + { id: "gold", roughness: 0.2, metalness: 1 } + ]); + const cache = makeCache({ materialGroups }); + + const plain = cache.resolve("atlas", 1); + const gold = cache.resolve("atlas", 1, kGold); + + assert.ok(plain instanceof THREE.MeshLambertMaterial); + assert.ok(gold instanceof THREE.MeshStandardMaterial); + assert.equal(gold.roughness, 0.2); + assert.equal(gold.metalness, 1); + }); + + it("keeps the view material for a group the document does not define", () => { + const cache = makeCache({ materialGroups: new MaterialGroupList() }); + + assert.ok( + cache.resolve("atlas", 1, kGold) instanceof THREE.MeshLambertMaterial + ); + }); + + it("runs the customizer after the finish", () => { + const materialGroups = new MaterialGroupList([ + { id: "gold", metalness: 1 } + ]); + const cache = makeCache({ + materialGroups, + customizer: (material) => { + if (material instanceof THREE.MeshStandardMaterial) { + material.metalness = 0.5; + } + } + }); + + const gold = cache.resolve("atlas", 1, kGold); + assert.ok(gold instanceof THREE.MeshStandardMaterial); + assert.equal(gold.metalness, 0.5); + }); + + it("updates a changed finish in place", () => { + const materialGroups = new MaterialGroupList([{ id: "gold" }]); + const cache = makeCache({ materialGroups }); + const gold = cache.resolve("atlas", 1, kGold); + + materialGroups.define({ id: "gold", metalness: 1 }); + + assert.equal(cache.refreshGroup("gold"), false); + assert.equal(cache.resolve("atlas", 1, kGold), gold); + assert.ok(gold instanceof THREE.MeshStandardMaterial); + assert.equal(gold.metalness, 1); + }); + + it("evicts the group materials when the group appears or goes", () => { + const materialGroups = new MaterialGroupList(); + const cache = makeCache({ materialGroups }); + const plain = cache.resolve("atlas", 1); + const before = cache.resolve("atlas", 1, kGold); + + materialGroups.define({ id: "gold" }); + assert.equal(cache.refreshGroup("gold"), true); + const after = cache.resolve("atlas", 1, kGold); + assert.notEqual(after, before); + assert.ok(after instanceof THREE.MeshStandardMaterial); + assert.equal(cache.resolve("atlas", 1), plain); + + materialGroups.remove("gold"); + assert.equal(cache.refreshGroup("gold"), true); + assert.ok( + cache.resolve("atlas", 1, kGold) instanceof THREE.MeshLambertMaterial + ); + }); +}); + describe("ChunkMaterialCache — invalidate", () => { it("rebuilds the materials of one tileset", () => { const cache = makeCache(); diff --git a/packages/voxel-renderer/test/serialization/world.spec.ts b/packages/voxel-renderer/test/serialization/world.spec.ts index 348695980..4cd96b277 100644 --- a/packages/voxel-renderer/test/serialization/world.spec.ts +++ b/packages/voxel-renderer/test/serialization/world.spec.ts @@ -13,6 +13,10 @@ import { BlockRegistry, resolveBlockDefinition } from "../../src/blocks/index.ts import type { TilesetDefinition } from "../../src/tileset/index.ts"; import { makeVoxelEntry } from "../helpers/voxelEntry.ts"; import { makeBlockDef } from "../helpers/blocks.ts"; +import { + MaterialGroup, + MaterialGroupList +} from "../../src/materials/index.ts"; // CONSTANTS const kAtlas: TilesetDefinition = { @@ -107,6 +111,21 @@ describe("serializeVoxelWorld", () => { ); }); + it("embeds material groups only when there are some", () => { + const world = new VoxelWorld(16); + + assert.equal( + serializeVoxelWorld(world, { materialGroups: [] }).materialGroups, + undefined + ); + assert.deepEqual( + serializeVoxelWorld(world, { + materialGroups: [new MaterialGroup({ id: "gold", metalness: 1 })] + }).materialGroups, + [new MaterialGroup({ id: "gold", metalness: 1 }).toJSON()] + ); + }); + it("serializes a single voxel correctly", () => { const world = new VoxelWorld(16); const layer = world.addLayer("Ground"); @@ -135,6 +154,27 @@ describe("serializeVoxelWorld", () => { }); describe("deserializeVoxelWorld", () => { + it("replaces the material groups, clearing them when none are saved", () => { + const materialGroups = new MaterialGroupList([{ id: "stale" }]); + const document: VoxelWorldJSON = { + version: 1, + chunkSize: 16, + tilesets: [], + layers: [], + materialGroups: [{ id: "gold", metalness: 1 }] + }; + + deserializeVoxelWorld(document, new VoxelWorld(16), { materialGroups }); + assert.deepEqual([...materialGroups.ids()], ["gold"]); + + deserializeVoxelWorld( + { ...document, materialGroups: undefined }, + new VoxelWorld(16), + { materialGroups } + ); + assert.equal(materialGroups.size, 0); + }); + it("throws when version is not 1", () => { const world = new VoxelWorld(16);