plum

#treesitter#compiler#wasm

git clone https://git.pyrossh.dev/plum

A statically typed, imperative programming language inspired by rust, python


2c0eaf2 — Peter John 2026-11-02T16:17:17+05:30
Add the gpu web renderer: own WebGPU batcher with a command-buffer decoder
plum-cli/src/game.rs CHANGED
@@ -35,6 +35,8 @@ pub enum Renderer {
35
35
  Macroquad,
36
36
  /// PixiJS, driven straight from the Plum module (experimental)
37
37
  Pixi,
38
+ /// our own batching renderer, WebGPU with a WebGL2 fallback, no library (experimental)
39
+ Gpu,
38
40
  }
39
41
 
40
42
  pub fn cmdGame(
@@ -58,6 +60,7 @@ pub fn cmdGame(
58
60
  return match renderer {
59
61
  Renderer::Macroquad => writeWeb(&wasm, &dist, &assets),
60
62
  Renderer::Pixi => writeWebPixi(&wasm, &dist, &assets),
63
+ Renderer::Gpu => writeWebGpu(&wasm, &dist, &assets),
61
64
  };
62
65
  }
63
66
  runNative(&wasm, &assets)
@@ -236,3 +239,16 @@ fn writeWebPixi(wasm: &[u8], dist: &Path, assets: &Path) -> Result<()> {
236
239
  eprintln!("wrote web build (pixi) to {}", dist.display());
237
240
  Ok(())
238
241
  }
242
+
243
+ /// The own-batcher build needs no host wasm and no library: the page, the bridge and the Plum module.
244
+ fn writeWebGpu(wasm: &[u8], dist: &Path, assets: &Path) -> Result<()> {
245
+ fs::create_dir_all(dist)?;
246
+ let web = repoRoot().join("plum-game/web");
247
+ fs::copy(web.join("index_gpu.html"), dist.join("index.html"))
248
+ .context("failed to copy index_gpu.html")?;
249
+ fs::copy(web.join("plum_bridge_gpu.js"), dist.join("plum_bridge_gpu.js"))
250
+ .context("failed to copy plum_bridge_gpu.js")?;
251
+ writeWebAssets(wasm, dist, assets)?;
252
+ eprintln!("wrote web build (gpu) to {}", dist.display());
253
+ Ok(())
254
+ }
plum-cli/src/main.rs CHANGED
@@ -83,7 +83,7 @@ enum Command {
83
83
  /// Write a static web build instead of opening a window
84
84
  #[arg(long)]
85
85
  web: bool,
86
- /// Browser renderer for --web: macroquad (default) or pixi (experimental)
86
+ /// Browser renderer for --web: macroquad (default), pixi or gpu (both experimental)
87
87
  #[arg(long, value_enum, default_value = "macroquad")]
88
88
  renderer: game::Renderer,
89
89
  /// Output directory for --web (default: `dist` beside the source file)
plum-cli/tests/game_web_test.rs CHANGED
@@ -102,3 +102,23 @@ fn pixiWebBuildNeedsNoHostWasm() {
102
102
  checkAssets(&dist);
103
103
  let _ = std::fs::remove_dir_all(&scratch);
104
104
  }
105
+
106
+ #[test]
107
+ fn gpuWebBuildNeedsNoHostWasmOrLibrary() {
108
+ let (dist, scratch) = exportFixture(Some("gpu"));
109
+ for f in ["index.html", "plum_bridge_gpu.js", "plum.wasm", "preload.txt"] {
110
+ assert!(dist.join(f).is_file(), "missing {f}");
111
+ }
112
+ for f in [
113
+ "mq_js_bundle.js",
114
+ "plum-game.wasm",
115
+ "plum_bridge.js",
116
+ "pixi.min.js",
117
+ ] {
118
+ assert!(!dist.join(f).exists(), "unexpected {f}");
119
+ }
120
+ let html = std::fs::read_to_string(dist.join("index.html")).unwrap();
121
+ assert!(html.contains("plum_bridge_gpu.js") && !html.contains("pixi"));
122
+ checkAssets(&dist);
123
+ let _ = std::fs::remove_dir_all(&scratch);
124
+ }
plum-game/web-test/README.md CHANGED
@@ -21,7 +21,7 @@ Downloads the Chromium build matching the bundled Playwright driver (about 100 M
21
21
 
22
22
  cargo run --manifest-path plum-game/web-test/Cargo.toml -- plum-examples/game.plum --screenshot /tmp/orb-run.png
23
23
 
24
- Options: `--frames-wait SECS` (default 8), `--screenshot OUT.png`, `--require-audio`, `--renderer pixi|macroquad` (default macroquad), `--headed` (a real GPU window instead of headless software GL), `--measure SECS` (records requestAnimationFrame intervals in the page and prints avg / p50 / p95 / p99 ms, fps and the renderer Pixi chose from `plumRenderer`) and `--uncapped` (lifts vsync so the frame time is the work per frame). `fixtures/stress.plum` is the scene behind [BENCHMARK.md](BENCHMARK.md).
24
+ Options: `--frames-wait SECS` (default 8), `--screenshot OUT.png`, `--require-audio`, `--renderer pixi|macroquad|gpu` (default macroquad), `--json` (adds a one-line JSON object with the renderer, backend and frame stats), `--headed` (a real GPU window instead of headless software GL), `--measure SECS` (records requestAnimationFrame intervals in the page and prints avg / p50 / p95 / p99 ms, fps and the renderer Pixi chose from `plumRenderer`) and `--uncapped` (lifts vsync so the frame time is the work per frame). `fixtures/stress.plum` is the scene behind [BENCHMARK.md](BENCHMARK.md).
25
25
  Exit code is non-zero with a message on failure.
26
26
 
27
27
  `cargo test --manifest-path plum-game/web-test/Cargo.toml` runs the same check on
plum-game/web-test/src/lib.rs CHANGED
@@ -13,6 +13,7 @@ use std::time::Duration;
13
13
  pub enum Renderer {
14
14
  Macroquad,
15
15
  Pixi,
16
+ Gpu,
16
17
  }
17
18
 
18
19
  impl Renderer {
@@ -20,6 +21,7 @@ impl Renderer {
20
21
  match self {
21
22
  Renderer::Macroquad => "macroquad",
22
23
  Renderer::Pixi => "pixi",
24
+ Renderer::Gpu => "gpu",
23
25
  }
24
26
  }
25
27
  }
@@ -33,6 +35,8 @@ pub struct Options {
33
35
  pub headed: bool,
34
36
  /// Record requestAnimationFrame intervals for this many seconds and report them.
35
37
  pub measure: Option<f64>,
38
+ /// Append a one-line JSON object with the renderer and frame statistics to the report.
39
+ pub json: bool,
36
40
  /// Lift Chromium's vsync / frame-rate cap so frame times show the work per frame.
37
41
  pub uncapped: bool,
38
42
  }
@@ -46,6 +50,7 @@ impl Default for Options {
46
50
  renderer: Renderer::Macroquad,
47
51
  headed: false,
48
52
  measure: None,
53
+ json: false,
49
54
  uncapped: false,
50
55
  }
51
56
  }
@@ -78,6 +83,29 @@ pub fn frame_stats(stamps: &[f64]) -> Option<FrameStats> {
78
83
  Some(FrameStats { frames: d.len(), avg: d.iter().sum::<f64>() / d.len() as f64, p50: pct(0.50), p95: pct(0.95), p99: pct(0.99) })
79
84
  }
80
85
 
86
+ fn json_str(s: &str) -> String {
87
+ format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
88
+ }
89
+
90
+ /// One JSON object: renderer, backend (`plumRenderer`), headed, uncapped and the frame statistics.
91
+ pub fn stats_json(opts: &Options, backend: Option<&str>, st: Option<&FrameStats>) -> String {
92
+ let mut s = format!(
93
+ "{{\"renderer\":{},\"backend\":{},\"headed\":{},\"uncapped\":{}",
94
+ json_str(opts.renderer.name()),
95
+ backend.map(json_str).unwrap_or_else(|| "null".into()),
96
+ opts.headed,
97
+ opts.uncapped
98
+ );
99
+ match st {
100
+ Some(st) => s.push_str(&format!(
101
+ ",\"frames\":{},\"avg_ms\":{:.3},\"p50_ms\":{:.3},\"p95_ms\":{:.3},\"p99_ms\":{:.3},\"fps\":{:.2}}}",
102
+ st.frames, st.avg, st.p50, st.p95, st.p99, st.fps()
103
+ )),
104
+ None => s.push('}'),
105
+ }
106
+ s
107
+ }
108
+
81
109
  /// Whether `playwright install chromium` has been run (browser cache dir exists).
82
110
  pub fn chromium_installed() -> bool {
83
111
  let dir = if let Ok(p) = std::env::var("PLAYWRIGHT_BROWSERS_PATH") {
@@ -269,6 +297,10 @@ async fn check(port: u16, opts: &Options) -> Result<String, String> {
269
297
  let mut args: Vec<String> = ["--ignore-gpu-blocklist", "--autoplay-policy=no-user-gesture-required"].map(String::from).to_vec();
270
298
  if !opts.headed {
271
299
  args.extend(["--use-angle=swiftshader", "--enable-unsafe-swiftshader"].map(String::from));
300
+ if opts.renderer == Renderer::Gpu {
301
+ // The gpu renderer is WebGPU only: give headless Chromium a software adapter.
302
+ args.extend(["--enable-unsafe-webgpu", "--enable-features=Vulkan", "--use-webgpu-adapter=swiftshader"].map(String::from));
303
+ }
272
304
  }
273
305
  if opts.uncapped {
274
306
  args.extend(["--disable-gpu-vsync", "--disable-frame-rate-limit"].map(String::from));
@@ -383,9 +415,12 @@ async fn check_page(browser: &playwright_rs::protocol::Browser, port: u16, opts:
383
415
  } else if opts.measure.is_some() {
384
416
  report.push_str("\nframes: none recorded");
385
417
  }
418
+ if opts.json {
419
+ report.push_str(&format!("\n{}", stats_json(opts, renderer.as_deref(), measured.as_ref())));
420
+ }
386
421
  let mut failures = vec![];
387
- if opts.renderer == Renderer::Pixi && renderer.is_none() {
422
+ if matches!(opts.renderer, Renderer::Pixi | Renderer::Gpu) && renderer.is_none() {
388
- failures.push("pixi build did not set globalThis.plumRenderer".to_string());
423
+ failures.push(format!("{} build did not set globalThis.plumRenderer", opts.renderer.name()));
389
424
  }
390
425
  if colours <= 1 {
391
426
  failures.push("canvas is blank (uniformly one colour)".to_string());
plum-game/web-test/src/main.rs CHANGED
@@ -1,7 +1,7 @@
1
1
  use plum_web_test::{chromium_installed, run, Options, Renderer};
2
2
  use std::path::PathBuf;
3
3
 
4
- const USAGE: &str = "usage: plum-web-test <file.plum> [--frames-wait SECS] [--screenshot OUT.png] [--require-audio]\n [--renderer pixi|macroquad] [--headed] [--measure SECS] [--uncapped]";
4
+ const USAGE: &str = "usage: plum-web-test <file.plum> [--frames-wait SECS] [--screenshot OUT.png] [--require-audio]\n [--renderer pixi|macroquad|gpu] [--headed] [--measure SECS] [--uncapped] [--json]";
5
5
 
6
6
  #[tokio::main]
7
7
  async fn main() {
@@ -17,12 +17,14 @@ async fn main() {
17
17
  "--require-audio" => opts.require_audio = true,
18
18
  "--headed" => opts.headed = true,
19
19
  "--uncapped" => opts.uncapped = true,
20
+ "--json" => opts.json = true,
20
21
  "--measure" => opts.measure = Some(args.next().and_then(|v| v.parse().ok()).unwrap_or_else(|| fail("--measure needs a number of seconds"))),
21
22
  "--renderer" => {
22
23
  opts.renderer = match args.next().as_deref() {
23
24
  Some("pixi") => Renderer::Pixi,
25
+ Some("gpu") => Renderer::Gpu,
24
26
  Some("macroquad") => Renderer::Macroquad,
25
- _ => fail("--renderer needs pixi or macroquad"),
27
+ _ => fail("--renderer needs pixi, macroquad or gpu"),
26
28
  }
27
29
  }
28
30
  "-h" | "--help" => {
plum-game/web-test/tests/web_check.rs CHANGED
@@ -10,6 +10,23 @@ fn orb_run_renders_and_animates_on_pixi() {
10
10
  orb_run(Renderer::Pixi);
11
11
  }
12
12
 
13
+ #[test]
14
+ fn orb_run_renders_and_animates_on_gpu() {
15
+ orb_run(Renderer::Gpu);
16
+ }
17
+
18
+ #[test]
19
+ fn stats_json_is_one_object() {
20
+ use plum_web_test::{stats_json, FrameStats, Options};
21
+ let st = FrameStats { frames: 3, avg: 2.0, p50: 2.0, p95: 3.0, p99: 3.0 };
22
+ let o = Options { renderer: Renderer::Gpu, headed: true, ..Options::default() };
23
+ assert_eq!(
24
+ stats_json(&o, Some("gpu-webgpu"), Some(&st)),
25
+ r#"{"renderer":"gpu","backend":"gpu-webgpu","headed":true,"uncapped":false,"frames":3,"avg_ms":2.000,"p50_ms":2.000,"p95_ms":3.000,"p99_ms":3.000,"fps":500.00}"#
26
+ );
27
+ assert_eq!(stats_json(&o, None, None), r#"{"renderer":"gpu","backend":null,"headed":true,"uncapped":false}"#);
28
+ }
29
+
13
30
  fn orb_run(renderer: Renderer) {
14
31
  if !chromium_installed() {
15
32
  eprintln!("SKIP: Playwright Chromium not installed (see plum-game/web-test/README.md)");
plum-game/web/gpu_cmd_check.mjs ADDED
@@ -0,0 +1,93 @@
1
+ // Usage: node gpu_cmd_check.mjs
2
+ //
3
+ // Hand-encodes one command of each opcode into an ArrayBuffer, decodes it with
4
+ // plum_bridge_gpu.js's decoder and checks the batcher's vertex/index/batch counts.
5
+ import { readFileSync } from "node:fs";
6
+ import { dirname, join } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import vm from "node:vm";
9
+ import assert from "node:assert/strict";
10
+
11
+ const here = dirname(fileURLToPath(import.meta.url));
12
+ vm.runInThisContext(readFileSync(join(here, "plum_bridge_gpu.js"), "utf8"));
13
+ const { makeBatcher, decodeCommands } = globalThis.PlumGpu;
14
+
15
+ // --- encoder ---------------------------------------------------------------------
16
+ const buf = new ArrayBuffer(4096);
17
+ const dv = new DataView(buf);
18
+ let p = 0;
19
+ const u = (v) => { dv.setUint32(p, v, true); p += 4; };
20
+ const i = (v) => { dv.setInt32(p, v, true); p += 4; };
21
+ const f = (...vs) => { for (const v of vs) { dv.setFloat32(p, v, true); p += 4; } };
22
+ const text = (s) => {
23
+ const bytes = new TextEncoder().encode(s);
24
+ i(bytes.length);
25
+ new Uint8Array(buf, p, bytes.length).set(bytes);
26
+ p += (bytes.length + 3) & ~3;
27
+ };
28
+
29
+ u(1); f(0.1, 0.2, 0.3, 1); // clear
30
+ u(2); f(10, 20, 30, 40, 1, 0, 0, 1); // rect : white batch
31
+ u(3); f(0, 0, 100, 0, 4, 0, 1, 0, 1); // line : white batch
32
+ u(4); f(50, 50, 10, 0, 0, 1, 1); // circle (24 segs): white batch
33
+ u(5); f(5, 6, 16, 1, 1, 1, 1); text("héllo"); // text : text batch
34
+ u(6); i(0); f(1, 2, 1, 1, 1, 1); // texture : tex 0 batch
35
+ u(7); i(0); f(0, 0, 8, 8, 10, 10, 16, 16, 0.5); i(1); f(1, 1, 1, 1); // textureEx: same batch, rotated, flipped
36
+ u(8); f(100, 50, 2); // setCamera
37
+ u(2); f(0, 0, 1, 1, 1, 1, 1, 1); // rect after camera: white batch again
38
+ u(9); // resetCamera
39
+ const len = p;
40
+
41
+ const texts = [];
42
+ const b = makeBatcher({
43
+ textTexture: (s, px) => {
44
+ const t = { w: s.length * px, h: px + 2, pad: 1 };
45
+ texts.push([s, px]);
46
+ return t;
47
+ },
48
+ });
49
+ b.textures.push({ w: 16, h: 16 });
50
+ b.beginFrame(800, 600);
51
+
52
+ const n = decodeCommands(new DataView(buf, 0, len), b);
53
+ assert.equal(n, 10, "command count");
54
+ assert.deepEqual(Array.from(b.clearColor).map((v) => Math.round(v * 10)), [1, 2, 3, 10]);
55
+ assert.deepEqual(texts, [["héllo", 16]], "text decoded as UTF-8 at size 16");
56
+ // rect 4v/6i, line 4/6, circle 25/72, text 4/6, texture 4/6, textureEx 4/6, rect 4/6
57
+ assert.equal(b.vertexCount, 4 + 4 + 25 + 4 + 4 + 4 + 4);
58
+ assert.equal(b.indexCount, 6 + 6 + 72 + 6 + 6 + 6 + 6);
59
+ // batches: white(rect,line,circle) | text | tex0(texture,textureEx) | white(rect)
60
+ assert.equal(b.batchCount, 4);
61
+ assert.deepEqual(Array.from({ length: 4 }, (_, k) => b.batches[k].count), [84, 6, 12, 6]);
62
+ assert.equal(b.batches[0].tex, b.white);
63
+ assert.equal(b.batches[2].tex, b.textures[0]);
64
+ assert.equal(b.batches[3].tex, b.white);
65
+ // every index points at an existing vertex
66
+ for (let k = 0; k < b.indexCount; k++) assert.ok(b.idx[k] < b.vertexCount);
67
+ // the first rect (10,20)-(40,60) in an 800x600 identity camera -> clip coordinates
68
+ assert.ok(Math.abs(b.f32[0] - (10 * 2 / 800 - 1)) < 1e-6);
69
+ assert.ok(Math.abs(b.f32[1] - (1 - 20 * 2 / 600)) < 1e-6);
70
+
71
+ // a clear mid-frame discards earlier geometry
72
+ const b2 = makeBatcher({ textTexture: () => null });
73
+ b2.beginFrame(100, 100);
74
+ b2.drawRect(0, 0, 1, 1, 1, 1, 1, 1);
75
+ b2.clear(0, 0, 0, 1);
76
+ assert.equal(b2.vertexCount, 0);
77
+ assert.equal(b2.batchCount, 0);
78
+
79
+ // growth past the initial capacity keeps earlier vertices
80
+ const b3 = makeBatcher({ textTexture: () => null });
81
+ b3.beginFrame(100, 100);
82
+ for (let k = 0; k < 10000; k++) b3.drawRect(k, 0, 1, 1, 1, 1, 1, 1);
83
+ assert.equal(b3.vertexCount, 40000);
84
+ assert.equal(b3.batchCount, 1);
85
+ assert.equal(b3.batches[0].count, 60000);
86
+ assert.ok(Math.abs(b3.f32[0] - (-1)) < 1e-6);
87
+
88
+ // truncated / unknown input is rejected
89
+ const bad = new DataView(new ArrayBuffer(4));
90
+ bad.setUint32(0, 99, true);
91
+ assert.throws(() => decodeCommands(bad, b2), /unknown opcode 99/);
92
+
93
+ console.log("gpu_cmd_check: ok");
plum-game/web/index_gpu.html ADDED
@@ -0,0 +1,17 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Plum Game</title>
7
+ <link rel="icon" href="data:,">
8
+ <style>
9
+ html, body, canvas { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #000; }
10
+ canvas:focus { outline: none; }
11
+ </style>
12
+ </head>
13
+ <body>
14
+ <canvas id="glcanvas" tabindex="1"></canvas>
15
+ <script src="plum_bridge_gpu.js"></script>
16
+ </body>
17
+ </html>
plum-game/web/plum_bridge_gpu.js ADDED
@@ -0,0 +1,904 @@
1
+ // Own batching renderer for a Plum game: plum.wasm calls this file directly (no
2
+ // second wasm module, no library). Every draw becomes quads or triangle fans
3
+ // written into one CPU vertex/index array (camera applied on the CPU), flushed
4
+ // with one buffer upload per frame and one draw call per run of the same texture.
5
+ // Untextured shapes sample a 1x1 white texture. The backend is WebGPU where the
6
+ // browser has it; there is no fallback (a page without navigator.gpu says so).
7
+ //
8
+ // Two input modes share the batcher: per-call (each Game.plum draw extern appends
9
+ // vertices) and, when the module exports `cmdPtr`, `cmdLen`, `cmdReset` and
10
+ // `memory`, a command buffer decoded once per frame (see decodeCommands).
11
+ // Plum's `Str` is a GC struct JS can't read: it is read through the module's
12
+ // `strLen`/`strByte` exports and built with `strNew`/`strSetByte`.
13
+ (function () {
14
+ "use strict";
15
+
16
+ const IMAGE_EXT = /\.(png|jpe?g|gif|webp|bmp)$/i;
17
+ const SOUND_EXT = /\.(wav|ogg)$/i;
18
+ // --- paths --------------------------------------------------------------------
19
+
20
+ // The asset-root-relative form of `p`, or null if it is empty, absolute or escapes the root.
21
+ function normPath(p) {
22
+ if (!p || p.includes("\\") || p.startsWith("/")) return null;
23
+ const parts = [];
24
+ for (const c of p.split("/")) {
25
+ if (c === "" || c === ".") continue;
26
+ if (c === "..") return null;
27
+ parts.push(c);
28
+ }
29
+ return parts.length ? parts.join("/") : null;
30
+ }
31
+
32
+ // --- Plum Str -------------------------------------------------------------------
33
+
34
+ function strToJs(plum, s) {
35
+ const n = plum.strLen(s);
36
+ const bytes = new Uint8Array(n);
37
+ for (let i = 0; i < n; i++) bytes[i] = plum.strByte(s, i);
38
+ return new TextDecoder().decode(bytes);
39
+ }
40
+
41
+ function bytesToStr(plum, bytes) {
42
+ const out = plum.strNew(bytes.length);
43
+ for (let i = 0; i < bytes.length; i++) plum.strSetByte(out, i, bytes[i]);
44
+ return out;
45
+ }
46
+
47
+ // --- input ----------------------------------------------------------------------
48
+
49
+ // KeyboardEvent.code -> GLFW key number (the numbering of `Key` in Game.plum).
50
+ function glfwKey(code) {
51
+ if (code.length === 4 && code.startsWith("Key")) return code.charCodeAt(3);
52
+ if (code.length === 6 && code.startsWith("Digit")) return code.charCodeAt(5);
53
+ switch (code) {
54
+ case "Space": return 32;
55
+ case "Escape": return 256;
56
+ case "Enter": case "NumpadEnter": return 257;
57
+ case "Tab": return 258;
58
+ case "Backspace": return 259;
59
+ case "ArrowRight": return 262;
60
+ case "ArrowLeft": return 263;
61
+ case "ArrowDown": return 264;
62
+ case "ArrowUp": return 265;
63
+ case "ShiftLeft": return 340;
64
+ case "ControlLeft": return 341;
65
+ default: return -1;
66
+ }
67
+ }
68
+
69
+ const MOUSE_BUTTONS = [0, 2, 1]; // DOM 0/1/2 = left/middle/right -> Mouse.Left/Middle/Right numbers 0/2/1
70
+
71
+ function makeInput(canvas) {
72
+ const keysDown = new Set();
73
+ let keysPressed = new Set();
74
+ let keysPending = new Set();
75
+ const mouseDown = new Set();
76
+ let mousePressed = new Set();
77
+ let mousePending = new Set();
78
+ let wheelPending = 0;
79
+ let wheel = 0;
80
+ const mouse = { x: 0, y: 0 };
81
+
82
+ window.addEventListener("keydown", (e) => {
83
+ const k = glfwKey(e.code);
84
+ if (k < 0) return;
85
+ if (k === 32 || k === 258 || k === 259 || (k >= 262 && k <= 265)) e.preventDefault();
86
+ if (!e.repeat) keysPending.add(k);
87
+ keysDown.add(k);
88
+ });
89
+ window.addEventListener("keyup", (e) => {
90
+ const k = glfwKey(e.code);
91
+ if (k >= 0) keysDown.delete(k);
92
+ });
93
+ window.addEventListener("blur", () => {
94
+ keysDown.clear();
95
+ mouseDown.clear();
96
+ });
97
+ const track = (e) => {
98
+ const r = canvas.getBoundingClientRect();
99
+ mouse.x = e.clientX - r.left;
100
+ mouse.y = e.clientY - r.top;
101
+ };
102
+ window.addEventListener("pointermove", track);
103
+ canvas.addEventListener("pointerdown", (e) => {
104
+ track(e);
105
+ const b = MOUSE_BUTTONS[e.button];
106
+ if (b === undefined) return;
107
+ mousePending.add(b);
108
+ mouseDown.add(b);
109
+ });
110
+ window.addEventListener("pointerup", (e) => {
111
+ const b = MOUSE_BUTTONS[e.button];
112
+ if (b !== undefined) mouseDown.delete(b);
113
+ });
114
+ canvas.addEventListener(
115
+ "wheel",
116
+ (e) => {
117
+ e.preventDefault();
118
+ wheelPending += Math.sign(-e.deltaY);
119
+ },
120
+ { passive: false }
121
+ );
122
+ document.addEventListener("contextmenu", (e) => e.preventDefault());
123
+
124
+ return {
125
+ mouse,
126
+ // Fixes what isKeyPressed / isMousePressed / mouseWheel report for the frame about to run.
127
+ beginFrame() {
128
+ [keysPressed, keysPending] = [keysPending, keysPressed];
129
+ keysPending.clear();
130
+ [mousePressed, mousePending] = [mousePending, mousePressed];
131
+ mousePending.clear();
132
+ wheel = Math.sign(wheelPending);
133
+ wheelPending = 0;
134
+ },
135
+ isKeyDown: (k) => (keysDown.has(k) ? 1 : 0),
136
+ isKeyPressed: (k) => (keysPressed.has(k) ? 1 : 0),
137
+ isMouseDown: (b) => (mouseDown.has(b) ? 1 : 0),
138
+ isMousePressed: (b) => (mousePressed.has(b) ? 1 : 0),
139
+ mouseWheel: () => wheel,
140
+ };
141
+ }
142
+
143
+ // --- sound ----------------------------------------------------------------------
144
+
145
+ function makeAudio() {
146
+ const AC = globalThis.AudioContext || globalThis.webkitAudioContext;
147
+ const ctx = AC ? new AC() : null;
148
+ const decoded = new Map(); // path -> AudioBuffer
149
+ const handles = []; // handle -> { path, plays: Set<{ src, gain }> }
150
+ if (ctx) {
151
+ const resume = () => {
152
+ if (ctx.state !== "running") ctx.resume();
153
+ };
154
+ for (const ev of ["pointerdown", "keydown", "touchstart"]) window.addEventListener(ev, resume);
155
+ }
156
+
157
+ const sound = (h) => (h >= 0 && h < handles.length ? handles[h] : null);
158
+ const clampVolume = (v) => (v > 0 ? Math.min(v, 1) : 0);
159
+ function start(h, volume, loop) {
160
+ const s = sound(h);
161
+ if (!s || !ctx) return;
162
+ // A one-shot before the first user gesture is dropped, not queued.
163
+ if (!loop && ctx.state !== "running") return;
164
+ const src = ctx.createBufferSource();
165
+ src.buffer = decoded.get(s.path);
166
+ src.loop = loop;
167
+ const gain = ctx.createGain();
168
+ gain.gain.value = clampVolume(volume);
169
+ src.connect(gain).connect(ctx.destination);
170
+ const play = { src, gain };
171
+ s.plays.add(play);
172
+ src.onended = () => s.plays.delete(play);
173
+ src.start();
174
+ }
175
+
176
+ return {
177
+ ctx,
178
+ // Decodes `bytes` for later soundLoad(path); false if undecodable.
179
+ async decode(path, bytes) {
180
+ if (!ctx) return false;
181
+ try {
182
+ decoded.set(path, await ctx.decodeAudioData(bytes.slice().buffer));
183
+ return true;
184
+ } catch (e) {
185
+ console.warn(path + ": could not decode sound", e);
186
+ return false;
187
+ }
188
+ },
189
+ load(path) {
190
+ if (!decoded.has(path)) return -1;
191
+ let h = handles.findIndex((s) => s.path === path);
192
+ if (h < 0) h = handles.push({ path, plays: new Set() }) - 1;
193
+ return h;
194
+ },
195
+ play: (h, v) => start(h, v, false),
196
+ loop: (h, v) => start(h, v, true),
197
+ stop(h) {
198
+ const s = sound(h);
199
+ if (!s) return;
200
+ for (const p of s.plays) {
201
+ p.src.onended = null;
202
+ try { p.src.stop(); } catch (e) { /* already stopped */ }
203
+ }
204
+ s.plays.clear();
205
+ },
206
+ setVolume(h, v) {
207
+ const s = sound(h);
208
+ if (s) for (const p of s.plays) p.gain.gain.value = clampVolume(v);
209
+ },
210
+ };
211
+ }
212
+
213
+ // --- files ----------------------------------------------------------------------
214
+
215
+ // Fetches every file listed in preload.txt. Returns path -> Uint8Array.
216
+ async function preload() {
217
+ const list = await (await fetch("preload.txt")).text();
218
+ const files = new Map();
219
+ await Promise.all(
220
+ list.split("\n").map((l) => l.trim()).filter(Boolean).map(async (path) => {
221
+ const r = await fetch("assets/" + path);
222
+ if (!r.ok) throw new Error(path + ": HTTP " + r.status);
223
+ files.set(path, new Uint8Array(await r.arrayBuffer()));
224
+ })
225
+ );
226
+ return files;
227
+ }
228
+
229
+ // --- batcher ----------------------------------------------------------------------
230
+
231
+ // One vertex is 20 bytes: x, y (clip space), u, v (f32) and rgba (u8x4).
232
+ const VERT_WORDS = 5;
233
+ const VERT_BYTES = 20;
234
+ const MAX_SEGMENTS = 96;
235
+
236
+ function clamp01(v) {
237
+ return v > 0 ? (v < 1 ? v : 1) : 0;
238
+ }
239
+ function packColor(r, g, b, a) {
240
+ return (
241
+ ((clamp01(r) * 255 + 0.5) | 0) |
242
+ (((clamp01(g) * 255 + 0.5) | 0) << 8) |
243
+ (((clamp01(b) * 255 + 0.5) | 0) << 16) |
244
+ (((clamp01(a) * 255 + 0.5) | 0) << 24)
245
+ ) >>> 0;
246
+ }
247
+
248
+ const circleTables = new Map(); // segments -> Float32Array [cos0, sin0, cos1, sin1, ...]
249
+ function circleTable(n) {
250
+ let t = circleTables.get(n);
251
+ if (!t) {
252
+ t = new Float32Array(n * 2);
253
+ for (let i = 0; i < n; i++) {
254
+ t[2 * i] = Math.cos((i / n) * 2 * Math.PI);
255
+ t[2 * i + 1] = Math.sin((i / n) * 2 * Math.PI);
256
+ }
257
+ circleTables.set(n, t);
258
+ }
259
+ return t;
260
+ }
261
+
262
+ // env.textTexture(str, px) -> { w, h, pad } texture object for a text run
263
+ // (white glyphs on transparent, `pad` px of margin on every side), or null.
264
+ function makeBatcher(env) {
265
+ let vcap = 1 << 14;
266
+ let icap = 1 << 15;
267
+ let buf = new ArrayBuffer(vcap * VERT_BYTES);
268
+ let f32 = new Float32Array(buf);
269
+ let u32 = new Uint32Array(buf);
270
+ let idx = new Uint32Array(icap);
271
+ let vn = 0; // vertices used
272
+ let inn = 0; // indices used
273
+ const batches = []; // pooled { tex, start, count }
274
+ let nb = 0;
275
+ let cur = null;
276
+ const white = { w: 1, h: 1, white: true };
277
+ const textures = []; // handle -> texture object { w, h, ... }
278
+ let sw = 1;
279
+ let sh = 1;
280
+ const cam = { x: 0, y: 0, s: 1 };
281
+ // world -> clip: X = x * mx + bx, Y = y * my + by
282
+ let mx = 2;
283
+ let bx = -1;
284
+ let my = -2;
285
+ let by = 1;
286
+ const clearColor = [0, 0, 0, 1];
287
+
288
+ function reserve(nv, ni) {
289
+ if (vn + nv > vcap) {
290
+ while (vn + nv > vcap) vcap *= 2;
291
+ const nbuf = new ArrayBuffer(vcap * VERT_BYTES);
292
+ new Uint8Array(nbuf).set(new Uint8Array(buf, 0, vn * VERT_BYTES));
293
+ buf = nbuf;
294
+ f32 = new Float32Array(buf);
295
+ u32 = new Uint32Array(buf);
296
+ }
297
+ if (inn + ni > icap) {
298
+ while (inn + ni > icap) icap *= 2;
299
+ const nidx = new Uint32Array(icap);
300
+ nidx.set(idx.subarray(0, inn));
301
+ idx = nidx;
302
+ }
303
+ }
304
+
305
+ // Starts a new batch only when the texture changes.
306
+ function useTex(t) {
307
+ if (cur && cur.tex === t) return;
308
+ cur = batches[nb];
309
+ if (!cur) batches.push((cur = { tex: t, start: 0, count: 0 }));
310
+ nb++;
311
+ cur.tex = t;
312
+ cur.start = inn;
313
+ cur.count = 0;
314
+ }
315
+
316
+ function vert(x, y, u, v, c) {
317
+ const o = vn * VERT_WORDS;
318
+ f32[o] = x * mx + bx;
319
+ f32[o + 1] = y * my + by;
320
+ f32[o + 2] = u;
321
+ f32[o + 3] = v;
322
+ u32[o + 4] = c;
323
+ vn++;
324
+ }
325
+
326
+ // Corners in order: top-left, top-right, bottom-right, bottom-left.
327
+ function quad(t, ax, ay, bx_, by_, cx, cy, dx, dy, u0, v0, u1, v1, c) {
328
+ reserve(4, 6);
329
+ useTex(t);
330
+ const base = vn;
331
+ vert(ax, ay, u0, v0, c);
332
+ vert(bx_, by_, u1, v0, c);
333
+ vert(cx, cy, u1, v1, c);
334
+ vert(dx, dy, u0, v1, c);
335
+ idx[inn] = base;
336
+ idx[inn + 1] = base + 1;
337
+ idx[inn + 2] = base + 2;
338
+ idx[inn + 3] = base;
339
+ idx[inn + 4] = base + 2;
340
+ idx[inn + 5] = base + 3;
341
+ inn += 6;
342
+ cur.count += 6;
343
+ }
344
+
345
+ function setCamera(x, y, s) {
346
+ cam.x = x;
347
+ cam.y = y;
348
+ cam.s = s;
349
+ mx = (2 * s) / sw;
350
+ bx = -x * mx - 1;
351
+ my = (-2 * s) / sh;
352
+ by = 1 - y * my;
353
+ }
354
+
355
+ const self = {
356
+ textures,
357
+ white,
358
+ cam,
359
+ clearColor,
360
+ // Per-frame state; (w, h) is the screen size in pixels.
361
+ beginFrame(w, h) {
362
+ sw = w;
363
+ sh = h;
364
+ vn = 0;
365
+ inn = 0;
366
+ nb = 0;
367
+ cur = null;
368
+ clearColor[0] = clearColor[1] = clearColor[2] = 0;
369
+ clearColor[3] = 1;
370
+ setCamera(0, 0, 1);
371
+ },
372
+ get f32() { return f32; },
373
+ get idx() { return idx; },
374
+ get vertexCount() { return vn; },
375
+ get indexCount() { return inn; },
376
+ get batchCount() { return nb; },
377
+ batches,
378
+ // Everything drawn so far this frame is discarded, as clearing the screen does.
379
+ clear(r, g, b, a) {
380
+ vn = 0;
381
+ inn = 0;
382
+ nb = 0;
383
+ cur = null;
384
+ clearColor[0] = clamp01(r);
385
+ clearColor[1] = clamp01(g);
386
+ clearColor[2] = clamp01(b);
387
+ clearColor[3] = clamp01(a);
388
+ },
389
+ drawRect(x, y, w, h, r, g, b, a) {
390
+ quad(white, x, y, x + w, y, x + w, y + h, x, y + h, 0, 0, 1, 1, packColor(r, g, b, a));
391
+ },
392
+ drawLine(x1, y1, x2, y2, th, r, g, b, a) {
393
+ const dx = x2 - x1;
394
+ const dy = y2 - y1;
395
+ const len = Math.sqrt(dx * dx + dy * dy);
396
+ if (!(len > 0)) return;
397
+ const nx = (-dy / len) * (th / 2);
398
+ const ny = (dx / len) * (th / 2);
399
+ quad(white, x1 + nx, y1 + ny, x2 + nx, y2 + ny, x2 - nx, y2 - ny, x1 - nx, y1 - ny, 0, 0, 1, 1, packColor(r, g, b, a));
400
+ },
401
+ drawCircle(x, y, rad, r, g, b, a) {
402
+ if (!(rad > 0)) return;
403
+ const n = Math.min(MAX_SEGMENTS, Math.max(24, Math.ceil(rad * cam.s * 0.6)));
404
+ reserve(n + 1, n * 3);
405
+ useTex(white);
406
+ const c = packColor(r, g, b, a);
407
+ const base = vn;
408
+ vert(x, y, 0.5, 0.5, c);
409
+ const t = circleTable(n);
410
+ for (let i = 0; i < n; i++) vert(x + rad * t[2 * i], y + rad * t[2 * i + 1], 0.5, 0.5, c);
411
+ for (let i = 0; i < n; i++) {
412
+ idx[inn] = base;
413
+ idx[inn + 1] = base + 1 + i;
414
+ idx[inn + 2] = base + 1 + ((i + 1) % n);
415
+ inn += 3;
416
+ }
417
+ cur.count += n * 3;
418
+ },
419
+ // `x, y` is the text's top-left. The run is rasterised at its on-screen
420
+ // size (size * camera scale), so it is drawn 1:1 in screen pixels.
421
+ drawText(str, x, y, size, r, g, b, a) {
422
+ if (!str) return;
423
+ const px = Math.max(1, Math.round(size * cam.s));
424
+ const t = env.textTexture(str, px);
425
+ if (!t) return;
426
+ const s = cam.s;
427
+ const x0 = x - t.pad / s;
428
+ const y0 = y - t.pad / s;
429
+ const x1 = x0 + t.w / s;
430
+ const y1 = y0 + t.h / s;
431
+ quad(t, x0, y0, x1, y0, x1, y1, x0, y1, 0, 0, 1, 1, packColor(r, g, b, a));
432
+ },
433
+ setCamera,
434
+ resetCamera() {
435
+ setCamera(0, 0, 1);
436
+ },
437
+ textureWidth: (h) => (textures[h] ? textures[h].w : 0),
438
+ textureHeight: (h) => (textures[h] ? textures[h].h : 0),
439
+ drawTexture(h, x, y, r, g, b, a) {
440
+ const t = textures[h];
441
+ if (!t) return;
442
+ quad(t, x, y, x + t.w, y, x + t.w, y + t.h, x, y + t.h, 0, 0, 1, 1, packColor(r, g, b, a));
443
+ },
444
+ drawTextureEx(h, sx, sy, sw_, sh_, x, y, w, ht, rotation, flip, r, g, b, a) {
445
+ const t = textures[h];
446
+ if (!t || sw_ === 0 || sh_ === 0) return;
447
+ let u0 = sx / t.w;
448
+ let u1 = (sx + sw_) / t.w;
449
+ const v0 = sy / t.h;
450
+ const v1 = (sy + sh_) / t.h;
451
+ if (flip) {
452
+ const tmp = u0;
453
+ u0 = u1;
454
+ u1 = tmp;
455
+ }
456
+ const c = packColor(r, g, b, a);
457
+ if (rotation === 0) {
458
+ quad(t, x, y, x + w, y, x + w, y + ht, x, y + ht, u0, v0, u1, v1, c);
459
+ return;
460
+ }
461
+ // Rotated clockwise (y points down) about the dest centre.
462
+ const cx = x + w / 2;
463
+ const cy = y + ht / 2;
464
+ const hw = w / 2;
465
+ const hh = ht / 2;
466
+ const co = Math.cos(rotation);
467
+ const si = Math.sin(rotation);
468
+ quad(
469
+ t,
470
+ cx - hw * co + hh * si, cy - hw * si - hh * co,
471
+ cx + hw * co + hh * si, cy + hw * si - hh * co,
472
+ cx + hw * co - hh * si, cy + hw * si + hh * co,
473
+ cx - hw * co - hh * si, cy - hw * si + hh * co,
474
+ u0, v0, u1, v1, c
475
+ );
476
+ },
477
+ };
478
+ return self;
479
+ }
480
+
481
+ // --- command buffer ---------------------------------------------------------------
482
+
483
+ // Decodes one frame of commands (little-endian, 4-byte fields; see
484
+ // docs/plans/2026-11-02-render-experiments.md) from `view` and feeds `r`,
485
+ // which has the batcher's draw methods. Returns the number of commands.
486
+ function decodeCommands(view, r) {
487
+ const end = view.byteLength;
488
+ const dec = new TextDecoder();
489
+ let p = 0;
490
+ let n = 0;
491
+ const f = () => {
492
+ const v = view.getFloat32(p, true);
493
+ p += 4;
494
+ return v;
495
+ };
496
+ const i = () => {
497
+ const v = view.getInt32(p, true);
498
+ p += 4;
499
+ return v;
500
+ };
501
+ while (p < end) {
502
+ const op = view.getUint32(p, true);
503
+ p += 4;
504
+ n++;
505
+ switch (op) {
506
+ case 1: r.clear(f(), f(), f(), f()); break;
507
+ case 2: r.drawRect(f(), f(), f(), f(), f(), f(), f(), f()); break;
508
+ case 3: r.drawLine(f(), f(), f(), f(), f(), f(), f(), f(), f()); break;
509
+ case 4: r.drawCircle(f(), f(), f(), f(), f(), f(), f()); break;
510
+ case 5: {
511
+ const x = f(), y = f(), size = f(), cr = f(), cg = f(), cb = f(), ca = f();
512
+ const len = i();
513
+ const str = dec.decode(new Uint8Array(view.buffer, view.byteOffset + p, len));
514
+ p += (len + 3) & ~3;
515
+ r.drawText(str, x, y, size, cr, cg, cb, ca);
516
+ break;
517
+ }
518
+ case 6: {
519
+ const h = i();
520
+ r.drawTexture(h, f(), f(), f(), f(), f(), f());
521
+ break;
522
+ }
523
+ case 7: {
524
+ const h = i();
525
+ const sx = f(), sy = f(), sw = f(), sh = f(), x = f(), y = f(), w = f(), ht = f(), rot = f();
526
+ const flip = i();
527
+ r.drawTextureEx(h, sx, sy, sw, sh, x, y, w, ht, rot, flip, f(), f(), f(), f());
528
+ break;
529
+ }
530
+ case 8: r.setCamera(f(), f(), f()); break;
531
+ case 9: r.resetCamera(); break;
532
+ default:
533
+ throw new Error("command buffer: unknown opcode " + op + " at byte " + (p - 4));
534
+ }
535
+ }
536
+ return n;
537
+ }
538
+
539
+ // --- backends -----------------------------------------------------------------------
540
+ // A backend: { name, createTexture(source, w, h, smooth) -> { w, h, ...gpu fields },
541
+ // setSmooth(tex, smooth), destroyTexture(tex), submit(batcher, w, h) }.
542
+
543
+ const WGSL = `
544
+ struct VOut {
545
+ @builtin(position) p: vec4f,
546
+ @location(0) uv: vec2f,
547
+ @location(1) c: vec4f,
548
+ };
549
+ @vertex fn vs(@location(0) p: vec2f, @location(1) uv: vec2f, @location(2) c: vec4f) -> VOut {
550
+ var o: VOut;
551
+ o.p = vec4f(p, 0.0, 1.0);
552
+ o.uv = uv;
553
+ o.c = c;
554
+ return o;
555
+ }
556
+ @group(0) @binding(0) var tex: texture_2d<f32>;
557
+ @group(0) @binding(1) var smp: sampler;
558
+ @fragment fn fs(i: VOut) -> @location(0) vec4f {
559
+ return textureSample(tex, smp, i.uv) * i.c;
560
+ }`;
561
+
562
+ async function makeWebGPU(canvas, white) {
563
+ if (!navigator.gpu) return null;
564
+ const adapter = await navigator.gpu.requestAdapter();
565
+ if (!adapter) return null;
566
+ const device = await adapter.requestDevice();
567
+ const ctx = canvas.getContext("webgpu");
568
+ if (!ctx) return null;
569
+ const format = navigator.gpu.getPreferredCanvasFormat();
570
+ ctx.configure({ device, format, alphaMode: "opaque" });
571
+ const module = device.createShaderModule({ code: WGSL });
572
+ const pipeline = device.createRenderPipeline({
573
+ layout: "auto",
574
+ vertex: {
575
+ module,
576
+ entryPoint: "vs",
577
+ buffers: [{
578
+ arrayStride: VERT_BYTES,
579
+ attributes: [
580
+ { shaderLocation: 0, format: "float32x2", offset: 0 },
581
+ { shaderLocation: 1, format: "float32x2", offset: 8 },
582
+ { shaderLocation: 2, format: "unorm8x4", offset: 16 },
583
+ ],
584
+ }],
585
+ },
586
+ fragment: {
587
+ module,
588
+ entryPoint: "fs",
589
+ targets: [{
590
+ format,
591
+ blend: {
592
+ color: { srcFactor: "src-alpha", dstFactor: "one-minus-src-alpha", operation: "add" },
593
+ alpha: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" },
594
+ },
595
+ }],
596
+ },
597
+ primitive: { topology: "triangle-list" },
598
+ });
599
+ const layout = pipeline.getBindGroupLayout(0);
600
+ const nearest = device.createSampler({ magFilter: "nearest", minFilter: "nearest" });
601
+ const linear = device.createSampler({ magFilter: "linear", minFilter: "linear" });
602
+ let vbuf = null;
603
+ let vsize = 0;
604
+ let ibuf = null;
605
+ let isize = 0;
606
+
607
+ function bind(t) {
608
+ t.bg = device.createBindGroup({
609
+ layout,
610
+ entries: [
611
+ { binding: 0, resource: t.view },
612
+ { binding: 1, resource: t.smooth ? linear : nearest },
613
+ ],
614
+ });
615
+ }
616
+ function createTexture(source, w, h, smooth) {
617
+ const gpu = device.createTexture({
618
+ size: [w, h],
619
+ format: "rgba8unorm",
620
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT,
621
+ });
622
+ if (source && source.data) device.queue.writeTexture({ texture: gpu }, source.data, { bytesPerRow: w * 4 }, [w, h]);
623
+ else if (source) device.queue.copyExternalImageToTexture({ source }, { texture: gpu }, [w, h]);
624
+ else device.queue.writeTexture({ texture: gpu }, new Uint8Array([255, 255, 255, 255]), { bytesPerRow: 4 }, [1, 1]);
625
+ const t = { w, h, gpu, view: gpu.createView(), smooth: !!smooth, bg: null };
626
+ bind(t);
627
+ return t;
628
+ }
629
+ Object.assign(white, createTexture(null, 1, 1, false));
630
+
631
+ return {
632
+ name: "gpu-webgpu",
633
+ createTexture,
634
+ setSmooth(t, smooth) {
635
+ if (t.smooth === !!smooth) return;
636
+ t.smooth = !!smooth;
637
+ bind(t);
638
+ },
639
+ destroyTexture(t) {
640
+ t.gpu.destroy();
641
+ },
642
+ submit(b) {
643
+ const vbytes = b.vertexCount * VERT_BYTES;
644
+ const ibytes = b.indexCount * 4;
645
+ if (vbytes > vsize) {
646
+ if (vbuf) vbuf.destroy();
647
+ vsize = Math.max(vbytes, vsize * 2, 1 << 16);
648
+ vbuf = device.createBuffer({ size: vsize, usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST });
649
+ }
650
+ if (ibytes > isize) {
651
+ if (ibuf) ibuf.destroy();
652
+ isize = Math.max(ibytes, isize * 2, 1 << 16);
653
+ ibuf = device.createBuffer({ size: isize, usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST });
654
+ }
655
+ if (vbytes) {
656
+ device.queue.writeBuffer(vbuf, 0, b.f32.buffer, 0, vbytes);
657
+ device.queue.writeBuffer(ibuf, 0, b.idx.buffer, 0, ibytes);
658
+ }
659
+ const [cr, cg, cb] = b.clearColor;
660
+ const enc = device.createCommandEncoder();
661
+ const pass = enc.beginRenderPass({
662
+ colorAttachments: [{
663
+ view: ctx.getCurrentTexture().createView(),
664
+ clearValue: { r: cr, g: cg, b: cb, a: 1 },
665
+ loadOp: "clear",
666
+ storeOp: "store",
667
+ }],
668
+ });
669
+ if (vbytes) {
670
+ pass.setPipeline(pipeline);
671
+ pass.setVertexBuffer(0, vbuf);
672
+ pass.setIndexBuffer(ibuf, "uint32");
673
+ for (let i = 0; i < b.batchCount; i++) {
674
+ const bt = b.batches[i];
675
+ pass.setBindGroup(0, bt.tex.bg);
676
+ pass.drawIndexed(bt.count, 1, bt.start);
677
+ }
678
+ }
679
+ pass.end();
680
+ device.queue.submit([enc.finish()]);
681
+ },
682
+ };
683
+ }
684
+
685
+ // --- text -------------------------------------------------------------------------
686
+
687
+ const TEXT_PAD = 1;
688
+ const TEXT_CACHE_MAX = 512;
689
+
690
+ // Rasterises (string, px) runs once into backend textures, LRU-evicted.
691
+ function makeTextCache(getBackend) {
692
+ const cache = new Map(); // "px|str" -> { tex, frame }; Map order = LRU order
693
+ let frameNo = 0;
694
+ let scratch = null;
695
+ let ctx2d = null;
696
+ return {
697
+ textTexture(str, px) {
698
+ const key = px + "|" + str;
699
+ let e = cache.get(key);
700
+ if (e) {
701
+ cache.delete(key);
702
+ cache.set(key, e);
703
+ e.frame = frameNo;
704
+ return e.tex;
705
+ }
706
+ if (!scratch) {
707
+ scratch = typeof OffscreenCanvas !== "undefined" ? new OffscreenCanvas(8, 8) : document.createElement("canvas");
708
+ ctx2d = scratch.getContext("2d", { willReadFrequently: false });
709
+ }
710
+ ctx2d.font = px + "px sans-serif";
711
+ const w = Math.min(4096, Math.ceil(ctx2d.measureText(str).width) + 2 * TEXT_PAD);
712
+ const h = Math.min(4096, Math.ceil(px * 1.4) + 2 * TEXT_PAD);
713
+ scratch.width = w;
714
+ scratch.height = h;
715
+ ctx2d.font = px + "px sans-serif";
716
+ ctx2d.textBaseline = "top";
717
+ ctx2d.fillStyle = "#fff";
718
+ ctx2d.fillText(str, TEXT_PAD, TEXT_PAD);
719
+ const tex = getBackend().createTexture(ctx2d.getImageData(0, 0, w, h), w, h, true);
720
+ tex.pad = TEXT_PAD;
721
+ cache.set(key, (e = { tex, frame: frameNo }));
722
+ return tex;
723
+ },
724
+ // Evicts least-recently-used runs not used this frame (their textures may be queued).
725
+ endFrame() {
726
+ frameNo++;
727
+ if (cache.size <= TEXT_CACHE_MAX) return;
728
+ for (const [k, e] of cache) {
729
+ if (cache.size <= TEXT_CACHE_MAX || e.frame >= frameNo - 1) break;
730
+ getBackend().destroyTexture(e.tex);
731
+ cache.delete(k);
732
+ }
733
+ },
734
+ };
735
+ }
736
+
737
+ // --- start-up --------------------------------------------------------------------
738
+
739
+ async function main() {
740
+ const canvas = document.getElementById("glcanvas");
741
+ const size = () => {
742
+ const w = Math.max(1, window.innerWidth | 0);
743
+ const h = Math.max(1, window.innerHeight | 0);
744
+ if (canvas.width !== w) canvas.width = w;
745
+ if (canvas.height !== h) canvas.height = h;
746
+ };
747
+ size();
748
+
749
+ let backend = null;
750
+ const text = makeTextCache(() => backend);
751
+ const b = makeBatcher({ textTexture: text.textTexture });
752
+ try {
753
+ backend = await makeWebGPU(canvas, b.white);
754
+ } catch (e) {
755
+ console.error("WebGPU failed to start:", e);
756
+ }
757
+ if (!backend) {
758
+ const msg = "WebGPU is not supported in this browser";
759
+ console.error(msg);
760
+ document.body.append(Object.assign(document.createElement("pre"), { textContent: msg, style: "position:fixed;top:0;left:0;color:#f55;margin:8px;font-size:18px" }));
761
+ return;
762
+ }
763
+ globalThis.plumRenderer = backend.name;
764
+ console.log("plumRenderer: " + backend.name);
765
+
766
+ const files = await preload();
767
+ const images = new Map();
768
+ const audio = makeAudio();
769
+ await Promise.all(
770
+ [...files].map(async ([path, bytes]) => {
771
+ if (IMAGE_EXT.test(path)) {
772
+ try {
773
+ const bitmap = await createImageBitmap(new Blob([bytes]));
774
+ images.set(path, { bitmap, w: bitmap.width, h: bitmap.height });
775
+ } catch (e) {
776
+ console.warn(path + ": could not decode image", e);
777
+ }
778
+ } else if (SOUND_EXT.test(path)) {
779
+ await audio.decode(path, bytes);
780
+ }
781
+ })
782
+ );
783
+
784
+ const input = makeInput(canvas);
785
+ const ctx = { plum: null };
786
+ const fileStrs = new Map(); // path -> Plum Str, built once per path
787
+ let dt = 1 / 60;
788
+
789
+ const host = {
790
+ screenWidth: () => canvas.width,
791
+ screenHeight: () => canvas.height,
792
+ frameTime: () => dt,
793
+ clear: b.clear,
794
+ drawRect: b.drawRect,
795
+ drawCircle: b.drawCircle,
796
+ drawLine: b.drawLine,
797
+ drawText: (s, ...rest) => b.drawText(strToJs(ctx.plum, s), ...rest),
798
+ setCamera: b.setCamera,
799
+ resetCamera: b.resetCamera,
800
+ textureLoad: (s) => {
801
+ const p = normPath(strToJs(ctx.plum, s));
802
+ const img = p && images.get(p);
803
+ if (!img) return -1;
804
+ return b.textures.push(backend.createTexture(img.bitmap, img.w, img.h, false)) - 1;
805
+ },
806
+ textureWidth: b.textureWidth,
807
+ textureHeight: b.textureHeight,
808
+ textureSetSmooth: (h, smooth) => {
809
+ if (b.textures[h]) backend.setSmooth(b.textures[h], smooth !== 0);
810
+ },
811
+ drawTexture: b.drawTexture,
812
+ drawTextureEx: b.drawTextureEx,
813
+ fileExists: (s) => {
814
+ const p = normPath(strToJs(ctx.plum, s));
815
+ return p && files.has(p) ? 1 : 0;
816
+ },
817
+ fileLoad: (s) => {
818
+ const p = normPath(strToJs(ctx.plum, s));
819
+ const key = p && files.has(p) ? p : "";
820
+ let str = fileStrs.get(key);
821
+ if (str === undefined) {
822
+ str = bytesToStr(ctx.plum, key ? files.get(key) : new Uint8Array(0));
823
+ fileStrs.set(key, str);
824
+ }
825
+ return str;
826
+ },
827
+ soundLoad: (s) => {
828
+ const p = normPath(strToJs(ctx.plum, s));
829
+ return p ? audio.load(p) : -1;
830
+ },
831
+ soundPlay: audio.play,
832
+ soundLoop: audio.loop,
833
+ soundStop: audio.stop,
834
+ soundSetVolume: audio.setVolume,
835
+ isKeyDown: input.isKeyDown,
836
+ isKeyPressed: input.isKeyPressed,
837
+ mousePos: () => [input.mouse.x, input.mouse.y],
838
+ isMouseDown: input.isMouseDown,
839
+ isMousePressed: input.isMousePressed,
840
+ mouseWheel: input.mouseWheel,
841
+ printLn: (s) => console.log(strToJs(ctx.plum, s)),
842
+ crash: (s) => {
843
+ throw new Error("crash: " + strToJs(ctx.plum, s));
844
+ },
845
+ };
846
+
847
+ const module = await WebAssembly.compileStreaming(fetch("plum.wasm"));
848
+ const plumImports = {};
849
+ for (const imp of WebAssembly.Module.imports(module)) {
850
+ if (imp.module !== "plum" || imp.kind !== "function") continue;
851
+ plumImports[imp.name] =
852
+ host[imp.name] ||
853
+ (() => {
854
+ throw new Error("host function '" + imp.name + "' is not available here");
855
+ });
856
+ }
857
+ const instance = await WebAssembly.instantiate(module, { plum: plumImports });
858
+ ctx.plum = instance.exports;
859
+ const ex = ctx.plum;
860
+ const cmdMode = !!(ex.cmdPtr && ex.cmdLen && ex.cmdReset && ex.memory);
861
+ globalThis.plumCommandBuffer = cmdMode;
862
+ console.log("plumCommandBuffer: " + cmdMode);
863
+ const state = ex.init();
864
+
865
+ let last = performance.now();
866
+ function frame(now) {
867
+ try {
868
+ dt = Math.max((now - last) / 1000, 0);
869
+ last = now;
870
+ size();
871
+ input.beginFrame();
872
+ b.beginFrame(canvas.width, canvas.height);
873
+ ex.update(state, dt);
874
+ if (cmdMode) {
875
+ ex.cmdReset();
876
+ ex.draw(state);
877
+ // memory may have grown since last frame: take a fresh view
878
+ decodeCommands(new DataView(ex.memory.buffer, ex.cmdPtr(), ex.cmdLen()), b);
879
+ } else {
880
+ ex.draw(state);
881
+ }
882
+ backend.submit(b, canvas.width, canvas.height);
883
+ text.endFrame();
884
+ } catch (e) {
885
+ console.error("frame failed:", e);
886
+ return;
887
+ }
888
+ requestAnimationFrame(frame);
889
+ }
890
+ requestAnimationFrame((now) => {
891
+ last = now - 1000 / 60;
892
+ frame(now);
893
+ });
894
+ }
895
+
896
+ globalThis.PlumGpu = { makeBatcher, decodeCommands, packColor, VERT_BYTES };
897
+
898
+ if (typeof document !== "undefined") {
899
+ main().catch((e) => {
900
+ console.error("plum game failed to start:", e);
901
+ document.body.append(Object.assign(document.createElement("pre"), { textContent: String(e), style: "position:fixed;top:0;left:0;color:#f55;margin:8px" }));
902
+ });
903
+ }
904
+ })();