EU-Utility/core/ui_render_optimized.py

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)