The first commit, the core and all files is adding to repo

This commit is contained in:
2026-10-04 14:20:13 +05:00
commit 53d7b263c4
94 changed files with 10668 additions and 0 deletions

19
renderer/index.html Normal file
View File

@@ -0,0 +1,19 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<style>
html,
body {
margin: 0;
background: #88bbed;
}
canvas {
display: block;
}
</style>
</head>
<body>
<script type="module" src="/renderer/scene.js"></script>
</body>
</html>

96
renderer/render.mjs Normal file
View File

@@ -0,0 +1,96 @@
import { chromium } from "playwright";
import { createServer } from "node:http";
import { readFile, writeFile, mkdir } from "node:fs/promises";
import { resolve, sep } from "node:path";
import sharp from "sharp";
import { assetsRoot } from "../scripts/assets.mjs";
/** @param {import('../shared/protocol').TerrainSnapshot} snapshot
* @param {Array<{id:string,x:number,y:number,z:number}>} viewpoints
* @param {string} output
* @param {{size?:number,signal?:AbortSignal}} [options] */
export async function renderSnapshot(
snapshot,
viewpoints,
output,
{ size = 1024, signal } = {},
) {
const start = Date.now();
await mkdir(output, { recursive: true });
const server = createServer(async (req, res) => {
try {
const url = new URL(req.url, "http://localhost");
let path =
url.pathname === "/three.js"
? "node_modules/three/build/three.module.js"
: url.pathname === "/three.core.js"
? "node_modules/three/build/three.core.js"
: url.pathname.startsWith("/assets/")
? `${assetsRoot}/${url.pathname.slice(8)}`
: url.pathname === "/"
? "renderer/index.html"
: url.pathname.slice(1);
path = resolve(path);
if (!path.startsWith(process.cwd() + sep))
throw new Error("Invalid path");
res.setHeader(
"Content-Type",
path.endsWith(".js")
? "text/javascript"
: path.endsWith(".json")
? "application/json"
: path.endsWith(".png")
? "image/png"
: "text/html",
);
res.end(await readFile(path));
} catch {
res.writeHead(404);
res.end();
}
});
await new Promise((r) => server.listen(0, "127.0.0.1", r));
let browser;
try {
browser = await chromium.launch({
headless: true,
args: ["--use-angle=swiftshader", "--enable-unsafe-swiftshader"],
});
const page = await browser.newPage();
page.on("pageerror", (err) => console.error("Renderer:", err.message));
await page.goto(`http://127.0.0.1:${server.address().port}`);
await page.waitForFunction(() => window.rendererReady);
const stats = await page.evaluate((s) => window.buildTerrain(s), snapshot);
for (const p of viewpoints) {
if (signal?.aborted) throw new Error("Rendering cancelled");
await mkdir(`${output}/${p.id}`, { recursive: true });
for (const face of ["px", "nx", "py", "ny", "pz", "nz"]) {
const data = await page.evaluate(
({ p, face, size }) => window.renderFace(p, face, size),
{ p, face, size },
);
const png = Buffer.from(data.split(",")[1], "base64");
await sharp(png)
.webp({ quality: 85 })
.toFile(`${output}/${p.id}/${face}.webp`);
if (face === "nz")
await sharp(png)
.resize(512, 512)
.webp({ quality: 76 })
.toFile(`${output}/${p.id}/preview.webp`);
}
}
const map = await page.evaluate(() => window.renderMap(1024));
await sharp(Buffer.from(map.split(",")[1], "base64"))
.webp({ quality: 90 })
.toFile(`${output}/map.webp`);
stats.renderMs = Date.now() - start;
await writeFile(
`${output}/render-report.json`,
JSON.stringify(stats, null, 2),
);
return stats;
} finally {
await browser?.close();
await new Promise((r) => server.close(r));
}
}

396
renderer/scene.js Normal file
View File

