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
+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
// "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*
+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,
// 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.
//
// 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;
const rgb = new Float32Array(buf, offset, vertexCount * 3);
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);
// Babylon's VertexData.colors wants RGBA.
@@ -28,5 +35,5 @@ export function parseMeshBuffer(buf) {
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
// drifting from JS consumer, or vice versa) rather than just testing the parser against its own
// 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 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);
view.setUint32(0, vertexCount, true);
view.setUint32(4, indexCount, true);
@@ -32,6 +41,18 @@ function buildMeshBuffer(positions: number[][], normals: number[][], colors: num
view.setFloat32(offset + 8, b, true);
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) {
view.setUint32(offset, i, true);
offset += 4;
@@ -99,4 +120,33 @@ describe("parseMeshBuffer", () => {
const { colors, positions } = parseMeshBuffer(buf);
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 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`);
if (!res.ok) return;
const buf = await res.arrayBuffer();
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;
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.indices = indices;
vertexData.colors = colors;
vertexData.uvs = uvs;
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);
// Winding isn't guaranteed to match Babylon's default front-face convention for every quad
// (see worker/src/mesh.rs's emit_quad doc comment) — disable culling as the safety net so
// 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;
if (atlasMaterial) {
mesh.material = atlasMaterial;
} else {
const mat = new BABYLON.StandardMaterial(`mat-${chunkX}-${chunkZ}-${sectionY}`, scene);
// Winding isn't guaranteed to match Babylon's default front-face convention for every quad
// (see worker/src/mesh.rs's emit_quad doc comment) — disable culling as the safety net so
// 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);
}
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}`);
if (!res.ok) return;
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() {
@@ -57,6 +145,8 @@ async function main() {
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 server = servers[0];
if (!server) {
@@ -66,7 +156,7 @@ async function main() {
const loads = [];
for (let cx = -CHUNK_RADIUS; cx <= CHUNK_RADIUS; cx++) {
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);
+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
// Babylon dependency) is what makes it unit-testable without a browser — see voxel-mesh.test.ts.
import { colorFor } from "./block-colors.js";
import { textureName } from "./block-textures.js";
const SIZE = 16;
const NO_ATLAS_RECT = [0, 0, 0, 0];
function blockAt(blocks, x, y, z) {
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];
}
export function meshSection(blocks) {
const buf = { positions: [], normals: [], colors: [], indices: [] };
// atlasRects: optional `{ [textureName]: [u0,v0,u1,v1] }` map (see gltf-export.js/export-worker.js
// — 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 (const dir of [-1, 1]) {
meshAxis(blocks, axis, dir, buf);
meshAxis(blocks, axis, dir, buf, atlasRects);
}
}
return {
positions: Float32Array.from(buf.positions),
normals: Float32Array.from(buf.normals),
colors: Float32Array.from(buf.colors),
uvs: Float32Array.from(buf.uvs),
atlasRects: Float32Array.from(buf.atlasRects),
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));
for (let layer = 0; layer < SIZE; layer++) {
@@ -62,11 +71,11 @@ function meshAxis(blocks, axis, dir, buf) {
}
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));
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;
}
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 = [
[u0, 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 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);
buf.positions.push(x, y, z);
buf.normals.push(...normal);
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
// 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[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);
});