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
+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.