"""Group multiple small images into larger textures. This module is used by :py:mod:`pyglet.resource` to efficiently pack small images into larger textures. :py:class:`~pyglet.image.atlas.TextureAtlas` maintains one texture; :py:class:`TextureBin` manages a collection of atlases of a given size. Example usage:: # Load images from disk car_image = pyglet.image.load('car.png') boat_image = pyglet.image.load('boat.png') # Pack these images into one or more textures bin = TextureBin() car_texture = bin.add(car_image) boat_texture = bin.add(boat_image) The result of :py:meth:`TextureBin.add` is a :py:class:`TextureRegion` containing the image. Once added, an image cannot be removed from a bin (or an atlas); nor can a list of images be obtained from a given bin or atlas -- it is the application's responsibility to keep track of the regions returned by the ``add`` methods. .. versionadded:: 1.1 """ from typing import TYPE_CHECKING, Tuple, Optional import pyglet if TYPE_CHECKING: from pyglet.image import AbstractImage, TextureRegion, TextureArrayRegion class AllocatorException(Exception): """The allocator does not have sufficient free space for the requested image size.""" pass class _Strip: __slots__ = 'x', 'y', 'max_height', 'y2' def __init__(self, y: int, max_height: int) -> None: self.x = 0 self.y = y self.max_height = max_height self.y2 = y def add(self, width: int, height: int) -> Tuple[int, int]: assert width > 0 and height > 0 assert height <= self.max_height x, y = self.x, self.y self.x += width self.y2 = max(self.y + height, self.y2) return x, y def compact(self) -> None: self.max_height = self.y2 - self.y class Allocator: """Rectangular area allocation algorithm. Initialise with a given ``width`` and ``height``, then repeatedly call `alloc` to retrieve free regions of the area and protect that area from future allocations. `Allocator` uses a fairly simple strips-based algorithm. It performs best when rectangles are allocated in decreasing height order. """ __slots__ = 'width', 'height', 'strips', 'used_area' def __init__(self, width: int, height: int) -> None: """Create an `Allocator` of the given size. :Parameters: `width` : int Width of the allocation region. `height` : int Height of the allocation region. """ assert width > 0 and height > 0 self.width = width self.height = height self.strips = [_Strip(0, height)] self.used_area = 0 def alloc(self, width: int, height: int) -> Tuple[int, int]: """Get a free area in the allocator of the given size. After calling `alloc`, the requested area will no longer be used. If there is not enough room to fit the given area `AllocatorException` is raised. :Parameters: `width` : int Width of the area to allocate. `height` : int Height of the area to allocate. :rtype: int, int :return: The X and Y coordinates of the bottom-left corner of the allocated region. """ for strip in self.strips: if self.width - strip.x >= width and strip.max_height >= height: self.used_area += width * height return strip.add(width, height) if self.width >= width and self.height - strip.y2 >= height: self.used_area += width * height strip.compact() newstrip = _Strip(strip.y2, self.height - strip.y2) self.strips.append(newstrip) return newstrip.add(width, height) raise AllocatorException('No more space in %r for box %dx%d' % (self, width, height)) def get_usage(self) -> float: """Get the fraction of area already allocated. This method is useful for debugging and profiling only. :rtype: float """ return self.used_area / float(self.width * self.height) def get_fragmentation(self) -> float: """Get the fraction of area that's unlikely to ever be used, based on current allocation behaviour. This method is useful for debugging and profiling only. :rtype: float """ # The total unused area in each compacted strip is summed. if not self.strips: return 0.0 possible_area = self.strips[-1].y2 * self.width return 1.0 - self.used_area / possible_area class TextureAtlas: """Collection of images within a texture.""" def __init__(self, width: int = 2048, height: int = 2048) -> None: """Create a texture atlas of the given size. :Parameters: `width` : int Width of the underlying texture. `height` : int Height of the underlying texture. """ max_texture_size = pyglet.image.get_max_texture_size() width = min(width, max_texture_size) height = min(height, max_texture_size) self.texture = pyglet.image.Texture.create(width, height) self.allocator = Allocator(width, height) def add(self, img: 'AbstractImage', border: int = 0) -> 'TextureRegion': """Add an image to the atlas. This method will fail if the given image cannot be transferred directly to a texture (for example, if it is another texture). :py:class:`~pyglet.image.ImageData` is the usual image type for this method. `AllocatorException` will be raised if there is no room in the atlas for the image. :Parameters: `img` : `~pyglet.image.AbstractImage` The image to add. `border` : int Leaves specified pixels of blank space around each image added to the Atlas. :rtype: :py:class:`~pyglet.image.TextureRegion` :return: The region of the atlas containing the newly added image. """ x, y = self.allocator.alloc(img.width + border * 2, img.height + border * 2) self.texture.blit_into(img, x + border, y + border, 0) return self.texture.get_region(x + border, y + border, img.width, img.height) class TextureBin: """Collection of texture atlases. :py:class:`~pyglet.image.atlas.TextureBin` maintains a collection of texture atlases, and creates new ones as necessary to accommodate images added to the bin. """ def __init__(self, texture_width: int = 2048, texture_height: int = 2048) -> None: """Create a texture bin for holding atlases of the given size. :Parameters: `texture_width` : int Width of texture atlases to create. `texture_height` : int Height of texture atlases to create. `border` : int Leaves specified pixels of blank space around each image added to the Atlases. """ max_texture_size = pyglet.image.get_max_texture_size() self.texture_width = min(texture_width, max_texture_size) self.texture_height = min(texture_height, max_texture_size) self.atlases = [] def add(self, img: 'AbstractImage', border: int = 0) -> 'TextureRegion': """Add an image into this texture bin. This method calls `TextureAtlas.add` for the first atlas that has room for the image. `AllocatorException` is raised if the image exceeds the dimensions of ``texture_width`` and ``texture_height``. :Parameters: `img` : `~pyglet.image.AbstractImage` The image to add. `border` : int Leaves specified pixels of blank space around each image added to the Atlas. :rtype: :py:class:`~pyglet.image.TextureRegion` :return: The region of an atlas containing the newly added image. """ for atlas in list(self.atlases): try: return atlas.add(img, border) except AllocatorException: # Remove atlases that are no longer useful (so that their textures # can later be freed if the images inside them get collected). if img.width < 64 and img.height < 64: self.atlases.remove(atlas) atlas = TextureAtlas(self.texture_width, self.texture_height) self.atlases.append(atlas) return atlas.add(img, border) class TextureArrayBin: """Collection of texture arrays. :py:class:`~pyglet.image.atlas.TextureArrayBin` maintains a collection of texture arrays, and creates new ones as necessary as the depth is exceeded. """ def __init__(self, texture_width: int = 2048, texture_height: int = 2048, max_depth: Optional[int] = None) -> None: max_texture_size = pyglet.image.get_max_texture_size() self.max_depth = max_depth or pyglet.image.get_max_array_texture_layers() self.texture_width = min(texture_width, max_texture_size) self.texture_height = min(texture_height, max_texture_size) self.arrays = [] def add(self, img: 'AbstractImage') -> 'TextureArrayRegion': """Add an image into this texture array bin. This method calls `TextureArray.add` for the first array that has room for the image. `TextureArraySizeExceeded` is raised if the image exceeds the dimensions of ``texture_width`` and ``texture_height``. :Parameters: `img` : `~pyglet.image.AbstractImage` The image to add. :rtype: :py:class:`~pyglet.image.TextureArrayRegion` :return: The region of an array containing the newly added image. """ try: array = self.arrays[-1] return array.add(img) except pyglet.image.TextureArrayDepthExceeded: pass except IndexError: pass array = pyglet.image.TextureArray.create(self.texture_width, self.texture_height, max_depth=self.max_depth) self.arrays.append(array) return array.add(img)