349. 由定时扫描线合成帧缓冲

用 240 条已完成的定时扫描线状态合成一个帧缓冲。

349 / 356 · tests/chapter_13_scrolling/test_349_timed_scanlines_to_framebuffer.py

要更新的文件

emulator/rendering/ppu_background_renderer.py

参考资料

https://www.nesdev.org/wiki/PPU_rendering
https://www.nesdev.org/wiki/PPU_scrolling

为什么需要这一步

在已完成的帧中,每个可见行都可能包含不同的水平视口位置。单个帧末的 t 快照无法同时表示固定的状态栏区域和移动的游戏区域,因此高层渲染器必须用与每行匹配的 BackgroundScanlineState 来合成每一行。

对于每个目标行

state = completed_scanline_scroll_states[screen_y]
left_base, right_base = logical pair selected by state
viewport_x = horizontal pixel position decoded from state

对于每个目标像素

logical_x = (viewport_x + screen_x) % 512

logical X 0-255:   read the left source framebuffer
logical X 256-511: read the right source framebuffer after subtracting 256

在这个水平里程碑中,源行仍然是 screen_y。完整的垂直源行选择属于另外的后续工作。

为什么要缓存源对?各行可能有着不同的视口X值,却读取相同的两个命名表。如果为每一行都重新渲染两个完整的源命名表,最多会执行 480 次源渲染。改为使用一个局部字典,在本次合成中把每个逻辑对只存储一次:

$2000 key -> rendered ($2000, $2400) pair
$2800 key -> rendered ($2800, $2C00) pair

该缓存有意设计为局部缓存。命名表、图案、属性或调色板数据可能在后续帧之前发生变化,因此跨帧缓存将需要显式且容易出错的失效规则。

重要不变量

  • 输入恰好包含 240 个已完成的状态
  • 输出尺寸恰好为 256x240
  • 状态索引、目标行和源行是同一个 screen_y
  • 每个目标行恰好接收 256 个像素
  • 水平选择在 512 像素的逻辑对上回绕
  • 每个逻辑源对在每次合成中至多渲染一次
  • 视口X的变化不会使已缓存的对失效或重复
  • 在源渲染内部,卡带镜像仍属于 PpuBus 的行为

常见误解

不要对每一行调用一次现有的全帧水平视口合成器。那会构造 240 个临时的 256x240 帧缓冲,却只保留每个帧缓冲中的一行。该辅助函数把每个目标行直接写入一个结果帧缓冲。

测试策略

这些测试用合成的源帧缓冲替代命名表渲染,其 RGB 元组编码了逻辑基址、源X和源Y。这样可以把行合成机制与图案解码、调色板选择、PPU 内存和镜像隔离开来。

超出范围

  • 更改公共视口适配器
  • 定时帧不可用时的备用行为
  • 不透明度掩码合成
  • 完整的垂直源行滚动
  • 持久化渲染缓存

完整示例实现

# emulator/rendering/ppu_background_renderer.py

# --- UPDATED LINES: IMPORT FRAMEBUFFER DIMENSIONS ---
from emulator.rendering.background_viewport import (
    NAMETABLE_PIXEL_HEIGHT,
    NAMETABLE_PIXEL_WIDTH,
    ...
)

# --- NEW BLOCK: COMPOSE COMPLETED TIMED SCANLINES ---
def _timed_scanlines_to_framebuffer(ppu: PPU) -> Framebuffer:
    states = ppu.completed_scanline_scroll_states

    if len(states) != NAMETABLE_PIXEL_HEIGHT:
        raise ValueError(
            "Timed framebuffer requires exactly 240 scanline states"
        )

    result = Framebuffer(
        width=NAMETABLE_PIXEL_WIDTH,
        height=NAMETABLE_PIXEL_HEIGHT,
    )
    pair_cache: dict[int, tuple[Framebuffer, Framebuffer]] = {}
    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_framebuffer(
                    ppu,
                    base_nametable_addr=left_base,
                ),
                ppu_background_to_framebuffer(
                    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.pixels[destination_index] = source.pixels[source_index]

    return result

运行本课

uv run pytest tests/chapter_13_scrolling/test_349_timed_scanlines_to_framebuffer.py -v