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

forked from trevato/cs · proposes

Assets become engine: a mesh builder, a texture painter and a sound device; the game keeps its recipes #8

Merged opened this

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

Merged

vesper/cs-engine:vesper/4-assets-become-engine-a-mesh-builder-a-te → main 4 commits fork

  • Merged. vesper/4-assets-become-engine-a-mesh-builder-a-te was merged into main as ace874d.

Review

Sign in to review this change.
16 files changed +867 −306
README.md +12 −6
@@ -22,7 +22,10 @@ FORGE_APP_DATA=./data bun run src/tty.ts # the terminal interface
2222 a 1:55 round with a 40-second bomb, plant and defuse timers, kill rewards and
2323 loss bonuses. The leaderboard is a real SQLite table under `FORGE_APP_DATA`.
2424 - **Generated, not licensed.** The map (`de_forge`), the textures, the player and
25 weapon models and every sound are produced in code at start-up. There are no
25 weapon models and every sound are produced in code at start-up, by three
26 generic tools in the engine — a box mesh builder, an atlas painter over hashed
27 value noise, and a two-voice sound device — driven by this game's recipes in
28 `src/client/{models,materials,audio}.ts`. There are no
2629 asset files and nothing from the real game is used — the numbers follow CS's
2730 published shape so the habits transfer, and the rest is original.
2831 - **Learned, and measured.** The bots do not aim by rule. A 32-input, two-layer
@@ -50,15 +53,18 @@ the rest.
5053 ## Shape
5154
5255 - `src/engine/` — the part a second game could use, and the rule is that it never
53 imports the game: `math` (vectors, matrices, angles, a seeded rng) and `trace`
54 (rays against boxes, and the `Box` they are traced against). `boundary.test.ts`
56 imports the game: `math` (vectors, matrices, angles, a seeded rng), `trace`
57 (rays against boxes, and the `Box` they are traced against), `mesh` (a box
58 builder), `texture` (hashed value noise, fbm and an atlas painter) and `sound`
59 (a lazy `AudioContext` with noise bursts and tones). `boundary.test.ts`
5560 reads the imports of every file in here and fails if one of them points at
5661 `src/client/`, at `src/rl/` or at a package.
5762 - `src/client/` — the game, in modules named for one thing each: `map`,
5863 `physics`, `weapons`, `combat`, `match` (the rules), `world` (the simulation),
59 `bots`, `observe`, `action`, `policy`, `weights`, `models`, `textures`,
60 `renderer`, `hud`, `input`, `audio`, `main`. Everything except the last five is
61 headless and tested.
64 `bots`, `observe`, `action`, `policy`, `weights`, `models`, `materials`,
65 `renderer`, `hud`, `input`, `audio`, `main`. The last five aside, everything is
66 headless and tested; `models`, `materials` and `audio` are this game's recipes
67 for the engine's three asset tools.
6268 - `src/rl/` — training, which the game never imports: `arena` is the environment
6369 (a real duel on de_forge with the round and the economy taken out) and `ppo` is
6470 the algorithm, with its backward pass checked against finite differences.
src/client/audio.test.ts added +78
@@ -0,0 +1,78 @@
1 import { expect, test } from "bun:test";
2 import { createAudio, loudness, shotTone } from "./audio.ts";
3 import type { Burst, SoundDevice, Tone } from "../engine/sound.ts";
4
5 /** A device that plays nothing and writes down everything. */
6 function recorder(): SoundDevice & { bursts: Burst[]; tones: Tone[]; resumes: number } {
7 const device = {
8 muted: false,
9 bursts: [] as Burst[],
10 tones: [] as Tone[],
11 resumes: 0,
12 resume() { device.resumes += 1; },
13 noiseBurst(burst: Burst) { if (!device.muted) device.bursts.push(burst); },
14 tone(tone: Tone) { if (!device.muted) device.tones.push(tone); },
15 };
16 return device;
17 }
18
19 test("a shot is a burst plus a thump, coloured by the weapon", () => {
20 const device = recorder();
21 const audio = createAudio(device);
22 audio.shot("ak47", [0, 0, 0], [0, 0, 0]);
23 expect(device.bursts.length).toBe(1);
24 expect(device.tones.length).toBe(1);
25 const burst = device.bursts[0] as Burst;
26 const thump = device.tones[0] as Tone;
27 expect(burst.frequency).toBe(shotTone("ak47").cut);
28 expect(burst.length).toBe(shotTone("ak47").length);
29 expect(thump.slideTo).toBeLessThan(thump.frequency);
30
31 audio.shot("awp", [0, 0, 0], [0, 0, 0]);
32 const sniper = device.bursts[1] as Burst;
33 // The sniper is lower and rings longer than the rifle.
34 expect(sniper.frequency).toBeLessThan(burst.frequency);
35 expect(sniper.length).toBeGreaterThan(burst.length);
36 });
37
38 test("a shot across the map is quieter than one at your feet", () => {
39 const device = recorder();
40 const audio = createAudio(device);
41 audio.shot("ak47", [0, 0, 0], [0, 0, 0]);
42 audio.shot("ak47", [4000, 0, 0], [0, 0, 0]);
43 const near = device.bursts[0] as Burst, far = device.bursts[1] as Burst;
44 expect(far.gain).toBeLessThan(near.gain / 2);
45 expect(near.gain).toBeCloseTo(0.35, 6);
46 expect(loudness([100, 0, 0], [0, 0, 0])).toBe(1);
47 expect(loudness([4000, 0, 0], [0, 0, 0])).toBeLessThan(0.3);
48 });
49
50 test("a headshot beeps higher than a body hit", () => {
51 const device = recorder();
52 const audio = createAudio(device);
53 audio.hit(true);
54 audio.hit(false);
55 const head = device.tones[0] as Tone, body = device.tones[1] as Tone;
56 expect(head.frequency).toBeGreaterThan(body.frequency);
57 expect(head.type).toBe("square");
58 });
59
60 test("muting the voice mutes the device under it", () => {
61 const device = recorder();
62 const audio = createAudio(device);
63 audio.muted = true;
64 expect(device.muted).toBe(true);
65 audio.shot("ak47", [0, 0, 0], [0, 0, 0]);
66 audio.beep(700);
67 expect(device.bursts).toEqual([]);
68 expect(device.tones).toEqual([]);
69 audio.muted = false;
70 audio.beep(700);
71 expect(device.tones.length).toBe(1);
72 });
73
74 test("resuming the voice resumes the device", () => {
75 const device = recorder();
76 createAudio(device).resume();
77 expect(device.resumes).toBe(1);
78 });
src/client/audio.ts +39 −65
@@ -1,10 +1,12 @@
11 /**
2 * Sound, synthesised. A gunshot is a noise burst through a resonant filter
3 * plus a low thump; the rest are short tones. Nothing is downloaded, and the
4 * context is only created once the person has clicked something.
2 * The game's voice. A gunshot is a noise burst through a resonant filter plus a
3 * low thump, and each weapon kind gets its own colour, length and thump; a hit
4 * and a buy are short tones. Nothing is downloaded, and the engine's device
5 * only opens a context once the person has clicked something.
56 */
67
78 import { dist, type Vec3 } from "../engine/math.ts";
9 import { attenuate, createSoundDevice, type SoundDevice } from "../engine/sound.ts";
810 import { WEAPONS, type WeaponId } from "./weapons.ts";
911
1012 export type Audio = {
@@ -23,77 +25,49 @@ const TONE: Record<string, { cut: number; thump: number; length: number }> = {
2325 knife: { cut: 4200, thump: 400, length: 0.08 },
2426 };
2527
26 export function createAudio(): Audio {
27 let ctx: AudioContext | null = null;
28 let noise: AudioBuffer | null = null;
28 /** Full volume within 120 units, and audible across the map after that. */
29 const EARSHOT = 900;
30 const CLOSE = 120;
2931
30 const ensure = (): AudioContext | null => {
31 if (ctx === null) {
32 const Ctor = window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
33 if (Ctor === undefined) return null;
34 ctx = new Ctor();
35 noise = ctx.createBuffer(1, ctx.sampleRate * 0.5, ctx.sampleRate);
36 const data = noise.getChannelData(0);
37 for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1;
38 }
39 if (ctx.state === "suspended") void ctx.resume();
40 return ctx;
41 };
32 /** The shot a weapon makes: its filter colour, its thump and how long it rings. */
33 export const shotTone = (weapon: WeaponId): { cut: number; thump: number; length: number } =>
34 TONE[WEAPONS[weapon]?.kind ?? "rifle"] ?? (TONE.rifle as { cut: number; thump: number; length: number });
4235
36 /** How loud a shot fired at `at` is to someone standing at `listener`. */
37 export const loudness = (at: Vec3, listener: Vec3): number =>
38 attenuate(dist(at, listener), EARSHOT, CLOSE);
39
40 export function createAudio(device: SoundDevice = createSoundDevice()): Audio {
4341 const audio: Audio = {
44 muted: false,
45 resume: () => void ensure(),
42 get muted() {
43 return device.muted;
44 },
45 set muted(value: boolean) {
46 device.muted = value;
47 },
48 resume: () => device.resume(),
4649 shot(weapon, at, listener) {
47 if (audio.muted) return;
48 const context = ensure();
49 if (context === null || noise === null) return;
50 const kind = WEAPONS[weapon]?.kind ?? "rifle";
51 const tone = TONE[kind] ?? TONE.rifle;
52 const d = dist(at, listener);
53 const volume = Math.min(1, 900 / Math.max(120, d));
54 const now = context.currentTime;
55
56 const source = context.createBufferSource();
57 source.buffer = noise;
58 source.playbackRate.value = 1 + Math.random() * 0.15;
59 const filter = context.createBiquadFilter();
60 filter.type = "bandpass";
61 filter.frequency.value = tone.cut;
62 filter.Q.value = 0.9;
63 const gain = context.createGain();
64 gain.gain.setValueAtTime(0.35 * volume, now);
65 gain.gain.exponentialRampToValueAtTime(0.0008, now + tone.length);
66 source.connect(filter).connect(gain).connect(context.destination);
67 source.start(now);
68 source.stop(now + tone.length);
69
70 const thump = context.createOscillator();
71 thump.frequency.setValueAtTime(tone.thump, now);
72 thump.frequency.exponentialRampToValueAtTime(tone.thump * 0.4, now + tone.length);
73 const thumpGain = context.createGain();
74 thumpGain.gain.setValueAtTime(0.3 * volume, now);
75 thumpGain.gain.exponentialRampToValueAtTime(0.0008, now + tone.length);
76 thump.connect(thumpGain).connect(context.destination);
77 thump.start(now);
78 thump.stop(now + tone.length);
50 const tone = shotTone(weapon);
51 const volume = loudness(at, listener);
52 device.noiseBurst({
53 frequency: tone.cut,
54 gain: 0.35 * volume,
55 length: tone.length,
56 q: 0.9,
57 rate: 1 + Math.random() * 0.15,
58 });
59 device.tone({
60 frequency: tone.thump,
61 slideTo: tone.thump * 0.4,
62 gain: 0.3 * volume,
63 length: tone.length,
64 });
7965 },
8066 hit(headshot) {
8167 audio.beep(headshot ? 1400 : 900, 0.08);
8268 },
8369 beep(pitch, gain = 0.06) {
84 if (audio.muted) return;
85 const context = ensure();
86 if (context === null) return;
87 const now = context.currentTime;
88 const osc = context.createOscillator();
89 osc.type = "square";
90 osc.frequency.value = pitch;
91 const volume = context.createGain();
92 volume.gain.setValueAtTime(gain, now);
93 volume.gain.exponentialRampToValueAtTime(0.0005, now + 0.09);
94 osc.connect(volume).connect(context.destination);
95 osc.start(now);
96 osc.stop(now + 0.1);
70 device.tone({ frequency: pitch, gain, length: 0.09, type: "square", floor: 0.0005, tail: 0.01 });
9771 },
9872 };
9973 return audio;
src/client/materials.test.ts added +33
@@ -0,0 +1,33 @@
1 import { expect, test } from "bun:test";
2 import { LAYERS, MATERIALS, TEX_SIZE, materialTextures } from "./materials.ts";
3
4 /**
5 * The atlas the game shipped before the painter moved into the engine. If a
6 * refactor, a reordered list or a changed constant repaints the world, this
7 * digest says so before anyone has to notice that the sand went green.
8 */
9 const ATLAS_SHA256 = "d063c0839f70367067065f31deadbfe291ba95df0973f7d6602e3150846d8b21";
10
11 test("the atlas is byte-for-byte the one the game has always had", () => {
12 const bytes = materialTextures();
13 expect(bytes.length).toBe(TEX_SIZE * TEX_SIZE * 4 * LAYERS);
14 expect(new Bun.CryptoHasher("sha256").update(bytes).digest("hex")).toBe(ATLAS_SHA256);
15 });
16
17 test("LAYERS is the length of the list, counted once", () => {
18 expect(LAYERS).toBe(MATERIALS.length);
19 expect(LAYERS).toBe(9);
20 });
21
22 test("every material paints something visible, and no two are the same", () => {
23 const swatches = new Set<string>();
24 for (const shade of MATERIALS) {
25 const [r, g, b] = shade(0.3, 0.7);
26 for (const channel of [r, g, b]) {
27 expect(Number.isFinite(channel)).toBe(true);
28 expect(channel).toBeGreaterThan(0);
29 }
30 swatches.add(`${Math.round(r)},${Math.round(g)},${Math.round(b)}`);
31 }
32 expect(swatches.size).toBe(MATERIALS.length);
33 });
src/client/materials.ts added +66
@@ -0,0 +1,66 @@
1 /**
2 * The nine surfaces this game is made of, as shaders for the engine's texture
3 * painter: sand, plaster, crate, site paint, metal, skin, fatigues, gear and a
4 * warning light. Nothing is downloaded; the map and the models refer to a
5 * material by its index in this list, which is the array-texture layer it is
6 * painted into.
7 */
8
9 import { fbm, hash, paintAtlas, type Shader } from "../engine/texture.ts";
10
11 export const TEX_SIZE = 128;
12
13 const tint = (base: [number, number, number], k: number): [number, number, number] =>
14 [base[0] * k, base[1] * k, base[2] * k];
15
16 /** Material 0..8, in the order the map and the models refer to them. */
17 export const MATERIALS: Shader[] = [
18 // 0 sand floor
19 (u, v) => {
20 const n = fbm(u * 6, v * 6, 6, 1);
21 const grit = hash(Math.floor(u * 128), Math.floor(v * 128), 9) * 0.12;
22 return tint([196, 174, 132], 0.8 + n * 0.35 + grit);
23 },
24 // 1 plaster wall, with a block course every quarter
25 (u, v) => {
26 const n = fbm(u * 8, v * 8, 8, 2);
27 const row = Math.floor(v * 4);
28 const line = Math.abs(v * 4 - Math.round(v * 4)) < 0.03 || Math.abs(((u + (row % 2) * 0.5) * 4) % 1 - 0.5) < 0.02;
29 return tint([176, 163, 140], (0.82 + n * 0.3) * (line ? 0.78 : 1));
30 },
31 // 2 wooden crate, planks across and a border
32 (u, v) => {
33 const grain = fbm(u * 3, v * 24, 24, 3);
34 const plank = Math.abs((v * 4) % 1 - 0.5) < 0.04 ? 0.7 : 1;
35 const edge = u < 0.06 || u > 0.94 || v < 0.06 || v > 0.94 ? 0.82 : 1;
36 return tint([158, 112, 60], (0.78 + grain * 0.4) * plank * edge);
37 },
38 // 3 painted site floor
39 (u, v) => {
40 const n = fbm(u * 6, v * 6, 6, 4);
41 const stripe = ((u + v) * 6) % 1 < 0.35 ? 1.12 : 0.92;
42 return tint([150, 128, 92], (0.85 + n * 0.25) * stripe);
43 },
44 // 4 metal
45 (u, v) => {
46 const brushed = fbm(u * 40, v * 3, 40, 5);
47 return tint([132, 138, 146], 0.8 + brushed * 0.4);
48 },
49 // 5 skin
50 (u, v) => tint([196, 152, 118], 0.9 + fbm(u * 8, v * 8, 8, 6) * 0.2),
51 // 6 fatigues
52 (u, v) => {
53 const n = fbm(u * 10, v * 10, 10, 7);
54 return tint([120, 116, 96], 0.75 + n * 0.5);
55 },
56 // 7 dark gear
57 (u, v) => tint([58, 62, 70], 0.8 + fbm(u * 12, v * 12, 12, 8) * 0.45),
58 // 8 warning light
59 (u, v) => tint([220, 60, 48], 0.8 + Math.abs(Math.sin((u + v) * 12)) * 0.4),
60 ];
61
62 /** How many layers the array texture has: one per material, counted once. */
63 export const LAYERS = MATERIALS.length;
64
65 /** All nine layers, tightly packed RGBA, ready for texImage3D. */
66 export const materialTextures = (): Uint8Array => paintAtlas(MATERIALS, TEX_SIZE);
src/client/models.ts +3 −69
@@ -1,70 +1,11 @@
11 /**
22 * Every model in the game is boxes, generated here. There are no asset files:
3 * the level, the players, the guns and the bomb are all built from the same
4 * six-quad primitive, which keeps the whole game one download.
3 * the level, the players, the guns and the bomb are all recipes for the
4 * engine's box builder, which keeps the whole game one download.
55 */
66
7 import { addBox, builder, finish, type Builder, type Mesh } from "../engine/mesh.ts";
78 import type { Box } from "./map.ts";
8 import type { Vec3 } from "../engine/math.ts";
9
10 export type Mesh = {
11 position: Float32Array;
12 normal: Float32Array;
13 uv: Float32Array;
14 layer: Float32Array;
15 index: Uint32Array;
16 };
17
18 type Builder = {
19 position: number[];
20 normal: number[];
21 uv: number[];
22 layer: number[];
23 index: number[];
24 };
25
26 export const builder = (): Builder => ({ position: [], normal: [], uv: [], layer: [], index: [] });
27
28 export function finish(b: Builder): Mesh {
29 return {
30 position: new Float32Array(b.position),
31 normal: new Float32Array(b.normal),
32 uv: new Float32Array(b.uv),
33 layer: new Float32Array(b.layer),
34 index: new Uint32Array(b.index),
35 };
36 }
37
38 const FACES: [Vec3, number, number, number][] = [
39 // normal, axis of the face, then the two axes across it
40 [[1, 0, 0], 0, 1, 2],
41 [[-1, 0, 0], 0, 1, 2],
42 [[0, 1, 0], 1, 0, 2],
43 [[0, -1, 0], 1, 0, 2],
44 [[0, 0, 1], 2, 0, 1],
45 [[0, 0, -1], 2, 0, 1],
46 ];
47
48 /** Six quads with the texture tiling every `scale` units in world space. */
49 export function addBox(b: Builder, min: Vec3, max: Vec3, layer: number, scale = 128): void {
50 for (const [normal, axis, u, v] of FACES) {
51 const base = b.position.length / 3;
52 const at = (normal[axis] as number) > 0 ? max : min;
53 for (const [du, dv] of [[0, 0], [1, 0], [1, 1], [0, 1]]) {
54 const p: Vec3 = [0, 0, 0];
55 p[axis] = at[axis] as number;
56 p[u] = (du === 0 ? min[u] : max[u]) as number;
57 p[v] = (dv === 0 ? min[v] : max[v]) as number;
58 b.position.push(p[0], p[1], p[2]);
59 b.normal.push(normal[0], normal[1], normal[2]);
60 b.uv.push((p[u] as number) / scale, (p[v] as number) / scale);
61 b.layer.push(layer);
62 }
63 // Wind the two triangles so the outside faces out.
64 if ((normal[axis] as number) > 0) b.index.push(base, base + 1, base + 2, base, base + 2, base + 3);
65 else b.index.push(base, base + 2, base + 1, base, base + 3, base + 2);
66 }
67 }
689
6910 /** The level, as one buffer. Fifty-odd boxes, three hundred quads, one draw. */
7011 export function levelMesh(boxes: Box[]): Mesh {
@@ -122,10 +63,3 @@ export function viewModelMesh(kind: string): Mesh {
12263 if (kind === "rifle" || kind === "smg") cube(b, 0.55, -0.66, 0.35, 0.2, 0.26, 0.8, GRIP); // stock
12364 return finish(b);
12465 }
125
126 /** A unit quad on the XY plane, for tracers, sprites and decals. */
127 export function quadMesh(): Mesh {
128 const b = builder();
129 addBox(b, [-0.5, -0.5, 0], [0.5, 0.5, 0], 0, 1);
130 return finish(b);
131 }
src/client/renderer.ts +3 −2
@@ -6,8 +6,9 @@
66 */
77
88 import { multiply, perspective, viewMatrix, modelMatrix, type Vec3 } from "../engine/math.ts";
9 import { bombMesh, levelMesh, playerMesh, viewModelMesh, type Mesh } from "./models.ts";
10 import { LAYERS, TEX_SIZE, materialTextures } from "./textures.ts";
9 import type { Mesh } from "../engine/mesh.ts";
10 import { bombMesh, levelMesh, playerMesh, viewModelMesh } from "./models.ts";
11 import { LAYERS, TEX_SIZE, materialTextures } from "./materials.ts";
1112 import type { World } from "./world.ts";
1213 import { hullHeight } from "./physics.ts";
1314 import { WEAPONS } from "./weapons.ts";
src/client/textures.ts deleted −106
@@ -1,106 +0,0 @@
1 /**
2 * Textures, generated. One 128×128 layer per material, written straight into
3 * the bytes a WebGL2 array texture wants — no images to download, no atlas
4 * bleeding, and the whole surface set costs about a millisecond to make.
5 */
6
7 export const TEX_SIZE = 128;
8 export const LAYERS = 9;
9
10 const hash = (x: number, y: number, seed: number): number => {
11 let h = (x * 374761393 + y * 668265263 + seed * 1274126177) | 0;
12 h = (h ^ (h >>> 13)) * 1274126177;
13 return ((h ^ (h >>> 16)) >>> 0) / 4294967295;
14 };
15
16 /** Value noise that wraps at `period`, so a tiled surface has no seam. */
17 function noise(x: number, y: number, period: number, seed: number): number {
18 const xi = Math.floor(x), yi = Math.floor(y);
19 const xf = x - xi, yf = y - yi;
20 const u = xf * xf * (3 - 2 * xf), v = yf * yf * (3 - 2 * yf);
21 const at = (i: number, j: number): number =>
22 hash(((i % period) + period) % period, ((j % period) + period) % period, seed);
23 const a = at(xi, yi), b = at(xi + 1, yi), c = at(xi, yi + 1), d = at(xi + 1, yi + 1);
24 return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v;
25 }
26
27 const fbm = (x: number, y: number, period: number, seed: number, octaves = 4): number => {
28 let sum = 0, amp = 0.5, freq = 1;
29 for (let o = 0; o < octaves; o++) {
30 sum += noise(x * freq, y * freq, period * freq, seed + o) * amp;
31 amp *= 0.5;
32 freq *= 2;
33 }
34 return sum;
35 };
36
37 type Shader = (u: number, v: number) => [number, number, number];
38
39 const tint = (base: [number, number, number], k: number): [number, number, number] =>
40 [base[0] * k, base[1] * k, base[2] * k];
41
42 /** Material 0..8, in the order the map and the models refer to them. */
43 const SHADERS: Shader[] = [
44 // 0 sand floor
45 (u, v) => {
46 const n = fbm(u * 6, v * 6, 6, 1);
47 const grit = hash(Math.floor(u * 128), Math.floor(v * 128), 9) * 0.12;
48 return tint([196, 174, 132], 0.8 + n * 0.35 + grit);
49 },
50 // 1 plaster wall, with a block course every quarter
51 (u, v) => {
52 const n = fbm(u * 8, v * 8, 8, 2);
53 const row = Math.floor(v * 4);
54 const line = Math.abs(v * 4 - Math.round(v * 4)) < 0.03 || Math.abs(((u + (row % 2) * 0.5) * 4) % 1 - 0.5) < 0.02;
55 return tint([176, 163, 140], (0.82 + n * 0.3) * (line ? 0.78 : 1));
56 },
57 // 2 wooden crate, planks across and a border
58 (u, v) => {
59 const grain = fbm(u * 3, v * 24, 24, 3);
60 const plank = Math.abs((v * 4) % 1 - 0.5) < 0.04 ? 0.7 : 1;
61 const edge = u < 0.06 || u > 0.94 || v < 0.06 || v > 0.94 ? 0.82 : 1;
62 return tint([158, 112, 60], (0.78 + grain * 0.4) * plank * edge);
63 },
64 // 3 painted site floor
65 (u, v) => {
66 const n = fbm(u * 6, v * 6, 6, 4);
67 const stripe = ((u + v) * 6) % 1 < 0.35 ? 1.12 : 0.92;
68 return tint([150, 128, 92], (0.85 + n * 0.25) * stripe);
69 },
70 // 4 metal
71 (u, v) => {
72 const brushed = fbm(u * 40, v * 3, 40, 5);
73 return tint([132, 138, 146], 0.8 + brushed * 0.4);
74 },
75 // 5 skin
76 (u, v) => tint([196, 152, 118], 0.9 + fbm(u * 8, v * 8, 8, 6) * 0.2),
77 // 6 fatigues
78 (u, v) => {
79 const n = fbm(u * 10, v * 10, 10, 7);
80 return tint([120, 116, 96], 0.75 + n * 0.5);
81 },
82 // 7 dark gear
83 (u, v) => tint([58, 62, 70], 0.8 + fbm(u * 12, v * 12, 12, 8) * 0.45),
84 // 8 warning light
85 (u, v) => tint([220, 60, 48], 0.8 + Math.abs(Math.sin((u + v) * 12)) * 0.4),
86 ];
87
88 /** All nine layers, tightly packed RGBA, ready for texSubImage3D. */
89 export function materialTextures(): Uint8Array {
90 const size = TEX_SIZE;
91 const out = new Uint8Array(size * size * 4 * LAYERS);
92 for (let layer = 0; layer < LAYERS; layer++) {
93 const shade = SHADERS[layer] ?? SHADERS[1];
94 for (let y = 0; y < size; y++) {
95 for (let x = 0; x < size; x++) {
96 const [r, g, b] = shade(x / size, y / size);
97 const i = (layer * size * size + y * size + x) * 4;
98 out[i] = Math.max(0, Math.min(255, r));
99 out[i + 1] = Math.max(0, Math.min(255, g));
100 out[i + 2] = Math.max(0, Math.min(255, b));
101 out[i + 3] = 255;
102 }
103 }
104 }
105 return out;
106 }
src/engine/mesh.test.ts added +81
@@ -0,0 +1,81 @@
1 import { expect, test } from "bun:test";
2 import { addBox, builder, finish, quadMesh, type Mesh } from "./mesh.ts";
3 import { cross, dot, normalize, sub, type Vec3 } from "./math.ts";
4
5 const at = (a: Float32Array, i: number): Vec3 =>
6 [a[i * 3] as number, a[i * 3 + 1] as number, a[i * 3 + 2] as number];
7 const vertex = (mesh: Mesh, i: number): Vec3 => at(mesh.position, i);
8
9 const box = (min: Vec3, max: Vec3, layer = 3, scale = 128): Mesh => {
10 const b = builder();
11 addBox(b, min, max, layer, scale);
12 return finish(b);
13 };
14
15 test("a box is six quads: 24 vertices, 12 triangles, one layer", () => {
16 const mesh = box([0, 0, 0], [64, 64, 64], 3);
17 expect(mesh.position.length).toBe(24 * 3);
18 expect(mesh.normal.length).toBe(24 * 3);
19 expect(mesh.uv.length).toBe(24 * 2);
20 expect(mesh.index.length).toBe(12 * 3);
21 expect([...mesh.layer]).toEqual(Array(24).fill(3));
22 expect(Math.max(...mesh.index)).toBe(23);
23 });
24
25 test("every triangle winds so its face points out of the box", () => {
26 const min: Vec3 = [-10, -20, -30], max: Vec3 = [40, 50, 60];
27 const mesh = box(min, max);
28 const centre: Vec3 = [(min[0] + max[0]) / 2, (min[1] + max[1]) / 2, (min[2] + max[2]) / 2];
29 for (let t = 0; t < mesh.index.length / 3; t++) {
30 const a = vertex(mesh, mesh.index[t * 3] as number);
31 const b = vertex(mesh, mesh.index[t * 3 + 1] as number);
32 const c = vertex(mesh, mesh.index[t * 3 + 2] as number);
33 const face = normalize(cross(sub(b, a), sub(c, a)));
34 // The winding normal agrees with the stored normal, and both lead away
35 // from the middle of the box.
36 const stored = at(mesh.normal, mesh.index[t * 3] as number);
37 expect(dot(face, stored)).toBeCloseTo(1, 5);
38 expect(dot(face, sub(a, centre))).toBeGreaterThan(0);
39 }
40 });
41
42 test("uv tiles once every `scale` units, whatever the face", () => {
43 const mesh = box([0, 0, 0], [256, 128, 384], 0, 128);
44 const spans = new Set<string>();
45 for (let q = 0; q < 6; q++) {
46 const us: number[] = [], vs: number[] = [];
47 for (let i = q * 4; i < q * 4 + 4; i++) {
48 us.push(mesh.uv[i * 2] as number);
49 vs.push(mesh.uv[i * 2 + 1] as number);
50 }
51 spans.add(`${Math.max(...us) - Math.min(...us)}x${Math.max(...vs) - Math.min(...vs)}`);
52 }
53 // 256/128 = 2 across X, 1 across Y, 3 across Z: the three face pairs.
54 expect([...spans].sort()).toEqual(["1x3", "2x1", "2x3"]);
55
56 const half = box([0, 0, 0], [64, 64, 64], 0, 64);
57 expect(Math.max(...half.uv)).toBeCloseTo(1, 6);
58 const quarter = box([0, 0, 0], [64, 64, 64], 0, 256);
59 expect(Math.max(...quarter.uv)).toBeCloseTo(0.25, 6);
60 });
61
62 test("boxes accumulate in one builder with indices that stay in range", () => {
63 const b = builder();
64 addBox(b, [0, 0, 0], [1, 1, 1], 0);
65 addBox(b, [2, 2, 2], [3, 3, 3], 1);
66 const mesh = finish(b);
67 expect(mesh.position.length).toBe(48 * 3);
68 expect(Math.max(...mesh.index)).toBe(47);
69 expect(mesh.index.slice(36, 39)).toEqual(new Uint32Array([24, 25, 26]));
70 });
71
72 test("the quad is a unit square on the XY plane", () => {
73 const mesh = quadMesh();
74 for (let i = 0; i < mesh.position.length / 3; i++) {
75 const [x, y, z] = vertex(mesh, i);
76 expect(Math.abs(x)).toBeCloseTo(0.5, 6);
77 expect(Math.abs(y)).toBeCloseTo(0.5, 6);
78 expect(z).toBe(0);
79 }
80 expect(Math.max(...mesh.uv)).toBeCloseTo(0.5, 6);
81 });
src/engine/mesh.ts added +85
@@ -0,0 +1,85 @@
1 /**
2 * Meshes, built from boxes. One interleaved-by-attribute buffer set — position,
3 * normal, uv, layer and an index — grown as plain arrays and frozen into typed
4 * ones at the end. The only primitive is a box, because a box with a texture
5 * layer per face is enough to build a level, a player and a gun, and because
6 * six quads upload as one draw.
7 */
8
9 import type { Vec3 } from "./math.ts";
10
11 export type Mesh = {
12 position: Float32Array;
13 normal: Float32Array;
14 uv: Float32Array;
15 layer: Float32Array;
16 index: Uint32Array;
17 };
18
19 export type Builder = {
20 position: number[];
21 normal: number[];
22 uv: number[];
23 layer: number[];
24 index: number[];
25 };
26
27 export const builder = (): Builder => ({ position: [], normal: [], uv: [], layer: [], index: [] });
28
29 export function finish(b: Builder): Mesh {
30 return {
31 position: new Float32Array(b.position),
32 normal: new Float32Array(b.normal),
33 uv: new Float32Array(b.uv),
34 layer: new Float32Array(b.layer),
35 index: new Uint32Array(b.index),
36 };
37 }
38
39 const FACES: [Vec3, number, number, number][] = [
40 // normal, axis of the face, then the two axes across it
41 [[1, 0, 0], 0, 1, 2],
42 [[-1, 0, 0], 0, 1, 2],
43 [[0, 1, 0], 1, 0, 2],
44 [[0, -1, 0], 1, 0, 2],
45 [[0, 0, 1], 2, 0, 1],
46 [[0, 0, -1], 2, 0, 1],
47 ];
48
49 /**
50 * Is (axis, u, v) a right-handed frame? It is for the X and Z faces, whose
51 * across-axes come round in order, and is not for the Y faces, whose u is X and
52 * whose v is Z. The winding has to account for it or the north and south face
53 * of every box comes out inside-out and the back-face cull eats it.
54 */
55 const handed = (axis: number, u: number, v: number): number =>
56 (u - axis + 3) % 3 === 1 && (v - u + 3) % 3 === 1 ? 1 : -1;
57
58 /** Six quads with the texture tiling every `scale` units in world space. */
59 export function addBox(b: Builder, min: Vec3, max: Vec3, layer: number, scale = 128): void {
60 for (const [normal, axis, u, v] of FACES) {
61 const base = b.position.length / 3;
62 const at = (normal[axis] as number) > 0 ? max : min;
63 for (const [du, dv] of [[0, 0], [1, 0], [1, 1], [0, 1]]) {
64 const p: Vec3 = [0, 0, 0];
65 p[axis] = at[axis] as number;
66 p[u] = (du === 0 ? min[u] : max[u]) as number;
67 p[v] = (dv === 0 ? min[v] : max[v]) as number;
68 b.position.push(p[0], p[1], p[2]);
69 b.normal.push(normal[0], normal[1], normal[2]);
70 b.uv.push((p[u] as number) / scale, (p[v] as number) / scale);
71 b.layer.push(layer);
72 }
73 // Wind the two triangles so the outside faces out.
74 if ((normal[axis] as number) * handed(axis, u, v) > 0)
75 b.index.push(base, base + 1, base + 2, base, base + 2, base + 3);
76 else b.index.push(base, base + 2, base + 1, base, base + 3, base + 2);
77 }
78 }
79
80 /** A unit quad on the XY plane, for tracers, sprites and decals. */
81 export function quadMesh(): Mesh {
82 const b = builder();
83 addBox(b, [-0.5, -0.5, 0], [0.5, 0.5, 0], 0, 1);
84 return finish(b);
85 }
src/engine/sound.test.ts added +150
@@ -0,0 +1,150 @@
1 import { expect, test } from "bun:test";
2 import { attenuate, createSoundDevice, type Ctor } from "./sound.ts";
3
4 // --- the arithmetic -------------------------------------------------------
5
6 test("attenuation is full up close, falls off with distance, and never exceeds 1", () => {
7 expect(attenuate(0, 900, 120)).toBe(1);
8 expect(attenuate(120, 900, 120)).toBe(1);
9 expect(attenuate(900, 900, 120)).toBe(1);
10 expect(attenuate(1800, 900, 120)).toBeCloseTo(0.5, 12);
11 let last = 1;
12 for (let d = 900; d < 6000; d += 100) {
13 const now = attenuate(d, 900, 120);
14 expect(now).toBeLessThanOrEqual(last);
15 expect(now).toBeGreaterThan(0);
16 last = now;
17 }
18 expect(attenuate(6000, 900, 120)).toBeLessThan(0.2);
19 });
20
21 // --- the wiring, against a context that only writes down what it was asked --
22
23 type Event = { kind: string; at: number; value: number; time: number };
24
25 /** The smallest thing that answers like an AudioContext, and remembers. */
26 function fakeContext(): {
27 Ctor: Ctor; log: Event[]; buffers: Float32Array[]; contexts: number; resumes: number;
28 } {
29 const log: Event[] = [];
30 const buffers: Float32Array[] = [];
31 const state = { contexts: 0, resumes: 0 };
32 const param = (kind: string) => ({
33 value: 0,
34 setValueAtTime: (value: number, time: number) => log.push({ kind: `${kind}.set`, at: 0, value, time }),
35 exponentialRampToValueAtTime: (value: number, time: number) =>
36 log.push({ kind: `${kind}.ramp`, at: 0, value, time }),
37 });
38 const node = (kind: string) => ({
39 kind,
40 frequency: param(`${kind}.frequency`),
41 playbackRate: param(`${kind}.rate`),
42 gain: param(`${kind}.gain`),
43 Q: param(`${kind}.q`),
44 type: "",
45 buffer: null as unknown,
46 connect(next: unknown) {
47 log.push({ kind: `${kind}.connect`, at: 0, value: 0, time: 0 });
48 return next;
49 },
50 start: (time: number) => log.push({ kind: `${kind}.start`, at: 0, value: 0, time }),
51 stop: (time: number) => log.push({ kind: `${kind}.stop`, at: 0, value: 0, time }),
52 });
53 class Fake {
54 sampleRate = 48000;
55 currentTime = 2;
56 state = "suspended";
57 destination = { kind: "destination" };
58 constructor() {
59 state.contexts += 1;
60 }
61 resume(): void {
62 state.resumes += 1;
63 this.state = "running";
64 }
65 createBuffer(_channels: number, length: number) {
66 const data = new Float32Array(length);
67 buffers.push(data);
68 return { length, getChannelData: () => data };
69 }
70 createBufferSource() { return node("source"); }
71 createBiquadFilter() { return node("filter"); }
72 createGain() { return node("gain"); }
73 createOscillator() { return node("osc"); }
74 }
75 return {
76 Ctor: Fake as unknown as Ctor,
77 log,
78 buffers,
79 get contexts() { return state.contexts; },
80 get resumes() { return state.resumes; },
81 };
82 }
83
84 const find = (log: Event[], kind: string): Event | undefined => log.find((e) => e.kind === kind);
85
86 test("a device with no AudioContext at all stays quiet instead of throwing", () => {
87 const device = createSoundDevice(undefined);
88 device.resume();
89 device.noiseBurst({ frequency: 1500, gain: 0.35, length: 0.2 });
90 device.tone({ frequency: 440, gain: 0.06, length: 0.09 });
91 expect(device.muted).toBe(false);
92 });
93
94 test("the context is opened once, lazily, and resumed when it is suspended", () => {
95 const fake = fakeContext();
96 const device = createSoundDevice(fake.Ctor);
97 expect(fake.contexts).toBe(0);
98 device.tone({ frequency: 440, gain: 0.06, length: 0.09 });
99 device.tone({ frequency: 880, gain: 0.06, length: 0.09 });
100 expect(fake.contexts).toBe(1);
101 expect(fake.resumes).toBe(1); // the second call found it already running
102 });
103
104 test("the noise buffer is half a second of noise, filled once and reused", () => {
105 const fake = fakeContext();
106 const device = createSoundDevice(fake.Ctor);
107 device.noiseBurst({ frequency: 1500, gain: 0.35, length: 0.2, rate: 1.1 });
108 device.noiseBurst({ frequency: 1500, gain: 0.35, length: 0.2 });
109 expect(fake.buffers.length).toBe(1);
110 const data = fake.buffers[0] as Float32Array;
111 expect(data.length).toBe(48000 * 0.5);
112 expect(Math.max(...data)).toBeGreaterThan(0.9);
113 expect(Math.min(...data)).toBeLessThan(-0.9);
114 expect(find(fake.log, "source.start")?.time).toBe(2);
115 expect(find(fake.log, "source.stop")?.time).toBeCloseTo(2.2, 6);
116 });
117
118 test("a burst decays from the gain asked to silence across its length", () => {
119 const fake = fakeContext();
120 createSoundDevice(fake.Ctor).noiseBurst({ frequency: 1500, gain: 0.35, length: 0.24 });
121 expect(find(fake.log, "gain.gain.set")?.value).toBeCloseTo(0.35, 6);
122 const end = find(fake.log, "gain.gain.ramp");
123 expect(end?.value).toBeGreaterThan(0); // exponential ramps cannot reach zero
124 expect(end?.time).toBeCloseTo(2.24, 6);
125 });
126
127 test("a tone slides only when asked, and holds its node open for the tail", () => {
128 const plain = fakeContext();
129 createSoundDevice(plain.Ctor).tone({ frequency: 440, gain: 0.06, length: 0.09, tail: 0.01 });
130 expect(find(plain.log, "osc.frequency.ramp")).toBeUndefined();
131 expect(find(plain.log, "osc.stop")?.time).toBeCloseTo(2.1, 6);
132
133 const slid = fakeContext();
134 createSoundDevice(slid.Ctor).tone({ frequency: 150, slideTo: 60, gain: 0.3, length: 0.16 });
135 expect(find(slid.log, "osc.frequency.set")?.value).toBe(150);
136 expect(find(slid.log, "osc.frequency.ramp")?.value).toBe(60);
137 expect(find(slid.log, "osc.stop")?.time).toBeCloseTo(2.16, 6);
138 });
139
140 test("a muted device builds no nodes at all", () => {
141 const fake = fakeContext();
142 const device = createSoundDevice(fake.Ctor);
143 device.muted = true;
144 device.noiseBurst({ frequency: 1500, gain: 0.35, length: 0.2 });
145 device.tone({ frequency: 440, gain: 0.06, length: 0.09 });
146 expect(fake.log).toEqual([]);
147 device.muted = false;
148 device.tone({ frequency: 440, gain: 0.06, length: 0.09 });
149 expect(fake.log.length).toBeGreaterThan(0);
150 });
src/engine/sound.ts added +129
@@ -0,0 +1,129 @@
1 /**
2 * Sound, synthesised. Two voices are enough for a game: a burst of noise
3 * through a resonant filter, which is anything that hits or explodes, and a
4 * tone that can slide, which is anything that beeps or thumps. Both are shaped
5 * by one decaying envelope and go straight to the destination.
6 *
7 * The `AudioContext` is opened on the first sound, not on load, because a
8 * browser will not start one until the person has clicked something — and
9 * because a page that never makes a sound should never pay for the device. The
10 * constructor is a parameter so a test can hand in its own.
11 */
12
13 export type Ctor = new () => AudioContext;
14
15 /** A burst of band-passed noise: a gunshot, an impact, a footstep. */
16 export type Burst = {
17 /** Centre of the bandpass, in Hz — the "colour" of the hit. */
18 frequency: number;
19 /** Peak gain, 0..1, before the envelope decays it to silence. */
20 gain: number;
21 /** Seconds from peak to silence. */
22 length: number;
23 q?: number;
24 /** Playback rate of the noise buffer; vary it so repeats do not phase. */
25 rate?: number;
26 };
27
28 /** A tone, optionally sliding to a second frequency across its length. */
29 export type Tone = {
30 frequency: number;
31 gain: number;
32 length: number;
33 slideTo?: number;
34 type?: OscillatorType;
35 /** Where the exponential envelope lands; it cannot ramp to zero. */
36 floor?: number;
37 /** Extra seconds to hold the node open after the envelope has landed. */
38 tail?: number;
39 };
40
41 export type SoundDevice = {
42 /** Open the context, or resume one the browser suspended. Safe to call often. */
43 resume(): void;
44 noiseBurst(burst: Burst): void;
45 tone(tone: Tone): void;
46 /** True while the device makes no sound at all. */
47 muted: boolean;
48 };
49
50 /**
51 * How loud a sound at `distance` is, 0..1: full inside `near`, and falling off
52 * with distance after it. Not inverse-square — a game wants to hear the fight
53 * across the map, and the ear cares about the ratio, not the physics.
54 */
55 export const attenuate = (distance: number, reference: number, near: number): number =>
56 Math.min(1, reference / Math.max(near, Math.max(0, distance)));
57
58 const NOISE_SECONDS = 0.5;
59
60 const defaultCtor = (): Ctor | undefined => {
61 const g = globalThis as unknown as { AudioContext?: Ctor; webkitAudioContext?: Ctor };
62 return g.AudioContext ?? g.webkitAudioContext;
63 };
64
65 export function createSoundDevice(Ctor: Ctor | undefined = defaultCtor()): SoundDevice {
66 let ctx: AudioContext | null = null;
67 let noise: AudioBuffer | null = null;
68
69 /** The context, opened on first use, or null where there is no audio at all. */
70 const ensure = (): AudioContext | null => {
71 if (ctx === null) {
72 if (Ctor === undefined) return null;
73 ctx = new Ctor();
74 noise = ctx.createBuffer(1, Math.round(ctx.sampleRate * NOISE_SECONDS), ctx.sampleRate);
75 const data = noise.getChannelData(0);
76 for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1;
77 }
78 if (ctx.state === "suspended") void ctx.resume();
79 return ctx;
80 };
81
82 /** A gain node that decays from `gain` to `floor` over `length` seconds. */
83 const decay = (context: AudioContext, now: number, gain: number, length: number, floor: number): GainNode => {
84 const node = context.createGain();
85 node.gain.setValueAtTime(gain, now);
86 node.gain.exponentialRampToValueAtTime(floor, now + length);
87 return node;
88 };
89
90 const device: SoundDevice = {
91 muted: false,
92 resume: () => void ensure(),
93
94 noiseBurst({ frequency, gain, length, q = 0.9, rate = 1 }) {
95 if (device.muted) return;
96 const context = ensure();
97 if (context === null || noise === null) return;
98 const now = context.currentTime;
99 const source = context.createBufferSource();
100 source.buffer = noise;
101 source.playbackRate.value = rate;
102 const filter = context.createBiquadFilter();
103 filter.type = "bandpass";
104 filter.frequency.value = frequency;
105 filter.Q.value = q;
106 source.connect(filter).connect(decay(context, now, gain, length, 0.0008)).connect(context.destination);
107 source.start(now);
108 source.stop(now + length);
109 },
110
111 tone({ frequency, gain, length, slideTo, type = "sine", floor = 0.0008, tail = 0 }) {
112 if (device.muted) return;
113 const context = ensure();
114 if (context === null) return;
115 const now = context.currentTime;
116 const osc = context.createOscillator();
117 osc.type = type;
118 if (slideTo === undefined) osc.frequency.value = frequency;
119 else {
120 osc.frequency.setValueAtTime(frequency, now);
121 osc.frequency.exponentialRampToValueAtTime(slideTo, now + length);
122 }
123 osc.connect(decay(context, now, gain, length, floor)).connect(context.destination);
124 osc.start(now);
125 osc.stop(now + length + tail);
126 },
127 };
128 return device;
129 }
src/engine/texture.test.ts added +65
@@ -0,0 +1,65 @@
1 import { expect, test } from "bun:test";
2 import { fbm, hash, noise, paintAtlas, type Shader } from "./texture.ts";
3
4 const flat = (r: number, g: number, b: number): Shader => () => [r, g, b];
5
6 test("the hash is stable, spread over 0..1, and moves with the seed", () => {
7 expect(hash(3, 7, 1)).toBe(hash(3, 7, 1));
8 expect(hash(3, 7, 1)).not.toBe(hash(3, 7, 2));
9 let lo = 1, hi = 0, sum = 0;
10 for (let x = 0; x < 64; x++) {
11 for (let y = 0; y < 64; y++) {
12 const h = hash(x, y, 5);
13 lo = Math.min(lo, h);
14 hi = Math.max(hi, h);
15 sum += h;
16 }
17 }
18 expect(lo).toBeGreaterThanOrEqual(0);
19 expect(hi).toBeLessThanOrEqual(1);
20 expect(sum / 4096).toBeCloseTo(0.5, 1);
21 });
22
23 test("noise is smooth, hits the lattice exactly, and wraps at its period", () => {
24 expect(noise(4, 9, 16, 3)).toBeCloseTo(hash(4, 9, 3), 12);
25 expect(noise(0.5, 0.25, 8, 3)).toBeCloseTo(noise(8.5, 8.25, 8, 3), 12);
26 const a = noise(2.4, 1.1, 8, 3), b = noise(2.401, 1.1, 8, 3);
27 expect(Math.abs(a - b)).toBeLessThan(0.01);
28 });
29
30 test("fbm stays inside 0..1 and adds detail with every octave", () => {
31 for (let i = 0; i < 200; i++) {
32 const value = fbm(i * 0.37, i * 0.11, 8, 2);
33 expect(value).toBeGreaterThanOrEqual(0);
34 expect(value).toBeLessThanOrEqual(1);
35 }
36 expect(fbm(1.3, 2.7, 8, 2, 1)).not.toBeCloseTo(fbm(1.3, 2.7, 8, 2, 4), 6);
37 });
38
39 test("an atlas is size*size*4*layers opaque bytes", () => {
40 const atlas = paintAtlas([flat(10, 20, 30), flat(40, 50, 60)], 8);
41 expect(atlas.length).toBe(8 * 8 * 4 * 2);
42 for (let i = 3; i < atlas.length; i += 4) expect(atlas[i]).toBe(255);
43 expect([...atlas.slice(0, 4)]).toEqual([10, 20, 30, 255]);
44 // Layers are stacked, not interleaved: the second starts halfway through.
45 expect([...atlas.slice(8 * 8 * 4, 8 * 8 * 4 + 4)]).toEqual([40, 50, 60, 255]);
46 });
47
48 test("painting the same shaders twice paints the same bytes", () => {
49 const shaders: Shader[] = [(u, v) => [fbm(u * 6, v * 6, 6, 1) * 255, u * 255, v * 255]];
50 expect(paintAtlas(shaders, 16)).toEqual(paintAtlas(shaders, 16));
51 });
52
53 test("a shader that returns out-of-range values is clamped, not wrapped", () => {
54 const atlas = paintAtlas([flat(300, -40, Number.NaN)], 4);
55 expect([...atlas.slice(0, 4)]).toEqual([255, 0, 0, 255]);
56 });
57
58 test("a shader sees uv across the layer, one texel at a time", () => {
59 const seen: [number, number][] = [];
60 paintAtlas([(u, v) => { seen.push([u, v]); return [0, 0, 0]; }], 4);
61 expect(seen.length).toBe(16);
62 expect(seen[0]).toEqual([0, 0]);
63 expect(seen[5]).toEqual([0.25, 0.25]);
64 expect(seen[15]).toEqual([0.75, 0.75]);
65 });
src/engine/texture.ts added +65
@@ -0,0 +1,65 @@
1 /**
2 * Textures, painted. A shader is a function of (u, v) returning RGB in 0..255,
3 * and an atlas is a stack of them written straight into the bytes a WebGL2
4 * array texture wants — no images to download, no atlas bleeding, and a set of
5 * nine 128×128 layers costs about a millisecond to make.
6 *
7 * The noise is value noise that wraps at a period, so a surface tiles without a
8 * seam, and it is hashed rather than tabled, so no state has to be carried and
9 * the same (x, y, seed) is the same byte on every machine.
10 */
11
12 /** A stable hash of a lattice point to 0..1. No tables, no state, no drift. */
13 export const hash = (x: number, y: number, seed: number): number => {
14 let h = (x * 374761393 + y * 668265263 + seed * 1274126177) | 0;
15 h = (h ^ (h >>> 13)) * 1274126177;
16 return ((h ^ (h >>> 16)) >>> 0) / 4294967295;
17 };
18
19 /** Value noise that wraps at `period`, so a tiled surface has no seam. */
20 export function noise(x: number, y: number, period: number, seed: number): number {
21 const xi = Math.floor(x), yi = Math.floor(y);
22 const xf = x - xi, yf = y - yi;
23 const u = xf * xf * (3 - 2 * xf), v = yf * yf * (3 - 2 * yf);
24 const at = (i: number, j: number): number =>
25 hash(((i % period) + period) % period, ((j % period) + period) % period, seed);
26 const a = at(xi, yi), b = at(xi + 1, yi), c = at(xi, yi + 1), d = at(xi + 1, yi + 1);
27 return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v;
28 }
29
30 /** Octaves of `noise`, halving in amplitude and doubling in frequency. */
31 export const fbm = (x: number, y: number, period: number, seed: number, octaves = 4): number => {
32 let sum = 0, amp = 0.5, freq = 1;
33 for (let o = 0; o < octaves; o++) {
34 sum += noise(x * freq, y * freq, period * freq, seed + o) * amp;
35 amp *= 0.5;
36 freq *= 2;
37 }
38 return sum;
39 };
40
41 /** One layer's surface: uv in 0..1 in, RGB in 0..255 out. */
42 export type Shader = (u: number, v: number) => [number, number, number];
43
44 /**
45 * One `size`×`size` layer per shader, tightly packed RGBA and fully opaque,
46 * ready for texImage3D. Anything a shader returns outside 0..255 is clamped,
47 * because a surface that is a little too bright should not wrap to black.
48 */
49 export function paintAtlas(shaders: Shader[], size: number): Uint8Array {
50 const out = new Uint8Array(size * size * 4 * shaders.length);
51 for (let layer = 0; layer < shaders.length; layer++) {
52 const shade = shaders[layer] as Shader;
53 for (let y = 0; y < size; y++) {
54 for (let x = 0; x < size; x++) {
55 const [r, g, b] = shade(x / size, y / size);
56 const i = (layer * size * size + y * size + x) * 4;
57 out[i] = Math.max(0, Math.min(255, r));
58 out[i + 1] = Math.max(0, Math.min(255, g));
59 out[i + 2] = Math.max(0, Math.min(255, b));
60 out[i + 3] = 255;
61 }
62 }
63 }
64 return out;
65 }
static/app.6864d577a4.js added +58

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

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

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

View file

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs