WorldForge / app.js
openfree's picture
Scale ecology and traversal for larger worlds
12c2844 verified
Raw
History Blame Contribute Delete
24.5 kB
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 => `<li><i style="background:${s.color}"></i>${s.label}<b>${counts.get(s.id)}</b></li>`);
document.getElementById('census').innerHTML = rows.join('') ||
'<li style="color:var(--dim)">nothing lives here</li>';
}
// --------------------------------------------------------- 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 =>
`<li><i style="background:${r.color}"></i>${r.label}<b>${Math.round(r.coverage * 100)}%</b></li>`
).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 = '<span class="dimmed">click the ground to inspect</span>'; 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 =
`<b>${p.region.label}</b>` +
`<span>elevation<b>${p.height.toFixed(1)} m</b></span>` +
`<span>slope<b>${(p.slope * 100).toFixed(0)}%</b></span>` +
`<span>moisture<b>${(p.moisture * 100).toFixed(0)}%</b></span>` +
`<span>water<b>${p.waterDepth > 0.02 ? p.waterDepth.toFixed(2) + ' m' : '—'}</b></span>` +
`<span>within 20 m<b>${near.length ? `${near.length} · ${kinds.join(', ')}` : 'nothing'}</b></span>`;
});
// --- 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);