Source code for picogl.backend.modern.core.vertex.array.object

"""
vertex_array_object.py

This module defines the `VertexArrayObject` class, which encapsulates the creation, management,
and usage of OpenGL Vertex Array Objects (VAOs) in modern OpenGL rendering workflows.

The `VertexArrayObject` class inherits from `VertexBuffer` and provides
a clean, object-oriented interface for managing VAO handles and vertex
attribute configurations.

It supports binding/unbinding operations,
attribute registration, and rendering via `glDrawArrays`.

Features:
- Automatic VAO generation if none is provided
- Storage and enabling of vertex attribute definitions
- Integration with VBOs via `ModernVBO` (used as context managers)
- Simplified draw calls for points or other primitive modes
- Graceful deletion and handle management

Dependencies:
- numpy
- PyOpenGL (OpenGL.gl and OpenGL.raw.gl)

Example usage:
==============
>>> vao = VertexArrayObject()
>>> vao.add_attribute(index=0, vertices=vertices, size=3)
>>> vao.add_attribute(index=1, vertices=colors, size=3)
>>> vao.update(index_count=100)

Intended for OpenGL 3.0+ with VAO support.

"""

import ctypes
from contextlib import contextmanager
from typing import Optional, Union

import numpy as np
from decologr import Decologr as log
from elmo.log.silence import SILENT_VAO
from PySide6.QtGui import QOpenGLContext

from picogl.backend.gl.enums import (
    GLBufferTarget,
    GLDrawMode,
    GLIndexType,
    GLNumeric,
    GLUsageHint,
)
from picogl.backend.gl.resource import GLResource
from picogl.backend.gl.wrappers import gl_draw_arrays, gl_draw_elements
from picogl.backend.gl.wrappers.buffer import gl_bind_buffer, gl_buffer_subdata
from picogl.backend.gl.wrappers.enable_vertex_array import gl_enable_vertex_array
from picogl.backend.gl.wrappers.glcleanup import (
    gl_delete_buffers,
    gl_delete_vertex_arrays,
)
from picogl.backend.gl.wrappers.vertex_array import (
    gl_bind_vertex_array,
    gl_gen_vertex_arrays,
    gl_is_vertex_array,
)
from picogl.backend.gl.wrappers.vertex_attrib_pointer import gl_vertex_attrib_pointer
from picogl.backend.modern.core.vertex.array.helpers import (
    enable_points_rendering_state,
)
from picogl.backend.modern.core.vertex.base import VertexBuffer
from picogl.backend.modern.core.vertex.buffer.element import ModernEBO
from picogl.backend.modern.core.vertex.buffer.object import ModernVBO
from picogl.gpu.buffers.attributes import LayoutDescriptor
from picogl.gpu.buffers.base import VertexBase
from picogl.gpu.buffers.vertex.aliases import NAME_ALIASES
from picogl.safe import gl_gen_safe


