Phase 13: real non-cube block models via blockstate/model JSON resolution
Adds worker/src/models.rs (parent-chain blockstate/model resolution, texture-variable substitution reusing Phase 12's atlas keys), routes non-cube blocks through new per-element mesh emission in mesh.rs while leaving full-cube blocks on the existing cube mesher, and threads a per-server ModelContext (vanilla worker-wide + modded per-job) through main.rs. Modded model JSON is stored in a new block_models Postgres table and read alongside the existing (previously write-only) block_registry table. Fixes a pre-existing face-culling bug as a side effect of excluding non-cube voxels from the cube mesher's input. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015tKdPZt78zbPUZMXWzKEKt
This commit is contained in:
@@ -163,6 +163,60 @@ blockstate/model JSON parsing (`BlockAssetExtractor`'s texture matching is still
|
|||||||
convention guess, not a real model resolution) — renumbered to **Phase 13** once the atlas/UV
|
convention guess, not a real model resolution) — renumbered to **Phase 13** once the atlas/UV
|
||||||
scope above turned out to be its own full phase; Thaumcraft remains the named stress test for it.
|
scope above turned out to be its own full phase; Thaumcraft remains the named stress test for it.
|
||||||
|
|
||||||
|
### Real (non-cube) block models (Phase 13)
|
||||||
|
|
||||||
|
`worker/src/models.rs` is a real blockstate/model JSON resolver: given a block's registry name, it
|
||||||
|
picks a variant, walks the model's `parent` chain (merging `textures` maps as it goes, child
|
||||||
|
overrides win), and resolves each element's face textures down to the same atlas-lookup key
|
||||||
|
`atlas.rs` already uses (Phase 12) — so a resolved non-cube model's faces are textured with zero
|
||||||
|
atlas-side changes. `ResolvedModel::is_full_cube()` tells `mesh.rs` whether a block still belongs
|
||||||
|
on the existing cube greedy-mesher (untouched — lower risk, and re-rendering a plain cube through
|
||||||
|
the new per-element path would be pure overhead) or needs its own per-element geometry.
|
||||||
|
|
||||||
|
Two model sources, deliberately split:
|
||||||
|
- **Vanilla**: extracted from the same Mojang client jar Phase 11/12 already download (cached to
|
||||||
|
disk under its own `vanilla-<version>-models.json`, a third instance of the same accepted
|
||||||
|
"duplicate download, cached after first build" tradeoff as the palette/atlas), worker-wide.
|
||||||
|
- **Modded**: shipped by the mod over a new `block_models` WS message (mirrors Phase 11's
|
||||||
|
`block_textures`, batched the same way) and stored per-server in a new `block_models` Postgres
|
||||||
|
table (`server_id, kind, name, json`, `kind` distinguishing a blockstate entry from a model entry
|
||||||
|
since both are keyed by resource-location-shaped strings that could otherwise collide) — fetched
|
||||||
|
fresh per chunk-job by the worker, alongside the already-existing `block_registry` table (Phase
|
||||||
|
11 wrote it but nothing ever read it back until now). A modded model's `parent` can point at a
|
||||||
|
vanilla base model (e.g. `"minecraft:block/cross"`) via `ModelRegistry::resolve`'s `fallback`
|
||||||
|
parameter — common in practice, since plenty of modded blocks just extend a vanilla shape.
|
||||||
|
|
||||||
|
**Deliberately out of scope**, documented in `models.rs`'s doc comments rather than silently
|
||||||
|
dropped:
|
||||||
|
- **No `multipart` blockstates** (fences, walls, redstone wire, glass panes) — there's no way to
|
||||||
|
know a block's neighbor-dependent connection state from this project's raw block-id/meta data
|
||||||
|
model, so a multipart-only blockstate resolves to `None` and the block falls back to a flat cube,
|
||||||
|
same as pre-Phase-13.
|
||||||
|
- **No property-based variant selection** — chunk data here only ever carries a numeric `meta`
|
||||||
|
(legacy) or a truncated packed state id (modern, per Phase 10), never named property strings, so
|
||||||
|
`resolve()` always picks a deterministic representative variant (the `""` key if present, else
|
||||||
|
alphabetically first) rather than the "correct" one for a given block's actual state.
|
||||||
|
- **No per-face UV rectangle or per-variant rotation** — element geometry (`from`/`to`) is real,
|
||||||
|
but face texturing reuses the same tile-relative "UV span in block units" scheme the cube mesher
|
||||||
|
already uses, not the model's literal declared UV rect, to avoid needing per-pixel atlas
|
||||||
|
remapping/a more complex shader.
|
||||||
|
- Weighted multi-model variant lists always take the first entry (weights ignored); `meta` is never
|
||||||
|
consulted for modded block resolution, since `block_registry` only carries `block_id -> name`
|
||||||
|
(no per-state granularity) — a real, pre-existing schema constraint, not new to this phase.
|
||||||
|
|
||||||
|
A genuine pre-existing rendering bug got fixed as a side effect: `compute_face_masks` treated *any*
|
||||||
|
non-zero block as solid for neighbor face-culling, so a torch (or any non-cube block) sitting next
|
||||||
|
to a solid block incorrectly culled that solid block's adjacent face. Excluding non-cube-resolved
|
||||||
|
voxels from the cube mesher's input array (needed anyway, for Phase 13's own correctness) fixes
|
||||||
|
this for free — see `mesh.rs`'s `a_non_cube_neighbor_no_longer_incorrectly_culls_an_adjacent_solid_faces`
|
||||||
|
test.
|
||||||
|
|
||||||
|
Verified via real `cargo build`/`cargo test --lib` (worker, 65/65 passing, up from 52) and a real
|
||||||
|
`./gradlew :forge-1_12_2:compileJava` against the actual legacy ForgeGradle toolchain for the mod
|
||||||
|
side. `api`'s new `models.test.ts` (mirrors `textures.test.ts`'s pattern) was written but **not run
|
||||||
|
against a live Postgres** — Docker isn't available in this dev environment, the same honestly-
|
||||||
|
documented gap Phase 11 already carries; `bunx tsc --noEmit` is clean.
|
||||||
|
|
||||||
## Running
|
## Running
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS "block_models" (
|
||||||
|
"server_id" uuid NOT NULL REFERENCES "servers"("id") ON DELETE CASCADE,
|
||||||
|
"kind" text NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"json" text NOT NULL,
|
||||||
|
"updated_at" timestamptz NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY ("server_id", "kind", "name")
|
||||||
|
);
|
||||||
@@ -194,6 +194,29 @@ export const blockTextures = pgTable(
|
|||||||
(table) => [primaryKey({ columns: [table.serverId, table.name] })],
|
(table) => [primaryKey({ columns: [table.serverId, table.name] })],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Phase 13: raw blockstate/model JSON text the mod extracted off its own classloader, mirroring
|
||||||
|
// `blockRegistry`/`blockTextures`'s "written by the api from the mod's WS connection, read later
|
||||||
|
// by the worker" shape — stored as plain text (not MinIO) since these are small JSON snippets, not
|
||||||
|
// binary blobs, matching `blockRegistry`'s own text-in-Postgres precedent. `kind` distinguishes a
|
||||||
|
// blockstate (`name` = the block's own registry name, e.g. `"thaumcraft:blockcustomplant"`) from a
|
||||||
|
// model (`name` = the model's own resource location, e.g. `"thaumcraft:block/customplant"`, as
|
||||||
|
// referenced by a blockstate variant's `"model"` field or another model's `"parent"`) since both
|
||||||
|
// are keyed by resource-location strings that could otherwise collide. See worker/src/models.rs's
|
||||||
|
// `ModelRegistry` for how these get parsed and resolved into real element geometry.
|
||||||
|
export const blockModels = pgTable(
|
||||||
|
"block_models",
|
||||||
|
{
|
||||||
|
serverId: uuid("server_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => servers.id, { onDelete: "cascade" }),
|
||||||
|
kind: text("kind").notNull(),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
json: text("json").notNull(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => [primaryKey({ columns: [table.serverId, table.kind, table.name] })],
|
||||||
|
);
|
||||||
|
|
||||||
// Metadata pointer to a rendered tile PNG in MinIO — the binary itself never touches Postgres.
|
// Metadata pointer to a rendered tile PNG in MinIO — the binary itself never touches Postgres.
|
||||||
// One row per (server, dimension, zoom, tileX, tileZ); zoom is always 0 until Phase 2 adds
|
// One row per (server, dimension, zoom, tileX, tileZ); zoom is always 0 until Phase 2 adds
|
||||||
// multi-resolution tiles.
|
// multi-resolution tiles.
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { db } from "./db/client";
|
||||||
|
import { blockModels } from "./db/schema";
|
||||||
|
import { storeBlockModels } from "./models";
|
||||||
|
import { createTestServer, deleteTestServer } from "./test-helpers";
|
||||||
|
|
||||||
|
describe("storeBlockModels", () => {
|
||||||
|
let server: { id: string };
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
server = await createTestServer("block-models");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await deleteTestServer(server.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("upserts blockstate and model entries, keyed by (serverId, kind, name)", async () => {
|
||||||
|
await storeBlockModels(server.id, [
|
||||||
|
{ kind: "blockstate", name: "thaumcraft:blockcustomplant", json: '{"variants":{}}' },
|
||||||
|
{ kind: "model", name: "thaumcraft:block/customplant", json: '{"elements":[]}' },
|
||||||
|
]);
|
||||||
|
const rows = await db.select().from(blockModels).where(eq(blockModels.serverId, server.id));
|
||||||
|
expect(rows.length).toBe(2);
|
||||||
|
expect(rows.find((r) => r.kind === "blockstate")?.name).toBe("thaumcraft:blockcustomplant");
|
||||||
|
expect(rows.find((r) => r.kind === "model")?.json).toBe('{"elements":[]}');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a blockstate and a model can share the same name without colliding (kind is part of the key)", async () => {
|
||||||
|
await storeBlockModels(server.id, [
|
||||||
|
{ kind: "blockstate", name: "thaumcraft:foo", json: '{"a":1}' },
|
||||||
|
{ kind: "model", name: "thaumcraft:foo", json: '{"b":2}' },
|
||||||
|
]);
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(blockModels)
|
||||||
|
.where(and(eq(blockModels.serverId, server.id), eq(blockModels.name, "thaumcraft:foo")));
|
||||||
|
expect(rows.length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("re-sending the same (kind, name) with new JSON overwrites, not duplicates", async () => {
|
||||||
|
await storeBlockModels(server.id, [{ kind: "model", name: "thaumcraft:block/customplant", json: '{"v":1}' }]);
|
||||||
|
await storeBlockModels(server.id, [{ kind: "model", name: "thaumcraft:block/customplant", json: '{"v":2}' }]);
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(blockModels)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(blockModels.serverId, server.id),
|
||||||
|
eq(blockModels.kind, "model"),
|
||||||
|
eq(blockModels.name, "thaumcraft:block/customplant"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(rows.length).toBe(1);
|
||||||
|
expect(rows[0]!.json).toBe('{"v":2}');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an empty list is a no-op, not an error", async () => {
|
||||||
|
await expect(storeBlockModels(server.id, [])).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
import { db } from "./db/client";
|
||||||
|
import { blockModels } from "./db/schema";
|
||||||
|
|
||||||
|
export interface BlockModelEntry {
|
||||||
|
kind: "blockstate" | "model";
|
||||||
|
name: string;
|
||||||
|
json: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upserts the mod's raw blockstate/model JSON dump for one server (Phase 13) — sent once per
|
||||||
|
* connection, same lifecycle as `storeBlockRegistry`/`storeBlockTextures` (see textures.ts):
|
||||||
|
* stable for a world's lifetime, no need to resend on a timer. Read by the worker's
|
||||||
|
* `models::ModelRegistry` (via new `fetch_block_models`/`fetch_block_registry` queries) to resolve
|
||||||
|
* a modded block's real (possibly non-cube) shape — see worker/src/models.rs's doc comment.
|
||||||
|
*/
|
||||||
|
export async function storeBlockModels(serverId: string, entries: BlockModelEntry[]) {
|
||||||
|
if (entries.length === 0) return;
|
||||||
|
await db
|
||||||
|
.insert(blockModels)
|
||||||
|
.values(entries.map((e) => ({ serverId, kind: e.kind, name: e.name, json: e.json })))
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [blockModels.serverId, blockModels.kind, blockModels.name],
|
||||||
|
set: { json: sql.raw("excluded.json"), updatedAt: new Date() },
|
||||||
|
});
|
||||||
|
}
|
||||||
+17
-3
@@ -6,6 +6,7 @@ import { storeLinkCode } from "./link";
|
|||||||
import { recordAndPublishChat } from "./chat";
|
import { recordAndPublishChat } from "./chat";
|
||||||
import { arePlayerPositionsVisible, publishPlayerPositions, type PlayerPosition } from "./players";
|
import { arePlayerPositionsVisible, publishPlayerPositions, type PlayerPosition } from "./players";
|
||||||
import { storeBlockRegistry, storeBlockTextures, type BlockRegistryEntry, type BlockTextureEntry } from "./textures";
|
import { storeBlockRegistry, storeBlockTextures, type BlockRegistryEntry, type BlockTextureEntry } from "./textures";
|
||||||
|
import { storeBlockModels, type BlockModelEntry } from "./models";
|
||||||
|
|
||||||
// Wire protocol (mod <-> api), one JSON object per WS text frame:
|
// Wire protocol (mod <-> api), one JSON object per WS text frame:
|
||||||
//
|
//
|
||||||
@@ -62,15 +63,22 @@ import { storeBlockRegistry, storeBlockTextures, type BlockRegistryEntry, type B
|
|||||||
//
|
//
|
||||||
// mod -> api {"type":"block_registry","entries":[{"id":4000,"name":"botania:manapool"}]}
|
// mod -> api {"type":"block_registry","entries":[{"id":4000,"name":"botania:manapool"}]}
|
||||||
// mod -> api {"type":"block_textures","textures":[{"name":"botania:manapool","dataBase64":"..."}]}
|
// mod -> api {"type":"block_textures","textures":[{"name":"botania:manapool","dataBase64":"..."}]}
|
||||||
|
// mod -> api {"type":"block_models","entries":[{"kind":"blockstate","name":"botania:manapool","json":"..."},{"kind":"model","name":"botania:block/manapool","json":"..."}]}
|
||||||
//
|
//
|
||||||
// Phase 11: sent once after `hello_ack` (a world's numeric block-id assignments and mod-jar
|
// Phase 11: sent once after `hello_ack` (a world's numeric block-id assignments and mod-jar
|
||||||
// contents are both stable for the server's lifetime, so there's no need to resend on a timer).
|
// contents are both stable for the server's lifetime, so there's no need to resend on a timer).
|
||||||
// `block_registry` is the numeric-id -> registry-name mapping needed to make sense of
|
// `block_registry` is the numeric-id -> registry-name mapping needed to make sense of
|
||||||
// `chunkColumns.blockId`/`blockMeta` for modded blocks (see MCMapperMod.java's connect-time
|
// `chunkColumns.blockId`/`blockMeta` for modded blocks (see MCMapperMod.java's connect-time
|
||||||
// registry dump); `block_textures` is the mod's best-effort classloader extraction of each
|
// registry dump); `block_textures` is the mod's best-effort classloader extraction of each
|
||||||
// block's texture PNG (see textures.ts's doc comment for storage). Both are stored now but not
|
// block's texture PNG (see textures.ts's doc comment for storage). Both are read by the worker's
|
||||||
// yet read by the render pipeline — see worker/README's Phase 11 note on the deferred per-server
|
// per-job model resolution as of Phase 13 (`block_registry` for the id->name lookup); the worker's
|
||||||
// palette-resolution work this unlocks.
|
// 2D/atlas texture-palette resolution itself is still process-wide only — see worker/README.
|
||||||
|
//
|
||||||
|
// Phase 13: `block_models` is the mod's raw blockstate/model JSON dump (see models.ts's doc
|
||||||
|
// comment) — `kind` distinguishes a blockstate entry (`name` = the block's own registry name)
|
||||||
|
// from a model entry (`name` = the model's resource location, as referenced by a variant's
|
||||||
|
// `"model"` or another model's `"parent"`). Same "sent once, stable for the world's lifetime"
|
||||||
|
// cadence as the other two.
|
||||||
|
|
||||||
interface Column {
|
interface Column {
|
||||||
x: number;
|
x: number;
|
||||||
@@ -244,6 +252,12 @@ export const wsGateway = {
|
|||||||
await storeBlockTextures(state.serverId, textures);
|
await storeBlockTextures(state.serverId, textures);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (msg.type === "block_models") {
|
||||||
|
const entries: BlockModelEntry[] = msg.entries ?? [];
|
||||||
|
await storeBlockModels(state.serverId, entries);
|
||||||
|
return;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
close(ws: any) {
|
close(ws: any) {
|
||||||
|
|||||||
@@ -53,6 +53,26 @@ pub fn texture_name(block_id: u16, meta: u8) -> Option<&'static str> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Maps a handful of well-known **non-cube** vanilla blocks to their real blockstate registry
|
||||||
|
/// name (see `models.rs`), so `mesh.rs` can render their actual shape instead of a flat cube.
|
||||||
|
/// Deliberately small and hand-picked, not a full 1-256 vanilla id table: this is a demo of the
|
||||||
|
/// Phase 13 model-resolution pipeline working end to end on real game data, not an attempt at
|
||||||
|
/// exhaustive vanilla coverage — expanding it is just adding more match arms, no design changes
|
||||||
|
/// needed. Cube-shaped blocks (including ones covered by `texture_name` above) are deliberately
|
||||||
|
/// absent: `models::ResolvedModel::is_full_cube` would just send them right back to the existing
|
||||||
|
/// greedy cube mesher, so there's no point resolving them through this path at all.
|
||||||
|
pub fn vanilla_model_name(block_id: u16, meta: u8) -> Option<&'static str> {
|
||||||
|
match block_id {
|
||||||
|
50 => Some("minecraft:torch"),
|
||||||
|
53 => Some("minecraft:oak_stairs"),
|
||||||
|
65 => Some("minecraft:ladder"),
|
||||||
|
85 => Some("minecraft:oak_fence"),
|
||||||
|
37 => Some("minecraft:dandelion"),
|
||||||
|
38 if meta == 0 => Some("minecraft:poppy"),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn wool_texture(meta: u8) -> &'static str {
|
fn wool_texture(meta: u8) -> &'static str {
|
||||||
match meta {
|
match meta {
|
||||||
0 => "white_wool",
|
0 => "white_wool",
|
||||||
@@ -102,4 +122,17 @@ mod tests {
|
|||||||
fn unmapped_block_returns_none() {
|
fn unmapped_block_returns_none() {
|
||||||
assert_eq!(texture_name(9999, 0), None);
|
assert_eq!(texture_name(9999, 0), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vanilla_model_name_covers_the_hand_picked_non_cube_demo_blocks() {
|
||||||
|
assert_eq!(vanilla_model_name(50, 0), Some("minecraft:torch"));
|
||||||
|
assert_eq!(vanilla_model_name(85, 0), Some("minecraft:oak_fence"));
|
||||||
|
assert_eq!(vanilla_model_name(9999, 0), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cube_shaped_blocks_are_absent_from_the_non_cube_table() {
|
||||||
|
assert_eq!(vanilla_model_name(1, 0), None); // stone
|
||||||
|
assert_eq!(vanilla_model_name(4, 0), None); // cobblestone
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,42 @@ pub async fn fetch_chunk_sections(
|
|||||||
Ok(rows)
|
Ok(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Phase 13: the mod's numeric-blockId -> registry-name dump for one server (see api's
|
||||||
|
/// `textures.ts`/`ws-gateway.ts`) — read here so `models::resolve_for_block` can look up a
|
||||||
|
/// modded voxel's real registry name (`meta` carries no equivalent per-server data; see
|
||||||
|
/// `models::ModelRegistry::resolve`'s doc comment for why that's an accepted gap, not an
|
||||||
|
/// oversight).
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
pub struct BlockRegistryRow {
|
||||||
|
pub block_id: i32,
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn fetch_block_registry(pool: &PgPool, server_id: Uuid) -> anyhow::Result<Vec<BlockRegistryRow>> {
|
||||||
|
let rows = sqlx::query_as::<_, BlockRegistryRow>(r#"SELECT block_id, name FROM block_registry WHERE server_id = $1"#)
|
||||||
|
.bind(server_id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Phase 13: the mod's raw blockstate/model JSON dump for one server (see api's `models.ts`) —
|
||||||
|
/// `kind` is `"blockstate"` or `"model"`, matching `models::ModelRegistry::build`'s two input maps.
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
pub struct BlockModelRow {
|
||||||
|
pub kind: String,
|
||||||
|
pub name: String,
|
||||||
|
pub json: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn fetch_block_models(pool: &PgPool, server_id: Uuid) -> anyhow::Result<Vec<BlockModelRow>> {
|
||||||
|
let rows = sqlx::query_as::<_, BlockModelRow>(r#"SELECT kind, name, json FROM block_models WHERE server_id = $1"#)
|
||||||
|
.bind(server_id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub async fn upsert_mesh_pointer(
|
pub async fn upsert_mesh_pointer(
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ pub mod block_names;
|
|||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod db;
|
pub mod db;
|
||||||
pub mod mesh;
|
pub mod mesh;
|
||||||
|
pub mod models;
|
||||||
pub mod palette;
|
pub mod palette;
|
||||||
pub mod render;
|
pub mod render;
|
||||||
pub mod storage;
|
pub mod storage;
|
||||||
|
|||||||
+55
-3
@@ -1,8 +1,9 @@
|
|||||||
use std::collections::hash_map::DefaultHasher;
|
use std::collections::hash_map::DefaultHasher;
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::hash::{Hash, Hasher};
|
use std::hash::{Hash, Hasher};
|
||||||
|
|
||||||
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
||||||
use mcmapper_worker::{atlas, config, db, mesh, storage, textures};
|
use mcmapper_worker::{atlas, config, db, mesh, models, storage, textures};
|
||||||
use rayon::prelude::*;
|
use rayon::prelude::*;
|
||||||
use redis::streams::{StreamReadOptions, StreamReadReply};
|
use redis::streams::{StreamReadOptions, StreamReadReply};
|
||||||
use redis::AsyncCommands;
|
use redis::AsyncCommands;
|
||||||
@@ -124,6 +125,24 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
"[worker] failed to build texture atlas, 3D meshes will use flat vertex colors only: {err:#}"
|
"[worker] failed to build texture atlas, 3D meshes will use flat vertex colors only: {err:#}"
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phase 13: the vanilla half of the model registry — extracted from the same client jar,
|
||||||
|
// worker-wide like the palette/atlas above (see models.rs's doc comment). The modded half
|
||||||
|
// is per-server and fetched fresh per chunk-job below (see fetch_chunk_data), since
|
||||||
|
// block_registry/block_models are genuinely server-scoped data, unlike the vanilla jar.
|
||||||
|
match models::load_or_build(std::path::Path::new(&cache_dir), &mc_version).await {
|
||||||
|
Ok(registry) => {
|
||||||
|
println!(
|
||||||
|
"[worker] vanilla model registry ready ({} blockstates, {} models)",
|
||||||
|
registry.blockstate_count(),
|
||||||
|
registry.model_count()
|
||||||
|
);
|
||||||
|
mcmapper_worker::render::set_vanilla_model_registry(registry);
|
||||||
|
}
|
||||||
|
Err(err) => eprintln!(
|
||||||
|
"[worker] failed to build vanilla model registry, non-cube vanilla blocks will render as flat cubes: {err:#}"
|
||||||
|
),
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
println!("[worker] ACCEPT_MINECRAFT_EULA not set — using hand-picked palette colors (see README)");
|
println!("[worker] ACCEPT_MINECRAFT_EULA not set — using hand-picked palette colors (see README)");
|
||||||
}
|
}
|
||||||
@@ -267,6 +286,13 @@ struct ChunkData {
|
|||||||
job: ChunkJob,
|
job: ChunkJob,
|
||||||
columns: Vec<db::StoredColumn>,
|
columns: Vec<db::StoredColumn>,
|
||||||
sections: Vec<db::StoredSection>,
|
sections: Vec<db::StoredSection>,
|
||||||
|
/// Phase 13: this job's server-scoped modded model data (empty registry/map if the server has
|
||||||
|
/// never had a mod ship any, which is every server before its mod adopts `block_models` — see
|
||||||
|
/// the Mod repo's `BlockAssetExtractor`). Fetched fresh per job rather than cached across
|
||||||
|
/// batches: simplest correct thing, and mirrors this stage's existing "one DB round trip per
|
||||||
|
/// job" shape (see `columns`/`sections` above) rather than a new batch-level cache.
|
||||||
|
modded_models: models::ModelRegistry,
|
||||||
|
modded_id_to_name: HashMap<u16, String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn fetch_chunk_data(pool: &sqlx::PgPool, job: &ChunkJob) -> anyhow::Result<ChunkData> {
|
async fn fetch_chunk_data(pool: &sqlx::PgPool, job: &ChunkJob) -> anyhow::Result<ChunkData> {
|
||||||
@@ -274,7 +300,30 @@ async fn fetch_chunk_data(pool: &sqlx::PgPool, job: &ChunkJob) -> anyhow::Result
|
|||||||
db::fetch_chunk_columns(pool, job.server_id, job.dimension, job.chunk_x, job.chunk_z).await?;
|
db::fetch_chunk_columns(pool, job.server_id, job.dimension, job.chunk_x, job.chunk_z).await?;
|
||||||
let sections =
|
let sections =
|
||||||
db::fetch_chunk_sections(pool, job.server_id, job.dimension, job.chunk_x, job.chunk_z).await?;
|
db::fetch_chunk_sections(pool, job.server_id, job.dimension, job.chunk_x, job.chunk_z).await?;
|
||||||
Ok(ChunkData { job: job.clone(), columns, sections })
|
|
||||||
|
let registry_rows = db::fetch_block_registry(pool, job.server_id).await.unwrap_or_default();
|
||||||
|
let modded_id_to_name: HashMap<u16, String> = registry_rows
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|r| u16::try_from(r.block_id).ok().map(|id| (id, r.name)))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let model_rows = db::fetch_block_models(pool, job.server_id).await.unwrap_or_default();
|
||||||
|
let mut blockstate_files = HashMap::new();
|
||||||
|
let mut model_files = HashMap::new();
|
||||||
|
for row in model_rows {
|
||||||
|
match row.kind.as_str() {
|
||||||
|
"blockstate" => {
|
||||||
|
blockstate_files.insert(row.name, row.json);
|
||||||
|
}
|
||||||
|
"model" => {
|
||||||
|
model_files.insert(row.name, row.json);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let modded_models = models::ModelRegistry::build(&blockstate_files, &model_files);
|
||||||
|
|
||||||
|
Ok(ChunkData { job: job.clone(), columns, sections, modded_models, modded_id_to_name })
|
||||||
}
|
}
|
||||||
|
|
||||||
struct RenderedMesh {
|
struct RenderedMesh {
|
||||||
@@ -325,6 +374,9 @@ fn render_chunk(data: &ChunkData, backend: &dyn RenderBackend) -> anyhow::Result
|
|||||||
let png_bytes = backend.rasterize_tile(&pixels)?;
|
let png_bytes = backend.rasterize_tile(&pixels)?;
|
||||||
let tile_content_hash = content_hash(&png_bytes);
|
let tile_content_hash = content_hash(&png_bytes);
|
||||||
|
|
||||||
|
let model_ctx =
|
||||||
|
mcmapper_worker::render::model_context(Some(&data.modded_models), data.modded_id_to_name.clone());
|
||||||
|
|
||||||
let mut meshes = Vec::new();
|
let mut meshes = Vec::new();
|
||||||
for section in &data.sections {
|
for section in &data.sections {
|
||||||
let blocks = match decode_blocks(§ion.blocks) {
|
let blocks = match decode_blocks(§ion.blocks) {
|
||||||
@@ -337,7 +389,7 @@ fn render_chunk(data: &ChunkData, backend: &dyn RenderBackend) -> anyhow::Result
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let mesh_buf = mesh::mesh_section(&blocks, backend);
|
let mesh_buf = mesh::mesh_section(&blocks, backend, Some(&model_ctx));
|
||||||
if mesh_buf.is_empty() {
|
if mesh_buf.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
+196
-9
@@ -1,5 +1,7 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use crate::palette::color_for_textured;
|
use crate::palette::color_for_textured;
|
||||||
use crate::render::RenderBackend;
|
use crate::render::{ModelContext, RenderBackend};
|
||||||
|
|
||||||
const SIZE: i32 = 16;
|
const SIZE: i32 = 16;
|
||||||
|
|
||||||
@@ -109,9 +111,43 @@ fn axis_pos(axis: usize, layer: i32, u: i32, v: i32) -> (i32, i32, i32) {
|
|||||||
/// quads. The per-voxel visibility extraction may have run on GPU; this merge/compaction step is
|
/// quads. The per-voxel visibility extraction may have run on GPU; this merge/compaction step is
|
||||||
/// always CPU — it's sequential and branchy (each cell's fate depends on what its neighbors in
|
/// always CPU — it's sequential and branchy (each cell's fate depends on what its neighbors in
|
||||||
/// the same pass already claimed), not a good GPU-parallel fit.
|
/// the same pass already claimed), not a good GPU-parallel fit.
|
||||||
pub fn mesh_section(blocks: &[u16; 4096], backend: &dyn RenderBackend) -> MeshBuffers {
|
///
|
||||||
let face_masks = backend.compute_face_masks(blocks);
|
/// Phase 13: `models`, if given, is consulted per non-air voxel to resolve its real shape (see
|
||||||
|
/// `models::ModelRegistry::resolve`). Voxels that resolve to a genuinely non-cube model are
|
||||||
|
/// excluded from `blocks` before it's handed to the cube mesher (so they neither get a spurious
|
||||||
|
/// flat-cube quad of their own, nor incorrectly count as a "solid neighbor" that culls an
|
||||||
|
/// adjacent block's face — both fixed as a side effect of the same exclusion) and instead get
|
||||||
|
/// their own per-element box geometry emitted directly, unmerged, via `emit_non_cube_elements`.
|
||||||
|
/// Voxels that resolve to a full cube (`ResolvedModel::is_full_cube`) are deliberately left alone
|
||||||
|
/// — the existing cube path already renders them correctly via `block_names::texture_name`.
|
||||||
|
pub fn mesh_section(blocks: &[u16; 4096], backend: &dyn RenderBackend, models: Option<&ModelContext>) -> MeshBuffers {
|
||||||
let mut buf = MeshBuffers::default();
|
let mut buf = MeshBuffers::default();
|
||||||
|
let mut cube_blocks = *blocks;
|
||||||
|
|
||||||
|
if let Some(models) = models {
|
||||||
|
// Memoized per distinct packed block value (block_id<<4|meta) — a section can easily
|
||||||
|
// repeat the same non-cube block (a run of torches, a fence line) hundreds of times, and
|
||||||
|
// re-walking the same blockstate/model parent chain for each instance is wasted work.
|
||||||
|
let mut cache: HashMap<u16, Option<crate::models::ResolvedModel>> = HashMap::new();
|
||||||
|
for (i, &block) in blocks.iter().enumerate() {
|
||||||
|
if block == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let resolved = cache
|
||||||
|
.entry(block)
|
||||||
|
.or_insert_with(|| models.resolve(block >> 4, (block & 0xF) as u8))
|
||||||
|
.clone();
|
||||||
|
let Some(resolved) = resolved else { continue };
|
||||||
|
if resolved.is_full_cube() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
cube_blocks[i] = 0;
|
||||||
|
let (vx, vy, vz) = ((i % 16) as f32, (i / 256) as f32, ((i / 16) % 16) as f32);
|
||||||
|
emit_non_cube_elements(&resolved, block >> 4, (block & 0xF) as u8, vx, vy, vz, &mut buf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let face_masks = backend.compute_face_masks(&cube_blocks);
|
||||||
let mut face_index = 0;
|
let mut face_index = 0;
|
||||||
for axis in 0..3 {
|
for axis in 0..3 {
|
||||||
for &dir in &[-1i32, 1i32] {
|
for &dir in &[-1i32, 1i32] {
|
||||||
@@ -244,6 +280,99 @@ fn emit_quad(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Emits one resolved model's element boxes at voxel `(vx, vy, vz)` (section-local, block units)
|
||||||
|
/// directly into `buf` — each element is its own standalone quad set, never greedy-merged with
|
||||||
|
/// neighbors (unlike the cube path), since non-cube geometry rarely tiles cleanly and merging it
|
||||||
|
/// would need real per-shape adjacency logic this phase doesn't attempt. `color` is the block's
|
||||||
|
/// existing flat vertex-color fallback (same `color_for_textured` lookup the cube path uses,
|
||||||
|
/// keyed by block id/meta — not a per-face-texture color, so every face of one block shares it).
|
||||||
|
fn emit_non_cube_elements(
|
||||||
|
model: &crate::models::ResolvedModel,
|
||||||
|
block_id: u16,
|
||||||
|
block_meta: u8,
|
||||||
|
vx: f32,
|
||||||
|
vy: f32,
|
||||||
|
vz: f32,
|
||||||
|
buf: &mut MeshBuffers,
|
||||||
|
) {
|
||||||
|
let [r, g, b] = color_for_textured(block_id, block_meta, crate::render::texture_palette());
|
||||||
|
let color = [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0];
|
||||||
|
for element in &model.elements {
|
||||||
|
let from = [element.from[0] / 16.0, element.from[1] / 16.0, element.from[2] / 16.0];
|
||||||
|
let to = [element.to[0] / 16.0, element.to[1] / 16.0, element.to[2] / 16.0];
|
||||||
|
for (face_idx, texture) in element.faces.iter().enumerate() {
|
||||||
|
let Some(texture) = texture else { continue };
|
||||||
|
let axis = face_idx / 2;
|
||||||
|
let dir: i32 = if face_idx % 2 == 1 { 1 } else { -1 };
|
||||||
|
emit_element_face(axis, dir, from, to, texture, color, vx, vy, vz, buf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One face of one element — generalizes `emit_quad`'s axis/u/v scheme to arbitrary (possibly
|
||||||
|
/// sub-block, possibly off-origin) box faces instead of always-integer whole-section quads.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn emit_element_face(
|
||||||
|
axis: usize,
|
||||||
|
dir: i32,
|
||||||
|
from: [f32; 3],
|
||||||
|
to: [f32; 3],
|
||||||
|
texture: &str,
|
||||||
|
color: [f32; 3],
|
||||||
|
vx: f32,
|
||||||
|
vy: f32,
|
||||||
|
vz: f32,
|
||||||
|
buf: &mut MeshBuffers,
|
||||||
|
) {
|
||||||
|
let (u_axis, v_axis) = match axis {
|
||||||
|
0 => (1, 2),
|
||||||
|
1 => (0, 2),
|
||||||
|
_ => (0, 1),
|
||||||
|
};
|
||||||
|
let layer = if dir == 1 { to[axis] } else { from[axis] };
|
||||||
|
let (u0, u1) = (from[u_axis], to[u_axis]);
|
||||||
|
let (v0, v1) = (from[v_axis], to[v_axis]);
|
||||||
|
let corners_uv = [(u0, v0), (u1, v0), (u1, v1), (u0, v1)];
|
||||||
|
|
||||||
|
let normal = match (axis, dir) {
|
||||||
|
(0, 1) => [1.0, 0.0, 0.0],
|
||||||
|
(0, -1) => [-1.0, 0.0, 0.0],
|
||||||
|
(1, 1) => [0.0, 1.0, 0.0],
|
||||||
|
(1, -1) => [0.0, -1.0, 0.0],
|
||||||
|
(2, 1) => [0.0, 0.0, 1.0],
|
||||||
|
_ => [0.0, 0.0, -1.0],
|
||||||
|
};
|
||||||
|
|
||||||
|
let atlas_rect = crate::render::texture_atlas().and_then(|atlas| atlas.rect(texture)).unwrap_or(NO_ATLAS_RECT);
|
||||||
|
// Same tile-relative local-UV scheme as emit_quad's merged cube quads (see
|
||||||
|
// models::ModelRegistry::resolve's doc comment on why this isn't the model's literal declared
|
||||||
|
// UV rectangle) — an element's own width/height in block units, not a fixed 0..1, so a
|
||||||
|
// sub-block-sized face (e.g. a torch's 2/16-block-wide side) samples a matching fraction of
|
||||||
|
// the atlas tile rather than stretching the whole tile across a sliver of geometry.
|
||||||
|
let width = u1 - u0;
|
||||||
|
let height = v1 - v0;
|
||||||
|
let local_uvs = [[0.0, 0.0], [width, 0.0], [width, height], [0.0, height]];
|
||||||
|
|
||||||
|
let base_index = buf.positions.len() as u32;
|
||||||
|
for (i, (u, v)) in corners_uv.into_iter().enumerate() {
|
||||||
|
let mut pos = [0.0f32; 3];
|
||||||
|
pos[axis] = layer;
|
||||||
|
pos[u_axis] = u;
|
||||||
|
pos[v_axis] = v;
|
||||||
|
buf.positions.push([pos[0] + vx, pos[1] + vy, pos[2] + vz]);
|
||||||
|
buf.normals.push(normal);
|
||||||
|
buf.colors.push(color);
|
||||||
|
buf.uvs.push(local_uvs[i]);
|
||||||
|
buf.atlas_rects.push(atlas_rect);
|
||||||
|
}
|
||||||
|
|
||||||
|
if dir == 1 {
|
||||||
|
buf.indices.extend_from_slice(&[base_index, base_index + 1, base_index + 2, base_index, base_index + 2, base_index + 3]);
|
||||||
|
} else {
|
||||||
|
buf.indices.extend_from_slice(&[base_index, base_index + 2, base_index + 1, base_index, base_index + 3, base_index + 2]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -256,7 +385,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn empty_section_produces_no_geometry() {
|
fn empty_section_produces_no_geometry() {
|
||||||
let blocks = [0u16; 4096];
|
let blocks = [0u16; 4096];
|
||||||
let mesh = mesh_section(&blocks, &cpu());
|
let mesh = mesh_section(&blocks, &cpu(), None);
|
||||||
assert!(mesh.is_empty());
|
assert!(mesh.is_empty());
|
||||||
assert_eq!(mesh.positions.len(), 0);
|
assert_eq!(mesh.positions.len(), 0);
|
||||||
}
|
}
|
||||||
@@ -265,7 +394,7 @@ mod tests {
|
|||||||
fn single_voxel_produces_six_unmerged_quads() {
|
fn single_voxel_produces_six_unmerged_quads() {
|
||||||
let mut blocks = [0u16; 4096];
|
let mut blocks = [0u16; 4096];
|
||||||
blocks[((0 * 16 + 0) * 16 + 0) as usize] = (2 << 4) | 0; // grass at local (0,0,0)
|
blocks[((0 * 16 + 0) * 16 + 0) as usize] = (2 << 4) | 0; // grass at local (0,0,0)
|
||||||
let mesh = mesh_section(&blocks, &cpu());
|
let mesh = mesh_section(&blocks, &cpu(), None);
|
||||||
assert_eq!(mesh.positions.len(), 6 * 4, "6 faces x 4 verts");
|
assert_eq!(mesh.positions.len(), 6 * 4, "6 faces x 4 verts");
|
||||||
assert_eq!(mesh.indices.len(), 6 * 6, "6 faces x 2 tris x 3 indices");
|
assert_eq!(mesh.indices.len(), 6 * 6, "6 faces x 2 tris x 3 indices");
|
||||||
}
|
}
|
||||||
@@ -279,7 +408,7 @@ mod tests {
|
|||||||
for b in blocks.iter_mut() {
|
for b in blocks.iter_mut() {
|
||||||
*b = (1 << 4) | 0; // stone everywhere
|
*b = (1 << 4) | 0; // stone everywhere
|
||||||
}
|
}
|
||||||
let mesh = mesh_section(&blocks, &cpu());
|
let mesh = mesh_section(&blocks, &cpu(), None);
|
||||||
assert_eq!(mesh.positions.len(), 6 * 4, "6 merged outer faces x 4 verts");
|
assert_eq!(mesh.positions.len(), 6 * 4, "6 merged outer faces x 4 verts");
|
||||||
assert_eq!(mesh.indices.len(), 6 * 6);
|
assert_eq!(mesh.indices.len(), 6 * 6);
|
||||||
}
|
}
|
||||||
@@ -300,7 +429,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let backend = cpu();
|
let backend = cpu();
|
||||||
let mesh = mesh_section(&blocks, &backend);
|
let mesh = mesh_section(&blocks, &backend, None);
|
||||||
// Just check the +y (top) face count via a targeted single-axis call.
|
// Just check the +y (top) face count via a targeted single-axis call.
|
||||||
// face_index 3 = axis 1 (y), dir +1 — see compute_face_masks's doc comment for the
|
// face_index 3 = axis 1 (y), dir +1 — see compute_face_masks's doc comment for the
|
||||||
// face_index = axis*2 + (dir==1) convention.
|
// face_index = axis*2 + (dir==1) convention.
|
||||||
@@ -315,7 +444,7 @@ mod tests {
|
|||||||
fn encode_round_trip_header() {
|
fn encode_round_trip_header() {
|
||||||
let mut blocks = [0u16; 4096];
|
let mut blocks = [0u16; 4096];
|
||||||
blocks[0] = (2 << 4) | 0;
|
blocks[0] = (2 << 4) | 0;
|
||||||
let mesh = mesh_section(&blocks, &cpu());
|
let mesh = mesh_section(&blocks, &cpu(), None);
|
||||||
let bytes = mesh.encode();
|
let bytes = mesh.encode();
|
||||||
let vertex_count = u32::from_le_bytes(bytes[0..4].try_into().unwrap());
|
let vertex_count = u32::from_le_bytes(bytes[0..4].try_into().unwrap());
|
||||||
let index_count = u32::from_le_bytes(bytes[4..8].try_into().unwrap());
|
let index_count = u32::from_le_bytes(bytes[4..8].try_into().unwrap());
|
||||||
@@ -332,7 +461,7 @@ mod tests {
|
|||||||
// local UVs should still be well-formed (start at the origin).
|
// local UVs should still be well-formed (start at the origin).
|
||||||
let mut blocks = [0u16; 4096];
|
let mut blocks = [0u16; 4096];
|
||||||
blocks[0] = (1 << 4) | 0; // stone
|
blocks[0] = (1 << 4) | 0; // stone
|
||||||
let mesh = mesh_section(&blocks, &cpu());
|
let mesh = mesh_section(&blocks, &cpu(), None);
|
||||||
assert!(!mesh.atlas_rects.is_empty());
|
assert!(!mesh.atlas_rects.is_empty());
|
||||||
for rect in &mesh.atlas_rects {
|
for rect in &mesh.atlas_rects {
|
||||||
assert_eq!(*rect, NO_ATLAS_RECT);
|
assert_eq!(*rect, NO_ATLAS_RECT);
|
||||||
@@ -359,4 +488,62 @@ mod tests {
|
|||||||
assert_eq!(max_u, 16.0);
|
assert_eq!(max_u, 16.0);
|
||||||
assert_eq!(max_v, 16.0);
|
assert_eq!(max_v, 16.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn torch_vanilla_registry() -> crate::models::ModelRegistry {
|
||||||
|
let mut bs = HashMap::new();
|
||||||
|
bs.insert(
|
||||||
|
"minecraft:torch".to_string(),
|
||||||
|
r#"{"variants":{"":{"model":"minecraft:block/torch"}}}"#.to_string(),
|
||||||
|
);
|
||||||
|
let mut models = HashMap::new();
|
||||||
|
models.insert(
|
||||||
|
"minecraft:block/torch".to_string(),
|
||||||
|
r##"{"textures":{"torch":"blocks/torch"},"elements":[{"from":[7,0,7],"to":[9,10,9],"faces":{
|
||||||
|
"up":{"texture":"#torch"}
|
||||||
|
}}]}"##
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
crate::models::ModelRegistry::build(&bs, &models)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_cube_block_is_excluded_from_cube_meshing_and_emits_its_own_geometry() {
|
||||||
|
let vanilla = torch_vanilla_registry();
|
||||||
|
let ctx = ModelContext { vanilla: Some(&vanilla), modded: None, modded_id_to_name: HashMap::new() };
|
||||||
|
|
||||||
|
let mut blocks = [0u16; 4096];
|
||||||
|
blocks[0] = (50 << 4) | 0; // torch at local (0,0,0), block_id 50 per vanilla_model_name
|
||||||
|
let mesh = mesh_section(&blocks, &cpu(), Some(&ctx));
|
||||||
|
|
||||||
|
// Only the torch model's single "up" face was declared — one quad, not a flat cube
|
||||||
|
// (which single_voxel_produces_six_unmerged_quads shows would be 6 quads / 24 verts).
|
||||||
|
assert_eq!(mesh.positions.len(), 4);
|
||||||
|
assert_eq!(mesh.indices.len(), 6);
|
||||||
|
// The element spans local (7,0,7)-(9,10,9) in 0..16 model space -> (0.4375, 0, 0.4375) to
|
||||||
|
// (0.5625, 0.625, 0.5625) in block units, offset by the voxel's own (0,0,0) position.
|
||||||
|
let ys: Vec<f32> = mesh.positions.iter().map(|p| p[1]).collect();
|
||||||
|
assert!((ys.iter().cloned().fold(0.0f32, f32::max) - 0.625).abs() < 1e-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_non_cube_neighbor_no_longer_incorrectly_culls_an_adjacent_solid_faces() {
|
||||||
|
// Before Phase 13, any non-zero block value (torch included) counted as a "solid
|
||||||
|
// neighbor" for face-culling purposes, since compute_face_masks only ever checked
|
||||||
|
// "non-zero" — this incorrectly culled a solid block's face against a torch sitting right
|
||||||
|
// next to it. Excluding non-cube blocks from `cube_blocks` (see mesh_section's doc
|
||||||
|
// comment) fixes this as a side effect: stone at (0,0,0) next to a torch at (1,0,0) must
|
||||||
|
// still show its +x face.
|
||||||
|
let vanilla = torch_vanilla_registry();
|
||||||
|
let ctx = ModelContext { vanilla: Some(&vanilla), modded: None, modded_id_to_name: HashMap::new() };
|
||||||
|
|
||||||
|
let mut blocks = [0u16; 4096];
|
||||||
|
blocks[0] = (1 << 4) | 0; // stone at (0,0,0)
|
||||||
|
blocks[1] = (50 << 4) | 0; // torch at (1,0,0)
|
||||||
|
let mesh = mesh_section(&blocks, &cpu(), Some(&ctx));
|
||||||
|
|
||||||
|
// 6 unmerged stone cube quads (24 verts / 36 indices, same shape as
|
||||||
|
// single_voxel_produces_six_unmerged_quads) + 1 torch quad (4 verts / 6 indices).
|
||||||
|
assert_eq!(mesh.positions.len(), 28);
|
||||||
|
assert_eq!(mesh.indices.len(), 42);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,512 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::io::{Cursor, Read};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
/// A single resolved 3D model as a flat list of axis-aligned element boxes, ready for `mesh.rs`
|
||||||
|
/// to render as standalone (non-greedy-merged) geometry — the Phase 13 counterpart to the cube
|
||||||
|
/// greedy-mesher for blocks whose real shape isn't a full 0..16/0..16/0..16 cube (torches, stairs,
|
||||||
|
/// fences, and — the named stress test — Thaumcraft's extensive custom block models).
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct ResolvedModel {
|
||||||
|
pub elements: Vec<ResolvedElement>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ResolvedModel {
|
||||||
|
/// A single element spanning the full 0..16 cube on every axis with all 6 faces present is
|
||||||
|
/// indistinguishable from what the existing greedy cube mesher already draws (just via a
|
||||||
|
/// different texture-lookup convention) — `mesh.rs` leaves these on the old path rather than
|
||||||
|
/// double-rendering them, see its doc comment.
|
||||||
|
pub fn is_full_cube(&self) -> bool {
|
||||||
|
matches!(self.elements.as_slice(), [only] if only.from == [0.0, 0.0, 0.0]
|
||||||
|
&& only.to == [16.0, 16.0, 16.0]
|
||||||
|
&& only.faces.iter().all(|f| f.is_some()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `faces[i]` indexed by the same `axis*2 + (dir==1)` convention `mesh.rs`/`render::FaceMasks`
|
||||||
|
/// already use: 0=-x(west) 1=+x(east) 2=-y(down) 3=+y(up) 4=-z(north) 5=+z(south).
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct ResolvedElement {
|
||||||
|
pub from: [f32; 3],
|
||||||
|
pub to: [f32; 3],
|
||||||
|
pub faces: [Option<String>; 6],
|
||||||
|
}
|
||||||
|
|
||||||
|
fn face_index(name: &str) -> Option<usize> {
|
||||||
|
Some(match name {
|
||||||
|
"west" => 0,
|
||||||
|
"east" => 1,
|
||||||
|
"down" => 2,
|
||||||
|
"up" => 3,
|
||||||
|
"north" => 4,
|
||||||
|
"south" => 5,
|
||||||
|
_ => return None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Clone)]
|
||||||
|
struct RawBlockState {
|
||||||
|
#[serde(default)]
|
||||||
|
variants: HashMap<String, VariantValue>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Clone)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
enum VariantValue {
|
||||||
|
Single(VariantEntry),
|
||||||
|
List(Vec<VariantEntry>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VariantValue {
|
||||||
|
fn first(&self) -> Option<&VariantEntry> {
|
||||||
|
match self {
|
||||||
|
VariantValue::Single(v) => Some(v),
|
||||||
|
VariantValue::List(v) => v.first(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Clone)]
|
||||||
|
struct VariantEntry {
|
||||||
|
model: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct RawModel {
|
||||||
|
parent: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
textures: HashMap<String, String>,
|
||||||
|
elements: Option<Vec<RawElement>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Clone)]
|
||||||
|
struct RawElement {
|
||||||
|
from: [f32; 3],
|
||||||
|
to: [f32; 3],
|
||||||
|
#[serde(default)]
|
||||||
|
faces: HashMap<String, RawFace>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Clone)]
|
||||||
|
struct RawFace {
|
||||||
|
texture: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parsed blockstate + model JSON for one "source" — either the vanilla set (extracted from the
|
||||||
|
/// downloaded Mojang client jar, see `load_or_build`) or one server's modded set (shipped by the
|
||||||
|
/// mod's `BlockAssetExtractor`, see the Mod repo, and stored per-server in Postgres). Deliberately
|
||||||
|
/// dumb about anything beyond single-representative-variant resolution — see `resolve`'s doc
|
||||||
|
/// comment for exactly what's out of scope and why.
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct ModelRegistry {
|
||||||
|
blockstates: HashMap<String, RawBlockStateDebug>,
|
||||||
|
models: HashMap<String, RawModelDebug>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// serde types above can't cheaply derive Debug (HashMap<String, RawFace> etc. don't need to be
|
||||||
|
// inspectable), so the registry wraps them in a thin newtype that just says "opaque" — only used
|
||||||
|
// so `ModelRegistry` itself can derive `Debug` for tests/assertions without extra ceremony at
|
||||||
|
// every call site.
|
||||||
|
struct RawBlockStateDebug(RawBlockState);
|
||||||
|
impl std::fmt::Debug for RawBlockStateDebug {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "RawBlockState({} variants)", self.0.variants.len())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
struct RawModelDebug(RawModel);
|
||||||
|
impl std::fmt::Debug for RawModelDebug {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "RawModel(parent={:?})", self.0.parent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ModelRegistry {
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.blockstates.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn blockstate_count(&self) -> usize {
|
||||||
|
self.blockstates.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn model_count(&self) -> usize {
|
||||||
|
self.models.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses every `(name, json)` pair, silently skipping entries that fail to parse — matches
|
||||||
|
/// this crate's existing "skip malformed input, never hard-fail a whole batch over one bad
|
||||||
|
/// entry" pattern (e.g. `main.rs`'s `decode_blocks` error handling for a malformed section).
|
||||||
|
pub fn build(blockstate_files: &HashMap<String, String>, model_files: &HashMap<String, String>) -> ModelRegistry {
|
||||||
|
let mut blockstates = HashMap::new();
|
||||||
|
for (name, json) in blockstate_files {
|
||||||
|
if let Ok(parsed) = serde_json::from_str::<RawBlockState>(json) {
|
||||||
|
blockstates.insert(name.clone(), RawBlockStateDebug(parsed));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut models = HashMap::new();
|
||||||
|
for (name, json) in model_files {
|
||||||
|
if let Ok(parsed) = serde_json::from_str::<RawModel>(json) {
|
||||||
|
models.insert(name.clone(), RawModelDebug(parsed));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ModelRegistry { blockstates, models }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves `registry_name` (e.g. `"minecraft:torch"`, `"thaumcraft:blockcustomplant"`) to
|
||||||
|
/// real element geometry: blockstate variant selection, model parent-chain walking, and
|
||||||
|
/// texture-variable substitution. `fallback` (typically the vanilla registry) is consulted
|
||||||
|
/// for any model reference not found in `self` — a modded block's model can `"parent"` a
|
||||||
|
/// vanilla one (e.g. `"minecraft:block/cross"`), and vanilla models are never shipped by the
|
||||||
|
/// mod (they're not on a dedicated server's classpath at all — see the Mod repo's
|
||||||
|
/// `BlockAssetExtractor` doc comment), only extracted backend-side from the client jar.
|
||||||
|
///
|
||||||
|
/// Several real blockstate/model features are deliberately **not** implemented — this is a
|
||||||
|
/// best-effort "show the real shape", not a full model resolver:
|
||||||
|
/// - `multipart` blockstates (fences/walls/redstone wire/glass panes) aren't resolved at all
|
||||||
|
/// — there's no way to know neighbor-dependent connection state from this project's raw
|
||||||
|
/// `block_id`/`meta` chunk data anyway. Returns `None`; the caller falls back to the
|
||||||
|
/// existing flat-cube rendering, same as any other unresolved block.
|
||||||
|
/// - When a `variants` map has multiple property-keyed entries (true for most blocks that
|
||||||
|
/// have *any* blockstate property), there's no way to know which one applies — this
|
||||||
|
/// project's chunk data model only ever carries a numeric `meta` (legacy blocks) or a
|
||||||
|
/// truncated packed state id (modern, see the Phase 10 `char[]` truncation note), never
|
||||||
|
/// named property values. This always picks one deterministic representative variant
|
||||||
|
/// (`""` if present, else the alphabetically-first key) — the same "no property data"
|
||||||
|
/// simplification already accepted for texture-name resolution elsewhere in this crate.
|
||||||
|
/// - Per-face `uv`/`rotation` and per-variant `x`/`y` block rotation are ignored. Element
|
||||||
|
/// geometry (`from`/`to`) is real, but texture mapping reuses the same tile-relative
|
||||||
|
/// "coordinate span in block units" scheme the existing cube mesher already uses (see
|
||||||
|
/// `mesh.rs::emit_quad`'s local UV), not the model's literal declared UV rectangle.
|
||||||
|
/// - A weighted multi-model variant list always picks its first entry, ignoring `weight`.
|
||||||
|
pub fn resolve(&self, registry_name: &str, fallback: Option<&ModelRegistry>) -> Option<ResolvedModel> {
|
||||||
|
let blockstate = self
|
||||||
|
.blockstates
|
||||||
|
.get(registry_name)
|
||||||
|
.or_else(|| fallback.and_then(|f| f.blockstates.get(registry_name)))?;
|
||||||
|
let variants = &blockstate.0.variants;
|
||||||
|
if variants.is_empty() {
|
||||||
|
return None; // multipart-only (or genuinely empty) — not resolved, see doc comment
|
||||||
|
}
|
||||||
|
let variant = match variants.get("") {
|
||||||
|
Some(v) => v,
|
||||||
|
None => {
|
||||||
|
let mut keys: Vec<&String> = variants.keys().collect();
|
||||||
|
keys.sort();
|
||||||
|
variants.get(keys.first()?.as_str())?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.first()?;
|
||||||
|
|
||||||
|
let mut current = normalize_ref(&variant.model);
|
||||||
|
let mut textures_merged: HashMap<String, String> = HashMap::new();
|
||||||
|
let mut elements: Option<Vec<RawElement>> = None;
|
||||||
|
for _ in 0..16 {
|
||||||
|
let model = self
|
||||||
|
.models
|
||||||
|
.get(current.as_str())
|
||||||
|
.or_else(|| fallback.and_then(|f| f.models.get(current.as_str())))?;
|
||||||
|
for (k, v) in &model.0.textures {
|
||||||
|
textures_merged.entry(k.clone()).or_insert_with(|| v.clone());
|
||||||
|
}
|
||||||
|
if elements.is_none() {
|
||||||
|
if let Some(e) = &model.0.elements {
|
||||||
|
elements = Some(e.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match &model.0.parent {
|
||||||
|
Some(parent) => current = normalize_ref(parent),
|
||||||
|
None => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let elements = elements?;
|
||||||
|
|
||||||
|
let resolved_elements = elements
|
||||||
|
.into_iter()
|
||||||
|
.map(|el| {
|
||||||
|
let mut faces: [Option<String>; 6] = Default::default();
|
||||||
|
for (face_name, raw_face) in &el.faces {
|
||||||
|
let Some(idx) = face_index(face_name) else { continue };
|
||||||
|
faces[idx] = resolve_texture(&raw_face.texture, &textures_merged);
|
||||||
|
}
|
||||||
|
ResolvedElement { from: el.from, to: el.to, faces }
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Some(ResolvedModel { elements: resolved_elements })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vanilla blockstate/model JSON omits the `minecraft:` namespace on internal references (a bare
|
||||||
|
/// `"block/torch"` model ref, or `"#all"` texture var) — this project's registries always key
|
||||||
|
/// vanilla entries with an explicit `minecraft:` prefix (see `extract_vanilla_source`) to keep
|
||||||
|
/// lookups uniform with modded entries (which are always already fully-qualified `"modid:path"`,
|
||||||
|
/// matching `block_registry.name`'s convention — see the api's `textures.ts` doc comment).
|
||||||
|
fn normalize_ref(s: &str) -> String {
|
||||||
|
if s.contains(':') {
|
||||||
|
s.to_string()
|
||||||
|
} else {
|
||||||
|
format!("minecraft:{s}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Follows `#variable` chains (a face's `texture` can point at another texture-map key, e.g.
|
||||||
|
/// `"#all": "#base"`, `"#base": "block/stone"`) to a literal texture path, then extracts the atlas
|
||||||
|
/// lookup key (the same file-stem convention `atlas.rs`/`textures.rs` key their maps by, e.g.
|
||||||
|
/// `"minecraft:block/stone"` -> `"stone"`) from it. Returns `None` on an unresolvable variable
|
||||||
|
/// (references a texture key nothing defines) or a pathological cycle, rather than guessing.
|
||||||
|
fn resolve_texture(texture: &str, textures: &HashMap<String, String>) -> Option<String> {
|
||||||
|
let mut current: &str = texture;
|
||||||
|
for _ in 0..16 {
|
||||||
|
match current.strip_prefix('#') {
|
||||||
|
Some(var) => current = textures.get(var)?.as_str(),
|
||||||
|
None => {
|
||||||
|
let stem = current.rsplit('/').next().unwrap_or(current);
|
||||||
|
return Some(stem.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Serialize, serde::Deserialize)]
|
||||||
|
struct CachedSource {
|
||||||
|
blockstates: HashMap<String, String>,
|
||||||
|
models: HashMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads a cached vanilla model registry from `<cache_dir>/vanilla-<version>-models.json` if
|
||||||
|
/// present, otherwise downloads the Mojang client jar (reuses `textures::download_client_jar_bytes`
|
||||||
|
/// — a small one-time duplicate download on a cold cache, the same accepted tradeoff `atlas.rs`
|
||||||
|
/// already makes for keeping each build path independent) and extracts every
|
||||||
|
/// `assets/minecraft/blockstates/*.json` and `assets/minecraft/models/block/**/*.json` entry.
|
||||||
|
pub async fn load_or_build(cache_dir: &Path, mc_version: &str) -> anyhow::Result<ModelRegistry> {
|
||||||
|
let cache_path = cache_dir.join(format!("vanilla-{mc_version}-models.json"));
|
||||||
|
if let Ok(bytes) = std::fs::read(&cache_path) {
|
||||||
|
if let Ok(source) = serde_json::from_slice::<CachedSource>(&bytes) {
|
||||||
|
let registry = ModelRegistry::build(&source.blockstates, &source.models);
|
||||||
|
println!(
|
||||||
|
"[worker] loaded cached vanilla model registry ({} blockstates, {} models) from {}",
|
||||||
|
registry.blockstate_count(),
|
||||||
|
registry.model_count(),
|
||||||
|
cache_path.display()
|
||||||
|
);
|
||||||
|
return Ok(registry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"[worker] downloading Minecraft {mc_version} client jar from Mojang to build the vanilla model registry..."
|
||||||
|
);
|
||||||
|
let jar_bytes = crate::textures::download_client_jar_bytes(mc_version).await?;
|
||||||
|
let source = extract_vanilla_source(&jar_bytes)?;
|
||||||
|
|
||||||
|
std::fs::create_dir_all(cache_dir)?;
|
||||||
|
std::fs::write(&cache_path, serde_json::to_vec(&source)?)?;
|
||||||
|
|
||||||
|
let registry = ModelRegistry::build(&source.blockstates, &source.models);
|
||||||
|
println!(
|
||||||
|
"[worker] built vanilla model registry ({} blockstates, {} models), cached to {}",
|
||||||
|
registry.blockstate_count(),
|
||||||
|
registry.model_count(),
|
||||||
|
cache_path.display()
|
||||||
|
);
|
||||||
|
Ok(registry)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_vanilla_source(jar_bytes: &[u8]) -> anyhow::Result<CachedSource> {
|
||||||
|
let mut archive = zip::ZipArchive::new(Cursor::new(jar_bytes))?;
|
||||||
|
let mut blockstates = HashMap::new();
|
||||||
|
let mut models = HashMap::new();
|
||||||
|
for i in 0..archive.len() {
|
||||||
|
let mut file = archive.by_index(i)?;
|
||||||
|
let name = file.name().to_string();
|
||||||
|
if let Some(path) =
|
||||||
|
name.strip_prefix("assets/minecraft/blockstates/").and_then(|p| p.strip_suffix(".json"))
|
||||||
|
{
|
||||||
|
let mut text = String::new();
|
||||||
|
if file.read_to_string(&mut text).is_ok() {
|
||||||
|
blockstates.insert(format!("minecraft:{path}"), text);
|
||||||
|
}
|
||||||
|
} else if let Some(path) =
|
||||||
|
name.strip_prefix("assets/minecraft/models/block/").and_then(|p| p.strip_suffix(".json"))
|
||||||
|
{
|
||||||
|
let mut text = String::new();
|
||||||
|
if file.read_to_string(&mut text).is_ok() {
|
||||||
|
models.insert(format!("minecraft:block/{path}"), text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(CachedSource { blockstates, models })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves one voxel's `(block_id, meta)` to real geometry, if any is known — the single entry
|
||||||
|
/// point `mesh.rs` calls per non-air voxel. Modded blocks take priority over vanilla when both a
|
||||||
|
/// per-server registry name (`modded_id_to_name`, sourced from the `block_registry` table — see
|
||||||
|
/// the api's `textures.ts`) and a matching modded model are available; `meta` is never consulted
|
||||||
|
/// for modded blocks since `block_registry` only carries `block_id -> name` (no per-state
|
||||||
|
/// granularity — the mod can't cheaply enumerate every possible state, and it wouldn't help
|
||||||
|
/// without the property-string data `ModelRegistry::resolve`'s doc comment already flags as
|
||||||
|
/// missing). Falls back to a small built-in table of vanilla non-cube blocks (`vanilla_model_name`,
|
||||||
|
/// `block_names.rs`) when there's no per-server match.
|
||||||
|
pub fn resolve_for_block(
|
||||||
|
vanilla: Option<&ModelRegistry>,
|
||||||
|
modded: Option<&ModelRegistry>,
|
||||||
|
modded_id_to_name: &HashMap<u16, String>,
|
||||||
|
block_id: u16,
|
||||||
|
meta: u8,
|
||||||
|
) -> Option<ResolvedModel> {
|
||||||
|
if let (Some(name), Some(modded_registry)) = (modded_id_to_name.get(&block_id), modded) {
|
||||||
|
if let Some(resolved) = modded_registry.resolve(name, vanilla) {
|
||||||
|
return Some(resolved);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let name = crate::block_names::vanilla_model_name(block_id, meta)?;
|
||||||
|
vanilla?.resolve(name, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn full_cube_source() -> (HashMap<String, String>, HashMap<String, String>) {
|
||||||
|
let blockstates = HashMap::from([(
|
||||||
|
"minecraft:stone".to_string(),
|
||||||
|
r#"{"variants":{"":{"model":"minecraft:block/stone"}}}"#.to_string(),
|
||||||
|
)]);
|
||||||
|
let models = HashMap::from([(
|
||||||
|
"minecraft:block/stone".to_string(),
|
||||||
|
r#"{"parent":"block/cube_all","textures":{"all":"block/stone"}}"#.to_string(),
|
||||||
|
), (
|
||||||
|
"minecraft:block/cube_all".to_string(),
|
||||||
|
r##"{"elements":[{"from":[0,0,0],"to":[16,16,16],"faces":{
|
||||||
|
"down":{"texture":"#all"},"up":{"texture":"#all"},
|
||||||
|
"north":{"texture":"#all"},"south":{"texture":"#all"},
|
||||||
|
"west":{"texture":"#all"},"east":{"texture":"#all"}
|
||||||
|
}}]}"##.to_string(),
|
||||||
|
)]);
|
||||||
|
(blockstates, models)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolves_a_full_cube_via_a_parent_chain_and_texture_variable() {
|
||||||
|
let (bs, models) = full_cube_source();
|
||||||
|
let registry = ModelRegistry::build(&bs, &models);
|
||||||
|
let resolved = registry.resolve("minecraft:stone", None).unwrap();
|
||||||
|
assert!(resolved.is_full_cube());
|
||||||
|
assert_eq!(resolved.elements[0].faces[3], Some("stone".to_string())); // +y = up
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolves_a_non_cube_torch_shaped_model() {
|
||||||
|
let mut bs = HashMap::new();
|
||||||
|
bs.insert(
|
||||||
|
"minecraft:torch".to_string(),
|
||||||
|
r#"{"variants":{"":{"model":"minecraft:block/torch"}}}"#.to_string(),
|
||||||
|
);
|
||||||
|
let mut models = HashMap::new();
|
||||||
|
models.insert(
|
||||||
|
"minecraft:block/torch".to_string(),
|
||||||
|
r##"{"textures":{"torch":"block/torch"},"elements":[{"from":[7,0,7],"to":[9,10,9],"faces":{
|
||||||
|
"up":{"texture":"#torch"},"down":{"texture":"#torch"}
|
||||||
|
}}]}"##.to_string(),
|
||||||
|
);
|
||||||
|
let registry = ModelRegistry::build(&bs, &models);
|
||||||
|
let resolved = registry.resolve("minecraft:torch", None).unwrap();
|
||||||
|
assert!(!resolved.is_full_cube());
|
||||||
|
assert_eq!(resolved.elements.len(), 1);
|
||||||
|
assert_eq!(resolved.elements[0].from, [7.0, 0.0, 7.0]);
|
||||||
|
assert_eq!(resolved.elements[0].faces[3], Some("torch".to_string()));
|
||||||
|
assert_eq!(resolved.elements[0].faces[0], None); // west face not modeled
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn multipart_only_blockstate_is_not_resolved() {
|
||||||
|
let mut bs = HashMap::new();
|
||||||
|
bs.insert(
|
||||||
|
"minecraft:oak_fence".to_string(),
|
||||||
|
r#"{"multipart":[{"apply":{"model":"minecraft:block/oak_fence_post"}}]}"#.to_string(),
|
||||||
|
);
|
||||||
|
let registry = ModelRegistry::build(&bs, &HashMap::new());
|
||||||
|
assert!(registry.resolve("minecraft:oak_fence", None).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_modded_model_can_parent_a_vanilla_one_via_the_fallback_registry() {
|
||||||
|
let (vanilla_bs, vanilla_models) = full_cube_source();
|
||||||
|
let vanilla = ModelRegistry::build(&vanilla_bs, &vanilla_models);
|
||||||
|
|
||||||
|
let mut modded_bs = HashMap::new();
|
||||||
|
modded_bs.insert(
|
||||||
|
"thaumcraft:blockcustomplant".to_string(),
|
||||||
|
r#"{"variants":{"":{"model":"thaumcraft:block/customplant"}}}"#.to_string(),
|
||||||
|
);
|
||||||
|
let mut modded_models = HashMap::new();
|
||||||
|
modded_models.insert(
|
||||||
|
"thaumcraft:block/customplant".to_string(),
|
||||||
|
r#"{"parent":"minecraft:block/cube_all","textures":{"all":"thaumcraft:block/customplant"}}"#
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
let modded = ModelRegistry::build(&modded_bs, &modded_models);
|
||||||
|
|
||||||
|
let resolved = modded.resolve("thaumcraft:blockcustomplant", Some(&vanilla)).unwrap();
|
||||||
|
assert!(resolved.is_full_cube());
|
||||||
|
assert_eq!(resolved.elements[0].faces[3], Some("customplant".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_registry_name_resolves_to_none() {
|
||||||
|
let registry = ModelRegistry::build(&HashMap::new(), &HashMap::new());
|
||||||
|
assert!(registry.resolve("minecraft:does_not_exist", None).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_for_block_prefers_modded_over_vanilla_for_a_known_id() {
|
||||||
|
let (vanilla_bs, vanilla_models) = full_cube_source();
|
||||||
|
let vanilla = ModelRegistry::build(&vanilla_bs, &vanilla_models);
|
||||||
|
|
||||||
|
let mut modded_bs = HashMap::new();
|
||||||
|
modded_bs.insert(
|
||||||
|
"thaumcraft:manapool".to_string(),
|
||||||
|
r#"{"variants":{"":{"model":"thaumcraft:block/manapool"}}}"#.to_string(),
|
||||||
|
);
|
||||||
|
let mut modded_models = HashMap::new();
|
||||||
|
modded_models.insert(
|
||||||
|
"thaumcraft:block/manapool".to_string(),
|
||||||
|
r##"{"textures":{"top":"thaumcraft:block/manapool_top"},"elements":[{"from":[0,0,0],"to":[16,4,16],"faces":{"up":{"texture":"#top"}}}]}"##.to_string(),
|
||||||
|
);
|
||||||
|
let modded = ModelRegistry::build(&modded_bs, &modded_models);
|
||||||
|
|
||||||
|
let mut id_to_name = HashMap::new();
|
||||||
|
id_to_name.insert(4000u16, "thaumcraft:manapool".to_string());
|
||||||
|
|
||||||
|
let resolved = resolve_for_block(Some(&vanilla), Some(&modded), &id_to_name, 4000, 0).unwrap();
|
||||||
|
assert_eq!(resolved.elements[0].to, [16.0, 4.0, 16.0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_for_block_falls_back_to_vanilla_for_an_unknown_id() {
|
||||||
|
let (vanilla_bs, vanilla_models) = full_cube_source();
|
||||||
|
let vanilla = ModelRegistry::build(&vanilla_bs, &vanilla_models);
|
||||||
|
// block_id 1 = stone, matches block_names::vanilla_model_name's vanilla table indirectly
|
||||||
|
// via a full-cube resolve — but stone isn't in the small non-cube demo table, so this
|
||||||
|
// should resolve to None (block_names::vanilla_model_name only covers non-cube blocks —
|
||||||
|
// see that function's doc comment) even though "minecraft:stone" itself is resolvable.
|
||||||
|
assert!(resolve_for_block(Some(&vanilla), None, &HashMap::new(), 1, 0).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_texture_follows_a_variable_chain_to_a_literal_stem() {
|
||||||
|
let mut textures = HashMap::new();
|
||||||
|
textures.insert("all".to_string(), "#base".to_string());
|
||||||
|
textures.insert("base".to_string(), "minecraft:block/stone".to_string());
|
||||||
|
assert_eq!(resolve_texture("#all", &textures), Some("stone".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_texture_on_an_unresolvable_variable_is_none() {
|
||||||
|
let textures = HashMap::new();
|
||||||
|
assert_eq!(resolve_texture("#missing", &textures), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,6 +48,51 @@ pub(crate) fn texture_atlas() -> Option<&'static crate::atlas::TextureAtlas> {
|
|||||||
TEXTURE_ATLAS.get()
|
TEXTURE_ATLAS.get()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Same `OnceLock`-once-at-startup pattern as `TEXTURE_PALETTE`/`TEXTURE_ATLAS`, for the Phase 13
|
||||||
|
/// vanilla model registry (see `models.rs`). Worker-wide, not per-server — the same simplification
|
||||||
|
/// tier already accepted for the texture palette/atlas. `None` until `main.rs` successfully builds
|
||||||
|
/// one; `mesh.rs` simply skips non-cube resolution entirely when this is `None`.
|
||||||
|
static VANILLA_MODEL_REGISTRY: OnceLock<crate::models::ModelRegistry> = OnceLock::new();
|
||||||
|
|
||||||
|
pub fn set_vanilla_model_registry(registry: crate::models::ModelRegistry) {
|
||||||
|
let _ = VANILLA_MODEL_REGISTRY.set(registry);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn vanilla_model_registry() -> Option<&'static crate::models::ModelRegistry> {
|
||||||
|
VANILLA_MODEL_REGISTRY.get()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bundles everything `mesh.rs` needs to resolve one voxel's real (possibly non-cube) shape:
|
||||||
|
/// the worker-wide vanilla registry plus one job's per-server modded registry/id-map, both
|
||||||
|
/// optional (a server with no modded model data yet, or a worker that never built the vanilla
|
||||||
|
/// registry, simply resolves nothing — mesh_section falls back to its pre-Phase-13 behavior).
|
||||||
|
/// Built fresh per chunk-job in `main.rs` (the modded half is genuinely per-server; the vanilla
|
||||||
|
/// half is just a borrow of the static above) rather than stored globally, since it's cheap and
|
||||||
|
/// keeps `mesh_section` ignorant of *where* the data came from.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct ModelContext<'a> {
|
||||||
|
pub vanilla: Option<&'a crate::models::ModelRegistry>,
|
||||||
|
pub modded: Option<&'a crate::models::ModelRegistry>,
|
||||||
|
pub modded_id_to_name: std::collections::HashMap<u16, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> ModelContext<'a> {
|
||||||
|
pub fn resolve(&self, block_id: u16, meta: u8) -> Option<crate::models::ResolvedModel> {
|
||||||
|
crate::models::resolve_for_block(self.vanilla, self.modded, &self.modded_id_to_name, block_id, meta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds a `ModelContext` for one chunk-job, combining the worker-wide vanilla registry (read
|
||||||
|
/// from the `OnceLock` above — kept private to this module, `main.rs` never reads it directly) with
|
||||||
|
/// the caller-supplied per-server modded registry/id-map (see `main.rs`'s `fetch_chunk_data`,
|
||||||
|
/// which queries `block_registry`/`block_models` for the job's `server_id`).
|
||||||
|
pub fn model_context(
|
||||||
|
modded: Option<&crate::models::ModelRegistry>,
|
||||||
|
modded_id_to_name: std::collections::HashMap<u16, String>,
|
||||||
|
) -> ModelContext<'_> {
|
||||||
|
ModelContext { vanilla: vanilla_model_registry(), modded, modded_id_to_name }
|
||||||
|
}
|
||||||
|
|
||||||
/// One rendered column within a chunk, in chunk-local coordinates (0..16).
|
/// One rendered column within a chunk, in chunk-local coordinates (0..16).
|
||||||
pub struct ColumnPixel {
|
pub struct ColumnPixel {
|
||||||
pub local_x: u8,
|
pub local_x: u8,
|
||||||
|
|||||||
Reference in New Issue
Block a user