Files
MCMapper-Backend/worker/src/render/mod.rs
T
octoturge a7b69bd038 Phase 13: real non-cube block models via blockstate/model JSON resolution
Adds worker/src/models.rs (parent-chain blockstate/model resolution,
texture-variable substitution reusing Phase 12's atlas keys), routes
non-cube blocks through new per-element mesh emission in mesh.rs while
leaving full-cube blocks on the existing cube mesher, and threads a
per-server ModelContext (vanilla worker-wide + modded per-job) through
main.rs. Modded model JSON is stored in a new block_models Postgres
table and read alongside the existing (previously write-only)
block_registry table. Fixes a pre-existing face-culling bug as a side
effect of excluding non-cube voxels from the cube mesher's input.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015tKdPZt78zbPUZMXWzKEKt
2026-08-10 01:19:13 +02:00

187 lines
9.0 KiB
Rust

mod cpu;
mod gpu;
mod hybrid;
pub use cpu::CpuRenderBackend;
pub use gpu::GpuRenderBackend;
pub use hybrid::HybridRenderBackend;
use image::{ImageEncoder, RgbImage};
use std::io::Cursor;
use std::sync::OnceLock;
use crate::textures::TexturePalette;
/// Set once at startup (see main.rs) if `ACCEPT_MINECRAFT_EULA` is set and a texture palette was
/// built/loaded successfully — read by `base_colors` below so every `RenderBackend` (cpu/gpu/
/// hybrid all share `base_colors`) automatically picks up texture-averaged colors without the
/// `RenderBackend` trait itself needing a new parameter. Left unset in tests and when the operator
/// hasn't opted into the EULA, which keeps `base_colors`/hand-picked-color test assertions valid —
/// `palette::color_for_textured` falls back to `palette::color_for` whenever this is `None`.
static TEXTURE_PALETTE: OnceLock<TexturePalette> = OnceLock::new();
/// Called at most once, before the first render (see main.rs). A second call is a no-op (`OnceLock`
/// semantics) — main.rs only ever calls this once anyway, since the palette is resolved once at
/// startup, not per-request.
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()
}
/// Same `OnceLock`-once-at-startup pattern as `TEXTURE_PALETTE`/`TEXTURE_ATLAS`, for the Phase 13
/// vanilla model registry (see `models.rs`). Worker-wide, not per-server — the same simplification
/// tier already accepted for the texture palette/atlas. `None` until `main.rs` successfully builds
/// one; `mesh.rs` simply skips non-cube resolution entirely when this is `None`.
static VANILLA_MODEL_REGISTRY: OnceLock<crate::models::ModelRegistry> = OnceLock::new();
pub fn set_vanilla_model_registry(registry: crate::models::ModelRegistry) {
let _ = VANILLA_MODEL_REGISTRY.set(registry);
}
pub(crate) fn vanilla_model_registry() -> Option<&'static crate::models::ModelRegistry> {
VANILLA_MODEL_REGISTRY.get()
}
/// Bundles everything `mesh.rs` needs to resolve one voxel's real (possibly non-cube) shape:
/// the worker-wide vanilla registry plus one job's per-server modded registry/id-map, both
/// optional (a server with no modded model data yet, or a worker that never built the vanilla
/// registry, simply resolves nothing — mesh_section falls back to its pre-Phase-13 behavior).
/// Built fresh per chunk-job in `main.rs` (the modded half is genuinely per-server; the vanilla
/// half is just a borrow of the static above) rather than stored globally, since it's cheap and
/// keeps `mesh_section` ignorant of *where* the data came from.
#[derive(Default)]
pub struct ModelContext<'a> {
pub vanilla: Option<&'a crate::models::ModelRegistry>,
pub modded: Option<&'a crate::models::ModelRegistry>,
pub modded_id_to_name: std::collections::HashMap<u16, String>,
}
impl<'a> ModelContext<'a> {
pub fn resolve(&self, block_id: u16, meta: u8) -> Option<crate::models::ResolvedModel> {
crate::models::resolve_for_block(self.vanilla, self.modded, &self.modded_id_to_name, block_id, meta)
}
}
/// Builds a `ModelContext` for one chunk-job, combining the worker-wide vanilla registry (read
/// from the `OnceLock` above — kept private to this module, `main.rs` never reads it directly) with
/// the caller-supplied per-server modded registry/id-map (see `main.rs`'s `fetch_chunk_data`,
/// which queries `block_registry`/`block_models` for the job's `server_id`).
pub fn model_context(
modded: Option<&crate::models::ModelRegistry>,
modded_id_to_name: std::collections::HashMap<u16, String>,
) -> ModelContext<'_> {
ModelContext { vanilla: vanilla_model_registry(), modded, modded_id_to_name }
}
/// One rendered column within a chunk, in chunk-local coordinates (0..16).
pub struct ColumnPixel {
pub local_x: u8,
pub local_z: u8,
pub block_id: u16,
pub block_meta: u8,
}
/// Native tile resolution (one pixel per block within a chunk) before upscaling for display.
pub const CHUNK_SIZE: u32 = 16;
/// Upscale factor applied so tiles are a reasonable size for a Leaflet `tileSize: 256` layer —
/// Phase 1 has one native zoom level (see api's tile route), so this is purely cosmetic, not a
/// multi-resolution pyramid (that's Phase 2's job).
pub const UPSCALE: u32 = 16;
/// Per-voxel face-visibility extraction for one 16x16x16 section: for each of the 6
/// axis+direction combinations (index = `axis*2 + (dir==1 as usize)`, axis 0=x/1=y/2=z, matching
/// `mesh.rs`'s own iteration order), a full section-sized grid (same x/y/z-major flat layout as
/// the `blocks` input) where each entry is the voxel's own blockId if a face should be drawn in
/// that direction (the voxel is solid and its neighbor along that axis/direction is air or out of
/// bounds) or 0 otherwise. This is the embarrassingly-parallel per-voxel step Phase 8 targets for
/// GPU offload; the caller (`mesh::mesh_section`) still does greedy-mesh merge/compaction on CPU
/// regardless of which backend produced these masks, since that step is sequential/branchy — see
/// the plan's "partial GPU rendering" note.
pub type FaceMasks = [[u16; 4096]; 6];
/// Swappable rendering strategy (CPU via rayon / GPU via wgpu compute / hybrid) selected at
/// startup via the `RENDER_BACKEND` env var, with automatic fallback to CPU (logged) if `gpu`/
/// `hybrid` is requested but no compatible GPU adapter is found — see `main.rs`.
pub trait RenderBackend: Send + Sync {
fn name(&self) -> &'static str;
/// Rasterize one chunk's worth of columns (up to 256, sparse if the chunk isn't fully
/// synced yet) into a single-resolution top-down PNG tile, returned as encoded bytes.
fn rasterize_tile(&self, columns: &[ColumnPixel]) -> anyhow::Result<Vec<u8>>;
fn compute_face_masks(&self, blocks: &[u16; 4096]) -> FaceMasks;
}
/// Resolves a batch of columns into the native-resolution (16x16) base color grid, flat in
/// row-major (z-major, matching `ColumnPixel`'s local_x/local_z) order. Shared by every backend
/// — palette lookup is cheap (256 entries at most) and keeping it in one place avoids maintaining
/// two copies of `palette::color_for`'s logic (one in Rust, one duplicated into WGSL).
pub(crate) fn base_colors(columns: &[ColumnPixel]) -> [[u8; 3]; 256] {
let textures = TEXTURE_PALETTE.get();
let mut base = [crate::palette::color_for(0, 0); 256]; // air everywhere until overwritten
for col in columns {
if col.local_x as u32 >= CHUNK_SIZE || col.local_z as u32 >= CHUNK_SIZE {
continue;
}
let color = crate::palette::color_for_textured(col.block_id, col.block_meta, textures);
base[(col.local_z as usize) * 16 + col.local_x as usize] = color;
}
base
}
/// Nearest-neighbor-upscales a 16x16 base color grid into a `CHUNK_SIZE*UPSCALE` square, on the
/// CPU. The GPU backend performs the equivalent expansion via a compute dispatch instead (see
/// `gpu.rs`) and feeds its readback through `encode_png` below — both paths are expected to
/// produce byte-identical output for the same input, only *how* the loop runs differs.
pub(crate) fn cpu_upscale(base: &[[u8; 3]; 256]) -> RgbImage {
let size = CHUNK_SIZE * UPSCALE;
let mut upscaled = RgbImage::new(size, size);
for y in 0..size {
for x in 0..size {
let bx = (x / UPSCALE) as usize;
let by = (y / UPSCALE) as usize;
let [r, g, b] = base[by * 16 + bx];
upscaled.put_pixel(x, y, image::Rgb([r, g, b]));
}
}
upscaled
}
pub(crate) fn upscale_and_encode(base: &[[u8; 3]; 256]) -> anyhow::Result<Vec<u8>> {
let upscaled = cpu_upscale(base);
encode_png(upscaled.as_raw(), upscaled.width(), upscaled.height())
}
/// PNG-encodes a flat RGB8 pixel buffer. Shared by every backend so tile output is
/// byte-identical regardless of which one produced the raw pixels.
pub(crate) fn encode_png(rgb: &[u8], width: u32, height: u32) -> anyhow::Result<Vec<u8>> {
let mut bytes = Vec::new();
image::codecs::png::PngEncoder::new(&mut Cursor::new(&mut bytes)).write_image(
rgb,
width,
height,
image::ExtendedColorType::Rgb8,
)?;
Ok(bytes)
}