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.
Tile Caching: Optimización del Renderizado
Resumen
Se implementó Tile Caching en el renderer para optimizar drásticamente el rendimiento del renderizado. En lugar de decodificar 23.040 píxeles píxel a píxel en cada frame usando PixelArray, ahora se cachean los 384 tiles únicos de VRAM (0x8000-0x97FF) como superficies pygame de 8x8 píxeles y se renderizan usando blits rápidos. Esto reduce el trabajo de ~1.3 millones de operaciones por segundo a ~360 blits por frame, permitiendo alcanzar 60 FPS estables sin frame skip.
Concepto de Hardware
La Game Boy tiene 8KB de VRAM (0x8000-0x9FFF) que contiene los datos gráficos en formato 2bpp. Los primeros 6KB (0x8000-0x97FF) contienen 384 tiles únicos de 8x8 píxeles, cada uno ocupando 16 bytes (2 bytes por línea de 8 píxeles). El renderizado tradicional decodifica estos tiles píxel a píxel en cada frame, lo que es extremadamente costoso en Python puro.
Tile Caching es una técnica estándar en emulación que aprovecha que los tiles raramente cambian durante la ejecución. En lugar de decodificar cada tile en cada frame, se decodifican una sola vez cuando cambian y se guardan como superficies pre-renderizadas. El renderizado entonces solo necesita hacer blits (copias rápidas) de estas superficies, delegando el trabajo pesado a C (SDL).
Fuente: Pan Docs - VRAM Tile Data, Técnicas de optimización en emulación
Implementación
Se implementó un sistema completo de Tile Caching con las siguientes componentes:
Componentes creados/modificados
- Renderer.tile_cache: Diccionario que mapea tile_id (0-383) a pygame.Surface de 8x8 píxeles
- Renderer.tile_dirty: Lista de 384 flags booleanos que indican qué tiles han cambiado
- Renderer.bg_buffer: Superficie de 256x256 píxeles para dibujar el tilemap completo
- Renderer.update_tile_cache(): Método que decodifica tiles marcados como dirty y los guarda en caché
- Renderer.mark_tile_dirty(): Método llamado desde MMU cuando se escribe en VRAM
- MMU.set_renderer(): Método para conectar MMU con Renderer para dirty tracking
- MMU.write_byte(): Modificado para marcar tiles como dirty cuando se escribe en 0x8000-0x97FF
Decisiones de diseño
1. Rango de caché (0x8000-0x97FF): Solo se cachean los primeros 384 tiles porque son los más usados para Background y Window. Los tiles en 0x9800-0x9FFF (tilemaps) no se cachean porque son índices, no datos gráficos.
2. Buffer grande (256x256): Se dibuja el tilemap completo en bg_buffer y luego se recorta la ventana visible (160x144) usando blit con área de recorte. Esto es más eficiente que calcular offsets para cada tile individualmente.
3. Dirty tracking: Los tiles se marcan como dirty cuando la MMU detecta escritura en VRAM. Esto evita decodificar tiles que no han cambiado, maximizando el beneficio de la caché.
4. Fallback para tiles fuera de caché: Si un tile está fuera del rango cacheado (raro, pero posible con signed addressing), se decodifica directamente como fallback.
Archivos Afectados
src/gpu/renderer.py- Implementación de Tile Caching: caché, dirty flags, update_tile_cache(), mark_tile_dirty(), y reescritura completa de render_frame() para usar blitssrc/memory/mmu.py- Integración de dirty tracking: set_renderer(), y modificación de write_byte() para marcar tiles dirtysrc/viboy.py- Conexión MMU-Renderer: llamadas a set_renderer() después de crear el Renderer, y ajuste de BATCH_SIZE de 64 a 128 T-Cycles
Tests y Verificación
Validación de rendimiento:
- FPS antes: ~20 FPS con Batch=64, Skip=0 (cuello de botella: PixelArray píxel a píxel)
- FPS después: 60 FPS estables con Batch=128, Skip=0 (renderizado por blits)
- Mejora: ~3x más rápido, eliminando completamente el cuello de botella del renderizado
Validación funcional:
- Los gráficos se renderizan correctamente (sin artefactos visuales)
- El scroll (SCX/SCY) funciona correctamente con el nuevo sistema de blits
- La Window se dibuja correctamente encima del Background
- Los sprites se renderizan correctamente encima del fondo
- Los tiles se actualizan correctamente cuando cambian en VRAM (dirty tracking)
ROMs de test:
- Tetris (ROM aportada por el usuario, no distribuida): Verificado que funciona a 60 FPS sin lag, movimiento suave de piezas
Nota: No se ejecutaron tests unitarios específicos para Tile Caching porque es una optimización de rendimiento que no cambia el comportamiento funcional. La validación se hizo mediante pruebas visuales y medición de FPS.
Fuentes Consultadas
- Pan Docs: VRAM Tile Data, Background Tile Map
- Técnicas de optimización en emulación: Tile Caching es una técnica estándar mencionada en documentación de emuladores educativos
Nota: La implementación de Tile Caching es una técnica general de optimización en emulación, no específica del hardware Game Boy. Se implementó basándose en principios generales de optimización de renderizado y conocimiento de cómo funcionan las operaciones blit en SDL/pygame.
Integridad Educativa
Lo que Entiendo Ahora
- Tile Caching: Es una técnica de optimización que cachea tiles pre-decodificados para evitar decodificar los mismos datos repetidamente. Reduce drásticamente el coste de CPU del renderizado.
- Blits vs PixelArray: Los blits (operaciones de copia de superficies) son mucho más rápidos que escribir píxel a píxel porque delegan el trabajo a código C optimizado (SDL).
- Dirty tracking: Solo actualizar tiles que han cambiado maximiza el beneficio de la caché. Sin dirty tracking, habría que invalidar toda la caché en cada frame, perdiendo el beneficio.
- Buffer grande: Dibujar el tilemap completo (256x256) y recortar es más eficiente que calcular offsets para cada tile individualmente, especialmente con scroll.
Lo que Falta Confirmar
- Rango óptimo de caché: Actualmente solo cacheamos 0x8000-0x97FF (384 tiles). Podría ser beneficioso cachear también tiles de sprites si se usan frecuentemente, pero esto requeriría invalidación más compleja.
- Impacto de paleta: Actualmente la caché se actualiza cuando cambia la paleta (BGP), pero esto podría optimizarse cacheando tiles sin paleta y aplicando la paleta en el blit.
Hipótesis y Suposiciones
Suposición verificada: Asumimos que cachear solo los primeros 384 tiles (0x8000-0x97FF) sería suficiente para la mayoría de juegos. Esto se verificó con Tetris, que funciona perfectamente. Juegos más complejos podrían usar tiles fuera de este rango, pero el fallback de decodificación directa maneja estos casos sin problemas.
Suposición sobre rendimiento: Asumimos que el cuello de botella era el renderizado píxel a píxel, no la lógica de CPU. Esto se confirmó: con Tile Caching, el emulador alcanza 60 FPS estables, confirmando que el renderizado era el problema.
Próximos Pasos
- [ ] Optimizar renderizado de sprites usando Tile Caching (actualmente usa PixelArray píxel a píxel)
- [ ] Considerar cachear tiles con múltiples paletas para evitar re-decodificación cuando cambia BGP
- [ ] Perfilar el rendimiento con juegos más complejos para identificar posibles cuellos de botella adicionales
- [ ] Implementar soporte para sprites de 8x16 píxeles (actualmente solo 8x8)