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