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

Merged opened this

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

Merged

vesper/cs:vesper/109-bullet-holes-that-stay-a-fixed-ring-of-d → main 1 commit fork

  • Merged. vesper/109-bullet-holes-that-stay-a-fixed-ring-of-d was merged into main as 1a46d4e.

Review

Sign in to review this change.
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
344344 that were actually drawn in an unclamped time count towards it, so a pause or a
345345 minute in another tab cannot talk the scale down.
346346
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
347359 What a frame costs is counted, not hoped for: one draw for the level, one per
348360 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.
357370
358371 The radar in the bottom-left corner is your view, not the room: rotated so
359372 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.
389402 `interpolate` (what is drawn between two ticks: a position lerped, a yaw
390403 turned the short way round the circle, and a teleport that snaps),
391404 `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),
392407 `spectate` (whose eyes you borrow when you are dead), `bodies` (the dead of
393408 this round, where they fell), `radar` (the blips the
394409 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";
2020 import { SHORT } from "./match.ts";
2121 import { MOVE, STEP_STRIDE } from "./physics.ts";
2222 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";
2327 import { createRenderer, LINE_CAPACITY, type Camera, type Renderer, type RendererHooks } from "./renderer.ts";
2428 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";
2632
2733 type Call = { name: string; args: unknown[] };
2834
@@ -65,6 +71,7 @@ function recorder(): {
6571 bufferData: log("bufferData"), bufferSubData: log("bufferSubData"),
6672 enableVertexAttribArray: log("enableVertexAttribArray"), vertexAttribPointer: log("vertexAttribPointer"),
6773 enable: log("enable"), disable: log("disable"), blendFunc: log("blendFunc"),
74 depthMask: log("depthMask"), depthFunc: log("depthFunc"),
6875 cullFace: log("cullFace"), clearColor: log("clearColor"),
6976 viewport: log("viewport"), clear: log("clear"), useProgram: log("useProgram"),
7077 activeTexture: log("activeTexture"), uniform1i: log("uniform1i"),
@@ -98,6 +105,13 @@ function recorder(): {
98105 return { canvas: canvas as unknown as HTMLCanvasElement, calls, fire };
99106 }
100107
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
101115 const CAMERA: Camera = { pos: [0, 0, 64], yaw: 0, pitch: 0, fov: 90 };
102116
103117 /** One frame, and the draws it made. */
@@ -190,7 +204,7 @@ function heldPlace(calls: Call[], kind: string): Float32Array | null {
190204
191205 /** Every run of lines drawn this frame: tracers, sparks and flashes alike. */
192206 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)
194208 .map((c) => ({ first: c.args[1] as number, count: c.args[2] as number }));
195209
196210 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",
660674 * pickups one per gun on the floor
661675 * grenades one per grenade in the air
662676 * bomb one while it is down (planted or dropped)
677 * decals one batched draw for every bullet hole in the level at once
663678 * lines one batched draw for the world's tracers, sparks and beam
664679 * flashes one more, additive, for everyone else's muzzles
665680 * 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",
669684 * `world.time`, so a live effect is one that is there at all. The rule refuses
670685 * a stale one rather than guessing which of the renderer's windows it fell out
671686 * 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.
672692 */
673693 function ruledDraws(world: World, view: Player): Record<string, number> {
674694 for (const effect of world.effects)
@@ -690,6 +710,7 @@ function ruledDraws(world: World, view: Player): Record<string, number> {
690710 pickups: world.pickups.length,
691711 grenades: world.grenades.length,
692712 bomb: down ? 1 : 0,
713 decals: world.effects.some((e) => e.kind === "impact" && e.surface) ? 1 : 0,
693714 lines: spark || world.bomb.state === "planted" ? 1 : 0,
694715 flashes: flashed ? 1 : 0,
695716 viewModel: held ? 1 : 0,
@@ -716,11 +737,12 @@ const passes = (calls: Call[], of: { scene: unknown; lines: unknown }): string[]
716737 * The passes the same rule predicts: the scene, then the world's lines if it
717738 * drew any, then the scene again for the view model's own projection and
718739 * 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.
720742 */
721743 function ruledPasses(terms: Record<string, number>): string[] {
722744 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");
724746 if ((terms.viewModel as number) > 0) list.push("scene");
725747 if ((terms.viewFlash as number) > 0) list.push("lines");
726748 return list;
@@ -736,7 +758,7 @@ test("a frame's draws are the world's, one apiece, and its passes are two progra
736758 const quiet = ruledDraws(world, world.me);
737759 expect(quiet).toEqual({
738760 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,
740762 });
741763 let frameCalls = record(renderer, calls, world);
742764 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
754776
755777 // And a loud one: bodies, guns on the floor, grenades in the air, a bomb
756778 // 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;
757786 for (let i = 0; i < 4; i += 1)
758787 addBody(world.bodies, { id: 30 + i, team: "t", pos: [i * 96, 0, 0], yaw: i * 40 }, world.boxes, world.time);
759788 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
762791 world.bomb.state = "planted";
763792 world.bomb.pos = [256, 256, 0];
764793 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 });
766795 world.effects.push({ kind: "blood", at: world.time, pos: [200, 0, 40] });
767796 const shooter = world.players.find((p) => p !== world.me && p.alive) as Player;
768797 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
793822 * fifth of the frame, which is the most of it the draws may have with the
794823 * simulation and the HUD still to pay for.
795824 *
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
797826 * not spare: it is what an unlucky round has in hand. A change that wants the
798827 * eighty-first draw batches something away instead of raising this line.
799828 */
@@ -822,10 +851,14 @@ test("the worst round this game can build stays inside the frame's draw budget",
822851 world.bomb.state = "planted";
823852 world.bomb.pos = [512, 512, 0];
824853 // 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;
826857 for (let i = 0; i < 40; i += 1) {
827858 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 });
829862 }
830863 for (let i = 0; i < 20; i += 1)
831864 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",
839872 expect(draws).toBeLessThanOrEqual(FRAME_BUDGET);
840873 // Said again as the sum it is, so a reader can see which term grew when this
841874 // 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.
843877 expect(terms).toEqual({
844878 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,
846880 });
847881
848882 // 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", () => {
9881022 expect(tip(0.5)).toBeGreaterThan(tip(0));
9891023 expect(tip(1)).toBeGreaterThan(tip(0.5));
9901024 });
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 @@
11 /**
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
56 * the frame loop except the numbers that change.
67 *
78 * Every one of those resources is made by `build`, and `build` can be run
@@ -13,6 +14,7 @@
1314 * all, and `webglcontextrestored` builds the lot a second time.
1415 */
1516
17 import { clear as clearDecals, decals, DECAL_FLOATS, mark, shade } from "./decals.ts";
1618 import { between, drawnAt, lerpPos, pose, type Pose } from "./interpolate.ts";
1719 import { TICK } from "./loop.ts";
1820 import { fallenMatrix, multiply, perspective, rad, viewMatrix, modelMatrix, type Vec3 } from "./math.ts";
@@ -257,6 +259,9 @@ type Gpu = {
257259 levelMap: MapDef | null;
258260 lineVao: WebGLVertexArrayObject;
259261 lineBuffer: WebGLBuffer | null;
262 /** The bullet holes, in the line program's own two attributes. */
263 decalVao: WebGLVertexArrayObject;
264 decalBuffer: WebGLBuffer | null;
260265 };
261266
262267 function build(gl: WebGL2RenderingContext): Gpu {
@@ -289,9 +294,27 @@ function build(gl: WebGL2RenderingContext): Gpu {
289294 gl.vertexAttribPointer(1, 3, gl.FLOAT, false, 24, 12);
290295 gl.bindVertexArray(null);
291296
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
292311 // Context state, not a resource, but it is lost with the context all the
293312 // same: a rebuilt renderer that forgot this would draw with no depth test.
294313 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);
295318 gl.enable(gl.CULL_FACE);
296319 gl.cullFace(gl.BACK);
297320 gl.clearColor(SKY[0], SKY[1], SKY[2], 1);
@@ -319,6 +342,8 @@ function build(gl: WebGL2RenderingContext): Gpu {
319342 levelMap: null,
320343 lineVao,
321344 lineBuffer,
345 decalVao,
346 decalBuffer,
322347 };
323348 }
324349
@@ -423,6 +448,14 @@ export function createRenderer(canvas: HTMLCanvasElement, hooks: RendererHooks =
423448 const gait: Stride = neutral();
424449 const held: Vec3 = [0, 0, 0];
425450
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
426459 let bobPhase = 0;
427460 // The gun the hands last had, and when they were given it: a change of
428461 // 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 =
556589 gl.drawElements(gl.TRIANGLES, bomb.count, gl.UNSIGNED_INT, 0);
557590 }
558591
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
559621 // --- tracers and sparks
560622 let n = 0;
561623 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 =
663725 star(mx, my, mz, rightX, rightY, 0, upX, upY, upZ, FLASH_WORLD * flash.size, effect.at, bright);
664726 }
665727
666 if (n > 0) {
728 if (n > 0 || holeVertices > 0) {
667729 gl.useProgram(gpu.lines);
668730 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) {
669750 gl.bindVertexArray(gpu.lineVao);
670751 gl.bindBuffer(gl.ARRAY_BUFFER, gpu.lineBuffer);
671752 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
9595
9696 export type Effect =
9797 | { 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 }
99102 | { kind: "blood"; at: number; pos: Vec3 }
100103 // A trigger pulled: where the gun was, what it was, whether it was the
101104 // 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 {
612615 // A crate the round went through is marked where it went in, as well as
613616 // wherever the round finally stopped.
614617 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 });
616621
617622 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 });
619624 } else if (hit.kind === "actor") {
620625 const victim = hit.actor as Player;
621626 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 {
800805 at: world.time,
801806 pos: [g.pos[0] + n[0] * BURST_SPREAD, g.pos[1] + n[1] * BURST_SPREAD, g.pos[2] + n[2] * BURST_SPREAD],
802807 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,
803811 });
804812 }
805813 const thrower = world.players.find((p) => p.id === g.owner) ?? null;

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs