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
4 changes: 2 additions & 2 deletions AGENTS.md

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions apps/embed/test/browser.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
7 changes: 4 additions & 3 deletions packages/minerender/src/assets/Entities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -149,17 +149,18 @@ export class Entities {
const list = await AssetLoader.get<ListAsset>(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<EntityVariant>(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. */
Expand Down
23 changes: 15 additions & 8 deletions packages/minerender/src/assets/ModelTextures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<MinecraftTextureMeta>(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<ListAsset>(listKey, AssetParser.LIST)).catch(() => undefined);
if (list && !list.files.includes(`${path.slice(slash + 1)}${key.extension}`)) return Caching.MISSING;
}
const asset = await AssetLoader.get<MinecraftTextureMeta>(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. */
Expand Down
9 changes: 6 additions & 3 deletions packages/minerender/src/cache/Caching.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -79,11 +82,11 @@ export class Caching {
.expireAfterAccess(Time.minutes(5))
.expirationInterval(Time.seconds(30))
.buildAsync<CacheKey, TextureAsset>();
static readonly textureMetaCache: AsyncLoadingCache<CacheKey, MinecraftTextureMeta> = Caches.builder()
static readonly textureMetaCache: AsyncLoadingCache<CacheKey, MinecraftTextureMeta | typeof Caching.MISSING> = Caches.builder()
.expireAfterWrite(Time.minutes(10))
.expireAfterAccess(Time.minutes(5))
.expirationInterval(Time.seconds(30))
.buildAsync<CacheKey, MinecraftTextureMeta>();
.buildAsync<CacheKey, MinecraftTextureMeta | typeof Caching.MISSING>();

static readonly rawModelCache: AsyncLoadingCache<CacheKey, Model> = Caches.builder()
.expireAfterWrite(Time.minutes(10))
Expand Down Expand Up @@ -113,7 +116,7 @@ export class Caching {

static readonly entityModelCache: AsyncLoadingCache<CacheKey, EntityModelFile> = Caching.createAssetCache<EntityModelFile>();

static readonly entityTextureCache: AsyncLoadingCache<CacheKey, AssetKey> = Caching.createAssetCache<AssetKey>();
static readonly entityTextureCache: AsyncLoadingCache<CacheKey, AssetKey | typeof Caching.MISSING> = Caching.createAssetCache<AssetKey | typeof Caching.MISSING>();

static readonly entityAnimationCache: AsyncLoadingCache<CacheKey, EntityAnimationFile> = Caching.createAssetCache<EntityAnimationFile>();
static readonly blockEntityIndexCache: AsyncLoadingCache<CacheKey, BlockEntityIndex> = Caching.createAssetCache<BlockEntityIndex>();
Expand Down
24 changes: 18 additions & 6 deletions packages/minerender/src/model/block/scene/BlockObject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ResolvedBlockEntity>;
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);
Expand All @@ -167,21 +175,25 @@ 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);
return true;
}

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;
Expand Down
53 changes: 53 additions & 0 deletions packages/minerender/test/BlockEntities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<void>(resolve => { release = resolve; });
const loading = new Promise<void>(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" }))!;
Expand Down
18 changes: 18 additions & 0 deletions packages/minerender/test/EntityLoading.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Loading
Loading