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

Merged opened this

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

Merged

vesper/cs-engine:vesper/48-the-renderer-moves-behind-the-door-src-e → main 2 commits fork

  • Merged. vesper/48-the-renderer-moves-behind-the-door-src-e was merged into main as 84edc40.

Review

Sign in to review this change.
17 files changed +683 −160
README.md +21 −13
@@ -108,11 +108,14 @@ from the angle it subtends, and you turn on the spot.
108108 knows nothing about de_forge's alphabet), `mesh` (a box builder), `texture`
109109 (hashed value noise, fbm and an atlas painter), `sound` (a lazy `AudioContext`
110110 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
112112 2D over the scene: a scale and a pad off the canvas width, scaled fonts,
113113 panels, aligned and faded text, a crosshair, a bar, right-aligned columns and
114114 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).
116119 Three rules keep it a package, and a test holds each: the engine imports
117120 nothing outside itself, it needs nothing from a browser to load, and
118121 everything outside it comes in through one door.
@@ -126,8 +129,10 @@ from the angle it subtends, and you turn on the spot.
126129 module a bench or a server-rendered page cannot import. (The second process is
127130 `purity.probe.ts`: a dynamic import is cached per process, so the first load
128131 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
131136 `AudioContext` on the first sound rather than on load.
132137 - `src/engine/index.ts` — the door, and the rule is that a second game imports
133138 `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.
143148 `sound` `attenuate`, `createSoundDevice`, `Burst`, `Tone`, `SoundDevice` and
144149 `Ctor`; from `loop` `createLoop`, `Frames`, `Loop` and `LoopOptions`; from
145150 `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
148155 module that is not behind the door, and any change to that list of names that
149156 is not also a change to the list written out in the test. One exception, and
150157 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.
160167 over the engine's grid), `physics`, `weapons`, `combat`, `match` (the rules),
161168 `world` (the simulation),
162169 `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
167176 function from a held action to an `Input`. `scene` is the frame as data —
168177 `buildScene(world, camera, into)` fills one scene object with the camera, the
169178 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
173181 about a frame — who is drawn, how far a duck squashes them, how long a tracer
174182 lives, how far the last shot kicked the view model — is checked in
175183 `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 };
3636 * this ceiling raised, and #18 takes the same bytes back out of the shooter.
3737 * The app grew 1,254 bytes for #47, which moved every decision about a frame
3838 * 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,
4045 * the clock and the score, the shot traced through the engine's `rayBox`, and
4146 * the blips the horizon draws them as.
4247 * 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 @@
1313 */
1414
1515 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";
1718 import { parseResult } from "../result.ts";
1819 import { fakeStage, type FakeStage } from "./stage.test-helper.ts";
1920 import type { World } from "./world.ts";
@@ -298,3 +299,21 @@ test("no WebGL2 is a sentence on the page, not an exception", () => {
298299 expect(stage.document.listening.size).toBe(0);
299300 expect(stage.draws.length).toBe(0);
300301 });
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 @@
22 * The frame. The engine's loop says how many 64 Hz steps this frame is worth
33 * and the engine's controls say which actions are held; `binding` turns those
44 * 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
67 * reachable with a keyboard and big enough for a thumb.
78 *
89 * Nothing in here is a browser. `boot(host)` takes everything it reaches for —
@@ -18,12 +19,14 @@ import { createAudio, type Audio } from "./audio.ts";
1819 import { CS_KEYS, CS_MOUSE, CS_TAP, readIntent } from "./binding.ts";
1920 import { drawHud, type Paint } from "./hud.ts";
2021 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,
2224 } from "../engine/index.ts";
2325 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";
2730 import {
2831 buy, canBuy, createWorld, eyePos, priceOf, update, type BuyItem, type Player, type World,
2932 } from "./world.ts";
@@ -355,11 +358,32 @@ export function boot(host: Host): Frame {
355358 return { world: () => world };
356359 }
357360
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
358378 /**
359379 * The browser, and the only place in this file where one is named: the real
360380 * 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.
363387 */
364388 const browser = (): Host => ({
365389 document,
@@ -367,7 +391,13 @@ const browser = (): Host => ({
367391 storage: localStorage,
368392 fetch: (url, init) => fetch(url, init),
369393 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 }),
371401 createAudio: () => createAudio(),
372402 });
373403
src/client/models.ts +4 −3
@@ -66,9 +66,10 @@ export function viewModelMesh(kind: string): Mesh {
6666
6767 /**
6868 * 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.
7273 */
7374 export function meshFor(name: string): Mesh {
7475 if (name === "level") return levelMesh(boxes);
src/client/scene.test.ts +16 −12
@@ -5,16 +5,17 @@
55 * squashes a player, how long a tracer lives, which gun is in your hands and
66 * how far the last shot kicked it — is a number a test can read.
77 *
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.
1111 */
1212
1313 import { expect, test } from "bun:test";
14 import { readFileSync } from "node:fs";
14 import { existsSync, readFileSync } from "node:fs";
1515 import { join } from "node:path";
16 import type { Camera } from "../engine/index.ts";
1617 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";
1819 import { SHORT } from "./match.ts";
1920 import { createWorld, type Effect, type World } from "./world.ts";
2021
@@ -223,11 +224,14 @@ test("a frame allocates nothing: the same scene, the same buffer, the same insta
223224 expect(scene.viewModel).toBe(held);
224225 });
225226
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 &");
233237 });
src/client/scene.ts +12 −24
@@ -2,9 +2,10 @@
22 * The frame, as data. Everything a draw has to decide about this game — which
33 * mesh stands where, what a team is tinted, how far a tracer has faded, which
44 * 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.
89 *
910 * Nothing is allocated in a frame. `createScene()` makes the one scene and the
1011 * one line buffer a game ever needs, and `buildScene(world, camera, into)`
@@ -13,37 +14,24 @@
1314 * and the view model object is kept even on the frames where there is none.
1415 */
1516
16 import { clamp, type Vec3 } from "../engine/index.ts";
17 import { clamp, type Camera, type Scene as Frame, type Vec3 } from "../engine/index.ts";
1718 import { HULL, hullHeight } from "./physics.ts";
1819 import { WEAPONS } from "./weapons.ts";
1920 import type { World } from "./world.ts";
2021
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
2422 export type Tint = [number, number, number];
2523
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
3324 /** The gun in your hands: which one, and where it sits in view space. */
3425 export type ViewModel = { mesh: string; offset: Vec3 };
3526
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. */
3834 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;
4735 /**
4836 * What the scene remembers between frames so a frame allocates nothing: the
4937 * 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
273273 o.crosshair(Math.round(width / 2), Math.round(height / 2), 0, Math.max(6, Math.round(width / 90)), "#8ea");
274274 ```
275275
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
276324 ## The seam, named as pairs
277325
278326 Two games exist here, and every place they differ is a place the engine chose to
@@ -288,7 +336,7 @@ split.
288336 | mesh recipes | `src/client/models.ts` | none: it draws no geometry yet |
289337 | texture recipes | `src/client/materials.ts` | none: the readout paints its own 2D |
290338 | 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` |
292340 | the tuning | `src/client/physics.ts`, `src/client/weapons.ts` | `targets.STANDARD` in `src/range/targets.ts` |
293341
294342 ## 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", () => {
220220 * copy of the surface, read twice, rather than two that drift.
221221 */
222222 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",
226227 "TouchEventish", "Touchish", "Vec3", "add", "addBox", "angleDelta", "angleVectors",
227228 "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",
229230 "findPath", "finish", "hash", "inside", "length", "length2d", "lerp", "mat4", "mergeSolid",
230231 "modelMatrix", "mul", "multiply", "noise", "normalize", "overlay", "paintAtlas", "perspective",
231232 "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_$]*$/;
4848 * this file ignores without needing to be told about it.
4949 */
5050 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",
5354 ]);
5455
5556 const guide = (): string => readFileSync(GUIDE, "utf8");
src/engine/index.ts +4
@@ -52,3 +52,7 @@ export type {
5252 /** The 2D over the scene: panels, scaled text, a crosshair, a bar and a clock. */
5353 export { overlay } from "./overlay.ts";
5454 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 @@
11 /**
22 * 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.
55 *
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.
1018 */
1119
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";
1622
1723 const SCENE_VS = `#version 300 es
1824 precision highp float;
@@ -79,8 +85,91 @@ in vec3 vColor;
7985 out vec4 outColor;
8086 void main() { outColor = vec4(vColor, 1.0); }`;
8187
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
82162 type Buffers = { vao: WebGLVertexArrayObject; count: number };
83163
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
84173 function compile(gl: WebGL2RenderingContext, type: number, source: string): WebGLShader {
85174 const shader = gl.createShader(type) as WebGLShader;
86175 gl.shaderSource(shader, source);
@@ -121,13 +210,31 @@ function upload(gl: WebGL2RenderingContext, mesh: Mesh): Buffers {
121210 return { vao, count: mesh.index.length };
122211 }
123212
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);
129237
130 export function createRenderer(canvas: HTMLCanvasElement): Renderer | null {
131238 const gl = canvas.getContext("webgl2", { antialias: true, alpha: false, depth: true });
132239 if (gl === null) return null;
133240
@@ -145,11 +252,12 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer | null {
145252 lineViewProj: uniform(strokes, "uViewProj"),
146253 };
147254
255 const atlas = assets.atlas;
148256 const texture = gl.createTexture();
149257 gl.bindTexture(gl.TEXTURE_2D_ARRAY, texture);
150258 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,
153261 );
154262 gl.generateMipmap(gl.TEXTURE_2D_ARRAY);
155263 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 {
157265 gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_WRAP_S, gl.REPEAT);
158266 gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_WRAP_T, gl.REPEAT);
159267
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.
161269 const uploaded = new Map<string, Buffers>();
270 for (const [name, mesh] of Object.entries(assets.meshes)) uploaded.set(name, upload(gl, mesh));
162271 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 );
168277 return buffers;
169278 };
170279
@@ -185,8 +294,11 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer | null {
185294 gl.enable(gl.CULL_FACE);
186295 gl.cullFace(gl.BACK);
187296
297 const pixelRatio = assets.devicePixelRatio;
298 const ownSky = assets.sky ?? BLACK;
299
188300 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);
190302 const width = Math.round(canvas.clientWidth * dpr);
191303 const height = Math.round(canvas.clientHeight * dpr);
192304 if (canvas.width !== width || canvas.height !== height) {
@@ -195,19 +307,18 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer | null {
195307 }
196308 };
197309
198 const ORIGIN: [number, number, number] = [0, 0, 0];
199
200310 /**
201311 * 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.
205315 */
206316 const draw = (scene: Scene, dt: number): void => {
207317 resize();
208318 const width = canvas.width, height = canvas.height;
319 const sky = scene.sky ?? ownSky;
209320 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);
211322 gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
212323 gl.enable(gl.DEPTH_TEST);
213324
@@ -225,7 +336,7 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer | null {
225336 gl.uniform1i(u.atlas, 0);
226337 gl.uniformMatrix4fv(u.viewProj, false, viewProj);
227338 gl.uniform3fv(u.eye, camera.pos);
228 gl.uniform3fv(u.fog, scene.sky);
339 gl.uniform3fv(u.fog, sky);
229340
230341 // --- every mesh the scene placed, in the order it placed them
231342 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 file
static/app.e528bd2a25.js deleted −58

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

View file
static/range.8fd26bf773.js deleted −1

Diff not shown — a line 12,533 characters wide (limit 2,000). −1

View file
static/range.925e79efd7.js added +1

Diff not shown — a line 12,533 characters wide (limit 2,000). +1

View file

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs