fable/val Public Public: anyone can read this; nobody can change it.
forked from benjivers/val
workbenchthis machine
Draw the map's 570 tiles once, not once a frame
The minimap redraws the whole grid every frame — 570 fillRects for a map that never changes, which is more than the entire first-person view costs now that the walls are batched. The full-screen map pays the same on every frame, and that is the whole frame on a phone. `drawTopdown` called with no snapshot has always drawn exactly the tile layer and stopped, so that is what is cached: one canvas per box size, built once and blitted, with `tiles: false` for the pass that draws the things that actually move. Two entries in practice, cleared if a window drag makes more. Also: the readout painted nothing when it was turned on in the first half second of the page, because the twice-a-second gate was measured against a zero. It paints straight away when it is toggled now. The client test counts fills on a frame after the caches are warm, and says so — the frame that builds a layer is not the frame that shows what one costs. Co-Authored-By: Claude Opus 5 <[email protected]>
4 files
changed +112
−89
bench.tmp/bodies.ts deleted −34
| @@ -1,34 +0,0 @@ | ||
| 1 | import { drawScene, makeScene } from "../static/scene.js"; | |
| 2 | import { gridRows, TILE } from "../src/map.ts"; | |
| 3 | const rows = gridRows(); | |
| 4 | const solid = (c:number,r:number)=> c<0||r<0||r>=rows.length||c>=rows[0]!.length||rows[r]![c]==="#"; | |
| 5 | const world = { solid, tile: TILE }; | |
| 6 | const me:any = {id:"me",x:0,y:0,angle:0,alive:true,side:"atk",hp:100,blind:0,ammo:25,reloading:false,planting:0,spike:false,agent:"ember"}; | |
| 7 | function ctxOf(){ const c:any={ops:0,fills:0,clips:0}; const noop=()=>{}; | |
| 8 | return new Proxy(c,{get(t,p:string){ if(p in t) return t[p]; | |
| 9 | if(p==="measureText") return (s:string)=>({width:s.length*6}); | |
| 10 | if(p==="createLinearGradient") return ()=>({addColorStop:noop}); | |
| 11 | return ()=>{ t.ops++; if(p==="fillRect") t.fills++; if(p==="clip") t.clips++; };},set:(t,p,v)=>{t[p]=v;return true;}}); | |
| 12 | } | |
| 13 | const spots:[number,number][]=[]; | |
| 14 | for (let r=1;r<rows.length-1;r++) for(let c=1;c<rows[0]!.length-1;c++) | |
| 15 | if(rows[r]![c]!=="#") spots.push([c*TILE+TILE/2, r*TILE+TILE/2]); | |
| 16 | const open = spots.filter((_,i)=>i%5===0); | |
| 17 | function run(W:number, bodies:number, marks:number){ | |
| 18 | const H = Math.round(W*570/900); | |
| 19 | const scene = makeScene(); | |
| 20 | const ctx:any = ctxOf(); | |
| 21 | let frames=0; | |
| 22 | for (const [x,y] of open) for (let a=0;a<4;a++){ | |
| 23 | const players = Array.from({length:bodies},(_,i)=>({id:"b"+i,name:"Bot",agent:"sable", | |
| 24 | x:x+Math.cos(a+i)*140, y:y+Math.sin(a+i)*140, angle:0, hp:90, alive:true, mate:i%2===0, spike:false, blind:false})); | |
| 25 | const mark = Array.from({length:marks},(_,i)=>({id:"m"+i,x:x+Math.cos(a+i)*180,y:y+Math.sin(a+i)*180,angle:0,mate:false,fade:0.6})); | |
| 26 | drawScene(ctx, scene, world, {x,y,angle:a}, | |
| 27 | {phase:"live",clock:40,result:null,me,players,zones:[],orbs:[],shots:[],spike:{planted:false,carried:false,fuse:0,defuse:0,x:null,y:null}} as any, | |
| 28 | {width:W,height:H,look:(p:any)=>p, shown:()=>1, marks:mark}); | |
| 29 | frames++; | |
| 30 | } | |
| 31 | console.log(`W=${String(W).padEnd(5)} bodies=${bodies} marks=${marks} ops/frame=${(ctx.ops/frames).toFixed(0).padEnd(5)} fills=${(ctx.fills/frames).toFixed(0).padEnd(5)} clips=${(ctx.clips/frames).toFixed(1)}`); | |
| 32 | } | |
| 33 | run(1280,0,0); run(1280,4,0); run(1280,9,3); | |
| 34 | run(960,0,0); run(960,4,0); run(960,9,3); |
src/client.test.ts +64 −38
| @@ -232,53 +232,39 @@ test("the bundled client connects, paints the HUD and draws a frame", async () = | ||
| 232 | 232 | expect(row).toContain(" ▸ "); |
| 233 | 233 | expect(row).not.toMatch(/\d/); |
| 234 | 234 | |
| 235 | // A frame of the first-person view actually draws walls: a slice a column, | |
| 236 | // far more rectangles than the map has tiles. | |
| 235 | // A frame of the first-person view draws the world into its own buffer and | |
| 236 | // blits it, with the minimap's tiles blitted too. It is a picture, but it | |
| 237 | // is not one fill a screen column any more, nor one a map tile: the map has | |
| 238 | // 570 tiles and the canvas 900 columns, and the frame used to pay for both. | |
| 239 | const tally = () => ({ | |
| 240 | fills: drawn.filter((op) => op.startsWith("fillRect")).length, | |
| 241 | blits: drawn.filter((op) => op.startsWith("drawImage")).length, | |
| 242 | }); | |
| 243 | runFrame(); // whatever was cached at this size is cached before we count | |
| 237 | 244 | drawn.length = 0; |
| 238 | 245 | runFrame(); |
| 239 | expect(drawn.filter((op) => op.startsWith("fillRect")).length).toBeGreaterThan(800); | |
| 246 | const fp = tally(); | |
| 247 | expect(fp.blits).toBeGreaterThanOrEqual(2); // the world, and the map's tiles | |
| 248 | expect(fp.fills).toBeGreaterThan(40); | |
| 249 | expect(fp.fills).toBeLessThan(570); | |
| 240 | 250 | |
| 241 | // Tab swaps that frame for the whole map — many fewer rectangles. | |
| 251 | // Tab swaps that frame for the whole map. Its tiles are drawn once into a | |
| 252 | // canvas of their own and blitted from then on: the first such frame pays | |
| 253 | // for all 570 of them, and no frame after it pays for any. | |
| 242 | 254 | for (const fn of listeners.get("keydown") ?? []) fn({ code: "Tab", key: "", target: {}, preventDefault: noop }); |
| 243 | 255 | drawn.length = 0; |
| 244 | 256 | runFrame(); |
| 245 | expect(drawn.filter((op) => op.startsWith("fillRect")).length).toBeLessThan(800); | |
| 257 | const built = tally(); | |
| 258 | drawn.length = 0; | |
| 259 | runFrame(); | |
| 260 | const top = tally(); | |
| 261 | expect(built.fills).toBeGreaterThan(400); | |
| 262 | expect(top.blits).toBe(1); | |
| 263 | expect(top.fills).toBeLessThan(100); | |
| 246 | 264 | for (const fn of listeners.get("keyup") ?? []) fn({ code: "Tab" }); |
| 247 | 265 | drawn.length = 0; |
| 248 | 266 | runFrame(); |
| 249 | expect(drawn.filter((op) => op.startsWith("fillRect")).length).toBeGreaterThan(800); | |
| 250 | const onePixel = drawn.filter((op) => op.startsWith("fillRect")).length; | |
| 251 | ||
| 252 | // The frame is not the screen's pixel count. On a retina display the same | |
| 253 | // window is twice the canvas, and the world is still drawn at a capped | |
| 254 | // internal width and blitted up: the work does not double with the device | |
| 255 | // pixel ratio, which is what made this choppy on a retina Mac. | |
| 256 | (globalThis as { devicePixelRatio: number }).devicePixelRatio = 2; | |
| 257 | for (const fn of listeners.get("resize") ?? []) fn({}); | |
| 258 | expect(nodes.get("view")!.width).toBe(1800); | |
| 259 | drawn.length = 0; | |
| 260 | runFrame(); | |
| 261 | expect(drawn.some((op) => op.startsWith("drawImage"))).toBe(true); | |
| 262 | expect(drawn.filter((op) => op.startsWith("fillRect")).length).toBeLessThan(onePixel * 1.3); | |
| 263 | (globalThis as { devicePixelRatio: number }).devicePixelRatio = 1; | |
| 264 | for (const fn of listeners.get("resize") ?? []) fn({}); | |
| 265 | ||
| 266 | // ` prints the numbers, so "it feels choppy" and "it delivers 30Hz" can be | |
| 267 | // told apart: the frame rate, the mean frame, the snapshot rate, the | |
| 268 | // interpolation delay, and the width the world is really drawn at. | |
| 269 | const perf = nodes.get("perf")!; | |
| 270 | expect((perf.style as Record<string, string>).display).not.toBe("block"); | |
| 271 | press("Backquote"); | |
| 272 | expect((perf.style as Record<string, string>).display).toBe("block"); | |
| 273 | const readout = String(perf.textContent); | |
| 274 | expect(readout).toContain("fps"); | |
| 275 | expect(readout).toContain("ms/frame"); | |
| 276 | expect(readout).toContain("Hz in"); | |
| 277 | expect(readout).toContain("ms behind"); | |
| 278 | expect(readout).toContain("900×570"); // the internal size, of a 900×570 canvas | |
| 279 | press("Backquote"); | |
| 280 | expect((perf.style as Record<string, string>).display).toBe("none"); | |
| 281 | ||
| 267 | expect(tally().blits).toBeGreaterThanOrEqual(2); | |
| 282 | 268 | // Holding W reaches the server as a direction, and it is the way we face. |
| 283 | 269 | const seat = (await import("./arena.ts")).match().players.find((p) => !p.bot)!; |
| 284 | 270 | press("KeyW"); |
| @@ -379,6 +365,46 @@ test("the bundled client connects, paints the HUD and draws a frame", async () = | ||
| 379 | 365 | expect(seat.y).toBe(startedAt.y); |
| 380 | 366 | arena.startArena(); |
| 381 | 367 | |
| 368 | // Back to the first-person view for the last of it, and a frame of it to | |
| 369 | // measure: the one before is the one that rebuilds anything cached. | |
| 370 | press("KeyV"); | |
| 371 | runFrame(); | |
| 372 | drawn.length = 0; | |
| 373 | runFrame(); | |
| 374 | const onePixel = tally().fills; | |
| 375 | expect(onePixel).toBeGreaterThan(40); | |
| 376 | ||
| 377 | // The frame is not the screen's pixel count. On a retina display the same | |
| 378 | // window is twice the canvas, and the world is still drawn at a capped | |
| 379 | // internal width and blitted up: the work does not double with the device | |
| 380 | // pixel ratio, which is what made this choppy on a retina Mac. | |
| 381 | (globalThis as { devicePixelRatio: number }).devicePixelRatio = 2; | |
| 382 | for (const fn of listeners.get("resize") ?? []) fn({}); | |
| 383 | expect(nodes.get("view")!.width).toBe(1800); | |
| 384 | runFrame(); // the first frame at a new size redraws the minimap's tiles | |
| 385 | drawn.length = 0; | |
| 386 | runFrame(); | |
| 387 | expect(drawn.some((op) => op.startsWith("drawImage"))).toBe(true); | |
| 388 | expect(drawn.filter((op) => op.startsWith("fillRect")).length).toBeLessThan(onePixel * 1.3); | |
| 389 | (globalThis as { devicePixelRatio: number }).devicePixelRatio = 1; | |
| 390 | for (const fn of listeners.get("resize") ?? []) fn({}); | |
| 391 | ||
| 392 | // ` prints the numbers, so "it feels choppy" and "it delivers 30Hz" can be | |
| 393 | // told apart: the frame rate, the mean frame, the snapshot rate, the | |
| 394 | // interpolation delay, and the width the world is really drawn at. | |
| 395 | const perf = nodes.get("perf")!; | |
| 396 | expect((perf.style as Record<string, string>).display).not.toBe("block"); | |
| 397 | press("Backquote"); | |
| 398 | expect((perf.style as Record<string, string>).display).toBe("block"); | |
| 399 | const readout = String(perf.textContent); | |
| 400 | expect(readout).toContain("fps"); | |
| 401 | expect(readout).toContain("ms/frame"); | |
| 402 | expect(readout).toContain("Hz in"); | |
| 403 | expect(readout).toContain("ms behind"); | |
| 404 | expect(readout).toContain("900×570"); // the internal size, of a 900×570 canvas | |
| 405 | press("Backquote"); | |
| 406 | expect((perf.style as Record<string, string>).display).toBe("none"); | |
| 407 | ||
| 382 | 408 | // This test's loopback address is shared with every other test in the |
| 383 | 409 | // run; close the socket so the address is free for the next real |
| 384 | 410 | // connection rather than left holding a seat for the rest of the suite. |
static/topdown.js +18 −11
| @@ -22,12 +22,18 @@ const TILE_COLOUR = { | ||
| 22 | 22 | * smoothed position; `opts.labels` draws site letters and names, which only |
| 23 | 23 | * fit on the big version; `opts.marks` are the last-seen rings from |
| 24 | 24 | * `presence.js`, which answer "where did they go" without claiming anybody |
| 25 | * is there now. | |
| 25 | * is there now; `opts.tiles: false` leaves out the map itself, for a caller | |
| 26 | * that has already drawn it once and kept it — it is 570 rectangles of a | |
| 27 | * map that never changes. | |
| 28 | * | |
| 29 | * Called with no snapshot it draws the tiles and stops, which is exactly | |
| 30 | * that layer. | |
| 26 | 31 | */ |
| 27 | 32 | export function drawTopdown(ctx, map, snap, look, box, opts = {}) { |
| 28 | 33 | const cols = map.rows[0].length; |
| 29 | 34 | const s = box.w / (cols * map.tile); |
| 30 | 35 | const labels = opts.labels !== false; |
| 36 | const tiles = opts.tiles !== false; | |
| 31 | 37 | ctx.save(); |
| 32 | 38 | ctx.beginPath(); |
| 33 | 39 | ctx.rect(box.x, box.y, box.w, box.h); |
| @@ -35,17 +41,18 @@ export function drawTopdown(ctx, map, snap, look, box, opts = {}) { | ||
| 35 | 41 | ctx.translate(box.x, box.y); |
| 36 | 42 | |
| 37 | 43 | const t = map.tile * s; |
| 38 | for (let row = 0; row < map.rows.length; row++) | |
| 39 | for (let col = 0; col < map.rows[row].length; col++) { | |
| 40 | const cell = map.rows[row][col]; | |
| 41 | ctx.fillStyle = TILE_COLOUR[cell] ?? "#171a21"; | |
| 42 | ctx.fillRect(col * t, row * t, t + 0.6, t + 0.6); | |
| 43 | if (labels && (cell === "A" || cell === "B")) { | |
| 44 | ctx.fillStyle = "#6b5f3a"; | |
| 45 | ctx.font = `${Math.round(t * 0.5)}px system-ui, sans-serif`; | |
| 46 | ctx.fillText(cell, col * t + t * 0.3, row * t + t * 0.68); | |
| 44 | if (tiles) | |
| 45 | for (let row = 0; row < map.rows.length; row++) | |
| 46 | for (let col = 0; col < map.rows[row].length; col++) { | |
| 47 | const cell = map.rows[row][col]; | |
| 48 | ctx.fillStyle = TILE_COLOUR[cell] ?? "#171a21"; | |
| 49 | ctx.fillRect(col * t, row * t, t + 0.6, t + 0.6); | |
| 50 | if (labels && (cell === "A" || cell === "B")) { | |
| 51 | ctx.fillStyle = "#6b5f3a"; | |
| 52 | ctx.font = `${Math.round(t * 0.5)}px system-ui, sans-serif`; | |
| 53 | ctx.fillText(cell, col * t + t * 0.3, row * t + t * 0.68); | |
| 54 | } | |
| 47 | 55 | } |
| 48 | } | |
| 49 | 56 | |
| 50 | 57 | if (!snap) { |
| 51 | 58 | ctx.restore(); |
static/val.js +30 −6
| @@ -429,7 +429,9 @@ function frame(now) { | ||
| 429 | 429 | drawGun(ctx, gun, view.width, view.height, snap); |
| 430 | 430 | drawMinimap(); |
| 431 | 431 | } else { |
| 432 | drawTopdown(ctx, map, snap, look, { x: 0, y: 0, w: view.width, h: view.height }, { marks }); | |
| 432 | const box = { x: 0, y: 0, w: view.width, h: view.height }; | |
| 433 | ctx.drawImage(tileLayer(view.width, view.height, true), 0, 0); | |
| 434 | drawTopdown(ctx, map, snap, look, box, { marks, tiles: false }); | |
| 433 | 435 | } |
| 434 | 436 | |
| 435 | 437 | if (snap.me.planting > 0) bar(snap.me.planting / 4, "#e0a040", "planting"); |
| @@ -453,8 +455,9 @@ function togglePerf() { | ||
| 453 | 455 | const box = $("perf"); |
| 454 | 456 | if (!box) return; |
| 455 | 457 | box.style.display = perfOn ? "block" : "none"; |
| 456 | perfAt = 0; | |
| 457 | if (perfOn) paintPerf(performance.now()); | |
| 458 | // Straight away, whatever the clock says: a readout you turn on and that | |
| 459 | // stays blank for half a second reads as a readout that does not work. | |
| 460 | if (perfOn) paintPerf(performance.now(), true); | |
| 458 | 461 | } |
| 459 | 462 | |
| 460 | 463 | /** |
| @@ -462,9 +465,9 @@ function togglePerf() { | ||
| 462 | 465 | * the governor decided on, the rate snapshots are actually arriving at, the |
| 463 | 466 | * interpolation delay in force, and how wide the world is being drawn. |
| 464 | 467 | */ |
| 465 | function paintPerf(nowMs) { | |
| 468 | function paintPerf(nowMs, force) { | |
| 466 | 469 | const box = $("perf"); |
| 467 | if (!perfOn || !box || nowMs - perfAt < 500) return; | |
| 470 | if (!perfOn || !box || (!force && nowMs - perfAt < 500)) return; | |
| 468 | 471 | perfAt = nowMs; |
| 469 | 472 | const hz = timeline.interval > 0 ? 1 / timeline.interval : 0; |
| 470 | 473 | box.textContent = |
| @@ -473,6 +476,26 @@ function paintPerf(nowMs) { | ||
| 473 | 476 | `${render.width}×${render.height} of ${view.width}×${view.height}`; |
| 474 | 477 | } |
| 475 | 478 | |
| 479 | /** | |
| 480 | * The map's own tiles, drawn once a size and kept. There are 570 of them and | |
| 481 | * they are the same 570 every frame — on the minimap that was more fills | |
| 482 | * than the whole first-person view costs. Called with no snapshot, | |
| 483 | * `drawTopdown` draws exactly this layer and nothing else. | |
| 484 | */ | |
| 485 | const layers = new Map(); | |
| 486 | function tileLayer(w, h, labels) { | |
| 487 | const key = `${w}x${h}${labels ? "L" : ""}`; | |
| 488 | const had = layers.get(key); | |
| 489 | if (had) return had; | |
| 490 | const layer = document.createElement("canvas"); | |
| 491 | layer.width = w; | |
| 492 | layer.height = h; | |
| 493 | drawTopdown(layer.getContext("2d"), map, null, look, { x: 0, y: 0, w, h }, { labels }); | |
| 494 | if (layers.size > 3) layers.clear(); // a window being dragged, not a leak | |
| 495 | layers.set(key, layer); | |
| 496 | return layer; | |
| 497 | } | |
| 498 | ||
| 476 | 499 | /** The same top-down drawing, small, in the corner, with you as an arrow. */ |
| 477 | 500 | function drawMinimap() { |
| 478 | 501 | const w = Math.min(view.width * 0.3, 260 * (view.width / 900)); |
| @@ -482,7 +505,8 @@ function drawMinimap() { | ||
| 482 | 505 | ctx.globalAlpha = 0.85; |
| 483 | 506 | ctx.fillStyle = "#0b0d11"; |
| 484 | 507 | ctx.fillRect(pad - 2, pad - 2, w + 4, h + 4); |
| 485 | drawTopdown(ctx, map, snap, look, { x: pad, y: pad, w, h }, { labels: false, marks }); | |
| 508 | ctx.drawImage(tileLayer(Math.max(1, Math.round(w)), Math.max(1, Math.round(h)), false), pad, pad, w, h); | |
| 509 | drawTopdown(ctx, map, snap, look, { x: pad, y: pad, w, h }, { labels: false, marks, tiles: false }); | |
| 486 | 510 | ctx.restore(); |
| 487 | 511 | ctx.strokeStyle = "rgba(255,255,255,0.18)"; |
| 488 | 512 | ctx.lineWidth = 1; |