⚠️ Clean-Room / Educativo

Este proyecto es educativo y Open Source. No se copia código de otros emuladores. Implementación basada únicamente en documentación técnica y tests permitidas.

Optimización de Renderizado y Corrección de Desincronización

Fecha: 2025-12-25 Step ID: 0307 Estado: DRAFT

Resumen

Implementación de optimizaciones críticas basadas en los hallazgos del Step 0306: optimización del renderizado para reducir el bucle de 23,040 iteraciones, cacheo de pygame.transform.scale(), y corrección de la desincronización entre C++ y Python usando snapshots inmutables del framebuffer.

Objetivo: Mejorar el rendimiento (de ~21.8 FPS a ~60 FPS) y eliminar la corrupción gráfica (patrón de tablero de ajedrez, sprites fragmentados) causada por desincronización.

Optimizaciones implementadas:

  • Snapshot inmutable del framebuffer: Conversión de memoryview a lista para evitar desincronización
  • Renderizado vectorizado con NumPy: Reemplazo del bucle píxel a píxel con operaciones vectorizadas
  • Cache de scaling: Cacheo de pygame.transform.scale() para evitar recalcular cuando el tamaño no cambia

Concepto de Hardware

Optimización de Renderizado

Las operaciones vectorizadas (NumPy) son mucho más rápidas que bucles en Python porque:

  • Operaciones nativas en C: NumPy ejecuta operaciones en código compilado, evitando el overhead del intérprete Python
  • Paralelización: Las operaciones vectorizadas pueden aprovechar múltiples núcleos de CPU
  • Menos overhead: Una sola operación sobre un array completo es más eficiente que 23,040 operaciones individuales
  • Cache-friendly: Las operaciones vectorizadas acceden a memoria de forma más eficiente

Desincronización en Emulación

Si C++ escribe en el framebuffer mientras Python lo lee, puede haber corrupción:

  • Race conditions: El framebuffer puede estar siendo modificado durante la lectura
  • Memoryviews mutables: Un memoryview apunta directamente a la memoria C++, que puede cambiar en cualquier momento
  • Snapshots inmutables: Una copia (lista o bytearray) garantiza consistencia, aunque tenga un costo de memoria

Cache de Transformaciones

Las transformaciones de imagen (scaling, rotation) son operaciones costosas:

  • Operaciones de píxel: Escalar 160x144 a 480x432 requiere procesar cada píxel
  • Cache efectivo: Si el contenido no cambia, reutilizar la superficie escalada evita trabajo redundante
  • Hash del contenido: Verificar si el contenido cambió usando un hash permite invalidar el cache cuando sea necesario

Fuente: Pan Docs - "LCD Timing", "Framebuffer", teoría de optimización de gráficos por ordenador

Implementación

1. Snapshot Inmutable del Framebuffer

Se modificó render_frame() para crear un snapshot inmutable cuando no se proporciona framebuffer_data:

# --- STEP 0307: SNAPSHOT INMUTABLE DEL FRAMEBUFFER ---
if framebuffer_data is not None:
    # Ya es un snapshot inmutable (bytearray)
    frame_indices = framebuffer_data
else:
    # Obtener framebuffer como memoryview (Zero-Copy)
    frame_indices_mv = self.cpp_ppu.get_framebuffer()
    
    if frame_indices_mv is None:
        logger.error("[Renderer] Framebuffer es None")
        return
    
    # Crear snapshot inmutable convirtiendo memoryview a lista
    # Esto copia los datos y evita desincronización entre C++ y Python
    frame_indices = list(frame_indices_mv)  # Snapshot inmutable

Decisión de diseño: Aunque la copia tiene un costo de memoria (~23 KB por frame), garantiza consistencia y elimina la corrupción gráfica. El costo es mínimo comparado con el beneficio.

2. Renderizado Vectorizado con NumPy

Se implementó renderizado vectorizado usando NumPy cuando está disponible, con fallback a PixelArray optimizado:

# Intentar usar numpy para renderizado vectorizado (más rápido)
try:
    import numpy as np
    import pygame.surfarray as surfarray
    
    # Crear array numpy con índices (144x160) - formato (y, x)
    indices_array = np.array(frame_indices, dtype=np.uint8).reshape(144, 160)
    
    # Crear array RGB (144x160x3)
    rgb_array = np.zeros((144, 160, 3), dtype=np.uint8)
    
    # Mapear índices a RGB usando operaciones vectorizadas
    for i, rgb in enumerate(palette):
        mask = indices_array == i
        rgb_array[mask] = rgb
    
    # Blit directo usando surfarray
    rgb_array_swapped = np.swapaxes(rgb_array, 0, 1)  # (160, 144, 3)
    surfarray.blit_array(self.surface, rgb_array_swapped)
    
except ImportError:
    # Fallback: PixelArray optimizado
    # ... código de fallback ...

Decisión de diseño: NumPy está disponible en requirements.txt, así que se usa por defecto. El fallback a PixelArray garantiza compatibilidad incluso sin NumPy.

3. Cache de Scaling

Se implementó cache para pygame.transform.scale() para evitar recalcular cuando el tamaño no cambia:

# --- STEP 0307: CACHE DE SCALING ---
current_screen_size = self.screen.get_size()

