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:
@@ -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]);
|
||||
});
|
||||
});
|
||||
|
||||
+102
-12
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user