player/src/engine/robloxterrain/SurfaceNetsMesher.js
МИН 87444ee2c8 Initial public release: Rublox Player v1.0
Open-source web player for Rublox games, dual-licensed under
AGPL-3.0 + Commercial.

Highlights:
- Babylon.js 7 + React 18 + Vite 5 stack
- Self-contained engine (~46k lines): BlockManager, ModelManager,
  PlayerController, ScriptSandboxWorker, MultiplayerSync, 30+ GD
  gamemodes
- Configurable backend via VITE_API_BASE and friends — works against
  staging (dev-api.rublox.pro) out of the box
- Standalone mode (VITE_STANDALONE=true) loads a bundled sample game
  for first-run without any backend
- Full docs: README, ARCHITECTURE, CONTRIBUTING, SECURITY, CHANGELOG
- Lint + format scaffolding (ESLint + Prettier + EditorConfig)
- Legal: LICENSE (AGPL-3.0), LICENSE-COMMERCIAL.md, CLA.md, COPYRIGHT.md
- Issue templates: bug_report, feature_request, security_disclosure

Removed before public release:
- frontend_deploy.py (contained production SSH credentials)
- ~27 admin endpoints (kept in private repo)
- Hard-coded internal URLs and IPs
- All previous git history (clean repo init)
2026-05-27 23:04:04 +03:00

440 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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<matKey, {positions, normals, uvs, indices, bbox}>}
*/
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);
}
}