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