351. 将定时扫描线合成为不透明度掩码

从 240 个已完成的扫描线状态合成一个背景不透明度掩码。

351 / 356 · tests/chapter_13_scrolling/test_351_timed_scanlines_to_opaque_mask.py

待更新文件

emulator/rendering/ppu_background_renderer.py

参考资料

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

为什么需要这一步

定时帧缓冲区的行可以在同一帧内展现固定的状态区域和滚动方式不同的游戏区域。精灵优先级和 sprite-zero-hit 判定同样需要在这些确切的屏幕坐标上得到背景不透明度信息。如果 RGB 像素使用的是定时状态,而不透明度却使用单一的最终 t 快照,精灵就可能出现在本应遮挡它们的实心图块前面。

BackgroundOpaqueMask 是一个扁平的 256x240 布尔值列表

True:  the decoded background pattern pixel is nonzero
False: the decoded background pattern pixel is transparent

不透明度是图案信息,而不是 RGB 颜色测试。一个可见的图案像素可以使用黑色调色板颜色,而一个透明的图案像素则显示通用背景色。

掩码合成器必须精确镜像帧缓冲区的坐标选择方式:

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 256

在此水平里程碑中,源 Y 仍然是 screen_y。

为什么要缓存源掩码?视口 X 可能在每条扫描线上都发生变化,而不改变逻辑源对。

本地缓存在每次合成中最多构建每一对一次

$2000 key -> masks for ($2000, $2400)
$2800 key -> masks for ($2800, $2C00)

重要不变量

  • 输入恰好包含 240 个已完成状态
  • 输出恰好包含 256 * 240 个布尔条目
  • 状态索引、源行和目标行使用相同的 screen_y
  • 水平坐标在完整的 512 像素对范围内环绕
  • 每个逻辑对在一次合成中最多构建一次
  • 视口 X 的变化会复用已有的源对
  • 不透明度掩码代码从不调用帧缓冲区渲染
  • 帧缓冲区与不透明度掩码的坐标映射保持一致

常见误解

不要从已渲染的 RGB 值推导此掩码。精灵优先级取决于原始背景图案值是否为零,而这一点无法从其最终调色板颜色中可靠地还原。

测试策略

这些测试用合成掩码替换了以 PPU 为后端的掩码构建。每个布尔值都是逻辑基址、源 X 和源 Y 的确定性函数。这使得错误的对选择、错误的行索引以及水平环绕错误都可以被观察到,而无需涉及 CHR 解码、调色板或卡带镜像。

不在本步骤范围内

  • 在公共不透明度掩码适配器中启用此辅助函数
  • 启动阶段或不完整帧的回退选择
  • sprite-zero-hit 集成
  • 完整的垂直源行滚动

完整示例实现(与 _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 result

运行本课

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