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) {