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. NOTE: Currently does not support multisample textures even though ``_samples`` is set. 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. """ __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", "__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, ): 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._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. """ 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_FLOAT, data, ) self.compare_func = "<=" # Create normal 2d texture else: try: self._format = _format[self._components] self._internal_format = _internal_format[self._components] 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 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") 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)
[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. Leaveing the default arguments will usually does the job. Building mipmaps will create several smaller versions of the texture (256 x 256, 128 x 128, 64 x 64, 32 x 32 etc) helping OpenGL in rendering a nicer version of texture when it's rendered to the screen in smaller version. Note that mipmaps will only be used if the texture 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 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: """ 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 )