import * as THREE from 'three'; import { OrbitControls } from './vendor/OrbitControls.js'; import { GLTFExporter } from './vendor/GLTFExporter.js'; import { GRID, WORLD, synthesize } from './world.js'; import { SPECIES, spawn, step as stepLife, bodyParts, waterAccess, probe } from './life.js'; import { Sky, climateOf } from './sky.js'; const canvas = document.getElementById('view'); // preserveDrawingBuffer keeps the last frame readable, so the view can be captured. const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, preserveDrawingBuffer: true }); renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); renderer.outputColorSpace = THREE.SRGBColorSpace; const scene = new THREE.Scene(); scene.background = new THREE.Color('#9fc4dc'); scene.fog = new THREE.Fog('#9fc4dc', WORLD * 0.7, WORLD * 2.0); const camera = new THREE.PerspectiveCamera(55, 1, 0.5, WORLD * 4); camera.position.set(WORLD * 0.45, WORLD * 0.34, WORLD * 0.45); const controls = new OrbitControls(camera, canvas); controls.enableDamping = true; controls.maxPolarAngle = Math.PI * 0.495; controls.target.set(0, 6, 0); const sky = new Sky(scene); let dayTime = 0.42; // 0..1; 0.5 is noon let dayRunning = false; const world = new THREE.Group(); scene.add(world); // Inhabitants live in their own group so a rebuild does not disturb the terrain. const fauna = new THREE.Group(); scene.add(fauna); let agents = [], waterPts = [], faunaMeshes = []; // ------------------------------------------------------------------ prop kit -- // Deliberately low-poly: these are placement stand-ins for generated assets, and a // thousand of them have to stay interactive in a browser tab. const PROPS = { tree: { trunk: [0.28, 0.35, 3.2], trunkColor: '#584028', crown: 'sphere', crownSize: 2.1, crownColor: '#3f6b34', scale: [0.7, 1.5] }, pine: { trunk: [0.22, 0.3, 3.6], trunkColor: '#4a3826', crown: 'cone', crownSize: 2.3, crownColor: '#2f5230', scale: [0.7, 1.6] }, palm: { trunk: [0.2, 0.26, 4.6], trunkColor: '#6b563a', crown: 'cone', crownSize: 2.0, crownColor: '#4a7c3a', scale: [0.8, 1.3] }, acacia: { trunk: [0.3, 0.4, 2.8], trunkColor: '#5c4a30', crown: 'disc', crownSize: 3.0, crownColor: '#6b7a3a', scale: [0.8, 1.4] }, cactus: { trunk: [0.42, 0.42, 2.6], trunkColor: '#4a7042', crown: 'none', crownSize: 0, crownColor: '#4a7042', scale: [0.6, 1.3] }, shrub: { trunk: null, trunkColor: '#000', crown: 'sphere', crownSize: 1.0, crownColor: '#55703a', scale: [0.6, 1.4] }, rock: { trunk: null, trunkColor: '#000', crown: 'rock', crownSize: 1.2, crownColor: '#7d7a72', scale: [0.5, 2.0] }, }; function crownGeometry(kind, size) { switch (kind) { case 'cone': return new THREE.ConeGeometry(size * 0.62, size * 1.9, 7); case 'disc': return new THREE.SphereGeometry(size * 0.62, 8, 5).scale(1, 0.42, 1); case 'rock': return new THREE.IcosahedronGeometry(size * 0.6, 0); case 'sphere': default: return new THREE.SphereGeometry(size * 0.55, 8, 6); } } // --------------------------------------------------------------- world build -- let current = null; function heightAt(field, gx, gy) { const x = Math.min(GRID - 1, Math.max(0, gx)); const y = Math.min(GRID - 1, Math.max(0, gy)); return field[y * GRID + x]; } function build(prompt, seed) { const t0 = performance.now(); const data = synthesize(prompt, seed); current = data; while (world.children.length) { const c = world.children.pop(); c.traverse?.(o => { o.geometry?.dispose(); o.material?.dispose?.(); }); } const { regions, masks, height, seaLevel, owner } = data; const step = WORLD / (GRID - 1); // --- terrain mesh, vertex-coloured from the same masks that shaped the height const geo = new THREE.PlaneGeometry(WORLD, WORLD, GRID - 1, GRID - 1); geo.rotateX(-Math.PI / 2); const pos = geo.attributes.position; const colors = new Float32Array(pos.count * 3); const col = new THREE.Color(); const regColors = regions.map(r => new THREE.Color(r.color)); const rockColor = new THREE.Color('#6f6b64'); const snowColor = new THREE.Color('#e8eef2'); let maxH = -Infinity; for (let i = 0; i < height.length; i++) maxH = Math.max(maxH, height[i]); for (let i = 0; i < pos.count; i++) { const gx = i % GRID, gy = Math.floor(i / GRID); const h = height[gy * GRID + gx]; pos.setY(i, h); // Color has no addScaledVector (that is Vector3), so blend the components. let cr = 0, cg = 0, cb = 0; for (let r = 0; r < regions.length; r++) { const m = masks[r][gy * GRID + gx]; if (m > 0.002) { const rc = regColors[r]; cr += rc.r * m; cg += rc.g * m; cb += rc.b * m; } } col.setRGB(cr, cg, cb); // slope from neighbouring samples -> exposed rock on steep faces const dx = heightAt(height, gx + 1, gy) - heightAt(height, gx - 1, gy); const dy = heightAt(height, gx, gy + 1) - heightAt(height, gx, gy - 1); const slope = Math.min(1, Math.sqrt(dx * dx + dy * dy) / (step * 3.4)); col.lerp(rockColor, slope * 0.75); // snow line, only on worlds tall enough to have one if (maxH > 26) { const t = Math.min(1, Math.max(0, (h - maxH * 0.72) / (maxH * 0.28))); col.lerp(snowColor, t * (1 - slope * 0.5) * 0.9); } colors[i * 3] = col.r; colors[i * 3 + 1] = col.g; colors[i * 3 + 2] = col.b; } geo.setAttribute('color', new THREE.BufferAttribute(colors, 3)); geo.computeVertexNormals(); const terrain = new THREE.Mesh( geo, new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.95, metalness: 0.0, flatShading: false }) ); terrain.name = 'terrain'; world.add(terrain); // --- water: built from the hydrology, so rivers show up as rivers rather // than a single flat sheet at sea level. const wdepth = data.water.depth, wsurf = data.water.surface; const wv = [], wc = []; const shallow = new THREE.Color('#5fa8bd'), deepC = new THREE.Color('#1d4f70'); const push = (gx, gy) => { const i = gy * GRID + gx; wv.push(-WORLD / 2 + gx * step, wsurf[i], -WORLD / 2 + gy * step); const t = Math.min(1, wdepth[i] / 6); const c = shallow.clone().lerp(deepC, t); wc.push(c.r, c.g, c.b); }; for (let y = 0; y < GRID - 1; y++) { for (let x = 0; x < GRID - 1; x++) { const quad = [[x, y], [x + 1, y], [x + 1, y + 1], [x, y + 1]]; if (!quad.every(([qx, qy]) => wdepth[qy * GRID + qx] > 0.02)) continue; push(x, y); push(x + 1, y); push(x + 1, y + 1); push(x, y); push(x + 1, y + 1); push(x, y + 1); } } if (wv.length) { const wg = new THREE.BufferGeometry(); wg.setAttribute('position', new THREE.Float32BufferAttribute(wv, 3)); wg.setAttribute('color', new THREE.Float32BufferAttribute(wc, 3)); wg.computeVertexNormals(); const water = new THREE.Mesh(wg, new THREE.MeshStandardMaterial({ vertexColors: true, transparent: true, opacity: 0.82, roughness: 0.14, metalness: 0.3, side: THREE.DoubleSide, })); water.name = 'water'; world.add(water); } // --- scatter, gated on the same rules the paper uses: region semantics, // elevation and slope, with orientation following the surface. const buckets = {}; const rng = (() => { let s = (seed * 2654435761) >>> 0 || 7; return () => { s ^= s << 13; s >>>= 0; s ^= s >> 17; s ^= s << 5; s >>>= 0; return s / 4294967296; }; })(); const attempts = 18000; const capacity = 3200; let placed = 0; for (let a = 0; a < attempts && placed < capacity; a++) { const gx = Math.floor(rng() * GRID), gy = Math.floor(rng() * GRID); const reg = regions[owner[gy * GRID + gx]]; if (!reg.props.length) continue; const cell = gy * GRID + gx; const h = height[cell]; if (h < seaLevel + 0.35) continue; if (data.water.depth[cell] > 0.02) continue; // nothing grows mid-river const dx = heightAt(height, gx + 1, gy) - heightAt(height, gx - 1, gy); const dy = heightAt(height, gx, gy + 1) - heightAt(height, gx, gy - 1); const slope = Math.sqrt(dx * dx + dy * dy) / (step * 2); const kind = reg.props[Math.floor(rng() * reg.props.length)]; if (kind !== 'rock' && slope > 0.85) continue; // only rock clings to cliffs // Moisture gates the planting: greenery crowds the riverbanks and thins // out away from water, while cactus wants the opposite. This is the whole // point of deriving water before vegetation. const wet = data.moisture[cell]; const thirst = { tree: 0.30, pine: 0.22, palm: 0.45, acacia: 0.12, shrub: 0.15, cactus: -1, rock: -1 }[kind] ?? 0.2; if (thirst >= 0) { if (wet < thirst * 0.5) continue; if (rng() > 0.35 + wet * 0.75) continue; } else { if (kind === 'cactus' && wet > 0.45) continue; // cacti avoid the banks if (rng() > 0.55) continue; } (buckets[kind] ||= []).push({ x: -WORLD / 2 + gx * step, z: -WORLD / 2 + gy * step, y: h, s: PROPS[kind].scale[0] + rng() * (PROPS[kind].scale[1] - PROPS[kind].scale[0]), rot: rng() * Math.PI * 2, tilt: kind === 'palm' ? (rng() - 0.5) * 0.35 : 0, }); placed++; } const dummy = new THREE.Object3D(); for (const [kind, list] of Object.entries(buckets)) { const spec = PROPS[kind]; const parts = []; if (spec.trunk) { const [rt, rb, hh] = spec.trunk; parts.push({ geo: new THREE.CylinderGeometry(rt, rb, hh, 6).translate(0, hh / 2, 0), color: spec.trunkColor, lift: 0, }); } if (spec.crown !== 'none') { const lift = spec.trunk ? spec.trunk[2] * 0.92 : spec.crownSize * 0.28; parts.push({ geo: crownGeometry(spec.crown, spec.crownSize), color: spec.crownColor, lift }); } for (const part of parts) { const mesh = new THREE.InstancedMesh( part.geo, new THREE.MeshStandardMaterial({ color: part.color, roughness: 0.9, flatShading: true }), list.length ); mesh.name = `${kind}-${part.color}`; list.forEach((p, i) => { dummy.position.set(p.x, p.y + part.lift * p.s - 0.15, p.z); dummy.rotation.set(p.tilt, p.rot, 0); dummy.scale.setScalar(p.s); dummy.updateMatrix(); mesh.setMatrixAt(i, dummy.matrix); }); mesh.instanceMatrix.needsUpdate = true; world.add(mesh); } } // --- inhabitants populateWorld(data, rng); // --- climate is read off the finished world, not chosen const climate = climateOf(data); data.climate = climate; sky.setWeather(climate.precipitation); sky.setTime(dayTime); controls.target.set(0, Math.max(4, maxH * 0.25), 0); drawLayout(data); renderLegend(regions); const ms = Math.round(performance.now() - t0); document.getElementById('stats').textContent = `${regions.length} regions · ${(GRID * GRID / 1000).toFixed(0)}k vertices · ` + `${placed} plants · ${agents.length} animals · ${ms} ms`; renderCensus(); } function populateWorld(data, rng) { while (fauna.children.length) { const c = fauna.children.pop(); c.geometry?.dispose(); c.material?.dispose?.(); } faunaMeshes = []; waterPts = waterAccess(data); agents = spawn(data, rng, 300); for (const sp of SPECIES) { const mine = agents.filter(a => a.sp.id === sp.id); if (!mine.length) continue; for (const [n, part] of bodyParts(THREE, sp).entries()) { const mesh = new THREE.InstancedMesh( part.geo, new THREE.MeshStandardMaterial({ color: part.color, roughness: 0.85, flatShading: true }), mine.length ); mesh.name = n === 0 ? `fauna-${sp.id}` : `fauna-${sp.id}-${n}`; mesh.frustumCulled = false; fauna.add(mesh); faunaMeshes.push({ mesh, list: mine, sp }); } } syncFauna(); } const faunaDummy = new THREE.Object3D(); function syncFauna() { for (const { mesh, list, sp } of faunaMeshes) { list.forEach((a, i) => { faunaDummy.position.set(a.x, a.y, a.z); faunaDummy.rotation.set(0, a.rot || 0, 0); const bob = sp.flying ? 1 : 1 + Math.sin(a.phase) * 0.05; faunaDummy.scale.set(a.scale, a.scale * bob, a.scale); faunaDummy.updateMatrix(); mesh.setMatrixAt(i, faunaDummy.matrix); }); mesh.instanceMatrix.needsUpdate = true; } } function renderCensus() { const counts = new Map(); for (const a of agents) counts.set(a.sp.id, (counts.get(a.sp.id) || 0) + 1); const rows = SPECIES.filter(s => counts.get(s.id)) .map(s => `