benjivers/val Public Public: anyone can read this; the crew can change it.

Draw the world at a fixed width and blit it up

resize() sized the canvas at the device pixel ratio, so a 1512pt retina
window was a 3024px canvas and the ray loop marched 1512 columns a frame for
a picture made of flat shaded rectangles. The screen's pixel count is not
the picture's.

The world is drawn into an offscreen canvas 1280 columns wide on a wide
window and 960 otherwise — never wider than the canvas actually has pixels
for — and drawn onto the visible canvas scaled. The crosshair, the HUD bars
and the minimap stay on the visible canvas at full device resolution, where
sharpness is what you are looking at. On that retina window it is 2.4x fewer
rays and 2.4x fewer fills; nothing about what is sent, predicted or
simulated moves.

resolution.js is the size, out of the window and a level, and nothing else:
the level is the knob the frame governor is about to turn.

Co-Authored-By: Claude Opus 5 <[email protected]>
Wren committed 01f4d8d Browse files
4 files changed +133 −3
src/client.test.ts +15
@@ -247,6 +247,21 @@ test("the bundled client connects, paints the HUD and draws a frame", async () =
247247 drawn.length = 0;
248248 runFrame();
249249 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({});
250265
251266 // Holding W reaches the server as a direction, and it is the way we face.
252267 const seat = (await import("./arena.ts")).match().players.find((p) => !p.bot)!;
src/resolution.test.ts added +50
@@ -0,0 +1,50 @@
1 /**
2 * The internal render size, as arithmetic. A retina window is the case that
3 * started this: 1512 CSS pixels across is a 3024px canvas, and the renderer
4 * was marching a ray for every other one of them.
5 */
6
7 import { expect, test } from "bun:test";
8 import { renderSize, startLevel, WIDTHS } from "../static/resolution.js";
9
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);
13 // The picture keeps the canvas's shape, so the blit only ever scales.
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);
17 });
18
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);
23 });
24
25 test("the world is never drawn wider than the canvas has pixels for", () => {
26 // A phone: 360 CSS pixels at dpr 3 is a 1080px canvas, and a level whose
27 // 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(600, 380, 0).width).toBe(600);
30 expect(renderSize(320, 203, 0).width).toBe(320);
31 });
32
33 test("every rung is coarser than the one before, and the ladder ends", () => {
34 const widths = WIDTHS.map((_, i) => renderSize(4000, 2500, i).width);
35 expect(widths).toEqual(WIDTHS);
36 for (let i = 1; i < widths.length; i++) expect(widths[i]!).toBeLessThan(widths[i - 1]!);
37 // Past the end it holds at the coarsest rung rather than vanishing.
38 expect(renderSize(4000, 2500, 99).width).toBe(WIDTHS[WIDTHS.length - 1]);
39 expect(renderSize(4000, 2500, -3).width).toBe(WIDTHS[0]);
40 });
41
42 test("a size is always drawable: no zero, no NaN, whatever it is handed", () => {
43 for (const [w, h] of [[0, 0], [NaN, 100], [-10, -10], [10, 0]] as [number, number][]) {
44 const size = renderSize(w, h, 0);
45 expect(Number.isFinite(size.width)).toBe(true);
46 expect(Number.isFinite(size.height)).toBe(true);
47 expect(size.width).toBeGreaterThan(0);
48 expect(size.height).toBeGreaterThan(0);
49 }
50 });
static/resolution.js added +42
@@ -0,0 +1,42 @@
1 /**
2 * How big the world is drawn, before it is scaled onto the screen.
3 *
4 * A raycaster does not need device pixels. Every column is a ray marched
5 * through the grid, so the cost of the frame is the width of the canvas, and
6 * a retina window asks for three thousand of them — 1512 rays and as many
7 * fills at every frame, sixty times a second, for a picture made of flat
8 * shaded columns that upscales almost invisibly.
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.
13 *
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.
18 */
19
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);
25
26 const clamp = (n, low, high) => (n < low ? low : n > high ? high : n);
27
28 /**
29 * The size to draw the world at: never wider than the canvas actually has
30 * pixels for — upscaling a 448px render onto a 360px phone would be paying
31 * for columns nobody can see — and never so small it stops being a picture.
32 * The aspect is the canvas's own, so the blit only ever scales.
33 */
34 export function renderSize(deviceWidth, deviceHeight, level) {
35 const want = WIDTHS[clamp(level | 0, 0, WIDTHS.length - 1)];
36 const width = Math.max(240, Math.min(want, Math.round(deviceWidth) || 240));
37 const height = Math.max(
38 1,
39 Math.round(width * (deviceHeight > 0 && deviceWidth > 0 ? deviceHeight / deviceWidth : 0.6)),
40 );
41 return { width, height };
42 }
static/val.js +26 −3
@@ -17,6 +17,7 @@ import { BODY_R, moveSpeed } from "../src/glide.ts";
1717 import { drawGun, gunSnapshot, gunTick, makeGun } from "./gun.js";
1818 import { advance, anchor, holdBack, insideWall, makeLocal, project, reconcile } from "./predict.js";
1919 import { choosePrompt, REACH } from "./prompt.js";
20 import { renderSize, startLevel } from "./resolution.js";
2021 import { roster, rosterKey } from "./roster.js";
2122 import { AGENT_COLOUR, drawScene, makeScene } from "./scene.js";
2223 import { moveVector } from "./steer.js";
@@ -26,6 +27,11 @@ import { aim as aimAt, makeYaw, settle, told, turn } from "./yaw.js";
2627 const $ = (id) => document.getElementById(id);
2728 const view = $("view");
2829 const ctx = view.getContext("2d");
30 // The world is drawn here, at a few hundred columns, and blitted onto the
31 // visible canvas scaled. The HUD, the crosshair and the minimap are drawn on
32 // `view` itself, at whatever the device pixel ratio really is.
33 const sceneCanvas = document.createElement("canvas");
34 const sceneCtx = sceneCanvas.getContext("2d");
2935
3036 const KEY_SLOT = { KeyC: "c", KeyQ: "q", KeyE: "e", KeyX: "x" };
3137 const COARSE = matchMedia("(pointer: coarse)").matches;
@@ -58,6 +64,10 @@ let aimTouch = null;
5864 let helpShown = false;
5965 let last = performance.now();
6066 let snapAt = last; // when the last snapshot landed: how stale the answer is
67 // How many columns the world is drawn at, and which rung of the ladder that
68 // is. See resolution.js: the visible canvas is device pixels, this is not.
69 let level = 0;
70 let render = { width: 960, height: 600 };
6171
6272 const walk = { x: 0, y: 0 };
6373 // What this person is doing right now, read once a frame for the drawing and
@@ -289,9 +299,18 @@ function resize() {
289299 const dpr = Math.min(devicePixelRatio || 1, 2);
290300 view.width = Math.round(box.width * dpr);
291301 view.height = Math.round(box.height * dpr);
302 level = startLevel(box.width);
303 sizeRender();
292304 }
293305 addEventListener("resize", resize);
294306
307 /** The offscreen the world is drawn into, at the level in force right now. */
308 function sizeRender() {
309 render = renderSize(view.width, view.height, level);
310 if (sceneCanvas.width !== render.width) sceneCanvas.width = render.width;
311 if (sceneCanvas.height !== render.height) sceneCanvas.height = render.height;
312 }
313
295314 const scale = () => (map ? view.width / (map.rows[0].length * map.tile) : 1);
296315 const worldToScreen = (p) => ({ x: p.x * scale(), y: p.y * scale() });
297316
@@ -365,11 +384,15 @@ function frame(now) {
365384 const facing = mode === "fp" ? head.angle : eye.angle;
366385
367386 if (mode === "fp" && !mapOpen) {
368 drawScene(ctx, scene, { solid, tile: map.tile }, { x: eye.x, y: eye.y, angle: facing }, snap, {
369 width: view.width,
370 height: view.height,
387 // The world at a few hundred columns, then scaled onto the canvas. The
388 // walls are flat shaded, so the upscale costs the picture almost
389 // nothing; the crosshair and the map below are drawn sharp on top.
390 drawScene(sceneCtx, scene, { solid, tile: map.tile }, { x: eye.x, y: eye.y, angle: facing }, snap, {
391 width: render.width,
392 height: render.height,
371393 look,
372394 });
395 ctx.drawImage(sceneCanvas, 0, 0, view.width, view.height);
373396 drawGun(ctx, gun, view.width, view.height, snap);
374397 drawMinimap();
375398 } else {
↑↓ navigate ↵ open esc close

Keyboard shortcuts