/** * TerrainManager — voxel-ландшафт для KubikonEditor. * * Архитектура: отдельный слой voxel-кубов 1×1×1, рисующийся через * Babylon thin-instances (один proto-mesh на каждый материал). По * структуре повторяет упрощённый BlockManager, но: * * - Никаких мульти-текстур и жидкостей — только однотонные материалы. * - 12 материалов из палитры (трава/камень/песок/снег/...), без id-блоков. * - Каждый voxel — `{x, y, z, mat}`, mat — id материала ('grass'/'rock'/...). * - Кисти работают по сферическому объёму вокруг точки клика. * - Сериализация: компактный массив `{x, y, z, m}` в `project_data.scene.terrain`. * * Public API: * * setVoxel(x, y, z, matId) * removeVoxel(x, y, z) * getVoxel(x, y, z) → matId | null * hasVoxel(x, y, z) → bool * * // Кисти. brush — { x, y, z, radius, shape: 'sphere'|'cube'|'cylinder' } * brushDraw(brush, matId) → добавляет voxel'ы материала * brushSculpt(brush, dir) → dir=+1 поднимает, -1 опускает столбцы * brushSmooth(brush) → сглаживает поверхность * brushFlatten(brush, targetY) → выравнивает по плоскости Y * brushPaint(brush, matId) → меняет материал у существующих * brushErase(brush) → удаляет voxel'ы * * serialize() / loadFromArray() * count() / clear() * * Производительность: thin-instances Babylon тянут 50К+ voxel'ов на 60 FPS. * При перестройке используется тот же приём что в BlockManager: индекс * слота переиспользуется при удалении (Map _freeSlots), без полной перестройки * массива matrices. */ import { MeshBuilder, StandardMaterial, MultiMaterial, SubMesh, Texture, Color3, Matrix, Vector3, BoundingInfo, Mesh, VertexData, } from '@babylonjs/core'; /** * Какой sub-material применить к какой грани куба Babylon.MeshBuilder.CreateBox. * Совпадает с FACE_INDEX_MAP в BlockManager.js. Порядок Babylon BoxBuilder: * 0: +Z front · 1: -Z back · 2: +X right · 3: -X left · 4: +Y top · 5: -Y bottom */ const FACE_INDEX = { front: 0, back: 1, right: 2, left: 3, top: 4, bottom: 5 }; // ============================================================================ // Палитра материалов террейна. Текстуры — Kenney Voxel Pack (CC0), // те же что используются в BlockTypes.js. За счёт этого террейн стилистически // сливается с обычными блоками сцены. // // У каждого материала: // name — отображаемое имя // color — fallback-цвет на случай если текстура не загрузилась + // цвет в превью-плашке TerrainPanel // texture — путь к одной текстуре на 6 граней (простой куб) // top/side/bottom — три текстуры (верх/бока/низ) для материалов вроде травы, // где верх отличается. Если задано — texture игнорируется. // emissive — RGB-эмиссия [0..1, 0..1, 0..1], опционально (для светящихся // материалов вроде лавы; пока не используется в палитре) // ============================================================================ const TEX = '/kubikon-assets/textures'; export const TERRAIN_MATERIALS = { grass: { name: 'Трава', color: '#52b15a', // Только ВЕРХ зелёный — бока и низ чистая земля, без зелёной // каёмки. Иначе на ступенчатом рельефе voxel-террейна // (множество уступов 0.5м) видны «полосы травы» на стене — // визуальный шум, ландшафт выглядит грязно. top: `${TEX}/grass_top.png`, side: `${TEX}/dirt.png`, bottom: `${TEX}/dirt.png`, }, rock: { name: 'Камень', color: '#7e7e7e', // stone_coal вместо greystone: greystone почти одноцветная → на // дистанции 30м+ паттерн не виден. stone_coal с осколками виден. texture: `${TEX}/stone_coal.png`, }, sand: { name: 'Песок', color: '#e6d27a', texture: `${TEX}/sand.png`, }, snow: { name: 'Снег', color: '#f5f7fb', texture: `${TEX}/snow.png`, }, dirt: { name: 'Земля', color: '#7c5430', texture: `${TEX}/dirt.png`, }, water: { name: 'Вода', color: '#3a8fd6', texture: `${TEX}/water.png`, alpha: 0.72, }, asphalt: { name: 'Асфальт', color: '#3b3b3b', texture: `${TEX}/stone.png`, }, concrete: { name: 'Бетон', color: '#b8b8b8', texture: `${TEX}/greystone.png`, }, wood: { name: 'Дерево', color: '#a06a3a', texture: `${TEX}/wood.png`, }, glacier: { name: 'Ледник', color: '#c8e6f5', texture: `${TEX}/ice.png`, alpha: 0.92, }, salt: { name: 'Соль', color: '#ecedef', // Соль ближе всего к снегу + лёгкий emissive для «искристости» texture: `${TEX}/snow.png`, }, mud: { name: 'Грязь', color: '#553a25', texture: `${TEX}/gravel_dirt.png`, }, // === Новые материалы для Voxlands-стиля (декорации мира) === leaves: { name: 'Листва', color: '#3f7a3a', texture: `${TEX}/leaves.png`, }, leaves_orange: { name: 'Листва осенняя', color: '#c2741e', texture: `${TEX}/leaves_orange.png`, }, trunk: { name: 'Ствол дерева', color: '#5a3b1f', // У ствола разные текстуры: top/bottom — спил, side — кора. top: `${TEX}/trunk_top.png`, side: `${TEX}/trunk_side.png`, bottom: `${TEX}/trunk_bottom.png`, }, trunk_white: { name: 'Ствол берёзы', color: '#e0dfd6', top: `${TEX}/trunk_top.png`, side: `${TEX}/trunk_white_side.png`, bottom: `${TEX}/trunk_top.png`, }, rock_moss: { name: 'Камень со мхом', color: '#5d6f43', texture: `${TEX}/rock_moss.png`, }, flower_red: { name: 'Красный цветок', color: '#b84141', texture: `${TEX}/cotton_red.png`, }, flower_blue: { name: 'Синий цветок', color: '#4673b8', texture: `${TEX}/cotton_blue.png`, }, flower_yellow: { name: 'Жёлтый цветок', color: '#d4c84a', texture: `${TEX}/cotton_tan.png`, }, mushroom_red: { name: 'Красный гриб', color: '#a02525', texture: `${TEX}/mushroom_red.png`, }, tall_grass: { name: 'Высокая трава', color: '#5fa84e', texture: `${TEX}/wheat_stage4.png`, }, }; const TERRAIN_MATERIAL_IDS = Object.keys(TERRAIN_MATERIALS); /** * Размер одной voxel-ячейки в метрах. 0.25 даёт 4 voxel'а на метр — * в 64 раза больше плотности чем у обычных блоков (1×1×1). * Холмы выглядят почти гладкими, но память/FPS ×64 относительно блоков. * * Прежние значения: * 1.0 — как Minecraft, грубо * 0.5 — детальнее (бывший дефолт) * 0.25 — тестовый режим, очень детально, ~64× памяти на ту же площадь * * Координаты voxel'ов в Map хранятся как ЦЕЛЫЕ индексы grid (x,y,z), * а в мире преобразуются как world = (gridX + 0.5) * VOXEL_SIZE. * Это позволяет легко уменьшать/увеличивать размер без переписки логики. */ export const VOXEL_SIZE = 0.25; export class TerrainManager { constructor(scene) { this.scene = scene; /** Map<"x,y,z", matId> — единственный источник правды. */ this.voxels = new Map(); /** Proto-mesh по материалу: matId → Mesh с thin-instances. */ this._protoMeshes = new Map(); /** Кешированные StandardMaterial по matId. */ this._materials = new Map(); /** matId → Array — индекс слота instance'а → ключ voxel. */ this._instanceKeys = new Map(); /** matId → Array — свободные слоты (после удаления). */ this._freeSlots = new Map(); /** "x,y,z" → { mat, idx } — обратный индекс для быстрого remove. */ this._cellToInst = new Map(); /** Колбэк изменения (для авто-сохранения). */ this._onChange = null; /** Колбэк создания proto-меша (для shadow caster registration). */ this._onProtoCreated = null; } setOnChange(cb) { this._onChange = cb; } setOnProtoCreated(cb) { this._onProtoCreated = cb; } _emit() { try { this._onChange?.(); } catch (e) {} } count() { return this.voxels.size; } // ======================================================================== // CRUD voxel'ов // ======================================================================== /** Существует ли voxel в (x,y,z). */ hasVoxel(x, y, z) { return this.voxels.has(`${x},${y},${z}`); } /** matId или null. */ getVoxel(x, y, z) { return this.voxels.get(`${x},${y},${z}`) || null; } /** * Поставить voxel. Если уже стоит — переставляет с новым материалом * (удаляет старый instance, добавляет новый). Координаты целые. */ setVoxel(x, y, z, matId) { if (!TERRAIN_MATERIALS[matId]) { // eslint-disable-next-line no-console console.warn(`[TerrainManager] unknown matId: ${matId}`); return; } this._ensureCellToInstHydrated(); const key = `${x},${y},${z}`; const existing = this._cellToInst.get(key); if (existing) { if (existing.mat === matId) return; // уже тот же материал this._removeInstance(key, existing); } this._addInstance(key, x, y, z, matId); this.voxels.set(key, matId); this._emit(); } /** Удалить voxel. Если нет — no-op. */ removeVoxel(x, y, z) { this._ensureCellToInstHydrated(); const key = `${x},${y},${z}`; const existing = this._cellToInst.get(key); if (!existing) return; this._removeInstance(key, existing); this.voxels.delete(key); this._emit(); } /** * Lazy-hydration _cellToInst после массовой загрузки большой карты. * * При loadFromArray на картах >100K вокселей на материал мы пропустили * заполнение _cellToInst (это экономит ~5-10 сек CPU + ~3 ГБ памяти). * При первом edit-операции — досоздаём Map по _instanceKeys. * * Стоимость одного prebuild: ~3-5 сек для 5.7M вокселей. Но это * происходит ОДИН РАЗ при первом клике кистью, а не при загрузке. */ _ensureCellToInstHydrated() { // Перед редактированием материализуем все pending регионы. // Иначе кисть не увидит дальние воксели и не сможет их изменить. if (this._pendingRegions && this._pendingRegions.size > 0) { console.log(`[TerrainManager] materializing ${this._pendingRegions.size} pending regions before edit`); const keys = [...this._pendingRegions.keys()]; for (const k of keys) this._materializeRegion(k); this._occupancySet = null; } if (!this._lazyCellToInstMats || this._lazyCellToInstMats.size === 0) return; const t0 = performance.now(); let total = 0; for (const matId of this._lazyCellToInstMats) { const keysArr = this._instanceKeys.get(matId); if (!keysArr) continue; for (let idx = 0; idx < keysArr.length; idx++) { const key = keysArr[idx]; if (key === null || key === undefined) continue; this._cellToInst.set(key, { mat: matId, idx }); total++; } } this._lazyCellToInstMats.clear(); const dt = performance.now() - t0; console.log(`[TerrainManager] lazy-hydrated _cellToInst: ${total} entries in ${dt.toFixed(0)}ms`); } /** Полная очистка. */ clear() { this.voxels.clear(); this._cellToInst.clear(); if (this._lazyCellToInstMats) this._lazyCellToInstMats.clear(); for (const [matId, proto] of this._protoMeshes) { try { proto.thinInstanceCount = 0; } catch (e) {} this._instanceKeys.set(matId, []); this._freeSlots.set(matId, []); } // Очищаем region-meshes от предыдущей генерации this._disposeRegionMeshes(); this._emit(); } // ======================================================================== // Внутренности thin-instances // ======================================================================== /** Получить или создать proto-меш для материала. */ _getOrCreateProto(matId) { let proto = this._protoMeshes.get(matId); if (proto) return proto; const def = TERRAIN_MATERIALS[matId]; if (!def) return null; // Куб размером VOXEL_SIZE, центр в (0,0,0). thinInstance ставится через // Matrix.Translation в _addInstance ниже. Размер ячейки 0.5×0.5×0.5 // даёт 8× плотность относительно стандартного блока 1×1×1 — холмы // выглядят детальнее. proto = MeshBuilder.CreateBox(`__terrainProto_${matId}`, { size: VOXEL_SIZE }, this.scene); const matEntry = this._getMaterial(matId); proto.material = matEntry.material; // Если у материала разные текстуры на гранях (например grass: верх трава, // бока земля-с-травой, низ земля) — нужно создать SubMesh'и и применить // MultiMaterial. Логика повторяет BlockManager._cutCubeFaces. if (matEntry.isMulti) { this._cutCubeFaces(proto); } // Метаданные: помечаем как террейн-proto чтобы _pickFromMouse мог // отличить от пользовательских мешей. proto.metadata = { _isTerrainProto: true, terrainMatId: matId }; // Без isPickable, чтобы стандартный scene.pick не возвращал // proto-mesh (как у BlockManager). Свой raycast — отдельно через // _cellToInst. proto.isPickable = false; // Тени: receiveShadows стоит — терреин может принимать тени от // других объектов (моделей). НО: cast shadows ОТКЛЮЧАЕМ через // не-регистрацию в shadowGenerator.renderList. Это критично для // больших карт — иначе shadow-pass отрендерит весь террейн дважды // (с player perspective + с sun perspective). proto.receiveShadows = true; // thinInstanceEnablePicking=false тоже, для производительности. try { proto.thinInstanceEnablePicking = false; } catch (e) {} // Критично для multi-material + thin-instances: без этих флагов // Babylon пересчитывает bbox при каждом thinInstanceAdd и может // потерять subMesh-разбиение (тогда все грани красятся subMaterials[0]). // То же делает BlockManager — иначе на блоке-«траве» бока тоже // становятся top-текстурой. proto.alwaysSelectAsActiveMesh = true; proto.doNotSyncBoundingInfo = true; // proto стоит в (0,0,0) и НИКОГДА не двигается — instance-матрицы // живут в GPU thin-instance buffer. freezeWorldMatrix экономит // ~5-10% CPU/кадр на больших картах. Безопасно потому что мы не // меняем proto.position/scaling/rotation. try { proto.freezeWorldMatrix(); } catch (e) {} this._protoMeshes.set(matId, proto); this._instanceKeys.set(matId, []); this._freeSlots.set(matId, []); try { this._onProtoCreated?.(proto); } catch (e) {} return proto; } /** * Разбить cube-mesh на 6 SubMesh (по одной на грань), чтобы MultiMaterial * мог применить разные материалы к front/back/right/left/top/bottom. * Логика идентична BlockManager._cutCubeFaces. */ _cutCubeFaces(mesh) { const totalIndices = mesh.getTotalIndices(); mesh.subMeshes = []; const indicesPerFace = totalIndices / 6; for (let face = 0; face < 6; face++) { new SubMesh( face, // материал-индекс 0, // verticesStart mesh.getTotalVertices(), // verticesCount face * indicesPerFace, // indexStart indicesPerFace, // indexCount mesh, ); } } /** * Получить (создать если нет) материал для material-id. * Возвращает: { material, isMulti } — где material это StandardMaterial * (для простого куба) или MultiMaterial (для top/side/bottom-куба). * * Текстуры берутся из Kenney Voxel Pack (общий для блоков и террейна) — * за счёт этого холмы стилистически сливаются с обычной сценой. * Sampling — NEAREST (pixel-art), без size-mipmap-blurring. */ _getMaterial(matId) { if (this._materials.has(matId)) { return this._materials.get(matId); } const def = TERRAIN_MATERIALS[matId]; if (!def) return null; let entry; if (def.top || def.side || def.bottom) { // Мульти-материал: разные текстуры на верх/бока/низ (трава и подобное). const matTop = this._createSingleMat(def, def.top || def.side, `__terrainMat_${matId}_top`); const matSide = this._createSingleMat(def, def.side || def.top, `__terrainMat_${matId}_side`); const matBottom = this._createSingleMat(def, def.bottom || def.side, `__terrainMat_${matId}_bot`); const multi = new MultiMaterial(`__terrainMulti_${matId}`, this.scene); multi.subMaterials[FACE_INDEX.front] = matSide; multi.subMaterials[FACE_INDEX.back] = matSide; multi.subMaterials[FACE_INDEX.right] = matSide; multi.subMaterials[FACE_INDEX.left] = matSide; multi.subMaterials[FACE_INDEX.top] = matTop; multi.subMaterials[FACE_INDEX.bottom] = matBottom; // freeze ОТЛОЖЕН до загрузки текстур — см. _scheduleFreezeAfterTextures this._scheduleFreezeAfterTextures([matSide, matTop, matBottom]); entry = { material: multi, isMulti: true }; } else { // Простой куб — одна текстура на все 6 граней. const mat = this._createSingleMat(def, def.texture, `__terrainMat_${matId}`); this._scheduleFreezeAfterTextures([mat]); entry = { material: mat, isMulti: false }; } this._materials.set(matId, entry); return entry; } /** * Замораживает материалы только ПОСЛЕ загрузки их diffuseTexture. * * Babylon генерит шейдер материала под текущее состояние (есть текстура * или нет). Если позвать .freeze() ДО onLoad текстуры — шейдер * собрался без неё, и текстура потом никогда не «прорастёт». * Виден только pure-light цвет (баг 2026-05-27 «текстуры не показываются»). */ _scheduleFreezeAfterTextures(mats) { const pending = []; for (const mat of mats) { if (!mat) continue; const tex = mat.diffuseTexture; if (!tex) { try { mat.freeze?.(); } catch (e) {} continue; } if (tex.isReady?.()) { try { mat.freeze?.(); } catch (e) {} continue; } pending.push({ mat, tex }); } for (const { mat, tex } of pending) { const onLoad = () => { try { mat.freeze?.(); } catch (e) {} }; try { if (tex.onLoadObservable && typeof tex.onLoadObservable.addOnce === 'function') { tex.onLoadObservable.addOnce(onLoad); } else if (typeof tex.onLoadObservable?.add === 'function') { tex.onLoadObservable.add(onLoad); } else { let tries = 0; const iv = setInterval(() => { tries++; if (tex.isReady?.() || tries > 50) { clearInterval(iv); try { mat.freeze?.(); } catch (e) {} } }, 100); } } catch (e) { try { mat.freeze?.(); } catch (_) {} } } } /** * Создать один StandardMaterial с диффузной текстурой. * Текстура семплируется в NEAREST-режиме (sharp pixel-art под Kenney pack). */ _createSingleMat(def, texturePath, name) { const mat = new StandardMaterial(name, this.scene); // Specular почти выключен — на pixel-art-текстуре любой блик уничтожает стиль. mat.specularColor = new Color3(0, 0, 0); // 2026-06-02: воксели «просвечивали» — видна была задняя грань сквозь // переднюю. Две страховки против этого: // 1) backFaceCulling=false — даже при инвертированном winding обе // стороны грани рисуются, ближняя перекрывает дальнюю по depth. // 2) hasAlpha=false ниже (RGBA-текстура не должна включать alpha-blend). // Для прозрачных материалов (water/glacier с def.alpha<1) culling // вернём true, чтобы blend выглядел корректно. mat.backFaceCulling = !(def.alpha != null && def.alpha < 1) ? false : true; // Ambient ставим в белый, чтобы hemisphere-light освещал материал // с любой стороны (иначе нижние/тыловые грани выходят серыми, что // особенно заметно на светло-бежевом песке — он становится серым). // С ambientColor=(1,1,1) текстура «читается» в полной интенсивности // независимо от угла нормали к directional-свету. mat.ambientColor = new Color3(1, 1, 1); if (texturePath) { // mipmap=ON + TRILINEAR для world-tiled UV (1 тайл = 1м). // На больших стенах тайл занимает несколько экранных px → без // mipmap GPU берёт один pixel из 128×128 → одноцветная стенка. // Mipmap даёт усреднённые уровни на каждую дистанцию. const tex = new Texture( texturePath, this.scene, /*noMipmap*/ false, /*invertY*/ false, Texture.TRILINEAR_SAMPLINGMODE, ); mat.diffuseTexture = tex; if (def.alpha != null && def.alpha < 1) { mat.diffuseTexture.hasAlpha = true; mat.useAlphaFromDiffuseTexture = true; mat.alpha = def.alpha; } else { // ВАЖНО (2026-06-02): наши PNG-текстуры в формате RGBA (с альфа- // каналом, даже если он весь 255 = непрозрачный). Babylon, видя // альфа-канал, может включить alpha-blending → грани рисуются // без записи в depth-buffer → дальние воксели «просвечивают» // сквозь ближние (листва/трава были полупрозрачными). Явно // выключаем альфу для непрозрачных материалов — крона/трава // становятся плотными. mat.diffuseTexture.hasAlpha = false; mat.useAlphaFromDiffuseTexture = false; mat.transparencyMode = 0; // OPAQUE } if (Array.isArray(def.emissive)) { mat.emissiveColor = new Color3(def.emissive[0], def.emissive[1], def.emissive[2]); } } else { // Fallback на цвет если по какой-то причине нет текстуры mat.diffuseColor = Color3.FromHexString(def.color || '#888'); if (def.alpha != null && def.alpha < 1) mat.alpha = def.alpha; } return mat; } /** Добавить thin-instance. Возвращает idx слота. */ _addInstance(key, x, y, z, matId) { const proto = this._getOrCreateProto(matId); if (!proto) return -1; // Перевод voxel-индекса → центр меша в мире. const wx = (x + 0.5) * VOXEL_SIZE; const wy = (y + 0.5) * VOXEL_SIZE; const wz = (z + 0.5) * VOXEL_SIZE; const matEntry = this._materials.get(matId); const isMulti = !!matEntry?.isMulti; let mat; if (isMulti) { mat = Matrix.Translation(wx, wy, wz); } else { const hash = ((x * 73856093) ^ (y * 19349663) ^ (z * 83492791)) >>> 0; const jitterY = ((hash % 1000) / 1000 - 0.5) * 0.10 * VOXEL_SIZE; mat = Matrix.Translation(wx, wy + jitterY, wz); } // Batch-режим: refresh=false → GPU-buffer не обновляется на каждом // add. Вызываем _flushBatch() в конце brush-операции. Это превращает // 17000 GPU-uploads (sphere r=16) → 1 upload. Драматическое ускорение // brushDraw на больших радиусах. const refresh = !this._inBatch; const free = this._freeSlots.get(matId); const keys = this._instanceKeys.get(matId); let idx; if (free && free.length > 0) { idx = free.pop(); proto.thinInstanceSetMatrixAt(idx, mat, refresh); keys[idx] = key; } else { idx = proto.thinInstanceAdd(mat, refresh); keys[idx] = key; } this._cellToInst.set(key, { mat: matId, idx }); if (this._inBatch) this._dirtyBatchProtos.add(matId); return idx; } /** Убрать thin-instance — сжимаем matrix до identity, помечаем слот свободным. */ _removeInstance(key, info) { const proto = this._protoMeshes.get(info.mat); if (!proto) return; // Замораживаем слот за пределами видимости (scale=0). const zero = Matrix.Scaling(0, 0, 0); const refresh = !this._inBatch; try { proto.thinInstanceSetMatrixAt(info.idx, zero, refresh); } catch (e) {} const free = this._freeSlots.get(info.mat); const keys = this._instanceKeys.get(info.mat); if (keys) keys[info.idx] = null; if (free) free.push(info.idx); this._cellToInst.delete(key); if (this._inBatch) this._dirtyBatchProtos.add(info.mat); } /** Начать batch — add/remove не будут обновлять GPU-buffer. */ _beginBatch() { this._inBatch = true; if (!this._dirtyBatchProtos) this._dirtyBatchProtos = new Set(); this._dirtyBatchProtos.clear(); } /** Завершить batch — один upload GPU-buffer'а на каждый затронутый proto. */ _flushBatch() { if (!this._inBatch) return; this._inBatch = false; if (!this._dirtyBatchProtos) return; for (const matId of this._dirtyBatchProtos) { const proto = this._protoMeshes.get(matId); if (!proto) continue; try { proto.thinInstanceBufferUpdated('matrix'); } catch (e) {} } this._dirtyBatchProtos.clear(); } // ======================================================================== // Кисти // ======================================================================== /** Вернуть набор voxel-ключей в кисти. shape: 'sphere'|'cube'|'cylinder'. */ _enumerateBrushCells(brush) { const { x: cx, y: cy, z: cz, radius, shape = 'sphere' } = brush; const r = Math.max(1, Math.floor(radius)); const cells = []; const rSq = r * r; for (let dx = -r; dx <= r; dx++) { for (let dy = -r; dy <= r; dy++) { for (let dz = -r; dz <= r; dz++) { let inside; if (shape === 'cube') { inside = true; } else if (shape === 'cylinder') { // Цилиндр вдоль Y: круг в XZ + полная высота 2r. inside = (dx * dx + dz * dz) <= rSq; } else { // sphere inside = (dx * dx + dy * dy + dz * dz) <= rSq; } if (inside) { cells.push([cx + dx, cy + dy, cz + dz]); } } } } return cells; } /** * Кисть «Рисовать» — добавить voxel'ы материала в области кисти. * Не перезаписывает существующие (иначе придётся ставить кисть рядом). * Для перекраски используй brushPaint. */ brushDraw(brush, matId) { this._ensureCellToInstHydrated(); const cells = this._enumerateBrushCells(brush); let added = 0; const affected = []; this._beginBatch(); try { for (const [x, y, z] of cells) { const key = `${x},${y},${z}`; if (this.voxels.has(key)) continue; this._addInstance(key, x, y, z, matId); this.voxels.set(key, matId); affected.push([x, y, z]); added++; } if (matId === 'grass' && affected.length < 2000) { this._normalizeGrass(affected); } } finally { this._flushBatch(); } if (added > 0) this._emit(); return added; } /** * Постобработка после кисти grass: оставляет grass только на ВЕРХНЕМ * voxel'е каждого столбца, всё что ниже — превращает в dirt. * * Логика как у Minecraft: блок grass всегда «корочка» поверх земли. * Без этого столбец из 3+ grass-кубов показывает 3 повторения боковой * текстуры (зелёная полоса каждые 0.5м), что визуально выглядит как * «полосы травы» на стене. * * Принимает список затронутых клеток (xyz-массивы), плюс перебирает * их верхних соседей чтобы исправить и предыдущие grass'ы. */ _normalizeGrass(cells) { if (!cells || cells.length === 0) return; // Собираем уникальные XZ-столбцы которые могли быть затронуты, // и для каждого — диапазон Y который реально поменялся. const colRange = new Map(); // "x,z" → {minY, maxY} for (const [x, y, z] of cells) { const k = `${x},${z}`; const r = colRange.get(k); if (!r) { colRange.set(k, { minY: y, maxY: y }); } else { if (y < r.minY) r.minY = y; if (y > r.maxY) r.maxY = y; } } // Для каждого столбца: сканируем только в [minY-1 .. maxY+1] — // верхний grass найдём за O(range) вместо O(voxels.size). for (const [colKey, range] of colRange) { const onlyComma = colKey.lastIndexOf(','); const x = parseInt(colKey.slice(0, onlyComma), 10); const z = parseInt(colKey.slice(onlyComma + 1), 10); // Сначала находим верхний grass в столбце вокруг изменённой зоны. // Если grass нет — нечего нормализовывать. let topGrassY = null; const scanMax = range.maxY + 1; const scanMin = range.minY - 1; for (let y = scanMax; y >= scanMin; y--) { if (this.voxels.get(`${x},${y},${z}`) === 'grass') { topGrassY = y; break; } } if (topGrassY === null) continue; // Теперь от topGrassY-1 вниз до scanMin превращаем все grass в dirt. for (let y = topGrassY - 1; y >= scanMin; y--) { const k = `${x},${y},${z}`; if (this.voxels.get(k) !== 'grass') continue; const info = this._cellToInst.get(k); if (info) this._removeInstance(k, info); this._addInstance(k, x, y, z, 'dirt'); this.voxels.set(k, 'dirt'); } } } /** * Кисть «Стереть» — убрать voxel'ы в области кисти. */ brushErase(brush) { this._ensureCellToInstHydrated(); const cells = this._enumerateBrushCells(brush); let removed = 0; this._beginBatch(); try { for (const [x, y, z] of cells) { const key = `${x},${y},${z}`; const info = this._cellToInst.get(key); if (!info) continue; this._removeInstance(key, info); this.voxels.delete(key); removed++; } } finally { this._flushBatch(); } if (removed > 0) this._emit(); return removed; } /** * Кисть «Скульпт» — поднимает (dir=+1) или опускает (dir=-1) поверхность. * Для каждого XZ-столбца в радиусе кисти находит верхний voxel и добавляет * над ним новый (или удаляет верхний если dir<0). * Сила в зависимости от strength: 1..1 столбец, 5..несколько строк. */ brushSculpt(brush, dir, matId, strength = 1) { this._ensureCellToInstHydrated(); const { x: cx, z: cz, radius } = brush; const r = Math.max(1, Math.floor(radius)); const rSq = r * r; let changed = 0; const layers = Math.max(1, Math.min(8, Math.round(strength / 25))); // 1..4 const affected = []; // Оптимизация: диапазон сканирования _findTopY = brush.y±r вместо ±r*2 // (поверхность не может быть сильно выше центра brush — drag-lock-Y // уже выровнял курсор по первой точке). const yMax = brush.y + r; const yMin = brush.y - r; this._beginBatch(); try { for (let dx = -r; dx <= r; dx++) { const dx2 = dx * dx; for (let dz = -r; dz <= r; dz++) { if (dx2 + dz * dz > rSq) continue; const x = cx + dx; const z = cz + dz; const topY = this._findTopY(x, z, yMax, yMin); if (dir > 0) { const startY = topY === null ? brush.y : topY + 1; for (let i = 0; i < layers; i++) { const ny = startY + i; const key = `${x},${ny},${z}`; if (this.voxels.has(key)) continue; this._addInstance(key, x, ny, z, matId); this.voxels.set(key, matId); affected.push([x, ny, z]); changed++; } } else { if (topY === null) continue; for (let i = 0; i < layers; i++) { const ny = topY - i; const key = `${x},${ny},${z}`; const info = this._cellToInst.get(key); if (!info) break; this._removeInstance(key, info); this.voxels.delete(key); affected.push([x, ny, z]); changed++; } } } } if (affected.length < 2000) { this._normalizeGrass(affected); } } finally { this._flushBatch(); } if (changed > 0) this._emit(); return changed; } /** * Кисть «Сгладить» — для каждого XZ-столбца в радиусе подгоняет верхнюю * Y к усреднённой высоте соседних столбцов. Резкие пики срезает, * глубокие ямы засыпает. * * matId=null → используем материал верхнего voxel каждого столбца. * Это позволяет сглаживать без выбора материала и без перекрашивания. */ brushSmooth(brush, matId) { this._ensureCellToInstHydrated(); const { x: cx, z: cz, radius } = brush; const r = Math.max(1, Math.floor(radius)); const rSq = r * r; // 1. Собрать высоты столбцов в радиусе const heights = new Map(); // "x,z" → topY let sum = 0; let n = 0; for (let dx = -r; dx <= r; dx++) { for (let dz = -r; dz <= r; dz++) { if (dx * dx + dz * dz > rSq) continue; const x = cx + dx; const z = cz + dz; const topY = this._findTopY(x, z, brush.y + r * 4, brush.y - r * 4); if (topY === null) continue; heights.set(`${x},${z}`, topY); sum += topY; n++; } } if (n === 0) return 0; const avg = sum / n; // 2. Для каждого столбца сдвинуть topY к avg (1 шаг) let changed = 0; this._beginBatch(); for (const [k, topY] of heights) { const [xs, zs] = k.split(','); const x = parseInt(xs, 10); const z = parseInt(zs, 10); // Сдвигаем на 1 шаг в сторону avg if (topY > avg + 0.5) { // Срезаем верх const info = this._cellToInst.get(`${x},${topY},${z}`); if (info) { this._removeInstance(`${x},${topY},${z}`, info); this.voxels.delete(`${x},${topY},${z}`); changed++; } } else if (topY < avg - 0.5) { // Засыпаем сверху — материалом этого столбца (если matId не задан) const ny = topY + 1; const nk = `${x},${ny},${z}`; if (!this.voxels.has(nk)) { // Берём материал ближайшего solid voxel в столбце, // fallback на 'grass' если столбец пуст. let mat = matId; if (!mat) { mat = this.voxels.get(`${x},${topY},${z}`) || 'grass'; } this._addInstance(nk, x, ny, z, mat); this.voxels.set(nk, mat); changed++; } } } this._flushBatch(); if (changed > 0) this._emit(); return changed; } /** * Кисть «Выровнять» — все столбцы в радиусе кисти приводит к Y=brush.y. * Лишнее срезает, нехватку засыпает. */ brushFlatten(brush, matId) { this._ensureCellToInstHydrated(); const { x: cx, y: targetY, z: cz, radius } = brush; const r = Math.max(1, Math.floor(radius)); const rSq = r * r; let changed = 0; const affected = []; this._beginBatch(); try { for (let dx = -r; dx <= r; dx++) { for (let dz = -r; dz <= r; dz++) { if (dx * dx + dz * dz > rSq) continue; const x = cx + dx; const z = cz + dz; let cur = this._findTopY(x, z, targetY + r * 4, targetY - r * 4); while (cur !== null && cur > targetY) { const info = this._cellToInst.get(`${x},${cur},${z}`); if (info) { this._removeInstance(`${x},${cur},${z}`, info); this.voxels.delete(`${x},${cur},${z}`); affected.push([x, cur, z]); changed++; } cur--; } const startY = cur === null ? targetY : cur + 1; for (let y = startY; y <= targetY; y++) { const k = `${x},${y},${z}`; if (this.voxels.has(k)) continue; this._addInstance(k, x, y, z, matId); this.voxels.set(k, matId); affected.push([x, y, z]); changed++; } } } if (affected.length < 2000) { this._normalizeGrass(affected); } } finally { this._flushBatch(); } if (changed > 0) this._emit(); return changed; } /** * Кисть «Раскрасить» — меняет матeриал у существующих voxel'ов в области * кисти, без изменения формы. */ brushPaint(brush, matId) { this._ensureCellToInstHydrated(); const cells = this._enumerateBrushCells(brush); let changed = 0; this._beginBatch(); try { for (const [x, y, z] of cells) { const key = `${x},${y},${z}`; const info = this._cellToInst.get(key); if (!info) continue; if (info.mat === matId) continue; this._removeInstance(key, info); this._addInstance(key, x, y, z, matId); this.voxels.set(key, matId); changed++; } } finally { this._flushBatch(); } if (changed > 0) this._emit(); return changed; } /** * Найти верхний voxel в столбце (x, z) в диапазоне [yMin..yMax]. * Возвращает Y верхнего voxel'а или null если столбец пуст. */ _findTopY(x, z, yMax, yMin) { const min = Math.floor(Math.min(yMin, yMax)); const max = Math.floor(Math.max(yMin, yMax)); for (let y = max; y >= min; y--) { if (this.voxels.has(`${x},${y},${z}`)) return y; } return null; } /** Найти voxel под точкой клика (raycast по AABB-сетке). * Работает в МИРОВЫХ координатах, но grid-индексы получаются делением * на VOXEL_SIZE. DDA-traversal идёт шагами длиной VOXEL_SIZE в мире, * каждый шаг = ±1 индекс voxel'а. */ pickVoxelByRay(rayOrigin, rayDir, maxDist = 200) { const o = rayOrigin; const d = rayDir; const S = VOXEL_SIZE; // Текущая клетка — мировая координата делённая на размер voxel'а let ix = Math.floor(o.x / S); let iy = Math.floor(o.y / S); let iz = Math.floor(o.z / S); const stepX = d.x > 0 ? 1 : -1; const stepY = d.y > 0 ? 1 : -1; const stepZ = d.z > 0 ? 1 : -1; // Длина мирового шага при переходе на одну клетку — VOXEL_SIZE / |d.k| const tDeltaX = Math.abs(S / d.x); const tDeltaY = Math.abs(S / d.y); const tDeltaZ = Math.abs(S / d.z); // Расстояние до ближайшей границы клетки в мире const nextX = (d.x > 0 ? (ix + 1) * S : ix * S); const nextY = (d.y > 0 ? (iy + 1) * S : iy * S); const nextZ = (d.z > 0 ? (iz + 1) * S : iz * S); let tMaxX = (nextX - o.x) / d.x; let tMaxY = (nextY - o.y) / d.y; let tMaxZ = (nextZ - o.z) / d.z; if (!isFinite(tMaxX)) tMaxX = Infinity; if (!isFinite(tMaxY)) tMaxY = Infinity; if (!isFinite(tMaxZ)) tMaxZ = Infinity; let t = 0; let lastAxis = 'none'; // Лимит итераций — в мире 200 м максимум, при шаге 0.5м = до 400 шагов const MAX_STEPS = Math.ceil(maxDist / S); for (let i = 0; i < MAX_STEPS; i++) { if (this.voxels.has(`${ix},${iy},${iz}`)) { // Нормаль — противоположна шагу по той оси что мы только что пересекли let nx = 0, ny = 0, nz = 0; if (lastAxis === 'x') nx = -stepX; else if (lastAxis === 'y') ny = -stepY; else if (lastAxis === 'z') nz = -stepZ; return { cell: { x: ix, y: iy, z: iz }, normal: { x: nx, y: ny, z: nz }, t, }; } if (tMaxX < tMaxY && tMaxX < tMaxZ) { ix += stepX; t = tMaxX; tMaxX += tDeltaX; lastAxis = 'x'; } else if (tMaxY < tMaxZ) { iy += stepY; t = tMaxY; tMaxY += tDeltaY; lastAxis = 'y'; } else { iz += stepZ; t = tMaxZ; tMaxZ += tDeltaZ; lastAxis = 'z'; } if (t > maxDist) return null; } return null; } // ======================================================================== // Сериализация // ======================================================================== /** * Сохранить как массив `{x, y, z, m}` (m — короткий ключ материала). * Используется в project_data.scene.terrain. */ serialize() { const out = []; for (const [key, mat] of this.voxels) { const [xs, ys, zs] = key.split(','); out.push({ x: parseInt(xs, 10), y: parseInt(ys, 10), z: parseInt(zs, 10), m: mat, }); } return out; } /** * RLE-сериализация (Этап 3 voxel-движка) — компактный формат для БД. * * Размер на 250м карте: ~1.5 МБ вместо ~38 МБ legacy JSON (×25 меньше). * Это критично — legacy JSON.stringify на 38МБ блокирует браузер на 10+ * секунд и БД отказывается принимать такой payload. * * Формат: * { * format: 'rle-v1', * palette: ['grass', 'rock', 'sand', ...], // index 0 = пусто (не используется) * chunks: { * "cx,cy,cz": "", // только непустые чанки * ... * } * } * * @param {Function} [progressCb] - (done, total) для отображения прогресса * @returns {Object} RLE-формат */ serializeRLE(progressCb = null) { // Группируем voxel'и по чанкам 32×32×32 (тот же CHUNK_SIZE что в VoxelChunk) const CHUNK_SIZE = 32; const CHUNK_VOLUME = CHUNK_SIZE ** 3; // 1. Палитра материалов (на лету) const palette = [null]; // index 0 = пусто const matToIdx = new Map(); const getMatIdx = (matId) => { let idx = matToIdx.get(matId); if (idx === undefined) { idx = palette.length; palette.push(matId); matToIdx.set(matId, idx); } return idx; }; // 2. Группируем voxel'и по chunkKey, заполняем Uint8Array на чанк // Используем sparse Map чтобы пустые чанки не аллоцировались. const chunkData = new Map(); // "cx,cy,cz" → Uint8Array(32768) let processed = 0; const total = this.voxels.size; for (const [key, matId] of this.voxels) { const lastComma = key.lastIndexOf(','); const midComma = key.lastIndexOf(',', lastComma - 1); const x = parseInt(key.slice(0, midComma), 10); const y = parseInt(key.slice(midComma + 1, lastComma), 10); const z = parseInt(key.slice(lastComma + 1), 10); const cx = Math.floor(x / CHUNK_SIZE); const cy = Math.floor(y / CHUNK_SIZE); const cz = Math.floor(z / CHUNK_SIZE); const lx = x - cx * CHUNK_SIZE; const ly = y - cy * CHUNK_SIZE; const lz = z - cz * CHUNK_SIZE; const chunkKey = `${cx},${cy},${cz}`; let data = chunkData.get(chunkKey); if (!data) { data = new Uint8Array(CHUNK_VOLUME); chunkData.set(chunkKey, data); } const idx = ly * CHUNK_SIZE * CHUNK_SIZE + lz * CHUNK_SIZE + lx; data[idx] = getMatIdx(matId); processed++; if (progressCb && processed % 10000 === 0) { progressCb(processed * 0.5, total); // первая половина прогресса } } // 3. Для каждого чанка — RLE encode + base64 const chunks = {}; let chunkIdx = 0; const totalChunks = chunkData.size; for (const [chunkKey, data] of chunkData) { const rleBytes = this._encodeRLE(data); const b64 = this._uint8ToBase64(rleBytes); chunks[chunkKey] = b64; chunkIdx++; if (progressCb && chunkIdx % 16 === 0) { progressCb(total * 0.5 + (chunkIdx / totalChunks) * total * 0.5, total); } } if (progressCb) progressCb(total, total); return { format: 'rle-v1', palette, chunks, }; } /** * Десериализация RLE-формата обратно в Map voxel'ов + загрузка через * loadFromArray (для region-meshes/рендера). Возвращает Promise. */ async loadFromRLE(rleData, onProgress) { if (!rleData || rleData.format !== 'rle-v1') { throw new Error('loadFromRLE: invalid format ' + (rleData?.format ?? 'unknown')); } const CHUNK_SIZE = 32; const palette = rleData.palette || [null]; const voxels = []; // {x, y, z, m} const chunkEntries = Object.entries(rleData.chunks || {}); let processed = 0; const total = chunkEntries.length; for (const [chunkKey, b64] of chunkEntries) { const [cx, cy, cz] = chunkKey.split(',').map(Number); const rleBytes = this._base64ToUint8(b64); const data = this._decodeRLE(rleBytes); for (let ly = 0; ly < CHUNK_SIZE; ly++) { for (let lz = 0; lz < CHUNK_SIZE; lz++) { for (let lx = 0; lx < CHUNK_SIZE; lx++) { const idx = data[ly * CHUNK_SIZE * CHUNK_SIZE + lz * CHUNK_SIZE + lx]; if (idx === 0) continue; const m = palette[idx]; if (!m) continue; voxels.push({ x: cx * CHUNK_SIZE + lx, y: cy * CHUNK_SIZE + ly, z: cz * CHUNK_SIZE + lz, m, }); } } } processed++; if (onProgress && processed % 8 === 0) onProgress(processed, total); } // Делегируем основной загрузке (она строит region-meshes и т.д.) return await this.loadFromArray(voxels); } // ======================================================================== // RLE encoding helpers (приватные) // ======================================================================== /** Uint8Array → RLE byte stream. * Формат: uint16 numRuns, [uint16 start, uint16 len, uint8 matIdx] × N */ _encodeRLE(data) { const CHUNK_VOLUME = data.length; const runs = []; let i = 0; while (i < CHUNK_VOLUME) { const mat = data[i]; if (mat === 0) { i++; continue; } let j = i + 1; while (j < CHUNK_VOLUME && data[j] === mat) j++; runs.push({ start: i, length: j - i, matIdx: mat }); i = j; } const buf = new Uint8Array(2 + runs.length * 5); const view = new DataView(buf.buffer); view.setUint16(0, runs.length, true); let offset = 2; for (const r of runs) { view.setUint16(offset, r.start, true); view.setUint16(offset + 2, r.length, true); view.setUint8(offset + 4, r.matIdx); offset += 5; } return buf; } /** RLE bytes → Uint8Array(32768). */ _decodeRLE(bytes) { const CHUNK_SIZE = 32; const CHUNK_VOLUME = CHUNK_SIZE ** 3; const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); const numRuns = view.getUint16(0, true); const data = new Uint8Array(CHUNK_VOLUME); let offset = 2; for (let i = 0; i < numRuns; i++) { const start = view.getUint16(offset, true); const length = view.getUint16(offset + 2, true); const matIdx = view.getUint8(offset + 4); for (let j = 0; j < length; j++) data[start + j] = matIdx; offset += 5; } return data; } /** Uint8Array → base64 (с чанками по 8KB чтобы не упереться в stack). */ _uint8ToBase64(bytes) { let binary = ''; const chunkSize = 8192; for (let i = 0; i < bytes.length; i += chunkSize) { const chunk = bytes.subarray(i, Math.min(i + chunkSize, bytes.length)); binary += String.fromCharCode.apply(null, chunk); } return btoa(binary); } /** base64 → Uint8Array. */ _base64ToUint8(b64) { const binary = atob(b64); const len = binary.length; const bytes = new Uint8Array(len); for (let i = 0; i < len; i++) bytes[i] = binary.charCodeAt(i); return bytes; } /** Загрузить из массива (формат serialize). * * АРХИТЕКТУРА (важна для производительности!): * * Старая версия делала `thinInstanceAdd` на каждом voxel'е — это * переаллоцирует GPU-буфер матриц каждый раз. На 66К voxel'ов это * занимало ~1 минуту. * * Сейчас: * 1. Группируем voxel'ы по материалу за O(N) — голые данные. * 2. Нормализуем grass прямо в данных (без создания instance'ов). * 3. Для каждого материала строим один большой Float32Array из * N×16 значений (N матриц) и заливаем через `thinInstanceSetBuffer` * — ОДИН GPU upload вместо N штук. * * На 66К voxel'ов это ~100-200мс вместо 60 секунд. * * Возвращает Promise (для совместимости со старым кодом). onProgress * больше не нужен — операция быстрая. */ async loadFromArray(arr, onProgress) { this.clear(); if (!Array.isArray(arr) || arr.length === 0) { this._emit(); return Promise.resolve(); } // ---- 1. Группировка voxel'ов по материалу ---- // Заодно строим this.voxels (Map<"x,y,z", matId>) — это источник правды. // ВНИМАНИЕ: пока НЕ создаём thin-instances. Только данные. // // ОПТИМИЗАЦИЯ: раньше делали 2 прохода — сначала this.voxels.set(), // потом re-парсили ключи через parseInt × 3 в byMat. На 5.7M // вокселей это давало 17M parseInt + 5.7M lastIndexOf — очень // дорого (10-20 секунд CPU). Теперь — ОДИН проход: складываем в // воксели и byMat одновременно. Дубликаты обрабатываем через // dedup-Set (если ключ уже видели, помечаем для замены матом). /** matId → Array<[x, y, z]> */ const byMat = new Map(); // Для дедупа дубликатов — если входной arr содержит один и тот же // ключ дважды, побеждает ПОСЛЕДНИЙ (как раньше). Чтобы не возвращаться // к старым, храним индекс в bucket каждого ключа. // На больших картах дубликатов почти нет → проверка дешёвая. const keyToBucketIdx = new Map(); for (let i = 0; i < arr.length; i++) { const v = arr[i]; if (!v || typeof v.x !== 'number') continue; const matId = v.m || v.mat || 'grass'; if (!TERRAIN_MATERIALS[matId]) continue; const x = v.x, y = v.y, z = v.z; const key = `${x},${y},${z}`; const prev = this.voxels.get(key); this.voxels.set(key, matId); if (prev === undefined) { // Новый ключ — добавляем в bucket let bucket = byMat.get(matId); if (!bucket) { bucket = []; byMat.set(matId, bucket); } keyToBucketIdx.set(key, { matId, idx: bucket.length }); bucket.push([x, y, z]); } else if (prev !== matId) { // Дубликат с другим материалом — удаляем из старого bucket // (помечаем null) и добавляем в новый. const oldInfo = keyToBucketIdx.get(key); if (oldInfo) { const oldBucket = byMat.get(oldInfo.matId); if (oldBucket) oldBucket[oldInfo.idx] = null; } let bucket = byMat.get(matId); if (!bucket) { bucket = []; byMat.set(matId, bucket); } keyToBucketIdx.set(key, { matId, idx: bucket.length }); bucket.push([x, y, z]); } // prev === matId → ничего не делаем (тот же ключ + тот же мат) } // Чистим null'ы (созданные при дедупе) for (const [matId, bucket] of byMat) { const compact = []; for (let i = 0; i < bucket.length; i++) { if (bucket[i] !== null) compact.push(bucket[i]); } byMat.set(matId, compact); } keyToBucketIdx.clear(); // ---- 2. Нормализация grass на голых данных ---- // Это работает напрямую с this.voxels (Map), переводит grass под верхушкой // в dirt. Без создания thin-instances — никаких аллокаций GPU. this._normalizeGrassData(byMat); // ---- 3. Bulk-заливка по материалам ---- // Создаём прото-меш + один Float32Array на 16×N значений + один // thinInstanceSetBuffer вызов на материал. // // ВАЖНО: после заливки переопределяем boundingInfo на КАЖДОМ proto-mesh // вычисленным AABB его voxel'ов. Это позволит Babylon правильно // выполнять frustum culling per-mesh. Без этого с alwaysSelectAsActiveMesh // false мы потеряли бы рендер совсем. let totalProcessed = 0; for (const [matId, cells] of byMat) { if (cells.length === 0) continue; const proto = this._getOrCreateProto(matId); if (!proto) continue; const N = cells.length; const buffer = new Float32Array(16 * N); const isMulti = !!this._materials.get(matId)?.isMulti; const tmpMat = new Float32Array(16); // Вычисляем AABB всех voxel'ов этого материала let minX = Infinity, minY = Infinity, minZ = Infinity; let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity; // ОПТИМИЗАЦИЯ: на больших картах _cellToInst строится lazy. // Сам _cellToInst нужен только при edit'инге (setVoxel/removeVoxel). // На карте 5.7M вокселей Map с 5.7M записями жрёт ~2-3 ГБ // памяти JS и 5-10 сек CPU. До первого edit'а он не нужен. // // Решение: при загрузке заполняем только _instanceKeys (массив // ключей по индексу — нужен для editing math). А _cellToInst // помечаем как "lazy" и строим только при первом обращении в edit. const lazyMode = N > 100_000; // порог lazy режима const keysArr = this._instanceKeys.get(matId); for (let i = 0; i < N; i++) { const [x, y, z] = cells[i]; const wx = (x + 0.5) * VOXEL_SIZE; const wy = (y + 0.5) * VOXEL_SIZE; const wz = (z + 0.5) * VOXEL_SIZE; if (wx < minX) minX = wx; if (wx > maxX) maxX = wx; if (wy < minY) minY = wy; if (wy > maxY) maxY = wy; if (wz < minZ) minZ = wz; if (wz > maxZ) maxZ = wz; let translateY = wy; if (!isMulti) { // Y-jitter ±5% детерминированно от координат const hash = ((x * 73856093) ^ (y * 19349663) ^ (z * 83492791)) >>> 0; const jitterY = ((hash % 1000) / 1000 - 0.5) * 0.10 * VOXEL_SIZE; translateY += jitterY; } // Матрица Translation в column-major (Babylon формат) tmpMat[0] = 1; tmpMat[1] = 0; tmpMat[2] = 0; tmpMat[3] = 0; tmpMat[4] = 0; tmpMat[5] = 1; tmpMat[6] = 0; tmpMat[7] = 0; tmpMat[8] = 0; tmpMat[9] = 0; tmpMat[10] = 1; tmpMat[11] = 0; tmpMat[12] = wx; tmpMat[13] = translateY; tmpMat[14] = wz; tmpMat[15] = 1; buffer.set(tmpMat, i * 16); // Обновляем индексные структуры (для последующих set/remove) const key = `${x},${y},${z}`; keysArr[i] = key; if (!lazyMode) { // Малая карта — сразу строим _cellToInst как раньше this._cellToInst.set(key, { mat: matId, idx: i }); } } // Большая карта — помечаем что _cellToInst для этого материала // нужно построить lazy при первом edit'е. if (lazyMode) { if (!this._lazyCellToInstMats) this._lazyCellToInstMats = new Set(); this._lazyCellToInstMats.add(matId); } // Одна команда вместо N — GPU upload весь буфер сразу try { proto.thinInstanceSetBuffer('matrix', buffer, 16); } catch (e) { // eslint-disable-next-line no-console console.error('thinInstanceSetBuffer failed for', matId, e); } // Сохраняем bbox этого материала для последующего region-split. // Пока не используется — для будущей оптимизации. proto.metadata = proto.metadata || {}; proto.metadata.terrainBBox = { minX, minY, minZ, maxX, maxY, maxZ }; // ОТКЛЮЧАЕМ proto-mesh СРАЗУ для больших карт (>50К voxels). // Раньше: proto оставался enabled до конца region-split (20+ сек), // рендеря всю карту thin-instance кубами (10-45M триангулов) = // FPS падал до 1. Region-split в конце выключал, но юзер успевал // увидеть лаги. // Теперь: для больших карт сразу выключаем — region-split построит // нормальные региональные меши и пользователь увидит карту // постепенно. if (N > 50000) { try { proto.setEnabled(false); } catch (e) {} } totalProcessed += N; try { onProgress?.(totalProcessed, this.voxels.size); } catch (e) {} } // ---- 4. REGION-SPLITTING для больших карт ---- // Если карта большая (>~80м), грубое разбиение по материалам не // даёт frustum culling: каждый материал распределён по всей карте, // его bbox = вся карта, Babylon всегда рендерит. // // Решение: для больших карт ДОПОЛНИТЕЛЬНО разбиваем по region 32м. // Каждый region × material = отдельный thin-instance proto-mesh // со своим bbox. Babylon культит регионы вне frustum автоматически. // // Регионы создаются как обычные proto-meshes с именем // "__terrainProto_${matId}__R_${rx}_${rz}". Они НЕ участвуют в // _cellToInst (он остаётся на основных protos). Если в будущем // нужно редактировать через кисти — выкинуть регионы и пересобрать. await this._buildRegionMeshes(byMat); // === Этап оптимизации после загрузки === // Применяем тяжёлые оптимизации Babylon для thin-instances. Они // безопасны потому что после loadFromArray террейн статичный // (изменения только через кисти, которые сами зовут _setInstance // и инвалидируют эти оптимизации при необходимости). this._optimizeForRender(); this._emit(); return Promise.resolve(); } /** * Применить оптимизации Babylon ко всем proto-мешам террейна. * Вызывается после loadFromArray (когда мир статичный). * * 1. Per-mesh thin-instance octree — frustum culling per-instance. * Babylon разбивает 100К матриц на пространственные partitions * и рендерит только видимые на экране. Это самая мощная * оптимизация для thin-instances. БЕЗОПАСНО (это per-mesh API, * не глобальный scene.createOrUpdateSelectionOctree). * * 2. material.freeze() — запрещает Babylon пересчитывать шейдер * каждый кадр. Экономит CPU на больших сценах. * * 3. proto.freezeWorldMatrix() — proto-mesh стоит в (0,0,0), * world matrix не меняется. Экономит matrix-recompute каждый кадр. */ /** * Собрать MERGED геометрию для региона: positions/normals/uvs/indices. * Добавляем только видимые грани (где сосед в this.voxels отсутствует * или прозрачный). * * Грани куба и UV — стандартный layout MeshBuilder.CreateBox. * * @param {Array<[x,y,z]>} cells - voxel'ы региона * @returns {{positions:Float32Array, normals:Float32Array, uvs:Float32Array, * indices:Uint32Array, bbox:{minX,minY,minZ,maxX,maxY,maxZ}} | null} */ _buildMergedRegionGeometry(cells) { // 6 граней куба: [normalAxis, normalSign, dx, dy, dz, faceCorners[4]] // Каждая грань — 4 угла в локальных координатах [-0.5..+0.5]. // dx/dy/dz — направление соседа (для surface culling). const HALF = VOXEL_SIZE * 0.5; const FACES = [ // +X (right). sign=+1, dx=+1 { n: [1, 0, 0], dx: 1, dy: 0, dz: 0, c: [ [HALF, -HALF, -HALF], [HALF, HALF, -HALF], [HALF, HALF, HALF], [HALF, -HALF, HALF], ]}, // -X (left). dx=-1 { n: [-1, 0, 0], dx: -1, dy: 0, dz: 0, c: [ [-HALF, -HALF, HALF], [-HALF, HALF, HALF], [-HALF, HALF, -HALF], [-HALF, -HALF, -HALF], ]}, // +Y (top). dy=+1 { n: [0, 1, 0], dx: 0, dy: 1, dz: 0, c: [ [-HALF, HALF, -HALF], [-HALF, HALF, HALF], [HALF, HALF, HALF], [HALF, HALF, -HALF], ]}, // -Y (bottom). dy=-1 { n: [0, -1, 0], dx: 0, dy: -1, dz: 0, c: [ [-HALF, -HALF, HALF], [-HALF, -HALF, -HALF], [HALF, -HALF, -HALF], [HALF, -HALF, HALF], ]}, // +Z (back). dz=+1 { n: [0, 0, 1], dx: 0, dy: 0, dz: 1, c: [ [HALF, -HALF, HALF], [HALF, HALF, HALF], [-HALF, HALF, HALF], [-HALF, -HALF, HALF], ]}, // -Z (front). dz=-1 { n: [0, 0, -1], dx: 0, dy: 0, dz: -1, c: [ [-HALF, -HALF, -HALF], [-HALF, HALF, -HALF], [HALF, HALF, -HALF], [HALF, -HALF, -HALF], ]}, ]; const positions = []; const normals = []; const uvs = []; const indices = []; let vIdx = 0; let minX = Infinity, minY = Infinity, minZ = Infinity; let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity; // Числовой occupancySet — должен быть построен в _buildRegionMeshes // перед вызовом этого метода. Surface culling делается через bit-packed // numeric key (inline для JIT). const occSet = this._occupancySet; const voxels = this.voxels; // UV-генератор: world-tiled, 1 тайл текстуры = 4 метра. // На дистанции 30м тайл занимает ~80px экрана, паттерн читается. const UV_SCALE = 0.25; function pushUVsForFace(axis, p0, p1, p2, p3) { const s = UV_SCALE; if (axis === 0) { uvs.push(p0[2]*s, p0[1]*s, p1[2]*s, p1[1]*s, p2[2]*s, p2[1]*s, p3[2]*s, p3[1]*s); } else if (axis === 1) { uvs.push(p0[0]*s, p0[2]*s, p1[0]*s, p1[2]*s, p2[0]*s, p2[2]*s, p3[0]*s, p3[2]*s); } else { uvs.push(p0[0]*s, p0[1]*s, p1[0]*s, p1[1]*s, p2[0]*s, p2[1]*s, p3[0]*s, p3[1]*s); } } const FACE_AXIS = [0, 0, 1, 1, 2, 2]; if (!occSet) { for (let i = 0; i < cells.length; i++) { const [x, y, z] = cells[i]; const cx = (x + 0.5) * VOXEL_SIZE; const cy = (y + 0.5) * VOXEL_SIZE; const cz = (z + 0.5) * VOXEL_SIZE; if (cx - HALF < minX) minX = cx - HALF; if (cx + HALF > maxX) maxX = cx + HALF; if (cy - HALF < minY) minY = cy - HALF; if (cy + HALF > maxY) maxY = cy + HALF; if (cz - HALF < minZ) minZ = cz - HALF; if (cz + HALF > maxZ) maxZ = cz + HALF; for (let f = 0; f < 6; f++) { const face = FACES[f]; const nKey = `${x + face.dx},${y + face.dy},${z + face.dz}`; if (voxels.has(nKey)) continue; const c0 = face.c[0], c1 = face.c[1], c2 = face.c[2], c3 = face.c[3]; const wp0 = [cx + c0[0], cy + c0[1], cz + c0[2]]; const wp1 = [cx + c1[0], cy + c1[1], cz + c1[2]]; const wp2 = [cx + c2[0], cy + c2[1], cz + c2[2]]; const wp3 = [cx + c3[0], cy + c3[1], cz + c3[2]]; positions.push( wp0[0], wp0[1], wp0[2], wp1[0], wp1[1], wp1[2], wp2[0], wp2[1], wp2[2], wp3[0], wp3[1], wp3[2], ); const nx = face.n[0], ny = face.n[1], nz = face.n[2]; normals.push(nx, ny, nz, nx, ny, nz, nx, ny, nz, nx, ny, nz); pushUVsForFace(FACE_AXIS[f], wp0, wp1, wp2, wp3); 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), bbox: { minX, minY, minZ, maxX, maxY, maxZ }, }; } for (let i = 0; i < cells.length; i++) { const cell = cells[i]; const x = cell[0], y = cell[1], z = cell[2]; const cx = (x + 0.5) * VOXEL_SIZE; const cy = (y + 0.5) * VOXEL_SIZE; const cz = (z + 0.5) * VOXEL_SIZE; if (cx - HALF < minX) minX = cx - HALF; if (cx + HALF > maxX) maxX = cx + HALF; if (cy - HALF < minY) minY = cy - HALF; if (cy + HALF > maxY) maxY = cy + HALF; if (cz - HALF < minZ) minZ = cz - HALF; if (cz + HALF > maxZ) maxZ = cz + HALF; for (let f = 0; f < 6; f++) { const face = FACES[f]; const nx_i = x + face.dx; const ny_i = y + face.dy; const nz_i = z + face.dz; const packed = ((nx_i + 2048) & 4095) * 16777216 + ((ny_i + 2048) & 4095) * 4096 + ((nz_i + 2048) & 4095); if (occSet.has(packed)) continue; const c0 = face.c[0], c1 = face.c[1], c2 = face.c[2], c3 = face.c[3]; const wp0 = [cx + c0[0], cy + c0[1], cz + c0[2]]; const wp1 = [cx + c1[0], cy + c1[1], cz + c1[2]]; const wp2 = [cx + c2[0], cy + c2[1], cz + c2[2]]; const wp3 = [cx + c3[0], cy + c3[1], cz + c3[2]]; positions.push( wp0[0], wp0[1], wp0[2], wp1[0], wp1[1], wp1[2], wp2[0], wp2[1], wp2[2], wp3[0], wp3[1], wp3[2], ); const nx = face.n[0], ny = face.n[1], nz = face.n[2]; normals.push(nx, ny, nz, nx, ny, nz, nx, ny, nz, nx, ny, nz); pushUVsForFace(FACE_AXIS[f], wp0, wp1, wp2, wp3); 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), bbox: { minX, minY, minZ, maxX, maxY, maxZ }, }; } /** * MERGED GEOMETRY для MultiCube-материалов (grass/trunk/trunk_white). * * Идея: разделить грани по типу текстуры (top / side / bottom) и собрать * 3 отдельных меша вместо thin-instances. Каждый меш использует обычный * StandardMaterial с одной текстурой → быстро, surface culling работает. * * Эффект на больших регионах: thin-instance grass ~10K кубов давал * 10K × 12 = 120K треугольников. С merged + surface culling — ~5-8K * (только видимые грани). Это ×15-20 уменьшение. * * @param {Array>} cells — [[x,y,z], ...] в этом регионе * @returns {Object|null} { top, side, bottom, bbox } — 3 группы геометрии * или null если регион пустой. * Каждая группа = { positions, normals, uvs, indices } * или null если такой грани не видно. */ _buildMultiCubeMergedRegion(cells) { const HALF = VOXEL_SIZE * 0.5; // ВСЕ 6 граней в одном массиве с тэгом группы (0=top, 1=side, 2=bottom). const FACES = [ { g: 0, n: [0, 1, 0], dx: 0, dy: 1, dz: 0, c: [ [-HALF, HALF, -HALF], [-HALF, HALF, HALF], [HALF, HALF, HALF], [HALF, HALF, -HALF], ]}, { g: 1, n: [1, 0, 0], dx: 1, dy: 0, dz: 0, c: [ [HALF, -HALF, -HALF], [HALF, HALF, -HALF], [HALF, HALF, HALF], [HALF, -HALF, HALF], ]}, { g: 1, n: [-1, 0, 0], dx: -1, dy: 0, dz: 0, c: [ [-HALF, -HALF, HALF], [-HALF, HALF, HALF], [-HALF, HALF, -HALF], [-HALF, -HALF, -HALF], ]}, { g: 1, n: [0, 0, 1], dx: 0, dy: 0, dz: 1, c: [ [HALF, -HALF, HALF], [HALF, HALF, HALF], [-HALF, HALF, HALF], [-HALF, -HALF, HALF], ]}, { g: 1, n: [0, 0, -1], dx: 0, dy: 0, dz: -1, c: [ [-HALF, -HALF, -HALF], [-HALF, HALF, -HALF], [HALF, HALF, -HALF], [HALF, -HALF, -HALF], ]}, { g: 2, n: [0, -1, 0], dx: 0, dy: -1, dz: 0, c: [ [-HALF, -HALF, HALF], [-HALF, -HALF, -HALF], [HALF, -HALF, -HALF], [HALF, -HALF, HALF], ]}, ]; const acc = [ { positions: [], normals: [], uvs: [], indices: [], vIdx: 0 }, { positions: [], normals: [], uvs: [], indices: [], vIdx: 0 }, { positions: [], normals: [], uvs: [], indices: [], vIdx: 0 }, ]; let minX = Infinity, minY = Infinity, minZ = Infinity; let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity; const occSet = this._occupancySet; const voxels = this.voxels; const hasOcc = !!occSet; for (let i = 0; i < cells.length; i++) { const [x, y, z] = cells[i]; const cx = (x + 0.5) * VOXEL_SIZE; const cy = (y + 0.5) * VOXEL_SIZE; const cz = (z + 0.5) * VOXEL_SIZE; if (cx - HALF < minX) minX = cx - HALF; if (cx + HALF > maxX) maxX = cx + HALF; if (cy - HALF < minY) minY = cy - HALF; if (cy + HALF > maxY) maxY = cy + HALF; if (cz - HALF < minZ) minZ = cz - HALF; if (cz + HALF > maxZ) maxZ = cz + HALF; for (let f = 0; f < 6; f++) { const face = FACES[f]; if (hasOcc) { const nx_i = x + face.dx; const ny_i = y + face.dy; const nz_i = z + face.dz; const packed = ((nx_i + 2048) & 4095) * 16777216 + ((ny_i + 2048) & 4095) * 4096 + ((nz_i + 2048) & 4095); if (occSet.has(packed)) continue; } else { const nKey = `${x + face.dx},${y + face.dy},${z + face.dz}`; if (voxels.has(nKey)) continue; } const A = acc[face.g]; const c0 = face.c[0], c1 = face.c[1], c2 = face.c[2], c3 = face.c[3]; A.positions.push( cx + c0[0], cy + c0[1], cz + c0[2], cx + c1[0], cy + c1[1], cz + c1[2], cx + c2[0], cy + c2[1], cz + c2[2], cx + c3[0], cy + c3[1], cz + c3[2], ); const nx = face.n[0], ny = face.n[1], nz = face.n[2]; A.normals.push(nx, ny, nz, nx, ny, nz, nx, ny, nz, nx, ny, nz); A.uvs.push(0, 0, 0, 1, 1, 1, 1, 0); A.indices.push(A.vIdx, A.vIdx + 1, A.vIdx + 2, A.vIdx, A.vIdx + 2, A.vIdx + 3); A.vIdx += 4; } } const pack = (A) => { if (A.vIdx === 0) return null; return { positions: new Float32Array(A.positions), normals: new Float32Array(A.normals), uvs: new Float32Array(A.uvs), indices: new Uint32Array(A.indices), }; }; const top = pack(acc[0]); const side = pack(acc[1]); const bottom = pack(acc[2]); if (!top && !side && !bottom) return null; return { top, side, bottom, bbox: { minX, minY, minZ, maxX, maxY, maxZ }, }; } /** * Получить (создать если нет) 3 раздельных StandardMaterial для * MultiCube-материала (top/side/bottom). Используются для merged-mesh. * Кэшируется в this._multiSubMats: Map. */ _getMultiSubMaterials(matId) { if (!this._multiSubMats) this._multiSubMats = new Map(); if (this._multiSubMats.has(matId)) return this._multiSubMats.get(matId); const def = TERRAIN_MATERIALS[matId]; if (!def) return null; const matTop = this._createSingleMat(def, def.top || def.side, `__terrainMatSub_${matId}_top`); const matSide = this._createSingleMat(def, def.side || def.top, `__terrainMatSub_${matId}_side`); const matBottom = this._createSingleMat(def, def.bottom || def.side, `__terrainMatSub_${matId}_bot`); try { matTop.freeze?.(); matSide.freeze?.(); matBottom.freeze?.(); } catch (e) {} const entry = { top: matTop, side: matSide, bottom: matBottom }; this._multiSubMats.set(matId, entry); return entry; } /** * Разбить voxel'и на region-meshes 64×64×64 grid units (~16×16×16 м). * Это позволяет Babylon делать frustum culling per-region и НЕ рендерить * regions за пределами вида камеры. * * Алгоритм: * 1. Группируем voxel'и по (matId, regionX, regionZ) — bucket'ы по 64×64 * 2. Для каждого bucket создаём отдельный proto-mesh (клон основного) * 3. Заливаем thin-instance матрицы только этого bucket * 4. Babylon культит per-mesh — невидимые regions не рендерятся * * Оригинальные proto-meshes (один на материал на всю карту) отключаются. * Они остаются для совместимости с editing API (_cellToInst), но при * region-mode они НЕ рендерятся. * * @param {Map} byMat - {matId → [[x,y,z], ...]} */ async _buildRegionMeshes(byMat) { // REGION_SIZE = 256 grid units = 64м при VOXEL_SIZE=0.25. const REGION_SIZE = 256; this._regionSize = REGION_SIZE; // =========================================================== // LAZY REGION BUILD — главное ускорение загрузки. // // Идея: НЕ строим геометрию всех регионов сразу. На больших // картах это занимает 30+ сек. Вместо этого: // 1. Здесь — быстро строим _pendingRegions: Map // Это ~1-2 сек на 5.7M voxels (просто группировка cells). // 2. Streaming-loop вызывает _materializeRegion(key) только для // тех ключей которые в радиусе видимости. Регионы вне radius // остаются pending — никакой работы не сделано. // // На большой карте 330м с radius 72м: в радиусе ~30 регионов из 284. // Стоит построить только их — это ~3 сек вместо 30 сек. // // При движении камеры новые регионы строятся по требованию (1-2 // региона за 100-300мс в момент захода в видимость). // =========================================================== const yieldUI = async () => { // Отдаём контроль браузеру: React обновит overlay-прогресс await new Promise(r => setTimeout(r, 0)); }; // Пропускаем region-split для маленьких карт. // Понизили порог с 30K до 15K voxels — на 100м карте уже видны лаги // без streaming. let totalCells = 0; for (const cells of byMat.values()) totalCells += cells.length; if (totalCells < 15000) { // Малая карта — оставляем 1 mesh на материал console.log(`[TerrainManager] skip region-split (${totalCells} voxels < 15000)`); return; } const t0 = performance.now(); // Очищаем старые region-meshes если были (повторный loadFromArray) this._disposeRegionMeshes(); // ОПТИМИЗАЦИЯ surface culling: вместо `this.voxels.has("x,y,z")` // делаем числовой Set ОДИН РАЗ. Bit-pack: координаты в [-2048..2047] // (12 бит), upacked = (x+2048)&4095, total 36 бит — помещается в Number. // Inline без замыканий — JIT даёт лучшую оптимизацию. const t1 = performance.now(); const occupancySet = new Set(); for (const cells of byMat.values()) { for (let i = 0; i < cells.length; i++) { const c = cells[i]; const x = c[0], y = c[1], z = c[2]; occupancySet.add(((x + 2048) & 4095) * 16777216 + ((y + 2048) & 4095) * 4096 + ((z + 2048) & 4095)); } } console.log(`[TerrainManager] occupancy-set built: ${occupancySet.size} keys in ${(performance.now() - t1).toFixed(0)}ms`); // Сохраняем для использования в _buildMergedRegionGeometry и _buildMultiCubeMergedRegion. this._occupancySet = occupancySet; if (!this._regionMeshes) this._regionMeshes = new Map(); // Pending regions — план который будет материализован по запросу // (streaming-loop). Каждая запись = "${matId}:${rx},${rz}" → {matId, cells, isMulti, bbox, centerX, centerZ, halfDiag} this._pendingRegions = new Map(); // Фаза 1: группировка cells по (matId, region). БЫСТРО. // Считаем bbox региона прямо здесь — не нужно строить меш. let pendingCount = 0; let stage = 0; const totalStages = byMat.size; for (const [matId, cells] of byMat) { if (cells.length === 0) { stage++; continue; } const def = TERRAIN_MATERIALS[matId]; if (!def) { stage++; continue; } const matEntry = this._getMaterial(matId); const isMulti = !!matEntry.isMulti; // Группируем по (rx, rz) И считаем bbox прямо в проходе const byRegion = new Map(); for (let i = 0; i < cells.length; i++) { const c = cells[i]; const x = c[0], y = c[1], z = c[2]; const rx = Math.floor(x / REGION_SIZE); const rz = Math.floor(z / REGION_SIZE); const key = rx + ',' + rz; let bucket = byRegion.get(key); if (!bucket) { bucket = { cells: [], minX: Infinity, minY: Infinity, minZ: Infinity, maxX: -Infinity, maxY: -Infinity, maxZ: -Infinity, }; byRegion.set(key, bucket); } bucket.cells.push(c); const wx = (x + 0.5) * VOXEL_SIZE; const wy = (y + 0.5) * VOXEL_SIZE; const wz = (z + 0.5) * VOXEL_SIZE; if (wx < bucket.minX) bucket.minX = wx; if (wx > bucket.maxX) bucket.maxX = wx; if (wy < bucket.minY) bucket.minY = wy; if (wy > bucket.maxY) bucket.maxY = wy; if (wz < bucket.minZ) bucket.minZ = wz; if (wz > bucket.maxZ) bucket.maxZ = wz; } // Сохраняем pending — НЕ строим меш for (const [regionKey, bucket] of byRegion) { const fullKey = matId + ':' + regionKey; const halfX = (bucket.maxX - bucket.minX) * 0.5; const halfZ = (bucket.maxZ - bucket.minZ) * 0.5; this._pendingRegions.set(fullKey, { matId, isMulti, cells: bucket.cells, bbox: { minX: bucket.minX, minY: bucket.minY, minZ: bucket.minZ, maxX: bucket.maxX, maxY: bucket.maxY, maxZ: bucket.maxZ, }, centerX: (bucket.minX + bucket.maxX) * 0.5, centerZ: (bucket.minZ + bucket.maxZ) * 0.5, halfDiag: Math.sqrt(halfX * halfX + halfZ * halfZ), }); pendingCount++; } // Отключаем основной proto-mesh — региональные его заменяют const mainProto = this._protoMeshes.get(matId); if (mainProto) mainProto.setEnabled(false); stage++; // Обновление прогресс-бара и yield UI if (typeof window !== 'undefined') { const pct = 40 + Math.floor((stage / Math.max(1, totalStages)) * 30); window.__kubikonLoadProgress = { percent: Math.min(70, pct), label: `Планирование: ${matId} (${stage} / ${totalStages})`, ts: performance.now(), }; } await yieldUI(); } const dt = performance.now() - t0; console.log(`[TerrainManager] region-plan: ${pendingCount} pending regions in ${dt.toFixed(0)}ms (lazy build on streaming)`); // НЕ освобождаем occupancySet — он нужен для lazy build регионов. // Освободим в _disposeRegionMeshes или после полной материализации. // Фаза 2: материализуем ВИДИМЫЕ регионы синхронно (для красивого // первого кадра без пустоты). Streaming-loop потом достроит остальные // лениво. Используем текущую камеру или (0,0,0) по умолчанию. let camX = 0, camZ = 0, radius = 60; try { const cam = this.scene?.activeCamera; if (cam?.position) { camX = cam.position.x; camZ = cam.position.z; } } catch (e) {} // editor-like radius (немного шире чем play radius=40м) radius = 72; if (typeof window !== 'undefined') { window.__kubikonLoadProgress = { percent: 70, label: 'Построение видимой области…', ts: performance.now() }; } await yieldUI(); // Собираем все pending ключи которые попадают в видимый радиус const toBuildKeys = []; for (const [key, info] of this._pendingRegions) { const dx = info.centerX - camX; const dz = info.centerZ - camZ; const d2 = dx * dx + dz * dz; const cutoff = radius + info.halfDiag * 0.5; if (d2 <= cutoff * cutoff) toBuildKeys.push(key); } let built = 0; const totalToBuild = toBuildKeys.length; for (const key of toBuildKeys) { this._materializeRegion(key); built++; // Прогресс + yield каждые ~5 регионов чтобы React успел отрисовать if (built % 5 === 0) { if (typeof window !== 'undefined' && totalToBuild > 0) { const pct = 70 + Math.floor((built / totalToBuild) * 25); window.__kubikonLoadProgress = { percent: Math.min(95, pct), label: `Построение мира: ${built} / ${totalToBuild} регионов`, ts: performance.now(), }; } await yieldUI(); } } const dtTotal = performance.now() - t0; console.log(`[TerrainManager] region-split: ${built} visible regions materialized (${this._pendingRegions.size - built} pending) in ${dtTotal.toFixed(0)}ms`); } /** * Материализовать (построить геометрию) одного pending региона. * Вызывается из updateStreaming при первом попадании региона в видимость. * Возвращает true если регион был построен, false если уже был построен или не найден. */ _materializeRegion(fullKey) { if (!this._pendingRegions) return false; const info = this._pendingRegions.get(fullKey); if (!info) return false; // Удаляем из pending — больше не нужно this._pendingRegions.delete(fullKey); const { matId, isMulti, cells, bbox } = info; const matEntry = this._getMaterial(matId); if (!matEntry) return false; const N = cells.length; if (N === 0) return false; // Парсим regionKey "rx,rz" из fullKey "matId:rx,rz" const colonIdx = fullKey.indexOf(':'); const regionKey = fullKey.slice(colonIdx + 1); const [rx, rz] = regionKey.split(',').map(Number); const regionMeshName = `__terrainProtoR_${matId}_${rx}_${rz}`; let regionMesh; if (isMulti) { // === thin-instance путь для MultiCube === regionMesh = MeshBuilder.CreateBox(regionMeshName, { size: VOXEL_SIZE }, this.scene); regionMesh.material = matEntry.material; this._cutCubeFaces(regionMesh); const buffer = new Float32Array(16 * N); for (let i = 0; i < N; i++) { const [x, y, z] = cells[i]; const wx = (x + 0.5) * VOXEL_SIZE; const wy = (y + 0.5) * VOXEL_SIZE; const wz = (z + 0.5) * VOXEL_SIZE; const off = i * 16; buffer[off] = 1; buffer[off+1] = 0; buffer[off+2] = 0; buffer[off+3] = 0; buffer[off+4] = 0; buffer[off+5] = 1; buffer[off+6] = 0; buffer[off+7] = 0; buffer[off+8] = 0; buffer[off+9] = 0; buffer[off+10]= 1; buffer[off+11]= 0; buffer[off+12]= wx; buffer[off+13]= wy; buffer[off+14]= wz; buffer[off+15]= 1; } try { regionMesh.thinInstanceSetBuffer('matrix', buffer, 16); } catch (e) {} regionMesh.alwaysSelectAsActiveMesh = true; regionMesh.doNotSyncBoundingInfo = true; } else { // === MERGED GEOMETRY (для одно-материальных кубов) === const built = this._buildMergedRegionGeometry(cells); if (!built) return false; regionMesh = new Mesh(regionMeshName, this.scene); const vd = new VertexData(); vd.positions = built.positions; vd.normals = built.normals; vd.uvs = built.uvs; vd.indices = built.indices; vd.applyToMesh(regionMesh, false); regionMesh.material = matEntry.material; regionMesh.alwaysSelectAsActiveMesh = false; } regionMesh.metadata = { _isTerrainProto: true, _isRegionMesh: true, terrainMatId: matId }; // isPickable=false: оставляем как было. Region-mesh бывает двух // типов — merged-геометрия и thin-instance путь. Для thin-instance // меша isPickable=true ломал рендер граней. Постановка моделей на // воксельный террейн решается через свой DDA-raycast по воксельной // сетке (см. _raycastVoxelSurface), а не через scene.pick. regionMesh.isPickable = false; regionMesh.receiveShadows = true; try { regionMesh.thinInstanceEnablePicking = false; } catch (e) {} const pad = VOXEL_SIZE * 0.5; try { regionMesh.setBoundingInfo(new BoundingInfo( new Vector3(bbox.minX - pad, bbox.minY - pad, bbox.minZ - pad), new Vector3(bbox.maxX + pad, bbox.maxY + pad, bbox.maxZ + pad), )); } catch (e) {} regionMesh.metadata.regionCenterX = info.centerX; regionMesh.metadata.regionCenterZ = info.centerZ; regionMesh.metadata.regionHalfDiag = info.halfDiag; this._regionMeshes.set(fullKey, regionMesh); return true; } /** * STREAMING: enable/disable region-meshes по расстоянию от камеры. * Регионы вне radius — отключаются (setEnabled false), не рендерятся. * Регионы внутри — включены. * * Вызывается из game-loop раз в ~150мс (или при изменении камеры на > 4м). * * @param {number} camX - X камеры в мире * @param {number} camZ - Z камеры в мире * @param {number} radius - радиус включения в метрах (default 60м) * @returns {{enabled: number, disabled: number, total: number}} */ updateStreaming(camX, camZ, radius = 60) { if (!this._regionMeshes) this._regionMeshes = new Map(); let enabled = 0, disabled = 0; // === ШАГ 1: материализуем pending регионы попавшие в радиус === // Это даёт lazy load — карта появляется по мере приближения камеры. // Ограничение: материализуем не более 8 регионов за один updateStreaming // (50-200мс работы), чтобы не вешать UI. Остальное достроится при // следующем вызове. if (this._pendingRegions && this._pendingRegions.size > 0) { const candidates = []; for (const [key, info] of this._pendingRegions) { const dx = info.centerX - camX; const dz = info.centerZ - camZ; const d2 = dx * dx + dz * dz; const cutoff = radius + info.halfDiag * 0.5; if (d2 <= cutoff * cutoff) { candidates.push({ key, d2 }); } } // Сортируем по расстоянию — материализуем ближайшие сначала candidates.sort((a, b) => a.d2 - b.d2); const limit = Math.min(8, candidates.length); for (let i = 0; i < limit; i++) { this._materializeRegion(candidates[i].key); } // Если pending пуст — освобождаем occupancySet (~45МБ heap) if (this._pendingRegions.size === 0 && this._occupancySet) { this._occupancySet = null; console.log('[TerrainManager] all regions materialized, occupancy-set freed'); } } if (this._regionMeshes.size === 0) { return { enabled: 0, disabled: 0, total: 0 }; } // === ШАГ 2: enable/disable существующих региональных мешей === for (const mesh of this._regionMeshes.values()) { const md = mesh.metadata; if (!md) continue; const dx = md.regionCenterX - camX; const dz = md.regionCenterZ - camZ; const d2 = dx * dx + dz * dz; const halfDiag = (md.regionHalfDiag ?? 0) * 0.5; const cutoff = radius + halfDiag; const shouldRender = d2 <= cutoff * cutoff; if (shouldRender) { if (!mesh.isEnabled()) mesh.setEnabled(true); enabled++; } else { if (mesh.isEnabled()) mesh.setEnabled(false); disabled++; } } return { enabled, disabled, total: this._regionMeshes.size }; } /** Количество regions для отладки (built + pending). */ getRegionCount() { const built = this._regionMeshes ? this._regionMeshes.size : 0; const pending = this._pendingRegions ? this._pendingRegions.size : 0; return built + pending; } /** * Включить picking воксельных мешей для raycast камеры (режим play). * * По умолчанию все proto- и region-меши имеют isPickable=false ради * производительности — но из-за этого camera _clampCameraToWorld не * может «увидеть» воксели в Ray-каст, и камера пролетает сквозь * стены. Включаем picking только на время play. * * ВАЖНО: для thin-instance мешей isPickable=true ЛОМАЕТ РЕНДЕР граней * (видна только одна грань вместо 6). Поэтому picking включаем * ТОЛЬКО для merged-mesh (regionMesh без thin-instance). Это и * есть основная масса воксельного террейна — multi-material материалы * (типа grass с разными top/side) идут через thin-instance, обычные * (rock/mud/dirt) — через merged. У merged-меша frustum в порядке. */ enablePickingForCamera(enable) { const flag = !!enable; if (this._regionMeshes) { for (const m of this._regionMeshes.values()) { let isThinInst = false; try { isThinInst = (m.thinInstanceCount || 0) > 0; } catch (e) {} if (isThinInst) continue; try { m.isPickable = flag; } catch (e) {} } } } /** Очистить все region-meshes (при clear / повторной загрузке). */ _disposeRegionMeshes() { if (this._pendingRegions) this._pendingRegions.clear(); this._occupancySet = null; if (!this._regionMeshes) return; for (const m of this._regionMeshes.values()) { try { m.dispose(); } catch (e) {} } this._regionMeshes.clear(); // Снова включаем основные proto-meshes (для совместимости) for (const proto of this._protoMeshes.values()) { try { proto.setEnabled(true); } catch (e) {} } } _optimizeForRender() { const t0 = performance.now(); let frozen = 0; for (const [matId, proto] of this._protoMeshes) { // НЕ используем octree и НЕ меняем alwaysSelectAsActiveMesh: // octree thin-instance в Babylon некорректно работает с большими // thin-instance grids — кроны деревьев пропадают под определёнными // углами камеры из-за плохой партиции по bbox. Лучше оставить // alwaysSelectAsActiveMesh=true (всегда рендерим) и оптимизировать // другими способами. // freezeWorldMatrix — proto-mesh не двигается, экономит CPU // на пересчёт world matrix каждый кадр. try { proto.freezeWorldMatrix(); frozen++; } catch (e) {} // material.freeze() — без перекомпиляции шейдера каждый кадр. // Безопасно потому что мы не меняем свойства материалов после // загрузки (alpha, emissive и т.п. установлены при _createSingleMat). try { if (proto.material && typeof proto.material.freeze === 'function') { proto.material.freeze(); } } catch (e) {} } const dt = performance.now() - t0; console.log(`[TerrainManager] optimized for render in ${dt.toFixed(0)}ms: frozen=${frozen} of ${this._protoMeshes.size} protos`); } /** * Снять оптимизации (нужно перед редактированием через кисти). * Кисти модифицируют thin-instances → octree становится невалидным, * material/world freeze тоже мешают. Вызывается из brushDraw/Sculpt/... */ _unfreezeForEdit() { for (const proto of this._protoMeshes.values()) { try { proto.unfreezeWorldMatrix(); } catch (e) {} try { if (proto.material && typeof proto.material.unfreeze === 'function') { proto.material.unfreeze(); } } catch (e) {} // octree автоматически инвалидируется при thinInstanceAdd/Remove } } /** Нормализация grass на ГОЛЫХ ДАННЫХ — переводит все grass-voxel'ы * под верхушкой столбца в dirt. Работает с this.voxels-Map и * byMat-словарём, БЕЗ создания thin-instance'ов (используется * только в loadFromArray). */ _normalizeGrassData(byMat) { const grassCells = byMat.get('grass'); if (!grassCells || grassCells.length === 0) return; // Группируем grass-voxel'ы по столбцу (x,z) → массив Y const colYs = new Map(); // "x,z" → [y, y, y, ...] for (const [x, y, z] of grassCells) { const k = `${x},${z}`; let arr = colYs.get(k); if (!arr) { arr = []; colYs.set(k, arr); } arr.push(y); } // Для каждого столбца: оставляем максимум, остальное → dirt const newGrass = []; const movedToDirt = []; for (const [colKey, ys] of colYs) { if (ys.length === 1) { newGrass.push(this._parseColKeyToXZ(colKey, ys[0])); continue; } let maxY = ys[0]; for (let i = 1; i < ys.length; i++) if (ys[i] > maxY) maxY = ys[i]; const onlyComma = colKey.lastIndexOf(','); const x = parseInt(colKey.slice(0, onlyComma), 10); const z = parseInt(colKey.slice(onlyComma + 1), 10); for (const y of ys) { if (y === maxY) { newGrass.push([x, y, z]); } else { movedToDirt.push([x, y, z]); this.voxels.set(`${x},${y},${z}`, 'dirt'); } } } // Обновляем bucket'ы byMat.set('grass', newGrass); let dirtBucket = byMat.get('dirt'); if (!dirtBucket) { dirtBucket = []; byMat.set('dirt', dirtBucket); } for (const c of movedToDirt) dirtBucket.push(c); } _parseColKeyToXZ(colKey, y) { const onlyComma = colKey.lastIndexOf(','); return [ parseInt(colKey.slice(0, onlyComma), 10), y, parseInt(colKey.slice(onlyComma + 1), 10), ]; } /** Освобождение ресурсов при выходе из редактора. * В _materials лежат entry-объекты {material, isMulti}, диспозим .material — * Babylon у MultiMaterial автоматически диспозит и subMaterials. */ dispose() { for (const proto of this._protoMeshes.values()) { try { proto.dispose(); } catch (e) {} } for (const entry of this._materials.values()) { try { entry?.material?.dispose?.(); } catch (e) {} } this._protoMeshes.clear(); this._materials.clear(); this._instanceKeys.clear(); this._freeSlots.clear(); this._cellToInst.clear(); this.voxels.clear(); } } // Список ID для проверок снаружи (если понадобится). export { TERRAIN_MATERIAL_IDS };