"""
Texture Loader
"""
import os
import struct
from pathlib import Path
from typing import Optional
from OpenGL.GL import (
glCompressedTexImage2D,
glDeleteTextures,
glGenTextures,
glTexImage2D,
)
from OpenGL.GL.framebufferobjects import glGenerateMipmap
from OpenGL.raw.GL.EXT.texture_compression_s3tc import (
GL_COMPRESSED_RGBA_S3TC_DXT1_EXT,
GL_COMPRESSED_RGBA_S3TC_DXT3_EXT,
GL_COMPRESSED_RGBA_S3TC_DXT5_EXT,
)
from OpenGL.raw.GL.VERSION.GL_1_0 import (
GL_LINEAR,
GL_LINEAR_MIPMAP_LINEAR,
GL_REPEAT,
GL_RGB,
GL_RGBA,
GL_TEXTURE_2D,
GL_TEXTURE_MAG_FILTER,
GL_TEXTURE_MIN_FILTER,
GL_TEXTURE_WRAP_S,
GL_TEXTURE_WRAP_T,
GL_UNSIGNED_BYTE,
glTexParameteri,
)
from OpenGL.raw.GL.VERSION.GL_1_1 import glBindTexture
from PIL import Image
[docs]
class TextureLoader:
"""
Loads a 2D texture from a DDS file or a standard image file using PIL.
Automatically creates an OpenGL texture ID.
"""
[docs]
def __init__(self, file_name: str, mode: str = "RGB") -> None:
[docs]
self.texture_gl_id: Optional[int] = None
[docs]
self.width: int = 0
[docs]
self.height: int = 0
[docs]
self.format: str = mode
[docs]
self.buffer: Optional[bytes] = None
[docs]
self.inversed_v_coords: bool = False
if not os.path.isabs(file_name):
file_name = os.path.abspath(
os.path.join(os.path.dirname(__file__), "..", file_name)
)
if file_name.lower().endswith(".dds"):
self.load_dds(file_name)
else:
self.load_by_pil(file_name, mode)
[docs]
def load_dds(self, file_name: str) -> None:
"""
Load a DDS texture from file.
Supports DXT1, DXT3, DXT5 compressed textures.
Falls back to PIL loading if compressed texture loading fails.
"""
try:
with open(file_name, "rb") as dds_file:
dfs_tag = dds_file.read(4)
if dfs_tag != b"DDS ":
raise ValueError(f"Invalid DDS file: {file_name}")
head = dds_file.read(124)
self.height = struct.unpack("<I", head[8:12])[0]
self.width = struct.unpack("<I", head[12:16])[0]
linear_size = struct.unpack("<I", head[16:20])[0]
mip_map_count = struct.unpack("<I", head[24:28])[0]
four_cc = head[80:84].decode("ascii")
supported_dds = ["DXT1", "DXT3", "DXT5"]
if four_cc not in supported_dds:
raise ValueError(f"Not supported DDS file: {four_cc}")
self.format = four_cc
block_size = 8 if four_cc == "DXT1" else 16
gl_format = {
"DXT1": GL_COMPRESSED_RGBA_S3TC_DXT1_EXT,
"DXT3": GL_COMPRESSED_RGBA_S3TC_DXT3_EXT,
"DXT5": GL_COMPRESSED_RGBA_S3TC_DXT5_EXT,
}[four_cc]
buffer_size = linear_size * 2 if mip_map_count > 1 else linear_size
with open(file_name, "rb") as dds_file:
dds_file.seek(128) # skip DDS header
dds_buffer = dds_file.read(buffer_size)
self.texture_gl_id = glGenTextures(1)
glBindTexture(GL_TEXTURE_2D, self.texture_gl_id)
offset = 0
w, h = self.width, self.height
for level in range(mip_map_count):
size = ((w + 3) // 4) * ((h + 3) // 4) * block_size
try:
glCompressedTexImage2D(
GL_TEXTURE_2D,
level,
gl_format,
w,
h,
0,
size,
dds_buffer[offset: offset + size],
)
except Exception as e:
# Fallback: try with explicit byte array conversion
try:
import array
byte_array = array.array('B', dds_buffer[offset: offset + size])
glCompressedTexImage2D(
GL_TEXTURE_2D,
level,
gl_format,
w,
h,
0,
size,
byte_array,
)
except Exception as e2:
raise RuntimeError(f"Failed to load DDS compressed texture: {e2}")
offset += size
w //= 2
h //= 2
if w == 0 or h == 0:
break
# Set texture parameters for DDS
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR)
self.inversed_v_coords = True
except Exception as e:
# Fallback: try to load as regular image with PIL
print(f"⚠️ DDS compressed loading failed: {e}")
print(f"🔄 Falling back to PIL loading for: {file_name}")
try:
self.load_by_pil(file_name, "RGBA")
except Exception as e2:
raise RuntimeError(f"Failed to load DDS file with both compressed and PIL methods: {e2}")
[docs]
def load_by_pil(self, file_name: str, mode: str) -> None:
"""
Load a standard image using PIL and upload as OpenGL texture.
"""
with Image.open(file_name) as image:
converted = image.convert(mode)
self.buffer = converted.transpose(Image.FLIP_TOP_BOTTOM).tobytes()
self.width, self.height = image.size
self.format = mode
# Map PIL mode to OpenGL format
gl_format_map = {"RGB": GL_RGB, "RGBA": GL_RGBA}
gl_format = gl_format_map.get(mode.upper(), GL_RGB)
self.texture_gl_id = glGenTextures(1)
glBindTexture(GL_TEXTURE_2D, self.texture_gl_id)
glTexImage2D(
GL_TEXTURE_2D,
0,
gl_format,
self.width,
self.height,
0,
gl_format,
GL_UNSIGNED_BYTE,
self.buffer,
)
# Texture parameters
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR)
glGenerateMipmap(GL_TEXTURE_2D)
[docs]
def delete(self) -> None:
"""
Deletes the OpenGL texture to free GPU memory.
"""
if self.texture_gl_id:
glDeleteTextures([self.texture_gl_id])
self.texture_gl_id = None
[docs]
def __len__(self) -> int:
return len(self.buffer) if self.buffer else 0
if __name__ == "__main__":
[docs]
texture = TextureLoader(
file_name=os.path.join(
str(Path.home()), "projects/PicoGL/examples/resources/tu02/uvtemplate.tga"
)
)
print(texture.texture_gl_id)
print(texture.width)
print(texture.height)
print(texture.format)
print(texture.buffer)
print(texture.inversed_v_coords)
texture.delete()