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)
667 lines
30 KiB
JavaScript
667 lines
30 KiB
JavaScript
/**
|
||
* RobloxTerrain — менеджер smooth-ландшафта в стиле Roblox.
|
||
*
|
||
* Параллельная подсистема к TerrainManager (legacy voxel). Они не пересекаются.
|
||
*
|
||
* Архитектура:
|
||
* - DensityGrid: Uint8Array(matId) + Uint8Array(density) хранилище, 4м/cell
|
||
* - Surface Nets mesher строит chunk-меши 16×16×16 ячеек (64×64×64 м)
|
||
* - При изменении density (brushes) затронутые chunks помечаются dirty
|
||
* - Render-loop перестраивает dirty chunks плавно (макс 4 за тик)
|
||
*
|
||
* Public API:
|
||
* loadFromGrid(grid) — заменить grid + построить chunks вокруг камеры
|
||
* serialize() — для save в БД
|
||
* loadFromState(obj) — для load
|
||
* buildAround(camX, camZ) — построить chunks в радиусе камеры
|
||
* updateStreaming(cx, cz) — render-loop streaming
|
||
* flushDirty(maxN) — перестроить dirty chunks
|
||
* getStats() — debug
|
||
*/
|
||
|
||
import {
|
||
Mesh, VertexData, StandardMaterial, ShaderMaterial, Effect, Color3, Texture, Vector3, BoundingInfo,
|
||
} from '@babylonjs/core';
|
||
import { DensityGrid, CELL_SIZE } from './DensityGrid';
|
||
import { buildSurfaceNetsMesh } from './SurfaceNetsMesher';
|
||
import { applyBrush } from './SmoothBrushes';
|
||
import { TERRAIN_MATERIALS } from '../TerrainManager';
|
||
|
||
/** Размер chunk'а в ячейках. 16×16×16 = 4096 cells = ~1-3K triangles. */
|
||
export const CHUNK_SIZE = 16;
|
||
|
||
export class RobloxTerrain {
|
||
constructor(scene) {
|
||
this.scene = scene;
|
||
/** @type {DensityGrid|null} */
|
||
this.grid = null;
|
||
/** Map<"cx,cy,cz", Map<matKey, Mesh>> */
|
||
this.chunks = new Map();
|
||
/** Set<chunkKey> — to build (lazy) */
|
||
this._pendingChunks = new Set();
|
||
/** Set<chunkKey> — to rebuild after brush */
|
||
this._dirtyChunks = new Set();
|
||
/** Map<matKey, StandardMaterial> */
|
||
this._materials = new Map();
|
||
/**
|
||
* Height cache для быстрой коллизии. Float32Array[gridSizeX * gridSizeZ].
|
||
* heightAt(x, z) → world Y поверхности (или -Infinity если нет surface).
|
||
* Заполняется при loadFromGrid через analytic density-traversal.
|
||
* Это намного быстрее scene.pickWithRay по mesh-чанкам.
|
||
*/
|
||
this._heightMap = null;
|
||
this._heightMapSx = 0;
|
||
this._heightMapSz = 0;
|
||
this._heightMapOriginX = 0; // world X of cell (0)
|
||
this._heightMapOriginZ = 0;
|
||
this._heightCellSize = 1; // м/cell в height-map
|
||
}
|
||
|
||
/**
|
||
* Заменить grid + поставить chunks как pending.
|
||
* @param {object} options
|
||
* @param {boolean} [options.skipEmpty=false] — если true, не добавлять
|
||
* chunk в pending если в нём НЕТ solid cells (density >= threshold).
|
||
* Используется при загрузке пустого grid для скульптинга с нуля —
|
||
* иначе 98 пустых chunks бы спамили mesher.
|
||
*/
|
||
loadFromGrid(grid, options = {}) {
|
||
this.disposeAll();
|
||
this.grid = grid;
|
||
const nx = Math.ceil(grid.size.x / CHUNK_SIZE);
|
||
const ny = Math.ceil(grid.size.y / CHUNK_SIZE);
|
||
const nz = Math.ceil(grid.size.z / CHUNK_SIZE);
|
||
let pending = 0;
|
||
for (let cx = 0; cx < nx; cx++) {
|
||
for (let cy = 0; cy < ny; cy++) {
|
||
for (let cz = 0; cz < nz; cz++) {
|
||
if (options.skipEmpty && !this._chunkHasSolidCells(grid, cx, cy, cz)) {
|
||
continue;
|
||
}
|
||
this._pendingChunks.add(`${cx},${cy},${cz}`);
|
||
pending++;
|
||
}
|
||
}
|
||
}
|
||
console.log(`[RobloxTerrain] loaded ${grid.size.x}×${grid.size.y}×${grid.size.z} grid, ${pending} pending chunks (of ${nx*ny*nz} total)`);
|
||
}
|
||
|
||
/** Быстрая проверка: есть ли хоть одна solid-cell в chunk'е (cx,cy,cz). */
|
||
_chunkHasSolidCells(grid, cx, cy, cz) {
|
||
const T = grid.matData; // shortcut
|
||
// grid.matData — Uint8Array размером size.x*size.y*size.z
|
||
// Проверяем mat-id != 0 (быстрее чем density compare)
|
||
const x0 = cx * CHUNK_SIZE;
|
||
const y0 = cy * CHUNK_SIZE;
|
||
const z0 = cz * CHUNK_SIZE;
|
||
const x1 = Math.min(x0 + CHUNK_SIZE, grid.size.x);
|
||
const y1 = Math.min(y0 + CHUNK_SIZE, grid.size.y);
|
||
const z1 = Math.min(z0 + CHUNK_SIZE, grid.size.z);
|
||
const sx = grid.size.x;
|
||
const sxy = sx * grid.size.y;
|
||
for (let z = z0; z < z1; z++) {
|
||
for (let y = y0; y < y1; y++) {
|
||
for (let x = x0; x < x1; x++) {
|
||
if (T[z * sxy + y * sx + x] !== 0) return true;
|
||
}
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Заинициализировать heightmap нужного размера. Заполняется потом
|
||
* из реальных mesh vertices в _buildChunk через _registerHeightFromMesh.
|
||
*/
|
||
_buildHeightMap() {
|
||
if (!this.grid) return;
|
||
const g = this.grid;
|
||
this._heightCellSize = 1;
|
||
this._heightMapSx = g.size.x * CELL_SIZE;
|
||
this._heightMapSz = g.size.z * CELL_SIZE;
|
||
this._heightMapOriginX = g.origin.x * CELL_SIZE;
|
||
this._heightMapOriginZ = g.origin.z * CELL_SIZE;
|
||
const N = this._heightMapSx * this._heightMapSz;
|
||
this._heightMap = new Float32Array(N);
|
||
this._heightMap.fill(-Infinity);
|
||
}
|
||
|
||
/**
|
||
* После построения mesh-chunk'а — заполняем heightmap из РЕАЛЬНЫХ
|
||
* vertex Y координат. Гарантированно совпадает с тем что видит игрок.
|
||
*
|
||
* Для каждой vertex (x, y, z) находим ближайшую (hx, hz) ячейку
|
||
* heightmap и обновляем её Y если новый Y выше (мы храним top surface).
|
||
*/
|
||
_registerHeightFromMesh(positions) {
|
||
if (!this._heightMap) return;
|
||
const sx = this._heightMapSx;
|
||
const sz = this._heightMapSz;
|
||
const ox = this._heightMapOriginX;
|
||
const oz = this._heightMapOriginZ;
|
||
for (let i = 0; i < positions.length; i += 3) {
|
||
const wx = positions[i];
|
||
const wy = positions[i + 1];
|
||
const wz = positions[i + 2];
|
||
const hx = Math.floor(wx - ox);
|
||
const hz = Math.floor(wz - oz);
|
||
if (hx < 0 || hz < 0 || hx >= sx || hz >= sz) continue;
|
||
const idx = hz * sx + hx;
|
||
if (wy > this._heightMap[idx]) this._heightMap[idx] = wy;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Получить высоту поверхности под точкой (worldX, worldZ).
|
||
* Билинейная интерполяция по heightmap для гладкости.
|
||
* Возвращает -Infinity если точка вне карты.
|
||
*/
|
||
getSurfaceHeight(worldX, worldZ) {
|
||
if (!this._heightMap) return -Infinity;
|
||
const fx = worldX - this._heightMapOriginX - 0.5;
|
||
const fz = worldZ - this._heightMapOriginZ - 0.5;
|
||
if (fx < 0 || fz < 0) return -Infinity;
|
||
const hx0 = Math.floor(fx);
|
||
const hz0 = Math.floor(fz);
|
||
if (hx0 < 0 || hz0 < 0 || hx0 + 1 >= this._heightMapSx || hz0 + 1 >= this._heightMapSz) return -Infinity;
|
||
const tx = fx - hx0;
|
||
const tz = fz - hz0;
|
||
const sx = this._heightMapSx;
|
||
const h00 = this._heightMap[hz0 * sx + hx0];
|
||
const h10 = this._heightMap[hz0 * sx + hx0 + 1];
|
||
const h01 = this._heightMap[(hz0 + 1) * sx + hx0];
|
||
const h11 = this._heightMap[(hz0 + 1) * sx + hx0 + 1];
|
||
// Если хоть одно -Infinity (за пределами terrain) — возвращаем -Infinity
|
||
if (h00 === -Infinity || h10 === -Infinity || h01 === -Infinity || h11 === -Infinity) {
|
||
return -Infinity;
|
||
}
|
||
return (1 - tx) * (1 - tz) * h00
|
||
+ tx * (1 - tz) * h10
|
||
+ (1 - tx) * tz * h01
|
||
+ tx * tz * h11;
|
||
}
|
||
|
||
disposeAll() {
|
||
if (!this.chunks) return;
|
||
for (const [key, meshes] of this.chunks) {
|
||
for (const m of meshes.values()) {
|
||
try { m.dispose(); } catch (e) {}
|
||
}
|
||
}
|
||
this.chunks.clear();
|
||
this._pendingChunks.clear();
|
||
this._dirtyChunks.clear();
|
||
this.grid = null;
|
||
}
|
||
|
||
/** Построить один chunk. */
|
||
_buildChunk(chunkKey) {
|
||
if (!this.grid) return false;
|
||
const [cx, cy, cz] = chunkKey.split(',').map(Number);
|
||
const cx0 = cx * CHUNK_SIZE;
|
||
const cy0 = cy * CHUNK_SIZE;
|
||
const cz0 = cz * CHUNK_SIZE;
|
||
const sizeX = Math.min(CHUNK_SIZE, this.grid.size.x - cx0);
|
||
const sizeY = Math.min(CHUNK_SIZE, this.grid.size.y - cy0);
|
||
const sizeZ = Math.min(CHUNK_SIZE, this.grid.size.z - cz0);
|
||
if (sizeX <= 0 || sizeY <= 0 || sizeZ <= 0) return false;
|
||
|
||
// Удалить старые меши этого chunk'а
|
||
this._disposeChunk(chunkKey);
|
||
|
||
const groups = buildSurfaceNetsMesh(this.grid, cx0, cy0, cz0, sizeX, sizeY, sizeZ);
|
||
if (groups.size === 0) return false;
|
||
|
||
const chunkMeshes = new Map();
|
||
for (const [matKey, data] of groups) {
|
||
const mesh = new Mesh(`__robloxMesh_${chunkKey}_${matKey}`, this.scene);
|
||
const vd = new VertexData();
|
||
vd.positions = data.positions;
|
||
vd.normals = data.normals;
|
||
vd.uvs = data.uvs;
|
||
if (data.colors) vd.colors = data.colors;
|
||
// 4 доп. материала через 2 vec2 attribute (uv2 + uv3):
|
||
// uvs2: (dirt, water) per vertex — Float32 длина = 2*verts
|
||
// uvs3: (wood, glacier) per vertex
|
||
if (data.uvs2) vd.uvs2 = data.uvs2;
|
||
if (data.uvs3) vd.uvs3 = data.uvs3;
|
||
vd.indices = data.indices;
|
||
vd.applyToMesh(mesh, false);
|
||
// Если есть vertex colors → используем blend-shader.
|
||
// Иначе — обычный StandardMaterial.
|
||
if (data.colors) {
|
||
mesh.material = this._getBlendMaterial();
|
||
} else {
|
||
mesh.material = this._getMaterial(matKey);
|
||
}
|
||
// Pickable нужно для коллизий через scene.pickWithRay (физика).
|
||
mesh.isPickable = true;
|
||
mesh.receiveShadows = true;
|
||
mesh.alwaysSelectAsActiveMesh = false;
|
||
mesh.metadata = {
|
||
_isRobloxTerrain: true,
|
||
chunkKey,
|
||
chunkCenterX: (data.bbox.minX + data.bbox.maxX) * 0.5,
|
||
chunkCenterZ: (data.bbox.minZ + data.bbox.maxZ) * 0.5,
|
||
chunkHalfDiag: Math.sqrt(
|
||
Math.pow((data.bbox.maxX - data.bbox.minX) * 0.5, 2)
|
||
+ Math.pow((data.bbox.maxZ - data.bbox.minZ) * 0.5, 2),
|
||
),
|
||
};
|
||
try {
|
||
const pad = CELL_SIZE * 0.5;
|
||
mesh.setBoundingInfo(new BoundingInfo(
|
||
new Vector3(data.bbox.minX - pad, data.bbox.minY - pad, data.bbox.minZ - pad),
|
||
new Vector3(data.bbox.maxX + pad, data.bbox.maxY + pad, data.bbox.maxZ + pad),
|
||
));
|
||
} catch (e) {}
|
||
try { mesh.freezeWorldMatrix(); } catch (e) {}
|
||
chunkMeshes.set(matKey, mesh);
|
||
}
|
||
this.chunks.set(chunkKey, chunkMeshes);
|
||
return true;
|
||
}
|
||
|
||
_disposeChunk(chunkKey) {
|
||
const m = this.chunks.get(chunkKey);
|
||
if (!m) return;
|
||
for (const mesh of m.values()) {
|
||
try { mesh.dispose(); } catch (e) {}
|
||
}
|
||
this.chunks.delete(chunkKey);
|
||
}
|
||
|
||
/** Перестроить N pending chunks ближайших к (camX, camZ). */
|
||
updateStreaming(camX, camZ, radius = 100, options) {
|
||
const maxBuild = options?.maxBuild ?? 4;
|
||
if (!this.grid) return { built: 0, total: 0 };
|
||
|
||
if (this._pendingChunks.size > 0) {
|
||
const candidates = [];
|
||
for (const key of this._pendingChunks) {
|
||
const [cx, , cz] = key.split(',').map(Number);
|
||
const ccx = (this.grid.origin.x + cx * CHUNK_SIZE + CHUNK_SIZE * 0.5) * CELL_SIZE;
|
||
const ccz = (this.grid.origin.z + cz * CHUNK_SIZE + CHUNK_SIZE * 0.5) * CELL_SIZE;
|
||
const dx = ccx - camX, dz = ccz - camZ;
|
||
const d2 = dx * dx + dz * dz;
|
||
if (d2 <= radius * radius) candidates.push({ key, d2 });
|
||
}
|
||
candidates.sort((a, b) => a.d2 - b.d2);
|
||
const limit = Math.min(maxBuild, candidates.length);
|
||
let built = 0;
|
||
for (let i = 0; i < limit; i++) {
|
||
this._pendingChunks.delete(candidates[i].key);
|
||
if (this._buildChunk(candidates[i].key)) built++;
|
||
}
|
||
return {
|
||
built,
|
||
total: this.chunks.size + this._pendingChunks.size,
|
||
pending: this._pendingChunks.size,
|
||
};
|
||
}
|
||
return { built: 0, total: this.chunks.size, pending: 0 };
|
||
}
|
||
|
||
/** Перестроить dirty chunks (после brush). */
|
||
flushDirty(maxRebuilds = 4) {
|
||
if (this._dirtyChunks.size === 0) return 0;
|
||
const keys = [...this._dirtyChunks];
|
||
let count = 0;
|
||
for (const key of keys) {
|
||
if (count >= maxRebuilds) break;
|
||
this._dirtyChunks.delete(key);
|
||
this._buildChunk(key);
|
||
count++;
|
||
}
|
||
return count;
|
||
}
|
||
|
||
/**
|
||
* Пометить chunks как dirty. Вызывается из brush-логики.
|
||
* @param {Iterable<string>} keys — ключи "cx,cy,cz" (LOCAL, как в applyBrush)
|
||
*/
|
||
markChunksDirty(keys) {
|
||
for (const k of keys) this._dirtyChunks.add(k);
|
||
}
|
||
|
||
/**
|
||
* Применить brush на DensityGrid + сразу перестроить dirty chunks.
|
||
* Удобно для интерактивных кистей (драг мышью).
|
||
*/
|
||
applyBrushAndRebuild(brushType, params) {
|
||
if (!this.grid) {
|
||
console.warn('[RobloxTerrain] applyBrushAndRebuild: no grid');
|
||
return 0;
|
||
}
|
||
const dirty = applyBrush(this.grid, brushType, params);
|
||
if (dirty.size === 0) {
|
||
// Кисть не затронула ни одной cell — диагностика.
|
||
const sample = this.grid.getDensity(
|
||
Math.floor(params.center.x / 4),
|
||
Math.floor(params.center.y / 4),
|
||
Math.floor(params.center.z / 4),
|
||
);
|
||
console.log(`[RobloxTerrain] brush='${brushType}' NO CELLS CHANGED. `
|
||
+ `center=(${params.center.x.toFixed(1)},${params.center.y.toFixed(1)},${params.center.z.toFixed(1)}) `
|
||
+ `r=${params.radius} grid-origin=(${this.grid.origin.x},${this.grid.origin.y},${this.grid.origin.z}) `
|
||
+ `grid-size=(${this.grid.size.x},${this.grid.size.y},${this.grid.size.z}) `
|
||
+ `density-at-center=${sample}`);
|
||
return 0;
|
||
}
|
||
this.markChunksDirty(dirty);
|
||
const built = this.flushDirty(dirty.size);
|
||
return built;
|
||
}
|
||
|
||
/**
|
||
* Blend-материал: **8 текстур** смешиваются через 3 vertex attribute.
|
||
* color (vec4): R=grass, G=rock, B=sand, A=snow
|
||
* uv2 (vec2): X=dirt, Y=water
|
||
* uv3 (vec2): X=wood, Y=glacier
|
||
*
|
||
* Использовать `tangent` нельзя — Babylon пересчитывает его как tangent-frame
|
||
* для normal-mapping (зависание на NaN если все нули). UV-attribute-ы
|
||
* передаются "as-is" без преобразований.
|
||
*/
|
||
_getBlendMaterial() {
|
||
if (this._blendMaterial) return this._blendMaterial;
|
||
const scene = this.scene;
|
||
// Регистрируем shader code один раз
|
||
if (!Effect.ShadersStore['robloxBlendVertexShader']) {
|
||
Effect.ShadersStore['robloxBlendVertexShader'] = `
|
||
precision highp float;
|
||
attribute vec3 position;
|
||
attribute vec3 normal;
|
||
attribute vec2 uv;
|
||
attribute vec2 uv2;
|
||
attribute vec2 uv3;
|
||
attribute vec4 color;
|
||
uniform mat4 worldViewProjection;
|
||
uniform mat4 world;
|
||
uniform mat4 view;
|
||
varying vec2 vUV;
|
||
varying vec3 vNormalW;
|
||
varying vec4 vColor;
|
||
varying vec4 vExtra; // dirt/water/wood/glacier weights
|
||
varying vec3 vWorldPos;
|
||
varying float vDepth;
|
||
void main() {
|
||
vec4 wp = world * vec4(position, 1.0);
|
||
gl_Position = worldViewProjection * vec4(position, 1.0);
|
||
vUV = uv;
|
||
vNormalW = normalize((world * vec4(normal, 0.0)).xyz);
|
||
vColor = color;
|
||
// uv2.x=dirt, uv2.y=water, uv3.x=wood, uv3.y=glacier
|
||
vExtra = vec4(uv2.x, uv2.y, uv3.x, uv3.y);
|
||
vWorldPos = wp.xyz;
|
||
vDepth = -(view * wp).z;
|
||
}
|
||
`;
|
||
// Фрагментный шейдер: 8-материал blend + Roblox-стиль освещение.
|
||
Effect.ShadersStore['robloxBlendFragmentShader'] = `
|
||
precision highp float;
|
||
varying vec2 vUV;
|
||
varying vec3 vNormalW;
|
||
varying vec4 vColor;
|
||
varying vec4 vExtra;
|
||
varying vec3 vWorldPos;
|
||
varying float vDepth;
|
||
uniform sampler2D texGrass;
|
||
uniform sampler2D texRock;
|
||
uniform sampler2D texSand;
|
||
uniform sampler2D texSnow;
|
||
uniform sampler2D texDirt;
|
||
uniform sampler2D texWater;
|
||
uniform sampler2D texWood;
|
||
uniform sampler2D texGlacier;
|
||
uniform vec3 sunDir;
|
||
uniform vec3 sunColor;
|
||
uniform vec3 hemiSky;
|
||
uniform vec3 hemiGround;
|
||
uniform vec3 fogColor;
|
||
uniform vec3 cameraPos;
|
||
|
||
vec3 sample2(sampler2D t, vec2 uvBig, vec2 uvSmall) {
|
||
vec3 a = texture2D(t, uvBig).rgb;
|
||
vec3 b = texture2D(t, uvSmall).rgb;
|
||
return a * 0.6 + b * 0.4;
|
||
}
|
||
|
||
void main() {
|
||
vec2 uvBig = vWorldPos.xz * 0.25;
|
||
vec2 uvSmall = vWorldPos.xz * 0.07;
|
||
|
||
// Первая четвёрка (vColor)
|
||
vec3 cg = sample2(texGrass, uvBig, uvSmall);
|
||
vec3 cr = sample2(texRock, uvBig, uvSmall);
|
||
vec3 cs = sample2(texSand, uvBig, uvSmall);
|
||
vec3 cn = sample2(texSnow, uvBig, uvSmall);
|
||
// Вторая четвёрка (vTangent)
|
||
vec3 cd = sample2(texDirt, uvBig, uvSmall);
|
||
vec3 cw = sample2(texWater, uvBig, uvSmall);
|
||
vec3 cwo= sample2(texWood, uvBig, uvSmall);
|
||
vec3 cgl= sample2(texGlacier, uvBig, uvSmall);
|
||
|
||
float wG = vColor.r;
|
||
float wR = vColor.g;
|
||
float wS = vColor.b;
|
||
float wN = vColor.a;
|
||
float wD = vExtra.x;
|
||
float wW = vExtra.y;
|
||
float wWO= vExtra.z;
|
||
float wGL= vExtra.w;
|
||
|
||
float wSum = wG + wR + wS + wN + wD + wW + wWO + wGL + 0.0001;
|
||
vec3 base = (cg*wG + cr*wR + cs*wS + cn*wN
|
||
+ cd*wD + cw*wW + cwo*wWO + cgl*wGL) / wSum;
|
||
|
||
// Снег приглушаем в лёгкую синеву
|
||
base *= mix(vec3(1.0), vec3(0.85, 0.88, 0.94), wN);
|
||
// Вода — тёмно-синяя альфа-микс (без real transparency пока)
|
||
base *= mix(vec3(1.0), vec3(0.55, 0.75, 0.95), wW);
|
||
// Glacier — голубоватая прозрачность
|
||
base *= mix(vec3(1.0), vec3(0.78, 0.90, 0.98), wGL);
|
||
|
||
// === Освещение ===
|
||
vec3 N = normalize(vNormalW);
|
||
vec3 L = -normalize(sunDir);
|
||
float ndl = max(dot(N, L), 0.0);
|
||
vec3 hemi = mix(hemiGround, hemiSky, N.y * 0.5 + 0.5);
|
||
vec3 lit = base * (sunColor * ndl + hemi);
|
||
|
||
float slopeDarken = mix(0.85, 1.0, max(N.y, 0.0));
|
||
lit *= slopeDarken;
|
||
|
||
vec3 V = normalize(cameraPos - vWorldPos);
|
||
float rim = pow(1.0 - max(dot(N, V), 0.0), 3.0);
|
||
lit += vec3(0.18, 0.22, 0.14) * rim * wG * 0.4;
|
||
|
||
float fogF = smoothstep(80.0, 350.0, vDepth);
|
||
lit = mix(lit, fogColor, fogF * 0.5);
|
||
|
||
gl_FragColor = vec4(lit, 1.0);
|
||
}
|
||
`;
|
||
}
|
||
const mat = new ShaderMaterial(
|
||
'__robloxBlendMat',
|
||
scene,
|
||
{ vertex: 'robloxBlend', fragment: 'robloxBlend' },
|
||
{
|
||
attributes: ['position', 'normal', 'uv', 'uv2', 'uv3', 'color'],
|
||
uniforms: ['worldViewProjection', 'world', 'view',
|
||
'sunDir', 'sunColor', 'hemiSky', 'hemiGround',
|
||
'fogColor', 'cameraPos'],
|
||
samplers: ['texGrass', 'texRock', 'texSand', 'texSnow',
|
||
'texDirt', 'texWater', 'texWood', 'texGlacier'],
|
||
},
|
||
);
|
||
// Текстуры. BILINEAR без mipmaps — пиксельные 16×16 текстуры
|
||
// ломаются в trilinear (mip-уровни усредняются до 1 пикселя серого).
|
||
const mkTex = (path) => {
|
||
const t = new Texture(path, scene, true, false, Texture.BILINEAR_SAMPLINGMODE);
|
||
t.wrapU = Texture.WRAP_ADDRESSMODE;
|
||
t.wrapV = Texture.WRAP_ADDRESSMODE;
|
||
return t;
|
||
};
|
||
const grassDef = TERRAIN_MATERIALS.grass;
|
||
const rockDef = TERRAIN_MATERIALS.rock;
|
||
const sandDef = TERRAIN_MATERIALS.sand;
|
||
const snowDef = TERRAIN_MATERIALS.snow;
|
||
const dirtDef = TERRAIN_MATERIALS.dirt;
|
||
const waterDef = TERRAIN_MATERIALS.water;
|
||
const woodDef = TERRAIN_MATERIALS.wood;
|
||
const glacierDef = TERRAIN_MATERIALS.glacier;
|
||
mat.setTexture('texGrass', mkTex(grassDef.top || grassDef.texture));
|
||
mat.setTexture('texRock', mkTex(rockDef.texture));
|
||
mat.setTexture('texSand', mkTex(sandDef.texture));
|
||
mat.setTexture('texSnow', mkTex(snowDef.texture));
|
||
mat.setTexture('texDirt', mkTex(dirtDef.texture));
|
||
mat.setTexture('texWater', mkTex(waterDef.texture));
|
||
mat.setTexture('texWood', mkTex(woodDef.texture));
|
||
mat.setTexture('texGlacier', mkTex(glacierDef.texture));
|
||
// Lighting params: суммарно НЕ ДОЛЖНО превышать 1.0 чтобы не было
|
||
// overexposure (выгорание ярких текстур типа sand в белое).
|
||
// directLight (max) = sunColor × 1.0 (ndl)
|
||
// ambient = hemi (mix sky/ground)
|
||
// total на верхушке = (sun + sky) ≤ ~(0.95, 1.0, 0.93)
|
||
mat.setVector3('sunDir', new Vector3(-0.3, -1, -0.2).normalize());
|
||
mat.setVector3('sunColor', new Vector3(0.55, 0.52, 0.45));
|
||
mat.setVector3('hemiSky', new Vector3(0.42, 0.48, 0.58));
|
||
mat.setVector3('hemiGround', new Vector3(0.24, 0.25, 0.27));
|
||
// Fog цвет = небо, дистанция 80..350м (плавный переход).
|
||
mat.setVector3('fogColor', new Vector3(0.62, 0.78, 0.95));
|
||
mat.setVector3('cameraPos', new Vector3(0, 0, 0));
|
||
mat.backFaceCulling = false;
|
||
// cameraPos нужно обновлять каждый кадр для rim-эффекта.
|
||
const cam = scene.activeCamera;
|
||
if (cam) {
|
||
mat.onBindObservable.add(() => {
|
||
const c = scene.activeCamera;
|
||
if (c && c.position) {
|
||
mat.setVector3('cameraPos', c.position);
|
||
}
|
||
});
|
||
}
|
||
this._blendMaterial = mat;
|
||
return mat;
|
||
}
|
||
|
||
/** Получить (создать) StandardMaterial для matKey. */
|
||
_getMaterial(matKey) {
|
||
let mat = this._materials.get(matKey);
|
||
if (mat) return mat;
|
||
const def = TERRAIN_MATERIALS[matKey];
|
||
mat = new StandardMaterial(`__robloxMat_${matKey}`, this.scene);
|
||
mat.specularColor = new Color3(0, 0, 0);
|
||
mat.ambientColor = new Color3(1, 1, 1);
|
||
// backFaceCulling=false временно — Surface Nets winding не вылизан.
|
||
// Когда стабилизируем, включим обратно.
|
||
mat.backFaceCulling = false;
|
||
if (def) {
|
||
const texPath = def.top || def.texture || def.side;
|
||
if (texPath) {
|
||
const tex = new Texture(texPath, this.scene, true, false, Texture.NEAREST_SAMPLINGMODE);
|
||
tex.wrapU = Texture.WRAP_ADDRESSMODE;
|
||
tex.wrapV = Texture.WRAP_ADDRESSMODE;
|
||
mat.diffuseTexture = tex;
|
||
} else if (def.color) {
|
||
mat.diffuseColor = Color3.FromHexString(def.color);
|
||
}
|
||
} else {
|
||
mat.diffuseColor = new Color3(0.4, 0.7, 0.3);
|
||
}
|
||
try { mat.freeze(); } catch (e) {}
|
||
this._materials.set(matKey, mat);
|
||
return mat;
|
||
}
|
||
|
||
// ============================================================
|
||
// BRUSH API (минимальный для теста; полный — Этап 3)
|
||
// ============================================================
|
||
|
||
/**
|
||
* Поставить density+matKey в сфере radius=R вокруг точки world (wx, wy, wz).
|
||
* @param {number} delta — изменение density (>0 add, <0 erase). Применяется
|
||
* плавно по расстоянию от центра.
|
||
*/
|
||
brushSphere(wx, wy, wz, radius, delta, matKey) {
|
||
if (!this.grid) return;
|
||
const r = radius / CELL_SIZE; // в cell-units
|
||
const cx = wx / CELL_SIZE - this.grid.origin.x;
|
||
const cy = wy / CELL_SIZE - this.grid.origin.y;
|
||
const cz = wz / CELL_SIZE - this.grid.origin.z;
|
||
const ri = Math.ceil(r);
|
||
const r2 = r * r;
|
||
for (let dz = -ri; dz <= ri; dz++) {
|
||
for (let dy = -ri; dy <= ri; dy++) {
|
||
for (let dx = -ri; dx <= ri; dx++) {
|
||
const d2 = dx * dx + dy * dy + dz * dz;
|
||
if (d2 > r2) continue;
|
||
const ix = Math.floor(cx + dx);
|
||
const iy = Math.floor(cy + dy);
|
||
const iz = Math.floor(cz + dz);
|
||
if (!this.grid.inBounds(ix, iy, iz)) continue;
|
||
// Falloff: 1 в центре, 0 на краях
|
||
const t = 1.0 - Math.sqrt(d2) / r;
|
||
const change = (delta * t) | 0;
|
||
const cur = this.grid.getDensity(ix, iy, iz);
|
||
const newD = Math.max(0, Math.min(255, cur + change));
|
||
this.grid.set(ix, iy, iz, newD, matKey);
|
||
// Пометить chunk и соседей dirty
|
||
this._markDirty(ix, iy, iz);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
_markDirty(ix, iy, iz) {
|
||
const cx = Math.floor(ix / CHUNK_SIZE);
|
||
const cy = Math.floor(iy / CHUNK_SIZE);
|
||
const cz = Math.floor(iz / CHUNK_SIZE);
|
||
this._dirtyChunks.add(`${cx},${cy},${cz}`);
|
||
// Если на границе — пометить соседа
|
||
const lx = ix - cx * CHUNK_SIZE;
|
||
const ly = iy - cy * CHUNK_SIZE;
|
||
const lz = iz - cz * CHUNK_SIZE;
|
||
if (lx === 0) this._dirtyChunks.add(`${cx-1},${cy},${cz}`);
|
||
if (lx === CHUNK_SIZE - 1) this._dirtyChunks.add(`${cx+1},${cy},${cz}`);
|
||
if (ly === 0) this._dirtyChunks.add(`${cx},${cy-1},${cz}`);
|
||
if (ly === CHUNK_SIZE - 1) this._dirtyChunks.add(`${cx},${cy+1},${cz}`);
|
||
if (lz === 0) this._dirtyChunks.add(`${cx},${cy},${cz-1}`);
|
||
if (lz === CHUNK_SIZE - 1) this._dirtyChunks.add(`${cx},${cy},${cz+1}`);
|
||
}
|
||
|
||
// ============================================================
|
||
// STATS / SERIALIZE
|
||
// ============================================================
|
||
|
||
getStats() {
|
||
let totalTris = 0;
|
||
for (const meshes of this.chunks.values()) {
|
||
for (const m of meshes.values()) {
|
||
if (!m.isEnabled()) continue;
|
||
totalTris += (m.getTotalIndices?.() ?? 0) / 3;
|
||
}
|
||
}
|
||
return {
|
||
chunks: this.chunks.size,
|
||
pending: this._pendingChunks.size,
|
||
dirty: this._dirtyChunks.size,
|
||
triangles: totalTris | 0,
|
||
solidCells: this.grid ? this.grid.countSolid() : 0,
|
||
};
|
||
}
|
||
|
||
serialize() {
|
||
if (!this.grid) return null;
|
||
return this.grid.serialize();
|
||
}
|
||
|
||
loadFromState(obj) {
|
||
if (!obj) return;
|
||
const grid = DensityGrid.deserialize(obj);
|
||
this.loadFromGrid(grid);
|
||
}
|
||
}
|