plum
git clone https://git.pyrossh.dev/plum
A statically typed, imperative programming language inspired by rust, python
4c5b5ce
— Peter John
2026-11-02T18:27:53+05:30
web-test: stress_world fixture (20k y-sorted sprites, culling) and benchmark results
plum-game/web-test/BENCHMARK.md
CHANGED
|
@@ -100,3 +100,15 @@ Same machine and method as above (headed, uncapped, `--measure 10 --frames-wait
|
|
|
100
100
|
|
|
101
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
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.
|
|
103
|
+
|
|
104
|
+
## Scene culling and y-sort (std/Scene)
|
|
105
|
+
|
|
106
|
+
Fixture `stress_world.plum`: one world layer with 20,000 sprites in a 4000x2300 world, a 1280x720 view at scale 1 (about 2,000 sprites in view while the camera drifts), 500 sprites walking each frame, and `layer.sortChildren(|a, b| a.y < b.y)` every frame. `World.Culling = 1` also calls `setCullingArea` with the camera view; the "no culling" row is the same file with `Culling = 0`. Same machine and method as above (headed, gpu per-call, `--uncapped --measure 10 --frames-wait 6`); one run per cell, so differences under about 5% are noise.
|
|
107
|
+
|
|
108
|
+
| fixture | avg ms | p95 | fps | update | draw | render |
|
|
109
|
+
|---|---|---|---|---|---|---|
|
|
110
|
+
| stress_world, 20k nodes, culling on | 1.15 | 1.3 | 870 | 0.423 | 0.522 | 0.087 |
|
|
111
|
+
| stress_world, 20k nodes, culling off | 4.20 | 4.7 | 238 | 0.486 | 2.922 | 0.693 |
|
|
112
|
+
| stress_scene, 5.8k nodes (reference) | 1.71 | 1.9 | 584 | 0.060 | 1.251 | 0.261 |
|
|
113
|
+
|
|
114
|
+
With culling the frame costs about what the 2,000 visible sprites cost, not what the 20,000 nodes cost: 1.15 ms against 4.20 ms without it (3.7x), and below the 5k-node `stress_scene` (1.71 ms), which draws every node. Culling removes draw (2.92 -> 0.52 ms) and render (0.69 -> 0.09 ms, 20,000 against about 2,000 sprites reaching the batcher); what stays is `update` at about 0.42 ms, which is the y-sort's O(n) scan over all 20,000 children (n - 1 comparisons through the closure, about 20 ns each, plus the merge of the few displaced sprites) and the 500 walkers. So the remaining cost grows with total nodes through the sort and the `act` walk, but at a fraction of a draw, and the draw side now scales with what is on screen.
|
plum-game/web-test/fixtures/stress_world.plum
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# A large y-sorted world through the scene graph (std/Scene): 20000 sprites in one
|
|
2
|
+
# world layer, about 2000 of them in the 1280x720 view. Every frame a few hundred
|
|
3
|
+
# sprites walk, the layer is y-sorted with `sortChildren`, and (when `Culling` is
|
|
4
|
+
# 1) the camera view is set as the layer's culling area so only the visible
|
|
5
|
+
# sprites are drawn. Set `Culling` to 0 to measure the same scene without culling.
|
|
6
|
+
module stress_world
|
|
7
|
+
import std/Str
|
|
8
|
+
import std/Int
|
|
9
|
+
import std/Float
|
|
10
|
+
import std/Math
|
|
11
|
+
import std/List
|
|
12
|
+
import std/Vec2
|
|
13
|
+
import std/Color
|
|
14
|
+
import std/Game
|
|
15
|
+
import std/Scene
|
|
16
|
+
|
|
17
|
+
primitive World =
|
|
18
|
+
Sprites = 20000
|
|
19
|
+
Walkers = 500
|
|
20
|
+
Width = 4000
|
|
21
|
+
Height = 2300
|
|
22
|
+
ViewW = 1280.0
|
|
23
|
+
ViewH = 720.0
|
|
24
|
+
Culling = 1
|
|
25
|
+
|
|
26
|
+
record Demo(sheet: Int, stage: Stage, layer: Node, frame: Int)
|
|
27
|
+
|
|
28
|
+
fun spriteAt(i: Int) -> Vec2 =
|
|
29
|
+
Vec2(x: Float({i * 7919 + 13} % World.Width), y: Float({i * i * 31 / 7 + i * 1013} % World.Height))
|
|
30
|
+
|
|
31
|
+
fun build(sheet: Int) -> Demo =
|
|
32
|
+
stage := Stage.create()
|
|
33
|
+
layer := Node.group()
|
|
34
|
+
for i := range World.Sprites
|
|
35
|
+
p := spriteAt(i)
|
|
36
|
+
layer.add(Node.sprite(sheet, Float(i % 4 * 16), 0.0, 16.0, 16.0, 16.0, 16.0).at(p.x, p.y))
|
|
37
|
+
stage.root.add(layer)
|
|
38
|
+
Demo(sheet: sheet, stage: stage, layer: layer, frame: 0)
|
|
39
|
+
|
|
40
|
+
# One frame: the camera drifts, some sprites walk, the layer is sorted and culled.
|
|
41
|
+
fun step(g: Demo) =
|
|
42
|
+
g.frame = g.frame + 1
|
|
43
|
+
t := Float(g.frame) * 0.01
|
|
44
|
+
g.stage.camX = 1300.0 + 600.0 * Math.sin(t)
|
|
45
|
+
g.stage.camY = 800.0 + 350.0 * Math.cos(t * 0.7)
|
|
46
|
+
for i := range World.Walkers
|
|
47
|
+
k := g.layer.children.at(i * 37 % World.Sprites)
|
|
48
|
+
k.y = k.y + Math.sin(Float(g.frame) * 0.05 + Float(i))
|
|
49
|
+
g.layer.sortChildren(|a, b| a.y < b.y)
|
|
50
|
+
if World.Culling == 1
|
|
51
|
+
g.layer.setCullingArea(g.stage.camX, g.stage.camY, World.ViewW, World.ViewH)
|
|
52
|
+
|
|
53
|
+
export fun init() -> Demo =
|
|
54
|
+
build(textureLoad("sprites.png"))
|
|
55
|
+
|
|
56
|
+
export fun update(g: Demo, dt: Float) =
|
|
57
|
+
step(g)
|
|
58
|
+
|
|
59
|
+
export fun draw(g: Demo) =
|
|
60
|
+
clear(Color.rgb(0.05, 0.06, 0.1))
|
|
61
|
+
g.stage.draw()
|
|
62
|
+
|
|
63
|
+
# --- tests ---
|
|
64
|
+
|
|
65
|
+
test "about 2000 of the 20000 sprites are in view, sorted by y"
|
|
66
|
+
g := build(-1)
|
|
67
|
+
step(g)
|
|
68
|
+
assert g.layer.children.length() == 20000
|
|
69
|
+
for i := range 19999
|
|
70
|
+
assert g.layer.children.at(i).y <= g.layer.children.at(i + 1).y
|
|
71
|
+
shown := flatten(g.stage.root).length() - 1
|
|
72
|
+
assert shown > 1500 && shown < 2600
|
|
73
|
+
|
|
74
|
+
test "without a culling area every sprite is listed"
|
|
75
|
+
g := build(-1)
|
|
76
|
+
assert flatten(g.stage.root).length() == 20002
|