From 731afcfd414c6f15d7a75d8b737496535f1c8c07 Mon Sep 17 00:00:00 2001 From: gh8sted Date: Sat, 15 Aug 2026 01:36:56 +0200 Subject: [PATCH 1/3] Batch chunk requests and add region LOD Adds a batched chunk request packet and a batched response (0x0B) so a client can ask for many chunks in one message instead of one packet each, including on the deferred path where a cold region previously answered one chunk per message. Adds region level-of-detail (0x0C): one averaged colour per chunk, so a whole region costs 768 bytes rather than the ~200KB its 256 chunks cost at full detail. The downsample is cached per region and invalidated on modification. Co-Authored-By: Claude Opus 5 --- src/client/Client.js | 204 ++++++++++++++++++++++++++++++++++++++++++- src/region/Region.js | 36 ++++++++ 2 files changed, 239 insertions(+), 1 deletion(-) diff --git a/src/client/Client.js b/src/client/Client.js index b1e6984..1c37778 100644 --- a/src/client/Client.js +++ b/src/client/Client.js @@ -11,6 +11,27 @@ let maxChunkCoord = 0xFFFFF let minPixelCoord = ~0xFFFFFF let maxPixelCoord = 0xFFFFFF +// Batched chunk requests (see handleChunkBatch). +// Request framing: [u16 guard][u16 count][u16 reserved] followed by count * (i32 x, i32 y), +// so the total length is always 6 + 8*count. No existing packet length is congruent to +// 6 mod 8, which is what keeps this from colliding with the length-based switch below. +let chunkBatchGuard = 25565 +// uWS drops frames over maxPayloadLength (32768), so a request can never legitimately +// carry more than (32768 - 6) / 8 chunks. Anything above that is malformed. +let maxChunkBatchCount = 4095 +// Cap on a single response message so one request can't force a multi-megabyte send. +let maxChunkBatchBytes = 512 * 1024 + +// Region level-of-detail: one averaged colour per chunk, 768 bytes for a whole region +// instead of the ~200KB its 256 chunks cost at full detail. Lets a zoomed-out client +// paint a coarse view immediately and fill in real chunks afterwards. +// Request is a fixed 16 bytes: [u16 guard][u16 reserved][i32 rx][i32 ry][u16 w][u16 h]. +let regionLodGuard = 25566 +let maxLodRegions = 1024 +let maxLodBytes = 256 * 1024 +let minRegionCoord = ~0xFFFF +let maxRegionCoord = 0xFFFF + let maxMessageLengths = [ 128, 128, @@ -365,6 +386,11 @@ export class Client { return } message = Buffer.from(message) + //batched chunk request - checked before the switch since its length is variable + if (message.length >= 14 && message.length % 8 === 6 && message.readUInt16LE(0) === chunkBatchGuard) { + this.handleChunkBatch(message) + return + } switch (message.length) { //request chunk case 8: { @@ -606,6 +632,20 @@ export class Client { this.y = y return } + //region level-of-detail request + case 16: { + if (message.readUInt16LE(0) !== regionLodGuard) { + this.destroy() + return + } + this.handleRegionLod( + message.readInt32LE(4), + message.readInt32LE(8), + message.readUInt16LE(12), + message.readUInt16LE(14) + ) + return + } //rank verification case 1: { if (message[0] > this.rank) { @@ -620,6 +660,136 @@ export class Client { } } + //handles a batch of chunk requests sent as a single packet, replying with as few + //messages as possible instead of one per chunk. chunks in regions that aren't loaded + //yet are deferred exactly like the single-chunk path does. + handleChunkBatch(message) { + let count = message.readUInt16LE(2) + if (count === 0 || count !== (message.length - 6) / 8 || count > maxChunkBatchCount) { + this.destroy() + return + } + let parts = [] + let pending = 3 + for (let i = 0; i < count; i++) { + let offset = 6 + i * 8 + let chunkX = message.readInt32LE(offset) + if (chunkX > maxChunkCoord || chunkX < minChunkCoord) { + this.destroy() + return + } + let chunkY = message.readInt32LE(offset + 4) + if (chunkY > maxChunkCoord || chunkY < minChunkCoord) { + this.destroy() + return + } + let chunkLocation = (chunkY & 0xf) << 4 | chunkX & 0xf + let regionId = ((chunkX >> 4) + 0x10000) + (((chunkY >> 4) + 0x10000) * 0x20000) + let region = this.world.getRegion(regionId) + if (!region.loaded) { + let deferredActions = this.handleUnloaded(region) + if (!deferredActions) return + let buffer = Buffer.allocUnsafe(1) + buffer[0] = chunkLocation + deferredActions.push(buffer) + if (++this.deferredAmount >= 100000 && this.rank < 3) { + this.destroy() + return + } + continue + } + region.lastHeld = this.server.currentTick + let data = region.getChunkData(chunkLocation) + //flush before exceeding the size cap, but never send an empty batch + if (parts.length && pending + 2 + data.length > maxChunkBatchBytes) { + this.sendChunkBatch(parts) + parts = [] + pending = 3 + } + parts.push(data) + pending += 2 + data.length + } + if (parts.length) this.sendChunkBatch(parts) + } + + //Serves a rectangle of region downsamples. Regions not yet loaded are deferred the + //same way chunk requests are, so a cold region answers once it comes off disk. + handleRegionLod(regionX, regionY, width, height) { + if (width === 0 || height === 0) return + if (width * height > maxLodRegions) { + this.destroy() + return + } + let parts = [] + let pending = 3 + for (let dy = 0; dy < height; dy++) { + for (let dx = 0; dx < width; dx++) { + let rx = regionX + dx + let ry = regionY + dy + if (rx > maxRegionCoord || rx < minRegionCoord || ry > maxRegionCoord || ry < minRegionCoord) continue + let regionId = (rx + 0x10000) + ((ry + 0x10000) * 0x20000) + let region = this.world.getRegion(regionId) + if (!region.loaded) { + let deferredActions = this.handleUnloaded(region) + if (!deferredActions) return + //3-byte marker meaning "send this region's LOD once loaded" + deferredActions.push(Buffer.allocUnsafe(3)) + if (++this.deferredAmount >= 100000 && this.rank < 3) { + this.destroy() + return + } + continue + } + region.lastHeld = this.server.currentTick + parts.push(rx, ry, region.getLodData()) + pending += 776 + if (pending >= maxLodBytes) { + this.sendRegionLod(parts) + parts = [] + pending = 3 + } + } + } + if (parts.length) this.sendRegionLod(parts) + } + + //0x0C: [u8 opcode][u16 regionCount] then regionCount * ([i32 rx][i32 ry][768 bytes]), + //the 768 being one RGB triplet per chunk in chunk-location order. + sendRegionLod(parts) { + let count = parts.length / 3 + let out = Buffer.allocUnsafeSlow(3 + count * 776) + out[0] = 0x0C + out.writeUInt16LE(count, 1) + let offset = 3 + for (let i = 0; i < parts.length; i += 3) { + out.writeInt32LE(parts[i], offset) + out.writeInt32LE(parts[i + 1], offset + 4) + parts[i + 2].copy(out, offset + 8) + offset += 776 + } + this.ws.send(out.buffer, true) + } + + //0x0B: [u8 opcode][u16 chunkCount] then chunkCount * ([u16 byteLength][chunk packet]), + //where each embedded chunk packet is byte-identical to a standalone 0x02 message. + sendChunkBatch(parts) { + let total = 3 + for (let i = 0; i < parts.length; i++) total += 2 + parts[i].length + //must be allocUnsafeSlow, same reason as Region.getChunkData + let out = Buffer.allocUnsafeSlow(total) + out[0] = 0x0B + out.writeUInt16LE(parts.length, 1) + let offset = 3 + for (let i = 0; i < parts.length; i++) { + let part = parts[i] + out.writeUInt16LE(part.length, offset) + offset += 2 + part.copy(out, offset) + offset += part.length + } + this.ws.send(out.buffer, true) + } + handleUnloaded(region) { if (!region.beganLoading) { if (this.rank < 3 && !this.regionloadquota.canSpend()) { @@ -646,34 +816,66 @@ export class Client { let deferredActions = this.deferredRegionActions.get(regionId) this.deferredAmount -= deferredActions.length this.deferredRegionActions.delete(regionId) + //A cold region is the common case on join and on zooming out, and every chunk in + //it lands here. Sending one message each is what made chunks trickle in, so + //consecutive chunk loads are gathered into a batch. The batch is flushed before + //any other action runs, which keeps ordering relative to pastes/erases exact. + let pendingChunks = [] + let pendingBytes = 3 + let flushChunks = () => { + if (pendingChunks.length === 0) return + if (pendingChunks.length === 1) { + this.ws.send(pendingChunks[0].buffer, true) + } else { + this.sendChunkBatch(pendingChunks) + } + pendingChunks = [] + pendingBytes = 3 + } for (let action of deferredActions) { switch (action.length) { //request chunk case 1: { - region.requestChunk(this, action[0]) + region.lastHeld = this.server.currentTick + let data = region.getChunkData(action[0]) + if (pendingChunks.length && pendingBytes + 2 + data.length > maxChunkBatchBytes) flushChunks() + pendingChunks.push(data) + pendingBytes += 2 + data.length + continue + } + //region level-of-detail + case 3: { + flushChunks() + region.lastHeld = this.server.currentTick + this.sendRegionLod([region.x, region.y, region.getLodData()]) continue } //set pixel case 5: { + flushChunks() region.setPixel(this, action[0], action[1], action[2], action[3], action[4]) continue } //chunk paste case 769: { + flushChunks() region.pasteChunk(action[0], action.subarray(1)) continue } //erase chunk case 4: { + flushChunks() region.eraseChunk(action[0], action[1], action[2], action[3]) continue } //protect chunk case 2: { + flushChunks() region.protectChunk(action[0], action[1]) } } } + flushChunks() } async handlePreWorld(message, isBinary) { diff --git a/src/region/Region.js b/src/region/Region.js index ece32a3..4fce019 100644 --- a/src/region/Region.js +++ b/src/region/Region.js @@ -24,6 +24,7 @@ export class Region { this.lastHeld = this.server.currentTick this.loadPromise = null this.dataModified = false + this.lodCache = null // cached downsample, see getLodData this.destroyed = false } @@ -66,6 +67,41 @@ export class Region { this.isEmpty = false this.dataModified = true this.latestDataBuffer = null // allow gc of outdated db buffer + this.lodCache = null // downsample no longer reflects the pixels + } + + // One averaged colour per chunk, so a whole region is 768 bytes instead of the ~200KB + // its 256 chunks cost at full detail. Used to paint a zoomed-out view immediately. + // Ordered by chunk location, i.e. localChunkY * 16 + localChunkX, matching getChunkData. + getLodData() { + if (this.lodCache) return this.lodCache + let out = Buffer.allocUnsafeSlow(768) + if (this.isEmpty === true || !this.pixels) { + let color = this.world.bgcolor.value + let r = color >> 16, g = (color & 0x00ff00) >> 8, b = color & 0x0000ff + for (let i = 0; i < 256; i++) { + out[i * 3] = r + out[i * 3 + 1] = g + out[i * 3 + 2] = b + } + this.lodCache = out + return out + } + for (let chunkLocation = 0; chunkLocation < 256; chunkLocation++) { + let base = chunkLocation * 768 + let r = 0, g = 0, b = 0 + for (let i = 0; i < 768; i += 3) { + r += this.pixels[base + i] + g += this.pixels[base + i + 1] + b += this.pixels[base + i + 2] + } + let o = chunkLocation * 3 + out[o] = (r / 256) | 0 + out[o + 1] = (g / 256) | 0 + out[o + 2] = (b / 256) | 0 + } + this.lodCache = out + return out } updateDataBuffer() { From 0b79c44e0b11d8a6a0078d0fa8f869313be0b740 Mon Sep 17 00:00:00 2001 From: gh8sted Date: Sat, 15 Aug 2026 01:37:05 +0200 Subject: [PATCH 2/3] Add fillWorld tool for generating test worlds Writes regions straight into the LevelDB store using the server's own saveData, so a large world can be generated for benchmarking chunk loading. Supports a configurable radius and either per-pixel noise (worst case for the encoding) or random blocks (compresses like a real world). Kept as a separate commit since it is a development tool rather than part of the server proper. Co-Authored-By: Claude Opus 5 --- tools/fillWorld.js | 164 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 tools/fillWorld.js diff --git a/tools/fillWorld.js b/tools/fillWorld.js new file mode 100644 index 0000000..9752c05 --- /dev/null +++ b/tools/fillWorld.js @@ -0,0 +1,164 @@ +// Fills a world with random pixel data, writing regions straight into the LevelDB +// store the server uses. Intended for generating a large test world to benchmark +// chunk loading against. +// +// The server MUST be stopped while this runs - LevelDB holds an exclusive lock. +// +// Usage: +// node tools/fillWorld.js [--world main] [--radius 5000] [--mode noise|blocks] [--seed 1] +// +// --radius is in pixels from spawn in every direction, so the default 5000 covers a +// 10000x10000 area. Regions are 256x256 pixels and are the unit of storage, so the +// filled area is rounded outwards to whole regions. +// +// --mode noise : every pixel an independent random colour. Incompressible, so this is +// the worst case for both the on-disk encoding and the wire encoding. +// --mode blocks : random axis-aligned rectangles of flat colour. Compresses like a real +// world does, and is far smaller on disk. + +import { Level } from "level" +import { saveData } from "../src/region/regionData.js" + +const REGION_SIZE = 256 // pixels per region axis +const REGION_PIXEL_BYTES = 196608 // 256 * 256 * 3 + +function parseArgs(argv) { + const args = { world: "main", radius: 5000, mode: "noise", seed: 1 } + for (let i = 0; i < argv.length; i++) { + const key = argv[i] + if (!key.startsWith("--")) continue + const name = key.slice(2) + const value = argv[++i] + if (value === undefined) throw new Error(`missing value for --${name}`) + if (name === "world") args.world = value + else if (name === "radius") args.radius = parseInt(value, 10) + else if (name === "mode") args.mode = value + else if (name === "seed") args.seed = parseInt(value, 10) + else throw new Error(`unknown option --${name}`) + } + if (!Number.isInteger(args.radius) || args.radius <= 0) throw new Error("--radius must be a positive integer") + if (args.mode !== "noise" && args.mode !== "blocks") throw new Error("--mode must be 'noise' or 'blocks'") + return args +} + +// Deterministic PRNG so a given --seed reproduces the same world exactly. +function makeRandom(seed) { + let state = seed >>> 0 + if (state === 0) state = 0x9e3779b9 + return () => { + // xorshift32 + state ^= state << 13; state >>>= 0 + state ^= state >>> 17 + state ^= state << 5; state >>>= 0 + return state + } +} + +function fillNoise(pixels, rand) { + for (let i = 0; i < REGION_PIXEL_BYTES; i += 3) { + const r = rand() + pixels[i] = r & 0xff + pixels[i + 1] = (r >>> 8) & 0xff + pixels[i + 2] = (r >>> 16) & 0xff + } +} + +function fillBlocks(pixels, rand) { + // start from a flat background so untouched areas still compress well + const bg = rand() + pixels.fill(Buffer.from([bg & 0xff, (bg >>> 8) & 0xff, (bg >>> 16) & 0xff])) + const rectangles = 24 + (rand() % 40) + for (let n = 0; n < rectangles; n++) { + const c = rand() + const r = c & 0xff, g = (c >>> 8) & 0xff, b = (c >>> 16) & 0xff + const w = 4 + (rand() % 64) + const h = 4 + (rand() % 64) + const x0 = rand() % REGION_SIZE + const y0 = rand() % REGION_SIZE + const x1 = Math.min(REGION_SIZE, x0 + w) + const y1 = Math.min(REGION_SIZE, y0 + h) + for (let y = y0; y < y1; y++) { + let p = (y * REGION_SIZE + x0) * 3 + for (let x = x0; x < x1; x++) { + pixels[p++] = r + pixels[p++] = g + pixels[p++] = b + } + } + } +} + +async function main() { + const args = parseArgs(process.argv.slice(2)) + + // Region coordinates are pixel coordinates shifted right by 8, which floors correctly + // for negatives too, so this rounds the requested area outwards to whole regions. + const minRegion = (-args.radius) >> 8 + const maxRegion = (args.radius - 1) >> 8 + const perAxis = maxRegion - minRegion + 1 + const totalRegions = perAxis * perAxis + + console.log(`world : ${args.world}`) + console.log(`requested area : ${args.radius * 2} x ${args.radius * 2} pixels centred on spawn`) + console.log(`region range : ${minRegion}..${maxRegion} on both axes (${perAxis} x ${perAxis} = ${totalRegions} regions)`) + console.log(`covered area : ${perAxis * REGION_SIZE} x ${perAxis * REGION_SIZE} pixels`) + console.log(`mode : ${args.mode}`) + console.log(`seed : ${args.seed}`) + console.log("") + + const db = new Level("./data/regions", { keyEncoding: "utf8", valueEncoding: "buffer" }) + try { + await db.open() + } catch (err) { + console.error("Failed to open ./data/regions - is the server still running? LevelDB needs an exclusive lock.") + console.error(String(err.message ?? err)) + process.exit(1) + } + + const rand = makeRandom(args.seed) + const pixels = Buffer.allocUnsafe(REGION_PIXEL_BYTES) + const protection = Buffer.alloc(256) // 0 = unprotected + + const started = Date.now() + let written = 0 + let bytes = 0 + let batch = db.batch() + let batched = 0 + + for (let ry = minRegion; ry <= maxRegion; ry++) { + for (let rx = minRegion; rx <= maxRegion; rx++) { + if (args.mode === "noise") fillNoise(pixels, rand) + else fillBlocks(pixels, rand) + + const regionId = (rx + 0x10000) + ((ry + 0x10000) * 0x20000) + const data = saveData(protection, pixels) + batch.put(`${args.world}-${regionId}`, data) + bytes += data.length + batched++ + written++ + + if (batched >= 64) { + await batch.write() + batch = db.batch() + batched = 0 + const pct = ((written / totalRegions) * 100).toFixed(1) + const mb = (bytes / 1048576).toFixed(1) + process.stdout.write(`\r${written}/${totalRegions} regions (${pct}%), ${mb} MiB encoded`) + } + } + } + if (batched > 0) await batch.write() + + await db.close() + + const seconds = (Date.now() - started) / 1000 + console.log(`\r${written}/${totalRegions} regions (100.0%), ${(bytes / 1048576).toFixed(1)} MiB encoded`) + console.log("") + console.log(`Done in ${seconds.toFixed(1)}s.`) + console.log(`Wrote ${written} regions totalling ${(bytes / 1048576).toFixed(1)} MiB of encoded pixel data.`) +} + +main().catch(err => { + console.error(err) + process.exit(1) +}) From ffda64ebf01b871bbe52d506648ece22eadce610 Mon Sep 17 00:00:00 2001 From: gh8sted Date: Sat, 15 Aug 2026 04:08:10 +0200 Subject: [PATCH 3/3] Trim explanatory comments Cuts the added commentary down to wire formats and non-obvious invariants. Co-Authored-By: Claude Opus 5 --- src/client/Client.js | 37 +++++++++---------------------------- src/region/Region.js | 7 ++----- 2 files changed, 11 insertions(+), 33 deletions(-) diff --git a/src/client/Client.js b/src/client/Client.js index 1c37778..f934f90 100644 --- a/src/client/Client.js +++ b/src/client/Client.js @@ -11,21 +11,13 @@ let maxChunkCoord = 0xFFFFF let minPixelCoord = ~0xFFFFFF let maxPixelCoord = 0xFFFFFF -// Batched chunk requests (see handleChunkBatch). -// Request framing: [u16 guard][u16 count][u16 reserved] followed by count * (i32 x, i32 y), -// so the total length is always 6 + 8*count. No existing packet length is congruent to -// 6 mod 8, which is what keeps this from colliding with the length-based switch below. +// Batched chunk request: [u16 guard][u16 count][u16 reserved] + count * (i32 x, i32 y). +// Length is always 6 + 8*count, which no other packet length can equal. let chunkBatchGuard = 25565 -// uWS drops frames over maxPayloadLength (32768), so a request can never legitimately -// carry more than (32768 - 6) / 8 chunks. Anything above that is malformed. let maxChunkBatchCount = 4095 -// Cap on a single response message so one request can't force a multi-megabyte send. let maxChunkBatchBytes = 512 * 1024 -// Region level-of-detail: one averaged colour per chunk, 768 bytes for a whole region -// instead of the ~200KB its 256 chunks cost at full detail. Lets a zoomed-out client -// paint a coarse view immediately and fill in real chunks afterwards. -// Request is a fixed 16 bytes: [u16 guard][u16 reserved][i32 rx][i32 ry][u16 w][u16 h]. +// Region LOD request: [u16 guard][u16 reserved][i32 rx][i32 ry][u16 w][u16 h] let regionLodGuard = 25566 let maxLodRegions = 1024 let maxLodBytes = 256 * 1024 @@ -386,7 +378,7 @@ export class Client { return } message = Buffer.from(message) - //batched chunk request - checked before the switch since its length is variable + //batched chunk request, variable length so checked before the switch if (message.length >= 14 && message.length % 8 === 6 && message.readUInt16LE(0) === chunkBatchGuard) { this.handleChunkBatch(message) return @@ -660,9 +652,6 @@ export class Client { } } - //handles a batch of chunk requests sent as a single packet, replying with as few - //messages as possible instead of one per chunk. chunks in regions that aren't loaded - //yet are deferred exactly like the single-chunk path does. handleChunkBatch(message) { let count = message.readUInt16LE(2) if (count === 0 || count !== (message.length - 6) / 8 || count > maxChunkBatchCount) { @@ -700,7 +689,6 @@ export class Client { } region.lastHeld = this.server.currentTick let data = region.getChunkData(chunkLocation) - //flush before exceeding the size cap, but never send an empty batch if (parts.length && pending + 2 + data.length > maxChunkBatchBytes) { this.sendChunkBatch(parts) parts = [] @@ -712,8 +700,6 @@ export class Client { if (parts.length) this.sendChunkBatch(parts) } - //Serves a rectangle of region downsamples. Regions not yet loaded are deferred the - //same way chunk requests are, so a cold region answers once it comes off disk. handleRegionLod(regionX, regionY, width, height) { if (width === 0 || height === 0) return if (width * height > maxLodRegions) { @@ -732,7 +718,6 @@ export class Client { if (!region.loaded) { let deferredActions = this.handleUnloaded(region) if (!deferredActions) return - //3-byte marker meaning "send this region's LOD once loaded" deferredActions.push(Buffer.allocUnsafe(3)) if (++this.deferredAmount >= 100000 && this.rank < 3) { this.destroy() @@ -753,8 +738,7 @@ export class Client { if (parts.length) this.sendRegionLod(parts) } - //0x0C: [u8 opcode][u16 regionCount] then regionCount * ([i32 rx][i32 ry][768 bytes]), - //the 768 being one RGB triplet per chunk in chunk-location order. + //0x0C: [u8 opcode][u16 regionCount] + regionCount * ([i32 rx][i32 ry][768 bytes]) sendRegionLod(parts) { let count = parts.length / 3 let out = Buffer.allocUnsafeSlow(3 + count * 776) @@ -770,12 +754,11 @@ export class Client { this.ws.send(out.buffer, true) } - //0x0B: [u8 opcode][u16 chunkCount] then chunkCount * ([u16 byteLength][chunk packet]), - //where each embedded chunk packet is byte-identical to a standalone 0x02 message. + //0x0B: [u8 opcode][u16 chunkCount] + chunkCount * ([u16 byteLength][0x02 packet]) sendChunkBatch(parts) { let total = 3 for (let i = 0; i < parts.length; i++) total += 2 + parts[i].length - //must be allocUnsafeSlow, same reason as Region.getChunkData + //allocUnsafeSlow, same reason as Region.getChunkData let out = Buffer.allocUnsafeSlow(total) out[0] = 0x0B out.writeUInt16LE(parts.length, 1) @@ -816,10 +799,8 @@ export class Client { let deferredActions = this.deferredRegionActions.get(regionId) this.deferredAmount -= deferredActions.length this.deferredRegionActions.delete(regionId) - //A cold region is the common case on join and on zooming out, and every chunk in - //it lands here. Sending one message each is what made chunks trickle in, so - //consecutive chunk loads are gathered into a batch. The batch is flushed before - //any other action runs, which keeps ordering relative to pastes/erases exact. + //consecutive chunk loads are batched, flushed before any other action so + //ordering relative to pastes/erases stays exact let pendingChunks = [] let pendingBytes = 3 let flushChunks = () => { diff --git a/src/region/Region.js b/src/region/Region.js index 4fce019..6d4de34 100644 --- a/src/region/Region.js +++ b/src/region/Region.js @@ -24,7 +24,7 @@ export class Region { this.lastHeld = this.server.currentTick this.loadPromise = null this.dataModified = false - this.lodCache = null // cached downsample, see getLodData + this.lodCache = null this.destroyed = false } @@ -67,12 +67,9 @@ export class Region { this.isEmpty = false this.dataModified = true this.latestDataBuffer = null // allow gc of outdated db buffer - this.lodCache = null // downsample no longer reflects the pixels + this.lodCache = null } - // One averaged colour per chunk, so a whole region is 768 bytes instead of the ~200KB - // its 256 chunks cost at full detail. Used to paint a zoomed-out view immediately. - // Ordered by chunk location, i.e. localChunkY * 16 + localChunkX, matching getChunkData. getLodData() { if (this.lodCache) return this.lodCache let out = Buffer.allocUnsafeSlow(768)