Bunnymark¶
a sprite-renderer stress test
▶ Run in browserTags: 2d sprites performance stress-test
Thousands of Sprite2D instances bounce under gravity, all sharing one procedural bunny texture generated with numpy at startup. The motion loop is numpy-vectorised (one position array, one velocity array, then a thin write-back to the nodes) so the Python-side cost stays low and the count on screen measures the renderer. A HUD shows the live count and a rolling FPS.
What it demonstrates¶
Many Sprite2D instances sharing a single
Textureresource (one GPU upload, one bindless slot, however many bunnies).Vectorised movement: integrate and bounce whole numpy arrays, then write each row back through
sprite.position[:] = row(in-place mutation notifies the transform cache, so the renderer re-collects the moved sprites).A live HUD with Text2D: bunny count plus an FPS figure averaged over a quarter-second window rather than a single jittery frame.
Controls: SPACE / left click - add 1000 bunnies ESC - quit
Run: uv run python examples/features/2d/bunnymark.py Headless self-check: uv run python examples/features/2d/bunnymark.py –test
Source¶
1"""Bunnymark: a sprite-renderer stress test
2
3Thousands of Sprite2D instances bounce under gravity, all sharing one
4procedural bunny texture generated with numpy at startup. The motion loop is
5numpy-vectorised (one position array, one velocity array, then a thin
6write-back to the nodes) so the Python-side cost stays low and the count on
7screen measures the renderer. A HUD shows the live count and a rolling FPS.
8
9# /// simvx
10# tags = ["2d", "sprites", "performance", "stress-test"]
11# web = { root = "Bunnymark", width = 960, height = 540, responsive = true }
12# ///
13
14## What it demonstrates
15- Many Sprite2D instances sharing a single `Texture` resource (one GPU upload,
16 one bindless slot, however many bunnies).
17- Vectorised movement: integrate and bounce whole numpy arrays, then write each
18 row back through `sprite.position[:] = row` (in-place mutation notifies the
19 transform cache, so the renderer re-collects the moved sprites).
20- A live HUD with Text2D: bunny count plus an FPS figure averaged over a
21 quarter-second window rather than a single jittery frame.
22
23Controls:
24 SPACE / left click - add 1000 bunnies
25 ESC - quit
26
27Run: uv run python examples/features/2d/bunnymark.py
28Headless self-check: uv run python examples/features/2d/bunnymark.py --test
29"""
30
31import numpy as np
32
33from simvx.core import Input, Key, MouseButton, Node2D, Sprite2D, Text2D, Texture, Vec2
34from simvx.graphics import App
35
36WIDTH, HEIGHT = 960, 540
37START_COUNT = 1000
38BATCH = 1000
39
40BUNNY_W, BUNNY_H = 26, 32
41HALF_W, HALF_H = BUNNY_W / 2, BUNNY_H / 2
42
43GRAVITY = 980.0 # px/s^2, downward
44BOUNCE = 0.85 # floor restitution
45KICK_MIN = 140.0 # a floor hit slower than this upward gets re-kicked...
46KICK_RANGE = (320.0, 760.0) # ...to a fresh upward speed in this range
47
48
49def _bunny_texture() -> np.ndarray:
50 """A small bunny sprite as an RGBA uint8 array: ears, head, body, face."""
51 ys, xs = np.mgrid[0:BUNNY_H, 0:BUNNY_W].astype(np.float64)
52
53 def ellipse(cx: float, cy: float, rx: float, ry: float) -> np.ndarray:
54 return ((xs - cx) / rx) ** 2 + ((ys - cy) / ry) ** 2 <= 1.0
55
56 img = np.zeros((BUNNY_H, BUNNY_W, 4), dtype=np.uint8)
57 fur = (
58 ellipse(13, 22, 9.0, 8.5) # body
59 | ellipse(13, 12, 7.0, 6.5) # head
60 | ellipse(9, 5, 2.6, 5.0) # left ear
61 | ellipse(17, 5, 2.6, 5.0) # right ear
62 )
63 img[fur] = (235, 232, 238, 255)
64 img[ellipse(9, 5, 1.2, 3.2) | ellipse(17, 5, 1.2, 3.2)] = (240, 170, 190, 255) # inner ears
65 img[ellipse(10.5, 11, 1.1, 1.3) | ellipse(15.5, 11, 1.1, 1.3)] = (40, 35, 45, 255) # eyes
66 img[ellipse(13, 14.5, 1.3, 0.9)] = (235, 140, 160, 255) # nose
67 return img
68
69
70class Bunnymark(Node2D):
71 """N bouncing bunnies, integrated as arrays, displayed as Sprite2D nodes."""
72
73 input_actions = {"spawn": [Key.SPACE, MouseButton.LEFT], "quit": [Key.ESCAPE]}
74
75 def __init__(self, count: int = START_COUNT, **kwargs):
76 super().__init__(**kwargs)
77 self._initial = count
78
79 def on_ready(self):
80 self._w, self._h = self.tree.screen_size
81 self._rng = np.random.default_rng()
82
83 # ONE texture resource shared by every sprite: a single upload, a
84 # single GPU slot, no matter how many bunnies reference it.
85 self._texture = Texture(_bunny_texture())
86
87 # The simulation state lives in two (N, 2) arrays; the nodes only
88 # display it. `_views` holds each sprite's observed position vector so
89 # the write-back is a slice assignment, not a property re-wrap.
90 self._sprites: list[Sprite2D] = []
91 self._views: list[Vec2] = []
92 self._pos = np.empty((0, 2), dtype=np.float64)
93 self._vel = np.empty((0, 2), dtype=np.float64)
94
95 # HUD above the swarm.
96 self._count_text = self.add_child(Text2D(text="", position=(12, 10), font_scale=1.4, outline=0.1, z_index=10))
97 self._fps_text = self.add_child(
98 Text2D(text="fps: ...", position=(12, 38), font_scale=1.4, outline=0.1, z_index=10)
99 )
100 self.add_child(
101 Text2D(
102 text="SPACE / click: +1000 bunnies ESC: quit",
103 position=(12, self._h - 28),
104 colour=(0.9, 0.9, 0.95, 1.0),
105 outline=0.1,
106 z_index=10,
107 )
108 )
109
110 self._fps_frames = 0
111 self._fps_time = 0.0
112 self._fps = 0.0
113
114 self._spawn(self._initial)
115
116 def _spawn(self, n: int):
117 """Add *n* bunnies: extend the arrays, then create the display nodes."""
118 pos = np.column_stack(
119 [
120 self._rng.uniform(HALF_W, self._w - HALF_W, n),
121 self._rng.uniform(HALF_H, self._h * 0.5, n),
122 ]
123 )
124 vel = np.column_stack(
125 [
126 self._rng.uniform(-260.0, 260.0, n),
127 self._rng.uniform(-120.0, 120.0, n),
128 ]
129 )
130 self._pos = np.concatenate([self._pos, pos])
131 self._vel = np.concatenate([self._vel, vel])
132
133 tints = self._rng.uniform(0.55, 1.0, (n, 3))
134 for (x, y), (r, g, b) in zip(pos, tints, strict=True):
135 sprite = self.add_child(Sprite2D(texture=self._texture, position=Vec2(x, y), colour=(r, g, b, 1.0)))
136 self._sprites.append(sprite)
137 self._views.append(sprite.position)
138
139 self._count_text.text = f"bunnies: {len(self._sprites)}"
140
141 def on_update(self, dt: float):
142 if Input.is_action_just_pressed("quit"):
143 self.app.quit()
144 return
145 if Input.is_action_just_pressed("spawn"):
146 self._spawn(BATCH)
147
148 # Integrate and bounce the whole population as arrays. Column views
149 # alias the arrays, so the in-place ops below edit _pos/_vel directly.
150 p, v = self._pos, self._vel
151 v[:, 1] += GRAVITY * dt
152 p += v * dt
153 x, y, vx, vy = p[:, 0], p[:, 1], v[:, 0], v[:, 1]
154
155 left = x < HALF_W
156 right = x > self._w - HALF_W
157 vx[left] = np.abs(vx[left])
158 vx[right] = -np.abs(vx[right])
159 np.clip(x, HALF_W, self._w - HALF_W, out=x)
160
161 floor = y > self._h - HALF_H
162 vy[floor] = -np.abs(vy[floor]) * BOUNCE
163 # A bounce that lost most of its energy gets a fresh kick, so the swarm
164 # never settles into a motionless row along the floor.
165 tired = floor & (vy > -KICK_MIN)
166 if tired.any():
167 vy[tired] = -self._rng.uniform(*KICK_RANGE, int(tired.sum()))
168 ceiling = y < HALF_H
169 vy[ceiling] = np.abs(vy[ceiling])
170 np.clip(y, HALF_H, self._h - HALF_H, out=y)
171
172 # Write-back: one slice assignment per sprite. In-place mutation of the
173 # observed position vector marks the transform dirty, so the item
174 # pipeline re-collects exactly the sprites that moved.
175 for view, row in zip(self._views, p, strict=True):
176 view[:] = row
177
178 # Rolling FPS over a quarter-second window.
179 self._fps_frames += 1
180 self._fps_time += dt
181 if self._fps_time >= 0.25:
182 self._fps = self._fps_frames / self._fps_time
183 self._fps_frames = 0
184 self._fps_time = 0.0
185 self._fps_text.text = f"fps: {self._fps:.1f}"
186
187
188def _selftest() -> bool:
189 """Headless: spawn a few thousand, step frames, and check the books balance.
190
191 The count claim is checked on both sides (arrays and nodes), SPACE goes
192 through the real action map to add a batch mid-run, the swarm must actually
193 move, and every bunny must end inside the window.
194 """
195 from simvx.core.testing import InputSimulator
196 from simvx.graphics.testing import assert_not_blank, save_png
197
198 START, ADD_AT, FRAMES = 2500, 30, 80
199
200 app = App(title="Bunnymark", width=WIDTH, height=HEIGHT, visible=False)
201 scene = Bunnymark(count=START, name="Bunnymark")
202 sim = InputSimulator()
203 marks: dict[str, object] = {}
204
205 def on_frame(idx: int, _t: float) -> bool:
206 if idx == 10:
207 marks["early_pos"] = scene._pos.copy()
208 if idx == ADD_AT - 1:
209 marks["before_add"] = len(scene._sprites)
210 if idx == ADD_AT:
211 sim.press_key(Key.SPACE)
212 elif idx == ADD_AT + 1:
213 sim.release_key(Key.SPACE)
214 return True
215
216 frames = app.run_headless(scene, frames=FRAMES, on_frame=on_frame, capture_frames=[FRAMES - 1])
217 assert_not_blank(frames[0])
218 save_png(frames[0], "/tmp/bunnymark_test.png")
219
220 ok = True
221
222 def check(label: str, passed: bool, detail: str) -> None:
223 nonlocal ok
224 ok = ok and passed
225 print(f"{'ok ' if passed else 'FAIL'} {label}: {detail}")
226
227 check(
228 "the run starts with the requested count",
229 marks["before_add"] == START,
230 f"{marks['before_add']} sprites before the add",
231 )
232 expected = START + BATCH
233 n_sprites, n_rows = len(scene._sprites), scene._pos.shape[0]
234 check(
235 "SPACE adds exactly one batch, arrays and nodes agreeing",
236 n_sprites == expected and n_rows == expected and scene._vel.shape[0] == expected,
237 f"{n_sprites} sprites, {n_rows} position rows, expected {expected}",
238 )
239 check(
240 "every sprite shares the one texture resource",
241 all(s.texture is scene._texture for s in scene._sprites),
242 "one Texture, one GPU upload",
243 )
244
245 moved = float(np.abs(scene._pos[:START] - marks["early_pos"][:START]).mean())
246 check("the swarm moves", moved > 5.0, f"mean displacement {moved:.1f}px since frame 10")
247
248 x, y = scene._pos[:, 0], scene._pos[:, 1]
249 inside = (x >= HALF_W - 1) & (x <= WIDTH - HALF_W + 1) & (y >= HALF_H - 1) & (y <= HEIGHT - HALF_H + 1)
250 check("every bunny ends inside the window", bool(inside.all()), f"{int(inside.sum())}/{expected} in bounds")
251
252 # Vec2 is float32, so the write-back rounds the float64 state at ~1e-5 px.
253 node_xy = np.array([[s.position.x, s.position.y] for s in scene._sprites])
254 drift = float(np.abs(node_xy - scene._pos).max())
255 check("the nodes display the array state", drift < 1e-3, f"max node/array drift {drift:.2e}px")
256
257 check(
258 "the HUD counted frames into an FPS figure",
259 scene._fps > 0.0 and scene._count_text.text == f"bunnies: {expected}",
260 f"fps {scene._fps:.1f}, count text {scene._count_text.text!r}",
261 )
262
263 print("screenshot: /tmp/bunnymark_test.png")
264 print("SELFTEST:", "PASS" if ok else "FAIL")
265 return ok
266
267
268if __name__ == "__main__":
269 import sys
270
271 if "--test" in sys.argv:
272 sys.exit(0 if _selftest() else 1)
273 App(title="SimVX Bunnymark", width=WIDTH, height=HEIGHT).run(Bunnymark())