@@ -0,0 +1,396 @@
import * as THREE from "/three.js";
// This renderer consumes snapshots, never connects to a Minecraft server.
const renderer = new THREE.WebGLRenderer({
antialias: true,
preserveDrawingBuffer: true,
});
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.setPixelRatio(1);
document.body.append(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color("#95c4eb");
scene.add(new THREE.HemisphereLight("#e9f3ff", "#73704e", 2.0));
const sun = new THREE.DirectionalLight("#fff4d6", 2.2);
sun.position.set(-40, 90, -30);
scene.add(sun);
const loader = new THREE.TextureLoader(),
jsonCache = new Map(),
textureCache = new Map(),
resolvedModels = new Map();
const resourcePath = (ref, kind) =>
`/assets/assets/${ref.includes(":") ? ref.split(":")[0] : "minecraft"}/${kind}/${ref.split(":").at(-1)}`;
async function json(path) {
if (!jsonCache.has(path))
jsonCache.set(
path,
fetch(path).then((r) => {
if (!r.ok) throw new Error(path);
return r.json();
}),
);
return jsonCache.get(path);
}
async function model(ref, chain = []) {
if (chain.includes(ref)) throw new Error("Cyclic model");
if (resolvedModels.has(ref)) return resolvedModels.get(ref);
const raw = await json(resourcePath(ref, "models") + ".json");
let parent = {};
if (raw.parent && !raw.parent.includes("builtin/"))
parent = await model(raw.parent, [...chain, ref]);
const merged = {
...parent,
...raw,
textures: { ...parent.textures, ...raw.textures },
};
resolvedModels.set(ref, merged);
return merged;
}
function textureRef(m, ref) {
let i = 0;
while (ref?.startsWith("#") && i++ < 20) ref = m.textures[ref.slice(1)];
return ref;
}
async function texture(ref) {
if (!ref) return null;
if (!textureCache.has(ref)) {
const t = await loader.loadAsync(resourcePath(ref, "textures") + ".png");
t.magFilter = THREE.NearestFilter;
t.minFilter = THREE.NearestFilter;
t.colorSpace = THREE.SRGBColorSpace;
textureCache.set(ref, t);
}
return textureCache.get(ref);
}
function parseState(s) {
const [name, raw] = s.split("[");
return {
name,
props: Object.fromEntries(
(raw?.replace("]", "") || "")
.split(",")
.filter(Boolean)
.map((p) => p.split("=")),
),
};
}
function match(condition, props) {
if (condition.OR) return condition.OR.some((c) => match(c, props));
if (condition.AND) return condition.AND.every((c) => match(c, props));
return Object.entries(condition).every(([k, v]) =>
String(v).split("|").includes(props[k]),
);
}
function selectModels(state, props) {
if (state.variants) {
for (const [key, v] of Object.entries(state.variants)) {
const c = Object.fromEntries(
key
.split(",")
.filter(Boolean)
.map((p) => p.split("=")),
);
if (match(c, props)) return [Array.isArray(v) ? v[0] : v];
}
}
return (state.multipart || [])
.filter((p) => !p.when || match(p.when, props))
.map((p) => (Array.isArray(p.apply) ? p.apply[0] : p.apply));
}
const faceData = {
east: {
normal: [1, 0, 0],
corners: [
[1, 0, 1],
[1, 0, 0],
[1, 1, 1],
[1, 1, 0],
],
},
west: {
normal: [-1, 0, 0],
corners: [
[0, 0, 0],
[0, 0, 1],
[0, 1, 0],
[0, 1, 1],
],
},
up: {
normal: [0, 1, 0],
corners: [
[0, 1, 1],
[1, 1, 1],
[0, 1, 0],
[1, 1, 0],
],
},
down: {
normal: [0, -1, 0],
corners: [
[0, 0, 0],
[1, 0, 0],
[0, 0, 1],
[1, 0, 1],
],
},
south: {
normal: [0, 0, 1],
corners: [
[0, 0, 1],
[1, 0, 1],
[0, 1, 1],
[1, 1, 1],
],
},
north: {
normal: [0, 0, -1],
corners: [
[1, 0, 0],
[0, 0, 0],
[1, 1, 0],
[0, 1, 0],
],
},
};
const nonOpaque = (s) =>
/air|water|lava|leaves|glass|grass(?!_block)|fern|flower|sapling|torch|fence|wall|slab|stairs|door|pane|rail|vine|carpet/.test(
s,
);
window.buildTerrain = async (snapshot) => {
const started = performance.now(),
batches = new Map(),
unsupported = new Set();
const occupancy = new Map(
snapshot.blocks.map((b) => [`${b[0]},${b[1]},${b[2]}`, b[3]]),
);
const stateCache = new Map();
async function stateModels(s) {
if (stateCache.has(s)) return stateCache.get(s);
const { name, props } = parseState(s);
let variants = [];
try {
const bs = await json(resourcePath(name, "blockstates") + ".json");
for (const v of selectModels(bs, props))
variants.push({ v, m: await model(v.model) });
} catch {
unsupported.add(name);
}
stateCache.set(s, variants);
return variants;
}
// Preload each distinct model once before constructing batched geometry.
for (const s of new Set(snapshot.blocks.map((b) => b[3])))
await stateModels(s);
async function emit(block, m, v = {}) {
const [bx, by, bz, s] = block;
for (const e of m.elements || []) {
const from = e.from.map((n) => n / 16),
to = e.to.map((n) => n / 16);
const transform = new THREE.Matrix4();
const r = new THREE.Euler(
(-(v.x || 0) * Math.PI) / 180,
(-(v.y || 0) * Math.PI) / 180,
0,
"YXZ",
);
transform.makeRotationFromEuler(r);
for (const [dir, f] of Object.entries(e.faces)) {
const fd = faceData[dir];
if (!fd) continue;
const n = new THREE.Vector3(...fd.normal).applyMatrix4(transform);
const neighbor = occupancy.get(
`${bx + Math.round(n.x)},${by + Math.round(n.y)},${bz + Math.round(n.z)}`,
);
if (f.cullface && neighbor && !nonOpaque(neighbor)) continue;
const ref = textureRef(m, f.texture),
tex = await texture(ref).catch(() => null);
if (!tex) {
unsupported.add(s);
continue;
}
const tint =
f.tintindex !== undefined
? s.includes("water")
? "#5b9eb5"
: s.includes("leaves")
? "#7ba54c"
: "#90b650"
: "#ffffff";
const key = ref + "|" + tint;
let batch = batches.get(key);
if (!batch) {
batch = {
positions: [],
uvs: [],
normals: [],
indices: [],
tex,
tint,
};
batches.set(key, batch);
}
const uv = f.uv || [0, 0, 16, 16],
coords = [
[uv[0] / 16, 1 - uv[3] / 16],
[uv[2] / 16, 1 - uv[3] / 16],
[uv[0] / 16, 1 - uv[1] / 16],
[uv[2] / 16, 1 - uv[1] / 16],
];
const base = batch.positions.length / 3;
for (let i = 0; i < 4; i++) {
let p = new THREE.Vector3(
...fd.corners[i].map((c, j) => from[j] + c * (to[j] - from[j])),
);
if (e.rotation) {
const er = e.rotation,
origin = new THREE.Vector3(...er.origin.map((a) => a / 16)),
axis = new THREE.Vector3(
er.axis === "x" ? 1 : 0,
er.axis === "y" ? 1 : 0,
er.axis === "z" ? 1 : 0,
);
p.sub(origin).applyAxisAngle(axis, (er.angle * Math.PI) / 180);
if (er.rescale) {
const scale = 1 / Math.cos((er.angle * Math.PI) / 180);
for (const a of ["x", "y", "z"]) if (a !== er.axis) p[a] *= scale;
}
p.add(origin);
}
p.subScalar(0.5)
.applyMatrix4(transform)
.addScalar(0.5)
.add(new THREE.Vector3(bx, by, bz));
batch.positions.push(p.x, p.y, p.z);
batch.normals.push(n.x, n.y, n.z);
let index = i;
if (f.rotation) {
const order = [0, 1, 3, 2],
rank = order.indexOf(i);
index = order[(rank + f.rotation / 90) % 4];
}
batch.uvs.push(...coords[index]);
}
batch.indices.push(
base,
base + 1,
base + 2,
base + 2,
base + 1,
base + 3,
);
}
}
}
for (const b of snapshot.blocks) {
if (/minecraft:(water|lava)(\[|$)/.test(b[3])) {
await emit(b, {
textures: {
all:
"minecraft:block/" +
(b[3].includes("water") ? "water_still" : "lava_still"),
},
elements: [
{
from: [0, 0, 0],
to: [16, 14, 16],
faces: Object.fromEntries(
Object.keys(faceData).map((k) => [
k,
{
texture: "#all",
tintindex: b[3].includes("water") ? 0 : undefined,
cullface: k,
},
]),
),
},
],
});
continue;
}
for (const { m, v } of await stateModels(b[3])) await emit(b, m, v);
}
for (const batch of batches.values()) {
const g = new THREE.BufferGeometry();
g.setAttribute(
"position",
new THREE.Float32BufferAttribute(batch.positions, 3),
);
g.setAttribute(
"normal",
new THREE.Float32BufferAttribute(batch.normals, 3),
);
g.setAttribute("uv", new THREE.Float32BufferAttribute(batch.uvs, 2));
g.setIndex(batch.indices);
const mat = new THREE.MeshLambertMaterial({
map: batch.tex,
color: batch.tint,
side: THREE.DoubleSide,
alphaTest: 0.3,
transparent: batch.tex.image.src.includes("water"),
opacity: batch.tex.image.src.includes("water") ? 0.8 : 1,
});
scene.add(new THREE.Mesh(g, mat));
}
window.terrainBounds = snapshot.bounds;
return {
blocks: snapshot.blocks.length,
drawCalls: batches.size,
unsupported: [...unsupported],
buildMs: performance.now() - started,
};
};
const directions = {
px: [
[1, 0, 0],
[0, 1, 0],
],
nx: [
[-1, 0, 0],
[0, 1, 0],
],
py: [
[0, 1, 0],
[0, 0, -1],
],
ny: [
[0, -1, 0],
[0, 0, 1],
],
pz: [
[0, 0, 1],
[0, 1, 0],
],
nz: [
[0, 0, -1],
[0, 1, 0],
],
};
window.renderFace = (p, face, size) => {
renderer.setSize(size, size);
const camera = new THREE.PerspectiveCamera(90, 1, 0.05, 512);
camera.position.set(p.x, p.y, p.z);
const [dir, up] = directions[face];
camera.up.set(...up);
camera.lookAt(p.x + dir[0], p.y + dir[1], p.z + dir[2]);
renderer.render(scene, camera);
return renderer.domElement.toDataURL("image/png");
};
window.renderMap = (size) => {
const b = window.terrainBounds;
renderer.setSize(size, size);
const camera = new THREE.OrthographicCamera(
-(b.maxX - b.minX) / 2,
(b.maxX - b.minX) / 2,
(b.maxZ - b.minZ) / 2,
-(b.maxZ - b.minZ) / 2,
0.1,
1024,
);
camera.position.set((b.minX + b.maxX) / 2, 512, (b.minZ + b.maxZ) / 2);
camera.up.set(0, 0, -1);
camera.lookAt(camera.position.x, 0, camera.position.z);
renderer.render(scene, camera);
return renderer.domElement.toDataURL("image/png");
};
window.rendererReady = true;