plum

#treesitter#compiler#wasm

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

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


8f2248b — Peter John 2026-11-02T16:32:09+05:30
Add per-frame phase split to web measurement; rewrite renderer benchmark with gpu/cmd/scene matrix
plum-game/web-test/BENCHMARK.md CHANGED
@@ -1,59 +1,85 @@
1
- # Web renderer benchmark: macroquad vs PixiJS
1
+ # Web renderer benchmark: macroquad vs PixiJS vs our gpu renderer
2
2
 
3
- Two browser renderers for `plum game --web`: the default macroquad-on-wasm32
3
+ Browser renderers for `plum game --web`: the default macroquad-on-wasm32, the
4
- (Plum -> JS -> macroquad wasm -> JS -> WebGL) and the experimental
4
+ experimental `--renderer pixi` (PixiJS 8.22.0) and the experimental
5
- `--renderer pixi` (Plum -> JS -> PixiJS 8.22.0, nothing else). This measures
5
+ `--renderer gpu` (our own batcher, WebGPU with a WebGL2 fallback, no library),
6
+ plus two ways of feeding the gpu renderer (per-call externs vs the `GameCmd`
6
- both on the stress scene and notes where the Game.plum semantics were hard to
7
+ command buffer) and two ways of structuring the game (flat immediate drawing vs
7
- match.
8
+ the Plum `Scene` tree). Plan: `docs/plans/2026-11-02-render-experiments.md`.
8
9
 
9
10
  ## Setup
10
11
 
11
- - Machine: Apple M3 Pro (`machdep.cpu.brand_string`), `arm64`, macOS 26.6.2, 120 Hz display
12
- - Browser: Chromium 156.0.8078.4 (Playwright build 1248; headless runs use its `chrome-headless-shell`)
13
- - Scene: [`fixtures/stress.plum`](fixtures/stress.plum) at 1280x720: 5000 moving rotated sprites (4-frame sheet, source rect + flipX), 500 rects, 200 circles, 50 texts whose number changes every frame, all through a camera (scale 0.8). Motion is a function of the frame counter, so every run draws the same frames.
14
- - Measure: `plum-web-test <stress.plum> --renderer R [--headed] [--uncapped] --measure 10` records the gaps between `requestAnimationFrame` timestamps over 10 s (after 6 s of warm-up), from a probe injected into the page.
15
- - Renderer picked by Pixi (`globalThis.plumRenderer`): **webgpu** when headed, **webgl** headless (no WebGPU adapter; software GL through SwiftShader).
16
- - `--uncapped` passes `--disable-gpu-vsync --disable-frame-rate-limit`, so the interval is the main-thread work per frame instead of the display refresh.
12
+ - Machine: Apple M3 Pro (`sysctl machdep.cpu.brand_string`), `arm64` (`uname -m`), macOS 26.6.2, 120 Hz display
13
+ - Browser: Playwright Chromium build 1248 (156.0.8078.4); headless runs use its `chrome-headless-shell`
14
+ - Scene (1280x720, camera scale 0.8): 5000 moving rotated sprites (4-frame sheet, source rect + flipX), 500 rects, 200 circles, 50 texts whose number changes every frame. Motion is a function of the frame counter, so every run draws the same frames.
15
+ - Fixtures (`fixtures/`): `stress.plum` (immediate, `std/Game`), `stress_cmd.plum` (immediate, `std/GameCmd` command buffer), `stress_scene.plum` (`std/Scene` tree, per-call), `stress_scene_cmd.plum` (`std/SceneCmd` tree, command buffer).
16
+ - Command: `plum-web-test <fixture> --renderer R [--headed] --uncapped --measure 10 --frames-wait 6 --json`. `--uncapped` passes `--disable-gpu-vsync --disable-frame-rate-limit`, so the rAF interval is the work per frame, not the display refresh. 6 s warm-up, then 10 s measured.
17
+ - Every cell was run twice; the table shows the run with the better average and the range of the two averages. Cells ran back to back on an otherwise idle laptop (no thermal control), so treat differences under about 5% as noise.
18
+ - Phase split (`update_ms`, `draw_ms`, `render_ms` in the JSON): mean ms per frame measured in the page with `performance.now()` around `update`, `draw` and the rest. The counters exist only while `--measure` runs (`globalThis.__plumMeasure`; a null check per frame otherwise).
19
+ - gpu per-call: draw = Plum `draw` including the extern calls that fill the batcher; render = `submit` (upload + WebGPU encode/submit).
20
+ - gpu command buffer: draw = Plum `draw` emitting commands into linear memory; render = JS decode + batching + submit.
21
+ - pixi: draw = Plum `draw` (sprite/graphics updates); render = `endFrame` + `renderer.render`.
22
+ - macroquad: only update and draw (the `plum_bridge_update/draw` callbacks) are separable; the GL work happens inside the macroquad wasm outside them, so render is n/a and `avg` is the only total.
23
+ - `performance.now()` is coarsened to about 0.1 ms in Chromium; the means over thousands of frames are still usable, single values below 0.05 ms are not.
24
+ - Native: release `plum game <fixture>` with `PLUM_GAME_MEASURE=10`. On macOS miniquad paces frames with a CVDisplayLink even when uncapped is requested, so fps is pinned to 120 Hz and only `update_ms`/`draw_ms` are comparable.
17
25
 
