473 lines
14 KiB
Python
473 lines
14 KiB
Python
"""
|
|
EU-Utility - UI Rendering Optimizations
|
|
|
|
High-performance UI rendering for 60+ FPS:
|
|
1. Dirty region tracking (only repaint changed areas)
|
|
2. Frame rate throttling
|
|
3. GPU-accelerated rendering where available
|
|
4. Layered rendering (background/foreground separation)
|
|
5. Text rendering optimization
|
|
6. Paint batching
|
|
"""
|
|
|
|
from typing import Optional, List, Set, Dict, Tuple, Callable
|
|
from dataclasses import dataclass
|
|
from enum import Enum
|
|
import time
|
|
import threading
|
|
|
|
try:
|
|
from PyQt6.QtWidgets import QWidget
|
|
from PyQt6.QtCore import Qt, QTimer, QRect, QSize, QPoint
|
|
from PyQt6.QtGui import QPainter, QPixmap, QColor, QRegion
|
|
PYQT_AVAILABLE = True
|
|
except ImportError:
|
|
PYQT_AVAILABLE = False
|
|
|
|
|
|
class RenderPriority(Enum):
|
|
"""Render priority levels."""
|
|
CRITICAL = 0 # Must render immediately
|
|
HIGH = 1 # Render next frame
|
|
NORMAL = 2 # Standard priority
|
|
LOW = 3 # Can be deferred
|
|
BACKGROUND = 4 # Render when idle
|
|
|
|
|
|
@dataclass
|
|
class DirtyRegion:
|
|
"""Represents a region that needs repainting."""
|
|
rect: QRect
|
|
priority: RenderPriority
|
|
timestamp: float
|
|
widget: Optional[QWidget] = None
|
|
|
|
|
|
class FrameRateController:
|
|
"""
|
|
Controls rendering frame rate for consistent performance.
|
|
"""
|
|
|
|
def __init__(self, target_fps: float = 60.0):
|
|
self.target_fps = target_fps
|
|
self.target_frame_time = 1.0 / target_fps
|
|
self._last_frame_time = 0
|
|
self._frame_times: List[float] = []
|
|
self._max_samples = 60
|
|
self._lock = threading.Lock()
|
|
|
|
def should_render(self) -> bool:
|
|
"""Check if we should render a new frame."""
|
|
now = time.perf_counter()
|
|
elapsed = now - self._last_frame_time
|
|
|
|
if elapsed >= self.target_frame_time:
|
|
with self._lock:
|
|
self._frame_times.append(elapsed)
|
|
if len(self._frame_times) > self._max_samples:
|
|
self._frame_times.pop(0)
|
|
|
|
self._last_frame_time = now
|
|
return True
|
|
|
|
return False
|
|
|
|
def wait_for_frame(self):
|
|
"""Block until next frame should render."""
|
|
now = time.perf_counter()
|
|
elapsed = now - self._last_frame_time
|
|
sleep_time = max(0, self.target_frame_time - elapsed)
|
|
|
|
if sleep_time > 0:
|
|
time.sleep(sleep_time)
|
|
|
|
def get_actual_fps(self) -> float:
|
|
"""Get measured FPS."""
|
|
with self._lock:
|
|
if not self._frame_times:
|
|
return 0
|
|
avg_frame_time = sum(self._frame_times) / len(self._frame_times)
|
|
return 1.0 / avg_frame_time if avg_frame_time > 0 else 0
|
|
|
|
def get_stats(self) -> Dict:
|
|
"""Get frame rate statistics."""
|
|
with self._lock:
|
|
if not self._frame_times:
|
|
return {'fps': 0, 'min': 0, 'max': 0, 'avg': 0}
|
|
|
|
return {
|
|
'fps': self.get_actual_fps(),
|
|
'min': 1.0 / max(self._frame_times) if self._frame_times else 0,
|
|
'max': 1.0 / min(self._frame_times) if self._frame_times else 0,
|
|
'avg': sum(self._frame_times) / len(self._frame_times) * 1000 # ms
|
|
}
|
|
|
|
|
|
class DirtyRegionTracker:
|
|
"""
|
|
Tracks dirty regions for efficient repainting.
|
|
Only repaints changed areas instead of entire widgets.
|
|
"""
|
|
|
|
def __init__(self):
|
|
self._regions: List[DirtyRegion] = []
|
|
self._lock = threading.Lock()
|
|
self._merged_cache: Optional[QRegion] = None
|
|
|
|
def add_dirty_region(self,
|
|
rect: QRect,
|
|
priority: RenderPriority = RenderPriority.NORMAL,
|
|
widget: Optional[QWidget] = None):
|
|
"""Mark a region as dirty."""
|
|
with self._lock:
|
|
self._regions.append(DirtyRegion(
|
|
rect=rect,
|
|
priority=priority,
|
|
timestamp=time.perf_counter(),
|
|
widget=widget
|
|
))
|
|
self._merged_cache = None # Invalidate cache
|
|
|
|
def get_merged_region(self) -> Optional[QRegion]:
|
|
"""Get merged dirty region (optimized for minimal repaints)."""
|
|
with self._lock:
|
|
if self._merged_cache is not None:
|
|
return self._merged_cache
|
|
|
|
if not self._regions:
|
|
return None
|
|
|
|
# Sort by priority
|
|
self._regions.sort(key=lambda r: r.priority.value)
|
|
|
|
# Merge overlapping regions
|
|
region = QRegion()
|
|
for dr in self._regions:
|
|
region = region.united(dr.rect)
|
|
|
|
self._merged_cache = region
|
|
return region
|
|
|
|
def clear(self):
|
|
"""Clear all dirty regions."""
|
|
with self._lock:
|
|
self._regions.clear()
|
|
self._merged_cache = None
|
|
|
|
def clear_region(self, rect: QRect):
|
|
"""Clear specific region."""
|
|
with self._lock:
|
|
self._regions = [
|
|
r for r in self._regions
|
|
if not r.rect.intersects(rect)
|
|
]
|
|
self._merged_cache = None
|
|
|
|
def is_dirty(self, rect: QRect) -> bool:
|
|
"""Check if a region is dirty."""
|
|
with self._lock:
|
|
for dr in self._regions:
|
|
if dr.rect.intersects(rect):
|
|
return True
|
|
return False
|
|
|
|
|
|
class OptimizedPainter:
|
|
"""
|
|
Optimized painting utilities.
|
|
"""
|
|
|
|
def __init__(self, widget: QWidget):
|
|
self.widget = widget
|
|
self._frame_controller = FrameRateController(60.0)
|
|
self._dirty_tracker = DirtyRegionTracker()
|
|
self._double_buffer: Optional[QPixmap] = None
|
|
self._buffer_valid = False
|
|
|
|
def begin_paint(self, painter: QPainter) -> bool:
|
|
"""Begin optimized painting. Returns True if should paint."""
|
|
if not self._frame_controller.should_render():
|
|
return False
|
|
|
|
# Set rendering hints for performance
|
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing, False)
|
|
painter.setRenderHint(QPainter.RenderHint.TextAntialiasing, True)
|
|
painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform, False)
|
|
|
|
return True
|
|
|
|
def paint_dirty_regions(self, paint_func: Callable[[QPainter, QRect], None]):
|
|
"""Paint only dirty regions."""
|
|
region = self._dirty_tracker.get_merged_region()
|
|
|
|
if region is None:
|
|
return
|
|
|
|
painter = QPainter(self.widget)
|
|
|
|
try:
|
|
# Set clip region to dirty areas only
|
|
painter.setClipRegion(region)
|
|
|
|
# Paint each dirty region
|
|
for rect in region.rects():
|
|
paint_func(painter, rect)
|
|
finally:
|
|
painter.end()
|
|
|
|
# Clear painted regions
|
|
self._dirty_tracker.clear()
|
|
|
|
def invalidate_region(self, rect: QRect, priority: RenderPriority = RenderPriority.NORMAL):
|
|
"""Invalidate a region for repainting."""
|
|
self._dirty_tracker.add_dirty_region(rect, priority)
|
|
self._buffer_valid = False
|
|
self.widget.update()
|
|
|
|
def get_fps_stats(self) -> Dict:
|
|
"""Get frame rate statistics."""
|
|
return self._frame_controller.get_stats()
|
|
|
|
|
|
class TextRenderer:
|
|
"""
|
|
Optimized text rendering with caching.
|
|
"""
|
|
|
|
_cache: Dict[str, QPixmap] = {}
|
|
_cache_size_limit = 100
|
|
|
|
@classmethod
|
|
def render_cached(cls,
|
|
painter: QPainter,
|
|
text: str,
|
|
x: int,
|
|
y: int,
|
|
font=None,
|
|
color=None) -> QRect:
|
|
"""
|
|
Render text using cache.
|
|
|
|
Returns bounding rect.
|
|
"""
|
|
from PyQt6.QtGui import QFontMetrics, QPainter
|
|
|
|
# Build cache key
|
|
cache_key = f"{text}:{id(font)}:{str(color)}"
|
|
|
|
# Check cache
|
|
if cache_key in cls._cache:
|
|
pixmap = cls._cache[cache_key]
|
|
painter.drawPixmap(x, y, pixmap)
|
|
return QRect(x, y, pixmap.width(), pixmap.height())
|
|
|
|
# Measure text
|
|
metrics = QFontMetrics(font) if font else painter.fontMetrics()
|
|
rect = metrics.boundingRect(text)
|
|
|
|
# Create pixmap
|
|
pixmap = QPixmap(rect.width(), rect.height())
|
|
pixmap.fill(Qt.GlobalColor.transparent)
|
|
|
|
# Render to pixmap
|
|
p = QPainter(pixmap)
|
|
if font:
|
|
p.setFont(font)
|
|
if color:
|
|
p.setPen(color)
|
|
p.drawText(0, metrics.ascent(), text)
|
|
p.end()
|
|
|
|
# Cache if not too big
|
|
if len(cls._cache) < cls._cache_size_limit and rect.width() < 200:
|
|
cls._cache[cache_key] = pixmap
|
|
|
|
# Draw
|
|
painter.drawPixmap(x, y, pixmap)
|
|
return QRect(x, y, rect.width(), rect.height())
|
|
|
|
@classmethod
|
|
def clear_cache(cls):
|
|
"""Clear text cache."""
|
|
cls._cache.clear()
|
|
|
|
|
|
class LayeredRenderer:
|
|
"""
|
|
Layered rendering for complex UIs.
|
|
Separates static background from dynamic foreground.
|
|
"""
|
|
|
|
def __init__(self, size: QSize):
|
|
self.size = size
|
|
self._background: Optional[QPixmap] = None
|
|
self._foreground: Optional[QPixmap] = None
|
|
self._background_dirty = True
|
|
self._foreground_dirty = True
|
|
|
|
def invalidate_background(self):
|
|
"""Mark background as needing redraw."""
|
|
self._background_dirty = True
|
|
|
|
def invalidate_foreground(self):
|
|
"""Mark foreground as needing redraw."""
|
|
self._foreground_dirty = True
|
|
|
|
def render(self,
|
|
painter: QPainter,
|
|
background_func: Callable[[QPainter], None],
|
|
foreground_func: Callable[[QPainter], None]):
|
|
"""Render layers."""
|
|
|
|
# Render background if needed
|
|
if self._background_dirty or self._background is None:
|
|
self._background = QPixmap(self.size)
|
|
bg_painter = QPainter(self._background)
|
|
background_func(bg_painter)
|
|
bg_painter.end()
|
|
self._background_dirty = False
|
|
|
|
# Render foreground
|
|
if self._foreground_dirty or self._foreground is None:
|
|
self._foreground = QPixmap(self.size)
|
|
self._foreground.fill(Qt.GlobalColor.transparent)
|
|
fg_painter = QPainter(self._foreground)
|
|
foreground_func(fg_painter)
|
|
fg_painter.end()
|
|
self._foreground_dirty = False
|
|
|
|
# Composite
|
|
painter.drawPixmap(0, 0, self._background)
|
|
painter.drawPixmap(0, 0, self._foreground)
|
|
|
|
def resize(self, size: QSize):
|
|
"""Handle resize."""
|
|
self.size = size
|
|
self._background = None
|
|
self._foreground = None
|
|
self._background_dirty = True
|
|
self._foreground_dirty = True
|
|
|
|
|
|
class PaintBatcher:
|
|
"""
|
|
Batches paint operations for efficiency.
|
|
"""
|
|
|
|
def __init__(self, flush_interval_ms: float = 16.0):
|
|
self._operations: List[Callable] = []
|
|
self._flush_interval = flush_interval_ms / 1000.0
|
|
self._timer: Optional[QTimer] = None
|
|
self._lock = threading.Lock()
|
|
|
|
def add(self, operation: Callable):
|
|
"""Add paint operation to batch."""
|
|
with self._lock:
|
|
self._operations.append(operation)
|
|
|
|
if self._timer is None and PYQT_AVAILABLE:
|
|
from PyQt6.QtCore import QTimer
|
|
self._timer = QTimer()
|
|
self._timer.setSingleShot(True)
|
|
self._timer.timeout.connect(self._flush)
|
|
self._timer.start(int(self._flush_interval * 1000))
|
|
|
|
def _flush(self):
|
|
"""Execute all batched operations."""
|
|
with self._lock:
|
|
operations = self._operations
|
|
self._operations = []
|
|
self._timer = None
|
|
|
|
for op in operations:
|
|
try:
|
|
op()
|
|
except Exception as e:
|
|
print(f"[PaintBatcher] Error: {e}")
|
|
|
|
def flush(self):
|
|
"""Force immediate flush."""
|
|
if self._timer:
|
|
self._timer.stop()
|
|
self._flush()
|
|
|
|
|
|
class GPUAcceleratedWidget:
|
|
"""
|
|
Mixin for GPU-accelerated widgets (OpenGL).
|
|
"""
|
|
|
|
def __init__(self):
|
|
self._use_opengl = False
|
|
self._gl_context = None
|
|
|
|
def enable_gpu_acceleration(self):
|
|
"""Enable OpenGL acceleration if available."""
|
|
try:
|
|
from PyQt6.QtOpenGLWidgets import QOpenGLWidget
|
|
from PyQt6.QtCore import QSurfaceFormat
|
|
|
|
# Request OpenGL 3.3
|
|
format = QSurfaceFormat()
|
|
format.setVersion(3, 3)
|
|
format.setProfile(QSurfaceFormat.OpenGLContextProfile.CoreProfile)
|
|
QSurfaceFormat.setDefaultFormat(format)
|
|
|
|
self._use_opengl = True
|
|
print("[GPU] OpenGL acceleration enabled")
|
|
|
|
except ImportError:
|
|
print("[GPU] OpenGL not available")
|
|
self._use_opengl = False
|
|
|
|
def is_gpu_accelerated(self) -> bool:
|
|
"""Check if GPU acceleration is active."""
|
|
return self._use_opengl
|
|
|
|
|
|
# Global instances
|
|
_frame_controller = FrameRateController(60.0)
|
|
_paint_batcher = PaintBatcher()
|
|
|
|
|
|
def get_frame_controller() -> FrameRateController:
|
|
"""Get global frame rate controller."""
|
|
return _frame_controller
|
|
|
|
|
|
def get_paint_batcher() -> PaintBatcher:
|
|
"""Get global paint batcher."""
|
|
return _paint_batcher
|
|
|
|
|
|
def set_target_fps(fps: float):
|
|
"""Set target frame rate globally."""
|
|
_frame_controller.target_fps = fps
|
|
_frame_controller.target_frame_time = 1.0 / fps
|
|
|
|
|
|
# Performance monitoring decorator
|
|
def measure_paint_time(func):
|
|
"""Decorator to measure paint operation time."""
|
|
def wrapper(*args, **kwargs):
|
|
start = time.perf_counter()
|
|
result = func(*args, **kwargs)
|
|
elapsed = (time.perf_counter() - start) * 1000
|
|
|
|
if elapsed > 16.67: # Slower than 60fps
|
|
print(f"[Paint] Slow paint: {elapsed:.2f}ms in {func.__name__}")
|
|
|
|
return result
|
|
return wrapper
|
|
|
|
|
|
if __name__ == "__main__":
|
|
# Test frame rate controller
|
|
controller = FrameRateController(60.0)
|
|
|
|
print("Testing frame rate controller...")
|
|
for _ in range(10):
|
|
if controller.should_render():
|
|
print(f"Frame rendered (FPS: {controller.get_actual_fps():.1f})")
|
|
time.sleep(0.01)
|