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 также требуют непрозрачности фона в тех же самых экранных координатах. Если RGB-пиксели используют тайминговые состояния, а непрозрачность — один финальный снимок t, спрайты могут оказаться перед сплошными тайлами, которые должны их перекрывать.
`BackgroundOpaqueMask` — это плоский список из 256×240 булевых значений
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.
Зачем кэшировать исходные маски? Viewport X может меняться на каждой строке развёртки, не изменяя логическую исходную пару.
Локальный кэш строит каждую пару один раз за композицию
$2000 key -> masks for ($2000, $2400)
$2800 key -> masks for ($2800, $2C00)Важные инварианты
- вход содержит ровно 240 завершённых состояний
- выход содержит ровно 256 * 240 булевых записей
- индекс состояния, исходная строка и строка назначения используют один и тот же screen_y
- горизонтальные координаты оборачиваются по всей 512-пиксельной паре
- каждая логическая пара строится не более одного раза за композицию
- изменения viewport-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