/** * ChunkSerializer — компактная сериализация чанков через RLE + base64. * * Чанк хранится как 32768 байт (по 1 байту на ячейку, индекс материала * в палитре слоя). Большинство ячеек обычно пустые → высокая степень * сжатия через Run-Length Encoding. * * Binary формат одного чанка: * uint16 numRuns * foreach run: * uint16 startOffset — где в data[] начинается run * uint16 length — сколько подряд ячеек * uint8 matIdx — материал run'а * * Сохраняем ТОЛЬКО non-empty runs. Пустые промежутки между ними * подразумеваются (расшифровщик заполняет нулями всё что не покрыто). * * Пример (типовая земля с холмами): 32 768 байт → ~200-1000 байт → * base64 → ~300-1400 символов. На карте 80×80м (126 чанков) суммарно * ~50-200 КБ vs 5.6 МБ JSON. Уменьшение в 30-100 раз. * * Не используем zlib/gzip — браузер всё равно gzip'ит HTTP-ответы, * и второй слой compression не даст много (RLE уже устраняет повторения). */ import { VoxelChunk, CHUNK_VOLUME } from './VoxelChunk'; // ============================================================================ // Encode: VoxelChunk → base64 RLE // ============================================================================ /** * Закодировать data[] чанка в RLE байты. Возвращает Uint8Array. * @param {Uint8Array} data — длина CHUNK_VOLUME (32768) * @returns {Uint8Array} */ export function encodeChunkRLE(data) { if (data.length !== CHUNK_VOLUME) { throw new Error(`encodeChunkRLE: expected ${CHUNK_VOLUME} bytes, got ${data.length}`); } // Сначала собираем runs в массив const runs = []; // [{start, length, matIdx}, ...] let i = 0; while (i < CHUNK_VOLUME) { const mat = data[i]; if (mat === 0) { i++; continue; } // пустую ячейку не записываем // Сколько подряд ячеек того же материала? let j = i + 1; // RLE длина run'а — max uint16 = 65535, но 32768 < 65535, поэтому // одно ограничение — длина CHUNK_VOLUME. while (j < CHUNK_VOLUME && data[j] === mat) j++; const length = j - i; runs.push({ start: i, length, matIdx: mat }); i = j; } // Аллоцируем буфер: 2 байта header + 5 байт на каждый run const bufSize = 2 + runs.length * 5; const buf = new Uint8Array(bufSize); const view = new DataView(buf.buffer); view.setUint16(0, runs.length, true); // little-endian let offset = 2; for (const r of runs) { view.setUint16(offset, r.start, true); view.setUint16(offset + 2, r.length, true); view.setUint8(offset + 4, r.matIdx); offset += 5; } return buf; } /** * Декодировать RLE байты обратно в data[] чанка. * @param {Uint8Array} bytes * @returns {Uint8Array} — длина CHUNK_VOLUME (32768) */ export function decodeChunkRLE(bytes) { const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); const numRuns = view.getUint16(0, true); const data = new Uint8Array(CHUNK_VOLUME); let offset = 2; for (let i = 0; i < numRuns; i++) { const start = view.getUint16(offset, true); const length = view.getUint16(offset + 2, true); const matIdx = view.getUint8(offset + 4); for (let j = 0; j < length; j++) { data[start + j] = matIdx; } offset += 5; } return data; } // ============================================================================ // Base64 helpers — встроенный browser btoa/atob с binary-safe wrapper. // ============================================================================ /** Uint8Array → base64 строка. */ export function bytesToBase64(bytes) { // Chunked encoding чтобы не упереться в stack-limit при больших массивах let binary = ''; const len = bytes.length; const chunkSize = 8192; for (let i = 0; i < len; i += chunkSize) { const chunk = bytes.subarray(i, Math.min(i + chunkSize, len)); binary += String.fromCharCode.apply(null, chunk); } return btoa(binary); } /** base64 строка → Uint8Array. */ export function base64ToBytes(b64) { const binary = atob(b64); const len = binary.length; const bytes = new Uint8Array(len); for (let i = 0; i < len; i++) bytes[i] = binary.charCodeAt(i); return bytes; } // ============================================================================ // High-level: VoxelChunk ↔ base64 // ============================================================================ /** VoxelChunk → base64 строка. */ export function chunkToBase64(chunk) { if (!(chunk instanceof VoxelChunk)) { throw new Error('chunkToBase64: expected VoxelChunk'); } const rle = encodeChunkRLE(chunk.data); return bytesToBase64(rle); } /** * base64 строка → новый VoxelChunk с указанными (cx,cy,cz). * data[] заполняется десериализованным RLE. */ export function chunkFromBase64(b64, cx, cy, cz) { const rle = base64ToBytes(b64); const data = decodeChunkRLE(rle); const chunk = new VoxelChunk(cx, cy, cz); // Прямая подмена data + пересчёт nonEmptyCount chunk.data = data; let count = 0; for (let i = 0; i < CHUNK_VOLUME; i++) { if (data[i] !== 0) count++; } chunk.nonEmptyCount = count; chunk.dirty = true; // нужен ремеш после загрузки return chunk; } // ============================================================================ // Layer-level: VoxelLayer ↔ JSON serializable // ============================================================================ /** * Сериализовать VoxelLayer в plain-object для JSON. * Не сохраняем пустые чанки (там 0 voxel'ов). */ export function serializeLayer(layer) { const chunks = {}; for (const [key, chunk] of layer.chunks) { if (chunk.isEmpty()) continue; chunks[key] = chunkToBase64(chunk); } return { voxelSize: layer.voxelSize, palette: layer.palette.slice(), // copy chunks, }; } /** * Десериализовать слой из plain-object. Возвращает массив { key, chunk, * palette } — caller должен загрузить в VoxelLayer (через прямую запись * .chunks и .palette). */ export function deserializeLayerData(layerData) { const palette = layerData.palette ?? [null]; const chunks = new Map(); for (const [key, b64] of Object.entries(layerData.chunks ?? {})) { const [cx, cy, cz] = key.split(',').map(n => parseInt(n, 10)); const ch = chunkFromBase64(b64, cx, cy, cz); chunks.set(key, ch); } return { voxelSize: layerData.voxelSize ?? 0.25, palette, chunks, }; }