/** * VoxelLayer — слой воксельного мира с фиксированным размером ячейки. * * Один слой = одна сетка чанков с одним voxelSize. Между слоями нет связи — * это позволяет иметь несколько разрешений в одном мире: * terrain — 0.25м, твёрдый, рендерится с MultiMaterial * deco — 0.05м (мини-voxel'ы для травы/цветов), не коллидит, без текстур * build — 0.5м (пользовательские блоки) — опционально, не первая версия * * Палитра — общий список строковых id материалов слоя. Внутри чанков * хранится индекс в этой палитре (1 байт), что экономит 99% размера vs * хранение строки на каждый voxel. * * Public API: * getVoxel(gx, gy, gz) — id материала или null * setVoxel(gx, gy, gz, matId) — устанавливает voxel * removeVoxel(gx, gy, gz) — то же что setVoxel(..., null) * getMatIdx(gx, gy, gz) — числовой индекс (для меширования) * matIdxToId(idx) / matIdToIdx(id) — конвертация * forEachChunk(fn) — обход всех чанков * stats() — статистика для отладки * * Координаты voxel'ов (gx, gy, gz) — целочисленные grid-индексы. * Перевод в мировые метры: worldX = gx * voxelSize. */ import { VoxelChunk, voxelToChunk, chunkKey, CHUNK_SIZE } from './VoxelChunk'; export class VoxelLayer { /** * @param {string} name - 'terrain' | 'deco' | 'build' | ... * @param {number} voxelSize - метры на одну ячейку */ constructor(name, voxelSize) { this.name = name; this.voxelSize = voxelSize; /** Map<"cx,cy,cz", VoxelChunk>. Пустые чанки удаляются. */ this.chunks = new Map(); /** * Палитра материалов. Индекс 0 зарезервирован под "пусто" (нельзя * использовать). 1..255 — реальные материалы. * Например: [null, 'grass', 'rock', 'sand', ...] */ this.palette = [null]; /** Обратный индекс: matId → matIdx. */ this.idToIdx = new Map(); } // ======================================================================== // Палитра // ======================================================================== /** * Получить или зарезервировать индекс материала. Палитра растёт по мере * появления новых материалов. Возвращает 0 если попытка зарегать null. */ matIdToIdx(matId) { if (matId == null) return 0; let idx = this.idToIdx.get(matId); if (idx !== undefined) return idx; if (this.palette.length >= 256) { console.warn(`[VoxelLayer ${this.name}] palette overflow (>255 materials), cannot register '${matId}'`); return 0; } idx = this.palette.length; this.palette.push(matId); this.idToIdx.set(matId, idx); return idx; } /** Получить matId по индексу. null если пусто или неизвестен. */ matIdxToId(idx) { if (idx <= 0 || idx >= this.palette.length) return null; return this.palette[idx]; } // ======================================================================== // CRUD voxel'ов // ======================================================================== /** Получить чанк по chunk-координатам. Возвращает null если не существует. */ getChunk(cx, cy, cz) { return this.chunks.get(chunkKey(cx, cy, cz)) ?? null; } /** Получить или создать чанк. */ getOrCreateChunk(cx, cy, cz) { const k = chunkKey(cx, cy, cz); let ch = this.chunks.get(k); if (!ch) { ch = new VoxelChunk(cx, cy, cz); this.chunks.set(k, ch); } return ch; } /** material id ('grass'/'rock'/...) или null если пусто. */ getVoxel(gx, gy, gz) { const { cx, cy, cz, lx, ly, lz } = voxelToChunk(gx, gy, gz); const ch = this.getChunk(cx, cy, cz); if (!ch) return null; return this.matIdxToId(ch.getLocal(lx, ly, lz)); } /** Числовой индекс материала (для меширования — быстрее чем строки). */ getMatIdx(gx, gy, gz) { const { cx, cy, cz, lx, ly, lz } = voxelToChunk(gx, gy, gz); const ch = this.getChunk(cx, cy, cz); if (!ch) return 0; return ch.getLocal(lx, ly, lz); } /** * Установить voxel. matId=null или undefined → удаление. * Возвращает true если значение изменилось. */ setVoxel(gx, gy, gz, matId) { const { cx, cy, cz, lx, ly, lz } = voxelToChunk(gx, gy, gz); const matIdx = this.matIdToIdx(matId); // Если ячейка очищается и чанка нет — нечего делать. if (matIdx === 0) { const ch = this.getChunk(cx, cy, cz); if (!ch) return false; const changed = ch.setLocal(lx, ly, lz, 0); // Если чанк опустел — удалим его (но не сейчас, чтобы не // дёрнуть renderer; пометим dirty и пусть mesher решит) return changed; } const ch = this.getOrCreateChunk(cx, cy, cz); return ch.setLocal(lx, ly, lz, matIdx); } /** Удалить voxel. */ removeVoxel(gx, gy, gz) { return this.setVoxel(gx, gy, gz, null); } /** Существует ли voxel. */ hasVoxel(gx, gy, gz) { return this.getMatIdx(gx, gy, gz) !== 0; } // ======================================================================== // Bulk операции // ======================================================================== /** * Bulk-загрузка из плоского массива {x, y, z, m} — используется при * миграции legacy формата terrain[] в новый формат. */ loadFromArray(voxels) { const t0 = performance.now(); let added = 0; for (const v of voxels) { if (this.setVoxel(v.x, v.y, v.z, v.m)) added++; } const dt = performance.now() - t0; console.log(`[VoxelLayer ${this.name}] loadFromArray: ${added}/${voxels.length} voxels, ${this.chunks.size} chunks, ${dt.toFixed(0)}ms`); } /** Полная очистка слоя. */ clear() { for (const ch of this.chunks.values()) { ch.clear(); } this.chunks.clear(); } /** Сколько занятых ячеек суммарно. */ voxelCount() { let n = 0; for (const ch of this.chunks.values()) n += ch.nonEmptyCount; return n; } /** Обход всех чанков (включая пустые с dirty=true — для очистки рендера). */ forEachChunk(fn) { for (const ch of this.chunks.values()) fn(ch); } /** Удалить пустые чанки. Безопасно вызывать после batch-операций. */ purgeEmptyChunks() { const toDelete = []; for (const [k, ch] of this.chunks) { if (ch.isEmpty()) toDelete.push(k); } for (const k of toDelete) this.chunks.delete(k); return toDelete.length; } /** Отладочная статистика. */ stats() { let totalVoxels = 0; let dirtyChunks = 0; for (const ch of this.chunks.values()) { totalVoxels += ch.nonEmptyCount; if (ch.dirty) dirtyChunks++; } return { name: this.name, voxelSize: this.voxelSize, chunks: this.chunks.size, voxels: totalVoxels, dirtyChunks, paletteSize: this.palette.length - 1, }; } }