player/src/engine/DecoManager.js
МИН 87444ee2c8 Initial public release: Rublox Player v1.0
Open-source web player for Rublox games, dual-licensed under
AGPL-3.0 + Commercial.

Highlights:
- Babylon.js 7 + React 18 + Vite 5 stack
- Self-contained engine (~46k lines): BlockManager, ModelManager,
  PlayerController, ScriptSandboxWorker, MultiplayerSync, 30+ GD
  gamemodes
- Configurable backend via VITE_API_BASE and friends — works against
  staging (dev-api.rublox.pro) out of the box
- Standalone mode (VITE_STANDALONE=true) loads a bundled sample game
  for first-run without any backend
- Full docs: README, ARCHITECTURE, CONTRIBUTING, SECURITY, CHANGELOG
- Lint + format scaffolding (ESLint + Prettier + EditorConfig)
- Legal: LICENSE (AGPL-3.0), LICENSE-COMMERCIAL.md, CLA.md, COPYRIGHT.md
- Issue templates: bug_report, feature_request, security_disclosure

Removed before public release:
- frontend_deploy.py (contained production SSH credentials)
- ~27 admin endpoints (kept in private repo)
- Hard-coded internal URLs and IPs
- All previous git history (clean repo init)
2026-05-27 23:04:04 +03:00

635 lines
29 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

/**
* 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();
}
}