"""
A module for GPU-resident indexed triangle meshes.
This module defines the `GLMesh` class, which represents a 3D mesh stored on the
GPU. It provides mechanisms for defining a mesh's vertices, faces, colors, normals,
UVs, and vertex layout, along with functionality for uploading these attributes to
GPU buffers and expanding indexed meshes into per-triangle vertex lists if needed.
"""
import ctypes
from typing import TYPE_CHECKING, Literal, Optional, Union
import numpy as np
from elmo.glsl.layouts import build_shader_layouts
from picogl.backend.gl.enums import GLDrawMode, GLIndexType
from picogl.backend.gl.wrappers.glcleanup import gl_delete_buffer_object
from picogl.backend.modern.core.vertex.array.object import VertexArrayObject
from picogl.gpu.buffers.helper import as_vec3_array
from picogl.gpu.buffers.vertex.vbo.vbo_class import MeshDataAttrs, VBOType
from picogl.shaders.type import ShaderType
if TYPE_CHECKING:
from picogl.renderer.meshdata import MeshData
[docs]
class GLMesh:
"""
GPU‐resident mesh: owns VAO/VBO/EBO/CBO/NBO for an indexed triangle mesh.
It does not know anything about shaders or matrices.
"""
def __init__(
self,
vertices: np.ndarray,
faces: np.ndarray,
colors: Optional[np.ndarray] = None,
normals: Optional[np.ndarray] = None,
uvs: Optional[np.ndarray] = None,
use_indices: bool = True,
*,
shader_type: Literal[
ShaderType.ISOSURFACE,
ShaderType.RIBBONS,
ShaderType.TEXTURES,
] = ShaderType.ISOSURFACE,
registry_label: Optional[str] = None,
):
[docs]
self.vao: Optional[VertexArrayObject] = None
[docs]
self.index_count: int = 0
# strict (N, 3)
[docs]
self.vertices = as_vec3_array(vertices)
# If using indices, expect a flat array of indices
[docs]
self.indices = np.asarray(faces, dtype=np.uint32).reshape(-1)
nverts = self.vertices.shape[0]
if self.indices.size == 0:
raise ValueError("GLMesh requires non-empty faces")
# Validate that we have a multiple of 3 indices for triangles
if self.indices.size % 3 != 0:
raise ValueError(
"GLMesh: faces must define a multiple of 3 indices (triangles)"
)
[docs]
self.use_indices = (
use_indices # present for compatibility and potential path changes
)
if shader_type not in (
ShaderType.ISOSURFACE,
ShaderType.RIBBONS,
ShaderType.TEXTURES,
):
raise ValueError(
"shader_type must be ShaderType.ISOSURFACE, "
"ShaderType.RIBBONS, or ShaderType.TEXTURES"
)
[docs]
self.shader_type = shader_type
[docs]
self._registry_label = registry_label
[docs]
self.colors = (
as_vec3_array(colors)
if colors is not None
else np.tile((0.0, 0.0, 1.0), (nverts, 1)).astype(np.float32)
)
[docs]
self.normals = (
as_vec3_array(normals)
if normals is not None
else np.zeros_like(self.vertices)
)
[docs]
self.uvs = (
np.asarray(uvs, dtype=np.float32).reshape(-1, 2)
if uvs is not None
else np.zeros((nverts, 2), dtype=np.float32)
)
# If non-indexed path is requested, prepare expanded data (optional)
[docs]
self._expanded_vertices = None # per-triangle vertices if needed
[docs]
self._expanded_colors = None
[docs]
self._expanded_normals = None
[docs]
self._expanded_uvs = None
[docs]
self._layouts = build_shader_layouts()
[docs]
self._layout_descriptor = self._layouts[shader_type]
if not self.use_indices:
self._expand_to_non_indexed()
[docs]
def _get_buffer_data(self, vbo_type: VBOType) -> Optional[np.ndarray]:
"""get_buffer_data(vbo_type) -> np.ndarray"""
return {
VBOType.VBO: self.vertices,
VBOType.CBO: self.colors,
VBOType.NBO: self.normals,
VBOType.UVS: self.uvs,
}.get(vbo_type)
[docs]
def _expand_to_non_indexed(self) -> None:
"""
Expand the mesh so that every triangle has its own copy of vertices/colors/normals/uvs.
This converts indexed data (shared vertices) into a per-triangle vertex list suitable
for glDrawArrays. The API remains the same; just keep the EBO empty and set index_count accordingly.
"""
if self.indices is None or self.indices.size == 0:
raise ValueError("Cannot expand: no indices to expand from")
# For each triangle, fetch its three vertices and associated attributes
v = self.vertices
c = self.colors
n = self.normals
t = self.uvs
# Build per-triangle vertex lists
tri_count = self.indices.size // 3
expanded_v = np.empty((tri_count * 3, 3), dtype=np.float32)
expanded_c = np.empty((tri_count * 3, 3), dtype=np.float32)
expanded_n = np.empty((tri_count * 3, 3), dtype=np.float32)
expanded_t = np.empty((tri_count * 3, 2), dtype=np.float32)
for i in range(tri_count):
a = self.indices[3 * i + 0]
b = self.indices[3 * i + 1]
cidx = self.indices[3 * i + 2]
expanded_v[3 * i + 0] = v[a]
expanded_v[3 * i + 1] = v[b]
expanded_v[3 * i + 2] = v[cidx]
expanded_c[3 * i + 0] = c[a]
expanded_c[3 * i + 1] = c[b]
expanded_c[3 * i + 2] = c[cidx]
expanded_n[3 * i + 0] = n[a]
expanded_n[3 * i + 1] = n[b]
expanded_n[3 * i + 2] = n[cidx]
expanded_t[3 * i + 0] = t[a]
expanded_t[3 * i + 1] = t[b]
expanded_t[3 * i + 2] = t[cidx]
self._expanded_vertices = expanded_v
self._expanded_colors = expanded_c
self._expanded_normals = expanded_n
self._expanded_uvs = expanded_t
# After expansion, there are no indices to upload
self.vertices = expanded_v
self.colors = expanded_c
self.normals = expanded_n
self.uvs = expanded_t
self.indices = np.array([], dtype=np.uint32)
self.index_count = expanded_v.shape[0] # equals tri_count * 3
@classmethod
[docs]
def from_mesh_data(
cls,
mesh: "MeshData",
*,
vertex_layout: Union[
ShaderType.ISOSURFACE,
ShaderType.RIBBONS,
ShaderType.TEXTURES,
] = ShaderType.ISOSURFACE,
registry_label: Optional[str] = None,
) -> "GLMesh":
"""
Construct a GLMesh from a MeshData container.
Parameters
----------
mesh : MeshData
Must have .vertices (Nx3), .ebo (Mx1), optional .cbo (Nx3), .nbo (Nx3), uvs (Nx2)
vertex_layout :
``surface`` → attr order pos, color, normal (``surface_with_lighting`` / mesh).
``ribbon`` → pos, normal, color (``ribbons`` / RibbonVAO).
Returns
-------
GLMesh
Ready-to-upload mesh (GPU buffers are allocated only when `upload()` is called).
"""
return cls(
vertices=mesh.vertices,
faces=mesh.indices,
colors=mesh.colors,
normals=mesh.normals,
uvs=getattr(mesh, MeshDataAttrs.TEXCOORDS, None),
shader_type=vertex_layout,
registry_label=registry_label,
)
[docs]
def update_colors(self, colors: np.ndarray):
self.colors = colors.astype(np.float32)
if self.vao:
self.vao.update_vbo(index=1, data=self.colors) # CBO slot
[docs]
def upload(self) -> None:
"""Allocate & fill GPU buffers."""
if self.vao:
return # already uploaded
vao: Optional[VertexArrayObject] = None
try:
vao = VertexArrayObject(registry_label=self._registry_label)
descriptor = self._layout_descriptor
for attr in descriptor.attributes:
data = self._get_buffer_data(attr.vbo_type)
if data is None:
continue
vao.add_vbo(
name=attr.name,
data=data,
index=attr.index,
size=attr.size,
)
# Legacy fallback: older layouts omitted UV from the descriptor.
if self.uvs is not None and not any(
attr.vbo_type == VBOType.UVS for attr in descriptor.attributes
):
vao.add_vbo(data=self.uvs, index=3, size=2)
if self.use_indices:
vao.add_ebo(data=self.indices)
# vao.configure_from_descriptor(descriptor)
self.vao = vao
self.index_count = (
self.indices.size if self.use_indices else self.vertices.shape[0]
)
vao = None # ownership transferred to self.vao
finally:
# If add_vbo/add_ebo failed after VAO gen, drop orphan VAO so the next
# upload() retry does not accumulate registry leaks.
if vao is not None:
gl_delete_buffer_object(vao)
[docs]
def bind(self):
self.upload()
if not self.vao:
raise RuntimeError("GLMesh not uploaded")
self.vao.__enter__() # context protocol
[docs]
def unbind(self):
if self.vao:
self.vao.__exit__(None, None, None)
[docs]
def delete(self):
"""Free GPU resources."""
if self.vao:
gl_delete_buffer_object(self.vao)
self.vao = None
self.index_count = 0
[docs]
def __enter__(self):
self.bind()
return self
[docs]
def __exit__(self, exc_type, exc, tb):
self.unbind()
[docs]
def draw(self, mode=GLDrawMode.TRIANGLES) -> None:
"""Draw the mesh."""
try:
if not self.vao:
raise RuntimeError("GLMesh not uploaded. Call upload() first.")
with self.vao:
self.vao.draw(
index_count=self.index_count,
mode=mode,
dtype=GLIndexType.UNSIGNED_INT,
pointer=ctypes.c_void_p(0),
)
except Exception as e:
# You might want to log e or re-raise
raise