18
- ## Results (frame interval over 10 s)
26
+ ## Results, headed (real GPU, Chromium WebGPU/WebGL on the M3 Pro), uncapped
19
27
 
28
+ | cell | fixture | renderer | avg ms (range of 2) | p95 | fps | update | draw | render |
29
+ |---|---|---|---|---|---|---|---|---|
30
+ | A | stress | macroquad (WebGL) | 1.12 (1.12-1.13) | 1.30 | 894 | 0.000 | 1.010 | n/a |
31
+ | B | stress | pixi (WebGPU) | 2.62 (2.62-2.66) | 3.10 | 381 | 0.001 | 1.120 | 1.420 |
32
+ | C | stress | gpu per-call (WebGPU) | 1.64 (1.64-1.77) | 2.00 | 609 | 0.000 | 1.354 | 0.224 |
33
+ | D | stress_cmd | gpu command buffer | 1.77 (1.77-1.78) | 2.60 | 566 | 0.001 | 0.041 | 1.654 |
34
+ | E-C | stress_scene | gpu per-call | 2.46 (2.46-2.47) | 3.30 | 407 | 0.069 | 2.090 | 0.227 |
35
+ | E-D | stress_scene_cmd | gpu command buffer | 2.15 (2.15-2.24) | 2.60 | 465 | 0.070 | 0.502 | 1.506 |
36
+ | - | stress_scene | macroquad (WebGL) | 1.73 (1.73-1.74) | 1.90 | 579 | 0.061 | 1.548 | n/a |
37
+
38
+ (update/draw/render are from the better run; the other run was within about 0.05 ms except C draw, 1.468 vs 1.354.)
39
+
40
+ ## Results, headless (software: SwiftShader), uncapped, for reference
41
+
20
- | renderer | mode | pacing | avg ms | p50 | p95 | p99 | fps |
42
+ | fixture | renderer | avg ms (range) | p95 | fps | update | draw | render |
21
43
  |---|---|---|---|---|---|---|---|
22
- | macroquad (WebGL) | headless | vsync (60) | 16.67 | 16.70 | 16.80 | 16.80 | 60.0 |
23
- | pixi (WebGL) | headless | vsync (60) | 16.67 | 16.70 | 16.70 | 16.80 | 60.0 |
24
- | macroquad (WebGL) | headless | uncapped | 16.68 | 15.90 | 33.50 | 34.80 | 59.9 |
25
- | pixi (WebGL) | headless | uncapped | 13.31 | 12.40 | 29.30 | 32.70 | 75.1 |
26
- | macroquad (WebGL) | headed | vsync (120) | 8.33 | 8.30 | 9.00 | 9.30 | 120.0 |
44
+ | stress | macroquad | 16.35 (16.35-16.40) | 33.4 | 61 | 0.003 | 0.977 | n/a |
27
- | pixi (WebGPU) | headed | vsync (120) | 8.33 | 8.30 | 9.00 | 9.30 | 120.0 |
45
+ | stress | pixi (WebGL) | 12.95 (12.95-13.05) | 17.1 | 77 | 0.003 | 1.534 | 11.267 |
28
- | macroquad (WebGL) | headed | uncapped | 1.17 | 1.20 | 1.30 | 1.40 | 853.8 |
46
+ | stress | gpu per-call | 5.52 (5.52-5.54) | 6.1 | 181 | 0.001 | 5.152 | 0.251 |
47
+ | stress_cmd | gpu command buffer | 5.51 (5.51-5.53) | 6.1 | 182 | 0.002 | 0.037 | 5.354 |
29
- | pixi (WebGPU) | headed | uncapped | 2.73 | 2.60 | 3.50 | 3.80 | 366.3 |
48
+ | stress_scene | gpu per-call | 5.58 (5.58-5.77) | 6.2 | 179 | 0.091 | 5.116 | 0.253 |
49
+ | stress_scene_cmd | gpu command buffer | 5.46 (5.46-5.48) | 6.0 | 183 | 0.089 | 0.475 | 4.774 |
50
+ | stress_scene | macroquad | 15.50 (15.50-15.51) | 31.8 | 64 | 0.083 | 1.538 | n/a |
30
51
 
