diff --git a/api/drizzle/0001_chunk_sections.sql b/api/drizzle/0001_chunk_sections.sql new file mode 100644 index 0000000..b6c79f7 --- /dev/null +++ b/api/drizzle/0001_chunk_sections.sql @@ -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") +); diff --git a/api/src/db/schema.ts b/api/src/db/schema.ts index f2a90d9..060c4ef 100644 --- a/api/src/db/schema.ts +++ b/api/src/db/schema.ts @@ -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. diff --git a/api/src/index.ts b/api/src/index.ts index 1968cce..94404b0 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -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, diff --git a/api/src/minio.ts b/api/src/minio.ts index f31cef4..08ac3f5 100644 --- a/api/src/minio.ts +++ b/api/src/minio.ts @@ -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({ diff --git a/api/src/ws-gateway.ts b/api/src/ws-gateway.ts index 0f93a09..7562e89 100644 --- a/api/src/ws-gateway.ts +++ b/api/src/ws-gateway.ts @@ -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":""}]} // // `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) { diff --git a/frontend/src/index.ts b/frontend/src/index.ts index d57bd73..c3f4d9c 100644 --- a/frontend/src/index.ts +++ b/frontend/src/index.ts @@ -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}`); diff --git a/frontend/src/public/js/mesh.js b/frontend/src/public/js/mesh.js new file mode 100644 index 0000000..206477c --- /dev/null +++ b/frontend/src/public/js/mesh.js @@ -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(); diff --git a/frontend/src/views/index.pug b/frontend/src/views/index.pug index ca8ea82..db069ba 100644 --- a/frontend/src/views/index.pug +++ b/frontend/src/views/index.pug @@ -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") diff --git a/frontend/src/views/scene3d.pug b/frontend/src/views/scene3d.pug new file mode 100644 index 0000000..450bf5c --- /dev/null +++ b/frontend/src/views/scene3d.pug @@ -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") diff --git a/worker/Cargo.lock b/worker/Cargo.lock index 741adeb..0773541 100644 --- a/worker/Cargo.lock +++ b/worker/Cargo.lock @@ -1732,6 +1732,7 @@ dependencies = [ "aws-config", "aws-credential-types", "aws-sdk-s3", + "base64", "image", "rayon", "redis", diff --git a/worker/Cargo.toml b/worker/Cargo.toml index b063e70..942263f 100644 --- a/worker/Cargo.toml +++ b/worker/Cargo.toml @@ -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 diff --git a/worker/src/db.rs b/worker/src/db.rs index cde0591..c21f1b6 100644 --- a/worker/src/db.rs +++ b/worker/src/db.rs @@ -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> { + 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, diff --git a/worker/src/main.rs b/worker/src/main.rs index 22f22ed..5dc01b7 100644 --- a/worker/src/main.rs +++ b/worker/src/main.rs @@ -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(§ion.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(()) } diff --git a/worker/src/mesh.rs b/worker/src/mesh.rs new file mode 100644 index 0000000..d33f35a --- /dev/null +++ b/worker/src/mesh.rs @@ -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, +} + +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 { + 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); + } +} diff --git a/worker/src/storage.rs b/worker/src/storage.rs index 17890f5..d3259ba 100644 --- a/worker/src/storage.rs +++ b/worker/src/storage.rs @@ -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) -> anyhow::Result<()> { +pub async fn put_object(client: &Client, key: &str, content_type: &str, bytes: Vec) -> anyhow::Result<()> { client .put_object() .bucket(TILE_BUCKET) .key(key) - .content_type("image/png") + .content_type(content_type) .body(ByteStream::from(bytes)) .send() .await?;