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
Check · Review · Land · Live done — Landed and live in production.
vesper/cs-engine:vesper/279-bodies-stand-on-the-floor-a-contact-shad → main 2 commits fork
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 | ||
| 50 | 50 | test("the entry table is what the app ships today, each with the size it may not pass", () => { |
| 51 | 51 | expect(ENTRIES).toEqual([ |
| 52 | 52 | { 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 } }, | |
| 54 | 54 | { name: "example", entry: "src/example/main.ts", ceiling: { raw: 23_500, gzip: 10_200 } }, |
| 55 | 55 | ]); |
| 56 | 56 | // 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 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * 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, | |
| 5 | 6 | * in plain numbers a test can read without a canvas. The engine's renderer then |
| 6 | 7 | * draws exactly this and knows nothing else, which is what lets a second game |
| 7 | 8 | * hand it a scene of its own: `Scene`, `Camera` and `Instance` are the door's |
| @@ -18,12 +19,13 @@ | ||
| 18 | 19 | */ |
| 19 | 20 | |
| 20 | 21 | 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, | |
| 23 | 25 | } from "../engine/index.ts"; |
| 24 | 26 | import { cloudRadius } from "./grenade.ts"; |
| 25 | 27 | import { CLOUD_MESH_RADIUS } from "./models.ts"; |
| 26 | import { CS_HULL } from "./movement.ts"; | |
| 28 | import { CS_HULL, NADE_HULL } from "./movement.ts"; | |
| 27 | 29 | import { FLASH, HE, WEAPONS } from "./weapons.ts"; |
| 28 | 30 | import type { Player, World } from "./world.ts"; |
| 29 | 31 | |
| @@ -64,6 +66,34 @@ export const LINE_CAPACITY = 4096; | ||
| 64 | 66 | */ |
| 65 | 67 | export const INSTANCE_CAPACITY = 48; |
| 66 | 68 | |
| 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 | ||
| 67 | 97 | /** How long a blast is drawn for, in seconds, and in how many spokes. */ |
| 68 | 98 | const BLAST_SECONDS = 0.45; |
| 69 | 99 | |
| @@ -117,10 +147,14 @@ function colour(into: Tint, r: number, g: number, b: number): Tint { | ||
| 117 | 147 | return into; |
| 118 | 148 | } |
| 119 | 149 | |
| 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. */ | |
| 121 | 151 | export function createScene(): Scene { |
| 122 | 152 | return { |
| 123 | ...createPooledScene({ instances: INSTANCE_CAPACITY, lineVertices: LINE_CAPACITY }), | |
| 153 | ...createPooledScene({ | |
| 154 | instances: INSTANCE_CAPACITY, | |
| 155 | lineVertices: LINE_CAPACITY, | |
| 156 | shadows: SHADOW_CAPACITY, | |
| 157 | }), | |
| 124 | 158 | sky: [SKY[0], SKY[1], SKY[2]], |
| 125 | 159 | keep: { bobPhase: 0, time: 0, viewModel: { mesh: "rifle", offset: [0, 0, 0] } }, |
| 126 | 160 | }; |
| @@ -145,6 +179,23 @@ function place( | ||
| 145 | 179 | into.tint[0] = tint[0]; into.tint[1] = tint[1]; into.tint[2] = tint[2]; |
| 146 | 180 | } |
| 147 | 181 | |
| 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 | ||
| 148 | 199 | /** |
| 149 | 200 | * Fill `into` with what this world looks like from this camera, and hand the |
| 150 | 201 | * 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 | ||
| 168 | 219 | resetScene(into); |
| 169 | 220 | place(into, "level", ORIGIN, 0, 1, WHITE); |
| 170 | 221 | 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); | |
| 171 | 226 | // The eye is inside this head: drawing it would fill the screen. Whose head |
| 172 | 227 | // 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; | |
| 174 | 229 | place(into, "player", p.pos, p.yaw, hullHeight(p, CS_HULL) / CS_HULL.stand, TEAM_TINT[p.team] ?? WHITE); |
| 175 | 230 | } |
| 176 | if (world.bomb.state === "planted" || world.bomb.state === "dropped") | |
| 231 | if (world.bomb.state === "planted" || world.bomb.state === "dropped") { | |
| 177 | 232 | place(into, "bomb", world.bomb.pos, 0, 1, WHITE); |
| 233 | shade(into, world, world.bomb.pos, NADE_HULL, BOMB_SHADOW, THROWN_DARK); | |
| 234 | } | |
| 178 | 235 | // Every grenade in the air or lying on the floor. It tumbles as it flies: |
| 179 | 236 | // 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) { | |
| 182 | 240 | 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 | } | |
| 183 | 243 | // Every cloud standing, as the ball the simulation traces against: the same |
| 184 | 244 | // centre and the same radius `canSee` reads, so what fills the screen and |
| 185 | 245 | // 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"; | ||
| 53 | 53 | export type GlRecorder = Record<string, unknown> & { calls: [string, ...unknown[]][] }; |
| 54 | 54 | |
| 55 | 55 | 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", | |
| 61 | 63 | ]; |
| 62 | 64 | |
| 63 | 65 | const GL_VOIDS = [ |
| 64 | 66 | "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", | |
| 68 | 71 | "uniformMatrix4fv", "drawElements", "drawArrays", "deleteVertexArray", "deleteBuffer", |
| 69 | 72 | ]; |
| 70 | 73 |
src/engine/GUIDE.md +42 −3
| @@ -517,7 +517,7 @@ nothing at all, because a game that draws is a game with a page. | ||
| 517 | 517 | |
| 518 | 518 | ### scene |
| 519 | 519 | |
| 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. | |
| 521 | 521 | |
| 522 | 522 | A frame, as plain data, and the pool it is kept in — the one piece of a game's |
| 523 | 523 | 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 | ||
| 546 | 546 | whose whole line buffer is one tracer: two vertices, 48 bytes, because there is |
| 547 | 547 | one shot in the air at a time. |
| 548 | 548 | |
| 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 | ||
| 549 | 573 | What a `Scene` holds is then: a `Camera` — a position, a yaw, a pitch and a |
| 550 | 574 | horizontal field of view — the sky this frame clears to and fades into, a pool |
| 551 | 575 | of `Instance` values of which the first `Scene.instanceCount` are live, each a |
| 552 | 576 | mesh name with a place, a yaw, a vertical squash and a tint; one |
| 553 | 577 | `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 | |
| 555 | 580 | gun in your hands. A game's own memory of the last frame hangs off its own type |
| 556 | 581 | beside these — the shooter keeps a walk cycle and one view model object there — |
| 557 | 582 | 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. | ||
| 598 | 623 | |
| 599 | 624 | ### renderer |
| 600 | 625 | |
| 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. | |
| 602 | 627 | |
| 603 | 628 | The 3D under the scene, and the largest thing behind the door. `createRenderer` |
| 604 | 629 | 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 | ||
| 626 | 651 | two in `src/range/scene.ts`, and the example's atlas and two in |
| 627 | 652 | `src/engine/example.ts`, each handed to the call in that game's entry. |
| 628 | 653 | |
| 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 | ||
| 629 | 667 | Then every frame is the `Scene` above, handed to `Renderer.draw`. Make that |
| 630 | 668 | scene once, at start-up, and refill it every frame — both games do, and neither |
| 631 | 669 | 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. | ||
| 835 | 873 | | 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 | |
| 836 | 874 | | renderer assets | an atlas and eight meshes, from `src/client/main.ts` | an atlas and two meshes, from `src/range/scene.ts` | |
| 837 | 875 | | 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 | | |
| 838 | 877 | | 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 | |
| 839 | 878 | | no WebGL2 | a sentence in the menu, and nothing starts | the 2D horizon in `src/range/readout.ts`, and it still plays | |
| 840 | 879 | | 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"; | ||
| 78 | 78 | export type { Overlay, Paint } from "./overlay.ts"; |
| 79 | 79 | |
| 80 | 80 | /** 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"; | |
| 83 | 83 | |
| 84 | 84 | /** |
| 85 | 85 | * 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( | ||
| 143 | 143 | * The floor holding a hull up: the highest box top within `reach` below the |
| 144 | 144 | * feet. Asking this directly, rather than resolving a downward move, is what |
| 145 | 145 | * 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. | |
| 146 | 151 | */ |
| 147 | 152 | 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">, | |
| 149 | 154 | ): number | null { |
| 150 | 155 | let best: number | null = null; |
| 151 | 156 | for (const box of boxes) { |
src/engine/renderer.test.ts +95 −16
| @@ -23,11 +23,13 @@ type Call = [string, ...unknown[]]; | ||
| 23 | 23 | |
| 24 | 24 | /** Every GL enum this module names. Numbered so a recorded call reads back. */ |
| 25 | 25 | 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", | |
| 31 | 33 | ] as const; |
| 32 | 34 | |
| 33 | 35 | /** A resource the stub handed out, in the order it handed them out. */ |
| @@ -64,10 +66,10 @@ function stubGl(): StubGl { | ||
| 64 | 66 | for (const name of [ |
| 65 | 67 | "shaderSource", "compileShader", "attachShader", "linkProgram", "bindTexture", "texImage3D", |
| 66 | 68 | "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", | |
| 71 | 73 | ]) gl[name] = note(name); |
| 72 | 74 | return gl; |
| 73 | 75 | } |
| @@ -126,6 +128,11 @@ function scene(mesh = "level"): Scene { | ||
| 126 | 128 | instanceCount: 2, |
| 127 | 129 | lines: new Float32Array(12), |
| 128 | 130 | 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, | |
| 129 | 136 | viewModel: null, |
| 130 | 137 | }; |
| 131 | 138 | } |
| @@ -191,14 +198,16 @@ test("construction uploads the atlas once, at the size and layer count it was ha | ||
| 191 | 198 | test("construction uploads one buffer set per mesh handed in, and nothing later", () => { |
| 192 | 199 | const two = renderer({ level: box(), player: box(6) }); |
| 193 | 200 | // 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); | |
| 198 | 207 | |
| 199 | 208 | 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); | |
| 202 | 211 | |
| 203 | 212 | // A frame uploads nothing: the whole point of taking the meshes at construction. |
| 204 | 213 | 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", ( | ||
| 215 | 224 | const madeBefore = of(two.gl, "createBuffer").length; |
| 216 | 225 | two.r.replace("level", box(4)); |
| 217 | 226 | 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); | |
| 219 | 228 | // Five buffers and one vertex array back to the driver: exactly one mesh. |
| 220 | 229 | expect(of(two.gl, "deleteBuffer").length).toBe(5); |
| 221 | 230 | expect(of(two.gl, "deleteVertexArray").length).toBe(1); |
| @@ -348,6 +357,76 @@ test("the lines grow their buffer once and are drawn in pairs", () => { | ||
| 348 | 357 | expect(r.gl.calls.slice(before).filter((c) => c[0] === "bufferData")).toEqual([]); |
| 349 | 358 | }); |
| 350 | 359 | |
| 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 | ||
| 351 | 430 | test("the gun in your hands is drawn last, on its own depth", () => { |
| 352 | 431 | const r = renderer(); |
| 353 | 432 | const held = scene(); |
src/engine/renderer.ts +97 −3
| @@ -1,7 +1,8 @@ | ||
| 1 | 1 | /** |
| 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. | |
| 5 | 6 | * |
| 6 | 7 | * It decides nothing and it owns no asset. A game hands it `RendererAssets` — |
| 7 | 8 | * the array texture its meshes' layers index into, and every mesh a scene of |
| @@ -73,6 +74,37 @@ void main() { | ||
| 73 | 74 | outColor = vec4(mix(lit, uFog, clamp(fog, 0.0, 0.85)), 1.0); |
| 74 | 75 | }`; |
| 75 | 76 | |
| 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 | ||
| 76 | 108 | const LINE_VS = `#version 300 es |
| 77 | 109 | precision highp float; |
| 78 | 110 | layout(location=0) in vec3 aPos; |
| @@ -157,6 +189,9 @@ const NEAR = 6, FAR = 12000; | ||
| 157 | 189 | const BLACK: Tint = [0, 0, 0]; |
| 158 | 190 | const ORIGIN: Vec3 = [0, 0, 0]; |
| 159 | 191 | |
| 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 | ||
| 160 | 195 | function compile(gl: WebGL2RenderingContext, type: number, source: string): WebGLShader { |
| 161 | 196 | const shader = gl.createShader(type) as WebGLShader; |
| 162 | 197 | gl.shaderSource(shader, source); |
| @@ -233,6 +268,7 @@ export function createRenderer(canvas: HTMLCanvasElement, assets: RendererAssets | ||
| 233 | 268 | |
| 234 | 269 | const surfaces = link(gl, SCENE_VS, SCENE_FS); |
| 235 | 270 | const strokes = link(gl, LINE_VS, LINE_FS); |
| 271 | const shadows = link(gl, SHADOW_VS, SHADOW_FS); | |
| 236 | 272 | const uniform = (program: WebGLProgram, name: string): WebGLUniformLocation | null => |
| 237 | 273 | gl.getUniformLocation(program, name); |
| 238 | 274 | const u = { |
| @@ -243,6 +279,10 @@ export function createRenderer(canvas: HTMLCanvasElement, assets: RendererAssets | ||
| 243 | 279 | fog: uniform(surfaces, "uFog"), |
| 244 | 280 | atlas: uniform(surfaces, "uAtlas"), |
| 245 | 281 | lineViewProj: uniform(strokes, "uViewProj"), |
| 282 | shadowViewProj: uniform(shadows, "uViewProj"), | |
| 283 | shadowCentre: uniform(shadows, "uCentre"), | |
| 284 | shadowRadius: uniform(shadows, "uRadius"), | |
| 285 | shadowStrength: uniform(shadows, "uStrength"), | |
| 246 | 286 | }; |
| 247 | 287 | |
| 248 | 288 | const atlas = assets.atlas; |
| @@ -283,9 +323,28 @@ export function createRenderer(canvas: HTMLCanvasElement, assets: RendererAssets | ||
| 283 | 323 | gl.vertexAttribPointer(1, 3, gl.FLOAT, false, 24, 12); |
| 284 | 324 | gl.bindVertexArray(null); |
| 285 | 325 | |
| 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 | ||
| 286 | 339 | gl.enable(gl.DEPTH_TEST); |
| 287 | 340 | gl.enable(gl.CULL_FACE); |
| 288 | 341 | 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); | |
| 289 | 348 | |
| 290 | 349 | const pixelRatio = assets.devicePixelRatio; |
| 291 | 350 | const ownSky = assets.sky ?? BLACK; |
| @@ -353,6 +412,41 @@ export function createRenderer(canvas: HTMLCanvasElement, assets: RendererAssets | ||
| 353 | 412 | gl.drawElements(gl.TRIANGLES, buffers.count, gl.UNSIGNED_INT, 0); |
| 354 | 413 | } |
| 355 | 414 | |
| 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 | ||
| 356 | 450 | // --- tracers, sparks and beams: whatever the scene wrote into its buffer |
| 357 | 451 | const n = scene.lineCount; |
| 358 | 452 | if (n > 0) { |
src/engine/scene.test.ts +104 −4
| @@ -11,7 +11,7 @@ | ||
| 11 | 11 | */ |
| 12 | 12 | |
| 13 | 13 | 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"; | |
| 15 | 15 | import type { Tint } from "./scene.ts"; |
| 16 | 16 | import type { Vec3 } from "./math.ts"; |
| 17 | 17 | |
| @@ -92,20 +92,114 @@ test("a full line buffer refuses whole lines and never writes half of one", () = | ||
| 92 | 92 | expect([...scene.lines.slice(12, 18)]).toEqual([0, 0, 0, 0, 0, 0]); |
| 93 | 93 | }); |
| 94 | 94 | |
| 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 | ||
| 95 | 183 | 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 }); | |
| 97 | 185 | const held = nextInstance(scene); |
| 98 | 186 | (held as { mesh: string }).mesh = "anything"; |
| 99 | 187 | pushLine(scene, A, B, RED); |
| 188 | pushShadow(scene, [1, 2, 0], 0, 64, 20, 0.5); | |
| 100 | 189 | const pool = scene.instances; |
| 101 | 190 | const lines = scene.lines; |
| 191 | const discs = scene.shadows; | |
| 102 | 192 | const written = [...scene.lines]; |
| 103 | 193 | |
| 104 | 194 | resetScene(scene); |
| 105 | 195 | expect(scene.instanceCount).toBe(0); |
| 106 | 196 | expect(scene.lineCount).toBe(0); |
| 197 | expect(scene.shadowCount).toBe(0); | |
| 107 | 198 | expect(scene.instances).toBe(pool); |
| 108 | 199 | 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); | |
| 109 | 203 | // Last frame's numbers are still there: nothing reads past a count. |
| 110 | 204 | expect([...scene.lines]).toEqual(written); |
| 111 | 205 | expect(nextInstance(scene)).toBe(held); |
| @@ -113,21 +207,27 @@ test("a reset puts the counts back and frees nothing", () => { | ||
| 113 | 207 | }); |
| 114 | 208 | |
| 115 | 209 | 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 }); | |
| 117 | 211 | const pool = scene.instances; |
| 118 | 212 | const objects = [...pool]; |
| 119 | 213 | const lines = scene.lines; |
| 214 | const discs = scene.shadows; | |
| 215 | const casts = [...discs]; | |
| 120 | 216 | |
| 121 | 217 | for (let frame = 0; frame < 200; frame++) { |
| 122 | 218 | 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. | |
| 124 | 220 | for (let i = 0; i < 12; i++) nextInstance(scene); |
| 125 | 221 | 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); | |
| 126 | 223 | expect(scene.instanceCount).toBe(8); |
| 127 | 224 | expect(scene.lineCount).toBe(16); |
| 225 | expect(scene.shadowCount).toBe(4); | |
| 128 | 226 | } |
| 129 | 227 | expect(scene.instances).toBe(pool); |
| 130 | 228 | expect([...scene.instances]).toEqual(objects); |
| 131 | 229 | expect(scene.lines).toBe(lines); |
| 132 | 230 | expect(scene.lines.length).toBe(16 * 6); |
| 231 | expect(scene.shadows).toBe(discs); | |
| 232 | expect([...scene.shadows]).toEqual(casts); | |
| 133 | 233 | }); |
src/engine/scene.ts +93 −5
| @@ -4,7 +4,8 @@ | ||
| 4 | 4 | * here are the bookkeeping both games wrote for themselves before this file |
| 5 | 5 | * existed — one scene made once, a pool of instances, a cursor that hands out |
| 6 | 6 | * 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. | |
| 8 | 9 | * |
| 9 | 10 | * **Nothing is allocated in a frame.** `createScene` takes the two capacities a |
| 10 | 11 | * game has to choose and builds both buffers whole: every `Instance` object the |
| @@ -54,6 +55,14 @@ export type Instance = { | ||
| 54 | 55 | size?: number; |
| 55 | 56 | }; |
| 56 | 57 | |
| 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 | ||
| 57 | 66 | /** |
| 58 | 67 | * One frame, as plain data. A game fills one of these per frame and allocates |
| 59 | 68 | * nothing doing it: `instances` is a pool of which the first `instanceCount` |
| @@ -76,15 +85,27 @@ export type Scene = { | ||
| 76 | 85 | lines: Float32Array; |
| 77 | 86 | /** Vertices written this frame — `lineCount * 6` floats of `lines` are live. */ |
| 78 | 87 | 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; | |
| 79 | 96 | viewModel: ViewModel | null; |
| 80 | 97 | }; |
| 81 | 98 | |
| 82 | 99 | /** |
| 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. | |
| 86 | 107 | */ |
| 87 | export type SceneCapacity = { instances: number; lineVertices: number }; | |
| 108 | export type SceneCapacity = { instances: number; lineVertices: number; shadows?: number }; | |
| 88 | 109 | |
| 89 | 110 | /** Why this capacity is not one, or null when it is. */ |
| 90 | 111 | function wrongCapacity(capacity: SceneCapacity): string | null { |
| @@ -93,6 +114,9 @@ function wrongCapacity(capacity: SceneCapacity): string | null { | ||
| 93 | 114 | if (!Number.isInteger(value) || value <= 0) |
| 94 | 115 | return `scene capacity: ${key} must be a whole number above zero, not ${value}`; |
| 95 | 116 | } |
| 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}`; | |
| 96 | 120 | return null; |
| 97 | 121 | } |
| 98 | 122 | |
| @@ -108,12 +132,17 @@ export function createScene(capacity: SceneCapacity): Scene { | ||
| 108 | 132 | const instances: Instance[] = []; |
| 109 | 133 | for (let i = 0; i < capacity.instances; i++) |
| 110 | 134 | 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 }); | |
| 111 | 138 | return { |
| 112 | 139 | camera: { pos: [0, 0, 0], yaw: 0, pitch: 0, fov: 90 }, |
| 113 | 140 | instances, |
| 114 | 141 | instanceCount: 0, |
| 115 | 142 | lines: new Float32Array(capacity.lineVertices * 6), |
| 116 | 143 | lineCount: 0, |
| 144 | shadows, | |
| 145 | shadowCount: 0, | |
| 117 | 146 | viewModel: null, |
| 118 | 147 | }; |
| 119 | 148 | } |
| @@ -127,6 +156,7 @@ export function createScene(capacity: SceneCapacity): Scene { | ||
| 127 | 156 | export function resetScene(scene: Scene): void { |
| 128 | 157 | scene.instanceCount = 0; |
| 129 | 158 | scene.lineCount = 0; |
| 159 | scene.shadowCount = 0; | |
| 130 | 160 | } |
| 131 | 161 | |
| 132 | 162 | /** |
| @@ -168,3 +198,61 @@ export function pushLine( | ||
| 168 | 198 | scene.lineCount += 2; |
| 169 | 199 | return true; |
| 170 | 200 | } |
| 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 @@ | ||
| 1 | 1 | { |
| 2 | "version": "0.2.0", | |
| 2 | "version": "0.3.0", | |
| 3 | 3 | "names": [ |
| 4 | 4 | "Activations", |
| 5 | 5 | "Adam", |
| @@ -67,6 +67,7 @@ | ||
| 67 | 67 | "SceneCapacity", |
| 68 | 68 | "Screen", |
| 69 | 69 | "Shader", |
| 70 | "Shadow", | |
| 70 | 71 | "Shape", |
| 71 | 72 | "SoundDevice", |
| 72 | 73 | "Stick", |
| @@ -149,6 +150,7 @@ | ||
| 149 | 150 | "perspective", |
| 150 | 151 | "pushEffect", |
| 151 | 152 | "pushLine", |
| 153 | "pushShadow", | |
| 152 | 154 | "quadMesh", |
| 153 | 155 | "quantise", |
| 154 | 156 | "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", () => { | ||
| 135 | 135 | test("the room, the plate and the shot are drawn through the engine's renderer", () => { |
| 136 | 136 | const { p, undo } = booted(); |
| 137 | 137 | 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. | |
| 139 | 141 | const made = p.gl?.calls ?? []; |
| 140 | 142 | 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); | |
| 142 | 144 | expect(made.filter((c) => c[0] === "drawElements").length).toBe(0); |
| 143 | 145 | // The scene canvas is the renderer's: nothing 2D was ever drawn on it. |
| 144 | 146 | expect(p.scene.paint?.calls).toEqual([]); |
src/games.ts +10 −2
| @@ -123,8 +123,16 @@ export const GAMES: readonly Game[] = [ | ||
| 123 | 123 | href: "/range", |
| 124 | 124 | label: "range", |
| 125 | 125 | 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 }, | |
| 128 | 136 | assets: [], |
| 129 | 137 | blurb: |
| 130 | 138 | "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( | ||
| 368 | 368 | const named = (calls: [string, ...unknown[]][], name: string): [string, ...unknown[]][] => |
| 369 | 369 | calls.filter((call) => call[0] === name); |
| 370 | 370 | |
| 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 | ||
| 371 | 382 | test("a browser with WebGL2 is drawn in three dimensions, through the engine's renderer", () => { |
| 372 | 383 | const { p, undo } = booted({ webgl: true }); |
| 373 | 384 | try { |
| @@ -375,8 +386,9 @@ test("a browser with WebGL2 is drawn in three dimensions, through the engine's r | ||
| 375 | 386 | // not the shooter's nine at 128². |
| 376 | 387 | const upload = named(glCalls(p), "texImage3D")[0]; |
| 377 | 388 | 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); | |
| 380 | 392 | |
| 381 | 393 | const before = glCalls(p).length; |
| 382 | 394 | p.step(20); |
| @@ -406,7 +418,9 @@ test("every standing plate is one more draw, and a shot is a tracer for 80 ms", | ||
| 406 | 418 | expect(standing(p)).toBe(1); |
| 407 | 419 | expect(named(since(p, before), "drawElements").length).toBe(2); |
| 408 | 420 | // 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); | |
| 410 | 424 | |
| 411 | 425 | // Three plates up by the time the gap has run twice: three draws and the room. |
| 412 | 426 | 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", | ||
| 414 | 428 | p.step(20); |
| 415 | 429 | expect(standing(p)).toBeGreaterThan(1); |
| 416 | 430 | 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)); | |
| 417 | 433 | |
| 418 | 434 | // A shot draws the one tracer, as two vertices of the scene's line buffer. |
| 419 | 435 | p.window.fire("keydown", { code: "KeyF" }); |
| 420 | 436 | before = glCalls(p).length; |
| 421 | 437 | p.step(20); |
| 422 | const drew = named(since(p, before), "drawArrays")[0]; | |
| 438 | const drew = arrays(p, since(p, before), "LINES")[0]; | |
| 423 | 439 | expect(drew?.slice(2)).toEqual([0, 2]); |
| 424 | 440 | |
| 425 | 441 | // 80 ms later it has faded, and the frame draws no line at all. |
| 426 | 442 | p.window.fire("keyup", { code: "KeyF" }); |
| 427 | 443 | before = glCalls(p).length; |
| 428 | 444 | p.step(100); |
| 429 | expect(named(since(p, before), "drawArrays")).toEqual([]); | |
| 445 | expect(arrays(p, since(p, before), "LINES")).toEqual([]); | |
| 430 | 446 | } finally { |
| 431 | 447 | undo(); |
| 432 | 448 | } |
src/range/scene.ts +46 −4
| @@ -19,10 +19,10 @@ | ||
| 19 | 19 | */ |
| 20 | 20 | |
| 21 | 21 | 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, | |
| 24 | 24 | } from "../engine/index.ts"; |
| 25 | import { roomMesh, targetMesh } from "./level.ts"; | |
| 25 | import { RANGE_ROOM, roomMesh, targetMesh } from "./level.ts"; | |
| 26 | 26 | import { RANGE_LAYERS, RANGE_TEX_SIZE, rangeAtlas } from "./paint.ts"; |
| 27 | 27 | import { EYE, STANDARD, centreOf, type Run } from "./targets.ts"; |
| 28 | 28 | |
| @@ -60,6 +60,27 @@ export const LINE_CAPACITY = 2; | ||
| 60 | 60 | */ |
| 61 | 61 | export const INSTANCE_CAPACITY = 16; |
| 62 | 62 | |
| 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 | ||
| 63 | 84 | /** |
| 64 | 85 | * The half-width the plate mesh is built at. Every plate is drawn from that one |
| 65 | 86 | * 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]; | ||
| 103 | 124 | const ORIGIN: Vec3 = [0, 0, 0]; |
| 104 | 125 | /** The one vector a frame writes a plate's centre into: allocation-free, as #47 asked. */ |
| 105 | 126 | 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 }; | |
| 106 | 135 | /** |
| 107 | 136 | * The tracer's two ends: hot white-yellow at the muzzle, thinner at the far |
| 108 | 137 | * 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"> => ({ | ||
| 135 | 164 | /** The one scene the page keeps, at this game's two capacities. */ |
| 136 | 165 | export function createRangeScene(): RangeScene { |
| 137 | 166 | 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 | }), | |
| 139 | 172 | sky: [RANGE_SKY[0], RANGE_SKY[1], RANGE_SKY[2]] as Tint, |
| 140 | 173 | }; |
| 141 | 174 | // The pool's camera starts at the origin, which is nobody's decision; the |
| @@ -195,6 +228,15 @@ export function buildRangeScene( | ||
| 195 | 228 | centreOf(target, PLACE); |
| 196 | 229 | const left = target.span > 0 ? target.life / target.span : 0; |
| 197 | 230 | 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); | |
| 198 | 240 | } |
| 199 | 241 | |
| 200 | 242 | // --- 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"]; | ||
| 33 | 33 | export type GlRecorder = Record<string, unknown> & { calls: [string, ...unknown[]][] }; |
| 34 | 34 | |
| 35 | 35 | 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", | |
| 41 | 43 | ]; |
| 42 | 44 | |
| 43 | 45 | const GL_VOIDS = [ |
| 44 | 46 | "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", | |
| 48 | 51 | "uniformMatrix4fv", "drawElements", "drawArrays", |
| 49 | 52 | ]; |
| 50 | 53 |
new above
offline