Public: anyone can read this; the crew can change it.
forked from trevato/cs · proposes
The frame's plumbing becomes engine: one pooled scene, filled by a game, allocating nothing #98
Check · Review · Land · Live done — Landed and live in production.
vesper/cs-engine:vesper/78-the-frame-s-plumbing-becomes-engine-one → main 1 commit fork
14 files
changed +727
−218
README.md +20 −5
| @@ -122,7 +122,12 @@ subtends — and the panel says which of the two you are looking at. | ||
| 122 | 122 | 2D over the scene: a scale and a pad off the canvas width, scaled fonts, |
| 123 | 123 | panels, aligned and faded text, a crosshair, a bar, right-aligned columns and |
| 124 | 124 | a `m:ss` clock, drawn onto the seventeen members of a 2D context it uses and |
| 125 | holding no colour a game chose) and `renderer` (the WebGL2: two programs, an | |
| 125 | holding no colour a game chose), `scene` (a frame as plain data and the pool | |
| 126 | it is kept in: `createScene` builds both buffers whole at the two capacities a | |
| 127 | game picks, `resetScene` puts the counts back at the top of a frame, | |
| 128 | `nextInstance` hands out the next pooled instance and `pushLine` writes one | |
| 129 | line of two vertices — and at a capacity a frame draws less rather than | |
| 130 | growing, the same policy in both games) and `renderer` (the WebGL2: two programs, an | |
| 126 | 131 | atlas and named meshes uploaded once at construction, and one `Scene` of |
| 127 | 132 | instances and lines drawn per frame, each instance a mesh name with a place, a |
| 128 | 133 | yaw, a size and a vertical squash — so one uploaded mesh draws at any size, |
| @@ -162,8 +167,10 @@ subtends — and the panel says which of the two you are looking at. | ||
| 162 | 167 | `Ctor`; from `loop` `createLoop`, `Frames`, `Loop` and `LoopOptions`; from |
| 163 | 168 | `controls` `createControls`, `attachButtons` and the event and option types |
| 164 | 169 | they are driven with; from `overlay` `overlay`, `Overlay` and the `Paint` |
| 165 | a 2D game draws onto; and from `renderer` `createRenderer` with `Renderer`, | |
| 166 | `RendererAssets` and the frame it draws — `Scene`, `Camera` and `Instance`. | |
| 170 | a 2D game draws onto; from `scene` the frame and its pool — `createScene`, | |
| 171 | `resetScene`, `nextInstance`, `pushLine`, `Scene`, `SceneCapacity`, `Camera` | |
| 172 | and `Instance`; and from `renderer` `createRenderer` with `Renderer` and | |
| 173 | `RendererAssets`, which draws that frame. | |
| 167 | 174 | `boundary.test.ts` fails a deep import from outside, a |
| 168 | 175 | module that is not behind the door, and any change to that list of names that |
| 169 | 176 | is not also a change to the list written out in the test. One exception, and |
| @@ -203,7 +210,11 @@ subtends — and the panel says which of the two you are looking at. | ||
| 203 | 210 | about a frame — who is drawn, how far a duck squashes them, how long a tracer |
| 204 | 211 | lives, how far the last shot kicked the view model — is checked in |
| 205 | 212 | `scene.test.ts` with no canvas in the room. Nothing is allocated per frame: |
| 206 | `main` keeps one scene and one `Float32Array` and the instances are a pool. `main` is the frame, and it takes | |
| 213 | `main` keeps one scene, and the pool it is kept in — the instances, the line | |
| 214 | buffer and the counts — is the engine's `scene` module, which both games fill | |
| 215 | and neither reimplements. `src/frames.test.ts` drives two hundred frames of | |
| 216 | each and checks the arrays are the same objects at the end as at the start. | |
| 217 | `main` is the frame, and it takes | |
| 207 | 218 | its host: `boot(host)` is handed the document, the window, a store, a `fetch`, |
| 208 | 219 | a source of match ids and the two factories that make a renderer and a voice, |
| 209 | 220 | and the last lines of the file are the only place a browser global is named. |
| @@ -249,7 +260,11 @@ subtends — and the panel says which of the two you are looking at. | ||
| 249 | 260 | crosshair through the engine's `overlay`, which is the range proving a second |
| 250 | 261 | game gets that furniture without copying a line out of the shooter; and `main.ts` |
| 251 | 262 | wires the engine's `loop` and `controls` to the shell's ids, steps the run in |
| 252 | whole ticks and fires one shot per press along the engine's `angleVectors`. | |
| 263 | whole ticks and fires one shot per press along the engine's `angleVectors`; | |
| 264 | `scene.ts` is its frame as data — the room, every plate standing and the | |
| 265 | tracer of the last shot — filled into the engine's pool at this game's own two | |
| 266 | capacities, sixteen instances and two line vertices, because there is one shot | |
| 267 | in the air at a time. | |
| 253 | 268 | **The range makes its own noise**, through the engine's `createSoundDevice` |
| 254 | 269 | and never the shooter's recipes: `audio.ts` is five verbs — `shot`, `hit`, |
| 255 | 270 | `pop`, `tick` and `over` — over one device, each a named recipe a test can |
src/client/scene.ts +77 −48
| @@ -7,14 +7,20 @@ | ||
| 7 | 7 | * hand it a scene of its own: `Scene`, `Camera` and `Instance` are the door's |
| 8 | 8 | * types, and what is added below is only what this game keeps between frames. |
| 9 | 9 | * |
| 10 | * Nothing is allocated in a frame. `createScene()` makes the one scene and the | |
| 11 | * one line buffer a game ever needs, and `buildScene(world, camera, into)` | |
| 12 | * fills that scene in place: the instance pool grows to the busiest frame it | |
| 13 | * has seen and then stops, the vectors are written into rather than replaced, | |
| 14 | * and the view model object is kept even on the frames where there is none. | |
| 10 | * Nothing is allocated in a frame, and since #78 the bookkeeping that makes | |
| 11 | * that true is the engine's: `createScene()` here is the door's `createScene` | |
| 12 | * at this game's two capacities, and `buildScene(world, camera, into)` fills | |
| 13 | * that scene in place through `resetScene`, `nextInstance` and `pushLine`. What | |
| 14 | * is left below is this game's decisions — who is drawn, in what order, tinted | |
| 15 | * how, and how far the gun in your hands has kicked — plus the two scratch | |
| 16 | * vectors and two scratch tints a decision is written into on its way to the | |
| 17 | * pool. The view model object is kept even on the frames where there is none. | |
| 15 | 18 | */ |
| 16 | 19 | |
| 17 | import { clamp, type Camera, type Scene as Frame, type Vec3 } from "../engine/index.ts"; | |
| 20 | import { | |
| 21 | clamp, createScene as createPooledScene, nextInstance, pushLine, resetScene, | |
| 22 | type Camera, type Scene as Frame, type Vec3, | |
| 23 | } from "../engine/index.ts"; | |
| 18 | 24 | import { HULL, hullHeight } from "./physics.ts"; |
| 19 | 25 | import { WEAPONS } from "./weapons.ts"; |
| 20 | 26 | import type { World } from "./world.ts"; |
| @@ -43,6 +49,15 @@ export type Scene = Frame & { | ||
| 43 | 49 | /** Line vertices one frame may draw. Past this, an effect is simply not drawn. */ |
| 44 | 50 | export const LINE_CAPACITY = 4096; |
| 45 | 51 | |
| 52 | /** | |
| 53 | * Instances one frame may place: the level, every other living player and the | |
| 54 | * bomb, which is eleven in the 5v5 `main.ts` starts. The pool is built at this | |
| 55 | * size once and never grows, so the headroom is for a bigger match rather than | |
| 56 | * for a busy frame; past it a player is simply not drawn, which is the engine's | |
| 57 | * policy at a cap and the same one `LINE_CAPACITY` has always had. | |
| 58 | */ | |
| 59 | export const INSTANCE_CAPACITY = 32; | |
| 60 | ||
| 46 | 61 | /** The horizon, and the colour distance fades into. */ |
| 47 | 62 | export const SKY: Tint = [0.66, 0.73, 0.82]; |
| 48 | 63 | |
| @@ -54,42 +69,47 @@ const TEAM_TINT: Record<string, Tint> = { | ||
| 54 | 69 | const WHITE: Tint = [1, 1, 1]; |
| 55 | 70 | const ORIGIN: Vec3 = [0, 0, 0]; |
| 56 | 71 | |
| 57 | /** The one scene a game keeps, with the one buffer every frame refills. */ | |
| 72 | /** | |
| 73 | * The scratch a decision about a line is written into on its way to the pool: | |
| 74 | * the two ends of the line, and the colour of each end. Every effect below | |
| 75 | * fills them and hands them straight to `pushLine`, which copies the numbers | |
| 76 | * out — so a frame that draws a thousand sparks still allocates nothing. | |
| 77 | */ | |
| 78 | const FROM: Vec3 = [0, 0, 0]; | |
| 79 | const TO: Vec3 = [0, 0, 0]; | |
| 80 | const NEAR: Tint = [0, 0, 0]; | |
| 81 | const FAR: Tint = [0, 0, 0]; | |
| 82 | ||
| 83 | /** One of the scratch points, written and handed back. */ | |
| 84 | function at(into: Vec3, x: number, y: number, z: number): Vec3 { | |
| 85 | into[0] = x; into[1] = y; into[2] = z; | |
| 86 | return into; | |
| 87 | } | |
| 88 | ||
| 89 | /** One of the scratch colours, written and handed back. */ | |
| 90 | function colour(into: Tint, r: number, g: number, b: number): Tint { | |
| 91 | into[0] = r; into[1] = g; into[2] = b; | |
| 92 | return into; | |
| 93 | } | |
| 94 | ||
| 95 | /** The one scene a game keeps, at this game's two capacities. */ | |
| 58 | 96 | export function createScene(): Scene { |
| 59 | 97 | return { |
| 60 | camera: { pos: [0, 0, 0], yaw: 0, pitch: 0, fov: 90 }, | |
| 98 | ...createPooledScene({ instances: INSTANCE_CAPACITY, lineVertices: LINE_CAPACITY }), | |
| 61 | 99 | sky: [SKY[0], SKY[1], SKY[2]], |
| 62 | instances: [], | |
| 63 | instanceCount: 0, | |
| 64 | lines: new Float32Array(LINE_CAPACITY * 6), | |
| 65 | lineCount: 0, | |
| 66 | viewModel: null, | |
| 67 | 100 | keep: { bobPhase: 0, time: 0, viewModel: { mesh: "rifle", offset: [0, 0, 0] } }, |
| 68 | 101 | }; |
| 69 | 102 | } |
| 70 | 103 | |
| 71 | /** Take the next instance out of the pool — growing it once, never per frame. */ | |
| 104 | /** One mesh placed, or nothing at all once the pool is spent. */ | |
| 72 | 105 | function place(scene: Scene, mesh: string, pos: Vec3, yaw: number, scale: number, tint: Tint): void { |
| 73 | let at = scene.instances[scene.instanceCount]; | |
| 74 | if (at === undefined) { | |
| 75 | at = { mesh, pos: [0, 0, 0], yaw: 0, scale: 1, tint: [1, 1, 1] }; | |
| 76 | scene.instances.push(at); | |
| 77 | } | |
| 78 | at.mesh = mesh; | |
| 79 | at.pos[0] = pos[0]; at.pos[1] = pos[1]; at.pos[2] = pos[2]; | |
| 80 | at.yaw = yaw; | |
| 81 | at.scale = scale; | |
| 82 | at.tint[0] = tint[0]; at.tint[1] = tint[1]; at.tint[2] = tint[2]; | |
| 83 | scene.instanceCount += 1; | |
| 84 | } | |
| 85 | ||
| 86 | /** One line vertex, or nothing at all once the frame's budget is spent. */ | |
| 87 | function push(scene: Scene, x: number, y: number, z: number, r: number, g: number, b: number): void { | |
| 88 | if (scene.lineCount >= LINE_CAPACITY) return; | |
| 89 | const i = scene.lineCount * 6; | |
| 90 | scene.lines[i] = x; scene.lines[i + 1] = y; scene.lines[i + 2] = z; | |
| 91 | scene.lines[i + 3] = r; scene.lines[i + 4] = g; scene.lines[i + 5] = b; | |
| 92 | scene.lineCount += 1; | |
| 106 | const into = nextInstance(scene); | |
| 107 | if (into === null) return; | |
| 108 | into.mesh = mesh; | |
| 109 | into.pos[0] = pos[0]; into.pos[1] = pos[1]; into.pos[2] = pos[2]; | |
| 110 | into.yaw = yaw; | |
| 111 | into.scale = scale; | |
| 112 | into.tint[0] = tint[0]; into.tint[1] = tint[1]; into.tint[2] = tint[2]; | |
| 93 | 113 | } |
| 94 | 114 | |
| 95 | 115 | /** |
| @@ -106,7 +126,7 @@ export function buildScene(world: World, camera: Camera, into: Scene): Scene { | ||
| 106 | 126 | into.camera.fov = camera.fov; |
| 107 | 127 | into.sky[0] = SKY[0]; into.sky[1] = SKY[1]; into.sky[2] = SKY[2]; |
| 108 | 128 | |
| 109 | into.instanceCount = 0; | |
| 129 | resetScene(into); | |
| 110 | 130 | place(into, "level", ORIGIN, 0, 1, WHITE); |
| 111 | 131 | for (const p of world.players) { |
| 112 | 132 | // The eye is inside your own head: drawing it would fill the screen. |
| @@ -117,36 +137,45 @@ export function buildScene(world: World, camera: Camera, into: Scene): Scene { | ||
| 117 | 137 | place(into, "bomb", world.bomb.pos, 0, 1, WHITE); |
| 118 | 138 | |
| 119 | 139 | // --- tracers, sparks and the planted bomb's beam, all as lines |
| 120 | into.lineCount = 0; | |
| 121 | 140 | for (const effect of world.effects) { |
| 122 | 141 | const age = world.time - effect.at; |
| 123 | 142 | if (effect.kind === "tracer" && age < 0.08) { |
| 124 | 143 | const fade = 1 - age / 0.08; |
| 125 | push(into, effect.from[0], effect.from[1], effect.from[2], 1, 0.92, 0.6 * fade); | |
| 126 | push(into, effect.to[0], effect.to[1], effect.to[2], 1, 0.86, 0.35 * fade); | |
| 144 | pushLine( | |
| 145 | into, effect.from, effect.to, | |
| 146 | colour(NEAR, 1, 0.92, 0.6 * fade), colour(FAR, 1, 0.86, 0.35 * fade), | |
| 147 | ); | |
| 127 | 148 | } else if (effect.kind === "impact" && age < 0.25) { |
| 128 | 149 | const k = 14 * (1 - age / 0.25); |
| 129 | 150 | for (let s = 0; s < 3; s++) { |
| 130 | 151 | const a = s * 2.1 + age * 9; |
| 131 | push(into, effect.pos[0], effect.pos[1], effect.pos[2], 0.95, 0.9, 0.75); | |
| 132 | push( | |
| 152 | pushLine( | |
| 133 | 153 | into, |
| 134 | effect.pos[0] + effect.normal[0] * k + Math.cos(a) * k * 0.6, | |
| 135 | effect.pos[1] + effect.normal[1] * k + Math.sin(a) * k * 0.6, | |
| 136 | effect.pos[2] + effect.normal[2] * k + Math.sin(a * 1.7) * k * 0.6, | |
| 137 | 0.8, 0.7, 0.5, | |
| 154 | effect.pos, | |
| 155 | at( | |
| 156 | TO, | |
| 157 | effect.pos[0] + effect.normal[0] * k + Math.cos(a) * k * 0.6, | |
| 158 | effect.pos[1] + effect.normal[1] * k + Math.sin(a) * k * 0.6, | |
| 159 | effect.pos[2] + effect.normal[2] * k + Math.sin(a * 1.7) * k * 0.6, | |
| 160 | ), | |
| 161 | colour(NEAR, 0.95, 0.9, 0.75), colour(FAR, 0.8, 0.7, 0.5), | |
| 138 | 162 | ); |
| 139 | 163 | } |
| 140 | 164 | } else if (effect.kind === "blood" && age < 0.3) { |
| 141 | 165 | const k = 18 * (1 - age / 0.3); |
| 142 | push(into, effect.pos[0], effect.pos[1], effect.pos[2], 0.75, 0.08, 0.08); | |
| 143 | push(into, effect.pos[0], effect.pos[1], effect.pos[2] - k, 0.5, 0.05, 0.05); | |
| 166 | pushLine( | |
| 167 | into, effect.pos, at(TO, effect.pos[0], effect.pos[1], effect.pos[2] - k), | |
| 168 | colour(NEAR, 0.75, 0.08, 0.08), colour(FAR, 0.5, 0.05, 0.05), | |
| 169 | ); | |
| 144 | 170 | } |
| 145 | 171 | } |
| 146 | 172 | if (world.bomb.state === "planted") { |
| 147 | 173 | const blink = Math.sin(world.time * 8) > 0 ? 1 : 0.2; |
| 148 | push(into, world.bomb.pos[0], world.bomb.pos[1], world.bomb.pos[2] + 14, 1, 0.2 * blink, 0.15); | |
| 149 | push(into, world.bomb.pos[0], world.bomb.pos[1], world.bomb.pos[2] + 140, 1 * blink, 0.15, 0.1); | |
| 174 | const pos = world.bomb.pos; | |
| 175 | pushLine( | |
| 176 | into, at(FROM, pos[0], pos[1], pos[2] + 14), at(TO, pos[0], pos[1], pos[2] + 140), | |
| 177 | colour(NEAR, 1, 0.2 * blink, 0.15), colour(FAR, 1 * blink, 0.15, 0.1), | |
| 178 | ); | |
| 150 | 179 | } |
| 151 | 180 | |
| 152 | 181 | // --- the gun in your hands: a walk cycle it sways on, a kick that decays |
src/engine/GUIDE.md +46 −12
| @@ -280,6 +280,45 @@ panel. There is no colour in here a game chose, beyond one neutral scrim. | ||
| 280 | 280 | `src/range/readout.ts` is a whole panel drawn through it; the example draws |
| 281 | 281 | nothing at all, because a game that draws is a game with a page. |
| 282 | 282 | |
| 283 | ### scene | |
| 284 | ||
| 285 | A frame, as plain data, and the pool it is kept in — the one piece of a game's | |
| 286 | frame that is bookkeeping rather than a decision, so it is the engine's. | |
| 287 | ||
| 288 | `createScene` takes a `SceneCapacity`, which is the two numbers a game picks | |
| 289 | once: how many instances its busiest frame places, and how many line vertices | |
| 290 | that frame strokes. Both buffers are then built whole — every `Instance` object | |
| 291 | the pool will ever hand out, and every float of the line buffer — before the | |
| 292 | first frame runs. After that a frame is three calls and nothing else allocates: | |
| 293 | `resetScene` puts both counts back to zero at the top of it, freeing nothing and | |
| 294 | clearing nothing, because no draw reads past a count; `nextInstance` hands back | |
| 295 | the next instance out of the pool, live from that moment, for the game to write | |
| 296 | a mesh name, a place, a yaw, a squash, a size and a tint into; and `pushLine` | |
| 297 | writes one line into the vertex buffer — two vertices of six floats each, a | |
| 298 | position and a colour — taking a second colour when the far end should differ | |
| 299 | from the near one, which is how both games fade a tracer along its length. | |
| 300 | ||
| 301 | **What happens at a capacity is the same in both games, and it is the reason to | |
| 302 | pick the number for the busiest frame you can have: a full frame draws less | |
| 303 | rather than growing.** `nextInstance` answers nothing once the pool is spent and | |
| 304 | `pushLine` answers false once the buffer is; neither array grows, and a line | |
| 305 | that does not fit is not written as half a line. A refusal is a capacity that | |
| 306 | wants raising, not an error to handle. The shooter's two are at the top of | |
| 307 | `src/client/scene.ts` — the level, every other living player and the bomb, with | |
| 308 | room for a bigger match — and the range's are at the top of `src/range/scene.ts`, | |
| 309 | whose whole line buffer is one tracer: two vertices, 48 bytes, because there is | |
| 310 | one shot in the air at a time. | |
| 311 | ||
| 312 | What a `Scene` holds is then: a `Camera` — a position, a yaw, a pitch and a | |
| 313 | horizontal field of view — the sky this frame clears to and fades into, a pool | |
| 314 | of `Instance` values of which the first `Scene.instanceCount` are live, each a | |
| 315 | mesh name with a place, a yaw, a vertical squash and a tint; one | |
| 316 | `Float32Array` of line vertices of which the first `Scene.lineCount` are live, | |
| 317 | six floats each for a tracer or a spark; and `Scene.viewModel`, when there is a | |
| 318 | gun in your hands. A game's own memory of the last frame hangs off its own type | |
| 319 | beside these — the shooter keeps a walk cycle and one view model object there — | |
| 320 | and the renderer never sees it. | |
| 321 | ||
| 283 | 322 | ### renderer |
| 284 | 323 | |
| 285 | 324 | The 3D under the scene, and the largest thing behind the door. `createRenderer` |
| @@ -304,18 +343,13 @@ shape to copy: the shooter's atlas and eight meshes in `src/client/main.ts`, and | ||
| 304 | 343 | the range's atlas and two in `src/range/scene.ts`, handed to the call in |
| 305 | 344 | `src/range/main.ts`. |
| 306 | 345 | |
| 307 | Then every frame is a `Scene`: a `Camera` — a position, a yaw, a pitch and a | |
| 308 | horizontal field of view — the sky this frame clears to and fades into, a pool | |
| 309 | of `Instance` values of which the first `Scene.instanceCount` are live, each a | |
| 310 | mesh name with a place, a yaw, a vertical squash and a tint; one | |
| 311 | `Float32Array` of line vertices of which the first `Scene.lineCount` are live, | |
| 312 | six floats each for a tracer or a spark; and `Scene.viewModel`, when there is a | |
| 313 | gun in your hands. Fill one scene object every frame rather than making one — | |
| 314 | `src/client/scene.ts` allocates nothing per frame and is worth copying — name a | |
| 315 | mesh that was never handed in and the draw throws with that name in the | |
| 316 | message, and remember that nothing in a draw animates itself: a bob, a fade or | |
| 317 | a tint is a decision, and decisions are the game's. One line a frame draws it, | |
| 318 | and `src/client/main.ts` and `src/range/main.ts` are where those two lines live. | |
| 346 | Then every frame is the `Scene` above, handed to `Renderer.draw`. Make that | |
| 347 | scene once, at start-up, and refill it every frame — both games do, and neither | |
| 348 | allocates while a frame is running. Name a mesh that was never handed in and the | |
| 349 | draw throws with that name in the message, and remember that nothing in a draw | |
| 350 | animates itself: a bob, a fade or a tint is a decision, and decisions are the | |
| 351 | game's. One line a frame draws it, and `src/client/main.ts` and | |
| 352 | `src/range/main.ts` are where those two lines live. | |
| 319 | 353 | |
| 320 | 354 | ## The seam, named as pairs |
| 321 | 355 |
src/engine/boundary.test.ts +7 −4
| @@ -240,13 +240,16 @@ export const SURFACE = [ | ||
| 240 | 240 | "Box", "Builder", "Burst", "Button", "ButtonOptions", "Camera", "Controls", "Ctor", "Frames", |
| 241 | 241 | "Grid", "Hit", "Host", "Instance", "KeyEventish", "Listens", "LockOwner", "Loop", "LoopOptions", |
| 242 | 242 | "Mat4", "Mesh", "MouseEventish", "Options", "Overlay", "Paint", "Renderer", "RendererAssets", |
| 243 | "Scene", "Shader", "SoundDevice", "Stick", "Surface", "Tone", | |
| 243 | "Scene", "SceneCapacity", "Shader", "SoundDevice", "Stick", "Surface", "Tone", | |
| 244 | 244 | "TouchEventish", "Touchish", "Vec3", "add", "addBox", "angleDelta", "angleVectors", |
| 245 | 245 | "attachButtons", "attenuate", "builder", "cellAt", "clamp", "colOf", "createControls", |
| 246 | "createLoop", "createRenderer", "createSoundDevice", "cross", "deg", "dist", "dist2d", "dot", "fbm", | |
| 246 | "createLoop", "createRenderer", "createScene", "createSoundDevice", "cross", "deg", "dist", | |
| 247 | "dist2d", "dot", "fbm", | |
| 247 | 248 | "findPath", "finish", "hash", "inside", "length", "length2d", "lerp", "mat4", "mergeSolid", |
| 248 | "modelMatrix", "mul", "multiply", "noise", "normalize", "overlay", "paintAtlas", "perspective", | |
| 249 | "quadMesh", "rad", "rayBox", "rng", "rowOf", "scale", "segmentLength", "sub", "traceWorld", | |
| 249 | "modelMatrix", "mul", "multiply", "nextInstance", "noise", "normalize", "overlay", "paintAtlas", | |
| 250 | "perspective", "pushLine", | |
| 251 | "quadMesh", "rad", "rayBox", "resetScene", "rng", "rowOf", "scale", "segmentLength", "sub", | |
| 252 | "traceWorld", | |
| 250 | 253 | "v3", "viewMatrix", "visible", "worldOf", |
| 251 | 254 | ]; |
| 252 | 255 |
src/engine/index.ts +5 −1
| @@ -53,6 +53,10 @@ export type { | ||
| 53 | 53 | export { overlay } from "./overlay.ts"; |
| 54 | 54 | export type { Overlay, Paint } from "./overlay.ts"; |
| 55 | 55 | |
| 56 | /** A frame as data, in a pool: made once at a capacity, refilled, never grown. */ | |
| 57 | export { createScene, nextInstance, pushLine, resetScene } from "./scene.ts"; | |
| 58 | export type { Camera, Instance, Scene, SceneCapacity } from "./scene.ts"; | |
| 59 | ||
| 56 | 60 | /** The WebGL2 draw: an atlas and named meshes in, a frame of instances out. */ |
| 57 | 61 | export { createRenderer } from "./renderer.ts"; |
| 58 | export type { Camera, Instance, Renderer, RendererAssets, Scene } from "./renderer.ts"; | |
| 62 | export type { Renderer, RendererAssets } from "./renderer.ts"; |
src/engine/renderer.ts +6 −51
| @@ -11,6 +11,11 @@ | ||
| 11 | 11 | * tile is are all the game's; this file turns numbers into GL calls and knows |
| 12 | 12 | * no game at all. |
| 13 | 13 | * |
| 14 | * What a frame *is* — `Scene`, `Camera`, `Instance` — and the pool it is kept | |
| 15 | * in are `scene.ts` next door, because a game fills a frame whether or not | |
| 16 | * there is a GL context in the room: `src/client/scene.test.ts` builds hundreds | |
| 17 | * of them with no canvas. This file only reads one. | |
| 18 | * | |
| 14 | 19 | * A canvas and a `WebGL2RenderingContext` are named here as *types*, which cost |
| 15 | 20 | * nothing to import: the only browser object this module ever touches is the |
| 16 | 21 | * surface it is handed, and the device pixel ratio arrives as a function so a |
| @@ -19,6 +24,7 @@ | ||
| 19 | 24 | |
| 20 | 25 | import { multiply, perspective, viewMatrix, modelMatrix, type Vec3 } from "./math.ts"; |
| 21 | 26 | import type { Mesh } from "./mesh.ts"; |
| 27 | import type { Scene, Tint } from "./scene.ts"; | |
| 22 | 28 | |
| 23 | 29 | const SCENE_VS = `#version 300 es |
| 24 | 30 | precision highp float; |
| @@ -85,57 +91,6 @@ in vec3 vColor; | ||
| 85 | 91 | out vec4 outColor; |
| 86 | 92 | void main() { outColor = vec4(vColor, 1.0); }`; |
| 87 | 93 | |
| 88 | /** A colour: r, g, b, written in place by a scene rather than replaced. */ | |
| 89 | type Tint = [number, number, number]; | |
| 90 | ||
| 91 | /** The gun in your hands: which mesh, and where it sits in view space. */ | |
| 92 | type ViewModel = { mesh: string; offset: Vec3 }; | |
| 93 | ||
| 94 | /** Where the eye is and what it can see. A horizontal field of view, as CS reports it. */ | |
| 95 | export type Camera = { pos: Vec3; yaw: number; pitch: number; fov: number }; | |
| 96 | ||
| 97 | /** | |
| 98 | * One mesh, placed. `mesh` is the name the game handed that mesh in under, | |
| 99 | * `size` is how big it stands — one mesh drawn at more than one size, which is | |
| 100 | * how the range draws plates of any half-width from the one cube it uploaded — | |
| 101 | * and `scale` squashes it vertically on top of that, a duck in the shooter. | |
| 102 | * Left out, `size` is 1: a game whose meshes are already the right size never | |
| 103 | * writes it. | |
| 104 | */ | |
| 105 | export type Instance = { | |
| 106 | mesh: string; | |
| 107 | pos: Vec3; | |
| 108 | yaw: number; | |
| 109 | scale: number; | |
| 110 | tint: Tint; | |
| 111 | size?: number; | |
| 112 | }; | |
| 113 | ||
| 114 | /** | |
| 115 | * One frame, as plain data. A game fills one of these per frame and allocates | |
| 116 | * nothing doing it: `instances` is a pool of which the first `instanceCount` | |
| 117 | * are live, and `lines` is one buffer of which the first `lineCount * 6` floats | |
| 118 | * are. Anything else a game wants to remember between frames it hangs off its | |
| 119 | * own type; this is only what a draw reads. | |
| 120 | */ | |
| 121 | export type Scene = { | |
| 122 | camera: Camera; | |
| 123 | /** | |
| 124 | * What this frame clears to and what distance fades into. Left out, it is the | |
| 125 | * `sky` the renderer was built with: a game whose horizon never changes says | |
| 126 | * so once rather than every frame. | |
| 127 | */ | |
| 128 | sky?: Tint; | |
| 129 | /** The pool. Only the first `instanceCount` of these are this frame's. */ | |
| 130 | instances: Instance[]; | |
| 131 | instanceCount: number; | |
| 132 | /** Line vertices, six floats each: x, y, z, r, g, b. */ | |
| 133 | lines: Float32Array; | |
| 134 | /** Vertices written this frame — `lineCount * 6` floats of `lines` are live. */ | |
| 135 | lineCount: number; | |
| 136 | viewModel: ViewModel | null; | |
| 137 | }; | |
| 138 | ||
| 139 | 94 | /** |
| 140 | 95 | * What a game brings to a renderer, all of it uploaded once at construction. |
| 141 | 96 | * |
src/engine/scene.test.ts added +133
| @@ -0,0 +1,133 @@ | ||
| 1 | /** | |
| 2 | * The container, checked as a container. Every test here is about arithmetic on | |
| 3 | * a pool — what `createScene` builds, what `nextInstance` hands out, what | |
| 4 | * `pushLine` writes and what both of them do when there is no room left — and | |
| 5 | * not one of them names a mesh, a colour or a rule, because this module knows | |
| 6 | * none. | |
| 7 | * | |
| 8 | * The rule it exists to hold is the one a game cannot check for itself: after | |
| 9 | * the first frame, no frame allocates. So the arrays are captured and compared | |
| 10 | * by identity, and the cap is asked for one more than it holds. | |
| 11 | */ | |
| 12 | ||
| 13 | import { expect, test } from "bun:test"; | |
| 14 | import { createScene, nextInstance, pushLine, resetScene } from "./scene.ts"; | |
| 15 | import type { Tint } from "./scene.ts"; | |
| 16 | import type { Vec3 } from "./math.ts"; | |
| 17 | ||
| 18 | const RED: Tint = [1, 0, 0]; | |
| 19 | const BLUE: Tint = [0, 0, 1]; | |
| 20 | const A: Vec3 = [1, 2, 3]; | |
| 21 | const B: Vec3 = [4, 5, 6]; | |
| 22 | ||
| 23 | test("a scene is built whole: every instance and every float, before the first frame", () => { | |
| 24 | const scene = createScene({ instances: 4, lineVertices: 8 }); | |
| 25 | expect(scene.instances.length).toBe(4); | |
| 26 | expect(scene.lines.length).toBe(8 * 6); | |
| 27 | expect(scene.instanceCount).toBe(0); | |
| 28 | expect(scene.lineCount).toBe(0); | |
| 29 | expect(scene.viewModel).toBeNull(); | |
| 30 | // Every slot is an object a game can write into, not a hole. | |
| 31 | for (const instance of scene.instances) { | |
| 32 | expect(instance.pos).toEqual([0, 0, 0]); | |
| 33 | expect(instance.tint).toEqual([1, 1, 1]); | |
| 34 | expect(instance.scale).toBe(1); | |
| 35 | expect(instance.size).toBe(1); | |
| 36 | } | |
| 37 | }); | |
| 38 | ||
| 39 | test("a capacity that is not a whole number above zero is refused with the number in it", () => { | |
| 40 | for (const capacity of [ | |
| 41 | { instances: 0, lineVertices: 8 }, | |
| 42 | { instances: -1, lineVertices: 8 }, | |
| 43 | { instances: 2.5, lineVertices: 8 }, | |
| 44 | { instances: Number.NaN, lineVertices: 8 }, | |
| 45 | ]) | |
| 46 | expect(() => createScene(capacity)).toThrow(/instances must be a whole number above zero/); | |
| 47 | expect(() => createScene({ instances: 4, lineVertices: 0 })).toThrow(/lineVertices/); | |
| 48 | expect(() => createScene({ instances: 4, lineVertices: 1.5 })).toThrow(/not 1.5/); | |
| 49 | }); | |
| 50 | ||
| 51 | test("the cursor hands out the pool in order and counts what it handed out", () => { | |
| 52 | const scene = createScene({ instances: 3, lineVertices: 2 }); | |
| 53 | const first = nextInstance(scene); | |
| 54 | expect(first).toBe(scene.instances[0] as object); | |
| 55 | expect(scene.instanceCount).toBe(1); | |
| 56 | expect(nextInstance(scene)).toBe(scene.instances[1] as object); | |
| 57 | expect(scene.instanceCount).toBe(2); | |
| 58 | }); | |
| 59 | ||
| 60 | test("a frame asking for more instances than the pool holds is refused, not grown", () => { | |
| 61 | const scene = createScene({ instances: 2, lineVertices: 2 }); | |
| 62 | const pool = scene.instances; | |
| 63 | expect(nextInstance(scene)).not.toBeNull(); | |
| 64 | expect(nextInstance(scene)).not.toBeNull(); | |
| 65 | expect(nextInstance(scene)).toBeNull(); | |
| 66 | expect(nextInstance(scene)).toBeNull(); | |
| 67 | expect(scene.instances).toBe(pool); | |
| 68 | expect(scene.instances.length).toBe(2); | |
| 69 | expect(scene.instanceCount).toBe(2); | |
| 70 | }); | |
| 71 | ||
| 72 | test("a line is two vertices of six floats, and a second colour fades the far end", () => { | |
| 73 | const scene = createScene({ instances: 1, lineVertices: 4 }); | |
| 74 | expect(pushLine(scene, A, B, RED)).toBe(true); | |
| 75 | expect(scene.lineCount).toBe(2); | |
| 76 | expect([...scene.lines.slice(0, 12)]).toEqual([1, 2, 3, 1, 0, 0, 4, 5, 6, 1, 0, 0]); | |
| 77 | ||
| 78 | expect(pushLine(scene, B, A, RED, BLUE)).toBe(true); | |
| 79 | expect(scene.lineCount).toBe(4); | |
| 80 | expect([...scene.lines.slice(12, 24)]).toEqual([4, 5, 6, 1, 0, 0, 1, 2, 3, 0, 0, 1]); | |
| 81 | }); | |
| 82 | ||
| 83 | test("a full line buffer refuses whole lines and never writes half of one", () => { | |
| 84 | const scene = createScene({ instances: 1, lineVertices: 3 }); | |
| 85 | const lines = scene.lines; | |
| 86 | expect(pushLine(scene, A, B, RED)).toBe(true); | |
| 87 | // One vertex of room left, and a line wants two: nothing is written. | |
| 88 | expect(pushLine(scene, B, A, BLUE)).toBe(false); | |
| 89 | expect(scene.lineCount).toBe(2); | |
| 90 | expect(scene.lines).toBe(lines); | |
| 91 | expect(scene.lines.length).toBe(3 * 6); | |
| 92 | expect([...scene.lines.slice(12, 18)]).toEqual([0, 0, 0, 0, 0, 0]); | |
| 93 | }); | |
| 94 | ||
| 95 | test("a reset puts the counts back and frees nothing", () => { | |
| 96 | const scene = createScene({ instances: 2, lineVertices: 2 }); | |
| 97 | const held = nextInstance(scene); | |
| 98 | (held as { mesh: string }).mesh = "anything"; | |
| 99 | pushLine(scene, A, B, RED); | |
| 100 | const pool = scene.instances; | |
| 101 | const lines = scene.lines; | |
| 102 | const written = [...scene.lines]; | |
| 103 | ||
| 104 | resetScene(scene); | |
| 105 | expect(scene.instanceCount).toBe(0); | |
| 106 | expect(scene.lineCount).toBe(0); | |
| 107 | expect(scene.instances).toBe(pool); | |
| 108 | expect(scene.lines).toBe(lines); | |
| 109 | // Last frame's numbers are still there: nothing reads past a count. | |
| 110 | expect([...scene.lines]).toEqual(written); | |
| 111 | expect(nextInstance(scene)).toBe(held as object); | |
| 112 | expect((held as { mesh: string }).mesh).toBe("anything"); | |
| 113 | }); | |
| 114 | ||
| 115 | test("two hundred frames on one scene allocate nothing at all", () => { | |
| 116 | const scene = createScene({ instances: 8, lineVertices: 16 }); | |
| 117 | const pool = scene.instances; | |
| 118 | const objects = [...pool]; | |
| 119 | const lines = scene.lines; | |
| 120 | ||
| 121 | for (let frame = 0; frame < 200; frame++) { | |
| 122 | resetScene(scene); | |
| 123 | // A frame that wants more of both than there is, every time. | |
| 124 | for (let i = 0; i < 12; i++) nextInstance(scene); | |
| 125 | for (let i = 0; i < 12; i++) pushLine(scene, A, B, RED); | |
| 126 | expect(scene.instanceCount).toBe(8); | |
| 127 | expect(scene.lineCount).toBe(16); | |
| 128 | } | |
| 129 | expect(scene.instances).toBe(pool); | |
| 130 | expect([...scene.instances]).toEqual(objects); | |
| 131 | expect(scene.lines).toBe(lines); | |
| 132 | expect(scene.lines.length).toBe(16 * 6); | |
| 133 | }); |
src/engine/scene.ts added +170
| @@ -0,0 +1,170 @@ | ||
| 1 | /** | |
| 2 | * A frame, as data, and the pool it is kept in. `Scene` is what a game fills | |
| 3 | * every frame and `src/engine/renderer.ts` is what reads it; the four functions | |
| 4 | * here are the bookkeeping both games wrote for themselves before this file | |
| 5 | * existed — one scene made once, a pool of instances, a cursor that hands out | |
| 6 | * the next one, a `Float32Array` of line vertices with a count, and a reset at | |
| 7 | * the top of every frame. | |
| 8 | * | |
| 9 | * **Nothing is allocated in a frame.** `createScene` takes the two capacities a | |
| 10 | * game has to choose and builds both buffers whole: every `Instance` object the | |
| 11 | * pool will ever hand out, and every float of the line buffer. After that a | |
| 12 | * frame is `resetScene`, then writes. | |
| 13 | * | |
| 14 | * **What happens at the cap is the same in both directions, and it is this: a | |
| 15 | * full frame draws less rather than growing.** `nextInstance` answers `null` | |
| 16 | * once the pool is spent and `pushLine` answers `false` once the line buffer | |
| 17 | * is, both without touching the arrays. A frame is 16 milliseconds and a | |
| 18 | * capacity is a number a game picked; dropping the twelfth plate is a frame a | |
| 19 | * player barely reads, and an allocation in a frame is a stutter they do. Pick | |
| 20 | * the capacity for the busiest frame the game can have — the two games here say | |
| 21 | * in their own scene modules how they arrived at theirs — and treat a refusal | |
| 22 | * as a number that wants raising, not as an error to handle. | |
| 23 | * | |
| 24 | * It knows no game: no colour, no mesh name, no world and no rule. What goes in | |
| 25 | * a frame is every game's own decision, and both `src/client/scene.ts` and | |
| 26 | * `src/range/scene.ts` keep all of theirs. | |
| 27 | */ | |
| 28 | ||
| 29 | import type { Vec3 } from "./math.ts"; | |
| 30 | ||
| 31 | /** A colour: r, g, b, written in place by a scene rather than replaced. */ | |
| 32 | export type Tint = [number, number, number]; | |
| 33 | ||
| 34 | /** The gun in your hands: which mesh, and where it sits in view space. */ | |
| 35 | export type ViewModel = { mesh: string; offset: Vec3 }; | |
| 36 | ||
| 37 | /** Where the eye is and what it can see. A horizontal field of view, as CS reports it. */ | |
| 38 | export type Camera = { pos: Vec3; yaw: number; pitch: number; fov: number }; | |
| 39 | ||
| 40 | /** | |
| 41 | * One mesh, placed. `mesh` is the name the game handed that mesh in under, | |
| 42 | * `size` is how big it stands — one mesh drawn at more than one size, which is | |
| 43 | * how the range draws plates of any half-width from the one cube it uploaded — | |
| 44 | * and `scale` squashes it vertically on top of that, a duck in the shooter. | |
| 45 | * Left out, `size` is 1: a game whose meshes are already the right size never | |
| 46 | * writes it. | |
| 47 | */ | |
| 48 | export type Instance = { | |
| 49 | mesh: string; | |
| 50 | pos: Vec3; | |
| 51 | yaw: number; | |
| 52 | scale: number; | |
| 53 | tint: Tint; | |
| 54 | size?: number; | |
| 55 | }; | |
| 56 | ||
| 57 | /** | |
| 58 | * One frame, as plain data. A game fills one of these per frame and allocates | |
| 59 | * nothing doing it: `instances` is a pool of which the first `instanceCount` | |
| 60 | * are live, and `lines` is one buffer of which the first `lineCount * 6` floats | |
| 61 | * are. Anything else a game wants to remember between frames it hangs off its | |
| 62 | * own type; this is only what a draw reads. | |
| 63 | */ | |
| 64 | export type Scene = { | |
| 65 | camera: Camera; | |
| 66 | /** | |
| 67 | * What this frame clears to and what distance fades into. Left out, it is the | |
| 68 | * `sky` the renderer was built with: a game whose horizon never changes says | |
| 69 | * so once rather than every frame. | |
| 70 | */ | |
| 71 | sky?: Tint; | |
| 72 | /** The pool. Only the first `instanceCount` of these are this frame's. */ | |
| 73 | instances: Instance[]; | |
| 74 | instanceCount: number; | |
| 75 | /** Line vertices, six floats each: x, y, z, r, g, b. */ | |
| 76 | lines: Float32Array; | |
| 77 | /** Vertices written this frame — `lineCount * 6` floats of `lines` are live. */ | |
| 78 | lineCount: number; | |
| 79 | viewModel: ViewModel | null; | |
| 80 | }; | |
| 81 | ||
| 82 | /** | |
| 83 | * The two numbers a game picks once: how many instances its busiest frame | |
| 84 | * places, and how many line vertices that frame strokes. A line is two | |
| 85 | * vertices, so a game counting lines doubles. | |
| 86 | */ | |
| 87 | export type SceneCapacity = { instances: number; lineVertices: number }; | |
| 88 | ||
| 89 | /** Why this capacity is not one, or null when it is. */ | |
| 90 | function wrongCapacity(capacity: SceneCapacity): string | null { | |
| 91 | for (const key of ["instances", "lineVertices"] as const) { | |
| 92 | const value = capacity[key]; | |
| 93 | if (!Number.isInteger(value) || value <= 0) | |
| 94 | return `scene capacity: ${key} must be a whole number above zero, not ${value}`; | |
| 95 | } | |
| 96 | return null; | |
| 97 | } | |
| 98 | ||
| 99 | /** | |
| 100 | * The one scene a game keeps, both buffers built whole. The camera starts at | |
| 101 | * the origin looking down +x with a 90° field of view and there is no view | |
| 102 | * model: a game writes every one of those in its first frame anyway, and a | |
| 103 | * default that is not a game's decision is better than an argument nobody uses. | |
| 104 | */ | |
| 105 | export function createScene(capacity: SceneCapacity): Scene { | |
| 106 | const wrong = wrongCapacity(capacity); | |
| 107 | if (wrong !== null) throw new Error(wrong); | |
| 108 | const instances: Instance[] = []; | |
| 109 | for (let i = 0; i < capacity.instances; i++) | |
| 110 | instances.push({ mesh: "", pos: [0, 0, 0], yaw: 0, scale: 1, size: 1, tint: [1, 1, 1] }); | |
| 111 | return { | |
| 112 | camera: { pos: [0, 0, 0], yaw: 0, pitch: 0, fov: 90 }, | |
| 113 | instances, | |
| 114 | instanceCount: 0, | |
| 115 | lines: new Float32Array(capacity.lineVertices * 6), | |
| 116 | lineCount: 0, | |
| 117 | viewModel: null, | |
| 118 | }; | |
| 119 | } | |
| 120 | ||
| 121 | /** | |
| 122 | * Every count back to zero, at the top of a frame. Nothing is freed and nothing | |
| 123 | * is cleared: what is past a count is last frame's numbers, which no draw | |
| 124 | * reads. The view model is left alone — whether there is a gun in your hands is | |
| 125 | * a decision, and a decision is the game's. | |
| 126 | */ | |
| 127 | export function resetScene(scene: Scene): void { | |
| 128 | scene.instanceCount = 0; | |
| 129 | scene.lineCount = 0; | |
| 130 | } | |
| 131 | ||
| 132 | /** | |
| 133 | * The next instance out of the pool, live from this moment, or `null` when the | |
| 134 | * pool is spent. What comes back still holds the numbers the last frame that | |
| 135 | * used it wrote, so fill every field the draw reads — which both games do. | |
| 136 | */ | |
| 137 | export function nextInstance(scene: Scene): Instance | null { | |
| 138 | const at = scene.instances[scene.instanceCount]; | |
| 139 | if (at === undefined) return null; | |
| 140 | scene.instanceCount += 1; | |
| 141 | return at; | |
| 142 | } | |
| 143 | ||
| 144 | /** | |
| 145 | * One line into the buffer: two vertices, six floats each. `to` takes | |
| 146 | * `toColour` when a second colour is given, which is how a tracer fades along | |
| 147 | * its length; left out, both ends are `colour`. | |
| 148 | * | |
| 149 | * True when it was written, false when the buffer had no room for both | |
| 150 | * vertices — in which case nothing at all was written, so a line is never half | |
| 151 | * a line. Neither array grows, ever. | |
| 152 | */ | |
| 153 | export function pushLine( | |
| 154 | scene: Scene, | |
| 155 | from: Vec3, | |
| 156 | to: Vec3, | |
| 157 | colour: Tint, | |
| 158 | toColour: Tint = colour, | |
| 159 | ): boolean { | |
| 160 | if ((scene.lineCount + 2) * 6 > scene.lines.length) return false; | |
| 161 | const lines = scene.lines; | |
| 162 | let i = scene.lineCount * 6; | |
| 163 | lines[i] = from[0]; lines[i + 1] = from[1]; lines[i + 2] = from[2]; | |
| 164 | lines[i + 3] = colour[0]; lines[i + 4] = colour[1]; lines[i + 5] = colour[2]; | |
| 165 | i += 6; | |
| 166 | lines[i] = to[0]; lines[i + 1] = to[1]; lines[i + 2] = to[2]; | |
| 167 | lines[i + 3] = toColour[0]; lines[i + 4] = toColour[1]; lines[i + 5] = toColour[2]; | |
| 168 | scene.lineCount += 2; | |
| 169 | return true; | |
| 170 | } |
src/frames.test.ts added +150
| @@ -0,0 +1,150 @@ | ||
| 1 | /** | |
| 2 | * Two hundred frames, no allocation — for both games at once, which is why this | |
| 3 | * test sits at the root beside `src/independence.test.ts` rather than in either | |
| 4 | * game's directory. Neither game may import the other, but the app may read | |
| 5 | * both, and the claim here is about the two of them together: since #78 they | |
| 6 | * share one pool — `createScene`, `resetScene`, `nextInstance` and `pushLine` | |
| 7 | * behind the engine's door — and the promise that pool makes is that after the | |
| 8 | * first frame nothing in a frame is ever allocated again. | |
| 9 | * | |
| 10 | * `src/engine/scene.test.ts` proves the container keeps that promise. This | |
| 11 | * proves the two games actually use it: the scene each game builds is driven | |
| 12 | * two hundred times, hungry frames and quiet ones, and the instance array and | |
| 13 | * the line buffer at the end are the same objects — and the same length — as at | |
| 14 | * the start. Then each is asked for more than its pool holds, and refuses. | |
| 15 | * | |
| 16 | * Identity is the whole assertion. A pool that quietly grew, a buffer that was | |
| 17 | * replaced with a bigger one, or a scene object rebuilt inside `buildScene` | |
| 18 | * would all pass every test about what a frame looks like and fail here. | |
| 19 | */ | |
| 20 | ||
| 21 | import { expect, test } from "bun:test"; | |
| 22 | import type { Camera } from "./engine/index.ts"; | |
| 23 | import { INSTANCE_CAPACITY, LINE_CAPACITY, buildScene, createScene } from "./client/scene.ts"; | |
| 24 | import { SHORT } from "./client/match.ts"; | |
| 25 | import { createWorld, type Player, type World } from "./client/world.ts"; | |
| 26 | import { | |
| 27 | INSTANCE_CAPACITY as RANGE_INSTANCES, LINE_CAPACITY as RANGE_LINES, | |
| 28 | buildRangeScene, createRangeScene, | |
| 29 | } from "./range/scene.ts"; | |
| 30 | import { EYE, PLATE_MAT, createRun, type Run, type Target } from "./range/targets.ts"; | |
| 31 | ||
| 32 | const FRAMES = 200; | |
| 33 | ||
| 34 | const camera = (): Camera => ({ pos: [10, 20, 30], yaw: 45, pitch: -10, fov: 90 }); | |
| 35 | ||
| 36 | /** A match with `crowd` extra bodies standing in it, all of them drawable. */ | |
| 37 | function crowded(crowd: number): World { | |
| 38 | const world = createWorld(SHORT, "you", "ct", 5, 7); | |
| 39 | world.time = 100; | |
| 40 | world.effects.length = 0; | |
| 41 | const bot = world.players.find((p) => p !== world.me) as Player; | |
| 42 | for (let i = 0; i < crowd; i++) world.players.push({ ...bot, id: 100 + i }); | |
| 43 | return world; | |
| 44 | } | |
| 45 | ||
| 46 | /** A run standing `count` plates down the lane, however many that is. */ | |
| 47 | function standing(count: number): Run { | |
| 48 | const run = createRun(1); | |
| 49 | run.phase = "live"; | |
| 50 | run.targets = []; | |
| 51 | for (let i = 0; i < count; i++) { | |
| 52 | const x = 400 + i * 20, y = i * 7, z = 60, half = 18; | |
| 53 | const target: Target = { | |
| 54 | id: i + 1, | |
| 55 | box: { min: [x - half, y - half, z - half], max: [x + half, y + half, z + half], mat: PLATE_MAT }, | |
| 56 | life: 1, span: 2, points: 100, distance: x, | |
| 57 | }; | |
| 58 | run.targets.push(target); | |
| 59 | } | |
| 60 | return run; | |
| 61 | } | |
| 62 | ||
| 63 | test("two hundred frames of the shooter leave the same pool and the same buffer", () => { | |
| 64 | const world = crowded(0); | |
| 65 | const scene = createScene(); | |
| 66 | buildScene(world, camera(), scene); | |
| 67 | const pool = scene.instances; | |
| 68 | const objects = [...pool]; | |
| 69 | const lines = scene.lines; | |
| 70 | ||
| 71 | let busiest = 0; | |
| 72 | for (let frame = 0; frame < FRAMES; frame++) { | |
| 73 | // Alternating: a frame with every effect this game draws, then a bare one. | |
| 74 | world.effects.length = 0; | |
| 75 | if (frame % 2 === 0) { | |
| 76 | world.bomb.state = "planted"; | |
| 77 | world.bomb.pos = [500, 600, 20]; | |
| 78 | for (let i = 0; i < 400; i++) { | |
| 79 | world.effects.push({ kind: "tracer", from: [i, 0, 0], to: [i, 1, 0], at: world.time - 0.01 }); | |
| 80 | world.effects.push({ kind: "impact", at: world.time - 0.1, pos: [i, 2, 3], normal: [0, 0, 1] }); | |
| 81 | world.effects.push({ kind: "blood", at: world.time - 0.1, pos: [i, 2, 3] }); | |
| 82 | } | |
| 83 | } else { | |
| 84 | world.bomb.state = "carried"; | |
| 85 | } | |
| 86 | buildScene(world, camera(), scene); | |
| 87 | busiest = Math.max(busiest, scene.lineCount); | |
| 88 | } | |
| 89 | ||
| 90 | expect(scene.instances).toBe(pool); | |
| 91 | expect(scene.instances.length).toBe(INSTANCE_CAPACITY); | |
| 92 | expect([...scene.instances]).toEqual(objects); | |
| 93 | expect(scene.lines).toBe(lines); | |
| 94 | expect(scene.lines.length).toBe(LINE_CAPACITY * 6); | |
| 95 | // The busy frames really were busy — a thousand effects is two thousand lines, | |
| 96 | // all but the last handful of the budget — and the quiet ones really were quiet. | |
| 97 | expect(busiest).toBeGreaterThan(4000); | |
| 98 | expect(busiest).toBeLessThanOrEqual(LINE_CAPACITY); | |
| 99 | expect(scene.lineCount).toBe(0); | |
| 100 | }); | |
| 101 | ||
| 102 | test("two hundred frames of the range leave the same pool and the same buffer", () => { | |
| 103 | const scene = createRangeScene(); | |
| 104 | const busy = standing(3); | |
| 105 | const empty = standing(0); | |
| 106 | buildRangeScene(busy, { yaw: 0, pitch: 0 }, null, 0, scene); | |
| 107 | const pool = scene.instances; | |
| 108 | const objects = [...pool]; | |
| 109 | const lines = scene.lines; | |
| 110 | ||
| 111 | for (let frame = 0; frame < FRAMES; frame++) { | |
| 112 | const trail = frame % 2 === 0 ? { from: EYE, to: [900, 0, 60] as [number, number, number], at: frame } : null; | |
| 113 | buildRangeScene(frame % 3 === 0 ? empty : busy, { yaw: frame, pitch: 0 }, trail, frame, scene); | |
| 114 | } | |
| 115 | ||
| 116 | expect(scene.instances).toBe(pool); | |
| 117 | expect(scene.instances.length).toBe(RANGE_INSTANCES); | |
| 118 | expect([...scene.instances]).toEqual(objects); | |
| 119 | expect(scene.lines).toBe(lines); | |
| 120 | expect(scene.lines.length).toBe(RANGE_LINES * 6); | |
| 121 | }); | |
| 122 | ||
| 123 | test("a frame that wants more than its pool holds is refused, in both games", () => { | |
| 124 | // The shooter: a crowd twice the pool, and the pool does not move. | |
| 125 | const world = crowded(INSTANCE_CAPACITY * 2); | |
| 126 | const scene = createScene(); | |
| 127 | const pool = scene.instances; | |
| 128 | buildScene(world, camera(), scene); | |
| 129 | expect(scene.instances).toBe(pool); | |
| 130 | expect(scene.instances.length).toBe(INSTANCE_CAPACITY); | |
| 131 | expect(scene.instanceCount).toBe(INSTANCE_CAPACITY); | |
| 132 | ||
| 133 | // And a flood of effects fills the line buffer without replacing it. | |
| 134 | for (let i = 0; i < 5000; i++) | |
| 135 | world.effects.push({ kind: "tracer", from: [i, 0, 0], to: [i, 1, 0], at: world.time - 0.01 }); | |
| 136 | const lines = scene.lines; | |
| 137 | buildScene(world, camera(), scene); | |
| 138 | expect(scene.lines).toBe(lines); | |
| 139 | expect(scene.lines.length).toBe(LINE_CAPACITY * 6); | |
| 140 | expect(scene.lineCount).toBe(LINE_CAPACITY); | |
| 141 | ||
| 142 | // The range: more plates standing than the pool holds, and it draws the ones | |
| 143 | // that fit — the room and fifteen plates — rather than growing for the rest. | |
| 144 | const range = createRangeScene(); | |
| 145 | const rangePool = range.instances; | |
| 146 | buildRangeScene(standing(RANGE_INSTANCES * 2), { yaw: 0, pitch: 0 }, null, 0, range); | |
| 147 | expect(range.instances).toBe(rangePool); | |
| 148 | expect(range.instances.length).toBe(RANGE_INSTANCES); | |
| 149 | expect(range.instanceCount).toBe(RANGE_INSTANCES); | |
| 150 | }); |
src/games.ts +6
| @@ -63,6 +63,12 @@ export type Game = { | ||
| 63 | 63 | * ceiling anybody is holding — so it is today's size plus about 10% again. |
| 64 | 64 | * Raising a ceiling is allowed, and it is a line in the pull that says what was |
| 65 | 65 | * added and why. |
| 66 | * #78 moved the frame's pool behind the door — `createScene`, `resetScene`, | |
| 67 | * `nextInstance` and `pushLine`, which both games now fill instead of each | |
| 68 | * keeping its own arithmetic — and both bundles grew by about 580 raw bytes for | |
| 69 | * it, because the container ships whole to a game that uses any of it. Measured | |
| 70 | * 2026-09-18 after #78: app 71,186 raw / 33,125 gzipped, range 27,234 raw / | |
| 71 | * 11,655 gzipped. Both fit today's ceilings, so neither moved. | |
| 66 | 72 | */ |
| 67 | 73 | export const GAMES: readonly Game[] = [ |
| 68 | 74 | { |
src/range/scene.ts +44 −34
| @@ -11,12 +11,17 @@ | ||
| 11 | 11 | * is. No canvas, no GL, no colour a renderer chose: `scene.test.ts` reads every |
| 12 | 12 | * frame below with no browser in the room. |
| 13 | 13 | * |
| 14 | * Nothing is allocated in a frame, as the shooter's scene established: | |
| 15 | * `createRangeScene()` makes the one scene, the one instance pool and the one | |
| 16 | * line buffer the page ever needs, and `buildRangeScene` writes into them. | |
| 14 | * Nothing is allocated in a frame, and since #78 the pool that makes that true | |
| 15 | * is the engine's: `createRangeScene()` is the door's `createScene` at this | |
| 16 | * game's two capacities, and `buildRangeScene` fills it through `resetScene`, | |
| 17 | * `nextInstance` and `pushLine`. What is decided here is only what goes in a | |
| 18 | * frame. | |
| 17 | 19 | */ |
| 18 | 20 | |
| 19 | import { type Mesh, type RendererAssets, type Scene, type Vec3 } from "../engine/index.ts"; | |
| 21 | import { | |
| 22 | createScene, nextInstance, pushLine, resetScene, | |
| 23 | type Mesh, type RendererAssets, type Scene, type Vec3, | |
| 24 | } from "../engine/index.ts"; | |
| 20 | 25 | import { roomMesh, targetMesh } from "./level.ts"; |
| 21 | 26 | import { RANGE_LAYERS, RANGE_TEX_SIZE, rangeAtlas } from "./paint.ts"; |
| 22 | 27 | import { EYE, STANDARD, type Run } from "./targets.ts"; |
| @@ -47,6 +52,14 @@ export const TRACER_LIFE = 0.08; | ||
| 47 | 52 | */ |
| 48 | 53 | export const LINE_CAPACITY = 2; |
| 49 | 54 | |
| 55 | /** | |
| 56 | * Instances one frame may place: the room, and one plate for every target | |
| 57 | * standing. `STANDARD.atOnce` is three, so a standard frame is four; the | |
| 58 | * headroom is for a `Rules` that stands more, and past it a plate is simply not | |
| 59 | * drawn — the engine's policy at a cap, which the tracer above has always had. | |
| 60 | */ | |
| 61 | export const INSTANCE_CAPACITY = 16; | |
| 62 | ||
| 50 | 63 | /** |
| 51 | 64 | * The half-width the plate mesh is built at. Every plate is drawn from that one |
| 52 | 65 | * cube, sized by `Instance.size` to the half-width its box actually has — so a |
| @@ -86,6 +99,13 @@ const GOING: Tint = [0.9, 0.7, 0.55]; | ||
| 86 | 99 | const ORIGIN: Vec3 = [0, 0, 0]; |
| 87 | 100 | /** The one vector a frame writes a plate's centre into: allocation-free, as #47 asked. */ |
| 88 | 101 | const PLACE: Vec3 = [0, 0, 0]; |
| 102 | /** | |
| 103 | * The tracer's two ends: hot white-yellow at the muzzle, thinner at the far | |
| 104 | * end. Only the blue of each fades, so a frame writes one number into each of | |
| 105 | * these and hands them to `pushLine` — two colours kept, none allocated. | |
| 106 | */ | |
| 107 | const NEAR: Tint = [1, 0.92, 0.6]; | |
| 108 | const FAR: Tint = [1, 0.86, 0.35]; | |
| 89 | 109 | |
| 90 | 110 | /** Past this fraction of its life a plate is drawn dim: the readout's 0.35, once. */ |
| 91 | 111 | const FADING = 0.35; |
| @@ -108,42 +128,32 @@ export const rangeAssets = (): Omit<RendererAssets, "devicePixelRatio"> => ({ | ||
| 108 | 128 | sky: RANGE_SKY, |
| 109 | 129 | }); |
| 110 | 130 | |
| 111 | /** The one scene the page keeps, with the one buffer every frame refills. */ | |
| 131 | /** The one scene the page keeps, at this game's two capacities. */ | |
| 112 | 132 | export function createRangeScene(): RangeScene { |
| 113 | return { | |
| 114 | camera: { pos: [EYE[0], EYE[1], EYE[2]], yaw: 0, pitch: 0, fov: RANGE_FOV }, | |
| 115 | sky: [RANGE_SKY[0], RANGE_SKY[1], RANGE_SKY[2]], | |
| 116 | instances: [], | |
| 117 | instanceCount: 0, | |
| 118 | lines: new Float32Array(LINE_CAPACITY * 6), | |
| 119 | lineCount: 0, | |
| 120 | viewModel: null, | |
| 133 | const scene = { | |
| 134 | ...createScene({ instances: INSTANCE_CAPACITY, lineVertices: LINE_CAPACITY }), | |
| 135 | sky: [RANGE_SKY[0], RANGE_SKY[1], RANGE_SKY[2]] as Tint, | |
| 121 | 136 | }; |
| 137 | // The pool's camera starts at the origin, which is nobody's decision; the | |
| 138 | // firing line and this game's field of view are written over it here and | |
| 139 | // again in every frame. | |
| 140 | scene.camera.pos[0] = EYE[0]; | |
| 141 | scene.camera.pos[1] = EYE[1]; | |
| 142 | scene.camera.pos[2] = EYE[2]; | |
| 143 | scene.camera.fov = RANGE_FOV; | |
| 144 | return scene; | |
| 122 | 145 | } |
| 123 | 146 | |
| 124 | /** Take the next instance out of the pool — growing it once, never per frame. */ | |
| 147 | /** One mesh placed, or nothing at all once the pool is spent. */ | |
| 125 | 148 | function place(scene: RangeScene, mesh: string, pos: Vec3, size: number, tint: Tint): void { |
| 126 | let at = scene.instances[scene.instanceCount]; | |
| 127 | if (at === undefined) { | |
| 128 | at = { mesh, pos: [0, 0, 0], yaw: 0, scale: 1, size: 1, tint: [1, 1, 1] }; | |
| 129 | scene.instances.push(at); | |
| 130 | } | |
| 149 | const at = nextInstance(scene); | |
| 150 | if (at === null) return; | |
| 131 | 151 | at.mesh = mesh; |
| 132 | 152 | at.pos[0] = pos[0]; at.pos[1] = pos[1]; at.pos[2] = pos[2]; |
| 133 | 153 | at.yaw = 0; |
| 134 | 154 | at.scale = 1; |
| 135 | 155 | at.size = size; |
| 136 | 156 | at.tint[0] = tint[0]; at.tint[1] = tint[1]; at.tint[2] = tint[2]; |
| 137 | scene.instanceCount += 1; | |
| 138 | } | |
| 139 | ||
| 140 | /** One line vertex, or nothing at all once the frame's budget is spent. */ | |
| 141 | function push(scene: RangeScene, x: number, y: number, z: number, r: number, g: number, b: number): void { | |
| 142 | if (scene.lineCount >= LINE_CAPACITY) return; | |
| 143 | const i = scene.lineCount * 6; | |
| 144 | scene.lines[i] = x; scene.lines[i + 1] = y; scene.lines[i + 2] = z; | |
| 145 | scene.lines[i + 3] = r; scene.lines[i + 4] = g; scene.lines[i + 5] = b; | |
| 146 | scene.lineCount += 1; | |
| 147 | 157 | } |
| 148 | 158 | |
| 149 | 159 | /** |
| @@ -171,7 +181,7 @@ export function buildRangeScene( | ||
| 171 | 181 | const sky = into.sky; |
| 172 | 182 | sky[0] = RANGE_SKY[0]; sky[1] = RANGE_SKY[1]; sky[2] = RANGE_SKY[2]; |
| 173 | 183 | |
| 174 | into.instanceCount = 0; | |
| 184 | resetScene(into); | |
| 175 | 185 | place(into, MESH.ROOM, ORIGIN, 1, ROOM_LIT); |
| 176 | 186 | for (const target of run.targets) { |
| 177 | 187 | const box = target.box; |
| @@ -183,14 +193,14 @@ export function buildRangeScene( | ||
| 183 | 193 | place(into, MESH.PLATE, PLACE, half / PLATE_HALF, left > FADING ? PLATE_LIT : GOING); |
| 184 | 194 | } |
| 185 | 195 | |
| 186 | // --- the tracer: two vertices, from the eye to wherever the bullet stopped | |
| 187 | into.lineCount = 0; | |
| 196 | // --- the tracer: one line, from the eye to wherever the bullet stopped | |
| 188 | 197 | if (trail !== null) { |
| 189 | 198 | const age = now - trail.at; |
| 190 | 199 | if (age >= 0 && age < TRACER_LIFE) { |
| 191 | 200 | const fade = 1 - age / TRACER_LIFE; |
| 192 | push(into, trail.from[0], trail.from[1], trail.from[2], 1, 0.92, 0.6 * fade); | |
| 193 | push(into, trail.to[0], trail.to[1], trail.to[2], 1, 0.86, 0.35 * fade); | |
| 201 | NEAR[2] = 0.6 * fade; | |
| 202 | FAR[2] = 0.35 * fade; | |
| 203 | pushLine(into, trail.from, trail.to, NEAR, FAR); | |
| 194 | 204 | } |
| 195 | 205 | } |
| 196 | 206 | return into; |
static/app.63bb6ab307.js added +58
Diff not shown — a line 58,700 characters wide (limit 2,000). +58
View filestatic/app.bb19cc2fb2.js deleted −58
Diff not shown — a line 59,011 characters wide (limit 2,000). −58
View filestatic/range.04a8ae5282.js → static/range.72363b5535.js renamed +5 −5
Diff not shown — a line 15,780 characters wide (limit 2,000). +5 −5
View file
new above
offline