La salida HDMI en plataformas FPGA es una forma poderosa de generar gráficos en tiempo real directamente desde hardware personalizado. En este artículo exploraremos cómo implementar salida HDMI utilizando el framework LiteX en la Tang Nano 9K, una FPGA de bajo costo y sorprendente versatilidad. Ya he escrito una entrada sobre cómo inicializar LiteX y crear un SoC básico en la entrada anterior.

🧱 Arquitectura HDMI en LiteX

Por defecto, el archivo tangnano9k.py de LiteX incluye un módulo de salida HDMI que simplemente muestra una señal de prueba con barras de color verticales. Esta se activa agregando la opción --with-video-terminal al comando de construcción del SoC.

También existe una función (comentada por defecto) que permite enviar la salida de UART a video, mostrando un terminal directamente en HDMI. Sin embargo, estas funciones consumen una cantidad considerable de recursos lógicos, por lo que en este artículo nos enfocaremos en construir nuestros propios módulos gráficos más prácticos y personalizados.

Componentes clave

Si queremos generar gráficos personalizados, debemos entender cómo funciona la arquitectura de salida HDMI en LiteX. El flujo de datos sigue esta estructura:

  • VideoTimingGenerator: genera señales de sincronización horizontal (hsync), vertical (vsync) y de habilitación (de).
  • VideoPattern (módulo personalizado): genera los valores RGB de cada píxel en base a su posición. 1
  • VideoGowinHDMIPHY: módulo físico que convierte la señal digital a HDMI compatible.

🖼️ Dibujando gráficos

LiteX nos permite definir módulos personalizados que generan gráficos en hardware. A continuación, presento algunos de los que he implementado:

🧱 TilemapRenderer: escenarios estilo videojuego retro

Este módulo permite renderizar una cuadrícula de tiles (por ejemplo, de 16×16 píxeles) sobre toda la pantalla:

  • Usa una ROM con los datos de píxel de cada tile.
  • Utiliza un mapa de tiles (tilemap) para saber qué tile mostrar en cada celda.
  • Calcula la dirección del píxel correspondiente dentro del tile.
  • Lee los valores RGB desde memorias independientes para cada canal.

Ideal para construir escenarios tipo Zelda, Pokémon o Metroidvania.

TilemapRenderer

🔵 BarsRenderer: render de tiles como franjas verticales

Este módulo es perfecto para testear visualmente todos los tiles de la ROM sin necesidad de un tilemap.

  • Divide la pantalla horizontalmente en franjas.
  • Cada franja representa un tile diferente del tileset.
  • El patrón de cada tile se repite verticalmente a lo largo de su franja.

Fragmento clave del código:

bar_idx = Signal(max=stripes_count)
expr = 0
for i in range(1, stripes_count):
    expr = Mux(h >= i * stripe_width, i, expr)
self.comb += bar_idx.eq(expr)

Este bloque implementa un Mux encadenado para determinar en qué franja (y por tanto, qué tile) se encuentra el píxel actual.

📌 Muy útil para:

  • Verificar visualmente todos los tiles cargados.
  • Crear efectos tipo demo scene.
  • Renderizado eficiente sin FSMs ni sincronización extra.

BarsRenderer

⚙️ BarsC: franjas controladas por software

Una versión avanzada de BarsRenderer que permite controlar la posición de cada franja desde la CPU usando CSRs (CSRStorage):

  • Cada franja tiene su propio registro que define su inicio horizontal.
  • Se puede modificar en tiempo real desde software (por UART, consola o lógica C).
  • Ideal para crear interfaces dinámicas o animaciones programables.
BarsC
self.comb += sprite_visible.eq(
    (hcount >= sprite_x) & (hcount < sprite_x + SPRITE_W) &
    (vcount >= sprite_y) & (vcount < sprite_y + SPRITE_H)
)

Perfecto para testear movimiento, colisiones básicas y visibilidad por hardware.

🛠️ Recomendaciones

  • Cargar tilemaps en BRAM local y no accederlos en tiempo real desde Wishbone.
  • Para cargas grandes, usar LiteDRAMDMAReader en modo burst.
  • Separar UART de consola vs. UART de bridge: Usa uart_name="crossover" junto con add_uartbone() para evitar interferencias.

🧪 Ejemplo de conexión

self.videophy = VideoGowinHDMIPHY(platform.request("hdmi"), clock_domain="hdmi")
self.submodules.vtg = VideoTimingGenerator(default_video_timings="640x480@75Hz")

self.submodules.video_pattern = MovingSpritePattern(hres=640, vres=480)

self.comb += [
    self.vtg.source.connect(self.video_pattern.vtg_sink),
    self.video_pattern.source.connect(self.videophy.sink)
]

Este fragmento muestra cómo conectar cualquier patrón de video al pipeline HDMI. Solo hace falta que el patrón implemente las interfaces vtg_sink y source.

📆 Archivos auxiliares

  • logo.mem: archivo con los datos RGB del sprite o tiles.
  • patterns.py: contiene la definición de los módulos mencionados arriba.

📊 Rendimiento

La Tang Nano 9K puede manejar resoluciones de 640×480 a 75 Hz sin problemas. Para resoluciones superiores:

  • Optimiza la lógica combinacional.
  • Reduce la profundidad de FSMs o fragmenta procesos.
  • Usa memoria externa si es necesario (HyperRAM + DMA).

🧹 Recursos y enlaces

📌 Conclusión

La Tang Nano 9K demuestra que incluso una FPGA económica puede renderizar gráficos HDMI en tiempo real mediante lógica personalizada. Gracias a LiteX, podemos diseñar SoCs que incluyan video, memoria, CPU y periféricos, todo en un mismo chip, y adaptarlos completamente a nuestras necesidades.



  1. El nombre VideoPattern es un término genérico. En este artículo veremos varios módulos personalizados como TilemapRenderer, BarsRenderer, entre otros. ↩︎