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)
856 lines
43 KiB
JavaScript
856 lines
43 KiB
JavaScript
/**
|
||
* 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 };
|
||
}
|
||
}
|