From fad85724e045122b4253eda00adb48ee7c0a3868 Mon Sep 17 00:00:00 2001 From: CaYatur Date: Thu, 6 Aug 2026 01:14:57 +0300 Subject: [PATCH 1/2] One canvas per region, not one per chunk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ground still vanished when you panned away and came back, and a page with a lot of it loaded went slow. Both come from the same choice: the client baked a 16x16 canvas PER CHUNK. A viewport is up to 4096 chunks, so holding even two screens meant thousands of canvas elements. Object overhead — not pixels — is what forced the cache limit to stay low, so #159's distance-ordered eviction was doing the right thing with far too little room: 8192 chunks is eight regions, and panning past that dropped ground that then had to be re-read. Drawing cost a walk of every held key plus up to 4096 drawImage calls, per frame, which is the "web starts to lag once a lot is loaded" half of the same report. The pixels are the same bytes either way. A region canvas is 512x512 and holds 1024 chunks, so the storage is unchanged and everything around it collapses by three orders of magnitude: MAP_REGIONS['rx,rz'] = { cv, st: {chunk: 1|0}, mk: {chunk: [...]} } 48 regions is about 48 MB and roughly twelve million blocks of ground — far more than a session pans over, which is what "it stays until I reload the page" actually needs. A viewport draws at most nine drawImage calls. Chunks are STAMPED IN as they arrive rather than baked once. A region arrives a few hundred chunks at a time, so baking it when its first chunk landed would leave it permanently mostly transparent — a half-loaded map, which is the thing being fixed. Per-chunk bookkeeping stays per chunk; only the pixels moved. Keeping the state map inside the region entry makes eviction correct by construction: dropping the canvas drops the claim to have drawn those chunks in the same statement, so the client can never believe it holds a tile it cannot draw. Markers moved in beside them for the same reason. Both clients, because they share the eviction policy and its limit — the desktop would otherwise have trimmed to 48 CHUNKS. Verification, each proved failable first (MSMS_SMOKE_WEB, by exit code): - MAP_JS is driven in a vm against a recording canvas. One response carries two chunks of one region, one of another, and one WEST OF ZERO, and the test asserts three canvases, four stamps, and the exact local offsets 0/16/128/496. A raw `cx % 32` gives -16 for the western chunk — outside the canvas, silently drawing nothing — and fails with the offsets named. - A second response must paint into the region that already exists: making regionFor rebuild instead fails with "a region did not record both of its chunks". - The retention fixture is 64 regions against the limit of 48, deliberately over but not wildly over, and asserts that panning one screen sideways keeps EVERY region of the screen you came from. Gates green: MSMS_SMOKE, _WEB, _WORLDS, _MODUPDATE, _ANALYSIS. --- src/main/smoke.ts | 181 ++++++++++++++++++++---- src/renderer/src/components/LiveMap.tsx | 156 ++++++++++++++------ src/shared/livemap.ts | 61 +++++++- src/shared/mapUi.ts | 167 ++++++++++++++++------ 4 files changed, 445 insertions(+), 120 deletions(-) diff --git a/src/main/smoke.ts b/src/main/smoke.ts index 89554e1..fe1f05a 100644 --- a/src/main/smoke.ts +++ b/src/main/smoke.ts @@ -157,10 +157,12 @@ import { zoomAt, tilesToDrop, MAX_SCALE, + MAX_REGION_CANVASES, MAX_TILES_PER_REQUEST, MAX_VIEWPORT_CHUNKS, MIN_SCALE, - TILE_KEEP_LIMIT, + REGION_CHUNKS, + REGION_SPAN, PUBLIC_MAP_DEFAULTS } from '@shared/livemap' import { normalizeMapPage, mapPagePublic, MAP_PAGE_DEFAULTS } from '@shared/mapPage' @@ -9045,34 +9047,48 @@ export async function runWebSmoke(): Promise { if (PUBLIC_MAP_DEFAULTS.heads) return fail('avatar heads are on by default') } - // ---- what a client keeps when it pans (#159) ---- + // ---- what a client keeps when it pans (#159, now per region #164) ---- { + // The unit is the REGION: a client bakes one 512x512 canvas per region + // rather than one 16x16 per chunk, so this is what eviction drops. + // // The invariant the whole policy rests on. Below this a single viewport - // exceeds the cache, so the map evicts tiles it just fetched and - // re-fetches them on the next draw — an endless loop, and the reason - // the old limit of 2048 was wrong against a 4096-chunk viewport. - if (TILE_KEEP_LIMIT <= MAX_VIEWPORT_CHUNKS) { - return fail('the tile cache is smaller than one viewport: ' + TILE_KEEP_LIMIT) + // exceeds the cache, so the map evicts what it just fetched and + // re-fetches it on the next draw — an endless loop, and the reason the + // old chunk limit of 2048 was wrong against a 4096-chunk viewport. + // A 4096-chunk view is 64x64 chunks, which straddles at most 3x3 + // regions however it is aligned. + const worstViewportRegions = Math.pow(Math.ceil(Math.sqrt(MAX_VIEWPORT_CHUNKS)) / REGION_CHUNKS + 1, 2) + if (MAX_REGION_CANVASES <= worstViewportRegions) { + return fail('the region cache is smaller than one viewport: ' + MAX_REGION_CANVASES) } // Under the limit nothing is given up, however far the view has moved. const few = ['0,0', '1,0', '900,900'] if (tilesToDrop(few, { x0: 0, x1: 1, z0: 0, z1: 1 }).length) { - return fail('tiles were dropped while the cache was under its limit') + return fail('regions were dropped while the cache was under its limit') } // A cache PAST the limit, which is the only state the body runs in — a // fixture of a few dozen would step straight over it and pass with the // whole policy deleted. + // + // 8x8 = 64 against a limit of 48, so sixteen have to go. Deliberately + // over the limit but not WILDLY over: at three times the limit + // everything is evicting everything and the pan property below stops + // being a property of the policy and starts being a property of the + // fixture size. const held: string[] = [] - for (let z = 0; z < 100; z++) for (let x = 0; x < 100; x++) held.push(x + ',' + z) - if (held.length <= TILE_KEEP_LIMIT) return fail('the retention fixture never crosses the limit') + for (let z = 0; z < 8; z++) for (let x = 0; x < 8; x++) held.push(x + ',' + z) + if (held.length <= MAX_REGION_CANVASES) { + return fail('the retention fixture never crosses the limit') + } // The view sits in the top-left corner; the far corner is what should go. - const box = { x0: 0, x1: 49, z0: 0, z1: 49 } + const box = { x0: 0, x1: 2, z0: 0, z1: 2 } const dropped = tilesToDrop(held, box) if (!dropped.length) return fail('nothing was dropped by an over-full cache') - if (held.length - dropped.length !== TILE_KEEP_LIMIT) { + if (held.length - dropped.length !== MAX_REGION_CANVASES) { return fail('the cache was not trimmed to its limit: ' + (held.length - dropped.length)) } const gone = new Set(dropped) @@ -9080,21 +9096,24 @@ export async function runWebSmoke(): Promise { // that is precisely the eviction that made a map re-fetch itself. for (let z = box.z0; z <= box.z1; z++) { for (let x = box.x0; x <= box.x1; x++) { - if (gone.has(x + ',' + z)) return fail('a tile in view was dropped: ' + x + ',' + z) + if (gone.has(x + ',' + z)) return fail('a region in view was dropped: ' + x + ',' + z) } } - if (!gone.has('99,99')) return fail('the farthest tile survived while nearer ones went') - - // The reported bug, as a property: pan one screen right, and the screen - // you came from is still held. The old rule kept the viewport and - // nothing else, so panning back re-fetched all of it. - const panned = new Set(tilesToDrop(held, { x0: 50, x1: 99, z0: 0, z1: 49 })) - let survivors = 0 - for (let z = 0; z <= 49; z++) for (let x = 0; x <= 49; x++) { - if (!panned.has(x + ',' + z)) survivors++ - } - if (survivors < 2000) { - return fail('panning one screen threw away the previous one: only ' + survivors + ' left') + if (!gone.has('7,7')) return fail('the farthest region survived while nearer ones went') + + // The reported bug, as a property: pan a screen sideways, and EVERY + // region of the screen you came from is still held. The old rule kept + // the viewport and nothing else, so panning back re-fetched all of it. + // Every one of them, not a proportion: at region granularity the cache + // holds far more ground than a session pans over, which is what "it + // stays until I reload the page" actually requires. + const panned = new Set(tilesToDrop(held, { x0: 3, x1: 5, z0: 0, z1: 2 })) + for (let z = 0; z <= 2; z++) { + for (let x = 0; x <= 2; x++) { + if (panned.has(x + ',' + z)) { + return fail('panning one screen threw away the previous one: lost ' + x + ',' + z) + } + } } // Deterministic: two runs on the same input must agree, or a redraw @@ -9104,6 +9123,118 @@ export async function runWebSmoke(): Promise { } } + // ---- chunks are stamped into their region canvas, incrementally ---- + // + // The risky half of #164. A region arrives a few hundred chunks at a + // time, so a region canvas has to be PAINTED INTO as they land. Baking it + // once — when its first chunk arrived — leaves it permanently 90% + // transparent, which would look exactly like the half-loaded map this is + // meant to fix. Driven for real, with a canvas stub that records where + // every putImageData landed. + { + const painted: { w: number; x: number; y: number }[] = [] + const canvases: number[] = [] + const canvasFor = (): unknown => { + canvases.push(1) + return { + width: 0, + height: 0, + getContext: () => ({ + createImageData: (w: number, h: number) => ({ data: new Uint8ClampedArray(w * h * 4) }), + putImageData: (img: { data: Uint8ClampedArray }, x: number, y: number) => + painted.push({ w: Math.sqrt(img.data.length / 4), x, y }), + drawImage: () => {}, + clearRect: () => {}, + fillRect: () => {} + }) + } + } + // Two chunks in ONE region, one in another, and one WEST OF ZERO, all + // in a single response. The negative one is not decoration: chunk -1 + // belongs to region -1 at local offset 31, and a raw `cx % 32` gives + // -1 there, which paints outside the canvas and silently draws nothing. + // Without it in the fixture that wrap could be deleted and this passes. + const tile = { c: new Array(256).fill(0x336699), h: new Array(256).fill(64) } + let sent = false + const ctx: Record = { + setTimeout: () => 0, clearTimeout: () => {}, setInterval: () => 0, clearInterval: () => {}, + console, Math, Date, Infinity, isFinite, Object, Array, + Uint8ClampedArray, + Path2D: class {}, + document: { getElementById: () => null, createElement: () => canvasFor() }, + mapGet: () => { + // Chunks 0,0 and 1,0 share region 0,0; chunk 40,0 is region 1,0. + const tiles: Record = sent + ? { '2,0': tile } + : { '0,0': tile, '1,0': tile, '40,0': tile, '-1,0': tile } + sent = true + return Promise.resolve({ tiles, empty: [], pending: 0 }) + }, + mapPost: () => Promise.resolve(null), + mapServerId: () => 's', + mapFeedUrl: () => '/feed', + mapTilesUrl: () => '/tiles', + mapAreasUrlFor: () => '/areas', + mapAvatarUrl: () => '', + mapIconFor: () => ({ path: '', colour: '#fff' }), + mapIconSvg: () => '', + MAP_ICONS: {}, + STRUCTURE_ICONS: {} + } + ctx.window = ctx + runInNewContext(MAP_JS, ctx) + const M = ctx.MAP as Record + M.view = { cx: 340, cz: 0, scale: 1 } + M.vp = { width: 800, height: 200 } + M.world = true + M.loadOnPan = true + const fetchTiles = ctx.mapFetchTiles as (force?: boolean) => void + + fetchTiles(true) + await new Promise((r) => setTimeout(r, 20)) + const regions = ctx.MAP_REGIONS as Record }> + const keys = Object.keys(regions).sort() + if (keys.join(' ') !== '-1,0 0,0 1,0') { + return fail('chunks were not filed into the right regions: ' + keys.join(' ')) + } + // Two chunks in one region means ONE canvas holding both, not two. + if (Object.keys(regions['0,0'].st).sort().join(' ') !== '0,0 1,0') { + return fail('a region did not record both of its chunks') + } + const madeFirst = canvases.length + const stampsFirst = painted.length + if (madeFirst !== 3) return fail('expected one canvas per region, got ' + madeFirst) + if (stampsFirst !== 4) return fail('expected one stamp per chunk, got ' + stampsFirst) + if (painted.some((p) => p.w !== 16)) return fail('a stamp was not one chunk wide') + // Local offsets inside the region, NOT world coordinates: chunk 1,0 goes + // 16px in, chunk 40,0 is local 8 in its own region (128px), and chunk + // -1,0 is local 31 in region -1 (496px). A raw modulo or a world + // coordinate here paints outside the canvas. + const at = painted.map((p) => p.x + ':' + p.y).sort().join(' ') + if (at !== '0:0 128:0 16:0 496:0') { + return fail('chunks were stamped at the wrong offsets: ' + at) + } + + // A later response must PAINT INTO the region that already exists. + // + // The backoff is stepped past deliberately: after the first response + // every chunk that did not come back is waiting on its region, which is + // correct and has its own test below. Leaving it in place here would + // mean the second fetch never asks for anything and this block would + // pass while proving nothing about stamping. + const wait = ctx.MAP_TILE_WAIT as Record + for (const k of Object.keys(wait)) delete wait[k] + fetchTiles(true) + await new Promise((r) => setTimeout(r, 20)) + if (canvases.length !== madeFirst) { + return fail('a second response rebuilt a region canvas instead of adding to it') + } + if (painted.length - stampsFirst !== 1) return fail('the follow-up chunk was not stamped') + if (Object.keys((ctx.MAP_REGIONS as Record)['0,0'].st).length !== 3) { + return fail('the follow-up chunk did not join its region') + } + } + // ---- a view whose chunks are all still parsing must wake itself ---- // // The backoff that stops the map re-asking for chunks it is already diff --git a/src/renderer/src/components/LiveMap.tsx b/src/renderer/src/components/LiveMap.tsx index 76dc1e4..e37ac84 100644 --- a/src/renderer/src/components/LiveMap.tsx +++ b/src/renderer/src/components/LiveMap.tsx @@ -10,11 +10,14 @@ import { mapBounds, panBy, screenToWorld, + chunkBoxToRegions, tilesToDrop, worldToScreen, zoomAt, MAX_TILES_PER_REQUEST, - MAX_VIEWPORT_CHUNKS + MAX_VIEWPORT_CHUNKS, + REGION_CHUNKS, + REGION_SPAN } from '@shared/livemap' import type { ChunkBox, LivePlayer, MapView, Viewport } from '@shared/livemap' import { avatarUrl } from '@shared/profile' @@ -71,15 +74,62 @@ function iconPath(kind: string): Path2D | null { } /** - * A chunk tile baked into a 16x16 offscreen canvas, shaded by the step to the - * column north of it. Baking once per chunk rather than per frame is the - * difference between a map that pans and one that stutters. + * The terrain held for one region: a 512x512 canvas with chunks stamped into it + * as they arrive, plus what is known about each of its 1024 chunks (#164). + * + * One canvas per REGION rather than per chunk. A viewport is up to 4096 chunks, + * so per-chunk canvases meant thousands of DOM objects, which forced a cache + * limit low enough that panning away and back lost the ground — and cost up to + * 4096 `drawImage` calls a frame. The pixels are the same bytes either way. + * + * `st` and `mk` live in here rather than beside it so that dropping a region + * drops the canvas AND the claim to have drawn its chunks in one statement. */ -function bakeTile(t: { c: number[]; h: number[] }): HTMLCanvasElement { +interface RegionTile { + cv: HTMLCanvasElement + g: CanvasRenderingContext2D + /** Chunk key -> 1 drawn, 0 read and empty. Absent means never read. */ + st: Map + mk: Map +} + +const regionOfChunk = (c: number): number => Math.floor(c / REGION_CHUNKS) +const regionKey = (cx: number, cz: number): string => + regionOfChunk(cx) + ',' + regionOfChunk(cz) + +function regionFor(store: Map, cx: number, cz: number): RegionTile { + const k = regionKey(cx, cz) + const hit = store.get(k) + if (hit) return hit const cv = document.createElement('canvas') - cv.width = 16 - cv.height = 16 - const g = cv.getContext('2d') as CanvasRenderingContext2D + cv.width = REGION_SPAN + cv.height = REGION_SPAN + const made: RegionTile = { + cv, + g: cv.getContext('2d') as CanvasRenderingContext2D, + st: new Map(), + mk: new Map() + } + store.set(k, made) + return made +} + +/** + * One chunk, painted into its region canvas at the chunk's own offset, shaded + * by the step to the column north of it. + * + * Incremental on purpose: a region arrives a few hundred chunks at a time, so + * baking it once when its first chunk landed would leave it permanently mostly + * transparent. + */ +function stampTile( + store: Map, + cx: number, + cz: number, + t: { c: number[]; h: number[]; m?: StructureMark[] } +): void { + const r = regionFor(store, cx, cz) + const g = r.g const img = g.createImageData(16, 16) for (let i = 0; i < 256; i++) { const c = t.c[i] @@ -95,8 +145,15 @@ function bakeTile(t: { c: number[]; h: number[] }): HTMLCanvasElement { img.data[o + 2] = Math.max(0, Math.min(255, Math.round((c & 255) * f))) img.data[o + 3] = 255 } - g.putImageData(img, 0, 0) - return cv + // Local chunk within the region. A remainder is negative west of zero, so it + // is wrapped — chunk -1 is local 31, and a raw modulo would paint outside the + // canvas and silently draw nothing. + const lx = ((cx % REGION_CHUNKS) + REGION_CHUNKS) % REGION_CHUNKS + const lz = ((cz % REGION_CHUNKS) + REGION_CHUNKS) % REGION_CHUNKS + g.putImageData(img, lx * 16, lz * 16) + const k = cx + ',' + cz + r.st.set(k, 1) + if (t.m) r.mk.set(k, t.m) } /** @@ -135,18 +192,20 @@ function headFor( * else the moment the cache passed its limit (#159). */ function trimTiles( - tiles: Map, - marks: Map, + store: Map, waiting: Map, box: ChunkBox | null ): void { if (!box) return - for (const k of tilesToDrop(tiles.keys(), box)) { - tiles.delete(k) - marks.delete(k) - // The backoff too, or it outlives every tile it was about and the map - // accumulates one entry per chunk ever looked at. - waiting.delete(k) + // In REGION coordinates, because that is the unit being dropped. + const rbox = chunkBoxToRegions(box) + for (const k of tilesToDrop(store.keys(), rbox)) { + const gone = store.get(k) + // The backoff entries for that region's chunks go with it, or they outlive + // every tile they were about and the map accumulates one entry per chunk + // ever looked at. + if (gone) for (const ck of gone.st.keys()) waiting.delete(ck) + store.delete(k) } } @@ -238,7 +297,8 @@ export function LiveMap({ serverId }: { serverId: string }): JSX.Element { // the world folder (#131). const [marks, setMarks] = useState(false) const [markKind, setMarkKind] = useState('') - const markStore = useRef(new Map()) + // Marks live inside each region entry, so they are dropped by the same + // eviction and can never outlive the terrain they annotate (#164). // Per-server map tuning (#133), read from the server's own config so it // survives a restart and applies to every surface, not just this one. @@ -289,12 +349,11 @@ export function LiveMap({ serverId }: { serverId: string }): JSX.Element { const clearCache = async (): Promise => { setCleared(await window.msms.clearMapCache()) tiles.current.clear() - markStore.current.clear() waiting.current.clear() setTick2((n) => n + 1) } const headCache = useRef(new Map()) - const tiles = useRef(new Map()) + const tiles = useRef(new Map()) const tilesPending = useRef(false) /** Chunk -> when it is worth asking for again, while its region is read. */ const waiting = useRef(new Map()) @@ -323,19 +382,21 @@ export function LiveMap({ serverId }: { serverId: string }): JSX.Element { }, [chunkBox]) /** - * What to DRAW: everything already held that falls in view. A different - * question from what to request — conflating them is why the terrain vanished - * when zoomed out (#135). + * What to DRAW: every held REGION that falls in view. A different question + * from what to request — conflating them is why the terrain vanished when + * zoomed out (#135). Iterating what is held rather than what is visible costs + * the size of the cache, which is now dozens of regions rather than thousands + * of chunks (#164). */ - const drawableChunks = useCallback((): { cx: number; cz: number }[] => { + const drawableRegions = useCallback((): { rx: number; rz: number; r: RegionTile }[] => { const b = chunkBox() if (!b) return [] - const out: { cx: number; cz: number }[] = [] - for (const k of tiles.current.keys()) { - if (!tiles.current.get(k)) continue - const [cx, cz] = k.split(',').map(Number) - if (cx < b.x0 - 1 || cx > b.x1 + 1 || cz < b.z0 - 1 || cz > b.z1 + 1) continue - out.push({ cx, cz }) + const rb = chunkBoxToRegions(b) + const out: { rx: number; rz: number; r: RegionTile }[] = [] + for (const [k, r] of tiles.current) { + const [rx, rz] = k.split(',').map(Number) + if (rx < rb.x0 || rx > rb.x1 || rz < rb.z0 || rz > rb.z1) continue + out.push({ rx, rz, r }) } return out }, [chunkBox]) @@ -356,7 +417,7 @@ export function LiveMap({ serverId }: { serverId: string }): JSX.Element { const want = visibleChunks() .filter((c: { cx: number; cz: number }) => { const k = c.cx + ',' + c.cz - if (tiles.current.has(k)) return false + if (tiles.current.get(regionKey(c.cx, c.cz))?.st.has(k)) return false const until = waiting.current.get(k) ?? 0 if (until > now) { soonest = Math.min(soonest, until) @@ -376,7 +437,7 @@ export function LiveMap({ serverId }: { serverId: string }): JSX.Element { } return } - trimTiles(tiles.current, markStore.current, waiting.current, chunkBox()) + trimTiles(tiles.current, waiting.current, chunkBox()) tilesPending.current = true window.msms .mapTiles(serverId, dim, want, marks) @@ -395,11 +456,13 @@ export function LiveMap({ serverId }: { serverId: string }): JSX.Element { const k = w.cx + ',' + w.cz const t = r.tiles[k] if (t) { - tiles.current.set(k, bakeTile(t)) - if (t.m) markStore.current.set(k, t.m) + // Stamped into the region canvas where it belongs rather than baked + // into a canvas of its own — a region arrives a few hundred chunks + // at a time, so this has to be incremental (#164). + stampTile(tiles.current, w.cx, w.cz, t) waiting.current.delete(k) } else if (known.has(k)) { - tiles.current.set(k, null) + regionFor(tiles.current, w.cx, w.cz).st.set(k, 0) waiting.current.delete(k) } else { // Still being read. Come back to it, but let the rest of the view @@ -422,7 +485,6 @@ export function LiveMap({ serverId }: { serverId: string }): JSX.Element { // dimension change would draw one world's terrain under another's players. useEffect(() => { tiles.current.clear() - markStore.current.clear() waiting.current.clear() setView(null) fitFor.current = '' @@ -432,7 +494,6 @@ export function LiveMap({ serverId }: { serverId: string }): JSX.Element { useEffect(() => { if (!marks) return tiles.current.clear() - markStore.current.clear() waiting.current.clear() }, [marks]) @@ -471,11 +532,12 @@ export function LiveMap({ serverId }: { serverId: string }): JSX.Element { // The world first; everything else sits on top of it. if (world) { g.imageSmoothingEnabled = false - for (const c of drawableChunks()) { - const t = tiles.current.get(c.cx + ',' + c.cz) - if (!t) continue - const p = worldToScreen({ x: c.cx * 16, z: c.cz * 16 }, v, size) - g.drawImage(t, p.x * sx, p.y * sy, 16 * v.scale * sx + 1, 16 * v.scale * sy + 1) + // One call per REGION. This loop used to run once per visible chunk, up + // to 4096 times a frame; a viewport spans at most nine regions (#164). + for (const c of drawableRegions()) { + const p = worldToScreen({ x: c.rx * REGION_SPAN, z: c.rz * REGION_SPAN }, v, size) + const side = REGION_SPAN * v.scale + g.drawImage(c.r.cv, p.x * sx, p.y * sy, side * sx + 1, side * sy + 1) } g.imageSmoothingEnabled = true } @@ -588,8 +650,10 @@ export function LiveMap({ serverId }: { serverId: string }): JSX.Element { // After the grid and the heatmap, before the players. if (marks) { - for (const c of drawableChunks()) { - for (const mk of markStore.current.get(c.cx + ',' + c.cz) ?? []) { + // Markers live in the region entry beside the pixels, so the same + // eviction drops both and one can never outlive the other. + for (const c of drawableRegions()) { + for (const mk of [...c.r.mk.values()].flat()) { if (markKind && mk.kind !== markKind) continue const ic = iconFor(mk.kind) const x = px(mk.x) @@ -643,7 +707,7 @@ export function LiveMap({ serverId }: { serverId: string }): JSX.Element { g.fillStyle = 'rgba(255,255,255,.92)' g.fillText(p.name, x, y - (head ? 12 : 7) * dpr) } - }, [shown, bounds, heat, cell, showHeat, view, vp, dim, heads, world, marks, markKind, tick2, drawableChunks, + }, [shown, bounds, heat, cell, showHeat, view, vp, dim, heads, world, marks, markKind, tick2, drawableRegions, areas, showAreas, pinned, picking, picked]) const localPoint = (e: React.MouseEvent): { x: number; y: number } => { diff --git a/src/shared/livemap.ts b/src/shared/livemap.ts index af10a1f..f22b16d 100644 --- a/src/shared/livemap.ts +++ b/src/shared/livemap.ts @@ -397,14 +397,38 @@ export const MAX_TILES_PER_REQUEST = 512 export const MAX_VIEWPORT_CHUNKS = 4096 /** - * Tiles a client keeps baked before it starts evicting. + * Chunks per region axis, and the blocks one region spans. * - * Strictly greater than `MAX_VIEWPORT_CHUNKS`, and that is the whole point: at - * the old limit of 2048 a single viewport could exceed the cache, so the map - * evicted tiles it had just fetched and re-fetched them on the next draw. Any - * value below the viewport cap turns this into a loop. + * A client bakes ONE canvas per region rather than one per chunk (#164). Both + * numbers matter to the clients: 32 chunks decides which region a chunk belongs + * to, and 512 is both the pixel size of that canvas (32 chunks x 16 px) and the + * blocks it covers, which is what makes the draw a single `drawImage` at the + * region's world origin. */ -export const TILE_KEEP_LIMIT = 8192 +export const REGION_CHUNKS = 32 +export const REGION_SPAN = REGION_CHUNKS * 16 + +/** + * Region canvases a client keeps before it starts evicting. + * + * The unit used to be the chunk, and it was the wrong one twice over. A + * viewport is up to 4096 chunks, so holding a couple of screens' worth meant + * thousands of `HTMLCanvasElement`s: enough object overhead that the limit had + * to stay low, which is why panning two screens away and back made the ground + * disappear and reload — the thing the operator reported after all of #157 and + * #159 had landed. Drawing them cost up to 4096 `drawImage` calls and a walk of + * every held key, per frame, which is the lag they reported alongside it. + * + * One region canvas is 512x512x4 = 1 MB and covers 1024 chunks, so the pixels + * cost the same and everything around them collapses by three orders of + * magnitude. 48 of them is about 48 MB and roughly twelve million blocks of + * ground — far more than a session pans over, which is what "it stays until I + * reload the page" actually requires. + * + * Must exceed the regions one viewport can touch (a 64x64-chunk view spans at + * most 3x3), or the map evicts what it is looking at. + */ +export const MAX_REGION_CANVASES = 48 /** A viewport in chunk coordinates, inclusive at both ends. */ export interface ChunkBox { @@ -437,8 +461,16 @@ function boxDistance(box: ChunkBox, cx: number, cz: number): number { * * Pure and total: the callers hold canvases and DOM objects, and the decision * about what to throw away should be testable without either. + * + * GRID-AGNOSTIC. Keys and `box` only have to be in the SAME units — it decides + * which regions to give up now that a client bakes one canvas per region, and + * the maths did not have to change to do it. */ -export function tilesToDrop(held: Iterable, box: ChunkBox, limit = TILE_KEEP_LIMIT): string[] { +export function tilesToDrop( + held: Iterable, + box: ChunkBox, + limit = MAX_REGION_CANVASES +): string[] { const keys = [...held] const over = keys.length - Math.max(0, limit) if (over <= 0) return [] @@ -457,3 +489,18 @@ export function tilesToDrop(held: Iterable, box: ChunkBox, limit = TILE_ ranked.sort((a, b) => b.d - a.d || (a.k < b.k ? -1 : a.k > b.k ? 1 : 0)) return ranked.slice(0, over).map((r) => r.k) } + +/** Which region a chunk belongs to. Floor, so it is right west of zero too. */ +export function regionOfChunk(c: number): number { + return Math.floor(c / REGION_CHUNKS) +} + +/** A chunk-coordinate viewport as the region grid sees it. */ +export function chunkBoxToRegions(box: ChunkBox): ChunkBox { + return { + x0: regionOfChunk(box.x0), + x1: regionOfChunk(box.x1), + z0: regionOfChunk(box.z0), + z1: regionOfChunk(box.z1) + } +} diff --git a/src/shared/mapUi.ts b/src/shared/mapUi.ts index 009fad5..53b3a10 100644 --- a/src/shared/mapUi.ts +++ b/src/shared/mapUi.ts @@ -15,7 +15,13 @@ * the server's cap, and a client asking for more than the server reads gets a * response that says nothing about the excess (#159). */ -import { MAX_TILES_PER_REQUEST, MAX_VIEWPORT_CHUNKS, TILE_KEEP_LIMIT } from './livemap' +import { + MAX_REGION_CANVASES, + MAX_TILES_PER_REQUEST, + MAX_VIEWPORT_CHUNKS, + REGION_CHUNKS, + REGION_SPAN +} from './livemap' export const MAP_CSS = ` .mp-wrap{display:flex;flex-direction:column;gap:10px} @@ -257,7 +263,7 @@ function mapRefresh(){ the overworld — so switching dimension without dropping them draws one world's terrain under another world's players. */ var dimChanged=MAP.dim!==d.dimension; - if(dimChanged){MAP_TILES={};MAP_MARKS={};MAP_TILE_WAIT={}} + if(dimChanged){MAP_REGIONS={};MAP_TILE_WAIT={}} MAP.data=d;MAP.dim=d.dimension; /* The public feed is asked for one dimension at a time, so a switch needs a new request; a pinned area from the last world would otherwise stay drawn. @@ -541,10 +547,51 @@ function mapToggleAreas(){MAP.areasOn=!MAP.areasOn; offscreen canvas per chunk and then blitted. Building an ImageData per frame for every visible chunk is the difference between a map that pans and one that stutters; a chunk only changes when the server rewrites its region. */ -var MAP_TILES={},MAP_TILE_PENDING=false; +/** + * The terrain a client is holding, ONE CANVAS PER REGION (#164). + * + * It used to be one 16x16 canvas per chunk. A viewport is up to 4096 chunks, so + * holding a couple of screens meant thousands of canvas elements — enough + * object overhead that the limit had to stay low, which is why panning away and + * back made the ground vanish and reload, and why a lot of loaded area made the + * page crawl: every draw walked every held key and issued up to 4096 drawImage + * calls. + * + * MAP_REGIONS['rx,rz'] = { + * cv: <512x512 canvas>, chunks stamped in as they arrive + * st: { 'cx,cz': 1|0 }, 1 drawn, 0 read-and-empty; absent means unknown + * mk: { 'cx,cz': [...] } structure markers + * } + * + * The per-chunk bookkeeping stays per chunk — only the PIXELS moved. Keeping + * the state map inside the region entry is what makes eviction correct by + * construction: dropping the canvas drops the claim to have drawn those chunks + * in the same statement, so the client can never believe it holds a tile it + * cannot draw. + */ +var MAP_REGIONS={},MAP_TILE_PENDING=false; /* Chunk -> when it is worth asking for again, while its region is read. */ var MAP_TILE_WAIT={}; function mapTileKey(cx,cz){return cx+','+cz} +/* Floor, not truncation: -1/32|0 is 0, which would file every chunk west of + spawn into the wrong region. */ +function mapRegionOf(c){return Math.floor(c/${REGION_CHUNKS})} +function mapRegionKey(cx,cz){return mapRegionOf(cx)+','+mapRegionOf(cz)} +/* The region entry a chunk belongs to, created on demand. The canvas is blank + until chunks are stamped into it, which is the point: a region arrives a few + hundred chunks at a time and must be drawable in between. */ +function mapRegionFor(cx,cz,make){ + var rk=mapRegionKey(cx,cz);var r=MAP_REGIONS[rk]; + if(r||!make)return r; + var cv=document.createElement('canvas'); + cv.width=${REGION_SPAN};cv.height=${REGION_SPAN}; + r={cv:cv,g:cv.getContext('2d'),st:{},mk:{}}; + MAP_REGIONS[rk]=r;return r} +/* What the client knows about one chunk: 1 drawn, 0 read and empty, undefined + never read. */ +function mapChunkState(cx,cz){ + var r=MAP_REGIONS[mapRegionKey(cx,cz)]; + return r?r.st[mapTileKey(cx,cz)]:undefined} /* The viewport in chunk coordinates. */ function mapChunkBox(){ if(!MAP.view)return null; @@ -563,24 +610,26 @@ function mapVisibleChunks(){ for(var z=b.z0;z<=b.z1;z++)for(var x=b.x0;x<=b.x1;x++)out.push({cx:x,cz:z}); return out} /** - * Tiles to DRAW: everything already held that falls in view. + * Regions to DRAW: everything held that falls in view. * * A different question from what to request, and conflating the two is why the * terrain vanished when zoomed out (#135) — the request cap correctly refused * to ask for a million chunks and took the drawing down with it. Iterating what * is HELD rather than what is visible also costs the size of the cache instead - * of the size of the viewport, so it stays cheap however far out you go. + * of the size of the viewport, so it stays cheap however far out you go — and + * the cache is now dozens of regions rather than thousands of chunks, so it is + * cheap by three orders of magnitude more than it was (#164). */ -function mapDrawableChunks(){ +function mapDrawableRegions(){ var b=mapChunkBox();if(!b)return []; + var r0=mapRegionOf(b.x0),r1=mapRegionOf(b.x1); + var s0=mapRegionOf(b.z0),s1=mapRegionOf(b.z1); var out=[]; - for(var k in MAP_TILES){ - if(!MAP_TILES[k])continue; - var p=k.split(',');var cx=+p[0],cz=+p[1]; - if(cxb.x1+1||czb.z1+1)continue; - out.push({cx:cx,cz:cz})} + for(var k in MAP_REGIONS){ + var p=k.split(',');var rx=+p[0],rz=+p[1]; + if(rxr1||rzs1)continue; + out.push({rx:rx,rz:rz,r:MAP_REGIONS[k]})} return out} -var MAP_MARKS={}; function mapFetchTiles(force){ if(!MAP.world||MAP_TILE_PENDING||!MAP.view)return; /* When loading-on-pan is off the map draws what it holds and asks for nothing @@ -606,7 +655,7 @@ function mapFetchTiles(force){ var now=Date.now(),soonest=Infinity; var want=chunks.filter(function(c){ var k=mapTileKey(c.cx,c.cz); - if(MAP_TILES[k]!==undefined)return false; + if(mapChunkState(c.cx,c.cz)!==undefined)return false; if(MAP_TILE_WAIT[k]>now){soonest=Math.min(soonest,MAP_TILE_WAIT[k]);return false} return true}); if(!want.length){ @@ -636,10 +685,16 @@ function mapFetchTiles(force){ var known={}; for(var e=0;e<(d.empty||[]).length;e++)known[d.empty[e]]=1; for(var i=0;ib.x1?cx-b.x1:0; - var dz=czb.z1?cz-b.z1:0; - ranked.push({k:keys[i],d:isFinite(cx)&&isFinite(cz)?Math.max(dx,dz):Infinity})} + var p=keys[i].split(',');var rx=+p[0],rz=+p[1]; + var dx=rxx1?rx-x1:0; + var dz=rzz1?rz-z1:0; + ranked.push({k:keys[i],d:isFinite(rx)&&isFinite(rz)?Math.max(dx,dz):Infinity})} ranked.sort(function(a,b2){return b2.d-a.d||(a.kb2.k?1:0)}); for(var j=0;j>8)&255)*f))); img.data[o+2]=Math.max(0,Math.min(255,Math.round((c&255)*f))); img.data[o+3]=255} - g.putImageData(img,0,0);return cv} + /* Local chunk within the region. A remainder is negative west of zero, so it + is wrapped — chunk -1 is local 31, not -1, and a raw modulo would stamp + outside the canvas and silently draw nothing. */ + var lx=((cx%${REGION_CHUNKS})+${REGION_CHUNKS})%${REGION_CHUNKS}; + var lz=((cz%${REGION_CHUNKS})+${REGION_CHUNKS})%${REGION_CHUNKS}; + r.g.putImageData(img,lx*16,lz*16); + r.st[mapTileKey(cx,cz)]=1; + if(t.m)r.mk[mapTileKey(cx,cz)]=t.m} /* Area nobody has ever been to. Drawn as a deliberate, themed hatch rather than left black, because black is indistinguishable from "still loading" and from "broken" — an operator was @@ -759,7 +841,7 @@ function mapDrawUngenerated(g,w,h,dpr){ if(size*sx<3)return; var accent=mapAccent();var ungen=0; for(var cz=b.z0;cz<=b.z1;cz++)for(var cx=b.x0;cx<=b.x1;cx++){ - if(MAP_TILES[mapTileKey(cx,cz)]!==null)continue; + if(mapChunkState(cx,cz)!==0)continue; var p=mapW2S({x:cx*16,z:cz*16}); var x=p.x*sx,y=p.y*sy,d=size*sx,dh2=size*sy; g.fillStyle='rgba('+accent+',0.055)'; @@ -783,18 +865,19 @@ function mapAccent(){ return '220,39,39'} function mapDrawTiles(g,w,h){ if(!MAP.world)return; - var chunks=mapDrawableChunks(); - if(!chunks.length)return; + var regions=mapDrawableRegions(); + if(!regions.length)return; var sx=w/MAP.vp.width,sy=h/MAP.vp.height; - var size=16*MAP.view.scale; - /* Nearest-neighbour: this is 16x16 pixel art scaled up, and smoothing it turns - a blocky world map into a blur. */ + var size=${REGION_SPAN}*MAP.view.scale; + /* Nearest-neighbour: this is pixel art scaled up, and smoothing it turns a + blocky world map into a blur. */ g.imageSmoothingEnabled=false; - for(var i=0;i Date: Thu, 6 Aug 2026 01:20:52 +0300 Subject: [PATCH 2/2] Review: an ocean must not evict the ground you loaded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A chunk that came back READ AND EMPTY created its region entry with the canvas already allocated. So a region of nothing but ungenerated chunks — an ocean, the edge of the explored world, the void past a border — took a megabyte for pixels nothing would ever be drawn into, and took a slot in a 48-slot cache, evicting terrain that had really been read. Panning across empty ground was a way to lose the ground behind you. The entry still has to exist: it is what remembers the chunk is empty, and without it the client asks for it forever. Only the canvas is deferred, to the first chunk that actually needs one. Both clients. Proved failable: allocating on the empty path again fails with "a region with nothing drawn in it allocated a canvas". The stamping fixture also gained the empty case and a wider, shorter test viewport — the viewport is walked row by row and capped at 512, so the tall one spent its whole budget above z=0 and the fixture chunk at x=80 was never requested at all. --- src/main/smoke.ts | 24 ++++++++++++--- src/renderer/src/components/LiveMap.tsx | 39 +++++++++++++++++-------- src/shared/mapUi.ts | 18 +++++++++--- 3 files changed, 61 insertions(+), 20 deletions(-) diff --git a/src/main/smoke.ts b/src/main/smoke.ts index fe1f05a..80e2fa0 100644 --- a/src/main/smoke.ts +++ b/src/main/smoke.ts @@ -9168,7 +9168,10 @@ export async function runWebSmoke(): Promise { ? { '2,0': tile } : { '0,0': tile, '1,0': tile, '40,0': tile, '-1,0': tile } sent = true - return Promise.resolve({ tiles, empty: [], pending: 0 }) + // Chunk 80,0 is region 2,0 and is READ AND EMPTY. It must be + // remembered — so it is never asked for again — without costing a + // megabyte of canvas nothing will ever be drawn into. + return Promise.resolve({ tiles, empty: ['80,0'], pending: 0 }) }, mapPost: () => Promise.resolve(null), mapServerId: () => 's', @@ -9184,8 +9187,13 @@ export async function runWebSmoke(): Promise { ctx.window = ctx runInNewContext(MAP_JS, ctx) const M = ctx.MAP as Record - M.view = { cx: 340, cz: 0, scale: 1 } - M.vp = { width: 800, height: 200 } + // Wide and short on purpose. Every fixture chunk has to fall inside the + // view AND inside the first request: the viewport is walked row by row + // and capped, so a tall view would spend its whole budget on rows above + // z=0 and the response below would be about chunks nobody asked for. + // 126 chunks across x 4 rows of z is 504, just under the cap. + M.view = { cx: 640, cz: 0, scale: 1 } + M.vp = { width: 2000, height: 40 } M.world = true M.loadOnPan = true const fetchTiles = ctx.mapFetchTiles as (force?: boolean) => void @@ -9194,13 +9202,21 @@ export async function runWebSmoke(): Promise { await new Promise((r) => setTimeout(r, 20)) const regions = ctx.MAP_REGIONS as Record }> const keys = Object.keys(regions).sort() - if (keys.join(' ') !== '-1,0 0,0 1,0') { + if (keys.join(' ') !== '-1,0 0,0 1,0 2,0') { return fail('chunks were not filed into the right regions: ' + keys.join(' ')) } // Two chunks in one region means ONE canvas holding both, not two. if (Object.keys(regions['0,0'].st).sort().join(' ') !== '0,0 1,0') { return fail('a region did not record both of its chunks') } + // The empty region is known but has no canvas: an ocean must not be + // able to evict the terrain you actually loaded. + const empties = regions['2,0'] + if (!empties) return fail('an empty chunk was not remembered; it will be asked for forever') + if (empties.st['80,0'] !== 0) return fail('an empty chunk was not recorded as empty') + if ((empties as unknown as { cv: unknown }).cv) { + return fail('a region with nothing drawn in it allocated a canvas') + } const madeFirst = canvases.length const stampsFirst = painted.length if (madeFirst !== 3) return fail('expected one canvas per region, got ' + madeFirst) diff --git a/src/renderer/src/components/LiveMap.tsx b/src/renderer/src/components/LiveMap.tsx index e37ac84..a66316a 100644 --- a/src/renderer/src/components/LiveMap.tsx +++ b/src/renderer/src/components/LiveMap.tsx @@ -86,8 +86,16 @@ function iconPath(kind: string): Path2D | null { * drops the canvas AND the claim to have drawn its chunks in one statement. */ interface RegionTile { - cv: HTMLCanvasElement - g: CanvasRenderingContext2D + /** + * Null until something is actually drawn into it. + * + * The canvas is a megabyte and allocating it with the entry meant a region of + * nothing but ungenerated chunks — an ocean, the edge of the explored world — + * took one anyway, and a slot in the cache, evicting terrain that had really + * been read. + */ + cv: HTMLCanvasElement | null + g: CanvasRenderingContext2D | null /** Chunk key -> 1 drawn, 0 read and empty. Absent means never read. */ st: Map mk: Map @@ -101,19 +109,23 @@ function regionFor(store: Map, cx: number, cz: number): Regi const k = regionKey(cx, cz) const hit = store.get(k) if (hit) return hit - const cv = document.createElement('canvas') - cv.width = REGION_SPAN - cv.height = REGION_SPAN - const made: RegionTile = { - cv, - g: cv.getContext('2d') as CanvasRenderingContext2D, - st: new Map(), - mk: new Map() - } + const made: RegionTile = { cv: null, g: null, st: new Map(), mk: new Map() } store.set(k, made) return made } +/** The canvas, made on the first chunk that needs one. */ +function regionCanvas(r: RegionTile): CanvasRenderingContext2D { + if (!r.g) { + const cv = document.createElement('canvas') + cv.width = REGION_SPAN + cv.height = REGION_SPAN + r.cv = cv + r.g = cv.getContext('2d') as CanvasRenderingContext2D + } + return r.g +} + /** * One chunk, painted into its region canvas at the chunk's own offset, shaded * by the step to the column north of it. @@ -129,7 +141,7 @@ function stampTile( t: { c: number[]; h: number[]; m?: StructureMark[] } ): void { const r = regionFor(store, cx, cz) - const g = r.g + const g = regionCanvas(r) const img = g.createImageData(16, 16) for (let i = 0; i < 256; i++) { const c = t.c[i] @@ -396,6 +408,8 @@ export function LiveMap({ serverId }: { serverId: string }): JSX.Element { for (const [k, r] of tiles.current) { const [rx, rz] = k.split(',').map(Number) if (rx < rb.x0 || rx > rb.x1 || rz < rb.z0 || rz > rb.z1) continue + // A region that only ever answered "nothing there" has no canvas. + if (!r.cv) continue out.push({ rx, rz, r }) } return out @@ -535,6 +549,7 @@ export function LiveMap({ serverId }: { serverId: string }): JSX.Element { // One call per REGION. This loop used to run once per visible chunk, up // to 4096 times a frame; a viewport spans at most nine regions (#164). for (const c of drawableRegions()) { + if (!c.r.cv) continue const p = worldToScreen({ x: c.rx * REGION_SPAN, z: c.rz * REGION_SPAN }, v, size) const side = REGION_SPAN * v.scale g.drawImage(c.r.cv, p.x * sx, p.y * sy, side * sx + 1, side * sy + 1) diff --git a/src/shared/mapUi.ts b/src/shared/mapUi.ts index 53b3a10..e667617 100644 --- a/src/shared/mapUi.ts +++ b/src/shared/mapUi.ts @@ -583,10 +583,18 @@ function mapRegionKey(cx,cz){return mapRegionOf(cx)+','+mapRegionOf(cz)} function mapRegionFor(cx,cz,make){ var rk=mapRegionKey(cx,cz);var r=MAP_REGIONS[rk]; if(r||!make)return r; - var cv=document.createElement('canvas'); - cv.width=${REGION_SPAN};cv.height=${REGION_SPAN}; - r={cv:cv,g:cv.getContext('2d'),st:{},mk:{}}; + r={cv:null,g:null,st:{},mk:{}}; MAP_REGIONS[rk]=r;return r} +/* The canvas is a megabyte and is only needed once something is actually drawn + into it. Allocating it with the entry meant a region of nothing but + ungenerated chunks — an ocean, the edge of the explored world — took a full + canvas and a slot in the cache, evicting terrain that had really been read. */ +function mapRegionCanvas(r){ + if(!r.cv){ + var cv=document.createElement('canvas'); + cv.width=${REGION_SPAN};cv.height=${REGION_SPAN}; + r.cv=cv;r.g=cv.getContext('2d')} + return r} /* What the client knows about one chunk: 1 drawn, 0 read and empty, undefined never read. */ function mapChunkState(cx,cz){ @@ -628,6 +636,8 @@ function mapDrawableRegions(){ for(var k in MAP_REGIONS){ var p=k.split(',');var rx=+p[0],rz=+p[1]; if(rxr1||rzs1)continue; + /* A region that only ever answered "nothing there" has no canvas. */ + if(!MAP_REGIONS[k].cv)continue; out.push({rx:rx,rz:rz,r:MAP_REGIONS[k]})} return out} function mapFetchTiles(force){ @@ -806,7 +816,7 @@ function mapTrimTiles(){ * over the region's blank background instead of blending with it. */ function mapStampTile(cx,cz,t){ - var r=mapRegionFor(cx,cz,true); + var r=mapRegionCanvas(mapRegionFor(cx,cz,true)); var img=r.g.createImageData(16,16); for(var i=0;i<256;i++){ var c=t.c[i];var o=i*4;