Phase 2: full-voxel chunk storage, greedy mesher, and Babylon 3D viewer

api: chunk_sections table (per 16x16x16 section, base64-encoded u16
blockStateId array) and mesh_pointers table, additive to Phase 1's
column-based chunk_columns/tile_pointers — 2D tile rendering keeps using
the cheap column path unchanged. New "sections" WS message (backfill on
chunk load + delta resend on flush, same "current state, not a diff"
philosophy as columns) reuses the existing dirty-chunk Redis event, so one
event now triggers the worker to re-render both the 2D tile and any 3D
meshes for that chunk. New mesh-serving routes.

worker: a from-scratch greedy mesher (per-axis 2D mask sweep + rectangle
merge — the standard voxel-meshing technique, reimplemented from its
public description, not copied from any codebase) producing a compact
custom binary vertex buffer per non-empty section. Verified with unit
tests, including one that specifically checks a uniform section collapses
to exactly 6 merged quads rather than one quad per voxel face (the
decisive signal that merging, not just per-voxel face emission, is
actually happening).

frontend: a barebones Babylon.js 3D viewer (/3d) that loads a fixed radius
of chunks, parses the mesh binary format, and renders each section as its
own mesh (no cross-section merging yet, no camera-based streaming yet —
both reasonable follow-ups once there's a reason to optimize).

End-to-end verified against live containers, including through the real
mod-side Java WS client (see MCMapper-Mod's matching commit): a known
half-solid section correctly round-trips to exactly 24 vertices / 36
indices at the mesh-serving endpoint, matching the "6 merged outer faces"
the unit tests predict.
This commit is contained in:
2026-08-08 16:19:03 +02:00
parent 7bed571ffa
commit 5ed4d32a56
15 changed files with 707 additions and 12 deletions
+22
View File
@@ -0,0 +1,22 @@
CREATE TABLE IF NOT EXISTS "chunk_sections" (
"server_id" uuid NOT NULL REFERENCES "servers"("id") ON DELETE CASCADE,
"dimension" integer NOT NULL,
"x" integer NOT NULL,
"z" integer NOT NULL,
"section_y" integer NOT NULL,
"blocks" text NOT NULL,
"updated_at" timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY ("server_id", "dimension", "x", "z", "section_y")
);
CREATE TABLE IF NOT EXISTS "mesh_pointers" (
"server_id" uuid NOT NULL REFERENCES "servers"("id") ON DELETE CASCADE,
"dimension" integer NOT NULL,
"x" integer NOT NULL,
"z" integer NOT NULL,
"section_y" integer NOT NULL,
"storage_key" text NOT NULL,
"content_hash" text NOT NULL,
"rendered_at" timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY ("server_id", "dimension", "x", "z", "section_y")
);
+49 -3
View File
@@ -11,9 +11,8 @@ export const servers = pgTable("servers", {
});
// Column-granularity world state: the topmost non-air block per (dimension, x, z), plus its
// height. This is deliberately not full per-voxel storage — Phase 1 only needs enough to
// rasterize a top-down 2D tile and to derive marker Y later. Full block/section data for 3D
// meshing is a Phase 2 extension of this table, not built now (see plan's phased scope).
// height. Kept deliberately separate from `chunkSections` below — cheap to write/read for 2D
// tile rendering, which never needs full voxel data.
export const chunkColumns = pgTable(
"chunk_columns",
{
@@ -31,6 +30,53 @@ export const chunkColumns = pgTable(
(table) => [primaryKey({ columns: [table.serverId, table.dimension, table.x, table.z] })],
);
// Full-voxel storage for one 16x16x16 section (sectionY = worldY / 16), for 3D meshing —
// additive to `chunkColumns`, not a replacement (see that table's comment). `blocks` is the
// same base64 the mod sends over the wire: 4096 little-endian u16 blockStateIds, indexed by
// `(ly*16 + lz)*16 + lx` within the section. Stored as base64 text rather than real bytea to
// avoid postgres-js/drizzle binary-column plumbing for what's still an MVP — worth revisiting
// if storage size ever matters (base64 is ~33% larger than raw bytes).
export const chunkSections = pgTable(
"chunk_sections",
{
serverId: uuid("server_id")
.notNull()
.references(() => servers.id, { onDelete: "cascade" }),
dimension: integer("dimension").notNull(),
x: integer("x").notNull(),
z: integer("z").notNull(),
sectionY: integer("section_y").notNull(),
blocks: text("blocks").notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
primaryKey({ columns: [table.serverId, table.dimension, table.x, table.z, table.sectionY] }),
],
);
// Metadata pointer to a rendered mesh buffer in MinIO, mirroring `tilePointers` but for 3D
// meshes — one row per rendered section (a chunk with N non-empty sections gets N mesh rows,
// each loaded as its own Babylon mesh; see worker/src/mesh/mod.rs for why section boundaries
// aren't merged in Phase 2).
export const meshPointers = pgTable(
"mesh_pointers",
{
serverId: uuid("server_id")
.notNull()
.references(() => servers.id, { onDelete: "cascade" }),
dimension: integer("dimension").notNull(),
x: integer("x").notNull(),
z: integer("z").notNull(),
sectionY: integer("section_y").notNull(),
storageKey: text("storage_key").notNull(),
contentHash: text("content_hash").notNull(),
renderedAt: timestamp("rendered_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
primaryKey({ columns: [table.serverId, table.dimension, table.x, table.z, table.sectionY] }),
],
);
// Metadata pointer to a rendered tile PNG in MinIO — the binary itself never touches Postgres.
// One row per (server, dimension, zoom, tileX, tileZ); zoom is always 0 until Phase 2 adds
// multi-resolution tiles.
+41 -1
View File
@@ -1,7 +1,7 @@
import { Elysia } from "elysia";
import { and, eq } from "drizzle-orm";
import { db } from "./db/client";
import { servers, tilePointers } from "./db/schema";
import { meshPointers, servers, tilePointers } from "./db/schema";
import { wsGateway } from "./ws-gateway";
import { minio, TILE_BUCKET, ensureTileBucket } from "./minio";
@@ -47,6 +47,46 @@ const app = new Elysia()
set.headers["content-type"] = "image/png";
return new Response(stream as any);
})
// Lists which sections of a chunk have a rendered mesh, so the frontend knows what to fetch
// (a chunk with no mesh_pointers rows yet just hasn't been rendered — not an error).
.get("/api/meshes/:serverId/:dimension/:chunkX/:chunkZ", async ({ params }) => {
const rows = await db
.select({ sectionY: meshPointers.sectionY })
.from(meshPointers)
.where(
and(
eq(meshPointers.serverId, params.serverId),
eq(meshPointers.dimension, Number(params.dimension)),
eq(meshPointers.x, Number(params.chunkX)),
eq(meshPointers.z, Number(params.chunkZ)),
),
);
return rows.map((r) => r.sectionY);
})
.get("/api/meshes/:serverId/:dimension/:chunkX/:chunkZ/:sectionY", async ({ params, set }) => {
const [pointer] = await db
.select()
.from(meshPointers)
.where(
and(
eq(meshPointers.serverId, params.serverId),
eq(meshPointers.dimension, Number(params.dimension)),
eq(meshPointers.x, Number(params.chunkX)),
eq(meshPointers.z, Number(params.chunkZ)),
eq(meshPointers.sectionY, Number(params.sectionY.replace(/\.bin$/, ""))),
),
)
.limit(1);
if (!pointer) {
set.status = 404;
return { error: "mesh_not_rendered" };
}
const stream = await minio.getObject(TILE_BUCKET, pointer.storageKey);
set.headers["content-type"] = "application/octet-stream";
return new Response(stream as any);
})
.ws("/ws", {
open: wsGateway.open,
message: wsGateway.message,
+5
View File
@@ -1,5 +1,10 @@
import { Client } from "minio";
// Holds both rendered 2D tile PNGs (key: `{serverId}/{dimension}/{zoom}/{chunkX}/{chunkZ}.png`)
// and 3D mesh buffers (key: `{serverId}/{dimension}/mesh/{chunkX}/{chunkZ}/{sectionY}.bin`) —
// one bucket, distinguished by key prefix, to avoid provisioning a second scoped bucket/IAM
// policy on the shared MinIO instance for what's still a small amount of data (see README's
// "Object storage" section).
export const TILE_BUCKET = "mcmapper-tiles";
export const minio = new Client({
+50 -3
View File
@@ -1,6 +1,6 @@
import { eq } from "drizzle-orm";
import { db } from "./db/client";
import { chunkColumns, servers } from "./db/schema";
import { chunkColumns, chunkSections, servers } from "./db/schema";
import { markChunkDirty } from "./redis";
// Wire protocol (mod <-> api), one JSON object per WS text frame:
@@ -10,13 +10,21 @@ import { markChunkDirty } from "./redis";
// {"type":"hello_ack","ok":false,"error":"..."} (connection closed after)
//
// mod -> api {"type":"columns","dimension":0,"columns":[{"x":..,"z":..,"height":..,"blockId":..,"blockMeta":..}]}
// mod -> api {"type":"sections","dimension":0,"chunkX":..,"chunkZ":..,"sections":[{"sectionY":4,"blocks":"<base64>"}]}
//
// `columns` doubles as both initial backfill (one message per loaded chunk, 256 columns) and
// live deltas (one message per flush tick, just the columns that changed) — both are just "here
// is the current topmost block + height for these XZ columns", the mod recomputes it from its
// own world access rather than the api trying to infer a post-break top block from a raw diff.
// Full per-voxel data (needed for Phase 2 3D meshing) is a natural extension of this same
// connection once the chunk store grows a full block-data column.
//
// `sections` is the Phase 2 addition for full-voxel 3D meshing, additive to `columns` (see
// db/schema.ts's chunkColumns/chunkSections comments) — one message per loaded chunk at load
// time (all non-empty 16x16x16 sections), and again at flush time for chunks touched since the
// last flush (the whole section is resent, same "current state, not a diff" philosophy as
// columns — see DeltaEvent's javadoc on the mod side). `blocks` is 4096 little-endian u16
// blockStateIds, base64-encoded, indexed by `(ly*16 + lz)*16 + lx` within the section.
// A "sections" message marks the chunk dirty the same way "columns" does — one dirty-chunk
// event now triggers the worker to re-render both the 2D tile and any 3D meshes for that chunk.
interface Column {
x: number;
@@ -26,6 +34,11 @@ interface Column {
blockMeta: number;
}
interface Section {
sectionY: number;
blocks: string;
}
interface ConnState {
serverId: string;
}
@@ -107,6 +120,40 @@ export const wsGateway = {
}
return;
}
if (msg.type === "sections") {
const dimension: number = msg.dimension;
const chunkX: number = msg.chunkX;
const chunkZ: number = msg.chunkZ;
const sections: Section[] = msg.sections ?? [];
if (sections.length === 0) return;
await db
.insert(chunkSections)
.values(
sections.map((s) => ({
serverId: state.serverId,
dimension,
x: chunkX,
z: chunkZ,
sectionY: s.sectionY,
blocks: s.blocks,
})),
)
.onConflictDoUpdate({
target: [
chunkSections.serverId,
chunkSections.dimension,
chunkSections.x,
chunkSections.z,
chunkSections.sectionY,
],
set: { blocks: sqlExcluded("blocks"), updatedAt: new Date() },
});
await markChunkDirty(state.serverId, dimension, chunkX, chunkZ);
return;
}
},
close(ws: any) {
+5 -2
View File
@@ -2,15 +2,18 @@ import { Elysia } from "elysia";
import pug from "pug";
import { join } from "path";
// Phase 1: a barebones Leaflet 2D viewer (see public/js/map.js). Babylon 3D canvas, chat box,
// marker tool, and admin panel land in later phases per the plan's phased delivery.
// Phase 1: a barebones Leaflet 2D viewer (see public/js/map.js). Phase 2 adds the Babylon 3D
// viewer (see public/js/mesh.js). Chat box, marker tool, and admin panel land in later phases.
const renderIndex = pug.compileFile(join(import.meta.dir, "views/index.pug"));
const renderScene3d = pug.compileFile(join(import.meta.dir, "views/scene3d.pug"));
const app = new Elysia()
.get("/", () => new Response(renderIndex({}), { headers: { "Content-Type": "text/html" } }))
.get("/3d", () => new Response(renderScene3d({}), { headers: { "Content-Type": "text/html" } }))
.get("/health", () => ({ status: "ok" }))
.get("/css/tailwind.css", () => Bun.file(join(import.meta.dir, "public/css/tailwind.css")))
.get("/js/map.js", () => Bun.file(join(import.meta.dir, "public/js/map.js")))
.get("/js/mesh.js", () => Bun.file(join(import.meta.dir, "public/js/mesh.js")))
.listen(Number(process.env.PORT ?? 3001));
console.log(`[frontend] listening on :${app.server?.port}`);
+108
View File
@@ -0,0 +1,108 @@
// Barebones Babylon 3D viewer (Phase 2). Loads a fixed radius of chunks around the origin once
// at startup — no camera-based dynamic streaming/culling yet, that's a natural follow-up once
// there's a reason to care about performance at scale. Dimension is hardcoded to 0 (overworld),
// matching the 2D map's assumption (see public/js/map.js).
const DIMENSION = 0;
const CHUNK_RADIUS = 2; // (2*2+1)^2 = 25 chunks
// Binary mesh format written by worker/src/mesh.rs's MeshBuffers::encode():
// u32 vertexCount, u32 indexCount,
// f32[vertexCount*3] positions, f32[vertexCount*3] normals, f32[vertexCount*3] colors,
// u32[indexCount] indices — all little-endian.
function parseMeshBuffer(buf) {
const view = new DataView(buf);
const vertexCount = view.getUint32(0, true);
const indexCount = view.getUint32(4, true);
let offset = 8;
const positions = new Float32Array(buf, offset, vertexCount * 3);
offset += vertexCount * 3 * 4;
const normals = new Float32Array(buf, offset, vertexCount * 3);
offset += vertexCount * 3 * 4;
const rgb = new Float32Array(buf, offset, vertexCount * 3);
offset += vertexCount * 3 * 4;
const indices = new Uint32Array(buf, offset, indexCount);
// Babylon's VertexData.colors wants RGBA.
const colors = new Float32Array(vertexCount * 4);
for (let i = 0; i < vertexCount; i++) {
colors[i * 4] = rgb[i * 3];
colors[i * 4 + 1] = rgb[i * 3 + 1];
colors[i * 4 + 2] = rgb[i * 3 + 2];
colors[i * 4 + 3] = 1;
}
return { positions, normals, colors, indices };
}
async function loadSectionMesh(scene, serverId, chunkX, chunkZ, sectionY) {
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);
if (indices.length === 0) return;
const mesh = new BABYLON.Mesh(`section-${chunkX}-${chunkZ}-${sectionY}`, scene);
const vertexData = new BABYLON.VertexData();
vertexData.positions = positions;
vertexData.normals = normals;
vertexData.indices = indices;
vertexData.colors = colors;
vertexData.applyToMesh(mesh);
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) {
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)));
}
async function main() {
const statusEl = document.getElementById("status");
const canvas = document.getElementById("renderCanvas");
const engine = new BABYLON.Engine(canvas, true);
const scene = new BABYLON.Scene(engine);
scene.clearColor = new BABYLON.Color4(0.1, 0.1, 0.12, 1);
const camera = new BABYLON.ArcRotateCamera(
"camera", -Math.PI / 2, Math.PI / 3, 80,
new BABYLON.Vector3(0, 70, 0), scene,
);
camera.attachControl(canvas, true);
camera.wheelPrecision = 5;
camera.lowerRadiusLimit = 5;
new BABYLON.HemisphericLight("light", new BABYLON.Vector3(0.3, 1, 0.2), scene);
const servers = await fetch("/api/servers").then((r) => r.json());
const server = servers[0];
if (!server) {
statusEl.textContent = "no server registered yet — see backend README (bun run seed)";
} else {
statusEl.textContent = `loading meshes for ${server.name}`;
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));
}
}
await Promise.all(loads);
statusEl.textContent = `${server.name}${scene.meshes.length} section meshes loaded`;
}
engine.runRenderLoop(() => scene.render());
window.addEventListener("resize", () => engine.resize());
}
main();
+1
View File
@@ -14,6 +14,7 @@ html(lang="en")
div#app.h-screen.flex.flex-col(x-data="mapmapper()" x-init="init()")
header.px-4.py-2.flex.items-center.gap-4.border-b.border-neutral-700
h1.text-lg.font-semibold MCMapper
a.text-sm.text-neutral-400.underline(href="/3d") 3D view
span.text-sm.text-neutral-400(x-show="!loading && server")
| Viewing:
span(x-text="server?.name")
+18
View File
@@ -0,0 +1,18 @@
doctype html
html(lang="en")
head
meta(charset="utf-8")
meta(name="viewport" content="width=device-width, initial-scale=1")
title MCMapper — 3D
link(rel="stylesheet" href="/css/tailwind.css")
script(src="https://cdn.babylonjs.com/babylon.js")
style.
html, body, #renderCanvas { height: 100%; margin: 0; touch-action: none; outline: none; }
body.bg-neutral-900.text-neutral-100
div.h-screen.flex.flex-col
header.px-4.py-2.flex.items-center.gap-4.border-b.border-neutral-700
h1.text-lg.font-semibold MCMapper — 3D
a.text-sm.text-neutral-400.underline(href="/") back to 2D map
span#status.text-sm.text-neutral-500 loading…
canvas#renderCanvas.flex-1
script(src="/js/mesh.js")
+1
View File
@@ -1732,6 +1732,7 @@ dependencies = [
"aws-config",
"aws-credential-types",
"aws-sdk-s3",
"base64",
"image",
"rayon",
"redis",
+1
View File
@@ -14,6 +14,7 @@ image = { version = "0.25", default-features = false, features = ["png"] }
aws-sdk-s3 = "1"
aws-config = "1"
aws-credential-types = "1"
base64 = "0.22"
[profile.release]
lto = true
+55
View File
@@ -38,6 +38,61 @@ pub async fn fetch_chunk_columns(
Ok(rows)
}
#[derive(sqlx::FromRow)]
pub struct StoredSection {
pub section_y: i32,
pub blocks: String,
}
pub async fn fetch_chunk_sections(
pool: &PgPool,
server_id: Uuid,
dimension: i32,
chunk_x: i32,
chunk_z: i32,
) -> anyhow::Result<Vec<StoredSection>> {
let rows = sqlx::query_as::<_, StoredSection>(
r#"SELECT section_y, blocks FROM chunk_sections
WHERE server_id = $1 AND dimension = $2 AND x = $3 AND z = $4"#,
)
.bind(server_id)
.bind(dimension)
.bind(chunk_x)
.bind(chunk_z)
.fetch_all(pool)
.await?;
Ok(rows)
}
#[allow(clippy::too_many_arguments)]
pub async fn upsert_mesh_pointer(
pool: &PgPool,
server_id: Uuid,
dimension: i32,
chunk_x: i32,
chunk_z: i32,
section_y: i32,
storage_key: &str,
content_hash: &str,
) -> anyhow::Result<()> {
sqlx::query(
r#"INSERT INTO mesh_pointers (server_id, dimension, x, z, section_y, storage_key, content_hash, rendered_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, now())
ON CONFLICT (server_id, dimension, x, z, section_y)
DO UPDATE SET storage_key = excluded.storage_key, content_hash = excluded.content_hash, rendered_at = now()"#,
)
.bind(server_id)
.bind(dimension)
.bind(chunk_x)
.bind(chunk_z)
.bind(section_y)
.bind(storage_key)
.bind(content_hash)
.execute(pool)
.await?;
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub async fn upsert_tile_pointer(
pool: &PgPool,
+71 -1
View File
@@ -1,4 +1,5 @@
mod db;
mod mesh;
mod palette;
mod render;
mod storage;
@@ -6,6 +7,7 @@ mod storage;
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use base64::{engine::general_purpose::STANDARD, Engine as _};
use redis::streams::{StreamReadOptions, StreamReadReply};
use redis::AsyncCommands;
use render::{ColumnPixel, CpuRenderBackend, RenderBackend};
@@ -119,7 +121,7 @@ async fn process_entry(
let content_hash = format!("{:x}", hasher.finish());
let storage_key = format!("{server_id}/{dimension}/0/{chunk_x}/{chunk_z}.png");
storage::put_tile(s3_client, &storage_key, png_bytes).await?;
storage::put_object(s3_client, &storage_key, "image/png", png_bytes).await?;
db::upsert_tile_pointer(
pool,
server_id,
@@ -133,5 +135,73 @@ async fn process_entry(
.await?;
println!("[worker] rendered tile {storage_key} ({} columns)", pixels.len());
mesh_chunk(pool, s3_client, server_id, dimension, chunk_x, chunk_z).await?;
Ok(())
}
fn decode_blocks(base64_blocks: &str) -> anyhow::Result<[u16; 4096]> {
let bytes = STANDARD.decode(base64_blocks)?;
if bytes.len() != 8192 {
anyhow::bail!("expected 8192 bytes (4096 u16), got {}", bytes.len());
}
let mut blocks = [0u16; 4096];
for (i, chunk) in bytes.chunks_exact(2).enumerate() {
blocks[i] = u16::from_le_bytes([chunk[0], chunk[1]]);
}
Ok(blocks)
}
async fn mesh_chunk(
pool: &sqlx::PgPool,
s3_client: &aws_sdk_s3::Client,
server_id: Uuid,
dimension: i32,
chunk_x: i32,
chunk_z: i32,
) -> anyhow::Result<()> {
let sections = db::fetch_chunk_sections(pool, server_id, dimension, chunk_x, chunk_z).await?;
if sections.is_empty() {
return Ok(()); // this server hasn't sent 3D data yet (Phase 2 mod support) — fine, no-op
}
for section in sections {
let blocks = match decode_blocks(&section.blocks) {
Ok(b) => b,
Err(err) => {
eprintln!(
"[worker] skipping malformed section ({chunk_x},{chunk_z},{}): {err:#}",
section.section_y
);
continue;
}
};
let mesh_buf = mesh::mesh_section(&blocks);
if mesh_buf.is_empty() {
continue;
}
let mesh_bytes = mesh_buf.encode();
let mut hasher = DefaultHasher::new();
mesh_bytes.hash(&mut hasher);
let content_hash = format!("{:x}", hasher.finish());
let storage_key =
format!("{server_id}/{dimension}/mesh/{chunk_x}/{chunk_z}/{}.bin", section.section_y);
storage::put_object(s3_client, &storage_key, "application/octet-stream", mesh_bytes).await?;
db::upsert_mesh_pointer(
pool,
server_id,
dimension,
chunk_x,
chunk_z,
section.section_y,
&storage_key,
&content_hash,
)
.await?;
println!("[worker] rendered mesh {storage_key}");
}
Ok(())
}
+278
View File
@@ -0,0 +1,278 @@
use crate::palette::color_for;
const SIZE: i32 = 16;
/// 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
/// chunk with N non-empty sections becomes N small Babylon meshes rather than one combined mesh.
/// That's a deliberate MVP simplification: it produces some redundant internal faces at section
/// seams but avoids needing to fetch/hold neighboring sections just to mesh one.
///
/// This implements the standard "sweep each axis, build a 2D visibility mask per layer, greedily
/// merge same-block runs into rectangles" technique (the general approach widely described for
/// voxel engines, e.g. 0fps.net's "Meshing in a Minecraft Game" — reimplemented from that public
/// description, not copied from any specific codebase).
#[derive(Default)]
pub struct MeshBuffers {
pub positions: Vec<[f32; 3]>,
pub normals: Vec<[f32; 3]>,
pub colors: Vec<[f32; 3]>,
pub indices: Vec<u32>,
}
impl MeshBuffers {
pub fn is_empty(&self) -> bool {
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.
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);
out.extend_from_slice(&vertex_count.to_le_bytes());
out.extend_from_slice(&index_count.to_le_bytes());
for p in &self.positions {
for c in p {
out.extend_from_slice(&c.to_le_bytes());
}
}
for n in &self.normals {
for c in n {
out.extend_from_slice(&c.to_le_bytes());
}
}
for c in &self.colors {
for ch in c {
out.extend_from_slice(&ch.to_le_bytes());
}
}
for i in &self.indices {
out.extend_from_slice(&i.to_le_bytes());
}
out
}
}
fn block_at(blocks: &[u16; 4096], x: i32, y: i32, z: i32) -> u16 {
if x < 0 || x >= SIZE || y < 0 || y >= SIZE || z < 0 || z >= SIZE {
return 0; // section boundary — treated as air, so boundary faces are always drawn
}
blocks[((y as usize) * 16 + z as usize) * 16 + x as usize]
}
/// Maps (axis, layer, u, v) to a 3D voxel coordinate. axis 0 fixes x, 1 fixes y, 2 fixes z.
fn axis_pos(axis: usize, layer: i32, u: i32, v: i32) -> (i32, i32, i32) {
match axis {
0 => (layer, u, v),
1 => (u, layer, v),
_ => (u, v, layer),
}
}
pub fn mesh_section(blocks: &[u16; 4096]) -> MeshBuffers {
let mut buf = MeshBuffers::default();
for axis in 0..3 {
for &dir in &[-1i32, 1i32] {
mesh_axis(blocks, axis, dir, &mut buf);
}
}
buf
}
fn mesh_axis(blocks: &[u16; 4096], axis: usize, dir: i32, buf: &mut MeshBuffers) {
let mut mask = [[0u16; SIZE as usize]; SIZE as usize];
for layer in 0..SIZE {
// Build the visibility mask for this layer: mask[u][v] = blockId if a face should be
// drawn there (the voxel is solid and the neighbor in `dir` along `axis` is air/boundary).
for u in 0..SIZE {
for v in 0..SIZE {
let (x, y, z) = axis_pos(axis, layer, u, v);
let block = block_at(blocks, x, y, z);
mask[u as usize][v as usize] = if block == 0 {
0
} else {
let (ox, oy, oz) = offset_along_axis(axis, dir);
let neighbor = block_at(blocks, x + ox, y + oy, z + oz);
if neighbor == 0 { block } else { 0 }
};
}
}
let face_plane = if dir == 1 { layer + 1 } else { layer };
greedy_merge_and_emit(&mut mask, axis, dir, face_plane, buf);
}
}
fn offset_along_axis(axis: usize, dir: i32) -> (i32, i32, i32) {
match axis {
0 => (dir, 0, 0),
1 => (0, dir, 0),
_ => (0, 0, dir),
}
}
fn greedy_merge_and_emit(
mask: &mut [[u16; SIZE as usize]; SIZE as usize],
axis: usize,
dir: i32,
face_plane: i32,
buf: &mut MeshBuffers,
) {
let mut done = [[false; SIZE as usize]; SIZE as usize];
for u0 in 0..SIZE as usize {
for v0 in 0..SIZE as usize {
let block = mask[u0][v0];
if block == 0 || done[u0][v0] {
continue;
}
// Grow width along v.
let mut v1 = v0 + 1;
while v1 < SIZE as usize && mask[u0][v1] == block && !done[u0][v1] {
v1 += 1;
}
// Grow height along u, as long as the whole [v0, v1) run matches.
let mut u1 = u0 + 1;
'grow: while u1 < SIZE as usize {
for v in v0..v1 {
if mask[u1][v] != block || done[u1][v] {
break 'grow;
}
}
u1 += 1;
}
for u in u0..u1 {
for v in v0..v1 {
done[u][v] = true;
}
}
emit_quad(axis, dir, face_plane, u0 as i32, v0 as i32, u1 as i32, v1 as i32, block, buf);
}
}
}
fn emit_quad(
axis: usize,
dir: i32,
face_plane: i32,
u0: i32,
v0: i32,
u1: i32,
v1: i32,
block: u16,
buf: &mut MeshBuffers,
) {
let corners_uv = [(u0, v0), (u1, v0), (u1, v1), (u0, v1)];
let base_index = buf.positions.len() as u32;
let normal = match (axis, dir) {
(0, 1) => [1.0, 0.0, 0.0],
(0, -1) => [-1.0, 0.0, 0.0],
(1, 1) => [0.0, 1.0, 0.0],
(1, -1) => [0.0, -1.0, 0.0],
(2, 1) => [0.0, 0.0, 1.0],
_ => [0.0, 0.0, -1.0],
};
let block_id = block >> 4;
let block_meta = (block & 0xF) as u8;
let [r, g, b] = color_for(block_id, block_meta);
let color = [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0];
for (u, v) in corners_uv {
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);
}
// Two triangles per quad; flip winding by direction so both face orientations are at least
// approximately correct. Backface culling is left off on the frontend material as the
// safety net — see this module's doc comment and frontend/src/public/js/mesh.js.
if dir == 1 {
buf.indices.extend_from_slice(&[base_index, base_index + 1, base_index + 2, base_index, base_index + 2, base_index + 3]);
} else {
buf.indices.extend_from_slice(&[base_index, base_index + 2, base_index + 1, base_index, base_index + 3, base_index + 2]);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_section_produces_no_geometry() {
let blocks = [0u16; 4096];
let mesh = mesh_section(&blocks);
assert!(mesh.is_empty());
assert_eq!(mesh.positions.len(), 0);
}
#[test]
fn single_voxel_produces_six_unmerged_quads() {
let mut blocks = [0u16; 4096];
blocks[((0 * 16 + 0) * 16 + 0) as usize] = (2 << 4) | 0; // grass at local (0,0,0)
let mesh = mesh_section(&blocks);
assert_eq!(mesh.positions.len(), 6 * 4, "6 faces x 4 verts");
assert_eq!(mesh.indices.len(), 6 * 6, "6 faces x 2 tris x 3 indices");
}
#[test]
fn full_solid_section_collapses_to_six_merged_quads() {
// If greedy merging weren't actually merging, this would instead emit thousands of
// per-voxel quads (6 faces * 4096 voxels minus internal ones) — a full uniform section
// must merge down to exactly one quad per outer face.
let mut blocks = [0u16; 4096];
for b in blocks.iter_mut() {
*b = (1 << 4) | 0; // stone everywhere
}
let mesh = mesh_section(&blocks);
assert_eq!(mesh.positions.len(), 6 * 4, "6 merged outer faces x 4 verts");
assert_eq!(mesh.indices.len(), 6 * 6);
}
#[test]
fn checkerboard_layer_does_not_merge_across_gaps() {
// A single y=0 layer, alternating solid/air in a checkerboard on x/z: no two solid
// cells are adjacent, so the +y face mask can't merge anything — expect one quad per
// solid cell for that face direction specifically.
let mut blocks = [0u16; 4096];
let mut solid_count = 0;
for x in 0..16 {
for z in 0..16 {
if (x + z) % 2 == 0 {
blocks[(z * 16 + x) as usize] = (1 << 4) | 0;
solid_count += 1;
}
}
}
let mesh = mesh_section(&blocks);
// Just check the +y (top) face count via a targeted single-axis call.
let mut buf = MeshBuffers::default();
mesh_axis(&blocks, 1, 1, &mut buf);
assert_eq!(buf.positions.len(), solid_count * 4);
let _ = mesh; // silence unused warning if full mesh isn't otherwise inspected
}
#[test]
fn encode_round_trip_header() {
let mut blocks = [0u16; 4096];
blocks[0] = (2 << 4) | 0;
let mesh = mesh_section(&blocks);
let bytes = mesh.encode();
let vertex_count = u32::from_le_bytes(bytes[0..4].try_into().unwrap());
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;
assert_eq!(bytes.len(), expected_len);
}
}
+2 -2
View File
@@ -25,12 +25,12 @@ pub async fn ensure_bucket(client: &Client) -> anyhow::Result<()> {
Ok(())
}
pub async fn put_tile(client: &Client, key: &str, bytes: Vec<u8>) -> anyhow::Result<()> {
pub async fn put_object(client: &Client, key: &str, content_type: &str, bytes: Vec<u8>) -> anyhow::Result<()> {
client
.put_object()
.bucket(TILE_BUCKET)
.key(key)
.content_type("image/png")
.content_type(content_type)
.body(ByteStream::from(bytes))
.send()
.await?;