Difficult-Rocket/libs/pyglet/image/atlas.py

289 lines
9.9 KiB
Python
Raw Normal View History

2021-04-16 23:21:06 +08:00
"""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
"""
2023-03-10 21:01:31 +08:00
from typing import TYPE_CHECKING, Tuple
2021-04-16 23:21:06 +08:00
import pyglet
2023-03-10 21:01:31 +08:00
if TYPE_CHECKING:
from pyglet.image import AbstractImage, TextureRegion, TextureArrayRegion
2021-04-16 23:21:06 +08:00
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'
2023-03-10 21:01:31 +08:00
def __init__(self, y: int, max_height: int):
2021-04-16 23:21:06 +08:00
self.x = 0
self.y = y
self.max_height = max_height
self.y2 = y
2023-03-10 21:01:31 +08:00
def add(self, width: int, height: int):
2021-04-16 23:21:06 +08:00
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):
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'
2023-03-10 21:01:31 +08:00
def __init__(self, width: int, height: int):
2021-04-16 23:21:06 +08:00
"""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
2023-03-10 21:01:31 +08:00
def alloc(self, width: int, height: int) -> Tuple[int, int]:
2021-04-16 23:21:06 +08:00
"""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))
2023-03-10 21:01:31 +08:00
def get_usage(self) -> float:
2021-04-16 23:21:06 +08:00
"""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)
2023-03-10 21:01:31 +08:00
def get_fragmentation(self) -> float:
2021-04-16 23:21:06 +08:00
"""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 / float(possible_area)
class TextureAtlas:
"""Collection of images within a texture."""
2023-03-10 21:01:31 +08:00
def __init__(self, width: int = 2048, height: int = 2048):
2021-04-16 23:21:06 +08:00
"""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)
2021-04-16 23:21:06 +08:00
self.allocator = Allocator(width, height)
2023-03-10 21:01:31 +08:00
def add(self, img: 'AbstractImage', border: int = 0) -> 'TextureRegion':
2021-04-16 23:21:06 +08:00
"""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.
"""
2023-03-10 21:01:31 +08:00
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)
2021-04-16 23:21:06 +08:00
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.
"""
2023-03-10 21:01:31 +08:00
def __init__(self, texture_width: int = 2048, texture_height: int = 2048):
2021-04-16 23:21:06 +08:00
"""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 = []
2023-03-10 21:01:31 +08:00
def add(self, img: 'AbstractImage', border: int = 0) -> 'TextureRegion':
2021-04-16 23:21:06 +08:00
"""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).
2021-04-16 23:21:06 +08:00
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)
2023-03-10 21:01:31 +08:00
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=2048, texture_height=2048, max_depth=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)