Public: anyone can read this; the crew can change it.
forked from trevato/cs · proposes
The renderer moves behind the door: src/engine/renderer.ts takes an atlas and named meshes #65
Check · Review · Land · Live done — Landed and live in production.
vesper/cs-engine:vesper/48-the-renderer-moves-behind-the-door-src-e → main 2 commits fork
17 files
changed +683
−160
README.md +21 −13
| @@ -108,11 +108,14 @@ from the angle it subtends, and you turn on the spot. | ||
| 108 | 108 | knows nothing about de_forge's alphabet), `mesh` (a box builder), `texture` |
| 109 | 109 | (hashed value noise, fbm and an atlas painter), `sound` (a lazy `AudioContext` |
| 110 | 110 | with noise bursts and tones), `loop` (a fixed step with a clamp on a long |
| 111 | frame), `controls` (a bindings table in, held actions out) and `overlay` (the | |
| 111 | frame), `controls` (a bindings table in, held actions out), `overlay` (the | |
| 112 | 112 | 2D over the scene: a scale and a pad off the canvas width, scaled fonts, |
| 113 | 113 | panels, aligned and faded text, a crosshair, a bar, right-aligned columns and |
| 114 | 114 | a `m:ss` clock, drawn onto the seventeen members of a 2D context it uses and |
| 115 | holding no colour a game chose). | |
| 115 | holding no colour a game chose) and `renderer` (the WebGL2: two programs, an | |
| 116 | atlas and named meshes uploaded once at construction, and one `Scene` of | |
| 117 | instances and lines drawn per frame — it knows no game, no world and no | |
| 118 | model, and a canvas and a GL context are named in it only as types). | |
| 116 | 119 | Three rules keep it a package, and a test holds each: the engine imports |
| 117 | 120 | nothing outside itself, it needs nothing from a browser to load, and |
| 118 | 121 | everything outside it comes in through one door. |
| @@ -126,8 +129,10 @@ from the angle it subtends, and you turn on the spot. | ||
| 126 | 129 | module a bench or a server-rendered page cannot import. (The second process is |
| 127 | 130 | `purity.probe.ts`: a dynamic import is cached per process, so the first load |
| 128 | 131 | of each module has to happen somewhere the engine's own tests have not been.) |
| 129 | The last two take their clock, their canvas and their window as parameters, so | |
| 130 | the tests drive them with a stub instead of a browser, and `sound` opens its | |
| 132 | `loop`, `controls` and `renderer` take their clock, their canvas, their window | |
| 133 | and their pixel ratio as parameters, so the tests drive them with a stub | |
| 134 | instead of a browser — `renderer.test.ts` uploads meshes and draws frames into | |
| 135 | a context that only writes down what it was asked for — and `sound` opens its | |
| 131 | 136 | `AudioContext` on the first sound rather than on load. |
| 132 | 137 | - `src/engine/index.ts` — the door, and the rule is that a second game imports |
| 133 | 138 | `src/engine/index.ts` and nothing deeper; inside the engine, modules import |
| @@ -143,8 +148,10 @@ from the angle it subtends, and you turn on the spot. | ||
| 143 | 148 | `sound` `attenuate`, `createSoundDevice`, `Burst`, `Tone`, `SoundDevice` and |
| 144 | 149 | `Ctor`; from `loop` `createLoop`, `Frames`, `Loop` and `LoopOptions`; from |
| 145 | 150 | `controls` `createControls`, `attachButtons` and the event and option types |
| 146 | they are driven with; and from `overlay` `overlay`, `Overlay` and the `Paint` | |
| 147 | a 2D game draws onto. `boundary.test.ts` fails a deep import from outside, a | |
| 151 | they are driven with; from `overlay` `overlay`, `Overlay` and the `Paint` | |
| 152 | a 2D game draws onto; and from `renderer` `createRenderer` with `Renderer`, | |
| 153 | `RendererAssets` and the frame it draws — `Scene`, `Camera` and `Instance`. | |
| 154 | `boundary.test.ts` fails a deep import from outside, a | |
| 148 | 155 | module that is not behind the door, and any change to that list of names that |
| 149 | 156 | is not also a change to the list written out in the test. One exception, and |
| 150 | 157 | it is written down there too: a `.test.ts` may import another `.test.ts`, which |
| @@ -160,16 +167,17 @@ from the angle it subtends, and you turn on the spot. | ||
| 160 | 167 | over the engine's grid), `physics`, `weapons`, `combat`, `match` (the rules), |
| 161 | 168 | `world` (the simulation), |
| 162 | 169 | `bots`, `observe`, `action`, `policy`, `weights`, `models`, `materials`, |
| 163 | `binding`, `scene`, `renderer`, `hud`, `audio`, `main`. Everything is headless | |
| 164 | and tested but `renderer`, which is the one module that needs a real WebGL2 | |
| 165 | context; `models`, `materials` and `audio` are this game's recipes for the | |
| 166 | engine's three asset tools, and `binding` is its key table and the one | |
| 170 | `binding`, `scene`, `hud`, `audio`, `main`. Everything in here is headless and | |
| 171 | tested: the WebGL2 is the engine's now, and this game has no renderer of its | |
| 172 | own. `models`, `materials` and `audio` are this game's recipes for the | |
| 173 | engine's three asset tools — `main` builds the atlas and all eight meshes at | |
| 174 | start-up and hands them to `createRenderer`, which keeps them by name — and | |
| 175 | `binding` is its key table and the one | |
| 167 | 176 | function from a held action to an `Input`. `scene` is the frame as data — |
| 168 | 177 | `buildScene(world, camera, into)` fills one scene object with the camera, the |
| 169 | 178 | sky, the instances to draw in the order to draw them, the line vertices every |
| 170 | tracer and spark and beam comes to, and the gun in your hands — and `renderer` | |
| 171 | draws that and nothing else: it names no world, no physics and no weapons, so | |
| 172 | a second game can hand it a scene of its own. Which is why every decision | |
| 179 | tracer and spark and beam comes to, and the gun in your hands — and the | |
| 180 | engine's renderer draws that and nothing else. Which is why every decision | |
| 173 | 181 | about a frame — who is drawn, how far a duck squashes them, how long a tracer |
| 174 | 182 | lives, how far the last shot kicked the view model — is checked in |
| 175 | 183 | `scene.test.ts` with no canvas in the room. Nothing is allocated per frame: |
scripts/build.ts +6 −1
| @@ -36,7 +36,12 @@ export type Entry = { name: string; entry: string; ceiling: Ceiling }; | ||
| 36 | 36 | * this ceiling raised, and #18 takes the same bytes back out of the shooter. |
| 37 | 37 | * The app grew 1,254 bytes for #47, which moved every decision about a frame |
| 38 | 38 | * into `src/client/scene.ts`; it fits under today's ceiling, so no ceiling moved |
| 39 | * with it. The range's ceiling was raised for #41, which gave it a game: the targets, | |
| 39 | * with it. It grew another 830 raw / 701 gzipped for #48, which moved the | |
| 40 | * renderer behind the door: the game now builds all eight of its meshes and | |
| 41 | * hands them in at construction rather than uploading each on the frame that | |
| 42 | * first asks for it. Measured after #48: app 70,558 raw / 32,850 gzipped, | |
| 43 | * range 12,548 raw / 5,729 gzipped — the range is unchanged in size, which is | |
| 44 | * the tree-shaking working: it imports the door and none of the WebGL2 behind it. The range's ceiling was raised for #41, which gave it a game: the targets, | |
| 40 | 45 | * the clock and the score, the shot traced through the engine's `rayBox`, and |
| 41 | 46 | * the blips the horizon draws them as. |
| 42 | 47 | * Raising a ceiling is allowed, and it is a line in the pull that says what was |
src/client/main.test.ts +20 −1
| @@ -13,7 +13,8 @@ | ||
| 13 | 13 | */ |
| 14 | 14 | |
| 15 | 15 | import { expect, test } from "bun:test"; |
| 16 | import { boot, type Frame } from "./main.ts"; | |
| 16 | import { boot, gameMeshes, type Frame } from "./main.ts"; | |
| 17 | import { WEAPONS } from "./weapons.ts"; | |
| 17 | 18 | import { parseResult } from "../result.ts"; |
| 18 | 19 | import { fakeStage, type FakeStage } from "./stage.test-helper.ts"; |
| 19 | 20 | import type { World } from "./world.ts"; |
| @@ -298,3 +299,21 @@ test("no WebGL2 is a sentence on the page, not an exception", () => { | ||
| 298 | 299 | expect(stage.document.listening.size).toBe(0); |
| 299 | 300 | expect(stage.draws.length).toBe(0); |
| 300 | 301 | }); |
| 302 | ||
| 303 | test("every mesh name a scene of this game can place is handed in at construction", () => { | |
| 304 | // The renderer keeps what it is given and resolves nothing, so a name this | |
| 305 | // table misses is an exception on the frame a player first holds that gun. | |
| 306 | const meshes = gameMeshes(); | |
| 307 | expect(Object.keys(meshes).sort()).toEqual([ | |
| 308 | "bomb", "knife", "level", "pistol", "player", "rifle", "smg", "sniper", | |
| 309 | ]); | |
| 310 | // Every kind a weapon can be, and the kind `scene.ts` falls back to. | |
| 311 | for (const weapon of Object.values(WEAPONS)) expect(meshes[weapon.kind]).toBeDefined(); | |
| 312 | expect(meshes["rifle"]).toBeDefined(); | |
| 313 | // And each one is geometry, not an empty buffer. | |
| 314 | for (const [name, mesh] of Object.entries(meshes)) { | |
| 315 | expect(mesh.index.length).toBeGreaterThan(0); | |
| 316 | expect(mesh.position.length).toBe((mesh.index.length / 6) * 4 * 3); | |
| 317 | expect(name).toMatch(/^[a-z]+$/); | |
| 318 | } | |
| 319 | }); |
src/client/main.ts +38 −8
| @@ -2,7 +2,8 @@ | ||
| 2 | 2 | * The frame. The engine's loop says how many 64 Hz steps this frame is worth |
| 3 | 3 | * and the engine's controls say which actions are held; `binding` turns those |
| 4 | 4 | * names into the `Input` the world takes. `scene.ts` then turns the world into |
| 5 | * the frame's data and the renderer draws that, and the HUD is painted on top. The page's overlays — menu, buy, pause — are plain DOM, so they are | |
| 5 | * the frame's data and the engine's renderer draws that — built here, from this | |
| 6 | * game's atlas and this game's meshes — and the HUD is painted on top. The page's overlays — menu, buy, pause — are plain DOM, so they are | |
| 6 | 7 | * reachable with a keyboard and big enough for a thumb. |
| 7 | 8 | * |
| 8 | 9 | * Nothing in here is a browser. `boot(host)` takes everything it reaches for — |
| @@ -18,12 +19,14 @@ import { createAudio, type Audio } from "./audio.ts"; | ||
| 18 | 19 | import { CS_KEYS, CS_MOUSE, CS_TAP, readIntent } from "./binding.ts"; |
| 19 | 20 | import { drawHud, type Paint } from "./hud.ts"; |
| 20 | 21 | import { |
| 21 | attachButtons, clamp, createControls, createLoop, type Button, type Listens, | |
| 22 | attachButtons, clamp, createControls, createLoop, createRenderer, type Button, type Camera, | |
| 23 | type Listens, type Mesh, | |
| 22 | 24 | } from "../engine/index.ts"; |
| 23 | 25 | import { COMPETITIVE, SHORT, type Rules } from "./match.ts"; |
| 24 | import { createRenderer } from "./renderer.ts"; | |
| 25 | import { buildScene, createScene, type Camera, type Scene } from "./scene.ts"; | |
| 26 | import { buyable } from "./weapons.ts"; | |
| 26 | import { LAYERS, TEX_SIZE, materialTextures } from "./materials.ts"; | |
| 27 | import { meshFor } from "./models.ts"; | |
| 28 | import { buildScene, createScene, SKY, type Scene } from "./scene.ts"; | |
| 29 | import { buyable, WEAPONS } from "./weapons.ts"; | |
| 27 | 30 | import { |
| 28 | 31 | buy, canBuy, createWorld, eyePos, priceOf, update, type BuyItem, type Player, type World, |
| 29 | 32 | } from "./world.ts"; |
| @@ -355,11 +358,32 @@ export function boot(host: Host): Frame { | ||
| 355 | 358 | return { world: () => world }; |
| 356 | 359 | } |
| 357 | 360 | |
| 361 | /** | |
| 362 | * Every mesh a scene of this game can name, built once and handed to the | |
| 363 | * renderer at construction: the level, a player, the bomb, and one view model | |
| 364 | * per weapon kind. The renderer keeps what it is given by name and resolves | |
| 365 | * nothing itself, so this table and `models.meshFor` are the whole of what the | |
| 366 | * engine would otherwise have had to know about this game's geometry. | |
| 367 | * | |
| 368 | * Exported because a name this misses is a crash mid-match — the first frame a | |
| 369 | * player picks that gun up — and `main.test.ts` holds it against every name | |
| 370 | * `scene.ts` can place. | |
| 371 | */ | |
| 372 | export function gameMeshes(): Record<string, Mesh> { | |
| 373 | const kinds = new Set(Object.values(WEAPONS).map((weapon) => weapon.kind)); | |
| 374 | const names = ["level", "player", "bomb", ...kinds]; | |
| 375 | return Object.fromEntries(names.map((name) => [name, meshFor(name)])); | |
| 376 | } | |
| 377 | ||
| 358 | 378 | /** |
| 359 | 379 | * The browser, and the only place in this file where one is named: the real |
| 360 | 380 | * document, window, store, network, ids and factories, handed in as a host. |
| 361 | * The cast is the seam — `createRenderer` wants a real canvas, and a `Stage` is | |
| 362 | * the smaller shape this file and a test can both satisfy. | |
| 381 | * The cast is the seam — the engine's `createRenderer` wants a real canvas, and | |
| 382 | * a `Stage` is the smaller shape this file and a test can both satisfy — and | |
| 383 | * the assets beside it are this game's, which is why they are passed in here | |
| 384 | * rather than reached for behind the door: the atlas `materials.ts` paints, the | |
| 385 | * size and layer count it painted at, the meshes above, the horizon a frame | |
| 386 | * that named none would get, and the pixel ratio of the screen we are on. | |
| 363 | 387 | */ |
| 364 | 388 | const browser = (): Host => ({ |
| 365 | 389 | document, |
| @@ -367,7 +391,13 @@ const browser = (): Host => ({ | ||
| 367 | 391 | storage: localStorage, |
| 368 | 392 | fetch: (url, init) => fetch(url, init), |
| 369 | 393 | randomUUID: () => crypto.randomUUID(), |
| 370 | createRenderer: (canvas) => createRenderer(canvas as unknown as HTMLCanvasElement), | |
| 394 | createRenderer: (canvas) => | |
| 395 | createRenderer(canvas as unknown as HTMLCanvasElement, { | |
| 396 | atlas: { bytes: materialTextures(), size: TEX_SIZE, layers: LAYERS }, | |
| 397 | meshes: gameMeshes(), | |
| 398 | sky: SKY, | |
| 399 | devicePixelRatio: () => window.devicePixelRatio, | |
| 400 | }), | |
| 371 | 401 | createAudio: () => createAudio(), |
| 372 | 402 | }); |
| 373 | 403 |
src/client/models.ts +4 −3
| @@ -66,9 +66,10 @@ export function viewModelMesh(kind: string): Mesh { | ||
| 66 | 66 | |
| 67 | 67 | /** |
| 68 | 68 | * A mesh by the name a scene calls it: `level`, `player`, `bomb`, or a weapon |
| 69 | * kind for the gun in your hands. The renderer keeps one upload per name and | |
| 70 | * never learns what a name is made of — that is this file, and this is the one | |
| 71 | * line a second game's model table would replace. | |
| 69 | * kind for the gun in your hands. `main.ts` builds one of each at start-up and | |
| 70 | * hands them to the engine's renderer, which keeps them by name and never | |
| 71 | * learns what a name is made of — that is this file, and this is the one line a | |
| 72 | * second game's model table would replace. | |
| 72 | 73 | */ |
| 73 | 74 | export function meshFor(name: string): Mesh { |
| 74 | 75 | if (name === "level") return levelMesh(boxes); |
src/client/scene.test.ts +16 −12
| @@ -5,16 +5,17 @@ | ||
| 5 | 5 | * squashes a player, how long a tracer lives, which gun is in your hands and |
| 6 | 6 | * how far the last shot kicked it — is a number a test can read. |
| 7 | 7 | * |
| 8 | * The last test is the boundary itself: the renderer may not name the world, | |
| 9 | * the physics or the weapons again, because a second game that hands it a scene | |
| 10 | * would have neither. | |
| 8 | * The last test is the boundary itself: the drawing is `src/engine/renderer.ts` | |
| 9 | * now and the `Scene` it reads is the door's type, so what this file decides is | |
| 10 | * exactly what any game's renderer is handed. | |
| 11 | 11 | */ |
| 12 | 12 | |
| 13 | 13 | import { expect, test } from "bun:test"; |
| 14 | import { readFileSync } from "node:fs"; | |
| 14 | import { existsSync, readFileSync } from "node:fs"; | |
| 15 | 15 | import { join } from "node:path"; |
| 16 | import type { Camera } from "../engine/index.ts"; | |
| 16 | 17 | import { HULL } from "./physics.ts"; |
| 17 | import { buildScene, createScene, LINE_CAPACITY, SKY, type Camera } from "./scene.ts"; | |
| 18 | import { buildScene, createScene, LINE_CAPACITY, SKY } from "./scene.ts"; | |
| 18 | 19 | import { SHORT } from "./match.ts"; |
| 19 | 20 | import { createWorld, type Effect, type World } from "./world.ts"; |
| 20 | 21 | |
| @@ -223,11 +224,14 @@ test("a frame allocates nothing: the same scene, the same buffer, the same insta | ||
| 223 | 224 | expect(scene.viewModel).toBe(held); |
| 224 | 225 | }); |
| 225 | 226 | |
| 226 | test("the renderer names no world, no physics and no weapons", () => { | |
| 227 | const source = readFileSync(join(import.meta.dir, "renderer.ts"), "utf8"); | |
| 228 | for (const forbidden of ["./world.ts", "./physics.ts", "./weapons.ts", "./map.ts"]) | |
| 229 | expect(source).not.toContain(forbidden); | |
| 230 | expect(source).toContain('from "./scene.ts"'); | |
| 231 | expect(source).toContain('from "./models.ts"'); | |
| 232 | expect(source).toContain('from "./materials.ts"'); | |
| 227 | test("this game has no renderer of its own: the frame is the door's type", () => { | |
| 228 | // The WebGL2 is `src/engine/renderer.ts` now, and `boundary.test.ts` holds it | |
| 229 | // to importing nothing but the engine. What is left here is the scene, whose | |
| 230 | // type comes through the door — so what this game decides about a frame and | |
| 231 | // what any game's renderer reads of it are the same thing by construction. | |
| 232 | expect(existsSync(join(import.meta.dir, "renderer.ts"))).toBe(false); | |
| 233 | const source = readFileSync(join(import.meta.dir, "scene.ts"), "utf8"); | |
| 234 | expect(source).toContain("type Scene as Frame"); | |
| 235 | expect(source).toContain('from "../engine/index.ts"'); | |
| 236 | expect(source).toContain("export type Scene = Frame &"); | |
| 233 | 237 | }); |
src/client/scene.ts +12 −24
| @@ -2,9 +2,10 @@ | ||
| 2 | 2 | * The frame, as data. Everything a draw has to decide about this game — which |
| 3 | 3 | * mesh stands where, what a team is tinted, how far a tracer has faded, which |
| 4 | 4 | * gun is in your hands and how far the last shot kicked it — is decided here, |
| 5 | * in plain numbers a test can read without a canvas. `renderer.ts` then draws | |
| 6 | * exactly this and knows nothing else, which is what lets a second game hand it | |
| 7 | * a scene of its own. | |
| 5 | * in plain numbers a test can read without a canvas. The engine's renderer then | |
| 6 | * draws exactly this and knows nothing else, which is what lets a second game | |
| 7 | * hand it a scene of its own: `Scene`, `Camera` and `Instance` are the door's | |
| 8 | * types, and what is added below is only what this game keeps between frames. | |
| 8 | 9 | * |
| 9 | 10 | * Nothing is allocated in a frame. `createScene()` makes the one scene and the |
| 10 | 11 | * one line buffer a game ever needs, and `buildScene(world, camera, into)` |
| @@ -13,37 +14,24 @@ | ||
| 13 | 14 | * and the view model object is kept even on the frames where there is none. |
| 14 | 15 | */ |
| 15 | 16 | |
| 16 | import { clamp, type Vec3 } from "../engine/index.ts"; | |
| 17 | import { clamp, type Camera, type Scene as Frame, type Vec3 } from "../engine/index.ts"; | |
| 17 | 18 | import { HULL, hullHeight } from "./physics.ts"; |
| 18 | 19 | import { WEAPONS } from "./weapons.ts"; |
| 19 | 20 | import type { World } from "./world.ts"; |
| 20 | 21 | |
| 21 | /** Where the eye is and what it can see. A horizontal field of view, as CS reports it. */ | |
| 22 | export type Camera = { pos: Vec3; yaw: number; pitch: number; fov: number }; | |
| 23 | ||
| 24 | 22 | export type Tint = [number, number, number]; |
| 25 | 23 | |
| 26 | /** | |
| 27 | * One mesh, placed. `mesh` is the name the renderer resolves — `level`, | |
| 28 | * `player`, `bomb`, or a weapon kind for a view model — and `scale` is the | |
| 29 | * vertical squash a duck applies, the only scaling this game asks for. | |
| 30 | */ | |
| 31 | export type Instance = { mesh: string; pos: Vec3; yaw: number; scale: number; tint: Tint }; | |
| 32 | ||
| 33 | 24 | /** The gun in your hands: which one, and where it sits in view space. */ |
| 34 | 25 | export type ViewModel = { mesh: string; offset: Vec3 }; |
| 35 | 26 | |
| 36 | export type Scene = { | |
| 37 | camera: Camera; | |
| 27 | /** | |
| 28 | * The engine's frame, plus this game's memory of the last one. The mesh names | |
| 29 | * placed below — `level`, `player`, `bomb`, and a weapon kind for a view model | |
| 30 | * — are the names `main.ts` hands the renderer at construction. | |
| 31 | */ | |
| 32 | export type Scene = Frame & { | |
| 33 | /** This game's horizon is never not named: `SKY`, written in every frame. */ | |
| 38 | 34 | sky: Tint; |
| 39 | /** The pool. Only the first `instanceCount` of these are this frame's. */ | |
| 40 | instances: Instance[]; | |
| 41 | instanceCount: number; | |
| 42 | /** Line vertices, six floats each: x, y, z, r, g, b. */ | |
| 43 | lines: Float32Array; | |
| 44 | /** Vertices written this frame — `lineCount * 6` floats of `lines` are live. */ | |
| 45 | lineCount: number; | |
| 46 | viewModel: ViewModel | null; | |
| 47 | 35 | /** |
| 48 | 36 | * What the scene remembers between frames so a frame allocates nothing: the |
| 49 | 37 | * walk cycle the view model bobs on, the world time it last saw, and the one |
src/engine/GUIDE.md +49 −1
| @@ -273,6 +273,54 @@ o.panel(pad, pad, width - pad * 2, step * lines.length + pad, "rgba(11,13,17,.72 | ||
| 273 | 273 | o.crosshair(Math.round(width / 2), Math.round(height / 2), 0, Math.max(6, Math.round(width / 90)), "#8ea"); |
| 274 | 274 | |
| 275 | 275 | |
| 276 | ### renderer | |
| 277 | ||
| 278 | The 3D under the scene, and the largest thing behind the door. `createRenderer` | |
| 279 | takes the canvas and the assets and hands back a `Renderer` — or nothing at all, | |
| 280 | when the browser has no WebGL2, which is a sentence to show the player rather | |
| 281 | than an error — with three members: `Renderer.draw` for one frame, | |
| 282 | `Renderer.resize` for the canvas in device pixels, and `Renderer.gl` for the | |
| 283 | context itself, if a game ever wants a pass of its own. | |
| 284 | ||
| 285 | What a game brings is `RendererAssets`, and every byte of it is uploaded once, | |
| 286 | at construction. `RendererAssets.atlas` is the `TEXTURE_2D_ARRAY` every mesh's | |
| 287 | layer indexes into: a size, a layer count, and exactly what `paintAtlas` | |
| 288 | returns at that size. Neither number is assumed — a game that paints 64-pixel | |
| 289 | tiles says 64 — so bytes of any other length are refused with both numbers in | |
| 290 | the message. `RendererAssets.meshes` is every `Mesh` a scene of this game can | |
| 291 | name, under the name its instances will ask for. | |
| 292 | `RendererAssets.sky` is the horizon for a frame that names none, and | |
| 293 | `RendererAssets.devicePixelRatio` is `window.devicePixelRatio` handed in as a | |
| 294 | function, because the engine reads no global and a test drives a whole frame | |
| 295 | with no window in the room. | |
| 296 | ||
| 297 | ts | |
| 298 | // src/client/main.ts | |
| 299 | createRenderer(canvas as unknown as HTMLCanvasElement, { | |
| 300 | atlas: { bytes: materialTextures(), size: TEX_SIZE, layers: LAYERS }, | |
| 301 | meshes: gameMeshes(), | |
| 302 | sky: SKY, | |
| 303 | devicePixelRatio: () => window.devicePixelRatio, | |
| 304 | }), | |
| 305 | ||
| 306 | ||
| 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. | |
| 318 | ||
| 319 | ts | |
| 320 | // src/client/main.ts | |
| 321 | renderer.draw(buildScene(world, camera, frameScene), elapsed); | |
| 322 | ||
| 323 | ||
| 276 | 324 | ## The seam, named as pairs |
| 277 | 325 | |
| 278 | 326 | Two games exist here, and every place they differ is a place the engine chose to |
| @@ -288,7 +336,7 @@ split. | ||
| 288 | 336 | | mesh recipes | `src/client/models.ts` | none: it draws no geometry yet | |
| 289 | 337 | | texture recipes | `src/client/materials.ts` | none: the readout paints its own 2D | |
| 290 | 338 | | sound recipes | `src/client/audio.ts` | none yet | |
| 291 | | a renderer | WebGL2, `src/client/renderer.ts` | a 2D horizon, `src/range/readout.ts` | | |
| 339 | | renderer assets | an atlas and eight meshes, from `src/client/main.ts` | none: it draws a 2D horizon in `src/range/readout.ts` | | |
| 292 | 340 | | the tuning | `src/client/physics.ts`, `src/client/weapons.ts` | `targets.STANDARD` in `src/range/targets.ts` | |
| 293 | 341 | |
| 294 | 342 | ## What the engine deliberately does not know |
src/engine/boundary.test.ts +5 −4
| @@ -220,12 +220,13 @@ test("no module in the engine goes through the engine's own door", () => { | ||
| 220 | 220 | * copy of the surface, read twice, rather than two that drift. |
| 221 | 221 | */ |
| 222 | 222 | export const SURFACE = [ |
| 223 | "Box", "Builder", "Burst", "Button", "ButtonOptions", "Controls", "Ctor", "Frames", "Grid", | |
| 224 | "Hit", "Host", "KeyEventish", "Listens", "LockOwner", "Loop", "LoopOptions", "Mat4", "Mesh", | |
| 225 | "MouseEventish", "Options", "Overlay", "Paint", "Shader", "SoundDevice", "Stick", "Surface", "Tone", | |
| 223 | "Box", "Builder", "Burst", "Button", "ButtonOptions", "Camera", "Controls", "Ctor", "Frames", | |
| 224 | "Grid", "Hit", "Host", "Instance", "KeyEventish", "Listens", "LockOwner", "Loop", "LoopOptions", | |
| 225 | "Mat4", "Mesh", "MouseEventish", "Options", "Overlay", "Paint", "Renderer", "RendererAssets", | |
| 226 | "Scene", "Shader", "SoundDevice", "Stick", "Surface", "Tone", | |
| 226 | 227 | "TouchEventish", "Touchish", "Vec3", "add", "addBox", "angleDelta", "angleVectors", |
| 227 | 228 | "attachButtons", "attenuate", "builder", "cellAt", "clamp", "colOf", "createControls", |
| 228 | "createLoop", "createSoundDevice", "cross", "deg", "dist", "dist2d", "dot", "fbm", | |
| 229 | "createLoop", "createRenderer", "createSoundDevice", "cross", "deg", "dist", "dist2d", "dot", "fbm", | |
| 229 | 230 | "findPath", "finish", "hash", "inside", "length", "length2d", "lerp", "mat4", "mergeSolid", |
| 230 | 231 | "modelMatrix", "mul", "multiply", "noise", "normalize", "overlay", "paintAtlas", "perspective", |
| 231 | 232 | "quadMesh", "rad", "rayBox", "rng", "rowOf", "scale", "segmentLength", "sub", "traceWorld", |
src/engine/guide.test.ts +3 −2
| @@ -48,8 +48,9 @@ const BARE = /^[A-Za-z_$][A-Za-z0-9_$]*$/; | ||
| 48 | 48 | * this file ignores without needing to be told about it. |
| 49 | 49 | */ |
| 50 | 50 | const NOT_THE_ENGINES = new Set([ |
| 51 | "AudioContext", "Float32Array", "addEventListener", "cancelAnimationFrame", "de_forge", | |
| 52 | "pointerLockElement", "removeEventListener", "requestAnimationFrame", "uniformMatrix4fv", | |
| 51 | "AudioContext", "Float32Array", "TEXTURE_2D_ARRAY", "addEventListener", "cancelAnimationFrame", | |
| 52 | "de_forge", "pointerLockElement", "removeEventListener", "requestAnimationFrame", | |
| 53 | "uniformMatrix4fv", | |
| 53 | 54 | ]); |
| 54 | 55 | |
| 55 | 56 | const guide = (): string => readFileSync(GUIDE, "utf8"); |
src/engine/index.ts +4
| @@ -52,3 +52,7 @@ export type { | ||
| 52 | 52 | /** The 2D over the scene: panels, scaled text, a crosshair, a bar and a clock. */ |
| 53 | 53 | export { overlay } from "./overlay.ts"; |
| 54 | 54 | export type { Overlay, Paint } from "./overlay.ts"; |
| 55 | ||
| 56 | /** The WebGL2 draw: an atlas and named meshes in, a frame of instances out. */ | |
| 57 | export { createRenderer } from "./renderer.ts"; | |
| 58 | export type { Camera, Instance, Renderer, RendererAssets, Scene } from "./renderer.ts"; |
src/engine/renderer.test.ts added +303
| @@ -0,0 +1,303 @@ | ||
| 1 | /** | |
| 2 | * The renderer, driven with no GPU and no browser. A stub context writes down | |
| 3 | * every call it was asked to make — the same trick `overlay.test.ts` plays with | |
| 4 | * a `Paint` for 2D — so what is uploaded, what is bound and what is drawn is | |
| 5 | * checked by reading a list instead of a screen. | |
| 6 | * | |
| 7 | * The stub is deliberately only as wide as this module: the enums it names and | |
| 8 | * the functions it calls, each recording its arguments and handing back a plain | |
| 9 | * object where GL would hand back a resource. It is not a fake GPU and must not | |
| 10 | * grow into one — nothing here checks what a pixel came out, only that the | |
| 11 | * right work was asked for once. | |
| 12 | */ | |
| 13 | ||
| 14 | import { expect, test } from "bun:test"; | |
| 15 | import { createRenderer, type Scene } from "./renderer.ts"; | |
| 16 | import { addBox, builder, finish, type Mesh } from "./mesh.ts"; | |
| 17 | ||
| 18 | type Call = [string, ...unknown[]]; | |
| 19 | ||
| 20 | /** Every GL enum this module names. Numbered so a recorded call reads back. */ | |
| 21 | const ENUMS = [ | |
| 22 | "ARRAY_BUFFER", "BACK", "COLOR_BUFFER_BIT", "COMPILE_STATUS", "CULL_FACE", "DEPTH_BUFFER_BIT", | |
| 23 | "DEPTH_TEST", "DYNAMIC_DRAW", "ELEMENT_ARRAY_BUFFER", "FLOAT", "FRAGMENT_SHADER", "LINEAR", | |
| 24 | "LINEAR_MIPMAP_LINEAR", "LINES", "LINK_STATUS", "REPEAT", "RGBA", "RGBA8", "STATIC_DRAW", | |
| 25 | "TEXTURE0", "TEXTURE_2D_ARRAY", "TEXTURE_MAG_FILTER", "TEXTURE_MIN_FILTER", "TEXTURE_WRAP_S", | |
| 26 | "TEXTURE_WRAP_T", "TRIANGLES", "UNSIGNED_BYTE", "UNSIGNED_INT", "VERTEX_SHADER", | |
| 27 | ] as const; | |
| 28 | ||
| 29 | /** A resource the stub handed out, in the order it handed them out. */ | |
| 30 | type Resource = { kind: string; id: number }; | |
| 31 | ||
| 32 | type StubGl = Record<string, unknown> & { calls: Call[]; resources: Resource[] }; | |
| 33 | ||
| 34 | /** A `WebGL2RenderingContext` that records what it was told to do, and nothing else. */ | |
| 35 | function stubGl(): StubGl { | |
| 36 | const calls: Call[] = []; | |
| 37 | const resources: Resource[] = []; | |
| 38 | const note = (name: string) => (...args: unknown[]): void => void calls.push([name, ...args]); | |
| 39 | const hand = (kind: string) => (...args: unknown[]): Resource => { | |
| 40 | calls.push([kind === "shader" ? "createShader" : `create${kind}`, ...args]); | |
| 41 | const made = { kind, id: resources.length + 1 }; | |
| 42 | resources.push(made); | |
| 43 | return made; | |
| 44 | }; | |
| 45 | const gl: StubGl = { | |
| 46 | calls, | |
| 47 | resources, | |
| 48 | ...Object.fromEntries(ENUMS.map((name, i) => [name, i + 1])), | |
| 49 | createShader: hand("shader"), | |
| 50 | createProgram: hand("Program"), | |
| 51 | createTexture: hand("Texture"), | |
| 52 | createBuffer: hand("Buffer"), | |
| 53 | createVertexArray: hand("VertexArray"), | |
| 54 | getUniformLocation: (_program: unknown, name: string): Resource => ({ kind: name, id: 0 }), | |
| 55 | getShaderParameter: (): boolean => true, | |
| 56 | getProgramParameter: (): boolean => true, | |
| 57 | getShaderInfoLog: (): string => "", | |
| 58 | getProgramInfoLog: (): string => "", | |
| 59 | }; | |
| 60 | for (const name of [ | |
| 61 | "shaderSource", "compileShader", "attachShader", "linkProgram", "bindTexture", "texImage3D", | |
| 62 | "generateMipmap", "texParameteri", "bindVertexArray", "bindBuffer", "bufferData", | |
| 63 | "bufferSubData", "enableVertexAttribArray", "vertexAttribPointer", "enable", "cullFace", | |
| 64 | "viewport", "clearColor", "clear", "useProgram", "activeTexture", "uniform1i", "uniform3f", | |
| 65 | "uniform3fv", "uniformMatrix4fv", "drawElements", "drawArrays", | |
| 66 | ]) gl[name] = note(name); | |
| 67 | return gl; | |
| 68 | } | |
| 69 | ||
| 70 | /** A canvas as far as the renderer is concerned: a size, and one context. */ | |
| 71 | function stubCanvas(gl: StubGl | null) { | |
| 72 | return { | |
| 73 | clientWidth: 800, | |
| 74 | clientHeight: 600, | |
| 75 | width: 0, | |
| 76 | height: 0, | |
| 77 | getContext(kind: string): unknown { | |
| 78 | return kind === "webgl2" ? gl : null; | |
| 79 | }, | |
| 80 | }; | |
| 81 | } | |
| 82 | ||
| 83 | /** One box, which is a real `Mesh` with nothing invented about it. */ | |
| 84 | function box(size = 10): Mesh { | |
| 85 | const b = builder(); | |
| 86 | addBox(b, [0, 0, 0], [size, size, size], 0); | |
| 87 | return finish(b); | |
| 88 | } | |
| 89 | ||
| 90 | const TILE = 4; | |
| 91 | ||
| 92 | /** An atlas of the smallest honest shape: two layers of 4x4 RGBA. */ | |
| 93 | const atlas = (size = TILE, layers = 2) => ({ | |
| 94 | bytes: new Uint8Array(size * size * 4 * layers), | |
| 95 | size, | |
| 96 | layers, | |
| 97 | }); | |
| 98 | ||
| 99 | /** A renderer on a stub context, with the meshes a test wants by name. */ | |
| 100 | function renderer(meshes: Record<string, Mesh> = { level: box(), player: box(6) }) { | |
| 101 | const gl = stubGl(); | |
| 102 | const canvas = stubCanvas(gl); | |
| 103 | const r = createRenderer(canvas as unknown as HTMLCanvasElement, { | |
| 104 | atlas: atlas(), | |
| 105 | meshes, | |
| 106 | sky: [0.1, 0.2, 0.3], | |
| 107 | devicePixelRatio: () => 1, | |
| 108 | }); | |
| 109 | if (r === null) throw new Error("the stub context should have made a renderer"); | |
| 110 | return { gl, canvas, r, made: gl.calls.length }; | |
| 111 | } | |
| 112 | ||
| 113 | /** The one scene the draw tests use: two instances of one mesh, no lines, no gun. */ | |
| 114 | function scene(mesh = "level"): Scene { | |
| 115 | const instance = (): Scene["instances"][number] => ({ | |
| 116 | mesh, pos: [0, 0, 0], yaw: 0, scale: 1, tint: [1, 1, 1], | |
| 117 | }); | |
| 118 | return { | |
| 119 | camera: { pos: [0, 0, 64], yaw: 0, pitch: 0, fov: 90 }, | |
| 120 | instances: [instance(), instance()], | |
| 121 | instanceCount: 2, | |
| 122 | lines: new Float32Array(12), | |
| 123 | lineCount: 0, | |
| 124 | viewModel: null, | |
| 125 | }; | |
| 126 | } | |
| 127 | ||
| 128 | /** Every recorded call of this name, arguments and all. */ | |
| 129 | const of = (gl: StubGl, name: string): Call[] => gl.calls.filter((c) => c[0] === name); | |
| 130 | ||
| 131 | /** Only the calls made after construction: what one frame asked for. */ | |
| 132 | const frameCalls = (r: ReturnType<typeof renderer>, name: string): Call[] => | |
| 133 | r.gl.calls.slice(r.made).filter((c) => c[0] === name); | |
| 134 | ||
| 135 | test("a browser with no webgl2 is no renderer, and not an error", () => { | |
| 136 | const made = createRenderer(stubCanvas(null) as unknown as HTMLCanvasElement, { | |
| 137 | atlas: atlas(), | |
| 138 | meshes: { level: box() }, | |
| 139 | }); | |
| 140 | expect(made).toBeNull(); | |
| 141 | }); | |
| 142 | ||
| 143 | test("construction uploads the atlas once, at the size and layer count it was handed", () => { | |
| 144 | const { gl } = renderer(); | |
| 145 | expect(of(gl, "createTexture").length).toBe(1); | |
| 146 | const upload = of(gl, "texImage3D")[0] as Call; | |
| 147 | expect(upload.slice(1, 8)).toEqual([ | |
| 148 | gl.TEXTURE_2D_ARRAY, 0, gl.RGBA8, TILE, TILE, 2, 0, | |
| 149 | ]); | |
| 150 | expect(upload[8]).toBe(gl.RGBA); | |
| 151 | expect(upload[9]).toBe(gl.UNSIGNED_BYTE); | |
| 152 | expect((upload[10] as Uint8Array).length).toBe(TILE * TILE * 4 * 2); | |
| 153 | // Mipmaps, and the filtering that reads them, are set once and never per frame. | |
| 154 | expect(of(gl, "generateMipmap").length).toBe(1); | |
| 155 | expect(of(gl, "texParameteri").length).toBe(4); | |
| 156 | }); | |
| 157 | ||
| 158 | test("construction uploads one buffer set per mesh handed in, and nothing later", () => { | |
| 159 | const two = renderer({ level: box(), player: box(6) }); | |
| 160 | // Five buffers a mesh — position, normal, uv, layer, index — plus the one the | |
| 161 | // lines share, and a vertex array each plus the lines'. | |
| 162 | expect(of(two.gl, "createBuffer").length).toBe(2 * 5 + 1); | |
| 163 | expect(of(two.gl, "createVertexArray").length).toBe(2 + 1); | |
| 164 | expect(of(two.gl, "bufferData").length).toBe(2 * 5); | |
| 165 | ||
| 166 | const three = renderer({ level: box(), player: box(6), bomb: box(2) }); | |
| 167 | expect(of(three.gl, "createBuffer").length).toBe(3 * 5 + 1); | |
| 168 | expect(of(three.gl, "createVertexArray").length).toBe(3 + 1); | |
| 169 | ||
| 170 | // A frame uploads nothing: the whole point of taking the meshes at construction. | |
| 171 | two.r.draw(scene(), 1 / 60); | |
| 172 | two.r.draw(scene(), 1 / 60); | |
| 173 | expect(frameCalls(two, "createBuffer")).toEqual([]); | |
| 174 | expect(frameCalls(two, "bufferData")).toEqual([]); | |
| 175 | expect(frameCalls(two, "texImage3D")).toEqual([]); | |
| 176 | }); | |
| 177 | ||
| 178 | test("two instances of one mesh bind that mesh's vertex array once", () => { | |
| 179 | const r = renderer(); | |
| 180 | r.r.draw(scene("level"), 1 / 60); | |
| 181 | const bound = frameCalls(r, "bindVertexArray").map((c) => c[1]); | |
| 182 | const vaos = bound.filter((v) => v !== null); | |
| 183 | expect(vaos.length).toBe(1); | |
| 184 | // Two instances, two draws, one bind — and the array is left unbound. | |
| 185 | expect(frameCalls(r, "drawElements").length).toBe(2); | |
| 186 | expect(bound[bound.length - 1]).toBeNull(); | |
| 187 | // And it is that mesh's own vertex array: the first one construction made. | |
| 188 | const arrays = r.gl.resources.filter((made) => made.kind === "VertexArray"); | |
| 189 | expect(vaos[0]).toBe(arrays[0] as Resource); | |
| 190 | }); | |
| 191 | ||
| 192 | test("a scene of two different meshes binds each of them once", () => { | |
| 193 | const r = renderer(); | |
| 194 | const two = scene("level"); | |
| 195 | (two.instances[1] as { mesh: string }).mesh = "player"; | |
| 196 | r.r.draw(two, 1 / 60); | |
| 197 | const vaos = frameCalls(r, "bindVertexArray").map((c) => c[1]).filter((v) => v !== null); | |
| 198 | expect(vaos.length).toBe(2); | |
| 199 | expect(vaos[0]).not.toEqual(vaos[1]); | |
| 200 | }); | |
| 201 | ||
| 202 | test("a mesh name nobody handed in throws, and says which name", () => { | |
| 203 | const r = renderer({ level: box() }); | |
| 204 | expect(() => r.r.draw(scene("player"), 1 / 60)).toThrow(/no mesh named "player"/); | |
| 205 | // And it names what it does have, because the mistake is usually a typo. | |
| 206 | expect(() => r.r.draw(scene("player"), 1 / 60)).toThrow(/level/); | |
| 207 | }); | |
| 208 | ||
| 209 | test("an atlas whose bytes are the wrong length is refused at construction", () => { | |
| 210 | const short = { bytes: new Uint8Array(10), size: TILE, layers: 2 }; | |
| 211 | const gl = stubGl(); | |
| 212 | expect(() => | |
| 213 | createRenderer(stubCanvas(gl) as unknown as HTMLCanvasElement, { | |
| 214 | atlas: short, | |
| 215 | meshes: { level: box() }, | |
| 216 | }), | |
| 217 | ).toThrow(/128 bytes, not 10/); | |
| 218 | // Refused before a context was even asked for: nothing was uploaded. | |
| 219 | expect(gl.calls).toEqual([]); | |
| 220 | for (const bad of [ | |
| 221 | { bytes: new Uint8Array(0), size: 0, layers: 2 }, | |
| 222 | { bytes: new Uint8Array(64), size: 4, layers: 0 }, | |
| 223 | { bytes: new Uint8Array(64), size: 4.5, layers: 1 }, | |
| 224 | ]) | |
| 225 | expect(() => | |
| 226 | createRenderer(stubCanvas(stubGl()) as unknown as HTMLCanvasElement, { | |
| 227 | atlas: bad, | |
| 228 | meshes: { level: box() }, | |
| 229 | }), | |
| 230 | ).toThrow(/whole numbers above zero/); | |
| 231 | }); | |
| 232 | ||
| 233 | test("the frame clears to the scene's sky, or the renderer's when it names none", () => { | |
| 234 | const r = renderer(); | |
| 235 | const named = scene(); | |
| 236 | named.sky = [0.66, 0.73, 0.82]; | |
| 237 | r.r.draw(named, 1 / 60); | |
| 238 | expect(frameCalls(r, "clearColor")).toEqual([["clearColor", 0.66, 0.73, 0.82, 1]]); | |
| 239 | ||
| 240 | const bare = renderer(); | |
| 241 | bare.r.draw(scene(), 1 / 60); | |
| 242 | expect(frameCalls(bare, "clearColor")).toEqual([["clearColor", 0.1, 0.2, 0.3, 1]]); | |
| 243 | }); | |
| 244 | ||
| 245 | test("the canvas is sized in device pixels, and the viewport with it", () => { | |
| 246 | const gl = stubGl(); | |
| 247 | const canvas = stubCanvas(gl); | |
| 248 | let ratio = 2; | |
| 249 | const r = createRenderer(canvas as unknown as HTMLCanvasElement, { | |
| 250 | atlas: atlas(), | |
| 251 | meshes: { level: box() }, | |
| 252 | devicePixelRatio: () => ratio, | |
| 253 | }); | |
| 254 | r?.resize(); | |
| 255 | expect([canvas.width, canvas.height]).toEqual([1600, 1200]); | |
| 256 | // Past 2x the pixels cost more than they show, so the ratio is clamped. | |
| 257 | ratio = 3; | |
| 258 | r?.resize(); | |
| 259 | expect([canvas.width, canvas.height]).toEqual([1600, 1200]); | |
| 260 | ratio = 1; | |
| 261 | r?.draw(scene(), 1 / 60); | |
| 262 | expect([canvas.width, canvas.height]).toEqual([800, 600]); | |
| 263 | expect(of(gl, "viewport").at(-1)).toEqual(["viewport", 0, 0, 800, 600]); | |
| 264 | }); | |
| 265 | ||
| 266 | test("the lines grow their buffer once and are drawn in pairs", () => { | |
| 267 | const r = renderer(); | |
| 268 | const lit = scene(); | |
| 269 | lit.lineCount = 3; | |
| 270 | r.r.draw(lit, 1 / 60); | |
| 271 | expect(frameCalls(r, "bufferData").length).toBe(1); | |
| 272 | expect(frameCalls(r, "bufferSubData").length).toBe(1); | |
| 273 | // Three vertices is one line: an odd vertex has nothing to be a line with. | |
| 274 | expect(frameCalls(r, "drawArrays")).toEqual([["drawArrays", r.gl.LINES, 0, 2]]); | |
| 275 | ||
| 276 | // A second frame of the same buffer reuses it rather than growing it again. | |
| 277 | const before = r.gl.calls.length; | |
| 278 | r.r.draw(lit, 1 / 60); | |
| 279 | expect(r.gl.calls.slice(before).filter((c) => c[0] === "bufferData")).toEqual([]); | |
| 280 | }); | |
| 281 | ||
| 282 | test("the gun in your hands is drawn last, on its own depth", () => { | |
| 283 | const r = renderer(); | |
| 284 | const held = scene(); | |
| 285 | held.viewModel = { mesh: "player", offset: [0, 0, 0] }; | |
| 286 | r.r.draw(held, 1 / 60); | |
| 287 | const calls = r.gl.calls.slice(r.made).map((c) => c[0]); | |
| 288 | // Two clears: the frame, then the depth buffer alone before the view model. | |
| 289 | expect(calls.filter((name) => name === "clear").length).toBe(2); | |
| 290 | expect(calls.lastIndexOf("clear")).toBeLessThan(calls.lastIndexOf("drawElements")); | |
| 291 | expect(frameCalls(r, "drawElements").length).toBe(3); | |
| 292 | }); | |
| 293 | ||
| 294 | test("nothing is drawn for a scene with nothing in it", () => { | |
| 295 | const r = renderer(); | |
| 296 | const empty = scene(); | |
| 297 | empty.instanceCount = 0; | |
| 298 | r.r.draw(empty, 1 / 60); | |
| 299 | expect(frameCalls(r, "drawElements")).toEqual([]); | |
| 300 | expect(frameCalls(r, "drawArrays")).toEqual([]); | |
| 301 | // The frame is still cleared: an empty scene is a sky, not a stale screen. | |
| 302 | expect(frameCalls(r, "clear").length).toBe(1); | |
| 303 | }); |
src/client/renderer.ts → src/engine/renderer.ts renamed +143 −32
| @@ -1,18 +1,24 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * The WebGL2 renderer. Two programs and a handful of buffers: one upload per |
| 3 | * mesh name, one draw per instance the scene lists, and tracers and sparks | |
| 4 | * share the single line buffer the scene brings with it. | |
| 3 | * mesh at construction, one draw per instance the scene lists, and tracers and | |
| 4 | * sparks share the single line buffer the scene brings with it. | |
| 5 | 5 | * |
| 6 | * It decides nothing. A `Scene` from `scene.ts` says where every mesh stands, | |
| 7 | * what it is tinted, which lines to stroke and which gun is in your hands; this | |
| 8 | * file turns that into GL calls and knows no game — no world, no physics, no | |
| 9 | * weapons. What a mesh name is made of is `models.ts`'s business. | |
| 6 | * It decides nothing and it owns no asset. A game hands it `RendererAssets` — | |
| 7 | * the array texture its meshes' layers index into, and every mesh a scene of | |
| 8 | * that game can name — and then hands it a `Scene` per frame: where every mesh | |
| 9 | * stands, what it is tinted, which lines to stroke and which gun is in your | |
| 10 | * hands. What a mesh is made of, how many materials there are and how big a | |
| 11 | * tile is are all the game's; this file turns numbers into GL calls and knows | |
| 12 | * no game at all. | |
| 13 | * | |
| 14 | * A canvas and a `WebGL2RenderingContext` are named here as *types*, which cost | |
| 15 | * nothing to import: the only browser object this module ever touches is the | |
| 16 | * surface it is handed, and the device pixel ratio arrives as a function so a | |
| 17 | * test can drive a whole frame with no window in the room. | |
| 10 | 18 | */ |
| 11 | 19 | |
| 12 | import { multiply, perspective, viewMatrix, modelMatrix, type Mesh } from "../engine/index.ts"; | |
| 13 | import { meshFor } from "./models.ts"; | |
| 14 | import { LAYERS, TEX_SIZE, materialTextures } from "./materials.ts"; | |
| 15 | import type { Scene } from "./scene.ts"; | |
| 20 | import { multiply, perspective, viewMatrix, modelMatrix, type Vec3 } from "./math.ts"; | |
| 21 | import type { Mesh } from "./mesh.ts"; | |
| 16 | 22 | |
| 17 | 23 | const SCENE_VS = `#version 300 es |
| 18 | 24 | precision highp float; |
| @@ -79,8 +85,91 @@ in vec3 vColor; | ||
| 79 | 85 | out vec4 outColor; |
| 80 | 86 | void main() { outColor = vec4(vColor, 1.0); }`; |
| 81 | 87 | |
| 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, and | |
| 99 | * `scale` squashes it vertically — a duck, in the shooter — which is the only | |
| 100 | * scaling this renderer applies. | |
| 101 | */ | |
| 102 | export type Instance = { mesh: string; pos: Vec3; yaw: number; scale: number; tint: Tint }; | |
| 103 | ||
| 104 | /** | |
| 105 | * One frame, as plain data. A game fills one of these per frame and allocates | |
| 106 | * nothing doing it: `instances` is a pool of which the first `instanceCount` | |
| 107 | * are live, and `lines` is one buffer of which the first `lineCount * 6` floats | |
| 108 | * are. Anything else a game wants to remember between frames it hangs off its | |
| 109 | * own type; this is only what a draw reads. | |
| 110 | */ | |
| 111 | export type Scene = { | |
| 112 | camera: Camera; | |
| 113 | /** | |
| 114 | * What this frame clears to and what distance fades into. Left out, it is the | |
| 115 | * `sky` the renderer was built with: a game whose horizon never changes says | |
| 116 | * so once rather than every frame. | |
| 117 | */ | |
| 118 | sky?: Tint; | |
| 119 | /** The pool. Only the first `instanceCount` of these are this frame's. */ | |
| 120 | instances: Instance[]; | |
| 121 | instanceCount: number; | |
| 122 | /** Line vertices, six floats each: x, y, z, r, g, b. */ | |
| 123 | lines: Float32Array; | |
| 124 | /** Vertices written this frame — `lineCount * 6` floats of `lines` are live. */ | |
| 125 | lineCount: number; | |
| 126 | viewModel: ViewModel | null; | |
| 127 | }; | |
| 128 | ||
| 129 | /** | |
| 130 | * What a game brings to a renderer, all of it uploaded once at construction. | |
| 131 | * | |
| 132 | * The atlas is a `TEXTURE_2D_ARRAY`: `layers` layers of `size` × `size` RGBA8 | |
| 133 | * pixels, tightly packed, which is exactly what `paintAtlas` returns. Neither | |
| 134 | * number is the engine's to assume — a game that paints 64-pixel tiles hands 64 | |
| 135 | * — and `bytes` that are not `size * size * 4 * layers` long is a mistake this | |
| 136 | * says so about rather than uploading garbage. | |
| 137 | */ | |
| 138 | export type RendererAssets = { | |
| 139 | atlas: { bytes: Uint8Array; size: number; layers: number }; | |
| 140 | /** | |
| 141 | * Every mesh a scene of this game can name, by that name. Each is uploaded | |
| 142 | * once, here; a scene that asks for a name that is not in this table throws, | |
| 143 | * because that is a bug in the game and not a frame to quietly skip. | |
| 144 | */ | |
| 145 | meshes: Record<string, Mesh>; | |
| 146 | /** The horizon a frame that names no sky is cleared to and fades into. Black when left out. */ | |
| 147 | sky?: Tint; | |
| 148 | /** | |
| 149 | * How many device pixels a CSS pixel is: `() => window.devicePixelRatio` in a | |
| 150 | * browser, 1 when left out. It is a function because it changes when a window | |
| 151 | * moves between screens, and a parameter because the engine reads no global. | |
| 152 | */ | |
| 153 | devicePixelRatio?: () => number; | |
| 154 | }; | |
| 155 | ||
| 156 | export type Renderer = { | |
| 157 | draw(scene: Scene, dt: number): void; | |
| 158 | resize(): void; | |
| 159 | readonly gl: WebGL2RenderingContext; | |
| 160 | }; | |
| 161 | ||
| 82 | 162 | type Buffers = { vao: WebGLVertexArrayObject; count: number }; |
| 83 | 163 | |
| 164 | /** The bytes an atlas of this shape must be: RGBA8, every layer, tightly packed. */ | |
| 165 | const atlasBytes = (size: number, layers: number): number => size * size * 4 * layers; | |
| 166 | ||
| 167 | /** Past this the pixels cost more than they show, on a phone especially. */ | |
| 168 | const MAX_PIXEL_RATIO = 2; | |
| 169 | ||
| 170 | const BLACK: Tint = [0, 0, 0]; | |
| 171 | const ORIGIN: Vec3 = [0, 0, 0]; | |
| 172 | ||
| 84 | 173 | function compile(gl: WebGL2RenderingContext, type: number, source: string): WebGLShader { |
| 85 | 174 | const shader = gl.createShader(type) as WebGLShader; |
| 86 | 175 | gl.shaderSource(shader, source); |
| @@ -121,13 +210,31 @@ function upload(gl: WebGL2RenderingContext, mesh: Mesh): Buffers { | ||
| 121 | 210 | return { vao, count: mesh.index.length }; |
| 122 | 211 | } |
| 123 | 212 | |
| 124 | export type Renderer = { | |
| 125 | draw(scene: Scene, dt: number): void; | |
| 126 | resize(): void; | |
| 127 | readonly gl: WebGL2RenderingContext; | |
| 128 | }; | |
| 213 | /** | |
| 214 | * Why these assets are not assets, or null when they are. Checked before a | |
| 215 | * context is asked for, because an atlas of the wrong length is a mistake in | |
| 216 | * the game whether or not this browser can draw at all. | |
| 217 | */ | |
| 218 | function wrongAssets(assets: RendererAssets): string | null { | |
| 219 | const { size, layers, bytes } = assets.atlas; | |
| 220 | if (!Number.isInteger(size) || size <= 0 || !Number.isInteger(layers) || layers <= 0) | |
| 221 | return `atlas: size and layers must be whole numbers above zero, not ${size} and ${layers}`; | |
| 222 | const want = atlasBytes(size, layers); | |
| 223 | if (bytes.length !== want) | |
| 224 | return `atlas: ${layers} layers of ${size}x${size} RGBA is ${want} bytes, not ${bytes.length}`; | |
| 225 | return null; | |
| 226 | } | |
| 227 | ||
| 228 | /** | |
| 229 | * A renderer on this surface, or null when the browser has no WebGL2 — which is | |
| 230 | * a browser to tell the player about, not an error. Everything else that is | |
| 231 | * wrong here throws: the assets are checked once, the meshes are uploaded once, | |
| 232 | * and after that a frame is arithmetic and GL calls. | |
| 233 | */ | |
| 234 | export function createRenderer(canvas: HTMLCanvasElement, assets: RendererAssets): Renderer | null { | |
| 235 | const wrong = wrongAssets(assets); | |
| 236 | if (wrong !== null) throw new Error(wrong); | |
| 129 | 237 | |
| 130 | export function createRenderer(canvas: HTMLCanvasElement): Renderer | null { | |
| 131 | 238 | const gl = canvas.getContext("webgl2", { antialias: true, alpha: false, depth: true }); |
| 132 | 239 | if (gl === null) return null; |
| 133 | 240 | |
| @@ -145,11 +252,12 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer | null { | ||
| 145 | 252 | lineViewProj: uniform(strokes, "uViewProj"), |
| 146 | 253 | }; |
| 147 | 254 | |
| 255 | const atlas = assets.atlas; | |
| 148 | 256 | const texture = gl.createTexture(); |
| 149 | 257 | gl.bindTexture(gl.TEXTURE_2D_ARRAY, texture); |
| 150 | 258 | gl.texImage3D( |
| 151 | gl.TEXTURE_2D_ARRAY, 0, gl.RGBA8, TEX_SIZE, TEX_SIZE, LAYERS, 0, gl.RGBA, gl.UNSIGNED_BYTE, | |
| 152 | materialTextures(), | |
| 259 | gl.TEXTURE_2D_ARRAY, 0, gl.RGBA8, atlas.size, atlas.size, atlas.layers, 0, gl.RGBA, | |
| 260 | gl.UNSIGNED_BYTE, atlas.bytes, | |
| 153 | 261 | ); |
| 154 | 262 | gl.generateMipmap(gl.TEXTURE_2D_ARRAY); |
| 155 | 263 | gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR); |
| @@ -157,14 +265,15 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer | null { | ||
| 157 | 265 | gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_WRAP_S, gl.REPEAT); |
| 158 | 266 | gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_WRAP_T, gl.REPEAT); |
| 159 | 267 | |
| 160 | // One upload per mesh name the scene asks for, the first time it asks. | |
| 268 | // One upload per mesh the game handed in, all of them here and none in a frame. | |
| 161 | 269 | const uploaded = new Map<string, Buffers>(); |
| 270 | for (const [name, mesh] of Object.entries(assets.meshes)) uploaded.set(name, upload(gl, mesh)); | |
| 162 | 271 | const buffersFor = (name: string): Buffers => { |
| 163 | let buffers = uploaded.get(name); | |
| 164 | if (buffers === undefined) { | |
| 165 | buffers = upload(gl, meshFor(name)); | |
| 166 | uploaded.set(name, buffers); | |
| 167 | } | |
| 272 | const buffers = uploaded.get(name); | |
| 273 | if (buffers === undefined) | |
| 274 | throw new Error( | |
| 275 | `no mesh named "${name}": the renderer was handed ${[...uploaded.keys()].join(", ") || "none"}`, | |
| 276 | ); | |
| 168 | 277 | return buffers; |
| 169 | 278 | }; |
| 170 | 279 | |
| @@ -185,8 +294,11 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer | null { | ||
| 185 | 294 | gl.enable(gl.CULL_FACE); |
| 186 | 295 | gl.cullFace(gl.BACK); |
| 187 | 296 | |
| 297 | const pixelRatio = assets.devicePixelRatio; | |
| 298 | const ownSky = assets.sky ?? BLACK; | |
| 299 | ||
| 188 | 300 | const resize = (): void => { |
| 189 | const dpr = Math.min(window.devicePixelRatio || 1, 2); | |
| 301 | const dpr = Math.min((pixelRatio === undefined ? 1 : pixelRatio()) || 1, MAX_PIXEL_RATIO); | |
| 190 | 302 | const width = Math.round(canvas.clientWidth * dpr); |
| 191 | 303 | const height = Math.round(canvas.clientHeight * dpr); |
| 192 | 304 | if (canvas.width !== width || canvas.height !== height) { |
| @@ -195,19 +307,18 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer | null { | ||
| 195 | 307 | } |
| 196 | 308 | }; |
| 197 | 309 | |
| 198 | const ORIGIN: [number, number, number] = [0, 0, 0]; | |
| 199 | ||
| 200 | 310 | /** |
| 201 | 311 | * One frame of the scene handed in. `dt` is the frame's seconds: nothing in |
| 202 | * the draw animates itself any more — the walk bob went to `scene.ts` with | |
| 203 | * every other decision — and it stays in the signature because a renderer's | |
| 204 | * own time (a fade, an exposure) would belong here rather than in a scene. | |
| 312 | * the draw animates itself — a walk bob is a decision and decisions are the | |
| 313 | * game's — and it stays in the signature because a renderer's own time (a | |
| 314 | * fade, an exposure) would belong here rather than in a scene. | |
| 205 | 315 | */ |
| 206 | 316 | const draw = (scene: Scene, dt: number): void => { |
| 207 | 317 | resize(); |
| 208 | 318 | const width = canvas.width, height = canvas.height; |
| 319 | const sky = scene.sky ?? ownSky; | |
| 209 | 320 | gl.viewport(0, 0, width, height); |
| 210 | gl.clearColor(scene.sky[0], scene.sky[1], scene.sky[2], 1); | |
| 321 | gl.clearColor(sky[0], sky[1], sky[2], 1); | |
| 211 | 322 | gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); |
| 212 | 323 | gl.enable(gl.DEPTH_TEST); |
| 213 | 324 | |
| @@ -225,7 +336,7 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer | null { | ||
| 225 | 336 | gl.uniform1i(u.atlas, 0); |
| 226 | 337 | gl.uniformMatrix4fv(u.viewProj, false, viewProj); |
| 227 | 338 | gl.uniform3fv(u.eye, camera.pos); |
| 228 | gl.uniform3fv(u.fog, scene.sky); | |
| 339 | gl.uniform3fv(u.fog, sky); | |
| 229 | 340 | |
| 230 | 341 | // --- every mesh the scene placed, in the order it placed them |
| 231 | 342 | let bound: Buffers | null = null; |
static/app.a3f495de96.js added +58
Diff not shown — a line 58,979 characters wide (limit 2,000). +58
View filestatic/app.e528bd2a25.js deleted −58
Diff not shown — a line 57,283 characters wide (limit 2,000). −58
View filestatic/range.8fd26bf773.js deleted −1
Diff not shown — a line 12,533 characters wide (limit 2,000). −1
View filestatic/range.925e79efd7.js added +1
Diff not shown — a line 12,533 characters wide (limit 2,000). +1
View file
new above
offline