⚠️ 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.

PPU Fase B - Renderizado Scanline y Framebuffer

Fecha: 2025-12-19 Step ID: 0112 Estado: Completado

Resumen

Se implementó el renderizado línea a línea (scanline rendering) de la PPU en C++, añadiendo la capacidad de generar píxeles reales para Background y Window. El framebuffer se expone como un memoryview de NumPy para transferencia Zero-Copy a Python/Pygame, lo que permite alcanzar rendimientos de miles de FPS potenciales en lugar de los 30 FPS limitados de la implementación Python pura. Esta es la Fase B de la migración de PPU, que transforma la unidad de timing en una unidad de procesamiento de píxeles completa.

Concepto de Hardware

En la Game Boy, la PPU renderiza la pantalla línea por línea durante el modo 3 (Pixel Transfer). En lugar de dibujar toda la pantalla al final de un frame, la PPU dibuja cada línea de 160 píxeles cuando entra en H-Blank (Mode 0), justo después de completar el Mode 3.

Scanline Rendering

Scanline Rendering es la técnica de renderizar una línea completa de píxeles de una vez. En la Game Boy, esto ocurre durante el período H-Blank, cuando la PPU ya tiene toda la información necesaria para esa línea. Este enfoque es crítico para el rendimiento, ya que permite procesar solo 160 píxeles por línea en lugar de procesar toda la pantalla (23,040 píxeles) al final del frame.

Formato de Tiles 2bpp

Los tiles de la Game Boy se almacenan en formato 2bpp (2 bits por píxel), lo que permite 4 colores por tile. Cada tile es un bloque de 8x8 píxeles que ocupa 16 bytes en VRAM:

  • 2 bytes por línea (una línea = 8 píxeles)
  • Byte 1: Bits bajos de cada píxel (bit 7 = píxel 0, bit 6 = píxel 1, ..., bit 0 = píxel 7)
  • Byte 2: Bits altos de cada píxel (mismo orden)
  • Color del píxel = (bit_alto << 1) | bit_bajo (valores 0-3)

Background y Window

La pantalla se compone de dos capas principales:

  • Background: Capa base que puede desplazarse usando SCX/SCY (scroll X/Y).
  • Window: Capa opaca que se dibuja encima del Background pero debajo de los Sprites. Se usa para HUDs y menús fijos.

Ambas capas usan el mismo formato de tiles y la misma paleta BGP (Background Palette), pero pueden usar tilemaps diferentes según los bits de LCDC.

Framebuffer y Zero-Copy

El framebuffer es un array plano de 160 * 144 = 23,040 píxeles en formato ARGB32 (32 bits por píxel). Al exponerlo como un memoryview de Cython, Python puede acceder directamente a la memoria C++ sin copiar datos. Esto permite usar pygame.surfarray.blit_array() para transferir el framebuffer completo a la GPU en una sola operación de bloque, resultando en un rendimiento de transferencia extremadamente alto.

Fuente: Pan Docs - Background, Window, Tile Data, 2bpp Format, LCD Control Register

Implementación

Se añadió el framebuffer y los métodos de renderizado a la clase PPU en C++. El framebuffer se inicializa a blanco y se actualiza línea por línea cuando la PPU entra en H-Blank después de completar el Mode 3 (Pixel Transfer).

Componentes creados/modificados

  • PPU.hpp: Añadido framebuffer_ (std::vector<uint32_t>), constantes de VRAM/tilemaps, métodos render_scanline(), render_bg(), render_window(), decode_tile_line(), get_framebuffer_ptr().
  • PPU.cpp: Implementación completa del renderizado scanline con decodificación 2bpp, scroll, paletas, y optimización de caché de tiles.
  • ppu.pxd: Añadido get_framebuffer_ptr() y uint32_t al import.
  • ppu.pyx: Añadida propiedad framebuffer que expone el framebuffer como memoryview para Zero-Copy.
  • tests/test_core_ppu_rendering.py: Suite de 4 tests para validar renderizado de Background, scroll, Window, y framebuffer memoryview.

Decisiones de diseño

Renderizado en H-Blank: El renderizado se ejecuta cuando la PPU entra en Mode 0 (H-Blank) después de completar Mode 3. Se usa un flag scanline_rendered_ para evitar renderizar múltiples veces la misma línea si se hacen múltiples llamadas a step() durante el mismo H-Blank.

Renderizado píxel por píxel: Aunque menos eficiente que renderizar por tiles, renderizamos píxel por píxel para manejar correctamente el scroll sub-píxel (cuando SCX no es múltiplo de 8). Se añadió optimización de caché para evitar decodificar el mismo tile múltiples veces dentro de la misma línea.