[docs] class VertexArrayObject(VertexBase, GLResource): """ OpenGL Vertex Array Objects (VAO) class """ def __init__(self, handle: int = None, *, registry_label: Optional[str] = None): """ VertexArrayObject :param handle: int Handle (ID) of the OpenGL Vertex Array Object (VAO). :param registry_label: Optional custom label for :func:`store_in_gl_registry` (defaults to ``self.__class__.__name__``). """
[docs] self._creation_context = QOpenGLContext.currentContext()
log.message( f"VAO context :{id(self._creation_context)}", scope="VertexArrayObject", silent=SILENT_VAO, )
[docs] self._registry_label = registry_label
[docs] self._configured: bool = False
if not handle or handle is None: if gl_gen_vertex_arrays(): handle = gl_gen_safe(gl_gen_vertex_arrays()) else: raise RuntimeError( "glGenVertexArrays not available — OpenGL context not ready" ) super().__init__(handle)
[docs] self.attributes = []
[docs] self.vbos = []
[docs] self.named_vbos: dict[str, VertexBuffer] = {}
[docs] self.vao: Optional[int] = ( None # Bonds Vertex Array Object. Does absolutely nothing )
[docs] self.ebo = None # Bond Index Buffer Object
[docs] self.layout: Optional[LayoutDescriptor] = None
self.bind()
[docs] def is_valid_in_current_context(self) -> bool: ctx = QOpenGLContext.currentContext() if ctx is None: return False # Fast path: correct context if ctx is self.context: return True # Optional fallback: gl-level validation (ONLY if sharing contexts exist) try: handle = getattr(self, "handle", None) if handle: return bool(gl_is_vertex_array(handle)) except Exception: return False return False
[docs] def set_layout(self, layout: LayoutDescriptor) -> None: """configure""" if self._configured: return if self.vao is None: return with self.bind(): self.layout = layout if self.ebo: gl_bind_buffer(GLBufferTarget.ELEMENT, self.ebo.handle) # Configure attributes for attr in layout.attributes: vbo = self.get_vbo_object(attr.name) # <-- CRITICAL if vbo is None: raise RuntimeError(f"No VBO bound for attribute '{attr.name}'") vbo.bind() # <-- THIS IS THE FIX gl_enable_vertex_array(attr.index) gl_vertex_attrib_pointer( index=attr.index, size=attr.size, num_type=attr.type, normalized=attr.normalized, stride=attr.stride, offset=attr.offset, ) gl_bind_vertex_array(0) self._configured = True
@contextmanager
[docs] def bound(self): if not self.bind(): raise RuntimeError("Failed to bind VAO") try: yield finally: gl_bind_vertex_array(0)
[docs] def bind(self) -> Union["VertexArrayObject", None]: """ Bind the VAO for use in rendering. :return: True if bound, False if skipped (wrong context — avoids GL_INVALID_OPERATION/segfault). """ if not self.is_valid_in_current_context(): log.error( "VAO created in different gl context; skipping bind to avoid invalid operation", scope=self.__class__.__name__, ) return None gl_bind_vertex_array(self.handle) return self
[docs] def unbind(self): """ Unbind the VAO by binding to zero. """ gl_bind_vertex_array(0)
[docs] def delete(self): """ Delete the VAO from GPU memory. """ gl_delete_vertex_arrays(1, [self.handle])
[docs] def configure(self): """set layout""" self.set_layout(self.layout)
[docs] def add_vbo_object(self, name: str, vbo: "ModernVBO") -> "ModernVBO": """Register a VBO by semantic name or shorthand alias.""" # normalize to canonical key canonical = NAME_ALIASES.get(name, name) if not isinstance(vbo, VertexBuffer): raise TypeError(f"Expected VertexBuffer, got {type(vbo)}") # store consistently self.named_vbos[canonical] = vbo # and assign to attribute if it exists if hasattr(self, canonical): setattr(self, canonical, vbo) return vbo
[docs] def get_vbo_object(self, name: str) -> VertexBuffer | None: """Retrieve a VBO by its semantic or shorthand name.""" canonical = NAME_ALIASES.get(name, name) return self.named_vbos.get(canonical)
[docs] def add_vbo_data( self, data: np.ndarray, index: int = 0, size: int = 3, dtype: int = GLNumeric.FLOAT, name: str = None, handle: int = None, ) -> ModernVBO: """ Add VBO data to this VAO. Compatibility wrapper for older callers that only supplied vertex data. """ return self.add_vbo( index=index, data=data, size=size, dtype=dtype, name=name, handle=handle, )
[docs] def add_vbo( self, index: int, data: np.ndarray, size: int, dtype: int = GLNumeric.FLOAT, name: str = None, handle: int = None, ) -> ModernVBO: """ Add a Vertex Buffer Object (VBO) to the VAO and set its attributes. :param handle: :param index: VAO attribute index :param data: Vertex data :param size: Size per vertex (e.g., 3 for vec3) :param dtype: OpenGL data type (e.g., GL_FLOAT) :param name: Optional semantic name (e.g., "position", "colour") :return: OpenGL buffer handle (GLuint) """ vbo = ModernVBO(handle=handle) vbo.bind() vbo.set_data(data) vbo.set_vertex_attributes(index=index, data=data, size=size, dtype=dtype) vbo.configure() self.attributes.append((index, vbo.handle, size, dtype, False, 0, 0)) self.vbos.append(vbo) if name: self.named_vbos[name] = vbo return vbo
[docs] def delete_buffers(self): """ delete_buffers :return: None """ for vbo in self.vbos: gl_delete_buffers(vbo) self.vbos.clear() if self.ebo: gl_delete_buffers(self.ebo) self.ebo = None self.named_vbos.clear()
[docs] def add_attribute( self, index: int, vbo: int, size: int = 3, dtype: int = GLNumeric.FLOAT, normalized: bool = False, stride: int = 0, offset: int = 0, ): """ add_attribute :param index: int Index of the vertex attribute. :param vbo: int Vertex Buffer Object (VBO) associated with this attribute. :param size: int Size of the vertex attribute (e.g., 3 for a 3D vector). :param dtype: int Data type of the vertex attribute (default is GL_FLOAT). :param normalized: bool Whether the data is normalized (default is False). :param stride: int Byte offset between consecutive vertex attributes (default is 0). :param offset: int Byte offset to the first component of the vertex attribute (default is 0). Add a vertex attribute to the VAO. """ self.attributes.append((index, vbo, size, dtype, normalized, stride, offset))
[docs] def add_ebo(self, data: np.ndarray) -> ModernEBO: """ add_ebo :param data: np.ndarray :return: int """ ebo = ModernEBO(data=data) ebo.bind() ebo.set_element_attributes( data=data, size=data.nbytes, dtype=GLUsageHint.STATIC_DRAW ) ebo.configure() self.ebo = ebo return ebo
[docs] def set_ebo(self, ebo: int) -> int: """ add_ebo :param ebo: int :return: int """ self.ebo = ModernEBO(handle=ebo) self.ebo.bind() return ebo
@property
[docs] def index_count(self) -> str | int | None: """ Return the number of indices in the EBO. :return: int """ try: if self.ebo: if hasattr(self.ebo, "data"): return len(self.ebo.data) return 0 except Exception as ex: log.error(f"error {ex} occurred")
[docs] def draw( self, index_count: int = None, dtype: int = GLIndexType.UNSIGNED_INT, mode: int = GLDrawMode.POINTS, pointer: int = ctypes.c_void_p(0), first: int = 0, ): """ draw :param pointer: ctypes.c_void_p(0) :param dtype: GL_UNSIGNED_INT :param index_count: int Number of vertices to draw. :param mode: int e.g. GL_POINT :param first: First vertex for non-indexed draws. :return: None """ atom_count = index_count or self.index_count if mode == GLDrawMode.POINTS: enable_points_rendering_state() if self.ebo: gl_draw_elements(atom_count, dtype, mode, pointer=pointer) else: gl_draw_arrays(atom_count, mode, first=int(first))
[docs] def _modern_vbo_for_attrib(self, attrib_index: int) -> Optional[ModernVBO]: """Return the :class:`ModernVBO` created for ``add_vbo(index=attrib_index, ...)``.""" for j, attr in enumerate(self.attributes): if not attr: continue if attr[0] == attrib_index and j < len(self.vbos): vbo = self.vbos[j] if isinstance(vbo, ModernVBO): return vbo return None
[docs] def update_vbo(self, index: int, data: np.ndarray) -> None: """ Upload new contents for the vertex buffer tied to attribute ``index``. ``index`` is the same value passed to :meth:`add_vbo` (e.g. ``0`` positions, ``1`` colours, ``2`` normals). If the new array has the same byte size as the existing GPU store, :func:`glBufferSubData` is used; otherwise :meth:`ModernVBO.set_data` (``glBufferData``) reallocates the buffer. The VAO must have been built via :meth:`add_vbo`; arbitrary :meth:`add_attribute` entries (raw handles only) are not updated here. """ if data is None: raise TypeError("update_vbo: data must be a numpy array") arr = np.ascontiguousarray(np.asarray(data, dtype=np.float32)) if not self.bind(): log.error( "update_vbo: VAO not valid in current context", scope=self.__class__.__name__, ) return vbo = self._modern_vbo_for_attrib(index) if vbo is None: log.warning( f"update_vbo: no ModernVBO for attribute index {index}", scope=self.__class__.__name__, ) self.unbind() return try: vbo.bind() old = getattr(vbo, "data", None) if ( old is not None and isinstance(old, np.ndarray) and old.dtype == arr.dtype and old.nbytes == arr.nbytes ): gl_buffer_subdata(GLBufferTarget.ARRAY, 0, arr.nbytes, arr) else: vbo.set_data(arr) finally: gl_bind_buffer(GLBufferTarget.ARRAY, 0) self.unbind()