diff --git a/README.md b/README.md index 028cc1d..79494dc 100644 --- a/README.md +++ b/README.md @@ -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 +`-` 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 ``` diff --git a/api/src/index.ts b/api/src/index.ts index 68c1b76..c71b2f6 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -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 }) => { diff --git a/frontend/src/public/js/block-textures.js b/frontend/src/public/js/block-textures.js new file mode 100644 index 0000000..63318ae --- /dev/null +++ b/frontend/src/public/js/block-textures.js @@ -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; + } +} diff --git a/frontend/src/public/js/block-textures.test.ts b/frontend/src/public/js/block-textures.test.ts new file mode 100644 index 0000000..ae610a0 --- /dev/null +++ b/frontend/src/public/js/block-textures.test.ts @@ -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(); +}); diff --git a/frontend/src/public/js/gltf-export.js b/frontend/src/public/js/gltf-export.js index fc597af..280cbe4 100644 --- a/frontend/src/public/js/gltf-export.js +++ b/frontend/src/public/js/gltf-export.js @@ -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* diff --git a/frontend/src/public/js/mesh-format.js b/frontend/src/public/js/mesh-format.js index 9941156..0cb3b74 100644 --- a/frontend/src/public/js/mesh-format.js +++ b/frontend/src/public/js/mesh-format.js @@ -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 }; } diff --git a/frontend/src/public/js/mesh-format.test.ts b/frontend/src/public/js/mesh-format.test.ts index 8eb6e1a..80ae347 100644 --- a/frontend/src/public/js/mesh-format.test.ts +++ b/frontend/src/public/js/mesh-format.test.ts @@ -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]); + }); }); diff --git a/frontend/src/public/js/mesh.js b/frontend/src/public/js/mesh.js index 5fc23d0..f3f4214 100644 --- a/frontend/src/public/js/mesh.js +++ b/frontend/src/public/js/mesh.js @@ -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); diff --git a/frontend/src/public/js/voxel-mesh.js b/frontend/src/public/js/voxel-mesh.js index f6959e5..1e34f3f 100644 --- a/frontend/src/public/js/voxel-mesh.js +++ b/frontend/src/public/js/voxel-mesh.js @@ -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). diff --git a/frontend/src/public/js/voxel-mesh.test.ts b/frontend/src/public/js/voxel-mesh.test.ts index fbad4d3..378c304 100644 --- a/frontend/src/public/js/voxel-mesh.test.ts +++ b/frontend/src/public/js/voxel-mesh.test.ts @@ -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); +}); diff --git a/worker/src/atlas.rs b/worker/src/atlas.rs new file mode 100644 index 0000000..2802b2c --- /dev/null +++ b/worker/src/atlas.rs @@ -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, +} + +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> { + 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> { + 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) -> 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); + +/// Loads a cached atlas from `/vanilla-[-]-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//` 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 `-` 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 { + 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()); + } +} diff --git a/worker/src/lib.rs b/worker/src/lib.rs index e48d9cb..e65222e 100644 --- a/worker/src/lib.rs +++ b/worker/src/lib.rs @@ -1,3 +1,4 @@ +pub mod atlas; pub mod block_names; pub mod config; pub mod db; diff --git a/worker/src/main.rs b/worker/src/main.rs index 97f1dce..05962c6 100644 --- a/worker/src/main.rs +++ b/worker/src/main.rs @@ -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)"); } diff --git a/worker/src/mesh.rs b/worker/src/mesh.rs index 237c82a..a5ec548 100644 --- a/worker/src/mesh.rs +++ b/worker/src/mesh.rs @@ -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, } @@ -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 { 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); + } } diff --git a/worker/src/render/mod.rs b/worker/src/render/mod.rs index e560baa..085f55f 100644 --- a/worker/src/render/mod.rs +++ b/worker/src/render/mod.rs @@ -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 = 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, diff --git a/worker/src/textures.rs b/worker/src/textures.rs index aa59103..f33013d 100644 --- a/worker/src/textures.rs +++ b/worker/src/textures.rs @@ -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 { 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> { + 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 { } 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 `/vanilla-.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> { +/// `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> { 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> { } fn extract_palette(jar_bytes: &[u8]) -> anyhow::Result { + 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> { 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 { 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)]