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,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