Spaces:
Running
Running
| // 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, | |
| }; | |
| } | |