Bunnymark

a sprite-renderer stress test

▶ Run in browser

Tags: 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 Texture resource (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())