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