Files
MineGeo/renderer/scene.js

367 lines
10 KiB
JavaScript

import * as THREE from "/three.js";
import { buildTerrain } from "./terrain-mesh.js";
import { firstTextureFrame } from "./block-colors.mjs";
import { mapDimensions } from "./map-view.mjs";
// 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();
const gl = renderer.getContext();
const gpuInfo = gl.getExtension("WEBGL_debug_renderer_info");
window.rendererInfo = {
vendor: gpuInfo
? gl.getParameter(gpuInfo.UNMASKED_VENDOR_WEBGL)
: "unavailable",
renderer: gpuInfo
? gl.getParameter(gpuInfo.UNMASKED_RENDERER_WEBGL)
: "unavailable",
};
scene.background = new THREE.Color("#95c4eb");
const ambient = new THREE.HemisphereLight("#e9f3ff", "#73704e", 2.0);
scene.add(ambient);
const sun = new THREE.DirectionalLight("#fff4d6", 2.2);
sun.position.set(-40, 90, -30);
scene.add(sun);
scene.add(sun.target);
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, { signal: AbortSignal.timeout(60000) }).then((r) => {
if (!r.ok) throw new Error(`Minecraft asset ${path}: HTTP ${r.status}`);
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)) {
// Cache the in-flight promise and failures as well as successful textures.
// Missing vanilla assets must not trigger another request for every face.
textureCache.set(
ref,
loader
.loadAsync(resourcePath(ref, "textures") + ".png")
.then(async (t) => {
// Vanilla animated textures are sprite sheets, not stretched faces.
const metadata =
t.image.width !== t.image.height
? await fetch(resourcePath(ref, "textures") + ".png.mcmeta").then(
(r) => (r.ok ? r.json() : null),
)
: null;
if (metadata?.animation) {
const frame = firstTextureFrame(
t.image.width,
t.image.height,
metadata.animation,
);
const canvas = document.createElement("canvas");
canvas.width = frame.width;
canvas.height = frame.height;
canvas
.getContext("2d")
.drawImage(
t.image,
frame.x,
frame.y,
frame.width,
frame.height,
0,
0,
frame.width,
frame.height,
);
t.image = canvas;
t.needsUpdate = true;
}
t.magFilter = THREE.NearestFilter;
t.minFilter = THREE.NearestFilter;
t.colorSpace = THREE.SRGBColorSpace;
return t;
})
.catch(() => null),
);
}
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("=")),
);
// Some exports omit default properties (for example snowy=false on a
// grass block). Match every property we do have, so the block remains
// visible instead of silently disappearing from the terrain and map.
if (
Object.entries(c).every(
([name, value]) =>
props[name] === undefined ||
String(value).split("|").includes(props[name]),
)
)
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],
],
},
};
window.buildTerrain = async (snapshot) => {
const unsupported = new Set(),
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 (error) {
unsupported.add(name);
if (unsupported.size <= 8)
console.warn(`Missing block model ${name}: ${error.message}`);
}
stateCache.set(s, variants);
return variants;
}
return buildTerrain(
snapshot,
scene,
faceData,
stateModels,
textureRef,
texture,
unsupported,
);
};
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],
],
};
function renderScene(camera, requireGeometry = false) {
if (gl.isContextLost())
throw new Error("Graphics context lost while rendering terrain");
renderer.render(scene, camera);
const error = gl.getError();
if (error !== gl.NO_ERROR)
throw new Error(`WebGL failed to render terrain (error ${error})`);
if (requireGeometry && renderer.info.render.triangles === 0)
throw new Error(
"Surface-map camera sees no terrain geometry. Check world coordinates and Minecraft assets.",
);
}
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]);
renderScene(camera);
return renderer.domElement.toDataURL("image/webp", 0.85);
};
window.renderMap = () => {
const b = window.terrainBounds;
const { width, height } = mapDimensions(b);
renderer.setSize(width, height);
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, 320, (b.minZ + b.maxZ) / 2);
camera.up.set(0, 0, -1);
camera.lookAt(camera.position.x, 0, camera.position.z);
const background = scene.background;
const previousSunPosition = sun.position.clone();
const previousSunTarget = sun.target.position.clone();
const previousAmbient = ambient.intensity;
const previousSunIntensity = sun.intensity;
const centerX = camera.position.x;
const centerZ = camera.position.z;
const radius = Math.hypot(b.maxX - b.minX, b.maxZ - b.minZ) / 2 + 256;
// The panorama renderer keeps its broad, shadowless lighting. For the map,
// an angled sun makes ridges, cliffs and buildings cast readable shadows.
ambient.intensity = 1.25;
sun.intensity = 2.2;
sun.position.set(centerX - 220, 300, centerZ - 180);
sun.target.position.set(centerX, 64, centerZ);
sun.castShadow = true;
sun.shadow.mapSize.set(4096, 4096);
sun.shadow.camera.left = -radius;
sun.shadow.camera.right = radius;
sun.shadow.camera.top = radius;
sun.shadow.camera.bottom = -radius;
sun.shadow.camera.near = 1;
sun.shadow.camera.far = radius * 4;
sun.shadow.camera.updateProjectionMatrix();
sun.shadow.bias = -0.0004;
sun.shadow.normalBias = 0.03;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFShadowMap;
for (const mesh of window.terrainMeshes) {
mesh.castShadow = true;
mesh.receiveShadow = true;
}
scene.background = new THREE.Color("#ddc79f");
try {
renderScene(camera, true);
return {
data: renderer.domElement.toDataURL("image/webp", 0.92),
width,
height,
};
} finally {
renderer.shadowMap.enabled = false;
sun.castShadow = false;
for (const mesh of window.terrainMeshes) {
mesh.castShadow = false;
mesh.receiveShadow = false;
}
sun.position.copy(previousSunPosition);
sun.target.position.copy(previousSunTarget);
ambient.intensity = previousAmbient;
sun.intensity = previousSunIntensity;
scene.background = background;
}
};
window.rendererReady = true;