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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/voxel-document-any-chunk-size.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/voxel-material-group-finish.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions packages/assets/voxel-map/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
9 changes: 7 additions & 2 deletions packages/assets/voxel-map/src/asset/VoxelMapState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
applyVoxelCommand,
BlockRegistry,
deserializeVoxelWorld,
MaterialGroupList,
parseVoxelDocument,
serializeVoxelWorld,
TilesetList,
Expand All @@ -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
Expand All @@ -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
});
}

Expand All @@ -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
});
}

Expand Down Expand Up @@ -67,5 +71,6 @@ export class VoxelMapState implements VoxelCommandTarget {
this.world.clear();
this.blocks.clear();
this.tilesets.clear();
this.materialGroups.clear();
}
}
7 changes: 4 additions & 3 deletions packages/assets/voxel-map/src/asset/voxelMapAssetKind.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
SnapshotPolicy
} from "@jolly-pixel/asset-server/kinds";
import {
DEFAULT_CHUNK_SIZE,
decodeVoxelDocument,
encodeVoxelDocument
} from "@jolly-pixel/voxel.renderer";
Expand All @@ -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.
*/
Expand All @@ -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;
Expand All @@ -50,7 +51,7 @@ export function voxelMapAssetKind(
options: VoxelMapAssetKindOptions = {}
): AssetKindHandler<VoxelMapState, VoxelNetworkCommand> {
const {
chunkSize = kDefaultChunkSize,
chunkSize = DEFAULT_CHUNK_SIZE,
snapshot = kDefaultSnapshot,
conflictResolver
} = options;
Expand Down
37 changes: 37 additions & 0 deletions packages/assets/voxel-map/src/network/VoxelCommand.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
MAX_TILE_SIZE,
type VoxelBlockCommandAction,
type VoxelLayerCommandAction,
type VoxelMaterialGroupCommandAction,
type VoxelTilesetCommandAction
} from "@jolly-pixel/voxel.renderer";

Expand Down Expand Up @@ -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<string, JSONSchema>
> = {
"material-group-defined": {
group: kMaterialGroupSchema
},
"material-group-removed": {
groupId: { type: "string", minLength: 1 }
}
};

export const voxelWorldSchema: JSONSchema = {
type: "object",
properties: {
Expand All @@ -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" }
},
Expand Down Expand Up @@ -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
})
Expand Down
4 changes: 4 additions & 0 deletions packages/assets/voxel-map/src/network/VoxelCommandArbiter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
74 changes: 62 additions & 12 deletions packages/assets/voxel-map/test/asset/voxelMapAssetKind.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -307,19 +308,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() => {
Expand Down Expand Up @@ -357,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());
});
Expand Down Expand Up @@ -568,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<typeof voxelMapAssetKind>,
Expand Down
28 changes: 28 additions & 0 deletions packages/assets/voxel-map/test/network/VoxelCommand.schema.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
21 changes: 21 additions & 0 deletions packages/assets/voxel-map/test/network/VoxelCommandArbiter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
2 changes: 1 addition & 1 deletion packages/editors/host/docs/mountStandalone.md
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
4 changes: 4 additions & 0 deletions packages/editors/voxel-map/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 0 additions & 1 deletion packages/editors/voxel-map/src/boot/VoxelMapEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ const kPerformanceStorageKey = "voxel-map:performance-hud";
const kPerformancePaneKey = "performance";
const kPerformanceToggleKey = "F3";
const kMapKind = voxelMapDocumentKind({
chunkSize: 16,
history: {
enabled: true
}
Expand Down
Loading
Loading