351. De scanlines temporizadas a máscara opaca
Componer una máscara de opacidad de fondo a partir de 240 estados de scanline completados.
Lección 351 de 356 · tests/chapter_13_scrolling/test_351_timed_scanlines_to_opaque_mask.py
Archivo a actualizar
emulator/rendering/ppu_background_renderer.pyReferencias
https://www.nesdev.org/wiki/PPU_rendering
https://www.nesdev.org/wiki/PPU_scrollingPor qué existe este paso
Las filas de framebuffer temporizadas pueden mostrar un área de estado fija y un área de juego desplazada de forma distinta en un mismo fotograma. Las decisiones de prioridad de sprites y de sprite-zero-hit también necesitan la opacidad del fondo en esas mismas coordenadas de pantalla exactas. Si los píxeles RGB usan estados temporizados mientras que la opacidad usa una única instantánea final de t, los sprites pueden aparecer delante de tiles sólidos que deberían ocultarlos.
Un BackgroundOpaqueMask es una lista plana de 256x240 valores booleanos
True: the decoded background pattern pixel is nonzero
False: the decoded background pattern pixel is transparentLa opacidad es información de patrón, no una prueba de color RGB. Un píxel de patrón visible puede usar un color de paleta negro, mientras que un píxel de patrón transparente muestra el color de fondo universal.
El compositor de la máscara debe reflejar exactamente la selección de coordenadas del framebuffer:
state = completed_scanline_scroll_states[screen_y]
logical_x = (viewport_x + screen_x) % 512
logical X 0-255: read the left source mask
logical X 256-511: read the right source mask after subtracting 256Para este hito horizontal, la Y de origen sigue siendo screen_y.
¿Por qué cachear máscaras de origen? El viewport X puede cambiar en cada scanline sin que cambie el par de origen lógico.
La caché local construye cada par una sola vez por composición
$2000 key -> masks for ($2000, $2400)
$2800 key -> masks for ($2800, $2C00)Invariantes importantes
- la entrada contiene exactamente 240 estados completados
- la salida contiene exactamente 256 * 240 entradas booleanas
- el índice de estado, la fila de origen y la fila de destino usan el mismo screen_y
- las coordenadas horizontales dan la vuelta a lo largo del par completo de 512 píxeles
- cada par lógico se construye como máximo una vez por composición
- los cambios de viewport-X reutilizan el par de origen existente
- el código de la máscara de opacidad nunca llama al renderizado del framebuffer
- las correspondencias de coordenadas del framebuffer y de la máscara de opacidad permanecen idénticas
Concepto erróneo habitual
No derives esta máscara a partir de valores RGB ya renderizados. La prioridad de sprites depende de si el valor original del patrón de fondo era cero, algo que no puede reconstruirse de forma fiable a partir de su color de paleta final.
Estrategia de pruebas
Estas pruebas sustituyen la construcción de máscaras respaldada por la PPU por máscaras sintéticas. Cada valor booleano es una función determinista de la dirección base lógica, la X de origen y la Y de origen. Eso hace observables la selección incorrecta de pares, el indexado incorrecto de filas y los errores de ajuste horizontal sin involucrar la decodificación de CHR, las paletas ni el mirroring del cartucho.
Fuera de alcance
- activar este helper en el adaptador público de la máscara de opacidad
- selección de reserva para fotogramas de inicio o incompletos
- integración con sprite-zero-hit
- desplazamiento vertical completo de la fila de origen
Implementación de ejemplo completa (lógica muy similar a _timed_scanlines_to_framebuffer):
# emulator/rendering/ppu_background_renderer.py
# --- NEW BLOCK: COMPOSE COMPLETED TIMED OPACITY ROWS ---
def _timed_scanlines_to_opaque_mask(
ppu: PPU,
) -> BackgroundOpaqueMask:
states = ppu.completed_scanline_scroll_states
if len(states) != NAMETABLE_PIXEL_HEIGHT:
raise ValueError(
"Timed opacity mask requires exactly 240 scanline states"
)
result: BackgroundOpaqueMask = [False] * (
NAMETABLE_PIXEL_WIDTH * NAMETABLE_PIXEL_HEIGHT
)
pair_cache: dict[
int,
tuple[BackgroundOpaqueMask, BackgroundOpaqueMask],
] = {}
logical_width = NAMETABLE_PIXEL_WIDTH * 2
for screen_y, state in enumerate(states):
left_base, right_base = _scanline_horizontal_pair(state)
if left_base not in pair_cache:
pair_cache[left_base] = (
ppu_background_to_opaque_mask(
ppu,
base_nametable_addr=left_base,
),
ppu_background_to_opaque_mask(
ppu,
base_nametable_addr=right_base,
),
)
left, right = pair_cache[left_base]
viewport_x = _scanline_viewport_x(state)
destination_row = screen_y * NAMETABLE_PIXEL_WIDTH
for screen_x in range(NAMETABLE_PIXEL_WIDTH):
logical_x = (viewport_x + screen_x) % logical_width
if logical_x < NAMETABLE_PIXEL_WIDTH:
source = left
source_x = logical_x
else:
source = right
source_x = logical_x - NAMETABLE_PIXEL_WIDTH
destination_index = destination_row + screen_x
source_index = destination_row + source_x
result[destination_index] = source[source_index]
return resultEjecutar esta lección
uv run pytest tests/chapter_13_scrolling/test_351_timed_scanlines_to_opaque_mask.py -v