Source code for wolfhece.opengl.filled_polygon_shader2d

"""
Shared-resource filled polygon shader for vector rendering.

Renders filled polygons from CPU-triangulated geometry (triangle list)
with optional alpha animation. The shader program is compiled once
and reused for all vectors.
"""

import ctypes
import logging
from pathlib import Path

import numpy as np
from PIL import Image
from OpenGL.GL import (
    glActiveTexture,
    glAttachShader,
    glBindBuffer,
    glBindTexture,
    glBindVertexArray,
    glBlendFunc,
    glBufferData,
    glCompileShader,
    glCreateProgram,
    glCreateShader,
    glDeleteBuffers,
    glDeleteProgram,
    glDeleteShader,
    glDeleteTextures,
    glDeleteVertexArrays,
    glDisable,
    glDrawArrays,
    glEnable,
    glEnableVertexAttribArray,
    glGenBuffers,
    glGenTextures,
    glGenVertexArrays,
    glGetProgramInfoLog,
    glGetProgramiv,
    glGetShaderInfoLog,
    glGetShaderiv,
    glGetUniformLocation,
    glIsEnabled,
    glLinkProgram,
    glPolygonMode,
    glShaderSource,
    glTexImage2D,
    glTexParameteri,
    glUniform1f,
    glUniform1i,
    glUniform2f,
    glUniform2fv,
    glUniform3f,
    glUniform4f,
    glUniformMatrix4fv,
    glUseProgram,
    glVertexAttribPointer,
    glGetIntegerv,
    glGenerateMipmap,
    GL_ARRAY_BUFFER,
    GL_BLEND,
    GL_COMPILE_STATUS,
    GL_DEPTH_TEST,
    GL_DYNAMIC_DRAW,
    GL_FALSE,
    GL_FLOAT,
    GL_FRAGMENT_SHADER,
    GL_FRONT_AND_BACK,
    GL_LINK_STATUS,
    GL_ONE_MINUS_SRC_ALPHA,
    GL_POLYGON_MODE,
    GL_REPEAT,
    GL_RGBA,
    GL_SRC_ALPHA,
    GL_TEXTURE_2D,
    GL_TEXTURE0,
    GL_TEXTURE1,
    GL_TEXTURE2,
    GL_TEXTURE3,
    GL_TEXTURE_MAG_FILTER,
    GL_TEXTURE_MIN_FILTER,
    GL_TEXTURE_WRAP_S,
    GL_TEXTURE_WRAP_T,
    GL_TRIANGLES,
    GL_UNSIGNED_BYTE,
    GL_VERTEX_SHADER,
    GL_FILL,
    GL_LINEAR,
    GL_LINEAR_MIPMAP_LINEAR,
)

from ..pyvertexvectors.polygon_pbr_material import PolygonPBRMaterial


