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 => `
  • ${s.label}${counts.get(s.id)}
  • `); document.getElementById('census').innerHTML = rows.join('') || '
  • nothing lives here
  • '; } // --------------------------------------------------------- semantic layout map -- function drawLayout(data) { const cv = document.getElementById('layout'); const ctx = cv.getContext('2d'); const img = ctx.createImageData(GRID, GRID); const cols = data.regions.map(r => { const c = new THREE.Color(r.color); return [c.r * 255, c.g * 255, c.b * 255]; }); for (let i = 0; i < GRID * GRID; i++) { const [r, g, b] = cols[data.owner[i]]; img.data[i * 4] = r; img.data[i * 4 + 1] = g; img.data[i * 4 + 2] = b; img.data[i * 4 + 3] = 255; } cv.width = GRID; cv.height = GRID; ctx.putImageData(img, 0, 0); } function renderLegend(regions) { document.getElementById('legend').innerHTML = regions.map(r => `
  • ${r.label}${Math.round(r.coverage * 100)}%
  • ` ).join(''); } // ------------------------------------------------------------------- fly mode -- let fly = false; const keys = new Set(); const flyState = { yaw: 0, pitch: 0 }; addEventListener('keydown', e => { if (e.code === 'Escape') setFly(false); keys.add(e.code); }); addEventListener('keyup', e => keys.delete(e.code)); function setFly(on) { fly = on; controls.enabled = !on; document.getElementById('fly').classList.toggle('on', on); document.getElementById('hint').style.display = on ? 'block' : 'none'; if (on) { const e = new THREE.Euler().setFromQuaternion(camera.quaternion, 'YXZ'); flyState.yaw = e.y; flyState.pitch = e.x; canvas.requestPointerLock(); } else if (document.pointerLockElement) { document.exitPointerLock(); } } document.addEventListener('pointerlockchange', () => { if (!document.pointerLockElement && fly) setFly(false); }); addEventListener('mousemove', e => { if (!fly || !document.pointerLockElement) return; flyState.yaw -= e.movementX * 0.0022; flyState.pitch = Math.max(-1.5, Math.min(1.5, flyState.pitch - e.movementY * 0.0022)); }); function stepFly(dt) { const speed = (keys.has('ShiftLeft') ? 180 : 60) * dt; camera.quaternion.setFromEuler(new THREE.Euler(flyState.pitch, flyState.yaw, 0, 'YXZ')); const fwd = new THREE.Vector3(0, 0, -1).applyQuaternion(camera.quaternion); const right = new THREE.Vector3(1, 0, 0).applyQuaternion(camera.quaternion); if (keys.has('KeyW')) camera.position.addScaledVector(fwd, speed); if (keys.has('KeyS')) camera.position.addScaledVector(fwd, -speed); if (keys.has('KeyD')) camera.position.addScaledVector(right, speed); if (keys.has('KeyA')) camera.position.addScaledVector(right, -speed); if (keys.has('KeyE') || keys.has('Space')) camera.position.y += speed; if (keys.has('KeyQ')) camera.position.y -= speed; // keep the camera above the ground so flying never ends up inside a hill if (current) { const step = WORLD / (GRID - 1); const gx = Math.round((camera.position.x + WORLD / 2) / step); const gy = Math.round((camera.position.z + WORLD / 2) / step); if (gx >= 0 && gx < GRID && gy >= 0 && gy < GRID) { const floor = current.height[gy * GRID + gx] + 1.8; if (camera.position.y < floor) camera.position.y = floor; } } } // ----------------------------------------------------------------------- ui ---- const promptEl = document.getElementById('prompt'); const seedEl = document.getElementById('seed'); function generate() { const seed = parseInt(seedEl.value, 10) || 1; document.body.classList.add('busy'); // setTimeout, not rAF: rAF never fires in a hidden or non-compositing tab, which // would leave the world unbuilt until the page happens to become visible. setTimeout(() => { try { build(promptEl.value, seed); } catch (err) { console.error(err); document.getElementById('stats').textContent = `generation failed: ${err.message}`; } document.body.classList.remove('busy'); }, 0); } document.getElementById('go').onclick = generate; promptEl.addEventListener('keydown', e => { if (e.key === 'Enter') generate(); }); document.getElementById('reseed').onclick = () => { seedEl.value = Math.floor(Math.random() * 999999); generate(); }; document.getElementById('fly').onclick = () => setFly(!fly); document.querySelectorAll('#presets button').forEach(b => { b.onclick = () => { promptEl.value = b.dataset.p; generate(); }; }); document.getElementById('glb').onclick = () => { const btn = document.getElementById('glb'); btn.disabled = true; btn.textContent = 'Exporting…'; new GLTFExporter().parse(world, result => { const blob = new Blob([result], { type: 'model/gltf-binary' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'worldforge.glb'; a.click(); URL.revokeObjectURL(a.href); btn.disabled = false; btn.textContent = 'Export GLB'; }, err => { console.error(err); btn.disabled = false; btn.textContent = 'Export GLB'; }, { binary: true }); }; // --- clock ------------------------------------------------------------------- const clockEl = document.getElementById('clock'); function showClock() { const mins = Math.round(dayTime * 24 * 60); const hh = String(Math.floor(mins / 60) % 24).padStart(2, '0'); const mm = String(mins % 60).padStart(2, '0'); document.getElementById('clockLabel').textContent = `${hh}:${mm}`; } clockEl.oninput = () => { dayTime = clockEl.value / 1000; sky.setTime(dayTime); showClock(); }; document.getElementById('play').onclick = (e) => { dayRunning = !dayRunning; e.target.classList.toggle('on', dayRunning); e.target.textContent = dayRunning ? 'Pause' : 'Run day'; }; showClock(); // --- inspect probe: click the ground, read what the model says is there -------- const ray = new THREE.Raycaster(); canvas.addEventListener('click', (e) => { if (fly || !current) return; const r = canvas.getBoundingClientRect(); ray.setFromCamera(new THREE.Vector2( ((e.clientX - r.left) / r.width) * 2 - 1, -((e.clientY - r.top) / r.height) * 2 + 1), camera); const hit = ray.intersectObject(world.getObjectByName('terrain'), false)[0]; const box = document.getElementById('probe'); if (!hit) { box.innerHTML = 'click the ground to inspect'; return; } const p = probe(current, hit.point.x, hit.point.z); const near = agents.filter(a => (a.x - hit.point.x) ** 2 + (a.z - hit.point.z) ** 2 < 400); const kinds = [...new Set(near.map(a => a.sp.label))].slice(0, 3); box.innerHTML = `${p.region.label}` + `elevation${p.height.toFixed(1)} m` + `slope${(p.slope * 100).toFixed(0)}%` + `moisture${(p.moisture * 100).toFixed(0)}%` + `water${p.waterDepth > 0.02 ? p.waterDepth.toFixed(2) + ' m' : '—'}` + `within 20 m${near.length ? `${near.length} · ${kinds.join(', ')}` : 'nothing'}`; }); // --- world spec: the model as data, not as a picture --------------------------- document.getElementById('json').onclick = () => { if (!current) return; let lake = 0, river = 0, wettest = 0; for (let i = 0; i < current.water.depth.length; i++) { const d = current.water.depth[i]; if (d > 0.02) (d > 0.5 ? lake++ : river++); wettest = Math.max(wettest, current.moisture[i]); } const cell = (WORLD / (GRID - 1)) ** 2; const census = {}; for (const a of agents) census[a.sp.id] = (census[a.sp.id] || 0) + 1; const spec = { prompt: promptEl.value, seed: parseInt(seedEl.value, 10) || 1, extent_m: WORLD, grid: GRID, regions: current.regions.map(r => ({ key: r.key, label: r.label, coverage: +r.coverage.toFixed(3), base_elevation_m: r.base, operator: r.op, })), hydrology: { sea_level_m: current.seaLevel > -900 ? current.seaLevel : null, lake_area_m2: Math.round(lake * cell), river_area_m2: Math.round(river * cell), max_moisture: +wettest.toFixed(3), }, climate: current.climate, ecology: Object.entries(census).map(([id, n]) => ({ species: id, count: n, habitat: SPECIES.find(s => s.id === id)?.habitat, })), time_of_day: +dayTime.toFixed(3), }; const blob = new Blob([JSON.stringify(spec, null, 2)], { type: 'application/json' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'world-spec.json'; a.click(); URL.revokeObjectURL(a.href); }; document.getElementById('png').onclick = () => { const a = document.createElement('a'); a.href = document.getElementById('layout').toDataURL('image/png'); a.download = 'layout-map.png'; a.click(); }; // ---------------------------------------------------------------------- loop --- function resize() { const w = canvas.clientWidth, h = canvas.clientHeight; if (canvas.width !== w || canvas.height !== h) { renderer.setSize(w, h, false); camera.aspect = w / h; camera.updateProjectionMatrix(); } } // Advancing the world is separate from drawing it, so the simulation can be // driven a step at a time without a visible frame — a hidden tab gets no rAF, and // a world that only moves while someone is watching cannot be tested. function tick(dt) { if (current && agents.length) { stepLife(current, agents, dt, waterPts); syncFauna(); } if (dayRunning) { dayTime = (dayTime + dt / 120) % 1; // a full day in two minutes sky.setTime(dayTime); document.getElementById('clock').value = Math.round(dayTime * 1000); showClock(); } sky.stepWeather(dt, camera); } let last = performance.now(); function loop(now) { const dt = Math.min(0.05, (now - last) / 1000); last = now; resize(); if (fly) stepFly(dt); else controls.update(); tick(dt); renderer.render(scene, camera); requestAnimationFrame(loop); } // Handle for debugging and for driving the app from a console or a test harness // (a headless tab gets no rAF, so the render has to be callable directly). window.worldforge = { renderer, scene, camera, controls, build, generate, tick, frame: () => { resize(); renderer.render(scene, camera); }, get agents() { return agents; }, get world() { return current; }, }; seedEl.value = Math.floor(Math.random() * 999999); generate(); requestAnimationFrame(loop);