a7b69bd038
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
187 lines
9.0 KiB
Rust
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)
|
|
}
|