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