studio/src/editor/engine/robloxterrain/SmoothDecoManager.js
МИН 31adbf151b Initial public release: Студия Рублокса v1.0
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)
2026-05-27 23:41:10 +03:00

856 lines
43 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.

/**
* SmoothDecoManager — менеджер декораций для гладкого ландшафта.
*
* Архитектура:
* - Каждая модель (GLB из nature-kit) загружается ОДИН РАЗ
* - Создаётся ОДИН Mesh-prototype с baked geometry+material
* - Все инстансы — через thin-instances (1 draw call на модель)
*
* FPS-оптимизация:
* - Hard cap инстансов 30K (был 50K)
* - Step grid поднят (трава раз в 1.5м вместо 0.7м, цветы 2м вместо 1м)
* - frustum culling Babylon включён через bounding info update
*/
import {
SceneLoader,
Mesh,
Matrix,
Quaternion,
Vector3,
} from '@babylonjs/core';
const ASSET_ROOT = '/kubikon-assets/models/nature-kit/';
/**
* Каталог декораций — scale подобран чтобы итог был 1.5-3м.
* Kenney модели сами по себе 0.13-0.29м, поэтому scale 7-13.
*/
// Размеры:
// - Цветы ~0.5-0.8м (до пояса персонажа)
// - Трава ~0.4-0.5м (до колен — как в Roblox)
// - Грибы ~0.3-0.6м (точечные акценты)
// Игрок ~2м — декорации заметно ниже.
export const DECO_CATALOG = {
flower_purpleA: { file: 'flower_purpleA.glb', kind: 'flower', scale: 2.5, biomes: ['plain', 'forest', 'oasis'] },
flower_purpleB: { file: 'flower_purpleB.glb', kind: 'flower', scale: 3.0, biomes: ['plain', 'forest', 'oasis'] },
flower_purpleC: { file: 'flower_purpleC.glb', kind: 'flower', scale: 3.5, biomes: ['plain', 'forest', 'oasis'] },
flower_redA: { file: 'flower_redA.glb', kind: 'flower', scale: 2.0, biomes: ['plain', 'forest', 'oasis'] },
flower_redB: { file: 'flower_redB.glb', kind: 'flower', scale: 2.5, biomes: ['plain', 'forest', 'oasis'] },
flower_redC: { file: 'flower_redC.glb', kind: 'flower', scale: 3.0, biomes: ['plain', 'forest', 'oasis'] },
flower_yellowA: { file: 'flower_yellowA.glb', kind: 'flower', scale: 3.5, biomes: ['plain', 'forest', 'oasis', 'desert'] },
flower_yellowB: { file: 'flower_yellowB.glb', kind: 'flower', scale: 4.0, biomes: ['plain', 'forest', 'oasis', 'desert'] },
flower_yellowC: { file: 'flower_yellowC.glb', kind: 'flower', scale: 4.5, biomes: ['plain', 'forest', 'oasis', 'desert'] },
grass: { file: 'grass.glb', kind: 'grass', scale: 2.0, biomes: ['plain', 'forest', 'oasis'] },
grass_large: { file: 'grass_large.glb', kind: 'grass', scale: 2.0, biomes: ['plain', 'forest', 'oasis'] },
grass_leafs: { file: 'grass_leafs.glb', kind: 'grass', scale: 3.5, biomes: ['plain', 'forest', 'oasis'] },
grass_leafsLarge:{ file: 'grass_leafsLarge.glb', kind: 'grass', scale: 3.5, biomes: ['plain', 'forest', 'oasis'] },
mushroom_red: { file: 'mushroom_red.glb', kind: 'mushroom', scale: 3.0, biomes: ['forest', 'oasis'] },
mushroom_redTall: { file: 'mushroom_redTall.glb', kind: 'mushroom', scale: 2.5, biomes: ['forest', 'oasis'] },
mushroom_redGroup: { file: 'mushroom_redGroup.glb', kind: 'mushroom', scale: 2.5, biomes: ['forest', 'oasis'] },
mushroom_tan: { file: 'mushroom_tan.glb', kind: 'mushroom', scale: 4.0, biomes: ['forest', 'oasis'] },
mushroom_tanTall: { file: 'mushroom_tanTall.glb', kind: 'mushroom', scale: 2.5, biomes: ['forest', 'oasis'] },
mushroom_tanGroup: { file: 'mushroom_tanGroup.glb', kind: 'mushroom', scale: 2.5, biomes: ['forest', 'oasis'] },
// === Деревья — высокие 4-8м объекты для леса/равнины/гор ===
// Лиственные: для plain/forest (зелёный лес)
tree_default: { file: 'tree_default.glb', kind: 'tree', scale: 6.0, biomes: ['plain', 'forest', 'oasis'] },
tree_simple: { file: 'tree_simple.glb', kind: 'tree', scale: 6.0, biomes: ['plain', 'forest', 'oasis'] },
tree_oak: { file: 'tree_oak.glb', kind: 'tree', scale: 7.0, biomes: ['plain', 'forest'] },
tree_detailed: { file: 'tree_detailed.glb', kind: 'tree', scale: 6.5, biomes: ['forest'] },
tree_fat: { file: 'tree_fat.glb', kind: 'tree', scale: 6.0, biomes: ['forest'] },
tree_thin: { file: 'tree_thin.glb', kind: 'tree', scale: 7.0, biomes: ['plain', 'forest'] },
tree_tall: { file: 'tree_tall.glb', kind: 'tree', scale: 8.0, biomes: ['forest'] },
tree_small: { file: 'tree_small.glb', kind: 'tree', scale: 4.5, biomes: ['plain', 'forest', 'oasis'] },
// Осенние: микс в обычные леса для разнообразия
tree_default_fall: { file: 'tree_default_fall.glb', kind: 'tree', scale: 6.0, biomes: ['plain', 'forest'] },
tree_oak_fall: { file: 'tree_oak_fall.glb', kind: 'tree', scale: 7.0, biomes: ['forest'] },
// Хвойные: для mountain/snow_peak (сосны/ели)
tree_pineDefaultA: { file: 'tree_pineDefaultA.glb', kind: 'tree', scale: 6.5, biomes: ['forest', 'mountain'] },
tree_pineRoundA: { file: 'tree_pineRoundA.glb', kind: 'tree', scale: 6.0, biomes: ['mountain'] },
tree_pineRoundC: { file: 'tree_pineRoundC.glb', kind: 'tree', scale: 6.5, biomes: ['mountain'] },
tree_pineTallA: { file: 'tree_pineTallA.glb', kind: 'tree', scale: 8.0, biomes: ['mountain', 'snow_peak'] },
tree_pineSmallA: { file: 'tree_pineSmallA.glb', kind: 'tree', scale: 4.0, biomes: ['mountain', 'snow_peak'] },
// Пальмы: для desert/beach (пляж/пустыня с оазисами)
tree_palm: { file: 'tree_palm.glb', kind: 'tree', scale: 7.0, biomes: ['beach', 'desert', 'oasis'] },
tree_palmShort: { file: 'tree_palmShort.glb', kind: 'tree', scale: 5.0, biomes: ['beach', 'desert'] },
tree_palmBend: { file: 'tree_palmBend.glb', kind: 'tree', scale: 7.0, biomes: ['beach', 'oasis'] },
// Кактусы для пустыни — вместо деревьев
cactus_tall: { file: 'cactus_tall.glb', kind: 'tree', scale: 4.0, biomes: ['desert'] },
cactus_short: { file: 'cactus_short.glb', kind: 'tree', scale: 3.0, biomes: ['desert'] },
};
export const DECO_KEYS = Object.keys(DECO_CATALOG);
export class SmoothDecoManager {
constructor(scene) {
this.scene = scene;
/** Map<decoKey, Mesh> */
this._protos = new Map();
/** Map<decoKey, Float32Array> — ПОЛНЫЕ буферы (для save/load и culling source) */
this._buffers = new Map();
/** Map<decoKey, Float32Array> — отфильтрованные буферы (видимые в radius) */
this._visibleBuffers = new Map();
this._loaded = false;
/** Distance culling: радиус видимости декораций */
this.viewRadius = 100;
this._cullObserver = null;
this._lastCullCheck = 0;
}
async loadAll() {
if (this._loaded) return;
const tasks = [];
for (const key of DECO_KEYS) {
tasks.push(this._loadOne(key));
}
await Promise.all(tasks);
this._loaded = true;
console.log(`[SmoothDecoManager] loaded ${this._protos.size}/${DECO_KEYS.length} models`);
}
async _loadOne(key) {
const def = DECO_CATALOG[key];
if (!def) return;
try {
const container = await SceneLoader.LoadAssetContainerAsync(
ASSET_ROOT, def.file, this.scene,
);
container.addAllToScene();
// Берём ВСЕ меши контейнера (включая __root__ и его потомков)
const allMeshes = container.meshes.slice();
const geoMeshes = allMeshes.filter((m) => m.getTotalVertices && m.getTotalVertices() > 0);
if (geoMeshes.length === 0) {
console.warn(`[SmoothDecoManager] ${key}: no geometry`);
return;
}
// === Bake вверх по иерархии ===
// Kenney glTF имеет __root__ с rotation Y-up→Z-up (поворот -PI/2 по X
// или просто scaling 100×). Если бэйкать только geometry-mesh с его
// local transform, накопленный transform parent'а ПОТЕРЯЕТСЯ.
// Поэтому форсируем computeWorldMatrix(true) на каждом — он
// рекурсивно вычислит ИЗ ROOT'а — и потом bakeCurrentTransform.
//
// bakeCurrentTransformIntoVertices() применяет mesh.computeWorldMatrix()
// — поэтому ВАЖНО что в этот момент transform-стек содержит root.
for (const m of geoMeshes) {
m.computeWorldMatrix(true); // force, накапливает parent chain
m.bakeCurrentTransformIntoVertices();
// ВАЖНО: после bake вершины уже содержат world transform.
// Отвязываем parent и сбрасываем local transform.
m.parent = null;
if (m.rotationQuaternion) m.rotationQuaternion = null;
m.rotation.set(0, 0, 0);
m.scaling.set(1, 1, 1);
m.position.set(0, 0, 0);
}
// Уничтожаем оставшиеся вспомогательные node-ы (__root__ и т.п.)
for (const m of allMeshes) {
if (!geoMeshes.includes(m)) {
try { m.dispose(); } catch (e) {}
}
}
let proto;
if (geoMeshes.length === 1) {
proto = geoMeshes[0];
proto.name = `__smoothDeco_${key}`;
} else {
proto = Mesh.MergeMeshes(geoMeshes, true, true, undefined, false, true);
if (!proto) return;
proto.name = `__smoothDeco_${key}`;
}
// Центрируем XZ + поднимаем низ до Y=0
proto.refreshBoundingInfo();
const bb = proto.getBoundingInfo().boundingBox;
const minY = bb.minimumWorld.y;
const cx = (bb.minimumWorld.x + bb.maximumWorld.x) * 0.5;
const cz = (bb.minimumWorld.z + bb.maximumWorld.z) * 0.5;
const sizeY = bb.maximumWorld.y - bb.minimumWorld.y;
if (Math.abs(minY) > 0.001 || Math.abs(cx) > 0.001 || Math.abs(cz) > 0.001) {
proto.position.set(-cx, -minY, -cz);
proto.bakeCurrentTransformIntoVertices();
proto.position.set(0, 0, 0);
}
console.log(`[SmoothDecoManager] ${key}: Y=${sizeY.toFixed(2)}м × scale=${def.scale} = ${(sizeY * def.scale).toFixed(2)}м`);
// === Фикс чёрных горизонтальных граней ===
// Диагностика (BEFORE-лог): Kenney glTF — PBRMaterial с
// metallic=1, roughness=1, albedo=(0.80,0.46,0.37), envInt=1.
// То есть цвет деревьев на самом деле даётся ОТРАЖЕНИЕМ
// окружения (металлический материал), а не albedo напрямую.
//
// ПРЕДЫДУЩАЯ ОШИБКА: я ставил metallic=0/roughness=1 →
// материал из "металл-зеркало" превращался в матовый
// диффузный, ловил весь свет сцены (hemi 0.65 + sun 0.8) и
// ПЕРЕСВЕЧИВАЛСЯ. Цвета вымывались.
//
// ПРАВИЛЬНО: metallic/roughness/intensity НЕ трогаем — материал
// остаётся таким, как задумано в glTF. Только:
// - backFaceCulling=false + twoSidedLighting — двусторонний
// рендер (страховка winding'а)
// - слабый emissiveColor (FILL от albedo) — «заливка» для
// нижних граней, чтобы не были чёрными. На металлическом
// материале emissive добавляется поверх отражений, поэтому
// FILL держим маленьким.
const EMISSIVE_FILL = 0.06;
const fixMat = (mat) => {
if (!mat) return;
mat.backFaceCulling = false;
mat.twoSidedLighting = true;
// emissive по albedo (или diffuse) — слабая заливка.
const base = mat.albedoColor || mat.diffuseColor;
if (base) {
mat.emissiveColor = base.scale
? base.scale(EMISSIVE_FILL)
: { r: base.r * EMISSIVE_FILL, g: base.g * EMISSIVE_FILL, b: base.b * EMISSIVE_FILL };
}
// metallic / roughness / emissiveIntensity / environmentIntensity
// НЕ меняем — оставляем как в исходном glTF.
};
if (proto.material) {
// MultiMaterial → чиним все sub-материалы.
if (proto.material.subMaterials) {
for (const sub of proto.material.subMaterials) fixMat(sub);
} else {
fixMat(proto.material);
}
}
proto.isPickable = false;
// Prototype = живой меш для thin-instances. Babylon не cull-ит его
// через frustum (alwaysSelect=true), все инстансы рендерятся
// одним draw call.
proto.alwaysSelectAsActiveMesh = true;
// Сохраняем kind для distance culling (деревья видны издалека).
proto._decoKind = def.kind;
// Сохраняем реальную высоту модели (для AABB-коллайдеров деревьев).
proto._decoSourceHeight = sizeY;
this._protos.set(key, proto);
} catch (e) {
console.error(`[SmoothDecoManager] load ${key} failed:`, e);
}
}
placeDecorations(opts) {
if (!this._loaded) {
console.warn('[SmoothDecoManager] not loaded yet');
return { total: 0 };
}
const {
sampleSurfaceY, sampleBiomeId, bbox,
densityFlowers = 0.015, densityGrass = 0.08, densityTrees = 0.05, seed = 1337,
} = opts;
this.clear();
const hash = (x, z, salt) => {
let h = (x * 374761393 + z * 668265263 + (seed + salt) * 1442695040) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
};
// Плотный grid. Один thin-instance buffer per модель.
// Каждый thin-instance = 1 draw call независимо от количества инстансов.
const STEP_FLOWERS = 1.5;
const STEP_GRASS = 1.0;
const STEP_MUSHROOM = 3.0;
// Деревья — большие объекты, ставим РЕЖЕ чтобы не лес сплошной стеной.
// step=8м для леса = 1 дерево на ~64м² при density=1.0.
const STEP_TREE = 8.0;
// === Per-kind caps ===
// Один общий cap (150K) исчерпывался травой ДО деревьев на 600м карте,
// и деревья просто не размещались. Каждый тип теперь имеет свой лимит.
const CAP_FLOWERS = 60000;
const CAP_GRASS = 80000;
const CAP_MUSHROOM = 10000;
const CAP_TREE = 15000; // на 600м карте полно: 360000/64 × 0.5 = ~2800
// tempBuffers: Map<decoKey, Matrix[]>
const tempBuffers = new Map();
let total = 0;
// Счётчики per kind для соблюдения отдельных cap'ов.
const kindCounts = { flower: 0, grass: 0, mushroom: 0, tree: 0 };
const kindCaps = { flower: CAP_FLOWERS, grass: CAP_GRASS, mushroom: CAP_MUSHROOM, tree: CAP_TREE };
// Список tree-AABB для физики (узкий цилиндр-ствол).
// Передаётся в physics.setSmoothDecoTrees() — игрок не проходит сквозь.
const treeColliders = [];
const placeAt = (decoKey, kind, x, y, z, ry, scale) => {
let arr = tempBuffers.get(decoKey);
if (!arr) {
arr = [];
tempBuffers.set(decoKey, arr);
}
const m = Matrix.Compose(
new Vector3(scale, scale, scale),
Quaternion.RotationAxis(Vector3.Up(), ry),
new Vector3(x, y, z),
);
arr.push(m);
total++;
kindCounts[kind]++;
// Для деревьев — добавляем коллайдер: AABB до верха дерева,
// ширина ствола около 1.5м (крона + ствол вместе).
if (kind === 'tree') {
const proto = this._protos.get(decoKey);
if (proto) {
// Реальная высота меша = sourceHeight × scale.
// Для обычного дерева ~2-3м, для tree_tall до 4м.
const sourceH = proto._decoSourceHeight || 0.3;
const treeHeight = sourceH * scale;
// Радиус AABB: для кактусов узкий (0.5м), для остальных
// достаточно широкий чтобы покрыть крону (1.2-1.5м).
const isCactus = decoKey.startsWith('cactus_');
const trunkHalfW = isCactus ? 0.5 : 1.3;
treeColliders.push({
x, z,
baseY: y, // низ ствола = поверхность
halfW: trunkHalfW,
halfH: treeHeight * 0.5, // ПОЛОВИНА реальной высоты
halfD: trunkHalfW,
});
}
}
};
const tryPlace = (kind, x, z, salt) => {
if (kindCounts[kind] >= kindCaps[kind]) return;
const surfY = sampleSurfaceY(x, z);
if (surfY === null) return;
const biomeId = sampleBiomeId(x, z);
if (!biomeId) return;
const candidates = DECO_KEYS.filter((k) => {
const def = DECO_CATALOG[k];
return def.kind === kind && def.biomes.includes(biomeId);
});
if (candidates.length === 0) return;
const rChoose = hash(Math.floor(x * 17), Math.floor(z * 31), salt);
const idx = Math.floor(rChoose * candidates.length);
const decoKey = candidates[idx];
const def = DECO_CATALOG[decoKey];
const rRot = hash(Math.floor(x * 11), Math.floor(z * 13), salt + 100);
const rScale = hash(Math.floor(x * 23), Math.floor(z * 19), salt + 200);
const ry = rRot * Math.PI * 2;
const scale = def.scale * (0.8 + rScale * 0.4);
placeAt(decoKey, kind, x, surfY, z, ry, scale);
};
// Порядок размещения: tree → flower → mushroom → grass.
// Деревья ПЕРВЫМИ — они самые важные визуально, нельзя чтобы их
// вытеснил cap травы на больших картах.
if (densityTrees > 0) {
for (let z = bbox.minZ; z < bbox.maxZ; z += STEP_TREE) {
for (let x = bbox.minX; x < bbox.maxX; x += STEP_TREE) {
if (hash(Math.floor(x * 13), Math.floor(z * 17), 8) < densityTrees) {
const dx = (hash(Math.floor(x * 19), Math.floor(z), 9) - 0.5) * STEP_TREE;
const dz = (hash(Math.floor(x), Math.floor(z * 19), 10) - 0.5) * STEP_TREE;
tryPlace('tree', x + dx, z + dz, 4000);
}
}
}
}
if (densityFlowers > 0) {
for (let z = bbox.minZ; z < bbox.maxZ; z += STEP_FLOWERS) {
for (let x = bbox.minX; x < bbox.maxX; x += STEP_FLOWERS) {
if (hash(Math.floor(x * 3), Math.floor(z * 5), 1) < densityFlowers) {
const dx = (hash(Math.floor(x * 7), Math.floor(z), 2) - 0.5) * STEP_FLOWERS;
const dz = (hash(Math.floor(x), Math.floor(z * 7), 3) - 0.5) * STEP_FLOWERS;
tryPlace('flower', x + dx, z + dz, 1000);
}
}
}
}
const mushroomDensity = densityFlowers * 0.3;
if (mushroomDensity > 0) {
for (let z = bbox.minZ; z < bbox.maxZ; z += STEP_MUSHROOM) {
for (let x = bbox.minX; x < bbox.maxX; x += STEP_MUSHROOM) {
if (hash(Math.floor(x * 11), Math.floor(z * 13), 7) < mushroomDensity) {
tryPlace('mushroom', x, z, 3000);
}
}
}
}
if (densityGrass > 0) {
for (let z = bbox.minZ; z < bbox.maxZ; z += STEP_GRASS) {
for (let x = bbox.minX; x < bbox.maxX; x += STEP_GRASS) {
if (hash(Math.floor(x * 5), Math.floor(z * 3), 4) < densityGrass) {
const dx = (hash(Math.floor(x * 9), Math.floor(z), 5) - 0.5) * STEP_GRASS;
const dz = (hash(Math.floor(x), Math.floor(z * 9), 6) - 0.5) * STEP_GRASS;
tryPlace('grass', x + dx, z + dz, 2000);
}
}
}
}
// Сохраняем полные буферы (для культинга и save/load)
let kindsUsed = 0;
for (const [decoKey, matrices] of tempBuffers) {
const proto = this._protos.get(decoKey);
if (!proto || matrices.length === 0) continue;
const buffer = new Float32Array(matrices.length * 16);
for (let i = 0; i < matrices.length; i++) {
matrices[i].copyToArray(buffer, i * 16);
}
this._buffers.set(decoKey, buffer);
kindsUsed++;
}
// Первый pass culling сразу — иначе на 1 кадр будет всё или ничего.
this._applyCulling();
// Render observer обновляет видимые инстансы каждые 300мс по камере.
this._enableCullObserver();
console.log(`[SmoothDecoManager] placed ${total} decorations across ${kindsUsed} models (trees=${treeColliders.length}, view radius ${this.viewRadius}м)`);
return { total, treeColliders };
}
/**
* Добавить N инстансов декорации указанного kind в сфере вокруг center.
* Используется кистями для интерактивной расстановки.
*
* @param {object} opts
* @param {'flower'|'grass'|'mushroom'|'tree'} opts.kind
* @param {{x,y,z}} opts.center — мировые координаты центра кисти
* @param {number} opts.radius — радиус сферы (м)
* @param {number} opts.count — сколько инстансов добавить
* @param {function} opts.sampleSurfaceY — (x,z) → Y поверхности
* @returns {number} реально добавлено инстансов
*/
addBrushDeco(opts) {
if (!this._loaded) {
console.warn('[SmoothDecoManager] addBrushDeco: not loaded');
return { added: 0, newTreeColliders: [] };
}
const { kind, center, radius, count = 5, sampleSurfaceY } = opts;
const candidates = DECO_KEYS.filter((k) => DECO_CATALOG[k].kind === kind);
if (candidates.length === 0) return { added: 0, newTreeColliders: [] };
let added = 0;
// Если посадили деревья — собираем колайдеры для physics.
const newTreeColliders = [];
for (let i = 0; i < count; i++) {
const angle = Math.random() * Math.PI * 2;
const r = Math.sqrt(Math.random()) * radius;
const x = center.x + Math.cos(angle) * r;
const z = center.z + Math.sin(angle) * r;
const surfY = sampleSurfaceY ? sampleSurfaceY(x, z) : center.y;
if (surfY === null || surfY === undefined) continue;
const entry = candidates[Math.floor(Math.random() * candidates.length)];
const def = DECO_CATALOG[entry];
const ry = Math.random() * Math.PI * 2;
const scale = def.scale * (0.8 + Math.random() * 0.4);
const m = Matrix.Compose(
new Vector3(scale, scale, scale),
Quaternion.RotationAxis(Vector3.Up(), ry),
new Vector3(x, surfY, z),
);
this._appendInstanceToBuffer(entry, m);
added++;
// Tree collider
if (kind === 'tree') {
const proto = this._protos.get(entry);
if (proto) {
const sourceH = proto._decoSourceHeight || 0.3;
const treeHeight = sourceH * scale;
const isCactus = entry.startsWith('cactus_');
const trunkHalfW = isCactus ? 0.5 : 1.3;
newTreeColliders.push({
x, z,
baseY: surfY,
halfW: trunkHalfW,
halfH: treeHeight * 0.5,
halfD: trunkHalfW,
});
}
}
}
if (added > 0) {
this._applyCulling();
}
return { added, newTreeColliders };
}
/**
* Удалить все инстансы (любого kind) в сфере вокруг center.
* @returns {number} удалено
*/
removeBrushDecoInRadius(center, radius) {
if (!this._loaded) return 0;
const r2 = radius * radius;
let removed = 0;
for (const [decoKey, fullBuf] of this._buffers) {
const count = fullBuf.length / 16;
// Собираем индексы НЕ-удаляемых
const keep = [];
for (let i = 0; i < count; i++) {
const dx = fullBuf[i * 16 + 12] - center.x;
const dz = fullBuf[i * 16 + 14] - center.z;
if (dx * dx + dz * dz > r2) {
keep.push(i);
}
}
if (keep.length === count) continue; // ничего не удалили в этом буфере
removed += count - keep.length;
// Перекомпилируем буфер только из keep'нутых
const newBuf = new Float32Array(keep.length * 16);
for (let j = 0; j < keep.length; j++) {
newBuf.set(fullBuf.subarray(keep[j] * 16, (keep[j] + 1) * 16), j * 16);
}
this._buffers.set(decoKey, newBuf);
}
if (removed > 0) this._applyCulling();
return removed;
}
/**
* Удалить все инстансы (любого kind) внутри прямоугольной зоны XZ.
* Используется билд-скриптами игр чтобы расчистить площадки под
* замок/лагерь/декор от процедурных деревьев и кустов.
*
* @param {number} minX
* @param {number} minZ
* @param {number} maxX
* @param {number} maxZ
* @returns {number} удалено инстансов
*/
removeDecoInBox(minX, minZ, maxX, maxZ) {
if (!this._loaded) return 0;
let removed = 0;
for (const [decoKey, fullBuf] of this._buffers) {
const count = fullBuf.length / 16;
const keep = [];
for (let i = 0; i < count; i++) {
const x = fullBuf[i * 16 + 12];
const z = fullBuf[i * 16 + 14];
if (x < minX || x > maxX || z < minZ || z > maxZ) {
keep.push(i);
}
}
if (keep.length === count) continue;
removed += count - keep.length;
const newBuf = new Float32Array(keep.length * 16);
for (let j = 0; j < keep.length; j++) {
newBuf.set(fullBuf.subarray(keep[j] * 16, (keep[j] + 1) * 16), j * 16);
}
this._buffers.set(decoKey, newBuf);
}
if (removed > 0) this._applyCulling();
return removed;
}
/**
* Получить ПОЛНЫЙ список tree-AABB-колайдеров на основе всех текущих
* tree-инстансов. Используется после plant/erase чтобы физика
* пересинхронизировала smooth-deco-trees.
*/
getAllTreeColliders() {
const out = [];
for (const [decoKey, buf] of this._buffers) {
const def = DECO_CATALOG[decoKey];
if (!def || def.kind !== 'tree') continue;
const proto = this._protos.get(decoKey);
if (!proto) continue;
const sourceH = proto._decoSourceHeight || 0.3;
const isCactus = decoKey.startsWith('cactus_');
const trunkHalfW = isCactus ? 0.5 : 1.3;
const count = buf.length / 16;
for (let i = 0; i < count; i++) {
const off = i * 16;
// uniform scale из ||row 0||
const sx = Math.sqrt(buf[off]*buf[off] + buf[off+1]*buf[off+1] + buf[off+2]*buf[off+2]);
const treeHeight = sourceH * sx;
out.push({
x: buf[off + 12], z: buf[off + 14],
baseY: buf[off + 13],
halfW: trunkHalfW,
halfH: treeHeight * 0.5,
halfD: trunkHalfW,
});
}
}
return out;
}
/**
* Включить/выключить пикинг thin-instances у всех прототипов.
* Нужно для поштучного клик-выбора декораций в редакторе.
* По умолчанию декорации isPickable=false (чтобы не мешали кистям).
*/
setPickingEnabled(enabled) {
for (const proto of this._protos.values()) {
proto.isPickable = !!enabled;
try {
proto.thinInstanceEnablePicking = !!enabled;
} catch (e) { /* старый Babylon — игнор */ }
}
}
/**
* Найти декорацию под точкой клика по результату raycast.
* Babylon при thinInstanceEnablePicking возвращает pickedMesh=прототип
* и thinInstanceIndex — но это индекс в ВИДИМОМ буфере (после culling).
* Берём позицию из видимого буфера и ищем ближайший инстанс в полном.
*
* @param {Mesh} pickedMesh — pick.pickedMesh из scene.pickWithRay
* @param {number} thinIndex — pick.thinInstanceIndex
* @returns {{decoKey, fullIndex, x, y, z}|null}
*/
findInstanceByPick(pickedMesh, thinIndex) {
if (!pickedMesh || thinIndex == null || thinIndex < 0) return null;
// Найти decoKey по прототипу
let decoKey = null;
for (const [k, proto] of this._protos) {
if (proto === pickedMesh) { decoKey = k; break; }
}
if (!decoKey) return null;
const visBuf = this._visibleBuffers.get(decoKey);
if (!visBuf || thinIndex * 16 + 14 >= visBuf.length) return null;
const vx = visBuf[thinIndex * 16 + 12];
const vy = visBuf[thinIndex * 16 + 13];
const vz = visBuf[thinIndex * 16 + 14];
// Ищем этот же инстанс в полном буфере (по точному совпадению XЗ)
const fullBuf = this._buffers.get(decoKey);
if (!fullBuf) return null;
const count = fullBuf.length / 16;
let bestIdx = -1, bestD2 = 1e9;
for (let i = 0; i < count; i++) {
const dx = fullBuf[i * 16 + 12] - vx;
const dz = fullBuf[i * 16 + 14] - vz;
const d2 = dx * dx + dz * dz;
if (d2 < bestD2) { bestD2 = d2; bestIdx = i; }
}
if (bestIdx < 0 || bestD2 > 0.01) return null;
return { decoKey, fullIndex: bestIdx, x: vx, y: vy, z: vz };
}
/** Удалить один инстанс декорации по decoKey + индексу в полном буфере. */
removeInstanceAt(decoKey, fullIndex) {
const fullBuf = this._buffers.get(decoKey);
if (!fullBuf) return false;
const count = fullBuf.length / 16;
if (fullIndex < 0 || fullIndex >= count) return false;
const newBuf = new Float32Array((count - 1) * 16);
let w = 0;
for (let i = 0; i < count; i++) {
if (i === fullIndex) continue;
newBuf.set(fullBuf.subarray(i * 16, (i + 1) * 16), w * 16);
w++;
}
this._buffers.set(decoKey, newBuf);
this._applyCulling();
return true;
}
/** Внутренний: расширить буфер decoKey'я одной новой матрицей. */
_appendInstanceToBuffer(decoKey, matrix) {
const old = this._buffers.get(decoKey);
const oldLen = old ? old.length : 0;
const newBuf = new Float32Array(oldLen + 16);
if (old) newBuf.set(old, 0);
matrix.copyToArray(newBuf, oldLen);
this._buffers.set(decoKey, newBuf);
}
/**
* Distance culling: для каждой модели создаём подбуфер только из инстансов
* в radius от камеры, и применяем его через thinInstanceSetBuffer.
* GPU рисует только эти инстансы — экономия 80-90% на больших картах.
*/
_applyCulling() {
const cam = this.scene.activeCamera;
if (!cam) return;
const camX = cam.position.x;
const camZ = cam.position.z;
// Per-kind view radius. Деревья БОЛЬШИЕ (5-8м), видны издалека.
// Трава/цветы — мелкие, дальше 100м их не видно.
const RADIUS_TREE = 400;
const RADIUS_OTHER = this.viewRadius;
const r2Tree = RADIUS_TREE * RADIUS_TREE;
const r2Other = RADIUS_OTHER * RADIUS_OTHER;
let totalVisible = 0;
for (const [decoKey, fullBuf] of this._buffers) {
const proto = this._protos.get(decoKey);
if (!proto) continue;
const isTree = proto._decoKind === 'tree';
const r2 = isTree ? r2Tree : r2Other;
const count = fullBuf.length / 16;
// Подсчитываем видимые
let visibleCount = 0;
for (let i = 0; i < count; i++) {
const dx = fullBuf[i * 16 + 12] - camX;
const dz = fullBuf[i * 16 + 14] - camZ;
if (dx * dx + dz * dz <= r2) visibleCount++;
}
// Создаём подбуфер. Если все видны — используем fullBuf без копии.
let outBuf;
if (visibleCount === count) {
outBuf = fullBuf;
} else if (visibleCount === 0) {
outBuf = new Float32Array(0);
} else {
outBuf = new Float32Array(visibleCount * 16);
let w = 0;
for (let i = 0; i < count; i++) {
const dx = fullBuf[i * 16 + 12] - camX;
const dz = fullBuf[i * 16 + 14] - camZ;
if (dx * dx + dz * dz <= r2) {
outBuf.set(fullBuf.subarray(i * 16, (i + 1) * 16), w * 16);
w++;
}
}
}
proto.thinInstanceSetBuffer('matrix', outBuf, 16, true);
this._visibleBuffers.set(decoKey, outBuf);
totalVisible += visibleCount;
}
return totalVisible;
}
_enableCullObserver() {
if (this._cullObserver) return;
this._cullObserver = this.scene.onBeforeRenderObservable.add(() => {
const now = performance.now();
// Раз в 300мс — достаточно для плавного движения камеры.
if (now - this._lastCullCheck < 300) return;
this._lastCullCheck = now;
this._applyCulling();
});
}
_disableCullObserver() {
if (this._cullObserver) {
this.scene.onBeforeRenderObservable.remove(this._cullObserver);
this._cullObserver = null;
}
}
/**
* Сериализация ВСЕХ инстансов для save в БД.
* Возвращает { format: 'smoothdeco-v1', items: [{k: decoKey, m: [16 floats]}, ...] }
*
* Каждый инстанс занимает 16 float = 64 байта + overhead JSON.
* При 5000 декораций ~ 350KB. При 50K — 3MB. Не использовать для огромных карт.
*
* Для процедурно-генерированных декораций лучше использовать decoParams +
* seed (см. _smoothDecoParams в BabylonScene) — это всего 100 байт.
*/
serialize() {
const items = [];
for (const [decoKey, buf] of this._buffers) {
if (!buf || buf.length === 0) continue;
// Кодируем матрицы только translation + rotation Y + scale,
// чтобы JSON был компактнее (8 чисел вместо 16).
const count = buf.length / 16;
for (let i = 0; i < count; i++) {
const off = i * 16;
// m[0..3]: row 0 (scale, rot)
// m[12,13,14]: translation
// Извлекаем uniform scale из ||m[0..2]|| (если scale-X)
const sx = Math.sqrt(buf[off] * buf[off] + buf[off + 1] * buf[off + 1] + buf[off + 2] * buf[off + 2]);
const rotY = Math.atan2(buf[off + 2] / (sx || 1), buf[off] / (sx || 1));
items.push({
k: decoKey,
x: +buf[off + 12].toFixed(2),
y: +buf[off + 13].toFixed(2),
z: +buf[off + 14].toFixed(2),
r: +rotY.toFixed(3),
s: +sx.toFixed(2),
});
}
}
return { format: 'smoothdeco-v1', items };
}
/**
* Загрузка декораций из serialize()-результата.
* Восстанавливает все инстансы один-в-один с момента сохранения.
* Загружает prototype'ы автоматически если не загружены.
*/
async loadFromState(state) {
if (!state || state.format !== 'smoothdeco-v1') {
console.warn('[SmoothDecoManager] loadFromState: bad format', state?.format);
return 0;
}
if (!this._loaded) {
await this.loadAll();
}
this.clear();
// Группируем items по decoKey, чтобы построить буфер за один проход.
const groups = new Map();
for (const it of (state.items || [])) {
let arr = groups.get(it.k);
if (!arr) { arr = []; groups.set(it.k, arr); }
arr.push(it);
}
let total = 0;
// Параллельно собираем tree-collider'ы для физики (та же логика что
// в placeDecorations.placeAt при kind==='tree').
const treeColliders = [];
for (const [decoKey, arr] of groups) {
const proto = this._protos.get(decoKey);
if (!proto) continue;
const def = DECO_CATALOG[decoKey];
const buf = new Float32Array(arr.length * 16);
for (let i = 0; i < arr.length; i++) {
const it = arr[i];
const m = Matrix.Compose(
new Vector3(it.s, it.s, it.s),
Quaternion.RotationAxis(Vector3.Up(), it.r || 0),
new Vector3(it.x, it.y, it.z),
);
m.copyToArray(buf, i * 16);
if (def && def.kind === 'tree') {
const sourceH = proto._decoSourceHeight || 0.3;
const treeHeight = sourceH * it.s;
const isCactus = decoKey.startsWith('cactus_');
const trunkHalfW = isCactus ? 0.5 : 1.3;
treeColliders.push({
x: it.x, z: it.z,
baseY: it.y,
halfW: trunkHalfW,
halfH: treeHeight * 0.5,
halfD: trunkHalfW,
});
}
}
this._buffers.set(decoKey, buf);
total += arr.length;
}
this._applyCulling();
this._enableCullObserver();
console.log(`[SmoothDecoManager] loadFromState: ${total} instances restored (${treeColliders.length} tree colliders)`);
return { total, treeColliders };
}
/** Очистить все инстансы (prototype-меши остаются). */
clear() {
for (const proto of this._protos.values()) {
try { proto.thinInstanceSetBuffer('matrix', new Float32Array(0), 16, true); }
catch (e) {}
}
this._buffers.clear();
this._visibleBuffers.clear();
this._disableCullObserver();
}
/** Полное удаление prototype-меш-ей. */
dispose() {
this._disableCullObserver();
for (const proto of this._protos.values()) {
try { proto.dispose(); } catch (e) {}
}
this._protos.clear();
this._buffers.clear();
this._visibleBuffers.clear();
this._loaded = false;
}
getStats() {
let total = 0;
for (const buf of this._buffers.values()) {
total += buf.length / 16;
}
return { total, kinds: this._buffers.size };
}
}