Bunnymark¶
a sprite-renderer stress test.
▶ Run in browserTags: 2d sprites performance stress-test
Thousands of Sprite2D nodes bounce under gravity, and every press adds a
thousand more, so the count on screen when the frame rate starts to fall is a
measure of the 2D renderer.
Every sprite shares one Texture built from a numpy RGBA array at startup: one
upload and one GPU slot however many bunnies reference it. Each sprite takes its
own colour tint.
The simulation lives in two numpy arrays, positions and velocities, and moves
the whole population per frame in a handful of vectorised operations: gravity,
integration, and a bounce off each edge of viewport_size, read every frame so
the box follows the window. The nodes only display that state. The write-back
is sprite.position[:] = row, which edits the sprite’s position in place; the
change marks its transform dirty just as assigning a new Vec2 would, at a
fraction of the cost.
A Label on a CanvasLayer shows the count and a frame rate averaged over a
quarter of a second rather than one jittery frame.
Controls: Space, click or tap adds 1000 bunnies.
Source¶
1# /// simvx
2# tags = ["2d", "sprites", "performance", "stress-test"]
3# web = { width = 960, height = 540, responsive = true }
4# ///
5"""Bunnymark: a sprite-renderer stress test.
6
7Thousands of `Sprite2D` nodes bounce under gravity, and every press adds a
8thousand more, so the count on screen when the frame rate starts to fall is a
9measure of the 2D renderer.
10
11Every sprite shares one `Texture` built from a numpy RGBA array at startup: one
12upload and one GPU slot however many bunnies reference it. Each sprite takes its
13own `colour` tint.
14
15The simulation lives in two numpy arrays, positions and velocities, and moves
16the whole population per frame in a handful of vectorised operations: gravity,
17integration, and a bounce off each edge of `viewport_size`, read every frame so
18the box follows the window. The nodes only display that state. The write-back
19is `sprite.position[:] = row`, which edits the sprite's position in place; the
20change marks its transform dirty just as assigning a new `Vec2` would, at a
21fraction of the cost.
22
23A `Label` on a `CanvasLayer` shows the count and a frame rate averaged over a
24quarter of a second rather than one jittery frame.
25
26Controls: Space, click or tap adds 1000 bunnies.
27"""
28
29import numpy as np
30
31from simvx.core import Anchor, CanvasLayer, Input, Key, Label, Node2D, Pointer, Sprite2D, Texture
32from simvx.graphics import App
33
34BATCH = 1000
35HALF = np.array([13.0, 16.0]) # half the bunny sprite, which is 26 x 32
36GRAVITY = 980.0 # px/s^2, downward
37BOUNCE = 0.85 # floor restitution
38KICK_MIN = 140.0 # a floor bounce slower than this upward is re-kicked...
39KICK_RANGE = (320.0, 760.0) # ...to a fresh upward speed in this range
40
41
42def bunny_image() -> np.ndarray:
43 """A 26 x 32 bunny as an RGBA uint8 array: ears, head, body and face."""
44 ys, xs = np.mgrid[0:32, 0:26]
45
46 def ellipse(cx, cy, rx, ry):
47 return ((xs - cx) / rx) ** 2 + ((ys - cy) / ry) ** 2 <= 1.0
48
49 img = np.zeros((32, 26, 4), dtype=np.uint8)
50 fur = ellipse(13, 22, 9, 8.5) | ellipse(13, 12, 7, 6.5) | ellipse(9, 5, 2.6, 5) | ellipse(17, 5, 2.6, 5)
51 img[fur] = (235, 232, 238, 255)
52 img[ellipse(9, 5, 1.2, 3.2) | ellipse(17, 5, 1.2, 3.2)] = (240, 170, 190, 255) # inner ears
53 img[ellipse(10.5, 11, 1.1, 1.3) | ellipse(15.5, 11, 1.1, 1.3)] = (40, 35, 45, 255) # eyes
54 img[ellipse(13, 14.5, 1.3, 0.9)] = (235, 140, 160, 255) # nose
55 return img
56
57
58class Bunnymark(Node2D):
59 input_context = {"spawn": [Key.SPACE, Pointer.PRIMARY]}
60
61 def on_ready(self):
62 self.rng = np.random.default_rng()
63 self.texture = Texture(bunny_image())
64 self.sprites: list[Sprite2D] = []
65 self.pos = np.empty((0, 2))
66 self.vel = np.empty((0, 2))
67 hud = self.add_child(CanvasLayer())
68 self.status = hud.add_child(Label(font_size=22, anchor=Anchor.TOP_LEFT, inset=12))
69 self.frames, self.elapsed, self.fps = 0, 0.0, 0.0
70 self.spawn(BATCH)
71
72 def spawn(self, n: int):
73 width, height = self.viewport_size
74 pos = self.rng.uniform((HALF[0], HALF[1]), (width - HALF[0], height / 2), (n, 2))
75 self.pos = np.concatenate([self.pos, pos])
76 self.vel = np.concatenate([self.vel, self.rng.uniform((-260, -120), (260, 120), (n, 2))])
77 for xy, tint in zip(pos, self.rng.uniform(0.55, 1.0, (n, 3)), strict=True):
78 self.sprites.append(self.add_child(Sprite2D(texture=self.texture, position=xy, colour=(*tint, 1.0))))
79
80 def on_update(self, dt: float):
81 if Input.just_pressed("spawn"):
82 self.spawn(BATCH)
83
84 pos, vel = self.pos, self.vel
85 low, high = HALF, np.asarray(self.viewport_size) - HALF
86 vel[:, 1] += GRAVITY * dt
87 pos += vel * dt
88 # Bounce off every edge: point the velocity back inside, then clamp.
89 vel[pos < low] = np.abs(vel[pos < low])
90 vel[pos > high] = -np.abs(vel[pos > high])
91 vel[pos[:, 1] > high[1], 1] *= BOUNCE
92 # A floor bounce that lost most of its energy gets a fresh kick, so the
93 # swarm never settles into a motionless row along the floor.
94 tired = (pos[:, 1] > high[1]) & (vel[:, 1] > -KICK_MIN)
95 vel[tired, 1] = -self.rng.uniform(*KICK_RANGE, np.count_nonzero(tired))
96 np.clip(pos, low, high, out=pos)
97
98 for sprite, row in zip(self.sprites, pos, strict=True):
99 sprite.position[:] = row
100
101 self.frames += 1
102 self.elapsed += dt
103 if self.elapsed >= 0.25:
104 self.fps, self.frames, self.elapsed = self.frames / self.elapsed, 0, 0.0
105 self.status.text = f"{len(self.sprites)} bunnies, {self.fps:.0f} fps\nSpace, click or tap: +{BATCH}"
106
107
108if __name__ == "__main__":
109 App(title="Bunnymark", width=960, height=540).run(Bunnymark())