31
- ## Reading the numbers
52
+ Headless is software rendering: the GPU work runs on the CPU and competes with the main thread, and macroquad's headless frames are paced at about 60 Hz regardless of `--uncapped` (p95 of 33 ms is pacing jitter). These rows say little about a real GPU; the per-phase attribution inside headless gpu (5 ms in draw for per-call, 5 ms in render for the command buffer) is the software adapter's work landing wherever the first GPU-touching call happens.
32
53
 
33
- - Both renderers hold the display rate on this scene (60 Hz in the headless software path, 120 Hz in a real window). Nothing here shows a frame-rate difference a player would see.
34
- - Uncapped and headed, macroquad's main thread needs 1.2 ms per frame and Pixi's 2.7 ms: Pixi costs about 2.3x the CPU per frame on 5000 sprites. A one-off in-page profile of the Pixi build split a frame into roughly 1.3 ms for the 5000 `drawTextureEx` calls (per-sprite transform, tint and alpha updates on pooled Sprites), 0.5 ms for the rect/circle Graphics rebuild and 1.8 ms inside `renderer.render` (instrumented, so each figure is inflated by the timer calls). The text path is cheap, but each changed number rasterises a new canvas texture inside `render`.
35
- - Headless is software GL (SwiftShader): the GPU work runs on the CPU and the vsync cap hides it, so headless numbers only say "both keep 60 fps here". Treat the uncapped headless rows as noisy (p95/p99 of 30 ms are frame-pacing jitter in the software path, not renderer cost).
36
- - rAF intervals measure main-thread time plus pacing, not GPU time. Neither renderer was profiled on the GPU.
54
+ ## Results, native host (release, `PLUM_GAME_MEASURE=10`, vsync-pinned at 120 fps)
37
55
 
38
- ## Semantics that were hard to match
56
+ | fixture | update ms | draw ms (run 1 / 2) | present ms (waits for vsync) |
57
+ |---|---|---|---|
58
+ | stress (per-call `std/Game`) | 0.009 | 0.814 / 0.809 | 7.51 / 7.52 |
59
+ | stress_cmd (`std/GameCmd`) | 0.010 / 0.009 | 0.538 / 0.532 | 7.78 / 7.80 |
39
60
 
