"""
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