Paleta de grises fija: Se usa una paleta de 4 tonos de gris (Blanco, Gris claro, Gris oscuro, Negro) correspondiente a los valores de BGP. Esto es suficiente para Game Boy original (DMG). El soporte de color (CGB) se implementará en una fase posterior.

Memoryview Zero-Copy: El framebuffer se expone como un memoryview de uint32_t, permitiendo a Python acceder directamente a la memoria C++ sin copias. Esto es crítico para el rendimiento, ya que permite transferir 23,040 píxeles (92 KB) a la GPU en una sola operación.

Archivos Afectados

  • src/core/cpp/PPU.hpp - Añadido framebuffer y métodos de renderizado
  • src/core/cpp/PPU.cpp - Implementación de render_bg(), render_window(), decode_tile_line()
  • src/core/cython/ppu.pxd - Añadido get_framebuffer_ptr()
  • src/core/cython/ppu.pyx - Añadida propiedad framebuffer (memoryview)
  • tests/test_core_ppu_rendering.py - Suite completa de tests de renderizado

Tests y Verificación

Se creó una suite completa de tests unitarios que valida el renderizado línea a línea:

  • Tests unitarios: pytest con 4 tests pasando

Test: Renderizado de Background simple

def test_bg_rendering_simple_tile(self):
    # Escribe un tile todo negro en VRAM 0x8000
    # Configura tilemap para usar tile ID 0
    # Avanza PPU hasta H-Blank
    # Verifica que el primer píxel es negro (0xFF000000)

Resultado: ✅ PASSED

Test: Scroll del Background

def test_bg_rendering_scroll(self):
    # Crea dos tiles (negro y blanco)
    # Configura SCX=8 para desplazar el fondo
    # Verifica que el primer píxel visible es del tile correcto

Resultado: ✅ PASSED

Test: Renderizado de Window

def test_window_rendering(self):
    # Configura Background con tile negro
    # Configura Window con tile blanco en (0,0)
    # Verifica que la Window sobrescribe el Background

Resultado: ✅ PASSED

Test: Framebuffer como Memoryview

def test_framebuffer_memoryview(self):
    # Verifica que el framebuffer se puede convertir a numpy array
    # sin copiar datos (Zero-Copy)

Resultado: ✅ PASSED

Comando ejecutado:

pytest tests/test_core_ppu_rendering.py -v

Resultado: 4 passed in 0.11s

Validación: Módulo compilado C++ validado con tests nativos.

Fuentes Consultadas

Integridad Educativa

Lo que Entiendo Ahora

  • Scanline Rendering: La técnica de renderizar línea por línea en lugar de frame por frame permite optimizar el rendimiento, procesando solo 160 píxeles a la vez en lugar de 23,040.
  • Formato 2bpp: Los tiles se almacenan con 2 bits por píxel, donde cada línea de 8 píxeles ocupa 2 bytes consecutivos (uno para bits bajos, otro para bits altos).
  • Zero-Copy Transfer: Al exponer el framebuffer C++ como memoryview, Python puede acceder directamente a la memoria sin copiar datos, permitiendo transferencias extremadamente rápidas a la GPU.
  • Scroll sub-píxel: El scroll (SCX/SCY) puede desplazar el fondo en incrementos de 1 píxel, no solo de 8 píxeles (tiles completos), lo que requiere renderizar píxel por píxel para manejar correctamente el desplazamiento.

Lo que Falta Confirmar

  • Renderizado de Sprites: Los sprites (OBJ) se renderizarán en una fase posterior, aplicando las mismas técnicas de scanline rendering pero con lógica de prioridad y transparencia.
  • Optimización de caché de tiles: La optimización actual es básica. Se podría mejorar cacheando tiles completos en lugar de solo líneas, o incluso pre-decodificando tiles cuando se escriben en VRAM.
  • Soporte CGB: La paleta actual es de grises. El soporte de color (CGB) requerirá paletas RGB555 y bancos de VRAM adicionales.

Hipótesis y Suposiciones

Se asume que renderizar en H-Blank es el momento correcto, ya que es cuando la PPU ha completado el Mode 3 (Pixel Transfer) y tiene toda la información necesaria para la línea. Esto coincide con el comportamiento real del hardware, donde la PPU dibuja durante el período de pixel transfer y "descansa" durante H-Blank.

Próximos Pasos

  • [ ] Renderizado de Sprites (OBJ) - Prioridad, transparencia, y atributos especiales
  • [ ] Optimización de caché de tiles más agresiva
  • [ ] Integración del framebuffer con Pygame para visualización real
  • [ ] Soporte de paletas CGB (RGB555) para Game Boy Color
  • [ ] Validación con ROMs reales para verificar renderizado correcto