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

forked from trevato/cs · proposes · 4 behind

The range paints its own world: a room, a target and an atlas of its own, all data and no renderer #66

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

vesper/cs-engine:vesper/50-the-range-paints-its-own-world-a-room-a → main 1 commit fork

  • Merged. vesper/50-the-range-paints-its-own-world-a-room-a was merged into main as 6f17dbc.

Review

Sign in to review this change.
5 files changed +497 −6
README.md +20 −6
@@ -7,7 +7,8 @@ and the leaderboard of the matches people played. The second, at `/range`, is
77 built only out of the engine the shooter was taken apart into: a shooting
88 **range** — pop-up targets from a seeded spawn, a shot traced through the
99 engine's boxes, a thirty second clock and a score, with the fixed step and the
10 controls under it and no renderer yet. The engine in
10 controls under it, a room and an atlas of its own built as data, and no
11 renderer yet to draw them. The engine in
1112 `src/engine/` is the point of the repository; the shooter is what proves it.
1213
1314 ```sh
@@ -31,7 +32,10 @@ FORGE_APP_DATA=./data bun run src/tty.ts # the terminal interface
3132 generic tools in the engine — a grid of cells that meshes and paths, a box mesh
3233 builder, an atlas painter over hashed value noise, and a two-voice sound
3334 device — driven by this game's recipes in
34 `src/client/{map,models,materials,audio}.ts`. There are no
35 `src/client/{map,models,materials,audio}.ts`. The range does the same for
36 itself out of the same two tools and on its own numbers, in
37 `src/range/{level,paint}.ts`: a room and a three-layer atlas that owe
38 `de_forge` nothing. There are no
3539 asset files and nothing from the real game is used — the numbers follow CS's
3640 published shape so the habits transfer, and the rest is original.
3741 - **Learned, and measured.** The bots do not aim by rule. A 32-input, two-layer
@@ -204,8 +208,18 @@ from the angle it subtends, and you turn on the spot.
204208 game gets that furniture without copying a line out of the shooter; and `main.ts`
205209 wires the engine's `loop` and `controls` to the shell's ids, steps the run in
206210 whole ticks and fires one shot per press along the engine's `angleVectors`.
207 No room, no models, no movement, no WebGL — those need the renderer and the
208 physics, and #13 builds the finale on this. `independence.test.ts` reads every source in
211 **The range paints its own world**, and it is data before it is a picture:
212 `level.ts` is one room — a floor, four walls and a ceiling as six overlapping
213 slabs around a 1,792 × 1,280 × 320 unit lane, derived from four named numbers
214 so nothing is written twice — with `roomMesh()` and `targetMesh()` built out
215 of the engine's box builder, and `paint.ts` is the range's own atlas through
216 the engine's painter: three 64² layers (a target face, concrete, the lane's
217 painted line) against the shooter's nine at 128², each a couple of lines of
218 `fbm`, on the range's own seeds. Neither is `de_forge` repainted and neither
219 is imported by the entry, so the bundle has not grown by a byte; `level.test.ts`
220 proves the shell has no seam a bullet can leave through and `paint.test.ts`
221 pins the atlas byte for byte. No movement and no WebGL yet — those need the
222 physics and the renderer, and #13 builds the finale on this. `independence.test.ts` reads every source in
209223 here and fails on an import that leaves those three places, reusing the
210224 `sources`/`specifiersOf` helpers from `src/engine/boundary.test.ts` rather than a
211225 second copy of the rule — a test may read another test's helpers, which is the
@@ -298,8 +312,8 @@ from the angle it subtends, and you turn on the spot.
298312
299313 | bundle | raw | ceiling | gzipped | ceiling |
300314 | ------- | ------ | ------- | ------- | ------- |
301 | `app` | 68,457 | 75,400 | 31,661 | 34,900 |
302 | `range` | 11,575 | 12,800 | 5,241 | 5,800 |
315 | `app` | 69,711 | 75,400 | 32,139 | 34,900 |
316 | `range` | 12,548 | 12,800 | 5,726 | 5,800 |
303317
304318 The ceilings are today's sizes plus about 10%. Gzip is measured at level 9
305319 and only at level 9, so the number does not move between a laptop and the
src/range/level.test.ts added +188
@@ -0,0 +1,188 @@
1 /**
2 * The room, checked headlessly. Nothing in here draws: a room is boxes a bullet
3 * stops in and buffers a renderer would upload, and both are arithmetic. The
4 * tests that matter are the ones a picture would not show — that the shell has
5 * no seam a bullet can leave through, and that every index into the mesh points
6 * at a vertex that exists.
7 */
8
9 import { expect, test } from "bun:test";
10 import {
11 LANE, RANGE_MAT, RANGE_ROOM, SHELL, TILE, insideRoom, roomMesh, targetMesh,
12 } from "./level.ts";
13 import { EYE, STANDARD } from "./targets.ts";
14 import { traceWorld, type Mesh, type Vec3 } from "../engine/index.ts";
15
16 /** The inside of the room, written out from `LANE` the way a reader would. */
17 const INNER: [Vec3, Vec3] = [
18 [-LANE.back, -LANE.half, 0],
19 [LANE.far, LANE.half, LANE.height],
20 ];
21
22 /** The middle of the air, which every ray in here is cast from. */
23 const CENTRE: Vec3 = [
24 (INNER[0][0] + INNER[1][0]) / 2,
25 0,
26 LANE.height / 2,
27 ];
28
29 const AXES = [0, 1, 2] as const;
30 const SIDES = [-1, 1] as const;
31
32 const vertexCount = (mesh: Mesh): number => mesh.position.length / 3;
33
34 test("every box in the room is a box: min below max on all three axes", () => {
35 expect(RANGE_ROOM.length).toBe(6);
36 for (const box of RANGE_ROOM)
37 for (const i of AXES) {
38 expect(box.min[i]).toBeLessThan(box.max[i]);
39 expect(Number.isFinite(box.min[i])).toBe(true);
40 expect(Number.isFinite(box.max[i])).toBe(true);
41 }
42 });
43
44 test("the six slabs are a floor, a ceiling and four walls, each one shell thick", () => {
45 const faces = new Set<string>();
46 for (const box of RANGE_ROOM) {
47 const thin = AXES.filter((i) => (box.max[i] as number) - (box.min[i] as number) === SHELL);
48 // Exactly one axis is thin, and on that axis the slab sits against the air.
49 expect(thin.length).toBe(1);
50 const axis = thin[0] as 0 | 1 | 2;
51 const side = box.max[axis] === INNER[0][axis] ? -1 : 1;
52 expect(side < 0 ? box.max[axis] : box.min[axis]).toBe(INNER[side < 0 ? 0 : 1][axis]);
53 faces.add(`${axis}${side}`);
54 }
55 expect([...faces].sort()).toEqual(["0-1", "01", "1-1", "11", "2-1", "21"]);
56 });
57
58 const lerpAxis = (axis: 0 | 1 | 2, t: number): number =>
59 (INNER[0][axis] as number) + ((INNER[1][axis] as number) - (INNER[0][axis] as number)) * t;
60
61 /**
62 * Every way out of this room a ray from the middle of it found. A grid of
63 * aiming points across each inner face — corners and edges included, because a
64 * corner is exactly where two slabs could fail to meet — each pushed well past
65 * the outside of the shell.
66 */
67 function leaks(room: typeof RANGE_ROOM): string[] {
68 const out: string[] = [];
69 for (const axis of AXES)
70 for (const side of SIDES)
71 for (let i = 0; i <= 8; i++)
72 for (let j = 0; j <= 8; j++) {
73 const across = AXES.filter((a) => a !== axis) as [0 | 1 | 2, 0 | 1 | 2];
74 const to: Vec3 = [0, 0, 0];
75 to[axis] = (INNER[side < 0 ? 0 : 1][axis] as number) + side * SHELL * 4;
76 to[across[0]] = lerpAxis(across[0], i / 8);
77 to[across[1]] = lerpAxis(across[1], j / 8);
78 const hit = traceWorld(CENTRE, to, room);
79 if (hit === null || hit.t >= 1) out.push(`${axis}${side} ${to.join(",")}`);
80 }
81 return out;
82 }
83
84 test("the shell has no gap on any side: every ray out of the lane stops in it", () => {
85 expect(leaks(RANGE_ROOM)).toEqual([]);
86 });
87
88 test("the ray grid has teeth: a doorway left in a wall is found by it", () => {
89 // The same room with a 320-unit hole cut through the middle of the north
90 // wall — wider than the grid's stride, which is what makes it findable — and
91 // nothing else changed.
92 const hole: [number, number] = [CENTRE[0] - 160, CENTRE[0] + 160];
93 const cut = RANGE_ROOM.flatMap((box) =>
94 box.min[1] === LANE.half
95 ? [
96 { ...box, max: [hole[0], box.max[1], box.max[2]] as Vec3 },
97 { ...box, min: [hole[1], box.min[1], box.min[2]] as Vec3 },
98 ]
99 : [box],
100 );
101 expect(leaks(cut).length).toBeGreaterThan(0);
102 });
103
104 test("the shooter's eye is inside the room and a point past a wall is not", () => {
105 expect(insideRoom(EYE)).toBe(true);
106 expect(insideRoom(CENTRE)).toBe(true);
107 // A plate at the far end of the lane, at full spread and full height, stands
108 // in the air and not in a wall: the room is built around the rules.
109 const r = STANDARD;
110 expect(insideRoom([r.depth.max + r.half, r.spread + r.half, r.height.max + r.half])).toBe(true);
111 expect(insideRoom([r.depth.min - r.half, -r.spread - r.half, r.height.min - r.half])).toBe(true);
112
113 for (const outside of [
114 [LANE.far + 1, 0, 64],
115 [-LANE.back - 1, 0, 64],
116 [0, LANE.half + 1, 64],
117 [0, -LANE.half - 1, 64],
118 [0, 0, LANE.height + 1],
119 [0, 0, -1],
120 ] as Vec3[])
121 expect(insideRoom(outside)).toBe(false);
122 });
123
124 test("a shot down the lane stops at the wall and not beyond it", () => {
125 // The range's own bullet: from the eye, down +X, as far as `STANDARD` carries.
126 const to: Vec3 = [EYE[0] + STANDARD.range, EYE[1], EYE[2]];
127 const hit = traceWorld(EYE, to, RANGE_ROOM);
128 expect(hit).not.toBeNull();
129 const point = (hit as { point: Vec3 }).point;
130 expect(point[0]).toBeCloseTo(LANE.far, 6);
131 expect(point[0]).toBeLessThan(to[0]);
132 // A step short of the hit is still in the room; a step past it is in concrete.
133 expect(insideRoom([point[0] - 1, point[1], point[2]])).toBe(true);
134 expect(insideRoom([point[0] + 1, point[1], point[2]])).toBe(false);
135
136 // The other five directions stop too, each at its own surface.
137 const stops: number[] = [];
138 for (const axis of AXES)
139 for (const side of SIDES) {
140 const far: Vec3 = [EYE[0], EYE[1], EYE[2]];
141 far[axis] += side * STANDARD.range;
142 const out = traceWorld(EYE, far, RANGE_ROOM);
143 expect(out).not.toBeNull();
144 stops.push((out as { point: Vec3 }).point[axis] as number);
145 }
146 expect(stops).toEqual([-LANE.back, LANE.far, -LANE.half, LANE.half, 0, LANE.height]);
147 });
148
149 test("the room mesh is the room's boxes, with every index inside the vertices", () => {
150 const mesh = roomMesh();
151 expect(vertexCount(mesh)).toBe(RANGE_ROOM.length * 24);
152 expect(mesh.index.length).toBe(RANGE_ROOM.length * 36);
153 expect(mesh.index.length % 3).toBe(0);
154 expect(mesh.normal.length).toBe(mesh.position.length);
155 expect(mesh.uv.length).toBe(vertexCount(mesh) * 2);
156 expect(mesh.layer.length).toBe(vertexCount(mesh));
157 for (const i of mesh.index) {
158 expect(Number.isInteger(i)).toBe(true);
159 expect(i).toBeLessThan(vertexCount(mesh));
160 }
161 for (const value of mesh.position) expect(Number.isFinite(value)).toBe(true);
162 // Every vertex carries the layer of the box it came from, 24 at a time.
163 for (let box = 0; box < RANGE_ROOM.length; box++)
164 for (let v = 0; v < 24; v++)
165 expect(mesh.layer[box * 24 + v]).toBe((RANGE_ROOM[box] as { mat: number }).mat);
166 // The lane's paint tiles rather than stretching: the floor is a dozen tiles
167 // long, so its uv runs to the far end of the shell divided by the tile.
168 expect(Math.max(...mesh.uv)).toBeCloseTo((LANE.far + SHELL) / TILE, 6);
169 });
170
171 test("the target mesh is one plate at the origin, its face fitted once", () => {
172 const mesh = targetMesh();
173 expect(vertexCount(mesh)).toBe(24);
174 expect(mesh.index.length).toBe(36);
175 for (const i of mesh.index) expect(i).toBeLessThan(vertexCount(mesh));
176 for (let v = 0; v < 24; v++) {
177 for (const axis of AXES)
178 expect(Math.abs(mesh.position[v * 3 + axis] as number)).toBeCloseTo(STANDARD.half, 6);
179 expect(mesh.layer[v]).toBe(RANGE_MAT.PLATE);
180 }
181 // Each face spans exactly one tile of the atlas: the bullseye reads once.
182 expect(Math.max(...mesh.uv) - Math.min(...mesh.uv)).toBeCloseTo(1, 6);
183
184 // The plate a renderer draws is the size of the plate a bullet hits.
185 const half = 40;
186 const wider = targetMesh(half);
187 expect(Math.max(...wider.position)).toBeCloseTo(half, 6);
188 });
src/range/level.ts added +128
@@ -0,0 +1,128 @@
1 /**
2 * The range's world, as data: one room — a floor, four walls and a ceiling —
3 * around a lane long enough to shoot down, and the two meshes that draw it.
4 *
5 * There is no renderer under this and no physics beside it. A `Box` is what the
6 * engine traces a bullet against, a `Mesh` is what a renderer would upload, and
7 * both are plain arrays a test can read with no canvas in the room: #13 turns
8 * them into a picture, and nothing here has to change when it does.
9 *
10 * Units are the engine's and the range's, as `targets.ts` set them: Source
11 * units, X east down the lane, Y north across it, Z up from the floor. The
12 * shooter stands at the origin with their feet on the floor and their eye at
13 * `EYE`, and the plates pop up in front of them — so the room is built around
14 * that, deeper down +X than anywhere else and only far enough behind the
15 * firing line to stand.
16 *
17 * It is deliberately not a map. There is no grid, no path, no cover and no
18 * prop: `src/client/map.ts` is a map, and a second game that needed one would
19 * reach for the engine's grid rather than for this file.
20 */
21
22 import { addBox, builder, finish, type Box, type Mesh, type Vec3 } from "../engine/index.ts";
23 import { PLATE_MAT, STANDARD } from "./targets.ts";
24
25 /**
26 * The air inside, in units: what a shooter and a plate share. Everything else
27 * in this file is derived from these five numbers, so the room changes size by
28 * one edit and the tests follow it.
29 *
30 * The lane is 1,792 units long and 1,280 wide with a 320 ceiling: `STANDARD`
31 * stands its farthest plate at 1,200 and its widest at ±420, so there are 300
32 * units of air past the farthest plate and 200 at the sides — a lane, not a
33 * corridor a plate is wedged into.
34 */
35 export const LANE = {
36 /** How far behind the firing line the back wall stands. */
37 back: 256,
38 /** How far down the lane the far wall stands. */
39 far: 1536,
40 /** Half the lane's width: the side walls stand at ±this. */
41 half: 640,
42 /** Floor to ceiling. */
43 height: 320,
44 } as const;
45
46 /** How thick every slab of the shell is. A wall has a depth, so a ray stops in it. */
47 export const SHELL = 32;
48
49 /**
50 * Which layer of the range's atlas a surface is painted from. `paint.ts` paints
51 * them in this order and `paint.test.ts` holds the two together; the plate's
52 * layer is `targets.ts`'s, named here rather than written a second time.
53 */
54 export const RANGE_MAT = { PLATE: PLATE_MAT, CONCRETE: 1, LANE: 2 } as const;
55
56 /** The inside corner-to-corner, low and high. The floor's surface is z = 0. */
57 const INNER: [Vec3, Vec3] = [
58 [-LANE.back, -LANE.half, 0],
59 [LANE.far, LANE.half, LANE.height],
60 ];
61
62 /** The outside of the shell: the inside, grown by `SHELL` on all six sides. */
63 const OUTER: [Vec3, Vec3] = [
64 [INNER[0][0] - SHELL, INNER[0][1] - SHELL, INNER[0][2] - SHELL],
65 [INNER[1][0] + SHELL, INNER[1][1] + SHELL, INNER[1][2] + SHELL],
66 ];
67
68 /**
69 * One side of the shell: the whole outer block, cut back to the shell on the
70 * far side of `axis`. Every slab therefore spans the two axes it is not, which
71 * is what makes the corners overlap rather than meet — there is no seam for a
72 * bullet to leave through, and no arithmetic anywhere deciding where a corner
73 * belongs.
74 */
75 function slab(axis: 0 | 1 | 2, side: -1 | 1, mat: number): Box {
76 const min: Vec3 = [OUTER[0][0], OUTER[0][1], OUTER[0][2]];
77 const max: Vec3 = [OUTER[1][0], OUTER[1][1], OUTER[1][2]];
78 if (side < 0) max[axis] = INNER[0][axis];
79 else min[axis] = INNER[1][axis];
80 return { min, max, mat };
81 }
82
83 /**
84 * The room: six slabs, in the order a reader would look for them. The floor
85 * carries the lane's paint and everything else is concrete, which is the whole
86 * of this room's material list.
87 */
88 export const RANGE_ROOM: Box[] = [
89 slab(2, -1, RANGE_MAT.LANE), // floor
90 slab(2, 1, RANGE_MAT.CONCRETE), // ceiling
91 slab(0, -1, RANGE_MAT.CONCRETE), // behind the firing line
92 slab(0, 1, RANGE_MAT.CONCRETE), // the wall you shoot at
93 slab(1, -1, RANGE_MAT.CONCRETE), // south
94 slab(1, 1, RANGE_MAT.CONCRETE), // north
95 ];
96
97 /** Is this point in the air inside the room? A point in a wall is not. */
98 export const insideRoom = (p: Vec3): boolean =>
99 [0, 1, 2].every(
100 (i) => (p[i] as number) > (INNER[0][i] as number) && (p[i] as number) < (INNER[1][i] as number),
101 );
102
103 /** How many units of wall one tile of the atlas covers. */
104 export const TILE = 128;
105
106 /**
107 * The room as one buffer: six boxes, thirty-six quads, one draw. The texture
108 * tiles every `TILE` units, so the floor's painted line repeats down the lane
109 * rather than being stretched once over 1,792 units of concrete.
110 */
111 export function roomMesh(room: Box[] = RANGE_ROOM): Mesh {
112 const b = builder();
113 for (const box of room) addBox(b, box.min, box.max, box.mat, TILE);
114 return finish(b);
115 }
116
117 /**
118 * One plate, standing at the origin: the mesh every target is drawn with, so a
119 * scene places it by instance and the renderer uploads it once. Its size is the
120 * rules' — a plate you can draw and a plate you can hit are the same cube — and
121 * its texture fits its face exactly, because a target that reads at a distance
122 * reads once and not nine times.
123 */
124 export function targetMesh(half: number = STANDARD.half): Mesh {
125 const b = builder();
126 addBox(b, [-half, -half, -half], [half, half, half], RANGE_MAT.PLATE, half * 2);
127 return finish(b);
128 }
src/range/paint.test.ts added +102
@@ -0,0 +1,102 @@
1 /**
2 * The atlas, checked headlessly: bytes, not pixels. What a layer looks like is
3 * a judgement nobody can assert, so what is asserted is what a bug would break
4 * — the size, the range of every byte, that the same seeds paint the same atlas
5 * twice, and that every layer a box in `level.ts` names is a layer that exists.
6 *
7 * The digest below came from a run of `rangeAtlas()`, not from arithmetic: the
8 * shaders' seeds (101, 102, 103 and the grit's 104) and `RANGE_TEX_SIZE` are
9 * what it pins. Change a seed, a colour or the size on purpose and it moves;
10 * paste the new one in with a sentence saying what was repainted.
11 */
12
13 import { expect, test } from "bun:test";
14 import { RANGE_LAYERS, RANGE_SHADERS, RANGE_TEX_SIZE, rangeAtlas } from "./paint.ts";
15 import { RANGE_MAT, RANGE_ROOM } from "./level.ts";
16
17 const ATLAS_SHA256 = "8ec1faf3cb36b551c512102547269ea7405ace35e651196cc1e13e2163946e6f";
18
19 test("the atlas is exactly size × size × 4 × layers bytes, and every one is a byte", () => {
20 const bytes = rangeAtlas();
21 expect(bytes.length).toBe(RANGE_TEX_SIZE * RANGE_TEX_SIZE * 4 * RANGE_LAYERS);
22 expect(bytes).toBeInstanceOf(Uint8Array);
23 for (const byte of bytes) {
24 expect(Number.isInteger(byte)).toBe(true);
25 expect(byte).toBeGreaterThanOrEqual(0);
26 expect(byte).toBeLessThanOrEqual(255);
27 }
28 // Fully opaque: every fourth byte is alpha, and the range has no glass.
29 for (let i = 3; i < bytes.length; i += 4) expect(bytes[i]).toBe(255);
30 });
31
32 test("the same seeds paint the same atlas twice, byte for byte", () => {
33 const digest = (bytes: Uint8Array): string =>
34 new Bun.CryptoHasher("sha256").update(bytes).digest("hex");
35 expect(digest(rangeAtlas())).toBe(digest(rangeAtlas()));
36 expect(digest(rangeAtlas())).toBe(ATLAS_SHA256);
37 });
38
39 test("the range's atlas is small on purpose, and smaller than the shooter's nine", () => {
40 expect(RANGE_LAYERS).toBe(RANGE_SHADERS.length);
41 expect(RANGE_LAYERS).toBe(3);
42 expect(RANGE_TEX_SIZE).toBe(64);
43 // 48 KB of texture, against the shooter's 576 KB.
44 expect(rangeAtlas().length).toBe(48 * 1024);
45 });
46
47 test("every layer paints something visible, and no two of them are the same", () => {
48 const swatches = new Set<string>();
49 for (const shade of RANGE_SHADERS) {
50 for (const [u, v] of [[0.2, 0.2], [0.5, 0.5], [0.8, 0.45]]) {
51 const rgb = shade(u, v);
52 expect(rgb.length).toBe(3);
53 for (const channel of rgb) {
54 expect(Number.isFinite(channel)).toBe(true);
55 expect(channel).toBeGreaterThan(0);
56 }
57 }
58 const [r, g, b] = shade(0.5, 0.5);
59 swatches.add(`${Math.round(r)},${Math.round(g)},${Math.round(b)}`);
60 }
61 expect(swatches.size).toBe(RANGE_SHADERS.length);
62 });
63
64 test("the target face reads at a distance: its middle is far from its edge", () => {
65 const face = RANGE_SHADERS[RANGE_MAT.PLATE] as (typeof RANGE_SHADERS)[number];
66 const brightness = (u: number, v: number): number => {
67 const [r, g, b] = face(u, v);
68 return (r + g + b) / 3;
69 };
70 // Centre, ring, field: three bands a plate 1.7° wide still shows as three.
71 expect(brightness(0.5, 0.5)).toBeGreaterThan(brightness(0.5, 0.29) * 1.6);
72 expect(brightness(0.5, 0.05)).toBeGreaterThan(brightness(0.5, 0.29) * 1.3);
73 });
74
75 test("the lane layer has a painted line on it, and the concrete has not", () => {
76 const lane = RANGE_SHADERS[RANGE_MAT.LANE] as (typeof RANGE_SHADERS)[number];
77 const concrete = RANGE_SHADERS[RANGE_MAT.CONCRETE] as (typeof RANGE_SHADERS)[number];
78 // The line is at the tile's seam, so it runs down the lane unbroken.
79 const [lr, lg, lb] = lane(0.5, 0.01);
80 const [fr, fg, fb] = lane(0.5, 0.5);
81 expect(lr - lb).toBeGreaterThan(80); // yellow paint
82 expect(Math.abs(fr - fb)).toBeLessThan(20); // grey floor
83 expect(lg).toBeGreaterThan(fg);
84 // Concrete is grey wherever it is asked.
85 for (const [u, v] of [[0.5, 0.01], [0.5, 0.5], [0.99, 0.99]]) {
86 const [r, , b] = concrete(u as number, v as number);
87 expect(Math.abs(r - b)).toBeLessThan(20);
88 }
89 });
90
91 test("every material a box in the room names is a layer of this atlas", () => {
92 const named = [...new Set(RANGE_ROOM.map((box) => box.mat)), ...Object.values(RANGE_MAT)];
93 for (const mat of named) {
94 expect(Number.isInteger(mat)).toBe(true);
95 expect(mat).toBeGreaterThanOrEqual(0);
96 expect(mat).toBeLessThan(RANGE_LAYERS);
97 expect(RANGE_SHADERS[mat]).toBeInstanceOf(Function);
98 }
99 // Every layer is used by something, so the atlas carries nothing dead: the
100 // plate is `targets.ts`'s, the other two are the room's.
101 expect(new Set(Object.values(RANGE_MAT)).size).toBe(RANGE_LAYERS);
102 });
src/range/paint.ts added +59
@@ -0,0 +1,59 @@
1 /**
2 * The three surfaces the range is made of, painted here as shaders for the
3 * engine's atlas painter: a target face, concrete, and the lane's floor with a
4 * line painted down it. Nothing is downloaded — a layer is a couple of lines of
5 * `fbm` over hashed value noise, and `level.ts` refers to one by the index it
6 * is painted into.
7 *
8 * It is deliberately not the shooter's palette painted again. Three layers at
9 * 64², not nine at 128², because a room with one kind of wall does not need
10 * nine materials and a bright plate at 1,200 units does not need 128 pixels to
11 * read: the whole atlas is 48 KB and takes about 12 ms to paint once at
12 * start-up on the forge's box, where the shooter's nine 128² layers take about
13 * 100 ms. The seeds below are the range's own numbers and not `materials.ts`'s.
14 *
15 * The plate is layer 0 because `targets.ts` already said `PLATE_MAT = 0`. An
16 * atlas is painted to suit the game; a game is not renumbered to suit an atlas.
17 */
18
19 import { fbm, hash, paintAtlas, type Shader } from "../engine/index.ts";
20
21 /** One layer, on a side. Small on purpose: see the note above. */
22 export const RANGE_TEX_SIZE = 64;
23
24 const tint = (base: [number, number, number], k: number): [number, number, number] =>
25 [base[0] * k, base[1] * k, base[2] * k];
26
27 /** Layer 0, 1, 2, in the order `RANGE_MAT` in `level.ts` names them. */
28 export const RANGE_SHADERS: Shader[] = [
29 // 0 a target face: a pale bullseye in a dark ring on orange, sized so the
30 // three bands are still three bands when the plate is 1.7° wide.
31 (u, v) => {
32 const r = Math.hypot(u - 0.5, v - 0.5);
33 const band = r < 0.14 ? 1.45 : r < 0.28 ? 0.5 : 1;
34 return tint([228, 120, 40], (0.9 + fbm(u * 4, v * 4, 4, 101) * 0.2) * band);
35 },
36 // 1 concrete: cast, poured in panels, a little gritty.
37 (u, v) => {
38 const n = fbm(u * 7, v * 7, 7, 102);
39 const grit = hash(Math.floor(u * RANGE_TEX_SIZE), Math.floor(v * RANGE_TEX_SIZE), 104) * 0.1;
40 const panel = Math.min(u, 1 - u, v, 1 - v) < 0.02 ? 0.85 : 1;
41 return tint([142, 143, 138], (0.82 + n * 0.3 + grit) * panel);
42 },
43 // 2 the lane floor: the same concrete, worn, with a painted line across the
44 // tile — the floor tiles every 128 units along Y, so the lines run down the
45 // lane 128 units apart and a shooter can see which one they are standing in.
46 (u, v) => {
47 const wear = fbm(u * 5, v * 5, 5, 103);
48 const line = Math.min(v, 1 - v) < 0.04;
49 return line
50 ? tint([214, 190, 88], 0.85 + wear * 0.3)
51 : tint([118, 119, 116], 0.8 + wear * 0.4);
52 },
53 ];
54
55 /** How many layers the array texture has: one per shader, counted once. */
56 export const RANGE_LAYERS = RANGE_SHADERS.length;
57
58 /** All three layers, tightly packed RGBA, ready for texImage3D. */
59 export const rangeAtlas = (): Uint8Array => paintAtlas(RANGE_SHADERS, RANGE_TEX_SIZE);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs