studio/src/editor/engine/voxel/ChunkMesher.js
МИН 31adbf151b Initial public release: Студия Рублокса v1.0
Open-source веб-студия для создания игр Рублокса, двойная лицензия
AGPL-3.0 + Коммерческая.

Главное:
- Vite 5 + React 18 + Babylon 7.54.3 + Monaco Editor + Colyseus 0.16
- Самодостаточный движок ~28к строк (66 файлов): BlockManager,
  TerrainVoxelBuilder, ModelManager, DecoManager, PlayerController,
  ScriptSandboxWorker, MultiplayerSync, 30+ GD-гейммодов
- Главный редактор KubikonEditor (~37к строк) + панели, ScriptEditor (Monaco)
- Витрина игр (KubikonFeed, KubikonStudio, KubikonDocs, KubikonLearn)
- Geometry Dash sub-app (GdMenu, GdShop, GdRules, GdCoverArt)
- 10 admin-preview каталогов для дизайнеров (скины, музыка, порталы и т.д.)
- Конфигурируемый бэкенд через VITE_API_BASE — работает со staging
  (dev-api.rublox.pro) без настройки
- Standalone-режим (VITE_STANDALONE=true) — открыть пустой редактор без бэка
- Полная документация (на русском): README, ARCHITECTURE, CONTRIBUTING,
  SECURITY, CHANGELOG
- ESLint + Prettier + EditorConfig
- Legal: LICENSE (AGPL-3.0), LICENSE-COMMERCIAL.md, CLA.md, COPYRIGHT.md
- Issue templates: bug_report, feature_request, security_disclosure

Перед публикацией:
- Все импорты из minecraftia заменены на локальные
- Все хардкоды URL (minecraftia-school.ru) и внутренних IP убраны → env
- Admin-эндпоинты Kubikon3DService вырезаны (остаются в приватном репо)
- AdminKubikonModeration не публикуется (модерация — в team.rublox.pro)
- 93 МБ ассетов public/kubikon-assets вынесены в .gitignore
  (раздаются через release artifact)
2026-05-27 23:41:10 +03:00

259 lines
12 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.

/**
* ChunkMesher — строит геометрию одного чанка из его voxel-данных.
*
* На этом этапе (1) — простой surface culling без greedy merge:
* Для каждой занятой ячейки проверяем 6 соседей.
* Если сосед пустой → грань видна → добавляем в геометрию (квад из 2 треугольников).
*
* Greedy meshing появится в Этапе 2.
*
* Производит геометрию в формате «по материалам»:
* Map<matId, GeometryData>
* 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<string, GeometryData>} 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 };
}