VIDraft/WorldForge
0
1import * as THREE from 'three';2import { OrbitControls } from './vendor/OrbitControls.js';3import { GLTFExporter } from './vendor/GLTFExporter.js';4import { GRID, WORLD, synthesize } from './world.js';5import { SPECIES, spawn, step as stepLife, bodyParts, waterAccess, probe } from './life.js';6import { Sky, climateOf } from './sky.js';7 8const canvas = document.getElementById('view');9// preserveDrawingBuffer keeps the last frame readable, so the view can be captured.10const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, preserveDrawingBuffer: true });11renderer.setPixelRatio(Math.min(devicePixelRatio, 2));12renderer.outputColorSpace = THREE.SRGBColorSpace;13 14const scene = new THREE.Scene();15scene.background = new THREE.Color('#9fc4dc');16scene.fog = new THREE.Fog('#9fc4dc', WORLD * 0.7, WORLD * 2.0);17 18const camera = new THREE.PerspectiveCamera(55, 1, 0.5, WORLD * 4);19camera.position.set(WORLD * 0.45, WORLD * 0.34, WORLD * 0.45);20 21const controls = new OrbitControls(camera, canvas);22controls.enableDamping = true;23controls.maxPolarAngle = Math.PI * 0.495;24controls.target.set(0, 6, 0);25 26const sky = new Sky(scene);27let dayTime = 0.42; // 0..1; 0.5 is noon28let dayRunning = false;29 30const world = new THREE.Group();31scene.add(world);32 33// Inhabitants live in their own group so a rebuild does not disturb the terrain.34const fauna = new THREE.Group();35scene.add(fauna);36let agents = [], waterPts = [], faunaMeshes = [];37 38// ------------------------------------------------------------------ prop kit --39// Deliberately low-poly: these are placement stand-ins for generated assets, and a40// thousand of them have to stay interactive in a browser tab.41const PROPS = {42 tree: { trunk: [0.28, 0.35, 3.2], trunkColor: '#584028', crown: 'sphere', crownSize: 2.1, crownColor: '#3f6b34', scale: [0.7, 1.5] },43 pine: { trunk: [0.22, 0.3, 3.6], trunkColor: '#4a3826', crown: 'cone', crownSize: 2.3, crownColor: '#2f5230', scale: [0.7, 1.6] },44 palm: { trunk: [0.2, 0.26, 4.6], trunkColor: '#6b563a', crown: 'cone', crownSize: 2.0, crownColor: '#4a7c3a', scale: [0.8, 1.3] },45 acacia: { trunk: [0.3, 0.4, 2.8], trunkColor: '#5c4a30', crown: 'disc', crownSize: 3.0, crownColor: '#6b7a3a', scale: [0.8, 1.4] },46 cactus: { trunk: [0.42, 0.42, 2.6], trunkColor: '#4a7042', crown: 'none', crownSize: 0, crownColor: '#4a7042', scale: [0.6, 1.3] },47 shrub: { trunk: null, trunkColor: '#000', crown: 'sphere', crownSize: 1.0, crownColor: '#55703a', scale: [0.6, 1.4] },48 rock: { trunk: null, trunkColor: '#000', crown: 'rock', crownSize: 1.2, crownColor: '#7d7a72', scale: [0.5, 2.0] },49};50 51function crownGeometry(kind, size) {52 switch (kind) {53 case 'cone': return new THREE.ConeGeometry(size * 0.62, size * 1.9, 7);54 case 'disc': return new THREE.SphereGeometry(size * 0.62, 8, 5).scale(1, 0.42, 1);55 case 'rock': return new THREE.IcosahedronGeometry(size * 0.6, 0);56 case 'sphere':57 default: return new THREE.SphereGeometry(size * 0.55, 8, 6);58 }59}60 61// --------------------------------------------------------------- world build --62let current = null;63 64function heightAt(field, gx, gy) {65 const x = Math.min(GRID - 1, Math.max(0, gx));66 const y = Math.min(GRID - 1, Math.max(0, gy));67 return field[y * GRID + x];68}69 70function build(prompt, seed) {71 const t0 = performance.now();72 const data = synthesize(prompt, seed);73 current = data;74 75 while (world.children.length) {76 const c = world.children.pop();77 c.traverse?.(o => { o.geometry?.dispose(); o.material?.dispose?.(); });78 }79 80 const { regions, masks, height, seaLevel, owner } = data;81 const step = WORLD / (GRID - 1);82 83 // --- terrain mesh, vertex-coloured from the same masks that shaped the height84 const geo = new THREE.PlaneGeometry(WORLD, WORLD, GRID - 1, GRID - 1);85 geo.rotateX(-Math.PI / 2);86 const pos = geo.attributes.position;87 const colors = new Float32Array(pos.count * 3);88 const col = new THREE.Color();89 const regColors = regions.map(r => new THREE.Color(r.color));90 const rockColor = new THREE.Color('#6f6b64');91 const snowColor = new THREE.Color('#e8eef2');92 93 let maxH = -Infinity;94 for (let i = 0; i < height.length; i++) maxH = Math.max(maxH, height[i]);95 96 for (let i = 0; i < pos.count; i++) {97 const gx = i % GRID, gy = Math.floor(i / GRID);98 const h = height[gy * GRID + gx];99 pos.setY(i, h);100 101 // Color has no addScaledVector (that is Vector3), so blend the components.102 let cr = 0, cg = 0, cb = 0;103 for (let r = 0; r < regions.length; r++) {104 const m = masks[r][gy * GRID + gx];105 if (m > 0.002) {106 const rc = regColors[r];107 cr += rc.r * m; cg += rc.g * m; cb += rc.b * m;108 }109 }110 col.setRGB(cr, cg, cb);111 112 // slope from neighbouring samples -> exposed rock on steep faces113 const dx = heightAt(height, gx + 1, gy) - heightAt(height, gx - 1, gy);114 const dy = heightAt(height, gx, gy + 1) - heightAt(height, gx, gy - 1);115 const slope = Math.min(1, Math.sqrt(dx * dx + dy * dy) / (step * 3.4));116 col.lerp(rockColor, slope * 0.75);117 118 // snow line, only on worlds tall enough to have one119 if (maxH > 26) {120 const t = Math.min(1, Math.max(0, (h - maxH * 0.72) / (maxH * 0.28)));121 col.lerp(snowColor, t * (1 - slope * 0.5) * 0.9);122 }123 124 colors[i * 3] = col.r; colors[i * 3 + 1] = col.g; colors[i * 3 + 2] = col.b;125 }126 geo.setAttribute('color', new THREE.BufferAttribute(colors, 3));127 geo.computeVertexNormals();128 129 const terrain = new THREE.Mesh(130 geo,131 new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.95, metalness: 0.0, flatShading: false })132 );133 terrain.name = 'terrain';134 world.add(terrain);135 136 // --- water: built from the hydrology, so rivers show up as rivers rather137 // than a single flat sheet at sea level.138 const wdepth = data.water.depth, wsurf = data.water.surface;139 const wv = [], wc = [];140 const shallow = new THREE.Color('#5fa8bd'), deepC = new THREE.Color('#1d4f70');141 const push = (gx, gy) => {142 const i = gy * GRID + gx;143 wv.push(-WORLD / 2 + gx * step, wsurf[i], -WORLD / 2 + gy * step);144 const t = Math.min(1, wdepth[i] / 6);145 const c = shallow.clone().lerp(deepC, t);146 wc.push(c.r, c.g, c.b);147 };148 for (let y = 0; y < GRID - 1; y++) {149 for (let x = 0; x < GRID - 1; x++) {150 const quad = [[x, y], [x + 1, y], [x + 1, y + 1], [x, y + 1]];151 if (!quad.every(([qx, qy]) => wdepth[qy * GRID + qx] > 0.02)) continue;152 push(x, y); push(x + 1, y); push(x + 1, y + 1);153 push(x, y); push(x + 1, y + 1); push(x, y + 1);154 }155 }156 if (wv.length) {157 const wg = new THREE.BufferGeometry();158 wg.setAttribute('position', new THREE.Float32BufferAttribute(wv, 3));159 wg.setAttribute('color', new THREE.Float32BufferAttribute(wc, 3));160 wg.computeVertexNormals();161 const water = new THREE.Mesh(wg, new THREE.MeshStandardMaterial({162 vertexColors: true, transparent: true, opacity: 0.82,163 roughness: 0.14, metalness: 0.3, side: THREE.DoubleSide,164 }));165 water.name = 'water';166 world.add(water);167 }168 169 // --- scatter, gated on the same rules the paper uses: region semantics,170 // elevation and slope, with orientation following the surface.171 const buckets = {};172 const rng = (() => { let s = (seed * 2654435761) >>> 0 || 7;173 return () => { s ^= s << 13; s >>>= 0; s ^= s >> 17; s ^= s << 5; s >>>= 0; return s / 4294967296; }; })();174 175 const attempts = 18000;176 const capacity = 3200;177 let placed = 0;178 for (let a = 0; a < attempts && placed < capacity; a++) {179 const gx = Math.floor(rng() * GRID), gy = Math.floor(rng() * GRID);180 const reg = regions[owner[gy * GRID + gx]];181 if (!reg.props.length) continue;182 183 const cell = gy * GRID + gx;184 const h = height[cell];185 if (h < seaLevel + 0.35) continue;186 if (data.water.depth[cell] > 0.02) continue; // nothing grows mid-river187 188 const dx = heightAt(height, gx + 1, gy) - heightAt(height, gx - 1, gy);189 const dy = heightAt(height, gx, gy + 1) - heightAt(height, gx, gy - 1);190 const slope = Math.sqrt(dx * dx + dy * dy) / (step * 2);191 const kind = reg.props[Math.floor(rng() * reg.props.length)];192 if (kind !== 'rock' && slope > 0.85) continue; // only rock clings to cliffs193 194 // Moisture gates the planting: greenery crowds the riverbanks and thins195 // out away from water, while cactus wants the opposite. This is the whole196 // point of deriving water before vegetation.197 const wet = data.moisture[cell];198 const thirst = { tree: 0.30, pine: 0.22, palm: 0.45, acacia: 0.12,199 shrub: 0.15, cactus: -1, rock: -1 }[kind] ?? 0.2;200 if (thirst >= 0) {201 if (wet < thirst * 0.5) continue;202 if (rng() > 0.35 + wet * 0.75) continue;203 } else {204 if (kind === 'cactus' && wet > 0.45) continue; // cacti avoid the banks205 if (rng() > 0.55) continue;206 }207 208 (buckets[kind] ||= []).push({209 x: -WORLD / 2 + gx * step,210 z: -WORLD / 2 + gy * step,211 y: h,212 s: PROPS[kind].scale[0] + rng() * (PROPS[kind].scale[1] - PROPS[kind].scale[0]),213 rot: rng() * Math.PI * 2,214 tilt: kind === 'palm' ? (rng() - 0.5) * 0.35 : 0,215 });216 placed++;217 }218 219 const dummy = new THREE.Object3D();220 for (const [kind, list] of Object.entries(buckets)) {221 const spec = PROPS[kind];222 const parts = [];223 if (spec.trunk) {224 const [rt, rb, hh] = spec.trunk;225 parts.push({226 geo: new THREE.CylinderGeometry(rt, rb, hh, 6).translate(0, hh / 2, 0),227 color: spec.trunkColor,228 lift: 0,229 });230 }231 if (spec.crown !== 'none') {232 const lift = spec.trunk ? spec.trunk[2] * 0.92 : spec.crownSize * 0.28;233 parts.push({ geo: crownGeometry(spec.crown, spec.crownSize), color: spec.crownColor, lift });234 }235 236 for (const part of parts) {237 const mesh = new THREE.InstancedMesh(238 part.geo,239 new THREE.MeshStandardMaterial({ color: part.color, roughness: 0.9, flatShading: true }),240 list.length241 );242 mesh.name = `${kind}-${part.color}`;243 list.forEach((p, i) => {244 dummy.position.set(p.x, p.y + part.lift * p.s - 0.15, p.z);245 dummy.rotation.set(p.tilt, p.rot, 0);246 dummy.scale.setScalar(p.s);247 dummy.updateMatrix();248 mesh.setMatrixAt(i, dummy.matrix);249 });250 mesh.instanceMatrix.needsUpdate = true;251 world.add(mesh);252 }253 }254 255 // --- inhabitants256 populateWorld(data, rng);257 258 // --- climate is read off the finished world, not chosen259 const climate = climateOf(data);260 data.climate = climate;261 sky.setWeather(climate.precipitation);262 sky.setTime(dayTime);263 264 controls.target.set(0, Math.max(4, maxH * 0.25), 0);265 drawLayout(data);266 renderLegend(regions);267 268 const ms = Math.round(performance.now() - t0);269 document.getElementById('stats').textContent =270 `${regions.length} regions · ${(GRID * GRID / 1000).toFixed(0)}k vertices · ` +271 `${placed} plants · ${agents.length} animals · ${ms} ms`;272 renderCensus();273}274 275function populateWorld(data, rng) {276 while (fauna.children.length) {277 const c = fauna.children.pop();278 c.geometry?.dispose(); c.material?.dispose?.();279 }280 faunaMeshes = [];281 waterPts = waterAccess(data);282 agents = spawn(data, rng, 300);283 284 for (const sp of SPECIES) {285 const mine = agents.filter(a => a.sp.id === sp.id);286 if (!mine.length) continue;287 for (const [n, part] of bodyParts(THREE, sp).entries()) {288 const mesh = new THREE.InstancedMesh(289 part.geo,290 new THREE.MeshStandardMaterial({ color: part.color, roughness: 0.85, flatShading: true }),291 mine.length292 );293 mesh.name = n === 0 ? `fauna-${sp.id}` : `fauna-${sp.id}-${n}`;294 mesh.frustumCulled = false;295 fauna.add(mesh);296 faunaMeshes.push({ mesh, list: mine, sp });297 }298 }299 syncFauna();300}301 302const faunaDummy = new THREE.Object3D();303function syncFauna() {304 for (const { mesh, list, sp } of faunaMeshes) {305 list.forEach((a, i) => {306 faunaDummy.position.set(a.x, a.y, a.z);307 faunaDummy.rotation.set(0, a.rot || 0, 0);308 const bob = sp.flying ? 1 : 1 + Math.sin(a.phase) * 0.05;309 faunaDummy.scale.set(a.scale, a.scale * bob, a.scale);310 faunaDummy.updateMatrix();311 mesh.setMatrixAt(i, faunaDummy.matrix);312 });313 mesh.instanceMatrix.needsUpdate = true;314 }315}316 317function renderCensus() {318 const counts = new Map();319 for (const a of agents) counts.set(a.sp.id, (counts.get(a.sp.id) || 0) + 1);320 const rows = SPECIES.filter(s => counts.get(s.id))321 .map(s => `<li><i style="background:${s.color}"></i>${s.label}<b>${counts.get(s.id)}</b></li>`);322 document.getElementById('census').innerHTML = rows.join('') ||323 '<li style="color:var(--dim)">nothing lives here</li>';324}325 326// --------------------------------------------------------- semantic layout map --327function drawLayout(data) {328 const cv = document.getElementById('layout');329 const ctx = cv.getContext('2d');330 const img = ctx.createImageData(GRID, GRID);331 const cols = data.regions.map(r => {332 const c = new THREE.Color(r.color);333 return [c.r * 255, c.g * 255, c.b * 255];334 });335 for (let i = 0; i < GRID * GRID; i++) {336 const [r, g, b] = cols[data.owner[i]];337 img.data[i * 4] = r; img.data[i * 4 + 1] = g; img.data[i * 4 + 2] = b; img.data[i * 4 + 3] = 255;338 }339 cv.width = GRID; cv.height = GRID;340 ctx.putImageData(img, 0, 0);341}342 343function renderLegend(regions) {344 document.getElementById('legend').innerHTML = regions.map(r =>345 `<li><i style="background:${r.color}"></i>${r.label}<b>${Math.round(r.coverage * 100)}%</b></li>`346 ).join('');347}348 349// ------------------------------------------------------------------- fly mode --350let fly = false;351const keys = new Set();352const flyState = { yaw: 0, pitch: 0 };353addEventListener('keydown', e => {354 if (e.code === 'Escape') setFly(false);355 keys.add(e.code);356});357addEventListener('keyup', e => keys.delete(e.code));358 359function setFly(on) {360 fly = on;361 controls.enabled = !on;362 document.getElementById('fly').classList.toggle('on', on);363 document.getElementById('hint').style.display = on ? 'block' : 'none';364 if (on) {365 const e = new THREE.Euler().setFromQuaternion(camera.quaternion, 'YXZ');366 flyState.yaw = e.y; flyState.pitch = e.x;367 canvas.requestPointerLock();368 } else if (document.pointerLockElement) {369 document.exitPointerLock();370 }371}372document.addEventListener('pointerlockchange', () => {373 if (!document.pointerLockElement && fly) setFly(false);374});375addEventListener('mousemove', e => {376 if (!fly || !document.pointerLockElement) return;377 flyState.yaw -= e.movementX * 0.0022;378 flyState.pitch = Math.max(-1.5, Math.min(1.5, flyState.pitch - e.movementY * 0.0022));379});380 381function stepFly(dt) {382 const speed = (keys.has('ShiftLeft') ? 180 : 60) * dt;383 camera.quaternion.setFromEuler(new THREE.Euler(flyState.pitch, flyState.yaw, 0, 'YXZ'));384 const fwd = new THREE.Vector3(0, 0, -1).applyQuaternion(camera.quaternion);385 const right = new THREE.Vector3(1, 0, 0).applyQuaternion(camera.quaternion);386 if (keys.has('KeyW')) camera.position.addScaledVector(fwd, speed);387 if (keys.has('KeyS')) camera.position.addScaledVector(fwd, -speed);388 if (keys.has('KeyD')) camera.position.addScaledVector(right, speed);389 if (keys.has('KeyA')) camera.position.addScaledVector(right, -speed);390 if (keys.has('KeyE') || keys.has('Space')) camera.position.y += speed;391 if (keys.has('KeyQ')) camera.position.y -= speed;392 393 // keep the camera above the ground so flying never ends up inside a hill394 if (current) {395 const step = WORLD / (GRID - 1);396 const gx = Math.round((camera.position.x + WORLD / 2) / step);397 const gy = Math.round((camera.position.z + WORLD / 2) / step);398 if (gx >= 0 && gx < GRID && gy >= 0 && gy < GRID) {399 const floor = current.height[gy * GRID + gx] + 1.8;400 if (camera.position.y < floor) camera.position.y = floor;401 }402 }403}404 405// ----------------------------------------------------------------------- ui ----406const promptEl = document.getElementById('prompt');407const seedEl = document.getElementById('seed');408 409function generate() {410 const seed = parseInt(seedEl.value, 10) || 1;411 document.body.classList.add('busy');412 // setTimeout, not rAF: rAF never fires in a hidden or non-compositing tab, which413 // would leave the world unbuilt until the page happens to become visible.414 setTimeout(() => {415 try {416 build(promptEl.value, seed);417 } catch (err) {418 console.error(err);419 document.getElementById('stats').textContent = `generation failed: ${err.message}`;420 }421 document.body.classList.remove('busy');422 }, 0);423}424 425document.getElementById('go').onclick = generate;426promptEl.addEventListener('keydown', e => { if (e.key === 'Enter') generate(); });427document.getElementById('reseed').onclick = () => {428 seedEl.value = Math.floor(Math.random() * 999999);429 generate();430};431document.getElementById('fly').onclick = () => setFly(!fly);432 433document.querySelectorAll('#presets button').forEach(b => {434 b.onclick = () => { promptEl.value = b.dataset.p; generate(); };435});436 437document.getElementById('glb').onclick = () => {438 const btn = document.getElementById('glb');439 btn.disabled = true; btn.textContent = 'Exporting…';440 new GLTFExporter().parse(world, result => {441 const blob = new Blob([result], { type: 'model/gltf-binary' });442 const a = document.createElement('a');443 a.href = URL.createObjectURL(blob);444 a.download = 'worldforge.glb';445 a.click();446 URL.revokeObjectURL(a.href);447 btn.disabled = false; btn.textContent = 'Export GLB';448 }, err => {449 console.error(err);450 btn.disabled = false; btn.textContent = 'Export GLB';451 }, { binary: true });452};453 454// --- clock -------------------------------------------------------------------455const clockEl = document.getElementById('clock');456function showClock() {457 const mins = Math.round(dayTime * 24 * 60);458 const hh = String(Math.floor(mins / 60) % 24).padStart(2, '0');459 const mm = String(mins % 60).padStart(2, '0');460 document.getElementById('clockLabel').textContent = `${hh}:${mm}`;461}462clockEl.oninput = () => { dayTime = clockEl.value / 1000; sky.setTime(dayTime); showClock(); };463document.getElementById('play').onclick = (e) => {464 dayRunning = !dayRunning;465 e.target.classList.toggle('on', dayRunning);466 e.target.textContent = dayRunning ? 'Pause' : 'Run day';467};468showClock();469 470// --- inspect probe: click the ground, read what the model says is there --------471const ray = new THREE.Raycaster();472canvas.addEventListener('click', (e) => {473 if (fly || !current) return;474 const r = canvas.getBoundingClientRect();475 ray.setFromCamera(new THREE.Vector2(476 ((e.clientX - r.left) / r.width) * 2 - 1,477 -((e.clientY - r.top) / r.height) * 2 + 1), camera);478 const hit = ray.intersectObject(world.getObjectByName('terrain'), false)[0];479 const box = document.getElementById('probe');480 if (!hit) { box.innerHTML = '<span class="dimmed">click the ground to inspect</span>'; return; }481 482 const p = probe(current, hit.point.x, hit.point.z);483 const near = agents.filter(a => (a.x - hit.point.x) ** 2 + (a.z - hit.point.z) ** 2 < 400);484 const kinds = [...new Set(near.map(a => a.sp.label))].slice(0, 3);485 box.innerHTML =486 `<b>${p.region.label}</b>` +487 `<span>elevation<b>${p.height.toFixed(1)} m</b></span>` +488 `<span>slope<b>${(p.slope * 100).toFixed(0)}%</b></span>` +489 `<span>moisture<b>${(p.moisture * 100).toFixed(0)}%</b></span>` +490 `<span>water<b>${p.waterDepth > 0.02 ? p.waterDepth.toFixed(2) + ' m' : '—'}</b></span>` +491 `<span>within 20 m<b>${near.length ? `${near.length} · ${kinds.join(', ')}` : 'nothing'}</b></span>`;492});493 494// --- world spec: the model as data, not as a picture ---------------------------495document.getElementById('json').onclick = () => {496 if (!current) return;497 let lake = 0, river = 0, wettest = 0;498 for (let i = 0; i < current.water.depth.length; i++) {499 const d = current.water.depth[i];500 if (d > 0.02) (d > 0.5 ? lake++ : river++);501 wettest = Math.max(wettest, current.moisture[i]);502 }503 const cell = (WORLD / (GRID - 1)) ** 2;504 const census = {};505 for (const a of agents) census[a.sp.id] = (census[a.sp.id] || 0) + 1;506 507 const spec = {508 prompt: promptEl.value,509 seed: parseInt(seedEl.value, 10) || 1,510 extent_m: WORLD,511 grid: GRID,512 regions: current.regions.map(r => ({513 key: r.key, label: r.label, coverage: +r.coverage.toFixed(3),514 base_elevation_m: r.base, operator: r.op,515 })),516 hydrology: {517 sea_level_m: current.seaLevel > -900 ? current.seaLevel : null,518 lake_area_m2: Math.round(lake * cell),519 river_area_m2: Math.round(river * cell),520 max_moisture: +wettest.toFixed(3),521 },522 climate: current.climate,523 ecology: Object.entries(census).map(([id, n]) => ({524 species: id, count: n,525 habitat: SPECIES.find(s => s.id === id)?.habitat,526 })),527 time_of_day: +dayTime.toFixed(3),528 };529 const blob = new Blob([JSON.stringify(spec, null, 2)], { type: 'application/json' });530 const a = document.createElement('a');531 a.href = URL.createObjectURL(blob);532 a.download = 'world-spec.json';533 a.click();534 URL.revokeObjectURL(a.href);535};536 537document.getElementById('png').onclick = () => {538 const a = document.createElement('a');539 a.href = document.getElementById('layout').toDataURL('image/png');540 a.download = 'layout-map.png';541 a.click();542};543 544// ---------------------------------------------------------------------- loop ---545function resize() {546 const w = canvas.clientWidth, h = canvas.clientHeight;547 if (canvas.width !== w || canvas.height !== h) {548 renderer.setSize(w, h, false);549 camera.aspect = w / h;550 camera.updateProjectionMatrix();551 }552}553 554// Advancing the world is separate from drawing it, so the simulation can be555// driven a step at a time without a visible frame — a hidden tab gets no rAF, and556// a world that only moves while someone is watching cannot be tested.557function tick(dt) {558 if (current && agents.length) {559 stepLife(current, agents, dt, waterPts);560 syncFauna();561 }562 if (dayRunning) {563 dayTime = (dayTime + dt / 120) % 1; // a full day in two minutes564 sky.setTime(dayTime);565 document.getElementById('clock').value = Math.round(dayTime * 1000);566 showClock();567 }568 sky.stepWeather(dt, camera);569}570 571let last = performance.now();572function loop(now) {573 const dt = Math.min(0.05, (now - last) / 1000);574 last = now;575 resize();576 if (fly) stepFly(dt); else controls.update();577 tick(dt);578 renderer.render(scene, camera);579 requestAnimationFrame(loop);580}581 582// Handle for debugging and for driving the app from a console or a test harness583// (a headless tab gets no rAF, so the render has to be callable directly).584window.worldforge = {585 renderer, scene, camera, controls,586 build, generate, tick,587 frame: () => { resize(); renderer.render(scene, camera); },588 get agents() { return agents; },589 get world() { return current; },590};591 592seedEl.value = Math.floor(Math.random() * 999999);593generate();594requestAnimationFrame(loop);595 