diff --git a/src/main/smoke.ts b/src/main/smoke.ts index 89554e1..80e2fa0 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,134 @@ 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 + // 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', + 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 + // 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 + + 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 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) + 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..a66316a 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,74 @@ 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 { - const cv = document.createElement('canvas') - cv.width = 16 - cv.height = 16 - const g = cv.getContext('2d') as CanvasRenderingContext2D +interface RegionTile { + /** + * 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 +} + +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 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. + * + * 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 = regionCanvas(r) const img = g.createImageData(16, 16) for (let i = 0; i < 256; i++) { const c = t.c[i] @@ -95,8 +157,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 +204,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 +309,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 +361,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 +394,23 @@ 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 + // A region that only ever answered "nothing there" has no canvas. + if (!r.cv) continue + out.push({ rx, rz, r }) } return out }, [chunkBox]) @@ -356,7 +431,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 +451,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 +470,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 +499,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 +508,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 +546,13 @@ 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()) { + 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) } g.imageSmoothingEnabled = true } @@ -588,8 +665,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 +722,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..e667617 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,59 @@ 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; + 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){ + 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 +618,28 @@ 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; + /* 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} -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 +665,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 +695,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 +851,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 +875,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