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
vesper/cs:vesper/196-the-renderer-owns-the-frame-and-nobody-c → main 1 commit fork
5 files
changed +466
−34
README.md +35
| @@ -912,3 +912,38 @@ fourteen times today's cost. | ||
| 912 | 912 | It is a regression alarm, not a target — it fails when a change to `bots.ts`, |
| 913 | 913 | `trace.ts` or `world.ts` makes a tick an order of magnitude dearer, and it does |
| 914 | 914 | 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"; | ||
| 19 | 19 | import { advance, TICK, waiting } from "./loop.ts"; |
| 20 | 20 | import { SHORT } from "./match.ts"; |
| 21 | 21 | 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"; | |
| 23 | 25 | import { |
| 24 | 26 | DECAL_CAPACITY, DECAL_FLOATS, DECAL_LIFE, DECAL_LIFT, DECAL_RADIUS, DECAL_VERTICES, |
| 25 | 27 | } from "./decals.ts"; |
| 28 | import { batches, draws, frameCost, type Draw } from "./draws.ts"; | |
| 26 | 29 | import type { Vec3 } from "./math.ts"; |
| 27 | 30 | import { createRenderer, LINE_CAPACITY, type Camera, type Renderer, type RendererHooks } from "./renderer.ts"; |
| 28 | 31 | 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 | ||
| 828 | 831 | */ |
| 829 | 832 | const FRAME_BUDGET = 80; |
| 830 | 833 | |
| 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 | ||
| 831 | 888 | test("the worst round this game can build stays inside the frame's draw budget", () => { |
| 832 | 889 | const { canvas, calls } = recorder(); |
| 833 | 890 | const renderer = attach(canvas); |
| 834 | 891 | const of = linkedPrograms(calls); |
| 835 | 892 | const world = scene(); |
| 836 | 893 | 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); | |
| 867 | 895 | |
| 868 | 896 | const terms = ruledDraws(world, world.me); |
| 869 | 897 | 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); | |
| 873 | 901 | // Said again as the sum it is, so a reader can see which term grew when this |
| 874 | 902 | // number moves: 1 level + 9 players + 10 bodies + 24 pickups + 10 grenades |
| 875 | 903 | // + 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", | ||
| 888 | 916 | expect(count(frameCalls, "bufferData")).toBe(0); |
| 889 | 917 | }); |
| 890 | 918 | |
| 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 | ||
| 891 | 1055 | // --- movement between ticks |
| 892 | 1056 | // |
| 893 | 1057 | // 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", | ||
| 119 | 119 | const round = section("Measuring the round"); |
| 120 | 120 | for (const name of MAP_NAMES) expect(round).toContain(name); |
| 121 | 121 | }); |
| 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 | }); |