/** * TerrainGenWorker.js — Web Worker для процедурной генерации ландшафта. * * Архитектура (Этап C оптимизации): * - main thread → отправляет params + bbox → worker * - worker (фоновый поток) → сэмплит heightmap, surface extraction, * деревья, decorations * - worker → отправляет результат: Array<{x,y,z,m}> + decorations[] * - main thread → передаёт в terrainManager.loadFromArray + decoManager * * Worker устраняет 5-15 секунд блокировки UI на больших картах. * UI остаётся отзывчивым — progress-bar анимируется плавно. * * Worker код — self-contained, без импортов. Загружается через blob URL. * * НЕ импортируется напрямую. Используй getTerrainGenWorkerUrl(). */ const SOURCE = ` "use strict"; // ============================================================================ // SimplexNoise (inline копия из engine/voxel/SimplexNoise.js) // ============================================================================ function mulberry32(seed) { let t = seed | 0; return function () { t = (t + 0x6D2B79F5) | 0; let r = Math.imul(t ^ (t >>> 15), 1 | t); r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r; return ((r ^ (r >>> 14)) >>> 0) / 4294967296; }; } const GRAD2 = [ 1, 1, -1, 1, 1, -1, -1, -1, 1, 0, -1, 0, 1, 0, -1, 0, 0, 1, 0, -1, 0, 1, 0, -1, 1, 1, 0, 0, -1, 1, 0, 0, ]; const F2 = 0.5 * (Math.sqrt(3) - 1); const G2 = (3 - Math.sqrt(3)) / 6; class SimplexNoise { constructor(seed) { this.seed = seed; const rand = mulberry32(seed); const perm = new Uint8Array(512); const p = new Uint8Array(256); for (let i = 0; i < 256; i++) p[i] = i; for (let i = 255; i > 0; i--) { const j = Math.floor(rand() * (i + 1)); const tmp = p[i]; p[i] = p[j]; p[j] = tmp; } for (let i = 0; i < 512; i++) perm[i] = p[i & 255]; this.perm = perm; } noise2(xin, yin) { const perm = this.perm; const s = (xin + yin) * F2; const i = Math.floor(xin + s); const j = Math.floor(yin + s); const t = (i + j) * G2; const X0 = i - t, Y0 = j - t; const x0 = xin - X0, y0 = yin - Y0; let i1, j1; if (x0 > y0) { i1 = 1; j1 = 0; } else { i1 = 0; j1 = 1; } const x1 = x0 - i1 + G2, y1 = y0 - j1 + G2; const x2 = x0 - 1 + 2 * G2, y2 = y0 - 1 + 2 * G2; const ii = i & 255, jj = j & 255; const gi0 = perm[ii + perm[jj]] & 15; const gi1 = perm[ii + i1 + perm[jj + j1]] & 15; const gi2 = perm[ii + 1 + perm[jj + 1]] & 15; let n0 = 0, n1 = 0, n2 = 0; let t0 = 0.5 - x0 * x0 - y0 * y0; if (t0 >= 0) { t0 *= t0; n0 = t0 * t0 * (GRAD2[gi0*2]*x0 + GRAD2[gi0*2+1]*y0); } let t1m = 0.5 - x1 * x1 - y1 * y1; if (t1m >= 0) { t1m *= t1m; n1 = t1m * t1m * (GRAD2[gi1*2]*x1 + GRAD2[gi1*2+1]*y1); } let t2m = 0.5 - x2 * x2 - y2 * y2; if (t2m >= 0) { t2m *= t2m; n2 = t2m * t2m * (GRAD2[gi2*2]*x2 + GRAD2[gi2*2+1]*y2); } return 70 * (n0 + n1 + n2); } fbm2(x, y, octaves, persistence, lacunarity) { let total = 0, amp = 1, freq = 1, norm = 0; for (let i = 0; i < octaves; i++) { total += this.noise2(x * freq, y * freq) * amp; norm += amp; amp *= persistence; freq *= lacunarity; } return total / norm; } } // ============================================================================ // Хэш для детерминированной расстановки структур // ============================================================================ function hash2(x, y, seed) { let h = (x * 374761393 + y * 668265263 + seed * 1442695040) | 0; h = Math.imul(h ^ (h >>> 13), 1274126177); return ((h ^ (h >>> 16)) >>> 0) / 4294967296; } // ============================================================================ // WorldGenerator (упрощённая копия — sample + bbox) // ============================================================================ let G_PARAMS = null; let G_HEIGHT_NOISE = null; let G_BIOME_NOISE = null; function setupGenerator(params) { G_PARAMS = params; G_HEIGHT_NOISE = new SimplexNoise(params.seed); G_BIOME_NOISE = new SimplexNoise(params.seed + (params.biomeMap?.seedOffset ?? 100)); } function rawHeight(x, z) { const hp = G_PARAMS.heightmap; let wx = x, wz = z; if (G_PARAMS.domainWarp?.enabled) { const ws = G_PARAMS.domainWarp.scale; const wstr = G_PARAMS.domainWarp.strength; wx = x + G_HEIGHT_NOISE.noise2(x * ws, z * ws) * wstr; wz = z + G_HEIGHT_NOISE.noise2(x * ws + 100, z * ws + 100) * wstr; } return G_HEIGHT_NOISE.fbm2(wx * hp.scale, wz * hp.scale, hp.octaves, hp.persistence, hp.lacunarity); } function sampleBiome(x, z) { const bm = G_PARAMS.biomeMap; const n = (G_BIOME_NOISE.fbm2(x * bm.scale, z * bm.scale, 3, 0.5, 2.0) + 1) * 0.5; const biomes = G_PARAMS.biomes; for (let i = 0; i < biomes.length; i++) { const b = biomes[i]; if (n >= b.threshold[0] && n < b.threshold[1]) return b; } return biomes[biomes.length - 1]; } function smoothBiomeBonus(x, z) { const BLEND_WIDTH = 0.08; const bm = G_PARAMS.biomeMap; const n = (G_BIOME_NOISE.fbm2(x * bm.scale, z * bm.scale, 3, 0.5, 2.0) + 1) * 0.5; const biomes = G_PARAMS.biomes; let currentIdx = -1; for (let i = 0; i < biomes.length; i++) { const b = biomes[i]; if (n >= b.threshold[0] && n < b.threshold[1]) { currentIdx = i; break; } } if (currentIdx === -1) currentIdx = biomes.length - 1; const cur = biomes[currentIdx]; const curBonus = cur.heightBonus ?? 1; const t0 = cur.threshold[0]; const t1 = cur.threshold[1]; const distFromStart = n - t0; const distFromEnd = t1 - n; if (distFromStart < BLEND_WIDTH && currentIdx > 0) { const prev = biomes[currentIdx - 1]; const prevBonus = prev.heightBonus ?? 1; const tt = distFromStart / BLEND_WIDTH; const ts = tt * tt * (3 - 2 * tt); return prevBonus * (1 - ts) + curBonus * ts; } if (distFromEnd < BLEND_WIDTH && currentIdx < biomes.length - 1) { const next = biomes[currentIdx + 1]; const nextBonus = next.heightBonus ?? 1; const tt = distFromEnd / BLEND_WIDTH; const ts = tt * tt * (3 - 2 * tt); return nextBonus * (1 - ts) + curBonus * ts; } return curBonus; } function sampleHeight(x, z) { const hp = G_PARAMS.heightmap; let n = rawHeight(x, z); n = (n + 1) * 0.5; n = Math.pow(Math.max(0, Math.min(1, n)), hp.exponent); const bonus = smoothBiomeBonus(x, z); return Math.round(hp.baseHeight + n * hp.amplitude * bonus); } function sampleTreeAt(x, z) { const s = G_PARAMS.structures?.trees; if (!s || !s.enabled) return null; const biome = sampleBiome(x, z); const treeProb = (biome.features?.trees ?? 0) * s.density; if (treeProb <= 0) return null; const grid = Math.max(2, s.minDistance); const gx = Math.floor(x / grid); const gz = Math.floor(z / grid); const r = hash2(gx, gz, G_PARAMS.seed + 999); if (r >= treeProb) return null; const ox = Math.floor(hash2(gx, gz, G_PARAMS.seed + 1001) * grid); const oz = Math.floor(hash2(gx, gz, G_PARAMS.seed + 1002) * grid); if (gx * grid + ox !== x || gz * grid + oz !== z) return null; const treeTypes = biome.features?.treeTypes ?? ['oak']; const tt = Math.floor(hash2(gx, gz, G_PARAMS.seed + 1003) * treeTypes.length); return { type: treeTypes[tt % treeTypes.length] }; } // ============================================================================ // Полная генерация: возвращает voxels[] + decorations[] // ============================================================================ function generateWorld(bbox, progressCb) { const { x0, z0, x1, z1 } = bbox; const width = x1 - x0; const depth = z1 - z0; const total = width * depth; let done = 0; const totalW = total * 2 + total * 0.3; // === Шаг 1: heightmap === const heightmap = new Map(); for (let z = z0; z < z1; z++) { for (let x = x0; x < x1; x++) { const h = sampleHeight(x, z); const biome = sampleBiome(x, z); heightmap.set(x + ',' + z, { h, biome }); } done += width; if ((z - z0) % 16 === 0 && progressCb) progressCb(done, totalW, 'heightmap'); } // === Шаг 2: surface extraction === const voxelMap = new Map(); // "x,y,z" → matId const setVoxel = (x, y, z, m) => voxelMap.set(x + ',' + y + ',' + z, m); const getVoxel = (x, y, z) => voxelMap.get(x + ',' + y + ',' + z); for (let z = z0; z < z1; z++) { for (let x = x0; x < x1; x++) { const info = heightmap.get(x + ',' + z); const h = info.h; const biome = info.biome; const nb = []; for (const [dx, dz] of [[-1, 0], [1, 0], [0, -1], [0, 1]]) { const nbInfo = heightmap.get((x + dx) + ',' + (z + dz)); nb.push(nbInfo ? nbInfo.h : 0); } const minNbH = Math.min(...nb); if (h <= 0) { setVoxel(x, 0, z, biome.softMaterial); done++; continue; } setVoxel(x, h - 1, z, biome.topMaterial); if (h >= 2 && (h - 2) >= minNbH) setVoxel(x, h - 2, z, biome.softMaterial); if (h >= 3 && (h - 3) >= minNbH) setVoxel(x, h - 3, z, biome.softMaterial); const bottomVisible = Math.max(0, minNbH); const topVisible = h - 3; for (let y = bottomVisible; y < topVisible; y++) { setVoxel(x, y, z, biome.hardMaterial); } if (minNbH <= 0 && !getVoxel(x, 0, z)) { setVoxel(x, 0, z, biome.hardMaterial); } done++; } if ((z - z0) % 16 === 0 && progressCb) progressCb(done, totalW, 'surface'); } // === Шаг 3: деревья === let treesPlaced = 0; if (G_PARAMS.structures?.trees?.enabled) { for (let z = z0; z < z1; z++) { for (let x = x0; x < x1; x++) { const tree = sampleTreeAt(x, z); if (!tree) continue; const info = heightmap.get(x + ',' + z); if (!info || info.h < 1) continue; if (info.biome.topMaterial !== 'grass') continue; placeTree(x, info.h - 1, z, tree.type, setVoxel); treesPlaced++; } done += width * 0.3; if ((z - z0) % 32 === 0 && progressCb) progressCb(done, totalW, 'trees'); } } // === Шаг 4: decorations === const decorations = []; const decoParams = G_PARAMS.structures?.decorations ?? {}; const flowersDensity = decoParams.flowersDensity ?? 0.015; const grassDensity = decoParams.grassDensity ?? 0.05; if ((flowersDensity > 0 || grassDensity > 0) && decoParams.enabled !== false) { const FLOWERS_BY_BIOME = { grass: ['daisy', 'cornflower', 'poppy', 'dandelion'], forest: ['daisy', 'fly_mushroom', 'brown_mushroom', 'fly_mushroom'], plain: ['dandelion', 'cornflower', 'daisy'], }; const GRASS_POOL = [ 'grass_short', 'grass_short', 'grass_short', 'grass_short', 'grass_tuft', 'grass_tuft', 'grass_tuft', 'grass_blade', 'grass_blade', 'grass_wide', 'grass_wide', 'grass_tall', 'grass_clump', 'grass_dry', ]; for (let z = z0; z < z1; z++) { for (let x = x0; x < x1; x++) { const info = heightmap.get(x + ',' + z); if (!info || info.h < 1) continue; if (info.biome.topMaterial !== 'grass') continue; const topY = info.h - 1; const aboveMat = getVoxel(x, topY + 1, z); if (aboveMat) continue; const wx = (x + 0.5) * 0.25; const wy = info.h * 0.25; const wz = (z + 0.5) * 0.25; const rFlower = hash2(x, z, G_PARAMS.seed + 5001); if (rFlower < flowersDensity) { const biomeId = info.biome.id; const pool = FLOWERS_BY_BIOME[biomeId] || FLOWERS_BY_BIOME.plain; if (pool && pool.length > 0) { const modelIdx = Math.floor(hash2(x, z, G_PARAMS.seed + 5002) * pool.length); const modelId = pool[modelIdx]; const rotation = Math.floor(hash2(x, z, G_PARAMS.seed + 5003) * 4); decorations.push({ x: wx, y: wy, z: wz, modelId, rotation }); continue; } } const rGrass = hash2(x, z, G_PARAMS.seed + 5004); if (rGrass < grassDensity) { const modelIdx = Math.floor(hash2(x, z, G_PARAMS.seed + 5005) * GRASS_POOL.length); const modelId = GRASS_POOL[modelIdx]; const rotation = Math.floor(hash2(x, z, G_PARAMS.seed + 5006) * 4); const scale = 0.7 + hash2(x, z, G_PARAMS.seed + 5007) * 0.7; decorations.push({ x: wx, y: wy, z: wz, modelId, rotation, scale }); } } } } // Конвертируем Map в массив для возврата const voxels = []; for (const [key, m] of voxelMap) { const lastComma = key.lastIndexOf(','); const midComma = key.lastIndexOf(',', lastComma - 1); voxels.push({ x: parseInt(key.slice(0, midComma), 10), y: parseInt(key.slice(midComma + 1, lastComma), 10), z: parseInt(key.slice(lastComma + 1), 10), m, }); } return { voxels, decorations, treesPlaced }; } // ============================================================================ // _placeTree — копия из engine/voxel/WorldGenerator.js (упрощённая) // ============================================================================ function trunkOffsets(thickness) { if (thickness <= 0) return [[0, 0]]; if (thickness === 1) return [[0, 0], [1, 0], [-1, 0], [0, 1], [0, -1]]; if (thickness === 2) { return [ [0, 0], [1, 0], [-1, 0], [0, 1], [0, -1], [1, 1], [-1, -1], [1, -1], [-1, 1], ]; } const out = []; for (let dx = -2; dx <= 2; dx++) { for (let dz = -2; dz <= 2; dz++) { if (Math.abs(dx) === 2 && Math.abs(dz) === 2) continue; out.push([dx, dz]); } } return out; } function placeCrown(cx, cy, cz, r, squish, placeLeaf) { const r2sq = r * r; const innerR2 = r > 2 ? (r - 2) * (r - 2) : 0; const yRange = Math.floor(r * squish) + 2; for (let dx = -r - 1; dx <= r + 1; dx++) { for (let dy = -Math.floor(r / 2); dy <= yRange; dy++) { for (let dz = -r - 1; dz <= r + 1; dz++) { const dyEff = dy / squish; const d2 = dx * dx + dyEff * dyEff + dz * dz; if (d2 > r2sq + 2) continue; if (d2 < innerR2) continue; const edgeThresh = r2sq - 1.5; if (d2 > edgeThresh) { if (hash2(cx + dx, cz + dz, G_PARAMS.seed + 3000 + dy) > 0.5) continue; } if (dx === 0 && dz === 0 && dy < 0) continue; const colorNoise = hash2(cx + dx, cz + dz + dy * 11, G_PARAMS.seed + 3500); placeLeaf(cx + dx, cy + dy, cz + dz, colorNoise); } } } } function placeTree(tx, topY, tz, type, setVoxel) { const r1 = hash2(tx, tz, G_PARAMS.seed + 2001); const r2 = hash2(tx, tz, G_PARAMS.seed + 2002); const r3 = hash2(tx, tz, G_PARAMS.seed + 2003); const sizeScale = Math.max(0.3, Math.min(5.0, G_PARAMS.structures?.trees?.sizeScale ?? 1.0)); let primaryTrunk, secondaryTrunk, primaryLeaf, secondaryLeaf; let secondaryLeafChance, trunkH, crownR, crownSquish, trunkThickness; if (type === 'birch') { primaryTrunk = 'trunk_white'; secondaryTrunk = 'trunk_white'; primaryLeaf = 'leaves'; secondaryLeaf = 'leaves_orange'; secondaryLeafChance = 0.10; trunkH = Math.round((10 + r1 * 4) * sizeScale); crownR = Math.round(3 * sizeScale); crownSquish = 1.5; trunkThickness = sizeScale < 1.5 ? 0 : 1; } else if (type === 'autumn') { primaryTrunk = 'trunk'; secondaryTrunk = 'wood'; primaryLeaf = 'leaves_orange'; secondaryLeaf = 'leaves'; secondaryLeafChance = 0.25; trunkH = Math.round((9 + r1 * 4) * sizeScale); crownR = Math.round((4 + r2 * 2) * sizeScale); crownSquish = 0.85; trunkThickness = sizeScale < 0.8 ? 0 : sizeScale < 1.5 ? 1 : sizeScale < 2.5 ? 2 : 3; } else { primaryTrunk = 'trunk'; secondaryTrunk = 'wood'; primaryLeaf = 'leaves'; secondaryLeaf = 'leaves_orange'; secondaryLeafChance = 0.07; trunkH = Math.round((8 + r1 * 4) * sizeScale); crownR = Math.round((3 + r2 * 2) * sizeScale); crownSquish = 1.0; trunkThickness = sizeScale < 0.8 ? 0 : sizeScale < 1.5 ? 1 : sizeScale < 2.5 ? 2 : 3; } if (trunkH < 3) trunkH = 3; if (crownR < 2) crownR = 2; const placeLeaf = (px, py, pz, noise) => { const isSecondary = noise < secondaryLeafChance; setVoxel(px, py, pz, isSecondary ? secondaryLeaf : primaryLeaf); }; const placeTrunk = (px, py, pz, noise) => { const isSecondary = (type !== 'birch') && noise < 0.05; setVoxel(px, py, pz, isSecondary ? secondaryTrunk : primaryTrunk); }; const taperStart = Math.floor(trunkH * 0.7); for (let dy = 1; dy <= trunkH; dy++) { const thicknessAtY = dy < taperStart ? trunkThickness : Math.max(0, trunkThickness - Math.floor((dy - taperStart) * 0.5)); const offsets = trunkOffsets(thicknessAtY); for (const [ox, oz] of offsets) { const noise = hash2(tx + ox * 13, tz + oz * 17 + dy * 7, G_PARAMS.seed + 2100); placeTrunk(tx + ox, topY + dy, tz + oz, noise); } } if (trunkThickness >= 2) { const baseH = Math.min(3, Math.floor(trunkH / 5)); for (let dy = 1; dy <= baseH; dy++) { for (const [ox, oz] of [[2, 0], [-2, 0], [0, 2], [0, -2], [1, 1], [-1, -1], [1, -1], [-1, 1]]) { if (hash2(tx + ox * 13, tz + oz * 17 + dy, G_PARAMS.seed + 2200) > 0.55) continue; placeTrunk(tx + ox, topY + dy, tz + oz, 0.5); } } } const branchTips = []; const numBranches = type === 'birch' ? (sizeScale < 1.5 ? 2 : 3) : Math.max(2, Math.min(8, Math.round((3 + r3 * 2) * Math.sqrt(sizeScale)))); const branchLenBase = Math.max(2, Math.floor(crownR * 0.8 + sizeScale)); for (let b = 0; b < numBranches; b++) { const baseAngle = (b / numBranches) * Math.PI * 2; const angleJitter = (hash2(tx + b * 31, tz + b * 53, G_PARAMS.seed + 2300) - 0.5) * 0.6; const angle = baseAngle + angleJitter + r1 * 0.5; const startYFrac = 0.5 + (b / numBranches) * 0.45; const branchStartY = Math.max(2, Math.floor(trunkH * startYFrac)); const branchLen = branchLenBase + Math.floor(hash2(tx + b, tz, G_PARAMS.seed + 2400) * 3); let bx = tx, by = topY + branchStartY, bz = tz; const dirX = Math.cos(angle), dirZ = Math.sin(angle); for (let step = 1; step <= branchLen; step++) { bx = Math.round(tx + dirX * step); bz = Math.round(tz + dirZ * step); const upBias = step * 0.4; const jitterY = (hash2(bx * 7, bz * 11, G_PARAMS.seed + 2500 + step) - 0.3) * 1.5; by = topY + branchStartY + Math.floor(upBias + jitterY); const noise = hash2(bx, bz + step * 3, G_PARAMS.seed + 2310); placeTrunk(bx, by, bz, noise); if (trunkThickness >= 2 && step <= branchLen / 2) { const sx = Math.round(bx - dirZ * 0.7); const sz = Math.round(bz + dirX * 0.7); if (sx !== bx || sz !== bz) { placeTrunk(sx, by, sz, noise + 0.1); } } } branchTips.push({ x: bx, y: by, z: bz }); } const crownCy = topY + trunkH; const mainCrownR = Math.max(2, Math.floor(crownR * 0.85)); placeCrown(tx, crownCy, tz, mainCrownR, crownSquish, placeLeaf); const subCrownRBase = Math.max(2, Math.floor(crownR * 0.6)); for (const tip of branchTips) { const sizeJitter = hash2(tip.x, tip.z, G_PARAMS.seed + 2600) * 0.4 - 0.2; const subR = Math.max(2, Math.round(subCrownRBase * (1.0 + sizeJitter))); placeCrown(tip.x, tip.y + 1, tip.z, subR, crownSquish, placeLeaf); } } // ============================================================================ // Worker message handler // ============================================================================ self.onmessage = function(e) { const msg = e.data; if (msg.type === 'generate') { try { setupGenerator(msg.params); const t0 = performance.now(); const result = generateWorld(msg.bbox, (done, total, phase) => { self.postMessage({ type: 'progress', done, total, phase }); }); const dt = performance.now() - t0; // Передаём voxels — может быть большой массив, лучше JSON через // postMessage (не Transferable т.к. это plain objects). self.postMessage({ type: 'done', voxels: result.voxels, decorations: result.decorations, treesPlaced: result.treesPlaced, timeMs: Math.round(dt), }); } catch (err) { self.postMessage({ type: 'error', message: err.message, stack: err.stack }); } } }; `; /** * Создаёт Blob URL Worker'а. Использовать в new Worker(url). */ export function getTerrainGenWorkerUrl() { const blob = new Blob([SOURCE], { type: 'application/javascript' }); return URL.createObjectURL(blob); }