rook/val Public Public: anyone can read this; nobody can change it.
forked from benjivers/val
workbenchthis machine
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]>
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 () = | ||
| 247 | 247 | drawn.length = 0; |
| 248 | 248 | runFrame(); |
| 249 | 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({}); | |
| 250 | 265 | |
| 251 | 266 | // Holding W reaches the server as a direction, and it is the way we face. |
| 252 | 267 | 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"; | ||
| 17 | 17 | import { drawGun, gunSnapshot, gunTick, makeGun } from "./gun.js"; |
| 18 | 18 | import { advance, anchor, holdBack, insideWall, makeLocal, project, reconcile } from "./predict.js"; |
| 19 | 19 | import { choosePrompt, REACH } from "./prompt.js"; |
| 20 | import { renderSize, startLevel } from "./resolution.js"; | |
| 20 | 21 | import { roster, rosterKey } from "./roster.js"; |
| 21 | 22 | import { AGENT_COLOUR, drawScene, makeScene } from "./scene.js"; |
| 22 | 23 | import { moveVector } from "./steer.js"; |
| @@ -26,6 +27,11 @@ import { aim as aimAt, makeYaw, settle, told, turn } from "./yaw.js"; | ||
| 26 | 27 | const $ = (id) => document.getElementById(id); |
| 27 | 28 | const view = $("view"); |
| 28 | 29 | 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"); | |
| 29 | 35 | |
| 30 | 36 | const KEY_SLOT = { KeyC: "c", KeyQ: "q", KeyE: "e", KeyX: "x" }; |
| 31 | 37 | const COARSE = matchMedia("(pointer: coarse)").matches; |
| @@ -58,6 +64,10 @@ let aimTouch = null; | ||
| 58 | 64 | let helpShown = false; |
| 59 | 65 | let last = performance.now(); |
| 60 | 66 | 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 }; | |
| 61 | 71 | |
| 62 | 72 | const walk = { x: 0, y: 0 }; |
| 63 | 73 | // What this person is doing right now, read once a frame for the drawing and |
| @@ -289,9 +299,18 @@ function resize() { | ||
| 289 | 299 | const dpr = Math.min(devicePixelRatio || 1, 2); |
| 290 | 300 | view.width = Math.round(box.width * dpr); |
| 291 | 301 | view.height = Math.round(box.height * dpr); |
| 302 | level = startLevel(box.width); | |
| 303 | sizeRender(); | |
| 292 | 304 | } |
| 293 | 305 | addEventListener("resize", resize); |
| 294 | 306 | |
| 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 | ||
| 295 | 314 | const scale = () => (map ? view.width / (map.rows[0].length * map.tile) : 1); |
| 296 | 315 | const worldToScreen = (p) => ({ x: p.x * scale(), y: p.y * scale() }); |
| 297 | 316 | |
| @@ -365,11 +384,15 @@ function frame(now) { | ||
| 365 | 384 | const facing = mode === "fp" ? head.angle : eye.angle; |
| 366 | 385 | |
| 367 | 386 | 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, | |
| 371 | 393 | look, |
| 372 | 394 | }); |
| 395 | ctx.drawImage(sceneCanvas, 0, 0, view.width, view.height); | |
| 373 | 396 | drawGun(ctx, gun, view.width, view.height, snap); |
| 374 | 397 | drawMinimap(); |
| 375 | 398 | } else { |