Source code for ppb.systems.renderer

import ctypes
import io
import logging
import random
from time import monotonic

import sdl2
import sdl2.ext

from sdl2 import (
    rw_from_object,  # https://pysdl2.readthedocs.io/en/latest/modules/sdl2.html#sdl2.sdl2.rw_from_object
    SDL_Window, SDL_Renderer,
    SDL_Rect,  # https://wiki.libsdl.org/SDL_Rect
    SDL_INIT_VIDEO, SDL_BLENDMODE_BLEND, SDL_FLIP_NONE,
    SDL_CreateWindowAndRenderer,  # https://wiki.libsdl.org/SDL_CreateWindowAndRenderer
    SDL_DestroyRenderer,  # https://wiki.libsdl.org/SDL_DestroyRenderer
    SDL_DestroyWindow,  # https://wiki.libsdl.org/SDL_DestroyWindow
    SDL_SetWindowTitle,  # https://wiki.libsdl.org/SDL_SetWindowTitle
    SDL_RenderPresent,  # https://wiki.libsdl.org/SDL_RenderPresent
    SDL_RenderClear,  # https://wiki.libsdl.org/SDL_RenderClear
    SDL_SetRenderDrawColor,  # https://wiki.libsdl.org/SDL_SetRenderDrawColor
    SDL_FreeSurface,  # https://wiki.libsdl.org/SDL_FreeSurface
    SDL_SetSurfaceBlendMode,  # https://wiki.libsdl.org/SDL_SetSurfaceBlendMode
    SDL_CreateTextureFromSurface,  # https://wiki.libsdl.org/SDL_CreateTextureFromSurface
    SDL_DestroyTexture,  # https://wiki.libsdl.org/SDL_DestroyTexture
    SDL_QueryTexture,  # https://wiki.libsdl.org/SDL_QueryTexture
    SDL_RenderCopyEx,  # https://wiki.libsdl.org/SDL_RenderCopyEx
    SDL_CreateRGBSurface,  # https://wiki.libsdl.org/SDL_CreateRGBSurface
)

from sdl2.sdlimage import (
    IMG_GetError, IMG_SetError,  # https://www.libsdl.org/projects/SDL_image/docs/SDL_image_43.html#SEC43
    IMG_Load_RW,  # https://www.libsdl.org/projects/SDL_image/docs/SDL_image_12.html#SEC12
    IMG_Init, IMG_Quit,  # https://www.libsdl.org/projects/SDL_image/docs/SDL_image_6.html#SEC6
    IMG_INIT_JPG, IMG_INIT_PNG, IMG_INIT_TIF,
)

import ppb.assetlib as assets
import ppb.events as events
import ppb.flags as flags
from ppb.systems._sdl_utils import SdlSubSystem, sdl_call, SdlError
from ppb.systems._utils import ObjectSideData

logger = logging.getLogger(__name__)


DEFAULT_RESOLUTION = 800, 600


class ImgError(SdlError):
    pass


def img_call(func, *pargs, _check_error=None, **kwargs):
    """
    Wrapper for calling SDL functions for handling errors.

    If _check_error is given, called with the return value to check for errors.
    If _check_error returns truthy, an error occurred.

    If _check_error is not given, it is assumed that a non-empty error from
    Mix_GetError indicates error.
    """
    IMG_SetError(b"")
    rv = func(*pargs, **kwargs)
    err = IMG_GetError()
    if (_check_error(rv) if _check_error else err):
        raise SdlError(f"Error calling {func.__name__}: {err.decode('utf-8')}")
    else:
        return rv


