Files
MineGeo/cinematics/scene.mjs
pavel444-byte a7085e4e80
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 2m37s
Improve MineGeo UI and application flows
2026-10-18 11:44:04 +05:00

333 lines
10 KiB
JavaScript

import * as THREE from "three";
import { composeFrame } from "./composition.mjs";
import { easeInOut, enter } from "./motion.mjs";
const params = new URLSearchParams(location.search);
const banner = params.get("mode") === "banner";
const width = Number(params.get("width")) || (banner ? 1200 : 1280);
const height = Number(params.get("height")) || (banner ? 480 : 720);
const renderer = new THREE.WebGLRenderer({
antialias: true,
preserveDrawingBuffer: true,
});
renderer.setSize(width, height);
renderer.setPixelRatio(1);
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.35;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
const scene = new THREE.Scene();
scene.background = new THREE.Color("#0c211c");
scene.fog = new THREE.FogExp2("#0c211c", 0.008);
const camera = new THREE.PerspectiveCamera(38, width / height, 0.1, 350);
scene.add(new THREE.HemisphereLight(0xc5e4d4, 0x263522, 2.1));
const sun = new THREE.DirectionalLight(0xffd8a2, 4.2);
sun.position.set(-24, 45, 22);
sun.castShadow = true;
sun.shadow.mapSize.set(2048, 2048);
sun.shadow.camera.left = -42;
sun.shadow.camera.right = 42;
sun.shadow.camera.top = 42;
sun.shadow.camera.bottom = -42;
sun.shadow.normalBias = 0.08;
scene.add(sun);
const rim = new THREE.DirectionalLight(0x74d6d3, 2.5);
rim.position.set(20, 8, -30);
scene.add(rim);
const world = new THREE.Group();
scene.add(world);
const palettes = {
rock: [0x4b655d, 0x546e62, 0x3c514c, 0x668177],
dirt: [0x66563c, 0x786548, 0x8a7150],
grass: [0x77a653, 0x90b962, 0x648f45, 0xa0bd6d],
sand: [0xceb98a, 0xe1cd9e, 0xb9ad80],
snow: [0xcddbd1, 0xe4eee1],
leaves: [0x3c7449, 0x4d925b, 0x79a456, 0x9fac61],
wood: [0x69513a, 0x84654a],
water: [0x3b9190, 0x56aeaa],
};
const instances = Object.fromEntries(
Object.keys(palettes).map((key) => [key, []]),
);
const hash = (x, z, salt = 0) => {
const n = Math.sin(x * 127.1 + z * 311.7 + salt * 74.7) * 43758.5453;
return n - Math.floor(n);
};
function block(kind, x, y, z, sx = 1, sy = 1, sz = 1, variation = 0) {
instances[kind].push({
x,
y,
z,
sx,
sy,
sz,
color:
palettes[kind][Math.floor(hash(x, z, variation) * palettes[kind].length)],
});
}
function groundHeight(x, z) {
const hill = Math.exp(-((x - 6) ** 2 + (z + 5) ** 2) / 49) * 10;
const hill2 = Math.exp(-((x + 8) ** 2 + (z - 5) ** 2) / 48) * 3;
return Math.floor(
2 + hill + hill2 + Math.sin(x * 0.32) * 0.8 + Math.cos(z * 0.38) * 0.7,
);
}
for (let x = -23; x <= 23; x++) {
for (let z = -19; z <= 19; z++) {
const edge = (x / 23) ** 2 + (z / 19) ** 2;
if (edge > 0.92 + Math.sin(x * 0.7 + z * 0.3) * 0.07) continue;
const river = Math.abs(z - (Math.sin(x * 0.16) * 3 + 3)) < 1.7;
const h = river ? 1 : groundHeight(x, z);
const depth = Math.max(1, Math.floor((1 - edge) * 8 + hash(x, z) * 2));
block("rock", x, -depth / 2, z, 0.99, depth, 0.99);
block("dirt", x, h / 2, z, 1, h, 1);
const kind = river
? "water"
: h > 9
? "snow"
: edge > 0.73
? "sand"
: "grass";
block(kind, x, h + 0.08, z, 1, 0.32, 1);
if (
!river &&
edge < 0.65 &&
h < 7 &&
hash(x, z, 7) > 0.958 &&
(x % 3 === 0 || z % 4 === 0)
) {
const tall = 3 + Math.floor(hash(x, z, 9) * 2);
block("wood", x, h + tall / 2, z, 0.55, tall, 0.55);
block("leaves", x, h + tall, z, 2.8, 1.8, 2.8);
block("leaves", x, h + tall + 1.1, z, 1.65, 1.2, 1.65, 2);
}
if (edge > 0.7 && edge < 0.91 && hash(x, z, 12) > 0.94)
block("leaves", x, -depth + 0.2, z, 0.24, 2 + hash(x, z, 10) * 3, 0.24);
}
}
// A warm-lit expedition hut and an old stone arch make the terrain recognizable.
const hutX = -8,
hutZ = -5,
hutY = groundHeight(hutX, hutZ) + 0.2;
block("wood", hutX, hutY + 1.15, hutZ, 3.2, 2.4, 3);
for (let i = 0; i < 3; i++)
block("rock", hutX, hutY + 2.5 + i * 0.4, hutZ, 4 - i, 0.5, 3.8);
for (const x of [9, 13]) for (let y = 0; y < 5; y++) block("rock", x, 4 + y, 8);
for (let x = 9; x <= 13; x++) block("rock", x, 9, 8);
const box = new THREE.BoxGeometry(1, 1, 1);
const dummy = new THREE.Object3D();
for (const [kind, cells] of Object.entries(instances)) {
const material = new THREE.MeshStandardMaterial({
color: 0xffffff,
roughness: kind === "water" ? 0.24 : 0.92,
metalness: kind === "water" ? 0.25 : 0.02,
});
const mesh = new THREE.InstancedMesh(box, material, cells.length);
cells.forEach((b, i) => {
dummy.position.set(b.x, b.y, b.z);
dummy.scale.set(b.sx, b.sy, b.sz);
dummy.updateMatrix();
mesh.setMatrixAt(i, dummy.matrix);
mesh.setColorAt(i, new THREE.Color(b.color));
});
mesh.instanceMatrix.needsUpdate = true;
mesh.instanceColor.needsUpdate = true;
mesh.castShadow = kind !== "water";
mesh.receiveShadow = true;
world.add(mesh);
}
const windowLight = new THREE.Mesh(
new THREE.BoxGeometry(0.12, 0.7, 0.7),
new THREE.MeshBasicMaterial({ color: 0xffce77 }),
);
windowLight.position.set(hutX - 1.64, hutY + 1.25, hutZ);
world.add(windowLight);
// Thin cartographic rings float beneath the cutaway, like a world waiting to be found.
const ringGroup = new THREE.Group();
ringGroup.position.y = -8;
for (const radius of [25, 29, 33]) {
const points = Array.from(
{ length: 161 },
(_, i) =>
new THREE.Vector3(
Math.cos((i / 160) * Math.PI * 2) * radius,
0,
Math.sin((i / 160) * Math.PI * 2) * radius,
),
);
const line = new THREE.LineLoop(
new THREE.BufferGeometry().setFromPoints(points),
new THREE.LineBasicMaterial({
color: 0x759b7b,
transparent: true,
opacity: 0.25,
}),
);
ringGroup.add(line);
}
world.add(ringGroup);
const sweepPoints = Array.from(
{ length: 49 },
(_, i) =>
new THREE.Vector3(
Math.cos((i / 48) * 0.7) * 29,
0.04,
Math.sin((i / 48) * 0.7) * 29,
),
);
ringGroup.add(
new THREE.Line(
new THREE.BufferGeometry().setFromPoints(sweepPoints),
new THREE.LineBasicMaterial({
color: 0xd6bb76,
transparent: true,
opacity: 0.8,
}),
),
);
function pin(color, x, z) {
const group = new THREE.Group();
const mat = new THREE.MeshStandardMaterial({
color,
emissive: color,
emissiveIntensity: 0.25,
metalness: 0.35,
roughness: 0.3,
});
const head = new THREE.Mesh(new THREE.OctahedronGeometry(1.4), mat);
head.position.y = 3;
const stem = new THREE.Mesh(new THREE.ConeGeometry(0.35, 2.2, 4), mat);
stem.rotation.z = Math.PI;
stem.position.y = 1.25;
group.add(head, stem);
const halo = new THREE.Mesh(
new THREE.TorusGeometry(2, 0.055, 6, 64),
new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.75 }),
);
halo.rotation.x = -Math.PI / 2;
halo.position.y = 0.15;
group.add(halo);
group.position.set(x, groundHeight(x, z) + 0.5, z);
world.add(group);
return { group, halo, base: group.position.y };
}
const pins = [
pin(0xffcf70, 5, -5),
pin(0xa3e271, -12, 9),
pin(0xef7967, 12, 5),
];
const moteGeo = new THREE.BufferGeometry();
const motePositions = [];
for (let i = 0; i < 150; i++)
motePositions.push(
(hash(i, 4) - 0.5) * 130,
hash(i, 5) * 58 - 12,
(hash(i, 6) - 0.5) * 100,
);
moteGeo.setAttribute(
"position",
new THREE.Float32BufferAttribute(motePositions, 3),
);
const motes = new THREE.Points(
moteGeo,
new THREE.PointsMaterial({
color: 0xdce8b9,
size: 0.13,
transparent: true,
opacity: 0.6,
}),
);
scene.add(motes);
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
document.body.prepend(canvas);
const ctx = canvas.getContext("2d");
await document.fonts.load("24px Pixel");
await document.fonts.ready;
const screenshots = banner
? {}
: Object.fromEntries(
await Promise.all(
["home", "catalog", "map", "game"].map(async (name) => {
const image = new Image();
image.src = `/shots/${name}.jpg`;
await image.decode();
return [name, image];
}),
),
);
// Terrain and lighting are static: cache their shadow map, not a moving world.
renderer.shadowMap.autoUpdate = false;
renderer.shadowMap.needsUpdate = true;
const projectedPin = new THREE.Vector3();
function renderAt(time) {
const t = banner ? ((time % 3) + 3) % 3 : Math.max(0, Math.min(30, time));
const loop = (t / 3) * Math.PI * 2;
const travel = banner
? (1 - Math.cos(loop)) / 2
: easeInOut(Math.min(t, 3) / 3);
const azimuth = banner ? 0.66 + 0.045 * Math.sin(loop) : 0.56 + 0.13 * travel;
const distance = banner ? 79 + Math.sin(loop) * 1.6 : 100 - 13 * travel;
camera.position.set(
Math.sin(azimuth) * distance,
banner ? 38 : 43,
Math.cos(azimuth) * distance,
);
camera.lookAt(0, 0, 0);
camera.setViewOffset(
width,
height,
-width * (banner ? 0.23 : 0.17),
0,
width,
height,
);
world.position.y = 0;
pins.forEach((p, i) => {
p.group.position.y =
p.base +
(banner
? Math.sin(loop) * 0.1
: (1 - enter(t, 23 + i * 0.08, 0.25)) * 1.5);
p.group.rotation.y = banner ? Math.sin(loop) * 0.12 : 0.15;
p.halo.scale.setScalar(1 + (banner ? (1 - Math.cos(loop)) * 0.035 : 0));
p.group.visible = i === 0 && (banner || t < 3 || t >= 23);
});
motes.rotation.y = banner ? Math.sin(loop) * 0.006 : 0;
ringGroup.rotation.y = banner ? loop : t * 0.12;
renderer.render(scene, camera);
projectedPin.copy(pins[0].group.position);
projectedPin.y += 3;
projectedPin.project(camera);
composeFrame(ctx, renderer.domElement, {
width,
height,
banner,
time: t,
screenshots,
pin: {
x: (projectedPin.x * 0.5 + 0.5) * width,
y: (-projectedPin.y * 0.5 + 0.5) * height,
},
});
}
window.renderCinematic = renderAt;
window.cinematicCanvas = canvas;
document.querySelector("#status").remove();
renderAt(Number(params.get("time")) || 0);
window.cinematicReady = true;
if (!params.has("still")) {
const start = performance.now();
function loopFrame(now) {
renderAt(((now - start) / 1000) % (banner ? 3 : 30));
requestAnimationFrame(loopFrame);
}
requestAnimationFrame(loopFrame);
}