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