/** * GdForest — расстановка декораций ландшафта в GD-уровне (этап G6 v2). * * Загружает GLB-модели из gd_deco_choices (выбор юзера в /admin-preview/gd-deco) * для текущей эпохи, расставляет их вдоль уровня случайно из выбранного набора. * * - Дальние позиции (z=8..22) — крупные деревья/камни. * - Ближние (z=-3..-7) — мелкие, мешают видимости меньше. * - Через thin-instances: 1 draw call на тип модели. */ import { Mesh, MeshBuilder, SceneLoader, Matrix, Quaternion, Vector3, Color3, StandardMaterial, DynamicTexture, VertexData, Ray, } from '@babylonjs/core'; import '@babylonjs/loaders/glTF'; import { DECO_CATALOG } from '../../admin-preview/gdDeco/decoFactories'; import { RobloxTerrain } from './robloxterrain/RobloxTerrain'; import { DensityGrid, CELL_SIZE } from './robloxterrain/DensityGrid'; import { applyBrush } from './robloxterrain/SmoothBrushes'; const ASSET_ROOT = '/kubikon-assets/models/nature-kit/'; // Дефолтный выбор по эпохе (выбор юзера сохранён в gd_deco_choices). const DEFAULT_DECO_BY_EPOCH = { 1: ['d1_v1', 'd1_v2', 'd1_v5', 'd1_v6', 'd1_v10'], 2: ['d2_v1', 'd2_v3', 'd2_v5', 'd2_v7', 'd2_v9'], // 5 видов сосен 3: ['d3_v8', 'd3_v10', 'd3_v7', 'd3_v4', 'd3_v5'], 4: ['d4_v4', 'd4_v1', 'd4_v2', 'd4_v8', 'd4_v7'], 5: ['d5_v4', 'd5_v2', 'd5_v1', 'd5_v8', 'd5_v5'], 6: ['d6_v5', 'd6_v6', 'd6_v8', 'd6_v9', 'd6_v10'], 7: ['d7_v3', 'd7_v4', 'd7_v5', 'd7_v9', 'd7_v10'], 8: ['d8_v5', 'd8_v4', 'd8_v3', 'd8_v2', 'd8_v9'], 9: ['d9_v5', 'd9_v4', 'd9_v3', 'd9_v2', 'd9_v1'], }; // Кол-во инстансов на тип модели — только дальний план, перед камерой пусто. // В low-quality режиме уменьшаем в 2 раза для FPS. const _quality = (typeof localStorage !== 'undefined' && localStorage.getItem('gd_graphics_quality')) || 'high'; const FAR_TOTAL = _quality === 'low' ? 40 : 80; const FAR_Z_MIN = 8; const FAR_Z_MAX = 22; // Y базы — корни врастают ниже верха пола (отрицательное Y), чтобы дерево // сидело глубже в траве и не «парило». const BASE_Y = -0.5; // Глобальный коэффициент масштаба (уменьшение в 1.4 раза от каталога). const SCALE_MULT = 0.7; export class GdForest { constructor() { this.scene = null; this._levelWidth = 1000; this._protos = []; // [{ mesh, items }] this._onBeforeRender = null; this._t = 0; } /** * @param scene Babylon scene * @param levelWidth ширина уровня в метрах * @param epoch номер эпохи (1..10) * @param decoIds массив id моделей (если не задан — берём DEFAULT_DECO_BY_EPOCH) */ async attach(scene, levelWidth = 1000, epoch = 1, decoIds = null) { if (!scene) return; this.scene = scene; this._levelWidth = levelWidth; this._epoch = epoch; const ids = decoIds || DEFAULT_DECO_BY_EPOCH[epoch] || []; if (ids.length === 0) { console.log('[GdForest] нет выбранных декораций для эпохи', epoch); return; } // Загружаем все GLB параллельно const loaded = await Promise.all(ids.map((id) => this._loadDecoProto(id))); const valid = loaded.filter((x) => x && x.proto); if (valid.length === 0) { console.warn('[GdForest] ни одна модель не загрузилась'); return; } this._protos = valid; this._createForestGround(); this._distributeInstances(); this._setupWind(); console.log(`[GdForest] загружено ${valid.length}/${ids.length} моделей для эпохи ${epoch}`); } /** Гладкий 3D-ландшафт «лесная поляна» через RobloxTerrain (как в редакторе). * Скульптим холмы вдоль трассы и просим RobloxTerrain построить все chunks. */ _createForestGround() { // === 1. DensityGrid под лесную полосу за трассой === const Z_NEAR = FAR_Z_MIN - 4; const Z_FAR = FAR_Z_MAX + 8; const xCells = Math.ceil((this._levelWidth + 60) / CELL_SIZE); const zCells = Math.ceil((Z_FAR - Z_NEAR) / CELL_SIZE); const yCells = 8; const originX = Math.floor((-30) / CELL_SIZE); const originY = Math.floor(-12 / CELL_SIZE); const originZ = Math.floor(Z_NEAR / CELL_SIZE); const grid = new DensityGrid({ origin: { x: originX, y: originY, z: originZ }, size: { x: xCells, y: yCells, z: zCells }, }); // === 2. Создаём RobloxTerrain и используем applyBrushAndRebuild === const rt = new RobloxTerrain(this.scene); rt.loadFromGrid(grid, { skipEmpty: true }); const Z_CENTER = (FAR_Z_MIN + FAR_Z_MAX) / 2; // Материал ландшафта по эпохе (соответствует TERRAIN_MATERIALS). const TERRAIN_MAT_BY_EPOCH = { 1: 'grass', 2: 'snow', 3: 'grass', 4: 'grass', 5: 'sand', 6: 'sand', 7: 'rock', 8: 'rock', 9: 'rock', 10: 'rock', }; const tMat = TERRAIN_MAT_BY_EPOCH[this._epoch] || 'grass'; // Базовый «толстый слой грунта» вдоль всей полосы for (let x = -20; x <= this._levelWidth + 20; x += 12) { rt.applyBrushAndRebuild('sculptUp', { center: { x, y: -2, z: Z_CENTER }, radius: 12, strength: 400, material: tMat, }); } // Холмы повыше — реже, разной высоты const numHills = Math.floor(this._levelWidth / 25); for (let i = 0; i < numHills; i++) { const x = (i + 0.5) * 25 + Math.sin(i * 12.9) * 6; const z = Z_CENTER + Math.sin(i * 7.7) * 4; const yPeak = 1 + Math.abs(Math.sin(i * 3.3)) * 3; rt.applyBrushAndRebuild('sculptUp', { center: { x, y: yPeak, z }, radius: 10 + Math.abs(Math.sin(i * 2.1)) * 4, strength: 350, material: tMat, }); } // Сглаживание for (let x = -20; x <= this._levelWidth + 20; x += 18) { rt.applyBrushAndRebuild('smooth', { center: { x, y: 0, z: Z_CENTER }, radius: 18, strength: 200, }); } this._robloxTerrain = rt; console.log('[GdForest] RobloxTerrain создан, chunks=', rt.chunks?.size); } /** Canvas-текстура: трава/земля с цветами/пятнами. Тайлится. */ _makeForestGroundTexture() { const W = 512, H = 256; const dt = new DynamicTexture('gd_forest_ground_tex', { width: W, height: H }, this.scene, true); const ctx = dt.getContext(); // Базовый зелёный (с лёгким noise) for (let y = 0; y < H; y += 4) { for (let x = 0; x < W; x += 4) { const r = Math.abs((Math.sin(x * 0.13 + y * 0.21) * 43758)) % 1; const g = 90 + Math.floor(r * 50); const rd = 35 + Math.floor(r * 25); ctx.fillStyle = `rgb(${rd},${g},45)`; ctx.fillRect(x, y, 4, 4); } } // Тёмные пятна — мох / земля for (let i = 0; i < 60; i++) { const x = Math.abs((Math.sin(i * 12.9898) * 43758)) % W; const y = Math.abs((Math.sin(i * 78.233 + 1) * 43758)) % H; const r = 25 + (i % 7) * 6; const grad = ctx.createRadialGradient(x, y, 0, x, y, r); grad.addColorStop(0, 'rgba(50,30,15,0.45)'); grad.addColorStop(1, 'rgba(50,30,15,0)'); ctx.fillStyle = grad; ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill(); } // Светлые травянистые пятна for (let i = 0; i < 80; i++) { const x = Math.abs((Math.sin(i * 17.1 + 0.7) * 43758)) % W; const y = Math.abs((Math.sin(i * 91.3 + 1.1) * 43758)) % H; const r = 12 + (i % 5) * 4; const grad = ctx.createRadialGradient(x, y, 0, x, y, r); grad.addColorStop(0, 'rgba(120,200,80,0.55)'); grad.addColorStop(1, 'rgba(120,200,80,0)'); ctx.fillStyle = grad; ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill(); } dt.update(); // Тайлим по X много раз — длина уровня большая dt.wrapU = 1; dt.wrapV = 1; dt.uScale = Math.ceil(this._levelWidth / 30); dt.vScale = 1; return dt; } /** Загрузить один прото-меш по id из DECO_CATALOG. * Логика как в SmoothDecoManager: bake parent-chain (root scale/rotation * из glTF) → merge → центрирование bbox. После этого инстансы можно * ставить через простую TRS матрицу. */ async _loadDecoProto(id) { const def = DECO_CATALOG.find((d) => d.id === id); if (!def) { console.warn('[GdForest] неизвестный deco-id:', id); return null; } try { const container = await SceneLoader.LoadAssetContainerAsync( ASSET_ROOT, def.file, this.scene, ); container.addAllToScene(); const allMeshes = container.meshes.slice(); const geoMeshes = allMeshes.filter( (m) => m.getTotalVertices && m.getTotalVertices() > 0, ); if (geoMeshes.length === 0) { console.warn('[GdForest]', def.file, 'нет геометрии'); return null; } // Bake parent-chain (Y-up→Z-up + scale из glTF __root__) в вершины. for (const m of geoMeshes) { m.computeWorldMatrix(true); m.bakeCurrentTransformIntoVertices(); 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]; } else { proto = Mesh.MergeMeshes(geoMeshes, true, true, undefined, false, true); if (!proto) { console.warn('[GdForest] merge failed', def.file); return null; } } proto.name = `gd_deco_proto_${id}`; proto.isPickable = false; // Отключить туман — иначе дальние деревья бледнеют до серого proto.applyFog = false; // Центрируем 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); } // Прото отключаем от рендера (рисуются только thin-instances) proto.isVisible = true; // Двусторонний рендер материала + лёгкая emissive-заливка // (как в SmoothDecoManager — иначе нижние грани чёрные) const meshesToFix = proto.subMeshes ? [proto] : geoMeshes; const seenMats = new Set(); for (const m of meshesToFix) { const mat = m.material; if (!mat || seenMats.has(mat)) continue; seenMats.add(mat); try { mat.backFaceCulling = false; if ('twoSidedLighting' in mat) mat.twoSidedLighting = true; const base = mat.albedoColor || mat.diffuseColor; if (base && base.scale) { mat.emissiveColor = base.scale(0.08); } } catch (e) {} } console.log(`[GdForest] ${id} (${def.file}): sizeY=${sizeY.toFixed(2)}м, baseScale=${def.scale}`); return { proto, baseScale: def.scale || 1.0, items: [] }; } catch (e) { console.warn('[GdForest] load failed', def.file, e); return null; } } _distributeInstances() { for (const proto of this._protos) { proto.items = []; } // FAR — только задний план. Передний план перед камерой пуст, // чтобы не закрывать обзор игроку. for (let i = 0; i < FAR_TOTAL; i++) { const protoIdx = i % this._protos.length; const proto = this._protos[protoIdx]; const xRand = Math.abs((Math.sin(i * 12.9898) * 43758)) % 1; const zRand = Math.abs((Math.sin(i * 78.233 + 1.1) * 43758)) % 1; const sRand = Math.abs((Math.sin(i * 41.21 + 0.5) * 43758)) % 1; const yawRand = Math.abs((Math.sin(i * 23.45 + 2.7) * 43758)) % 1; const x = (i / FAR_TOTAL) * this._levelWidth + (xRand - 0.5) * 12; const z = FAR_Z_MIN + zRand * (FAR_Z_MAX - FAR_Z_MIN); const scale = proto.baseScale * SCALE_MULT * (0.85 + sRand * 0.35); const yaw = yawRand * Math.PI * 2; // Узнаём высоту террейна в (x,z) raycast'ом сверху-вниз — модель // ставится корнями на surface, а не «утопает» в склоне. const surfaceY = this._sampleTerrainHeight(x, z); const y = Number.isFinite(surfaceY) ? surfaceY - 0.1 : BASE_Y; proto.items.push({ x, y, z, scale, yaw, phaseSeed: i }); } // Заливаем матрицы каждого прото в thin-instances for (const proto of this._protos) { const n = proto.items.length; const matrices = new Float32Array(n * 16); const tmp = new Matrix(); for (let i = 0; i < n; i++) { const it = proto.items[i]; const q = Quaternion.RotationYawPitchRoll(it.yaw, 0, 0); const s = new Vector3(it.scale, it.scale, it.scale); const p = new Vector3(it.x, it.y, it.z); Matrix.ComposeToRef(s, q, p, tmp); tmp.copyToArray(matrices, i * 16); } proto.proto.thinInstanceSetBuffer('matrix', matrices, 16, false); proto.proto.thinInstanceCount = n; proto.matrices = matrices; } } /** Опрос высоты террейна в точке (x,z) через raycast сверху вниз. * Берём только меши RobloxTerrain (по имени-префиксу). */ _sampleTerrainHeight(x, z) { if (!this._robloxTerrain) return NaN; const origin = new Vector3(x, 50, z); const direction = new Vector3(0, -1, 0); const ray = new Ray(origin, direction, 100); const hit = this.scene.pickWithRay(ray, (mesh) => { // меши RobloxTerrain имеют имя "__robloxMesh_..." const n = mesh.name || ''; return n.startsWith('__robloxMesh_'); }); if (hit && hit.hit && hit.pickedPoint) return hit.pickedPoint.y; return NaN; } /** Лёгкое колебание моделей от ветра — каждые 4 кадра. */ _setupWind() { let frame = 0; this._onBeforeRender = () => { frame++; if (frame % 4 !== 0) return; this._t += 0.04; for (const proto of this._protos) { if (!proto.matrices) continue; const tmp = new Matrix(); for (let i = 0; i < proto.items.length; i++) { const it = proto.items[i]; const phase = it.phaseSeed * 0.17; const sway = Math.sin(this._t * 0.7 + phase) * 0.05; const q = Quaternion.RotationYawPitchRoll(it.yaw, 0, sway); const s = new Vector3(it.scale, it.scale, it.scale); const p = new Vector3(it.x, it.y, it.z); Matrix.ComposeToRef(s, q, p, tmp); tmp.copyToArray(proto.matrices, i * 16); } proto.proto.thinInstanceBufferUpdated('matrix'); } }; this.scene.onBeforeRenderObservable.add(this._onBeforeRender); } dispose() { if (!this.scene) return; try { if (this._onBeforeRender) { this.scene.onBeforeRenderObservable.removeCallback(this._onBeforeRender); this._onBeforeRender = null; } } catch (e) {} for (const proto of this._protos) { try { proto.proto.dispose(false, true); } catch (e) {} } if (this._forestGround) { try { this._forestGround.material?.dispose(true, true); } catch (e) {} try { this._forestGround.dispose(); } catch (e) {} this._forestGround = null; } if (this._robloxTerrain) { try { this._robloxTerrain.disposeAll && this._robloxTerrain.disposeAll(); } catch (e) {} this._robloxTerrain = null; } this._protos = []; this.scene = null; } }