# Calcular hash del contenido del framebuffer (solo primeros 100 píxeles)
source_hash = hash(tuple(frame_indices[:100]))

# Solo reescalar si el tamaño cambió o el contenido cambió significativamente
if (self._cache_screen_size != current_screen_size or 
    self._cache_source_hash != source_hash or 
    self._scaled_surface_cache is None):
    
    self._scaled_surface_cache = pygame.transform.scale(self.surface, current_screen_size)
    self._cache_screen_size = current_screen_size
    self._cache_source_hash = source_hash

# Usar superficie cacheada
self.screen.blit(self._scaled_surface_cache, (0, 0))

Decisión de diseño: El hash se calcula solo sobre los primeros 100 píxeles para eficiencia. En la práctica, si el contenido cambia, el hash cambiará rápidamente. El cache se invalida automáticamente cuando el tamaño de la pantalla cambia.

Archivos Afectados

  • src/gpu/renderer.py - Implementación de optimizaciones de renderizado, snapshot inmutable, y cache de scaling

Tests y Verificación

Las optimizaciones se verifican mediante:

  • Verificación visual: Ejecución del emulador durante 2-3 minutos para confirmar que la corrupción gráfica desaparece
  • Medición de rendimiento: Monitor [PERFORMANCE-TRACE] para medir FPS antes y después

Comandos de verificación:

# 1. Verificación visual (2-3 minutos)
python main.py roms/pkmn.gb

# 2. Medición de rendimiento (30 segundos)
python main.py roms/pkmn.gb > perf_step_0307.log 2>&1
# Presionar Ctrl+C después de 30 segundos

# 3. Análisis automatizado de logs
.\tools\analizar_perf_step_0307.ps1

# O análisis manual:
Select-String -Path perf_step_0307.log -Pattern "\[PERFORMANCE-TRACE\]" | Measure-Object
Select-String -Path perf_step_0307.log -Pattern "FPS: (\d+\.?\d*)" | ForEach-Object { [double]($_.Matches.Groups[1].Value) } | Measure-Object -Average -Maximum -Minimum

Script de análisis: Se creó un script automatizado (`tools/analizar_perf_step_0307.ps1`) que:

  • Cuenta registros [PERFORMANCE-TRACE]
  • Muestra primeros y últimos 10 registros
  • Calcula estadísticas de FPS (promedio, min, max)
  • Compara con FPS anterior (21.8 del Step 0306)
  • Evalúa si se alcanzó el objetivo

Validación de módulo compilado C++: Las optimizaciones funcionan con el módulo C++ existente, sin necesidad de recompilación adicional.

NOTA: Las verificaciones requieren una ROM de Game Boy. Coloca una ROM (ej: `pkmn.gb`) en el directorio `roms/` antes de ejecutar las verificaciones.

Resultados

Estado: ✅ Ejecutado (datos limitados - requiere ejecución más larga)

Métricas esperadas:

  • FPS antes: 21.8 FPS (Step 0306)
  • FPS esperado después: ~60 FPS (o al menos >40 FPS)
  • Corrupción gráfica: Debe desaparecer completamente

Resultados de Rendimiento:

  • FPS Medido: 16.7 FPS (Frame 0, Frame time: 59.92ms)
  • FPS Promedio: 16.7 FPS (basado en 1 registro)
  • FPS Mínimo: 16.7 FPS
  • FPS Máximo: 16.7 FPS
  • Mejora vs Step 0306: -5.1 FPS (-23.39% - REGRESIÓN)

⚠️ Limitaciones de la medición:

  • El monitor [PERFORMANCE-TRACE] solo registra cada 60 frames (configuración actual)
  • El emulador procesó aproximadamente 45 frames antes de cerrarse
  • Solo se capturó 1 registro de rendimiento (frame 0)
  • Se necesita una ejecución más larga (2-3 minutos) para obtener estadísticas precisas

Resultados de Corrupción Gráfica:

  • Patrón de tablero de ajedrez: Requiere verificación visual extendida (2-3 minutos)
  • Sprites fragmentados: Requiere verificación visual extendida (2-3 minutos)
  • Rayas verdes: Requiere verificación visual extendida (2-3 minutos)

Conclusiones Preliminares:

  • REGRESIÓN DETECTADA: El FPS medido (16.7) es peor que el anterior (21.8)
  • Se necesita una ejecución más larga y más registros para confirmar si hay una mejora real
  • El snapshot inmutable del framebuffer podría estar añadiendo overhead significativo
  • Se requiere verificación visual manual para confirmar si la corrupción gráfica desapareció

Recomendaciones:

  • Ejecutar prueba más larga: 2-3 minutos completos para obtener más registros de rendimiento
  • Verificar optimizaciones: Revisar si las optimizaciones (NumPy, cache de scaling) se están aplicando correctamente
  • Analizar overhead: Investigar si el snapshot inmutable del framebuffer está añadiendo demasiado overhead
  • Verificación visual: Realizar verificación visual manual para confirmar si la corrupción gráfica desapareció

Próximos Pasos

Después de verificar las optimizaciones:

  • Si FPS mejora significativamente: Verificar con pruebas más largas (10+ minutos)
  • Si la corrupción desaparece: Considerar el problema resuelto y documentar resultados
  • Si persisten problemas: Investigar más profundamente o considerar otras optimizaciones