349. Синхронизированные scanlines в framebuffer
Составить один framebuffer из 240 завершённых синхронизированных состояний scanline.
Урок 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Зачем нужен этот шаг
Завершённый кадр может содержать разную горизонтальную позицию viewport для каждой видимой строки. Один снимок 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 для этого горизонтального этапа. Полный выбор вертикальной исходной строки — это отдельная будущая работа.
Почему кэшировать исходные пары? Строки могут иметь разные значения viewport X при чтении одних и тех же двух nametable. Рендеринг обеих полных исходных nametable снова для каждой строки выполнил бы столько же, сколько 480 исходных рендеров. Локальный словарь вместо этого хранит каждую логическую пару один раз для этой композиции:
$2000 key -> rendered ($2000, $2400) pair
$2800 key -> rendered ($2800, $2C00) pairКэш намеренно локален. Данные nametable, pattern, attribute или palette могут измениться перед более поздним кадром, поэтому кэширование между кадрами потребует явных и подверженных ошибкам правил инвалидации.
Важные инварианты
- вход содержит ровно 240 завершённых состояний
- размеры выхода ровно 256x240
- индекс состояния, строка назначения и исходная строка — это один и тот же screen_y
- каждая строка назначения получает ровно 256 пикселей
- горизонтальный выбор переносится через 512-пиксельную логическую пару
- каждая логическая исходная пара отрендеривается максимум один раз за композицию
- изменения viewport X не инвалидируют и не дублируют кэшированную пару
- зеркалирование картриджа остаётся поведением PpuBus внутри исходного рендеринга
Распространённое заблуждение
Не вызывайте существующий полнокадровый горизонтальный композитор viewport один раз за строку. Это построит 240 временных framebuffer 256x240 для сохранения только одной строки из каждого. Эта вспомогательная функция записывает каждую строку назначения непосредственно в один результирующий framebuffer.
Стратегия тестирования
Эти тесты заменяют рендеринг nametable синтетическими исходными framebuffer, чьи кортежи RGB кодируют логическую базу, исходный X и исходный Y. Это изолирует механику композиции строк от декодирования pattern, выбора palette, памяти PPU и зеркалирования.
Вне области действия
- изменение публичного адаптера viewport
- поведение fallback для недоступного синхронизированного кадра
- композиция opacity-mask
- полная вертикальная прокрутка исходной строки
- постоянные кэши рендеринга
Полный пример реализации
# 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