plum
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
|
-
|
|
3
|
+
Browser renderers for `plum game --web`: the default macroquad-on-wasm32, the
|
|
4
|
-
|
|
4
|
+
experimental `--renderer pixi` (PixiJS 8.22.0) and the experimental
|
|
5
|
-
`--renderer
|
|
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
|
-
|
|
7
|
+
command buffer) and two ways of structuring the game (flat immediate drawing vs
|
|
7
|
-
|
|
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
|
|
13
|
-
- Scene
|
|
14
|
-
-
|
|
15
|
-
-
|
|
16
|
-
-
|
|
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 (
|
|
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
|
-
|
|
|
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
|
-
|
|
|
44
|
+
| stress | macroquad | 16.35 (16.35-16.40) | 33.4 | 61 | 0.003 | 0.977 | n/a |
|
|
27
|
-
| pixi (
|
|
45
|
+
| stress | pixi (WebGL) | 12.95 (12.95-13.05) | 17.1 | 77 | 0.003 | 1.534 | 11.267 |
|
|
28
|
-
|
|
|
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
|
-
|
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
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/
|
|
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
|
|
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
|
|
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) =>
|
|
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 = () =>
|
|
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;
|