plum
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 +0 -0
- plum-game/web-test/fixtures/assets/tex1.png +0 -0
- plum-game/web-test/fixtures/assets/tex2.png +0 -0
- plum-game/web-test/fixtures/assets/tex3.png +0 -0
- plum-game/web-test/fixtures/assets/tex4.png +0 -0
- plum-game/web-test/fixtures/assets/tex5.png +0 -0
- plum-game/web-test/fixtures/assets/tex6.png +0 -0
- plum-game/web-test/fixtures/assets/tex7.png +0 -0
- plum-game/web-test/fixtures/interleave.plum +35 -0
- plum-game/web-test/fixtures/tools/make_textures.py +42 -0
- plum-game/web-test/src/lib.rs +17 -12
- plum-game/web-test/tests/web_check.rs +10 -1
- plum-game/web/plum_bridge.js +4 -3
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}
|
|
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
|
-
|
|
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
|
-
|
|
426
|
+
if let Some(d) = ph.draws {
|
|
422
|
-
format!("{:.
|
|
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);
|