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