Source code for arcade.gl.texture

from array import array
from ctypes import byref
import weakref
from typing import Any, Optional, Tuple, Union, TYPE_CHECKING

from pyglet import gl

from .buffer import Buffer
from .utils import data_to_ctypes
from .types import pixel_formats

if TYPE_CHECKING:  # handle import cycle caused by type hinting
    from arcade.gl import Context


[docs]class Texture: """ An OpenGL 2D texture. We can create an empty black texture or a texture from byte data. A texture can also be created with different datatypes such as float, integer or unsigned integer. The best way to create a texture instance is through :py:meth:`arcade.gl.Context.texture` Supported ``dtype`` values are:: # Float formats 'f1': UNSIGNED_BYTE 'f2': HALF_FLOAT 'f4': FLOAT # int formats 'i1': BYTE 'i2': SHORT 'i4': INT # uint formats 'u1': UNSIGNED_BYTE 'u2': UNSIGNED_SHORT 'u4': UNSIGNED_INT :param Context ctx: The context the object belongs to :param Tuple[int, int] size: The size of the texture :param int components: The number of components (1: R, 2: RG, 3: RGB, 4: RGBA) :param str dtype: The data type of each component: f1, f2, f4 / i1, i2, i4 / u1, u2, u4 :param data: The texture data (optional). Can be bytes or any object supporting the buffer protocol. :param Any data: The byte data of the texture. bytes or anything supporting the buffer protocol. :param Tuple[gl.GLuint,gl.GLuint] filter: The minification/magnification filter of the texture :param gl.GLuint wrap_x: Wrap mode x :param gl.GLuint wrap_y: Wrap mode y :param int target: The texture type (Ignored. Legacy) :param bool depth: creates a depth texture if `True` :param int samples: Creates a multisampled texture for values > 0. This value will be clamped between 0 and the max sample capability reported by the drivers. :param bool immutable: Make the storage (not the contents) immutable. This can sometimes be required when using textures with compute shaders. """ __slots__ = ( "_ctx", "_glo", "_width", "_height", "_dtype", "_target", "_components", "_alignment", "_depth", "_compare_func", "_format", "_internal_format", "_type", "_component_size", "_samples", "_filter", "_wrap_x", "_wrap_y", "_anisotropy", "_immutable", "__weakref__", ) _compare_funcs = { None: gl.GL_NONE, "<=": gl.GL_LEQUAL, "<": gl.GL_LESS, ">=": gl.GL_GEQUAL, ">": gl.GL_GREATER, "==": gl.GL_EQUAL, "!=": gl.GL_NOTEQUAL, "0": gl.GL_NEVER, "1": gl.GL_ALWAYS, } # Swizzle conversion lookup _swizzle_enum_to_str = { gl.GL_RED: 'R', gl.GL_GREEN: 'G', gl.GL_BLUE: 'B', gl.GL_ALPHA: 'A', gl.GL_ZERO: '0', gl.GL_ONE: '1', } _swizzle_str_to_enum = { 'R': gl.GL_RED, 'G': gl.GL_GREEN, 'B': gl.GL_BLUE, 'A': gl.GL_ALPHA, '0': gl.GL_ZERO, '1': gl.GL_ONE, } def __init__( self, ctx: "Context", size: Tuple[int, int], *, components: int = 4, dtype: str = "f1", data: Any = None, filter: Tuple[gl.GLuint, gl.GLuint] = None, wrap_x: gl.GLuint = None, wrap_y: gl.GLuint = None, target=gl.GL_TEXTURE_2D, depth=False, samples: int = 0, immutable: bool = False, ): self._glo = glo = gl.GLuint() self._ctx = ctx self._width, self._height = size self._dtype = dtype self._components = components self._alignment = 1 self._target = target self._samples = min(max(0, samples), self._ctx.info.MAX_SAMPLES) self._depth = depth self._immutable = immutable self._compare_func: Optional[str] = None self._anisotropy = 1.0 # Default filters for float and integer textures # Integer textures should have NEAREST interpolation # by default 3.3 core doesn't really support it consistently. if "f" in self._dtype: self._filter = gl.GL_LINEAR, gl.GL_LINEAR else: self._filter = gl.GL_NEAREST, gl.GL_NEAREST self._wrap_x = gl.GL_REPEAT self._wrap_y = gl.GL_REPEAT if self._components not in [1, 2, 3, 4]: raise ValueError("Components must be 1, 2, 3 or 4") if data and self._samples > 0: raise ValueError("Multisamples textures are not writable (cannot be initialized with data)") self._target = gl.GL_TEXTURE_2D if self._samples == 0 else gl.GL_TEXTURE_2D_MULTISAMPLE gl.glActiveTexture(gl.GL_TEXTURE0 + self._ctx.default_texture_unit) gl.glGenTextures(1, byref(self._glo)) if self._glo.value == 0: raise RuntimeError( "Cannot create Texture. OpenGL failed to generate a texture id" ) gl.glBindTexture(self._target, self._glo) if data is not None: byte_length, data = data_to_ctypes(data) self._texture_2d(data) # Only set texture parameters on non-multisamples textures if self._samples == 0: self.filter = filter or self._filter self.wrap_x = wrap_x or self._wrap_x self.wrap_y = wrap_y or self._wrap_y if self._ctx.gc_mode == "auto": weakref.finalize(self, Texture.delete_glo, self._ctx, glo) self.ctx.stats.incr("texture")
[docs] def resize(self, size: Tuple[int, int]): """ Resize the texture. This will re-allocate the internal memory and all pixel data will be lost. """ if self._immutable: raise ValueError("Immutable textures cannot be resized") gl.glActiveTexture(gl.GL_TEXTURE0 + self._ctx.default_texture_unit) gl.glBindTexture(self._target, self._glo) self._width, self._height = size self._texture_2d(None)
def __del__(self): # Intercept garbage collection if we are using Context.gc() if self._ctx.gc_mode == "context_gc" and self._glo.value > 0: self._ctx.objects.append(self) def _texture_2d(self, data): """Create a 2D texture""" # Start by resolving the texture format try: format_info = pixel_formats[self._dtype] except KeyError: raise ValueError( f"dype '{self._dtype}' not support. Supported types are : {tuple(pixel_formats.keys())}" ) _format, _internal_format, self._type, self._component_size = format_info # If we are dealing with a multisampled texture we have less options if self._target == gl.GL_TEXTURE_2D_MULTISAMPLE: gl.glTexImage2DMultisample( self._target, self._samples, _internal_format[self._components], self._width, self._height, True, # Fixed sample locations ) return # Make sure we unpack the pixel data with correct alignment # or we'll end up with corrupted textures gl.glPixelStorei(gl.GL_UNPACK_ALIGNMENT, self._alignment) gl.glPixelStorei(gl.GL_PACK_ALIGNMENT, self._alignment) # Create depth 2d texture if self._depth: gl.glTexImage2D( self._target, 0, # level gl.GL_DEPTH_COMPONENT24, self._width, self._height, 0, gl.GL_DEPTH_COMPONENT, gl.GL_UNSIGNED_INT, # gl.GL_FLOAT, data, ) self.compare_func = "<=" # Create normal 2d texture else: try: self._format = _format[self._components] self._internal_format = _internal_format[self._components] if self._immutable: # Specify immutable storage for this texture. # glTexStorage2D can only be called once gl.glTexStorage2D( self._target, 1, # Levels self._internal_format, self._width, self._height, ) if data: self.write(data) else: # Specify mutable storage for this texture. # glTexImage2D can be called multiple times to re-allocate storage gl.glTexImage2D( self._target, # target 0, # level self._internal_format, # internal_format self._width, # width self._height, # height 0, # border self._format, # format self._type, # type data, # data ) except gl.GLException as ex: raise gl.GLException( ( f"Unable to create texture: {ex} : dtype={self._dtype} " f"size={self.size} components={self._components} " f"MAX_TEXTURE_SIZE = {self.ctx.info.MAX_TEXTURE_SIZE}" ) ) @property def ctx(self) -> "Context": """ The context this texture belongs to :type: :py:class:`~arcade.gl.Context` """ return self._ctx @property def glo(self) -> gl.GLuint: """ The OpenGL texture id :type: GLuint """ return self._glo @property def width(self) -> int: """ The width of the texture in pixels :type: int """ return self._width @property def height(self) -> int: """ The height of the texture in pixels :type: int """ return self._height @property def dtype(self) -> str: """ The data type of each component :type: str """ return self._dtype @property def size(self) -> Tuple[int, int]: """ The size of the texture as a tuple :type: tuple (width, height) """ return self._width, self._height @property def samples(self) -> int: """ Number of samples if multisampling is enabled (read only) :type: int """ return self._samples @property def byte_size(self) -> int: """ The byte size of the texture. :type: int """ return pixel_formats[self._dtype][3] * self._components * self.width * self.height @property def components(self) -> int: """ Number of components in the texture :type: int """ return self._components @property def depth(self) -> bool: """ If this is a depth texture. :type: bool """ return self._depth @property def immutable(self) -> bool: """ Does this texture have immutable storage? :type: bool """ return self._immutable @property def swizzle(self) -> str: """ str: The swizzle mask of the texture (Default ``'RGBA'``). The swizzle mask change/reorder the ``vec4`` value returned by the ``texture()`` function in a GLSL shaders. This is represented by a 4 character string were each character can be:: 'R' GL_RED 'G' GL_GREEN 'B' GL_BLUE 'A' GL_ALPHA '0' GL_ZERO '1' GL_ONE Example:: # Alpha channel will always return 1.0 texture.swizzle = 'RGB1' # Only return the red component. The rest is masked to 0.0 texture.swizzle = 'R000' # Reverse the components texture.swizzle = 'ABGR' """ gl.glActiveTexture(gl.GL_TEXTURE0 + self._ctx.default_texture_unit) gl.glBindTexture(self._target, self._glo) # Read the current swizzle values from the texture swizzle_r = gl.GLint() swizzle_g = gl.GLint() swizzle_b = gl.GLint() swizzle_a = gl.GLint() gl.glGetTexParameteriv(self._target, gl.GL_TEXTURE_SWIZZLE_R, swizzle_r) gl.glGetTexParameteriv(self._target, gl.GL_TEXTURE_SWIZZLE_G, swizzle_g) gl.glGetTexParameteriv(self._target, gl.GL_TEXTURE_SWIZZLE_B, swizzle_b) gl.glGetTexParameteriv(self._target, gl.GL_TEXTURE_SWIZZLE_A, swizzle_a) swizzle_str = "" for v in [swizzle_r, swizzle_g, swizzle_b, swizzle_a]: swizzle_str += self._swizzle_enum_to_str[v.value] return swizzle_str @swizzle.setter def swizzle(self, value: str): if not isinstance(value, str): raise ValueError(f"Swizzle must be a string, not '{type(str)}'") if len(value) != 4: raise ValueError("Swizzle must be a string of length 4") swizzle_enums = [] for c in value: try: c = c.upper() swizzle_enums.append(self._swizzle_str_to_enum[c]) except KeyError: raise ValueError(f"Swizzle value '{c}' invalid. Must be one of RGBA01") gl.glTexParameteri(self._target, gl.GL_TEXTURE_SWIZZLE_R, swizzle_enums[0]) gl.glTexParameteri(self._target, gl.GL_TEXTURE_SWIZZLE_G, swizzle_enums[1]) gl.glTexParameteri(self._target, gl.GL_TEXTURE_SWIZZLE_B, swizzle_enums[2]) gl.glTexParameteri(self._target, gl.GL_TEXTURE_SWIZZLE_A, swizzle_enums[3]) @property def filter(self) -> Tuple[int, int]: """Get or set the ``(min, mag)`` filter for this texture. These are rules for how a texture interpolates. The filter is specified for minification and magnification. Default value is ``LINEAR, LINEAR``. Can be set to ``NEAREST, NEAREST`` for pixelated graphics. When mipmapping is used the min filter needs to be one of the ``MIPMAP`` variants. Accepted values:: # Enums can be accessed on the context or arcade.gl NEAREST # Nearest pixel LINEAR # Linear interpolate NEAREST_MIPMAP_NEAREST # Minification filter for mipmaps LINEAR_MIPMAP_NEAREST # Minification filter for mipmaps NEAREST_MIPMAP_LINEAR # Minification filter for mipmaps LINEAR_MIPMAP_LINEAR # Minification filter for mipmaps Also see * https://www.khronos.org/opengl/wiki/Texture#Mip_maps * https://www.khronos.org/opengl/wiki/Sampler_Object#Filtering :type: tuple (min filter, mag filter) """ return self._filter @filter.setter def filter(self, value: Tuple[int, int]): if not isinstance(value, tuple) or not len(value) == 2: raise ValueError("Texture filter must be a 2 component tuple (min, mag)") self._filter = value gl.glActiveTexture(gl.GL_TEXTURE0 + self._ctx.default_texture_unit) gl.glBindTexture(self._target, self._glo) gl.glTexParameteri(self._target, gl.GL_TEXTURE_MIN_FILTER, self._filter[0]) gl.glTexParameteri(self._target, gl.GL_TEXTURE_MAG_FILTER, self._filter[1]) @property def wrap_x(self) -> int: """ Get or set the horizontal wrapping of the texture. This decides how textures are read when texture coordinates are outside the ``[0.0, 1.0]`` area. Default value is ``REPEAT``. Valid options are:: # Note: Enums can also be accessed in arcade.gl # Repeat pixels on the y axis texture.wrap_x = ctx.REPEAT # Repeat pixels on the y axis mirrored texture.wrap_x = ctx.MIRRORED_REPEAT # Repeat the edge pixels when reading outside the texture texture.wrap_x = ctx.CLAMP_TO_EDGE # Use the border color (black by default) when reading outside the texture texture.wrap_x = ctx.CLAMP_TO_BORDER :type: int """ return self._wrap_x @wrap_x.setter def wrap_x(self, value: int): self._wrap_x = value gl.glActiveTexture(gl.GL_TEXTURE0 + self._ctx.default_texture_unit) gl.glBindTexture(self._target, self._glo) gl.glTexParameteri(self._target, gl.GL_TEXTURE_WRAP_S, value) @property def wrap_y(self) -> int: """ Get or set the horizontal wrapping of the texture. This decides how textures are read when texture coordinates are outside the ``[0.0, 1.0]`` area. Default value is ``REPEAT``. Valid options are:: # Note: Enums can also be accessed in arcade.gl # Repeat pixels on the x axis texture.wrap_x = ctx.REPEAT # Repeat pixels on the x axis mirrored texture.wrap_x = ctx.MIRRORED_REPEAT # Repeat the edge pixels when reading outside the texture texture.wrap_x = ctx.CLAMP_TO_EDGE # Use the border color (black by default) when reading outside the texture texture.wrap_x = ctx.CLAMP_TO_BORDER :type: int """ return self._wrap_y @wrap_y.setter def wrap_y(self, value: int): self._wrap_y = value gl.glActiveTexture(gl.GL_TEXTURE0 + self._ctx.default_texture_unit) gl.glBindTexture(self._target, self._glo) gl.glTexParameteri(self._target, gl.GL_TEXTURE_WRAP_T, value) @property def anisotropy(self) -> float: """ Get or set the anisotropy for this texture. """ return self._anisotropy @anisotropy.setter def anisotropy(self, value): self._anisotropy = max(1.0, min(value, self._ctx.info.MAX_TEXTURE_MAX_ANISOTROPY)) gl.glActiveTexture(gl.GL_TEXTURE0 + self._ctx.default_texture_unit) gl.glBindTexture(self._target, self._glo) gl.glTexParameterf(self._target, gl.GL_TEXTURE_MAX_ANISOTROPY, self._anisotropy) @property def compare_func(self) -> Optional[str]: """ Get or set the compare function for a depth texture:: texture.compare_func = None # Disable depth comparison completely texture.compare_func = '<=' # GL_LEQUAL texture.compare_func = '<' # GL_LESS texture.compare_func = '>=' # GL_GEQUAL texture.compare_func = '>' # GL_GREATER texture.compare_func = '==' # GL_EQUAL texture.compare_func = '!=' # GL_NOTEQUAL texture.compare_func = '0' # GL_NEVER texture.compare_func = '1' # GL_ALWAYS :type: str """ return self._compare_func @compare_func.setter def compare_func(self, value: Union[str, None]): if not self._depth: raise ValueError( "Depth comparison function can only be set on depth textures" ) if not isinstance(value, str) and value is not None: raise ValueError(f"value must be as string: {self._compare_funcs.keys()}") func = self._compare_funcs.get(value, None) if func is None: raise ValueError(f"value must be as string: {self._compare_funcs.keys()}") self._compare_func = value gl.glActiveTexture(gl.GL_TEXTURE0 + self._ctx.default_texture_unit) gl.glBindTexture(self._target, self._glo) if value is None: gl.glTexParameteri(self._target, gl.GL_TEXTURE_COMPARE_MODE, gl.GL_NONE) else: gl.glTexParameteri( self._target, gl.GL_TEXTURE_COMPARE_MODE, gl.GL_COMPARE_REF_TO_TEXTURE ) gl.glTexParameteri(self._target, gl.GL_TEXTURE_COMPARE_FUNC, func)
[docs] def read(self, level: int = 0, alignment: int = 1) -> bytearray: """ Read the contents of the texture. :param int level: The texture level to read :param int alignment: Alignment of the start of each row in memory in number of bytes. Possible values: 1,2,4 :rtype: bytearray """ if self._samples > 0: raise ValueError("Multisampled textures cannot be read directly") if self._ctx.gl_api == "gl": gl.glActiveTexture(gl.GL_TEXTURE0 + self._ctx.default_texture_unit) gl.glBindTexture(self._target, self._glo) gl.glPixelStorei(gl.GL_PACK_ALIGNMENT, alignment) buffer = ( gl.GLubyte * (self.width * self.height * self._component_size * self._components) )() gl.glGetTexImage(gl.GL_TEXTURE_2D, level, self._format, self._type, buffer) return bytearray(buffer) elif self._ctx.gl_api == "gles": fbo = self._ctx.framebuffer(color_attachments=[self]) return fbo.read(components=self._components, dtype=self._dtype) else: raise ValueError("Unknown gl_api: '{self._ctx.gl_api}'")
[docs] def write(self, data: Union[bytes, Buffer, array], level: int = 0, viewport=None) -> None: """Write byte data to the texture. This can be bytes or a :py:class:`~arcade.gl.Buffer`. :param Union[bytes,Buffer] data: bytes or a Buffer with data to write :param int level: The texture level to write :param tuple viewport: The are of the texture to write. 2 or 4 component tuple """ # TODO: Support writing to layers using viewport + alignment if self._samples > 0: raise ValueError("Writing to multisampled textures not supported") x, y, w, h = 0, 0, self._width, self._height if viewport: if len(viewport) == 2: w, h = viewport elif len(viewport) == 4: x, y, w, h = viewport else: raise ValueError("Viewport must be of length 2 or 4") if isinstance(data, Buffer): gl.glBindBuffer(gl.GL_PIXEL_UNPACK_BUFFER, data.glo) gl.glActiveTexture(gl.GL_TEXTURE0 + self._ctx.default_texture_unit) gl.glBindTexture(self._target, self._glo) gl.glPixelStorei(gl.GL_PACK_ALIGNMENT, 1) gl.glPixelStorei(gl.GL_UNPACK_ALIGNMENT, 1) gl.glTexSubImage2D( self._target, level, x, y, w, h, self._format, self._type, 0 ) gl.glBindBuffer(gl.GL_PIXEL_UNPACK_BUFFER, 0) else: byte_size, data = data_to_ctypes(data) gl.glActiveTexture(gl.GL_TEXTURE0 + self._ctx.default_texture_unit) gl.glBindTexture(self._target, self._glo) gl.glPixelStorei(gl.GL_PACK_ALIGNMENT, 1) gl.glPixelStorei(gl.GL_UNPACK_ALIGNMENT, 1) gl.glTexSubImage2D( self._target, # target level, # level x, # x offset y, # y offset w, # width h, # height self._format, # format self._type, # type data, # pixel data )
[docs] def build_mipmaps(self, base: int = 0, max_level: int = 1000) -> None: """Generate mipmaps for this texture. The default values usually work well. Mipmaps are successively smaller versions of an original texture with special filtering applied. Using mipmaps allows OpenGL to render scaled versions of original textures with fewer scaling artifacts. Mipmaps can be made for textures of any size. Each mipmap version halves the width and height of the previous one (e.g. 256 x 256, 128 x 128, 64 x 64, etc) down to a minimum of 1 x 1. .. note:: Mipmaps will only be used if a texture's filter is configured with a mipmap-type minification:: # Set up linear interpolating minification filter texture.filter = ctx.LINEAR_MIPMAP_LINEAR, ctx.LINEAR :param int base: Level the mipmaps start at (usually 0) :param int max_level: The maximum number of levels to generate Also see: https://www.khronos.org/opengl/wiki/Texture#Mip_maps """ if self._samples > 0: raise ValueError("Multisampled textures don't support mimpmaps") gl.glActiveTexture(gl.GL_TEXTURE0 + self._ctx.default_texture_unit) gl.glBindTexture(gl.GL_TEXTURE_2D, self._glo) gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_BASE_LEVEL, base) gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_MAX_LEVEL, max_level) gl.glGenerateMipmap(gl.GL_TEXTURE_2D)
[docs] def delete(self): """ Destroy the underlying OpenGL resource. Don't use this unless you know exactly what you are doing. """ Texture.delete_glo(self._ctx, self._glo) self._glo.value = 0
[docs] @staticmethod def delete_glo(ctx: "Context", glo: gl.GLuint): """ Destroy the texture. This is called automatically when the object is garbage collected. :param arcade.gl.Context ctx: OpenGL Context :param gl.GLuint glo: The OpenGL texture id """ # If we have no context, then we are shutting down, so skip this if gl.current_context is None: return if glo.value != 0: gl.glDeleteTextures(1, byref(glo)) ctx.stats.decr("texture")
[docs] def use(self, unit: int = 0) -> None: """Bind the texture to a channel, :param int unit: The texture unit to bind the texture. """ gl.glActiveTexture(gl.GL_TEXTURE0 + unit) gl.glBindTexture(self._target, self._glo)
[docs] def bind_to_image(self, unit: int, read: bool = True, write: bool = True, level: int = 0): """ Bind textures to image units. Note that either or both ``read`` and ``write`` needs to be ``True``. The supported modes are: read only, write only, read-write :param int unit: The image unit :param bool read: The compute shader intends to read from this image :param bool write: The compute shader intends to write to this image :param int level: """ if self._ctx.gl_api == "gles" and not self._immutable: raise ValueError("Textures bound to image units must be created with immutable=True") access = gl.GL_READ_WRITE if read and write: access = gl.GL_READ_WRITE elif read and not write: access = gl.GL_READ_ONLY elif not read and write: access = gl.GL_WRITE_ONLY else: raise ValueError("Illegal access mode. The texture must at least be read or write only") gl.glBindImageTexture(unit, self._glo, level, 0, 0, access, self._internal_format)
def __repr__(self) -> str: return "<Texture glo={} size={}x{} components={}>".format( self._glo.value, self._width, self._height, self._components )