pike/val Public Public: anyone can read this; nobody can change it.

forked from benjivers/val

workbenchthis machine

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]>
Wren committed 4990f87 Browse files
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 @@
55 */
66
77 import { expect, test } from "bun:test";
8 import { renderSize, startLevel, WIDTHS } from "../static/resolution.js";
8 import { renderSize, WIDTHS } from "../static/resolution.js";
99
1010 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);
1313 // The picture keeps the canvas's shape, so the blit only ever scales.
1414 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);
1718 });
1819
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);
2324 });
2425
2526 test("the world is never drawn wider than the canvas has pixels for", () => {
2627 // A phone: 360 CSS pixels at dpr 3 is a 1080px canvas, and a level whose
2728 // 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);
2931 expect(renderSize(600, 380, 0).width).toBe(600);
3032 expect(renderSize(320, 203, 0).width).toBe(320);
3133 });
src/scene.test.ts +25 −1
@@ -6,7 +6,7 @@
66
77 import { expect, test } from "bun:test";
88 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";
1010 import { drawScene, makeScene } from "../static/scene.js";
1111 import { drawTopdown } from "../static/topdown.js";
1212 import { gridRows, TILE } from "./map.ts";
@@ -296,6 +296,30 @@ test("a last-seen mark is an outline, dimming, and never a body", () => {
296296 expect(GHOST).toBeGreaterThan(0);
297297 });
298298
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
299323 test("a mark where the wall now stands between you and it is not drawn", () => {
300324 const marks = [{ id: "them", x: 700, y: 200, angle: 0, mate: false, fade: 1 }];
301325 const ctx = fakeContext();
static/resolution.js +11 −12
@@ -7,21 +7,20 @@
77 * fills at every frame, sixty times a second, for a picture made of flat
88 * shaded columns that upscales almost invisibly.
99 *
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.
1315 *
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.
1820 */
1921
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];
2524
2625 const clamp = (n, low, high) => (n < low ? low : n > high ? high : n);
2726
static/scene.js +5 −1
@@ -108,7 +108,11 @@ function drawWalls(ctx, scene, step) {
108108 export function drawScene(ctx, scene, world, eye, snap, view) {
109109 const W = view.width;
110110 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;
112116 const columns = Math.ceil(W / step);
113117 if (scene.depth.length !== columns) {
114118 scene.depth = new Float32Array(columns);
static/val.js +2 −4
@@ -19,7 +19,7 @@ import { drawGun, gunSnapshot, gunTick, makeGun } from "./gun.js";
1919 import { advance, anchor, holdBack, insideWall, makeLocal, project, reconcile } from "./predict.js";
2020 import { forget, ghosts, makePresence, shown, sighted, slide } from "./presence.js";
2121 import { choosePrompt, REACH } from "./prompt.js";
22 import { renderSize, startLevel, WIDTHS } from "./resolution.js";
22 import { renderSize, WIDTHS } from "./resolution.js";
2323 import { roster, rosterKey } from "./roster.js";
2424 import { AGENT_COLOUR, drawScene, makeScene } from "./scene.js";
2525 import { moveVector } from "./steer.js";
@@ -76,7 +76,7 @@ let snapAt = last; // when the last snapshot landed: how stale the answer is
7676 // How many columns the world is drawn at, and what the frame is costing.
7777 // See resolution.js for the ladder and budget.js for the rung it settles on:
7878 // 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);
8080 let render = { width: 960, height: 600 };
8181 let perfOn = false;
8282 let perfAt = 0;
@@ -320,8 +320,6 @@ function resize() {
320320 const dpr = Math.min(devicePixelRatio || 1, 2);
321321 view.width = Math.round(box.width * dpr);
322322 view.height = Math.round(box.height * dpr);
323 budget.base = startLevel(box.width);
324 if (budget.level < budget.base) budget.level = budget.base;
325323 sizeRender();
326324 }
327325 addEventListener("resize", resize);
↑↓ navigate ↵ open esc close

Keyboard shortcuts