plum

#treesitter#compiler#wasm

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

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


225328f — Peter John 2026-11-02T17:19:23+05:30
web-test: report GPU draw calls per frame (draws); interleave fixture with 8 generated textures
plum-game/web-test/fixtures/assets/tex0.png ADDED
Binary file
plum-game/web-test/fixtures/assets/tex1.png ADDED
Binary file
plum-game/web-test/fixtures/assets/tex2.png ADDED
Binary file
plum-game/web-test/fixtures/assets/tex3.png ADDED
Binary file
plum-game/web-test/fixtures/assets/tex4.png ADDED
Binary file
plum-game/web-test/fixtures/assets/tex5.png ADDED
Binary file
plum-game/web-test/fixtures/assets/tex6.png ADDED
Binary file
plum-game/web-test/fixtures/assets/tex7.png ADDED
Binary file
plum-game/web-test/fixtures/interleave.plum ADDED
@@ -0,0 +1,35 @@
1
+ # Eight textures interleaved sprite by sprite (sprite i uses texture i % 8): the
2
+ # worst case for a batcher that breaks a draw call at every texture switch.
3
+ # 4000 sprites drift over the screen as a function of the frame counter.
4
+ module interleave
5
+ import std/Str
6
+ import std/Int
7
+ import std/Float
8
+ import std/Array
9
+ import std/Vec2
10
+ import std/Color
11
+ import std/Game
12
+
13
+ primitive Scene =
14
+ Sprites = 4000
15
+ Textures = 8
16
+ WorldW = 1264
17
+ WorldH = 704
18
+
19
+ record Interleave(tex: Array[Int], t: Int)
20
+
21
+ export fun init() -> Interleave =
22
+ tex := Array.filled(Scene.Textures, -1)
23
+ for i := range Scene.Textures
24
+ tex.set(i, textureLoad("tex{i}.png"))
25
+ Interleave(tex: tex, t: 0)
26
+
27
+ export fun update(g: Interleave, dt: Float) =
28
+ g.t = g.t + 1
29
+
30
+ export fun draw(g: Interleave) =
31
+ clear(Color.rgb(0.05, 0.06, 0.1))
32
+ for i := range Scene.Sprites
33
+ x := {i * 37 + g.t * {1 + i % 5}} % Scene.WorldW
34
+ y := {i * 53 + g.t * {1 + i % 3}} % Scene.WorldH
35
+ drawTexture(g.tex.get(i % Scene.Textures), Float(x), Float(y), Color.White)
plum-game/web-test/fixtures/tools/make_textures.py ADDED
@@ -0,0 +1,42 @@
1
+ #!/usr/bin/env python3
2
+ """Writes ../assets/tex0.png .. tex7.png: eight 16x16 textures, each a distinct
3
+ colour with a different mark, for interleave.plum. Standard library only."""
4
+ import os
5
+ import struct
6
+ import zlib
7
+
8
+ COLOURS = [(230, 60, 60), (240, 150, 40), (240, 220, 50), (70, 200, 80),
9
+ (50, 200, 210), (60, 110, 240), (160, 80, 230), (240, 90, 190)]
10
+
11
+
12
+ def png(w, h, rows):
13
+ def chunk(tag, data):
14
+ c = struct.pack(">I", len(data)) + tag + data
15
+ return c + struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF)
16
+ raw = b"".join(b"\x00" + bytes(v for px in row for v in px) for row in rows)
17
+ return b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 6, 0, 0, 0)) + chunk(b"IDAT", zlib.compress(raw)) + chunk(b"IEND", b"")
18
+
19
+
20
+ def tex(i):
21
+ r, g, b = COLOURS[i]
22
+ rows = []
23
+ for y in range(16):
24
+ row = []
25
+ for x in range(16):
26
+ edge = x in (0, 15) or y in (0, 15)
27
+ # a bar whose position depends on i marks each texture
28
+ mark = (y // 2) == i
29
+ if edge:
30
+ row.append((r // 3, g // 3, b // 3, 255))
31
+ elif mark:
32
+ row.append((255, 255, 255, 255))
33
+ else:
34
+ row.append((r, g, b, 255))
35
+ rows.append(row)
36
+ return png(16, 16, rows)
37
+
38
+
39
+ out = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "assets")
40
+ for i in range(8):
41
+ with open(os.path.join(out, f"tex{i}.png"), "wb") as f:
42
+ f.write(tex(i))
plum-game/web-test/src/lib.rs CHANGED
@@ -76,18 +76,21 @@ pub struct Phases {
76
76
  pub update_ms: f64,
77
77
  pub draw_ms: f64,
78
78
  pub render_ms: f64,
79
+ /// Mean GPU draw calls per frame, when the page reports them.
80
+ pub draws: Option<f64>,
79
81
  }
80
82
 
81
- /// Phases from the page counters `{n, u, d, r}` (frames, summed update/draw/render ms).
83
+ /// Phases from the page counters `{n, u, d, r, dr}` (frames, summed update/draw/render ms,
84
+ /// summed draw calls; the draw count is optional).
82
85
  pub fn phases_from(c: &[f64]) -> Option<Phases> {
83
86
  if c.len() < 4 || c[0] < 1.0 {
84
87
  return None;
85
88
  }
86
- Some(Phases { update_ms: c[1] / c[0], draw_ms: c[2] / c[0], render_ms: c[3] / c[0] })
89
+ Some(Phases { update_ms: c[1] / c[0], draw_ms: c[2] / c[0], render_ms: c[3] / c[0], draws: c.get(4).map(|d| d / c[0]) })
87
90
  }
88
91
 
89
92
  /// One JSON object: backend (`plumRenderer`), headed, uncapped, the frame statistics
90
- /// and (when measured) update_ms / draw_ms / render_ms.
93
+ /// and (when measured) update_ms / draw_ms / render_ms / draws (GPU draw calls per frame).
91
94
  pub fn stats_json(opts: &Options, backend: Option<&str>, st: Option<&FrameStats>, ph: Option<&Phases>) -> String {
92
95
  let mut s = format!(
93
96
  "{{\"backend\":{},\"headed\":{},\"uncapped\":{}",
@@ -104,7 +107,11 @@ pub fn stats_json(opts: &Options, backend: Option<&str>, st: Option<&FrameStats>
104
107
  }
105
108
  if let Some(ph) = ph {
106
109
  s.pop();
107
- s.push_str(&format!(",\"update_ms\":{:.3},\"draw_ms\":{:.3},\"render_ms\":{:.3}}}", ph.update_ms, ph.draw_ms, ph.render_ms));
110
+ s.push_str(&format!(",\"update_ms\":{:.3},\"draw_ms\":{:.3},\"render_ms\":{:.3}", ph.update_ms, ph.draw_ms, ph.render_ms));
111
+ if let Some(d) = ph.draws {
112
+ s.push_str(&format!(",\"draws\":{d:.2}"));
113
+ }
114
+ s.push('}');
108
115
  }
109
116
  s
110
117
  }
@@ -361,7 +368,7 @@ async fn check_page(browser: &playwright_rs::protocol::Browser, port: u16, opts:
361
368
  let mut phases = None;
362
369
  if let Some(secs) = opts.measure {
363
370
  let _: bool = page
364
- .evaluate("() => { window.__rafStamps.length = 0; window.__rafOn = true; window.__plumMeasure = {n: 0, u: 0, d: 0, r: 0}; return true; }", None::<&()>)
371
+ .evaluate("() => { window.__rafStamps.length = 0; window.__rafOn = true; window.__plumMeasure = {n: 0, u: 0, d: 0, r: 0, dr: 0}; return true; }", None::<&()>)
365
372
  .await
366
373
  .map_err(pe("start measuring"))?;
367
374
  tokio::time::sleep(Duration::from_secs_f64(secs)).await;
@@ -371,7 +378,7 @@ async fn check_page(browser: &playwright_rs::protocol::Browser, port: u16, opts:
371
378
  .map_err(pe("read frame stamps"))?;
372
379
  measured = frame_stats(&stamps);
373
380
  let c: Vec<f64> = page
374
- .evaluate("() => { const m = window.__plumMeasure; window.__plumMeasure = null; return m ? [m.n, m.u, m.d, m.r] : []; }", None::<&()>)
381
+ .evaluate("() => { const m = window.__plumMeasure; window.__plumMeasure = null; return m ? [m.n, m.u, m.d, m.r, m.dr] : []; }", None::<&()>)
375
382
  .await
376
383
  .unwrap_or_default();
377
384
  phases = phases_from(&c);
@@ -415,12 +422,10 @@ async fn check_page(browser: &playwright_rs::protocol::Browser, port: u16, opts:
415
422
  st.fps()
416
423
  ));
417
424
  if let Some(ph) = &phases {
418
- report.push_str(&format!(
419
- "\nphases per frame: update {:.3} ms, draw {:.3} ms, render {}",
425
+ report.push_str(&format!("\nphases per frame: update {:.3} ms, draw {:.3} ms, render {:.3} ms", ph.update_ms, ph.draw_ms, ph.render_ms));
420
- ph.update_ms,
421
- ph.draw_ms,
426
+ if let Some(d) = ph.draws {
422
- format!("{:.3} ms", ph.render_ms)
427
+ report.push_str(&format!(", {d:.2} GPU draw calls"));
423
- ));
428
+ }
424
429
  }
425
430
  } else if opts.measure.is_some() {
426
431
  report.push_str("\nframes: none recorded");
plum-game/web-test/tests/web_check.rs CHANGED
@@ -60,8 +60,17 @@ fn frame_stats_are_percentiles_of_the_gaps() {
60
60
  fn phases_in_json() {
61
61
  use plum_web_test::{phases_from, stats_json, Options};
62
62
  let ph = phases_from(&[10.0, 5.0, 20.0, 3.0]).unwrap();
63
- assert_eq!((ph.update_ms, ph.draw_ms, ph.render_ms), (0.5, 2.0, 0.3));
63
+ assert_eq!((ph.update_ms, ph.draw_ms, ph.render_ms, ph.draws), (0.5, 2.0, 0.3, None));
64
64
  assert!(phases_from(&[0.0, 0.0, 0.0, 0.0]).is_none());
65
65
  let j = stats_json(&Options::default(), None, None, Some(&ph));
66
66
  assert!(j.ends_with(r#","update_ms":0.500,"draw_ms":2.000,"render_ms":0.300}"#), "{j}");
67
67
  }
68
+
69
+ #[test]
70
+ fn draws_in_json() {
71
+ use plum_web_test::{phases_from, stats_json, Options};
72
+ let ph = phases_from(&[10.0, 5.0, 20.0, 3.0, 25.0]).unwrap();
73
+ assert_eq!(ph.draws, Some(2.5));
74
+ let j = stats_json(&Options::default(), None, None, Some(&ph));
75
+ assert!(j.ends_with(r#","render_ms":0.300,"draws":2.50}"#), "{j}");
76
+ }
plum-game/web/plum_bridge.js CHANGED
@@ -478,7 +478,7 @@
478
478
 
479
479
  // --- backends -----------------------------------------------------------------------
480
480
  // A backend: { name, createTexture(source, w, h, smooth) -> { w, h, ...gpu fields },
481
- // setSmooth(tex, smooth), destroyTexture(tex), submit(batcher, w, h) }.
481
+ // setSmooth(tex, smooth), destroyTexture(tex), submit(batcher, w, h) -> draw calls issued }.
482
482
 
483
483
  const WGSL = `
484
484
  struct VOut {
@@ -618,6 +618,7 @@ struct VOut {
618
618
  }
619
619
  pass.end();
620
620
  device.queue.submit([enc.finish()]);
621
+ return vbytes ? b.batchCount : 0;
621
622
  },
622
623
  };
623
624
  }
@@ -813,8 +814,8 @@ struct VOut {
813
814
  const t1 = m ? performance.now() : 0;
814
815
  ex.draw(state);
815
816
  const t2 = m ? performance.now() : 0;
816
- backend.submit(b, canvas.width, canvas.height);
817
+ const draws = backend.submit(b, canvas.width, canvas.height);
817
- if (m) { m.n++; m.u += t1 - t0; m.d += t2 - t1; m.r += performance.now() - t2; }
818
+ if (m) { m.n++; m.u += t1 - t0; m.d += t2 - t1; m.r += performance.now() - t2; m.dr += draws; }
818
819
  text.endFrame();
819
820
  } catch (e) {
820
821
  console.error("frame failed:", e);