Source code for wolfhece.opengl.polyline_shader2d

"""
Shared-resource polyline shader for vector rendering.

Renders polylines as geometry-shader-extruded quads with support for:
- Variable width along the polyline
- Dash / dot patterns
- Glow effect
- Animation (pulse, marching ants)

The shader program is compiled ONCE and reused for all vectors.

Author: HECE - University of Liege, Pierre Archambeau
Date: 2026

Copyright (c) 2026 University of Liege. All rights reserved.
"""

import logging
import ctypes
import numpy as np
from pathlib import Path

from OpenGL.GL import (
    glCreateShader, glShaderSource, glCompileShader, glGetShaderiv,
    glGetShaderInfoLog, glDeleteShader,
    glCreateProgram, glAttachShader, glLinkProgram, glGetProgramiv,
    glGetProgramInfoLog, glDeleteProgram,
    glUseProgram, glGetUniformLocation,
    glGenVertexArrays, glBindVertexArray, glDeleteVertexArrays,
    glGenBuffers, glBindBuffer, glBufferData, glDeleteBuffers,
    glEnableVertexAttribArray, glVertexAttribPointer,
    glDrawArrays,
    glEnable, glDisable, glBlendFunc,
    glIsEnabled,
    glGetIntegerv, glPolygonMode,
    glUniform1f, glUniform1i, glUniform2f, glUniform4f,
    glUniformMatrix4fv,
    GL_VERTEX_SHADER, GL_FRAGMENT_SHADER, GL_GEOMETRY_SHADER,
    GL_COMPILE_STATUS, GL_LINK_STATUS, GL_FALSE,
    GL_ARRAY_BUFFER, GL_DYNAMIC_DRAW,
    GL_LINES_ADJACENCY, GL_LINES,
    GL_FLOAT,
    GL_BLEND, GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA,
    GL_DEPTH_TEST,
    GL_FRONT_AND_BACK, GL_FILL, GL_POLYGON_MODE,
)

