/** * VoxelRenderer — Babylon-адаптер для рендера VoxelWorld. * * Идея: один Mesh на чанк × материал. Чанк перестраивается только когда * dirty=true. Меш кешируется в chunk.meshRef = Map. * * Материалы переиспользуются из существующего TERRAIN_MATERIALS — тот * же набор текстур (Kenney Voxel Pack), что и в legacy TerrainManager. * Это даёт визуальную совместимость и единую палитру. * * MultiCube материалы (grass: top/side/bottom): * В этой версии (Этап 1) — НЕ поддерживаются. Все грани куба красятся * ОДНОЙ текстурой материала. Для grass — берём top (зелёная травка), * что выглядит OK на верхушке, но на боках будет тоже травка вместо * земли. Это компромисс: greedy meshing (Этап 2) сделает корректный * MultiCube через разделение mesh'ей по граням. */ import { Mesh, VertexData, StandardMaterial, Texture, Color3, } from '@babylonjs/core'; // ВАЖНО: для terrain-слоя (0.25м, текстуры Kenney) используем surface // culling (ChunkMesher), а НЕ greedy. Voxlands-стиль требует чтобы // каждый voxel был виден как отдельный куб с тёмными гранями между // ячейками. Greedy сливает соседние грани в один большой квад → теряется // объём (см. RUBLOX_VOXEL_ENGINE_PLAN.md, Этап 2 примечание). // // GreedyMesher оставлен в коде — будет использоваться для будущего // build-слоя (пользовательские постройки из 1×1м блоков), где как раз // нужно мерджить большие плоские стены. import { buildChunkGeometry } from './ChunkMesher'; /** * Описание материалов слоя. Структура совпадает с TERRAIN_MATERIALS * в TerrainManager.js, но дублируется чтобы не было кругового импорта. * * matId → { color, texture, top, side, bottom, alpha, emissive } */ const TEX = '/kubikon-assets/textures'; export const VOXEL_MATERIALS = { grass: { color: '#52b15a', top: `${TEX}/grass_top.png`, side: `${TEX}/dirt.png`, bottom: `${TEX}/dirt.png` }, rock: { color: '#7e7e7e', texture: `${TEX}/greystone.png` }, sand: { color: '#e6d27a', texture: `${TEX}/sand.png` }, snow: { color: '#f5f7fb', texture: `${TEX}/snow.png` }, dirt: { color: '#7c5430', texture: `${TEX}/dirt.png` }, water: { color: '#3a8fd6', texture: `${TEX}/water.png`, alpha: 0.72 }, asphalt: { color: '#3b3b3b', texture: `${TEX}/stone.png` }, concrete: { color: '#b8b8b8', texture: `${TEX}/greystone.png` }, wood: { color: '#a06a3a', texture: `${TEX}/wood.png` }, glacier: { color: '#c8e6f5', texture: `${TEX}/ice.png`, alpha: 0.92 }, salt: { color: '#ecedef', texture: `${TEX}/snow.png` }, mud: { color: '#553a25', texture: `${TEX}/gravel_dirt.png` }, // Voxlands-декорации leaves: { color: '#3f7a3a', texture: `${TEX}/leaves.png` }, leaves_orange: { color: '#c2741e', texture: `${TEX}/leaves_orange.png` }, trunk: { color: '#5a3b1f', top: `${TEX}/trunk_top.png`, side: `${TEX}/trunk_side.png`, bottom: `${TEX}/trunk_bottom.png` }, trunk_white: { color: '#e0dfd6', top: `${TEX}/trunk_top.png`, side: `${TEX}/trunk_white_side.png`, bottom: `${TEX}/trunk_top.png` }, rock_moss: { color: '#5d6f43', texture: `${TEX}/rock_moss.png` }, flower_red: { color: '#b84141', texture: `${TEX}/cotton_red.png` }, flower_blue: { color: '#4673b8', texture: `${TEX}/cotton_blue.png` }, flower_yellow: { color: '#d4c84a', texture: `${TEX}/cotton_tan.png` }, mushroom_red: { color: '#a02525', texture: `${TEX}/mushroom_red.png` }, tall_grass: { color: '#5fa84e', texture: `${TEX}/wheat_stage4.png` }, }; /** Конвертация hex '#aabbcc' → Color3. */ function hexToColor3(hex) { const m = /^#?([a-f0-9]{6})$/i.exec(hex || ''); if (!m) return new Color3(0.7, 0.7, 0.7); const n = parseInt(m[1], 16); return new Color3(((n >> 16) & 0xff) / 255, ((n >> 8) & 0xff) / 255, (n & 0xff) / 255); } export class VoxelRenderer { /** * @param {VoxelWorld} world * @param {BABYLON.Scene} scene */ constructor(world, scene) { this.world = world; this.scene = scene; /** Map — кеш материалов. */ this._materials = new Map(); /** Колбэк создания меша — для теневой регистрации (shadowMap.renderList). */ this._onMeshCreated = null; } setOnMeshCreated(cb) { this._onMeshCreated = cb; } /** * Получить или создать Babylon-материал по ключу. * Ключ может быть простым ('rock', 'sand', ...) или MultiCube * вариантом ('grass:top', 'grass:side', 'grass:bottom', 'trunk:top', ...). * * Для MultiCube ключей берём соответствующую текстуру из def.top/side/ * bottom; для простых — def.texture. * * Wrap-режим: REPEAT (Texture.WRAP_ADDRESSMODE) — критично для greedy * meshing. Большой квад W×H требует чтобы текстура повторялась W раз * по одной оси и H раз по другой. UV-coords тогда идут (0..W, 0..H). */ _getMaterial(matKey) { let mat = this._materials.get(matKey); if (mat) return mat; // Разбираем MultiCube ключ const [matId, face] = matKey.includes(':') ? matKey.split(':') : [matKey, null]; const def = VOXEL_MATERIALS[matId]; if (!def) { mat = new StandardMaterial(`__voxel_mat_${matKey}_missing`, this.scene); mat.diffuseColor = new Color3(1, 0, 1); this._materials.set(matKey, mat); return mat; } mat = new StandardMaterial(`__voxel_mat_${matKey}`, this.scene); // Specular выключен — на pixel-art-текстуре любой блик ломает стиль. mat.specularColor = new Color3(0, 0, 0); // КРИТИЧНО: ambient=(1,1,1) чтобы hemisphere-свет освещал материал // со всех сторон. Без этого тыловые/нижние грани выходят почти // чёрными (как было на скрине — потемневший террейн). mat.ambientColor = new Color3(1, 1, 1); // Выбираем текстуру в зависимости от грани (для MultiCube) let texPath; if (face === 'top') { texPath = def.top ?? def.texture; } else if (face === 'bottom') { texPath = def.bottom ?? def.texture; } else if (face === 'side') { texPath = def.side ?? def.texture; } else { texPath = def.texture ?? def.top; } if (texPath) { // noMipmap=true — критично для pixel-art (mipmap усредняет пиксели). // invertY=false — Kenney pack уже в нормальной ориентации. const tex = new Texture( texPath, this.scene, /*noMipmap*/ true, /*invertY*/ false, Texture.NEAREST_SAMPLINGMODE, ); tex.wrapU = Texture.CLAMP_ADDRESSMODE; tex.wrapV = Texture.CLAMP_ADDRESSMODE; mat.diffuseTexture = tex; if (def.alpha != null && def.alpha < 1) { mat.diffuseTexture.hasAlpha = true; mat.useAlphaFromDiffuseTexture = true; mat.alpha = def.alpha; } // НЕ ставим diffuseColor — иначе текстура × цвет → потемнение. // Текстура показывается в полной интенсивности. } else { // Fallback на цвет если нет текстуры mat.diffuseColor = hexToColor3(def.color); if (def.alpha != null && def.alpha < 1) mat.alpha = def.alpha; } this._materials.set(matKey, mat); return mat; } /** * Перестроить mesh'и одного чанка. Если чанк не dirty — no-op. * Вызывается из update() для каждого dirty-чанка. */ rebuildChunk(chunk, layer) { if (!chunk.dirty) return; // Хелпер для проверки соседних чанков по глобальной voxel-координате const neighborMatIdx = (gx, gy, gz) => layer.getMatIdx(gx, gy, gz); // Surface culling без greedy — сохраняем Voxlands-look (каждый voxel // виден как отдельный куб с тёмными гранями между ячейками). // Greedy убирал эти грани и портил визуальный стиль. const mesh = buildChunkGeometry(chunk, layer, neighborMatIdx); // Удаляем старые меши (если были) if (chunk.meshRef) { for (const m of chunk.meshRef.values()) { m.dispose(); } } if (mesh.byMaterial.size === 0) { chunk.meshRef = null; chunk.dirty = false; return; } // Создаём по одному Mesh на каждый материал const meshMap = new Map(); for (const [matId, geo] of mesh.byMaterial) { const meshName = `__voxel_${layer.name}_${chunk.cx}_${chunk.cy}_${chunk.cz}_${matId}`; const m = new Mesh(meshName, this.scene); const vd = new VertexData(); vd.positions = geo.positions; vd.normals = geo.normals; vd.uvs = geo.uvs; vd.indices = geo.indices; vd.applyToMesh(m, false); // not updateable — статичные данные m.material = this._getMaterial(matId); // isPickable=false: терреин не пикается через scene.pick, мы // используем свой raycast по voxel-grid. m.isPickable = false; m.receiveShadows = true; // Метаданные для дебага/идентификации m.metadata = { _isVoxelChunkMesh: true, layerName: layer.name, chunkKey: `${chunk.cx},${chunk.cy},${chunk.cz}`, matId, }; meshMap.set(matId, m); try { this._onMeshCreated?.(m); } catch (e) {} } chunk.meshRef = meshMap; chunk.dirty = false; } /** * Перестроить все dirty-чанки во всех слоях. Вызывается из game loop * или после загрузки мира. Возвращает количество перестроенных чанков. */ rebuildDirty() { let rebuilt = 0; for (const layer of this.world.layers.values()) { for (const chunk of layer.chunks.values()) { if (chunk.dirty) { this.rebuildChunk(chunk, layer); rebuilt++; } } } return rebuilt; } /** Полная перестройка всего мира (для миграции после loadFromArray). */ rebuildAll() { const t0 = performance.now(); let chunks = 0; let totalQuads = 0; let totalTriangles = 0; let totalMeshes = 0; for (const layer of this.world.layers.values()) { for (const chunk of layer.chunks.values()) { chunk.dirty = true; this.rebuildChunk(chunk, layer); chunks++; if (chunk.meshRef) { totalMeshes += chunk.meshRef.size; for (const m of chunk.meshRef.values()) { if (m.getIndices) { const idx = m.getIndices(); if (idx) { const tris = idx.length / 3; totalTriangles += tris; totalQuads += tris / 2; } } } } } } const dt = performance.now() - t0; console.log(`[VoxelRenderer] rebuildAll: ${chunks} chunks, ${totalMeshes} meshes, ${Math.round(totalQuads)} quads, ${Math.round(totalTriangles)} triangles in ${dt.toFixed(0)}ms`); return chunks; } // ======================================================================== // Этап 4 — Streaming (рендер только чанков в радиусе от камеры) // ======================================================================== /** * Уничтожить mesh'и одного чанка, оставив данные voxel'ов в памяти. * Используется при выходе чанка из render distance. */ unloadChunkMesh(chunk) { if (!chunk.meshRef) return; for (const m of chunk.meshRef.values()) m.dispose(); chunk.meshRef = null; // dirty НЕ ставим — данные не изменились, при следующем входе в // radius re-build пересоздаст mesh. } /** * Обновить streaming: для каждого слоя — определить какие чанки * должны быть видимы (в радиусе renderRadius от точки center). * Чанки внутри — meshed (lazy build). Чанки снаружи — unloaded. * * @param {Vector3-like} center — мировые координаты центра ({x,z} or Vector3) * @param {number} renderRadius — радиус в метрах (default 64м = 8 чанков × 0.25 × 32) * @returns {{loaded:number, unloaded:number, total:number}} */ updateStreaming(center, renderRadius = 64) { let loaded = 0; let unloaded = 0; let total = 0; const cx = center.x ?? 0; const cz = center.z ?? 0; for (const layer of this.world.layers.values()) { // Размер чанка в метрах = CHUNK_SIZE × voxelSize const chunkWorldSize = 32 * layer.voxelSize; // 8м для terrain // Квадратичный radius для сравнения с distance² (быстрее sqrt) const r2 = renderRadius * renderRadius; for (const chunk of layer.chunks.values()) { total++; // Центр чанка в мире const chunkCenterX = (chunk.cx + 0.5) * chunkWorldSize; const chunkCenterZ = (chunk.cz + 0.5) * chunkWorldSize; const dx = chunkCenterX - cx; const dz = chunkCenterZ - cz; const dist2 = dx * dx + dz * dz; // Учитываем «полу-диагональ» чанка чтобы не cull-ить почти // видимые. Если центр чанка дальше radius+halfDiagonal — // точно невидим. const halfDiag = chunkWorldSize * 0.707; // sqrt(2)/2 const cutoff = renderRadius + halfDiag; const shouldRender = dist2 <= cutoff * cutoff; if (shouldRender) { if (!chunk.meshRef && !chunk.isEmpty()) { // Lazy mesh build chunk.dirty = true; this.rebuildChunk(chunk, layer); loaded++; } else if (chunk.dirty) { // Mesh уже есть, но данные изменились — пересборка this.rebuildChunk(chunk, layer); } } else { if (chunk.meshRef) { this.unloadChunkMesh(chunk); unloaded++; } } } } return { loaded, unloaded, total }; } /** Текущее число активных (отрендеренных) чанков. */ getActiveChunkCount() { let n = 0; for (const layer of this.world.layers.values()) { for (const ch of layer.chunks.values()) { if (ch.meshRef) n++; } } return n; } /** Освободить все mesh'и (при unload или dispose редактора). */ dispose() { for (const layer of this.world.layers.values()) { for (const chunk of layer.chunks.values()) { if (chunk.meshRef) { for (const m of chunk.meshRef.values()) m.dispose(); chunk.meshRef = null; } } } for (const m of this._materials.values()) m.dispose(); this._materials.clear(); } }