Bunnymark

a sprite-renderer stress test.

▶ Run in browser

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