plum

#treesitter#compiler#wasm

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

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


4f8f99a — Peter John 2026-11-02T17:00:46+05:30
web-test: always the own renderer, --assets passthrough; benchmark outcome note; regenerate docs/game.png
docs/game.png CHANGED
Binary file
plum-game/web-test/BENCHMARK.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # Web renderer benchmark: macroquad vs PixiJS vs our gpu renderer
2
2
 
3
+ **Outcome:** browser-only engine on the gpu renderer (per-call); macroquad, Pixi and the command buffer were removed after this benchmark (see git history).
4
+
3
5
  Browser renderers for `plum game --web`: the default macroquad-on-wasm32, the
4
6
  experimental `--renderer pixi` (PixiJS 8.22.0) and the experimental
5
7
  `--renderer gpu` (our own batcher, WebGPU with a WebGL2 fallback, no library),
plum-game/web-test/README.md CHANGED
@@ -1,14 +1,14 @@
1
1
  # plum-web-test
2
2
 
3
- Browser check for `plum game --web` builds, driven by [playwright-rs](https://crates.io/crates/playwright-rs) 0.20.
3
+ Browser check for `plum game` builds, driven by [playwright-rs](https://crates.io/crates/playwright-rs) 0.20.
4
4
  Standalone crate (own `[workspace]`, not part of the root workspace).
5
5
 
6
- It builds `plum-cli`, exports the game with `plum game --web`, serves the output on
6
+ It builds `plum-cli`, exports the game with `plum game -o`, serves the output on
7
- 127.0.0.1:0, opens it in headless Chromium, clicks the canvas once, then asserts:
7
+ 127.0.0.1:0, opens it in headless Chromium (software WebGPU), clicks the canvas once, then asserts:
8
- the canvas is not one uniform colour, two screenshots 1 s apart differ (the game
9
- animates), and there are no page errors or console errors (except the known harmless
8
+ the page set `plumRenderer` (WebGPU started), the canvas is not one uniform colour, two
10
- macroquad `register_plugin is not defined`). AudioContext state is reported; a missing
9
+ screenshots 1 s apart differ (the game animates), and there are no page or console errors.
11
- running context is only a warning unless `--require-audio` is given.
10
+ AudioContext state is reported; a missing running context is only a warning unless
11
+ `--require-audio` is given.
12
12
 
13
13
  ## One-time setup
14
14
 
@@ -21,8 +21,8 @@ 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|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, the renderer from `plumRenderer` and the mean per-frame update/draw/render split; `--json` adds `update_ms`, `draw_ms`, `render_ms`) 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`, `--assets DIR` (default: `assets` beside the source), `--require-audio`, `--json` (adds a one-line JSON object with the backend and frame stats), `--headed` (a real GPU window instead of headless software WebGPU), `--measure SECS` (records requestAnimationFrame intervals in the page and prints avg / p50 / p95 / p99 ms, fps and the mean per-frame update/draw/render split; `--json` adds `update_ms`, `draw_ms`, `render_ms`) and `--uncapped` (lifts vsync so the frame time is the work per frame). `fixtures/stress.plum` and `fixtures/stress_scene.plum` are the scenes 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
28
- `plum-examples/game.plum` with both renderers; it skips with a note if Chromium is not installed.
28
+ `plum-examples/game.plum` and `plum-examples/scene.plum`; it skips with a note if Chromium is not installed.
plum-game/web-test/src/lib.rs CHANGED
@@ -1,4 +1,4 @@
1
- //! Browser check for `plum game --web` builds, driven by playwright-rs.
1
+ //! Browser check for `plum game` builds, driven by playwright-rs.
2
2
  use playwright_rs::{LaunchOptions, Playwright};
3
3
  use std::io::{Read, Write};
4
4
  use std::net::{TcpListener, TcpStream};
@@ -8,34 +8,17 @@ use std::sync::atomic::{AtomicBool, Ordering};
8
8
  use std::sync::{Arc, Mutex};
9
9
  use std::time::Duration;
10
10
 
11
- /// The browser renderer of the web build under test.
12
- #[derive(Clone, Copy, Debug, PartialEq, Eq)]
13
- pub enum Renderer {
14
- Macroquad,
15
- Pixi,
16
- Gpu,
17
- }
18
-
19
- impl Renderer {
20
- pub fn name(self) -> &'static str {
21
- match self {
22
- Renderer::Macroquad => "macroquad",
23
- Renderer::Pixi => "pixi",
24
- Renderer::Gpu => "gpu",
25
- }
26
- }
27
- }
28
-
29
11
  pub struct Options {
30
12
  pub frames_wait: f64,
31
13
  pub screenshot: Option<PathBuf>,
32
14
  pub require_audio: bool,
15
+ /// Asset directory passed to `plum game --assets` (default: `assets` beside the source).
33
- pub renderer: Renderer,
16
+ pub assets: Option<PathBuf>,
34
- /// A real (GPU) browser window instead of headless software GL.
17
+ /// A real (GPU) browser window instead of headless software WebGPU.
35
18
  pub headed: bool,
36
19
  /// Record requestAnimationFrame intervals for this many seconds and report them.
37
20
  pub measure: Option<f64>,
38
- /// Append a one-line JSON object with the renderer and frame statistics to the report.
21
+ /// Append a one-line JSON object with the frame statistics to the report.
39
22
  pub json: bool,
40
23
  /// Lift Chromium's vsync / frame-rate cap so frame times show the work per frame.
41
24
  pub uncapped: bool,
@@ -47,7 +30,7 @@ impl Default for Options {
47
30
  frames_wait: 8.0,
48
31
  screenshot: None,
49
32
  require_audio: false,
50
- renderer: Renderer::Macroquad,
33
+ assets: None,
51
34
  headed: false,
52
35
  measure: None,
53
36
  json: false,
@@ -88,28 +71,26 @@ fn json_str(s: &str) -> String {
88
71
  }
89
72
 
90
73
  /// Mean per-frame phase times (ms) from the in-page `__plumMeasure` counters.
91
- /// `render_ms` is None where the renderer cannot separate it (macroquad).
92
74
  #[derive(Debug, PartialEq)]
93
75
  pub struct Phases {
94
76
  pub update_ms: f64,
95
77
  pub draw_ms: f64,
96
- pub render_ms: Option<f64>,
78
+ pub render_ms: f64,
97
79
  }
98
80
 
99
81
  /// Phases from the page counters `{n, u, d, r}` (frames, summed update/draw/render ms).
100
- pub fn phases_from(c: &[f64], has_render: bool) -> Option<Phases> {
82
+ pub fn phases_from(c: &[f64]) -> Option<Phases> {
101
83
  if c.len() < 4 || c[0] < 1.0 {
102
84
  return None;
103
85
  }
104
- Some(Phases { update_ms: c[1] / c[0], draw_ms: c[2] / c[0], render_ms: has_render.then(|| c[3] / c[0]) })
86
+ Some(Phases { update_ms: c[1] / c[0], draw_ms: c[2] / c[0], render_ms: c[3] / c[0] })
105
87
  }
106
88
 
107
- /// One JSON object: renderer, backend (`plumRenderer`), headed, uncapped, the frame statistics
89
+ /// One JSON object: backend (`plumRenderer`), headed, uncapped, the frame statistics
108
90
  /// and (when measured) update_ms / draw_ms / render_ms.
109
91
  pub fn stats_json(opts: &Options, backend: Option<&str>, st: Option<&FrameStats>, ph: Option<&Phases>) -> String {
110
92
  let mut s = format!(
111
- "{{\"renderer\":{},\"backend\":{},\"headed\":{},\"uncapped\":{}",
93
+ "{{\"backend\":{},\"headed\":{},\"uncapped\":{}",
112
- json_str(opts.renderer.name()),
113
94
  backend.map(json_str).unwrap_or_else(|| "null".into()),
114
95
  opts.headed,
115
96
  opts.uncapped
@@ -123,11 +104,7 @@ pub fn stats_json(opts: &Options, backend: Option<&str>, st: Option<&FrameStats>
123
104
  }
124
105
  if let Some(ph) = ph {
125
106
  s.pop();
126
- s.push_str(&format!(",\"update_ms\":{:.3},\"draw_ms\":{:.3}", ph.update_ms, ph.draw_ms));
107
+ s.push_str(&format!(",\"update_ms\":{:.3},\"draw_ms\":{:.3},\"render_ms\":{:.3}}}", ph.update_ms, ph.draw_ms, ph.render_ms));
127
- match ph.render_ms {
128
- Some(r) => s.push_str(&format!(",\"render_ms\":{r:.3}}}")),
129
- None => s.push_str(",\"render_ms\":null}"),
130
- }
131
108
  }
132
109
  s
133
110
  }
@@ -155,8 +132,8 @@ pub fn repo_root() -> PathBuf {
155
132
  Path::new(env!("CARGO_MANIFEST_DIR")).join("../..").canonicalize().expect("repo root")
156
133
  }
157
134
 
158
- /// Builds plum-cli and exports `file` as a web build into `out`.
135
+ /// Builds plum-cli and exports `file` as a static web build into `out`.
159
- pub fn build_web(file: &Path, out: &Path, renderer: Renderer) -> Result<(), String> {
136
+ pub fn build_web(file: &Path, out: &Path, assets: Option<&Path>) -> Result<(), String> {
160
137
  let root = repo_root();
161
138
  let st = Command::new("cargo")
162
139
  .args(["build", "-p", "plum-cli"])
@@ -167,17 +144,15 @@ pub fn build_web(file: &Path, out: &Path, renderer: Renderer) -> Result<(), Stri
167
144
  return Err("cargo build -p plum-cli failed".into());
168
145
  }
169
146
  let file = file.canonicalize().map_err(|e| format!("{}: {e}", file.display()))?;
170
- let o = Command::new(root.join("target/debug/plum"))
147
+ let mut cmd = Command::new(root.join("target/debug/plum"));
171
- .args(["game", "--web", "--renderer", renderer.name()])
148
+ cmd.arg("game").arg(&file).arg("-o").arg(out);
172
- .arg(&file)
173
- .arg("-o")
174
- .arg(out)
175
- .current_dir(&root)
149
+ if let Some(a) = assets {
176
- .output()
150
+ cmd.arg("--assets").arg(a.canonicalize().map_err(|e| format!("{}: {e}", a.display()))?);
151
+ }
177
- .map_err(|e| format!("plum game --web: {e}"))?;
152
+ let o = cmd.current_dir(&root).output().map_err(|e| format!("plum game: {e}"))?;
178
153
  if !o.status.success() {
179
154
  return Err(format!(
180
- "plum game --web failed:\n{}{}",
155
+ "plum game failed:\n{}{}",
181
156
  String::from_utf8_lossy(&o.stdout),
182
157
  String::from_utf8_lossy(&o.stderr)
183
158
  ));
@@ -305,7 +280,7 @@ pub async fn run(file: &Path, opts: &Options) -> Result<String, String> {
305
280
  }
306
281
 
307
282
  async fn run_in(file: &Path, opts: &Options, dir: &Path) -> Result<String, String> {
308
- build_web(file, dir, opts.renderer)?;
283
+ build_web(file, dir, opts.assets.as_deref())?;
309
284
  let stop = Arc::new(AtomicBool::new(false));
310
285
  let port = serve(dir.to_path_buf(), stop.clone());
311
286
  let res = check(port, opts).await;
@@ -321,12 +296,10 @@ fn pe<E: std::fmt::Display>(what: &'static str) -> impl FnOnce(E) -> String {
321
296
  async fn check(port: u16, opts: &Options) -> Result<String, String> {
322
297
  let pw = Playwright::launch().await.map_err(pe("launch playwright"))?;
323
298
  let mut args: Vec<String> = ["--ignore-gpu-blocklist", "--autoplay-policy=no-user-gesture-required"].map(String::from).to_vec();
299
+ // The renderer is WebGPU only: headless Chromium needs a software adapter.
300
+ args.extend(["--enable-unsafe-webgpu", "--enable-features=Vulkan"].map(String::from));
324
301
  if !opts.headed {
325
- args.extend(["--use-angle=swiftshader", "--enable-unsafe-swiftshader"].map(String::from));
326
- if opts.renderer == Renderer::Gpu {
327
- // The gpu renderer is WebGPU only: give headless Chromium a software adapter.
328
- args.extend(["--enable-unsafe-webgpu", "--enable-features=Vulkan", "--use-webgpu-adapter=swiftshader"].map(String::from));
302
+ args.extend(["--use-angle=swiftshader", "--enable-unsafe-swiftshader", "--use-webgpu-adapter=swiftshader"].map(String::from));
329
- }
330
303
  }
331
304
  if opts.uncapped {
332
305
  args.extend(["--disable-gpu-vsync", "--disable-frame-rate-limit"].map(String::from));
@@ -348,7 +321,7 @@ async fn check_page(browser: &playwright_rs::protocol::Browser, port: u16, opts:
348
321
  let (errors, logs) = (errors.clone(), logs.clone());
349
322
  async move {
350
323
  let line = format!("console.{}: {}", m.type_(), m.text());
351
- if m.type_() == "error" && !m.text().contains("register_plugin is not defined") {
324
+ if m.type_() == "error" {
352
325
  errors.lock().unwrap().push(line.clone());
353
326
  }
354
327
  logs.lock().unwrap().push(line);
@@ -363,9 +336,7 @@ async fn check_page(browser: &playwright_rs::protocol::Browser, port: u16, opts:
363
336
  page.on_pageerror(move |m| {
364
337
  let errors = errors.clone();
365
338
  async move {
366
- if !m.contains("register_plugin is not defined") {
367
- errors.lock().unwrap().push(format!("pageerror: {m}"));
339
+ errors.lock().unwrap().push(format!("pageerror: {m}"));
368
- }
369
340
  Ok(())
370
341
  }
371
342
  })
@@ -403,7 +374,7 @@ async fn check_page(browser: &playwright_rs::protocol::Browser, port: u16, opts:
403
374
  .evaluate("() => { const m = window.__plumMeasure; window.__plumMeasure = null; return m ? [m.n, m.u, m.d, m.r] : []; }", None::<&()>)
404
375
  .await
405
376
  .unwrap_or_default();
406
- phases = phases_from(&c, opts.renderer != Renderer::Macroquad);
377
+ phases = phases_from(&c);
407
378
  }
408
379
 
409
380
  let png1 = page.screenshot(None).await.map_err(pe("screenshot"))?;
@@ -428,8 +399,7 @@ async fn check_page(browser: &playwright_rs::protocol::Browser, port: u16, opts:
428
399
  if audio.is_empty() { "no AudioContext created".to_string() } else { format!("AudioContext states {audio:?}") },
429
400
  );
430
401
  report.push_str(&format!(
431
- "\nrenderer: {} ({}, {})",
402
+ "\nrenderer: {}, {}",
432
- opts.renderer.name(),
433
403
  renderer.as_deref().map(|r| format!("plumRenderer={r}")).unwrap_or_else(|| "plumRenderer not set".into()),
434
404
  if opts.headed { "headed" } else { "headless" },
435
405
  ));
@@ -449,7 +419,7 @@ async fn check_page(browser: &playwright_rs::protocol::Browser, port: u16, opts:
449
419
  "\nphases per frame: update {:.3} ms, draw {:.3} ms, render {}",
450
420
  ph.update_ms,
451
421
  ph.draw_ms,
452
- ph.render_ms.map(|r| format!("{r:.3} ms")).unwrap_or_else(|| "n/a".into())
422
+ format!("{:.3} ms", ph.render_ms)
453
423
  ));
454
424
  }
455
425
  } else if opts.measure.is_some() {
@@ -459,8 +429,8 @@ async fn check_page(browser: &playwright_rs::protocol::Browser, port: u16, opts:
459
429
  report.push_str(&format!("\n{}", stats_json(opts, renderer.as_deref(), measured.as_ref(), phases.as_ref())));
460
430
  }
461
431
  let mut failures = vec![];
462
- if matches!(opts.renderer, Renderer::Pixi | Renderer::Gpu) && renderer.is_none() {
432
+ if renderer.is_none() {
463
- failures.push(format!("{} build did not set globalThis.plumRenderer", opts.renderer.name()));
433
+ failures.push("the page did not set globalThis.plumRenderer (WebGPU failed to start?)".to_string());
464
434
  }
465
435
  if colours <= 1 {
466
436
  failures.push("canvas is blank (uniformly one colour)".to_string());
plum-game/web-test/src/main.rs CHANGED
@@ -1,7 +1,7 @@
1
- use plum_web_test::{chromium_installed, run, Options, Renderer};
1
+ use plum_web_test::{chromium_installed, run, Options};
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|gpu] [--headed] [--measure SECS] [--uncapped] [--json]";
4
+ const USAGE: &str = "usage: plum-web-test <file.plum> [--frames-wait SECS] [--screenshot OUT.png] [--require-audio]\n [--assets DIR] [--headed] [--measure SECS] [--uncapped] [--json]";
5
5
 
6
6
  #[tokio::main]
7
7
  async fn main() {
@@ -19,14 +19,7 @@ async fn main() {
19
19
  "--uncapped" => opts.uncapped = true,
20
20
  "--json" => opts.json = true,
21
21
  "--measure" => opts.measure = Some(args.next().and_then(|v| v.parse().ok()).unwrap_or_else(|| fail("--measure needs a number of seconds"))),
22
- "--renderer" => {
23
- opts.renderer = match args.next().as_deref() {
24
- Some("pixi") => Renderer::Pixi,
25
- Some("gpu") => Renderer::Gpu,
26
- Some("macroquad") => Renderer::Macroquad,
27
- _ => fail("--renderer needs pixi, macroquad or gpu"),
22
+ "--assets" => opts.assets = Some(args.next().map(PathBuf::from).unwrap_or_else(|| fail("--assets needs a directory"))),
28
- }
29
- }
30
23
  "-h" | "--help" => {
31
24
  println!("{USAGE}");
32
25
  return;
plum-game/web-test/tests/web_check.rs CHANGED
@@ -1,40 +1,35 @@
1
- use plum_web_test::{chromium_installed, repo_root, run, Options, Renderer};
1
+ use plum_web_test::{chromium_installed, repo_root, run, Options};
2
2
 
3
3
  #[test]
4
4
  fn orb_run_renders_and_animates() {
5
- orb_run(Renderer::Macroquad);
5
+ orb_run("plum-examples/game.plum");
6
6
  }
7
7
 
8
8
  #[test]
9
- fn orb_run_renders_and_animates_on_pixi() {
10
- orb_run(Renderer::Pixi);
11
- }
12
-
13
- #[test]
14
- fn orb_run_renders_and_animates_on_gpu() {
9
+ fn scene_run_renders_and_animates() {
15
- orb_run(Renderer::Gpu);
10
+ orb_run("plum-examples/scene.plum");
16
11
  }
17
12
 
18
13
  #[test]
19
14
  fn stats_json_is_one_object() {
20
15
  use plum_web_test::{stats_json, FrameStats, Options};
21
16
  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() };
17
+ let o = Options { headed: true, ..Options::default() };
23
18
  assert_eq!(
24
19
  stats_json(&o, Some("gpu-webgpu"), Some(&st), None),
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}"#
20
+ r#"{"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
21
  );
27
- assert_eq!(stats_json(&o, None, None, None), r#"{"renderer":"gpu","backend":null,"headed":true,"uncapped":false}"#);
22
+ assert_eq!(stats_json(&o, None, None, None), r#"{"backend":null,"headed":true,"uncapped":false}"#);
28
23
  }
29
24
 
30
- fn orb_run(renderer: Renderer) {
25
+ fn orb_run(example: &str) {
31
26
  if !chromium_installed() {
32
27
  eprintln!("SKIP: Playwright Chromium not installed (see plum-game/web-test/README.md)");
33
28
  return;
34
29
  }
35
30
  let rt = tokio::runtime::Runtime::new().unwrap();
36
- let file = repo_root().join("plum-examples/game.plum");
31
+ let file = repo_root().join(example);
37
- let report = rt.block_on(run(&file, &Options { renderer, ..Options::default() })).unwrap_or_else(|e| panic!("{e}"));
32
+ let report = rt.block_on(run(&file, &Options::default())).unwrap_or_else(|e| panic!("{e}"));
38
33
  println!("{report}");
39
34
  }
40
35
 
@@ -64,11 +59,9 @@ fn frame_stats_are_percentiles_of_the_gaps() {
64
59
  #[test]
65
60
  fn phases_in_json() {
66
61
  use plum_web_test::{phases_from, stats_json, Options};
67
- let ph = phases_from(&[10.0, 5.0, 20.0, 3.0], true).unwrap();
62
+ let ph = phases_from(&[10.0, 5.0, 20.0, 3.0]).unwrap();
68
- assert_eq!((ph.update_ms, ph.draw_ms, ph.render_ms), (0.5, 2.0, Some(0.3)));
63
+ assert_eq!((ph.update_ms, ph.draw_ms, ph.render_ms), (0.5, 2.0, 0.3));
69
- assert!(phases_from(&[0.0, 0.0, 0.0, 0.0], true).is_none());
64
+ assert!(phases_from(&[0.0, 0.0, 0.0, 0.0]).is_none());
70
65
  let j = stats_json(&Options::default(), None, None, Some(&ph));
71
66
  assert!(j.ends_with(r#","update_ms":0.500,"draw_ms":2.000,"render_ms":0.300}"#), "{j}");
72
- let mq = phases_from(&[10.0, 5.0, 20.0, 0.0], false).unwrap();
73
- assert!(stats_json(&Options::default(), None, None, Some(&mq)).ends_with(r#""render_ms":null}"#));
74
67
  }