[docs] SHADER_DIR = Path(__file__).resolve().parent.parent / "shaders"
# Per-vertex stride: x, y, distance, width = 4 floats
[docs] _STRIDE = 4
[docs] _BYTES_PER_FLOAT = 4
[docs] class PolylineShader2D: """Shared-resource polyline shader with VBO management. A single instance is shared across all vector/zone rendering. Call :meth:`draw_polyline` for each polyline to render. """
[docs] _instance: "PolylineShader2D | None" = None
def __init__(self):
[docs] self._program: int | None = None
[docs] self._locs: dict | None = None
[docs] self._vao: int | None = None
[docs] self._vbo: int | None = None
[docs] self._draw_count: int = 0
@classmethod
[docs] def get_instance(cls) -> "PolylineShader2D": """Return the singleton instance, creating it if needed.""" if cls._instance is None: cls._instance = cls() return cls._instance
# ================================================================ # Shader compilation # ================================================================
[docs] def _init_program(self): """Compile and link vertex / geometry / fragment shaders.""" if self._program is not None: return vs = self._compile_shader(GL_VERTEX_SHADER, SHADER_DIR / "polyline_vertex.glsl") gs = self._compile_shader(GL_GEOMETRY_SHADER, SHADER_DIR / "polyline_geom.glsl") fs = self._compile_shader(GL_FRAGMENT_SHADER, SHADER_DIR / "polyline_frag.glsl") program = glCreateProgram() glAttachShader(program, vs) glAttachShader(program, gs) glAttachShader(program, fs) glLinkProgram(program) if glGetProgramiv(program, GL_LINK_STATUS) == GL_FALSE: info = glGetProgramInfoLog(program) glDeleteProgram(program) glDeleteShader(vs); glDeleteShader(gs); glDeleteShader(fs) raise RuntimeError(f"Polyline shader link failed: {info}") glDeleteShader(vs) glDeleteShader(gs) glDeleteShader(fs) self._program = program # Locate uniforms glUseProgram(program) self._locs = {} for name in ('mvp', 'lineWidth', 'viewport', 'widthInPixels', 'lineColor', 'aaRadius', 'dashEnabled', 'dashLength', 'gapLength', 'dashOffset', 'glowEnabled', 'glowWidth', 'glowColor', 'animPhase', 'animMode', 'joinStyle', 'joinSize'): self._locs[name] = glGetUniformLocation(program, name) glUseProgram(0) logging.debug('PolylineShader2D: program %d linked OK. Uniform locations: %s', program, self._locs) missing = [k for k, v in self._locs.items() if v == -1] if missing: logging.warning('PolylineShader2D: MISSING uniforms (loc=-1): %s', missing)
@staticmethod
[docs] def _compile_shader(shader_type: int, path: Path) -> int: """Compile a single GLSL shader from *path*.""" shader = glCreateShader(shader_type) with open(path, 'r') as f: glShaderSource(shader, f.read()) glCompileShader(shader) if glGetShaderiv(shader, GL_COMPILE_STATUS, None) == GL_FALSE: info = glGetShaderInfoLog(shader) glDeleteShader(shader) raise RuntimeError(f"Shader compilation failed ({path.name}): {info}") return shader
# ================================================================ # VBO management # ================================================================
[docs] def _ensure_vao_vbo(self): """Create the shared VAO/VBO pair (once).""" 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 = _STRIDE * _BYTES_PER_FLOAT # location 0: aPos (vec2) glEnableVertexAttribArray(0) glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, stride, None) # location 1: aDistance (float) glEnableVertexAttribArray(1) glVertexAttribPointer(1, 1, GL_FLOAT, GL_FALSE, stride, ctypes.c_void_p(2 * _BYTES_PER_FLOAT)) # location 2: aWidth (float) glEnableVertexAttribArray(2) glVertexAttribPointer(2, 1, GL_FLOAT, GL_FALSE, stride, ctypes.c_void_p(3 * _BYTES_PER_FLOAT)) glBindVertexArray(0) glBindBuffer(GL_ARRAY_BUFFER, 0)
[docs] def _upload(self, data: np.ndarray): """Upload float32 vertex data to the shared VBO.""" glBindBuffer(GL_ARRAY_BUFFER, self._vbo) glBufferData(GL_ARRAY_BUFFER, data.nbytes, data, GL_DYNAMIC_DRAW) glBindBuffer(GL_ARRAY_BUFFER, 0)
# ================================================================ # Cleanup # ================================================================
[docs] def destroy(self): """Release all GPU resources.""" 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 self._program is not None: try: glDeleteProgram(self._program) except Exception: pass self._program = None self._locs = None PolylineShader2D._instance = None
# ================================================================ # High-level drawing API # ================================================================
[docs] def draw_polyline(self, vbo_data: np.ndarray, vertex_count: int, mvp: np.ndarray, viewport: tuple[int, int], color: tuple[float, float, float, float], line_width: float = 2.0, width_in_pixels: bool = True, aa_radius: float = 0.06, dash_enabled: bool = False, dash_length: float = 10.0, gap_length: float = 5.0, dash_offset: float = 0.0, glow_enabled: bool = False, glow_width: float = 0.4, glow_color: tuple[float, float, float, float] = (1.0, 1.0, 1.0, 0.4), anim_phase: float = 0.0, anim_mode: int = 0, join_style: int = 0, join_size: float = 1.0): """Draw a single polyline with the shader pipeline. :param vbo_data: float32 array of shape ``(N, 4)`` — ``[x, y, dist, width]`` per vertex. :param vertex_count: Number of vertices in *vbo_data*. :param mvp: 4×4 projection matrix (column-major float32). :param viewport: ``(width_px, height_px)`` of the viewport. :param color: ``(r, g, b, a)`` each in ``[0, 1]``. :param line_width: Full visible width of the line (pixels or world units), same semantics as ``glLineWidth``. :param width_in_pixels: If ``True``, *line_width* is in pixels. :param aa_radius: Anti-aliasing smoothing (0–1 in edge space). :param dash_enabled: Enable the dash pattern. :param dash_length: Dash length in world units. :param gap_length: Gap length in world units. :param dash_offset: Pattern offset for animation. :param glow_enabled: Enable the glow halo. :param glow_width: Glow halo extent as fraction of core half-width (e.g. 0.4 adds 40% extra width on each side). :param glow_color: ``(r, g, b, a)`` for the glow. :param anim_phase: Animation phase ``[0, 1]``. :param anim_mode: ``0`` = none, ``1`` = pulse alpha, ``2`` = marching ants. :param join_style: ``0`` = miter, ``1`` = bevel, ``2`` = round. :param join_size: Join radius multiplier (``1.0`` = line half-width). """ self._init_program() self._ensure_vao_vbo() # Upload vertex data self._upload(vbo_data) glUseProgram(self._program) locs = self._locs # Diagnostic: log key values for draw calls self._draw_count += 1 if self._draw_count <= 3 or line_width != 1.0: logging.debug( 'PolylineShader2D.draw #%d: line_width=%.2f, viewport=%s, ' 'widthInPixels=%s, vcount=%d, mvp_diag=[%.6f, %.6f, %.6f, %.6f], ' 'mvp.order=%s, mvp.shape=%s', self._draw_count, line_width, viewport, width_in_pixels, vertex_count, mvp.flat[0], mvp.flat[5] if len(mvp.flat) > 5 else 0, mvp.flat[10] if len(mvp.flat) > 10 else 0, mvp.flat[15] if len(mvp.flat) > 15 else 0, 'F' if mvp.flags['F_CONTIGUOUS'] else 'C', mvp.shape, ) # Blending (required for anti-aliased edges and glow) glEnable(GL_BLEND) glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) # Disable depth test: overlapping segment caps must all be drawn depth_was_enabled = glIsEnabled(GL_DEPTH_TEST) if depth_was_enabled: glDisable(GL_DEPTH_TEST) # Force GL_FILL: the geometry shader outputs triangle_strip quads # that MUST be filled. Legacy list rendering may leave GL_LINE. prev_poly_mode = glGetIntegerv(GL_POLYGON_MODE) glPolygonMode(GL_FRONT_AND_BACK, GL_FILL) # Uniforms glUniformMatrix4fv(locs['mvp'], 1, GL_FALSE, mvp) glUniform1f(locs['lineWidth'], line_width) glUniform2f(locs['viewport'], float(viewport[0]), float(viewport[1])) glUniform1i(locs['widthInPixels'], 1 if width_in_pixels else 0) glUniform4f(locs['lineColor'], *color) glUniform1f(locs['aaRadius'], aa_radius) glUniform1i(locs['dashEnabled'], 1 if dash_enabled else 0) glUniform1f(locs['dashLength'], dash_length) glUniform1f(locs['gapLength'], gap_length) glUniform1f(locs['dashOffset'], dash_offset) glUniform1i(locs['glowEnabled'], 1 if glow_enabled else 0) glUniform1f(locs['glowWidth'], glow_width) glUniform4f(locs['glowColor'], *glow_color) glUniform1f(locs['animPhase'], anim_phase) glUniform1i(locs['animMode'], anim_mode) glUniform1i(locs['joinStyle'], join_style) glUniform1f(locs['joinSize'], join_size) # Draw glBindVertexArray(self._vao) glDrawArrays(GL_LINES_ADJACENCY, 0, vertex_count) glBindVertexArray(0) glUseProgram(0) glDisable(GL_BLEND) # Restore polygon mode if prev_poly_mode is not None and len(prev_poly_mode) >= 2: glPolygonMode(GL_FRONT_AND_BACK, int(prev_poly_mode[0])) # Restore depth test state if depth_was_enabled: glEnable(GL_DEPTH_TEST)
# ================================================================ # Helper: build VBO data from vertices # ================================================================
[docs] def build_polyline_vbo(vertices: list, widths: np.ndarray | None = None) -> tuple[np.ndarray, int]: """Build VBO data for a polyline from a list of wolfvertex objects. Each segment is emitted as four vertices for ``GL_LINES_ADJACENCY`` (prev, start, end, next) to be consumed by the geometry shader for miter join computation. :param vertices: List of objects with ``.x`` and ``.y`` attributes. :param widths: Optional per-vertex width multipliers (length `N`). Defaults to ``1.0`` for all vertices. :returns: ``(vbo_data, vertex_count)`` where *vbo_data* is a contiguous float32 array of shape ``(vertex_count, 4)`` and *vertex_count* is the number of vertices (= 4 × number of segments). """ n = len(vertices) if n < 2: return np.array([], dtype=np.float32), 0 if widths is None: widths = np.ones(n, dtype=np.float32) # Compute cumulative curvilinear distance xs = np.array([v.x for v in vertices], dtype=np.float64) ys = np.array([v.y for v in vertices], dtype=np.float64) diffs = np.sqrt(np.diff(xs)**2 + np.diff(ys)**2) distances = np.zeros(n, dtype=np.float32) distances[1:] = np.cumsum(diffs).astype(np.float32) # Each segment → 4 vertices for GL_LINES_ADJACENCY num_segments = n - 1 vertex_count = num_segments * 4 data = np.empty((vertex_count, _STRIDE), dtype=np.float32) for i in range(num_segments): # Previous vertex (adjacency) pi = max(i - 1, 0) # Next vertex (adjacency) ni = min(i + 2, n - 1) # prev data[4*i, 0] = xs[pi] data[4*i, 1] = ys[pi] data[4*i, 2] = distances[pi] data[4*i, 3] = widths[pi] # start data[4*i+1, 0] = xs[i] data[4*i+1, 1] = ys[i] data[4*i+1, 2] = distances[i] data[4*i+1, 3] = widths[i] # end data[4*i+2, 0] = xs[i+1] data[4*i+2, 1] = ys[i+1] data[4*i+2, 2] = distances[i+1] data[4*i+2, 3] = widths[i+1] # next data[4*i+3, 0] = xs[ni] data[4*i+3, 1] = ys[ni] data[4*i+3, 2] = distances[ni] data[4*i+3, 3] = widths[ni] return np.ascontiguousarray(data), vertex_count