diff --git a/AGENTS.md b/AGENTS.md index 7b1416f9..61fbde28 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -90,7 +90,7 @@ The private Yarn workspace root contains the public library and its consumers. U **Entities** (`src/entity/`): `Entities.getEntity` loads a versioned `entity-models` file. By default it selects `main` plus the dataset `passes` that have no `when` label or whose label is in `options.when` (`getPassList` lists them); `layer`/`layers` select exactly the named layers in order, without passes. `textures` overrides are keyed like `EntityModel.layers`, where a further draw of the same geometry is `#` from 2. `getLayerList` lists available names. Each draw uses the material for its dataset `render` mode (`Materials.createEntityCanvasMaterial`), cached per texture, mode, and tint; modes that vanilla culls get no inward faces. `EntityObject` `tints` maps pass tint labels to colours. `energy_swirl` and `breeze_wind` materials are owned by their object: `Ticker` advances `EntityObject.age` and the texture offset while the object has a parent; removal pauses it and reattachment resumes it. `EntityObject.getLayerGroup` and layer-scoped `getGroupByName`/`getMeshByName` expose each layer’s visibility and poses. Parts form a nested `Object3D` hierarchy with radian poses, inherited texture dimensions, cube growth, and mirrored box UVs. The entity root applies the dataset's `transform` (vanilla's renderer operations for the default state: living entities face +Z with their feet at the origin); models without one get scale `(-1, -1, 1)`. Nested child nodes hold this transform, one per operation, so every operation order is exact and caller position, rotation, and scale apply on top of it. `flip: true` forces that plain flip and `flip: false` keeps raw model space. Without an explicit texture key, use dataset `textureLocation` first, then the resolver (model path, a same-named texture in its directory, then variant data); `getBlock` and `getBlockList` are deprecated aliases. `Entities.getDatasetRoot` loads geometry, lists, animations, and the block index from the nearest published release when the exact dataset version is missing; textures keep the requested root. `src/bedrock/` contains type declarations only. -**World** (`src/world/`): `MineRenderWorld` is an early prototype with sparse chunks keyed by signed coordinates. Cubic 16³ `Chunk`s store `ChunkData` palettes with immutable states and recycled unused entries, 4,096 `Uint16` state indices, and per-cell NBT. Render handles and stable `BlockInfo` views remain separate; `.block` returns detached snapshots. Block coordinates are integers; chunk-local coordinates are 0–15. World and owned-chunk setters refresh `cullface` masks after edits, including across chunk boundaries; only opaque full-cube models occlude neighbors. The versioned `blockCulling` dataset lists vanilla's further face rules, applied in both rendering modes: glass, ice, slime and similar blocks hide faces toward the same block, mangrove roots toward the same block above or below, and connected panes or bars toward each other. Leaves keep their faces, as vanilla's cutout leaves do; data without `blockCulling` applies no such rule. `new MineRenderWorld(scene, { sectionMeshing: true })` merges models of any shape, including cutout, translucent, multipart, and animated models, plus fluid surfaces into per-section meshes with separate solid, translucent, and fluid atlas pages (at most 2048×2048 pixels by default; `maxAtlasSize`), with one mesh per page. Page selection uses texture alpha (`hasTranslucency`), so plain glass and panes use the solid/cutout page where vanilla uses the translucent layer. Transparent model and fluid pages draw their quads back to front: perspective views sort by distance from the camera and re-sort when it enters another block, as vanilla does; orthographic views sort by view depth and re-sort when the view direction changes. Sorting stays within each atlas page; intersecting surfaces can still blend incorrectly, and Three.js sorts the meshes for different pages or sections as whole objects. Per-position flower offsets remain unsupported. Only block entities keep individual objects; only opaque full cubes occlude neighbors. Animated atlases occupy their own pages and redraw on frame changes. Fluid surfaces use an 18×18×18 cell neighborhood and are built in the section worker when available. The section worker entry must stay free of three, DOM, and Node imports, so pure geometry code lives in `SectionGeometry.ts` and `FluidQuads.ts`. Merged `BlockInfo.object` values are `undefined`; edit through world/chunk setters. Replacing, removing, or clearing blocks releases their instance slots. `await world.unloadChunkColumn(x, z)` removes every section at absolute chunk-column coordinates and refreshes neighboring faces and fluids. `await world.unloadChunkColumns([{ x, z }, ...])` validates, copies, and deduplicates a group before removal, then refreshes surviving faces, fluids, lighting, AO, and biome colors once. `await world.setBlockVisibleAt(position, visible)` preserves block data and reveals neighboring faces in both rendering modes; replacement or removal resets visibility. Without an executor, parsed columns copy each section's palette into the chunk, resolve each palette entry once, and fill cells in one synchronous pass; `placeMultiBlock` groups blocks per chunk and resolves each distinct state once per group, with weighted variants chosen per cell and yields after about 8 ms of work. With `sectionMeshing`, `placeMultiBlock` starts loading every distinct state's models and atlases, excluding block entities, before it places the first chunk. Blocks that keep individual objects are created concurrently, with their position set before their models join the scene. `await world.placeChunks(columns)` places parsed columns in input order with one final neighbor refresh, including after partial failure. Passing a `BatchedExecutor` keeps the previous per-position batches, order at repeated positions, and wait-for-active-batch failure semantics. Bulk placement applies saved properties before model creation and flushes neighbor culling once, including after partial failure; single-block edits update neighbors immediately. Parsed column placement and unloading mark whole sections and neighboring sections' shared boundary cells dirty; structure placement and single edits queue chunk-local indices, and the culling drain recomputes masks in a flat pass per section and rebuilds each changed section once through `SectionMesh.buildAsync`, which runs the pure `buildSectionGeometry` (`SectionGeometry.ts`, typed arrays in and out) in the browser section worker when `Env.provider.createWorker` returns one and synchronously otherwise; a rebuild superseded by a newer rebuild or `clear()` is disposed. `BatchedExecutor.submit` returns a task promise; `stop()` rejects queued and future tasks while active tasks finish. Idle executors hold no timer; caller-provided executors remain caller-owned. Saved biomes supply grass, foliage, dry-foliage, and per-cell water colors in both rendering modes. Saved lighting, corner ambient occlusion, and biome blending are opt-in; light propagation, exact partial-shape ambient occlusion, and LOD remain unsupported. The demos in `apps/web/` exercise `MineRenderWorld`. +**World** (`src/world/`): `MineRenderWorld` is an early prototype with sparse chunks keyed by signed coordinates. Cubic 16³ `Chunk`s store `ChunkData` palettes with immutable states and recycled unused entries, 4,096 `Uint16` state indices, and per-cell NBT. Render handles and stable `BlockInfo` views remain separate; `.block` returns detached snapshots. Block coordinates are integers; chunk-local coordinates are 0–15. World and owned-chunk setters refresh `cullface` masks after edits, including across chunk boundaries; only opaque full-cube models occlude neighbors. The versioned `blockCulling` dataset lists vanilla's further face rules, applied in both rendering modes: glass, ice, slime and similar blocks hide faces toward the same block, mangrove roots toward the same block above or below, and connected panes or bars toward each other. Leaves keep their faces, as vanilla's cutout leaves do; data without `blockCulling` applies no such rule. `new MineRenderWorld(scene, { sectionMeshing: true })` merges models of any shape, including cutout, translucent, multipart, and animated models, plus fluid surfaces into per-section meshes with separate solid, translucent, and fluid atlas pages (at most 2048×2048 pixels by default; `maxAtlasSize`), with one mesh per page. Page selection uses texture alpha (`hasTranslucency`), so plain glass and panes use the solid/cutout page where vanilla uses the translucent layer. Transparent model and fluid pages draw their quads back to front: perspective views sort by distance from the camera and re-sort when it enters another block, as vanilla does; orthographic views sort by view depth and re-sort when the view direction changes. Sorting stays within each atlas page; intersecting surfaces can still blend incorrectly, and Three.js sorts the meshes for different pages or sections as whole objects. Per-position flower offsets remain unsupported. Only block entities keep individual objects; only opaque full cubes occlude neighbors. Placed player heads use vanilla's default skin texture; profiles in block-entity data are not read. Animated atlases occupy their own pages and redraw on frame changes. Fluid surfaces use an 18×18×18 cell neighborhood and are built in the section worker when available. The section worker entry must stay free of three, DOM, and Node imports, so pure geometry code lives in `SectionGeometry.ts` and `FluidQuads.ts`. Merged `BlockInfo.object` values are `undefined`; edit through world/chunk setters. Replacing, removing, or clearing blocks releases their instance slots. `await world.unloadChunkColumn(x, z)` removes every section at absolute chunk-column coordinates and refreshes neighboring faces and fluids. `await world.unloadChunkColumns([{ x, z }, ...])` validates, copies, and deduplicates a group before removal, then refreshes surviving faces, fluids, lighting, AO, and biome colors once. `await world.setBlockVisibleAt(position, visible)` preserves block data and reveals neighboring faces in both rendering modes; replacement or removal resets visibility. Without an executor, parsed columns copy each section's palette into the chunk, resolve each palette entry once, and fill cells in one synchronous pass; `placeMultiBlock` groups blocks per chunk and resolves each distinct state once per group, with weighted variants chosen per cell and yields after about 8 ms of work. With `sectionMeshing`, `placeMultiBlock` starts loading every distinct state's models and atlases, excluding block entities, before it places the first chunk. Blocks that keep individual objects are created concurrently, with their position set before their models join the scene. `await world.placeChunks(columns)` places parsed columns in input order with one final neighbor refresh, including after partial failure. Passing a `BatchedExecutor` keeps the previous per-position batches, order at repeated positions, and wait-for-active-batch failure semantics. Bulk placement applies saved properties before model creation and flushes neighbor culling once, including after partial failure; single-block edits update neighbors immediately. Parsed column placement and unloading mark whole sections and neighboring sections' shared boundary cells dirty; structure placement and single edits queue chunk-local indices, and the culling drain recomputes masks in a flat pass per section and rebuilds each changed section once through `SectionMesh.buildAsync`, which runs the pure `buildSectionGeometry` (`SectionGeometry.ts`, typed arrays in and out) in the browser section worker when `Env.provider.createWorker` returns one and synchronously otherwise; a rebuild superseded by a newer rebuild or `clear()` is disposed. `BatchedExecutor.submit` returns a task promise; `stop()` rejects queued and future tasks while active tasks finish. Idle executors hold no timer; caller-provided executors remain caller-owned. Saved biomes supply grass, foliage, dry-foliage, and per-cell water colors in both rendering modes. Saved lighting, corner ambient occlusion, and biome blending are opt-in; light propagation, exact partial-shape ambient occlusion, and LOD remain unsupported. The demos in `apps/web/` exercise `MineRenderWorld`. `world.raycast(raycaster)` returns the nearest visible rendered block: integer `position`, detached `block` data, and scene-space `point`, `normal`, and `distance`. It resolves merged geometry through per-quad cell indices and individual geometry through owned model/instance references. Hidden blocks and unrelated scene objects are excluded; transparent texture pixels still participate in geometric picking. @@ -141,7 +141,7 @@ The private Yarn workspace root contains the public library and its consumers. U - Idle caches and request queues let Node exit. `shutdown()` clears shared caches, permanently ends request queues, and stops Ticker. - Requests require Fetch and `AbortSignal.any/timeout`, with bounded GET retries and timeouts through body reading. Cancellation aborts a call; shutdown rejects waiting work and lets active calls finish. -- Async list/dictionary caches evict missing and rejected loads. Clear in-memory caches when sources change; persistent storage must be cleared separately. +- Async list/dictionary caches evict missing and rejected loads. Texture metadata and entity texture lookups store `Caching.MISSING` for missing files instead, and persist missing metadata. With only the vanilla source, `.mcmeta` lookups check the hosted directory list before requesting the file. Clear in-memory caches when sources change; persistent storage must be cleared separately. - Image decode failures reject and evict the matching cache entry. Failed synchronous image placeholders are retried on the next lookup. - Item previews accept `Models.getMerged(key, { displayContext, components, count, properties, itemReferences })`. Component JSON supplies indexed custom-model-data flags, strings, and floats, named component values/presence, damage, block-state properties, and charged projectiles. Damage and count use each node's `normalize` setting; numeric dispatch then applies its scale. `properties` overrides derived values by property ID, while `displayContext` retains precedence for display-context selection and defaults to GUI. Count defaults to 1. `await ItemDefaults.get(itemId, root?)` returns the selected version's default components: `max_stack_size`, `max_damage` with `damage: 0` for damageable items, and `enchantment_glint_override` for items that glint by default; an item the dataset does not list stacks to 64. Supplied components override these defaults in model selectors, glint, and GUI durability bars. Other registry components are not loaded, and `has_component` with `ignore_default: true` still requires an explicit property override. Component selectors compare JSON values without reconstructing component codecs or registry values. Component, property, and reference keys accept IDs with or without the `minecraft:` prefix. `itemReferences` supplies item keys for dynamic nodes such as `minecraft:bundle/selected_item`; referenced items use their own definitions and default state, retaining the display context. Resolved caches snapshot and serialize the whole context. `ItemModel.itemId` retains the original item ID through model replacements and composite children; referenced items retain their own IDs. Item tint sources read `dyed_color`, `map_color`, `firework_explosion.colors` (average RGB), `potion_contents`, and indexed `custom_model_data.colors` from the supplied components. Missing values use each source's declared default; explicit `tints` override automatic colors. Constant colors and grass colormap samples remain supported. Potion tints resolve `potion_contents` (a potion ID, or `potion` plus `custom_effects`) from the selected version's potion and effect data, weighting visible effects by `amplifier + 1`; `custom_color` takes precedence, and unknown IDs use the source's default. Releases before 1.20.5 use vanilla float32 color mixing with the legacy empty/hidden-effect colors; later releases use integer channel averages and the declared fallback for no visible effects. `await ItemTints.getPotionList(root?)` lists the selected registry's potion IDs. Potions with effects also inherit the pre-1.19.4 glint rule, unless an explicit component overrides it. Team colors use their declared defaults. Nested composite definitions retain ordered `ItemModel.parts`; each child keeps its own textures, display pose, `gui_light`, and tint sources. Composite objects and their children do not use instancing. - **Special renderers**: Chests, beds, mob heads, player heads, shulker boxes, banners, shields, tridents, conduits, decorated pots, and copper-golem statues use entity geometry with the base model's display pose and `gui_light`. A display node under the item holds that pose and the parts; every node uses a plain position, rotation, and scale, so `Box3.setFromObject` is current without a render. Shulkers use the definition's texture, openness, and orientation (default `up`); banners use the definition's dye color; shields use `base_color`; both draw the first 16 supplied `banner_patterns` in order via the versioned `data//banner_pattern` registry, preserving uncolored banner support and shield handles; tridents select flat icons or held/throwing poses through display-context and `using_item`; conduits draw static shells; pots read `pot_decorations` item IDs in back/left/right/front order, with omitted sides, bricks, and unrecognized items using plain side textures. Player heads read `profile`: a name or UUID-only profile resolves a skin, while supplied texture properties or `texture` resource IDs select a texture directly. Profile UUIDs use four signed integers, as in item JSON; the playground accepts conventional UUID text. The `texture` resource override takes precedence. Missing profiles use the default Steve skin; unresolved profiles use vanilla's UUID-based default. Remote skins retain the hat overlay and use the shared legacy-skin conversion. Copper-golem statues use the definition's full texture path and pose; vanilla definitions select standing, sitting, running, or star from `block_state.copper_golem_pose`. Other special renderers and automatic world/player state remain unsupported. diff --git a/apps/embed/test/browser.test.mjs b/apps/embed/test/browser.test.mjs index b67e01f3..2fe6f2eb 100644 --- a/apps/embed/test/browser.test.mjs +++ b/apps/embed/test/browser.test.mjs @@ -79,6 +79,9 @@ async function open(t, source, { sandbox, intercept } = {}) { if (url.pathname.endsWith("/textures/block/embed_test.png")) { return await request.respond({ status: 200, contentType: "image/png", headers, body: Buffer.from(red.split(",")[1], "base64") }); } + if (url.pathname.endsWith("/textures/block/_list.json")) { + return await request.respond({ status: 200, contentType: "application/json", headers, body: JSON.stringify({ directories: [], files: ["embed_test.png"] }) }); + } if (url.pathname.endsWith(".mcmeta")) return await request.respond({ status: 404, headers }); } unexpected.push(request.url()); diff --git a/packages/minerender/src/assets/Entities.ts b/packages/minerender/src/assets/Entities.ts index 4105a07b..f5d74222 100644 --- a/packages/minerender/src/assets/Entities.ts +++ b/packages/minerender/src/assets/Entities.ts @@ -139,7 +139,7 @@ export class Entities { const path = isAssetKey(modelKey) ? modelKey.getFullPath() : modelKey.path; const root = isAssetKey(modelKey) ? modelKey.root : undefined; const key = new AssetKey(modelKey.namespace, path, undefined, undefined, "entity-models", ".json", root); - return Caching.entityTextureCache.get(key.serialize(), async () => { + const texture = await Caching.entityTextureCache.get(key.serialize(), async () => { const name = path.split("/").pop()!; for (const candidate of [path, `${path}/${name}`]) { const texture = new AssetKey(modelKey.namespace, candidate, "textures", "entity", "assets", ".png", root); @@ -149,17 +149,18 @@ export class Entities { const list = await AssetLoader.get(listKey, AssetParser.LIST); const files = list?.files.filter(file => file.endsWith(".json") && file !== "_list.json"); const file = files?.find(file => file === "temperate.json") ?? files?.[0]; - if (!file) return undefined; + if (!file) return Caching.MISSING; const variantKey = new AssetKey(modelKey.namespace, file.slice(0, -5), `${name}_variant`, undefined, "data", ".json", root); const variant = await AssetLoader.get(variantKey, AssetParser.JSON); const assets = variant?.assets; const textureId = variant?.asset_id ?? (typeof assets?.wild === "string" ? assets.wild : Object.values(assets ?? {}).find(value => typeof value === "string")); - if (typeof textureId !== "string") return undefined; + if (typeof textureId !== "string") return Caching.MISSING; const texture = AssetKey.parse("textures", textureId); texture.root = root; return texture; }); + return texture === Caching.MISSING ? undefined : texture; } /** @deprecated Use getEntityList(); block entities share the entity dataset. */ diff --git a/packages/minerender/src/assets/ModelTextures.ts b/packages/minerender/src/assets/ModelTextures.ts index 944854a8..067565b3 100644 --- a/packages/minerender/src/assets/ModelTextures.ts +++ b/packages/minerender/src/assets/ModelTextures.ts @@ -9,6 +9,7 @@ import { MinecraftTextureMeta } from "../MinecraftTextureMeta"; import { PersistentCache } from "../cache/PersistentCache"; import { AssetKey } from "./AssetKey"; import { AssetParser } from "./source/parser/AssetParsers"; +import type { ListAsset } from "../ListAsset"; /** Loads texture pixels and `.mcmeta` files through the active asset sources. */ export class ModelTextures { @@ -59,15 +60,21 @@ export class ModelTextures { } const keyStr = key.serialize(); - return Caching.textureMetaCache.get(keyStr, k => { - return this.PERSISTENT_META_CACHE.getOrLoad(AssetLoader.persistentKey(keyStr), k1 => { - return AssetLoader.get(key, AssetParser.META).then(asset => { - if (asset) - asset.key = key; - return asset; - }) + const meta = await Caching.textureMetaCache.get(keyStr, k => { + return this.PERSISTENT_META_CACHE.getOrLoad(AssetLoader.persistentKey(keyStr), async k1 => { + if (AssetLoader.persistentScope === "") { + const path = key.getFullPath(); + const slash = path.lastIndexOf("/"); + const listKey = new AssetKey(key.namespace, `${path.slice(0, slash + 1)}_list`, "textures", undefined, "assets", ".json", key.root); + const list = await Caching.listAssetCache.get(listKey.serialize(), () => AssetLoader.get(listKey, AssetParser.LIST)).catch(() => undefined); + if (list && !list.files.includes(`${path.slice(slash + 1)}${key.extension}`)) return Caching.MISSING; + } + const asset = await AssetLoader.get(key, AssetParser.META); + if (asset) asset.key = key; + return asset ?? Caching.MISSING; }) - }) + }); + return meta === Caching.MISSING ? undefined : meta; } /** Clears persisted texture metadata. Use {@link Caching.clear} to discard in-memory textures and metadata. */ diff --git a/packages/minerender/src/cache/Caching.ts b/packages/minerender/src/cache/Caching.ts index 62d2a4b8..375f56ec 100644 --- a/packages/minerender/src/cache/Caching.ts +++ b/packages/minerender/src/cache/Caching.ts @@ -24,6 +24,9 @@ import type { Biome } from "../assets/Biomes"; /** Shared in-memory caches for loaded assets and render resources. Persistent stores are managed separately. */ export class Caching { + /** Cached in place of a missing asset. The caches drop `undefined` results, which would request the asset again. */ + public static readonly MISSING = false as const; + private static _generation = 0; /** Changes when shared assets are cleared, so derived caches can discard their old values. */ @@ -79,11 +82,11 @@ export class Caching { .expireAfterAccess(Time.minutes(5)) .expirationInterval(Time.seconds(30)) .buildAsync(); - static readonly textureMetaCache: AsyncLoadingCache = Caches.builder() + static readonly textureMetaCache: AsyncLoadingCache = Caches.builder() .expireAfterWrite(Time.minutes(10)) .expireAfterAccess(Time.minutes(5)) .expirationInterval(Time.seconds(30)) - .buildAsync(); + .buildAsync(); static readonly rawModelCache: AsyncLoadingCache = Caches.builder() .expireAfterWrite(Time.minutes(10)) @@ -113,7 +116,7 @@ export class Caching { static readonly entityModelCache: AsyncLoadingCache = Caching.createAssetCache(); - static readonly entityTextureCache: AsyncLoadingCache = Caching.createAssetCache(); + static readonly entityTextureCache: AsyncLoadingCache = Caching.createAssetCache(); static readonly entityAnimationCache: AsyncLoadingCache = Caching.createAssetCache(); static readonly blockEntityIndexCache: AsyncLoadingCache = Caching.createAssetCache(); diff --git a/packages/minerender/src/model/block/scene/BlockObject.ts b/packages/minerender/src/model/block/scene/BlockObject.ts index 85681c21..bb2ce330 100644 --- a/packages/minerender/src/model/block/scene/BlockObject.ts +++ b/packages/minerender/src/model/block/scene/BlockObject.ts @@ -144,18 +144,26 @@ export class BlockObject extends SceneObject { if (!key || this.options.displayPosition) return false; const generation = this._entityGeneration; const created: EntityObject[] = []; + const placement = new Matrix4(); let resolved: Maybe; try { resolved = BlockEntities.resolve(await BlockEntities.getIndex(key.root), key.toNamespacedString(), this.state); if (!resolved) return false; + BlockEntities.matrix(resolved, placement); for (const part of resolved.parts) { const [namespace, path] = part.model.includes(":") ? part.model.split(":") : [key.namespace, part.model]; - const texture = part.textureLocation === undefined ? undefined + let texture = part.textureLocation === undefined ? undefined : AssetKey.parse("textures", part.textureLocation.replace(/^([^:]+:)?textures\//, "$1")); + if (part.textureLocation === undefined && namespace === "minecraft" && path === "player_head") { + const { PlayerHeadTextures } = await import("../../../skin/PlayerHeadTextures"); + texture = (await PlayerHeadTextures.get(undefined, key.root)).texture; + } if (texture) texture.root = key.root; const entity = await Entities.getEntity(new BasicAssetKey(namespace, path), texture, { layer: part.layer }); if (!entity) throw new MineRenderError(`Missing entity model ${part.model}`); - created.push(await this.scene.addEntity(entity, { wireframe: this.options.wireframe }) as EntityObject); + const object = await this.scene.addEntity(entity, { wireframe: this.options.wireframe }) as EntityObject; + this.placeEntity(object, placement); + created.push(object); } } catch (error) { log.warn(`Could not draw block entity ${key.toNamespacedString()}, using its block model`, error); @@ -167,7 +175,7 @@ export class BlockObject extends SceneObject { this.removeEntities(created); return true; } - BlockEntities.matrix(resolved, this._entityPlacement); + this._entityPlacement.copy(placement); this._entities = created; this.placeEntities(); this.setVisible(this.visible); @@ -175,13 +183,17 @@ export class BlockObject extends SceneObject { } private placeEntities() { - // Block models are centred on the block position; entity transforms use the block's 0..16 space. - const matrix = new Matrix4().makeTranslation(this.position.x - 8, this.position.y - 8, this.position.z - 8).multiply(this._entityPlacement); for (const entity of this._entities) { - matrix.decompose(entity.position, entity.quaternion, entity.scale); + this.placeEntity(entity, this._entityPlacement); } } + private placeEntity(entity: EntityObject, placement: Matrix4) { + // Block models are centred on the block position; entity transforms use the block's 0..16 space. + const matrix = new Matrix4().makeTranslation(this.position.x - 8, this.position.y - 8, this.position.z - 8).multiply(placement); + matrix.decompose(entity.position, entity.quaternion, entity.scale); + } + public get isOccluding(): boolean { if (!this.visible) { return false; diff --git a/packages/minerender/test/BlockEntities.test.ts b/packages/minerender/test/BlockEntities.test.ts index 148c9162..0f5c6d5f 100644 --- a/packages/minerender/test/BlockEntities.test.ts +++ b/packages/minerender/test/BlockEntities.test.ts @@ -7,6 +7,7 @@ import { BlockEntities, BlockEntityIndex } from "../src/assets/BlockEntities"; import { BlockStates } from "../src/assets/BlockStates"; import { Entities } from "../src/assets/Entities"; import { Models } from "../src/assets/Models"; +import { ModelTextures } from "../src/assets/ModelTextures"; import { AssetSource } from "../src/assets/source/AssetSource"; import { HostedAssetSource } from "../src/assets/source/HostedAssetSource"; import { AssetParser } from "../src/assets/source/parser/AssetParsers"; @@ -243,6 +244,58 @@ test.serial("block models with geometry stay next to the entity, and blocks with t.is(entities().length, 0); }); +test.serial("placed player heads pass the default slim Steve texture to the entity loader", async t => { + const { scene, requests } = fixture(t); + const preload = ModelTextures.preload; + t.teardown(() => { ModelTextures.preload = preload; }); + ModelTextures.preload = async key => ({ key, width: 64, height: 64, type: "png", data: Buffer.from([1]) }); + BlockEntities.getIndex = async () => ({ "minecraft:player_head": { parts: [{ model: "minecraft:player_head" }] } }); + const key = AssetKey.parse("blockstates", "minecraft:player_head"); + key.root = "https://example.test/1.21.11"; + const block = await scene.addBlock((await BlockStates.get(key))!) as BlockObject; + t.is(block.blockEntities.length, 1); + t.deepEqual(requests, [{ + model: "minecraft:player_head", layer: undefined, + texture: new AssetKey("minecraft", "player/slim/steve", "textures", "entity", "assets", ".png", key.root).serialize() + }]); + block.dispose(); +}); + +test.serial("each block-entity part is placed while the next part is still loading", async t => { + const { scene, entities } = fixture(t); + BlockEntities.getIndex = async () => ({ + "minecraft:oak_sign": { + parts: [{ model: "minecraft:sign" }, { model: "minecraft:sign_back" }], + rotation: { property: "rotation", step: 22.5 }, translation: [0, 4, 4] + } + }); + const getEntity = Entities.getEntity; + let release!: () => void, started!: () => void; + const pending = new Promise(resolve => { release = resolve; }); + const loading = new Promise(resolve => { started = resolve; }); + Entities.getEntity = async (...args) => { + if (args[0].path === "sign_back") { started(); await pending; } + return getEntity(...args); + }; + const block = new BlockObject((await BlockStates.get(AssetKey.parse("blockstates", "minecraft:oak_sign")))!, { initialState: { rotation: "4" } }); + block.scene = scene; + block.setPosition(new Vector3(48, 32, -64)); + t.teardown(() => block.dispose()); + const placement = block.init(); + await loading; + const first = entities()[0]; + try { + t.is(entities().length, 1); + t.deepEqual(first.position.toArray().map(Math.round), [44, 28, -56]); + t.true(Math.abs(first.rotation.y - Math.PI / 2) < 1e-9); + t.deepEqual(first.scale.toArray(), [1, 1, 1]); + } finally { + release(); + await placement; + } + t.deepEqual(entities().map(entity => entity.position.toArray()), [first.position.toArray(), first.position.toArray()]); +}); + test.serial("block visibility preserves entity poses and hides only the matching frame instance", async t => { const { world, scene, models } = fixture(t, { sectionMeshing: true }); const bell = (await world.setBlockAt(0, 0, 0, { type: "minecraft:bell" }))!; diff --git a/packages/minerender/test/EntityLoading.test.ts b/packages/minerender/test/EntityLoading.test.ts index 489575b6..11199e6a 100644 --- a/packages/minerender/test/EntityLoading.test.ts +++ b/packages/minerender/test/EntityLoading.test.ts @@ -110,6 +110,24 @@ test.serial("the dataset's root transform is passed on with the entity", async t t.deepEqual(entity!.transform, [{ scale: [-1, -1, 1] }]); }); +test.serial("unresolvable entity textures run their probes once per cache lifetime", async t => { + const originals = { get: ModelTextures.get, asset: AssetLoader.get }; + t.teardown(() => { ModelTextures.get = originals.get; AssetLoader.get = originals.asset; }); + for (const hasVariant of [false, true]) { + Caching.clear(); + const calls: string[] = []; + ModelTextures.get = async key => { calls.push(key.toNamespacedString()); return undefined; }; + AssetLoader.get = async (key, parser) => { + calls.push(key.path); + return hasVariant && parser === AssetParser.LIST ? { files: ["temperate.json"], directories: [] } as never : undefined; + }; + const key = new BasicAssetKey("minecraft", "missing"); + t.is(await Entities.resolveTexture(key), undefined); + t.is(await Entities.resolveTexture(key), undefined); + t.deepEqual(calls, ["minecraft:entity/missing", "minecraft:entity/missing/missing", "_list", ...(hasVariant ? ["temperate"] : [])]); + } +}); + test.serial("selected layer texture locations skip probing and explicit textures take precedence", async t => { const file = model(); file.layers.main.textureLocation = "minecraft:textures/entity/cow/temperate_cow.png"; diff --git a/packages/minerender/test/TextureMeta.test.ts b/packages/minerender/test/TextureMeta.test.ts new file mode 100644 index 00000000..a0bc1736 --- /dev/null +++ b/packages/minerender/test/TextureMeta.test.ts @@ -0,0 +1,107 @@ +import { installMineRenderDataFixtures } from "./helpers/minerender-data"; +import test, { ExecutionContext } from "ava"; +import { AssetKey } from "../src/assets/AssetKey"; +import { AssetLoader } from "../src/assets/AssetLoader"; +import { ModelTextures } from "../src/assets/ModelTextures"; +import { HostedAssetSource } from "../src/assets/source/HostedAssetSource"; +import { AssetParser } from "../src/assets/source/parser/AssetParsers"; +import { Caching } from "../src/cache/Caching"; +import { PersistentCache } from "../src/cache/PersistentCache"; +import type { MinecraftTextureMeta } from "../src/MinecraftTextureMeta"; + +class MemoryCache extends PersistentCache> { + constructor() { super(new Map()); } + async get(key: string): Promise { return this.backing.get(key) as T; } + async put(key: string, value: T): Promise { this.backing.set(key, value); return value; } + async delete(key: string): Promise { this.backing.delete(key); } + async clear(): Promise { this.backing.clear(); } + async length(): Promise { return this.backing.size; } + async keys(): Promise { return [...this.backing.keys()]; } + async forEach(callback: (value: T, key: string) => void): Promise { this.backing.forEach((v, k) => callback(v as T, k)); } +} + +let restoreData: () => void; +test.before(() => { restoreData = installMineRenderDataFixtures(); }); +test.after.always(() => restoreData()); + +function fixture(t: ExecutionContext) { + const original = { get: AssetLoader.get, sources: [...AssetLoader["_SOURCES"]], cache: ModelTextures["_persistentMetaCache"] }; + const cache = new MemoryCache(); + ModelTextures["_persistentMetaCache"] = cache; + Caching.clear(); + t.teardown(() => { + AssetLoader.get = original.get; + AssetLoader["_SOURCES"] = original.sources; + ModelTextures["_persistentMetaCache"] = original.cache; + Caching.clear(); + }); + return cache; +} + +test.serial("missing metadata is cached in memory and persists when the directory list is unavailable", async t => { + const cache = fixture(t); + const calls: (AssetParser | string)[] = []; + AssetLoader.get = async (key, parser) => { calls.push(parser); return undefined; }; + const key = AssetKey.parse("textures", "minecraft:block/stone"); + t.is(await ModelTextures.getMeta(key), undefined); + t.is(await ModelTextures.getMeta(key), undefined); + t.deepEqual(calls, [AssetParser.LIST, AssetParser.META]); + t.is(await cache.get(key.serialize()), Caching.MISSING); + Caching.clear(); + t.is(await ModelTextures.getMeta(key), undefined); + t.deepEqual(calls, [AssetParser.LIST, AssetParser.META]); +}); + +test.serial("the vanilla directory list skips missing metadata and permits listed files", async t => { + fixture(t); + const root = "https://example.test/1.21.11"; + const meta: MinecraftTextureMeta = { texture: { blur: true } }; + const calls: (AssetParser | string)[] = []; + AssetLoader.get = async (key, parser) => { + calls.push(parser); + if (parser === AssetParser.LIST) { + t.deepEqual(key, new AssetKey("minecraft", "block/_list", "textures", undefined, "assets", ".json", root)); + return { files: ["water_still.png.mcmeta"], directories: [] } as never; + } + t.is(key.extension, ".png.mcmeta"); + t.is(key.getFullPath(), "block/water_still"); + return meta as never; + }; + const key = AssetKey.parse("textures", "minecraft:block/stone"); + key.root = root; + t.is(await ModelTextures.getMeta(key), undefined); + const water = AssetKey.parse("textures", "minecraft:block/water_still", key); + t.is(await ModelTextures.getMeta(water), meta); + t.is(meta.key?.root, root); + t.deepEqual(calls, [AssetParser.LIST, AssetParser.META]); +}); + +test.serial("failed directory lists fall back to metadata requests without caching the list failure", async t => { + fixture(t); + const meta: MinecraftTextureMeta = { texture: { blur: true } }; + const calls: (AssetParser | string)[] = []; + AssetLoader.get = async (key, parser) => { + calls.push(parser); + if (parser === AssetParser.LIST) throw new Error("Network request failed"); + t.is(parser, AssetParser.META); + return key.path === "stone" ? meta as never : undefined; + }; + t.is(await ModelTextures.getMeta(AssetKey.parse("textures", "minecraft:block/stone")), meta); + t.is(await ModelTextures.getMeta(AssetKey.parse("textures", "minecraft:block/water_still")), undefined); + t.deepEqual(calls, [AssetParser.LIST, AssetParser.META, AssetParser.LIST, AssetParser.META]); +}); + +test.serial("added sources request metadata without consulting a directory list", async t => { + fixture(t); + AssetLoader.addSource("pack", new HostedAssetSource("https://pack.example/assets")); + t.not(AssetLoader.persistentScope, ""); + let calls = 0; + const meta: MinecraftTextureMeta = { texture: { clamp: true } }; + AssetLoader.get = async (key, parser) => { + calls++; + t.is(parser, AssetParser.META); + return meta as never; + }; + t.is(await ModelTextures.getMeta(AssetKey.parse("textures", "minecraft:block/stone")), meta); + t.is(calls, 1); +});