/** * ChunkMesher — строит геометрию одного чанка из его voxel-данных. * * На этом этапе (1) — простой surface culling без greedy merge: * Для каждой занятой ячейки проверяем 6 соседей. * Если сосед пустой → грань видна → добавляем в геометрию (квад из 2 треугольников). * * Greedy meshing появится в Этапе 2. * * Производит геометрию в формате «по материалам»: * Map * GeometryData = { positions, normals, uvs, indices } * * Это позволяет renderer'у создать ОДИН Mesh на чанк × материал, * а не один большой mesh с MultiMaterial (последнее сложнее с * корректным culling и shadow casting в Babylon). * * Pure function — не зависит от Babylon/Filament. Тестируется отдельно. * Renderer-адаптеры (VoxelRenderer.js для веба, VoxelRenderer.kt для * Android) принимают этот результат и создают meshes своей графикой. */ import { CHUNK_SIZE, VoxelChunk } from './VoxelChunk'; /** * 6 граней voxel-куба. Каждая запись: * - normal: направление нормали (для культинга соседа) * - corners: 4 угла грани в относительных координатах (0/1 по 3 осям) * - faceIdx: индекс грани (0=-Z, 1=+X, 2=+Z, 3=-X, 4=-Y, 5=+Y) для * совместимости с FACE_INDEX в существующем TerrainManager * * Порядок углов: counter-clockwise при взгляде снаружи воксел'а. * UV: первый угол — (0,1), второй — (0,0), третий — (1,0), четвёртый — (1,1). * Это совпадает с тем как Babylon BoxBuilder раскладывает UV на гранях. */ const FACES = [ // 0: -Z (front к камере при стандартной ориентации) { faceIdx: 0, normal: [0, 0, -1], dx: 0, dy: 0, dz: -1, corners: [ [0, 0, 0], [0, 1, 0], [1, 1, 0], [1, 0, 0], ]}, // 1: +X (right) { faceIdx: 1, normal: [1, 0, 0], dx: 1, dy: 0, dz: 0, corners: [ [1, 0, 0], [1, 1, 0], [1, 1, 1], [1, 0, 1], ]}, // 2: +Z (back) { faceIdx: 2, normal: [0, 0, 1], dx: 0, dy: 0, dz: 1, corners: [ [1, 0, 1], [1, 1, 1], [0, 1, 1], [0, 0, 1], ]}, // 3: -X (left) { faceIdx: 3, normal: [-1, 0, 0], dx: -1, dy: 0, dz: 0, corners: [ [0, 0, 1], [0, 1, 1], [0, 1, 0], [0, 0, 0], ]}, // 4: -Y (bottom) { faceIdx: 4, normal: [0, -1, 0], dx: 0, dy: -1, dz: 0, corners: [ [0, 0, 1], [0, 0, 0], [1, 0, 0], [1, 0, 1], ]}, // 5: +Y (top) { faceIdx: 5, normal: [0, 1, 0], dx: 0, dy: 1, dz: 0, corners: [ [0, 1, 0], [0, 1, 1], [1, 1, 1], [1, 1, 0], ]}, ]; /** * Прозрачные материалы (через них видна геометрия за ними). Используются * для пропуска грани при surface culling — например водный voxel рядом с * каменной стеной должен показать стену. * * Список совпадает с веб-плеером и Android (NON_SOLID_TERRAIN). */ const TRANSPARENT_MATERIALS = new Set([ 'water', 'glacier', 'leaves', 'leaves_orange', 'flower_red', 'flower_blue', 'flower_yellow', 'mushroom_red', 'tall_grass', ]); /** * Multi-cube материалы — top/side/bottom разные текстуры. Mesher * разделяет их в ключи материалов с суффиксом ":top" / ":side" / * ":bottom". VoxelRenderer применяет соответствующую текстуру. * * Без этого верх grass-voxel'а был бы землёй, а бока — травой. */ const MULTICUBE_MATERIALS = new Set([ 'grass', 'trunk', 'trunk_white', ]); /** * Получить ключ материала с учётом грани (для MultiCube). * grass + faceIdx=5 (top) → "grass:top" * grass + faceIdx=4 (bottom) → "grass:bottom" * grass + side (0..3) → "grass:side" * rock + любая грань → "rock" */ function materialKey(matId, faceIdx) { if (!MULTICUBE_MATERIALS.has(matId)) return matId; if (faceIdx === 5) return `${matId}:top`; if (faceIdx === 4) return `${matId}:bottom`; return `${matId}:side`; } /** * Хелпер: грань между own (текущий voxel) и neighbor (сосед) видна? * - neighbor пустой → грань видна * - оба непрозрачные → грань НЕ видна (поверхности слиплись) * - own непрозрачный, neighbor прозрачный → грань видна (стена за водой) * - own прозрачный, neighbor непрозрачный → грань НЕ видна (внутри стены) * - оба прозрачные, разные → грань видна (граница вода/листва) * - оба прозрачные, одинаковые → грань НЕ видна (вода-вода) */ function isFaceVisible(ownMat, neighborMat) { if (!neighborMat) return true; const ownTransparent = TRANSPARENT_MATERIALS.has(ownMat); const nbTransparent = TRANSPARENT_MATERIALS.has(neighborMat); if (!ownTransparent && !nbTransparent) return false; if (!ownTransparent && nbTransparent) return true; if (ownTransparent && !nbTransparent) return false; return ownMat !== neighborMat; } /** * @typedef {Object} GeometryData * @property {Float32Array} positions — Vector3 на каждую вершину * @property {Float32Array} normals * @property {Float32Array} uvs * @property {Uint32Array} indices * @property {number} faceCount — для статистики */ /** * @typedef {Object} MeshResult * @property {Map} byMaterial * @property {number} totalFaces * @property {number} timeMs */ /** * Построить геометрию чанка. * * @param {VoxelChunk} chunk * @param {VoxelLayer} layer * @param {(gx:number,gy:number,gz:number)=>number} neighborMatIdx * Функция получения соседа по глобальным voxel-координатам — нужна * для проверки соседних чанков на границе. Если не передана — * соседи за пределами чанка считаются пустыми (всегда видимые * грани на границах; визуально может быть швы между чанками, * но безопасно как fallback). * @returns {MeshResult} */ export function buildChunkGeometry(chunk, layer, neighborMatIdx = null) { const t0 = (typeof performance !== 'undefined' ? performance.now() : Date.now()); const result = new Map(); // matId → GeometryData accumulator (arrays) let totalFaces = 0; const voxelSize = layer.voxelSize; const ox = chunk.voxelOriginX(); const oy = chunk.voxelOriginY(); const oz = chunk.voxelOriginZ(); // Pre-аллоцированные временные массивы; финальные Float32Array // соберём в конце. const accumulators = new Map(); // matId → { positions:[], normals:[], uvs:[], indices:[] } function getAccum(matId) { let a = accumulators.get(matId); if (!a) { a = { positions: [], normals: [], uvs: [], indices: [], faceCount: 0 }; accumulators.set(matId, a); } return a; } // Хелпер: matIdx соседа по локальным координатам внутри чанка, либо // через callback для соседнего чанка. function getNeighborIdx(lx, ly, lz) { if (lx >= 0 && lx < CHUNK_SIZE && ly >= 0 && ly < CHUNK_SIZE && lz >= 0 && lz < CHUNK_SIZE) { return chunk.data[VoxelChunk.localIndex(lx, ly, lz)]; } // За пределами чанка if (neighborMatIdx) { return neighborMatIdx(ox + lx, oy + ly, oz + lz); } return 0; } // Основной цикл: идём по всем ячейкам чанка for (let ly = 0; ly < CHUNK_SIZE; ly++) { for (let lz = 0; lz < CHUNK_SIZE; lz++) { for (let lx = 0; lx < CHUNK_SIZE; lx++) { const ownIdx = chunk.data[VoxelChunk.localIndex(lx, ly, lz)]; if (ownIdx === 0) continue; const ownMat = layer.matIdxToId(ownIdx); if (!ownMat) continue; // Глобальные координаты в воксельных единицах const gx = ox + lx; const gy = oy + ly; const gz = oz + lz; // Мировые координаты левого-нижнего угла voxel'а const wx0 = gx * voxelSize; const wy0 = gy * voxelSize; const wz0 = gz * voxelSize; // Проверяем 6 граней for (let f = 0; f < 6; f++) { const face = FACES[f]; const nbIdx = getNeighborIdx(lx + face.dx, ly + face.dy, lz + face.dz); const nbMat = nbIdx !== 0 ? layer.matIdxToId(nbIdx) : null; if (!isFaceVisible(ownMat, nbMat)) continue; // Добавляем 4 вершины + 2 треугольника. // Для MultiCube (grass/trunk/trunk_white) ключ материала // содержит суффикс ":top"/":side"/":bottom" — Renderer // применит правильную текстуру. const matKey = materialKey(ownMat, face.faceIdx); const accum = getAccum(matKey); const baseV = accum.positions.length / 3; const nx = face.normal[0], ny = face.normal[1], nz = face.normal[2]; const uvCorners = [[0, 1], [0, 0], [1, 0], [1, 1]]; for (let i = 0; i < 4; i++) { const corner = face.corners[i]; accum.positions.push( wx0 + corner[0] * voxelSize, wy0 + corner[1] * voxelSize, wz0 + corner[2] * voxelSize, ); accum.normals.push(nx, ny, nz); const uv = uvCorners[i]; accum.uvs.push(uv[0], uv[1]); } // Два треугольника: (0,1,2) и (0,2,3) accum.indices.push(baseV, baseV + 1, baseV + 2); accum.indices.push(baseV, baseV + 2, baseV + 3); accum.faceCount++; totalFaces++; } } } } // Преобразуем аккумуляторы в Typed Arrays for (const [matId, accum] of accumulators) { result.set(matId, { positions: new Float32Array(accum.positions), normals: new Float32Array(accum.normals), uvs: new Float32Array(accum.uvs), indices: new Uint32Array(accum.indices), faceCount: accum.faceCount, }); } const dt = (typeof performance !== 'undefined' ? performance.now() : Date.now()) - t0; return { byMaterial: result, totalFaces, timeMs: dt }; }