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

forked from trevato/cs

Bodies stand on the floor: a contact shadow under everything that moves, in the engine so every game gets it #330

Merged opened this

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

Merged

vesper/cs-engine:vesper/279-bodies-stand-on-the-floor-a-contact-shad → main 2 commits fork

  • Merged. vesper/279-bodies-stand-on-the-floor-a-contact-shad was merged into main as 24dcb2d.

Review

Sign in to review this change.
16 files changed +617 −76
scripts/build.test.ts +1 −1
@@ -50,7 +50,7 @@ function fixture(): { dir: string; entries: Entry[]; source: (n: string, t: stri
5050 test("the entry table is what the app ships today, each with the size it may not pass", () => {
5151 expect(ENTRIES).toEqual([
5252 { name: "app", entry: "src/client/main.ts", ceiling: { raw: 122_000, gzip: 47_500 } },
53 { name: "range", entry: "src/range/main.ts", ceiling: { raw: 37_000, gzip: 15_500 } },
53 { name: "range", entry: "src/range/main.ts", ceiling: { raw: 41_500, gzip: 17_200 } },
5454 { name: "example", entry: "src/example/main.ts", ceiling: { raw: 23_500, gzip: 10_200 } },
5555 ]);
5656 // And it is the registry projected, not a second list beside it: a game's
src/client/scene.ts +71 −11
@@ -1,7 +1,8 @@
11 /**
22 * The frame, as data. Everything a draw has to decide about this game — which
3 * mesh stands where, what a team is tinted, how far a tracer has faded, which
4 * gun is in your hands and how far the last shot kicked it — is decided here,
3 * mesh stands where, what a team is tinted, what darkens the ground under it,
4 * how far a tracer has faded, which gun is in your hands and how far the last
5 * shot kicked it — is decided here,
56 * in plain numbers a test can read without a canvas. The engine's renderer then
67 * draws exactly this and knows nothing else, which is what lets a second game
78 * hand it a scene of its own: `Scene`, `Camera` and `Instance` are the door's
@@ -18,12 +19,13 @@
1819 */
1920
2021 import {
21 clamp, createScene as createPooledScene, effectAt, hullHeight, nextInstance, pushLine, resetScene,
22 type Camera, type Scene as Frame, type Vec3,
22 clamp, createScene as createPooledScene, effectAt, groundUnder, hullHeight, nextInstance,
23 pushLine, pushShadow, resetScene,
24 type Camera, type Hull, type Scene as Frame, type Vec3,
2325 } from "../engine/index.ts";
2426 import { cloudRadius } from "./grenade.ts";
2527 import { CLOUD_MESH_RADIUS } from "./models.ts";
26 import { CS_HULL } from "./movement.ts";
28 import { CS_HULL, NADE_HULL } from "./movement.ts";
2729 import { FLASH, HE, WEAPONS } from "./weapons.ts";
2830 import type { Player, World } from "./world.ts";
2931
@@ -64,6 +66,34 @@ export const LINE_CAPACITY = 4096;
6466 */
6567 export const INSTANCE_CAPACITY = 48;
6668
69 /**
70 * Contact shadows one frame may cast: ten bodies, the bomb, and thirteen thrown
71 * things in the air or lying where they landed. The clouds cast none — a ball of
72 * smoke standing on a site is not a body, and a dark disc under one would read
73 * as a hole in the floor — so this is smaller than the instance pool rather than
74 * equal to it. Past it a body simply has no shadow, which is the engine's policy
75 * at a cap and the one the other two pools have always had.
76 */
77 export const SHADOW_CAPACITY = 24;
78
79 /**
80 * How far above a surface a body still darkens it, in units. A jump clears 54,
81 * so a player at the top of one is two thirds of the way through this and casts
82 * a shadow a fifth as dark as the one they took off with; a player who stepped
83 * off a 96-unit crate has none until they are near the floor, which is what
84 * makes the two heights read differently at all.
85 *
86 * It is also the reach of the downward trace, so the two cannot disagree: a
87 * surface too far below to be darkened is a surface this never looks for.
88 */
89 export const SHADOW_REACH = 96;
90
91 /** A body's shadow on the ground it stands on: a touch wider than the 32-unit hull. */
92 const BODY_SHADOW = 22, BODY_DARK = 0.55;
93 /** A thrown thing is eight units across; the bomb's case is twenty-two by fourteen. */
94 const THROWN_SHADOW = 7, THROWN_DARK = 0.5;
95 const BOMB_SHADOW = 13;
96
6797 /** How long a blast is drawn for, in seconds, and in how many spokes. */
6898 const BLAST_SECONDS = 0.45;
6999
@@ -117,10 +147,14 @@ function colour(into: Tint, r: number, g: number, b: number): Tint {
117147 return into;
118148 }
119149
120 /** The one scene a game keeps, at this game's two capacities. */
150 /** The one scene a game keeps, at this game's three capacities. */
121151 export function createScene(): Scene {
122152 return {
123 ...createPooledScene({ instances: INSTANCE_CAPACITY, lineVertices: LINE_CAPACITY }),
153 ...createPooledScene({
154 instances: INSTANCE_CAPACITY,
155 lineVertices: LINE_CAPACITY,
156 shadows: SHADOW_CAPACITY,
157 }),
124158 sky: [SKY[0], SKY[1], SKY[2]],
125159 keep: { bobPhase: 0, time: 0, viewModel: { mesh: "rifle", offset: [0, 0, 0] } },
126160 };
@@ -145,6 +179,23 @@ function place(
145179 into.tint[0] = tint[0]; into.tint[1] = tint[1]; into.tint[2] = tint[2];
146180 }
147181
182 /**
183 * One contact shadow, on whatever a downward trace finds under this point — the
184 * top of the crate a player is standing on, not the sand two metres under it,
185 * which is the whole reason this is a trace and not a zero.
186 *
187 * Nothing at all when the trace finds no surface within `SHADOW_REACH`: a body
188 * that high off the ground darkens nothing, and `groundUnder` allocates nothing
189 * answering, which is why the trace is that one and not `traceWorld`.
190 */
191 function shade(
192 scene: Scene, world: World, pos: Vec3, hull: Pick<Hull, "radius">, radius: number, dark: number,
193 ): void {
194 const ground = groundUnder(pos, 0, world.level.boxes, SHADOW_REACH, hull);
195 if (ground === null) return;
196 pushShadow(scene, pos, ground, SHADOW_REACH, radius, dark);
197 }
198
148199 /**
149200 * Fill `into` with what this world looks like from this camera, and hand the
150201 * same object back. The order the instances go in is the order they are drawn:
@@ -168,18 +219,27 @@ export function buildScene(world: World, camera: Camera, into: Scene, view: Play
168219 resetScene(into);
169220 place(into, "level", ORIGIN, 0, 1, WHITE);
170221 for (const p of world.players) {
222 if (!p.alive) continue;
223 // Every living body stands on something, including the one the camera is
224 // inside: look down while you jump and the floor is where it says you are.
225 shade(into, world, p.pos, CS_HULL, BODY_SHADOW, BODY_DARK);
171226 // The eye is inside this head: drawing it would fill the screen. Whose head
172227 // that is moves when you die, which is why it is `view` and not `world.me`.
173 if (!p.alive || p === view) continue;
228 if (p === view) continue;
174229 place(into, "player", p.pos, p.yaw, hullHeight(p, CS_HULL) / CS_HULL.stand, TEAM_TINT[p.team] ?? WHITE);
175230 }
176 if (world.bomb.state === "planted" || world.bomb.state === "dropped")
231 if (world.bomb.state === "planted" || world.bomb.state === "dropped") {
177232 place(into, "bomb", world.bomb.pos, 0, 1, WHITE);
233 shade(into, world, world.bomb.pos, NADE_HULL, BOMB_SHADOW, THROWN_DARK);
234 }
178235 // Every grenade in the air or lying on the floor. It tumbles as it flies:
179236 // the yaw is the simulated time, so a frame decides nothing and a paused
180 // one turns it no further.
181 for (const g of world.grenades)
237 // one turns it no further. Its shadow is the solver's own four-unit hull
238 // traced down — the same footprint that decided where it landed.
239 for (const g of world.grenades) {
182240 place(into, GRENADE_MESH[g.weapon] ?? "grenade", g.pos, g.onGround ? 0 : world.time * 540, 1, WHITE);
241 shade(into, world, g.pos, NADE_HULL, THROWN_SHADOW, THROWN_DARK);
242 }
183243 // Every cloud standing, as the ball the simulation traces against: the same
184244 // centre and the same radius `canSee` reads, so what fills the screen and
185245 // what takes a sightline away are one object. It is opaque and it is drawn in
src/client/stage.test-helper.ts +11 −8
@@ -53,18 +53,21 @@ import type { Scene } from "./scene.ts";
5353 export type GlRecorder = Record<string, unknown> & { calls: [string, ...unknown[]][] };
5454
5555 const GL_ENUMS = [
56 "ARRAY_BUFFER", "BACK", "COLOR_BUFFER_BIT", "COMPILE_STATUS", "CULL_FACE", "DEPTH_BUFFER_BIT",
57 "DEPTH_TEST", "DYNAMIC_DRAW", "ELEMENT_ARRAY_BUFFER", "FLOAT", "FRAGMENT_SHADER", "LINEAR",
58 "LINEAR_MIPMAP_LINEAR", "LINES", "LINK_STATUS", "REPEAT", "RGBA", "RGBA8", "STATIC_DRAW",
59 "TEXTURE0", "TEXTURE_2D_ARRAY", "TEXTURE_MAG_FILTER", "TEXTURE_MIN_FILTER", "TEXTURE_WRAP_S",
60 "TEXTURE_WRAP_T", "TRIANGLES", "UNSIGNED_BYTE", "UNSIGNED_INT", "VERTEX_SHADER",
56 "ARRAY_BUFFER", "BACK", "BLEND", "COLOR_BUFFER_BIT", "COMPILE_STATUS", "CULL_FACE",
57 "DEPTH_BUFFER_BIT", "DEPTH_TEST", "DYNAMIC_DRAW", "ELEMENT_ARRAY_BUFFER", "FLOAT",
58 "FRAGMENT_SHADER", "LINEAR", "LINEAR_MIPMAP_LINEAR", "LINES", "LINK_STATUS",
59 "ONE_MINUS_SRC_ALPHA", "POLYGON_OFFSET_FILL", "REPEAT", "RGBA", "RGBA8", "SRC_ALPHA",
60 "STATIC_DRAW", "TEXTURE0", "TEXTURE_2D_ARRAY", "TEXTURE_MAG_FILTER", "TEXTURE_MIN_FILTER",
61 "TEXTURE_WRAP_S", "TEXTURE_WRAP_T", "TRIANGLES", "TRIANGLE_STRIP", "UNSIGNED_BYTE",
62 "UNSIGNED_INT", "VERTEX_SHADER",
6163 ];
6264
6365 const GL_VOIDS = [
6466 "shaderSource", "compileShader", "attachShader", "linkProgram", "bindTexture", "texImage3D",
65 "generateMipmap", "texParameteri", "bindVertexArray", "bindBuffer", "bufferData", "bufferSubData",
66 "enableVertexAttribArray", "vertexAttribPointer", "enable", "cullFace", "viewport", "clearColor",
67 "clear", "useProgram", "activeTexture", "uniform1i", "uniform3f", "uniform3fv",
67 "generateMipmap", "texParameteri", "bindVertexArray", "bindBuffer", "bufferData",
68 "bufferSubData", "enableVertexAttribArray", "vertexAttribPointer", "enable", "disable",
69 "cullFace", "blendFunc", "polygonOffset", "depthMask", "viewport", "clearColor", "clear",
70 "useProgram", "activeTexture", "uniform1i", "uniform1f", "uniform3f", "uniform3fv",
6871 "uniformMatrix4fv", "drawElements", "drawArrays", "deleteVertexArray", "deleteBuffer",
6972 ];
7073
src/engine/GUIDE.md +42 −3
@@ -517,7 +517,7 @@ nothing at all, because a game that draws is a game with a page.
517517
518518 ### scene
519519
520 **Weight**: 1.0 kB raw, 0.5 kB gzipped — this module alone.
520 **Weight**: 1.5 kB raw, 0.7 kB gzipped — this module alone.
521521
522522 A frame, as plain data, and the pool it is kept in — the one piece of a game's
523523 frame that is bookkeeping rather than a decision, so it is the engine's.
@@ -546,12 +546,37 @@ room for a bigger match — and the range's are at the top of `src/range/scene.t
546546 whose whole line buffer is one tracer: two vertices, 48 bytes, because there is
547547 one shot in the air at a time.
548548
549 A third pool joined those two: the contact shadows the bodies in a frame cast.
550 `SceneCapacity.shadows` is the only one of the three that may be left out, and
551 left out it is zero — a game that wants none pays for none, which is what the
552 example does. `pushShadow` takes where the body is, the ground a downward
553 trace found under it, the reach past which it darkens nothing, and the radius
554 and strength its shadow has *standing on that surface*; what it writes into the
555 pool is a `Shadow` — a point on the surface, a radius and a strength — with the
556 height already spent:
557
558 radius = radius * (1 + t)
559 strength = strength * (1 - t)² t = height / reach
560
561 So a body on the ground gets exactly the two numbers you named, one halfway up
562 its reach is half again as wide and a quarter as dark, and one at or past the
563 reach gets nothing at all — false, and nothing written, the same answer as a
564 spent pool. Two things follow that are worth knowing before you pick the
565 numbers. **The unit is yours**, as everywhere else in here: the reach is in
566 whatever your world of boxes is measured in, and it is usually also the reach of
567 the trace that found the ground, so the two cannot disagree. And **the widening
568 is the cap on fill rate**: a shadow is never wider than twice the radius you
569 asked for, so that one number is how you bound the pixels the pass can spend.
570 The trace itself is yours too — `groundUnder` is the cheap one, a footprint and
571 a reach against your boxes, and it allocates nothing answering.
572
549573 What a `Scene` holds is then: a `Camera` — a position, a yaw, a pitch and a
550574 horizontal field of view — the sky this frame clears to and fades into, a pool
551575 of `Instance` values of which the first `Scene.instanceCount` are live, each a
552576 mesh name with a place, a yaw, a vertical squash and a tint; one
553577 `Float32Array` of line vertices of which the first `Scene.lineCount` are live,
554 six floats each for a tracer or a spark; and `Scene.viewModel`, when there is a
578 six floats each for a tracer or a spark; a pool of `Shadow` values of which the
579 first `Scene.shadowCount` are live; and `Scene.viewModel`, when there is a
555580 gun in your hands. A game's own memory of the last frame hangs off its own type
556581 beside these — the shooter keeps a walk cycle and one view model object there —
557582 and the renderer never sees it.
@@ -598,7 +623,7 @@ in. A game reads the first two of those and leaves the ring arithmetic here.
598623
599624 ### renderer
600625
601 **Weight**: 7.4 kB raw, 3.1 kB gzipped — including math, which comes with it.
626 **Weight**: 9.0 kB raw, 3.6 kB gzipped — including math, which comes with it.
602627
603628 The 3D under the scene, and the largest thing behind the door. `createRenderer`
604629 takes the canvas and the assets and hands back a `Renderer` — or nothing at all,
@@ -626,6 +651,19 @@ shooter's atlas and eight meshes in `src/client/main.ts`, the range's atlas and
626651 two in `src/range/scene.ts`, and the example's atlas and two in
627652 `src/engine/example.ts`, each handed to the call in that game's entry.
628653
654 What a frame draws, in the order it draws it: every instance the scene placed,
655 in the order it placed them; then the contact shadows, as one blended quad each
656 lying on the surface the scene traced them to; then the lines; then the view
657 model, on its own projection and its own depth. **The shadows are where they are
658 on purpose.** By then every opaque thing has written depth, so the depth test
659 alone puts a shadow behind whatever is in front of it — a smoke cloud between
660 the eye and one hides it, and a shadow never darkens the cloud, because the quad
661 is rejected where the cloud is nearer. The pass writes no depth of its own, for
662 the reason a decal does not: a shadow is paint on a surface and not a surface,
663 and one that wrote depth would occlude the tracers that follow. It costs four
664 GL calls a shadow — a centre, a radius, a strength and a four-vertex strip —
665 plus five for the pass, and a frame with no shadows in it makes none of them.
666
629667 Then every frame is the `Scene` above, handed to `Renderer.draw`. Make that
630668 scene once, at start-up, and refill it every frame — both games do, and neither
631669 allocates while a frame is running. Name a mesh that was never handed in and the
@@ -835,6 +873,7 @@ rows, an entry aside, and issue #130 is where it gets one.
835873 | sound recipes | `src/client/audio.ts` — a gun per weapon kind, the round's own noises, and `panOf` straight through | `src/range/audio.ts` — five: a crack, a hit, a knock, a tick and the clock running out, the two out in the lane placed with `panOf` and softened to 0.7 of it |
836874 | renderer assets | an atlas and eight meshes, from `src/client/main.ts` | an atlas and two meshes, from `src/range/scene.ts` |
837875 | a frame as data | `src/client/scene.ts` | `src/range/scene.ts` |
876 | what casts a contact shadow | every living player, the bomb and every grenade, traced against the level's boxes — 24 of them, reaching 96 units | every plate, from its feet, traced against the room — one per instance the pool holds, reaching 128 units |
838877 | a pinned run | `src/client/replay.ts` — a script of held inputs, a round and a duel | `src/range/pin.ts` — a script of trigger pulls, one digest per drill |
839878 | no WebGL2 | a sentence in the menu, and nothing starts | the 2D horizon in `src/range/readout.ts`, and it still plays |
840879 | the mover's tuning and hull | `movement.CS_MOVE` and `movement.CS_HULL` in `src/client/movement.ts` | `movement.RANGE_MOVE` and `movement.RANGE_HULL` in `src/range/movement.ts` — its own numbers, the same solver |
src/engine/index.ts +2 −2
@@ -78,8 +78,8 @@ export { overlay } from "./overlay.ts";
7878 export type { Overlay, Paint } from "./overlay.ts";
7979
8080 /** A frame as data, in a pool: made once at a capacity, refilled, never grown. */
81 export { createScene, nextInstance, pushLine, resetScene } from "./scene.ts";
82 export type { Camera, Instance, Scene, SceneCapacity } from "./scene.ts";
81 export { createScene, nextInstance, pushLine, pushShadow, resetScene } from "./scene.ts";
82 export type { Camera, Instance, Scene, SceneCapacity, Shadow } from "./scene.ts";
8383
8484 /**
8585 * What just happened, kept for a moment: a capped ring of timestamped events,
src/engine/physics.ts +6 −1
@@ -143,9 +143,14 @@ function slideAxis(
143143 * The floor holding a hull up: the highest box top within `reach` below the
144144 * feet. Asking this directly, rather than resolving a downward move, is what
145145 * keeps a player standing under a low ceiling from being shoved through it.
146 *
147 * It reads one field of the hull, so it asks for one, the way `eyeHeight` does:
148 * the downward trace under a thrown grenade or a contact shadow is a footprint
149 * and a reach, and a caller that has only those should not have to invent an eye
150 * height to ask what is under them.
146151 */
147152 export function groundUnder(
148 pos: Vec3, height: number, boxes: Box[], reach: number, hull: Hull,
153 pos: Vec3, height: number, boxes: Box[], reach: number, hull: Pick<Hull, "radius">,
149154 ): number | null {
150155 let best: number | null = null;
151156 for (const box of boxes) {
src/engine/renderer.test.ts +95 −16
@@ -23,11 +23,13 @@ type Call = [string, ...unknown[]];
2323
2424 /** Every GL enum this module names. Numbered so a recorded call reads back. */
2525 const ENUMS = [
26 "ARRAY_BUFFER", "BACK", "COLOR_BUFFER_BIT", "COMPILE_STATUS", "CULL_FACE", "DEPTH_BUFFER_BIT",
27 "DEPTH_TEST", "DYNAMIC_DRAW", "ELEMENT_ARRAY_BUFFER", "FLOAT", "FRAGMENT_SHADER", "LINEAR",
28 "LINEAR_MIPMAP_LINEAR", "LINES", "LINK_STATUS", "REPEAT", "RGBA", "RGBA8", "STATIC_DRAW",
29 "TEXTURE0", "TEXTURE_2D_ARRAY", "TEXTURE_MAG_FILTER", "TEXTURE_MIN_FILTER", "TEXTURE_WRAP_S",
30 "TEXTURE_WRAP_T", "TRIANGLES", "UNSIGNED_BYTE", "UNSIGNED_INT", "VERTEX_SHADER",
26 "ARRAY_BUFFER", "BACK", "BLEND", "COLOR_BUFFER_BIT", "COMPILE_STATUS", "CULL_FACE",
27 "DEPTH_BUFFER_BIT", "DEPTH_TEST", "DYNAMIC_DRAW", "ELEMENT_ARRAY_BUFFER", "FLOAT",
28 "FRAGMENT_SHADER", "LINEAR", "LINEAR_MIPMAP_LINEAR", "LINES", "LINK_STATUS",
29 "ONE_MINUS_SRC_ALPHA", "POLYGON_OFFSET_FILL", "REPEAT", "RGBA", "RGBA8", "SRC_ALPHA",
30 "STATIC_DRAW", "TEXTURE0", "TEXTURE_2D_ARRAY", "TEXTURE_MAG_FILTER", "TEXTURE_MIN_FILTER",
31 "TEXTURE_WRAP_S", "TEXTURE_WRAP_T", "TRIANGLES", "TRIANGLE_STRIP", "UNSIGNED_BYTE",
32 "UNSIGNED_INT", "VERTEX_SHADER",
3133 ] as const;
3234
3335 /** A resource the stub handed out, in the order it handed them out. */
@@ -64,10 +66,10 @@ function stubGl(): StubGl {
6466 for (const name of [
6567 "shaderSource", "compileShader", "attachShader", "linkProgram", "bindTexture", "texImage3D",
6668 "generateMipmap", "texParameteri", "bindVertexArray", "bindBuffer", "bufferData",
67 "bufferSubData", "enableVertexAttribArray", "vertexAttribPointer", "enable", "cullFace",
68 "viewport", "clearColor", "clear", "useProgram", "activeTexture", "uniform1i", "uniform3f",
69 "uniform3fv", "uniformMatrix4fv", "drawElements", "drawArrays", "deleteVertexArray",
70 "deleteBuffer",
69 "bufferSubData", "enableVertexAttribArray", "vertexAttribPointer", "enable", "disable",
70 "cullFace", "blendFunc", "polygonOffset", "depthMask", "viewport", "clearColor", "clear",
71 "useProgram", "activeTexture", "uniform1i", "uniform1f", "uniform3f", "uniform3fv",
72 "uniformMatrix4fv", "drawElements", "drawArrays", "deleteVertexArray", "deleteBuffer",
7173 ]) gl[name] = note(name);
7274 return gl;
7375 }
@@ -126,6 +128,11 @@ function scene(mesh = "level"): Scene {
126128 instanceCount: 2,
127129 lines: new Float32Array(12),
128130 lineCount: 0,
131 shadows: [
132 { pos: [10, 20, 0], radius: 24, strength: 0.5 },
133 { pos: [30, 40, 16], radius: 36, strength: 0.2 },
134 ],
135 shadowCount: 0,
129136 viewModel: null,
130137 };
131138 }
@@ -191,14 +198,16 @@ test("construction uploads the atlas once, at the size and layer count it was ha
191198 test("construction uploads one buffer set per mesh handed in, and nothing later", () => {
192199 const two = renderer({ level: box(), player: box(6) });
193200 // Five buffers a mesh — position, normal, uv, layer, index — plus the one the
194 // lines share, and a vertex array each plus the lines'.
195 expect(of(two.gl, "createBuffer").length).toBe(2 * 5 + 1);
196 expect(of(two.gl, "createVertexArray").length).toBe(2 + 1);
197 expect(of(two.gl, "bufferData").length).toBe(2 * 5);
201 // lines share and the one every contact shadow is stamped from, and a vertex
202 // array each plus those two.
203 expect(of(two.gl, "createBuffer").length).toBe(2 * 5 + 2);
204 expect(of(two.gl, "createVertexArray").length).toBe(2 + 2);
205 // The meshes, and the shadow quad: four corners, uploaded once like the rest.
206 expect(of(two.gl, "bufferData").length).toBe(2 * 5 + 1);
198207
199208 const three = renderer({ level: box(), player: box(6), bomb: box(2) });
200 expect(of(three.gl, "createBuffer").length).toBe(3 * 5 + 1);
201 expect(of(three.gl, "createVertexArray").length).toBe(3 + 1);
209 expect(of(three.gl, "createBuffer").length).toBe(3 * 5 + 2);
210 expect(of(three.gl, "createVertexArray").length).toBe(3 + 2);
202211
203212 // A frame uploads nothing: the whole point of taking the meshes at construction.
204213 two.r.draw(scene(), 1 / 60);
@@ -215,7 +224,7 @@ test("a mesh is replaced whole: the new one uploaded, the old one given back", (
215224 const madeBefore = of(two.gl, "createBuffer").length;
216225 two.r.replace("level", box(4));
217226 expect(of(two.gl, "createBuffer").length).toBe(madeBefore + 5);
218 expect(of(two.gl, "createVertexArray").length).toBe(2 + 1 + 1);
227 expect(of(two.gl, "createVertexArray").length).toBe(2 + 2 + 1);
219228 // Five buffers and one vertex array back to the driver: exactly one mesh.
220229 expect(of(two.gl, "deleteBuffer").length).toBe(5);
221230 expect(of(two.gl, "deleteVertexArray").length).toBe(1);
@@ -348,6 +357,76 @@ test("the lines grow their buffer once and are drawn in pairs", () => {
348357 expect(r.gl.calls.slice(before).filter((c) => c[0] === "bufferData")).toEqual([]);
349358 });
350359
360 test("a contact shadow is one blended quad, and it writes no depth", () => {
361 const r = renderer();
362 const lit = scene();
363 lit.shadowCount = 2;
364 r.r.draw(lit, 1 / 60);
365 // One strip of four corners each, out of the quad uploaded at construction.
366 expect(frameCalls(r, "drawArrays")).toEqual([
367 ["drawArrays", r.gl.TRIANGLE_STRIP, 0, 4],
368 ["drawArrays", r.gl.TRIANGLE_STRIP, 0, 4],
369 ]);
370 // Blending and the depth offset are on for the pass and off after it, so the
371 // lines and the view model that follow are drawn in the state they expect.
372 expect(frameCalls(r, "enable").map((c) => c[1])).toEqual([
373 r.gl.DEPTH_TEST, r.gl.BLEND, r.gl.POLYGON_OFFSET_FILL,
374 ]);
375 expect(frameCalls(r, "disable").map((c) => c[1])).toEqual([
376 r.gl.POLYGON_OFFSET_FILL, r.gl.BLEND,
377 ]);
378 // Paint on a surface, not a surface: depth is read and never written.
379 expect(frameCalls(r, "depthMask").map((c) => c[1])).toEqual([false, true]);
380 // And each quad carries its own place, width and darkness.
381 const centres = frameCalls(r, "uniform3fv")
382 .filter((c) => (c[1] as Resource).kind === "uCentre")
383 .map((c) => c[2]);
384 expect(centres).toEqual([[10, 20, 0], [30, 40, 16]]);
385 expect(frameCalls(r, "uniform1f").map((c) => [(c[1] as Resource).kind, c[2]])).toEqual([
386 ["uRadius", 24], ["uStrength", 0.5], ["uRadius", 36], ["uStrength", 0.2],
387 ]);
388 });
389
390 test("the shadows are drawn after every solid thing and before the effects", () => {
391 // The order is the decision: the opaque pass has written depth by the time a
392 // shadow is blended, so anything in front of one hides it and a shadow
393 // darkens nothing that is nearer than the ground it lies on.
394 const r = renderer();
395 const lit = scene();
396 lit.shadowCount = 1;
397 lit.lineCount = 2;
398 lit.viewModel = { mesh: "player", offset: [0, 0, 0] };
399 r.r.draw(lit, 1 / 60);
400 const calls = r.gl.calls.slice(r.made);
401 const strip = calls.findIndex((c) => c[0] === "drawArrays" && c[1] === r.gl.TRIANGLE_STRIP);
402 const lines = calls.findIndex((c) => c[0] === "drawArrays" && c[1] === r.gl.LINES);
403 const solid = calls.map((c) => c[0]).indexOf("drawElements");
404 const gun = calls.map((c) => c[0]).lastIndexOf("drawElements");
405 expect(solid).toBeGreaterThan(-1);
406 expect(solid).toBeLessThan(strip);
407 expect(strip).toBeLessThan(lines);
408 expect(lines).toBeLessThan(gun);
409 });
410
411 test("a shadow with nothing left of it costs no quad, and no shadows cost no pass", () => {
412 const r = renderer();
413 const faded = scene();
414 faded.shadowCount = 2;
415 (faded.shadows[0] as { strength: number }).strength = 0;
416 (faded.shadows[1] as { radius: number }).radius = 0;
417 r.r.draw(faded, 1 / 60);
418 expect(frameCalls(r, "drawArrays")).toEqual([]);
419 // The pass was still entered — the count said two — so the state moved.
420 expect(frameCalls(r, "depthMask").length).toBe(2);
421
422 // A frame with no shadows at all touches none of it: no program, no blend.
423 const bare = renderer();
424 bare.r.draw(scene(), 1 / 60);
425 expect(frameCalls(bare, "depthMask")).toEqual([]);
426 expect(frameCalls(bare, "enable").map((c) => c[1])).toEqual([bare.gl.DEPTH_TEST]);
427 expect(frameCalls(bare, "drawArrays")).toEqual([]);
428 });
429
351430 test("the gun in your hands is drawn last, on its own depth", () => {
352431 const r = renderer();
353432 const held = scene();
src/engine/renderer.ts +97 −3
@@ -1,7 +1,8 @@
11 /**
2 * The WebGL2 renderer. Two programs and a handful of buffers: one upload per
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.
2 * The WebGL2 renderer. Three programs and a handful of buffers: one upload per
3 * mesh at construction, one draw per instance the scene lists, one blended quad
4 * per contact shadow, and tracers and sparks share the single line buffer the
5 * scene brings with it.
56 *
67 * It decides nothing and it owns no asset. A game hands it `RendererAssets` —
78 * the array texture its meshes' layers index into, and every mesh a scene of
@@ -73,6 +74,37 @@ void main() {
7374 outColor = vec4(mix(lit, uFog, clamp(fog, 0.0, 0.85)), 1.0);
7475 }`;
7576
77 /**
78 * A contact shadow: one quad lying on the surface the scene found, scaled to
79 * the shadow's radius about its centre. Two attributes would be one too many —
80 * the corner is the only thing that differs between the four vertices, and
81 * everything else about the quad is a uniform.
82 */
83 const SHADOW_VS = `#version 300 es
84 precision highp float;
85 layout(location=0) in vec2 aCorner;
86 uniform mat4 uViewProj;
87 uniform vec3 uCentre;
88 uniform float uRadius;
89 out vec2 vCorner;
90 void main() {
91 vCorner = aCorner;
92 gl_Position = uViewProj * vec4(uCentre + vec3(aCorner * uRadius, 0.0), 1.0);
93 }`;
94
95 const SHADOW_FS = `#version 300 es
96 precision highp float;
97 in vec2 vCorner;
98 uniform float uStrength;
99 out vec4 outColor;
100 void main() {
101 // A dome rather than a disc: darkest under the body, nothing at the rim, and
102 // no edge for a player to see the quad by. r² costs no square root.
103 float d = 1.0 - dot(vCorner, vCorner);
104 if (d <= 0.0) discard;
105 outColor = vec4(0.0, 0.0, 0.0, uStrength * d);
106 }`;
107
76108 const LINE_VS = `#version 300 es
77109 precision highp float;
78110 layout(location=0) in vec3 aPos;
@@ -157,6 +189,9 @@ const NEAR = 6, FAR = 12000;
157189 const BLACK: Tint = [0, 0, 0];
158190 const ORIGIN: Vec3 = [0, 0, 0];
159191
192 /** The four corners of the unit quad every shadow is one instance of. */
193 const SHADOW_QUAD = new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]);
194
160195 function compile(gl: WebGL2RenderingContext, type: number, source: string): WebGLShader {
161196 const shader = gl.createShader(type) as WebGLShader;
162197 gl.shaderSource(shader, source);
@@ -233,6 +268,7 @@ export function createRenderer(canvas: HTMLCanvasElement, assets: RendererAssets
233268
234269 const surfaces = link(gl, SCENE_VS, SCENE_FS);
235270 const strokes = link(gl, LINE_VS, LINE_FS);
271 const shadows = link(gl, SHADOW_VS, SHADOW_FS);
236272 const uniform = (program: WebGLProgram, name: string): WebGLUniformLocation | null =>
237273 gl.getUniformLocation(program, name);
238274 const u = {
@@ -243,6 +279,10 @@ export function createRenderer(canvas: HTMLCanvasElement, assets: RendererAssets
243279 fog: uniform(surfaces, "uFog"),
244280 atlas: uniform(surfaces, "uAtlas"),
245281 lineViewProj: uniform(strokes, "uViewProj"),
282 shadowViewProj: uniform(shadows, "uViewProj"),
283 shadowCentre: uniform(shadows, "uCentre"),
284 shadowRadius: uniform(shadows, "uRadius"),
285 shadowStrength: uniform(shadows, "uStrength"),
246286 };
247287
248288 const atlas = assets.atlas;
@@ -283,9 +323,28 @@ export function createRenderer(canvas: HTMLCanvasElement, assets: RendererAssets
283323 gl.vertexAttribPointer(1, 3, gl.FLOAT, false, 24, 12);
284324 gl.bindVertexArray(null);
285325
326 // The one quad every contact shadow is drawn from: four corners of a unit
327 // square in the ground plane, uploaded once, scaled and placed by uniforms.
328 // Wound so the front face is the one seen from above, which is the only side
329 // a shadow on a floor is ever looked at from.
330 const shadowVao = gl.createVertexArray() as WebGLVertexArrayObject;
331 const shadowBuffer = gl.createBuffer();
332 gl.bindVertexArray(shadowVao);
333 gl.bindBuffer(gl.ARRAY_BUFFER, shadowBuffer);
334 gl.bufferData(gl.ARRAY_BUFFER, SHADOW_QUAD, gl.STATIC_DRAW);
335 gl.enableVertexAttribArray(0);
336 gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
337 gl.bindVertexArray(null);
338
286339 gl.enable(gl.DEPTH_TEST);
287340 gl.enable(gl.CULL_FACE);
288341 gl.cullFace(gl.BACK);
342 gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
343 // The shadow lies exactly on the floor it was traced to, so it is exactly
344 // where the floor is: the offset is what keeps the two out of each other's
345 // depth, and it is in depth units rather than world ones, which is what makes
346 // it the same decision in a game measured in metres and one in Source units.
347 gl.polygonOffset(-1, -1);
289348
290349 const pixelRatio = assets.devicePixelRatio;
291350 const ownSky = assets.sky ?? BLACK;
@@ -353,6 +412,41 @@ export function createRenderer(canvas: HTMLCanvasElement, assets: RendererAssets
353412 gl.drawElements(gl.TRIANGLES, buffers.count, gl.UNSIGNED_INT, 0);
354413 }
355414
415 // --- the contact shadows, on the ground the scene traced them to
416 //
417 // Here on purpose, and the order is the whole of the decision. Every opaque
418 // thing in the frame — the level, the bodies, and the shooter's smoke, which
419 // is a solid ball in this pass and not a transparent one — has been drawn
420 // and has written depth, so the depth test alone puts a shadow behind
421 // whatever is in front of it: a cloud between the eye and a shadow hides it,
422 // and a shadow never darkens a cloud, because the quad is rejected where the
423 // cloud is nearer. Depth *writing* is off for the same reason a decal's is:
424 // a shadow is paint on a surface, not a surface, and one that wrote depth
425 // would occlude the tracers and the view model that come after it. Nothing
426 // here is sorted — they are blended black over ground that is already there,
427 // so two shadows that overlap read the same in either order.
428 const shadowCount = scene.shadowCount;
429 if (shadowCount > 0) {
430 gl.useProgram(shadows);
431 gl.uniformMatrix4fv(u.shadowViewProj, false, viewProj);
432 gl.bindVertexArray(shadowVao);
433 gl.enable(gl.BLEND);
434 gl.enable(gl.POLYGON_OFFSET_FILL);
435 gl.depthMask(false);
436 for (let i = 0; i < shadowCount; i++) {
437 const shadow = scene.shadows[i];
438 if (shadow === undefined) break;
439 if (shadow.strength <= 0 || shadow.radius <= 0) continue;
440 gl.uniform3fv(u.shadowCentre, shadow.pos);
441 gl.uniform1f(u.shadowRadius, shadow.radius);
442 gl.uniform1f(u.shadowStrength, shadow.strength);
443 gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
444 }
445 gl.depthMask(true);
446 gl.disable(gl.POLYGON_OFFSET_FILL);
447 gl.disable(gl.BLEND);
448 }
449
356450 // --- tracers, sparks and beams: whatever the scene wrote into its buffer
357451 const n = scene.lineCount;
358452 if (n > 0) {
src/engine/scene.test.ts +104 −4
@@ -11,7 +11,7 @@
1111 */
1212
1313 import { expect, test } from "bun:test";
14 import { createScene, nextInstance, pushLine, resetScene } from "./scene.ts";
14 import { createScene, nextInstance, pushLine, pushShadow, resetScene } from "./scene.ts";
1515 import type { Tint } from "./scene.ts";
1616 import type { Vec3 } from "./math.ts";
1717
@@ -92,20 +92,114 @@ test("a full line buffer refuses whole lines and never writes half of one", () =
9292 expect([...scene.lines.slice(12, 18)]).toEqual([0, 0, 0, 0, 0, 0]);
9393 });
9494
95 test("a game that asks for no shadows has no pool, and one that does has it whole", () => {
96 const none = createScene({ instances: 2, lineVertices: 2 });
97 expect(none.shadows).toEqual([]);
98 expect(none.shadowCount).toBe(0);
99 // Nothing to write into is the same answer as a spent pool: false, nothing written.
100 expect(pushShadow(none, [1, 2, 0], 0, 64, 20, 0.5)).toBe(false);
101
102 const some = createScene({ instances: 2, lineVertices: 2, shadows: 3 });
103 expect(some.shadows.length).toBe(3);
104 for (const shadow of some.shadows) {
105 expect(shadow.pos).toEqual([0, 0, 0]);
106 expect(shadow.radius).toBe(0);
107 expect(shadow.strength).toBe(0);
108 }
109 expect(() => createScene({ instances: 2, lineVertices: 2, shadows: -1 })).toThrow(
110 /shadows must be a whole number, zero or above, not -1/,
111 );
112 expect(() => createScene({ instances: 2, lineVertices: 2, shadows: 1.5 })).toThrow(/shadows/);
113 });
114
115 test("a shadow lands on the surface under the body, not at the body", () => {
116 const scene = createScene({ instances: 1, lineVertices: 2, shadows: 2 });
117 // A player standing on a crate 64 up: the shadow is on the crate.
118 expect(pushShadow(scene, [10, 20, 64], 64, 128, 20, 0.6)).toBe(true);
119 expect(scene.shadowCount).toBe(1);
120 const shadow = scene.shadows[0]!;
121 expect(shadow.pos).toEqual([10, 20, 64]);
122 expect(shadow.radius).toBe(20);
123 expect(shadow.strength).toBeCloseTo(0.6, 10);
124
125 // A body a hair inside the floor is standing on it, not under it.
126 expect(pushShadow(scene, [0, 0, -0.5], 0, 128, 20, 0.6)).toBe(true);
127 expect(scene.shadows[1]?.strength).toBeCloseTo(0.6, 10);
128 });
129
130 test("a shadow fades and widens with height, and past its reach there is none", () => {
131 const scene = createScene({ instances: 1, lineVertices: 2, shadows: 8 });
132 // A copy, because the pool hands back the same slot every time: comparing two
133 // heights means holding two answers, and the second overwrote the first.
134 const cast = (z: number): { radius: number; strength: number } | null => {
135 resetScene(scene);
136 if (!pushShadow(scene, [0, 0, z], 0, 100, 20, 0.8)) return null;
137 const { radius, strength } = scene.shadows[0]!;
138 return { radius, strength };
139 };
140
141 const standing = cast(0) as { radius: number; strength: number };
142 const jumping = cast(50) as { radius: number; strength: number };
143 // Halfway up its reach: half again as wide, and a quarter as dark.
144 expect(jumping.radius).toBeCloseTo(30, 10);
145 expect(jumping.strength).toBeCloseTo(0.2, 10);
146 expect(jumping.strength).toBeLessThan(standing.strength);
147 expect(jumping.radius).toBeGreaterThan(standing.radius);
148 // Monotonic the whole way up, both of them.
149 let last = standing;
150 for (const z of [10, 25, 40, 60, 80, 99]) {
151 const now = cast(z) as { radius: number; strength: number };
152 expect(now.strength).toBeLessThan(last.strength);
153 expect(now.radius).toBeGreaterThan(last.radius);
154 last = now;
155 }
156 // And a body at or past the reach has none at all: nothing written.
157 expect(cast(100)).toBeNull();
158 expect(cast(1000)).toBeNull();
159 expect(scene.shadowCount).toBe(0);
160 // The widening is the cap on fill rate: never past twice the radius asked for.
161 expect(last.radius).toBeLessThan(40);
162 });
163
164 test("a shadow that cannot be drawn is refused without writing anything", () => {
165 const scene = createScene({ instances: 1, lineVertices: 2, shadows: 1 });
166 // A reach of nothing is not divided by.
167 expect(pushShadow(scene, [1, 1, 0], 0, 0, 20, 0.5)).toBe(false);
168 expect(pushShadow(scene, [1, 1, 0], 0, -10, 20, 0.5)).toBe(false);
169 // A trace that found nothing, handed in anyway: too high, not at the origin.
170 expect(pushShadow(scene, [1, 1, 0], Number.NaN, 64, 20, 0.5)).toBe(false);
171 expect(scene.shadowCount).toBe(0);
172 expect(scene.shadows[0]?.radius).toBe(0);
173
174 // And the pool refuses rather than growing, like the other two.
175 const pool = scene.shadows;
176 expect(pushShadow(scene, [1, 1, 0], 0, 64, 20, 0.5)).toBe(true);
177 expect(pushShadow(scene, [2, 2, 0], 0, 64, 20, 0.5)).toBe(false);
178 expect(scene.shadows).toBe(pool);
179 expect(scene.shadows.length).toBe(1);
180 expect(scene.shadowCount).toBe(1);
181 });
182
95183 test("a reset puts the counts back and frees nothing", () => {
96 const scene = createScene({ instances: 2, lineVertices: 2 });
184 const scene = createScene({ instances: 2, lineVertices: 2, shadows: 2 });
97185 const held = nextInstance(scene);
98186 (held as { mesh: string }).mesh = "anything";
99187 pushLine(scene, A, B, RED);
188 pushShadow(scene, [1, 2, 0], 0, 64, 20, 0.5);
100189 const pool = scene.instances;
101190 const lines = scene.lines;
191 const discs = scene.shadows;
102192 const written = [...scene.lines];
103193
104194 resetScene(scene);
105195 expect(scene.instanceCount).toBe(0);
106196 expect(scene.lineCount).toBe(0);
197 expect(scene.shadowCount).toBe(0);
107198 expect(scene.instances).toBe(pool);
108199 expect(scene.lines).toBe(lines);
200 expect(scene.shadows).toBe(discs);
201 // Last frame's shadow is still in the slot, as last frame's floats are.
202 expect(scene.shadows[0]?.radius).toBe(20);
109203 // Last frame's numbers are still there: nothing reads past a count.
110204 expect([...scene.lines]).toEqual(written);
111205 expect(nextInstance(scene)).toBe(held);
@@ -113,21 +207,27 @@ test("a reset puts the counts back and frees nothing", () => {
113207 });
114208
115209 test("two hundred frames on one scene allocate nothing at all", () => {
116 const scene = createScene({ instances: 8, lineVertices: 16 });
210 const scene = createScene({ instances: 8, lineVertices: 16, shadows: 4 });
117211 const pool = scene.instances;
118212 const objects = [...pool];
119213 const lines = scene.lines;
214 const discs = scene.shadows;
215 const casts = [...discs];
120216
121217 for (let frame = 0; frame < 200; frame++) {
122218 resetScene(scene);
123 // A frame that wants more of both than there is, every time.
219 // A frame that wants more of all three than there is, every time.
124220 for (let i = 0; i < 12; i++) nextInstance(scene);
125221 for (let i = 0; i < 12; i++) pushLine(scene, A, B, RED);
222 for (let i = 0; i < 12; i++) pushShadow(scene, [i, i, 0], 0, 64, 20, 0.5);
126223 expect(scene.instanceCount).toBe(8);
127224 expect(scene.lineCount).toBe(16);
225 expect(scene.shadowCount).toBe(4);
128226 }
129227 expect(scene.instances).toBe(pool);
130228 expect([...scene.instances]).toEqual(objects);
131229 expect(scene.lines).toBe(lines);
132230 expect(scene.lines.length).toBe(16 * 6);
231 expect(scene.shadows).toBe(discs);
232 expect([...scene.shadows]).toEqual(casts);
133233 });
src/engine/scene.ts +93 −5
@@ -4,7 +4,8 @@
44 * here are the bookkeeping both games wrote for themselves before this file
55 * existed — one scene made once, a pool of instances, a cursor that hands out
66 * the next one, a `Float32Array` of line vertices with a count, and a reset at
7 * the top of every frame.
7 * the top of every frame. A third pool joined them in #279: the contact shadows
8 * the bodies in a frame cast, filled the same way and capped the same way.
89 *
910 * **Nothing is allocated in a frame.** `createScene` takes the two capacities a
1011 * game has to choose and builds both buffers whole: every `Instance` object the
@@ -54,6 +55,14 @@ export type Instance = {
5455 size?: number;
5556 };
5657
58 /**
59 * A body standing on something: where the ground under it is, how wide the dark
60 * patch is and how dark. `pos` is on the surface, not in the body — `pushShadow`
61 * puts it there — and `strength` is an opacity at the centre, 0 to 1, which the
62 * renderer fades to nothing at the rim.
63 */
64 export type Shadow = { pos: Vec3; radius: number; strength: number };
65
5766 /**
5867 * One frame, as plain data. A game fills one of these per frame and allocates
5968 * nothing doing it: `instances` is a pool of which the first `instanceCount`
@@ -76,15 +85,27 @@ export type Scene = {
7685 lines: Float32Array;
7786 /** Vertices written this frame — `lineCount * 6` floats of `lines` are live. */
7887 lineCount: number;
88 /**
89 * The contact shadows this frame casts, in a pool of their own. Only the
90 * first `shadowCount` are live, and a game that asked for no capacity has an
91 * empty one — which is a game that draws none, not a game that is missing
92 * something.
93 */
94 shadows: Shadow[];
95 shadowCount: number;
7996 viewModel: ViewModel | null;
8097 };
8198
8299 /**
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.
100 * The numbers a game picks once: how many instances its busiest frame places,
101 * how many line vertices that frame strokes, and how many bodies in it stand on
102 * something. A line is two vertices, so a game counting lines doubles.
103 *
104 * `shadows` is the one that may be left out, and left out it is zero: a game
105 * that wants none — the guide's example is one — pays for none, because the
106 * pool it does not ask for is not built.
86107 */
87 export type SceneCapacity = { instances: number; lineVertices: number };
108 export type SceneCapacity = { instances: number; lineVertices: number; shadows?: number };
88109
89110 /** Why this capacity is not one, or null when it is. */
90111 function wrongCapacity(capacity: SceneCapacity): string | null {
@@ -93,6 +114,9 @@ function wrongCapacity(capacity: SceneCapacity): string | null {
93114 if (!Number.isInteger(value) || value <= 0)
94115 return `scene capacity: ${key} must be a whole number above zero, not ${value}`;
95116 }
117 const shadows = capacity.shadows;
118 if (shadows !== undefined && (!Number.isInteger(shadows) || shadows < 0))
119 return `scene capacity: shadows must be a whole number, zero or above, not ${shadows}`;
96120 return null;
97121 }
98122
@@ -108,12 +132,17 @@ export function createScene(capacity: SceneCapacity): Scene {
108132 const instances: Instance[] = [];
109133 for (let i = 0; i < capacity.instances; i++)
110134 instances.push({ mesh: "", pos: [0, 0, 0], yaw: 0, scale: 1, size: 1, tint: [1, 1, 1] });
135 const shadows: Shadow[] = [];
136 for (let i = 0; i < (capacity.shadows ?? 0); i++)
137 shadows.push({ pos: [0, 0, 0], radius: 0, strength: 0 });
111138 return {
112139 camera: { pos: [0, 0, 0], yaw: 0, pitch: 0, fov: 90 },
113140 instances,
114141 instanceCount: 0,
115142 lines: new Float32Array(capacity.lineVertices * 6),
116143 lineCount: 0,
144 shadows,
145 shadowCount: 0,
117146 viewModel: null,
118147 };
119148 }
@@ -127,6 +156,7 @@ export function createScene(capacity: SceneCapacity): Scene {
127156 export function resetScene(scene: Scene): void {
128157 scene.instanceCount = 0;
129158 scene.lineCount = 0;
159 scene.shadowCount = 0;
130160 }
131161
132162 /**
@@ -168,3 +198,61 @@ export function pushLine(
168198 scene.lineCount += 2;
169199 return true;
170200 }
201
202 /**
203 * One contact shadow into the pool: a body at `pos`, the surface a downward
204 * trace found at `ground`, and the two numbers that body's shadow has when it
205 * is standing on it — `radius` wide and `strength` dark.
206 *
207 * **Where the body is above that surface is the whole of what this decides.**
208 * With `t` the height as a fraction of `reach`:
209 *
210 * radius = radius * (1 + t)
211 * strength = strength * (1 - t)²
212 *
213 * so a body on the ground gets exactly the two numbers the game named, one
214 * halfway up gets a shadow half again as wide and a quarter as dark, and one at
215 * `reach` or above gets none at all — `false`, nothing written. A jumping player
216 * is fainter than a standing one because the light that missed them spreads over
217 * more ground the further they are from it, and the square is what makes a
218 * player who has left the floor stop reading as glued to it long before they
219 * are out of reach.
220 *
221 * **The cap on fill rate is that first line.** A shadow is never wider than
222 * twice the radius the game asked for, whatever the height, so a game caps the
223 * pixels it can spend by choosing that one number: the shooter's players are 20
224 * units and so never paint a disc wider than 80 units across, which is a little
225 * over a crate.
226 *
227 * The unit is the game's, as everywhere else in this engine: `reach` is in
228 * whatever a world of boxes is measured in. A `reach` that is not above zero
229 * draws nothing rather than dividing by it.
230 *
231 * True when it was written, false when the pool is spent, the body is too high,
232 * or the numbers are not numbers — in which case nothing at all was written.
233 * The pool never grows, exactly as the instances' does not.
234 */
235 export function pushShadow(
236 scene: Scene,
237 pos: Vec3,
238 ground: number,
239 reach: number,
240 radius: number,
241 strength: number,
242 ): boolean {
243 if (!(reach > 0)) return false;
244 const height = pos[2] - ground;
245 // Written as `!(h < reach)` so a NaN height — a trace that found nothing and
246 // was handed the nothing anyway — is too high rather than drawn at the origin.
247 if (!(height < reach)) return false;
248 const at = scene.shadows[scene.shadowCount];
249 if (at === undefined) return false;
250 // A body a hair inside the floor is standing on it, not under it.
251 const t = height > 0 ? height / reach : 0;
252 const fade = 1 - t;
253 at.pos[0] = pos[0]; at.pos[1] = pos[1]; at.pos[2] = ground;
254 at.radius = radius * (1 + t);
255 at.strength = strength * fade * fade;
256 scene.shadowCount += 1;
257 return true;
258 }
src/engine/surface.json +3 −1
@@ -1,5 +1,5 @@
11 {
2 "version": "0.2.0",
2 "version": "0.3.0",
33 "names": [
44 "Activations",
55 "Adam",
@@ -67,6 +67,7 @@
6767 "SceneCapacity",
6868 "Screen",
6969 "Shader",
70 "Shadow",
7071 "Shape",
7172 "SoundDevice",
7273 "Stick",
@@ -149,6 +150,7 @@
149150 "perspective",
150151 "pushEffect",
151152 "pushLine",
153 "pushShadow",
152154 "quadMesh",
153155 "quantise",
154156 "rad",
src/example/boot.test.ts +4 −2
@@ -135,10 +135,12 @@ test("it boots, asks for a frame, and draws the readout over the room", () => {
135135 test("the room, the plate and the shot are drawn through the engine's renderer", () => {
136136 const { p, undo } = booted();
137137 try {
138 // The atlas and both meshes were uploaded once, before any frame ran.
138 // The atlas and both meshes were uploaded once, before any frame ran: four
139 // float buffers each, and the renderer's own shadow quad, which every game
140 // gets whether or not it casts one — the example asks for no shadow pool.
139141 const made = p.gl?.calls ?? [];
140142 expect(made.filter((c) => c[0] === "texImage3D").length).toBe(1);
141 expect(made.filter((c) => c[0] === "bufferData" && c[2] instanceof Float32Array).length).toBe(8);
143 expect(made.filter((c) => c[0] === "bufferData" && c[2] instanceof Float32Array).length).toBe(9);
142144 expect(made.filter((c) => c[0] === "drawElements").length).toBe(0);
143145 // The scene canvas is the renderer's: nothing 2D was ever drawn on it.
144146 expect(p.scene.paint?.calls).toEqual([]);
src/games.ts +10 −2
@@ -123,8 +123,16 @@ export const GAMES: readonly Game[] = [
123123 href: "/range",
124124 label: "range",
125125 entry: "src/range/main.ts",
126 /** Raised by #63, which gave the range a person to be: its tuning, its hull and the engine's solver. */
127 ceiling: { raw: 37_000, gzip: 15_500 },
126 /**
127 * Raised by #279, which gave the engine contact shadows and this game one
128 * under every plate: a third program with its own two shaders, the blended
129 * pass that draws it, and the pool and the height arithmetic behind it.
130 * 38,690 raw and 16,050 gzipped, measured by `bun run scripts/build.ts` —
131 * 2,631 and 881 bytes more than the ceiling #63 set, of which most is the
132 * renderer's and arrives whether a game casts a shadow or not. Worth it
133 * here: a plate hanging over the lane now says how far over it is hanging.
134 */
135 ceiling: { raw: 41_500, gzip: 17_200 },
128136 assets: [],
129137 blurb:
130138 "thirty seconds on the clock and two drills down one lane — plates that pop up, and plates that slide across it — a shot traced in three dimensions and a score, every line of it out of the engine and none of it out of the shooter.",
src/range/boot.test.ts +21 −5
@@ -368,6 +368,17 @@ const since = (p: RangePage, from: number): [string, ...unknown[]][] => glCalls(
368368 const named = (calls: [string, ...unknown[]][], name: string): [string, ...unknown[]][] =>
369369 calls.filter((call) => call[0] === name);
370370
371 /**
372 * The `drawArrays` calls in one mode. Two things go through it since #279: a
373 * tracer is a pair of `LINES` and a plate's contact shadow is a
374 * `TRIANGLE_STRIP`, so a test about one says which.
375 */
376 const arrays = (
377 p: RangePage, calls: [string, ...unknown[]][], mode: "LINES" | "TRIANGLE_STRIP",
378 ): [string, ...unknown[]][] =>
379 named(calls, "drawArrays")
380 .filter((call) => call[1] === (p.scene.gl as unknown as Record<string, number>)[mode]);
381
371382 test("a browser with WebGL2 is drawn in three dimensions, through the engine's renderer", () => {
372383 const { p, undo } = booted({ webgl: true });
373384 try {
@@ -375,8 +386,9 @@ test("a browser with WebGL2 is drawn in three dimensions, through the engine's r
375386 // not the shooter's nine at 128².
376387 const upload = named(glCalls(p), "texImage3D")[0];
377388 expect(upload?.slice(4, 7)).toEqual([64, 64, 3]);
378 // Its two meshes: five buffers each, plus the one the lines share.
379 expect(named(glCalls(p), "createBuffer").length).toBe(2 * 5 + 1);
389 // Its two meshes: five buffers each, plus the one the lines share and the
390 // one every contact shadow is stamped from.
391 expect(named(glCalls(p), "createBuffer").length).toBe(2 * 5 + 2);
380392
381393 const before = glCalls(p).length;
382394 p.step(20);
@@ -406,7 +418,9 @@ test("every standing plate is one more draw, and a shot is a tracer for 80 ms",
406418 expect(standing(p)).toBe(1);
407419 expect(named(since(p, before), "drawElements").length).toBe(2);
408420 // No shot yet, so no line was drawn at all.
409 expect(named(since(p, before), "drawArrays")).toEqual([]);
421 expect(arrays(p, since(p, before), "LINES")).toEqual([]);
422 // The plate stands over the lane, so one quad of shadow is under it.
423 expect(arrays(p, since(p, before), "TRIANGLE_STRIP").length).toBe(1);
410424
411425 // Three plates up by the time the gap has run twice: three draws and the room.
412426 for (let i = 0; i < 70; i++) p.step(20);
@@ -414,19 +428,21 @@ test("every standing plate is one more draw, and a shot is a tracer for 80 ms",
414428 p.step(20);
415429 expect(standing(p)).toBeGreaterThan(1);
416430 expect(named(since(p, before), "drawElements").length).toBe(1 + standing(p));
431 // And one shadow per plate: the room casts none, it is what they fall on.
432 expect(arrays(p, since(p, before), "TRIANGLE_STRIP").length).toBe(standing(p));
417433
418434 // A shot draws the one tracer, as two vertices of the scene's line buffer.
419435 p.window.fire("keydown", { code: "KeyF" });
420436 before = glCalls(p).length;
421437 p.step(20);
422 const drew = named(since(p, before), "drawArrays")[0];
438 const drew = arrays(p, since(p, before), "LINES")[0];
423439 expect(drew?.slice(2)).toEqual([0, 2]);
424440
425441 // 80 ms later it has faded, and the frame draws no line at all.
426442 p.window.fire("keyup", { code: "KeyF" });
427443 before = glCalls(p).length;
428444 p.step(100);
429 expect(named(since(p, before), "drawArrays")).toEqual([]);
445 expect(arrays(p, since(p, before), "LINES")).toEqual([]);
430446 } finally {
431447 undo();
432448 }
src/range/scene.ts +46 −4
@@ -19,10 +19,10 @@
1919 */
2020
2121 import {
22 createScene, nextInstance, pushLine, resetScene,
23 type Mesh, type RendererAssets, type Scene, type Vec3,
22 createScene, groundUnder, nextInstance, pushLine, pushShadow, resetScene,
23 type Hull, type Mesh, type RendererAssets, type Scene, type Vec3,
2424 } from "../engine/index.ts";
25 import { roomMesh, targetMesh } from "./level.ts";
25 import { RANGE_ROOM, roomMesh, targetMesh } from "./level.ts";
2626 import { RANGE_LAYERS, RANGE_TEX_SIZE, rangeAtlas } from "./paint.ts";
2727 import { EYE, STANDARD, centreOf, type Run } from "./targets.ts";
2828
@@ -60,6 +60,27 @@ export const LINE_CAPACITY = 2;
6060 */
6161 export const INSTANCE_CAPACITY = 16;
6262
63 /**
64 * Contact shadows one frame may cast: one under every plate the pool can draw,
65 * and none under the room, which is the thing they are cast on. The two
66 * capacities are the same number for that reason rather than by coincidence.
67 */
68 export const SHADOW_CAPACITY = INSTANCE_CAPACITY;
69
70 /**
71 * How far above the floor a plate still darkens it, in units. `STANDARD` hangs
72 * its plates with their feet about forty units up, so a standing plate's shadow
73 * is a little under half as dark as one resting on the floor would be and a
74 * third again as wide — which is what says it is hanging. A drill that hung one
75 * over 128 units up would have it cast nothing, and that is the right answer:
76 * nothing that high reads as standing on the lane.
77 */
78 export const SHADOW_REACH = 128;
79
80 /** How dark a plate's shadow is where it touches, and how wide beside the plate. */
81 const PLATE_DARK = 0.55;
82 const SHADOW_SPREAD = 1.25;
83
6384 /**
6485 * The half-width the plate mesh is built at. Every plate is drawn from that one
6586 * cube, sized by `Instance.size` to the half-width its box actually has — so a
@@ -103,6 +124,14 @@ const GOING: Tint = [0.9, 0.7, 0.55];
103124 const ORIGIN: Vec3 = [0, 0, 0];
104125 /** The one vector a frame writes a plate's centre into: allocation-free, as #47 asked. */
105126 const PLACE: Vec3 = [0, 0, 0];
127 /** And the one a frame writes the point under a plate's feet into, for the same reason. */
128 const FOOT: Vec3 = [0, 0, 0];
129 /**
130 * The footprint the downward trace under a plate uses, rewritten per plate: a
131 * drill with bigger plates traces a wider one, and the object is kept rather
132 * than made, like every other scratch in this file.
133 */
134 const FOOTPRINT: Pick<Hull, "radius"> = { radius: PLATE_HALF };
106135 /**
107136 * The tracer's two ends: hot white-yellow at the muzzle, thinner at the far
108137 * end. Only the blue of each fades, so a frame writes one number into each of
@@ -135,7 +164,11 @@ export const rangeAssets = (): Omit<RendererAssets, "devicePixelRatio"> => ({
135164 /** The one scene the page keeps, at this game's two capacities. */
136165 export function createRangeScene(): RangeScene {
137166 const scene = {
138 ...createScene({ instances: INSTANCE_CAPACITY, lineVertices: LINE_CAPACITY }),
167 ...createScene({
168 instances: INSTANCE_CAPACITY,
169 lineVertices: LINE_CAPACITY,
170 shadows: SHADOW_CAPACITY,
171 }),
139172 sky: [RANGE_SKY[0], RANGE_SKY[1], RANGE_SKY[2]] as Tint,
140173 };
141174 // The pool's camera starts at the origin, which is nobody's decision; the
@@ -195,6 +228,15 @@ export function buildRangeScene(
195228 centreOf(target, PLACE);
196229 const left = target.span > 0 ? target.life / target.span : 0;
197230 place(into, MESH.PLATE, PLACE, half / PLATE_HALF, left > FADING ? PLATE_LIT : GOING);
231 // And what it darkens under itself, from its feet rather than its middle:
232 // a plate is a cube and the gap that matters is the one below the bottom of
233 // it. The trace is against the room — six slabs, of which the floor is one —
234 // so a drill that ever stood a plate on a step would find the step.
235 FOOT[0] = PLACE[0]; FOOT[1] = PLACE[1]; FOOT[2] = box.min[2];
236 FOOTPRINT.radius = half;
237 const ground = groundUnder(FOOT, 0, RANGE_ROOM, SHADOW_REACH, FOOTPRINT);
238 if (ground !== null)
239 pushShadow(into, FOOT, ground, SHADOW_REACH, half * SHADOW_SPREAD, PLATE_DARK);
198240 }
199241
200242 // --- the tracer: one line, from the eye to wherever the bullet stopped
src/range/stage.test-helper.ts +11 −8
@@ -33,18 +33,21 @@ export const RANGE_ACTIONS = ["duck", "jump", "fire"];
3333 export type GlRecorder = Record<string, unknown> & { calls: [string, ...unknown[]][] };
3434
3535 const GL_ENUMS = [
36 "ARRAY_BUFFER", "BACK", "COLOR_BUFFER_BIT", "COMPILE_STATUS", "CULL_FACE", "DEPTH_BUFFER_BIT",
37 "DEPTH_TEST", "DYNAMIC_DRAW", "ELEMENT_ARRAY_BUFFER", "FLOAT", "FRAGMENT_SHADER", "LINEAR",
38 "LINEAR_MIPMAP_LINEAR", "LINES", "LINK_STATUS", "REPEAT", "RGBA", "RGBA8", "STATIC_DRAW",
39 "TEXTURE0", "TEXTURE_2D_ARRAY", "TEXTURE_MAG_FILTER", "TEXTURE_MIN_FILTER", "TEXTURE_WRAP_S",
40 "TEXTURE_WRAP_T", "TRIANGLES", "UNSIGNED_BYTE", "UNSIGNED_INT", "VERTEX_SHADER",
36 "ARRAY_BUFFER", "BACK", "BLEND", "COLOR_BUFFER_BIT", "COMPILE_STATUS", "CULL_FACE",
37 "DEPTH_BUFFER_BIT", "DEPTH_TEST", "DYNAMIC_DRAW", "ELEMENT_ARRAY_BUFFER", "FLOAT",
38 "FRAGMENT_SHADER", "LINEAR", "LINEAR_MIPMAP_LINEAR", "LINES", "LINK_STATUS",
39 "ONE_MINUS_SRC_ALPHA", "POLYGON_OFFSET_FILL", "REPEAT", "RGBA", "RGBA8", "SRC_ALPHA",
40 "STATIC_DRAW", "TEXTURE0", "TEXTURE_2D_ARRAY", "TEXTURE_MAG_FILTER", "TEXTURE_MIN_FILTER",
41 "TEXTURE_WRAP_S", "TEXTURE_WRAP_T", "TRIANGLES", "TRIANGLE_STRIP", "UNSIGNED_BYTE",
42 "UNSIGNED_INT", "VERTEX_SHADER",
4143 ];
4244
4345 const GL_VOIDS = [
4446 "shaderSource", "compileShader", "attachShader", "linkProgram", "bindTexture", "texImage3D",
45 "generateMipmap", "texParameteri", "bindVertexArray", "bindBuffer", "bufferData", "bufferSubData",
46 "enableVertexAttribArray", "vertexAttribPointer", "enable", "cullFace", "viewport", "clearColor",
47 "clear", "useProgram", "activeTexture", "uniform1i", "uniform3f", "uniform3fv",
47 "generateMipmap", "texParameteri", "bindVertexArray", "bindBuffer", "bufferData",
48 "bufferSubData", "enableVertexAttribArray", "vertexAttribPointer", "enable", "disable",
49 "cullFace", "blendFunc", "polygonOffset", "depthMask", "viewport", "clearColor", "clear",
50 "useProgram", "activeTexture", "uniform1i", "uniform1f", "uniform3f", "uniform3fv",
4851 "uniformMatrix4fv", "drawElements", "drawArrays",
4952 ];
5053

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs