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) {
|
||||
|
||||
Reference in New Issue
Block a user