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

Revamp texture loading and caching #1634

Merged
merged 22 commits into from
Jan 2, 2024
Merged
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
4 changes: 4 additions & 0 deletions arcade/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ def configure_logging(level: Optional[int] = None):
from .application import open_window

from .texture import Texture
from .texture import TextureManager
from .texture import SpriteSheet
from .texture import load_spritesheet
from .texture import load_texture
from .texture import load_texture_pair
Expand Down Expand Up @@ -273,6 +275,8 @@ def configure_logging(level: Optional[int] = None):
'SpriteSolidColor',
'Text',
'Texture',
'TextureManager',
'SpriteSheet',
'TextureAtlas',
'load_atlas',
'save_atlas',
Expand Down
1 change: 0 additions & 1 deletion arcade/resources/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,6 @@ def resolve(path: Union[str, Path]) -> Path:
except FileNotFoundError:
raise FileNotFoundError(f"Cannot locate resource : {path}")


def add_resource_handle(handle: str, path: Union[str, Path]) -> None:
"""
Add a resource handle or path to an existing handle.
Expand Down
6 changes: 5 additions & 1 deletion arcade/texture/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

from .texture import Texture, ImageData
from .spritesheet import SpriteSheet
from .loading import (
load_texture,
load_textures,
Expand All @@ -17,6 +18,7 @@
get_default_texture,
get_default_image,
)
from .manager import TextureManager

__all__ = [
"Texture",
Expand All @@ -31,4 +33,6 @@
"cleanup_texture_cache",
"get_default_texture",
"get_default_image",
]
"TextureManager",
"SpriteSheet",
]
1 change: 0 additions & 1 deletion arcade/texture/loading.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,6 @@ def _load_or_get_image(
Load an image, or return a cached version

:param file_path: Path to image
:param hit_box_algorithm: The hit box algorithm
:param hash: Hash of the image
:return: Tuple of image data and a boolean indicating if the image
was fetched from cache
Expand Down
84 changes: 84 additions & 0 deletions arcade/texture/manager.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
from pathlib import Path
from typing import Union, Optional

import arcade
from arcade.hitbox import HitBoxAlgorithm
from .texture import Texture
from arcade.cache import (
TextureCache,
ImageDataCache,
HitBoxCache,
)
from . import SpriteSheet

class TextureManager:
"""
This class is used to manage textures. It is used to keep track of
textures that have been loaded, and to make sure we don't load the
same texture twice.

Textures loaded through this manager is cached internally.
"""
def __init__(self):
self._sprite_sheets = {}
self._hit_box_cache = HitBoxCache()
self._image_data_cache = ImageDataCache()
self._texture_cache = TextureCache()

def texture(
self,
path: Union[str, Path],
hit_box_algorithm: Optional[HitBoxAlgorithm] = None,
cache: bool = True,
) -> Texture:
"""
Loads a texture or returns a cached version.

:param path: Path to the file to load.
:param hit_box_algorithm: Algorithm to use to create a hit box for the texture.
"""
# TODO: DON'T CALL arcade.load_texture?
texture = arcade.load_texture(path, hit_box_algorithm=hit_box_algorithm)
# Do caching here
return texture

def spritesheet(self, path: Union[str, Path], cache: bool = True) -> SpriteSheet:
"""
Loads a spritesheet or returns a cached version.

:param path: Path to the file to load.
:param cache: If ``True``, the spritesheet will be cached. If ``False``, the
spite sheet will not be cached or returned from the cache.
"""
path = arcade.resources.resolve_resource_path(path)
if path in self._sprite_sheets and cache:
return self._sprite_sheets[path]

sprite_sheet = SpriteSheet(path)
if cache:
self._sprite_sheets[path] = sprite_sheet
return sprite_sheet

def flush(
self,
sprite_sheets: bool = True,
textures: bool = True,
image_data: bool = True,
hit_boxes: bool = False,
):
"""
Remove contents from the texture manager.

:param sprite_sheets: If ``True``, sprite sheets will be flushed.
:param textures: If ``True``, textures will be flushed.
:param image_data: If ``True``, image data will be flushed.
:param hit_boxes: If ``True``, hit boxes will be flushed.
"""
if sprite_sheets:
self._sprite_sheets.clear()
if textures:
self._texture_cache.clear()
if image_data:
self._image_data_cache.clear()
if hit_boxes:
self._hit_box_cache.clear()
148 changes: 148 additions & 0 deletions arcade/texture/spritesheet.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
from PIL import Image
from pathlib import Path
from typing import Union, Tuple, Optional, List, TYPE_CHECKING

# from arcade import Texture
from arcade.types import Rect
from .texture import Texture

if TYPE_CHECKING:
from arcade.hitbox import HitBoxAlgorithm


class SpriteSheet:
"""
Class to hold a sprite sheet. A sprite sheet is a single image that contains
multiple textures. Textures can be created from the sprite sheet by cropping
out sections of the image.

This is only a utility class. It does not have any special functionality

:param path: Path to the file to load.
:param image: PIL image to use.
"""
def __init__(
self,
path: Optional[Union[str, Path]] = None,
image: Optional[Image.Image] = None,
):
from arcade.resources import resolve
self._path = None
if path:
self._path = resolve(path)
self._image = Image.open(self._path).convert("RGBA")
elif image:
self._image = image
else:
raise ValueError("Must provide either path or image")

self._flip_flags = (False, False)

@classmethod
def from_image(cls, image: Image.Image):
"""
Create a sprite sheet from a PIL image.

