Phase 12: texture atlas + UV-mapped 3D mesh textures

Fixes a real gap left over from Phase 11: mesh.rs's 3D mesher was still
using the hand-picked flat palette instead of texture-averaged colors.
Adds worker/src/atlas.rs to pack downloaded block textures into a single
PNG atlas + UV rect map, threads tile-relative UV and atlas-rect buffers
through the mesh binary format (v2, hard break — meshes are a
regenerable render cache), serves the atlas from MinIO via two new api
routes, and adds a custom Babylon shader that falls back to flat vertex
colors per-fragment for untextured quads. glTF export intentionally
stays vertex-color-only (documented reasoning in gltf-export.js) since
standard glTF materials can't express that same per-fragment fallback.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015tKdPZt78zbPUZMXWzKEKt
This commit is contained in:
2026-08-10 00:41:15 +02:00
parent f8216b6e77
commit 6ad8051eae
16 changed files with 843 additions and 44 deletions
+61 -1
View File
@@ -90,7 +90,7 @@ to thread a server-scoped palette through `main.rs`'s batch loop instead of one
already has every loaded mod's texture assets, just unused server-side. The `forge-1_12_2` mod already has every loaded mod's texture assets, just unused server-side. The `forge-1_12_2` mod
leaf (Enigmatica 2, this project's primary target) extracts them once at startup leaf (Enigmatica 2, this project's primary target) extracts them once at startup
(`BlockAssetExtractor`, best-effort: guesses each block's texture by its registry-name path (`BlockAssetExtractor`, best-effort: guesses each block's texture by its registry-name path
segment, not a real blockstate/model JSON resolution — that's Phase 12's job) and ships two new segment, not a real blockstate/model JSON resolution — that's Phase 13's job, see below) and ships two new
WS messages after connecting: `block_registry` (numeric id -> registry name, needed since a WS messages after connecting: `block_registry` (numeric id -> registry name, needed since a
numeric `blockId` alone is meaningless without the mod list that assigned it) and `block_textures` numeric `blockId` alone is meaningless without the mod list that assigned it) and `block_textures`
(the extracted PNGs, batched). The api stores both (`api/src/textures.ts`: registry rows in a new (the extracted PNGs, batched). The api stores both (`api/src/textures.ts`: registry rows in a new
@@ -103,6 +103,66 @@ implement extraction yet either — `BackendConnection#sendBlockRegistry`/`#send
on the shared interface (so any leaf can adopt them later with no protocol change), but only the on the shared interface (so any leaf can adopt them later with no protocol change), but only the
1.12.2 leaf calls them so far, matching this phase's Enigmatica-2-focused verification target. 1.12.2 leaf calls them so far, matching this phase's Enigmatica-2-focused verification target.
### Texture atlas + UV-mapped 3D meshes (Phase 12)
Phase 11 only fed texture-averaged colors into the 2D tile path — `worker/src/mesh.rs`'s 3D
section mesher still called the plain hand-picked `palette::color_for`, so the 3D viewer's "now
accurate" claim in that phase's writeup wasn't actually true yet. Fixed first: `mesh.rs` now calls
`color_for_textured` too, same as the 2D path.
On top of that, `worker/src/atlas.rs` packs every block texture the worker already downloads (see
Phase 11 above) into a single RGBA PNG atlas (one native `16x16` tile per texture, deterministically
laid out in a square-ish grid) plus a `texture name -> normalized [u0,v0,u1,v1]` rect map, cached
to disk like the palette. It does its own jar download rather than sharing Phase 11's — a small,
one-time, cached duplicate fetch, accepted to keep the two build paths independent. Unlike the
palette's post-hoc `texturepacks/` overlay, the atlas always rebuilds (and caches under its own
`-<pack>` suffixed filename) when `TEXTURE_PACK` is set, since there's no cheap way to patch one
tile back out of an already-packed image.
`mesh.rs` now emits two new per-vertex buffers alongside the existing position/normal/color ones:
tile-relative `uv` (unbounded — a merged quad spanning N blocks has that UV coordinate run 0..N,
not 0..1) and `atlasRect` (the same 4 floats repeated for all 4 vertices of a quad; `[0,0,0,0]`
sentinel when the block has no atlas entry — the frontend falls back to the flat vertex color for
that quad). This is a hard break in the section-mesh binary wire format (v2) — safe to do without
any migration, since rendered meshes are a fully regenerable cache (MinIO + a Postgres pointer
row), not a durable artifact; an old-format blob just gets silently overwritten the next time that
section's dirty-chunk job runs.
The atlas PNG + UV-map JSON are uploaded once at worker startup to fixed, version-agnostic MinIO
keys (`atlas/current.png`, `atlas/current.json` — matches the worker-wide-only scope already
established for the palette/texturepack in Phase 11) and served by `api` at `GET /api/atlas.png`
/ `GET /api/atlas.json` (404 until a worker with `ACCEPT_MINECRAFT_EULA=true` has built one).
The live Babylon 3D viewer (`frontend/src/public/js/mesh.js`) uses a custom unlit `ShaderMaterial`
(nearest-neighbor sampling, mipmaps disabled — bilinear/mip blending would bleed a tile's edge
pixels into its atlas neighbor) whose fragment shader `fract()`s the tile-relative UV to repeat a
single atlas tile across a merged quad, and falls back to the plain vertex color per-fragment when
`atlasRect` is the `[0,0,0,0]` sentinel — this per-fragment branch is what makes the live viewer
strictly more capable than the exported glTF here (see below), and is what actually makes greedy
meshing (which merges many blocks into one quad) compatible with per-block texture tiling at all.
Falls back entirely to the pre-Phase-12 flat-color `StandardMaterial` if no atlas was ever
uploaded (fetch 404/error). **Not yet empirically verified against a real running worker + browser**
(no headless-GL environment available in this dev setup) — in particular `invertY`'s row-order
assumption against `atlas.rs`'s top-down PNG rows is unconfirmed, worth checking on first live
test, same "flagged, not yet live-tested" caveat this project already carries for the Xaero
waypoint format (Phase 4).
The client-side region-export mesher (`voxel-mesh.js`) gained the identical UV/atlasRect output
(ported by hand from `mesh.rs`, same pattern as `block-colors.js` mirroring `palette.rs` — see the
new `block-textures.js` mirroring `block_names.rs`), but **`gltf-export.js` deliberately does not
embed the atlas texture into exported glTFs** — still vertex-color-only, unchanged from before this
phase. Reason: standard glTF materials only support one fixed formula (`baseColorTexture *
baseColorFactor * COLOR_0`, no branching), so the live viewer's per-fragment vertex-color fallback
for untextured quads isn't expressible in a way that works in arbitrary external viewers (Blender,
generic glTF web viewers) — properly supporting it needs either a reserved always-white atlas tile
baked into `atlas.rs` or splitting merged geometry into per-material primitives, both real scope,
deliberately deferred rather than shipping some faces textured and others visibly wrong.
**Still not done** (see the plan's phase list): real non-cube block/blockentity geometry via
blockstate/model JSON parsing (`BlockAssetExtractor`'s texture matching is still a filename
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.
## Running ## Running
``` ```
+24
View File
@@ -99,6 +99,30 @@ export const app = new Elysia()
set.headers["content-type"] = "application/octet-stream"; set.headers["content-type"] = "application/octet-stream";
return new Response(stream as any); return new Response(stream as any);
}) })
// Phase 12: the texture atlas (packed block-texture PNG + UV rect map) worker uploads once at
// startup to a fixed, version-agnostic key — no per-server/pointer-row lookup needed since it's
// worker-wide, same scope limitation as the Phase 11 texture palette (see README). 404s (not a
// 500) until a worker with ACCEPT_MINECRAFT_EULA=true has actually built and uploaded one.
.get("/api/atlas.png", async ({ set }) => {
try {
const stream = await minio.getObject(TILE_BUCKET, "atlas/current.png");
set.headers["content-type"] = "image/png";
return new Response(stream as any);
} catch {
set.status = 404;
return { error: "atlas_not_built" };
}
})
.get("/api/atlas.json", async ({ set }) => {
try {
const stream = await minio.getObject(TILE_BUCKET, "atlas/current.json");
set.headers["content-type"] = "application/json";
return new Response(stream as any);
} catch {
set.status = 404;
return { error: "atlas_not_built" };
}
})
// See link.ts's doc comment: the session token comes back in the body, not an httpOnly // See link.ts's doc comment: the session token comes back in the body, not an httpOnly
// cookie, and is sent back via this header on subsequent requests. // cookie, and is sent back via this header on subsequent requests.
.post("/api/link/redeem", async ({ body, set }) => { .post("/api/link/redeem", async ({ body, set }) => {
+80
View File
@@ -0,0 +1,80 @@
// Port of worker/src/block_names.rs's `texture_name` — kept in exact parity so a client-side
// glTF export (voxel-mesh.js/gltf-export.js) resolves the same atlas tile the live 3D viewer's
// worker-rendered meshes use. Ported by hand (no shared code between Rust and JS), same pattern
// as block-colors.js mirroring palette.rs.
//
// Deliberately covers only blocks block-colors.js already hand-picks a color for; anything not
// covered here returns null and the caller falls back to that flat color instead (never a hard
// error) — see block_names.rs's doc comment for why a handful of blocks (grass top, leaves,
// water, lava) are excluded even though Mojang ships a texture for them (biome tinting / animated
// frames would make a raw-texture atlas lookup produce a wrong color, not just an imprecise one).
function woolTexture(meta) {
switch (meta) {
case 0: return "white_wool";
case 1: return "orange_wool";
case 2: return "magenta_wool";
case 3: return "light_blue_wool";
case 4: return "yellow_wool";
case 5: return "lime_wool";
case 6: return "pink_wool";
case 7: return "gray_wool";
case 8: return "light_gray_wool";
case 9: return "cyan_wool";
case 10: return "purple_wool";
case 11: return "blue_wool";
case 12: return "brown_wool";
case 13: return "green_wool";
case 14: return "red_wool";
default: return "black_wool";
}
}
function plankTexture(meta) {
switch (meta) {
case 1: return "spruce_planks";
case 2: return "birch_planks";
case 3: return "jungle_planks";
default: return "oak_planks";
}
}
export function textureName(blockId, meta) {
switch (blockId) {
case 1: return "stone";
case 3: return "dirt";
case 4: return "cobblestone";
case 5: return plankTexture(meta);
case 7: return "bedrock";
case 12: return "sand";
case 13: return "gravel";
case 14: return "gold_ore";
case 15: return "iron_ore";
case 16: return "coal_ore";
case 17: return "oak_log";
case 20: return "glass";
case 24: return "sandstone";
case 35: return woolTexture(meta);
case 41: return "gold_block";
case 42: return "iron_block";
case 45: return "bricks";
case 48: return "mossy_cobblestone";
case 49: return "obsidian";
case 56: return "diamond_ore";
case 73:
case 74:
return "redstone_ore";
case 78:
case 80:
return "snow";
case 82: return "clay";
case 86: return "pumpkin_side";
case 87: return "netherrack";
case 88: return "soul_sand";
case 89: return "glowstone";
case 110: return "mycelium_top";
case 121: return "end_stone";
case 129: return "emerald_ore";
case 133: return "emerald_block";
default: return null;
}
}
@@ -0,0 +1,23 @@
import { test, expect } from "bun:test";
import { textureName } from "./block-textures.js";
test("tinted/animated blocks are deliberately excluded", () => {
expect(textureName(2, 0)).toBeNull(); // grass block (biome-tinted)
expect(textureName(18, 0)).toBeNull(); // leaves (biome-tinted)
expect(textureName(8, 0)).toBeNull(); // water (animated/transparent)
expect(textureName(10, 0)).toBeNull(); // lava (animated)
});
test("wool meta maps to sixteen distinct names", () => {
const names = new Set(Array.from({ length: 16 }, (_, meta) => textureName(35, meta)));
expect(names.size).toBe(16);
});
test("planks vary by meta", () => {
expect(textureName(5, 0)).not.toBe(textureName(5, 1));
expect(textureName(5, 1)).not.toBe(textureName(5, 2));
});
test("unmapped block returns null", () => {
expect(textureName(9999, 0)).toBeNull();
});
+13
View File
@@ -1,3 +1,16 @@
// Phase 12 note: voxel-mesh.js's meshSection() now also produces uvs/atlasRects (see its doc
// comment), but this writer deliberately does NOT embed the texture atlas into exported glTFs —
// only vertex colors (COLOR_0), same as before. Reason: a merged quad without a real atlas entry
// (grass top, leaves, water, lava, any unmapped block — see block-textures.js) needs to fall back
// to its flat vertex color instead of sampling the atlas, and the live Babylon viewer (mesh.js)
// does that with a per-fragment branch in a custom shader — but standard glTF materials only
// support one fixed formula (baseColorTexture * baseColorFactor * COLOR_0, no branching), so the
// same trick isn't expressible in a way that works in arbitrary external viewers (Blender, generic
// glTF web viewers). Properly supporting this needs either a reserved always-white atlas tile for
// untextured quads (baked into the Rust atlas builder) or splitting merged geometry into
// per-material primitives — real scope, deliberately deferred rather than shipping a half-correct
// texture (some faces right, some visibly sampling the wrong atlas region).
//
// Hand-rolled minimal glTF 2.0 binary (.glb) writer. The plan's marker feature section mentions // Hand-rolled minimal glTF 2.0 binary (.glb) writer. The plan's marker feature section mentions
// "via Babylon's GLTF2Export serializer", but that class needs a live Babylon Scene/Engine (a // "via Babylon's GLTF2Export serializer", but that class needs a live Babylon Scene/Engine (a
// real WebGL/DOM context) to run — this project's Babylon usage (mesh.js) only ever *renders* // real WebGL/DOM context) to run — this project's Babylon usage (mesh.js) only ever *renders*
+9 -2
View File
@@ -1,6 +1,9 @@
// Binary mesh format written by worker/src/mesh.rs's MeshBuffers::encode(): // Binary mesh format written by worker/src/mesh.rs's MeshBuffers::encode() (v2, Phase 12 — see
// its doc comment for why this is a hard format break rather than a versioned one: rendered
// meshes are a fully regenerable cache, not a durable artifact):
// u32 vertexCount, u32 indexCount, // u32 vertexCount, u32 indexCount,
// f32[vertexCount*3] positions, f32[vertexCount*3] normals, f32[vertexCount*3] colors, // f32[vertexCount*3] positions, f32[vertexCount*3] normals, f32[vertexCount*3] colors,
// f32[vertexCount*2] uvs, f32[vertexCount*4] atlasRects,
// u32[indexCount] indices — all little-endian. // u32[indexCount] indices — all little-endian.
// //
// Pulled into its own module (rather than living inline in mesh.js) so it can be unit tested // Pulled into its own module (rather than living inline in mesh.js) so it can be unit tested
@@ -17,6 +20,10 @@ export function parseMeshBuffer(buf) {
offset += vertexCount * 3 * 4; offset += vertexCount * 3 * 4;
const rgb = new Float32Array(buf, offset, vertexCount * 3); const rgb = new Float32Array(buf, offset, vertexCount * 3);
offset += vertexCount * 3 * 4; offset += vertexCount * 3 * 4;
const uvs = new Float32Array(buf, offset, vertexCount * 2);
offset += vertexCount * 2 * 4;
const atlasRects = new Float32Array(buf, offset, vertexCount * 4);
offset += vertexCount * 4 * 4;
const indices = new Uint32Array(buf, offset, indexCount); const indices = new Uint32Array(buf, offset, indexCount);
// Babylon's VertexData.colors wants RGBA. // Babylon's VertexData.colors wants RGBA.
@@ -28,5 +35,5 @@ export function parseMeshBuffer(buf) {
colors[i * 4 + 3] = 1; colors[i * 4 + 3] = 1;
} }
return { positions, normals, colors, indices }; return { positions, normals, colors, uvs, atlasRects, indices };
} }
+52 -2
View File
@@ -5,10 +5,19 @@ import { parseMeshBuffer } from "./mesh-format";
// parseMeshBuffer itself, so these tests catch a mismatch in either direction (Rust producer // parseMeshBuffer itself, so these tests catch a mismatch in either direction (Rust producer
// drifting from JS consumer, or vice versa) rather than just testing the parser against its own // drifting from JS consumer, or vice versa) rather than just testing the parser against its own
// assumptions. // assumptions.
function buildMeshBuffer(positions: number[][], normals: number[][], colors: number[][], indices: number[]): ArrayBuffer { function buildMeshBuffer(
positions: number[][],
normals: number[][],
colors: number[][],
indices: number[],
uvs?: number[][],
atlasRects?: number[][],
): ArrayBuffer {
const vertexCount = positions.length; const vertexCount = positions.length;
const indexCount = indices.length; const indexCount = indices.length;
const buf = new ArrayBuffer(8 + vertexCount * 36 + indexCount * 4); const uvsFilled = uvs ?? positions.map(() => [0, 0]);
const atlasRectsFilled = atlasRects ?? positions.map(() => [0, 0, 0, 0]);
const buf = new ArrayBuffer(8 + vertexCount * 60 + indexCount * 4);
const view = new DataView(buf); const view = new DataView(buf);
view.setUint32(0, vertexCount, true); view.setUint32(0, vertexCount, true);
view.setUint32(4, indexCount, true); view.setUint32(4, indexCount, true);
@@ -32,6 +41,18 @@ function buildMeshBuffer(positions: number[][], normals: number[][], colors: num
view.setFloat32(offset + 8, b, true); view.setFloat32(offset + 8, b, true);
offset += 12; offset += 12;
} }
for (const [u, v] of uvsFilled) {
view.setFloat32(offset, u, true);
view.setFloat32(offset + 4, v, true);
offset += 8;
}
for (const [u0, v0, u1, v1] of atlasRectsFilled) {
view.setFloat32(offset, u0, true);
view.setFloat32(offset + 4, v0, true);
view.setFloat32(offset + 8, u1, true);
view.setFloat32(offset + 12, v1, true);
offset += 16;
}
for (const i of indices) { for (const i of indices) {
view.setUint32(offset, i, true); view.setUint32(offset, i, true);
offset += 4; offset += 4;
@@ -99,4 +120,33 @@ describe("parseMeshBuffer", () => {
const { colors, positions } = parseMeshBuffer(buf); const { colors, positions } = parseMeshBuffer(buf);
expect(colors.length).toBe((positions.length / 3) * 4); expect(colors.length).toBe((positions.length / 3) * 4);
}); });
test("parses uvs and atlasRects unchanged", () => {
const buf = buildMeshBuffer(
[
[0, 0, 0],
[1, 1, 1],
],
[
[0, 1, 0],
[0, 1, 0],
],
[
[1, 0, 0],
[0, 1, 0],
],
[0, 1],
[
[0, 0],
[2, 3],
],
[
[0.125, 0.25, 0.375, 0.5],
[0.125, 0.25, 0.375, 0.5],
],
);
const { uvs, atlasRects } = parseMeshBuffer(buf);
expect(Array.from(uvs)).toEqual([0, 0, 2, 3]);
expect(Array.from(atlasRects)).toEqual([0.125, 0.25, 0.375, 0.5, 0.125, 0.25, 0.375, 0.5]);
});
}); });
+102 -12
View File
@@ -7,12 +7,93 @@ import { parseMeshBuffer } from "./mesh-format.js";
const DIMENSION = 0; const DIMENSION = 0;
const CHUNK_RADIUS = 2; // (2*2+1)^2 = 25 chunks const CHUNK_RADIUS = 2; // (2*2+1)^2 = 25 chunks
async function loadSectionMesh(scene, serverId, chunkX, chunkZ, sectionY) { // Phase 12: custom unlit shader so a merged quad can fall back to its flat vertex color
// per-fragment when it has no texture-atlas entry (grass top, leaves, water, lava, unmapped
// blocks — see block-names.rs/block-textures.js), which a standard glTF-style fixed material
// formula can't branch on (see gltf-export.js's doc comment for why the exported glTF doesn't
// attempt the same trick). `uv` is tile-relative and unbounded (see mesh.rs's emit_quad doc
// comment) — `fract()` here is what turns that into "repeat the atlas tile N times across a
// merged quad" instead of stretching one copy across it.
BABYLON.Effect.ShadersStore["mcmapperAtlasVertexShader"] = `
precision highp float;
attribute vec3 position;
attribute vec3 normal;
attribute vec4 color;
attribute vec2 uv;
attribute vec4 atlasRect;
uniform mat4 worldViewProjection;
varying vec4 vColor;
varying vec2 vUv;
varying vec4 vAtlasRect;
void main() {
gl_Position = worldViewProjection * vec4(position, 1.0);
vColor = color;
vUv = uv;
vAtlasRect = atlasRect;
}`;
BABYLON.Effect.ShadersStore["mcmapperAtlasFragmentShader"] = `
precision highp float;
varying vec4 vColor;
varying vec2 vUv;
varying vec4 vAtlasRect;
uniform sampler2D atlasSampler;
void main() {
float rectWidth = vAtlasRect.z - vAtlasRect.x;
float rectHeight = vAtlasRect.w - vAtlasRect.y;
if (rectWidth <= 0.0 || rectHeight <= 0.0) {
gl_FragColor = vColor;
} else {
vec2 tiled = fract(vUv);
vec2 atlasUv = vAtlasRect.xy + tiled * vec2(rectWidth, rectHeight);
gl_FragColor = texture2D(atlasSampler, atlasUv);
}
}`;
/**
* Best-effort: resolves to a shared `ShaderMaterial` if the worker has built and uploaded a
* texture atlas (`ACCEPT_MINECRAFT_EULA=true`, see backend README), or `null` if not (no atlas
* yet, or the fetch failed) — callers fall back to the pre-Phase-12 flat-vertex-color
* `StandardMaterial` in that case, so this is purely additive.
*
* NEAREST sampling + mipmaps disabled: bilinear filtering or mip generation would blend texel
* colors across an atlas tile's edge into its neighboring tile (classic atlas "bleeding"), and
* this project's block textures are 16x16 pixel art anyway, where nearest-neighbor is the more
* period-correct look regardless (matches the 2D tile path's own nearest-neighbor upscale).
* `invertY` left at Babylon's default (`true`) to match how `atlas.rs` writes rows top-down —
* not yet empirically verified against a real running worker + browser (no headless-GL
* environment available here), worth confirming on first live test alongside the Xaero waypoint
* format's similar "flagged, not yet live-tested" caveat (see mcmapper-project-context memory).
*/
function loadAtlasMaterial(scene) {
return new Promise((resolve) => {
const texture = new BABYLON.Texture(
"/api/atlas.png",
scene,
true, // noMipmap
true, // invertY
BABYLON.Texture.NEAREST_SAMPLINGMODE,
() => {
const mat = new BABYLON.ShaderMaterial("atlasMat", scene, { vertex: "mcmapperAtlas", fragment: "mcmapperAtlas" }, {
attributes: ["position", "normal", "color", "uv", "atlasRect"],
uniforms: ["worldViewProjection"],
samplers: ["atlasSampler"],
});
mat.setTexture("atlasSampler", texture);
mat.backFaceCulling = false; // same safety net as the flat-color fallback material below
resolve(mat);
},
() => resolve(null), // no atlas built yet (404) or a network error — fall back silently
);
});
}
async function loadSectionMesh(scene, serverId, chunkX, chunkZ, sectionY, atlasMaterial) {
const res = await fetch(`/api/meshes/${serverId}/${DIMENSION}/${chunkX}/${chunkZ}/${sectionY}.bin`); const res = await fetch(`/api/meshes/${serverId}/${DIMENSION}/${chunkX}/${chunkZ}/${sectionY}.bin`);
if (!res.ok) return; if (!res.ok) return;
const buf = await res.arrayBuffer(); const buf = await res.arrayBuffer();
if (buf.byteLength < 8) return; if (buf.byteLength < 8) return;
const { positions, normals, colors, indices } = parseMeshBuffer(buf); const { positions, normals, colors, uvs, atlasRects, indices } = parseMeshBuffer(buf);
if (indices.length === 0) return; if (indices.length === 0) return;
const mesh = new BABYLON.Mesh(`section-${chunkX}-${chunkZ}-${sectionY}`, scene); const mesh = new BABYLON.Mesh(`section-${chunkX}-${chunkZ}-${sectionY}`, scene);
@@ -21,23 +102,30 @@ async function loadSectionMesh(scene, serverId, chunkX, chunkZ, sectionY) {
vertexData.normals = normals; vertexData.normals = normals;
vertexData.indices = indices; vertexData.indices = indices;
vertexData.colors = colors; vertexData.colors = colors;
vertexData.uvs = uvs;
vertexData.applyToMesh(mesh); vertexData.applyToMesh(mesh);
// Not one of VertexData's built-in kinds (position/normal/uv/color/...) — set directly.
mesh.setVerticesData("atlasRect", atlasRects, false, 4);
const mat = new BABYLON.StandardMaterial(`mat-${chunkX}-${chunkZ}-${sectionY}`, scene); if (atlasMaterial) {
// Winding isn't guaranteed to match Babylon's default front-face convention for every quad mesh.material = atlasMaterial;
// (see worker/src/mesh.rs's emit_quad doc comment) — disable culling as the safety net so } else {
// every face renders regardless of which side it's viewed from. const mat = new BABYLON.StandardMaterial(`mat-${chunkX}-${chunkZ}-${sectionY}`, scene);
mat.backFaceCulling = false; // Winding isn't guaranteed to match Babylon's default front-face convention for every quad
mat.specularColor = new BABYLON.Color3(0, 0, 0); // (see worker/src/mesh.rs's emit_quad doc comment) — disable culling as the safety net so
mesh.material = mat; // every face renders regardless of which side it's viewed from.
mat.backFaceCulling = false;
mat.specularColor = new BABYLON.Color3(0, 0, 0);
mesh.material = mat;
}
mesh.position = new BABYLON.Vector3(chunkX * 16, sectionY * 16, chunkZ * 16); mesh.position = new BABYLON.Vector3(chunkX * 16, sectionY * 16, chunkZ * 16);
} }
async function loadChunk(scene, serverId, chunkX, chunkZ) { async function loadChunk(scene, serverId, chunkX, chunkZ, atlasMaterial) {
const res = await fetch(`/api/meshes/${serverId}/${DIMENSION}/${chunkX}/${chunkZ}`); const res = await fetch(`/api/meshes/${serverId}/${DIMENSION}/${chunkX}/${chunkZ}`);
if (!res.ok) return; if (!res.ok) return;
const sectionYs = await res.json(); const sectionYs = await res.json();
await Promise.all(sectionYs.map((sy) => loadSectionMesh(scene, serverId, chunkX, chunkZ, sy))); await Promise.all(sectionYs.map((sy) => loadSectionMesh(scene, serverId, chunkX, chunkZ, sy, atlasMaterial)));
} }
async function main() { async function main() {
@@ -57,6 +145,8 @@ async function main() {
new BABYLON.HemisphericLight("light", new BABYLON.Vector3(0.3, 1, 0.2), scene); new BABYLON.HemisphericLight("light", new BABYLON.Vector3(0.3, 1, 0.2), scene);
const atlasMaterial = await loadAtlasMaterial(scene);
const servers = await fetch("/api/servers").then((r) => r.json()); const servers = await fetch("/api/servers").then((r) => r.json());
const server = servers[0]; const server = servers[0];
if (!server) { if (!server) {
@@ -66,7 +156,7 @@ async function main() {
const loads = []; const loads = [];
for (let cx = -CHUNK_RADIUS; cx <= CHUNK_RADIUS; cx++) { for (let cx = -CHUNK_RADIUS; cx <= CHUNK_RADIUS; cx++) {
for (let cz = -CHUNK_RADIUS; cz <= CHUNK_RADIUS; cz++) { for (let cz = -CHUNK_RADIUS; cz <= CHUNK_RADIUS; cz++) {
loads.push(loadChunk(scene, server.id, cx, cz)); loads.push(loadChunk(scene, server.id, cx, cz, atlasMaterial));
} }
} }
await Promise.all(loads); await Promise.all(loads);
+35 -10
View File
@@ -7,8 +7,10 @@
// arrays to hand to gltf-export.js, not a live Babylon Scene/Mesh, and keeping this pure (no // arrays to hand to gltf-export.js, not a live Babylon Scene/Mesh, and keeping this pure (no
// Babylon dependency) is what makes it unit-testable without a browser — see voxel-mesh.test.ts. // Babylon dependency) is what makes it unit-testable without a browser — see voxel-mesh.test.ts.
import { colorFor } from "./block-colors.js"; import { colorFor } from "./block-colors.js";
import { textureName } from "./block-textures.js";
const SIZE = 16; const SIZE = 16;
const NO_ATLAS_RECT = [0, 0, 0, 0];
function blockAt(blocks, x, y, z) { function blockAt(blocks, x, y, z) {
if (x < 0 || x >= SIZE || y < 0 || y >= SIZE || z < 0 || z >= SIZE) return 0; if (x < 0 || x >= SIZE || y < 0 || y >= SIZE || z < 0 || z >= SIZE) return 0;
@@ -28,22 +30,29 @@ function offsetAlongAxis(axis, dir) {
return [0, 0, dir]; return [0, 0, dir];
} }
export function meshSection(blocks) { // atlasRects: optional `{ [textureName]: [u0,v0,u1,v1] }` map (see gltf-export.js/export-worker.js
const buf = { positions: [], normals: [], colors: [], indices: [] }; // — fetched once from GET /api/atlas.json) mirroring worker/src/render's TEXTURE_ATLAS. Passing
// null/undefined (or omitting it) keeps every quad's atlasRect at NO_ATLAS_RECT, i.e. plain
// vertex-color output — the pre-Phase-12 behavior — so callers that don't care about texturing
// don't need to change.
export function meshSection(blocks, atlasRects) {
const buf = { positions: [], normals: [], colors: [], uvs: [], atlasRects: [], indices: [] };
for (let axis = 0; axis < 3; axis++) { for (let axis = 0; axis < 3; axis++) {
for (const dir of [-1, 1]) { for (const dir of [-1, 1]) {
meshAxis(blocks, axis, dir, buf); meshAxis(blocks, axis, dir, buf, atlasRects);
} }
} }
return { return {
positions: Float32Array.from(buf.positions), positions: Float32Array.from(buf.positions),
normals: Float32Array.from(buf.normals), normals: Float32Array.from(buf.normals),
colors: Float32Array.from(buf.colors), colors: Float32Array.from(buf.colors),
uvs: Float32Array.from(buf.uvs),
atlasRects: Float32Array.from(buf.atlasRects),
indices: Uint32Array.from(buf.indices), indices: Uint32Array.from(buf.indices),
}; };
} }
function meshAxis(blocks, axis, dir, buf) { function meshAxis(blocks, axis, dir, buf, atlasRects) {
const mask = Array.from({ length: SIZE }, () => new Uint16Array(SIZE)); const mask = Array.from({ length: SIZE }, () => new Uint16Array(SIZE));
for (let layer = 0; layer < SIZE; layer++) { for (let layer = 0; layer < SIZE; layer++) {
@@ -62,11 +71,11 @@ function meshAxis(blocks, axis, dir, buf) {
} }
const facePlane = dir === 1 ? layer + 1 : layer; const facePlane = dir === 1 ? layer + 1 : layer;
greedyMergeAndEmit(mask, axis, dir, facePlane, buf); greedyMergeAndEmit(mask, axis, dir, facePlane, buf, atlasRects);
} }
} }
function greedyMergeAndEmit(mask, axis, dir, facePlane, buf) { function greedyMergeAndEmit(mask, axis, dir, facePlane, buf, atlasRects) {
const done = Array.from({ length: SIZE }, () => new Uint8Array(SIZE)); const done = Array.from({ length: SIZE }, () => new Uint8Array(SIZE));
for (let u0 = 0; u0 < SIZE; u0++) { for (let u0 = 0; u0 < SIZE; u0++) {
@@ -89,12 +98,12 @@ function greedyMergeAndEmit(mask, axis, dir, facePlane, buf) {
for (let v = v0; v < v1; v++) done[u][v] = 1; for (let v = v0; v < v1; v++) done[u][v] = 1;
} }
emitQuad(axis, dir, facePlane, u0, v0, u1, v1, block, buf); emitQuad(axis, dir, facePlane, u0, v0, u1, v1, block, buf, atlasRects);
} }
} }
} }
function emitQuad(axis, dir, facePlane, u0, v0, u1, v1, block, buf) { function emitQuad(axis, dir, facePlane, u0, v0, u1, v1, block, buf, atlasRects) {
const cornersUv = [ const cornersUv = [
[u0, v0], [u0, v0],
[u1, v0], [u1, v0],
@@ -113,12 +122,28 @@ function emitQuad(axis, dir, facePlane, u0, v0, u1, v1, block, buf) {
const [r, g, b] = colorFor(blockId, blockMeta); const [r, g, b] = colorFor(blockId, blockMeta);
const color = [r / 255, g / 255, b / 255]; const color = [r / 255, g / 255, b / 255];
for (const [u, v] of cornersUv) { // Mirrors mesh.rs's emit_quad: local UV is tile-relative (starts at 0,0, spans the merged
// quad's width/height in block units), atlasRect is the same NO_ATLAS_RECT sentinel when this
// block/meta has no texture-atlas entry (no atlas fetched, or textureName() returns null).
const name = textureName(blockId, blockMeta);
const rect = (name && atlasRects && atlasRects[name]) || NO_ATLAS_RECT;
const width = u1 - u0;
const height = v1 - v0;
const localUvs = [
[0, 0],
[width, 0],
[width, height],
[0, height],
];
cornersUv.forEach(([u, v], i) => {
const [x, y, z] = axisPos(axis, facePlane, u, v); const [x, y, z] = axisPos(axis, facePlane, u, v);
buf.positions.push(x, y, z); buf.positions.push(x, y, z);
buf.normals.push(...normal); buf.normals.push(...normal);
buf.colors.push(...color); buf.colors.push(...color);
} buf.uvs.push(...localUvs[i]);
buf.atlasRects.push(...rect);
});
// Two triangles per quad; flip winding by direction, same as mesh.rs's emit_quad — backface // Two triangles per quad; flip winding by direction, same as mesh.rs's emit_quad — backface
// culling is left off on the material side as the safety net (see mesh.js). // culling is left off on the material side as the safety net (see mesh.js).
+21
View File
@@ -63,3 +63,24 @@ test("colors come from the block-color palette, normalized to 0..1", () => {
expect(mesh.colors[1]).toBeCloseTo(159 / 255, 5); expect(mesh.colors[1]).toBeCloseTo(159 / 255, 5);
expect(mesh.colors[2]).toBeCloseTo(53 / 255, 5); expect(mesh.colors[2]).toBeCloseTo(53 / 255, 5);
}); });
test("without atlasRects, every quad gets the NO_ATLAS_RECT sentinel", () => {
const blocks = emptyBlocks();
blocks[0] = (1 << 4) | 0; // stone — has a texture name, but no atlas map was passed
const mesh = meshSection(blocks);
for (let i = 0; i < mesh.atlasRects.length; i++) expect(mesh.atlasRects[i]).toBe(0);
});
test("with a matching atlasRects entry, the quad's rect is looked up by texture name", () => {
const blocks = emptyBlocks();
blocks.fill((1 << 4) | 0); // stone everywhere -> textureName "stone"
const atlasRects = { stone: [0.25, 0.5, 0.375, 0.625] };
const mesh = meshSection(blocks, atlasRects);
// 6 merged faces * 4 verts, every one should carry the stone rect.
for (let i = 0; i < mesh.atlasRects.length; i += 4) {
expect(Array.from(mesh.atlasRects.slice(i, i + 4))).toEqual([0.25, 0.5, 0.375, 0.625]);
}
// The merged top face spans the full 16x16 section, so local UV should reach (16,16), not (1,1).
const maxU = Math.max(...mesh.uvs.filter((_, i) => i % 2 === 0));
expect(maxU).toBe(16);
});
+233
View File
@@ -0,0 +1,233 @@
use std::collections::HashMap;
use std::io::Cursor;
use std::path::Path;
use image::{ImageEncoder, RgbaImage};
use serde::{Deserialize, Serialize};
use crate::textures;
/// Every block texture is packed as a single native-resolution tile — this project's priority
/// targets (1.7.10/1.12.2) ship 16x16 block textures; anything a different size (a handful of
/// modded/animated-strip textures) is resized down to this on packing (see `pack`'s doc comment).
const TILE: u32 = 16;
/// A packed RGBA atlas image plus a `texture name -> normalized [u0, v0, u1, v1]` rect map, so
/// `mesh.rs` can look up where a block's texture lives in the atlas without needing the raw
/// per-texture images at meshing time (see `render::texture_atlas()`). Kept separate from
/// `textures::TexturePalette` (the Phase 11 averaged-color map) rather than merged into it — the
/// atlas is meaningfully heavier (a real image, not 3 bytes per entry) and only the 3D mesh path
/// needs it; the 2D tile path only ever needs the averaged color.
#[derive(Debug, Clone)]
pub struct TextureAtlas {
pub image: RgbaImage,
rects: HashMap<String, [f32; 4]>,
}
impl TextureAtlas {
pub fn rect(&self, name: &str) -> Option<[f32; 4]> {
self.rects.get(name).copied()
}
pub fn len(&self) -> usize {
self.rects.len()
}
pub fn is_empty(&self) -> bool {
self.rects.is_empty()
}
pub fn encode_png(&self) -> anyhow::Result<Vec<u8>> {
let mut bytes = Vec::new();
image::codecs::png::PngEncoder::new(&mut Cursor::new(&mut bytes)).write_image(
self.image.as_raw(),
self.image.width(),
self.image.height(),
image::ExtendedColorType::Rgba8,
)?;
Ok(bytes)
}
pub fn rects_json(&self) -> anyhow::Result<Vec<u8>> {
Ok(serde_json::to_vec(&self.rects)?)
}
}
/// Packs a `name -> image` map into a single square-ish grid atlas, one `TILE`x`TILE` cell per
/// entry (images of a different size are nearest-neighbor-resized down to `TILE`x`TILE` first —
/// matches the project's existing "one representative frame, not a real mipmap/animation" stance
/// on non-uniform textures, see `block_names.rs`'s doc comment on excluding animated blocks
/// entirely from texture-name mapping in the first place). Iterates names in sorted order so the
/// packing is deterministic (stable rects across runs with the same input set, useful for tests
/// and for not needlessly invalidating a cached atlas).
pub fn pack(images: &HashMap<String, RgbaImage>) -> TextureAtlas {
let mut names: Vec<&String> = images.keys().collect();
names.sort();
let tile_count = names.len().max(1) as u32; // at least a 1-tile atlas even if empty
let cols = (tile_count as f64).sqrt().ceil() as u32;
let rows = tile_count.div_ceil(cols);
let atlas_w = cols * TILE;
let atlas_h = rows * TILE;
let mut atlas = RgbaImage::new(atlas_w, atlas_h);
let mut rects = HashMap::new();
for (i, name) in names.into_iter().enumerate() {
let col = (i as u32) % cols;
let row = (i as u32) / cols;
let x0 = col * TILE;
let y0 = row * TILE;
let img = &images[name];
if img.width() == TILE && img.height() == TILE {
image::imageops::replace(&mut atlas, img, x0 as i64, y0 as i64);
} else {
let resized = image::imageops::resize(img, TILE, TILE, image::imageops::FilterType::Nearest);
image::imageops::replace(&mut atlas, &resized, x0 as i64, y0 as i64);
}
rects.insert(
name.clone(),
[
x0 as f32 / atlas_w as f32,
y0 as f32 / atlas_h as f32,
(x0 + TILE) as f32 / atlas_w as f32,
(y0 + TILE) as f32 / atlas_h as f32,
],
);
}
TextureAtlas { image: atlas, rects }
}
#[derive(Serialize, Deserialize)]
struct CachedRects(HashMap<String, [f32; 4]>);
/// Loads a cached atlas from `<cache_dir>/vanilla-<version>[-<pack>]-atlas.{png,json}` if
/// present, otherwise downloads the Mojang client jar (does its own fetch, separate from
/// `textures::load_or_build`'s — a small one-time duplicate download on a cold cache, accepted
/// for keeping the two build paths independent rather than threading jar bytes through both call
/// sites) and packs every extracted block texture, optionally overlaid with a
/// `texturepacks/<texture_pack>/` directory's PNGs (unlike `textures::TexturePalette::overlay`,
/// which layers post-hoc onto an already-averaged palette, the atlas overlay happens before
/// packing — the atlas has no cheap way to patch one already-packed tile back out of a cached PNG,
/// so a texturepack always forces a fresh pack, cached under its own `-<pack>` suffixed filename
/// rather than sharing the vanilla-only cache entry).
pub async fn load_or_build(
cache_dir: &Path,
mc_version: &str,
texture_pack: Option<&str>,
) -> anyhow::Result<TextureAtlas> {
let suffix = texture_pack.map(|p| format!("-{p}")).unwrap_or_default();
let png_path = cache_dir.join(format!("vanilla-{mc_version}{suffix}-atlas.png"));
let json_path = cache_dir.join(format!("vanilla-{mc_version}{suffix}-atlas.json"));
if let (Ok(png_bytes), Ok(json_bytes)) = (std::fs::read(&png_path), std::fs::read(&json_path)) {
if let (Ok(decoded), Ok(CachedRects(rects))) =
(image::load_from_memory(&png_bytes), serde_json::from_slice(&json_bytes))
{
let atlas = TextureAtlas { image: decoded.to_rgba8(), rects };
println!(
"[worker] loaded cached texture atlas ({} tiles) from {}",
atlas.len(),
png_path.display()
);
return Ok(atlas);
}
}
println!("[worker] downloading Minecraft {mc_version} client jar from Mojang to build the texture atlas...");
let jar_bytes = textures::download_client_jar_bytes(mc_version).await?;
let mut images = textures::extract_images(&jar_bytes)?;
if let Some(pack) = texture_pack {
let pack_dir = Path::new("./texturepacks").join(pack);
match textures::images_in_directory(&pack_dir) {
Ok(overrides) if !overrides.is_empty() => {
println!(
"[worker] applying texturepack '{pack}' ({} overrides) to the texture atlas from {}",
overrides.len(),
pack_dir.display()
);
images.extend(overrides);
}
Ok(_) => {}
Err(err) => eprintln!("[worker] failed to load texturepack '{pack}' for atlas: {err:#}"),
}
}
let atlas = pack(&images);
std::fs::create_dir_all(cache_dir)?;
std::fs::write(&png_path, atlas.encode_png()?)?;
std::fs::write(&json_path, serde_json::to_vec(&CachedRects(atlas.rects.clone()))?)?;
println!("[worker] built texture atlas ({} tiles), cached to {}", atlas.len(), png_path.display());
Ok(atlas)
}
#[cfg(test)]
mod tests {
use super::*;
fn solid(w: u32, h: u32, rgba: [u8; 4]) -> RgbaImage {
let mut img = RgbaImage::new(w, h);
for p in img.pixels_mut() {
*p = image::Rgba(rgba);
}
img
}
#[test]
fn packing_two_tiles_produces_distinct_non_overlapping_rects() {
let mut images = HashMap::new();
images.insert("stone".to_string(), solid(16, 16, [125, 125, 125, 255]));
images.insert("dirt".to_string(), solid(16, 16, [134, 96, 67, 255]));
let atlas = pack(&images);
assert_eq!(atlas.len(), 2);
let stone = atlas.rect("stone").unwrap();
let dirt = atlas.rect("dirt").unwrap();
assert_ne!(stone, dirt);
for rect in [stone, dirt] {
assert!(rect[2] > rect[0]);
assert!(rect[3] > rect[1]);
}
}
#[test]
fn unknown_texture_name_has_no_rect() {
let images = HashMap::new();
let atlas = pack(&images);
assert_eq!(atlas.rect("nonexistent"), None);
}
#[test]
fn non_native_size_textures_are_resized_into_a_single_tile() {
// A 16x64 image (e.g. an animated-frame strip that slipped through) must still end up
// as exactly one TILExTILE cell — the atlas has no notion of animation frames.
let mut images = HashMap::new();
images.insert("weird".to_string(), solid(16, 64, [1, 2, 3, 255]));
let atlas = pack(&images);
assert_eq!(atlas.image.width() % TILE, 0);
assert_eq!(atlas.image.height() % TILE, 0);
let rect = atlas.rect("weird").unwrap();
assert_eq!((rect[2] - rect[0]) * atlas.image.width() as f32, TILE as f32);
assert_eq!((rect[3] - rect[1]) * atlas.image.height() as f32, TILE as f32);
}
#[test]
fn packing_an_empty_set_produces_a_minimal_atlas_with_no_rects() {
let atlas = pack(&HashMap::new());
assert!(atlas.is_empty());
assert_eq!(atlas.image.width(), TILE);
assert_eq!(atlas.image.height(), TILE);
}
#[test]
fn encode_png_round_trips_through_the_image_crate() {
let mut images = HashMap::new();
images.insert("stone".to_string(), solid(16, 16, [125, 125, 125, 255]));
let atlas = pack(&images);
let bytes = atlas.encode_png().unwrap();
let decoded = image::load_from_memory(&bytes).unwrap().to_rgba8();
assert_eq!(decoded.dimensions(), atlas.image.dimensions());
}
}
+1
View File
@@ -1,3 +1,4 @@
pub mod atlas;
pub mod block_names; pub mod block_names;
pub mod config; pub mod config;
pub mod db; pub mod db;
+43 -1
View File
@@ -2,7 +2,7 @@ use std::collections::hash_map::DefaultHasher;
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::{config, db, mesh, storage, textures}; use mcmapper_worker::{atlas, config, db, mesh, storage, textures};
use rayon::prelude::*; use rayon::prelude::*;
use redis::streams::{StreamReadOptions, StreamReadReply}; use redis::streams::{StreamReadOptions, StreamReadReply};
use redis::AsyncCommands; use redis::AsyncCommands;
@@ -82,6 +82,48 @@ async fn main() -> anyhow::Result<()> {
"[worker] failed to build vanilla texture palette, falling back to hand-picked colors: {err:#}" "[worker] failed to build vanilla texture palette, falling back to hand-picked colors: {err:#}"
), ),
} }
// Phase 12: the texture atlas is the 3D-mesh/UV counterpart to the palette above (a real
// packed image + UV rects, not just an averaged color per block) — built/cached
// independently (see atlas::load_or_build's doc comment for why) and uploaded once to a
// fixed, version-agnostic MinIO key so `api` can serve it without needing to know
// MC_TEXTURE_VERSION itself (this worker-wide-only palette/atlas limitation already
// applies to the palette above — see README).
let texture_pack = std::env::var("TEXTURE_PACK").ok();
match atlas::load_or_build(std::path::Path::new(&cache_dir), &mc_version, texture_pack.as_deref()).await {
Ok(built_atlas) => {
println!("[worker] texture atlas ready ({} tiles)", built_atlas.len());
match built_atlas.encode_png() {
Ok(png) => {
if let Err(err) =
storage::put_object(&s3_client, "atlas/current.png", "image/png", png).await
{
eprintln!("[worker] failed to upload texture atlas PNG: {err:#}");
}
}
Err(err) => eprintln!("[worker] failed to encode texture atlas PNG: {err:#}"),
}
match built_atlas.rects_json() {
Ok(json) => {
if let Err(err) = storage::put_object(
&s3_client,
"atlas/current.json",
"application/json",
json,
)
.await
{
eprintln!("[worker] failed to upload texture atlas UV map: {err:#}");
}
}
Err(err) => eprintln!("[worker] failed to encode texture atlas UV map: {err:#}"),
}
mcmapper_worker::render::set_texture_atlas(built_atlas);
}
Err(err) => eprintln!(
"[worker] failed to build texture atlas, 3D meshes will use flat vertex colors only: {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)");
} }
+92 -8
View File
@@ -1,8 +1,13 @@
use crate::palette::color_for; use crate::palette::color_for_textured;
use crate::render::RenderBackend; use crate::render::RenderBackend;
const SIZE: i32 = 16; const SIZE: i32 = 16;
/// Sentinel meaning "no atlas entry for this quad's block/texture — render flat `colors` only".
/// A genuine atlas rect can never collapse to this: `u1`/`v1` are always a whole tile-width past
/// `u0`/`v0` (see `atlas::pack`), so `u1 == u0` is impossible for a real entry.
const NO_ATLAS_RECT: [f32; 4] = [0.0, 0.0, 0.0, 0.0];
/// Greedy-meshes a single 16x16x16 section into a flat vertex/index buffer. Sections are meshed /// Greedy-meshes a single 16x16x16 section into a flat vertex/index buffer. Sections are meshed
/// independently (no merging across section/chunk boundaries in Phase 2 — a voxel at a section /// independently (no merging across section/chunk boundaries in Phase 2 — a voxel at a section
/// edge treats the neighbor as air even if an adjacent section has a solid block there), so a /// edge treats the neighbor as air even if an adjacent section has a solid block there), so a
@@ -19,6 +24,17 @@ pub struct MeshBuffers {
pub positions: Vec<[f32; 3]>, pub positions: Vec<[f32; 3]>,
pub normals: Vec<[f32; 3]>, pub normals: Vec<[f32; 3]>,
pub colors: Vec<[f32; 3]>, pub colors: Vec<[f32; 3]>,
/// Phase 12: tile-relative surface UV, unbounded (a merged quad spanning N blocks along an
/// axis has that coordinate range 0..N, not 0..1) so the frontend shader can `fract()` it to
/// tile a single atlas tile N times across the merged quad instead of stretching one copy
/// across it — see mesh.js's material.
pub uvs: Vec<[f32; 2]>,
/// Phase 12: `[u0, v0, u1, v1]` normalized atlas sub-rect for this quad's resolved texture,
/// repeated for all 4 vertices of a quad (same lookup for the whole quad, never per-vertex).
/// `NO_ATLAS_RECT` when the block has no atlas entry (no texture atlas loaded, or this
/// block/meta isn't in `block_names::texture_name`'s table) — the frontend falls back to
/// `colors` for those quads.
pub atlas_rects: Vec<[f32; 4]>,
pub indices: Vec<u32>, pub indices: Vec<u32>,
} }
@@ -27,13 +43,24 @@ impl MeshBuffers {
self.indices.is_empty() self.indices.is_empty()
} }
/// Binary layout consumed directly by the frontend (see frontend/src/public/js/mesh.js): /// Binary layout consumed directly by the frontend (see frontend/src/public/js/mesh-format.js).
/// `u32 vertexCount, u32 indexCount, f32[vertexCount*3] positions, f32[vertexCount*3] /// Phase 12 bumped this to v2 by appending two new per-vertex buffers (`uvs`, `atlas_rects`)
/// normals, f32[vertexCount*3] colors, u32[indexCount] indices` — all little-endian. /// between `colors` and `indices` — safe to do as a hard break rather than a versioned/
/// backward-compatible format: rendered meshes are a fully regenerable cache (MinIO + a
/// Postgres pointer row per section, both worker-owned — see the plan's object-storage
/// design), not a durable artifact, so an old-format blob left over from before this change
/// simply gets overwritten the next time that section's dirty-chunk job runs; nothing reads
/// a stale mesh blob against this new parser (the frontend ships in lockstep with the api and
/// isn't independently versioned).
///
/// `u32 vertexCount, u32 indexCount,
/// f32[vertexCount*3] positions, f32[vertexCount*3] normals, f32[vertexCount*3] colors,
/// f32[vertexCount*2] uvs, f32[vertexCount*4] atlasRects,
/// u32[indexCount] indices` — all little-endian.
pub fn encode(&self) -> Vec<u8> { pub fn encode(&self) -> Vec<u8> {
let vertex_count = self.positions.len() as u32; let vertex_count = self.positions.len() as u32;
let index_count = self.indices.len() as u32; let index_count = self.indices.len() as u32;
let mut out = Vec::with_capacity(8 + (vertex_count as usize) * 36 + (index_count as usize) * 4); let mut out = Vec::with_capacity(8 + (vertex_count as usize) * 60 + (index_count as usize) * 4);
out.extend_from_slice(&vertex_count.to_le_bytes()); out.extend_from_slice(&vertex_count.to_le_bytes());
out.extend_from_slice(&index_count.to_le_bytes()); out.extend_from_slice(&index_count.to_le_bytes());
for p in &self.positions { for p in &self.positions {
@@ -51,6 +78,16 @@ impl MeshBuffers {
out.extend_from_slice(&ch.to_le_bytes()); out.extend_from_slice(&ch.to_le_bytes());
} }
} }
for uv in &self.uvs {
for c in uv {
out.extend_from_slice(&c.to_le_bytes());
}
}
for r in &self.atlas_rects {
for c in r {
out.extend_from_slice(&c.to_le_bytes());
}
}
for i in &self.indices { for i in &self.indices {
out.extend_from_slice(&i.to_le_bytes()); out.extend_from_slice(&i.to_le_bytes());
} }
@@ -175,14 +212,26 @@ fn emit_quad(
}; };
let block_id = block >> 4; let block_id = block >> 4;
let block_meta = (block & 0xF) as u8; let block_meta = (block & 0xF) as u8;
let [r, g, b] = color_for(block_id, block_meta); 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]; let color = [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0];
for (u, v) in corners_uv { let atlas_rect = crate::block_names::texture_name(block_id, block_meta)
.and_then(|name| crate::render::texture_atlas().and_then(|atlas| atlas.rect(name)))
.unwrap_or(NO_ATLAS_RECT);
// Tile-relative, not the absolute mask-space corners_uv above: a merged quad's local UV
// always starts at (0,0) regardless of where it sits in the section, and its far corner is
// exactly (width, height) in block units — one atlas-tile repeat per block along each edge.
let width = (u1 - u0) as f32;
let height = (v1 - v0) as f32;
let local_uvs = [[0.0, 0.0], [width, 0.0], [width, height], [0.0, height]];
for (i, (u, v)) in corners_uv.into_iter().enumerate() {
let (x, y, z) = axis_pos(axis, face_plane, u, v); let (x, y, z) = axis_pos(axis, face_plane, u, v);
buf.positions.push([x as f32, y as f32, z as f32]); buf.positions.push([x as f32, y as f32, z as f32]);
buf.normals.push(normal); buf.normals.push(normal);
buf.colors.push(color); buf.colors.push(color);
buf.uvs.push(local_uvs[i]);
buf.atlas_rects.push(atlas_rect);
} }
// Two triangles per quad; flip winding by direction so both face orientations are at least // Two triangles per quad; flip winding by direction so both face orientations are at least
@@ -272,7 +321,42 @@ mod tests {
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());
assert_eq!(vertex_count as usize, mesh.positions.len()); assert_eq!(vertex_count as usize, mesh.positions.len());
assert_eq!(index_count as usize, mesh.indices.len()); assert_eq!(index_count as usize, mesh.indices.len());
let expected_len = 8 + vertex_count as usize * 36 + index_count as usize * 4; let expected_len = 8 + vertex_count as usize * 60 + index_count as usize * 4;
assert_eq!(bytes.len(), expected_len); assert_eq!(bytes.len(), expected_len);
} }
#[test]
fn quad_without_a_texture_atlas_gets_the_sentinel_rect() {
// No atlas is set up in tests (see render::texture_atlas()'s doc comment — it's a
// OnceLock only main.rs ever populates), so every quad should carry NO_ATLAS_RECT and
// local UVs should still be well-formed (start at the origin).
let mut blocks = [0u16; 4096];
blocks[0] = (1 << 4) | 0; // stone
let mesh = mesh_section(&blocks, &cpu());
assert!(!mesh.atlas_rects.is_empty());
for rect in &mesh.atlas_rects {
assert_eq!(*rect, NO_ATLAS_RECT);
}
assert_eq!(mesh.uvs.len(), mesh.positions.len());
}
#[test]
fn merged_quad_local_uv_spans_its_full_merged_width() {
// A full solid section's +y face collapses to one 16x16 merged quad (see
// full_solid_section_collapses_to_six_merged_quads) — its local UV should span 0..16 on
// both axes, not 0..1, so the frontend can tile the atlas 16 times across it.
let mut blocks = [0u16; 4096];
for b in blocks.iter_mut() {
*b = (1 << 4) | 0;
}
let backend = cpu();
let face_masks = backend.compute_face_masks(&blocks);
let mut buf = MeshBuffers::default();
mesh_axis_from_visibility(&face_masks[3], 1, 1, &mut buf); // +y face
assert_eq!(buf.uvs.len(), 4);
let max_u = buf.uvs.iter().map(|uv| uv[0]).fold(0.0f32, f32::max);
let max_v = buf.uvs.iter().map(|uv| uv[1]).fold(0.0f32, f32::max);
assert_eq!(max_u, 16.0);
assert_eq!(max_v, 16.0);
}
} }
+21
View File
@@ -27,6 +27,27 @@ pub fn set_texture_palette(palette: TexturePalette) {
let _ = TEXTURE_PALETTE.set(palette); let _ = TEXTURE_PALETTE.set(palette);
} }
/// Read by `mesh.rs` so 3D section meshing's vertex-color fallback picks up the same
/// texture-averaged colors the 2D tile path already uses via `base_colors` — see this module's
/// doc comment above.
pub(crate) fn texture_palette() -> Option<&'static TexturePalette> {
TEXTURE_PALETTE.get()
}
/// Same `OnceLock`-once-at-startup pattern as `TEXTURE_PALETTE`, for the Phase 12 texture atlas
/// (see `atlas.rs`). `None` until `main.rs` successfully builds one (`ACCEPT_MINECRAFT_EULA` not
/// set, or the build failed) — `mesh.rs` falls back to a flat vertex color per quad whenever this
/// is `None` or the block's texture name has no atlas entry.
static TEXTURE_ATLAS: OnceLock<crate::atlas::TextureAtlas> = OnceLock::new();
pub fn set_texture_atlas(atlas: crate::atlas::TextureAtlas) {
let _ = TEXTURE_ATLAS.set(atlas);
}
pub(crate) fn texture_atlas() -> Option<&'static crate::atlas::TextureAtlas> {
TEXTURE_ATLAS.get()
}
/// 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,
+33 -8
View File
@@ -65,8 +65,20 @@ pub fn average_rgb(img: &image::RgbaImage) -> [u8; 3] {
/// via the same averaging logic) for textures extracted from the Mojang client jar. /// via the same averaging logic) for textures extracted from the Mojang client jar.
pub fn average_directory(dir: &Path) -> anyhow::Result<TexturePalette> { pub fn average_directory(dir: &Path) -> anyhow::Result<TexturePalette> {
let mut colors = HashMap::new(); let mut colors = HashMap::new();
for (name, img) in images_in_directory(dir)? {
colors.insert(name, average_rgb(&img));
}
Ok(TexturePalette { colors })
}
/// Decodes (not averaged) every `*.png` directly inside `dir` (non-recursive), keyed by file
/// stem — the Phase 12 atlas-building counterpart to `average_directory` above, which only kept
/// the averaged color and discarded the pixels. Returns an empty map (not an error) for a missing
/// directory, same as `average_directory`.
pub fn images_in_directory(dir: &Path) -> anyhow::Result<HashMap<String, image::RgbaImage>> {
let mut images = HashMap::new();
if !dir.is_dir() { if !dir.is_dir() {
return Ok(TexturePalette { colors }); return Ok(images);
} }
for entry in std::fs::read_dir(dir)? { for entry in std::fs::read_dir(dir)? {
let entry = entry?; let entry = entry?;
@@ -76,9 +88,9 @@ pub fn average_directory(dir: &Path) -> anyhow::Result<TexturePalette> {
} }
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else { continue }; let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else { continue };
let Ok(img) = image::open(&path) else { continue }; let Ok(img) = image::open(&path) else { continue };
colors.insert(stem.to_string(), average_rgb(&img.to_rgba8())); images.insert(stem.to_string(), img.to_rgba8());
} }
Ok(TexturePalette { colors }) Ok(images)
} }
/// Loads a cached palette from `<cache_dir>/vanilla-<version>.json` if present, otherwise /// Loads a cached palette from `<cache_dir>/vanilla-<version>.json` if present, otherwise
@@ -102,7 +114,7 @@ pub async fn load_or_build(cache_dir: &Path, mc_version: &str) -> anyhow::Result
} }
println!("[worker] downloading Minecraft {mc_version} client jar from Mojang to build the vanilla texture palette..."); println!("[worker] downloading Minecraft {mc_version} client jar from Mojang to build the vanilla texture palette...");
let client_jar = download_client_jar(mc_version).await?; let client_jar = download_client_jar_bytes(mc_version).await?;
let palette = extract_palette(&client_jar)?; let palette = extract_palette(&client_jar)?;
std::fs::create_dir_all(cache_dir)?; std::fs::create_dir_all(cache_dir)?;
@@ -141,7 +153,10 @@ struct DownloadInfo {
url: String, url: String,
} }
async fn download_client_jar(mc_version: &str) -> anyhow::Result<Vec<u8>> { /// `pub(crate)` (not `pub`) — reused by `atlas.rs` to build the Phase 12 texture atlas from the
/// same jar without duplicating the version-manifest lookup, but this is worker-internal
/// plumbing, not part of the crate's public surface.
pub(crate) async fn download_client_jar_bytes(mc_version: &str) -> anyhow::Result<Vec<u8>> {
let manifest: VersionManifest = let manifest: VersionManifest =
reqwest::get("https://launchermeta.mojang.com/mc/game/version_manifest_v2.json") reqwest::get("https://launchermeta.mojang.com/mc/game/version_manifest_v2.json")
.await? .await?
@@ -156,8 +171,18 @@ async fn download_client_jar(mc_version: &str) -> anyhow::Result<Vec<u8>> {
} }
fn extract_palette(jar_bytes: &[u8]) -> anyhow::Result<TexturePalette> { fn extract_palette(jar_bytes: &[u8]) -> anyhow::Result<TexturePalette> {
let images = extract_images(jar_bytes)?;
let colors = images.into_iter().map(|(name, img)| (name, average_rgb(&img))).collect();
Ok(TexturePalette { colors })
}
/// Decodes (not averaged) every vanilla block texture from a Mojang client jar's bytes, keyed by
/// file stem — the Phase 12 atlas-building counterpart to `extract_palette` above, which shares
/// this same path-matching logic but immediately averages and discards the pixels. Split out so
/// `extract_palette` can be implemented in terms of this instead of duplicating the zip-walking.
pub(crate) fn extract_images(jar_bytes: &[u8]) -> anyhow::Result<HashMap<String, image::RgbaImage>> {
let mut archive = zip::ZipArchive::new(Cursor::new(jar_bytes))?; let mut archive = zip::ZipArchive::new(Cursor::new(jar_bytes))?;
let mut colors = HashMap::new(); let mut images = HashMap::new();
for i in 0..archive.len() { for i in 0..archive.len() {
let mut file = archive.by_index(i)?; let mut file = archive.by_index(i)?;
let name = file.name().to_string(); let name = file.name().to_string();
@@ -177,9 +202,9 @@ fn extract_palette(jar_bytes: &[u8]) -> anyhow::Result<TexturePalette> {
let Ok(img) = image::load_from_memory(&bytes) else { let Ok(img) = image::load_from_memory(&bytes) else {
continue; // a handful of non-image entries can share the extension in odd jars continue; // a handful of non-image entries can share the extension in odd jars
}; };
colors.insert(stem.to_string(), average_rgb(&img.to_rgba8())); images.insert(stem.to_string(), img.to_rgba8());
} }
Ok(TexturePalette { colors }) Ok(images)
} }
#[cfg(test)] #[cfg(test)]