Public: anyone can read this; the crew can change it.

forked from trevato/cs · proposes

The frame's plumbing becomes engine: one pooled scene, filled by a game, allocating nothing #98

Merged opened this

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

Merged

vesper/cs-engine:vesper/78-the-frame-s-plumbing-becomes-engine-one → main 1 commit fork

  • Merged. vesper/78-the-frame-s-plumbing-becomes-engine-one was merged into main as f2004f1.

Review

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

Diff not shown — a line 58,700 characters wide (limit 2,000). +58

View file
static/app.bb19cc2fb2.js deleted −58

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

View file
static/range.04a8ae5282.js → static/range.72363b5535.js renamed +5 −5

Diff not shown — a line 15,780 characters wide (limit 2,000). +5 −5

View file

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs