plum
git clone https://git.pyrossh.dev/plum
A statically typed, imperative programming language inspired by rust, python
a2bf62b
— Peter John
2026-11-02T17:37:36+05:30
README, benchmark and docs/game.png for the renderer features
- README.md +10 -7
- docs/game.png +0 -0
- docs/plans/2026-11-02-traits.review.md +123 -0
- plum-game/web-test/BENCHMARK.md +15 -0
- plum-game/web-test/README.md +1 -1
README.md
CHANGED
|
@@ -1037,13 +1037,13 @@ Every module that imports `std/Str` also exports four helpers for hosts that can
|
|
|
1037
1037
|
|
|
1038
1038
|
## Games
|
|
1039
1039
|
|
|
1040
|
-
Games run in the browser on Plum's own WebGPU renderer (`plum-game/web/plum_bridge.js`): the Plum module calls the bridge directly, one extern call per draw, and the bridge batches everything into one buffer upload per frame. WebGPU is required (Chrome or Edge, Safari 26+, Firefox); there is no fallback.
|
|
1040
|
+
Games run in the browser on Plum's own WebGPU renderer (`plum-game/web/plum_bridge.js`): the Plum module calls the bridge directly, one extern call per draw, and the bridge batches everything into one buffer upload per frame. A draw call binds up to 16 textures (each vertex carries its texture slot), so mixing atlases in y-sorted scenes costs a handful of draw calls; the canvas is `CSS size x devicePixelRatio` while every game coordinate (`screenWidth`, `mousePos`, the camera) stays in CSS pixels; text is drawn from a signed-distance-field glyph atlas, so it stays crisp at any size or zoom; and if the GPU device is lost the renderer requests a new one, re-uploads the textures and logs `GPU device lost (<reason>); restored` (render-target contents start transparent again). WebGPU is required (Chrome or Edge, Safari 26+, Firefox); there is no fallback.
|
|
1041
1041
|
|
|
1042
1042
|
`plum game file.plum [--assets DIR] [--port N] [--no-open]` compiles the game, serves it on 127.0.0.1 (port 8080, else any free port; `--port` picks one), prints the URL and opens the default browser unless `--no-open` is given. Ctrl-C stops it. `plum game file.plum -o dist [--assets DIR]` writes a static build instead and exits: `index.html`, `plum_bridge.js`, `plum.wasm`, `preload.txt` and an `assets/` copy of the whole asset directory (every file in it is listed in `preload.txt` and fetched before the first frame). Host that directory on any static file server; the browser needs wasm GC support. The asset root defaults to `assets` beside the source file.
|
|
1043
1043
|
|
|
1044
|
-
`plum-examples/game.plum` is a small arcade game: steer a ship with the arrow keys or WASD, collect orbs, dodge mines (Enter restarts). It draws shapes, text and an animated sprite sheet (`plum-examples/assets/sprites.png`, generated by `plum-examples/tools/make_sprites.py`) and plays music and sound effects (`plum-examples/assets/*.wav`, generated by `plum-examples/tools/make_sounds.py`); both generators live outside the asset dir so they are not shipped. For a bigger game built on this engine see the Knights and Merchants port in the sibling repo `../kam_plum` (`./run.sh` there). Its rules are plain functions with `test` blocks, so `plum test plum-examples` covers them without a browser.
|
|
1044
|
+
`plum-examples/game.plum` is a small arcade game: steer a ship with the arrow keys or WASD, collect orbs, dodge mines (Enter restarts). It draws shapes, text, additive orb glow (`blendMode(Blend.Add)`) and an animated sprite sheet (`plum-examples/assets/sprites.png`, generated by `plum-examples/tools/make_sprites.py`) and plays music and sound effects (`plum-examples/assets/*.wav`, generated by `plum-examples/tools/make_sounds.py`); both generators live outside the asset dir so they are not shipped. For a bigger game built on this engine see the Knights and Merchants port in the sibling repo `../kam_plum` (`./run.sh` there). Its rules are plain functions with `test` blocks, so `plum test plum-examples` covers them without a browser.
|
|
1045
1045
|
|
|
1046
|
-
**`std/Scene`** is a scene-graph library written in Plum (see [`plum-examples/scene.plum`](plum-examples/scene.plum)): a tree of `Node`s (group, sprite, rect, circle, text) under a `Stage`, with parent-relative position, rotation, scale and colour, per-node `act` behaviours, a camera and hit testing. Use it for UI and other hierarchical scenes, where moving a parent should move everything in it. For a large world, keep the game objects in flat records and lists instead (for example `enum Entity = Unit | House` with an `update` and a `draw` that the enum dispatches to its variants) and draw them directly through `std/Game`; a tree of thousands of nodes costs a closure call and a matrix composition per node every frame.
|
|
1046
|
+
**`std/Scene`** is a scene-graph library written in Plum (see [`plum-examples/scene.plum`](plum-examples/scene.plum)): a tree of `Node`s (group, sprite, rect, circle, text) under a `Stage`, with parent-relative position, rotation, scale and colour, per-node `act` behaviours, a camera and hit testing; its demo renders a minimap into a texture with `textureCreate` and `renderTarget`. Use it for UI and other hierarchical scenes, where moving a parent should move everything in it. For a large world, keep the game objects in flat records and lists instead (for example `enum Entity = Unit | House` with an `update` and a `draw` that the enum dispatches to its variants) and draw them directly through `std/Game`; a tree of thousands of nodes costs a closure call and a matrix composition per node every frame.
|
|
1047
1047
|
|
|
1048
1048
|
A game declares three exports. `G` is any record and holds all game state (there are no globals):
|
|
1049
1049
|
|
|
@@ -1057,7 +1057,7 @@ The page calls `init` once after the assets are loaded, then `update` and `draw`
|
|
|
1057
1057
|
|
|
1058
1058
|
**Headless tests:** the `plum-game` crate (a standalone crate, wasmtime only) runs a compiled game with no browser against a `Recorder` backend that logs every host call as a string and answers input from scripted values, so `cargo test --manifest-path plum-game/Cargo.toml` checks games' draw-call sequences (`plum-game/tests/host_test.rs`). `plum-game <wasm> [--assets DIR] [--frames N]` does the same from the command line and prints the host calls of the last frame.
|
|
1059
1059
|
|
|
1060
|
-
**Browser check:** `cargo run --manifest-path plum-game/web-test/Cargo.toml -- plum-examples/game.plum --screenshot /tmp/orb-run.png` builds the static web version, serves it and drives headless Chromium (software WebGPU) through [playwright-rs](https://crates.io/crates/playwright-rs): it fails if the canvas stays blank or stops changing, or the page logs an error, and reports whether audio started. It also takes `--assets DIR`, `--require-audio`, `--measure SECS`
|
|
1060
|
+
**Browser check:** `cargo run --manifest-path plum-game/web-test/Cargo.toml -- plum-examples/game.plum --screenshot /tmp/orb-run.png` builds the static web version, serves it and drives headless Chromium (software WebGPU) through [playwright-rs](https://crates.io/crates/playwright-rs): it fails if the canvas stays blank or stops changing, or the page logs an error, and reports whether audio started. It also takes `--assets DIR`, `--require-audio`, `--measure SECS` (with `--json`, `draws` is the GPU draw calls per frame), `--headed`, `--dpr N` (a device pixel ratio of N), `--lose-device` (destroys the GPU device after a few frames and requires rendering to resume) and `--expect-pixel x,y,r,g,b[,tol]` (repeatable; the screenshot pixel at CSS (x, y)). `plum-game/web-test/fixtures/` has `interleave.plum`, `text_sizes.plum` and `rendertarget.plum` for these. One-time setup: `cargo run --manifest-path plum-game/web-test/Cargo.toml --example install-browsers`. See [`plum-game/web-test/README.md`](plum-game/web-test/README.md); `docs/game.png` above was made with `--screenshot`. [`plum-game/web-test/BENCHMARK.md`](plum-game/web-test/BENCHMARK.md) records the renderer benchmark that led to this design.
|
|
1061
1061
|
|
|
1062
1062
|
**Asset root:** textures and files are read relative to the asset root. A path that escapes the root (`..` or absolute) fails like a missing file: `-1`, `""` or `false`. The headless runner's root is `--assets` or `assets` beside the wasm; it reads image headers for `textureWidth`/`textureHeight` and records sounds without decoding them.
|
|
1063
1063
|
|
|
@@ -1067,12 +1067,15 @@ The page calls `init` once after the assets are loaded, then `update` and `draw`
|
|
|
1067
1067
|
|
|
1068
1068
|
| function | meaning |
|
|
1069
1069
|
|---|---|
|
|
1070
|
-
| `screenWidth()`, `screenHeight()`, `frameTime()` | canvas size in pixels, last frame time in seconds |
|
|
1070
|
+
| `screenWidth()`, `screenHeight()`, `frameTime()` | canvas size in CSS pixels (the unit of game coordinates, independent of the device pixel ratio), last frame time in seconds |
|
|
1071
1071
|
| `clear(c)` | fill the screen |
|
|
1072
1072
|
| `drawRect(x, y, w, h, c)`, `drawCircle(x, y, r, c)`, `drawLine(x1, y1, x2, y2, thickness, c)`, `drawText(s, x, y, size, c)` | shapes and text (`x, y` is the text's top-left, a circle's centre) |
|
|
1073
1073
|
| `setCamera(x, y, scale)`, `resetCamera()` | world point `(x, y)` maps to the screen's top-left, one world unit is `scale` pixels |
|
|
1074
1074
|
| `textureLoad(path)` | load an image (PNG at least) relative to the asset root |
|
|
1075
1075
|
| `textureWidth(t)`, `textureHeight(t)` | size in pixels (0 for a bad handle) |
|
|
1076
|
+
| `textureCreate(w, h)` | a blank, transparent texture to draw into (`-1` if a side is below 1 or above 8192) |
|
|
1077
|
+
| `renderTarget(t)` | following draws and `clear` go into texture `t`, or to the screen for `-1`; each target has its own camera (reset to its pixels when selected, the screen's is restored on return); contents persist across frames |
|
|
1078
|
+
| `blendMode(m)` | `Blend.Normal`, `Blend.Add`, `Blend.Multiply` or `Blend.Screen` for the following draws; every frame starts with Normal |
|
|
1076
1079
|
| `textureSetSmooth(t, smooth)` | linear (`true`) or nearest (`false`, the default) filtering |
|
|
1077
1080
|
| `drawTexture(t, x, y, tint)` | the texture at its natural size, top-left at `(x, y)` |
|
|
1078
1081
|
| `drawTextureEx(t, sx, sy, sw, sh, x, y, w, h, rotation, flipX, tint)` | source rect `(sx, sy, sw, sh)` in texture pixels drawn into dest rect `(x, y, w, h)`, rotated `rotation` radians clockwise about the dest centre |
|
|
@@ -1081,9 +1084,9 @@ The page calls `init` once after the assets are loaded, then `update` and `draw`
|
|
|
1081
1084
|
| `soundStop(s)`, `soundSetVolume(s, volume)` | stop every play of the sound; change its volume. A bad handle is a no-op |
|
|
1082
1085
|
| `fileExists(path)`, `fileLoad(path)` | whole file as a `Str` (`""` if missing), relative to the asset root |
|
|
1083
1086
|
| `isKeyDown(k)`, `isKeyPressed(k)`, `isMouseDown(b)`, `isMousePressed(b)` | input; `k` is a `Key` constant, `b` a `Mouse` constant |
|
|
1084
|
-
| `mousePos()`, `mouseWheel()` | pointer in
|
|
1087
|
+
| `mousePos()`, `mouseWheel()` | pointer in CSS pixels; wheel direction this frame (+ is up) |
|
|
1085
1088
|
|
|
1086
|
-
`Key` constants use GLFW numbering: `Key.Space`, `Key.KeyA` to `Key.KeyZ`, `Key.Num0` to `Key.Num9`, `Key.Escape`, `Key.Enter`, `Key.Tab`, `Key.Backspace`, `Key.Right`, `Key.Left`, `Key.Down`, `Key.Up`, `Key.LeftShift`, `Key.LeftControl`. `Mouse` has `Mouse.Left`, `Mouse.Right` and `Mouse.Middle`. `Color` holds RGBA channels in 0..1 with the constants `White`, `Black`, `Red`, `Green`, `Blue`, `Yellow` and `Transparent`, plus `Color.rgb(r, g, b)` and `withAlpha(a)`.
|
|
1089
|
+
`Key` constants use GLFW numbering: `Key.Space`, `Key.KeyA` to `Key.KeyZ`, `Key.Num0` to `Key.Num9`, `Key.Escape`, `Key.Enter`, `Key.Tab`, `Key.Backspace`, `Key.Right`, `Key.Left`, `Key.Down`, `Key.Up`, `Key.LeftShift`, `Key.LeftControl`. `Mouse` has `Mouse.Left`, `Mouse.Right` and `Mouse.Middle`; `Blend` has `Blend.Normal`, `Blend.Add`, `Blend.Multiply` and `Blend.Screen`. `Color` holds RGBA channels in 0..1 with the constants `White`, `Black`, `Red`, `Green`, `Blue`, `Yellow` and `Transparent`, plus `Color.rgb(r, g, b)` and `withAlpha(a)`.
|
|
1087
1090
|
|
|
1088
1091
|
## Standard library highlights
|
|
1089
1092
|
|
docs/game.png
CHANGED
|
Binary file
|
docs/plans/2026-11-02-traits.review.md
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# Traits with fields (embedding): review plan
|
|
2
|
+
|
|
3
|
+
## Summary
|
|
4
|
+
Plum gets `trait`: a named bundle of fields and methods that a `record` takes on by listing the trait at the start of its field list, like Go embedding. Unlike Go, the record then really *is* that trait: a `House` can go in a `List[Entity]`, and calling `e.update(dt)` on it runs House's own `update`. This lets the engine (std) define extension points like `Entity` or `Clickable` that a game (kam_plum) fills with its own records, without the engine knowing about them. Because Plum always compiles the whole program at once, the compiler knows every record that embeds a trait, so everything is resolved statically: no inheritance, no vtables.
|
|
5
|
+
|
|
6
|
+
```plum
|
|
7
|
+
trait Entity(pos: Vec2, z: Int, visible: Bool) =
|
|
8
|
+
fun update(self, dt: Float) # no body: every record must define it
|
|
9
|
+
fun draw(self)
|
|
10
|
+
fun moveBy(self, d: Vec2) = # body: a default the record may replace
|
|
11
|
+
self.pos = self.pos + d
|
|
12
|
+
|
|
13
|
+
record House(Entity, Clickable, kind: HouseKind) =
|
|
14
|
+
fun update(self, dt: Float) = ...
|
|
15
|
+
fun draw(self) = ...
|
|
16
|
+
fun onClick(self) = openMenu(self)
|
|
17
|
+
|
|
18
|
+
h := House(pos: p, z: 0, visible: True, bounds: b, kind: Farm)
|
|
19
|
+
scene.entities.push(h) # House converts to Entity, nothing written
|
|
20
|
+
for e := range scene.entities
|
|
21
|
+
e.update(dt) # runs House.update
|
|
22
|
+
e.pos = e.pos + drift # trait fields read and write through Entity
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## What you will get
|
|
26
|
+
- A `trait Name(fields) = methods` declaration. A method without a body is required, a method with a body is a default.
|
|
27
|
+
- A record lists traits first in its field list. It gains their fields (set in its constructor like its own), their methods, and converts to each trait automatically wherever one is expected.
|
|
28
|
+
- A trait used as a type (`List[Entity]`, `fun f(e: Entity)`): its fields can be read and written, its methods called, and each call goes to the record's own method.
|
|
29
|
+
- Inside a default method, `self.draw()` calls the record's own `draw`, so the engine can call methods the game overrides.
|
|
30
|
+
- `match e | House(kind) -> ... | _ -> ...` narrows back to the record. On a trait, `_` is always required.
|
|
31
|
+
- Clear compile errors for: a missing required method, two traits (or a trait and the record) declaring the same field, two traits with the same default method that the record doesn't define itself, embedding something that isn't a trait, a trait in a `struct`, and calling a method on a trait value that the trait doesn't declare.
|
|
32
|
+
- Highlighting, formatting and editor support (hover, completion, go-to-definition) for traits and embedded fields, a README section, and an example program.
|
|
33
|
+
|
|
34
|
+
## What will be built
|
|
35
|
+
| Part | What it does | Size | Built by |
|
|
36
|
+
|---|---|---|---|
|
|
37
|
+
| Grammar | Parses `trait` declarations, methods with no body, and trait names at the start of a record's field list | M | a careful helper |
|
|
38
|
+
| Syntax tree | Represents traits and a record's embedded traits | S | a fast helper |
|
|
39
|
+
| Trait lowering | Copies trait fields and missing default methods into each embedding record, and turns each trait into a hidden union of its records | L | a careful helper |
|
|
40
|
+
| Checker rules | All the errors above, the "only the trait's own methods" rule, and the `_`-required rule for `match` | L | a careful helper |
|
|
41
|
+
| Field access through a trait | Reads and writes `e.pos` on a trait value by picking the record type at run time | M | a careful helper |
|
|
42
|
+
| Code generation | Emits the field reads and writes, and checks the dispatch and conversion paths reused from union enums | M | a careful helper |
|
|
43
|
+
| Formatter and editor | Formats traits idempotently; hover, completion and go-to-definition for embedded fields | M | a fast helper |
|
|
44
|
+
| Docs and example | README section, an example program with tests, and a game-style example (Scene of Entity values) | S | a fast helper |
|
|
45
|
+
|
|
46
|
+
## How it fits together
|
|
47
|
+
```mermaid
|
|
48
|
+
flowchart LR
|
|
49
|
+
SRC["Source: std trait + game records"] --> P[Grammar and syntax tree]
|
|
50
|
+
P --> M["Merge all modules (exists)"]
|
|
51
|
+
M --> TL["Trait lowering: copy fields and defaults, build hidden union"]
|
|
52
|
+
TL --> UL["Union lowering (exists)"]
|
|
53
|
+
UL --> CK["Checker: trait rules + field access plan"]
|
|
54
|
+
CK --> CG["Code generation: dispatch (exists) + field read/write (new)"]
|
|
55
|
+
CG --> W[Running wasm game]
|
|
56
|
+
CK --> LSP[Editor support]
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## How it gets built
|
|
60
|
+
```mermaid
|
|
61
|
+
flowchart TB
|
|
62
|
+
subgraph W1["Wave 1"]
|
|
63
|
+
G[Grammar and syntax tree]
|
|
64
|
+
end
|
|
65
|
+
subgraph W2["Wave 2, in parallel"]
|
|
66
|
+
TL[Trait lowering]
|
|
67
|
+
F[Formatter]
|
|
68
|
+
end
|
|
69
|
+
subgraph W3["Wave 3, in parallel"]
|
|
70
|
+
CK[Checker rules]
|
|
71
|
+
FA[Field access through a trait]
|
|
72
|
+
end
|
|
73
|
+
subgraph W4["Wave 4, in parallel"]
|
|
74
|
+
CG[Code generation and run tests]
|
|
75
|
+
ED[Editor support]
|
|
76
|
+
end
|
|
77
|
+
D[Docs and examples]
|
|
78
|
+
G --> TL
|
|
79
|
+
G --> F
|
|
80
|
+
TL --> CK
|
|
81
|
+
TL --> FA
|
|
82
|
+
CK --> CG
|
|
83
|
+
FA --> CG
|
|
84
|
+
CK --> ED
|
|
85
|
+
CG --> D
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Decisions you can change
|
|
89
|
+
- Syntax: traits listed first in the record's field list (chosen) | an `is` clause.
|
|
90
|
+
- Struct embedding (Go-style field sharing with no conversion): not in v1 (chosen) | include it.
|
|
91
|
+
- A trait including another trait (`trait Unit(Entity, hp: Int)`): not in v1 (default) | include it.
|
|
92
|
+
- Generic traits (`trait Container[T]`): not in v1 (default) | include it.
|
|
93
|
+
- Iterator methods (`fun*`) in a trait: not in v1 (default) | include it.
|
|
94
|
+
- How a record becomes a trait value: wrapped in a small box, reusing union enums (default) | wasm-gc subtyping with no box, faster but much more work. The box can be swapped later without any change to source code.
|
|
95
|
+
- Default methods: copied into each record, so `self` calls resolve statically (default) | shared once with dispatch inside.
|
|
96
|
+
- Which methods a trait value exposes: only the trait's own (default, so adding a game record never changes what engine code can call) | any method all embedding records share.
|
|
97
|
+
- A trait with no records embedding it: still a valid type that nothing can be converted to, and calls on it compile (default) | a compile error.
|
|
98
|
+
|
|
99
|
+
## Risks
|
|
100
|
+
- **A box per conversion.** Each push of a House into a `List[Entity]` allocates a small wrapper. That's fine for hundreds or a few thousand entities. If it shows up in a profile, the subtyping option above removes it with no source change.
|
|
101
|
+
- **Union rules leaking into traits.** Union enums are exhaustive and allow any shared method. Traits need the opposite on both counts. The checker keeps an explicit "this union came from a trait" mark, and tests cover both rules.
|
|
102
|
+
- **Grammar ambiguity.** A bare name in a record's field list next to `name: Type` fields. We regenerate with optimizations disabled (a known landmine here) and run the full corpus.
|
|
103
|
+
- **Name clashes after copying.** Copied default methods and fields could clash with the record's own. Lowering checks this first and reports the error at the record, not at a generated copy.
|
|
104
|
+
- **Same record in a trait and a union enum.** Both use hidden wrapping, which could produce two candidate conversions. Tests cover a record that is both an `Entity` and a member of an enum.
|
|
105
|
+
|
|
106
|
+
## Out of scope
|
|
107
|
+
- Inheritance between records, `open record`, abstract records.
|
|
108
|
+
- Embedding structs or records.
|
|
109
|
+
- Generic traits, traits including traits, `fun*` in traits.
|
|
110
|
+
- Traits as bounds on generics (generics stay duck-typed).
|
|
111
|
+
- Removing the box (subtyping layout).
|
|
112
|
+
- Comparing trait values with `==`.
|
|
113
|
+
|
|
114
|
+
## How we will know it works
|
|
115
|
+
- The opening example compiles and runs: a `Scene` in std holding `List[Entity]` updates and draws game-defined `House` and `Unit` records, and a default `moveBy` changes `pos` as seen through both `House` and `Entity`.
|
|
116
|
+
- A default method calling `self.draw()` runs the record's own `draw`.
|
|
117
|
+
- Each error listed under "What you will get" has a test asserting its message and position.
|
|
118
|
+
- A `match` on a trait without `_` is rejected. With `_`, it narrows to the record and binds its fields.
|
|
119
|
+
- `plum format` on the new example makes no changes when run twice.
|
|
120
|
+
- The full Plum and Rust test suites and the tree-sitter corpus pass.
|
|
121
|
+
|
|
122
|
+
## Rough time and cost
|
|
123
|
+
Eight parts: four large or medium logic parts, the rest small. Roughly 60 to 120 minutes and $15 to $35 (rough, until the machine plan is linted).
|
plum-game/web-test/BENCHMARK.md
CHANGED
|
@@ -85,3 +85,18 @@ Headless is software rendering: the GPU work runs on the CPU and competes with t
|
|
|
85
85
|
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
|
|
86
86
|
|
|
87
87
|
Drop `--headed` for the software path. Native: `PLUM_GAME_MEASURE=10 target/release/plum game plum-game/web-test/fixtures/stress.plum`.
|
|
88
|
+
|
|
89
|
+
## Multi-texture batching and SDF text (renderer features)
|
|
90
|
+
|
|
91
|
+
Same machine and method as above (headed, uncapped, `--measure 10 --frames-wait 6`, two runs per cell). The renderer now binds up to 16 textures per draw call (a switch over 16 texture bindings picked by a per-vertex slot, because binding arrays are not in core WebGPU) and draws text as glyph quads of one SDF atlas instead of one canvas texture per string. `draws` is the mean GPU draw calls per frame (`--json`).
|
|
92
|
+
|
|
93
|
+
| fixture | version | avg ms (2 runs) | p95 | draw / render ms | draws per frame |
|
|
94
|
+
|---|---|---|---|---|---|
|
|
95
|
+
| stress | before (one draw per texture switch, per-string text textures) | 1.58, 1.60, 1.67 | 1.8-2.2 | 1.30-1.38 / 0.21-0.22 | 52 |
|
|
96
|
+
| stress | 16 slots, per-string text textures | 1.67, 1.80, 1.73, 1.67, 1.86 | 1.9-2.7 | 1.34-1.50 / 0.26-0.28 | 4 |
|
|
97
|
+
| stress | 16 slots + SDF text | 0.98, 1.03 | 1.1-1.2 | 0.64-0.66 / 0.27 | 1 |
|
|
98
|
+
| interleave (4000 sprites, 8 textures alternating) | before | 1.29, 1.31 | 1.6 | 0.20 / 1.00-1.04 | 4000 |
|
|
99
|
+
| interleave | 16 slots | 0.46, 0.44 | 0.5-0.6 | 0.21 / 0.15 | 1 |
|
|
100
|
+
|
|
101
|
+
- Interleaved textures: 4000 draw calls became 1 (the bound is ceil(textures / 16)), and the frame time fell from 1.3 ms to 0.45 ms (-65%); render time (encode + submit) from 1.0 ms to 0.15 ms.
|
|
102
|
+
- Stress: the 16-slot batcher alone cost about 5-8% (the vertex grew from 20 to 24 bytes for the slot), within run-to-run spread; the 50 changing strings used to cost 50 canvas rasterisations plus 50 texture uploads per frame, and the SDF atlas removes both, so the scene is now about 40% faster than before (0.98-1.03 ms against 1.58-1.67 ms) with 1 draw call instead of 52.
|
plum-game/web-test/README.md
CHANGED
|
@@ -21,7 +21,7 @@ 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`, `--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).
|
|
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), `--dpr N` (Playwright `deviceScaleFactor`; also checks the backing store is CSS size x ratio), `--lose-device` (destroys the GPU device from the page after a few frames and requires the `GPU device lost (...); restored` log and resumed rendering; the page exposes the device only when the harness asks) and `--expect-pixel x,y,r,g,b[,tol]` (repeatable; the screenshot pixel at CSS (x, y), default tolerance 2). `--json` also reports `draws`, the GPU draw calls per frame. `fixtures/interleave.plum`, `text_sizes.plum` and `rendertarget.plum` exercise multi-texture batching, SDF text and render targets/blend modes. `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
|