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

forked from trevato/cs · proposes · 4 behind

Boot is not a freeze: the atlas is painted in slices, and the shooter's menu answers while it paints #125

Merged opened this

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

Merged

torque/cs-engine:torque/99-boot-is-not-a-freeze-the-atlas-is-painte → main 2 commits fork

  • Merged. torque/99-boot-is-not-a-freeze-the-atlas-is-painte was merged into main as a7dcb6a.

Review

Sign in to review this change.
18 files changed +541 −172
src/client/bundle.test.ts +23 −3
@@ -35,6 +35,14 @@ const REAL_FETCH = globalThis.fetch;
3535
3636 const FRAME = 1000 / 30;
3737
38 /**
39 * How many frames the entry paints its atlas over: one layer each, and this
40 * game has nine. Written out rather than imported from `src/client/materials.ts`
41 * — nothing under `src/client/` but the stub page belongs in here, and a
42 * minified file has no constant to read anyway.
43 */
44 const LAYERS = 9;
45
3846 /** Every id the shell renders that the entry has to find to run a match. */
3947 const NEEDED = ["scene", "hud", "fps", "touch", "play", "name", "length", "side", "sens"];
4048
@@ -73,10 +81,12 @@ async function booted(
7381 return { stage, asked, undo };
7482 }
7583
76 /** Every GL call the scene canvas was given, and the draws among them. */
84 /** Every GL call the scene canvas was given, and the draws and uploads among them. */
7785 const glCalls = (stage: FakeStage): [string, ...unknown[]][] => stage.scene.gl?.calls ?? [];
7886 const drawn = (stage: FakeStage): [string, ...unknown[]][] =>
7987 glCalls(stage).filter((call) => call[0] === "drawElements");
88 const uploads = (stage: FakeStage): [string, ...unknown[]][] =>
89 glCalls(stage).filter((call) => call[0] === "texImage3D");
8090
8191 test("the committed bundle boots the page, and a click on play plays a match", async () => {
8292 const { stage, asked, undo } = await booted({ coarse: true });
@@ -86,11 +96,21 @@ test("the committed bundle boots the page, and a click on play plays a match", a
8696 expect(stage.frames.length).toBe(1);
8797 expect(stage.by("menu").hidden).toBe(false);
8898 expect(drawn(stage).length).toBe(0);
89 // Its own atlas, uploaded once by the minified file: nine 128² layers.
90 expect(glCalls(stage).find((call) => call[0] === "texImage3D")?.slice(4, 7)).toEqual([128, 128, 9]);
99 // Nothing is uploaded yet and Play is held: the minified file paints its
100 // atlas across the frames after boot, the same as the sources do.
101 expect(uploads(stage).length).toBe(0);
102 expect(stage.by("play").disabled).toBe(true);
91103 // The touch buttons the shell renders were bound too, every one of them.
92104 expect(stage.buttons.every((button) => button.listening.size > 0)).toBe(true);
93105
106 // A layer a frame, and on the last of them one upload of nine 128² layers.
107 for (let layer = 0; layer < LAYERS; layer++) stage.step(FRAME);
108 expect(uploads(stage).length).toBe(1);
109 expect(uploads(stage)[0]?.slice(4, 7)).toEqual([128, 128, 9]);
110 expect(stage.by("play").disabled).toBe(false);
111 expect(stage.by("prep").hidden).toBe(true);
112 expect(drawn(stage).length).toBe(0);
113
94114 stage.by("name").value = "Vesper";
95115 stage.by("side").value = "t";
96116 stage.by("length").value = "short";
src/client/main.test.ts +78 −3
@@ -6,6 +6,10 @@
66 * nothing is timed by a real clock — the test decides when a frame happens and
77 * how long it took.
88 *
9 * The first frames are the atlas: `boot` paints a layer per frame and builds
10 * the renderer on the last of them, so a test that wants a match steps those
11 * frames first — `painted` below — and two tests are about that window itself.
12 *
913 * The world is not reached for behind `boot`'s back: `boot` hands back the
1014 * running frame and that frame says what it is simulating, so a test that wants
1115 * a match that is nearly over says so on that world and steps another frame.
@@ -14,6 +18,7 @@
1418
1519 import { expect, test } from "bun:test";
1620 import { boot, gameMeshes, type Frame } from "./main.ts";
21 import { LAYERS, materialTextures } from "./materials.ts";
1722 import { WEAPONS } from "./weapons.ts";
1823 import { parseResult } from "../result.ts";
1924 import { fakeStage, type FakeStage } from "./stage.test-helper.ts";
@@ -34,9 +39,19 @@ const worldOf = (stage: FakeStage): World | null => running.get(stage)?.world()
3439 const TICK = 1 / 64;
3540 const FRAME = 1000 / 30;
3641
37 /** A booted page, and the match already started on it. */
42 /**
43 * Step the frames the atlas is painted over: one layer each, and the renderer
44 * is built on the last of them. Nothing can be played before this is done, so
45 * every test that plays goes through it.
46 */
47 function painted(stage: FakeStage): FakeStage {
48 for (let layer = 0; layer < LAYERS; layer++) stage.step(FRAME);
49 return stage;
50 }
51
52 /** A booted page, its atlas painted, and the match already started on it. */
3853 function playing(options: { webgl?: boolean; coarse?: boolean } = {}): FakeStage {
39 const stage = start(fakeStage(options));
54 const stage = painted(start(fakeStage(options)));
4055 stage.by("play").fire("click");
4156 return stage;
4257 }
@@ -58,8 +73,66 @@ test("it boots on the page's ids, asks for a frame, and draws nothing until a ma
5873 expect(stage.frames.length).toBe(1);
5974 });
6075
76 test("the atlas is painted a layer a frame, and the menu says so while it is", () => {
77 const stage = start(fakeStage());
78 // Nothing was painted inside `boot`: the frame was asked for, the page has it
79 // back, and the menu is up and honest about what it is doing.
80 expect(stage.atlases.length).toBe(0);
81 expect(stage.by("prep").hidden).toBe(false);
82 expect(stage.by("play").disabled).toBe(true);
83 expect(stage.frames.length).toBe(1);
84
85 // Nine layers, nine frames: the renderer is not built until the last one, and
86 // the menu is answering the whole way through — the buy panel's key, the
87 // pointer, the fields all belong to the page between slices.
88 for (let layer = 1; layer < LAYERS; layer++) {
89 stage.step(FRAME);
90 expect(stage.atlases.length).toBe(0);
91 expect(stage.by("prep").hidden).toBe(false);
92 }
93 stage.step(FRAME);
94
95 // One renderer, built once, out of the finished bytes — the same bytes the
96 // one-call painter gives — and before anything has been drawn with it.
97 expect(stage.atlases.length).toBe(1);
98 expect(stage.atlases[0]).toEqual(materialTextures());
99 expect(stage.draws.length).toBe(0);
100 expect(stage.by("prep").hidden).toBe(true);
101 expect(stage.by("play").disabled).toBe(false);
102
103 // And it stays one: the frames keep coming and nothing is painted again.
104 stage.step(FRAME);
105 stage.step(FRAME);
106 expect(stage.atlases.length).toBe(1);
107 });
108
109 test("a match cannot be started before the atlas is finished", () => {
110 const stage = start(fakeStage());
111 // The button is disabled, but a click that arrives anyway is not a match: no
112 // world, no pointer asked for, no id handed out, nothing stored.
113 stage.by("play").fire("click");
114 expect(worldOf(stage)).toBeNull();
115 expect(stage.scene.locks).toBe(0);
116 expect(stage.ids.length).toBe(0);
117 expect(stage.store.size).toBe(0);
118 expect(stage.by("menu").hidden).toBe(false);
119
120 // Eight of the nine layers in: still not a match.
121 for (let layer = 1; layer < LAYERS; layer++) stage.step(FRAME);
122 stage.by("play").fire("click");
123 expect(worldOf(stage)).toBeNull();
124
125 // The ninth lands, and the same click is one.
126 stage.step(FRAME);
127 stage.by("play").fire("click");
128 expect(worldOf(stage)).not.toBeNull();
129 expect(stage.by("menu").hidden).toBe(true);
130 stage.step(FRAME);
131 expect(stage.draws.length).toBe(1);
132 });
133
61134 test("the name is remembered between visits, and a blank one is `you`", () => {
62 const first = start(fakeStage());
135 const first = painted(start(fakeStage()));
63136 first.by("name").value = "Vesper";
64137 first.by("play").fire("click");
65138 expect(first.store.get("cs.name")).toBe("Vesper");
@@ -298,6 +371,8 @@ test("no WebGL2 is a sentence on the page, not an exception", () => {
298371 expect(stage.window.listening.size).toBe(0);
299372 expect(stage.document.listening.size).toBe(0);
300373 expect(stage.draws.length).toBe(0);
374 // And it cost no atlas: a browser that cannot draw pays for nothing.
375 expect(stage.atlases.length).toBe(0);
301376 });
302377
303378 test("every mesh name a scene of this game can place is handed in at construction", () => {
src/client/main.ts +80 −18
@@ -6,6 +6,12 @@
66 * game's atlas and this game's meshes — and the HUD is painted on top. The page's overlays — menu, buy, pause — are plain DOM, so they are
77 * reachable with a keyboard and big enough for a thumb.
88 *
9 * Booting does not freeze the page. This game's atlas is nine 128-pixel layers
10 * and about 170 ms of arithmetic, so `boot` paints one layer per frame through
11 * the engine's `painter` and builds the renderer on the frame the last row
12 * lands: until then the menu says so and holds Play, and every frame in between
13 * belongs to the browser. `boot` itself still returns its `Frame` at once.
14 *
915 * Nothing in here is a browser. `boot(host)` takes everything it reaches for —
1016 * the document, the window, a store, a `fetch`, a source of ids, and the two
1117 * factories that make a renderer and a voice — as one plain object, typed by
@@ -19,13 +25,13 @@ import { createAudio, type Audio } from "./audio.ts";
1925 import { CS_KEYS, CS_MOUSE, CS_TAP, readIntent } from "./binding.ts";
2026 import { drawHud, type Paint } from "./hud.ts";
2127 import {
22 attachButtons, clamp, createControls, createLoop, createRenderer, type Button, type Camera,
23 type Listens, type Mesh,
28 attachButtons, clamp, createControls, createLoop, createRenderer, painter, type Button,
29 type Camera, type Listens, type Mesh,
2430 } from "../engine/index.ts";
2531 import { DE_FORGE } from "./map.ts";
2632 import type { Level } from "./level.ts";
2733 import { COMPETITIVE, SHORT, type Rules } from "./match.ts";
28 import { LAYERS, TEX_SIZE, materialTextures } from "./materials.ts";
34 import { LAYERS, MATERIALS, TEX_SIZE } from "./materials.ts";
2935 import { meshFor } from "./models.ts";
3036 import { buildScene, createScene, SKY, type Scene } from "./scene.ts";
3137 import { buyable, WEAPONS } from "./weapons.ts";
@@ -93,6 +99,16 @@ export type Reply = { readonly ok: boolean; text(): Promise<string> };
9399 /** All this frame asks of a renderer: one draw call, of one scene. */
94100 export type Draws = { draw(scene: Scene, dt: number): void };
95101
102 /**
103 * What a browser that can draw hands back: the renderer, built from the atlas
104 * once the atlas is painted. It is two steps rather than one because the two
105 * questions have different prices — whether there is a WebGL2 context is free
106 * and is asked before the first frame, and the atlas is 170 milliseconds of
107 * arithmetic this frame spreads over nine of them. A browser with no context
108 * pays for neither: it gets a sentence in the menu and nothing is painted.
109 */
110 export type BuildRenderer = (atlas: Uint8Array) => Draws;
111
96112 export type Host = {
97113 document: PageDocument;
98114 window: PageWindow;
@@ -101,7 +117,7 @@ export type Host = {
101117 /** A fresh match id. `crypto.randomUUID` is one; `src/result.ts` checks it. */
102118 randomUUID(): string;
103119 /** Null when this browser has no WebGL2: the menu says so and nothing starts. */
104 createRenderer(canvas: Stage): Draws | null;
120 createRenderer(canvas: Stage): BuildRenderer | null;
105121 createAudio(): Audio;
106122 };
107123
@@ -134,9 +150,11 @@ export function boot(host: Host): Frame {
134150 const lengthField = by<Field>("length");
135151 const sideField = by<Field>("side");
136152 const sensField = by<Field>("sens");
153 const prep = by("prep");
154 const play = by<Panel & { disabled: boolean }>("play");
137155
138 const renderer = host.createRenderer(sceneCanvas);
139 if (renderer === null) {
156 const build = host.createRenderer(sceneCanvas);
157 if (build === null) {
140158 menu.innerHTML = "<h2>No WebGL2</h2><p>This browser cannot run the game. Everything else on the page still works.</p>";
141159 return { world: () => null };
142160 }
@@ -153,6 +171,17 @@ export function boot(host: Host): Frame {
153171 now: () => win.performance.now(),
154172 });
155173
174 /**
175 * The atlas, painted a layer a frame. Nine 128-pixel layers are about 170 ms
176 * of arithmetic — eleven frames — and painting them here, inside `boot`,
177 * meant the menu the player is looking at answered nothing until it was done.
178 * So the buffer is allocated now and filled from the frame loop below; the
179 * renderer is built exactly once, on the frame the last row lands, out of the
180 * finished bytes, and until then there is nothing to draw with and no match.
181 */
182 const atlas = painter(MATERIALS, TEX_SIZE);
183 let renderer: Draws | null = null;
184
156185 let world: World | null = null;
157186 // One scene and one camera for the whole run: a frame fills them, never makes
158187 // them. `scene.ts` keeps the line buffer inside the scene for the same reason.
@@ -168,6 +197,12 @@ export function boot(host: Host): Frame {
168197 element.hidden = !on;
169198 };
170199
200 // The menu says what it is doing while the atlas is painted, and Play cannot
201 // be pressed until there is something to play with. Both are undone on the
202 // frame the last slice lands.
203 play.disabled = true;
204 show(prep, true);
205
171206 // --- the buy menu, rebuilt whenever it opens so the prices are honest
172207 function renderBuy(): void {
173208 if (world === null) return;
@@ -250,6 +285,9 @@ export function boot(host: Host): Frame {
250285 }
251286
252287 function start(): void {
288 // The button is disabled until the atlas is painted; a click that arrives
289 // anyway — a keyboard, a script, a browser being generous — is not a match.
290 if (renderer === null) return;
253291 const rules: Rules = lengthField.value === "long" ? COMPETITIVE : SHORT;
254292 const team = sideField.value === "t" ? "t" : "ct";
255293 const name = nameField.value.trim().slice(0, 24) || "you";
@@ -268,7 +306,7 @@ export function boot(host: Host): Frame {
268306 host.storage.setItem("cs.name", name);
269307 }
270308
271 by("play").addEventListener("click", start);
309 play.addEventListener("click", start);
272310 by("resume").addEventListener("click", () => {
273311 show(pause, false);
274312 void sceneCanvas.requestPointerLock();
@@ -303,6 +341,16 @@ export function boot(host: Host): Frame {
303341
304342 function frame(elapsed: number): void {
305343 frameAverage += (elapsed * 1000 - frameAverage) * 0.05;
344 // One layer of the atlas per frame, and the browser has the rest of it: the
345 // menu is clickable the whole way through. The last slice is what builds
346 // the renderer, before anything has been drawn with it.
347 if (renderer === null) {
348 if (!atlas.paint(TEX_SIZE)) return;
349 renderer = build(atlas.bytes);
350 show(prep, false);
351 play.disabled = false;
352 return;
353 }
306354 if (world === null || !running) return;
307355
308356 const me = world.me;
@@ -333,7 +381,7 @@ export function boot(host: Host): Frame {
333381 running = false;
334382 doc.exitPointerLock();
335383 show(menu, true);
336 by("play").focus();
384 play.focus();
337385 }
338386
339387 const view = spectated(world);
@@ -384,9 +432,16 @@ export function gameMeshes(level: Level = DE_FORGE): Record<string, Mesh> {
384432 * The cast is the seam — the engine's `createRenderer` wants a real canvas, and
385433 * a `Stage` is the smaller shape this file and a test can both satisfy — and
386434 * the assets beside it are this game's, which is why they are passed in here
387 * rather than reached for behind the door: the atlas `materials.ts` paints, the
388 * size and layer count it painted at, the meshes above, the horizon a frame
389 * that named none would get, and the pixel ratio of the screen we are on.
435 * rather than reached for behind the door: the atlas bytes `boot` paints a
436 * slice at a time, the size and layer count they were painted at, the meshes
437 * above, the horizon a frame that named none would get, and the pixel ratio of
438 * the screen we are on.
439 *
440 * The context is asked for before anything is painted, and asking twice hands
441 * back the same one: a browser with no WebGL2 costs nothing and gets the
442 * sentence in the menu, and a browser that has one cannot lose it between the
443 * question and the answer — which is the only way the engine's `createRenderer`
444 * returns null, and why the line below says so rather than pretending.
390445 */
391446 const browser = (): Host => ({
392447 document,
@@ -394,13 +449,20 @@ const browser = (): Host => ({
394449 storage: localStorage,
395450 fetch: (url, init) => fetch(url, init),
396451 randomUUID: () => crypto.randomUUID(),
397 createRenderer: (canvas) =>
398 createRenderer(canvas as unknown as HTMLCanvasElement, {
399 atlas: { bytes: materialTextures(), size: TEX_SIZE, layers: LAYERS },
400 meshes: gameMeshes(),
401 sky: SKY,
402 devicePixelRatio: () => window.devicePixelRatio,
403 }),
452 createRenderer: (canvas) => {
453 const surface = canvas as unknown as HTMLCanvasElement;
454 if (surface.getContext("webgl2") === null) return null;
455 return (bytes) => {
456 const drawn = createRenderer(surface, {
457 atlas: { bytes, size: TEX_SIZE, layers: LAYERS },
458 meshes: gameMeshes(),
459 sky: SKY,
460 devicePixelRatio: () => window.devicePixelRatio,
461 });
462 if (drawn === null) throw new Error("the WebGL2 context went away while the atlas was painted");
463 return drawn;
464 };
465 },
404466 createAudio: () => createAudio(),
405467 });
406468
src/client/stage.test-helper.ts +20 −4
@@ -30,7 +30,7 @@
3030
3131 import { fakePage, type FakeCanvas, type FakeDocument, type FakeElement, type FakePaint, type FakeWindow } from "../engine/index.ts";
3232 import type { Audio } from "./audio.ts";
33 import type { Draws, Host, Stage } from "./main.ts";
33 import type { BuildRenderer, Draws, Host, Stage } from "./main.ts";
3434 import type { Scene } from "./scene.ts";
3535
3636 /**
@@ -122,6 +122,13 @@ export type FakeStage = {
122122 posts: Post[];
123123 answer(post: Post): Promise<{ ok: boolean; text(): Promise<string> }>;
124124 draws: Drawn[];
125 /**
126 * The atlas bytes each renderer was built from, newest last. The frame paints
127 * its atlas a slice at a time and builds the renderer on the last one, so
128 * this is empty while the menu says it is preparing textures and has exactly
129 * one entry from then on.
130 */
131 atlases: Uint8Array[];
125132 /** Every sound asked for, as `shot:ak47`, `hit`, `beep` or `resume`. */
126133 sounds: string[];
127134 store: Map<string, string>;
@@ -143,7 +150,7 @@ export type FakeStage = {
143150 * contract between the page and this file, so `src/server.test.ts` checks the
144151 * served page against this same list rather than a second copy of it.
145152 */
146 export const PAGE_IDS = ["scene", "hud", "menu", "pause", "buy", "touch", "stats", "name", "length", "side", "sens", "play", "resume", "quit", "fps"];
153 export const PAGE_IDS = ["scene", "hud", "menu", "pause", "buy", "touch", "stats", "name", "length", "side", "sens", "play", "prep", "resume", "quit", "fps"];
147154
148155 /** The ones the server renders with `hidden` on them: the page starts at the menu. */
149156 const HIDDEN = ["pause", "buy", "touch"];
@@ -181,6 +188,7 @@ export function fakeStage(options: { webgl?: boolean; coarse?: boolean } = {}):
181188
182189 const posts: Post[] = [];
183190 const draws: Drawn[] = [];
191 const atlases: Uint8Array[] = [];
184192 const sounds: string[] = [];
185193 const store = new Map<string, string>();
186194 const ids: string[] = [];
@@ -217,7 +225,15 @@ export function fakeStage(options: { webgl?: boolean; coarse?: boolean } = {}):
217225 ids.push(`00000000-0000-4000-8000-${String(uuid).padStart(12, "0")}`);
218226 return ids[ids.length - 1] as string;
219227 },
220 createRenderer: (_canvas: Stage) => (options.webgl === false ? null : renderer),
228 createRenderer: (_canvas: Stage): BuildRenderer | null =>
229 options.webgl === false
230 ? null
231 : (bytes) => {
232 // Every atlas the frame ever built a renderer out of, kept whole:
233 // a test says there was one of them and that it was finished.
234 atlases.push(bytes);
235 return renderer;
236 },
221237 createAudio: () => audio,
222238 },
223239 document: page.document,
@@ -226,7 +242,7 @@ export function fakeStage(options: { webgl?: boolean; coarse?: boolean } = {}):
226242 hud,
227243 buttons: page.buttons,
228244 frames: page.frames,
229 posts, draws, sounds, store, ids,
245 posts, draws, atlases, sounds, store, ids,
230246 by: page.by,
231247 step: page.step,
232248 lock(on) {
src/engine/GUIDE.md +17 −2
@@ -203,8 +203,23 @@ A `Shader` is `(u, v) => [r, g, b]`, and that is the whole of the interface.
203203 period, `fbm` is a stack of them, and `paintAtlas` runs a list of shaders into
204204 one tightly packed RGBA buffer whose layers are the material indices the meshes
205205 already carry. `src/client/materials.ts` is the shooter's nine surfaces and
206 `src/range/paint.ts` the range's three layers, each painted once at start-up;
207 the example paints nothing, because it uploads no texture.
206 `src/range/paint.ts` the range's three layers; the example paints nothing,
207 because it uploads no texture.
208
209 Painting is arithmetic, and a big atlas is a lot of it: the shooter's nine
210 128-pixel layers are about 170 milliseconds on a desktop and several times that
211 on a phone, which is eleven frames in one call that a page answers nothing
212 during. So an atlas can also be painted across frames. `painter` hands back a
213 `Painter` over the buffer it allocated up front: `Painter.bytes` is that buffer,
214 `Painter.paint` paints at most the number of rows it is given — a row is one row
215 of one layer, so `Painter.paint(size)` is exactly one layer — and says whether
216 the atlas is finished, and `Painter.rowsLeft` is what is still to do. There is
217 no clock in it and no callback out of it: the game asks for the next slice from
218 the frame loop it already has, tells the player it is working, and builds the
219 renderer on the frame the last slice lands. `src/client/main.ts` does exactly
220 that, a layer a frame. An atlas small enough to pay for at start-up stays one
221 call: the range's three 64-pixel layers are about 17 ms, and `paintAtlas` is
222 `painter` asked for every row at once.
208223
209224 ### sound
210225
src/engine/boundary.test.ts +2 −2
@@ -284,7 +284,7 @@ export const SURFACE = [
284284 "Frames",
285285 "Grid", "Hit", "Host", "Instance", "KeyEventish", "Listens", "LockOwner", "Loop", "LoopOptions",
286286 "Mat4", "Mesh", "MouseEventish", "Options", "Overlay",
287 "Packed", "PageOptions", "Paint", "Policy", "Renderer",
287 "Packed", "PageOptions", "Paint", "Painter", "Policy", "Renderer",
288288 "RendererAssets",
289289 "Scene", "SceneCapacity", "Shader", "Shape", "SoundDevice", "Stick", "Surface", "Tone",
290290 "TouchEventish", "Touchish", "Vec3", "add", "addBox", "angleDelta", "angleVectors",
@@ -298,7 +298,7 @@ export const SURFACE = [
298298 "logitCount", "mat4", "mergeSolid",
299299 "modelMatrix", "mul", "multiply",
300300 "newActivations", "nextInstance", "noise", "normalize", "overlay",
301 "pack", "paintAtlas", "paramCount",
301 "pack", "paintAtlas", "painter", "paramCount",
302302 "perspective", "pushLine",
303303 "quadMesh", "rad", "rayBox", "resetScene", "rng", "rowOf",
304304 "sample", "scale", "segmentLength", "softmax", "sub", "tensorsOf",
src/engine/index.ts +3 −3
@@ -30,9 +30,9 @@ export type { Grid } from "./grid.ts";
3030 export { addBox, builder, finish, quadMesh } from "./mesh.ts";
3131 export type { Builder, Mesh } from "./mesh.ts";
3232
33 /** Hashed value noise, fbm, and an atlas painted from a list of shaders. */
34 export { fbm, hash, noise, paintAtlas } from "./texture.ts";
35 export type { Shader } from "./texture.ts";
33 /** Hashed value noise, fbm, and an atlas painted from a list of shaders — whole, or a slice a frame. */
34 export { fbm, hash, noise, paintAtlas, painter } from "./texture.ts";
35 export type { Painter, Shader } from "./texture.ts";
3636
3737 /** A lazy `AudioContext` with noise bursts, tones and distance attenuation. */
3838 export { attenuate, createSoundDevice } from "./sound.ts";
src/engine/texture.test.ts +93 −1
@@ -1,8 +1,27 @@
11 import { expect, test } from "bun:test";
2 import { fbm, hash, noise, paintAtlas, type Shader } from "./texture.ts";
2 import { fbm, hash, noise, paintAtlas, painter, type Shader } from "./texture.ts";
33
44 const flat = (r: number, g: number, b: number): Shader => () => [r, g, b];
55
6 /**
7 * The two atlases this repository actually paints, as recipes rather than as
8 * imports: the shooter's nine 128-pixel layers and the range's three 64-pixel
9 * ones. A test in the engine may not import a game — `boundary.test.ts` holds
10 * that — and what matters here is the shape and the cost, not the palette.
11 */
12 const nine = (): Shader[] =>
13 Array.from({ length: 9 }, (_, layer): Shader => (u, v) => [
14 fbm(u * 6, v * 6, 6, layer) * 255,
15 u * 255,
16 (v + layer * 0.1) * 255,
17 ]);
18 const three = (): Shader[] =>
19 Array.from({ length: 3 }, (_, layer): Shader => (u, v) => [
20 hash(Math.floor(u * 64), Math.floor(v * 64), layer) * 255,
21 fbm(u * 4, v * 4, 4, layer) * 255,
22 v * 255,
23 ]);
24
625 test("the hash is stable, spread over 0..1, and moves with the seed", () => {
726 expect(hash(3, 7, 1)).toBe(hash(3, 7, 1));
827 expect(hash(3, 7, 1)).not.toBe(hash(3, 7, 2));
@@ -55,6 +74,79 @@ test("a shader that returns out-of-range values is clamped, not wrapped", () =>
5574 expect([...atlas.slice(0, 4)]).toEqual([255, 0, 0, 255]);
5675 });
5776
77 test("a sliced atlas is the same bytes as a whole one, at every slice size", () => {
78 // The two real shapes, painted a row at a time, a layer at a time, and in one
79 // call larger than the atlas is: each is byte for byte what `paintAtlas` gives.
80 for (const [shaders, size] of [[nine(), 128], [three(), 64]] as [Shader[], number][]) {
81 const whole = paintAtlas(shaders, size);
82 for (const rows of [1, 7, size, size * 2, size * shaders.length + 10]) {
83 const p = painter(shaders, size);
84 let slices = 0;
85 while (!p.paint(rows)) slices += 1;
86 expect(p.bytes).toEqual(whole);
87 expect(slices).toBe(Math.max(0, Math.ceil((size * shaders.length) / rows) - 1));
88 }
89 }
90 });
91
92 test("a slice paints no more rows than it was asked for, and the last one says so", () => {
93 let rowsSeen = 0;
94 let lastRow = -1;
95 const counting: Shader = (u, v) => {
96 const row = Math.round(v * 8);
97 if (row !== lastRow) {
98 lastRow = row;
99 rowsSeen += 1;
100 }
101 return [u * 255, v * 255, 0];
102 };
103 const p = painter([counting, counting], 8);
104 expect(p.rowsLeft).toBe(16);
105 expect(p.paint(3)).toBe(false);
106 expect(rowsSeen).toBe(3);
107 expect(p.rowsLeft).toBe(13);
108 // A slice wider than what is left paints what is left and no more.
109 expect(p.paint(5)).toBe(false);
110 expect(rowsSeen).toBe(8);
111 expect(p.paint(100)).toBe(true);
112 expect(rowsSeen).toBe(16);
113 expect(p.rowsLeft).toBe(0);
114 });
115
116 test("a call after the atlas is done is a no-op that leaves the bytes alone", () => {
117 let calls = 0;
118 const once: Shader = () => {
119 calls += 1;
120 return [calls % 256, 0, 0];
121 };
122 const p = painter([once], 4);
123 expect(p.paint(4)).toBe(true);
124 const painted = p.bytes.slice();
125 expect(calls).toBe(16);
126 expect(p.paint(4)).toBe(true);
127 expect(p.paint(1000)).toBe(true);
128 expect(calls).toBe(16);
129 expect(p.bytes).toEqual(painted);
130 // And a slice of no rows at all is the same nothing.
131 const fresh = painter([once], 4);
132 expect(fresh.paint(0)).toBe(false);
133 expect(fresh.paint(-5)).toBe(false);
134 expect(calls).toBe(16);
135 });
136
137 test("the painter allocates its whole buffer up front, and hands back the same one", () => {
138 const p = painter([flat(1, 2, 3), flat(4, 5, 6)], 8);
139 const bytes = p.bytes;
140 expect(bytes.length).toBe(8 * 8 * 4 * 2);
141 p.paint(8);
142 expect(p.bytes).toBe(bytes);
143 // Half painted: the first layer is opaque and the second is still zeroes.
144 expect([...bytes.slice(0, 4)]).toEqual([1, 2, 3, 255]);
145 expect([...bytes.slice(8 * 8 * 4, 8 * 8 * 4 + 4)]).toEqual([0, 0, 0, 0]);
146 expect(p.paint(8)).toBe(true);
147 expect([...bytes.slice(8 * 8 * 4, 8 * 8 * 4 + 4)]).toEqual([4, 5, 6, 255]);
148 });
149
58150 test("a shader sees uv across the layer, one texel at a time", () => {
59151 const seen: [number, number][] = [];
60152 paintAtlas([(u, v) => { seen.push([u, v]); return [0, 0, 0]; }], 4);
src/engine/texture.ts +67 −18
@@ -42,24 +42,73 @@ export const fbm = (x: number, y: number, period: number, seed: number, octaves
4242 export type Shader = (u: number, v: number) => [number, number, number];
4343
4444 /**
45 * One `size`×`size` layer per shader, tightly packed RGBA and fully opaque,
46 * ready for texImage3D. Anything a shader returns outside 0..255 is clamped,
47 * because a surface that is a little too bright should not wrap to black.
45 * An atlas being painted, a bounded amount at a time. `bytes` is the buffer it
46 * fills — allocated whole at the start, so nothing is copied or grown — and
47 * `paint(rows)` paints at most that many rows of it and says whether the whole
48 * atlas is done. A row is one row of one layer, counted straight through the
49 * stack: `paint(size)` is exactly one layer, whatever the layer count.
50 *
51 * There is no clock in here and no callback out of it. The caller decides when
52 * the next slice happens, which for a game is its own frame loop — the atlas
53 * for nine 128-pixel layers is about 170 milliseconds of arithmetic, eleven
54 * frames' worth, and a page that spends them in one call answers nothing while
55 * it does. `src/client/main.ts` paints a layer a frame through this.
56 */
57 export type Painter = {
58 /** The bytes being filled: `size * size * 4 * shaders.length` of them. */
59 readonly bytes: Uint8Array;
60 /** Rows not painted yet; zero once the atlas is finished. */
61 readonly rowsLeft: number;
62 /** Paint at most `rows` more rows. True when the whole atlas is painted. */
63 paint(rows: number): boolean;
64 };
65
66 /**
67 * A `Painter` for one `size`×`size` layer per shader, tightly packed RGBA and
68 * fully opaque, ready for texImage3D. Anything a shader returns outside 0..255
69 * is clamped, because a surface that is a little too bright should not wrap to
70 * black.
71 *
72 * This is the one loop in this file: `paintAtlas` below is this, asked for
73 * every row at once.
74 */
75 export function painter(shaders: Shader[], size: number): Painter {
76 const bytes = new Uint8Array(size * size * 4 * shaders.length);
77 const rows = size * shaders.length;
78 let row = 0;
79 return {
80 bytes,
81 get rowsLeft() {
82 return rows - row;
83 },
84 paint(count) {
85 // A slice never paints more rows than it was asked for, and a call after
86 // the last one is a no-op: `stop` is already `row`.
87 const stop = Math.min(rows, row + Math.max(0, Math.floor(count)));
88 for (; row < stop; row++) {
89 const shade = shaders[Math.floor(row / size)] as Shader;
90 const y = row % size;
91 for (let x = 0; x < size; x++) {
92 const [r, g, b] = shade(x / size, y / size);
93 const i = (row * size + x) * 4;
94 bytes[i] = Math.max(0, Math.min(255, r));
95 bytes[i + 1] = Math.max(0, Math.min(255, g));
96 bytes[i + 2] = Math.max(0, Math.min(255, b));
97 bytes[i + 3] = 255;
98 }
99 }
100 return row >= rows;
101 },
102 };
103 }
104
105 /**
106 * The whole atlas in one call: every layer, every row, painted before this
107 * returns. A game whose atlas is small enough to pay for at start-up — the
108 * range's three 64-pixel layers are about 17 ms — asks for it this way.
48109 */
49110 export function paintAtlas(shaders: Shader[], size: number): Uint8Array {
50 const out = new Uint8Array(size * size * 4 * shaders.length);
51 for (let layer = 0; layer < shaders.length; layer++) {
52 const shade = shaders[layer] as Shader;
53 for (let y = 0; y < size; y++) {
54 for (let x = 0; x < size; x++) {
55 const [r, g, b] = shade(x / size, y / size);
56 const i = (layer * size * size + y * size + x) * 4;
57 out[i] = Math.max(0, Math.min(255, r));
58 out[i + 1] = Math.max(0, Math.min(255, g));
59 out[i + 2] = Math.max(0, Math.min(255, b));
60 out[i + 3] = 255;
61 }
62 }
63 }
64 return out;
111 const whole = painter(shaders, size);
112 whole.paint(size * shaders.length);
113 return whole.bytes;
65114 }
src/games.ts +11
@@ -94,6 +94,17 @@ export type Game = {
9494 * because it is 2D: no renderer, no atlas, no sound and no net ship in it, and
9595 * `src/example/bundle.test.ts` reads the file to say so. Neither other bundle
9696 * moved a byte for it.
97 * #99 made the atlas paintable a slice at a time — `painter` in
98 * `src/engine/texture.ts`, which `paintAtlas` is now one call of — and the
99 * shooter paints a layer a frame through it instead of freezing the page for
100 * 170 ms inside `boot`. The app grew 402 raw / 184 gzipped bytes for the
101 * painter's state and the boot that steps it, and the range grew 165 raw / 71
102 * gzipped for the painter under the one call it still makes. Measured
103 * 2026-09-20 after #99: app 72,048 raw / 33,457 gzipped, range 27,433 raw /
104 * 11,745 gzipped, example 8,754 raw / 4,228 gzipped — the same size to the byte,
105 * differing only in which short names the minifier handed out, because it
106 * paints no texture and ships none of this. Both fit today's ceilings, so
107 * neither moved.
97108 */
98109 export const GAMES: readonly Game[] = [
99110 {
src/pages/game.ts +8 −1
@@ -60,6 +60,12 @@ const SIGN_IN = "/__forge/signin?to=%2F";
6060 * The menu, which says under which name the result will be recorded. A signed
6161 * in player's name is the forge's and the field only shows it; a guest picks
6262 * theirs and the page says plainly that it is listed as a guest's.
63 *
64 * Play is rendered disabled with a line under it saying why: the textures are
65 * painted a layer per frame once the bundle boots, and `src/client/main.ts`
66 * hides that line and enables the button on the frame the last layer lands.
67 * Rendered that way rather than switched on by the script so the first paint is
68 * already true and nothing moves under the pointer afterwards.
6369 */
6470 const menu = (visitor: Who): string => `
6571 <h2>Defuse or detonate</h2>
@@ -76,7 +82,8 @@ const menu = (visitor: Who): string => `
7682 <label>Side<select id="side"><option value="ct">Counter-Terrorists</option><option value="t">Terrorists</option></select></label>
7783 <label>Sensitivity<input id="sens" type="number" min="0.2" max="12" step="0.1" value="2.2"></label>
7884 </div>
79 <div class="row"><button type="button" class="primary" id="play">Play</button></div>
85 <div class="row"><button type="button" class="primary" id="play" disabled>Play</button></div>
86 <p class="hint" id="prep">Preparing textures…</p>
8087 <p class="hint"><kbd>WASD</kbd> move · <kbd>Space</kbd> jump · <kbd>Ctrl</kbd> crouch · <kbd>Shift</kbd> walk ·
8188 mouse to aim and fire · <kbd>R</kbd> reload · <kbd>E</kbd> plant or defuse · <kbd>B</kbd> buy ·
8289 <kbd>1</kbd><kbd>2</kbd><kbd>3</kbd> guns · <kbd>Tab</kbd> scores · <kbd>Esc</kbd> pause</p>
src/range/paint.test.ts +16
@@ -11,6 +11,7 @@
1111 */
1212
1313 import { expect, test } from "bun:test";
14 import { painter } from "../engine/index.ts";
1415 import { RANGE_LAYERS, RANGE_SHADERS, RANGE_TEX_SIZE, rangeAtlas } from "./paint.ts";
1516 import { RANGE_MAT, RANGE_ROOM } from "./level.ts";
1617
@@ -36,6 +37,21 @@ test("the same seeds paint the same atlas twice, byte for byte", () => {
3637 expect(digest(rangeAtlas())).toBe(ATLAS_SHA256);
3738 });
3839
40 test("these three layers are the same bytes painted a row at a time as painted whole", () => {
41 // `rangeAtlas` is one call because 17 ms at start-up is affordable, but it is
42 // the engine's `painter` underneath and a slower machine could spread it over
43 // frames the way the shooter does. These are the range's own shaders, painted
44 // both ways: a row at a time, a layer at a time, and in one slice wider than
45 // the whole atlas.
46 const whole = rangeAtlas();
47 for (const rows of [1, RANGE_TEX_SIZE, RANGE_TEX_SIZE * RANGE_LAYERS + 5]) {
48 const p = painter(RANGE_SHADERS, RANGE_TEX_SIZE);
49 while (!p.paint(rows));
50 expect(p.bytes).toEqual(whole);
51 expect(p.rowsLeft).toBe(0);
52 }
53 });
54
3955 test("the range's atlas is small on purpose, and smaller than the shooter's nine", () => {
4056 expect(RANGE_LAYERS).toBe(RANGE_SHADERS.length);
4157 expect(RANGE_LAYERS).toBe(3);
src/server.test.ts +6
@@ -58,6 +58,12 @@ test("the page carries every id and action the client reaches for", async () =>
5858 for (const id of PAGE_IDS) expect(body).toContain(`id="${id}"`);
5959 for (const action of PAGE_ACTIONS) expect(body).toContain(`data-action="${action}"`);
6060 expect(body).toContain(`<div id="stage">`);
61 // The first paint is already true about what the entry is about to do: the
62 // textures are painted over the frames after boot, so Play is served disabled
63 // and the line under it says why. src/client/main.ts undoes both when the
64 // last layer lands, and nothing on the page moves when it does.
65 expect(body).toContain(`id="play" disabled>Play</button>`);
66 expect(body).toContain(`id="prep">Preparing textures…</p>`);
6167 });
6268
6369 test("serves the range on its own route, with its own page and its own bundle", async () => {
static/app.dee3add82f.js added +58

Diff not shown — a line 59,465 characters wide (limit 2,000). +58

View file
static/app.fcd92e70fb.js deleted −58

Diff not shown — a line 59,160 characters wide (limit 2,000). −58

View file
static/example.95c913d802.js → static/example.c0d730d304.js renamed +1 −1

Diff not shown — a line 8,748 characters wide (limit 2,000). +1 −1

View file
static/range.37362a7d93.js deleted −58

Diff not shown — a line 15,483 characters wide (limit 2,000). −58

View file
static/range.f0416248f9.js added +58

Diff not shown — a line 15,483 characters wide (limit 2,000). +58

View file

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs