/** * DecoManager — рендерит мелкие воксельные декорации (цветы, грибы, трава). * * Размер voxel'а — 0.05м (×5 меньше terrain). Каждая декорация состоит из * 7-15 мини-вокселей, формируя цветок/гриб/кустик. * * Архитектура (Этап B оптимизации — merged geometry): * - Все voxel'ы группируются по ЦВЕТУ (а не по модели) * - Для каждой группы цвета строится ОДИН большой merged mesh: * surface culling внутри этого цвета (соседи без учёта других цветов) * - На карте 80м с травой ~15% = ~100К mini-voxel'ов → ~10 merged mesh'ей * вместо ~100К thin-instances. Главный выигрыш — драстически меньше * draw calls. * * Surface culling считает соседей ВНУТРИ группы цвета (упрощение). Это * означает: грани между «стебелем» и «лепестком» рисуются обе. На мелких * моделях это не критично — лепестки разные цвета, всё равно были видны. * * Без коллизий — игрок проходит сквозь decorations. * Без shadow casting — мелкие воксели не дают красивые тени. * * Сериализация: список { x, y, z, modelId, rotation, scale } — позиции в * МИРОВЫХ координатах (метры). Воксели модели разворачиваются при рендере. * * Public API: * placeModel(worldX, worldY, worldZ, modelId, rotation=0, scale=1) * clear() * serialize() / loadFromArray() * count() */ import { Mesh, VertexData, StandardMaterial, Color3, } from '@babylonjs/core'; import { DECO_VOXEL_SIZE, DECO_PALETTE, DECO_MODELS } from './DecoModels'; /** * Алиасы цветов — объединяем близкие оттенки в один меш. * Это даёт ×2-3 меньше mesh'ей в сцене. * * Стратегия: похожие зелёные (grass1/2/3/4, leafGrass, stemLight, * leafDark, stemGreen) → 3 группы (тёмный, средний, светлый). * * Цветы — каждый цвет отдельно (важно для контраста). */ const COLOR_ALIAS = { // === Зелёные → 3 группы === grass2: 'greenDark', leafDark: 'greenDark', stemGreen: 'greenDark', grass1: 'greenMid', leafGrass: 'greenMid', grass4: 'greenMid', grass3: 'greenLight', stemLight: 'greenLight', // === Жёлто-сухие → отдельно === grassYellow: 'greenYellow', grassRed: 'greenYellow', // === Белые → один === petalWhite: 'white', capWhite: 'white', capDots: 'white', mushroomStem: 'mushroomStem', // Остальные — без алиаса (используют свой ключ) }; // Цвета для алиасов (используются если ключ в COLOR_ALIAS) const ALIAS_COLORS = { greenDark: [0.20, 0.50, 0.15], greenMid: [0.32, 0.62, 0.20], greenLight: [0.40, 0.70, 0.25], greenYellow: [0.50, 0.60, 0.18], white: [0.95, 0.95, 0.90], mushroomStem: [0.92, 0.88, 0.75], }; function getAliasedKey(colorKey) { return COLOR_ALIAS[colorKey] || colorKey; } function getAliasedColor(colorKey) { if (ALIAS_COLORS[colorKey]) return ALIAS_COLORS[colorKey]; return DECO_PALETTE[colorKey] || [0.5, 0.5, 0.5]; } export class DecoManager { constructor(scene) { this.scene = scene; /** Array<{ x, y, z, modelId, rotation, scale }> */ this.placements = []; /** Map — merged mesh на цвет. */ this._meshes = new Map(); /** Map — материал на цвет. */ this._materials = new Map(); /** Этап D: per-CHUNK раздробление. Map<"cx,cz", Array>. */ this._byChunk = new Map(); /** Map<"cx,cz", Map> — меши по чанкам. */ this._chunkMeshes = new Map(); /** Lazy build: Map<"cx,cz", placement[]> — чанки которые ещё не материализованы. * Заполняется в loadFromArray, очищается в updateStreaming при заходе * чанка в радиус (или в _materializeDecoChunk при ручном вызове). */ this._pendingDecoChunks = new Map(); /** Размер чанка для LOD-стриминга (метры). 64м даёт ×4 меньше mesh'ей * в scene.meshes (4-9 chunks вместо 16+). */ this._chunkSize = 64; /** Радиус LOD: декорации видны только в этом радиусе от камеры. */ this._lodRadius = 50; /** Колбэк изменений. */ this._onChange = null; } setOnChange(cb) { this._onChange = cb; } _emit() { try { this._onChange?.(); } catch (e) {} } count() { return this.placements.length; } /** * Получить или создать материал для цвета. */ _getOrCreateMaterial(colorKey) { // colorKey уже aliased на этапе rebuildAllMeshes let mat = this._materials.get(colorKey); if (mat) return mat; const color = getAliasedColor(colorKey); mat = new StandardMaterial(`__decoMat_${colorKey}`, this.scene); mat.diffuseColor = new Color3(color[0], color[1], color[2]); mat.specularColor = new Color3(0, 0, 0); mat.ambientColor = new Color3(1, 1, 1); mat.emissiveColor = new Color3(color[0] * 0.15, color[1] * 0.15, color[2] * 0.15); try { mat.freeze(); } catch (e) {} this._materials.set(colorKey, mat); return mat; } /** * Разместить декорацию. Только добавляет в placements; сборка mesh'ей * происходит в _flushBuffers() / loadFromArray. */ placeModel(worldX, worldY, worldZ, modelId, rotation = 0, scale = 1.0) { if (!DECO_MODELS[modelId]) { console.warn(`[DecoManager] unknown modelId: ${modelId}`); return; } const placement = { x: worldX, y: worldY, z: worldZ, modelId, rotation, scale }; this.placements.push(placement); // Сразу материализуем chunk, чтобы декорация появилась. // Если chunk уже построен — перестраиваем (новый placement добавляется // в _byChunk, и старый mesh диспозится). const cx = Math.floor(worldX / this._chunkSize); const cz = Math.floor(worldZ / this._chunkSize); const chunkKey = cx + ',' + cz; // Кладём в pending (даже если chunk уже был построен) и перестраиваем. let arrChunk = this._pendingDecoChunks.get(chunkKey); if (!arrChunk) { // Если chunk уже materializ'ован — переносим его placements обратно // в pending, добавляем новый, и заново строим. const existing = this._byChunk.get(chunkKey); arrChunk = existing ? [...existing] : []; this._pendingDecoChunks.set(chunkKey, arrChunk); } arrChunk.push(placement); // Удаляем старые меши этого chunk перед перестроением const oldMeshes = this._chunkMeshes.get(chunkKey); if (oldMeshes) { for (const m of oldMeshes.values()) { try { m.dispose(); } catch (e) {} } this._chunkMeshes.delete(chunkKey); } this._materializeDecoChunk(chunkKey); this._emit(); } /** * Полная очистка. */ clear() { this.placements = []; for (const m of this._meshes.values()) { try { m.dispose(); } catch (e) {} } this._meshes.clear(); // Этап D: чистим chunk meshes тоже for (const colorMap of this._chunkMeshes.values()) { for (const m of colorMap.values()) { try { m.dispose(); } catch (e) {} } } this._chunkMeshes.clear(); this._byChunk.clear(); // Lazy: pending chunks тоже сбрасываем if (this._pendingDecoChunks) this._pendingDecoChunks.clear(); this._emit(); } /** * Этап D LOD streaming: enable/disable chunk-мешей по дистанции * от точки (camX, camZ). Декорации дальше lodRadius метров скрыты. * Вызывается из game-loop раз в 100-150мс. * @returns {{visible:number, hidden:number, total:number}} */ updateStreaming(camX, camZ, radius, options) { const r = radius ?? this._lodRadius; const halfDiag = this._chunkSize * 0.71; const cutoff = r + halfDiag; const cutoff2 = cutoff * cutoff; const maxBuild = options?.maxBuild ?? 4; // === ШАГ 1: материализуем pending chunks в радиусе === // Лимит — не более maxBuild за один тик (deco тяжелее чем regions). // При первом вызове из loadFromState можно поднять maxBuild чтобы // сразу построить все видимые chunks. if (this._pendingDecoChunks.size > 0) { const candidates = []; for (const [chunkKey] of this._pendingDecoChunks) { const [cx, cz] = chunkKey.split(',').map(Number); const ccx = (cx + 0.5) * this._chunkSize; const ccz = (cz + 0.5) * this._chunkSize; const dx = ccx - camX, dz = ccz - camZ; const dist2 = dx * dx + dz * dz; if (dist2 <= cutoff2) candidates.push({ chunkKey, dist2 }); } candidates.sort((a, b) => a.dist2 - b.dist2); const limit = Math.min(maxBuild, candidates.length); for (let i = 0; i < limit; i++) { this._materializeDecoChunk(candidates[i].chunkKey); } } // === ШАГ 2: enable/disable существующих === let visible = 0, hidden = 0, total = 0; for (const [chunkKey, colorMap] of this._chunkMeshes) { total++; const [cx, cz] = chunkKey.split(',').map(Number); const ccx = (cx + 0.5) * this._chunkSize; const ccz = (cz + 0.5) * this._chunkSize; const dx = ccx - camX, dz = ccz - camZ; const dist2 = dx * dx + dz * dz; const shouldShow = dist2 <= cutoff2; for (const mesh of colorMap.values()) { if (shouldShow) { if (!mesh.isEnabled()) mesh.setEnabled(true); } else { if (mesh.isEnabled()) mesh.setEnabled(false); } } if (shouldShow) visible++; else hidden++; } return { visible, hidden, total }; } /** * Материализовать один pending chunk: построить меши по цветам. * Вызывается из updateStreaming при заходе в радиус. */ _materializeDecoChunk(chunkKey) { const placements = this._pendingDecoChunks.get(chunkKey); if (!placements || placements.length === 0) return false; this._pendingDecoChunks.delete(chunkKey); this._byChunk.set(chunkKey, placements); // Разворачиваем модели → группируем по цвету const ANCHOR_X = 2, ANCHOR_Z = 2; const voxelByColor = new Map(); const occupiedSet = new Set(); for (const p of placements) { const model = DECO_MODELS[p.modelId]; if (!model) continue; const scale = p.scale || 1.0; const stepSize = DECO_VOXEL_SIZE * scale; const rot = p.rotation & 3; for (const v of model.voxels) { const localDx = v.x - ANCHOR_X; const localDz = v.z - ANCHOR_Z; let dx, dz; switch (rot) { case 1: dx = -localDz; dz = localDx; break; case 2: dx = -localDx; dz = -localDz; break; case 3: dx = localDz; dz = -localDx; break; default: dx = localDx; dz = localDz; } const dy = v.y; const cx = p.x + dx * stepSize; const cy = p.y + dy * stepSize; const cz = p.z + dz * stepSize; const ix = Math.round(cx / DECO_VOXEL_SIZE); const iy = Math.round(cy / DECO_VOXEL_SIZE); const iz = Math.round(cz / DECO_VOXEL_SIZE); const key = ix + ',' + iy + ',' + iz; if (occupiedSet.has(key)) continue; occupiedSet.add(key); const aliasedColor = getAliasedKey(v.c); let arr = voxelByColor.get(aliasedColor); if (!arr) { arr = []; voxelByColor.set(aliasedColor, arr); } arr.push({ cx, cy, cz, scale, ix, iy, iz }); } } // Строим mesh per цвет const chunkMeshMap = new Map(); for (const [colorKey, voxels] of voxelByColor) { const built = this._buildMergedColorGeometry(voxels, occupiedSet); if (!built) continue; const meshName = `__decoMesh_${chunkKey}_${colorKey}`; const mesh = new Mesh(meshName, this.scene); const vd = new VertexData(); vd.positions = built.positions; vd.normals = built.normals; vd.uvs = built.uvs; vd.indices = built.indices; vd.applyToMesh(mesh, false); mesh.material = this._getOrCreateMaterial(colorKey); mesh.isPickable = false; mesh.receiveShadows = false; mesh.alwaysSelectAsActiveMesh = false; try { mesh.freezeWorldMatrix?.(); } catch (e) {} chunkMeshMap.set(colorKey, mesh); } this._chunkMeshes.set(chunkKey, chunkMeshMap); return true; } /** Установить LOD radius из настроек. */ setLodRadius(r) { this._lodRadius = Math.max(10, r); } getChunkCount() { const built = this._chunkMeshes.size; const pending = this._pendingDecoChunks ? this._pendingDecoChunks.size : 0; return built + pending; } /** * Загрузить из массива placements. Очищает существующее. * * LAZY BUILD: меши НЕ строятся здесь, только группируются по chunks. * Реальное построение происходит в updateStreaming() когда камера * заходит в радиус chunk'а. На больших картах (500K+ decorations) * это даёт ×10-20 ускорения загрузки. */ loadFromArray(arr) { this.clear(); if (!Array.isArray(arr) || arr.length === 0) { this._emit(); return; } const t0 = performance.now(); const chunkSize = this._chunkSize; // Сохраняем placements и группируем по chunk одним проходом for (const p of arr) { if (!DECO_MODELS[p.modelId]) continue; const placement = { x: p.x, y: p.y, z: p.z, modelId: p.modelId, rotation: p.rotation || 0, scale: p.scale ?? 1.0, }; this.placements.push(placement); const cx = Math.floor(p.x / chunkSize); const cz = Math.floor(p.z / chunkSize); const k = cx + ',' + cz; let arrChunk = this._pendingDecoChunks.get(k); if (!arrChunk) { arrChunk = []; this._pendingDecoChunks.set(k, arrChunk); } arrChunk.push(placement); } const dt = performance.now() - t0; console.log(`[DecoManager] loadFromArray (lazy plan): ${this.placements.length} decorations → ${this._pendingDecoChunks.size} pending chunks in ${dt.toFixed(0)}ms`); this._emit(); } /** * Главный билдер (Этап D): chunks × colors. * Группируем placements по chunkKey (32м), внутри chunk — по цвету, * строим merged mesh per (chunk, color). Это даёт ~100-300 мешей * на большую карту, но streaming скрывает дальние сразу. */ _rebuildAllMeshes() { // Очищаем старые меши for (const m of this._meshes.values()) { try { m.dispose(); } catch (e) {} } this._meshes.clear(); for (const colorMap of this._chunkMeshes.values()) { for (const m of colorMap.values()) { try { m.dispose(); } catch (e) {} } } this._chunkMeshes.clear(); this._byChunk.clear(); const chunkSize = this._chunkSize; // Группируем placements по chunk for (const p of this.placements) { const cx = Math.floor(p.x / chunkSize); const cz = Math.floor(p.z / chunkSize); const k = cx + ',' + cz; let arr = this._byChunk.get(k); if (!arr) { arr = []; this._byChunk.set(k, arr); } arr.push(p); } // Для каждого chunk → разворачиваем модели → группируем по цвету → строим merged const ANCHOR_X = 2, ANCHOR_Z = 2; for (const [chunkKey, chunkPlacements] of this._byChunk) { const voxelByColor = new Map(); const occupiedSet = new Set(); for (const p of chunkPlacements) { const model = DECO_MODELS[p.modelId]; if (!model) continue; const scale = p.scale || 1.0; const stepSize = DECO_VOXEL_SIZE * scale; const rot = p.rotation & 3; for (const v of model.voxels) { const localDx = v.x - ANCHOR_X; const localDz = v.z - ANCHOR_Z; let dx, dz; switch (rot) { case 1: dx = -localDz; dz = localDx; break; case 2: dx = -localDx; dz = -localDz; break; case 3: dx = localDz; dz = -localDx; break; default: dx = localDx; dz = localDz; } const dy = v.y; const cx = p.x + dx * stepSize; const cy = p.y + dy * stepSize; const cz = p.z + dz * stepSize; const ix = Math.round(cx / DECO_VOXEL_SIZE); const iy = Math.round(cy / DECO_VOXEL_SIZE); const iz = Math.round(cz / DECO_VOXEL_SIZE); const key = ix + ',' + iy + ',' + iz; if (occupiedSet.has(key)) continue; occupiedSet.add(key); // Применяем алиас цвета — близкие оттенки → одна группа const aliasedColor = getAliasedKey(v.c); let arr = voxelByColor.get(aliasedColor); if (!arr) { arr = []; voxelByColor.set(aliasedColor, arr); } arr.push({ cx, cy, cz, scale, ix, iy, iz }); } } // Строим mesh per цвет в этом chunk const chunkMeshMap = new Map(); for (const [colorKey, voxels] of voxelByColor) { const built = this._buildMergedColorGeometry(voxels, occupiedSet); if (!built) continue; const meshName = `__decoMesh_${chunkKey}_${colorKey}`; const mesh = new Mesh(meshName, this.scene); const vd = new VertexData(); vd.positions = built.positions; vd.normals = built.normals; vd.uvs = built.uvs; vd.indices = built.indices; vd.applyToMesh(mesh, false); mesh.material = this._getOrCreateMaterial(colorKey); mesh.isPickable = false; mesh.receiveShadows = false; mesh.alwaysSelectAsActiveMesh = false; try { mesh.freezeWorldMatrix?.(); } catch (e) {} chunkMeshMap.set(colorKey, mesh); } this._chunkMeshes.set(chunkKey, chunkMeshMap); } } /** * Построить merged geometry для одной группы цвета. * voxels — Array<{cx, cy, cz, scale, ix, iy, iz}> * occupiedSet — Set<"ix,iy,iz"> всех занятых ячеек (для surface culling) * * ОПТИМИЗАЦИЯ (greedy): для каждой грани соединяем соседние voxel'и * одного цвета (которые уже в этом vLoxels-list) в один большой квад. * Делаем простую версию: scan по оси, склеиваем подряд идущие. */ _buildMergedColorGeometry(voxels, occupiedSet) { if (voxels.length === 0) return null; // Хэш voxel'ов этого цвета по ix,iy,iz для быстрого поиска соседа // того же цвета. const sameColorSet = new Set(); const voxelByKey = new Map(); for (const v of voxels) { const key = v.ix + ',' + v.iy + ',' + v.iz; sameColorSet.add(key); voxelByKey.set(key, v); } // 6 граней с осью растяжения для greedy. // Для каждой грани мы будем "тянуть" квад вдоль 2 осей. const FACES = [ // +X: грань в плоскости YZ, растягиваем по Y и Z { dx: 1, dy: 0, dz: 0, nx: 1, ny: 0, nz: 0, u: 'y', v: 'z', c: [[1,-1,-1],[1,1,-1],[1,1,1],[1,-1,1]] }, // -X: плоскость YZ { dx: -1, dy: 0, dz: 0, nx: -1, ny: 0, nz: 0, u: 'y', v: 'z', c: [[-1,-1,1],[-1,1,1],[-1,1,-1],[-1,-1,-1]] }, // +Y: плоскость XZ { dx: 0, dy: 1, dz: 0, nx: 0, ny: 1, nz: 0, u: 'x', v: 'z', c: [[-1,1,-1],[-1,1,1],[1,1,1],[1,1,-1]] }, // -Y: плоскость XZ { dx: 0, dy: -1, dz: 0, nx: 0, ny: -1, nz: 0, u: 'x', v: 'z', c: [[-1,-1,1],[-1,-1,-1],[1,-1,-1],[1,-1,1]] }, // +Z: плоскость XY { dx: 0, dy: 0, dz: 1, nx: 0, ny: 0, nz: 1, u: 'x', v: 'y', c: [[1,-1,1],[1,1,1],[-1,1,1],[-1,-1,1]] }, // -Z: плоскость XY { dx: 0, dy: 0, dz: -1, nx: 0, ny: 0, nz: -1, u: 'x', v: 'y', c: [[-1,-1,-1],[-1,1,-1],[1,1,-1],[1,-1,-1]] }, ]; const positions = []; const normals = []; const uvs = []; const indices = []; let vIdx = 0; // Greedy: вместо одного voxel = один quad, склеиваем соседние voxel'и // ОДНОГО ЦВЕТА с одинаковой видимой гранью. // // Pass per face direction. Для каждого voxel определяем видимость // грани, и если видна — проверяем, не «съели» ли мы её уже соседом. // Помечаем съеденные через consumed-Set. for (let f = 0; f < 6; f++) { const face = FACES[f]; const consumed = new Set(); // ключи voxel'ов чьи грани уже добавлены for (const v of voxels) { const vKey = v.ix + ',' + v.iy + ',' + v.iz; if (consumed.has(vKey)) continue; // Видна ли грань? const nKey = (v.ix + face.dx) + ',' + (v.iy + face.dy) + ',' + (v.iz + face.dz); if (occupiedSet.has(nKey)) continue; consumed.add(vKey); // Greedy: пробуем растянуть вдоль одной оси // (берём первую совпадающую — простой 1D greedy, не 2D). // Это даёт ×1.5-3 уменьшение, не максимум, но просто и надёжно. let lastVoxel = v; let dxAxis, dyAxis, dzAxis; // Идём вдоль оси u (что соответствует одной из x/y/z для face) if (face.u === 'x') { dxAxis = 1; dyAxis = 0; dzAxis = 0; } else if (face.u === 'y') { dxAxis = 0; dyAxis = 1; dzAxis = 0; } else { dxAxis = 0; dyAxis = 0; dzAxis = 1; } let extendCount = 0; while (true) { const nextIx = lastVoxel.ix + dxAxis; const nextIy = lastVoxel.iy + dyAxis; const nextIz = lastVoxel.iz + dzAxis; const nextKey = nextIx + ',' + nextIy + ',' + nextIz; if (!sameColorSet.has(nextKey)) break; if (consumed.has(nextKey)) break; // Также нужно чтобы грань этого voxel'а тоже была видна const nFaceNb = (nextIx + face.dx) + ',' + (nextIy + face.dy) + ',' + (nextIz + face.dz); if (occupiedSet.has(nFaceNb)) break; // Scale должен совпадать (иначе кубы разного размера) const nextV = voxelByKey.get(nextKey); if (!nextV || Math.abs(nextV.scale - v.scale) > 0.001) break; consumed.add(nextKey); lastVoxel = nextV; extendCount++; if (extendCount >= 32) break; // safety } // Строим квад от v до lastVoxel (растянутый по оси u) const half = DECO_VOXEL_SIZE * v.scale * 0.5; // Центр квада — середина между v и lastVoxel const cxQ = (v.cx + lastVoxel.cx) * 0.5; const cyQ = (v.cy + lastVoxel.cy) * 0.5; const czQ = (v.cz + lastVoxel.cz) * 0.5; // Размер квада: в направлении оси u = half × (extendCount + 1) const lenHalf = half * (extendCount + 1); // Подменяем размер только по оси u const halfX = face.u === 'x' || face.v === 'x' ? (face.u === 'x' ? lenHalf : half) : half; const halfY = face.u === 'y' || face.v === 'y' ? (face.u === 'y' ? lenHalf : half) : half; const halfZ = face.u === 'z' || face.v === 'z' ? (face.u === 'z' ? lenHalf : half) : half; const c0 = face.c[0], c1 = face.c[1], c2 = face.c[2], c3 = face.c[3]; positions.push( cxQ + c0[0] * halfX, cyQ + c0[1] * halfY, czQ + c0[2] * halfZ, cxQ + c1[0] * halfX, cyQ + c1[1] * halfY, czQ + c1[2] * halfZ, cxQ + c2[0] * halfX, cyQ + c2[1] * halfY, czQ + c2[2] * halfZ, cxQ + c3[0] * halfX, cyQ + c3[1] * halfY, czQ + c3[2] * halfZ, ); normals.push( face.nx, face.ny, face.nz, face.nx, face.ny, face.nz, face.nx, face.ny, face.nz, face.nx, face.ny, face.nz, ); uvs.push(0, 0, 0, 1, 1, 1, 1, 0); indices.push(vIdx, vIdx + 1, vIdx + 2, vIdx, vIdx + 2, vIdx + 3); vIdx += 4; } } if (vIdx === 0) return null; return { positions: new Float32Array(positions), normals: new Float32Array(normals), uvs: new Float32Array(uvs), indices: new Uint32Array(indices), }; } /** * Сериализация для БД. */ serialize() { return this.placements.map(p => ({ x: +p.x.toFixed(3), y: +p.y.toFixed(3), z: +p.z.toFixed(3), modelId: p.modelId, rotation: p.rotation || 0, ...(p.scale && p.scale !== 1.0 ? { scale: +p.scale.toFixed(2) } : {}), })); } /** * Освободить все meshes. */ dispose() { for (const m of this._meshes.values()) { try { m.dispose(); } catch (e) {} } this._meshes.clear(); for (const colorMap of this._chunkMeshes.values()) { for (const m of colorMap.values()) { try { m.dispose(); } catch (e) {} } } this._chunkMeshes.clear(); for (const mat of this._materials.values()) { try { mat.dispose(); } catch (e) {} } this._materials.clear(); this.placements = []; this._byChunk.clear(); } }