:param image: PIL image to use.
"""
return cls(image=image)

@property
def image(self) -> Image.Image:
"""
Get or set the PIL image for this sprite sheet.
"""
return self._image

@image.setter
def image(self, image: Image.Image):
self._image = image

@property
def path(self) -> Optional[Path]:
"""
The path to the sprite sheet.

:return: The path.
"""
return self._path

@property
def flip_flags(self) -> Tuple[bool, bool]:
"""
Query the orientation of the sprite sheet.
This can be used to determine if the sprite sheet needs to be flipped.

Default values are ``(False, False)``. Will be modified when
:py:meth:`flip_left_right` or :py:meth:`flip_top_bottom` is called.

:return: Tuple of booleans ``(flip_left_right, flip_top_bottom)``.
"""
return self._flip_flags

def flip_left_right(self) -> None:
"""
Flip the sprite sheet left/right.
"""
self._image = self._image.transpose(Image.FLIP_LEFT_RIGHT)
self._flip_flags = (not self._flip_flags[0], self._flip_flags[1])

def flip_top_bottom(self):
"""
Flip the sprite sheet up/down.
"""
self._image = self._image.transpose(Image.FLIP_TOP_BOTTOM)
self._flip_flags = (self._flip_flags[0], not self._flip_flags[1])

def crop(self, area: Rect):
"""
Crop a texture from the sprite sheet.

:param area: Area to crop ``(x, y, width, height)``
"""
pass

def crop_sections(self, sections: List[Rect]):
"""
Crop multiple textures from the sprite sheet by specifying a list of
areas to crop.

:param sections: List of areas to crop ``[(x, y, width, height), ...]``
"""
pass

def crop_grid(
self,
size: Tuple[int, int],
columns: int,
count: int,
margin: Rect = (0, 0, 0, 0),
hit_box_algorithm: Optional["HitBoxAlgorithm"] = None,
) -> List[Texture]:
"""
Crop a grid of textures from the sprite sheet.

:param size: Size of each texture ``(width, height)``
:param columns: Number of columns in the grid
:param count: Number of textures to crop
:param margin: The margin around each texture ``(left, right, bottom, top)``
:param hit_box_algorithm: Hit box algorithm to use for the textures.
"""
textures = []
width, height = size
left, right, bottom, top = margin

for sprite_no in range(count):
row = sprite_no // columns
column = sprite_no % columns

x = (width + left + right) * column
y = (height + top + bottom) * row
im = self.image.crop((x, y, x + width, y + height))

texture = Texture(im, hit_box_algorithm=hit_box_algorithm)
texture.file_path = self._path
texture.crop_values = x, y, width, height
textures.append(texture)

return textures
8 changes: 7 additions & 1 deletion arcade/texture/texture.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

import hashlib
import logging
from typing import Any, Dict, Optional, Tuple, Type, Union, TYPE_CHECKING
from pathlib import Path
from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple, Type, Union
from weakref import WeakSet

import PIL.Image
Expand All @@ -25,6 +25,12 @@
TransposeTransform,
TransverseTransform,
)

from arcade.types import PointList
from arcade.color import TRANSPARENT_BLACK
from arcade.hitbox import HitBoxAlgorithm
from arcade import cache as _cache
from arcade import hitbox
from arcade.types import RGBA255, PointList

if TYPE_CHECKING:
Expand Down
11 changes: 11 additions & 0 deletions tests/unit/camera/test_camera.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import arcade


def test_camera(window):
c1 = arcade.SimpleCamera()
assert c1.viewport == (0, 0, *window.size)
assert c1.projection == (0, window.width, 0, window.height)

c2 = arcade.Camera()
assert c2.viewport == (0, 0, *window.size)
assert c2.projection == (0, window.width, 0, window.height)
64 changes: 64 additions & 0 deletions tests/unit/texture/test_sprite_sheet.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import pytest
from PIL import Image
import arcade

SPRITE_SHEET_RESOURCE = ":resources:images/spritesheets/codepage_437.png"
SPRITE_SHEET_PATH = arcade.resources.resolve(SPRITE_SHEET_RESOURCE)


@pytest.fixture
def image():
return Image.new("RGBA", (10, 10))


def test_create_from_path():
ss = arcade.SpriteSheet(SPRITE_SHEET_RESOURCE)
assert ss.flip_flags == (False, False)
assert ss.image is not None
assert ss.path == SPRITE_SHEET_PATH


def test_create_from_image(image):
ss = arcade.SpriteSheet(image=image)
assert ss.flip_flags == (False, False)
assert ss.image == image
assert ss.path is None


def test_flip():
ss = arcade.SpriteSheet(SPRITE_SHEET_RESOURCE)
im = Image.open(SPRITE_SHEET_PATH).convert("RGBA")
assert ss.image == im
assert ss.flip_flags == (False, False)

ss.flip_left_right()
assert ss.flip_flags == (True, False)
assert ss.image != im
im = im.transpose(Image.FLIP_LEFT_RIGHT)
assert ss.image == im

ss.flip_top_bottom()
assert ss.flip_flags == (True, True)
assert ss.image != im
im = im.transpose(Image.FLIP_TOP_BOTTOM)
assert ss.image == im

# Flip back to original
ss.flip_left_right()
ss.flip_top_bottom()
assert ss.flip_flags == (False, False)


def test_crop():
pass


def test_crop_grid():
ss = arcade.SpriteSheet(SPRITE_SHEET_RESOURCE)
textures = ss.crop_grid(
size=(8, 16),
margin=(0, 1, 0, 0),
columns=32,
count=255,
)
assert len(textures) == 255
Loading