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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ The private Yarn workspace root contains the public library and its consumers. U

**Skins** (`src/skin/`): `SkinObject` uses vanilla part dimensions, UVs, and joint pivots. The player faces +Z as in V1, with its own right side on -X, so a negative X rotation raises a limb forward. PNG decoding preserves hidden RGB before base-layer opacity is enforced; overlay alpha is retained. Await `setSkinTexture` to load and detect the layout/model; legacy skins are normalized to a square atlas. `setSlim` updates arms in place, preserving named parts, poses, materials, and visibility. PNG model detection is a heuristic; explicit `slim`/`legacy` options or setters override detection; pass `undefined` to a setter to restore it. Await `setCapeTexture(src, layout)` for a `minecraft` (default), `optifine`, or `labymod` cape; pass `undefined` to remove it. `Skins.capeFromCapesDev` resolves a name or UUID to a full static texture URL. The named `cape` group follows the torso; the group and mesh support posing and visibility. Geometry and prepared skin/cape materials are shared cache entries.

**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 `<layer>#<n>` 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. `src/bedrock/` contains type declarations only.
**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 `<layer>#<n>` 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. Translucent quads within a section are not depth-sorted. 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. 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`.

Expand Down
2 changes: 2 additions & 0 deletions packages/minerender/reference/concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ Pass an explicit asset root as the second argument to `MineRenderData.get(datase

The former synchronous data lookups now return promises: `ItemDefaults.get`, `ItemTints.getPotionList`, `getBlockFluidState`, `getFluidKind`, and `resolveLegacyBlock`. For repeated synchronous calculations, load `fluids` or `legacyBlocks` once with `MineRenderData.get` and pass the table to `resolveBlockFluidState` or `resolveLegacyBlockState`. Rendering and parser entry points await their data automatically. `BannerPatterns.getColors(root?)` provides versioned texture dyes; `DYE_COLORS` and `BannerPatterns.getColor` retain the fixed 1.21.11 palette for compatibility and are deprecated.

Entity and block-entity geometry from `entity-models/` uses the same version selection when the exact dataset version is missing. This also applies to entity lists, animations, and the block-entity index. An unpublished snapshot keeps its own version and draws block entities with their block models. Textures keep the requested asset version.

## Redrawing after changes

[Renderer](/api/index/classes/Renderer) draws only when the scene is dirty, `render.renderAlways` is enabled, an `onFrame` subscription is active, or a video is recording. Calling `start()` runs this loop; it does not force an unchanged scene to redraw continuously.
Expand Down
2 changes: 2 additions & 0 deletions packages/minerender/src/assets/BlockEntities.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Matrix4 } from "three";
import { AssetKey } from "./AssetKey";
import { Entities } from "./Entities";
import { AssetLoader } from "./AssetLoader";
import { AssetParser } from "./source";
import { Caching } from "../cache/Caching";
Expand Down Expand Up @@ -58,6 +59,7 @@ export class BlockEntities {
key.rootType = undefined!;
const index = await Caching.blockEntityIndexCache.get(key.serialize(), async () => {
try {
key.root = await Entities.getDatasetRoot(root);
return await AssetLoader.get<BlockEntityIndex>(key, AssetParser.JSON) ?? {};
} catch (error) {
log.warn("Could not load the block entity index; block entities are drawn as plain block models", error);
Expand Down
34 changes: 31 additions & 3 deletions packages/minerender/src/assets/Entities.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { AssetKey, BasicAssetKey, isAssetKey } from "./AssetKey";
import type { EntityLayer, EntityModel, EntityModelFile, EntityModelPass } from "../entity/EntityModel";
import type { EntityAnimation, EntityAnimationFile } from "../entity/EntityAnimation";
import { parseRelease, nearestRelease } from "./_versions/DatasetVersions";
import { AssetLoader } from "./AssetLoader";
import { AssetParser } from "./source";
import { Maybe } from "../util";
Expand All @@ -14,11 +15,38 @@ import type { MinecraftAsset } from "../MinecraftAsset";
/** Loads entity and block-entity geometry, texture selections, and animation clips from the dataset. */
export class Entities {

/**
* Resolves `root` (default: the active version). Uses the exact dataset when published or when the
* version is not a release; otherwise selects the nearest published release, like {@link MineRenderData.resolve}.
* Textures keep the requested root.
*/
public static async getDatasetRoot(root?: string): Promise<Maybe<string>> {
const requestRoot = (root ?? AssetLoader.ROOT).replace(/\/+$/, "");
const requestedVersion = requestRoot.slice(requestRoot.lastIndexOf("/") + 1);
const base = requestRoot.slice(0, requestRoot.lastIndexOf("/"));
const selected = await Caching.entityDatasetVersionCache.get(root ?? `sources:${AssetLoader.ROOT}`, async () => {
const key = new AssetKey("entity-models", "version", undefined, undefined, undefined, ".json", root);
key.rootType = undefined!;
if (!parseRelease(requestedVersion) || await AssetLoader.get(key, AssetParser.JSON) !== undefined) return requestedVersion;
const indexKey = new AssetKey("entity-models", "versions", undefined, undefined, undefined, ".json", base);
indexKey.rootType = undefined!;
const index = await AssetLoader.get<MinecraftAsset & { versions: { name: string }[] }>(indexKey, AssetParser.JSON);
if (index === undefined) return requestedVersion;
if (!index || typeof index !== "object" || Array.isArray(index) || !Array.isArray(index.versions)
|| !index.versions.every(entry => entry && typeof entry === "object" && !Array.isArray(entry) && typeof entry.name === "string")) {
throw new MineRenderError(`Invalid entity model version index at ${base}/entity-models/versions.json`);
}
return nearestRelease(requestedVersion, index.versions.map(entry => entry.name)) ?? requestedVersion;
});
return selected === requestedVersion ? root : `${base}/${selected}`;
}

/** Lists model paths in the `minecraft` namespace, including subdirectories and excluding `.json`. */
public static async getEntityList(): Promise<string[]> {
const root = await this.getDatasetRoot();
const collect = async (path: string): Promise<string[]> => {
const prefix = path ? `${path}/` : "";
const key = new AssetKey(DEFAULT_NAMESPACE, `${prefix}_list`, undefined, undefined, "entity-models", ".json");
const key = new AssetKey(DEFAULT_NAMESPACE, `${prefix}_list`, undefined, undefined, "entity-models", ".json", root);
const list = await Caching.listAssetCache.get(key.serialize(), () => AssetLoader.get<ListAsset>(key, AssetParser.LIST));
if (!list) return [];
const files = list.files.filter(file => file.endsWith(".json") && file !== "_list.json")
Expand All @@ -31,7 +59,7 @@ export class Entities {

private static async getModelFile(modelKey: BasicAssetKey): Promise<Maybe<EntityModelFile>> {
const path = isAssetKey(modelKey) ? modelKey.getFullPath() : modelKey.path;
const key = new AssetKey(modelKey.namespace, path, undefined, undefined, "entity-models", ".json", isAssetKey(modelKey) ? modelKey.root : undefined);
const key = new AssetKey(modelKey.namespace, path, undefined, undefined, "entity-models", ".json", await this.getDatasetRoot(isAssetKey(modelKey) ? modelKey.root : undefined));
return Caching.entityModelCache.get(key.serialize(), () => AssetLoader.get<EntityModelFile>(key, AssetParser.JSON));
}

Expand Down Expand Up @@ -101,7 +129,7 @@ export class Entities {
*/
public static async getAnimations(modelKey: BasicAssetKey): Promise<Maybe<Record<string, EntityAnimation>>> {
const path = isAssetKey(modelKey) ? modelKey.getFullPath() : modelKey.path;
const key = new AssetKey(modelKey.namespace, path, undefined, undefined, "entity-models/animations", ".json", isAssetKey(modelKey) ? modelKey.root : undefined);
const key = new AssetKey(modelKey.namespace, path, undefined, undefined, "entity-models/animations", ".json", await this.getDatasetRoot(isAssetKey(modelKey) ? modelKey.root : undefined));
const file = await Caching.entityAnimationCache.get(key.serialize(), () => AssetLoader.get<EntityAnimationFile>(key, AssetParser.JSON));
return file?.animations;
}
Expand Down
30 changes: 8 additions & 22 deletions packages/minerender/src/assets/MineRenderData.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { parseRelease, nearestRelease } from "./_versions/DatasetVersions";
import { AssetKey } from "./AssetKey";
import { AssetLoader } from "./AssetLoader";
import { AssetParser } from "./source/parser/AssetParsers";
Expand Down Expand Up @@ -48,14 +49,6 @@ export interface ResolvedMineRenderData {
}

const object = (value: unknown): value is Record<string, any> => value !== null && typeof value === "object" && !Array.isArray(value);
const release = (value: string): number[] | undefined => /^\d+\.\d+(?:\.\d+)?$/.test(value) ? value.split(".").map(Number) : undefined;
const compare = (left: number[], right: number[]): number => {
for (let i = 0; i < 3; i++) {
const difference = (left[i] ?? 0) - (right[i] ?? 0);
if (difference) return difference;
}
return 0;
};

/** Loads extracted Minecraft registries from the active asset sources and version. */
export class MineRenderData {
Expand Down Expand Up @@ -88,9 +81,9 @@ export class MineRenderData {
const scope = AssetLoader.persistentScope;
return this.cached(`resolve:${requestRoot === undefined ? "sources" : "explicit"}:${root}`, scope, defaultRoot, async () => {
const requestedVersion = root.slice(root.lastIndexOf("/") + 1);
let manifest = await this.manifest(root, requestRoot !== undefined && release(requestedVersion) ? requestedVersion : undefined, requestRoot);
let manifest = await this.manifest(root, requestRoot !== undefined && parseRelease(requestedVersion) ? requestedVersion : undefined, requestRoot);
if (manifest) return { requestedVersion, version: manifest.minecraftVersion, root, manifest };
const requested = release(requestedVersion);
const requested = parseRelease(requestedVersion);
if (!requested) {
throw new Error(`No MineRender data is published for ${requestedVersion}; automatic fallback requires a release`);
}
Expand All @@ -103,19 +96,12 @@ export class MineRenderData {
}
return value.versions as { name: string }[];
});
const versions = index.map(entry => ({ name: entry.name, number: release(entry.name) }))
.filter((entry): entry is { name: string; number: number[] } => !!entry.number)
.sort((left, right) => compare(left.number, right.number));
const sameLine = versions.filter(entry => entry.number[0] === requested[0] && entry.number[1] === requested[1]);
const selected = sameLine.sort((left, right) => Math.abs((left.number[2] ?? 0) - (requested[2] ?? 0))
- Math.abs((right.number[2] ?? 0) - (requested[2] ?? 0)) || compare(left.number, right.number))[0]
?? versions.filter(entry => compare(entry.number, requested) <= 0).pop()
?? versions[0];
const selected = nearestRelease(requestedVersion, index.map(entry => entry.name));
if (!selected) throw new Error(`No compatible MineRender data is published for ${requestedVersion}`);
const resolvedRoot = `${base}/${selected.name}`;
manifest = await this.manifest(resolvedRoot, selected.name, resolvedRoot);
if (!manifest) throw new Error(`Missing MineRender manifest for published version ${selected.name}`);
return { requestedVersion, version: selected.name, root: resolvedRoot, manifest };
const resolvedRoot = `${base}/${selected}`;
manifest = await this.manifest(resolvedRoot, selected, resolvedRoot);
if (!manifest) throw new Error(`Missing MineRender manifest for published version ${selected}`);
return { requestedVersion, version: selected, root: resolvedRoot, manifest };
});
}

Expand Down
Loading
Loading