Add GPU-accelerated rendering: cpu/gpu/hybrid RenderBackend (Phase 8)
Splits RenderBackend's per-voxel face-visibility extraction and tile shading out as GPU-offloadable steps (wgpu compute shaders), while keeping greedy-mesh merge/compaction CPU-only per the plan's "partial GPU rendering" design. RENDER_BACKEND=cpu|gpu|hybrid selects the strategy, falling back to cpu automatically (logged) if no compatible GPU adapter is found. Verified against a real GPU: all tests pass, including ones asserting byte-identical output between the cpu and gpu backends; a new benchmark example honestly shows cpu currently outperforming gpu/hybrid at realistic batch sizes since each call is its own dispatch/readback round trip rather than batched across a whole render batch (documented as a follow-up optimization). Also fixes two real, pre-existing gaps found while validating the worker's actual `docker build`: a missing .dockerignore was sending the local multi-GB target/ dir into the build context, and the Dockerfile's rust:1.80 pin was already too old for current transitive dependency MSRVs (bumped to rust:1.97). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015tKdPZt78zbPUZMXWzKEKt
This commit is contained in:
@@ -1,6 +1,13 @@
|
||||
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;
|
||||
|
||||
/// One rendered column within a chunk, in chunk-local coordinates (0..16).
|
||||
pub struct ColumnPixel {
|
||||
@@ -10,14 +17,85 @@ pub struct ColumnPixel {
|
||||
pub block_meta: u8,
|
||||
}
|
||||
|
||||
/// Swappable rendering strategy (CPU via rayon / GPU via wgpu / hybrid) selected at startup
|
||||
/// via the `RENDER_BACKEND` env var. Only the CPU path exists so far; GPU/hybrid land in
|
||||
/// Phase 8, with automatic fallback to CPU if `gpu`/`hybrid` is requested but no compatible
|
||||
/// GPU is found.
|
||||
/// 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 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(col.block_id, col.block_meta);
|
||||
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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user