40
- - `clear(c)` is immediate in macroquad: it erases what was drawn before it. The Pixi host discards the frame's draw lists at `clear`, then sets the background colour.
41
- - Draw order across camera changes: rects/circles/lines go to one Graphics per run, sprites and texts are separate objects, and every camera change starts a new Container layer, so the exact call order survives as a flat scene order. A Graphics is split whenever a sprite or text interrupts it.
42
- - Text: macroquad draws a bitmap font (the glyph box top is `y`); Pixi rasterises `sans-serif` through a 2D canvas, so glyph shapes and widths differ (Pixi text is about 15% wider at the same size; see the two Orb Run screenshots). The text is rasterised at `size * camera scale` and shrunk by the layer scale to stay sharp under zoom, and colour is a tint on white text so the cache key is content + pixel size. Cached Text objects idle for 120 frames are destroyed.
43
- - `drawCircle`: macroquad draws a 20-sided polygon, Pixi a smooth circle.
44
- - `drawTextureEx`: the Pixi sprite is anchored at the dest centre with a per-handle source-rect texture, so rotation about the dest centre and flipX-before-rotate match.
45
- - `textureSetSmooth` is per handle: each `textureLoad` makes its own Pixi texture source from the shared decoded bitmap.
46
- - Sounds: macroquad drops sounds played before the first gesture; the Pixi host drops one-shots while the AudioContext is suspended but starts loops, which begin once a key press or click resumes the context. OGG decoding depends on the browser (Chromium yes, Safari no).
47
- - Input: DOM `KeyboardEvent.code` maps to GLFW numbers; DOM mouse buttons 1/2 (middle/right) swap to Plum's 2/1. `isKeyPressed` ignores key repeat. `mouseWheel` is the sign of the notches scrolled since the previous frame, positive up.
48
- - `screenWidth`/`screenHeight` are CSS pixels (resolution 1), the same as macroquad without high-DPI, so both look soft on a Retina display.
61
+ ## Analysis
62
+
63
+ **(a) Per-call vs command buffer on our gpu renderer.** Headed, they are tied within noise: 1.64-1.77 ms per-call against 1.77-1.78 ms command buffer (the per-call runs differ by 0.13 ms between themselves, more than the gap). The work moves rather than shrinks: with the command buffer Plum `draw` falls from about 1.35 ms to 0.04 ms (just writing 5750 commands into memory), but JS decode + batching + submit rises from 0.22 ms to 1.65 ms. So the per-command JS work (reading the DataView, building vertices) costs about as much as the 5750 wasm-to-JS extern calls it replaces; the boundary crossing itself is not the dominant cost in V8. The native host does benefit: draw 0.81 -> 0.53 ms. The scene tree also shows a small gain (E-D 2.15 vs E-C 2.46 ms, 0.3 ms, about 13%) because there Plum's own tree walk is no longer interleaved with extern calls; that is one pair of cells, so it is a hint, not a result.
49
64
 
50
- ## Recommendation
65
+ **(b) gpu vs macroquad vs pixi** (headed, uncapped, main-thread ms per frame): macroquad 1.12, gpu 1.64-1.77, pixi 2.62. Our gpu renderer is not faster than macroquad on this scene (about 1.5x its CPU); it is about 1.5x cheaper than pixi. All three hold 120 Hz (8.3 ms) with a large margin, so none is visible to a player here. These are rAF intervals (main-thread work plus pacing), not GPU time; GPU cost was not measured. Macroquad's draw (1.0 ms) excludes the GL flush inside its wasm, which is in the 1.12 total but not separable.
51
66
 
