Some checks failed
Движок: 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>
2374 lines
114 KiB
JavaScript
2374 lines
114 KiB
JavaScript
/**
|
||
* 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 };
|