WorldForge / world.js
openfree's picture
Expand WorldForge to one-kilometre worlds
c6b7649 verified
Raw
History Blame Contribute Delete
15.9 kB
// World synthesis: prompt -> region plan -> semantic layout -> composite height field.
//
// This is an independent implementation of the terrain foundation described in
// "WorldClaw: Agentic 3D Open-world Generation at Scale" (arXiv 2608.05248).
// No Tencent code or model weights are used — that repository publishes neither.
// The structure it describes and that we follow here:
// H(x) = sum_r m_r(x) * [ h_r + sum_k w_rk N_rk(x) + sum_j a_rj G_rj(x) ]
// i.e. per-region base elevation, per-region noise bands, and per-region geomorphic
// operators, combined through smoothed region masks so borders blend rather than step.
import { erode, fillDepressions, drainage, hydrology, moisture } from './hydro.js';
export const GRID = 256; // height field resolution (65k terrain samples)
export const WORLD = 1000; // one-kilometre world extent in metres
const VERTICAL_SCALE = 2.2; // keep kilometre-scale relief visually meaningful
// ---------------------------------------------------------------- rng + noise --
export function makeRng(seed) {
let s = seed >>> 0 || 1;
return function rng() {
s ^= s << 13; s >>>= 0;
s ^= s >> 17;
s ^= s << 5; s >>>= 0;
return s / 4294967296;
};
}
function makeValueNoise(rng) {
const size = 256;
const perm = new Uint8Array(size * 2);
const base = new Uint8Array(size);
for (let i = 0; i < size; i++) base[i] = i;
for (let i = size - 1; i > 0; i--) {
const j = Math.floor(rng() * (i + 1));
[base[i], base[j]] = [base[j], base[i]];
}
for (let i = 0; i < size * 2; i++) perm[i] = base[i & 255];
const grad = new Float32Array(size * 2);
for (let i = 0; i < size * 2; i++) grad[i] = rng() * 2 - 1;
const fade = t => t * t * t * (t * (t * 6 - 15) + 10);
const lerp = (a, b, t) => a + (b - a) * t;
return function noise2(x, y) {
const xi = Math.floor(x) & 255, yi = Math.floor(y) & 255;
const xf = x - Math.floor(x), yf = y - Math.floor(y);
const u = fade(xf), v = fade(yf);
const aa = grad[perm[perm[xi] + yi]];
const ab = grad[perm[perm[xi] + yi + 1]];
const ba = grad[perm[perm[xi + 1] + yi]];
const bb = grad[perm[perm[xi + 1] + yi + 1]];
return lerp(lerp(aa, ba, u), lerp(ab, bb, u), v);
};
}
function fbm(noise, x, y, octaves, lacunarity = 2.0, gain = 0.5) {
let sum = 0, amp = 1, freq = 1, norm = 0;
for (let o = 0; o < octaves; o++) {
sum += amp * noise(x * freq, y * freq);
norm += amp;
amp *= gain;
freq *= lacunarity;
}
return sum / norm;
}
// ------------------------------------------------------------------- biomes ----
// Each biome is one row of the height formula: a base elevation, a noise band, and
// a geomorphic operator. Colours are the semantic layout map's encoding as well as
// the terrain tint, so the map and the mesh cannot drift apart.
export const BIOMES = {
ocean: { label: 'Ocean', color: '#1d4e6b', base: -13, amp: 1.2, freq: 1.4, op: 'flat', rough: 0.1, props: [] },
lake: { label: 'Lake', color: '#2a6b8a', base: -6, amp: 0.8, freq: 1.6, op: 'flat', rough: 0.1, props: [] },
beach: { label: 'Beach', color: '#d9c89a', base: 0.6, amp: 1.0, freq: 2.2, op: 'flat', rough: 0.2, props: ['palm', 'rock'] },
plains: { label: 'Plains', color: '#7d9455', base: 4, amp: 3.0, freq: 1.8, op: 'none', rough: 0.4, props: ['tree', 'shrub', 'rock'] },
meadow: { label: 'Meadow', color: '#94a95c', base: 5, amp: 4.0, freq: 2.4, op: 'none', rough: 0.4, props: ['shrub', 'tree'] },
savanna: { label: 'Savanna', color: '#a89a5a', base: 5, amp: 3.5, freq: 1.6, op: 'none', rough: 0.4, props: ['acacia', 'rock'] },
forest: { label: 'Forest', color: '#3f6b3a', base: 7, amp: 6.0, freq: 2.2, op: 'none', rough: 0.6, props: ['pine', 'tree', 'rock'] },
jungle: { label: 'Jungle', color: '#2f6b39', base: 6, amp: 7.0, freq: 2.8, op: 'none', rough: 0.7, props: ['palm', 'tree', 'shrub'] },
swamp: { label: 'Swamp', color: '#4a5c3a', base: 1.2, amp: 1.6, freq: 2.6, op: 'flat', rough: 0.3, props: ['tree', 'shrub'] },
desert: { label: 'Desert', color: '#c9a86a', base: 4, amp: 5.0, freq: 1.5, op: 'dune', rough: 0.3, props: ['cactus', 'rock'] },
badlands: { label: 'Badlands', color: '#a8734a', base: 9, amp: 12.0, freq: 1.9, op: 'terrace', rough: 0.8, props: ['rock', 'cactus'] },
canyon: { label: 'Canyon', color: '#9c5f42', base: 12, amp: 16.0, freq: 1.2, op: 'erosion', rough: 1.0, props: ['rock'] },
mesa: { label: 'Mesa', color: '#b07048', base: 14, amp: 10.0, freq: 1.0, op: 'terrace', rough: 0.7, props: ['rock', 'cactus'] },
hills: { label: 'Hills', color: '#6f8a4e', base: 10, amp: 9.0, freq: 1.6, op: 'none', rough: 0.5, props: ['tree', 'shrub', 'rock'] },
mountain: { label: 'Mountains', color: '#7c7a72', base: 20, amp: 26.0, freq: 1.1, op: 'peak', rough: 1.0, props: ['pine', 'rock'] },
alpine: { label: 'Alpine', color: '#8c8d8a', base: 26, amp: 24.0, freq: 1.3, op: 'peak', rough: 1.0, props: ['pine', 'rock'] },
snow: { label: 'Snowfield', color: '#dfe6ea', base: 30, amp: 16.0, freq: 1.2, op: 'peak', rough: 0.8, props: ['rock'] },
tundra: { label: 'Tundra', color: '#9aa89c', base: 6, amp: 4.0, freq: 1.8, op: 'none', rough: 0.4, props: ['rock', 'shrub'] },
volcano: { label: 'Volcano', color: '#4a3f3d', base: 22, amp: 30.0, freq: 1.0, op: 'cone', rough: 1.0, props: ['rock'] },
crater: { label: 'Crater', color: '#6b665f', base: 8, amp: 14.0, freq: 1.4, op: 'crater', rough: 0.9, props: ['rock'] },
};
// Keyword -> biome. Longer phrases are matched first so "snow mountain" does not
// collapse to "snow" alone.
const KEYWORDS = [
[['ocean', 'sea', 'coastline', '바다', '해안'], ['ocean', 'beach']],
[['island', '섬'], ['ocean', 'beach', 'jungle', 'mountain']],
[['lake', 'pond', '호수'], ['lake', 'meadow']],
[['river', 'valley', '계곡', '강'], ['canyon', 'hills', 'forest']],
[['beach', 'shore', '해변'], ['beach', 'ocean']],
[['canyon', 'gorge', '협곡'], ['canyon', 'mesa', 'desert']],
[['mesa', 'butte'], ['mesa', 'desert']],
[['badland', 'wasteland', '황무지'], ['badlands', 'desert']],
[['desert', 'dune', 'sand', '사막'], ['desert', 'badlands']],
[['oasis', '오아시스'], ['desert', 'lake']],
[['volcano', 'lava', '화산'], ['volcano', 'badlands']],
[['crater', 'moon', 'lunar', '분화구'], ['crater', 'tundra']],
[['jungle', 'rainforest', '정글'], ['jungle', 'hills']],
[['forest', 'wood', 'pine', 'taiga', '숲'], ['forest', 'hills']],
[['swamp', 'marsh', 'bog', '늪'], ['swamp', 'forest']],
[['savanna', 'safari'], ['savanna', 'plains']],
[['tundra', 'arctic', 'frozen', '툰드라'], ['tundra', 'snow']],
[['glacier', 'snow', 'ice', '설원', '빙하'], ['snow', 'alpine']],
[['alpine', 'alps'], ['alpine', 'snow', 'forest']],
[['mountain', 'peak', 'ridge', '산'], ['mountain', 'hills']],
[['hill', 'highland', '언덕'], ['hills', 'meadow']],
[['meadow', 'grass', 'field', 'prairie', '초원'], ['meadow', 'plains']],
[['plain', 'steppe', '평원'], ['plains', 'hills']],
];
export function planRegions(prompt, rng) {
const text = (prompt || '').toLowerCase();
// Rank by where the word appears in the prompt, not by dictionary order: in
// "a snowy alpine range above a pine forest and a lake" the subject is the range,
// and coverage below is derived from this ranking.
const hits = [];
for (const [words, biomes] of KEYWORDS) {
let at = Infinity;
for (const w of words) {
const i = text.indexOf(w);
if (i >= 0) at = Math.min(at, i);
}
if (at < Infinity) hits.push({ at, biomes });
}
hits.sort((a, b) => a.at - b.at);
const picked = [];
for (const h of hits) {
for (const b of h.biomes) if (BIOMES[b] && !picked.includes(b)) picked.push(b);
}
if (picked.length === 0) picked.push('meadow', 'forest', 'hills', 'mountain');
if (picked.length === 1) {
const companions = { ocean: 'beach', desert: 'mesa', snow: 'alpine', jungle: 'hills' };
picked.push(companions[picked[0]] || 'plains');
}
const regions = picked.slice(0, 5);
// Coverage: earlier keywords weigh more, so the leading noun dominates the map.
const weights = regions.map((_, i) => 1 / (1 + i * 0.55));
const total = weights.reduce((a, b) => a + b, 0);
return regions.map((key, i) => ({
key,
...BIOMES[key],
coverage: weights[i] / total,
seeds: Math.max(1, Math.round(weights[i] / total * 9)),
jitter: rng(),
}));
}
// -------------------------------------------------------- semantic layout map --
// Region ownership by warped Voronoi: the domain warp is what keeps borders from
// looking like a polygon diagram.
function buildLayout(regions, rng, noise) {
const seeds = [];
regions.forEach((r, ri) => {
for (let s = 0; s < r.seeds; s++) {
seeds.push({ ri, x: rng() * GRID, y: rng() * GRID });
}
});
const owner = new Int16Array(GRID * GRID);
for (let y = 0; y < GRID; y++) {
for (let x = 0; x < GRID; x++) {
const wx = x + fbm(noise, x * 0.018, y * 0.018, 3) * 26;
const wy = y + fbm(noise, x * 0.018 + 40, y * 0.018 + 40, 3) * 26;
let best = 0, bestD = Infinity;
for (const s of seeds) {
const d = (s.x - wx) ** 2 + (s.y - wy) ** 2;
if (d < bestD) { bestD = d; best = s.ri; }
}
owner[y * GRID + x] = best;
}
}
return owner;
}
// Soft masks: one blurred 0..1 field per region, renormalised so they sum to 1.
function buildMasks(owner, count, passes = 3) {
const masks = [];
for (let r = 0; r < count; r++) {
const m = new Float32Array(GRID * GRID);
for (let i = 0; i < m.length; i++) m[i] = owner[i] === r ? 1 : 0;
masks.push(m);
}
const tmp = new Float32Array(GRID * GRID);
for (const m of masks) {
for (let p = 0; p < passes; p++) {
for (let y = 0; y < GRID; y++) {
for (let x = 0; x < GRID; x++) {
let sum = 0, n = 0;
for (let dy = -2; dy <= 2; dy++) {
const yy = y + dy;
if (yy < 0 || yy >= GRID) continue;
for (let dx = -2; dx <= 2; dx++) {
const xx = x + dx;
if (xx < 0 || xx >= GRID) continue;
sum += m[yy * GRID + xx]; n++;
}
}
tmp[y * GRID + x] = sum / n;
}
}
m.set(tmp);
}
}
for (let i = 0; i < GRID * GRID; i++) {
let sum = 0;
for (const m of masks) sum += m[i];
if (sum > 1e-6) for (const m of masks) m[i] /= sum;
else masks[0][i] = 1;
}
return masks;
}
// -------------------------------------------------------- geomorphic operators --
function operator(kind, x, y, cx, cy, noise, phase) {
const nx = (x - cx) / GRID * 2, ny = (y - cy) / GRID * 2;
const d = Math.sqrt(nx * nx + ny * ny);
switch (kind) {
case 'peak': // ridged mass falling off outward
return Math.max(0, 1 - d * 1.15) ** 1.6 * (0.55 + 0.45 * Math.abs(fbm(noise, x * 0.02, y * 0.02, 4)));
case 'cone': // volcano: cone with a summit vent
return Math.max(0, 1 - d * 1.3) ** 1.2 - Math.max(0, 1 - d * 7) ** 2 * 0.55;
case 'crater': // rim up, floor down
return Math.max(0, 1 - Math.abs(d * 3.2 - 1) * 2.2) * 0.9 - Math.max(0, 1 - d * 3.2) ** 2 * 0.7;
case 'dune': // travelling ridges
return Math.sin((x * 0.16 + y * 0.07) + phase * 6.28 + fbm(noise, x * 0.02, y * 0.02, 2) * 2.2) * 0.5;
case 'terrace': // stepped plateaus
return Math.round(fbm(noise, x * 0.012, y * 0.012, 3) * 3.2) / 3.2;
case 'erosion': { // incised channels
const v = Math.abs(fbm(noise, x * 0.014 + phase * 10, y * 0.014, 4));
return -((1 - v) ** 2) * 1.3; // parens required: `-x ** 2` is a syntax error
}
case 'flat':
return 0;
default:
return 0;
}
}
// ------------------------------------------------------------------ synthesis --
export function synthesize(prompt, seed) {
const rng = makeRng(seed);
const noise = makeValueNoise(rng);
const regions = planRegions(prompt, rng);
const owner = buildLayout(regions, rng, noise);
const masks = buildMasks(owner, regions.length);
// Region centroids feed the radial operators (peak, cone, crater).
const cent = regions.map(() => ({ x: 0, y: 0, n: 0 }));
for (let y = 0; y < GRID; y++) {
for (let x = 0; x < GRID; x++) {
const c = cent[owner[y * GRID + x]];
c.x += x; c.y += y; c.n++;
}
}
cent.forEach(c => { if (c.n) { c.x /= c.n; c.y /= c.n; } });
const height = new Float32Array(GRID * GRID);
for (let y = 0; y < GRID; y++) {
for (let x = 0; x < GRID; x++) {
const i = y * GRID + x;
let h = 0;
for (let r = 0; r < regions.length; r++) {
const m = masks[r][i];
if (m < 0.002) continue;
const reg = regions[r];
const bands =
fbm(noise, x * 0.012 * reg.freq, y * 0.012 * reg.freq, 5) * reg.amp +
fbm(noise, x * 0.05 * reg.freq, y * 0.05 * reg.freq, 3) * reg.amp * 0.22 * reg.rough;
const geo = operator(reg.op, x, y, cent[r].x, cent[r].y, noise, reg.jitter) * reg.amp;
h += m * (reg.base + bands + geo);
}
height[i] = h * VERTICAL_SCALE;
}
}
// One smoothing pass: masks blend the fields, but operator seams still benefit.
const sm = new Float32Array(height.length);
for (let y = 0; y < GRID; y++) {
for (let x = 0; x < GRID; x++) {
let sum = 0, n = 0;
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
const yy = y + dy, xx = x + dx;
if (yy < 0 || yy >= GRID || xx < 0 || xx >= GRID) continue;
sum += height[yy * GRID + xx]; n++;
}
}
sm[y * GRID + x] = sum / n;
}
}
const hasWater = regions.some(r => r.key === 'ocean' || r.key === 'lake' || r.key === 'swamp');
const seaLevel = hasWater ? 0 : -999;
// The world model proper: erosion cuts the valleys, the valleys carry the
// rivers, the rivers set the moisture. Without this the terrain is scenery;
// with it, every later decision (colour, vegetation, wildlife) has a cause.
erode(sm, rng); // carve the valleys
const filled = fillDepressions(sm); // make every cell drain, and find the lakes
const { acc: flow } = drainage(filled); // drainage area on a surface that connects
const water = hydrology(sm, flow, seaLevel, filled);
const moist = moisture(water.depth);
return {
regions, owner, masks,
height: sm,
seaLevel,
flow,
water,
moisture: moist,
};
}