Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] Various render and event loop optimizations to share #483

Draft
wants to merge 2 commits into
base: canon
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions ppb/assetlib.py
Original file line number Diff line number Diff line change
Expand Up @@ -212,9 +212,13 @@ def load(self, timeout: float = None):
Will block until the data is loaded.
"""
# NOTE: This is called by FreeingMixin.__del__()
if not self.is_loaded() and not _executor.running():
logger.warning(f"Waited on {self!r} outside of the engine")
return self._future.result(timeout)
try:
return self._cached_result
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should benchmark this independently. We used to do something like this in Vector and found just doing the work was cheaper than the cost of the look-ups.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I definitely would want to benchmark this, but I will at least point out that in any case where we want to cache something doing it with a try/except block is cheaper than a lookup followed by a calculation because it incurs 0 costs on the hit branch. The try-except variant does not have a cost of look-ups, necessarily.

except AttributeError:
if not self.is_loaded() and not _executor.running():
logger.warning(f"Waited on {self!r} outside of the engine")
self._cached_result = self._future.result(timeout)
return self._cached_result


class ChainingMixin(BackgroundMixin):
Expand Down
8 changes: 4 additions & 4 deletions ppb/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ def publish(self):
callback(event)

event_handler_name = _get_handler_name(type(event).__name__)
for obj in self.walk():
for obj in self.get_objects_for_handler(event_handler_name):
method = getattr(obj, event_handler_name, None)
if callable(method):
try:
Expand Down Expand Up @@ -333,9 +333,9 @@ def _flush_events(self):
"""
self.events = deque()

def walk(self):
def get_objects_for_handler(self, event_handler_name):
"""
Walk the object tree.
Walk the object tree for a specific event handler name.

Publication order: The :class:`GameEngine`, the
:class:`~ppb.systemslib.System` list, the current
Expand All @@ -346,4 +346,4 @@ def walk(self):
yield from self.systems
yield self.current_scene
if self.current_scene is not None:
yield from self.current_scene
yield from self.current_scene.get_objects_for_handler(event_handler_name)
20 changes: 19 additions & 1 deletion ppb/scenes.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@
from ppb.camera import Camera


get_layer = lambda s: getattr(s, "layer", 0)


class GameObjectCollection(Collection):
"""A container for game objects."""

Expand Down Expand Up @@ -111,6 +114,7 @@ def __init__(self, *,
setattr(self, k, v)

self.game_objects = self.container_class()
self.event_handler_cache = {}

if set_up is not None:
set_up(self)
Expand All @@ -120,6 +124,18 @@ def __contains__(self, item: Hashable) -> bool:

def __iter__(self) -> Iterator:
return (x for x in self.game_objects)

def get_objects_for_handler(self, event_handler_name):
try:
return self.event_handler_cache[event_handler_name]
except:
results = []
for obj in self:
method = getattr(obj, event_handler_name, None)
if callable(method):
results.append(obj)
self.event_handler_cache[event_handler_name] = results
return results

@property
def kinds(self):
Expand Down Expand Up @@ -158,6 +174,7 @@ def add(self, game_object: Hashable, tags: Iterable=())-> None:
scene.add(MyGameObject(), tags=("red", "blue")
"""
self.game_objects.add(game_object, tags)
self.event_handler_cache = {}

def get(self, *, kind: Type=None, tag: Hashable=None, **kwargs) -> Iterator:
"""
Expand Down Expand Up @@ -192,6 +209,7 @@ def remove(self, game_object: Hashable) -> None:
scene.remove(my_game_object)
"""
self.game_objects.remove(game_object)
self.event_handler_cache = {}