[docs] SHADER_DIR = Path(__file__).resolve().parent.parent / "shaders"
[docs] class FilledPolygonShader2D: """Shared shader pipeline for CPU-triangulated filled polygons."""
[docs] _instance: "FilledPolygonShader2D | None" = None
def __init__(self):
[docs] self._program: int | None = None
[docs] self._locs: dict[str, int] | None = None
[docs] self._vao: int | None = None
[docs] self._vbo: int | None = None
[docs] self._texture_cache: dict[tuple[str, float], int] = {}
@classmethod
[docs] def get_instance(cls) -> "FilledPolygonShader2D": if cls._instance is None: cls._instance = cls() return cls._instance
[docs] def _init_program(self): if self._program is not None: return vs = self._compile_shader(GL_VERTEX_SHADER, SHADER_DIR / "filled_poly_vertex.glsl") fs = self._compile_shader(GL_FRAGMENT_SHADER, SHADER_DIR / "filled_poly_frag.glsl") program = glCreateProgram() glAttachShader(program, vs) glAttachShader(program, fs) glLinkProgram(program) if glGetProgramiv(program, GL_LINK_STATUS) == GL_FALSE: info = glGetProgramInfoLog(program) glDeleteProgram(program) glDeleteShader(vs) glDeleteShader(fs) raise RuntimeError(f"Filled polygon shader link failed: {info}") glDeleteShader(vs) glDeleteShader(fs) self._program = program glUseProgram(program) self._locs = { "mvp": glGetUniformLocation(program, "mvp"), "fillColor": glGetUniformLocation(program, "fillColor"), "animPhase": glGetUniformLocation(program, "animPhase"), "animMode": glGetUniformLocation(program, "animMode"), "viewport": glGetUniformLocation(program, "viewport"), "fillAnimCenter": glGetUniformLocation(program, "fillAnimCenter"), "fillAnimRadius": glGetUniformLocation(program, "fillAnimRadius"), "fillAnimStartAngle": glGetUniformLocation(program, "fillAnimStartAngle"), "materialEnabled": glGetUniformLocation(program, "materialEnabled"), "materialUVScale": glGetUniformLocation(program, "materialUVScale"), "materialUVOffset": glGetUniformLocation(program, "materialUVOffset"), "baseColorFactor": glGetUniformLocation(program, "baseColorFactor"), "metallicFactor": glGetUniformLocation(program, "metallicFactor"), "roughnessFactor": glGetUniformLocation(program, "roughnessFactor"), "normalScale": glGetUniformLocation(program, "normalScale"), "occlusionStrength": glGetUniformLocation(program, "occlusionStrength"), "emissiveFactor": glGetUniformLocation(program, "emissiveFactor"), "hasAlbedoTex": glGetUniformLocation(program, "hasAlbedoTex"), "hasNormalTex": glGetUniformLocation(program, "hasNormalTex"), "hasOrmTex": glGetUniformLocation(program, "hasOrmTex"), "hasEmissiveTex": glGetUniformLocation(program, "hasEmissiveTex"), "albedoTex": glGetUniformLocation(program, "albedoTex"), "normalTex": glGetUniformLocation(program, "normalTex"), "ormTex": glGetUniformLocation(program, "ormTex"), "emissiveTex": glGetUniformLocation(program, "emissiveTex"), "cushionStrength": glGetUniformLocation(program, "cushionStrength"), "numBoundaryPts": glGetUniformLocation(program, "numBoundaryPts"), "boundaryPts": glGetUniformLocation(program, "boundaryPts"), "cushionMaxDist": glGetUniformLocation(program, "cushionMaxDist"), } glUniform1i(self._locs["albedoTex"], 0) glUniform1i(self._locs["normalTex"], 1) glUniform1i(self._locs["ormTex"], 2) glUniform1i(self._locs["emissiveTex"], 3) glUseProgram(0) missing = [k for k, v in self._locs.items() if v == -1] if missing: logging.error("FilledPolygonShader2D: MISSING UNIFORMS - shader chain broken: %s", missing) else: logging.debug("FilledPolygonShader2D: All uniform locations found successfully")
@staticmethod
[docs] def _compile_shader(shader_type: int, path: Path) -> int: shader = glCreateShader(shader_type) with open(path, "r", encoding="utf-8") as f: glShaderSource(shader, f.read()) glCompileShader(shader) if glGetShaderiv(shader, GL_COMPILE_STATUS) == GL_FALSE: info = glGetShaderInfoLog(shader) glDeleteShader(shader) raise RuntimeError(f"Shader compilation failed ({path.name}): {info}") return shader
[docs] def _ensure_vao_vbo(self): if self._vao is not None: return self._vao = glGenVertexArrays(1) self._vbo = glGenBuffers(1) glBindVertexArray(self._vao) glBindBuffer(GL_ARRAY_BUFFER, self._vbo) stride = 3 * 4 # vec3: x, y, edgeDist glEnableVertexAttribArray(0) glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, stride, ctypes.c_void_p(0)) glEnableVertexAttribArray(1) glVertexAttribPointer(1, 1, GL_FLOAT, GL_FALSE, stride, ctypes.c_void_p(2 * 4)) glBindVertexArray(0) glBindBuffer(GL_ARRAY_BUFFER, 0)
[docs] def _upload(self, data: np.ndarray): glBindBuffer(GL_ARRAY_BUFFER, self._vbo) glBufferData(GL_ARRAY_BUFFER, data.nbytes, data, GL_DYNAMIC_DRAW) glBindBuffer(GL_ARRAY_BUFFER, 0)
[docs] def _get_texture_id(self, texture_path: str) -> int | None: if not texture_path: return None path = Path(texture_path) if not path.exists(): logging.warning("FilledPolygonShader2D: texture not found: %s", path) return None try: key = (str(path.resolve()), float(path.stat().st_mtime)) except OSError: key = (str(path), 0.0) if key in self._texture_cache: logging.debug("FilledPolygonShader2D: texture cache hit: %s", path.name) return self._texture_cache[key] logging.debug("FilledPolygonShader2D: loading texture: %s", path.name) with Image.open(path) as src: image = src.convert('RGBA') tex_id = glGenTextures(1) glBindTexture(GL_TEXTURE_2D, tex_id) glTexImage2D( GL_TEXTURE_2D, 0, GL_RGBA, image.width, image.height, 0, GL_RGBA, GL_UNSIGNED_BYTE, image.tobytes(), ) glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR) glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR) glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT) glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT) glGenerateMipmap(GL_TEXTURE_2D) glBindTexture(GL_TEXTURE_2D, 0) self._texture_cache[key] = tex_id return tex_id
[docs] def _bind_material(self, material: PolygonPBRMaterial | None): material = material if isinstance(material, PolygonPBRMaterial) else PolygonPBRMaterial() # DEBUG: Log material state logging.debug( 'FilledPolygonShader2D._bind_material: enabled=%s, metallic=%.2f, has_orm=%s, orm_path=%s', material.enabled, material.metallic_factor, bool(material.orm_texture), material.orm_texture[:80] if material.orm_texture else 'NONE' ) glUniform1i(self._locs["materialEnabled"], 1 if material.enabled else 0) glUniform2f( self._locs["materialUVScale"], float(max(abs(material.uv_scale[0]), 1e-6)), float(max(abs(material.uv_scale[1]), 1e-6)), ) glUniform2f(self._locs["materialUVOffset"], float(material.uv_offset[0]), float(material.uv_offset[1])) glUniform4f(self._locs["baseColorFactor"], *material.base_color_factor) glUniform1f(self._locs["metallicFactor"], float(material.metallic_factor)) glUniform1f(self._locs["roughnessFactor"], float(material.roughness_factor)) glUniform1f(self._locs["normalScale"], float(material.normal_scale)) glUniform1f(self._locs["occlusionStrength"], float(material.occlusion_strength)) glUniform3f(self._locs["emissiveFactor"], *material.emissive_factor) slots = [ ("hasAlbedoTex", GL_TEXTURE0, material.albedo_texture), ("hasNormalTex", GL_TEXTURE1, material.normal_texture), ("hasOrmTex", GL_TEXTURE2, material.orm_texture), ("hasEmissiveTex", GL_TEXTURE3, material.emissive_texture), ] tex_flags = {} for flag_name, tex_slot, tex_path in slots: tex_id = self._get_texture_id(tex_path) if material.enabled else None tex_flags[flag_name] = tex_id is not None if material.enabled: logging.debug( '_bind_material tex: %s path=%s tex_id=%s', flag_name, tex_path[:60] if tex_path else 'NONE', tex_id ) glUniform1i(self._locs[flag_name], 1 if tex_id is not None else 0) glActiveTexture(tex_slot) glBindTexture(GL_TEXTURE_2D, 0 if tex_id is None else tex_id) glActiveTexture(GL_TEXTURE0) logging.debug( '_bind_material: materialEnabled=%s, metallic=%.2f, roughness=%.2f, tex_flags=%s, locs_valid=%s', material.enabled, material.metallic_factor, material.roughness_factor, tex_flags, {k: v for k, v in self._locs.items() if k in ('materialEnabled', 'metallicFactor', 'roughnessFactor', 'hasAlbedoTex', 'hasOrmTex')} )
[docs] def draw_filled(self, vbo_data: np.ndarray, vertex_count: int, mvp: np.ndarray, viewport: tuple[int, int], color: tuple[float, float, float, float], material: PolygonPBRMaterial | None = None, fill_anim_center: tuple[float, float] = (0.0, 0.0), fill_anim_radius: float = 1.0, fill_anim_start_angle: float = 90.0, anim_phase: float = 0.0, anim_mode: int = 0, cushion_strength: float = 0.0, boundary_pts: np.ndarray | None = None, cushion_max_dist: float = 1.0): """Draw triangles for a filled polygon.""" if vertex_count <= 0: return self._init_program() self._ensure_vao_vbo() self._upload(vbo_data) glUseProgram(self._program) glEnable(GL_BLEND) glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) depth_was_enabled = glIsEnabled(GL_DEPTH_TEST) if depth_was_enabled: glDisable(GL_DEPTH_TEST) prev_poly_mode = glGetIntegerv(GL_POLYGON_MODE) glPolygonMode(GL_FRONT_AND_BACK, GL_FILL) glUniformMatrix4fv(self._locs["mvp"], 1, GL_FALSE, np.ascontiguousarray(mvp, dtype=np.float32)) glUniform4f(self._locs["fillColor"], *color) glUniform1f(self._locs["animPhase"], float(anim_phase)) glUniform1i(self._locs["animMode"], int(anim_mode)) glUniform2f(self._locs["viewport"], float(max(viewport[0], 1)), float(max(viewport[1], 1))) glUniform2f(self._locs["fillAnimCenter"], float(fill_anim_center[0]), float(fill_anim_center[1])) glUniform1f(self._locs["fillAnimRadius"], float(max(fill_anim_radius, 1e-6))) glUniform1f(self._locs["fillAnimStartAngle"], float(fill_anim_start_angle)) glUniform1f(self._locs["cushionStrength"], float(cushion_strength)) # Upload polygon boundary for per-fragment cushion distance if boundary_pts is not None and len(boundary_pts) >= 3 and cushion_strength > 0: n = min(len(boundary_pts), 256) glUniform1i(self._locs["numBoundaryPts"], n) flat = np.ascontiguousarray(boundary_pts[:n], dtype=np.float32).flatten() glUniform2fv(self._locs["boundaryPts"], n, flat) glUniform1f(self._locs["cushionMaxDist"], float(max(cushion_max_dist, 1e-6))) else: glUniform1i(self._locs["numBoundaryPts"], 0) self._bind_material(material) glBindVertexArray(self._vao) glDrawArrays(GL_TRIANGLES, 0, vertex_count) glBindVertexArray(0) glUseProgram(0) glDisable(GL_BLEND) if prev_poly_mode is not None and len(prev_poly_mode) >= 2: glPolygonMode(GL_FRONT_AND_BACK, int(prev_poly_mode[0])) if depth_was_enabled: glEnable(GL_DEPTH_TEST)
[docs] def destroy(self): if self._vao is not None: try: glDeleteVertexArrays(1, [self._vao]) except Exception: pass self._vao = None if self._vbo is not None: try: glDeleteBuffers(1, [self._vbo]) except Exception: pass self._vbo = None if len(self._texture_cache) > 0: try: tex_ids = list(self._texture_cache.values()) glDeleteTextures(len(tex_ids), tex_ids) except Exception: pass self._texture_cache = {} if self._program is not None: try: glDeleteProgram(self._program) except Exception: pass self._program = None self._locs = None FilledPolygonShader2D._instance = None