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]>
Wren committed 3c9f6b3 Browse files
4 files changed +275 −21
src/scene.test.ts +18 −3
@@ -88,12 +88,27 @@ const body = (over: Record<string, unknown> = {}) => ({
8888 const view = { width: 900, height: 570, look: (p: { x: number; y: number; angle: number }) => p };
8989 const eye = { x: 120, y: 200, angle: 0 };
9090
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", () => {
9292 const ctx = fakeContext();
9393 drawScene(ctx as never, makeScene(), world, eye, snapshot([]), view);
9494 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);
97112 });
98113
99114 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 @@
77 * drawn at all. Smoke is measured along each ray, not painted as a disc:
88 * cross one and everything past it fogs.
99 *
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.
1316 */
1417
18 import { makeColumns, SKY, wallRuns } from "./slices.js";
1519 import {
1620 castRay,
1721 columnOffset,
@@ -55,7 +59,8 @@ const FOG = "rgb(198,203,212)";
5559 export function makeScene() {
5660 return {
5761 depth: new Float32Array(0),
58 fog: new Float32Array(0),
62 cols: makeColumns(0),
63 runs: [],
5964 hit: hitResult(),
6065 proj: projectResult(),
6166 order: [],
@@ -65,7 +70,31 @@ export function makeScene() {
6570 };
6671 }
6772
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 }
6998
7099 /**
71100 * Draw one frame.
@@ -82,7 +111,7 @@ export function drawScene(ctx, scene, world, eye, snap, view) {
82111 const columns = Math.ceil(W / step);
83112 if (scene.depth.length !== columns) {
84113 scene.depth = new Float32Array(columns);
85 scene.fog = new Float32Array(columns);
114 scene.cols = makeColumns(columns);
86115 }
87116 const proj = projection(W);
88117 const horizon = H / 2;
@@ -94,7 +123,8 @@ export function drawScene(ctx, scene, world, eye, snap, view) {
94123 for (const zone of snap.zones) if ((zone.k === "smoke" || zone.k === "wall") && zone.f <= 0) smokes.push(zone);
95124
96125 const depth = scene.depth;
97 const fog = scene.fog;
126 const cols = scene.cols;
127 const fog = cols.haze;
98128 const hit = scene.hit;
99129 for (let i = 0; i < columns; i++) {
100130 const offset = columnOffset(i * step + step / 2, W);
@@ -114,18 +144,17 @@ export function drawScene(ctx, scene, world, eye, snap, view) {
114144 haze = Math.min(1, haze);
115145 }
116146 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;
127150 }
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;
128156 }
157 drawWalls(ctx, scene, step);
129158
130159 drawThings(ctx, scene, world, eye, snap, view, proj, horizon, step, columns);
131160 }
@@ -199,7 +228,7 @@ function drawThings(ctx, scene, world, eye, snap, view, proj, horizon, step, col
199228
200229 const hazeAt = (scene, column, step, columns) => {
201230 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;
203232 };
204233
205234 /**
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 }
↑↓ navigate ↵ open esc close

Keyboard shortcuts