Split The code is public; the app answers only to the crew.
Bullet holes that stay: a fixed ring of decals, drawn in one call #144
Check · Review · Land · Live done — Landed and live in production.
vesper/cs:vesper/109-bullet-holes-that-stay-a-fixed-ring-of-d → main 1 commit fork
6 files
changed +635
−26
README.md +23 −8
| @@ -344,16 +344,29 @@ pixels you are getting. Nothing on the page moves when it changes. Only frames | ||
| 344 | 344 | that were actually drawn in an unclamped time count towards it, so a pause or a |
| 345 | 345 | minute in another tab cannot talk the scale down. |
| 346 | 346 | |
| 347 | A wall you have shot stays shot. Every round that hits the level leaves a mark | |
| 348 | where it landed, lying in that surface and turned by the normal the trace | |
| 349 | already reported, so a corner somebody has been spraying reads as sprayed and a | |
| 350 | missed shot leaves evidence that it missed *there*. They are a fixed ring of 64 | |
| 351 | — two full magazines of evidence, allocated once at start-up, the oldest | |
| 352 | replaced when the ring is full — they fade out over 25 seconds of the clock and | |
| 353 | not a number of frames, and a new round starts on clean walls. Nothing is | |
| 354 | marked for a hit on a player, or for the sparks a grenade throws into open air. | |
| 355 | All 64 of them are **one draw**: each is a small fan of triangles multiplied | |
| 356 | into the wall behind it, in the same two attributes the tracers use, so a | |
| 357 | sprayed corner costs one draw call and no second shader. | |
| 358 | ||
| 347 | 359 | What a frame costs is counted, not hoped for: one draw for the level, one per |
| 348 | 360 | player drawn, one per body, one per gun on the floor, one per grenade, one for |
| 349 | a bomb that is down, one batched draw for every line and one more for the | |
| 350 | muzzle flashes over them, plus the view model and its own flash — two programs, | |
| 351 | switched at most four times. `src/client/renderer.test.ts` draws a frame | |
| 352 | through the recording context and checks the count against that rule, so a | |
| 353 | change that costs a second draw per player fails the check rather than a phone; | |
| 354 | the worst round the game can build (ten players, ten bodies, a floor of | |
| 355 | twenty-four dropped guns, ten grenades in the air, a planted bomb and a screen | |
| 356 | full of tracers) is 59 draws against a budget of 80. | |
| 361 | a bomb that is down, one for every bullet hole in the level at once, one | |
| 362 | batched draw for every line and one more for the muzzle flashes over them, plus | |
| 363 | the view model and its own flash — two programs, switched at most four times. | |
| 364 | `src/client/renderer.test.ts` draws a frame through the recording context and | |
| 365 | checks the count against that rule, so a change that costs a second draw per | |
| 366 | player fails the check rather than a phone; the worst round the game can build | |
| 367 | (ten players, ten bodies, a floor of twenty-four dropped guns, ten grenades in | |
| 368 | the air, a planted bomb, a sprayed wall and a screen full of tracers) is 60 | |
| 369 | draws against a budget of 80. | |
| 357 | 370 | |
| 358 | 371 | The radar in the bottom-left corner is your view, not the room: rotated so |
| 359 | 372 | the way you are facing is up, it shows your living teammates, the bomb once |
| @@ -389,6 +402,8 @@ projections they feed cost 0.04 ms of a 16.7 ms frame, measured. | ||
| 389 | 402 | `interpolate` (what is drawn between two ticks: a position lerped, a yaw |
| 390 | 403 | turned the short way round the circle, and a teleport that snaps), |
| 391 | 404 | `quality` (the render scale the frame time buys), |
| 405 | `decals` (the round's bullet holes: a fixed ring of marks, as the vertices | |
| 406 | one draw needs and the colours a clock fades them through), | |
| 392 | 407 | `spectate` (whose eyes you borrow when you are dead), `bodies` (the dead of |
| 393 | 408 | this round, where they fell), `radar` (the blips the |
| 394 | 409 | HUD's minimap draws, and nothing it draws them with), |
src/client/decals.test.ts added +125
| @@ -0,0 +1,125 @@ | ||
| 1 | /** | |
| 2 | * The decal ring on its own: no GL, just the vertices it writes and the | |
| 3 | * colours it ages them into. What matters here is that the ring is a ring — | |
| 4 | * fixed, wrapping, replacing its oldest — that a mark lies in the surface it | |
| 5 | * was given, facing out of it, and that the fade is a function of the clock. | |
| 6 | */ | |
| 7 | ||
| 8 | import { expect, test } from "bun:test"; | |
| 9 | import { | |
| 10 | clear, decals, mark, shade, | |
| 11 | DECAL_CAPACITY, DECAL_FLOATS, DECAL_LIFE, DECAL_LIFT, DECAL_RADIUS, DECAL_VERTICES, | |
| 12 | } from "./decals.ts"; | |
| 13 | import type { Vec3 } from "./math.ts"; | |
| 14 | ||
| 15 | /** Vertex `v` of hole `slot`, as position then colour. */ | |
| 16 | const vertex = (data: Float32Array, slot: number, v: number): number[] => | |
| 17 | Array.from(data.subarray((slot * DECAL_VERTICES + v) * 6, (slot * DECAL_VERTICES + v) * 6 + 6)); | |
| 18 | ||
| 19 | test("a mark lies in the surface it was given, lifted off it and no wider than it says", () => { | |
| 20 | const d = decals(); | |
| 21 | const at: Vec3 = [100, 200, 40]; | |
| 22 | const normal: Vec3 = [0, -1, 0]; // a wall facing south | |
| 23 | expect(mark(d, at, normal, 1)).toBe(true); | |
| 24 | shade(d, 1); | |
| 25 | ||
| 26 | for (let v = 0; v < DECAL_VERTICES; v += 1) { | |
| 27 | const p = vertex(d.data, 0, v); | |
| 28 | // Every vertex is in one plane, half a unit out along the normal: a mark | |
| 29 | // in the wall z-fights with it and one off to the side is not the hit. | |
| 30 | expect(p[1]).toBeCloseTo(200 - DECAL_LIFT, 4); | |
| 31 | expect(Math.hypot((p[0] as number) - 100, (p[2] as number) - 40)) | |
| 32 | .toBeLessThanOrEqual(DECAL_RADIUS + 1e-4); | |
| 33 | } | |
| 34 | // The fan is wound out of the wall: the cull keeps the face you can see, and | |
| 35 | // a mark wound the other way would be a hole you could only see from inside. | |
| 36 | for (let t = 0; t < DECAL_VERTICES / 3; t += 1) { | |
| 37 | const c = vertex(d.data, 0, t * 3), a = vertex(d.data, 0, t * 3 + 1), b = vertex(d.data, 0, t * 3 + 2); | |
| 38 | const ax = (a[0] as number) - (c[0] as number), ay = (a[1] as number) - (c[1] as number), az = (a[2] as number) - (c[2] as number); | |
| 39 | const bx = (b[0] as number) - (c[0] as number), by = (b[1] as number) - (c[1] as number), bz = (b[2] as number) - (c[2] as number); | |
| 40 | const face: Vec3 = [ay * bz - az * by, az * bx - ax * bz, ax * by - ay * bx]; | |
| 41 | expect(face[0] * normal[0] + face[1] * normal[1] + face[2] * normal[2]).toBeGreaterThan(0); | |
| 42 | } | |
| 43 | }); | |
| 44 | ||
| 45 | test("a floor and a wall both get a frame that is not degenerate", () => { | |
| 46 | const d = decals(); | |
| 47 | for (const normal of [[0, 0, 1], [0, 0, -1], [1, 0, 0], [0, 1, 0]] as Vec3[]) { | |
| 48 | const fresh = decals(); | |
| 49 | expect(mark(fresh, [0, 0, 0], normal, 0)).toBe(true); | |
| 50 | for (let v = 0; v < DECAL_VERTICES; v += 1) { | |
| 51 | const p = vertex(fresh.data, 0, v); | |
| 52 | expect(Number.isFinite(p[0] as number)).toBe(true); | |
| 53 | expect(Math.hypot(p[0] as number, p[1] as number, p[2] as number)).toBeGreaterThan(0); | |
| 54 | } | |
| 55 | } | |
| 56 | // And nothing that is not a surface is marked at all: a zero normal has no | |
| 57 | // plane to lie in, and `NaN` in a vertex buffer is a triangle across the sky. | |
| 58 | expect(mark(d, [0, 0, 0], [0, 0, 0], 0)).toBe(false); | |
| 59 | expect(mark(d, [NaN, 0, 0], [0, 0, 1], 0)).toBe(false); | |
| 60 | expect(d.used).toBe(0); | |
| 61 | }); | |
| 62 | ||
| 63 | test("the ring is a ring: the oldest hole is the one that goes", () => { | |
| 64 | const d = decals(); | |
| 65 | expect(d.data.length).toBe(DECAL_FLOATS); | |
| 66 | for (let i = 0; i < DECAL_CAPACITY + 3; i += 1) mark(d, [i, 0, 0], [0, 0, 1], i); | |
| 67 | expect(d.used).toBe(DECAL_CAPACITY); | |
| 68 | expect(shade(d, DECAL_CAPACITY)).toBe(DECAL_CAPACITY * DECAL_VERTICES); | |
| 69 | ||
| 70 | // The three oldest were overwritten in place, and the buffer is the size it | |
| 71 | // was when it was made: nothing here grows with the round. | |
| 72 | const xs = []; | |
| 73 | for (let slot = 0; slot < DECAL_CAPACITY; slot += 1) xs.push(vertex(d.data, slot, 0)[0] as number); | |
| 74 | xs.sort((a, b) => a - b); | |
| 75 | expect(xs[0]).toBe(3); | |
| 76 | expect(xs[DECAL_CAPACITY - 1]).toBe(DECAL_CAPACITY + 2); | |
| 77 | expect(d.data.length).toBe(DECAL_FLOATS); | |
| 78 | }); | |
| 79 | ||
| 80 | test("the fade is the clock's, and a spent hole is drawn by nothing", () => { | |
| 81 | const d = decals(); | |
| 82 | mark(d, [0, 0, 0], [0, 0, 1], 10); | |
| 83 | const centre = (): number => { | |
| 84 | shade(d, now); | |
| 85 | return vertex(d.data, 0, 0)[3] as number; | |
| 86 | }; | |
| 87 | let now = 10; | |
| 88 | const fresh = centre(); | |
| 89 | expect(fresh).toBeLessThan(0.5); // dark: the wall under it is a quarter of itself | |
| 90 | ||
| 91 | // Held through most of its life, then on its way back to the wall as it was. | |
| 92 | now = 10 + DECAL_LIFE * 0.5; | |
| 93 | expect(centre()).toBe(fresh); | |
| 94 | now = 10 + DECAL_LIFE * 0.9; | |
| 95 | expect(centre()).toBeGreaterThan(fresh); | |
| 96 | expect(centre()).toBeLessThan(1); | |
| 97 | ||
| 98 | // Past its life there is nothing to draw: not a faint one, none. | |
| 99 | now = 10 + DECAL_LIFE; | |
| 100 | expect(shade(d, now)).toBe(0); | |
| 101 | expect(centre()).toBe(1); // white, multiplied, is the wall untouched | |
| 102 | ||
| 103 | // A hole made after it is drawn alongside it, and the spent slot costs a | |
| 104 | // triangle the wall does not notice rather than a second draw to skip it. | |
| 105 | mark(d, [50, 0, 0], [0, 0, 1], now); | |
| 106 | expect(shade(d, now)).toBe(2 * DECAL_VERTICES); | |
| 107 | }); | |
| 108 | ||
| 109 | test("clearing takes the holes and the memory of the impacts that made them", () => { | |
| 110 | const d = decals(); | |
| 111 | for (let i = 0; i < 5; i += 1) mark(d, [i, 0, 0], [0, 0, 1], i); | |
| 112 | d.taken = 4; | |
| 113 | expect(shade(d, 4)).toBeGreaterThan(0); | |
| 114 | ||
| 115 | clear(d, 10); | |
| 116 | expect(d.used).toBe(0); | |
| 117 | expect(d.next).toBe(0); | |
| 118 | expect(shade(d, 10)).toBe(0); | |
| 119 | // `since` is what the next round starts on: an impact from the round that | |
| 120 | // just ended is still in the effect list for two seconds, and it is not a | |
| 121 | // mark on this round's walls. | |
| 122 | expect(d.taken).toBe(10); | |
| 123 | clear(d); | |
| 124 | expect(d.taken).toBe(-Infinity); | |
| 125 | }); |
src/client/decals.ts added +171
| @@ -0,0 +1,171 @@ | ||
| 1 | /** | |
| 2 | * Bullet holes: a fixed ring of marks on the level, drawn in one call. | |
| 3 | * | |
| 4 | * A spark says a round landed somewhere a quarter of a second ago. A hole says | |
| 5 | * it landed *there*, and it is still saying it when you come back round the | |
| 6 | * corner. That is the whole of this file: where a shot hit the level, which way | |
| 7 | * the surface faced, and when — as vertices, ready for the buffer. | |
| 8 | * | |
| 9 | * `DECAL_CAPACITY` slots are allocated once, at start-up, and the `next` one is | |
| 10 | * overwritten when the ring is full: the oldest hole in the round is the one | |
| 11 | * that goes. Sixty-four of them is two full AK magazines of evidence, 1152 | |
| 12 | * vertices and 27 KB of buffer — a corner someone has been spraying reads as | |
| 13 | * sprayed, and a round that fires five hundred shots costs exactly the same as | |
| 14 | * one that fires sixty-five. Nothing here allocates after `decals()`: a shot | |
| 15 | * writes over a slot's vertices in place and a frame rewrites six floats of it. | |
| 16 | * | |
| 17 | * The colours are the whole of the fade, and the reason this needs no texture, | |
| 18 | * no second program and no alpha channel. Each mark is a fan of `DECAL_SPOKES` | |
| 19 | * triangles about a dark centre, drawn multiplied into what is already on the | |
| 20 | * wall (`DST_COLOR, ZERO`), so a vertex colour of 1 changes nothing at all: the | |
| 21 | * rim of every mark is white and invisible, the centre darkens the wall under | |
| 22 | * it, and as a hole ages its centre walks back up to white. Two attributes, a | |
| 23 | * position and a colour — which is exactly what the line program already takes. | |
| 24 | */ | |
| 25 | ||
| 26 | import type { Vec3 } from "./math.ts"; | |
| 27 | ||
| 28 | /** How many holes a round may show at once. The oldest is replaced. */ | |
| 29 | export const DECAL_CAPACITY = 64; | |
| 30 | /** Triangles in the fan: six is round enough at the size a hole is drawn. */ | |
| 31 | export const DECAL_SPOKES = 6; | |
| 32 | /** Seconds a hole lasts, of which the last `DECAL_FADE` of it is the fading. */ | |
| 33 | export const DECAL_LIFE = 25; | |
| 34 | const DECAL_FADE = 0.35; | |
| 35 | /** How wide a mark is, in world units — a player is 72 tall. */ | |
| 36 | export const DECAL_RADIUS = 3.4; | |
| 37 | /** | |
| 38 | * How far off the surface the mark sits. The depth buffer's precision at three | |
| 39 | * thousand units out, with this projection's near plane, is under a tenth of a | |
| 40 | * unit, so half a unit is clear of z-fighting everywhere the eye can see a wall | |
| 41 | * and close enough that nothing reads as floating. | |
| 42 | */ | |
| 43 | export const DECAL_LIFT = 0.5; | |
| 44 | /** How dark the centre of a fresh hole leaves the wall it is multiplied into. */ | |
| 45 | const DECAL_DARK = 0.24; | |
| 46 | ||
| 47 | export const DECAL_VERTICES = DECAL_SPOKES * 3; | |
| 48 | const FLOATS_PER_VERTEX = 6; // x, y, z, r, g, b — the line program's two attributes | |
| 49 | const FLOATS_PER_DECAL = DECAL_VERTICES * FLOATS_PER_VERTEX; | |
| 50 | /** The size of the ring's buffer, in floats: what the renderer allocates once. */ | |
| 51 | export const DECAL_FLOATS = DECAL_CAPACITY * FLOATS_PER_DECAL; | |
| 52 | ||
| 53 | export type Decals = { | |
| 54 | /** The vertices themselves, interleaved the way the line buffer is. */ | |
| 55 | data: Float32Array; | |
| 56 | /** When each slot was marked; `-Infinity` for one that never has been. */ | |
| 57 | born: Float32Array; | |
| 58 | /** The slot the next hole takes. */ | |
| 59 | next: number; | |
| 60 | /** How many slots have ever been filled: what is worth drawing and no more. */ | |
| 61 | used: number; | |
| 62 | /** | |
| 63 | * The newest impact already turned into a hole. An impact effect lives two | |
| 64 | * seconds and a frame sees it a hundred and twenty times; this is what makes | |
| 65 | * it one hole rather than a hundred and twenty. | |
| 66 | */ | |
| 67 | taken: number; | |
| 68 | }; | |
| 69 | ||
| 70 | export function decals(): Decals { | |
| 71 | const born = new Float32Array(DECAL_CAPACITY); | |
| 72 | born.fill(-Infinity); | |
| 73 | return { data: new Float32Array(DECAL_FLOATS), born, next: 0, used: 0, taken: -Infinity }; | |
| 74 | } | |
| 75 | ||
| 76 | /** | |
| 77 | * A fresh round starts on clean walls — and nothing is freed doing it. | |
| 78 | * | |
| 79 | * `since` is the clock the ring starts again on: an effect list holds two | |
| 80 | * seconds of impacts, and the last round's are not marks on this round's walls. | |
| 81 | */ | |
| 82 | export function clear(d: Decals, since = -Infinity): void { | |
| 83 | d.born.fill(-Infinity); | |
| 84 | d.next = 0; | |
| 85 | d.used = 0; | |
| 86 | d.taken = since; | |
| 87 | } | |
| 88 | ||
| 89 | const TAU = Math.PI * 2; | |
| 90 | ||
| 91 | /** | |
| 92 | * Put a hole at `pos`, lying in the surface whose outward normal is `normal`. | |
| 93 | * A normal that is not a direction and a position that is not a number are the | |
| 94 | * two ways this can be asked for something it cannot draw, and both are refused | |
| 95 | * here rather than written into the buffer as `NaN`. | |
| 96 | */ | |
| 97 | export function mark(d: Decals, pos: Vec3, normal: Vec3, at: number): boolean { | |
| 98 | const len = Math.hypot(normal[0], normal[1], normal[2]); | |
| 99 | if (!(len > 1e-6) || !Number.isFinite(pos[0] + pos[1] + pos[2]) || !Number.isFinite(at)) return false; | |
| 100 | const nx = normal[0] / len, ny = normal[1] / len, nz = normal[2] / len; | |
| 101 | ||
| 102 | // Two axes across the surface. The one the normal is least aligned with is | |
| 103 | // crossed into it, so a floor and a wall both get a frame that is not | |
| 104 | // degenerate, and `t × b = n` keeps the fan wound outwards for the cull. | |
| 105 | const flat = Math.abs(nz) > 0.9; | |
| 106 | let tx = flat ? 0 : -ny, ty = flat ? -nz : nx, tz = flat ? ny : 0; | |
| 107 | const tl = Math.hypot(tx, ty, tz); | |
| 108 | tx /= tl; ty /= tl; tz /= tl; | |
| 109 | const bx = ny * tz - nz * ty, by = nz * tx - nx * tz, bz = nx * ty - ny * tx; | |
| 110 | ||
| 111 | const slot = d.next; | |
| 112 | d.next = (d.next + 1) % DECAL_CAPACITY; | |
| 113 | if (d.used < DECAL_CAPACITY) d.used += 1; | |
| 114 | d.born[slot] = at; | |
| 115 | ||
| 116 | const cx = pos[0] + nx * DECAL_LIFT, cy = pos[1] + ny * DECAL_LIFT, cz = pos[2] + nz * DECAL_LIFT; | |
| 117 | // The fan is spun by the shot's own timestamp: two holes side by side are | |
| 118 | // not the same hexagon, and no frame asks `Math.random`. | |
| 119 | const spin = at * 2.3; | |
| 120 | const data = d.data; | |
| 121 | let o = slot * FLOATS_PER_DECAL; | |
| 122 | for (let s = 0; s < DECAL_SPOKES; s += 1) { | |
| 123 | // The centre. Its colour is the fade, written every frame by `shade`. | |
| 124 | data[o] = cx; data[o + 1] = cy; data[o + 2] = cz; | |
| 125 | o += FLOATS_PER_VERTEX; | |
| 126 | // The two rim corners of this triangle, in order: a `for … of` over a pair | |
| 127 | // would be one array allocated per spoke per shot, which is the one thing | |
| 128 | // a ring allocated once is for. | |
| 129 | for (let k = 0; k < 2; k += 1) { | |
| 130 | const a = spin + ((s + k) / DECAL_SPOKES) * TAU; | |
| 131 | const ca = Math.cos(a) * DECAL_RADIUS, sa = Math.sin(a) * DECAL_RADIUS; | |
| 132 | data[o] = cx + tx * ca + bx * sa; | |
| 133 | data[o + 1] = cy + ty * ca + by * sa; | |
| 134 | data[o + 2] = cz + tz * ca + bz * sa; | |
| 135 | // The rim changes nothing: white, multiplied, is the wall as it was. | |
| 136 | data[o + 3] = 1; data[o + 4] = 1; data[o + 5] = 1; | |
| 137 | o += FLOATS_PER_VERTEX; | |
| 138 | } | |
| 139 | } | |
| 140 | return true; | |
| 141 | } | |
| 142 | ||
| 143 | /** | |
| 144 | * Age every hole into its centre's colour, and say how many vertices are worth | |
| 145 | * drawing: `0` when the ring is empty or everything in it has faded out, so a | |
| 146 | * round nobody has fired in pays no draw and no upload at all. | |
| 147 | * | |
| 148 | * A slot that has faded inside a ring that has not is written white, which is | |
| 149 | * a triangle the wall does not notice — the alternative is a second draw to | |
| 150 | * skip it with, and one draw is the point. | |
| 151 | */ | |
| 152 | export function shade(d: Decals, time: number): number { | |
| 153 | let live = false; | |
| 154 | const data = d.data; | |
| 155 | for (let slot = 0; slot < d.used; slot += 1) { | |
| 156 | // A float32 stamp read back against a float64 clock can be a shade in the | |
| 157 | // future, so an age at or below zero is a hole that has just been made and | |
| 158 | // not one to hide: only the far end of the life turns a mark off. | |
| 159 | const age = time - (d.born[slot] as number); | |
| 160 | const left = (DECAL_LIFE - age) / (DECAL_LIFE * DECAL_FADE); | |
| 161 | const strength = age >= DECAL_LIFE ? 0 : Math.min(1, left); | |
| 162 | if (strength > 0) live = true; | |
| 163 | const dark = 1 - (1 - DECAL_DARK) * strength; | |
| 164 | let o = slot * FLOATS_PER_DECAL + 3; | |
| 165 | for (let s = 0; s < DECAL_SPOKES; s += 1) { | |
| 166 | data[o] = dark; data[o + 1] = dark; data[o + 2] = dark; | |
| 167 | o += FLOATS_PER_VERTEX * 3; // past this triangle's two rim corners | |
| 168 | } | |
| 169 | } | |
| 170 | return live ? d.used * DECAL_VERTICES : 0; | |
| 171 | } |
src/client/renderer.test.ts +220 −11
| @@ -20,9 +20,15 @@ import { advance, TICK, waiting } from "./loop.ts"; | ||
| 20 | 20 | import { SHORT } from "./match.ts"; |
| 21 | 21 | import { MOVE, STEP_STRIDE } from "./physics.ts"; |
| 22 | 22 | import { grenadeMesh, HELD_MUZZLE, HIP_Z, levelMesh, muzzle, playerMesh, viewModelMesh } from "./models.ts"; |
| 23 | import { | |
| 24 | DECAL_CAPACITY, DECAL_FLOATS, DECAL_LIFE, DECAL_LIFT, DECAL_RADIUS, DECAL_VERTICES, | |
| 25 | } from "./decals.ts"; | |
| 26 | import type { Vec3 } from "./math.ts"; | |
| 23 | 27 | import { createRenderer, LINE_CAPACITY, type Camera, type Renderer, type RendererHooks } from "./renderer.ts"; |
| 24 | 28 | import { WEAPONS, type WeaponId } from "./weapons.ts"; |
| 25 | import { createWorld, eyePos, startRound, type Effect, type Player, type World } from "./world.ts"; | |
| 29 | import { | |
| 30 | createWorld, eyePos, NO_INPUT, startRound, update, type Effect, type Player, type World, | |
| 31 | } from "./world.ts"; | |
| 26 | 32 | |
| 27 | 33 | type Call = { name: string; args: unknown[] }; |
| 28 | 34 | |
| @@ -65,6 +71,7 @@ function recorder(): { | ||
| 65 | 71 | bufferData: log("bufferData"), bufferSubData: log("bufferSubData"), |
| 66 | 72 | enableVertexAttribArray: log("enableVertexAttribArray"), vertexAttribPointer: log("vertexAttribPointer"), |
| 67 | 73 | enable: log("enable"), disable: log("disable"), blendFunc: log("blendFunc"), |
| 74 | depthMask: log("depthMask"), depthFunc: log("depthFunc"), | |
| 68 | 75 | cullFace: log("cullFace"), clearColor: log("clearColor"), |
| 69 | 76 | viewport: log("viewport"), clear: log("clear"), useProgram: log("useProgram"), |
| 70 | 77 | activeTexture: log("activeTexture"), uniform1i: log("uniform1i"), |
| @@ -98,6 +105,13 @@ function recorder(): { | ||
| 98 | 105 | return { canvas: canvas as unknown as HTMLCanvasElement, calls, fire }; |
| 99 | 106 | } |
| 100 | 107 | |
| 108 | /** | |
| 109 | * A GL constant, as the recorder above makes them: `prop.length + 0x1000`. A | |
| 110 | * draw's mode is the one constant a test here reads back, because it is what | |
| 111 | * separates the frame's lines from the triangles its bullet holes are drawn as. | |
| 112 | */ | |
| 113 | const MODE = { lines: "LINES".length + 0x1000, triangles: "TRIANGLES".length + 0x1000 }; | |
| 114 | ||
| 101 | 115 | const CAMERA: Camera = { pos: [0, 0, 64], yaw: 0, pitch: 0, fov: 90 }; |
| 102 | 116 | |
| 103 | 117 | /** One frame, and the draws it made. */ |
| @@ -190,7 +204,7 @@ function heldPlace(calls: Call[], kind: string): Float32Array | null { | ||
| 190 | 204 | |
| 191 | 205 | /** Every run of lines drawn this frame: tracers, sparks and flashes alike. */ |
| 192 | 206 | const lineRuns = (calls: Call[]): { first: number; count: number }[] => |
| 193 | calls.filter((c) => c.name === "drawArrays") | |
| 207 | calls.filter((c) => c.name === "drawArrays" && c.args[0] === MODE.lines) | |
| 194 | 208 | .map((c) => ({ first: c.args[1] as number, count: c.args[2] as number })); |
| 195 | 209 | |
| 196 | 210 | const shotBy = (world: World, p: Player, weapon: WeaponId): Effect => |
| @@ -660,6 +674,7 @@ test("a lost view waits exactly as a pause does: no steps, and nothing banked", | ||
| 660 | 674 | * pickups one per gun on the floor |
| 661 | 675 | * grenades one per grenade in the air |
| 662 | 676 | * bomb one while it is down (planted or dropped) |
| 677 | * decals one batched draw for every bullet hole in the level at once | |
| 663 | 678 | * lines one batched draw for the world's tracers, sparks and beam |
| 664 | 679 | * flashes one more, additive, for everyone else's muzzles |
| 665 | 680 | * view model one while the holder is alive and not scoped |
| @@ -669,6 +684,11 @@ test("a lost view waits exactly as a pause does: no steps, and nothing banked", | ||
| 669 | 684 | * `world.time`, so a live effect is one that is there at all. The rule refuses |
| 670 | 685 | * a stale one rather than guessing which of the renderer's windows it fell out |
| 671 | 686 | * of — a test that pinned those windows would pin them twice. |
| 687 | * | |
| 688 | * `decals` is the one term that outlives the effect that made it: a hole stays | |
| 689 | * on the wall for the round. The rule counts it as arriving with the impact, | |
| 690 | * which is true of every frame below — each of them either has that impact in | |
| 691 | * hand or belongs to a renderer that has never been shown one. | |
| 672 | 692 | */ |
| 673 | 693 | function ruledDraws(world: World, view: Player): Record<string, number> { |
| 674 | 694 | for (const effect of world.effects) |
| @@ -690,6 +710,7 @@ function ruledDraws(world: World, view: Player): Record<string, number> { | ||
| 690 | 710 | pickups: world.pickups.length, |
| 691 | 711 | grenades: world.grenades.length, |
| 692 | 712 | bomb: down ? 1 : 0, |
| 713 | decals: world.effects.some((e) => e.kind === "impact" && e.surface) ? 1 : 0, | |
| 693 | 714 | lines: spark || world.bomb.state === "planted" ? 1 : 0, |
| 694 | 715 | flashes: flashed ? 1 : 0, |
| 695 | 716 | viewModel: held ? 1 : 0, |
| @@ -716,11 +737,12 @@ const passes = (calls: Call[], of: { scene: unknown; lines: unknown }): string[] | ||
| 716 | 737 | * The passes the same rule predicts: the scene, then the world's lines if it |
| 717 | 738 | * drew any, then the scene again for the view model's own projection and |
| 718 | 739 | * depth, then the lines again for the flash off its barrel. Two programs, and |
| 719 | * never a third. | |
| 740 | * never a third — the bullet holes are triangles of the line program's own two | |
| 741 | * attributes, so a marked wall is a draw and not a pass. | |
| 720 | 742 | */ |
| 721 | 743 | function ruledPasses(terms: Record<string, number>): string[] { |
| 722 | 744 | const list = ["scene"]; |
| 723 | if ((terms.lines as number) + (terms.flashes as number) > 0) list.push("lines"); | |
| 745 | if ((terms.lines as number) + (terms.flashes as number) + (terms.decals as number) > 0) list.push("lines"); | |
| 724 | 746 | if ((terms.viewModel as number) > 0) list.push("scene"); |
| 725 | 747 | if ((terms.viewFlash as number) > 0) list.push("lines"); |
| 726 | 748 | return list; |
| @@ -736,7 +758,7 @@ test("a frame's draws are the world's, one apiece, and its passes are two progra | ||
| 736 | 758 | const quiet = ruledDraws(world, world.me); |
| 737 | 759 | expect(quiet).toEqual({ |
| 738 | 760 | level: 1, players: 9, bodies: 0, pickups: 0, grenades: 0, bomb: 0, |
| 739 | lines: 0, flashes: 0, viewModel: 1, viewFlash: 0, | |
| 761 | decals: 0, lines: 0, flashes: 0, viewModel: 1, viewFlash: 0, | |
| 740 | 762 | }); |
| 741 | 763 | let frameCalls = record(renderer, calls, world); |
| 742 | 764 | expect(count(frameCalls, "drawElements") + count(frameCalls, "drawArrays")).toBe(total(quiet)); |
| @@ -754,6 +776,13 @@ test("a frame's draws are the world's, one apiece, and its passes are two progra | ||
| 754 | 776 | |
| 755 | 777 | // And a loud one: bodies, guns on the floor, grenades in the air, a bomb |
| 756 | 778 | // down and lines over the lot. Every term of the rule non-zero at once. |
| 779 | // | |
| 780 | // On a tick the renderer has not drawn yet, which is where effects come | |
| 781 | // from: the world stamps them as it steps, and the frame after that step is | |
| 782 | // the first to see them. A hole is the one effect that would notice — it | |
| 783 | // outlives its impact, so the renderer takes each impact once and only on | |
| 784 | // the tick it arrived. | |
| 785 | world.time += TICK; | |
| 757 | 786 | for (let i = 0; i < 4; i += 1) |
| 758 | 787 | addBody(world.bodies, { id: 30 + i, team: "t", pos: [i * 96, 0, 0], yaw: i * 40 }, world.boxes, world.time); |
| 759 | 788 | for (let i = 0; i < 6; i += 1) |
| @@ -762,7 +791,7 @@ test("a frame's draws are the world's, one apiece, and its passes are two progra | ||
| 762 | 791 | world.bomb.state = "planted"; |
| 763 | 792 | world.bomb.pos = [256, 256, 0]; |
| 764 | 793 | world.effects.push({ kind: "tracer", at: world.time, from: [0, 0, 40], to: [400, 0, 40] }); |
| 765 | world.effects.push({ kind: "impact", at: world.time, pos: [400, 0, 40], normal: [1, 0, 0] }); | |
| 794 | world.effects.push({ kind: "impact", at: world.time, pos: [400, 0, 40], normal: [1, 0, 0], surface: true }); | |
| 766 | 795 | world.effects.push({ kind: "blood", at: world.time, pos: [200, 0, 40] }); |
| 767 | 796 | const shooter = world.players.find((p) => p !== world.me && p.alive) as Player; |
| 768 | 797 | world.effects.push(shotBy(world, shooter, "ak47")); |
| @@ -793,7 +822,7 @@ test("a frame's draws are the world's, one apiece, and its passes are two progra | ||
| 793 | 822 | * fifth of the frame, which is the most of it the draws may have with the |
| 794 | 823 | * simulation and the HUD still to pay for. |
| 795 | 824 | * |
| 796 | * So eighty is the budget. The worst frame below is 59 of it, and the rest is | |
| 825 | * So eighty is the budget. The worst frame below is 60 of it, and the rest is | |
| 797 | 826 | * not spare: it is what an unlucky round has in hand. A change that wants the |
| 798 | 827 | * eighty-first draw batches something away instead of raising this line. |
| 799 | 828 | */ |
| @@ -822,10 +851,14 @@ test("the worst round this game can build stays inside the frame's draw budget", | ||
| 822 | 851 | world.bomb.state = "planted"; |
| 823 | 852 | world.bomb.pos = [512, 512, 0]; |
| 824 | 853 | // A screen full of effects: forty tracers, forty sparks, twenty hits, and |
| 825 | // every gun in the server firing at once. | |
| 854 | // every gun in the server firing at once — on a tick of their own, the way | |
| 855 | // the world hands them over. | |
| 856 | world.time += TICK; | |
| 826 | 857 | for (let i = 0; i < 40; i += 1) { |
| 827 | 858 | world.effects.push({ kind: "tracer", at: world.time, from: [0, 0, 40], to: [i * 16, 300, 40] }); |
| 828 | world.effects.push({ kind: "impact", at: world.time, pos: [i * 16, 300, 40], normal: [0, -1, 0] }); | |
| 859 | world.effects.push({ | |
| 860 | kind: "impact", at: world.time, pos: [i * 16, 300, 40], normal: [0, -1, 0], surface: true, | |
| 861 | }); | |
| 829 | 862 | } |
| 830 | 863 | for (let i = 0; i < 20; i += 1) |
| 831 | 864 | world.effects.push({ kind: "blood", at: world.time, pos: [i * 32, 100, 40] }); |
| @@ -839,10 +872,11 @@ test("the worst round this game can build stays inside the frame's draw budget", | ||
| 839 | 872 | expect(draws).toBeLessThanOrEqual(FRAME_BUDGET); |
| 840 | 873 | // Said again as the sum it is, so a reader can see which term grew when this |
| 841 | 874 | // number moves: 1 level + 9 players + 10 bodies + 24 pickups + 10 grenades |
| 842 | // + 1 bomb + 2 line draws + the view model and its flash. | |
| 875 | // + 1 bomb + every bullet hole of the round in one draw + 2 line draws + | |
| 876 | // the view model and its flash. | |
| 843 | 877 | expect(terms).toEqual({ |
| 844 | 878 | level: 1, players: 9, bodies: 10, pickups: 24, grenades: 10, bomb: 1, |
| 845 | lines: 1, flashes: 1, viewModel: 1, viewFlash: 1, | |
| 879 | decals: 1, lines: 1, flashes: 1, viewModel: 1, viewFlash: 1, | |
| 846 | 880 | }); |
| 847 | 881 | |
| 848 | 882 | // The passes do not grow with the world: the worst frame is still the two |
| @@ -988,3 +1022,178 @@ test("a body topples by the frame and not by the step", () => { | ||
| 988 | 1022 | expect(tip(0.5)).toBeGreaterThan(tip(0)); |
| 989 | 1023 | expect(tip(1)).toBeGreaterThan(tip(0.5)); |
| 990 | 1024 | }); |
| 1025 | ||
| 1026 | // --- bullet holes that stay | |
| 1027 | // | |
| 1028 | // A spark lives a quarter of a second; a hole lives the round. What can be | |
| 1029 | // checked here is that it is one draw however many of them there are, that the | |
| 1030 | // ring is the fixed size it says it is, and that a round that ends takes them. | |
| 1031 | ||
| 1032 | /** The one draw every bullet hole of the round is in, and the data behind it. */ | |
| 1033 | function wallHoles(calls: Call[]): { vertices: number; data: Float32Array } | null { | |
| 1034 | const draws = calls.filter((c) => c.name === "drawArrays" && c.args[0] === MODE.triangles); | |
| 1035 | // One draw for all of them, or this is not the feature it says it is. | |
| 1036 | expect(draws.length).toBeLessThanOrEqual(1); | |
| 1037 | if (draws.length === 0) return null; | |
| 1038 | const upload = calls.find( | |
| 1039 | (c) => c.name === "bufferSubData" && (c.args[2] as Float32Array).length === DECAL_FLOATS) as Call; | |
| 1040 | const vertices = (draws[0] as Call).args[2] as number; | |
| 1041 | // The ring is uploaded exactly as far as it is drawn, out of a buffer sized | |
| 1042 | // once at start-up: a hundred more shots do not grow it by a float. | |
| 1043 | expect(upload.args[4]).toBe(vertices * 6); | |
| 1044 | return { vertices, data: upload.args[2] as Float32Array }; | |
| 1045 | } | |
| 1046 | ||
| 1047 | /** The centre of hole `slot`: the first vertex of the first triangle of its fan. */ | |
| 1048 | const centreOf = (data: Float32Array, slot: number): number[] => | |
| 1049 | Array.from(data.subarray(slot * DECAL_VERTICES * 6, slot * DECAL_VERTICES * 6 + 3)); | |
| 1050 | ||
| 1051 | /** How dark that centre leaves the wall it is multiplied into: 1 is untouched. */ | |
| 1052 | const shadeOf = (data: Float32Array, slot: number): number => | |
| 1053 | data[slot * DECAL_VERTICES * 6 + 3] as number; | |
| 1054 | ||
| 1055 | /** An impact on a wall running east-west, three hundred units north of the origin. */ | |
| 1056 | const wallHit = (world: World, x: number): Effect => | |
| 1057 | ({ kind: "impact", at: world.time, pos: [x, 300, 40], normal: [0, -1, 0], surface: true }); | |
| 1058 | ||
| 1059 | test("a shot into the level leaves a hole, oriented by the surface it hit", () => { | |
| 1060 | const { canvas, calls } = recorder(); | |
| 1061 | const renderer = attach(canvas); | |
| 1062 | const world = scene(); | |
| 1063 | expect(wallHoles(record(renderer, calls, world))).toBeNull(); // a round starts clean | |
| 1064 | ||
| 1065 | world.time += TICK; // the tick the shot landed on, which no frame has drawn | |
| 1066 | world.effects.push(wallHit(world, 128)); | |
| 1067 | const marked = wallHoles(record(renderer, calls, world)) as { vertices: number; data: Float32Array }; | |
| 1068 | expect(marked.vertices).toBe(DECAL_VERTICES); | |
| 1069 | // Lifted along the normal it was given, and only along it: a mark sunk into | |
| 1070 | // the wall z-fights and one nudged sideways is not where the round went. | |
| 1071 | expect(centreOf(marked.data, 0)).toEqual([128, 300 - DECAL_LIFT, 40]); | |
| 1072 | // And it lies in that surface: every vertex of the fan is in the plane the | |
| 1073 | // centre is in, no further out than the radius. | |
| 1074 | for (let v = 0; v < DECAL_VERTICES; v += 1) { | |
| 1075 | const at = marked.data.subarray(v * 6, v * 6 + 3); | |
| 1076 | expect(at[1]).toBeCloseTo(300 - DECAL_LIFT, 5); | |
| 1077 | expect(Math.hypot((at[0] as number) - 128, (at[2] as number) - 40)).toBeLessThanOrEqual(DECAL_RADIUS + 1e-4); | |
| 1078 | } | |
| 1079 | ||
| 1080 | // The effect lives two seconds and a frame meets it a hundred and twenty | |
| 1081 | // times. It is one hole all the same. | |
| 1082 | for (let i = 0; i < 20; i += 1) record(renderer, calls, world); | |
| 1083 | expect((wallHoles(calls.slice()) as { vertices: number }).vertices).toBe(DECAL_VERTICES); | |
| 1084 | }); | |
| 1085 | ||
| 1086 | test("a hit on a player marks nothing, and neither does a grenade's burst", () => { | |
| 1087 | const { canvas, calls } = recorder(); | |
| 1088 | const renderer = attach(canvas); | |
| 1089 | const world = scene(); | |
| 1090 | const victim = world.players.find((p) => p !== world.me) as Player; | |
| 1091 | ||
| 1092 | world.effects.push({ kind: "blood", at: world.time, pos: [...victim.pos] as Vec3 }); | |
| 1093 | // The five sparks a grenade throws are in whatever air is around it: they | |
| 1094 | // struck nothing, and `surface` is the trace's own word for that. | |
| 1095 | world.effects.push({ kind: "impact", at: world.time, pos: [0, 0, 60], normal: [0, 0, 1], surface: false }); | |
| 1096 | expect(wallHoles(record(renderer, calls, world))).toBeNull(); | |
| 1097 | ||
| 1098 | // The level, in the same breath, does mark — so the silence above is the | |
| 1099 | // rule and not an absence of decals altogether. | |
| 1100 | world.time += 0.1; | |
| 1101 | world.effects.push(wallHit(world, 64)); | |
| 1102 | expect(wallHoles(record(renderer, calls, world))).not.toBeNull(); | |
| 1103 | }); | |
| 1104 | ||
| 1105 | test("the ring is fixed: N + 2 impacts are N holes in one draw of the same size", () => { | |
| 1106 | const { canvas, calls } = recorder(); | |
| 1107 | const renderer = attach(canvas); | |
| 1108 | const world = scene(); | |
| 1109 | const drawn = (frameCalls: Call[]): number => | |
| 1110 | count(frameCalls, "drawElements") + count(frameCalls, "drawArrays"); | |
| 1111 | const quiet = drawn(record(renderer, calls, world)); | |
| 1112 | ||
| 1113 | let marked = 0; | |
| 1114 | let last: Call[] = []; | |
| 1115 | for (let i = 0; i < DECAL_CAPACITY + 2; i += 1) { | |
| 1116 | world.time += 0.1; | |
| 1117 | world.effects.length = 0; | |
| 1118 | world.effects.push(wallHit(world, i * 16)); | |
| 1119 | last = record(renderer, calls, world); | |
| 1120 | // Two more draws than a clean frame — the impact's sparks, and every hole | |
| 1121 | // in the level at once — on the first marked frame and on the sixty-sixth | |
| 1122 | // alike. The draw count does not know how many holes there are. | |
| 1123 | if (i === 0) marked = drawn(last); | |
| 1124 | expect(drawn(last)).toBe(marked); | |
| 1125 | expect(count(last, "bufferData")).toBe(0); // nothing is allocated per shot | |
| 1126 | } | |
| 1127 | expect(marked).toBe(quiet + 2); | |
| 1128 | ||
| 1129 | const ring = wallHoles(last) as { vertices: number; data: Float32Array }; | |
| 1130 | expect(ring.vertices).toBe(DECAL_CAPACITY * DECAL_VERTICES); | |
| 1131 | expect(ring.data.length).toBe(DECAL_FLOATS); | |
| 1132 | // And it is the last N impacts: the two oldest holes are the two that went. | |
| 1133 | const xs = []; | |
| 1134 | for (let slot = 0; slot < DECAL_CAPACITY; slot += 1) xs.push(centreOf(ring.data, slot)[0] as number); | |
| 1135 | xs.sort((a, b) => a - b); | |
| 1136 | expect(xs[0]).toBe(2 * 16); | |
| 1137 | expect(xs[DECAL_CAPACITY - 1]).toBe((DECAL_CAPACITY + 1) * 16); | |
| 1138 | }); | |
| 1139 | ||
| 1140 | test("a hole fades over seconds, not frames, and goes altogether when it is spent", () => { | |
| 1141 | const { canvas, calls } = recorder(); | |
| 1142 | const renderer = attach(canvas); | |
| 1143 | const world = scene(); | |
| 1144 | world.effects.push(wallHit(world, 64)); | |
| 1145 | const fresh = wallHoles(record(renderer, calls, world)) as { data: Float32Array }; | |
| 1146 | const dark = shadeOf(fresh.data, 0); | |
| 1147 | expect(dark).toBeLessThan(0.5); // the wall under it is a quarter of what it was | |
| 1148 | ||
| 1149 | // A hundred frames on the same clock are the same hole: nothing here counts | |
| 1150 | // frames, so a phone at 30 fps and a desktop at 144 fade at the same rate. | |
| 1151 | world.effects.length = 0; | |
| 1152 | for (let i = 0; i < 100; i += 1) record(renderer, calls, world); | |
| 1153 | expect(shadeOf((wallHoles(calls.slice()) as { data: Float32Array }).data, 0)).toBe(dark); | |
| 1154 | ||
| 1155 | // Most of a lifetime later it is on its way out, and past it there is | |
| 1156 | // nothing left to draw at all — no draw, no upload, no ring. | |
| 1157 | world.time += DECAL_LIFE * 0.95; | |
| 1158 | const old = wallHoles(record(renderer, calls, world)) as { data: Float32Array }; | |
| 1159 | expect(shadeOf(old.data, 0)).toBeGreaterThan(dark); | |
| 1160 | expect(shadeOf(old.data, 0)).toBeLessThan(1); | |
| 1161 | world.time += DECAL_LIFE; | |
| 1162 | expect(wallHoles(record(renderer, calls, world))).toBeNull(); | |
| 1163 | }); | |
| 1164 | ||
| 1165 | test("a round that ends takes its holes with it: the next one starts on clean walls", () => { | |
| 1166 | const { canvas, calls } = recorder(); | |
| 1167 | const renderer = attach(canvas); | |
| 1168 | const world = scene(); | |
| 1169 | // Eight shots into the same wall, a tick and a frame apart, the way they | |
| 1170 | // arrive: a hole is taken on the tick its impact was stamped. | |
| 1171 | for (let i = 0; i < 8; i += 1) { | |
| 1172 | world.time += 0.1; | |
| 1173 | world.effects.push(wallHit(world, i * 24)); | |
| 1174 | record(renderer, calls, world); | |
| 1175 | } | |
| 1176 | const sprayed = wallHoles(calls.slice()) as { vertices: number }; | |
| 1177 | expect(sprayed.vertices).toBe(8 * DECAL_VERTICES); | |
| 1178 | ||
| 1179 | // The round ending as the game ends one: the clock runs out on the last | |
| 1180 | // phase, `match.round` moves, and the world puts everyone back on a spawn. | |
| 1181 | world.match.phase = "over"; | |
| 1182 | world.match.timer = 0; | |
| 1183 | const round = world.match.round; | |
| 1184 | update(world, TICK, NO_INPUT); | |
| 1185 | expect(world.match.round).toBe(round + 1); | |
| 1186 | const phase: string = world.match.phase; | |
| 1187 | expect(phase).toBe("freeze"); | |
| 1188 | ||
| 1189 | // Last round's impacts are still in the effect list — they live two seconds | |
| 1190 | // — and not one of them is a mark on this round's walls. | |
| 1191 | expect(world.effects.some((e) => e.kind === "impact")).toBe(true); | |
| 1192 | expect(wallHoles(record(renderer, calls, world))).toBeNull(); | |
| 1193 | ||
| 1194 | // A shot in the new round marks it again, from an empty ring. | |
| 1195 | world.time += 0.1; | |
| 1196 | world.effects.push(wallHit(world, 512)); | |
| 1197 | const again = wallHoles(record(renderer, calls, world)) as { vertices: number }; | |
| 1198 | expect(again.vertices).toBe(DECAL_VERTICES); | |
| 1199 | }); |
src/client/renderer.ts +85 −4
| @@ -1,7 +1,8 @@ | ||
| 1 | 1 | /** |
| 2 | * The WebGL2 renderer. Two programs and five buffers: the level is one static | |
| 3 | * draw, each player is one draw of a shared mesh, and tracers and sparks share | |
| 4 | * a single line buffer that is refilled each frame. Nothing is allocated in | |
| 2 | * The WebGL2 renderer. Two programs and six buffers: the level is one static | |
| 3 | * draw, each player is one draw of a shared mesh, tracers and sparks share a | |
| 4 | * single line buffer that is refilled each frame, and every bullet hole of the | |
| 5 | * round is one more draw out of a ring allocated once. Nothing is allocated in | |
| 5 | 6 | * the frame loop except the numbers that change. |
| 6 | 7 | * |
| 7 | 8 | * Every one of those resources is made by `build`, and `build` can be run |
| @@ -13,6 +14,7 @@ | ||
| 13 | 14 | * all, and `webglcontextrestored` builds the lot a second time. |
| 14 | 15 | */ |
| 15 | 16 | |
| 17 | import { clear as clearDecals, decals, DECAL_FLOATS, mark, shade } from "./decals.ts"; | |
| 16 | 18 | import { between, drawnAt, lerpPos, pose, type Pose } from "./interpolate.ts"; |
| 17 | 19 | import { TICK } from "./loop.ts"; |
| 18 | 20 | import { fallenMatrix, multiply, perspective, rad, viewMatrix, modelMatrix, type Vec3 } from "./math.ts"; |
| @@ -257,6 +259,9 @@ type Gpu = { | ||
| 257 | 259 | levelMap: MapDef | null; |
| 258 | 260 | lineVao: WebGLVertexArrayObject; |
| 259 | 261 | lineBuffer: WebGLBuffer | null; |
| 262 | /** The bullet holes, in the line program's own two attributes. */ | |
| 263 | decalVao: WebGLVertexArrayObject; | |
| 264 | decalBuffer: WebGLBuffer | null; | |
| 260 | 265 | }; |
| 261 | 266 | |
| 262 | 267 | function build(gl: WebGL2RenderingContext): Gpu { |
| @@ -289,9 +294,27 @@ function build(gl: WebGL2RenderingContext): Gpu { | ||
| 289 | 294 | gl.vertexAttribPointer(1, 3, gl.FLOAT, false, 24, 12); |
| 290 | 295 | gl.bindVertexArray(null); |
| 291 | 296 | |
| 297 | // And one for the bullet holes, the same two attributes in the same layout — | |
| 298 | // the same program draws both — but filled from a ring that outlives the | |
| 299 | // frame rather than from one refilled by it. | |
| 300 | const decalVao = gl.createVertexArray() as WebGLVertexArrayObject; | |
| 301 | const decalBuffer = gl.createBuffer(); | |
| 302 | gl.bindVertexArray(decalVao); | |
| 303 | gl.bindBuffer(gl.ARRAY_BUFFER, decalBuffer); | |
| 304 | gl.bufferData(gl.ARRAY_BUFFER, DECAL_FLOATS * Float32Array.BYTES_PER_ELEMENT, gl.DYNAMIC_DRAW); | |
| 305 | gl.enableVertexAttribArray(0); | |
| 306 | gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 24, 0); | |
| 307 | gl.enableVertexAttribArray(1); | |
| 308 | gl.vertexAttribPointer(1, 3, gl.FLOAT, false, 24, 12); | |
| 309 | gl.bindVertexArray(null); | |
| 310 | ||
| 292 | 311 | // Context state, not a resource, but it is lost with the context all the |
| 293 | 312 | // same: a rebuilt renderer that forgot this would draw with no depth test. |
| 294 | 313 | gl.enable(gl.DEPTH_TEST); |
| 314 | // Equal passes, so two bullet holes that overlap on the same wall are both | |
| 315 | // drawn: they are lifted off it by the same amount, which puts them in one | |
| 316 | // plane, and `LESS` would keep whichever of them the ring reached first. | |
| 317 | gl.depthFunc(gl.LEQUAL); | |
| 295 | 318 | gl.enable(gl.CULL_FACE); |
| 296 | 319 | gl.cullFace(gl.BACK); |
| 297 | 320 | gl.clearColor(SKY[0], SKY[1], SKY[2], 1); |
| @@ -319,6 +342,8 @@ function build(gl: WebGL2RenderingContext): Gpu { | ||
| 319 | 342 | levelMap: null, |
| 320 | 343 | lineVao, |
| 321 | 344 | lineBuffer, |
| 345 | decalVao, | |
| 346 | decalBuffer, | |
| 322 | 347 | }; |
| 323 | 348 | } |
| 324 | 349 | |
| @@ -423,6 +448,14 @@ export function createRenderer(canvas: HTMLCanvasElement, hooks: RendererHooks = | ||
| 423 | 448 | const gait: Stride = neutral(); |
| 424 | 449 | const held: Vec3 = [0, 0, 0]; |
| 425 | 450 | |
| 451 | // The round's bullet holes, and the two numbers that say a round has ended: | |
| 452 | // its number, and its clock. A ring of vertices this side of the bus, like | |
| 453 | // `lineData` — a lost context takes the buffer and not the holes, so a view | |
| 454 | // that comes back comes back to the wall it left. | |
| 455 | const holes = decals(); | |
| 456 | let holesRound = -1; | |
| 457 | let holesTime = 0; | |
| 458 | ||
| 426 | 459 | let bobPhase = 0; |
| 427 | 460 | // The gun the hands last had, and when they were given it: a change of |
| 428 | 461 | // weapon — or of the player the camera is inside — starts a raise. It is |
| @@ -556,6 +589,35 @@ export function createRenderer(canvas: HTMLCanvasElement, hooks: RendererHooks = | ||
| 556 | 589 | gl.drawElements(gl.TRIANGLES, bomb.count, gl.UNSIGNED_INT, 0); |
| 557 | 590 | } |
| 558 | 591 | |
| 592 | // --- bullet holes. The whole of the decal ring's dealings with the frame: | |
| 593 | // which impacts are new, and whether the round they belong to is still on. | |
| 594 | // | |
| 595 | // A round that has ended takes its holes with it — a fresh round starts on | |
| 596 | // clean walls, and a ring that outlived its round would be a memory leak | |
| 597 | // with a picture. `match.round` moving is exactly what a new round is; the | |
| 598 | // clock going backwards is a new match, or a new world entirely. | |
| 599 | if (world.match.round !== holesRound || world.time < holesTime) { | |
| 600 | // Everything up to the tick before this one counts as already seen: the | |
| 601 | // effect list still holds two seconds of the round that just ended, and | |
| 602 | // none of it belongs on these walls. What is stamped at this tick is | |
| 603 | // this round's, including the first frame a renderer ever draws. | |
| 604 | clearDecals(holes, world.time - TICK); | |
| 605 | holesRound = world.match.round; | |
| 606 | } | |
| 607 | holesTime = world.time; | |
| 608 | for (const effect of world.effects) { | |
| 609 | // A hit on a player is blood and never a hole, and a grenade's burst | |
| 610 | // sparks in open air: `surface` is the trace's own answer to which of | |
| 611 | // these an impact was, and only the level is marked. | |
| 612 | if (effect.kind !== "impact" || !effect.surface || effect.at <= holes.taken) continue; | |
| 613 | mark(holes, effect.pos, effect.normal, effect.at); | |
| 614 | } | |
| 615 | // Everything stamped at or before this tick has now been seen: the same | |
| 616 | // impact effect, met again for the next two seconds of frames, is not a | |
| 617 | // second hole. | |
| 618 | holes.taken = world.time; | |
| 619 | const holeVertices = shade(holes, world.time); | |
| 620 | ||
| 559 | 621 | // --- tracers and sparks |
| 560 | 622 | let n = 0; |
| 561 | 623 | const push = (x: number, y: number, z: number, r: number, g: number, b: number): void => { |
| @@ -663,9 +725,28 @@ export function createRenderer(canvas: HTMLCanvasElement, hooks: RendererHooks = | ||
| 663 | 725 | star(mx, my, mz, rightX, rightY, 0, upX, upY, upZ, FLASH_WORLD * flash.size, effect.at, bright); |
| 664 | 726 | } |
| 665 | 727 | |
| 666 | if (n > 0) { | |
| 728 | if (n > 0 || holeVertices > 0) { | |
| 667 | 729 | gl.useProgram(gpu.lines); |
| 668 | 730 | gl.uniformMatrix4fv(u.lineViewProj, false, viewProj); |
| 731 | } | |
| 732 | ||
| 733 | // Every hole in the level, in one draw of the ring, multiplied into the | |
| 734 | // wall that is already there: white leaves it alone and the dark centres | |
| 735 | // are the marks. It writes no depth — two holes on one wall are coplanar, | |
| 736 | // and neither of them is something a tracer should be hidden behind. | |
| 737 | if (holeVertices > 0) { | |
| 738 | gl.bindVertexArray(gpu.decalVao); | |
| 739 | gl.bindBuffer(gl.ARRAY_BUFFER, gpu.decalBuffer); | |
| 740 | gl.bufferSubData(gl.ARRAY_BUFFER, 0, holes.data, 0, holeVertices * 6); | |
| 741 | gl.enable(gl.BLEND); | |
| 742 | gl.blendFunc(gl.DST_COLOR, gl.ZERO); | |
| 743 | gl.depthMask(false); | |
| 744 | gl.drawArrays(gl.TRIANGLES, 0, holeVertices); | |
| 745 | gl.depthMask(true); | |
| 746 | gl.disable(gl.BLEND); | |
| 747 | } | |
| 748 | ||
| 749 | if (n > 0) { | |
| 669 | 750 | gl.bindVertexArray(gpu.lineVao); |
| 670 | 751 | gl.bindBuffer(gl.ARRAY_BUFFER, gpu.lineBuffer); |
| 671 | 752 | gl.bufferSubData(gl.ARRAY_BUFFER, 0, lineData, 0, n * 6); |
src/client/world.ts +11 −3
| @@ -95,7 +95,10 @@ export type SoundName = "step" | "beep" | "plant" | "defuse" | "explode" | "empt | ||
| 95 | 95 | |
| 96 | 96 | export type Effect = |
| 97 | 97 | | { kind: "tracer"; from: Vec3; to: Vec3; at: number } |
| 98 | | { kind: "impact"; at: number; pos: Vec3; normal: Vec3 } | |
| 98 | // `surface` is whether the thing that was struck is level geometry the trace | |
| 99 | // reported: a wall keeps the hole the renderer leaves in it, and a grenade's | |
| 100 | // burst — which sparks in open air, around nothing — keeps none. | |
| 101 | | { kind: "impact"; at: number; pos: Vec3; normal: Vec3; surface: boolean } | |
| 99 | 102 | | { kind: "blood"; at: number; pos: Vec3 } |
| 100 | 103 | // A trigger pulled: where the gun was, what it was, whether it was the |
| 101 | 104 | // person's own, and who pulled it. `shooter` is what lets the renderer put a |
| @@ -612,10 +615,12 @@ export function shoot(world: World, p: Player): boolean { | ||
| 612 | 615 | // A crate the round went through is marked where it went in, as well as |
| 613 | 616 | // wherever the round finally stopped. |
| 614 | 617 | if (hit.through !== null) |
| 615 | world.effects.push({ kind: "impact", at: world.time, pos: hit.through.point, normal: hit.through.normal }); | |
| 618 | world.effects.push({ | |
| 619 | kind: "impact", at: world.time, pos: hit.through.point, normal: hit.through.normal, surface: true, | |
| 620 | }); | |
| 616 | 621 | |
| 617 | 622 | if (hit.kind === "world") { |
| 618 | world.effects.push({ kind: "impact", at: world.time, pos: hit.point, normal: hit.normal }); | |
| 623 | world.effects.push({ kind: "impact", at: world.time, pos: hit.point, normal: hit.normal, surface: true }); | |
| 619 | 624 | } else if (hit.kind === "actor") { |
| 620 | 625 | const victim = hit.actor as Player; |
| 621 | 626 | if (victim.team === p.team && victim !== p) return true; // no friendly fire in this match |
| @@ -800,6 +805,9 @@ function detonate(world: World, g: Grenade): void { | ||
| 800 | 805 | at: world.time, |
| 801 | 806 | pos: [g.pos[0] + n[0] * BURST_SPREAD, g.pos[1] + n[1] * BURST_SPREAD, g.pos[2] + n[2] * BURST_SPREAD], |
| 802 | 807 | normal: n, |
| 808 | // A star of sparks around the blast, in whatever air is there: nothing | |
| 809 | // was struck at any of these five points, so nothing is marked by them. | |
| 810 | surface: false, | |
| 803 | 811 | }); |
| 804 | 812 | } |
| 805 | 813 | const thrower = world.players.find((p) => p.id === g.owner) ?? null; |
new above
offline