benjivers/val Public Public: anyone can read this; the crew can change it.
960 columns, every window, and rungs that really are cheaper
Taking the permission in the issue thread: fidelity is cheap here and frame rate is not, so the world is drawn 960 columns wide whatever the window is, rather than 1280 on a wide one. The ladder starts there and only goes down. The old `step = W > 1100 ? 2 : 1` rule made the ladder lie: the 960 rung cast 960 rays where the 1280 rung cast 640, so going a rung coarser cost more. The ray step is now a property of the buffer and not of a magic width — one ray every other column, the slice two columns wide, above 640 — so every rung down the ladder is genuinely cheaper than the one above it. Sprites are still drawn at the buffer's full resolution; it is the walls that are coarse, and flat shading does not reward a ray a pixel. Co-Authored-By: Claude Opus 5 <[email protected]>
6 files
changed +89
−28
bench.tmp/bodies.ts added +34
| @@ -0,0 +1,34 @@ | ||
| 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/resolution.test.ts +12 −10
| @@ -5,27 +5,29 @@ | ||
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | 7 | import { expect, test } from "bun:test"; |
| 8 | import { renderSize, startLevel, WIDTHS } from "../static/resolution.js"; | |
| 8 | import { renderSize, WIDTHS } from "../static/resolution.js"; | |
| 9 | 9 | |
| 10 | 10 | test("a retina window draws the world at a fixed width, not at its pixels", () => { |
| 11 | const size = renderSize(3024, 1915, startLevel(1512)); | |
| 12 | expect(size.width).toBe(1280); | |
| 11 | const size = renderSize(3024, 1915, 0); | |
| 12 | expect(size.width).toBe(960); | |
| 13 | 13 | // The picture keeps the canvas's shape, so the blit only ever scales. |
| 14 | 14 | expect(size.height / size.width).toBeCloseTo(1915 / 3024, 3); |
| 15 | // Which is what the frame costs: rays are the width, and this is 2.4x fewer. | |
| 16 | expect(3024 / size.width).toBeGreaterThan(2.3); | |
| 15 | // Which is what the frame costs: rays come out of the width, and this is | |
| 16 | // three times fewer of them. | |
| 17 | expect(3024 / size.width).toBeGreaterThan(3); | |
| 17 | 18 | }); |
| 18 | 19 | |
| 19 | test("a narrower window starts narrower still", () => { | |
| 20 | expect(renderSize(1400, 887, startLevel(700)).width).toBe(960); | |
| 21 | expect(startLevel(1200)).toBe(0); | |
| 22 | expect(startLevel(1199)).toBe(2); | |
| 20 | test("every window starts at the same width: the canvas size is not the knob", () => { | |
| 21 | expect(renderSize(1400, 887, 0).width).toBe(960); | |
| 22 | expect(renderSize(2400, 1520, 0).width).toBe(960); | |
| 23 | expect(renderSize(5120, 3240, 0).width).toBe(960); | |
| 23 | 24 | }); |
| 24 | 25 | |
| 25 | 26 | test("the world is never drawn wider than the canvas has pixels for", () => { |
| 26 | 27 | // A phone: 360 CSS pixels at dpr 3 is a 1080px canvas, and a level whose |
| 27 | 28 | // rung is wider than that would be paying for columns nobody can see. |
| 28 | expect(renderSize(1080, 684, startLevel(360)).width).toBe(960); | |
| 29 | expect(renderSize(1080, 684, 0).width).toBe(960); | |
| 30 | expect(renderSize(720, 456, 0).width).toBe(720); | |
| 29 | 31 | expect(renderSize(600, 380, 0).width).toBe(600); |
| 30 | 32 | expect(renderSize(320, 203, 0).width).toBe(320); |
| 31 | 33 | }); |
src/scene.test.ts +25 −1
| @@ -6,7 +6,7 @@ | ||
| 6 | 6 | |
| 7 | 7 | import { expect, test } from "bun:test"; |
| 8 | 8 | import { drawGun, makeGun } from "../static/gun.js"; |
| 9 | import { GHOST, makePresence, shown, sighted } from "../static/presence.js"; | |
| 9 | import { GHOST, ghosts, makePresence, shown, sighted } from "../static/presence.js"; | |
| 10 | 10 | import { drawScene, makeScene } from "../static/scene.js"; |
| 11 | 11 | import { drawTopdown } from "../static/topdown.js"; |
| 12 | 12 | import { gridRows, TILE } from "./map.ts"; |
| @@ -296,6 +296,30 @@ test("a last-seen mark is an outline, dimming, and never a body", () => { | ||
| 296 | 296 | expect(GHOST).toBeGreaterThan(0); |
| 297 | 297 | }); |
| 298 | 298 | |
| 299 | test("a body that steps out of sight leaves the outline the store hands over", () => { | |
| 300 | const seen = makePresence(); | |
| 301 | const them = body(); | |
| 302 | sighted(seen, [them], 100); // in sight, in the open | |
| 303 | sighted(seen, [], 100.1); // and gone behind the corner | |
| 304 | const ctx = fakeContext(); | |
| 305 | drawScene(ctx as never, makeScene(), world, eye, snapshot([]), { | |
| 306 | ...view, | |
| 307 | marks: ghosts(seen, 100.1), | |
| 308 | }); | |
| 309 | // Nobody is in the snapshot at all, and there is still something there. | |
| 310 | const edge = ctx.ops.find((o) => o.op === "strokeRect"); | |
| 311 | expect(edge).toBeDefined(); | |
| 312 | expect(edge!.alpha).toBeCloseTo(1, 2); | |
| 313 | ||
| 314 | // A second later the store hands over nothing, and nothing is drawn. | |
| 315 | const after = fakeContext(); | |
| 316 | drawScene(after as never, makeScene(), world, eye, snapshot([]), { | |
| 317 | ...view, | |
| 318 | marks: ghosts(seen, 100.1 + GHOST), | |
| 319 | }); | |
| 320 | expect(after.ops.some((o) => o.op === "strokeRect")).toBe(false); | |
| 321 | }); | |
| 322 | ||
| 299 | 323 | test("a mark where the wall now stands between you and it is not drawn", () => { |
| 300 | 324 | const marks = [{ id: "them", x: 700, y: 200, angle: 0, mate: false, fade: 1 }]; |
| 301 | 325 | const ctx = fakeContext(); |
static/resolution.js +11 −12
| @@ -7,21 +7,20 @@ | ||
| 7 | 7 | * fills at every frame, sixty times a second, for a picture made of flat |
| 8 | 8 | * shaded columns that upscales almost invisibly. |
| 9 | 9 | * |
| 10 | * So the world is drawn into an offscreen canvas of a fixed width and | |
| 11 | * blitted up, and the HUD, the crosshair and the minimap stay on the visible | |
| 12 | * canvas at full resolution, where sharpness actually shows. | |
| 10 | * So the world is drawn into an offscreen canvas 960 columns wide however | |
| 11 | * big the window is, and blitted up; the HUD, the crosshair and the minimap | |
| 12 | * stay on the visible canvas at full resolution, where sharpness actually | |
| 13 | * shows. Fidelity is cheap here and frame rate is not: this game can be | |
| 14 | * stick figures if that is what runs. | |
| 13 | 15 | * |
| 14 | * The ladder is the knob the frame governor turns: one step coarser is one | |
| 15 | * step fewer rays. Nothing here touches what is sent, predicted or | |
| 16 | * simulated — it is a choice of how many columns to draw, out of the window | |
| 17 | * size and a level, and nothing else. | |
| 16 | * The ladder is the knob the frame governor turns: a rung coarser is about a | |
| 17 | * fifth fewer rays, and the rungs are in order of what they cost, so going | |
| 18 | * down one can never cost more. Nothing here touches what is sent, predicted | |
| 19 | * or simulated — it is a count of columns out of a canvas size and a level. | |
| 18 | 20 | */ |
| 19 | 21 | |
| 20 | /** Internal widths, finest first. A step is about a fifth of the rays. */ | |
| 21 | export const WIDTHS = [1280, 1080, 960, 800, 672, 544, 448]; | |
| 22 | ||
| 23 | /** The finest level a window of this many CSS pixels across starts at. */ | |
| 24 | export const startLevel = (cssWidth) => (cssWidth >= 1200 ? 0 : 2); | |
| 22 | /** Internal widths, finest first. The first rung is what every window gets. */ | |
| 23 | export const WIDTHS = [960, 800, 672, 544, 448]; | |
| 25 | 24 | |
| 26 | 25 | const clamp = (n, low, high) => (n < low ? low : n > high ? high : n); |
| 27 | 26 |
static/scene.js +5 −1
| @@ -108,7 +108,11 @@ function drawWalls(ctx, scene, step) { | ||
| 108 | 108 | export function drawScene(ctx, scene, world, eye, snap, view) { |
| 109 | 109 | const W = view.width; |
| 110 | 110 | const H = view.height; |
| 111 | const step = W > 1100 ? 2 : 1; | |
| 111 | // One ray every other column of the buffer, and the wall slice two columns | |
| 112 | // wide: flat shading does not reward a ray a pixel, and the sprites are | |
| 113 | // still drawn at the buffer's own resolution. Below that width a picture | |
| 114 | // this coarse stops being one. | |
| 115 | const step = W >= 640 ? 2 : 1; | |
| 112 | 116 | const columns = Math.ceil(W / step); |
| 113 | 117 | if (scene.depth.length !== columns) { |
| 114 | 118 | scene.depth = new Float32Array(columns); |
static/val.js +2 −4
| @@ -19,7 +19,7 @@ import { drawGun, gunSnapshot, gunTick, makeGun } from "./gun.js"; | ||
| 19 | 19 | import { advance, anchor, holdBack, insideWall, makeLocal, project, reconcile } from "./predict.js"; |
| 20 | 20 | import { forget, ghosts, makePresence, shown, sighted, slide } from "./presence.js"; |
| 21 | 21 | import { choosePrompt, REACH } from "./prompt.js"; |
| 22 | import { renderSize, startLevel, WIDTHS } from "./resolution.js"; | |
| 22 | import { renderSize, WIDTHS } from "./resolution.js"; | |
| 23 | 23 | import { roster, rosterKey } from "./roster.js"; |
| 24 | 24 | import { AGENT_COLOUR, drawScene, makeScene } from "./scene.js"; |
| 25 | 25 | import { moveVector } from "./steer.js"; |
| @@ -76,7 +76,7 @@ let snapAt = last; // when the last snapshot landed: how stale the answer is | ||
| 76 | 76 | // How many columns the world is drawn at, and what the frame is costing. |
| 77 | 77 | // See resolution.js for the ladder and budget.js for the rung it settles on: |
| 78 | 78 | // the visible canvas is device pixels, this is not. |
| 79 | const budget = makeBudget(startLevel(900), WIDTHS.length - 1); | |
| 79 | const budget = makeBudget(0, WIDTHS.length - 1); | |
| 80 | 80 | let render = { width: 960, height: 600 }; |
| 81 | 81 | let perfOn = false; |
| 82 | 82 | let perfAt = 0; |
| @@ -320,8 +320,6 @@ function resize() { | ||
| 320 | 320 | const dpr = Math.min(devicePixelRatio || 1, 2); |
| 321 | 321 | view.width = Math.round(box.width * dpr); |
| 322 | 322 | view.height = Math.round(box.height * dpr); |
| 323 | budget.base = startLevel(box.width); | |
| 324 | if (budget.level < budget.base) budget.level = budget.base; | |
| 325 | 323 | sizeRender(); |
| 326 | 324 | } |
| 327 | 325 | addEventListener("resize", resize); |