studio/src/editor/engine/voxel/VoxelRenderer.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

373 lines
18 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.

/**
* 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();
}
}