/** * SurfaceNetsMesher — построить гладкий mesh из DensityGrid через * **Naive Surface Nets** (Mikola, 2012). * * Главное отличие от vertex-per-cell: * Вершина ставится не в центре cell, а в **средней точке всех edges * с sign-change** через эту cell. Position вершины **интерполируется** * по density значениям (lerp). Это даёт **по-настоящему гладкие** * поверхности без ступенек (как в Roblox). * * Алгоритм: * 1. Для каждой "ячейки" (которая теперь = куб 1×1×1 с 8 corner-values из density): * смотрим 12 рёбер куба. Если ребро пересекает threshold * (один corner solid, другой пуст) — находим точку пересечения * по Lerp. * 2. Position вершины = среднее всех intersection-точек. * 3. Material = доминирующий solid corner. * 4. Для каждого ребра воксельной решётки (между двумя cells) с sign-change * — создаём quad из 4 vertices соседних ячеек. * * Babylon: эмпирически работает CW (inverted), индексы пишем в reverse order. * * UV: triplanar simulation — берём worldPos / cellSize по доминирующей оси. */ import { DENSITY_THRESHOLD, CELL_SIZE } from './DensityGrid'; // 12 рёбер куба: каждое — [cornerIdx0, cornerIdx1, edgeAxis (0=X,1=Y,2=Z)] // Corners индексируются: ((dx) | (dy<<1) | (dz<<2)) для dx,dy,dz ∈ {0,1}. // 0 = (0,0,0), 1=(1,0,0), 2=(0,1,0), 3=(1,1,0) // 4 = (0,0,1), 5=(1,0,1), 6=(0,1,1), 7=(1,1,1) const EDGES = [ // Edges along X (axis 0) [0, 1, 0], [2, 3, 0], [4, 5, 0], [6, 7, 0], // Edges along Y (axis 1) [0, 2, 1], [1, 3, 1], [4, 6, 1], [5, 7, 1], // Edges along Z (axis 2) [0, 4, 2], [1, 5, 2], [2, 6, 2], [3, 7, 2], ]; // Координаты corner'ов внутри куба (0..1) const CORNER_OFFSETS = [ [0, 0, 0], [1, 0, 0], [0, 1, 0], [1, 1, 0], [0, 0, 1], [1, 0, 1], [0, 1, 1], [1, 1, 1], ]; /** * @param {DensityGrid} grid * @param {number} cx0 * @param {number} cy0 * @param {number} cz0 * @param {number} sizeX * @param {number} sizeY * @param {number} sizeZ * @returns {Map} */ export function buildSurfaceNetsMesh(grid, cx0, cy0, cz0, sizeX, sizeY, sizeZ) { const palette = grid.palette; const groups = new Map(); const getGroup = (key) => { let g = groups.get(key); if (!g) { g = { positions: [], normals: [], uvs: [], uvs2: [], uvs3: [], indices: [], vIdx: 0, colors: [] }; groups.set(key, g); } return g; }; const T = DENSITY_THRESHOLD; // ВАЖНО: cell-куб — между corners (i, j, k) и (i+1, j+1, k+1). У него // 8 corner'ей со значениями density. Cells в чанке: lx ∈ [0..sizeX], то есть // одна больше чем sizeX чтобы захватить край. const lsX = sizeX + 1, lsY = sizeY + 1, lsZ = sizeZ + 1; const lsxy = lsX * lsY; // Для каждой граничной ячейки храним: // vertexX/Y/Z — мировая позиция (interpolated avg) // vertexMatId — доминирующий material // vertexGlobalIdx — индекс в группе после регистрации // cellMask — маска 8 corners (для определения видимости quad по граням) const vertexX = new Float32Array(lsX * lsY * lsZ); const vertexY = new Float32Array(lsX * lsY * lsZ); const vertexZ = new Float32Array(lsX * lsY * lsZ); const vertexMatId = new Uint8Array(lsX * lsY * lsZ); // AO per vertex (0..1). 1.0 = открыто (полностью освещено), 0.0 = закрыто // (глубокая впадина). Считаем как доля воздушных cells в кубе 3×3×3 // вокруг вершины, в OFFSET 1 от центра. Чем больше solid вокруг → меньше AO. const vertexAO = new Float32Array(lsX * lsY * lsZ); const cellMask = new Uint8Array(lsX * lsY * lsZ); const vertexLocalIdx = new Int32Array(lsX * lsY * lsZ).fill(-1); let minX = Infinity, minY = Infinity, minZ = Infinity; let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity; const localIdx = (lx, ly, lz) => lz * lsxy + ly * lsX + lx; // === Шаг 1: для каждой ячейки чанка считаем 8 corner-density, // находим intersection-точки на рёбрах, position vertex = их average. for (let lz = 0; lz < lsZ; lz++) { for (let ly = 0; ly < lsY; ly++) { for (let lx = 0; lx < lsX; lx++) { const gx = cx0 + lx; const gy = cy0 + ly; const gz = cz0 + lz; // 8 corners — density в "до-cell" точках (то есть corner // (0,0,0) = density ячейки (gx-1, gy-1, gz-1)) const corners = [ grid.getDensity(gx - 1, gy - 1, gz - 1), grid.getDensity(gx, gy - 1, gz - 1), grid.getDensity(gx - 1, gy, gz - 1), grid.getDensity(gx, gy, gz - 1), grid.getDensity(gx - 1, gy - 1, gz), grid.getDensity(gx, gy - 1, gz), grid.getDensity(gx - 1, gy, gz), grid.getDensity(gx, gy, gz), ]; // Mask: bit k = corner k solid let mask = 0; for (let k = 0; k < 8; k++) if (corners[k] >= T) mask |= (1 << k); if (mask === 0 || mask === 255) continue; // Перебираем 12 рёбер. Для каждого с sign-change → находим // intersection point (lerp по density). // Edge endpoints в local-coord (внутри 1×1×1 куба): 0..1. // Convert в world: cellOriginWorld + edgePoint * CELL_SIZE. let sumX = 0, sumY = 0, sumZ = 0, count = 0; for (let e = 0; e < 12; e++) { const c0 = EDGES[e][0], c1 = EDGES[e][1]; const d0 = corners[c0], d1 = corners[c1]; const solid0 = d0 >= T, solid1 = d1 >= T; if (solid0 === solid1) continue; // Lerp t = (T - d0) / (d1 - d0) let t = (T - d0) / (d1 - d0); if (!isFinite(t)) t = 0.5; t = Math.max(0.0, Math.min(1.0, t)); const o0 = CORNER_OFFSETS[c0]; const o1 = CORNER_OFFSETS[c1]; sumX += o0[0] + (o1[0] - o0[0]) * t; sumY += o0[1] + (o1[1] - o0[1]) * t; sumZ += o0[2] + (o1[2] - o0[2]) * t; count++; } if (count === 0) continue; // Average локальная позиция (0..1 within cube) const lpX = sumX / count; const lpY = sumY / count; const lpZ = sumZ / count; // Мировая координата: cell-cube начало = (gx-1, gy-1, gz-1) cell-units // Worldorigin: grid.origin × CELL_SIZE. const wx = (grid.origin.x + cx0 + lx - 1 + lpX) * CELL_SIZE; const wy = (grid.origin.y + cy0 + ly - 1 + lpY) * CELL_SIZE; const wz = (grid.origin.z + cz0 + lz - 1 + lpZ) * CELL_SIZE; const lIdx = localIdx(lx, ly, lz); vertexX[lIdx] = wx; vertexY[lIdx] = wy; vertexZ[lIdx] = wz; cellMask[lIdx] = mask; // Material: доминирующий из solid corner с наибольшим density. let bestMat = 0, bestDen = 0; const cornerMats = [ grid.getMatId(gx - 1, gy - 1, gz - 1), grid.getMatId(gx, gy - 1, gz - 1), grid.getMatId(gx - 1, gy, gz - 1), grid.getMatId(gx, gy, gz - 1), grid.getMatId(gx - 1, gy - 1, gz), grid.getMatId(gx, gy - 1, gz), grid.getMatId(gx - 1, gy, gz), grid.getMatId(gx, gy, gz), ]; for (let k = 0; k < 8; k++) { if (corners[k] >= T && corners[k] > bestDen) { bestDen = corners[k]; bestMat = cornerMats[k]; } } vertexMatId[lIdx] = bestMat; vertexLocalIdx[lIdx] = 1; // pre-mark // === Vertex AO === // Считаем долю solid-cells в окружении ВЫШЕ вершины (gy+0..gy+3). // ВЫШЕ — это важно: AO имитирует свет сверху. Вершина в открытой // зоне (над ней воздух) = яркая. В пещере/углублении (над ней // solid-материал) = тёмная. let solidAbove = 0; let totalAbove = 0; for (let dz = -1; dz <= 1; dz++) { for (let dy = 0; dy <= 3; dy++) { for (let dx = -1; dx <= 1; dx++) { const d = grid.getDensity(gx + dx, gy + dy, gz + dz); if (d >= T) solidAbove++; totalAbove++; } } } const aboveRatio = solidAbove / totalAbove; // Плоская поверхность: над ней воздух → aboveRatio ≈ 0 → AO ≈ 1.0 // Глубокая впадина: над ней может быть solid → aboveRatio высоко → AO низко // На обычной открытой плоскости должно быть AO=1. let ao = 1.0 - aboveRatio * 0.6; if (ao > 1.0) ao = 1.0; if (ao < 0.55) ao = 0.55; vertexAO[lIdx] = ao; if (wx < minX) minX = wx; if (wx > maxX) maxX = wx; if (wy < minY) minY = wy; if (wy > maxY) maxY = wy; if (wz < minZ) minZ = wz; if (wz > maxZ) maxZ = wz; } } } // === Шаг 2: register vertices в ЕДИНУЮ группу '_blend'. // // ВАЖНО: чтобы избежать ДЫР на границах материалов (vertex принадлежит // только одной группе → quad другой группы не имеет shared vertex), // делаем ОДИН mesh для всей карты. Material каждого vertex кодируется // в vertex color RGBA: R=grass, G=rock, B=sand, A=snow (или 1.0 если // нет других — пока binary). // // На стыке материалов соседние vertex имеют разный RGB → shader будет // делать smooth blend (когда внедрим custom material). Сейчас же // используем diffuseColor.fromVertexColor=true со standard material — // получим грубый mix цветов (без текстур разных материалов). const SINGLE_GROUP_KEY = '_blend'; for (let lz = 0; lz < lsZ; lz++) { for (let ly = 0; ly < lsY; ly++) { for (let lx = 0; lx < lsX; lx++) { const li = localIdx(lx, ly, lz); if (vertexLocalIdx[li] < 0) continue; const g = getGroup(SINGLE_GROUP_KEY); g.positions.push(vertexX[li], vertexY[li], vertexZ[li]); g.normals.push(0, 1, 0); g.uvs.push(vertexX[li] / CELL_SIZE, vertexZ[li] / CELL_SIZE); // Vertex 8-material encoding: // color (vec4) = grass / rock / sand / snow // uv2 (vec2) = dirt / water // uv3 (vec2) = wood / glacier // Доминирующий материал → его канал = 1, остальные = 0. const matKey = palette[vertexMatId[li]] || 'grass'; let r = 0, gC = 0, b = 0, a = 0; let u2x = 0, u2y = 0, u3x = 0, u3y = 0; switch (matKey) { case 'rock': gC = 1; break; case 'sand': b = 1; break; case 'snow': a = 1; break; case 'dirt': u2x = 1; break; case 'water': u2y = 1; break; case 'wood': u3x = 1; break; case 'glacier': u3y = 1; break; // Маппинг "близких" материалов: case 'asphalt': case 'concrete': case 'rock_moss': case 'salt': gC = 1; break; case 'mud': u2x = 1; break; case 'trunk': case 'trunk_white': u3x = 1; break; case 'leaves': case 'leaves_orange': case 'tall_grass': case 'flower_red': case 'flower_blue': case 'flower_yellow': case 'mushroom_red': default: r = 1; break; } g.colors.push(r, gC, b, a); g.uvs2.push(u2x, u2y); g.uvs3.push(u3x, u3y); const newIdx = g.vIdx++; vertexLocalIdx[li] = newIdx; } } } // Helper: получить вершину const lookupVtx = (lx, ly, lz) => { if (lx < 0 || ly < 0 || lz < 0 || lx >= lsX || ly >= lsY || lz >= lsZ) return null; const li = localIdx(lx, ly, lz); if (vertexLocalIdx[li] < 0) return null; return li; }; // === Шаг 3: edges. Для каждой пары соседних cells (по оси), // если на их общей грани есть sign-change — создаём quad. // Sign-change по оси X между (gx-1) и (gx) на line (gy-1, gz-1) → cells: // (lx, ly-1, lz-1), (lx, ly, lz-1), (lx, ly-1, lz), (lx, ly, lz) // Это аналог из vertex-per-cell версии. for (let lz = 0; lz < lsZ; lz++) { for (let ly = 0; ly < lsY; ly++) { for (let lx = 0; lx < lsX; lx++) { const gx = cx0 + lx; const gy = cy0 + ly; const gz = cz0 + lz; const a = grid.getDensity(gx - 1, gy - 1, gz - 1); // X-edge: corner (gx-1,gy-1,gz-1) vs (gx,gy-1,gz-1) const bX = grid.getDensity(gx, gy - 1, gz - 1); if ((a >= T) !== (bX >= T)) { const v0 = lookupVtx(lx, ly - 1, lz - 1); const v1 = lookupVtx(lx, ly, lz - 1); const v2 = lookupVtx(lx, ly - 1, lz); const v3 = lookupVtx(lx, ly, lz); if (v0 !== null && v1 !== null && v2 !== null && v3 !== null) { emitQuad(getGroup(SINGLE_GROUP_KEY), vertexLocalIdx[v0], vertexLocalIdx[v1], vertexLocalIdx[v2], vertexLocalIdx[v3], a >= T); } } // Y-edge: corner (gx-1,gy-1,gz-1) vs (gx-1,gy,gz-1) const bY = grid.getDensity(gx - 1, gy, gz - 1); if ((a >= T) !== (bY >= T)) { const v0 = lookupVtx(lx - 1, ly, lz - 1); const v1 = lookupVtx(lx, ly, lz - 1); const v2 = lookupVtx(lx - 1, ly, lz); const v3 = lookupVtx(lx, ly, lz); if (v0 !== null && v1 !== null && v2 !== null && v3 !== null) { emitQuad(getGroup(SINGLE_GROUP_KEY), vertexLocalIdx[v0], vertexLocalIdx[v1], vertexLocalIdx[v2], vertexLocalIdx[v3], a >= T); } } // Z-edge: corner (gx-1,gy-1,gz-1) vs (gx-1,gy-1,gz) const bZ = grid.getDensity(gx - 1, gy - 1, gz); if ((a >= T) !== (bZ >= T)) { const v0 = lookupVtx(lx - 1, ly - 1, lz); const v1 = lookupVtx(lx, ly - 1, lz); const v2 = lookupVtx(lx - 1, ly, lz); const v3 = lookupVtx(lx, ly, lz); if (v0 !== null && v1 !== null && v2 !== null && v3 !== null) { emitQuad(getGroup(SINGLE_GROUP_KEY), vertexLocalIdx[v0], vertexLocalIdx[v1], vertexLocalIdx[v2], vertexLocalIdx[v3], a >= T); } } } } } // === Шаг 4: вычислить smooth normals через DENSITY GRADIENT. // // Для каждой surface-vertex нормаль = -∇density (направление от // высокой плотности к низкой). Считаем градиент через центральные // конечные разности по 3 осям. // // Это даёт ФИЗИЧЕСКИ ПРАВИЛЬНЫЕ плавные нормали — намного лучше // face-normal averaging (который дёргался на гладких поверхностях). // // Обрабатываем все группы одновременно: проходим по cells второй раз // и записываем normals в правильное место в каждой группе. for (let lz = 0; lz < lsZ; lz++) { for (let ly = 0; ly < lsY; ly++) { for (let lx = 0; lx < lsX; lx++) { const li = localIdx(lx, ly, lz); const vIdx = vertexLocalIdx[li]; if (vIdx < 0) continue; // gx,gy,gz — координата текущей cell в global grid // Surface vertex placed in pos = (lx-1+lpX..., ly-1+lpY..., lz-1+lpZ...) // Sample density в окрестности: берем gradient = density(+1) - density(-1). const gx = cx0 + lx - 1; // -1 потому что vertex в "до-cell" const gy = cy0 + ly - 1; const gz = cz0 + lz - 1; // Сэмплируем 8 corner-density вокруг точки и считаем градиент. // Используем те же 8 углов (как в Шаге 1) для consistent. const cx_p = grid.getDensity(gx + 1, gy, gz) + grid.getDensity(gx + 1, gy + 1, gz) + grid.getDensity(gx + 1, gy, gz + 1) + grid.getDensity(gx + 1, gy + 1, gz + 1); const cx_n = grid.getDensity(gx, gy, gz) + grid.getDensity(gx, gy + 1, gz) + grid.getDensity(gx, gy, gz + 1) + grid.getDensity(gx, gy + 1, gz + 1); const cy_p = grid.getDensity(gx, gy + 1, gz) + grid.getDensity(gx + 1, gy + 1, gz) + grid.getDensity(gx, gy + 1, gz + 1) + grid.getDensity(gx + 1, gy + 1, gz + 1); const cy_n = grid.getDensity(gx, gy, gz) + grid.getDensity(gx + 1, gy, gz) + grid.getDensity(gx, gy, gz + 1) + grid.getDensity(gx + 1, gy, gz + 1); const cz_p = grid.getDensity(gx, gy, gz + 1) + grid.getDensity(gx + 1, gy, gz + 1) + grid.getDensity(gx, gy + 1, gz + 1) + grid.getDensity(gx + 1, gy + 1, gz + 1); const cz_n = grid.getDensity(gx, gy, gz) + grid.getDensity(gx + 1, gy, gz) + grid.getDensity(gx, gy + 1, gz) + grid.getDensity(gx + 1, gy + 1, gz); // Gradient = (D_positive - D_negative). Нормаль = -gradient (поверхность смотрит от высокой к низкой плотности... wait, наоборот — нормаль смотрит ОТ solid К empty, то есть В сторону низкой density). gradient указывает на повышение, поэтому normal = -gradient. // НО: в нашем тесте density УБЫВАЕТ с высотой y → gradient_y < 0 на поверхности → normal_y = -grad_y > 0 (правильно, нормаль смотрит вверх). let nx = -(cx_p - cx_n); let ny = -(cy_p - cy_n); let nz = -(cz_p - cz_n); const len = Math.sqrt(nx * nx + ny * ny + nz * nz); if (len > 1e-6) { nx /= len; ny /= len; nz /= len; } else { nx = 0; ny = 1; nz = 0; } // Записываем в группу: нужна group по matKey const matKey = palette[vertexMatId[li]] || 'grass'; const g = groups.get(matKey); if (g) { const off = vIdx * 3; g.normals[off] = nx; g.normals[off + 1] = ny; g.normals[off + 2] = nz; } } } } // === Шаг 5: упаковка const out = new Map(); for (const [key, g] of groups) { if (g.indices.length === 0) continue; out.set(key, { positions: new Float32Array(g.positions), normals: g.normals instanceof Float32Array ? g.normals : new Float32Array(g.normals), uvs: new Float32Array(g.uvs), uvs2: g.uvs2 && g.uvs2.length > 0 ? new Float32Array(g.uvs2) : null, uvs3: g.uvs3 && g.uvs3.length > 0 ? new Float32Array(g.uvs3) : null, colors: g.colors && g.colors.length > 0 ? new Float32Array(g.colors) : null, indices: new Uint32Array(g.indices), bbox: { minX, minY, minZ, maxX, maxY, maxZ }, }); } return out; } /** * Emit quad из 4 vIdx. Cell vertices layout: * v0 — корень (-,-) * v1 — (+,-) * v2 — (-,+) * v3 — (+,+) * flip: меняет winding для правильной нормали. */ function emitQuad(g, v0, v1, v2, v3, flip) { // Эмпирически в нашем Babylon: CW = front. Поэтому используем CW порядок. if (flip) { g.indices.push(v0, v3, v1, v0, v2, v3); } else { g.indices.push(v0, v1, v3, v0, v3, v2); } }