wren/val Public Public: anyone can read this; nobody can change it.
forked from benjivers/val
workbenchthis machine
One fillRect a run of wall, not one a column
The ray loop drew a rectangle per screen column: on a retina canvas that is 1512 fills a frame for the walls alone, most of them the same rectangle as their neighbour, because the shading is quantised into 48 levels already. The loop now writes a level, a face, a slice and the haze over it into column arrays and draws nothing; slices.js walks those once and hands back the runs where all four agree, so a flat wall face is one fill however wide it is. Same picture — the runs tile the width end to end, which the scene test now checks — for about two thirds of the fills across the real map, and far fewer than that looking down a corridor. The scene test's old assertion counted one fill a column. That was a description of the implementation, not of the picture, so it is replaced by one about coverage. Co-Authored-By: Claude Opus 5 <[email protected]>
4 files
changed +275
−21
src/scene.test.ts +18 −3
| @@ -88,12 +88,27 @@ const body = (over: Record<string, unknown> = {}) => ({ | ||
| 88 | 88 | const view = { width: 900, height: 570, look: (p: { x: number; y: number; angle: number }) => p }; |
| 89 | 89 | const eye = { x: 120, y: 200, angle: 0 }; |
| 90 | 90 | |
| 91 | test("a frame draws walls: one filled column per screen column, and the horizon", () => { | |
| 91 | test("a frame draws walls in runs: the whole width covered, far fewer fills", () => { | |
| 92 | 92 | const ctx = fakeContext(); |
| 93 | 93 | drawScene(ctx as never, makeScene(), world, eye, snapshot([]), view); |
| 94 | 94 | const rects = ctx.ops.filter((o) => o.op === "fillRect"); |
| 95 | // The two bands and the horizon, then a slice for every column of the wall. | |
| 96 | expect(rects.length).toBeGreaterThan(800); | |
| 95 | // The bands are the full width; the rest are wall runs. | |
| 96 | const walls = rects | |
| 97 | .map((o) => ({ x: Number(o.args[0]), w: Number(o.args[2]) })) | |
| 98 | .filter((r) => r.w < view.width) | |
| 99 | .sort((a, b) => a.x - b.x); | |
| 100 | // This room is closed, so every column of the picture is wall: the runs | |
| 101 | // start at the left edge, meet end to end and reach the right one. That is | |
| 102 | // the same picture the old one-fill-a-column loop drew. | |
| 103 | expect(walls[0]!.x).toBe(0); | |
| 104 | let reach = 0; | |
| 105 | for (const rect of walls) { | |
| 106 | expect(rect.x).toBeLessThanOrEqual(reach); | |
| 107 | reach = Math.max(reach, rect.x + rect.w); | |
| 108 | } | |
| 109 | expect(reach).toBeGreaterThanOrEqual(view.width); | |
| 110 | // And it costs a fraction of the fills: one a column would be 900. | |
| 111 | expect(walls.length).toBeLessThan(view.width / 2); | |
| 97 | 112 | }); |
| 98 | 113 | |
| 99 | 114 | test("a body in the open is drawn; the same body behind the pillar is not", () => { |
src/slices.test.ts added +116
| @@ -0,0 +1,116 @@ | ||
| 1 | /** | |
| 2 | * The wall batching, as arithmetic: columns in, runs out. What matters is | |
| 3 | * that the runs say the same thing the columns did — every column covered | |
| 4 | * exactly once, in order — while there are far fewer of them. | |
| 5 | */ | |
| 6 | ||
| 7 | import { expect, test } from "bun:test"; | |
| 8 | import { covered, GEOM, hazeStep, makeColumns, SKY, wallRuns } from "../static/slices.js"; | |
| 9 | ||
| 10 | type Col = { level: number; side?: number; top?: number; high?: number; haze?: number }; | |
| 11 | ||
| 12 | /** Columns from a list, so a case reads as the picture it describes. */ | |
| 13 | function columns(list: Col[]) { | |
| 14 | const cols = makeColumns(list.length); | |
| 15 | list.forEach((c, i) => { | |
| 16 | cols.level[i] = c.level; | |
| 17 | cols.side[i] = c.side ?? 0; | |
| 18 | cols.top[i] = c.top ?? 100; | |
| 19 | cols.high[i] = c.high ?? 200; | |
| 20 | cols.haze[i] = c.haze ?? 0; | |
| 21 | }); | |
| 22 | return cols; | |
| 23 | } | |
| 24 | ||
| 25 | const flat = (n: number, over: Col = { level: 7 }) => columns(Array.from({ length: n }, () => over)); | |
| 26 | ||
| 27 | test("a run of equal levels is one rectangle, however long it is", () => { | |
| 28 | const out: never[] = []; | |
| 29 | const used = wallRuns(flat(1280), out); | |
| 30 | expect(used).toBe(1); | |
| 31 | expect(out[0]).toMatchObject({ from: 0, to: 1279, level: 7 }); | |
| 32 | }); | |
| 33 | ||
| 34 | test("the picture's column count is unchanged: every column covered once", () => { | |
| 35 | const cols = columns([ | |
| 36 | { level: 3 }, | |
| 37 | { level: 3 }, | |
| 38 | { level: 4 }, | |
| 39 | { level: 4 }, | |
| 40 | { level: 4 }, | |
| 41 | { level: 9, side: 1 }, | |
| 42 | ]); | |
| 43 | const out: { from: number; to: number }[] = []; | |
| 44 | const used = wallRuns(cols, out); | |
| 45 | expect(used).toBe(3); | |
| 46 | expect(covered(out, used)).toBe(6); | |
| 47 | // In order, end to end, no gaps and no overlap. | |
| 48 | expect(out.slice(0, used).map((r) => [r.from, r.to])).toEqual([ | |
| 49 | [0, 1], | |
| 50 | [2, 4], | |
| 51 | [5, 5], | |
| 52 | ]); | |
| 53 | }); | |
| 54 | ||
| 55 | test("each column its own level is one rectangle each: no picture is lost", () => { | |
| 56 | const cols = columns(Array.from({ length: 40 }, (_, i) => ({ level: i }))); | |
| 57 | const out: never[] = []; | |
| 58 | const used = wallRuns(cols, out); | |
| 59 | expect(used).toBe(40); | |
| 60 | expect(covered(out, used)).toBe(40); | |
| 61 | }); | |
| 62 | ||
| 63 | test("the same level on a different face is a different run", () => { | |
| 64 | const cols = columns([{ level: 5, side: 0 }, { level: 5, side: 1 }, { level: 5, side: 1 }]); | |
| 65 | const out: { side: number }[] = []; | |
| 66 | const used = wallRuns(cols, out); | |
| 67 | expect(used).toBe(2); | |
| 68 | expect(out[1]!.side).toBe(1); | |
| 69 | }); | |
| 70 | ||
| 71 | test("sky ends a run and is covered by nothing", () => { | |
| 72 | const cols = columns([{ level: 2 }, { level: SKY }, { level: 2 }, { level: 2 }]); | |
| 73 | const out: { from: number; to: number }[] = []; | |
| 74 | const used = wallRuns(cols, out); | |
| 75 | expect(used).toBe(2); | |
| 76 | expect(covered(out, used)).toBe(3); | |
| 77 | expect(out[1]).toMatchObject({ from: 2, to: 3 }); | |
| 78 | }); | |
| 79 | ||
| 80 | test("slices that differ in height by more than half a pixel do not merge", () => { | |
| 81 | const near = columns([ | |
| 82 | { level: 6, top: 100, high: 200 }, | |
| 83 | { level: 6, top: 100 + GEOM / 2, high: 200 }, | |
| 84 | ]); | |
| 85 | const far = columns([ | |
| 86 | { level: 6, top: 100, high: 200 }, | |
| 87 | { level: 6, top: 100, high: 200 + GEOM * 4 }, | |
| 88 | ]); | |
| 89 | expect(wallRuns(near, [])).toBe(1); | |
| 90 | expect(wallRuns(far, [])).toBe(2); | |
| 91 | }); | |
| 92 | ||
| 93 | test("haze joins the run's identity, quantised, so fog is batched too", () => { | |
| 94 | expect(hazeStep(0)).toBe(0); | |
| 95 | expect(hazeStep(1)).toBe(16); | |
| 96 | expect(hazeStep(2)).toBe(16); // clamped: nothing is fogged past opaque | |
| 97 | const cols = columns([ | |
| 98 | { level: 6, haze: 0.5 }, | |
| 99 | { level: 6, haze: 0.51 }, // the same step: one rectangle | |
| 100 | { level: 6, haze: 0.9 }, | |
| 101 | ]); | |
| 102 | const out: { haze: number }[] = []; | |
| 103 | const used = wallRuns(cols, out); | |
| 104 | expect(used).toBe(2); | |
| 105 | expect(out[0]!.haze).toBeCloseTo(0.5, 5); | |
| 106 | }); | |
| 107 | ||
| 108 | test("the run buffer is reused: a smaller frame allocates nothing new", () => { | |
| 109 | const out: unknown[] = []; | |
| 110 | wallRuns(columns(Array.from({ length: 30 }, (_, i) => ({ level: i }))), out); | |
| 111 | const grown = out.length; | |
| 112 | expect(grown).toBe(30); | |
| 113 | const used = wallRuns(flat(30), out); | |
| 114 | expect(used).toBe(1); | |
| 115 | expect(out.length).toBe(grown); | |
| 116 | }); |
static/scene.js +47 −18
| @@ -7,11 +7,15 @@ | ||
| 7 | 7 | * drawn at all. Smoke is measured along each ray, not painted as a disc: |
| 8 | 8 | * cross one and everything past it fogs. |
| 9 | 9 | * |
| 10 | * The ray loop allocates nothing. Colours come out of a table built once, | |
| 11 | * the depth and fog buffers are kept between frames, and the sprite list is | |
| 12 | * sorted in a buffer the scene owns. | |
| 10 | * The ray loop allocates nothing and draws nothing: it writes a shade level, | |
| 11 | * a face and a slice into the column arrays, and `slices.js` collapses those | |
| 12 | * into runs so a flat wall costs one `fillRect` and not one a column. | |
| 13 | * Colours come out of a table built once, the depth and column buffers are | |
| 14 | * kept between frames, and the sprite list is sorted in a buffer the scene | |
| 15 | * owns. | |
| 13 | 16 | */ |
| 14 | 17 | |
| 18 | import { makeColumns, SKY, wallRuns } from "./slices.js"; | |
| 15 | 19 | import { |
| 16 | 20 | castRay, |
| 17 | 21 | columnOffset, |
| @@ -55,7 +59,8 @@ const FOG = "rgb(198,203,212)"; | ||
| 55 | 59 | export function makeScene() { |
| 56 | 60 | return { |
| 57 | 61 | depth: new Float32Array(0), |
| 58 | fog: new Float32Array(0), | |
| 62 | cols: makeColumns(0), | |
| 63 | runs: [], | |
| 59 | 64 | hit: hitResult(), |
| 60 | 65 | proj: projectResult(), |
| 61 | 66 | order: [], |
| @@ -65,7 +70,31 @@ export function makeScene() { | ||
| 65 | 70 | }; |
| 66 | 71 | } |
| 67 | 72 | |
| 68 | const shadeOf = (light) => SHADES[Math.min(LEVELS - 1, Math.max(0, (light * LEVELS) | 0))]; | |
| 73 | const levelOf = (light) => Math.min(LEVELS - 1, Math.max(0, (light * LEVELS) | 0)); | |
| 74 | ||
| 75 | /** | |
| 76 | * The walls, a rectangle a run rather than a rectangle a column. The runs | |
| 77 | * are stretches where the shade level, the face, the fog and the geometry | |
| 78 | * all agree — see `slices.js` — so this is the same picture the per-column | |
| 79 | * loop drew, in a fraction of the calls. | |
| 80 | */ | |
| 81 | function drawWalls(ctx, scene, step) { | |
| 82 | const runs = scene.runs; | |
| 83 | const used = wallRuns(scene.cols, runs); | |
| 84 | for (let r = 0; r < used; r++) { | |
| 85 | const run = runs[r]; | |
| 86 | const x = run.from * step; | |
| 87 | const wide = (run.to - run.from + 1) * step + 1; | |
| 88 | ctx.fillStyle = SHADES[run.level]; | |
| 89 | ctx.fillRect(x, run.top, wide, run.high); | |
| 90 | if (run.haze > 0.01) { | |
| 91 | ctx.globalAlpha = run.haze; | |
| 92 | ctx.fillStyle = FOG; | |
| 93 | ctx.fillRect(x, run.top, wide, run.high); | |
| 94 | ctx.globalAlpha = 1; | |
| 95 | } | |
| 96 | } | |
| 97 | } | |
| 69 | 98 | |
| 70 | 99 | /** |
| 71 | 100 | * Draw one frame. |
| @@ -82,7 +111,7 @@ export function drawScene(ctx, scene, world, eye, snap, view) { | ||
| 82 | 111 | const columns = Math.ceil(W / step); |
| 83 | 112 | if (scene.depth.length !== columns) { |
| 84 | 113 | scene.depth = new Float32Array(columns); |
| 85 | scene.fog = new Float32Array(columns); | |
| 114 | scene.cols = makeColumns(columns); | |
| 86 | 115 | } |
| 87 | 116 | const proj = projection(W); |
| 88 | 117 | const horizon = H / 2; |
| @@ -94,7 +123,8 @@ export function drawScene(ctx, scene, world, eye, snap, view) { | ||
| 94 | 123 | for (const zone of snap.zones) if ((zone.k === "smoke" || zone.k === "wall") && zone.f <= 0) smokes.push(zone); |
| 95 | 124 | |
| 96 | 125 | const depth = scene.depth; |
| 97 | const fog = scene.fog; | |
| 126 | const cols = scene.cols; | |
| 127 | const fog = cols.haze; | |
| 98 | 128 | const hit = scene.hit; |
| 99 | 129 | for (let i = 0; i < columns; i++) { |
| 100 | 130 | const offset = columnOffset(i * step + step / 2, W); |
| @@ -114,18 +144,17 @@ export function drawScene(ctx, scene, world, eye, snap, view) { | ||
| 114 | 144 | haze = Math.min(1, haze); |
| 115 | 145 | } |
| 116 | 146 | fog[i] = haze; |
| 117 | if (!hit.wall) continue; | |
| 118 | const h = Math.min(H * 6, sliceHeight(perp, proj, WALL)); | |
| 119 | const top = horizon - h / 2; | |
| 120 | ctx.fillStyle = shadeOf(wallLight(perp, hit.side, world.tile)); | |
| 121 | ctx.fillRect(i * step, top, step + 1, h); | |
| 122 | if (haze > 0.01) { | |
| 123 | ctx.globalAlpha = haze; | |
| 124 | ctx.fillStyle = FOG; | |
| 125 | ctx.fillRect(i * step, top, step + 1, h); | |
| 126 | ctx.globalAlpha = 1; | |
| 147 | if (!hit.wall) { | |
| 148 | cols.level[i] = SKY; | |
| 149 | continue; | |
| 127 | 150 | } |
| 151 | const h = Math.min(H * 6, sliceHeight(perp, proj, WALL)); | |
| 152 | cols.level[i] = levelOf(wallLight(perp, hit.side, world.tile)); | |
| 153 | cols.side[i] = hit.side; | |
| 154 | cols.top[i] = horizon - h / 2; | |
| 155 | cols.high[i] = h; | |
| 128 | 156 | } |
| 157 | drawWalls(ctx, scene, step); | |
| 129 | 158 | |
| 130 | 159 | drawThings(ctx, scene, world, eye, snap, view, proj, horizon, step, columns); |
| 131 | 160 | } |
| @@ -199,7 +228,7 @@ function drawThings(ctx, scene, world, eye, snap, view, proj, horizon, step, col | ||
| 199 | 228 | |
| 200 | 229 | const hazeAt = (scene, column, step, columns) => { |
| 201 | 230 | const i = Math.max(0, Math.min(columns - 1, Math.round(column / step))); |
| 202 | return scene.fog[i] ?? 0; | |
| 231 | return scene.cols.haze[i] ?? 0; | |
| 203 | 232 | }; |
| 204 | 233 | |
| 205 | 234 | /** |
static/slices.js added +94
| @@ -0,0 +1,94 @@ | ||
| 1 | /** | |
| 2 | * Wall columns, collapsed into runs. | |
| 3 | * | |
| 4 | * A raycaster produces one vertical slice per screen column, and the naive | |
| 5 | * renderer pays one `fillRect` for each of them — a couple of thousand fills | |
| 6 | * a frame on a wide canvas, most of them identical to their neighbour. The | |
| 7 | * shading is quantised into levels already (`scene.js`), so a flat wall face | |
| 8 | * is a stretch of columns asking for exactly the same rectangle, drawn one | |
| 9 | * pixel at a time. | |
| 10 | * | |
| 11 | * This walks the columns once and hands back the runs: the stretches where | |
| 12 | * the level, the face, the haze over it and the geometry all agree, so the | |
| 13 | * caller draws one rectangle for the stretch. A corridor collapses hundreds | |
| 14 | * of fills into a handful and the picture does not change. | |
| 15 | * | |
| 16 | * Pure data in, pure data out — no canvas here — and nothing is allocated | |
| 17 | * per frame: the column arrays and the run objects are kept and rewritten. | |
| 18 | */ | |
| 19 | ||
| 20 | /** A level of -1 is sky: no wall was struck, and no run covers it. */ | |
| 21 | export const SKY = -1; | |
| 22 | /** How far apart two slices may sit and still be drawn as one rectangle. */ | |
| 23 | export const GEOM = 0.5; | |
| 24 | /** Haze is quantised this finely before it joins the run's identity. */ | |
| 25 | export const HAZE_STEPS = 16; | |
| 26 | ||
| 27 | /** The columns of one frame: what the ray loop writes, all of it flat. */ | |
| 28 | export function makeColumns(count) { | |
| 29 | return { | |
| 30 | count, | |
| 31 | level: new Int16Array(count).fill(SKY), | |
| 32 | side: new Int8Array(count), | |
| 33 | top: new Float32Array(count), | |
| 34 | high: new Float32Array(count), | |
| 35 | haze: new Float32Array(count), | |
| 36 | }; | |
| 37 | } | |
| 38 | ||
| 39 | const run = () => ({ from: 0, to: 0, level: SKY, side: 0, top: 0, high: 0, step: 0, haze: 0 }); | |
| 40 | ||
| 41 | /** The quantised haze of a column: equal steps draw as one rectangle. */ | |
| 42 | export const hazeStep = (haze) => Math.round(Math.min(1, Math.max(0, haze)) * HAZE_STEPS); | |
| 43 | ||
| 44 | /** | |
| 45 | * Write the runs of `cols` into `out`, growing it as needed, and return how | |
| 46 | * many of its entries are this frame's. `out[i].from` and `.to` are column | |
| 47 | * indices, both inclusive. | |
| 48 | */ | |
| 49 | export function wallRuns(cols, out) { | |
| 50 | let used = 0; | |
| 51 | let open = false; // is out[used - 1] still growing, or did sky end it? | |
| 52 | for (let i = 0; i < cols.count; i++) { | |
| 53 | const level = cols.level[i]; | |
| 54 | if (level === SKY) { | |
| 55 | open = false; | |
| 56 | continue; | |
| 57 | } | |
| 58 | const step = hazeStep(cols.haze[i]); | |
| 59 | const top = cols.top[i]; | |
| 60 | const high = cols.high[i]; | |
| 61 | const last = used > 0 ? out[used - 1] : null; | |
| 62 | if ( | |
| 63 | open && | |
| 64 | last.level === level && | |
| 65 | last.side === cols.side[i] && | |
| 66 | last.step === step && | |
| 67 | Math.abs(last.top - top) <= GEOM && | |
| 68 | Math.abs(last.high - high) <= GEOM | |
| 69 | ) { | |
| 70 | last.to = i; | |
| 71 | continue; | |
| 72 | } | |
| 73 | if (out.length <= used) out.push(run()); | |
| 74 | const fresh = out[used]; | |
| 75 | fresh.from = i; | |
| 76 | fresh.to = i; | |
| 77 | fresh.level = level; | |
| 78 | fresh.side = cols.side[i]; | |
| 79 | fresh.top = top; | |
| 80 | fresh.high = high; | |
| 81 | fresh.step = step; | |
| 82 | fresh.haze = step / HAZE_STEPS; | |
| 83 | used++; | |
| 84 | open = true; | |
| 85 | } | |
| 86 | return used; | |
| 87 | } | |
| 88 | ||
| 89 | /** How many columns the runs cover: the picture's width, for a test to check. */ | |
| 90 | export function covered(out, used) { | |
| 91 | let total = 0; | |
| 92 | for (let i = 0; i < used; i++) total += out[i].to - out[i].from + 1; | |
| 93 | return total; | |
| 94 | } |