def sprite_layers(self) -> Iterator:
"""
Expand All @@ -205,4 +223,4 @@ def sprite_layers(self) -> Iterator:
This function exists primarily to assist the Renderer subsystem,
but will be left public for other creative uses.
"""
return sorted(self, key=lambda s: getattr(s, "layer", 0))
return sorted(self, key=get_layer)
117 changes: 77 additions & 40 deletions ppb/systems/renderer.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import ctypes
from functools import lru_cache
import io
import logging
import random
Expand Down Expand Up @@ -139,6 +140,9 @@ def __init__(
self.target_frame_rate = target_frame_rate
self.target_frame_length = 1 / self.target_frame_rate
self.target_clock = get_time() + self.target_frame_length
self.last_opacity = 255
self.last_opacity_mode = OPACITY_MODES[flags.BlendModeNone]
self.last_tint = (255, 255, 255)

self._texture_cache = ObjectSideData()

Expand Down Expand Up @@ -240,12 +244,17 @@ def prepare_resource(self, game_object):
return None

if not hasattr(game_object, '__image__'):
return
return None

image = game_object.__image__()
if image is None:
try:
image = game_object.__image__()
except AttributeError:
return None
else:
if image is flags.DoNotRender or image is None:
return None

# Can change for animated objects, cannot reliable cache
surface = image.load()
try:
texture = self._texture_cache[surface]
Expand All @@ -261,24 +270,56 @@ def prepare_resource(self, game_object):
opacity_mode = OPACITY_MODES[opacity_mode]
tint = getattr(game_object, 'tint', (255, 255, 255))

sdl_call(
SDL_SetTextureAlphaMod, texture.inner, opacity,
_check_error=lambda rv: rv < 0
)
if self.last_opacity != opacity:
self.last_opacity = opacity
sdl_call(
SDL_SetTextureAlphaMod, texture.inner, opacity,
_check_error=lambda rv: rv < 0
)

sdl_call(
SDL_SetTextureBlendMode, texture.inner, opacity_mode,
_check_error=lambda rv: rv < 0
)
if self.last_opacity_mode != opacity_mode:
self.last_opacity_mode = opacity_mode
sdl_call(
SDL_SetTextureBlendMode, texture.inner, opacity_mode,
_check_error=lambda rv: rv < 0
)

sdl_call(
SDL_SetTextureColorMod, texture.inner, tint[0], tint[1], tint[2],
_check_error=lambda rv: rv < 0
)
if self.last_tint != tint:
self.last_tine = tint
sdl_call(
SDL_SetTextureColorMod, texture.inner, tint[0], tint[1], tint[2],
_check_error=lambda rv: rv < 0
)

return texture

_compute_cache = {}
def compute_rectangles(self, texture, game_object, camera):
if hasattr(game_object, 'width'):
obj_w = game_object.width
obj_h = game_object.height
else:
obj_w, obj_h = game_object.size

rect = getattr(game_object, 'rect', None)
key = (rect, id(texture), tuple(game_object.position), obj_w, obj_h, camera.pixel_ratio,)

try:
win_w, win_h, src_rect, dest_rect = self._compute_cache[key]
except KeyError:
self._compute_cache[key] = self._compute_rectangles(
rect,
texture,
game_object.position,
obj_w, obj_h, camera.pixel_ratio,
camera,
)
win_w, win_h, src_rect, dest_rect = self._compute_cache[key]

return src_rect, dest_rect, ctypes.c_double(-game_object.rotation)

@staticmethod
def _compute_rectangles(rect, texture, position, obj_width, obj_height, pixel_ratio, camera):
flags = sdl2.stdinc.Uint32()
access = ctypes.c_int()
img_w = ctypes.c_int()
Expand All @@ -289,36 +330,32 @@ def compute_rectangles(self, texture, game_object, camera):
_check_error=lambda rv: rv < 0
)

src_rect = SDL_Rect(x=0, y=0, w=img_w, h=img_h)

if hasattr(game_object, 'width'):
obj_w = game_object.width
obj_h = game_object.height
if rect:
src_rect = SDL_Rect(*rect)
win_w = int(rect[2] * obj_width)
win_h = int(rect[3] * obj_height)
else:
obj_w, obj_h = game_object.size

win_w, win_h = self.target_resolution(img_w.value, img_h.value, obj_w, obj_h, camera.pixel_ratio)

center = camera.translate_point_to_screen(game_object.position)
src_rect = SDL_Rect(x=0, y=0, w=img_w, h=img_h)

if not obj_width:
print("no width")
ratio = img_h / (pixel_ratio * obj_height)
elif not obj_height:
print("no height")
ratio = img_w.value / (pixel_ratio * obj_width)
else:
ratio_w = img_w.value / (pixel_ratio * obj_width)
ratio_h = img_h.value / (pixel_ratio * obj_height)
ratio = min(ratio_w, ratio_h) # smaller value -> less reduction

win_w, win_h = round(img_w.value / ratio), round(img_h.value / ratio)

center = camera.translate_point_to_screen(position)
dest_rect = SDL_Rect(
x=int(center.x - win_w / 2),
y=int(center.y - win_h / 2),
w=win_w,
h=win_h,
)

return src_rect, dest_rect, ctypes.c_double(-game_object.rotation)

@staticmethod
def target_resolution(img_width, img_height, obj_width, obj_height, pixel_ratio):
if not obj_width:
print("no width")
ratio = img_height / (pixel_ratio * obj_height)
elif not obj_height:
print("no height")
ratio = img_width / (pixel_ratio * obj_width)
else:
ratio_w = img_width / (pixel_ratio * obj_width)
ratio_h = img_height / (pixel_ratio * obj_height)
ratio = min(ratio_w, ratio_h) # smaller value -> less reduction
return round(img_width / ratio), round(img_height / ratio)
return win_w, win_h, src_rect, dest_rect