studio/src/editor/engine/TerrainManager.js
min ee1b7352b7
Some checks failed
CI / Lint (pull_request) Successful in 1m15s
CI / Build (pull_request) Successful in 1m58s
CI / Secret scan (pull_request) Failing after 9s
CI / PR size check (pull_request) Successful in 6s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
feat(11): placement mode — расстановка предметов (tycoon)
Движок: PlacementManager (тень-превью формой воксельной модели за курсором,
снап к сетке, стопка, проверка зоны и баланса, поворот R/колесо, ПКМ/Esc),
ShopInventoryUi (магазин-слоты, авто-серые при нехватке валюты); проводка
game.placement.* и game.inventoryUi.* в worker/GameRuntime/BabylonScene.

Попутные фиксы:
- TerrainManager: backFaceCulling=false — воксели не просвечивают (видна была
  задняя грань сквозь переднюю);
- KubikonEditor: guard от потери userModels/scripts при частичной загрузке
  (terrain догрузился, модели/скрипт нет → автосейв затирал) — критичный
  фикс защиты данных для ВСЕХ игр;
- Hotbar: пустой инвентарь не показывает панель (глобальное правило);
- MinimapOverlay: миникарта только по флагу игры (не авто на больших картах);
- cleanup usermodel-инстансов при Stop.

Вики: карточка #58 + статья-урок «Мой завод» (g5 Разбор готовых игр),
openProjectId=2345, скриншоты залиты на прод.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 19:06:03 +03:00

2374 lines
114 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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<key|null> — индекс слота instance'а → ключ voxel. */
this._instanceKeys = new Map();
/** matId → Array<idx> — свободные слоты (после удаления). */
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": "<base64 of RLE bytes>", // только непустые чанки
* ...
* }
* }
*
* @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<Array<number>>} 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<matId, {top, side, bottom}>.
*/
_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<string, Array>} 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<key, {matId, cells, isMulti}>
// Это ~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 };