/** * UserModelManager — менеджер пользовательских воксельных моделей в сцене проекта. * * Этап 5 редактора моделей: постановка собственных voxel-моделей пользователя * (созданных через ModelEditorScreen) в сцену игры. * * Архитектура (упрощённый baseline-путь; greedy meshing — позже): * - Каждый инстанс = один объединённый Mesh, собранный из всех voxel-кубов модели * - Internal-face culling: грани между двумя соседними voxels не рисуем * - Vertex-color per face: цвет/текстура запекается в материале на грань * - Кэш model_data по userModelId: грузим из API один раз * * Формат внутри model_data: * { version:2, size:N, voxels:[{x,y,z,c:'#RRGGBB'} | {x,y,z,t:'grass'}] } * * Использование: * const mgr = new UserModelManager(scene); * mgr.setApi({ getUserModel }); // подключение API-функции * const instId = await mgr.addInstance('user:42', x, y, z, rotationY); * mgr.removeInstance(instId); * * Изменения в БД хранилище: * model_data → JSON с voxels * thumbnail_b64 → preview для Toolbox * * При сохранении проекта (project_data) пользовательские инстансы * сериализуются как { type: 'user:42', x, y, z, ry } — id используется * для повторной загрузки model_data при открытии проекта. */ import { Mesh, VertexData, StandardMaterial, Texture, Color3, Color4, Vector3, TransformNode, VertexBuffer, } from '@babylonjs/core'; import { decodeVoxelModel } from './voxelModelCodec'; /** Размер voxel-кубика модели в мире (метров). * Должен совпадать с VOXEL_SIZE редактора моделей. */ const VOXEL_SIZE = 0.0625; /** Базовый URL текстур — для voxel'ов с texture-id. */ const TEX_BASE = '/kubikon-assets/textures'; /** Каталог текстур — id → URL. Должен совпадать с VOXEL_TEXTURES в редакторе. */ const TEXTURES = { grass: `${TEX_BASE}/grass_top.png`, stone: `${TEX_BASE}/greystone.png`, dirt: `${TEX_BASE}/dirt.png`, sand: `${TEX_BASE}/sand.png`, snow: `${TEX_BASE}/snow.png`, wood: `${TEX_BASE}/wood.png`, leaves: `${TEX_BASE}/leaves.png`, trunk: `${TEX_BASE}/trunk_side.png`, water: `${TEX_BASE}/water.png`, ice: `${TEX_BASE}/ice.png`, gravel: `${TEX_BASE}/gravel_dirt.png`, brick_red: `${TEX_BASE}/brick_red.png`, brick_grey: `${TEX_BASE}/brick_grey.png`, }; /** Конвертация '#RRGGBB' → Color4(0..1, 1). */ function hexToColor4(hex) { if (!hex || typeof hex !== 'string') return new Color4(1, 1, 1, 1); const m = hex.trim().match(/^#?([0-9a-fA-F]{6})$/); if (!m) return new Color4(1, 1, 1, 1); const n = parseInt(m[1], 16); return new Color4( ((n >> 16) & 0xff) / 255, ((n >> 8) & 0xff) / 255, (n & 0xff) / 255, 1, ); } /** Префикс id пользовательских моделей. */ export const USER_MODEL_PREFIX = 'user:'; /** Извлечь numeric id из 'user:42'. */ export function parseUserModelId(modelTypeId) { if (typeof modelTypeId !== 'string') return null; if (!modelTypeId.startsWith(USER_MODEL_PREFIX)) return null; const n = parseInt(modelTypeId.slice(USER_MODEL_PREFIX.length), 10); return isNaN(n) ? null : n; } /** * Описание граней куба для face-culling алгоритма. * Для каждой стороны: смещение к соседу + 4 vertex-positions грани (CCW от +нормали) * + нормаль для shading + shade — запечённый множитель яркости грани. * * shade — лёгкий запечённый множитель яркости грани (Minecraft-style): * верх самый светлый, бока чуть темнее, низ ещё темнее. Значения близки * к 1.0 — это даёт МЯГКИЙ объём; основной контраст даёт освещение сцены. * Слишком тёмные значения (0.5 и ниже) при включённом освещении делали * грани почти чёрными — поэтому диапазон сжат к светлому. */ const FACES = [ // +X (бок) { dx: 1, dy: 0, dz: 0, n: [1, 0, 0], shade: 0.90, verts: [[1,0,0],[1,1,0],[1,1,1],[1,0,1]] }, // -X (бок) { dx: -1, dy: 0, dz: 0, n: [-1, 0, 0], shade: 0.90, verts: [[0,0,1],[0,1,1],[0,1,0],[0,0,0]] }, // +Y (верх — самый светлый) { dx: 0, dy: 1, dz: 0, n: [0, 1, 0], shade: 1.00, verts: [[0,1,0],[0,1,1],[1,1,1],[1,1,0]] }, // -Y (низ — чуть темнее) { dx: 0, dy: -1, dz: 0, n: [0, -1, 0], shade: 0.78, verts: [[0,0,1],[0,0,0],[1,0,0],[1,0,1]] }, // +Z (бок — чуть темнее X для разницы граней) { dx: 0, dy: 0, dz: 1, n: [0, 0, 1], shade: 0.84, verts: [[1,0,1],[1,1,1],[0,1,1],[0,0,1]] }, // -Z (бок) { dx: 0, dy: 0, dz: -1, n: [0, 0, -1], shade: 0.84, verts: [[0,0,0],[0,1,0],[1,1,0],[1,0,0]] }, ]; /** * UV-координаты для грани куба (стандартный quad 0,0 → 1,0 → 1,1 → 0,1). * Используется только для voxels с текстурой. */ const FACE_UVS = [[0,0],[1,0],[1,1],[0,1]]; /** * Скомпилировать voxels из model_data в готовые VertexData для Babylon. * Возвращает Map. * * materialKey: * 'solid' — voxels с цветом (используется vertex color) * 'tex:' — voxels с текстурой * * Каждая materialKey → отдельный submesh с отдельным StandardMaterial. */ export function compileVoxelModel(modelData) { // Кодек читает v3 (компактный), v2 и v1 — единая точка разбора формата. const decoded = decodeVoxelModel(modelData); if (!decoded || decoded.voxels.length === 0) return null; const data = { size: decoded.size, voxels: decoded.voxels }; // Карта occupancy — для быстрого face-culling const occ = new Map(); // 'x,y,z' → voxel{c|t} for (const v of data.voxels) { if (typeof v.x !== 'number') continue; occ.set(`${v.x | 0},${v.y | 0},${v.z | 0}`, v); } // Группируем faces по material — на каждый материал отдельный mesh // (нельзя смешивать textured/solid faces в один меш с одним материалом). const buckets = new Map(); // materialKey → { positions[], indices[], normals[], colors[], uvs[] } const getBucket = (key) => { let b = buckets.get(key); if (!b) { b = { positions: [], indices: [], normals: [], colors: [], uvs: [] }; buckets.set(key, b); } return b; }; const S = VOXEL_SIZE; for (const v of data.voxels) { const x = v.x | 0, y = v.y | 0, z = v.z | 0; const matKey = v.t ? `tex:${v.t}` : 'solid'; const color = v.t ? null : hexToColor4(v.c || '#ffffff'); const bucket = getBucket(matKey); for (const face of FACES) { // Face culling: если сосед в направлении нормали есть — грань скрыта. const nx = x + face.dx, ny = y + face.dy, nz = z + face.dz; if (occ.has(`${nx},${ny},${nz}`)) continue; const baseIdx = bucket.positions.length / 3; const sh = face.shade; for (let i = 0; i < 4; i++) { const vx = face.verts[i][0] + x; const vy = face.verts[i][1] + y; const vz = face.verts[i][2] + z; bucket.positions.push(vx * S, vy * S, vz * S); bucket.normals.push(face.n[0], face.n[1], face.n[2]); bucket.uvs.push(FACE_UVS[i][0], FACE_UVS[i][1]); if (color) { // Запекаем face-shading прямо в vertex color. bucket.colors.push(color.r * sh, color.g * sh, color.b * sh, 1); } else { // Текстурные voxels: vertex color = серый по shade, // материал домножит текстуру на него (useVertexColors). bucket.colors.push(sh, sh, sh, 1); } } // Два треугольника на quad bucket.indices.push(baseIdx, baseIdx + 1, baseIdx + 2); bucket.indices.push(baseIdx, baseIdx + 2, baseIdx + 3); } } return buckets; } /** * Построить воксельную маску коллизий из model_data. * * Маска — это компактная occupancy-сетка по воксельным индексам модели. * Используется PhysicsAABB для narrow-фазы коллизии: вместо одного грубого * AABB-параллелепипеда проверяется пересечение игрока с конкретными * занятыми вокселями. Это позволяет ходить по «дорожке» арочного моста, * заходить в проёмы и т.п. — там где раньше был сплошной короб. * * Производительность: * - Строится ОДИН раз на модель (кэшируется в _dataCache рядом с data). * - Хранится как Uint8Array (1 байт/воксель) — для модели 64³ это 256 КБ, * для типовой 32³ — 32 КБ. Доступ к ячейке O(1) по индексу. * - narrow-тест в физике итерирует ТОЛЬКО воксели в пределах AABB игрока * (обычно 1-3 слоя на касании), не всю модель. * * @returns {object|null} { sx, sy, sz, minX, minY, minZ, grid:Uint8Array, count } * sx/sy/sz — размеры сетки в вокселях; minX/Y/Z — индекс нижнего угла bbox; * grid[idx] = 1 если воксель занят; count — число занятых вокселей. */ export function buildVoxelMask(modelData) { // Кодек читает v3 (компактный), v2 и v1. const decoded = decodeVoxelModel(modelData); if (!decoded || decoded.voxels.length === 0) return null; const data = { size: decoded.size, voxels: decoded.voxels }; // Границы по занятым вокселям. let minX = Infinity, minY = Infinity, minZ = Infinity; let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity; for (const v of data.voxels) { if (typeof v.x !== 'number') continue; const x = v.x | 0, y = v.y | 0, z = v.z | 0; if (x < minX) minX = x; if (x > maxX) maxX = x; if (y < minY) minY = y; if (y > maxY) maxY = y; if (z < minZ) minZ = z; if (z > maxZ) maxZ = z; } if (!isFinite(minX)) return null; const sx = maxX - minX + 1; const sy = maxY - minY + 1; const sz = maxZ - minZ + 1; // Защита от абсурдных размеров (битый model_data). if (sx <= 0 || sy <= 0 || sz <= 0 || sx * sy * sz > 4_000_000) return null; const grid = new Uint8Array(sx * sy * sz); let count = 0; for (const v of data.voxels) { if (typeof v.x !== 'number') continue; const x = (v.x | 0) - minX; const y = (v.y | 0) - minY; const z = (v.z | 0) - minZ; const idx = (y * sz + z) * sx + x; if (grid[idx] === 0) { grid[idx] = 1; count++; } } return { sx, sy, sz, minX, minY, minZ, grid, count }; } /** * UserModelManager — управляет инстансами пользовательских моделей в сцене. */ export class UserModelManager { constructor(scene) { this.scene = scene; this._api = null; // Кэш прото-данных (model_data из API) по userModelId. this._dataCache = new Map(); // number → modelDataString // Кэш воксельных масок коллизий по userModelId. Маска одинакова для // всех инстансов модели — строим один раз, переиспользуем. this._maskCache = new Map(); // number → { sx,sy,sz,minX,minY,minZ,grid,count } // Активные инстансы. id → { rootNode, userModelId, meshes:[Mesh] } this.instances = new Map(); this._nextInstanceId = 1; // Кэш материалов — переиспользуем StandardMaterial между инстансами. this._matCache = new Map(); // matKey → StandardMaterial } /** Получить (с кэшем) воксельную маску коллизий для userModelId. * Строится один раз из model_data, переиспользуется всеми инстансами. */ _getVoxelMask(userModelId, modelData) { if (this._maskCache.has(userModelId)) { return this._maskCache.get(userModelId); } const mask = buildVoxelMask(modelData); this._maskCache.set(userModelId, mask); // кэшируем и null (битый data) return mask; } setApi(api) { this._api = api; } /** Получить (с кэшем) model_data для userModelId. */ async _loadModelData(userModelId, currentUserId = null) { if (this._dataCache.has(userModelId)) { return this._dataCache.get(userModelId); } if (!this._api || !this._api.getUserModel) { console.warn('[UserModelManager] API not configured'); return null; } try { const res = await this._api.getUserModel(userModelId, currentUserId); const data = res.data?.model_data; if (!data) return null; this._dataCache.set(userModelId, data); return data; } catch (err) { console.warn('[UserModelManager] failed to load model', userModelId, err); return null; } } /** Получить (с кэшем) материал по matKey. * * ЯРКОСТЬ И ОБЪЁМ: * Объём кубиков задаётся запечённым face-shading в vertex colors * (см. FACES[].shade — верх светлее, бока темнее). Материал — * ОБЫЧНЫЙ StandardMaterial с useVertexColors: vertex colors красят * diffuse, освещение сцены даёт картинку. Дополнительно небольшой * emissiveColor «подсвечивает» модель, чтобы цвета были яркие и не * тусклые. НЕ используем disableLighting — он отключает vertex * colors целиком (они часть lighting pipeline) → модель чернеет. */ _getMaterial(matKey) { let m = this._matCache.get(matKey); if (m) return m; m = new StandardMaterial(`userModel_${matKey}`, this.scene); m.specularColor = new Color3(0, 0, 0); // ВАЖНО: двусторонний рендер — winding треугольников нашего // ручного VertexData может оказаться "обратным" в left-handed сцене. m.backFaceCulling = false; m.twoSidedLighting = true; if (matKey === 'solid') { // diffuse=white → vertex colors (с запечённым face-shading) // полностью определяют цвет под освещением сцены. m.diffuseColor = new Color3(1, 1, 1); // Лёгкая эмиссия — поднимает яркость, цвета не выглядят тускло. m.emissiveColor = new Color3(0.32, 0.32, 0.32); } else if (matKey.startsWith('tex:')) { const texId = matKey.slice(4); const url = TEXTURES[texId]; m.diffuseColor = new Color3(1, 1, 1); m.emissiveColor = new Color3(0.32, 0.32, 0.32); if (url) { try { const t = new Texture(url, this.scene); t.updateSamplingMode(Texture.NEAREST_NEAREST_MIPNEAREST); m.diffuseTexture = t; // Эмиссия по той же текстуре — подсветка не «вымывает» // рисунок, а равномерно поднимает яркость. m.emissiveTexture = t; } catch (e) { m.diffuseColor = new Color3(1, 0.4, 0.8); // fallback magenta } } else { m.diffuseColor = new Color3(1, 0.4, 0.8); } } this._matCache.set(matKey, m); return m; } /** * Поставить инстанс пользовательской модели в сцену. * @param {string} userModelTypeId — 'user:42' * @param {number} x,y,z — мировые координаты НИЖНЕЙ-СЕВЕРО-ЗАПАДНОЙ точки voxel-bbox * @param {number} rotationY — поворот вокруг Y (радианы) * @param {object} options * @param {number} options.currentUserId — для приватных моделей нужен (auth-check) * @returns {Promise} instanceId */ async addInstance(userModelTypeId, x, y, z, rotationY = 0, options = {}) { const userModelId = parseUserModelId(userModelTypeId); if (userModelId == null) { console.warn('[UserModelManager] invalid id:', userModelTypeId); return null; } const modelData = await this._loadModelData(userModelId, options.currentUserId); if (!modelData) return null; const buckets = compileVoxelModel(modelData); if (!buckets || buckets.size === 0) return null; // forceInstanceId — явный id из serialize (нужен чтобы target-скрипты // могли стабильно ссылаться на конкретный userModel-инстанс, напр. // животных). Если занят/не задан — берём авто-инкремент. let instanceId; if (Number.isFinite(options.forceInstanceId) && !this.instances.has(options.forceInstanceId)) { instanceId = options.forceInstanceId; if (instanceId >= this._nextInstanceId) { this._nextInstanceId = instanceId + 1; } } else { instanceId = this._nextInstanceId++; } const rootName = `userModel_${userModelId}_${instanceId}`; const root = new TransformNode(rootName, this.scene); root.position = new Vector3(x, y, z); root.rotation = new Vector3(0, rotationY, 0); const meshes = []; for (const [matKey, geom] of buckets) { const mesh = new Mesh(`${rootName}_${matKey}`, this.scene); const vd = new VertexData(); vd.positions = geom.positions; vd.indices = geom.indices; vd.normals = geom.normals; vd.colors = geom.colors; vd.uvs = geom.uvs; vd.applyToMesh(mesh, false); mesh.material = this._getMaterial(matKey); // Включаем per-vertex color для StandardMaterial mesh.hasVertexAlpha = false; mesh.useVertexColors = true; mesh.isPickable = true; mesh.metadata = { isUserModel: true, userModelId, instanceId, }; mesh.parent = root; meshes.push(mesh); } // Воксельная маска коллизий (кэш по userModelId — одна на модель). // Используется PhysicsAABB для narrow-фазы: точная коллизия по // занятым вокселям вместо одного грубого AABB-короба. const voxelMask = this._getVoxelMask(userModelId, modelData); const data = { instanceId, userModelId, userModelTypeId, rootNode: root, meshes, x, y, z, rotationY, // Свойства из Inspector — по умолчанию: сталкивается, видим, заякорен. canCollide: options.canCollide !== false, visible: options.visible !== false, anchored: options.anchored !== false, mass: typeof options.mass === 'number' ? options.mass : 1, scale: typeof options.scale === 'number' ? options.scale : 1, // localAABB — кэшируется ниже через _computeLocalAABB. localAABB: null, // voxelMask — общая для всех инстансов модели (ссылка на кэш). voxelMask, }; this.instances.set(instanceId, data); // Вычисляем локальный AABB через вершины (для PhysicsAABB). this._computeLocalAABB(data); // Применяем visible/scale из options если они пришли при load. if (options.visible === false) { try { root.setEnabled(false); } catch (e) {} for (const m of meshes) { try { m.setEnabled(false); } catch (e) {} } } if (typeof options.scale === 'number' && options.scale !== 1) { root.scaling.x = options.scale; root.scaling.y = options.scale; root.scaling.z = options.scale; } try { this._onChange?.(); } catch (e) {} return instanceId; } /** Посчитать локальный AABB модели через прямой обход вершин. * data.localAABB = {minX, maxX, minY, maxY, minZ, maxZ} в локальных * координатах (без position/rotation/scale). * Используется PhysicsAABB._aabbIntersectsUserModel для коллизий. */ _computeLocalAABB(data) { let minX = Infinity, maxX = -Infinity; let minY = Infinity, maxY = -Infinity; let minZ = Infinity, maxZ = -Infinity; for (const m of data.meshes) { if (!m || typeof m.getTotalVertices !== 'function') continue; if (m.getTotalVertices() <= 0) continue; let positions; try { positions = m.getVerticesData(VertexBuffer.PositionKind); } catch (e) { continue; } if (!positions) continue; // Vertices в локальных координатах меша. Меш парентится к rootNode, // позиция meша = (0,0,0) внутри rootNode, поэтому local AABB меша // = local AABB модели по этому материалу. for (let i = 0; i < positions.length; i += 3) { const x = positions[i], y = positions[i + 1], z = positions[i + 2]; if (x < minX) minX = x; if (x > maxX) maxX = x; if (y < minY) minY = y; if (y > maxY) maxY = y; if (z < minZ) minZ = z; if (z > maxZ) maxZ = z; } } if (isFinite(minX) && isFinite(maxX)) { data.localAABB = { minX, maxX, minY, maxY, minZ, maxZ }; } else { // Fallback 1×1×1 data.localAABB = { minX: -0.5, maxX: 0.5, minY: 0, maxY: 1, minZ: -0.5, maxZ: 0.5 }; } } /** Удалить инстанс. */ removeInstance(instanceId) { const inst = this.instances.get(instanceId); if (!inst) return; for (const m of inst.meshes) { try { m.dispose(); } catch (e) {} } try { inst.rootNode.dispose(); } catch (e) {} this.instances.delete(instanceId); // Уведомляем onChange — внешний код (BabylonScene) дёрнет // physics.setSpatialDirty() + onSceneChange. try { this._onChange?.(); } catch (e) {} } /** Подписка на изменения (BabylonScene использует для setSpatialDirty / markDirty). */ setOnChange(cb) { this._onChange = cb; } /** Инвалидировать модель после её редактирования. * Сбрасывает кэш model_data → следующий addInstance возьмёт свежие данные. * Если опция rebuild=true (по умолчанию) — пересоздаёт ВСЕ существующие * инстансы этой модели в сцене с новой геометрией. * * @param {number} userModelId — id из БД (без 'user:'-префикса) * @param {object} options * @param {boolean} options.rebuild — пересоздать существующие инстансы (default true) * @param {number} options.currentUserId — для приватных моделей * @returns {Promise} количество пересозданных инстансов */ async invalidateModel(userModelId, options = {}) { const { rebuild = true, currentUserId = null } = options; // Сбрасываем кэш data — следующий addInstance загрузит свежие данные. // Маску коллизий тоже сбрасываем — модель изменилась, форма другая. this._dataCache.delete(userModelId); this._maskCache.delete(userModelId); if (!rebuild) return 0; // Собираем существующие инстансы ЭТОЙ модели — нужно запомнить их // позицию/поворот/свойства, удалить, заново создать с новой геометрией. const matches = []; for (const inst of this.instances.values()) { if (inst.userModelId === userModelId) { matches.push({ instanceId: inst.instanceId, userModelTypeId: inst.userModelTypeId, x: inst.x, y: inst.y, z: inst.z, rotationY: inst.rotationY, scale: inst.scale, canCollide: inst.canCollide, visible: inst.visible, anchored: inst.anchored, mass: inst.mass, }); } } if (matches.length === 0) return 0; // Удаляем старые инстансы (это вызовет _onChange после каждого). // Сохранять выделение бесполезно — instanceId сменится. for (const m of matches) { this.removeInstance(m.instanceId); } // Создаём заново — каждый получит новый instanceId. // _loadModelData теперь подтянет model_data заново (кэш сброшен). let rebuilt = 0; for (const m of matches) { const newId = await this.addInstance( m.userModelTypeId, m.x, m.y, m.z, m.rotationY, { currentUserId, scale: m.scale, canCollide: m.canCollide, visible: m.visible, anchored: m.anchored, mass: m.mass, }, ); if (newId != null) rebuilt++; } return rebuilt; } /** Сериализация инстансов для сохранения в project_data. */ serialize() { const arr = []; for (const inst of this.instances.values()) { arr.push({ type: inst.userModelTypeId, x: inst.x, y: inst.y, z: inst.z, ry: inst.rotationY, scale: inst.scale ?? 1, canCollide: inst.canCollide !== false, visible: inst.visible !== false, anchored: inst.anchored !== false, mass: inst.mass ?? 1, // instanceId — чтобы target-скрипты могли стабильно ссылаться // на конкретный инстанс после перезагрузки. instanceId: inst.instanceId, }); } return arr; } /** Восстановить инстансы из serialize-формата. * * ОПТИМИЗАЦИЯ ЗАГРУЗКИ: раньше инстансы грузились строго последовательно * (await addInstance в цикле) — N моделей = N HTTP-запросов один за * другим, на старте игры это давало секунды задержки. * * Теперь — две фазы: * 1. PREFETCH: собираем уникальные userModelId, грузим их model_data * пачками с ОГРАНИЧЕННЫМ параллелизмом (PREFETCH_CONCURRENCY). * ВАЖНО: нельзя грузить все разом через Promise.all — браузер * лимитирует ~6 соединений к домену, и 17 параллельных запросов * забивают пул → другие запросы (напр. открытие модели в * редакторе) висят до таймаута. Пачки по 4 — быстро и безопасно. * 2. BUILD: создаём инстансы из уже-прогретого кэша — addInstance не * делает сеть (данные в _dataCache), только строит меши. */ async loadFromArray(arr, options = {}) { if (!Array.isArray(arr)) return 0; // --- Фаза 1: prefetch model_data пачками (bounded concurrency) --- const PREFETCH_CONCURRENCY = 4; const uniqueIds = []; const seenIds = new Set(); for (const item of arr) { const uid = parseUserModelId(item.type); if (uid != null && !seenIds.has(uid)) { seenIds.add(uid); uniqueIds.push(uid); } } for (let i = 0; i < uniqueIds.length; i += PREFETCH_CONCURRENCY) { const batch = uniqueIds.slice(i, i + PREFETCH_CONCURRENCY); await Promise.all( batch.map((uid) => this._loadModelData(uid, options.currentUserId) .catch((e) => { console.warn('[UserModelManager] prefetch failed', uid, e); return null; }), ), ); } // --- Фаза 2: создание инстансов (model_data уже в кэше) --- let loaded = 0; for (const item of arr) { try { const id = await this.addInstance( item.type, item.x, item.y, item.z, item.ry || 0, { ...options, scale: item.scale, canCollide: item.canCollide, visible: item.visible, anchored: item.anchored, mass: item.mass, forceInstanceId: item.instanceId, }, ); if (id != null) loaded++; } catch (e) { console.warn('[UserModelManager] failed to load instance', item, e); } } return loaded; } /** Полная очистка — все инстансы убраны, кэш сброшен. */ clear() { for (const inst of this.instances.values()) { for (const m of inst.meshes) { try { m.dispose(); } catch (e) {} } try { inst.rootNode.dispose(); } catch (e) {} } this.instances.clear(); } /** Количество активных инстансов. */ getInstanceCount() { return this.instances.size; } }