Skip to content

Commit 0444db5

Browse files
einarfDragonMoffon
andauthored
Sprite scale: Preserve performance (#2389)
* Initial work * More fixes + tests * Example fixes * Updated scale method names, and reimplemented scalar setting of scale * Fix examples and tutorials * fixing typing error * linting and type pass * The last place the old method was hiding * perf tests and improved scale setter * comparing func vs unpacking and random numbers in perf test * arcade scale incorrect type unit test --------- Co-authored-by: DragonMoffon <[email protected]>
1 parent ffd9cfd commit 0444db5

17 files changed

+1058
-75
lines changed

arcade/examples/particle_fireworks.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -385,10 +385,11 @@ def rocket_smoke_mutator(particle: LifetimeParticle):
385385
particle.scale = lerp(
386386
0.5,
387387
3.0,
388-
particle.lifetime_elapsed / particle.lifetime_original # type: ignore
388+
particle.lifetime_elapsed / particle.lifetime_original, # type: ignore
389389
)
390390

391391

392+
392393
def main():
393394
""" Main function """
394395
# Create a window class. This is what actually shows up on screen

arcade/examples/sprite_explosion_particles.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ def update(self, delta_time: float = 1/60):
9090
self.alpha -= int(SMOKE_FADE_RATE * time_step)
9191
self.center_x += self.change_x * time_step
9292
self.center_y += self.change_y * time_step
93-
self.scale += SMOKE_EXPANSION_RATE * time_step
93+
self.add_scale(SMOKE_EXPANSION_RATE * time_step)
9494

9595

9696
class Particle(arcade.SpriteCircle):

arcade/examples/sprite_move_animation.py

+1-3
Original file line numberDiff line numberDiff line change
@@ -112,9 +112,7 @@ def setup(self):
112112
# Set up the player
113113
self.score = 0
114114
self.player = PlayerCharacter(self.idle_texture_pair, self.walk_texture_pairs)
115-
116-
self.player.center_x = WINDOW_WIDTH // 2
117-
self.player.center_y = WINDOW_HEIGHT // 2
115+
self.player.position = self.center
118116
self.player.scale = 0.8
119117

120118
self.player_list.append(self.player)

arcade/sprite/base.py

+46-38
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@
22

33
from typing import TYPE_CHECKING, Any, Iterable, TypeVar
44

5-
from pyglet.math import Vec2
6-
75
import arcade
86
from arcade.color import BLACK, WHITE
97
from arcade.exceptions import ReplacementWarning, warning
@@ -262,49 +260,24 @@ def scale_y(self, new_scale_y: AsFloat):
262260
sprite_list._update_size(self)
263261

264262
@property
265-
def scale(self) -> Vec2:
263+
def scale(self) -> Point2:
266264
"""Get or set the x & y scale of the sprite as a pair of values.
265+
You may set both the x & y with a single scalar, but scale will always return
266+
a length 2 tuple of the x & y scale
267267
268-
You may set it to either a single value or a pair of values:
269-
270-
.. list-table::
271-
:header-rows: 0
272-
273-
* - Single value
274-
- ``sprite.scale = 2.0``
275-
276-
* - Tuple or :py:class:`~pyglet,math.Vec2`
277-
- ``sprite.scale = (1.0, 3.0)``
278-
279-
The two-channel version is useful for making health bars and
280-
other indicators.
281-
282-
.. note:: Returns a :py:class:`pyglet.math.Vec2` for
283-
compatibility.
284-
285-
Arcade versions lower than 3,0 used one or both of the following
286-
for scale:
268+
See :py:attr:`.scale_x` and :py:attr:`.scale_y` for individual access.
287269
288-
* A single :py:class:`float` on versions <= 2.6
289-
* A ``scale_xy`` property and exposing only the x component
290-
on some intermediate dev releases
291-
292-
Although scale is internally stored as a :py:class:`tuple`, we
293-
return a :py:class:`pyglet.math.Vec2` to allow the in-place
294-
operators to work in addition to setting values directly:
295-
296-
* Old-style (``sprite.scale *= 2.0``)
297-
* New-style (``sprite.scale *= 2.0, 2.0``)
270+
See :py:meth:`.scale_multiply_uniform` for uniform scaling.
298271
299272
.. note:: Negative scale values are supported.
300273
301-
This applies to both single-axis and dual-axis.
302-
Negatives will flip & mirror the sprite, but the
303-
with will use :py:func:`abs` to report total width
304-
and height instead of negatives.
274+
This applies to both single-axis and dual-axis.
275+
Negatives will flip & mirror the sprite, but the
276+
with will use :py:func:`abs` to report total width
277+
and height instead of negatives.
305278
306279
"""
307-
return Vec2(*self._scale)
280+
return self._scale
308281

309282
@scale.setter
310283
def scale(self, new_scale: Point2 | AsFloat):
@@ -607,6 +580,41 @@ def update_animation(self, delta_time: float = 1 / 60, *args, **kwargs) -> None:
607580

608581
# --- Scale methods -----
609582

583+
def add_scale(self, factor: AsFloat) -> None:
584+
"""Add to the sprite's scale by the factor.
585+
This adds the factor to both the x and y scale values.
586+
587+
Args:
588+
factor: The factor to add to the sprite's scale.
589+
"""
590+
self._scale = self._scale[0] + factor, self._scale[1] + factor
591+
self._hit_box.scale = self._scale
592+
tex_width, tex_height = self._texture.size
593+
self._width = tex_width * self._scale[0]
594+
self._height = tex_height * self._scale[1]
595+
596+
self.update_spatial_hash()
597+
for sprite_list in self.sprite_lists:
598+
sprite_list._update_size(self)
599+
600+
def multiply_scale(self, factor: AsFloat) -> None:
601+
"""multiply the sprite's scale by the factor.
602+
This multiplies both the x and y scale values by the factor.
603+
604+
Args:
605+
factor: The factor to scale up the sprite by.
606+
"""
607+
608+
self._scale = self._scale[0] * factor, self._scale[1] * factor
609+
self._hit_box.scale = self._scale
610+
tex_width, tex_height = self._texture.size
611+
self._width = tex_width * factor
612+
self._height = tex_height * factor
613+
614+
self.update_spatial_hash()
615+
for sprite_list in self.sprite_lists:
616+
sprite_list._update_size(self)
617+
610618
def rescale_relative_to_point(self, point: Point2, scale_by: AsFloat | Point2) -> None:
611619
"""Rescale the sprite and its distance from the passed point.
612620
@@ -695,7 +703,7 @@ def rescale_xy_relative_to_point(self, point: Point, factors_xy: Iterable[float]
695703
Use :py:meth:`.rescale_relative_to_point` instead.
696704
697705
This was added during the 3.0 development cycle before scale was
698-
made into a vector quantitity.
706+
made into a vector quantity.
699707
700708
This method can scale by different amounts on each axis. To
701709
scale along only one axis, set the other axis to ``1.0`` in

benchmarks/sprite/README.md

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
2+
# Sprite Benchmark
3+
4+
Contains performance comparison between two alternative implementations of
5+
sprite handling scaling somewhat differently. This was done at the end
6+
of arcade 3.0 to measure performance of scaling changes.
7+
8+
This can be changed and adjusted as needed to test different scenarios.

benchmarks/sprite/main.py

+148
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
"""
2+
Quick and dirty system measuring differences between two sprite classes.
3+
"""
4+
import gc
5+
import math
6+
import timeit
7+
import arcade
8+
from itertools import cycle
9+
from random import random
10+
11+
from sprite_alt import BasicSprite as SpriteA
12+
from arcade import BasicSprite as SpriteB
13+
14+
random_numbers = cycle(tuple(random() + 0.1 for _ in range(1009)))
15+
16+
N = 100
17+
MEASUREMENT_CONFIG = [
18+
{"name": "populate", "number": N, "measure_method": "populate", "post_methods": ["flush"]},
19+
{"name": "scale_set", "number": N, "measure_method": "scale_set", "post_methods": []},
20+
{"name": "scale_set_uniform", "number": N, "measure_method": "scale_set_uniform", "post_methods": []},
21+
{"name": "scale_mult", "number": N, "measure_method": "scale_mult", "post_methods": []},
22+
{"name": "scale_mult_uniform", "number": N, "measure_method": "scale_mult_uniform", "post_methods": []},
23+
]
24+
25+
26+
class Measurement:
27+
def __init__(self, avg=0.0, min=0.0, max=0.0):
28+
self.avg = avg
29+
self.min = min
30+
self.max = max
31+
32+
@classmethod
33+
def from_values(cls, values: list[float]) -> "Measurement":
34+
return cls(avg=sum(values) / len(values), min=min(values), max=max(values))
35+
36+
# TODO: Compare measurements
37+
38+
def __str__(self):
39+
return f"avg={self.avg}, min={self.min}, max={self.max}"
40+
41+
42+
class SpriteCollection:
43+
sprite_type = None
44+
sprite_count = 100_000
45+
46+
def __init__(self):
47+
self.spritelist = arcade.SpriteList(lazy=True, capacity=self.sprite_count)
48+
49+
def flush(self):
50+
"""Remove all sprites from the spritelist."""
51+
self.spritelist.clear()
52+
53+
def populate(self):
54+
"""Populate the spritelist with sprites."""
55+
texture = arcade.load_texture(":assets:images/items/coinBronze.png")
56+
N = int(math.sqrt(self.sprite_count))
57+
for y in range(N):
58+
for x in range(N):
59+
self.spritelist.append(
60+
self.sprite_type(
61+
texture=texture,
62+
center_x=x * 64,
63+
center_y=y * 64,
64+
scale=(1.0, 1.0),
65+
)
66+
)
67+
68+
# Scale
69+
def scale_set(self):
70+
"""Set the scale of all sprites."""
71+
for sprite in self.spritelist:
72+
sprite.scale = next(random_numbers)
73+
74+
def scale_set_uniform(self):
75+
"""Set the scale of all sprites."""
76+
for sprite in self.spritelist:
77+
sprite.scale_set_uniform(next(random_numbers))
78+
79+
def scale_mult_uniform(self):
80+
"""Multiply the scale of all sprites."""
81+
for sprite in self.spritelist:
82+
sprite.scale_multiply_uniform(next(random_numbers))
83+
84+
def scale_mult(self):
85+
"""Multiply the scale of all sprites uniformly."""
86+
for sprite in self.spritelist:
87+
sprite.multiply_scale(next(random_numbers, 1.0))
88+
89+
# Rotate
90+
# Move
91+
# Collision detection
92+
93+
94+
class SpriteCollectionA(SpriteCollection):
95+
sprite_type = SpriteA
96+
97+
class SpriteCollectionB(SpriteCollection):
98+
sprite_type = SpriteB
99+
100+
101+
def measure_sprite_collection(collection: SpriteCollection, number=10) -> dict[str, Measurement]:
102+
"""Perform actions on the sprite collections and measure the time."""
103+
print(f"Measuring {collection.__class__.__name__}...")
104+
measurements: dict[str, Measurement] = {}
105+
106+
for config in MEASUREMENT_CONFIG:
107+
name = config["name"]
108+
number = config["number"]
109+
measure_method = getattr(collection, config["measure_method"])
110+
post_methods = [getattr(collection, method) for method in config.get("post_methods", [])]
111+
112+
results = []
113+
try:
114+
for _ in range(number):
115+
results.append(timeit.timeit(measure_method, number=1))
116+
for method in post_methods:
117+
method()
118+
measurement = Measurement.from_values(results)
119+
measurements[name] = measurement
120+
print(f"{name}: {measurement}")
121+
except Exception as e:
122+
print(f"Failed to measure {name}: {e}")
123+
124+
collection.flush()
125+
collection.populate()
126+
gc_until_nothing()
127+
128+
return measurements
129+
130+
131+
def gc_until_nothing():
132+
"""Run the garbage collector until no more objects are found."""
133+
while gc.collect():
134+
pass
135+
136+
137+
def main():
138+
a = SpriteCollectionA()
139+
b = SpriteCollectionB()
140+
141+
m1 = measure_sprite_collection(a)
142+
gc_until_nothing()
143+
m2 = measure_sprite_collection(b)
144+
# FIXME: Compare measurements
145+
146+
147+
if __name__ == '__main__':
148+
main()

0 commit comments

Comments
 (0)