/** * SmoothDecoManager — менеджер декораций для гладкого ландшафта. * * Архитектура: * - Каждая модель (GLB из nature-kit) загружается ОДИН РАЗ * - Создаётся ОДИН Mesh-prototype с baked geometry+material * - Все инстансы — через thin-instances (1 draw call на модель) * * FPS-оптимизация: * - Hard cap инстансов 30K (был 50K) * - Step grid поднят (трава раз в 1.5м вместо 0.7м, цветы 2м вместо 1м) * - frustum culling Babylon включён через bounding info update */ import { SceneLoader, Mesh, Matrix, Quaternion, Vector3, } from '@babylonjs/core'; const ASSET_ROOT = '/kubikon-assets/models/nature-kit/'; /** * Каталог декораций — scale подобран чтобы итог был 1.5-3м. * Kenney модели сами по себе 0.13-0.29м, поэтому scale 7-13. */ // Размеры: // - Цветы ~0.5-0.8м (до пояса персонажа) // - Трава ~0.4-0.5м (до колен — как в Roblox) // - Грибы ~0.3-0.6м (точечные акценты) // Игрок ~2м — декорации заметно ниже. export const DECO_CATALOG = { flower_purpleA: { file: 'flower_purpleA.glb', kind: 'flower', scale: 2.5, biomes: ['plain', 'forest', 'oasis'] }, flower_purpleB: { file: 'flower_purpleB.glb', kind: 'flower', scale: 3.0, biomes: ['plain', 'forest', 'oasis'] }, flower_purpleC: { file: 'flower_purpleC.glb', kind: 'flower', scale: 3.5, biomes: ['plain', 'forest', 'oasis'] }, flower_redA: { file: 'flower_redA.glb', kind: 'flower', scale: 2.0, biomes: ['plain', 'forest', 'oasis'] }, flower_redB: { file: 'flower_redB.glb', kind: 'flower', scale: 2.5, biomes: ['plain', 'forest', 'oasis'] }, flower_redC: { file: 'flower_redC.glb', kind: 'flower', scale: 3.0, biomes: ['plain', 'forest', 'oasis'] }, flower_yellowA: { file: 'flower_yellowA.glb', kind: 'flower', scale: 3.5, biomes: ['plain', 'forest', 'oasis', 'desert'] }, flower_yellowB: { file: 'flower_yellowB.glb', kind: 'flower', scale: 4.0, biomes: ['plain', 'forest', 'oasis', 'desert'] }, flower_yellowC: { file: 'flower_yellowC.glb', kind: 'flower', scale: 4.5, biomes: ['plain', 'forest', 'oasis', 'desert'] }, grass: { file: 'grass.glb', kind: 'grass', scale: 2.0, biomes: ['plain', 'forest', 'oasis'] }, grass_large: { file: 'grass_large.glb', kind: 'grass', scale: 2.0, biomes: ['plain', 'forest', 'oasis'] }, grass_leafs: { file: 'grass_leafs.glb', kind: 'grass', scale: 3.5, biomes: ['plain', 'forest', 'oasis'] }, grass_leafsLarge:{ file: 'grass_leafsLarge.glb', kind: 'grass', scale: 3.5, biomes: ['plain', 'forest', 'oasis'] }, mushroom_red: { file: 'mushroom_red.glb', kind: 'mushroom', scale: 3.0, biomes: ['forest', 'oasis'] }, mushroom_redTall: { file: 'mushroom_redTall.glb', kind: 'mushroom', scale: 2.5, biomes: ['forest', 'oasis'] }, mushroom_redGroup: { file: 'mushroom_redGroup.glb', kind: 'mushroom', scale: 2.5, biomes: ['forest', 'oasis'] }, mushroom_tan: { file: 'mushroom_tan.glb', kind: 'mushroom', scale: 4.0, biomes: ['forest', 'oasis'] }, mushroom_tanTall: { file: 'mushroom_tanTall.glb', kind: 'mushroom', scale: 2.5, biomes: ['forest', 'oasis'] }, mushroom_tanGroup: { file: 'mushroom_tanGroup.glb', kind: 'mushroom', scale: 2.5, biomes: ['forest', 'oasis'] }, // === Деревья — высокие 4-8м объекты для леса/равнины/гор === // Лиственные: для plain/forest (зелёный лес) tree_default: { file: 'tree_default.glb', kind: 'tree', scale: 6.0, biomes: ['plain', 'forest', 'oasis'] }, tree_simple: { file: 'tree_simple.glb', kind: 'tree', scale: 6.0, biomes: ['plain', 'forest', 'oasis'] }, tree_oak: { file: 'tree_oak.glb', kind: 'tree', scale: 7.0, biomes: ['plain', 'forest'] }, tree_detailed: { file: 'tree_detailed.glb', kind: 'tree', scale: 6.5, biomes: ['forest'] }, tree_fat: { file: 'tree_fat.glb', kind: 'tree', scale: 6.0, biomes: ['forest'] }, tree_thin: { file: 'tree_thin.glb', kind: 'tree', scale: 7.0, biomes: ['plain', 'forest'] }, tree_tall: { file: 'tree_tall.glb', kind: 'tree', scale: 8.0, biomes: ['forest'] }, tree_small: { file: 'tree_small.glb', kind: 'tree', scale: 4.5, biomes: ['plain', 'forest', 'oasis'] }, // Осенние: микс в обычные леса для разнообразия tree_default_fall: { file: 'tree_default_fall.glb', kind: 'tree', scale: 6.0, biomes: ['plain', 'forest'] }, tree_oak_fall: { file: 'tree_oak_fall.glb', kind: 'tree', scale: 7.0, biomes: ['forest'] }, // Хвойные: для mountain/snow_peak (сосны/ели) tree_pineDefaultA: { file: 'tree_pineDefaultA.glb', kind: 'tree', scale: 6.5, biomes: ['forest', 'mountain'] }, tree_pineRoundA: { file: 'tree_pineRoundA.glb', kind: 'tree', scale: 6.0, biomes: ['mountain'] }, tree_pineRoundC: { file: 'tree_pineRoundC.glb', kind: 'tree', scale: 6.5, biomes: ['mountain'] }, tree_pineTallA: { file: 'tree_pineTallA.glb', kind: 'tree', scale: 8.0, biomes: ['mountain', 'snow_peak'] }, tree_pineSmallA: { file: 'tree_pineSmallA.glb', kind: 'tree', scale: 4.0, biomes: ['mountain', 'snow_peak'] }, // Пальмы: для desert/beach (пляж/пустыня с оазисами) tree_palm: { file: 'tree_palm.glb', kind: 'tree', scale: 7.0, biomes: ['beach', 'desert', 'oasis'] }, tree_palmShort: { file: 'tree_palmShort.glb', kind: 'tree', scale: 5.0, biomes: ['beach', 'desert'] }, tree_palmBend: { file: 'tree_palmBend.glb', kind: 'tree', scale: 7.0, biomes: ['beach', 'oasis'] }, // Кактусы для пустыни — вместо деревьев cactus_tall: { file: 'cactus_tall.glb', kind: 'tree', scale: 4.0, biomes: ['desert'] }, cactus_short: { file: 'cactus_short.glb', kind: 'tree', scale: 3.0, biomes: ['desert'] }, }; export const DECO_KEYS = Object.keys(DECO_CATALOG); export class SmoothDecoManager { constructor(scene) { this.scene = scene; /** Map */ this._protos = new Map(); /** Map — ПОЛНЫЕ буферы (для save/load и culling source) */ this._buffers = new Map(); /** Map — отфильтрованные буферы (видимые в radius) */ this._visibleBuffers = new Map(); this._loaded = false; /** Distance culling: радиус видимости декораций */ this.viewRadius = 100; this._cullObserver = null; this._lastCullCheck = 0; } async loadAll() { if (this._loaded) return; const tasks = []; for (const key of DECO_KEYS) { tasks.push(this._loadOne(key)); } await Promise.all(tasks); this._loaded = true; console.log(`[SmoothDecoManager] loaded ${this._protos.size}/${DECO_KEYS.length} models`); } async _loadOne(key) { const def = DECO_CATALOG[key]; if (!def) return; try { const container = await SceneLoader.LoadAssetContainerAsync( ASSET_ROOT, def.file, this.scene, ); container.addAllToScene(); // Берём ВСЕ меши контейнера (включая __root__ и его потомков) const allMeshes = container.meshes.slice(); const geoMeshes = allMeshes.filter((m) => m.getTotalVertices && m.getTotalVertices() > 0); if (geoMeshes.length === 0) { console.warn(`[SmoothDecoManager] ${key}: no geometry`); return; } // === Bake вверх по иерархии === // Kenney glTF имеет __root__ с rotation Y-up→Z-up (поворот -PI/2 по X // или просто scaling 100×). Если бэйкать только geometry-mesh с его // local transform, накопленный transform parent'а ПОТЕРЯЕТСЯ. // Поэтому форсируем computeWorldMatrix(true) на каждом — он // рекурсивно вычислит ИЗ ROOT'а — и потом bakeCurrentTransform. // // bakeCurrentTransformIntoVertices() применяет mesh.computeWorldMatrix() // — поэтому ВАЖНО что в этот момент transform-стек содержит root. for (const m of geoMeshes) { m.computeWorldMatrix(true); // force, накапливает parent chain m.bakeCurrentTransformIntoVertices(); // ВАЖНО: после bake вершины уже содержат world transform. // Отвязываем parent и сбрасываем local transform. m.parent = null; if (m.rotationQuaternion) m.rotationQuaternion = null; m.rotation.set(0, 0, 0); m.scaling.set(1, 1, 1); m.position.set(0, 0, 0); } // Уничтожаем оставшиеся вспомогательные node-ы (__root__ и т.п.) for (const m of allMeshes) { if (!geoMeshes.includes(m)) { try { m.dispose(); } catch (e) {} } } let proto; if (geoMeshes.length === 1) { proto = geoMeshes[0]; proto.name = `__smoothDeco_${key}`; } else { proto = Mesh.MergeMeshes(geoMeshes, true, true, undefined, false, true); if (!proto) return; proto.name = `__smoothDeco_${key}`; } // Центрируем XZ + поднимаем низ до Y=0 proto.refreshBoundingInfo(); const bb = proto.getBoundingInfo().boundingBox; const minY = bb.minimumWorld.y; const cx = (bb.minimumWorld.x + bb.maximumWorld.x) * 0.5; const cz = (bb.minimumWorld.z + bb.maximumWorld.z) * 0.5; const sizeY = bb.maximumWorld.y - bb.minimumWorld.y; if (Math.abs(minY) > 0.001 || Math.abs(cx) > 0.001 || Math.abs(cz) > 0.001) { proto.position.set(-cx, -minY, -cz); proto.bakeCurrentTransformIntoVertices(); proto.position.set(0, 0, 0); } console.log(`[SmoothDecoManager] ${key}: Y=${sizeY.toFixed(2)}м × scale=${def.scale} = ${(sizeY * def.scale).toFixed(2)}м`); // === Фикс чёрных горизонтальных граней === // Диагностика (BEFORE-лог): Kenney glTF — PBRMaterial с // metallic=1, roughness=1, albedo=(0.80,0.46,0.37), envInt=1. // То есть цвет деревьев на самом деле даётся ОТРАЖЕНИЕМ // окружения (металлический материал), а не albedo напрямую. // // ПРЕДЫДУЩАЯ ОШИБКА: я ставил metallic=0/roughness=1 → // материал из "металл-зеркало" превращался в матовый // диффузный, ловил весь свет сцены (hemi 0.65 + sun 0.8) и // ПЕРЕСВЕЧИВАЛСЯ. Цвета вымывались. // // ПРАВИЛЬНО: metallic/roughness/intensity НЕ трогаем — материал // остаётся таким, как задумано в glTF. Только: // - backFaceCulling=false + twoSidedLighting — двусторонний // рендер (страховка winding'а) // - слабый emissiveColor (FILL от albedo) — «заливка» для // нижних граней, чтобы не были чёрными. На металлическом // материале emissive добавляется поверх отражений, поэтому // FILL держим маленьким. const EMISSIVE_FILL = 0.06; const fixMat = (mat) => { if (!mat) return; mat.backFaceCulling = false; mat.twoSidedLighting = true; // emissive по albedo (или diffuse) — слабая заливка. const base = mat.albedoColor || mat.diffuseColor; if (base) { mat.emissiveColor = base.scale ? base.scale(EMISSIVE_FILL) : { r: base.r * EMISSIVE_FILL, g: base.g * EMISSIVE_FILL, b: base.b * EMISSIVE_FILL }; } // metallic / roughness / emissiveIntensity / environmentIntensity // НЕ меняем — оставляем как в исходном glTF. }; if (proto.material) { // MultiMaterial → чиним все sub-материалы. if (proto.material.subMaterials) { for (const sub of proto.material.subMaterials) fixMat(sub); } else { fixMat(proto.material); } } proto.isPickable = false; // Prototype = живой меш для thin-instances. Babylon не cull-ит его // через frustum (alwaysSelect=true), все инстансы рендерятся // одним draw call. proto.alwaysSelectAsActiveMesh = true; // Сохраняем kind для distance culling (деревья видны издалека). proto._decoKind = def.kind; // Сохраняем реальную высоту модели (для AABB-коллайдеров деревьев). proto._decoSourceHeight = sizeY; this._protos.set(key, proto); } catch (e) { console.error(`[SmoothDecoManager] load ${key} failed:`, e); } } placeDecorations(opts) { if (!this._loaded) { console.warn('[SmoothDecoManager] not loaded yet'); return { total: 0 }; } const { sampleSurfaceY, sampleBiomeId, bbox, densityFlowers = 0.015, densityGrass = 0.08, densityTrees = 0.05, seed = 1337, } = opts; this.clear(); const hash = (x, z, salt) => { let h = (x * 374761393 + z * 668265263 + (seed + salt) * 1442695040) | 0; h = Math.imul(h ^ (h >>> 13), 1274126177); return ((h ^ (h >>> 16)) >>> 0) / 4294967296; }; // Плотный grid. Один thin-instance buffer per модель. // Каждый thin-instance = 1 draw call независимо от количества инстансов. const STEP_FLOWERS = 1.5; const STEP_GRASS = 1.0; const STEP_MUSHROOM = 3.0; // Деревья — большие объекты, ставим РЕЖЕ чтобы не лес сплошной стеной. // step=8м для леса = 1 дерево на ~64м² при density=1.0. const STEP_TREE = 8.0; // === Per-kind caps === // Один общий cap (150K) исчерпывался травой ДО деревьев на 600м карте, // и деревья просто не размещались. Каждый тип теперь имеет свой лимит. const CAP_FLOWERS = 60000; const CAP_GRASS = 80000; const CAP_MUSHROOM = 10000; const CAP_TREE = 15000; // на 600м карте полно: 360000/64 × 0.5 = ~2800 // tempBuffers: Map const tempBuffers = new Map(); let total = 0; // Счётчики per kind для соблюдения отдельных cap'ов. const kindCounts = { flower: 0, grass: 0, mushroom: 0, tree: 0 }; const kindCaps = { flower: CAP_FLOWERS, grass: CAP_GRASS, mushroom: CAP_MUSHROOM, tree: CAP_TREE }; // Список tree-AABB для физики (узкий цилиндр-ствол). // Передаётся в physics.setSmoothDecoTrees() — игрок не проходит сквозь. const treeColliders = []; const placeAt = (decoKey, kind, x, y, z, ry, scale) => { let arr = tempBuffers.get(decoKey); if (!arr) { arr = []; tempBuffers.set(decoKey, arr); } const m = Matrix.Compose( new Vector3(scale, scale, scale), Quaternion.RotationAxis(Vector3.Up(), ry), new Vector3(x, y, z), ); arr.push(m); total++; kindCounts[kind]++; // Для деревьев — добавляем коллайдер: AABB до верха дерева, // ширина ствола около 1.5м (крона + ствол вместе). if (kind === 'tree') { const proto = this._protos.get(decoKey); if (proto) { // Реальная высота меша = sourceHeight × scale. // Для обычного дерева ~2-3м, для tree_tall до 4м. const sourceH = proto._decoSourceHeight || 0.3; const treeHeight = sourceH * scale; // Радиус AABB: для кактусов узкий (0.5м), для остальных // достаточно широкий чтобы покрыть крону (1.2-1.5м). const isCactus = decoKey.startsWith('cactus_'); const trunkHalfW = isCactus ? 0.5 : 1.3; treeColliders.push({ x, z, baseY: y, // низ ствола = поверхность halfW: trunkHalfW, halfH: treeHeight * 0.5, // ПОЛОВИНА реальной высоты halfD: trunkHalfW, }); } } }; const tryPlace = (kind, x, z, salt) => { if (kindCounts[kind] >= kindCaps[kind]) return; const surfY = sampleSurfaceY(x, z); if (surfY === null) return; const biomeId = sampleBiomeId(x, z); if (!biomeId) return; const candidates = DECO_KEYS.filter((k) => { const def = DECO_CATALOG[k]; return def.kind === kind && def.biomes.includes(biomeId); }); if (candidates.length === 0) return; const rChoose = hash(Math.floor(x * 17), Math.floor(z * 31), salt); const idx = Math.floor(rChoose * candidates.length); const decoKey = candidates[idx]; const def = DECO_CATALOG[decoKey]; const rRot = hash(Math.floor(x * 11), Math.floor(z * 13), salt + 100); const rScale = hash(Math.floor(x * 23), Math.floor(z * 19), salt + 200); const ry = rRot * Math.PI * 2; const scale = def.scale * (0.8 + rScale * 0.4); placeAt(decoKey, kind, x, surfY, z, ry, scale); }; // Порядок размещения: tree → flower → mushroom → grass. // Деревья ПЕРВЫМИ — они самые важные визуально, нельзя чтобы их // вытеснил cap травы на больших картах. if (densityTrees > 0) { for (let z = bbox.minZ; z < bbox.maxZ; z += STEP_TREE) { for (let x = bbox.minX; x < bbox.maxX; x += STEP_TREE) { if (hash(Math.floor(x * 13), Math.floor(z * 17), 8) < densityTrees) { const dx = (hash(Math.floor(x * 19), Math.floor(z), 9) - 0.5) * STEP_TREE; const dz = (hash(Math.floor(x), Math.floor(z * 19), 10) - 0.5) * STEP_TREE; tryPlace('tree', x + dx, z + dz, 4000); } } } } if (densityFlowers > 0) { for (let z = bbox.minZ; z < bbox.maxZ; z += STEP_FLOWERS) { for (let x = bbox.minX; x < bbox.maxX; x += STEP_FLOWERS) { if (hash(Math.floor(x * 3), Math.floor(z * 5), 1) < densityFlowers) { const dx = (hash(Math.floor(x * 7), Math.floor(z), 2) - 0.5) * STEP_FLOWERS; const dz = (hash(Math.floor(x), Math.floor(z * 7), 3) - 0.5) * STEP_FLOWERS; tryPlace('flower', x + dx, z + dz, 1000); } } } } const mushroomDensity = densityFlowers * 0.3; if (mushroomDensity > 0) { for (let z = bbox.minZ; z < bbox.maxZ; z += STEP_MUSHROOM) { for (let x = bbox.minX; x < bbox.maxX; x += STEP_MUSHROOM) { if (hash(Math.floor(x * 11), Math.floor(z * 13), 7) < mushroomDensity) { tryPlace('mushroom', x, z, 3000); } } } } if (densityGrass > 0) { for (let z = bbox.minZ; z < bbox.maxZ; z += STEP_GRASS) { for (let x = bbox.minX; x < bbox.maxX; x += STEP_GRASS) { if (hash(Math.floor(x * 5), Math.floor(z * 3), 4) < densityGrass) { const dx = (hash(Math.floor(x * 9), Math.floor(z), 5) - 0.5) * STEP_GRASS; const dz = (hash(Math.floor(x), Math.floor(z * 9), 6) - 0.5) * STEP_GRASS; tryPlace('grass', x + dx, z + dz, 2000); } } } } // Сохраняем полные буферы (для культинга и save/load) let kindsUsed = 0; for (const [decoKey, matrices] of tempBuffers) { const proto = this._protos.get(decoKey); if (!proto || matrices.length === 0) continue; const buffer = new Float32Array(matrices.length * 16); for (let i = 0; i < matrices.length; i++) { matrices[i].copyToArray(buffer, i * 16); } this._buffers.set(decoKey, buffer); kindsUsed++; } // Первый pass culling сразу — иначе на 1 кадр будет всё или ничего. this._applyCulling(); // Render observer обновляет видимые инстансы каждые 300мс по камере. this._enableCullObserver(); console.log(`[SmoothDecoManager] placed ${total} decorations across ${kindsUsed} models (trees=${treeColliders.length}, view radius ${this.viewRadius}м)`); return { total, treeColliders }; } /** * Добавить N инстансов декорации указанного kind в сфере вокруг center. * Используется кистями для интерактивной расстановки. * * @param {object} opts * @param {'flower'|'grass'|'mushroom'|'tree'} opts.kind * @param {{x,y,z}} opts.center — мировые координаты центра кисти * @param {number} opts.radius — радиус сферы (м) * @param {number} opts.count — сколько инстансов добавить * @param {function} opts.sampleSurfaceY — (x,z) → Y поверхности * @returns {number} реально добавлено инстансов */ addBrushDeco(opts) { if (!this._loaded) { console.warn('[SmoothDecoManager] addBrushDeco: not loaded'); return { added: 0, newTreeColliders: [] }; } const { kind, center, radius, count = 5, sampleSurfaceY } = opts; const candidates = DECO_KEYS.filter((k) => DECO_CATALOG[k].kind === kind); if (candidates.length === 0) return { added: 0, newTreeColliders: [] }; let added = 0; // Если посадили деревья — собираем колайдеры для physics. const newTreeColliders = []; for (let i = 0; i < count; i++) { const angle = Math.random() * Math.PI * 2; const r = Math.sqrt(Math.random()) * radius; const x = center.x + Math.cos(angle) * r; const z = center.z + Math.sin(angle) * r; const surfY = sampleSurfaceY ? sampleSurfaceY(x, z) : center.y; if (surfY === null || surfY === undefined) continue; const entry = candidates[Math.floor(Math.random() * candidates.length)]; const def = DECO_CATALOG[entry]; const ry = Math.random() * Math.PI * 2; const scale = def.scale * (0.8 + Math.random() * 0.4); const m = Matrix.Compose( new Vector3(scale, scale, scale), Quaternion.RotationAxis(Vector3.Up(), ry), new Vector3(x, surfY, z), ); this._appendInstanceToBuffer(entry, m); added++; // Tree collider if (kind === 'tree') { const proto = this._protos.get(entry); if (proto) { const sourceH = proto._decoSourceHeight || 0.3; const treeHeight = sourceH * scale; const isCactus = entry.startsWith('cactus_'); const trunkHalfW = isCactus ? 0.5 : 1.3; newTreeColliders.push({ x, z, baseY: surfY, halfW: trunkHalfW, halfH: treeHeight * 0.5, halfD: trunkHalfW, }); } } } if (added > 0) { this._applyCulling(); } return { added, newTreeColliders }; } /** * Удалить все инстансы (любого kind) в сфере вокруг center. * @returns {number} удалено */ removeBrushDecoInRadius(center, radius) { if (!this._loaded) return 0; const r2 = radius * radius; let removed = 0; for (const [decoKey, fullBuf] of this._buffers) { const count = fullBuf.length / 16; // Собираем индексы НЕ-удаляемых const keep = []; for (let i = 0; i < count; i++) { const dx = fullBuf[i * 16 + 12] - center.x; const dz = fullBuf[i * 16 + 14] - center.z; if (dx * dx + dz * dz > r2) { keep.push(i); } } if (keep.length === count) continue; // ничего не удалили в этом буфере removed += count - keep.length; // Перекомпилируем буфер только из keep'нутых const newBuf = new Float32Array(keep.length * 16); for (let j = 0; j < keep.length; j++) { newBuf.set(fullBuf.subarray(keep[j] * 16, (keep[j] + 1) * 16), j * 16); } this._buffers.set(decoKey, newBuf); } if (removed > 0) this._applyCulling(); return removed; } /** * Удалить все инстансы (любого kind) внутри прямоугольной зоны XZ. * Используется билд-скриптами игр чтобы расчистить площадки под * замок/лагерь/декор от процедурных деревьев и кустов. * * @param {number} minX * @param {number} minZ * @param {number} maxX * @param {number} maxZ * @returns {number} удалено инстансов */ removeDecoInBox(minX, minZ, maxX, maxZ) { if (!this._loaded) return 0; let removed = 0; for (const [decoKey, fullBuf] of this._buffers) { const count = fullBuf.length / 16; const keep = []; for (let i = 0; i < count; i++) { const x = fullBuf[i * 16 + 12]; const z = fullBuf[i * 16 + 14]; if (x < minX || x > maxX || z < minZ || z > maxZ) { keep.push(i); } } if (keep.length === count) continue; removed += count - keep.length; const newBuf = new Float32Array(keep.length * 16); for (let j = 0; j < keep.length; j++) { newBuf.set(fullBuf.subarray(keep[j] * 16, (keep[j] + 1) * 16), j * 16); } this._buffers.set(decoKey, newBuf); } if (removed > 0) this._applyCulling(); return removed; } /** * Получить ПОЛНЫЙ список tree-AABB-колайдеров на основе всех текущих * tree-инстансов. Используется после plant/erase чтобы физика * пересинхронизировала smooth-deco-trees. */ getAllTreeColliders() { const out = []; for (const [decoKey, buf] of this._buffers) { const def = DECO_CATALOG[decoKey]; if (!def || def.kind !== 'tree') continue; const proto = this._protos.get(decoKey); if (!proto) continue; const sourceH = proto._decoSourceHeight || 0.3; const isCactus = decoKey.startsWith('cactus_'); const trunkHalfW = isCactus ? 0.5 : 1.3; const count = buf.length / 16; for (let i = 0; i < count; i++) { const off = i * 16; // uniform scale из ||row 0|| const sx = Math.sqrt(buf[off]*buf[off] + buf[off+1]*buf[off+1] + buf[off+2]*buf[off+2]); const treeHeight = sourceH * sx; out.push({ x: buf[off + 12], z: buf[off + 14], baseY: buf[off + 13], halfW: trunkHalfW, halfH: treeHeight * 0.5, halfD: trunkHalfW, }); } } return out; } /** * Включить/выключить пикинг thin-instances у всех прототипов. * Нужно для поштучного клик-выбора декораций в редакторе. * По умолчанию декорации isPickable=false (чтобы не мешали кистям). */ setPickingEnabled(enabled) { for (const proto of this._protos.values()) { proto.isPickable = !!enabled; try { proto.thinInstanceEnablePicking = !!enabled; } catch (e) { /* старый Babylon — игнор */ } } } /** * Найти декорацию под точкой клика по результату raycast. * Babylon при thinInstanceEnablePicking возвращает pickedMesh=прототип * и thinInstanceIndex — но это индекс в ВИДИМОМ буфере (после culling). * Берём позицию из видимого буфера и ищем ближайший инстанс в полном. * * @param {Mesh} pickedMesh — pick.pickedMesh из scene.pickWithRay * @param {number} thinIndex — pick.thinInstanceIndex * @returns {{decoKey, fullIndex, x, y, z}|null} */ findInstanceByPick(pickedMesh, thinIndex) { if (!pickedMesh || thinIndex == null || thinIndex < 0) return null; // Найти decoKey по прототипу let decoKey = null; for (const [k, proto] of this._protos) { if (proto === pickedMesh) { decoKey = k; break; } } if (!decoKey) return null; const visBuf = this._visibleBuffers.get(decoKey); if (!visBuf || thinIndex * 16 + 14 >= visBuf.length) return null; const vx = visBuf[thinIndex * 16 + 12]; const vy = visBuf[thinIndex * 16 + 13]; const vz = visBuf[thinIndex * 16 + 14]; // Ищем этот же инстанс в полном буфере (по точному совпадению XЗ) const fullBuf = this._buffers.get(decoKey); if (!fullBuf) return null; const count = fullBuf.length / 16; let bestIdx = -1, bestD2 = 1e9; for (let i = 0; i < count; i++) { const dx = fullBuf[i * 16 + 12] - vx; const dz = fullBuf[i * 16 + 14] - vz; const d2 = dx * dx + dz * dz; if (d2 < bestD2) { bestD2 = d2; bestIdx = i; } } if (bestIdx < 0 || bestD2 > 0.01) return null; return { decoKey, fullIndex: bestIdx, x: vx, y: vy, z: vz }; } /** Удалить один инстанс декорации по decoKey + индексу в полном буфере. */ removeInstanceAt(decoKey, fullIndex) { const fullBuf = this._buffers.get(decoKey); if (!fullBuf) return false; const count = fullBuf.length / 16; if (fullIndex < 0 || fullIndex >= count) return false; const newBuf = new Float32Array((count - 1) * 16); let w = 0; for (let i = 0; i < count; i++) { if (i === fullIndex) continue; newBuf.set(fullBuf.subarray(i * 16, (i + 1) * 16), w * 16); w++; } this._buffers.set(decoKey, newBuf); this._applyCulling(); return true; } /** Внутренний: расширить буфер decoKey'я одной новой матрицей. */ _appendInstanceToBuffer(decoKey, matrix) { const old = this._buffers.get(decoKey); const oldLen = old ? old.length : 0; const newBuf = new Float32Array(oldLen + 16); if (old) newBuf.set(old, 0); matrix.copyToArray(newBuf, oldLen); this._buffers.set(decoKey, newBuf); } /** * Distance culling: для каждой модели создаём подбуфер только из инстансов * в radius от камеры, и применяем его через thinInstanceSetBuffer. * GPU рисует только эти инстансы — экономия 80-90% на больших картах. */ _applyCulling() { const cam = this.scene.activeCamera; if (!cam) return; const camX = cam.position.x; const camZ = cam.position.z; // Per-kind view radius. Деревья БОЛЬШИЕ (5-8м), видны издалека. // Трава/цветы — мелкие, дальше 100м их не видно. const RADIUS_TREE = 400; const RADIUS_OTHER = this.viewRadius; const r2Tree = RADIUS_TREE * RADIUS_TREE; const r2Other = RADIUS_OTHER * RADIUS_OTHER; let totalVisible = 0; for (const [decoKey, fullBuf] of this._buffers) { const proto = this._protos.get(decoKey); if (!proto) continue; const isTree = proto._decoKind === 'tree'; const r2 = isTree ? r2Tree : r2Other; const count = fullBuf.length / 16; // Подсчитываем видимые let visibleCount = 0; for (let i = 0; i < count; i++) { const dx = fullBuf[i * 16 + 12] - camX; const dz = fullBuf[i * 16 + 14] - camZ; if (dx * dx + dz * dz <= r2) visibleCount++; } // Создаём подбуфер. Если все видны — используем fullBuf без копии. let outBuf; if (visibleCount === count) { outBuf = fullBuf; } else if (visibleCount === 0) { outBuf = new Float32Array(0); } else { outBuf = new Float32Array(visibleCount * 16); let w = 0; for (let i = 0; i < count; i++) { const dx = fullBuf[i * 16 + 12] - camX; const dz = fullBuf[i * 16 + 14] - camZ; if (dx * dx + dz * dz <= r2) { outBuf.set(fullBuf.subarray(i * 16, (i + 1) * 16), w * 16); w++; } } } proto.thinInstanceSetBuffer('matrix', outBuf, 16, true); this._visibleBuffers.set(decoKey, outBuf); totalVisible += visibleCount; } return totalVisible; } _enableCullObserver() { if (this._cullObserver) return; this._cullObserver = this.scene.onBeforeRenderObservable.add(() => { const now = performance.now(); // Раз в 300мс — достаточно для плавного движения камеры. if (now - this._lastCullCheck < 300) return; this._lastCullCheck = now; this._applyCulling(); }); } _disableCullObserver() { if (this._cullObserver) { this.scene.onBeforeRenderObservable.remove(this._cullObserver); this._cullObserver = null; } } /** * Сериализация ВСЕХ инстансов для save в БД. * Возвращает { format: 'smoothdeco-v1', items: [{k: decoKey, m: [16 floats]}, ...] } * * Каждый инстанс занимает 16 float = 64 байта + overhead JSON. * При 5000 декораций ~ 350KB. При 50K — 3MB. Не использовать для огромных карт. * * Для процедурно-генерированных декораций лучше использовать decoParams + * seed (см. _smoothDecoParams в BabylonScene) — это всего 100 байт. */ serialize() { const items = []; for (const [decoKey, buf] of this._buffers) { if (!buf || buf.length === 0) continue; // Кодируем матрицы только translation + rotation Y + scale, // чтобы JSON был компактнее (8 чисел вместо 16). const count = buf.length / 16; for (let i = 0; i < count; i++) { const off = i * 16; // m[0..3]: row 0 (scale, rot) // m[12,13,14]: translation // Извлекаем uniform scale из ||m[0..2]|| (если scale-X) const sx = Math.sqrt(buf[off] * buf[off] + buf[off + 1] * buf[off + 1] + buf[off + 2] * buf[off + 2]); const rotY = Math.atan2(buf[off + 2] / (sx || 1), buf[off] / (sx || 1)); items.push({ k: decoKey, x: +buf[off + 12].toFixed(2), y: +buf[off + 13].toFixed(2), z: +buf[off + 14].toFixed(2), r: +rotY.toFixed(3), s: +sx.toFixed(2), }); } } return { format: 'smoothdeco-v1', items }; } /** * Загрузка декораций из serialize()-результата. * Восстанавливает все инстансы один-в-один с момента сохранения. * Загружает prototype'ы автоматически если не загружены. */ async loadFromState(state) { if (!state || state.format !== 'smoothdeco-v1') { console.warn('[SmoothDecoManager] loadFromState: bad format', state?.format); return 0; } if (!this._loaded) { await this.loadAll(); } this.clear(); // Группируем items по decoKey, чтобы построить буфер за один проход. const groups = new Map(); for (const it of (state.items || [])) { let arr = groups.get(it.k); if (!arr) { arr = []; groups.set(it.k, arr); } arr.push(it); } let total = 0; // Параллельно собираем tree-collider'ы для физики (та же логика что // в placeDecorations.placeAt при kind==='tree'). const treeColliders = []; for (const [decoKey, arr] of groups) { const proto = this._protos.get(decoKey); if (!proto) continue; const def = DECO_CATALOG[decoKey]; const buf = new Float32Array(arr.length * 16); for (let i = 0; i < arr.length; i++) { const it = arr[i]; const m = Matrix.Compose( new Vector3(it.s, it.s, it.s), Quaternion.RotationAxis(Vector3.Up(), it.r || 0), new Vector3(it.x, it.y, it.z), ); m.copyToArray(buf, i * 16); if (def && def.kind === 'tree') { const sourceH = proto._decoSourceHeight || 0.3; const treeHeight = sourceH * it.s; const isCactus = decoKey.startsWith('cactus_'); const trunkHalfW = isCactus ? 0.5 : 1.3; treeColliders.push({ x: it.x, z: it.z, baseY: it.y, halfW: trunkHalfW, halfH: treeHeight * 0.5, halfD: trunkHalfW, }); } } this._buffers.set(decoKey, buf); total += arr.length; } this._applyCulling(); this._enableCullObserver(); console.log(`[SmoothDecoManager] loadFromState: ${total} instances restored (${treeColliders.length} tree colliders)`); return { total, treeColliders }; } /** Очистить все инстансы (prototype-меши остаются). */ clear() { for (const proto of this._protos.values()) { try { proto.thinInstanceSetBuffer('matrix', new Float32Array(0), 16, true); } catch (e) {} } this._buffers.clear(); this._visibleBuffers.clear(); this._disableCullObserver(); } /** Полное удаление prototype-меш-ей. */ dispose() { this._disableCullObserver(); for (const proto of this._protos.values()) { try { proto.dispose(); } catch (e) {} } this._protos.clear(); this._buffers.clear(); this._visibleBuffers.clear(); this._loaded = false; } getStats() { let total = 0; for (const buf of this._buffers.values()) { total += buf.length / 16; } return { total, kinds: this._buffers.size }; } }