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)
373 lines
18 KiB
JavaScript
373 lines
18 KiB
JavaScript
/**
|
||
* VoxelRenderer — Babylon-адаптер для рендера VoxelWorld.
|
||
*
|
||
* Идея: один Mesh на чанк × материал. Чанк перестраивается только когда
|
||
* dirty=true. Меш кешируется в chunk.meshRef = Map<matId, Mesh>.
|
||
*
|
||
* Материалы переиспользуются из существующего TERRAIN_MATERIALS — тот
|
||
* же набор текстур (Kenney Voxel Pack), что и в legacy TerrainManager.
|
||
* Это даёт визуальную совместимость и единую палитру.
|
||
*
|
||
* MultiCube материалы (grass: top/side/bottom):
|
||
* В этой версии (Этап 1) — НЕ поддерживаются. Все грани куба красятся
|
||
* ОДНОЙ текстурой материала. Для grass — берём top (зелёная травка),
|
||
* что выглядит OK на верхушке, но на боках будет тоже травка вместо
|
||
* земли. Это компромисс: greedy meshing (Этап 2) сделает корректный
|
||
* MultiCube через разделение mesh'ей по граням.
|
||
*/
|
||
|
||
import {
|
||
Mesh,
|
||
VertexData,
|
||
StandardMaterial,
|
||
Texture,
|
||
Color3,
|
||
} from '@babylonjs/core';
|
||
// ВАЖНО: для terrain-слоя (0.25м, текстуры Kenney) используем surface
|
||
// culling (ChunkMesher), а НЕ greedy. Voxlands-стиль требует чтобы
|
||
// каждый voxel был виден как отдельный куб с тёмными гранями между
|
||
// ячейками. Greedy сливает соседние грани в один большой квад → теряется
|
||
// объём (см. RUBLOX_VOXEL_ENGINE_PLAN.md, Этап 2 примечание).
|
||
//
|
||
// GreedyMesher оставлен в коде — будет использоваться для будущего
|
||
// build-слоя (пользовательские постройки из 1×1м блоков), где как раз
|
||
// нужно мерджить большие плоские стены.
|
||
import { buildChunkGeometry } from './ChunkMesher';
|
||
|
||
/**
|
||
* Описание материалов слоя. Структура совпадает с TERRAIN_MATERIALS
|
||
* в TerrainManager.js, но дублируется чтобы не было кругового импорта.
|
||
*
|
||
* matId → { color, texture, top, side, bottom, alpha, emissive }
|
||
*/
|
||
const TEX = '/kubikon-assets/textures';
|
||
|
||
export const VOXEL_MATERIALS = {
|
||
grass: { color: '#52b15a', top: `${TEX}/grass_top.png`, side: `${TEX}/dirt.png`, bottom: `${TEX}/dirt.png` },
|
||
rock: { color: '#7e7e7e', texture: `${TEX}/greystone.png` },
|
||
sand: { color: '#e6d27a', texture: `${TEX}/sand.png` },
|
||
snow: { color: '#f5f7fb', texture: `${TEX}/snow.png` },
|
||
dirt: { color: '#7c5430', texture: `${TEX}/dirt.png` },
|
||
water: { color: '#3a8fd6', texture: `${TEX}/water.png`, alpha: 0.72 },
|
||
asphalt: { color: '#3b3b3b', texture: `${TEX}/stone.png` },
|
||
concrete: { color: '#b8b8b8', texture: `${TEX}/greystone.png` },
|
||
wood: { color: '#a06a3a', texture: `${TEX}/wood.png` },
|
||
glacier: { color: '#c8e6f5', texture: `${TEX}/ice.png`, alpha: 0.92 },
|
||
salt: { color: '#ecedef', texture: `${TEX}/snow.png` },
|
||
mud: { color: '#553a25', texture: `${TEX}/gravel_dirt.png` },
|
||
// Voxlands-декорации
|
||
leaves: { color: '#3f7a3a', texture: `${TEX}/leaves.png` },
|
||
leaves_orange: { color: '#c2741e', texture: `${TEX}/leaves_orange.png` },
|
||
trunk: { color: '#5a3b1f', top: `${TEX}/trunk_top.png`, side: `${TEX}/trunk_side.png`, bottom: `${TEX}/trunk_bottom.png` },
|
||
trunk_white: { color: '#e0dfd6', top: `${TEX}/trunk_top.png`, side: `${TEX}/trunk_white_side.png`, bottom: `${TEX}/trunk_top.png` },
|
||
rock_moss: { color: '#5d6f43', texture: `${TEX}/rock_moss.png` },
|
||
flower_red: { color: '#b84141', texture: `${TEX}/cotton_red.png` },
|
||
flower_blue: { color: '#4673b8', texture: `${TEX}/cotton_blue.png` },
|
||
flower_yellow: { color: '#d4c84a', texture: `${TEX}/cotton_tan.png` },
|
||
mushroom_red: { color: '#a02525', texture: `${TEX}/mushroom_red.png` },
|
||
tall_grass: { color: '#5fa84e', texture: `${TEX}/wheat_stage4.png` },
|
||
};
|
||
|
||
/** Конвертация hex '#aabbcc' → Color3. */
|
||
function hexToColor3(hex) {
|
||
const m = /^#?([a-f0-9]{6})$/i.exec(hex || '');
|
||
if (!m) return new Color3(0.7, 0.7, 0.7);
|
||
const n = parseInt(m[1], 16);
|
||
return new Color3(((n >> 16) & 0xff) / 255, ((n >> 8) & 0xff) / 255, (n & 0xff) / 255);
|
||
}
|
||
|
||
export class VoxelRenderer {
|
||
/**
|
||
* @param {VoxelWorld} world
|
||
* @param {BABYLON.Scene} scene
|
||
*/
|
||
constructor(world, scene) {
|
||
this.world = world;
|
||
this.scene = scene;
|
||
/** Map<matId, StandardMaterial> — кеш материалов. */
|
||
this._materials = new Map();
|
||
/** Колбэк создания меша — для теневой регистрации (shadowMap.renderList). */
|
||
this._onMeshCreated = null;
|
||
}
|
||
|
||
setOnMeshCreated(cb) { this._onMeshCreated = cb; }
|
||
|
||
/**
|
||
* Получить или создать Babylon-материал по ключу.
|
||
* Ключ может быть простым ('rock', 'sand', ...) или MultiCube
|
||
* вариантом ('grass:top', 'grass:side', 'grass:bottom', 'trunk:top', ...).
|
||
*
|
||
* Для MultiCube ключей берём соответствующую текстуру из def.top/side/
|
||
* bottom; для простых — def.texture.
|
||
*
|
||
* Wrap-режим: REPEAT (Texture.WRAP_ADDRESSMODE) — критично для greedy
|
||
* meshing. Большой квад W×H требует чтобы текстура повторялась W раз
|
||
* по одной оси и H раз по другой. UV-coords тогда идут (0..W, 0..H).
|
||
*/
|
||
_getMaterial(matKey) {
|
||
let mat = this._materials.get(matKey);
|
||
if (mat) return mat;
|
||
// Разбираем MultiCube ключ
|
||
const [matId, face] = matKey.includes(':') ? matKey.split(':') : [matKey, null];
|
||
const def = VOXEL_MATERIALS[matId];
|
||
if (!def) {
|
||
mat = new StandardMaterial(`__voxel_mat_${matKey}_missing`, this.scene);
|
||
mat.diffuseColor = new Color3(1, 0, 1);
|
||
this._materials.set(matKey, mat);
|
||
return mat;
|
||
}
|
||
mat = new StandardMaterial(`__voxel_mat_${matKey}`, this.scene);
|
||
// Specular выключен — на pixel-art-текстуре любой блик ломает стиль.
|
||
mat.specularColor = new Color3(0, 0, 0);
|
||
// КРИТИЧНО: ambient=(1,1,1) чтобы hemisphere-свет освещал материал
|
||
// со всех сторон. Без этого тыловые/нижние грани выходят почти
|
||
// чёрными (как было на скрине — потемневший террейн).
|
||
mat.ambientColor = new Color3(1, 1, 1);
|
||
|
||
// Выбираем текстуру в зависимости от грани (для MultiCube)
|
||
let texPath;
|
||
if (face === 'top') {
|
||
texPath = def.top ?? def.texture;
|
||
} else if (face === 'bottom') {
|
||
texPath = def.bottom ?? def.texture;
|
||
} else if (face === 'side') {
|
||
texPath = def.side ?? def.texture;
|
||
} else {
|
||
texPath = def.texture ?? def.top;
|
||
}
|
||
if (texPath) {
|
||
// noMipmap=true — критично для pixel-art (mipmap усредняет пиксели).
|
||
// invertY=false — Kenney pack уже в нормальной ориентации.
|
||
const tex = new Texture(
|
||
texPath, this.scene,
|
||
/*noMipmap*/ true,
|
||
/*invertY*/ false,
|
||
Texture.NEAREST_SAMPLINGMODE,
|
||
);
|
||
tex.wrapU = Texture.CLAMP_ADDRESSMODE;
|
||
tex.wrapV = Texture.CLAMP_ADDRESSMODE;
|
||
mat.diffuseTexture = tex;
|
||
if (def.alpha != null && def.alpha < 1) {
|
||
mat.diffuseTexture.hasAlpha = true;
|
||
mat.useAlphaFromDiffuseTexture = true;
|
||
mat.alpha = def.alpha;
|
||
}
|
||
// НЕ ставим diffuseColor — иначе текстура × цвет → потемнение.
|
||
// Текстура показывается в полной интенсивности.
|
||
} else {
|
||
// Fallback на цвет если нет текстуры
|
||
mat.diffuseColor = hexToColor3(def.color);
|
||
if (def.alpha != null && def.alpha < 1) mat.alpha = def.alpha;
|
||
}
|
||
this._materials.set(matKey, mat);
|
||
return mat;
|
||
}
|
||
|
||
/**
|
||
* Перестроить mesh'и одного чанка. Если чанк не dirty — no-op.
|
||
* Вызывается из update() для каждого dirty-чанка.
|
||
*/
|
||
rebuildChunk(chunk, layer) {
|
||
if (!chunk.dirty) return;
|
||
|
||
// Хелпер для проверки соседних чанков по глобальной voxel-координате
|
||
const neighborMatIdx = (gx, gy, gz) => layer.getMatIdx(gx, gy, gz);
|
||
|
||
// Surface culling без greedy — сохраняем Voxlands-look (каждый voxel
|
||
// виден как отдельный куб с тёмными гранями между ячейками).
|
||
// Greedy убирал эти грани и портил визуальный стиль.
|
||
const mesh = buildChunkGeometry(chunk, layer, neighborMatIdx);
|
||
|
||
// Удаляем старые меши (если были)
|
||
if (chunk.meshRef) {
|
||
for (const m of chunk.meshRef.values()) {
|
||
m.dispose();
|
||
}
|
||
}
|
||
|
||
if (mesh.byMaterial.size === 0) {
|
||
chunk.meshRef = null;
|
||
chunk.dirty = false;
|
||
return;
|
||
}
|
||
|
||
// Создаём по одному Mesh на каждый материал
|
||
const meshMap = new Map();
|
||
for (const [matId, geo] of mesh.byMaterial) {
|
||
const meshName = `__voxel_${layer.name}_${chunk.cx}_${chunk.cy}_${chunk.cz}_${matId}`;
|
||
const m = new Mesh(meshName, this.scene);
|
||
const vd = new VertexData();
|
||
vd.positions = geo.positions;
|
||
vd.normals = geo.normals;
|
||
vd.uvs = geo.uvs;
|
||
vd.indices = geo.indices;
|
||
vd.applyToMesh(m, false); // not updateable — статичные данные
|
||
m.material = this._getMaterial(matId);
|
||
// isPickable=false: терреин не пикается через scene.pick, мы
|
||
// используем свой raycast по voxel-grid.
|
||
m.isPickable = false;
|
||
m.receiveShadows = true;
|
||
// Метаданные для дебага/идентификации
|
||
m.metadata = {
|
||
_isVoxelChunkMesh: true,
|
||
layerName: layer.name,
|
||
chunkKey: `${chunk.cx},${chunk.cy},${chunk.cz}`,
|
||
matId,
|
||
};
|
||
meshMap.set(matId, m);
|
||
try { this._onMeshCreated?.(m); } catch (e) {}
|
||
}
|
||
chunk.meshRef = meshMap;
|
||
chunk.dirty = false;
|
||
}
|
||
|
||
/**
|
||
* Перестроить все dirty-чанки во всех слоях. Вызывается из game loop
|
||
* или после загрузки мира. Возвращает количество перестроенных чанков.
|
||
*/
|
||
rebuildDirty() {
|
||
let rebuilt = 0;
|
||
for (const layer of this.world.layers.values()) {
|
||
for (const chunk of layer.chunks.values()) {
|
||
if (chunk.dirty) {
|
||
this.rebuildChunk(chunk, layer);
|
||
rebuilt++;
|
||
}
|
||
}
|
||
}
|
||
return rebuilt;
|
||
}
|
||
|
||
/** Полная перестройка всего мира (для миграции после loadFromArray). */
|
||
rebuildAll() {
|
||
const t0 = performance.now();
|
||
let chunks = 0;
|
||
let totalQuads = 0;
|
||
let totalTriangles = 0;
|
||
let totalMeshes = 0;
|
||
for (const layer of this.world.layers.values()) {
|
||
for (const chunk of layer.chunks.values()) {
|
||
chunk.dirty = true;
|
||
this.rebuildChunk(chunk, layer);
|
||
chunks++;
|
||
if (chunk.meshRef) {
|
||
totalMeshes += chunk.meshRef.size;
|
||
for (const m of chunk.meshRef.values()) {
|
||
if (m.getIndices) {
|
||
const idx = m.getIndices();
|
||
if (idx) {
|
||
const tris = idx.length / 3;
|
||
totalTriangles += tris;
|
||
totalQuads += tris / 2;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
const dt = performance.now() - t0;
|
||
console.log(`[VoxelRenderer] rebuildAll: ${chunks} chunks, ${totalMeshes} meshes, ${Math.round(totalQuads)} quads, ${Math.round(totalTriangles)} triangles in ${dt.toFixed(0)}ms`);
|
||
return chunks;
|
||
}
|
||
|
||
// ========================================================================
|
||
// Этап 4 — Streaming (рендер только чанков в радиусе от камеры)
|
||
// ========================================================================
|
||
|
||
/**
|
||
* Уничтожить mesh'и одного чанка, оставив данные voxel'ов в памяти.
|
||
* Используется при выходе чанка из render distance.
|
||
*/
|
||
unloadChunkMesh(chunk) {
|
||
if (!chunk.meshRef) return;
|
||
for (const m of chunk.meshRef.values()) m.dispose();
|
||
chunk.meshRef = null;
|
||
// dirty НЕ ставим — данные не изменились, при следующем входе в
|
||
// radius re-build пересоздаст mesh.
|
||
}
|
||
|
||
/**
|
||
* Обновить streaming: для каждого слоя — определить какие чанки
|
||
* должны быть видимы (в радиусе renderRadius от точки center).
|
||
* Чанки внутри — meshed (lazy build). Чанки снаружи — unloaded.
|
||
*
|
||
* @param {Vector3-like} center — мировые координаты центра ({x,z} or Vector3)
|
||
* @param {number} renderRadius — радиус в метрах (default 64м = 8 чанков × 0.25 × 32)
|
||
* @returns {{loaded:number, unloaded:number, total:number}}
|
||
*/
|
||
updateStreaming(center, renderRadius = 64) {
|
||
let loaded = 0;
|
||
let unloaded = 0;
|
||
let total = 0;
|
||
const cx = center.x ?? 0;
|
||
const cz = center.z ?? 0;
|
||
|
||
for (const layer of this.world.layers.values()) {
|
||
// Размер чанка в метрах = CHUNK_SIZE × voxelSize
|
||
const chunkWorldSize = 32 * layer.voxelSize; // 8м для terrain
|
||
// Квадратичный radius для сравнения с distance² (быстрее sqrt)
|
||
const r2 = renderRadius * renderRadius;
|
||
|
||
for (const chunk of layer.chunks.values()) {
|
||
total++;
|
||
// Центр чанка в мире
|
||
const chunkCenterX = (chunk.cx + 0.5) * chunkWorldSize;
|
||
const chunkCenterZ = (chunk.cz + 0.5) * chunkWorldSize;
|
||
const dx = chunkCenterX - cx;
|
||
const dz = chunkCenterZ - cz;
|
||
const dist2 = dx * dx + dz * dz;
|
||
|
||
// Учитываем «полу-диагональ» чанка чтобы не cull-ить почти
|
||
// видимые. Если центр чанка дальше radius+halfDiagonal —
|
||
// точно невидим.
|
||
const halfDiag = chunkWorldSize * 0.707; // sqrt(2)/2
|
||
const cutoff = renderRadius + halfDiag;
|
||
const shouldRender = dist2 <= cutoff * cutoff;
|
||
|
||
if (shouldRender) {
|
||
if (!chunk.meshRef && !chunk.isEmpty()) {
|
||
// Lazy mesh build
|
||
chunk.dirty = true;
|
||
this.rebuildChunk(chunk, layer);
|
||
loaded++;
|
||
} else if (chunk.dirty) {
|
||
// Mesh уже есть, но данные изменились — пересборка
|
||
this.rebuildChunk(chunk, layer);
|
||
}
|
||
} else {
|
||
if (chunk.meshRef) {
|
||
this.unloadChunkMesh(chunk);
|
||
unloaded++;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return { loaded, unloaded, total };
|
||
}
|
||
|
||
/** Текущее число активных (отрендеренных) чанков. */
|
||
getActiveChunkCount() {
|
||
let n = 0;
|
||
for (const layer of this.world.layers.values()) {
|
||
for (const ch of layer.chunks.values()) {
|
||
if (ch.meshRef) n++;
|
||
}
|
||
}
|
||
return n;
|
||
}
|
||
|
||
/** Освободить все mesh'и (при unload или dispose редактора). */
|
||
dispose() {
|
||
for (const layer of this.world.layers.values()) {
|
||
for (const chunk of layer.chunks.values()) {
|
||
if (chunk.meshRef) {
|
||
for (const m of chunk.meshRef.values()) m.dispose();
|
||
chunk.meshRef = null;
|
||
}
|
||
}
|
||
}
|
||
for (const m of this._materials.values()) m.dispose();
|
||
this._materials.clear();
|
||
}
|
||
}
|