diff --git a/.changeset/wacky-squids-smoke.md b/.changeset/wacky-squids-smoke.md new file mode 100644 index 0000000..5c0c808 --- /dev/null +++ b/.changeset/wacky-squids-smoke.md @@ -0,0 +1,5 @@ +--- +"@jolly-pixel/voxel.renderer": major +--- + +Introduce a new class to preload Tileset textures and remove async APIs from VoxelRenderer class diff --git a/packages/editors/voxel-map/src/scene/editor.ts b/packages/editors/voxel-map/src/scene/editor.ts index 1ed305a..9f5e591 100644 --- a/packages/editors/voxel-map/src/scene/editor.ts +++ b/packages/editors/voxel-map/src/scene/editor.ts @@ -5,6 +5,7 @@ import { } from "@jolly-pixel/engine"; import { VoxelRenderer, + TilesetLoader, type TilesetDefinition, type VoxelWorldJSON } from "@jolly-pixel/voxel.renderer"; @@ -32,7 +33,7 @@ export interface EditorSceneOptions { } export class EditorScene extends Systems.Scene { - #texture: THREE.Texture; + #tilesetLoader!: TilesetLoader; #defaultLayerName: string; #defaultTileset: TilesetDefinition; #pendingLoad: VoxelWorldJSON | null = null; @@ -47,15 +48,14 @@ export class EditorScene extends Systems.Scene { ): Promise { const { assetManager } = context; - const textureLoader = new THREE.TextureLoader( - assetManager.context.manager - ); - const texture = await textureLoader.loadAsync( - this.#defaultTileset.src - ); - - this.#texture = texture; + this.#tilesetLoader = new TilesetLoader({ manager: assetManager.context.manager }); this.#pendingLoad = LocalStoragePersistence.load(); + + // Pre-load world tilesets first (if restoring), then default (idempotent if already loaded). + if (this.#pendingLoad !== null) { + await this.#tilesetLoader.fromWorld(this.#pendingLoad); + } + await this.#tilesetLoader.fromTileDefinition(this.#defaultTileset); } constructor( @@ -104,13 +104,12 @@ export class EditorScene extends Systems.Scene { material.transparent = true; }, alphaTest: 0, - onLayerUpdated: (evt) => this.editorState.dispatchLayerUpdated(evt) + onLayerUpdated: (evt) => this.editorState.dispatchLayerUpdated(evt), + tilesetLoader: this.#tilesetLoader }); this.vr = vr; this.editorState.setSelectedLayer(this.#defaultLayerName); - vr.loadTilesetSync(this.#defaultTileset, this.#texture); - // Skip default blocks when restoring a saved world — vr.load() will // register the persisted definitions (which carry user edits). // Registering defaults first would cause load() to skip saved blocks @@ -126,15 +125,12 @@ export class EditorScene extends Systems.Scene { persistence.start(); if (this.#pendingLoad !== null) { - vr.load(this.#pendingLoad) - .then(() => { - this.editorState.dispatchBlockRegistryChanged(); - const layers = vr.world.getLayers(); - if (layers.length > 0) { - this.editorState.setSelectedLayer(layers[0].name); - } - }) - .catch(console.error); + vr.load(this.#pendingLoad); + this.editorState.dispatchBlockRegistryChanged(); + const layers = vr.world.getLayers(); + if (layers.length > 0) { + this.editorState.setSelectedLayer(layers[0].name); + } this.#pendingLoad = null; } diff --git a/packages/voxel-renderer/docs/Tileset.md b/packages/voxel-renderer/docs/Tileset.md index da948c9..24c807b 100644 --- a/packages/voxel-renderer/docs/Tileset.md +++ b/packages/voxel-renderer/docs/Tileset.md @@ -4,15 +4,17 @@ Tileset loading, UV computation, and pixel-art texture management. `NearestFilter` and `SRGBColorSpace` are applied automatically to preserve pixel-art crispness. ```ts -const vr = new VoxelRenderer({}); - -await vr.loadTileset({ +// Pre-load tilesets using TilesetLoader, then pass the loader to VoxelRenderer. +const loader = new TilesetLoader(); +await loader.fromTileDefinition({ id: "default", src: "assets/tileset.png", tileSize: 16 // cols and rows are optional — derived from the image at load time }); +const vr = actor.addComponentAndGet(VoxelRenderer, { tilesetLoader: loader }); + // Tile at column 2, row 0 — uses the default tileset const tileRef: TileRef = { col: 2, @@ -135,3 +137,70 @@ interface TilesetDefaultBlockOptions { #### `dispose(): void` Disposes all textures and materials and clears the registry. + +## TilesetLoader + +Pre-loading utility that fetches tileset textures asynchronously before a `VoxelRenderer` +is constructed. Pass the populated loader via `VoxelRendererOptions.tilesetLoader` so all +textures register synchronously during construction — no async code is needed inside +lifecycle methods (`awake`, `start`, `update`). + +### TilesetLoaderOptions + +```ts +interface TilesetLoaderOptions { + /** + * Optional THREE.LoadingManager to track load progress. + */ + manager?: THREE.LoadingManager; + /** + * Custom loader implementation. For testing only. + */ + loader?: { loadAsync(url: string): Promise> }; +} +``` + +### Properties + +```ts +readonly tilesets: Map; +``` + +Map from tileset ID to `{ def: TilesetDefinition, texture: THREE.Texture }`. +Populated by `fromTileDefinition` and `fromWorld`. + +### Methods + +#### `fromTileDefinition(def: TilesetDefinition): Promise` + +Loads the atlas image at `def.src` and stores the result in `tilesets`. Idempotent — +calling with the same `def.id` a second time is a no-op (the loader is not invoked again). + +#### `fromWorld(data: VoxelWorldJSON): Promise` + +Iterates `data.tilesets` and calls `fromTileDefinition` for each. Useful when restoring a +saved world before constructing `VoxelRenderer`. + +### Usage examples + +**Single tileset:** + +```ts +const loader = new TilesetLoader(); +await loader.fromTileDefinition({ id: "default", src: "tileset.png", tileSize: 16 }); + +const vr = actor.addComponentAndGet(VoxelRenderer, { tilesetLoader: loader }); +``` + +**Restoring a saved world (multi-tileset):** + +```ts +const snapshot = JSON.parse(localStorage.getItem("world")!); + +const loader = new TilesetLoader({ manager: assetManager.context.manager }); +await loader.fromWorld(snapshot); // pre-load every tileset +await loader.fromTileDefinition(defaultTilesetDef); // idempotent if already loaded + +const vr = actor.addComponentAndGet(VoxelRenderer, { tilesetLoader: loader }); +vr.load(snapshot); // fully synchronous +``` diff --git a/packages/voxel-renderer/docs/VoxelRenderer.md b/packages/voxel-renderer/docs/VoxelRenderer.md index 597e925..67dc4a3 100644 --- a/packages/voxel-renderer/docs/VoxelRenderer.md +++ b/packages/voxel-renderer/docs/VoxelRenderer.md @@ -4,7 +4,16 @@ Each chunk is rebuilt only when its content changes, keeping GPU work proportional to edits rather than world size. ```ts +// Pre-load tilesets before constructing VoxelRenderer (no async in lifecycle). +const loader = new TilesetLoader(); +await loader.fromTileDefinition({ + id: "default", + src: "tileset.png", + tileSize: 16 +}); + const vr = actor.addComponentAndGet(VoxelRenderer, { + tilesetLoader: loader, layers: ["Ground"], blocks: [ { @@ -21,12 +30,6 @@ const vr = actor.addComponentAndGet(VoxelRenderer, { ] }); -await vr.loadTileset({ - id: "default", - src: "tileset.png", - tileSize: 16 -}); - vr.setVoxel("Ground", { position: { x: 0, y: 0, z: 0 }, blockId: 1 @@ -136,6 +139,13 @@ interface VoxelRendererOptions { * Useful for synchronizing external systems with changes to the voxel world. */ onLayerUpdated?: VoxelLayerHookListener; + + /** + * Optional pre-loaded tileset collection. All tilesets in the loader are + * registered synchronously during construction. Use `TilesetLoader.fromTileDefinition()` + * or `TilesetLoader.fromWorld()` to populate it before constructing `VoxelRenderer`. + */ + tilesetLoader?: TilesetLoader; } ``` @@ -285,23 +295,22 @@ getVoxelNeighbour(layerName: string, position: VoxelCoord, face: Face): VoxelEnt Returns the voxel immediately adjacent to `position` in the given face direction. Composited (first overload) or restricted to a specific layer (second overload). -#### `loadTileset(def: TilesetDefinition): Promise` - -Loads a tileset image via the actor's loading manager. The first loaded tileset becomes -the default for `TileRef` values with no explicit `tilesetId`. - -#### `loadTilesetSync(def: TilesetDefinition, texture: THREE.Texture< HTMLImageElement >): void` +#### `loadTileset(def: TilesetDefinition, texture: THREE.Texture): void` -Same as `loadTileset` but synchronous and you have to provide the texture. +Registers an already-loaded texture for a tileset definition. The first registered tileset +becomes the default for `TileRef` values with no explicit `tilesetId`. +Prefer passing a `TilesetLoader` via `VoxelRendererOptions.tilesetLoader` for pre-loading; +use this method only when adding a tileset after construction. #### `save(): VoxelWorldJSON` Serialises the full world state (layers, voxels, tileset metadata) to a plain JSON object. -#### `load(data: VoxelWorldJSON): Promise` +#### `load(data: VoxelWorldJSON): void` -Clears the current world, restores state from a JSON snapshot, and reloads any -referenced tilesets that are not already loaded. +Clears the current world and restores state from a JSON snapshot. All tilesets referenced +by the snapshot must have been pre-loaded via `TilesetLoader` before this call — if a +tileset is missing, an error is thrown. Already-registered tilesets are skipped. #### `markAllChunksDirty(source?: string): void` diff --git a/packages/voxel-renderer/src/VoxelRenderer.ts b/packages/voxel-renderer/src/VoxelRenderer.ts index bb61892..bba6867 100644 --- a/packages/voxel-renderer/src/VoxelRenderer.ts +++ b/packages/voxel-renderer/src/VoxelRenderer.ts @@ -37,6 +37,7 @@ import { TilesetManager, type TilesetDefinition } from "./tileset/TilesetManager.ts"; +import type { TilesetLoader } from "./tileset/TilesetLoader.ts"; import { VoxelWorld } from "./world/VoxelWorld.ts"; import { VoxelLayer, @@ -132,17 +133,26 @@ export interface VoxelRendererOptions { * Useful for synchronizing external systems with changes to the voxel world. */ onLayerUpdated?: VoxelLayerHookListener; + + /** + * Optional pre-loaded tileset collection. All tilesets in the loader are + * registered synchronously during construction so no async is needed inside + * lifecycle methods. Use `TilesetLoader.fromTileDefinition()` or + * `TilesetLoader.fromWorld()` before constructing `VoxelRenderer`. + */ + tilesetLoader?: TilesetLoader; } /** * ActorComponent that renders a layered voxel world as chunked THREE.js meshes. * * Usage: - * const vr = actor.addComponentAndGet(VoxelRenderer, { chunkSize: 16 }); - * await vr.loadTileset({ id: "default", src: "tileset.png", tileSize: 16, cols: 16, rows: 16 }); - * vr.blockRegistry.register({ id: 1, name: "Grass", shapeId: "fullCube", ... }); + * const loader = new TilesetLoader(); + * await loader.fromTileDefinition({ id: "default", src: "tileset.png", tileSize: 16 }); + * const vr = actor.addComponentAndGet(VoxelRenderer, { chunkSize: 16, tilesetLoader: loader }); + * vr.blockRegistry.register({ id: 1, name: "Grass", shapeId: "cube", ... }); * const layer = vr.addLayer("Ground"); - * vr.setVoxel(layer.id, 0, 0, 0, 1); + * vr.setVoxel(layer.id, { position: { x: 0, y: 0, z: 0 }, blockId: 1 }); */ export class VoxelRenderer extends ActorComponent { readonly world: VoxelWorld; @@ -172,6 +182,7 @@ export class VoxelRenderer extends ActorComponent { #materialType: "lambert" | "standard"; #alphaTest: number; + #tilesetLoader: TilesetLoader | null; #logger: Systems.Logger; #onLayerUpdated?: VoxelLayerHookListener; @@ -200,7 +211,8 @@ export class VoxelRenderer extends ActorComponent { shapes = [], alphaTest = 0.1, logger = actor.world.logger, - onLayerUpdated + onLayerUpdated, + tilesetLoader } = options; this.#materialType = material; @@ -224,6 +236,12 @@ export class VoxelRenderer extends ActorComponent { ); this.tilesetManager = new TilesetManager(); + this.#tilesetLoader = tilesetLoader ?? null; + if (tilesetLoader) { + for (const entry of tilesetLoader.tilesets.values()) { + this.tilesetManager.registerTexture(entry.def, entry.texture); + } + } this.serializer = new VoxelSerializer(); this.#meshBuilder = new VoxelMeshBuilder({ @@ -742,35 +760,12 @@ export class VoxelRenderer extends ActorComponent { return result; } - loadTilesetSync( + loadTileset( def: TilesetDefinition, texture: THREE.Texture ): void { this.tilesetManager.registerTexture(def, texture); - this.#logger.debug(`Loaded tileset '${def.id}' from '${def.src}' (synchronous)`); - - // Invalidate the cached material for this tileset so it is recreated - // with the new texture. - const existingMaterial = this.#materials.get(def.id); - existingMaterial?.dispose(); - this.#materials.delete(def.id); - - // Force all chunks to rebuild geometry (UV offsets may have changed). - this.markAllChunksDirty("loadTilesetSync"); - } - - async loadTileset( - def: TilesetDefinition - ): Promise { - const textureLoader = new THREE.TextureLoader( - this.actor.world.loadingManager - ); - const texture = await textureLoader.loadAsync( - def.src - ); - - this.tilesetManager.registerTexture(def, texture); - this.#logger.debug(`Loaded tileset '${def.id}' from '${def.src}' (asynchronous)`); + this.#logger.debug(`Loaded tileset '${def.id}' from '${def.src}'`); // Invalidate the cached material for this tileset so it is recreated // with the new texture. @@ -795,9 +790,9 @@ export class VoxelRenderer extends ActorComponent { }; } - async load( + load( data: VoxelWorldJSON - ): Promise { + ): void { // Clear existing meshes before replacing world data. for (const mesh of this.#chunkMeshes.values()) { this.actor.object3D.remove(mesh); @@ -818,15 +813,20 @@ export class VoxelRenderer extends ActorComponent { this.serializer.deserialize(data, this.world); - const textureLoader = new THREE.TextureLoader( - this.actor.world.loadingManager - ); - - // Reload any tilesets listed in the snapshot that are not already loaded. + // Register any tilesets in the snapshot that are not already loaded. + // Tilesets must have been pre-loaded via TilesetLoader before this call. for (const tilesetDef of data.tilesets) { - if (!this.tilesetManager.getTexture(tilesetDef.id)) { - await this.tilesetManager.loadTileset(tilesetDef, textureLoader); + if (this.tilesetManager.getTexture(tilesetDef.id)) { + continue; + } + const entry = this.#tilesetLoader?.tilesets.get(tilesetDef.id); + if (!entry) { + throw new Error( + `VoxelRenderer.load(): tileset '${tilesetDef.id}' is not pre-loaded. ` + + "Call TilesetLoader.fromWorld() before constructing VoxelRenderer." + ); } + this.tilesetManager.registerTexture(entry.def, entry.texture); } // Dispose cached materials so they are recreated with the correct textures. diff --git a/packages/voxel-renderer/src/index.ts b/packages/voxel-renderer/src/index.ts index c96f28e..b6b751e 100644 --- a/packages/voxel-renderer/src/index.ts +++ b/packages/voxel-renderer/src/index.ts @@ -61,6 +61,11 @@ export { type TileRef, type TilesetDefinition } from "./tileset/TilesetManager.ts"; +export { + TilesetLoader, + type TilesetLoaderOptions, + type TilesetEntry +} from "./tileset/TilesetLoader.ts"; // World export { diff --git a/packages/voxel-renderer/src/network/VoxelSyncClient.ts b/packages/voxel-renderer/src/network/VoxelSyncClient.ts index abb8e76..3ef21f3 100644 --- a/packages/voxel-renderer/src/network/VoxelSyncClient.ts +++ b/packages/voxel-renderer/src/network/VoxelSyncClient.ts @@ -53,7 +53,7 @@ export class VoxelSyncClient { // Load world snapshots received from the server. this.#transport.onSnapshot = (snapshot: VoxelWorldJSON) => { - void this.#renderer.load(snapshot); + this.#renderer.load(snapshot); }; } diff --git a/packages/voxel-renderer/src/tileset/TilesetLoader.ts b/packages/voxel-renderer/src/tileset/TilesetLoader.ts new file mode 100644 index 0000000..c4b3e7c --- /dev/null +++ b/packages/voxel-renderer/src/tileset/TilesetLoader.ts @@ -0,0 +1,67 @@ +// Import Third-party Dependencies +import * as THREE from "three"; + +// Import Internal Dependencies +import type { TilesetDefinition } from "./TilesetManager.ts"; +import type { VoxelWorldJSON } from "../serialization/VoxelSerializer.ts"; + +export interface TilesetLoaderOptions { + manager?: THREE.LoadingManager; + /** + * Custom loader implementation. + * @internal For testing only. + */ + loader?: { + loadAsync(url: string): Promise>; + }; +} + +export interface TilesetEntry { + def: TilesetDefinition; + texture: THREE.Texture; +} + +/** + * Pre-loads tileset textures before a `VoxelRenderer` is constructed. + * Pass the loader instance via `VoxelRendererOptions.tilesetLoader` so all + * textures register synchronously during construction — no async in lifecycle. + * + * @example + * ```ts + * const loader = new TilesetLoader(); + * await loader.fromTileDefinition({ id: "default", src: "tileset.png", tileSize: 16 }); + * + * const vr = actor.addComponentAndGet(VoxelRenderer, { tilesetLoader: loader }); + * ``` + */ +export class TilesetLoader { + #loader: { + loadAsync(url: string): Promise>; + }; + readonly tilesets = new Map(); + + constructor( + options: TilesetLoaderOptions = {} + ) { + const { manager, loader } = options; + this.#loader = loader ?? new THREE.TextureLoader(manager); + } + + async fromTileDefinition( + def: TilesetDefinition + ): Promise { + if (this.tilesets.has(def.id)) { + return; + } + const texture = await this.#loader.loadAsync(def.src); + this.tilesets.set(def.id, { def, texture }); + } + + async fromWorld( + data: VoxelWorldJSON + ): Promise { + for (const tilesetDef of data.tilesets) { + await this.fromTileDefinition(tilesetDef); + } + } +} diff --git a/packages/voxel-renderer/test/network/VoxelSyncClient.spec.ts b/packages/voxel-renderer/test/network/VoxelSyncClient.spec.ts index 643aa40..6aef891 100644 --- a/packages/voxel-renderer/test/network/VoxelSyncClient.spec.ts +++ b/packages/voxel-renderer/test/network/VoxelSyncClient.spec.ts @@ -17,7 +17,7 @@ import type { VoxelRenderer } from "../../src/VoxelRenderer.ts"; interface MockRenderer { onLayerUpdated: VoxelLayerHookListener | undefined; applyRemoteCommand(cmd: VoxelLayerHookEvent): void; - load(data: VoxelWorldJSON): Promise; + load(data: VoxelWorldJSON): void; // Test helper: simulate a local mutation firing the hook triggerLocal(event: VoxelLayerHookEvent): void; appliedCommands: VoxelLayerHookEvent[]; @@ -41,8 +41,6 @@ function createMockRenderer(): MockRenderer { }, load(data) { loadedSnapshots.push(data); - - return Promise.resolve(); }, triggerLocal(event) { listener?.(event); diff --git a/packages/voxel-renderer/test/tileset/TilesetLoader.spec.ts b/packages/voxel-renderer/test/tileset/TilesetLoader.spec.ts new file mode 100644 index 0000000..8da819a --- /dev/null +++ b/packages/voxel-renderer/test/tileset/TilesetLoader.spec.ts @@ -0,0 +1,149 @@ +// Import Node.js Dependencies +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +// Import Internal Dependencies +import { + TilesetLoader, + type TilesetEntry +} from "../../src/tileset/TilesetLoader.ts"; +import type { TilesetDefinition } from "../../src/tileset/TilesetManager.ts"; +import type { VoxelWorldJSON } from "../../src/serialization/VoxelSerializer.ts"; + +// CONSTANTS +const kDefaultTilesetDef: TilesetDefinition = { + id: "default", + src: "/assets/tileset.png", + tileSize: 16 +}; + +const kSecondTilesetDef: TilesetDefinition = { + id: "decor", + src: "/assets/decor.png", + tileSize: 16 +}; + +/** + * Structural mock for the texture loader — no THREE import needed. + * Returns a minimal object that satisfies the `loadAsync` contract. + */ +function makeMockLoader( + loadCalls: string[] = [] +): { loadAsync(url: string): Promise; } { + return { + async loadAsync(url: string) { + loadCalls.push(url); + + return { + magFilter: 0, + minFilter: 0, + colorSpace: "", + generateMipmaps: true, + image: { width: 256, height: 256 } + }; + } + }; +} + +function makeSnapshot(tilesetIds: string[]): VoxelWorldJSON { + return { + version: 1, + chunkSize: 16, + tilesets: tilesetIds.map((id) => { + return { + id, + src: `/assets/${id}.png`, + tileSize: 16 + }; + }), + layers: [] + }; +} + +describe("TilesetLoader — initial state", () => { + it("starts with an empty tilesets map", () => { + const loader = new TilesetLoader({ + loader: makeMockLoader() + }); + + assert.equal(loader.tilesets.size, 0); + }); +}); + +describe("TilesetLoader.fromTileDefinition", () => { + it("loads and stores { def, texture } under the tileset ID", async() => { + const loader = new TilesetLoader({ + loader: makeMockLoader() + }); + + await loader.fromTileDefinition(kDefaultTilesetDef); + + assert.equal(loader.tilesets.size, 1); + const entry = loader.tilesets.get("default") as TilesetEntry; + assert.ok(entry !== undefined); + assert.equal(entry.def.id, "default"); + assert.ok(entry.texture !== undefined); + }); + + it("is idempotent — mock loader is called exactly once for duplicate IDs", async() => { + const calls: string[] = []; + const loader = new TilesetLoader({ + loader: makeMockLoader(calls) + }); + + await loader.fromTileDefinition(kDefaultTilesetDef); + await loader.fromTileDefinition(kDefaultTilesetDef); + + assert.equal(calls.length, 1); + assert.equal(loader.tilesets.size, 1); + }); + + it("adds distinct entries for different IDs", async() => { + const loader = new TilesetLoader({ + loader: makeMockLoader() + }); + + await loader.fromTileDefinition(kDefaultTilesetDef); + await loader.fromTileDefinition(kSecondTilesetDef); + + assert.equal(loader.tilesets.size, 2); + assert.ok(loader.tilesets.has("default")); + assert.ok(loader.tilesets.has("decor")); + }); +}); + +describe("TilesetLoader.fromWorld", () => { + it("loads all tilesets from a snapshot", async() => { + const loader = new TilesetLoader({ + loader: makeMockLoader() + }); + + await loader.fromWorld(makeSnapshot(["terrain", "decor"])); + + assert.equal(loader.tilesets.size, 2); + assert.ok(loader.tilesets.has("terrain")); + assert.ok(loader.tilesets.has("decor")); + }); + + it("resolves without error when the snapshot has an empty tilesets array", async() => { + const loader = new TilesetLoader({ + loader: makeMockLoader() + }); + + await loader.fromWorld(makeSnapshot([])); + + assert.equal(loader.tilesets.size, 0); + }); + + it("is idempotent across multiple calls — loads each tileset only once", async() => { + const calls: string[] = []; + const loader = new TilesetLoader({ + loader: makeMockLoader(calls) + }); + + await loader.fromWorld(makeSnapshot(["terrain"])); + await loader.fromWorld(makeSnapshot(["terrain"])); + + assert.equal(calls.length, 1); + }); +});