# Copyright (c) 2023-2024 Thomas Mathieson.
# Distributed under the terms of the MIT license.
from __future__ import annotations
from typing import TYPE_CHECKING, Optional, Union
import numpy as np
import numpy.typing as npt
import logging
from PIL import Image
from .ssv_logging import log
if TYPE_CHECKING:
from .ssv_render_process_client import SSVRenderProcessClient
from .ssv_shader_preprocessor import SSVShaderPreprocessor
[docs]
def determine_texture_shape(data: npt.NDArray,
override_dtype: Optional[str],
treat_as_normalized_integer: bool = True) -> tuple[int, int, int, int, Optional[str]]:
"""
Attempts to determine suitable texture parameters given an ndarray. This method returns (0,0,0,0,None) if a
suitable format cannot be found.
:param data: the ndarray to parse.
:param override_dtype: optionally, a moderngl dtype string to use instead of the numpy dtype.
:param treat_as_normalized_integer: when enabled, integer types (singed/unsigned) are treated as normalized
integers by OpenGL, such that when the texture is sampled values in the
texture are mapped to floats in the range [0, 1] or [-1, 1]. See:
https://www.khronos.org/opengl/wiki/Normalized_Integer for more details.
:return: (components, depth, height, width, dtype)
"""
width, height, depth, components, dtype = (0, 0, 0, 0, None)
if len(data.shape) == 1:
# Simple 1D single component texture/buffer texture
width = data.shape[0]
height = 1
components = 1
depth = 0
elif len(data.shape) == 2:
if data.shape[1] <= 4:
# 1D texture with up to 4 components
width, components = data.shape
height = 1
depth = 1
else:
# 2D texture with 1 component
width, height = data.shape
components = 1
depth = 1
elif len(data.shape) == 3:
if data.shape[2] <= 4:
# 2D texture with up to 4 components
width, height, components = data.shape
depth = 1
else:
# 3D texture with 1 component
width, height, depth = data.shape
components = 1
elif len(data.shape) == 4:
if data.shape[3] <= 4:
width, height, depth, components = data.shape
else:
# Too many dimensions
log(f"Couldn't convert array with shape: {data.shape} into a texture! Too many dimensions.",
severity=logging.ERROR)
else:
# Too many dimensions
log(f"Couldn't convert array with shape: {data.shape} into a texture! Too many dimensions.",
severity=logging.ERROR)
if override_dtype is not None:
if len(override_dtype) != 2:
log(f"Invalid dtype '{override_dtype}' provided!", severity=logging.ERROR)
return 0, 0, 0, 0, None
try:
if int(override_dtype[1]) != data.dtype.itemsize:
log(f"Override dtype '{override_dtype}' item size does not match that of the input array "
f"({int(override_dtype[1])} != {data.dtype.itemsize})!", severity=logging.ERROR)
return 0, 0, 0, 0, None
except ValueError:
log(f"Invalid dtype '{override_dtype}' provided!", severity=logging.ERROR)
return 0, 0, 0, 0, None
dtype = override_dtype
conversion = {
"b": "u", # bool
"u": "u", # uint
"i": "i", # int
"f": "f", # float
"c": "f", # complex -> 2 floats
"S": "u", # byte string
"V": "u", # void
}
if dtype is None and data.dtype.kind in conversion:
if not data.dtype.isnative:
log(f"Unsupported dtype '{data.dtype}', must match system endianess.", severity=logging.ERROR)
return 0, 0, 0, 0, None
dtype = conversion[data.dtype.kind]
if data.dtype.kind == "c" and (
data.dtype.itemsize == 2 or data.dtype.itemsize == 4 or data.dtype.itemsize == 8):
# Special case for complex data types
if components <= 2:
# Split complex values into two floats
components *= 2
dtype = f"{dtype}{data.dtype.itemsize / 2}"
else:
log(f"Unsupported dtype '{data.dtype}', complex types can only be used in 1 or 2 component textures!",
severity=logging.ERROR)
return 0, 0, 0, 0, None
elif data.dtype.itemsize == 1 or data.dtype.itemsize == 2 or data.dtype.itemsize == 4:
dtype = f"{dtype}{data.dtype.itemsize}"
else:
log(f"Unsupported dtype '{data.dtype}', must have an itemsize of 1, 2, or 4!", severity=logging.ERROR)
return 0, 0, 0, 0, None
if treat_as_normalized_integer \
and (data.dtype.itemsize == 1 or data.dtype.itemsize == 2) \
and (dtype[0] == "u" or dtype[0] == "i"):
dtype = "n" + dtype
return components, depth, height, width, dtype
[docs]
class SSVTexture:
"""
A lightweight class representing a Texture object.
"""
def __init__(self, texture_uid: Optional[int], render_process_client: SSVRenderProcessClient,
preprocessor: SSVShaderPreprocessor,
data: Union[npt.NDArray, Image.Image], uniform_name: str, force_2d: bool = False,
force_3d: bool = False, override_dtype: Optional[str] = None, treat_as_normalized_integer: bool = True,
declare_uniform: bool = True):
"""
*Used Internally*
Note that ``SSVTexture`` objects should be constructed using the factory method on either an ``SSVCanvas``.
:param texture_uid: the UID to give this texture buffer. Set to ``None`` to generate one automatically.
:param render_process_client: the render process connection belonging to the canvas.
:param preprocessor: the preprocessor belonging to the canvas.
:param data: a NumPy array or a PIL/Pillow Image containing the image data to copy to the texture.
:param uniform_name: the name of the shader uniform to associate this texture with.
:param force_2d: when set, forces the texture to be treated as 2-dimensional, even if it could be represented
by a 1D texture. This only applies in the ambiguous case where a 2D single component texture
has a height <= 4 (eg: ``np.array([[0.0, 0.1, 0.2], [0.3, 0.4, 0.5], [0.6, 0.7, 0.8]])``),
with this parameter set to ``False``, the array would be converted to a 1D texture with a
width of 3 and 3 components; setting this to ``True`` ensures that it becomes a 3x3 texture
with 1 component.
:param force_3d: when set, forces the texture to be treated as 3-dimensional, even if it could be represented
by a 2D texture. See the description of the ``force_2d`` parameter for a full explanation.
:param override_dtype: optionally, a moderngl datatype to force on the texture.
:param treat_as_normalized_integer: when enabled, integer types (singed/unsigned) are treated as normalized
integers by OpenGL, such that when the texture is sampled values in the
texture are mapped to floats in the range [0, 1] or [-1, 1]. See:
https://www.khronos.org/opengl/wiki/Normalized_Integer for more details.
:param declare_uniform: when set, a shader uniform is automatically declared for this uniform in shaders.
"""
self._texture_uid = id(self) if texture_uid is None else texture_uid
self._render_process_client = render_process_client
self._preprocessor = preprocessor
self._uniform_name = uniform_name
self._treat_as_normalized_integer = treat_as_normalized_integer
if isinstance(data, Image.Image):
data_np = np.array(data)
else:
data_np = data
if force_2d and len(data_np.shape) == 2 and data_np.shape[1] <= 4:
data_np = data_np.reshape((*data_np.shape, 1))
if force_3d and len(data_np.shape) == 3 and data_np.shape[2] <= 4:
data_np = data_np.reshape((*data_np.shape, 1))
(self._components, self._depth, self._height, self._width, _dtype) = \
determine_texture_shape(data_np, override_dtype, treat_as_normalized_integer)
assert _dtype is not None
self._dtype = _dtype
sampler_prefix = ""
if not treat_as_normalized_integer:
if data_np.dtype.kind in {"f", "c"}:
sampler_prefix = ""
elif data_np.dtype.kind in {"b", "u", "S", "V"}:
sampler_prefix = "u"
else:
sampler_prefix = "i"
sampler_type = f"{sampler_prefix}sampler3D" if self._depth > 1 else f"{sampler_prefix}sampler2D"
self._render_process_client.update_texture(self._texture_uid, data_np, uniform_name, override_dtype, None,
treat_as_normalized_integer)
if declare_uniform:
self._preprocessor.add_dynamic_uniform(self._uniform_name, sampler_type)
self._is_valid = True
@property
def texture_uid(self) -> int:
"""
Gets the internal UID of this texture object.
"""
return self._texture_uid
@property
def uniform_name(self) -> str:
"""
Gets the shader uniform name associated with this texture.
"""
return self._uniform_name
@property
def components(self) -> int:
"""
Gets the number of components for a single pixel (RGB=3, RGBA=4). Always at least 1, never more than 4.
"""
return self._components
@property
def depth(self) -> int:
"""
Gets the depth of the texture. Always returns 1 for 1D and 2D textures.
"""
return self._depth
@property
def height(self) -> int:
"""
Gets the height of the texture. Always returns 1 for 1D textures.
"""
return self._height
@property
def width(self) -> int:
"""
Gets the width of the texture.
"""
return self._width
@property
def dtype(self) -> str:
"""
Gets the data type of a single component in the texture.
See https://moderngl.readthedocs.io/en/latest/topics/texture_formats.html for a full list of available data
types.
"""
return self._dtype
@property
def repeat_x(self) -> None:
"""
Sets whether the texture should repeat or be clamped in the x-axis.
"""
return None
@repeat_x.setter
def repeat_x(self, value: bool):
self._render_process_client.update_texture_sampler(self._texture_uid, repeat_x=value)
@property
def repeat_y(self) -> None:
"""
Sets whether the texture should repeat or be clamped in the y-axis.
"""
return None
@repeat_y.setter
def repeat_y(self, value: bool):
self._render_process_client.update_texture_sampler(self._texture_uid, repeat_y=value)
@property
def linear_filtering(self) -> None:
"""
Sets whether the texture should use nearest neighbour (``False``) or linear (``True``) interpolation.
"""
return None
@linear_filtering.setter
def linear_filtering(self, value: bool):
self._render_process_client.update_texture_sampler(self._texture_uid, linear_filtering=value)
@property
def linear_mipmap_filtering(self) -> None:
"""
Sets whether different mipmap levels should blend linearly (``True``) or not (``False``).
"""
return None
@linear_mipmap_filtering.setter
def linear_mipmap_filtering(self, value: bool):
self._render_process_client.update_texture_sampler(self._texture_uid, linear_mipmap_filtering=value)
@property
def anisotropy(self) -> None:
"""
Sets the number of anisotropy samples to use. (minimum of 1 = disabled, maximum of 16)
"""
return None
@anisotropy.setter
def anisotropy(self, value: int):
self._render_process_client.update_texture_sampler(self._texture_uid, anisotropy=value)
@property
def is_valid(self) -> bool:
"""Gets whether this texture object represents a valid texture that hasn't been destroyed yet."""
return self._is_valid
[docs]
def update_texture(self, data: npt.NDArray,
rect: Optional[Union[tuple[int, int, int, int], tuple[int, int, int, int, int, int]]] = None):
"""
Updates the contents of this texture from the NumPy array provided.
:param data: a NumPy array containing the image data to copy to the texture.
:param rect: optionally, a rectangle (left, top, right, bottom) specifying the area of the target texture to
update.
"""
self._render_process_client.update_texture(self._texture_uid, data, None, None, rect,
self._treat_as_normalized_integer)
[docs]
def build_mipmaps(self):
"""
Generates mipmaps for the texture.
"""
self._render_process_client.update_texture_sampler(self._texture_uid, build_mip_maps=True)
[docs]
def release(self):
"""
Destroys this texture object and releases the associated GPU resources.
"""
self._is_valid = False
self._render_process_client.delete_texture(self._texture_uid)
def __del__(self):
self.release()