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:
@@ -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
|
||||
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
|
||||
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
|
||||
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
|
||||
@@ -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
|
||||
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
|
||||
|
||||
```
|
||||
|
||||
@@ -99,6 +99,30 @@ export const app = new Elysia()
|
||||
set.headers["content-type"] = "application/octet-stream";
|
||||
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
|
||||
// cookie, and is sent back via this header on subsequent requests.
|
||||
.post("/api/link/redeem", async ({ body, set }) => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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*
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,8 +102,14 @@ 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);
|
||||
|
||||
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
|
||||
@@ -30,14 +117,15 @@ async function loadSectionMesh(scene, serverId, chunkX, chunkZ, sectionY) {
|
||||
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);
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
pub mod atlas;
|
||||
pub mod block_names;
|
||||
pub mod config;
|
||||
pub mod db;
|
||||
|
||||
+43
-1
@@ -2,7 +2,7 @@ use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
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 redis::streams::{StreamReadOptions, StreamReadReply};
|
||||
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:#}"
|
||||
),
|
||||
}
|
||||
|
||||
// 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 {
|
||||
println!("[worker] ACCEPT_MINECRAFT_EULA not set — using hand-picked palette colors (see README)");
|
||||
}
|
||||
|
||||
+92
-8
@@ -1,8 +1,13 @@
|
||||
use crate::palette::color_for;
|
||||
use crate::palette::color_for_textured;
|
||||
use crate::render::RenderBackend;
|
||||
|
||||
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
|
||||
/// 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
|
||||
@@ -19,6 +24,17 @@ pub struct MeshBuffers {
|
||||
pub positions: Vec<[f32; 3]>,
|
||||
pub normals: 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>,
|
||||
}
|
||||
|
||||
@@ -27,13 +43,24 @@ impl MeshBuffers {
|
||||
self.indices.is_empty()
|
||||
}
|
||||
|
||||
/// Binary layout consumed directly by the frontend (see frontend/src/public/js/mesh.js):
|
||||
/// `u32 vertexCount, u32 indexCount, f32[vertexCount*3] positions, f32[vertexCount*3]
|
||||
/// normals, f32[vertexCount*3] colors, u32[indexCount] indices` — all little-endian.
|
||||
/// Binary layout consumed directly by the frontend (see frontend/src/public/js/mesh-format.js).
|
||||
/// Phase 12 bumped this to v2 by appending two new per-vertex buffers (`uvs`, `atlas_rects`)
|
||||
/// 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> {
|
||||
let vertex_count = self.positions.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(&index_count.to_le_bytes());
|
||||
for p in &self.positions {
|
||||
@@ -51,6 +78,16 @@ impl MeshBuffers {
|
||||
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 {
|
||||
out.extend_from_slice(&i.to_le_bytes());
|
||||
}
|
||||
@@ -175,14 +212,26 @@ fn emit_quad(
|
||||
};
|
||||
let block_id = block >> 4;
|
||||
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];
|
||||
|
||||
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);
|
||||
buf.positions.push([x as f32, y as f32, z as f32]);
|
||||
buf.normals.push(normal);
|
||||
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
|
||||
@@ -272,7 +321,42 @@ mod tests {
|
||||
let index_count = u32::from_le_bytes(bytes[4..8].try_into().unwrap());
|
||||
assert_eq!(vertex_count as usize, mesh.positions.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);
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,27 @@ pub fn set_texture_palette(palette: TexturePalette) {
|
||||
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).
|
||||
pub struct ColumnPixel {
|
||||
pub local_x: u8,
|
||||
|
||||
+33
-8
@@ -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.
|
||||
pub fn average_directory(dir: &Path) -> anyhow::Result<TexturePalette> {
|
||||
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() {
|
||||
return Ok(TexturePalette { colors });
|
||||
return Ok(images);
|
||||
}
|
||||
for entry in std::fs::read_dir(dir)? {
|
||||
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 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
|
||||
@@ -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...");
|
||||
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)?;
|
||||
|
||||
std::fs::create_dir_all(cache_dir)?;
|
||||
@@ -141,7 +153,10 @@ struct DownloadInfo {
|
||||
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 =
|
||||
reqwest::get("https://launchermeta.mojang.com/mc/game/version_manifest_v2.json")
|
||||
.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> {
|
||||
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 colors = HashMap::new();
|
||||
let mut images = HashMap::new();
|
||||
for i in 0..archive.len() {
|
||||
let mut file = archive.by_index(i)?;
|
||||
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 {
|
||||
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)]
|
||||
|
||||
Reference in New Issue
Block a user