wren/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]>
Wren committed a0d9f59 Browse files
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 () =
232232 expect(row).toContain(" ▸ ");
233233 expect(row).not.toMatch(/\d/);
234234
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
237244 drawn.length = 0;
238245 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);
240250
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.
242254 for (const fn of listeners.get("keydown") ?? []) fn({ code: "Tab", key: "", target: {}, preventDefault: noop });
243255 drawn.length = 0;
244256 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);
246264 for (const fn of listeners.get("keyup") ?? []) fn({ code: "Tab" });
247265 drawn.length = 0;
248266 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);
282268 // Holding W reaches the server as a direction, and it is the way we face.
283269 const seat = (await import("./arena.ts")).match().players.find((p) => !p.bot)!;
284270 press("KeyW");
@@ -379,6 +365,46 @@ test("the bundled client connects, paints the HUD and draws a frame", async () =
379365 expect(seat.y).toBe(startedAt.y);
380366 arena.startArena();
381367
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
382408 // This test's loopback address is shared with every other test in the
383409 // run; close the socket so the address is free for the next real
384410 // 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 = {
2222 * smoothed position; `opts.labels` draws site letters and names, which only
2323 * fit on the big version; `opts.marks` are the last-seen rings from
2424 * `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.
2631 */
2732 export function drawTopdown(ctx, map, snap, look, box, opts = {}) {
2833 const cols = map.rows[0].length;
2934 const s = box.w / (cols * map.tile);
3035 const labels = opts.labels !== false;
36 const tiles = opts.tiles !== false;
3137 ctx.save();
3238 ctx.beginPath();
3339 ctx.rect(box.x, box.y, box.w, box.h);
@@ -35,17 +41,18 @@ export function drawTopdown(ctx, map, snap, look, box, opts = {}) {
3541 ctx.translate(box.x, box.y);
3642
3743 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 }
4755 }
48 }
4956
5057 if (!snap) {
5158 ctx.restore();
static/val.js +30 −6
@@ -429,7 +429,9 @@ function frame(now) {
429429 drawGun(ctx, gun, view.width, view.height, snap);
430430 drawMinimap();
431431 } 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 });
433435 }
434436
435437 if (snap.me.planting > 0) bar(snap.me.planting / 4, "#e0a040", "planting");
@@ -453,8 +455,9 @@ function togglePerf() {
453455 const box = $("perf");
454456 if (!box) return;
455457 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);
458461 }
459462
460463 /**
@@ -462,9 +465,9 @@ function togglePerf() {
462465 * the governor decided on, the rate snapshots are actually arriving at, the
463466 * interpolation delay in force, and how wide the world is being drawn.
464467 */
465 function paintPerf(nowMs) {
468 function paintPerf(nowMs, force) {
466469 const box = $("perf");
467 if (!perfOn || !box || nowMs - perfAt < 500) return;
470 if (!perfOn || !box || (!force && nowMs - perfAt < 500)) return;
468471 perfAt = nowMs;
469472 const hz = timeline.interval > 0 ? 1 / timeline.interval : 0;
470473 box.textContent =
@@ -473,6 +476,26 @@ function paintPerf(nowMs) {
473476 `${render.width}×${render.height} of ${view.width}×${view.height}`;
474477 }
475478
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
476499 /** The same top-down drawing, small, in the corner, with you as an arrow. */
477500 function drawMinimap() {
478501 const w = Math.min(view.width * 0.3, 260 * (view.width / 900));
@@ -482,7 +505,8 @@ function drawMinimap() {
482505 ctx.globalAlpha = 0.85;
483506 ctx.fillStyle = "#0b0d11";
484507 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 });
486510 ctx.restore();
487511 ctx.strokeStyle = "rgba(255,255,255,0.18)";
488512 ctx.lineWidth = 1;
↑↓ navigate ↵ open esc close

Keyboard shortcuts