/** * 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> */ this.chunks = new Map(); /** Set — to build (lazy) */ this._pendingChunks = new Set(); /** Set — to rebuild after brush */ this._dirtyChunks = new Set(); /** Map */ 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} 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); } }