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); }