# TODO: Move Image out of the renderer so sprites can type hint appropriately.
[docs]class Image(assets.Asset): # Wraps POINTER(SDL_Surface) def background_parse(self, data): file = rw_from_object(io.BytesIO(data)) # ^^^^ is a pure-python emulation, does not need cleanup. surface = img_call( IMG_Load_RW, file, False, _check_error=lambda rv: not rv ) sdl_call( SDL_SetSurfaceBlendMode, surface, SDL_BLENDMODE_BLEND, _check_error=lambda rv: rv < 0 ) return surface def file_missing(self): width = height = 70 # Pixels, arbitrary surface = sdl_call( SDL_CreateRGBSurface, 0, width, height, 32, 0, 0, 0, 0, _check_error=lambda rv: not rv ) rand = random.Random(str(self.name)) r = rand.randint(65, 255) g = rand.randint(65, 255) b = rand.randint(65, 255) color = sdl2.ext.Color(r, g, b) sdl2.ext.fill(surface.contents, color) return surface def free(self, object, _SDL_FreeSurface=SDL_FreeSurface): # ^^^ is a way to keep required functions during interpreter cleanup _SDL_FreeSurface(object) # Can't fail
# object.contents = None # Can't actually nullify the pointer. Good thing this is __del__. class SmartPointer: def __init__(self, obj, dest): self.inner = obj self.destructor = dest def __del__(self): self.destructor(self.inner) class Renderer(SdlSubSystem): _sdl_subsystems = SDL_INIT_VIDEO def __init__( self, resolution=DEFAULT_RESOLUTION, window_title: str = "PursuedPyBear", target_frame_rate: int = 30, **kwargs ): self.resolution = resolution self.window = None self.window_title = window_title self.pixel_ratio = None self.target_frame_rate = target_frame_rate self.target_frame_length = 1 / self.target_frame_rate self.target_clock = monotonic() + self.target_frame_length self._texture_cache = ObjectSideData() def __enter__(self): super().__enter__() img_call(IMG_Init, IMG_INIT_JPG | IMG_INIT_PNG | IMG_INIT_TIF) self.window = ctypes.POINTER(SDL_Window)() self.renderer = ctypes.POINTER(SDL_Renderer)() sdl_call( SDL_CreateWindowAndRenderer, self.resolution[0], # Width self.resolution[1], # Height 0, # Flags # SDL_WINDOW_ALLOW_HIGHDPI - Allow the renderer to work in HiDPI natively ctypes.byref(self.window), ctypes.byref(self.renderer), _check_error=lambda rv: rv < 0 ) # NOTE: It looks like SDL_RENDERER_PRESENTVSYNC will cause SDL_RenderPresent() to block? sdl_call(SDL_SetWindowTitle, self.window, self.window_title.encode('utf-8')) def __exit__(self, *exc): sdl_call(SDL_DestroyRenderer, self.renderer) sdl_call(SDL_DestroyWindow, self.window) img_call(IMG_Quit) super().__exit__(*exc) def on_idle(self, idle_event: events.Idle, signal): t = monotonic() if t >= self.target_clock: self.pre_render_updates(idle_event.scene) signal(events.PreRender()) signal(events.Render()) self.target_clock = t + self.target_frame_length def pre_render_updates(self, scene): camera = scene.main_camera camera.viewport_width, camera.viewport_height = self.resolution self.pixel_ratio = camera.pixel_ratio def on_render(self, render_event, signal): camera = render_event.scene.main_camera self.render_background(render_event.scene) for game_object in render_event.scene.sprite_layers(): texture = self.prepare_resource(game_object) if texture is None: continue src_rect, dest_rect, angle = self.compute_rectangles( texture.inner, game_object, camera ) sdl_call( SDL_RenderCopyEx, self.renderer, texture.inner, ctypes.byref(src_rect), ctypes.byref(dest_rect), angle, None, SDL_FLIP_NONE, _check_error=lambda rv: rv < 0 ) sdl_call(SDL_RenderPresent, self.renderer) def render_background(self, scene): bg = scene.background_color sdl_call( SDL_SetRenderDrawColor, self.renderer, bg[0], bg[1], bg[2], 255, _check_error=lambda rv: rv < 0 ) sdl_call(SDL_RenderClear, self.renderer, _check_error=lambda rv: rv < 0) def prepare_resource(self, game_object): if game_object.size <= 0: return None image = game_object.__image__() if image is flags.DoNotRender or image is None: return None surface = image.load() try: return self._texture_cache[surface] except KeyError: texture = SmartPointer(sdl_call( SDL_CreateTextureFromSurface, self.renderer, surface, _check_error=lambda rv: not rv ), SDL_DestroyTexture) self._texture_cache[surface] = texture return texture def compute_rectangles(self, texture, game_object, camera): flags = sdl2.stdinc.Uint32() access = ctypes.c_int() img_w = ctypes.c_int() img_h = ctypes.c_int() sdl_call( SDL_QueryTexture, texture, ctypes.byref(flags), ctypes.byref(access), ctypes.byref(img_w), ctypes.byref(img_h), _check_error=lambda rv: rv < 0 ) src_rect = SDL_Rect(x=0, y=0, w=img_w, h=img_h) win_w, win_h = self.target_resolution(img_w.value, img_h.value, game_object.size) center = camera.translate_to_viewport(game_object.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) def target_resolution(self, width, height, game_unit_size): values = [width, height] short_side_index = width > height target = self.pixel_ratio * game_unit_size ratio = values[short_side_index] / target return tuple(round(value / ratio) for value in values)