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:
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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();
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
Generated
+1
@@ -1732,6 +1732,7 @@ dependencies = [
|
||||
"aws-config",
|
||||
"aws-credential-types",
|
||||
"aws-sdk-s3",
|
||||
"base64",
|
||||
"image",
|
||||
"rayon",
|
||||
"redis",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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?;
|
||||
|
||||
Reference in New Issue
Block a user