МИН 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

667 lines
30 KiB
JavaScript
Raw Permalink 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.

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