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.
PPU Fase B: Framebuffer y Renderizado en C++
Resumen
Después de lograr que la ventana de Pygame aparezca y se actualice a 60 FPS (Step 0123), el siguiente paso crítico era implementar el framebuffer y el renderizado de píxeles en C++. La ventana estaba en blanco porque aunque la PPU avanzaba correctamente (LY ciclaba de 0 a 153), no había datos gráficos reales para mostrar.
En este paso, se implementó la Fase B de la migración de la PPU: el framebuffer con índices de color (0-3) y un renderizador simplificado que genera un patrón de degradado de prueba. Esto permite verificar que toda la tubería de datos funciona correctamente: CPU C++ → PPU C++ → Framebuffer C++ → Cython MemoryView → Python Pygame.
Resultado esperado: Una pantalla con un patrón de degradado diagonal que se actualiza a 60 FPS, confirmando que el framebuffer se está escribiendo y mostrando correctamente. Una vez confirmado, el siguiente paso será reemplazar el degradado de prueba por el renderizado real de tiles.
Concepto de Hardware
En la Game Boy real, la PPU renderiza cada línea de escaneo (scanline) en tiempo real, generando 160 píxeles por línea y 144 líneas visibles por fotograma. Cada píxel se representa como un índice de color (0-3) que se mapea a un color final usando la paleta BGP (Background Palette, registro 0xFF47).
Formato de píxeles: La Game Boy usa un formato 2bpp (2 bits por píxel), lo que permite 4 colores posibles (0, 1, 2, 3). Estos índices no son colores RGB directos, sino referencias a una paleta de 4 colores configurable. El registro BGP contiene 4 pares de bits que definen qué tono de gris (en Game Boy original) corresponde a cada índice:
- Bits 0-1: Color para índice 0 (típicamente blanco)
- Bits 2-3: Color para índice 1 (típicamente gris claro)
- Bits 4-5: Color para índice 2 (típicamente gris oscuro)
- Bits 6-7: Color para índice 3 (típicamente negro)
Ventaja del formato de índices: Almacenar índices de color (uint8_t) en lugar de colores RGB completos (uint32_t ARGB) tiene múltiples ventajas:
- Menor uso de memoria: 1 byte por píxel vs 4 bytes (reducción del 75%)
- Flexibilidad: Cambiar la paleta BGP actualiza todos los píxeles sin re-renderizar
- Eficiencia: La conversión a RGB solo ocurre una vez en Python, no en cada frame en C++
- Zero-Copy: Python puede leer los índices directamente desde memoria C++ sin copias
Fuente: Pan Docs - Background Palette (BGP), Tile Data, 2bpp Format
Implementación
La implementación se dividió en 4 componentes principales:
1. Framebuffer en C++ (PPU.hpp / PPU.cpp)
Se cambió el framebuffer de std::vector<uint32_t> (ARGB32) a std::vector<uint8_t> (índices de color):
// En PPU.hpp
std::vector<uint8_t> framebuffer_; // Índices de color (0-3)
// En PPU.cpp (constructor)
framebuffer_(FRAMEBUFFER_SIZE, 0) // Inicializar a índice 0 (blanco por defecto)
// Método para obtener puntero
uint8_t* get_framebuffer_ptr() {
return framebuffer_.data();
}
2. Renderizador Simplificado (PPU.cpp)
Se implementó un método render_scanline() simplificado que genera un patrón de degradado diagonal de prueba:
void PPU::render_scanline() {
if (ly_ >= VISIBLE_LINES) {
return;
}
// Patrón de degradado diagonal: (ly_ + x) % 4
int line_start_index = static_cast<int>(ly_) * SCREEN_WIDTH;
for (int x = 0; x < SCREEN_WIDTH; ++x) {
framebuffer_[line_start_index + x] = static_cast<uint8_t>((ly_ + x) % 4);
}
}
Este patrón se llama automáticamente cuando la PPU entra en Mode 0 (H-Blank) dentro de una línea visible, asegurando que cada línea se renderice exactamente una vez.
3. Exposición Zero-Copy a Python (ppu.pyx)
Se actualizó el wrapper Cython para exponer el framebuffer como un memoryview 1D de uint8_t:
@property
def framebuffer(self):
"""
Obtiene el framebuffer como un memoryview de índices de color (Zero-Copy).
El framebuffer está organizado en filas: píxel (y, x) está en índice [y * 160 + x].
"""
cdef uint8_t* ptr = self._ppu.get_framebuffer_ptr()
cdef unsigned char[:] view = <unsigned char[:144*160]>ptr
return view
Nota importante: Los memoryviews de Cython no soportan reshape() directamente, por lo que se devuelve un array 1D y Python calcula el índice manualmente usando [y * 160 + x].
4. Renderer de Python (renderer.py)
Se actualizó el método render_frame() para leer los índices del framebuffer C++, aplicar la paleta BGP, y renderizar en Pygame:
# Obtener framebuffer como memoryview (Zero-Copy)
frame_indices = self.cpp_ppu.framebuffer # 1D array de 23040 elementos
# Leer y decodificar paleta BGP
bgp = self.mmu.read_byte(IO_BGP) & 0xFF
palette = [
PALETTE_GREYSCALE[(bgp >> 0) & 0x03],
PALETTE_GREYSCALE[(bgp >> 2) & 0x03],
PALETTE_GREYSCALE[(bgp >> 4) & 0x03],
PALETTE_GREYSCALE[(bgp >> 6) & 0x03],
]
# Crear superficie y aplicar paleta
frame_surface = pygame.Surface((GB_WIDTH, GB_HEIGHT))
with pygame.PixelArray(frame_surface) as pixels:
for y in range(GB_HEIGHT):
for x in range(GB_WIDTH):
idx = y * GB_WIDTH + x # Calcular índice 1D
color_index = frame_indices[idx] & 0x03
rgb_color = palette[color_index]
pixels[x, y] = rgb_color
Decisiones de Diseño
- Índices vs RGB: Se eligió índices de color para reducir memoria y permitir cambios de paleta sin re-renderizar. La conversión a RGB ocurre solo una vez en Python.
- Memoryview 1D: Aunque un array 2D sería más intuitivo, los memoryviews de Cython no soportan reshape. El cálculo manual del índice
[y * 160 + x]es trivial y no afecta el rendimiento. - Patrón de prueba: Se implementó un degradado diagonal simple para verificar que el framebuffer funciona antes de implementar el renderizado real de tiles. Esto permite validar toda la cadena de datos sin la complejidad adicional del decodificado de tiles.
Archivos Afectados
src/core/cpp/PPU.hpp- Cambio de framebuffer de uint32_t a uint8_tsrc/core/cpp/PPU.cpp- Implementación de render_scanline() simplificadosrc/core/cython/ppu.pxd- Actualización de firma de get_framebuffer_ptr()src/core/cython/ppu.pyx- Exposición de framebuffer como memoryview uint8_tsrc/gpu/renderer.py- Actualización de render_frame() para usar índices y aplicar paleta
Tests y Verificación
Compilación: La extensión C++ se compiló exitosamente sin errores (solo warnings menores de variables no usadas en métodos legacy).
Resultado esperado al ejecutar: Al ejecutar python main.py tu_rom.gbc, se debe ver:
- Una ventana de Pygame que se actualiza a 60 FPS
- Un patrón de degradado diagonal visible (no pantalla blanca)
- Los logs de consola muestran que LY cicla de 0 a 153 correctamente
Validación de módulo compilado C++: El framebuffer se expone correctamente como memoryview y Python puede leer los índices sin copias (Zero-Copy).
Próximo paso de validación: Una vez confirmado que el patrón de degradado se muestra correctamente, se reemplazará el código de prueba por el renderizado real de Background, Window y Sprites desde VRAM.
Fuentes Consultadas
- Pan Docs: Background Palette (BGP), Tile Data, 2bpp Format
- Pan Docs: LCD Timing, Scanline Rendering
- Cython Documentation: Memoryviews and Zero-Copy
Integridad Educativa
Lo que Entiendo Ahora
- Framebuffer con índices: Almacenar índices de color (0-3) en lugar de colores RGB completos reduce memoria y permite cambios de paleta dinámicos sin re-renderizar.
- Zero-Copy con Cython: Los memoryviews de Cython permiten que Python acceda directamente a la memoria C++ sin copias, esencial para alcanzar 60 FPS sin cuellos de botella.
- Separación de responsabilidades: C++ se encarga del cálculo pesado (renderizado de scanlines), Python se encarga de la presentación (aplicar paleta y mostrar en Pygame).
- Patrón de prueba: Implementar primero un patrón simple (degradado) permite validar toda la cadena de datos antes de añadir la complejidad del renderizado real.
Lo que Falta Confirmar
- Rendimiento real: Verificar que el acceso al framebuffer mediante memoryview no cause overhead significativo en el bucle de renderizado.
- Renderizado real: Una vez confirmado que el framebuffer funciona, implementar el renderizado real de Background, Window y Sprites desde VRAM.
- Sincronización: Asegurar que el renderizado de scanlines ocurra en el momento correcto del ciclo PPU (Mode 0 H-Blank).
Hipótesis y Suposiciones
Suposición principal: El patrón de degradado diagonal (ly_ + x) % 4 producirá un patrón visual distintivo que permitirá verificar que LY está avanzando correctamente y que el framebuffer se está escribiendo y mostrando. Si este patrón se muestra correctamente, toda la cadena de datos funciona y podemos proceder con el renderizado real.
Próximos Pasos
- [ ] Verificar que el patrón de degradado se muestra correctamente en la ventana
- [ ] Confirmar que LY cicla de 0 a 153 y que el framebuffer se actualiza a 60 FPS
- [ ] Reemplazar el código de prueba por el renderizado real de Background desde VRAM
- [ ] Implementar renderizado de Window y Sprites
- [ ] Optimizar el acceso al framebuffer si es necesario (profiling)