52
- Keep macroquad as the default for now; ship Pixi as `--renderer pixi` (experimental). Pixi matches the look and every extern, needs no second wasm module, no `pg_*` FFI layer and no Rust wasm32 toolchain for the web build (a 0.8 MB vendored script instead of the macroquad host, bundle and bridge), and WebGPU is picked automatically where the browser has it. It costs about 2x the per-frame CPU of macroquad on a very heavy sprite scene, which still fits a 120 Hz frame with 5000 sprites. The next step if the Pixi path becomes the default is to cut that cost (batch sprites through `ParticleContainer`/`Sprite` property fast paths, `BitmapText` for numbers) and to compare on a lower-end machine and a real Orb Run-sized game, where per-call overhead matters less than here.
67
+ **(c) Plum scene tree vs flat drawing, 5000 sprites.** Headed: per-call gpu 2.46 vs 1.64-1.77 ms (+0.7-0.8 ms, +40-50%); command buffer 2.15-2.24 vs 1.77 ms (+0.4-0.5 ms, +20-25%); macroquad 1.73 vs 1.12 ms (+0.6 ms). The cost is in `draw`, not `update`: update goes from 0.00 to about 0.07 ms in all three; draw rises by about 0.5 ms with the command buffer (0.04 -> 0.50 ms, that is Plum's tree traversal alone, about 100 ns per node), by 0.6-0.7 ms per-call and 0.54 ms on macroquad. Render is unchanged (same sprites reach the GPU). Native shows the same direction only for flat fixtures (the scene fixtures were not run natively).
68
+
69
+ **(d) Recommendation for a browser-only WebGPU engine.** Build on the gpu renderer: it needs no Rust/wasm32 toolchain or second wasm module, and 5750 draws per frame cost 1.6-1.8 ms against an 8.3 ms budget at 120 Hz. Between the two APIs this benchmark does not separate them on speed, so choose on structure: the command buffer gives a single flat, inspectable stream (testable without a browser, usable by the native host where it measures 0.28 ms cheaper, and a precondition for moving decode off the main thread), and its 1.65 ms render is now the part worth optimising (decode straight into the instance buffer). The Plum scene tree is affordable at this size (about 0.5 ms, 100 ns per node on the command-buffer path) and does not need a lower-level escape hatch yet. Not covered: GPU time, a lower-end machine, much larger counts, text-heavy scenes, and the 4-way sprite/rect/circle/text mix measured as one number only.
70
+
71
+ ## Semantics that were hard to match (macroquad vs pixi host)
72
+
73
+ - `clear(c)` is immediate in macroquad: it erases what was drawn before it. The Pixi host discards the frame's draw lists at `clear`, then sets the background colour.
74
+ - Draw order across camera changes: rects/circles/lines go to one Graphics per run, sprites and texts are separate objects, and every camera change starts a new Container layer, so the exact call order survives as a flat scene order.
75
+ - Text: macroquad draws a bitmap font; Pixi rasterises `sans-serif` through a 2D canvas (about 15% wider at the same size).
76
+ - `drawCircle`: macroquad draws a 20-sided polygon, Pixi a smooth circle.
77
+ - Sounds: macroquad drops sounds played before the first gesture; the Pixi host drops one-shots while the AudioContext is suspended but starts loops.
78
+ - Input: DOM `KeyboardEvent.code` maps to GLFW numbers; DOM mouse buttons 1/2 swap to Plum's 2/1. `screenWidth`/`screenHeight` are CSS pixels (resolution 1).
53
79
 
54
80
  ## Reproduce
55
81
 
56
82
  cargo build --manifest-path plum-game/web-test/Cargo.toml
57
- plum-game/web-test/target/debug/plum-web-test plum-game/web-test/fixtures/stress.plum --renderer pixi --headed --uncapped --measure 10 --frames-wait 6
83
+ plum-game/web-test/target/debug/plum-web-test plum-game/web-test/fixtures/stress_cmd.plum --renderer gpu --headed --uncapped --measure 10 --frames-wait 6 --json
58
84
 
59
- Drop `--headed` for the software-GL path, `--uncapped` to keep the display's pacing, and use `--renderer macroquad` for the other column.
85
+ Drop `--headed` for the software path. Native: `PLUM_GAME_MEASURE=10 target/release/plum game plum-game/web-test/fixtures/stress.plum`.
plum-game/web-test/src/lib.rs CHANGED
@@ -87,8 +87,26 @@ fn json_str(s: &str) -> String {
87
87
  format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
88
88
  }
89
89
 
90
+ /// 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
+ #[derive(Debug, PartialEq)]
93
+ pub struct Phases {
94
+ pub update_ms: f64,
95
+ pub draw_ms: f64,
96
+ pub render_ms: Option<f64>,
97
+ }
98
+
99
+ /// 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> {
101
+ if c.len() < 4 || c[0] < 1.0 {
102
+ return None;
103
+ }
104
+ Some(Phases { update_ms: c[1] / c[0], draw_ms: c[2] / c[0], render_ms: has_render.then(|| c[3] / c[0]) })
105
+ }
106
+
90
- /// One JSON object: renderer, backend (`plumRenderer`), headed, uncapped and the frame statistics.
107
+ /// One JSON object: renderer, backend (`plumRenderer`), headed, uncapped, the frame statistics
108
+ /// and (when measured) update_ms / draw_ms / render_ms.
91
- pub fn stats_json(opts: &Options, backend: Option<&str>, st: Option<&FrameStats>) -> String {
109
+ pub fn stats_json(opts: &Options, backend: Option<&str>, st: Option<&FrameStats>, ph: Option<&Phases>) -> String {
92
110
  let mut s = format!(
93
111
  "{{\"renderer\":{},\"backend\":{},\"headed\":{},\"uncapped\":{}",
94
112
  json_str(opts.renderer.name()),
@@ -103,6 +121,14 @@ pub fn stats_json(opts: &Options, backend: Option<&str>, st: Option<&FrameStats>
103
121
  )),
104
122
  None => s.push('}'),
105
123
  }
124
+ if let Some(ph) = ph {
125
+ s.pop();
126
+ s.push_str(&format!(",\"update_ms\":{:.3},\"draw_ms\":{:.3}", ph.update_ms, ph.draw_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
+ }
106
132
  s
107
133
  }
108
134
 
@@ -361,9 +387,10 @@ async fn check_page(browser: &playwright_rs::protocol::Browser, port: u16, opts:
361
387
 
362
388
  let renderer: Option<String> = page.evaluate("() => window.plumRenderer || null", None::<&()>).await.unwrap_or(None);
363
389
  let mut measured = None;
390
+ let mut phases = None;
364
391
  if let Some(secs) = opts.measure {
365
392
  let _: bool = page
366
- .evaluate("() => { window.__rafStamps.length = 0; window.__rafOn = true; return true; }", None::<&()>)
393
+ .evaluate("() => { window.__rafStamps.length = 0; window.__rafOn = true; window.__plumMeasure = {n: 0, u: 0, d: 0, r: 0}; return true; }", None::<&()>)
367
394
  .await
368
395
  .map_err(pe("start measuring"))?;
369
396
  tokio::time::sleep(Duration::from_secs_f64(secs)).await;
@@ -372,6 +399,11 @@ async fn check_page(browser: &playwright_rs::protocol::Browser, port: u16, opts:
372
399
  .await
373
400
  .map_err(pe("read frame stamps"))?;
374
401
  measured = frame_stats(&stamps);
402
+ let c: Vec<f64> = page
403
+ .evaluate("() => { const m = window.__plumMeasure; window.__plumMeasure = null; return m ? [m.n, m.u, m.d, m.r] : []; }", None::<&()>)
404
+ .await
405
+ .unwrap_or_default();
406
+ phases = phases_from(&c, opts.renderer != Renderer::Macroquad);
375
407
  }
376
408
 
377
409
  let png1 = page.screenshot(None).await.map_err(pe("screenshot"))?;
@@ -412,11 +444,19 @@ async fn check_page(browser: &playwright_rs::protocol::Browser, port: u16, opts:
412
444
  st.p99,
413
445
  st.fps()
414
446
  ));
447
+ if let Some(ph) = &phases {
448
+ report.push_str(&format!(
449
+ "\nphases per frame: update {:.3} ms, draw {:.3} ms, render {}",
450
+ ph.update_ms,
451
+ ph.draw_ms,
452
+ ph.render_ms.map(|r| format!("{r:.3} ms")).unwrap_or_else(|| "n/a".into())
453
+ ));
454
+ }
415
455
  } else if opts.measure.is_some() {
416
456
  report.push_str("\nframes: none recorded");
417
457
  }
418
458
  if opts.json {
419
- report.push_str(&format!("\n{}", stats_json(opts, renderer.as_deref(), measured.as_ref())));
459
+ report.push_str(&format!("\n{}", stats_json(opts, renderer.as_deref(), measured.as_ref(), phases.as_ref())));
420
460
  }
421
461
  let mut failures = vec![];
422
462
  if matches!(opts.renderer, Renderer::Pixi | Renderer::Gpu) && renderer.is_none() {
plum-game/web-test/tests/web_check.rs CHANGED
@@ -21,10 +21,10 @@ fn stats_json_is_one_object() {
21
21
  let st = FrameStats { frames: 3, avg: 2.0, p50: 2.0, p95: 3.0, p99: 3.0 };
22
22
  let o = Options { renderer: Renderer::Gpu, headed: true, ..Options::default() };
23
23
  assert_eq!(
24
- stats_json(&o, Some("gpu-webgpu"), Some(&st)),
24
+ stats_json(&o, Some("gpu-webgpu"), Some(&st), None),
25
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
26
  );
27
- assert_eq!(stats_json(&o, None, None), r#"{"renderer":"gpu","backend":null,"headed":true,"uncapped":false}"#);
27
+ assert_eq!(stats_json(&o, None, None, None), r#"{"renderer":"gpu","backend":null,"headed":true,"uncapped":false}"#);
28
28
  }
29
29
 
30
30
  fn orb_run(renderer: Renderer) {
@@ -60,3 +60,15 @@ fn frame_stats_are_percentiles_of_the_gaps() {
60
60
  assert_eq!(st.p99, 30.0);
61
61
  assert!((st.fps() - 1000.0 / 15.0).abs() < 1e-9);
62
62
  }
63
+
64
+ #[test]
65
+ fn phases_in_json() {
66
+ use plum_web_test::{phases_from, stats_json, Options};
67
+ let ph = phases_from(&[10.0, 5.0, 20.0, 3.0], true).unwrap();
68
+ assert_eq!((ph.update_ms, ph.draw_ms, ph.render_ms), (0.5, 2.0, Some(0.3)));
69
+ assert!(phases_from(&[0.0, 0.0, 0.0, 0.0], true).is_none());
70
+ let j = stats_json(&Options::default(), None, None, Some(&ph));
71
+ 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
+ }
plum-game/web/plum_bridge.js CHANGED
@@ -136,8 +136,21 @@
136
136
  env.plum_bridge_init = () => {
137
137
  state = ctx.plum.init();
138
138
  };
139
+ // __plumMeasure (set by plum-web-test --measure) times Plum update/draw; render_ms is not separable here.
139
- env.plum_bridge_update = (dt) => ctx.plum.update(state, dt);
140
+ env.plum_bridge_update = (dt) => {
141
+ const m = globalThis.__plumMeasure;
142
+ if (!m) return ctx.plum.update(state, dt);
143
+ const t = performance.now();
144
+ ctx.plum.update(state, dt);
145
+ m.n++; m.u += performance.now() - t;
146
+ };
140
- env.plum_bridge_draw = () => ctx.plum.draw(state);
147
+ env.plum_bridge_draw = () => {
148
+ const m = globalThis.__plumMeasure;
149
+ if (!m) return ctx.plum.draw(state);
150
+ const t = performance.now();
151
+ ctx.plum.draw(state);
152
+ m.d += performance.now() - t;
153
+ };
141
154
  }
142
155
 
143
156
  // Runs after plum-game.wasm is instantiated and before its main().
plum-game/web/plum_bridge_gpu.js CHANGED
@@ -870,16 +870,23 @@ struct VOut {
870
870
  size();
871
871
  input.beginFrame();
872
872
  b.beginFrame(canvas.width, canvas.height);
873
+ const m = globalThis.__plumMeasure; // set by plum-web-test --measure; null otherwise
874
+ const t0 = m ? performance.now() : 0;
873
875
  ex.update(state, dt);
876
+ const t1 = m ? performance.now() : 0;
877
+ let t2 = 0;
874
878
  if (cmdMode) {
875
879
  ex.cmdReset();
876
880
  ex.draw(state);
881
+ if (m) t2 = performance.now();
877
882
  // memory may have grown since last frame: take a fresh view
878
883
  decodeCommands(new DataView(ex.memory.buffer, ex.cmdPtr(), ex.cmdLen()), b);
879
884
  } else {
880
885
  ex.draw(state);
886
+ if (m) t2 = performance.now();
881
887
  }
882
888
  backend.submit(b, canvas.width, canvas.height);
889
+ if (m) { m.n++; m.u += t1 - t0; m.d += t2 - t1; m.r += performance.now() - t2; }
883
890
  text.endFrame();
884
891
  } catch (e) {
885
892
  console.error("frame failed:", e);
plum-game/web/plum_bridge_pixi.js CHANGED
@@ -557,10 +557,15 @@
557
557
  last = now;
558
558
  input.beginFrame();
559
559
  r.beginFrame();
560
+ const m = globalThis.__plumMeasure; // set by plum-web-test --measure; null otherwise
561
+ const t0 = m ? performance.now() : 0;
560
562
  ctx.plum.update(state, dt);
563
+ const t1 = m ? performance.now() : 0;
561
564
  ctx.plum.draw(state);
565
+ const t2 = m ? performance.now() : 0;
562
566
  r.endFrame();
563
567
  app.renderer.render(app.stage);
568
+ if (m) { m.n++; m.u += t1 - t0; m.d += t2 - t1; m.r += performance.now() - t2; }
564
569
  } catch (e) {
565
570
  console.error("frame failed:", e);
566
571
  return;