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)
440 lines
23 KiB
JavaScript
440 lines
23 KiB
JavaScript
/**
|
||
* 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);
|
||
}
|
||
}
|