Split The code is public; the app answers only to the crew.

The renderer owns the frame and nobody counts it: a per-frame draw budget the recording WebGL2 context enforces #213

Closed opened this
Closed without merging

vesper/cs:vesper/196-the-renderer-owns-the-frame-and-nobody-c → main 1 commit fork

  • Closed without merging. Nothing was applied to main.

Review

Sign in to review this change.
5 files changed +466 −34
README.md +35
@@ -912,3 +912,38 @@ fourteen times today's cost.
912912 It is a regression alarm, not a target — it fails when a change to `bots.ts`,
913913 `trace.ts` or `world.ts` makes a tick an order of magnitude dearer, and it does
914914 not fail because CI was busy. It costs about a fifth of a second to run.
915
916 ### Counting the frame
917
918 The renderer owns the rest of the tick, so the rest of the tick is counted too
919 — in calls, not milliseconds. A frame is the list of GL calls it makes;
920 `src/client/draws.ts` is a pure function over that list, and
921 `src/client/renderer.test.ts` draws **one named worst frame** through the
922 recording context and holds it to four ceilings.
923
924 The worst frame is a seeded world with everything in it at once: ten players
925 alive (the camera inside one, so nine drawn), ten bodies on the floor,
926 twenty-four dropped guns — `MAX_PICKUPS`, every gun that can be lying about —
927 ten grenades in the air, the bomb planted and blinking, the decal ring **full**
928 at `DECAL_CAPACITY` bullet holes, forty tracers with their sparks and hits, and
929 every gun in the server fired on that tick. Measured, against the budget:
930
931 | what | today | budget |
932 | ------------------------------- | ----: | -----: |
933 | draws (`drawElements`/`Arrays`) | 60 | 80 |
934 | `useProgram` | 4 | 8 |
935 | uploads (`bufferData`/`Sub`) | 3 | 8 |
936 | uniforms set | 99 | 130 |
937
938 And per batch, because a total cannot tell batching from luck: 1 level,
939 19 players and bodies off one mesh, 24 pickups, 10 grenades, 1 bomb, 1 view
940 model, every bullet hole in **one** draw, and the lines in **three** — the
941 world's, the muzzle flashes over them, and the flash off your own barrel. A
942 change that stops batching fails with the name of the batch that broke.
943
944 These are regression alarms, not targets: about a third of headroom over
945 today's numbers, which is room for a pass a feature genuinely needs — #110's
946 flashbang will want one — and no room at all for a frame that quietly doubles.
947 Raising one is a one-line diff with the new number beside the old, which is the
948 sort of thing a review should argue about. Nothing here reads a clock, so none
949 of it can fail because CI was busy.
src/client/draws.test.ts added +97
@@ -0,0 +1,97 @@
1 /**
2 * The counting itself, on lists of calls written by hand: no renderer, no
3 * canvas, no GL. What can be checked here is that each of the four counts
4 * takes exactly the calls it claims — and, in particular, that a question
5 * about a uniform is not a set of one.
6 */
7
8 import { expect, test } from "bun:test";
9 import { batches, draws, frameCost, type GlCall } from "./draws.ts";
10
11 const call = (name: string, ...args: unknown[]): GlCall => ({ name, args });
12
13 /** A GL constant, as renderer.test.ts's recorder makes them. Compared, never read. */
14 const MODE = { lines: "LINES".length + 0x1000, triangles: "TRIANGLES".length + 0x1000 };
15
16 test("an empty frame costs nothing, and is not NaN or undefined", () => {
17 expect(frameCost([])).toEqual({ draws: 0, programs: 0, uploads: 0, uniforms: 0 });
18 expect(draws([])).toEqual([]);
19 expect(batches([], () => "any")).toEqual({});
20 });
21
22 test("the four counts take the calls they name and nothing else", () => {
23 const frame = [
24 call("viewport", 0, 0, 1280, 720),
25 call("clear", 0x4000),
26 call("useProgram", "scene"),
27 call("uniformMatrix4fv", "viewProj", false, new Float32Array(16)),
28 call("uniform3fv", "eye", [0, 0, 64]),
29 call("uniform3f", "tint", 1, 1, 1),
30 call("uniform1i", "atlas", 0),
31 call("bindVertexArray", "vao"),
32 call("drawElements", MODE.triangles, 900, 0x1405, 0),
33 call("bufferSubData", 0x8892, 0, new Float32Array(12), 0, 12),
34 call("drawArrays", MODE.lines, 0, 8),
35 call("bufferData", 0x8892, 4096, 0x88e4),
36 call("useProgram", "lines"),
37 ];
38 expect(frameCost(frame)).toEqual({ draws: 2, programs: 2, uploads: 2, uniforms: 4 });
39 });
40
41 test("asking where a uniform lives is not setting one", () => {
42 // `getUniformLocation` is a start-up question, and renderer.test.ts's
43 // recorder logs its answers under the bare name `uniform`. Neither is a set,
44 // and a budget that counted them would alarm on a longer shader.
45 const start = [call("getUniformLocation", "program", "uModel"), call("uniform", "program", "uTint")];
46 expect(frameCost(start).uniforms).toBe(0);
47 expect(frameCost([...start, call("uniform3f", "uTint", 1, 1, 1)]).uniforms).toBe(1);
48 });
49
50 test("a draw carries the mode it was drawn as and the count of whichever call it was", () => {
51 // drawElements(mode, count, type, offset) against drawArrays(mode, first, count).
52 const frame = [
53 call("drawElements", MODE.triangles, 900, 0x1405, 0),
54 call("drawArrays", MODE.lines, 64, 12),
55 ];
56 expect(draws(frame)).toEqual([
57 { indexed: true, mode: MODE.triangles, vertices: 900, vao: null },
58 { indexed: false, mode: MODE.lines, vertices: 12, vao: null },
59 ]);
60 // And the totals agree with the list: one frame, one number of draws in it.
61 expect(frameCost(frame).draws).toBe(draws(frame).length);
62 });
63
64 test("a draw belongs to the vertex array bound before it, until the next binding", () => {
65 const player = { vao: "player" }, lines = { vao: "lines" };
66 const frame = [
67 call("drawElements", MODE.triangles, 900, 0x1405, 0), // nothing bound yet
68 call("bindVertexArray", player),
69 call("drawElements", MODE.triangles, 252, 0x1405, 0),
70 call("drawElements", MODE.triangles, 252, 0x1405, 0), // still the same buffer
71 call("bindVertexArray", lines),
72 call("drawArrays", MODE.lines, 0, 80),
73 call("bindVertexArray", null), // the renderer's own tidying up at the end
74 ];
75 expect(draws(frame).map((d) => d.vao)).toEqual([null, player, player, lines]);
76 });
77
78 test("a draw whose count is not a number is NaN, not a silent zero", () => {
79 const [bad] = draws([call("drawArrays", MODE.lines, 0, undefined)]);
80 expect(Number.isNaN((bad as { vertices: number }).vertices)).toBe(true);
81 });
82
83 test("batches group by the name the caller gives, and name what grew", () => {
84 const frame = [
85 call("drawElements", MODE.triangles, 900, 0x1405, 0), // the level
86 call("drawElements", MODE.triangles, 240, 0x1405, 0), // three players, one mesh
87 call("drawElements", MODE.triangles, 240, 0x1405, 0),
88 call("drawElements", MODE.triangles, 240, 0x1405, 0),
89 call("drawArrays", MODE.lines, 0, 80),
90 call("drawArrays", MODE.lines, 80, 24),
91 ];
92 const name = ({ indexed, mode, vertices }: { indexed: boolean; mode: unknown; vertices: number }): string =>
93 !indexed ? (mode === MODE.lines ? "lines" : "decals") : vertices === 900 ? "level" : "player";
94 expect(batches(frame, name)).toEqual({ level: 1, player: 3, lines: 2 });
95 // Every draw lands in exactly one batch: the table sums to the total.
96 expect(Object.values(batches(frame, name)).reduce((a, b) => a + b, 0)).toBe(frameCost(frame).draws);
97 });
src/client/draws.ts added +111
@@ -0,0 +1,111 @@
1 /**
2 * What a frame cost, in calls. A frame is a list of GL calls — that is all a
3 * recording context is — and the four numbers that decide whether a laptop
4 * holds 60 fps are counts over that list: the draws, the program switches, the
5 * bytes pushed to the GPU, and the uniforms set between them.
6 *
7 * Every function here is pure and takes the calls as plain data, so the budget
8 * test reads as a table of numbers rather than as four hand-rolled filters,
9 * and so this can be tested on a list written by hand with no canvas, no
10 * renderer and no clock in sight.
11 *
12 * Nothing in the game imports this: it is the measuring instrument, not the
13 * thing measured, and it is here rather than in the test so that the counting
14 * has one definition and one test of its own.
15 */
16
17 /** One recorded call: the method's name, and the arguments it was given. */
18 export type GlCall = { name: string; args: unknown[] };
19
20 /** The four counts a per-frame budget is set on. */
21 export type FrameCost = {
22 /** `drawElements` + `drawArrays`: every batch that reached the GPU. */
23 draws: number;
24 /** `useProgram`: a pipeline switch, the dearest thing in this list. */
25 programs: number;
26 /** `bufferData` + `bufferSubData`: bytes crossing the bus inside the frame. */
27 uploads: number;
28 /** Every `uniform*` set — matrices, vectors, samplers alike. */
29 uniforms: number;
30 };
31
32 const DRAW_CALLS = new Set(["drawElements", "drawArrays"]);
33
34 const UPLOAD_CALLS = new Set(["bufferData", "bufferSubData"]);
35
36 /**
37 * `uniform1i`, `uniform3f`, `uniform3fv`, `uniformMatrix4fv` and every sibling
38 * of theirs. Deliberately not `getUniformLocation`, which asks where a uniform
39 * lives once at start-up and sets nothing, nor the bare `uniform` a recorder
40 * may log those answers under.
41 */
42 const UNIFORM_CALL = /^uniform(?:Matrix)?[1-4]/;
43
44 /** The four counts of one frame's calls, in one pass over them. */
45 export function frameCost(calls: readonly GlCall[]): FrameCost {
46 const cost: FrameCost = { draws: 0, programs: 0, uploads: 0, uniforms: 0 };
47 for (const call of calls) {
48 if (DRAW_CALLS.has(call.name)) cost.draws += 1;
49 else if (UPLOAD_CALLS.has(call.name)) cost.uploads += 1;
50 else if (call.name === "useProgram") cost.programs += 1;
51 else if (UNIFORM_CALL.test(call.name)) cost.uniforms += 1;
52 }
53 return cost;
54 }
55
56 /** One draw, with the three things that say which batch it came out of. */
57 export type Draw = {
58 /** `drawElements` — off an index buffer — rather than `drawArrays`. */
59 indexed: boolean;
60 /** The GL constant it was drawn as: triangles, lines. Compared, never read. */
61 mode: unknown;
62 /** Indices for an indexed draw, vertices for a direct one, NaN if malformed. */
63 vertices: number;
64 /**
65 * The vertex array bound when it was made, which is the buffer it came out
66 * of: `null` before anything is bound. This is the honest name of a batch —
67 * two meshes may share an index count, but never a buffer.
68 */
69 vao: unknown;
70 };
71
72 /** Every draw of the frame, in the order it was made. */
73 export function draws(calls: readonly GlCall[]): Draw[] {
74 const out: Draw[] = [];
75 // A draw belongs to whatever was bound before it, so the walk carries the
76 // last binding along: the state is the calls' own, not this function's.
77 let vao: unknown = null;
78 for (const call of calls) {
79 if (call.name === "bindVertexArray") vao = call.args[0] ?? null;
80 // drawElements(mode, count, type, offset) and drawArrays(mode, first, count):
81 // the count is in a different place, which is the whole of the difference.
82 else if (call.name === "drawElements")
83 out.push({ indexed: true, mode: call.args[0], vertices: whole(call.args[1]), vao });
84 else if (call.name === "drawArrays")
85 out.push({ indexed: false, mode: call.args[0], vertices: whole(call.args[2]), vao });
86 }
87 return out;
88 }
89
90 /** A count as the call gave it, or NaN when that argument was not a number. */
91 const whole = (value: unknown): number => (typeof value === "number" ? value : NaN);
92
93 /**
94 * The frame's draws grouped by the batch they belong to, as `name` decides:
95 * the caller knows which index count is which mesh and which mode constant is
96 * lines, so naming is its job and counting is this one's.
97 *
98 * The result is a plain object of batch name to draws, which is what a
99 * per-batch claim wants to be — `{ level: 1, player: 19, lines: 3 }` names the
100 * batch that broke when it fails, where a bare total would only say it grew.
101 */
102 export function batches(
103 calls: readonly GlCall[], name: (draw: Draw) => string,
104 ): Record<string, number> {
105 const out: Record<string, number> = {};
106 for (const draw of draws(calls)) {
107 const key = name(draw);
108 out[key] = (out[key] ?? 0) + 1;
109 }
110 return out;
111 }
src/client/renderer.test.ts +198 −34
@@ -19,10 +19,13 @@ import { addBody } from "./bodies.ts";
1919 import { advance, TICK, waiting } from "./loop.ts";
2020 import { SHORT } from "./match.ts";
2121 import { MOVE, STEP_STRIDE } from "./physics.ts";
22 import { grenadeMesh, HELD_MUZZLE, HIP_Z, levelMesh, muzzle, playerMesh, viewModelMesh } from "./models.ts";
22 import {
23 bombMesh, grenadeMesh, HELD_MUZZLE, HIP_Z, levelMesh, muzzle, pickupMesh, playerMesh, viewModelMesh,
24 } from "./models.ts";
2325 import {
2426 DECAL_CAPACITY, DECAL_FLOATS, DECAL_LIFE, DECAL_LIFT, DECAL_RADIUS, DECAL_VERTICES,
2527 } from "./decals.ts";
28 import { batches, draws, frameCost, type Draw } from "./draws.ts";
2629 import type { Vec3 } from "./math.ts";
2730 import { createRenderer, LINE_CAPACITY, type Camera, type Renderer, type RendererHooks } from "./renderer.ts";
2831 import { WEAPONS, type WeaponId } from "./weapons.ts";
@@ -828,48 +831,73 @@ test("a frame's draws are the world's, one apiece, and its passes are two progra
828831 */
829832 const FRAME_BUDGET = 80;
830833
834 /**
835 * THE WORST FRAME. One scene, named once and built from a seeded world
836 * (`scene()` is `createWorld(SHORT, "me", "ct", 5, 401)`) through nothing but
837 * the public shape of a world, so every state below is one a round can
838 * actually arrive at:
839 *
840 * ten players all alive, the camera inside one of them, so nine are drawn
841 * ten bodies more corpses than a round can hold beside ten living players
842 * twenty-four guns on the floor — `MAX_PICKUPS` in world.ts, which shifts
843 * the oldest off, so this is every gun that can lie about
844 * ten grenades every player's, in the air on the same frame
845 * the bomb planted, which is also the beam that blinks off it
846 * the decals `DECAL_CAPACITY` bullet holes, the ring full to its last slot
847 * the tracers forty rounds in flight, sixty-four sparks, twenty hits
848 * ten muzzles every gun in the server fired on this tick, the view's own
849 * included, so both flash passes are live
850 *
851 * The effects all land on a tick the renderer has not drawn yet, which is
852 * where effects come from: the world stamps them as it steps, and the frame
853 * after that step is the first to see them.
854 *
855 * Two things a worst case might be expected to carry are not here, and
856 * deliberately. The radar is `hud.ts`'s 2D canvas and costs this context
857 * nothing at all — its call budget is a different one. And the scope *removes*
858 * draws: it puts the view model and its flash away, so the worst frame is the
859 * unscoped one, and the last test below proves the scoped frame is nowhere
860 * dearer rather than leaving it to be assumed.
861 */
862 function worstRound(world: World): void {
863 expect(world.players.length).toBe(10);
864 for (const p of world.players) expect(p.alive).toBe(true);
865 for (let i = 0; i < 10; i += 1)
866 addBody(world.bodies, { id: 40 + i, team: i % 2 ? "t" : "ct", pos: [i * 72, 0, 0], yaw: i * 36 }, world.boxes, world.time);
867 for (let i = 0; i < 24; i += 1)
868 world.pickups.push({ weapon: "m4a4", pos: [i * 48, 256, 0], ammo: { mag: 30, reserve: 90 } });
869 for (const p of world.players)
870 world.grenades.push({ kind: "he", owner: p.id, team: p.team, pos: [p.pos[0], p.pos[1], 60], prev: [p.pos[0], p.pos[1], 60], vel: [0, 0, 100], fuse: 2 });
871 world.bomb.state = "planted";
872 world.bomb.pos = [512, 512, 0];
873 world.time += TICK;
874 for (let i = 0; i < 40; i += 1)
875 world.effects.push({ kind: "tracer", at: world.time, from: [0, 0, 40], to: [i * 16, 300, 40] });
876 // One impact a slot: the ring fills to its capacity on this frame, which is
877 // the most holes the level can ever be showing at once.
878 for (let i = 0; i < DECAL_CAPACITY; i += 1)
879 world.effects.push({
880 kind: "impact", at: world.time, pos: [i * 16, 300, 40], normal: [0, -1, 0], surface: true,
881 });
882 for (let i = 0; i < 20; i += 1)
883 world.effects.push({ kind: "blood", at: world.time, pos: [i * 32, 100, 40] });
884 for (const p of world.players) world.effects.push(shotBy(world, p, "ak47"));
885 world.me.lastShotAt = world.time;
886 }
887
831888 test("the worst round this game can build stays inside the frame's draw budget", () => {
832889 const { canvas, calls } = recorder();
833890 const renderer = attach(canvas);
834891 const of = linkedPrograms(calls);
835892 const world = scene();
836893 record(renderer, calls, world); // the level and the view model go up here
837
838 // Ten players, all alive — the camera is inside one, so nine are drawn.
839 expect(world.players.length).toBe(10);
840 // Ten bodies: more than a round can hold at once alongside ten living
841 // players, which is the point of a ceiling.
842 for (let i = 0; i < 10; i += 1)
843 addBody(world.bodies, { id: 40 + i, team: i % 2 ? "t" : "ct", pos: [i * 72, 0, 0], yaw: i * 36 }, world.boxes, world.time);
844 // A floor of dropped guns: `MAX_PICKUPS` in world.ts is 24 and it shifts the
845 // oldest off, so 24 is every gun that can be lying about at once.
846 for (let i = 0; i < 24; i += 1)
847 world.pickups.push({ weapon: "m4a4", pos: [i * 48, 256, 0], ammo: { mag: 30, reserve: 90 } });
848 // Every player's grenade in the air on the same frame.
849 for (const p of world.players)
850 world.grenades.push({ kind: "he", owner: p.id, team: p.team, pos: [p.pos[0], p.pos[1], 60], prev: [p.pos[0], p.pos[1], 60], vel: [0, 0, 100], fuse: 2 });
851 world.bomb.state = "planted";
852 world.bomb.pos = [512, 512, 0];
853 // A screen full of effects: forty tracers, forty sparks, twenty hits, and
854 // every gun in the server firing at once — on a tick of their own, the way
855 // the world hands them over.
856 world.time += TICK;
857 for (let i = 0; i < 40; i += 1) {
858 world.effects.push({ kind: "tracer", at: world.time, from: [0, 0, 40], to: [i * 16, 300, 40] });
859 world.effects.push({
860 kind: "impact", at: world.time, pos: [i * 16, 300, 40], normal: [0, -1, 0], surface: true,
861 });
862 }
863 for (let i = 0; i < 20; i += 1)
864 world.effects.push({ kind: "blood", at: world.time, pos: [i * 32, 100, 40] });
865 for (const p of world.players) world.effects.push(shotBy(world, p, "ak47"));
866 world.me.lastShotAt = world.time;
894 worstRound(world);
867895
868896 const terms = ruledDraws(world, world.me);
869897 const frameCalls = record(renderer, calls, world);
870 const draws = count(frameCalls, "drawElements") + count(frameCalls, "drawArrays");
871 expect(draws).toBe(total(terms));
872 expect(draws).toBeLessThanOrEqual(FRAME_BUDGET);
898 const drawn = count(frameCalls, "drawElements") + count(frameCalls, "drawArrays");
899 expect(drawn).toBe(total(terms));
900 expect(drawn).toBeLessThanOrEqual(FRAME_BUDGET);
873901 // Said again as the sum it is, so a reader can see which term grew when this
874902 // number moves: 1 level + 9 players + 10 bodies + 24 pickups + 10 grenades
875903 // + 1 bomb + every bullet hole of the round in one draw + 2 line draws +
@@ -888,6 +916,142 @@ test("the worst round this game can build stays inside the frame's draw budget",
888916 expect(count(frameCalls, "bufferData")).toBe(0);
889917 });
890918
919 /**
920 * The other three ceilings, beside the draws. Same worst frame, same rule: a
921 * regression alarm and not a target, each one today's measured number plus
922 * about a third, rounded to something a person can hold in their head.
923 *
924 * A third is the headroom because these are counts and not a clock: nothing
925 * here varies run to run, so the slack is only for the features still to come.
926 * #110's flashbang is the one that will want it — a pass has to fit, and a
927 * budget a new pass cannot fit is a budget people delete. Raising one of these
928 * on purpose is a one-line diff with the new number beside the old, which is
929 * exactly what should be argued about in a review.
930 *
931 * `useProgram` is the dearest call in the list and the one a careless pass
932 * doubles; the uploads are the bus; the uniforms are what a draw costs when
933 * the draw itself is free.
934 */
935 const PROGRAM_BUDGET = 8; // measured 4: scene, lines, scene again, lines again
936 const UPLOAD_BUDGET = 8; // measured 3: the holes, the world's lines, the view flash
937 const UNIFORM_BUDGET = 130; // measured 99: a matrix and a tint per body, and a pose per living one
938
939 /**
940 * The batches the worst frame is allowed to be split into, by name. A total
941 * cannot tell batching from luck — nineteen player draws and one line draw
942 * cost the same as one player draw and nineteen line draws — so the claim is
943 * per batch, and a regression that stops batching the lines says so by name.
944 */
945 const WORST_BATCHES: Record<string, number> = {
946 level: 1, // the whole map, one static indexed draw
947 player: 19, // nine living players and ten bodies, one mesh between them
948 pickup: 24, // a gun on the floor apiece
949 grenade: 10, // one in the air apiece
950 bomb: 1,
951 viewModel: 1, // the gun in your hands
952 decals: 1, // every bullet hole in the level, in one batch
953 lines: 3, // the world's opaque lines, its additive flashes, the view's flash
954 };
955
956 /**
957 * Which batch a draw came out of, by name. A batch is a vertex array and not
958 * an index count — a pickup, a grenade and the pistol in your hands are all
959 * 108 indices, so counting by mesh size would file three batches under one
960 * name — and the handles are *found* rather than assumed: one thing is added
961 * to a quiet world at a time, through the same public `draw` as everything
962 * else here, and whatever buffer appears is that thing's.
963 *
964 * Nothing here reads the order `build` creates its buffers in, so moving a
965 * line in `build` cannot quietly relabel a batch.
966 */
967 function batchNames(renderer: Renderer, calls: Call[]): (draw: Draw) => string {
968 const named = new Map<unknown, string>();
969 // Nobody else alive and nothing on the floor: the level and the gun in your
970 // hands, which are the first draw of a frame and the last.
971 const world = scene();
972 for (const p of world.players) if (p !== world.me) p.alive = false;
973 const quiet = draws(record(renderer, calls, world));
974 expect(quiet.length).toBe(2);
975 named.set((quiet[0] as Draw).vao, "level");
976 named.set((quiet[1] as Draw).vao, "viewModel");
977
978 const add = (label: string, put: () => void): void => {
979 put();
980 const fresh = draws(record(renderer, calls, world)).filter((d) => !named.has(d.vao));
981 // Exactly one buffer that was not there before, or this probe added two
982 // things and the name it is about to hand out is a guess.
983 expect([label, new Set(fresh.map((d) => d.vao)).size]).toEqual([label, 1]);
984 named.set((fresh[0] as Draw).vao, label);
985 };
986 add("player", () => { (world.players.find((p) => p !== world.me) as Player).alive = true; });
987 add("pickup", () => {
988 world.pickups.push({ weapon: "m4a4", pos: [0, 128, 0], ammo: { mag: 30, reserve: 90 } });
989 });
990 add("grenade", () => {
991 world.grenades.push({
992 kind: "he", owner: world.me.id, team: world.me.team,
993 pos: [0, 0, 60], prev: [0, 0, 60], vel: [0, 0, 0], fuse: 2,
994 });
995 });
996 // Dropped rather than planted: a planted bomb also blinks, and a probe that
997 // added a line batch as well would not know which of the two it had found.
998 add("bomb", () => { world.bomb.state = "dropped"; world.bomb.pos = [128, 128, 0]; });
999
1000 return (draw: Draw): string => {
1001 if (!draw.indexed) return draw.mode === MODE.lines ? "lines" : "decals";
1002 return named.get(draw.vao) ?? `an unnamed buffer of ${draw.vertices} indices`;
1003 };
1004 }
1005
1006 test("the worst frame's programs, uploads and uniforms are inside their budgets too", () => {
1007 const { canvas, calls } = recorder();
1008 const renderer = attach(canvas);
1009 const world = scene();
1010 record(renderer, calls, world); // the level and the view model go up here
1011 worstRound(world);
1012 const frameCalls = record(renderer, calls, world);
1013
1014 // The ring really is full: this is the worst the decals get, not a sample.
1015 const holes = wallHoles(frameCalls) as { vertices: number };
1016 expect(holes.vertices).toBe(DECAL_CAPACITY * DECAL_VERTICES);
1017
1018 const cost = frameCost(frameCalls);
1019 expect(cost.draws).toBeLessThanOrEqual(FRAME_BUDGET);
1020 expect(cost.programs).toBeLessThanOrEqual(PROGRAM_BUDGET);
1021 expect(cost.uploads).toBeLessThanOrEqual(UPLOAD_BUDGET);
1022 expect(cost.uniforms).toBeLessThanOrEqual(UNIFORM_BUDGET);
1023 // And the numbers themselves, so a change that halves one is as visible in
1024 // the diff as one that doubles it.
1025 expect(cost).toEqual({ draws: 60, programs: 4, uploads: 3, uniforms: 99 });
1026
1027 // Per batch, named: the failure says which one stopped batching. The names
1028 // are found after the counted frame, on the same renderer and so on the same
1029 // buffers — a probe frame cannot change what the frame above already drew.
1030 const table = batches(frameCalls, batchNames(renderer, calls));
1031 for (const [name, drawn] of Object.entries(WORST_BATCHES))
1032 expect([name, table[name] ?? 0]).toEqual([name, drawn]);
1033 expect(table).toEqual(WORST_BATCHES); // and nothing drew that is not in it
1034 expect(Object.values(table).reduce((sum, n) => sum + n, 0)).toBe(cost.draws);
1035 });
1036
1037 test("the scope costs the frame nothing: no count of a scoped frame is higher", () => {
1038 const { canvas, calls } = recorder();
1039 const renderer = attach(canvas);
1040 const world = scene();
1041 record(renderer, calls, world);
1042 worstRound(world);
1043 const open = frameCost(record(renderer, calls, world));
1044
1045 // Scoped is the same round with the view model put away: a narrower
1046 // projection is a different number in the same matrix, never another pass.
1047 world.me.scoped = true;
1048 const scoped = frameCost(record(renderer, calls, world));
1049 for (const key of ["draws", "programs", "uploads", "uniforms"] as const)
1050 expect([key, scoped[key] <= open[key]]).toEqual([key, true]);
1051 // Said exactly: the gun and its flash go, and the two passes they needed.
1052 expect(scoped).toEqual({ draws: 58, programs: 2, uploads: 2, uniforms: 94 });
1053 });
1054
8911055 // --- movement between ticks
8921056 //
8931057 // The world steps at 64 Hz and the screen draws at 60 or 120, so most frames
src/readme.test.ts +25
@@ -119,3 +119,28 @@ test("the readme says which map the policy was trained on and what that costs",
119119 const round = section("Measuring the round");
120120 for (const name of MAP_NAMES) expect(round).toContain(name);
121121 });
122
123 // The frame's four ceilings and the four numbers under them live in the test
124 // that enforces them; the README may only repeat them, so a budget raised on
125 // purpose is a raise the README cannot silently disagree with.
126 test("the readme quotes the frame's budgets and today's counts from the test that holds them", () => {
127 const source = read("src/client/renderer.test.ts");
128 const constant = (name: string): string => {
129 const found = new RegExp(`const ${name} = (\\d+);`).exec(source);
130 expect(found).not.toBeNull();
131 return (found as RegExpExecArray)[1] as string;
132 };
133 const measured = /expect\(cost\)\.toEqual\(\{ draws: (\d+), programs: (\d+), uploads: (\d+), uniforms: (\d+) \}\)/
134 .exec(source);
135 expect(measured).not.toBeNull();
136 const [, draws, programs, uploads, uniforms] = measured as RegExpExecArray;
137
138 const counted = section("Measuring the tick");
139 for (const row of [
140 [draws, constant("FRAME_BUDGET")],
141 [programs, constant("PROGRAM_BUDGET")],
142 [uploads, constant("UPLOAD_BUDGET")],
143 [uniforms, constant("UNIFORM_BUDGET")],
144 ]) expect(counted).toContain(`| ${(row[0] as string).padStart(5)} | ${(row[1] as string).padStart(6)} |`);
145 expect(counted).toContain("`src/client/draws.ts`");
146 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs