Выпущенная в 1983 году домашняя консоль Nintendo Entertainment System (NES) была дешёвой, но мощной машиной, достигшей феноменального успеха. При помощи блока обработки изображений (Picture Processing Unit, PPU) система могла создавать достаточно впечатляющую по тем временам графику, которая и сегодня в нужном контексте выглядит вполне неплохо. Самым важным аспектом была эффективность памяти — при создании графики приходилось обходиться как можно меньшим количеством байтов. Однако вместе с этим NES предоставила разработчикам мощные и простые в использовании функции, позволившие ей выделиться на фоне более старых домашних консолей. Поняв принципы создания графики NES, можно проникнуться техническим совершенством системы и осознать, насколько проще работать современным разработчикам игр.
Фоновая графика NES собиралась из четырёх отдельных компонентов, комбинация которых образовывала изображение, которое мы видим на экране. Каждый компонент отвечал за отдельный аспект; цвет, расположение, «сырая» пиксельная графика и т.д. Такая система может показаться излишне сложной и громоздкой, но в конечном итоге она намного эффективнее использовала память и позволяла создавать простые эффекты в малом объёме кода. Если вы хотите понимать графику NES, то ключевой информацией будут эти четыре компонента.
В этой статье подразумевается, что вы знакомы с компьютерной математикой, и в частности с тем, что 8 бит = 1 байт, а 8 бит могут обозначать 256 значений. Также необходимо понимание того, как работает шестнадцатеричная запись. Но даже без этих технических знаний статья может показаться интересной.
Краткий обзор
Выше представлено изображение из первой сцены Castlevania (1986 год): ворота, ведущие в замок, где будет происходить действие игры. Это изображение имеет размер 256×240 пикселей, и в нём использовано 10 разных цветов. Чтобы описать это изображение в памяти, мы должны воспользоваться преимуществом ограниченной цветовой палитры, и сэкономить место, храня только минимальный объём информации. Одним из наивных подходов является использование индексированной палитры, в которой каждый пиксель имеет объём 4 бита, то есть в байте помещаются 2 пикселя. Для этого потребуется 256*240/2 = 30720 байт, но как мы вскоре увидим, NES может справляться с этой задачей гораздо эффективнее.
Основными понятиями в теме графики NES являются тайлы и блоки[1]. Тайл (tile) — это область 8×8 пикселей, а блок — область 16×16 пикселей, и каждый из них привязан к сетке с таким же размером ячейки. После добавления этих сеток мы сможем увидеть структуру графики. Вот вход в замок с сеткой при двукратном увеличении.
В этой сетке светло-зелёным показаны блоки, а тёмно-зелёным — тайлы. Линейки вдоль осей имеют шестнадцатеричные значения, которые можно складывать для нахождения позиции; например, сердце в панели состояния находится в $15+$60 = $75, что в десятиричном виде равно 117. Каждый экран содержит 16×15 блоков (240) и 32×30 тайлов (960). Теперь давайте посмотрим, как описывается это изображение, и начнём с «сырой» пиксельной графики.
CHR
Структура CHR описывает «сырую» пиксельную графику без её цвета и позиции, и задаётся потайлово. Вся страница памяти содержит 256 тайлов CHR, и каждый тайл имеет глубину 2 бита. Вот графика сердца:
А вот как оно описывается в CHR[2]:
Такое описание занимает 2 бита на пиксель, то есть при размере 8×8 получается 8*8*2 = 128 бит = 16 байт. Тогда вся страница занимает 16*256 = 4096 байт. Вот все CHR, использованные в изображении из Castlevania.
Вспомним, что для заполнения изображения необходимо 960 тайлов, но CHR позволяет использовать только 256. Это значит, что большинство тайлов повторяется, в среднем по 3,75 раза, но чаще в основном используется лишь небольшое их число (например, пустой фон, одноцветные тайлы или повторяющиеся паттерны). В изображении из Castlevania используется много пустых тайлов, а также одноцветных синих. Чтобы увидеть, как назначаются тайлы, мы используем таблицы имён (nametables).
NAMETABLE
Таблица имён присваивает тайл CHR каждой позиции на экране, а всего их 960. Каждая позиция задаётся одним байтом, то есть вся таблица имён занимает до 960 байт. Тайлы назначаются в порядке слева направо, сверху вниз, и соответствуют вычисленной позиции, найденной сложением значений из показанных выше линеек. То есть позиция в верхнем левом углу равна $0, справа от неё — $1, а под ней — $20.
Значения в nametable зависят от порядка, в котором заполняется CHR. Вот один из вариантов[3]:
В этом случае сердце (в позиции $75) имеет значение $13.
Далее, чтобы добавить цвет, нам нужно выбрать палитру.
Палитра
У NES есть системная палитра из 64 цветов[4], и из неё мы выбираем палитры, которые будут использоваться в рендеринге. Каждая палитра содержит 3 уникальных цвета плюс общий цвет фона. Изображение имеет максимум 4 палитры, которые в сумме занимают 16 байт. Вот палитры для изображения из Castlevania:
Палитры нельзя использовать произвольно. На блок применяется только одна палитра. Именно из-за этой необходимости разделения каждой области 16×16 по цветовой палитре игры для NES имеют такой «блочный» вид. Мастерски выполненная графика, например, из заставки Castlevania, позволяет избежать этого благодаря смешиванию цветов на краях блоков, что скрывает наличие сетки.
Выбор палитры для каждого блока выполняется при помощи последнего компонента — атрибутов.
Атрибуты
Атрибуты занимают по 2 бита на каждый блок. Они определяют, какую из 4 палитр использовать. На этом изображении показано, какие палитры, заданные атрибутами, используют разные блоки[5]:
Как можно заметить, палитры разделены на секции, но это хитрым образом скрывается благодаря использованию одинаковых цветов в разных областях. Красный в средней части ворот сливается с окружающими его стенами,, а чёрный фон размывает линию между замком
и воротами.
При 2 битах на блок или 4 блоках на байт атрибуты изображения занимают всего 240/4=60 байт, но из-за способа их кодирования впустую тратятся ещё 4 байта, то есть в сумме получается 64 байта. Это значит, что всё изображение, включая CHR, nametable, палитры и атрибуты, занимает 4096+960+16+64 = 5136 байт — намного лучше, чем упомянутые выше 30720.
MAKECHR
Создавать эти четыре компонента для графики NES сложнее, чем при работе с обычными API битовых карт, но тут на помощь приходят инструменты. У разработчиков NES вероятно имелся некий тулчейн, но каким бы он ни был, история его не сохранила. Сегодня разработчики обычно сами пишут программы для преобразования графики в нужный NES формат.
Все изображения в этом посте созданы при помощи makechr — переписанного инструмента, который использовался при разработке Star Versus. Это инструмент командной строки, созданный для автоматизированных сборок и нацеленный на скорость, качественные сообщения об ошибках, портируемость и понятность. Также он создаёт интересные визуализации наподобие использованных в посте.
Справочные материалы
В основном знания о программировании для NES, и особенно о создании графики, я получил из следующих источников:
Примечания
[1] Терминология — в некоторых документах блоки называют «метатайлами» (meta-tiles), что лично мне кажется менее полезным.
[2] Кодирование CHR — 2 бита на пиксель не хранятся рядом друг с другом. Полное изображение сначала сохраняется только с младшими битами, а затем снова сохраняется только со старшими битами.
То есть сердце будет храниться вот так:
Каждая строка — это один байт. То есть 01100110 — это $66, 01111111 — $7f. В сумме байты сердца имеют такой вид:
$66 $7f $ff $ff $ff $7e $3c $18 $66 $5f $bf $bf $ff $7e $3c $18
[3] Nametable — в настоящей внутриигровой графике таблица имён используется иначе. Обычно буквы алфавита хранятся в памяти по соседству, в том числе и в Castlevania.
[4] Системная палитра — NES не использует RGB-палитру, и настоящие цвета, которая она рендерит, зависят от конкретного телевизора. В эмуляторах обычно используются совершенно другие RGB-палитры. Цвета в этой статье соответствуют прописанной в makechr палитре.
[5] Кодирование атрибутов — атрибуты хранятся в странном порядке. Они не идут слева направо, сверху вниз — область блоков 2×2 кодируется одним байтом, в форме буквы Z. Именно поэтому 4 байта тратятся впустую; нижняя строка занимает полных 8 байт.
Например, блок в $308 хранится с $30a, $348 и $34a. Их значения палитр равны 1, 2, 3 и 3, и хранятся в порядке от младшей позиции к старшей позиции, или 11 :: 11 :: 10 :: 01 = 11111001. Следовательно, байтовое значение этих атрибутов равно $f9.
Часть 2
В первой части мы рассказали о компонентах фоновой графики NES — CHR, nametable, палитрах и атрибутах. Но это только половина истории.
Начнём с того, что на самом деле существует две таблицы имён[6]. Каждая из них имеет собственные атрибуты для задания цвета, однако CHR у них одинаковые. Оборудование картриджа определяет их позиции: или рядом друг с другом, или одна над другой. Ниже показаны примеры двух разных видов расположений — игры Lode Runner (1984 год) и Bubble Bobble (1988 год).
Скроллинг
Чтобы использовать преимущества наличия двух таблиц имён, PPU поддерживает возможность скроллинга по пикселю за раз по осям X и Y. Он управляется регистром с отображением в памяти по адресу $2005: запись всего двух байт по этому адресу перемещает весь экран на нужное количество пикселей[7]. В момент выпуска NES это было основным преимуществом перед остальными домашними консолями, в которых для скроллинга часто приходилось перезаписывать видеопамять целиком. Такая простая в использовании схема привела к появлению большого количества платформеров и шутеров, и стала основной причиной такого большого успеха системы.
Для простой игры, поле которой составляет в ширину всего два экрана, например, Load Runner, достаточно было всего лишь заполнить обе таблицы имён и соответствующим образом менять скроллинг. Но в большинстве игр со скроллингом уровни имели произвольную ширину. Чтобы реализовать их, игра должна обновлять внеэкранную часть таблиц имён до того, как они появятся на экране. Значение скроллинга зациклено, но поскольку таблица имён постоянно обновляется, это создаёт иллюзию бесконечного размера.
Спрайты
Кроме скроллинга по таблицам имён, NES также имела и совершенно иной аспект графики: спрайты. В отличие от таблиц имён, которые должны быть выровнены в сетки, спрайты могли располагаться произвольно, поэтому могли использоваться для отображения персонажей игрока, препятствий, снарядов и любых объектов со сложным движением. Например, в показанной выше сцене из Mega Man (1987 год) для отображения персонажа игрока. очков и полоски энергии используются спрайты, что позволяет им вырываться из сетки таблиц имён при скроллинге экрана.
Спрайты имеют собственную страницу CHR [8] и набор из 4 палитр. Кроме того, они занимают 256-байтную страницу памяти. в которой перечислены позиция и внешний вид каждого спрайта (как оказывается, видеопамять NES в два с небольшим раза больше, чем говорилось в первой части статьи). Формат этих записей довольно необычен — в них содержится сначала позиция по Y, затем номер тайла, затем атрибут, затем позиция по X[9]. Так как каждая запись занимает 4 байта, существует жёсткое ограничение: на экране одновременно может быть не более 256 / 4 = 64 спрайтов.
Байты Y и X задают верхний левый пиксель отрисовываемого спрайта. Поэтому в правой части экрана спрайт может быть обрезан, но в левой части оставляет пустое пространство. Байт тайла похож на значение в таблице имён, только для данных тайлов спрайты используют собственные CHR. Байт атрибутов — это пакет бит, выполняющий три задачи: два бита отводится под палитру, два бита под отзеркаливание спрайта по горизонтали или вертикали, а один бит определяет, нужно ли рендерить спрайт под таблицами имён[10].
Ограничения
Современные системы позволяют работать со спрайтами любого произвольного размера, но на NES спрайт из-за ограничений CHR должен был иметь размер 8×8[11]. Более крупные объекты составляются из нескольких спрайтов, а программа должна сделать так, чтобы все отдельные части рендерились рядом друг с другом. Например, размер персонажа Megaman может достигать 10 спрайтов, что также позволяет использовать больше цветов, в частности для его белых глаз и оттенка кожи.
Основное ограничение, связанное с использованием спрайтов, заключается в том, что на одну растровую строку должно приходиться не более 8 спрайтов. Если в любой горизонтальной строке экрана появляется больше 8 спрайтов, то те, которые появились позднее, просто не будут отрендерены. В этом и заключается причина мерцания в играх с большим количеством спрайтов; программа меняет местами адреса спрайтов в памяти, чтобы каждый из них рендерился хотя бы иногда.
И наконец, скроллинг не влияет на спрайты: позиция спрайта на экране определяется его значениями Y и X вне зависимости от позиции скроллинга. Иногда это плюс, например, когда уровень движется относительно игрока или интерфейс остаётся в фиксированной позиции. Однако в других случаях это минус — подвижный объект нужно перемещать, а затем менять его позицию на величину изменения скроллинга.
Примечания
[6] Теоретически таблиц имён на самом деле четыре, но они зеркалируются таким образом, что только 2 из них содержат уникальную графику. При расположении рядом это называется вертикальным отзеркаливанием (vertical mirroring), а когда таблицы имён расположены одна над другой — горизонтальным отзеркаливанием (horizontal mirroring).
[7] Также существует регистр, выбирающий, с какой таблицы имён начинать рендеринг, то есть скроллинг на самом деле является 10-битным значением, или 9-битным, если учесть отзеркаливание.
[8] Это не всегда так. PPU можно сконфигурировать так, чтобы он использовал ту же страницу CHR для таблиц имён, что и для спрайтов.
[9] Возможно, такой порядок использован потому, что он соответствует данным, которые должен обрабатывать PPU для эффективного рендеринга.
[10] Этот бит используется для различных эффектов, например, чтобы перемещать Марио под белыми блоками в Super Mario Bros 3, или для рендеринга тумана поверх спрайтов в Castlevania 3.
[11] В PPU также есть опция для включения спрайтов 8×16, которая используется в играх наподобие Contra, где есть высокие персонажи. Тем не менее, все остальные ограничения остаются в силе.
Часть 3
В предыдущих частях мы рассказали о данных CHR, фонах на основе таблиц имён, спрайтах и скроллинге. И это практически всё, что простой картридж NES может делать без дополнительного оборудования. Но чтобы пойти дальше, нам нужно подробно объяснить, как работает рендеринг.
Рендеринг
Растровый рендеринг с паузой для vblank
Как и другие старые компьютеры, NES была рассчитана на работу с ЭЛТ-телевизорами. Они отрисовывают на экране строки развёртки, по одной за раз, слева направо, сверху вниз, при помощи электронной пушки, которая физически перемещается к точке экрана, в которой рисуются эти строки. После достижения нижнего угла наступает промежуток времени под названием «vertical blank» (или vblank): электронная пушка возвращается в верхний левый угол, чтобы подготовиться к отрисовке следующего кадра. Внутри NES блок PPU (Picture Processing Unit) выполняет растровый рендеринг автоматически, в каждом кадре, а работающий в ЦП код занимается всеми задачами, которые должна выполнять игра. Vblank даёт программе возможность заменить данные в памяти PPU, потому что иначе эти данные будут использованы для рендеринга. Чаще всего изменения в таблицу имён и палитры PPU вносятся во время этого небольшого окна.
Однако некоторые изменения в состоянии PPU можно выполнять во время рендеринга экрана. Они называются «растровыми эффектами». Самым распространённым действием, выполняемым во время отрисовки экрана, является назначение позиции скроллинга. Благодаря этому часть изображения остаётся статичной (например, интерфейс игры), а всё остальное продолжает прокручиваться. Для достижения этого эффекта необходимо точно подбирать время смены величины скроллинга, чтобы она произошла на нужной растровой строке. Существует множество методик реализации подобной синхронизации между кодом игры и PPU.
Разделение экрана
Уровень скроллится, а интерфейс в верхней части экрана остаётся неподвижным
Во-первых, у PPU есть встроенное оборудование, обрабатывающее спрайт в нулевой позиции памяти особым образом. При рендеринге этого спрайта, если один из его пикселей накладывается на видимую часть фона, устанавливается бит под названием «sprite0 flag». Код игры сначала может поместить этот спрайт туда где должно произойти разделение экрана, а затем ждать в цикле, проверяя значение флага sprite0. Поэтому когда будет выполнен выход из цикла, игра точно будет знать, какая растровая строка сейчас рендерится. Такая методика используется для реализации простого разделения экрана во многих играх для NES, в том числе и в Ninja Gaiden (1989 год), показанной выше[12]
Sprite0 находится в координатах Y $26, X $a0. Когда рендерится его нижняя строка пикселей, устанавливается флаг sprite0
В некоторых играх sprite0 flag комбинируется с другой методикой — predictably timed loop («цикл с прогнозируемым таймингом»): программа ждёт, пока отрендерится несколько дополнительных строк, чтобы разделить экран на большее количество частей. Например, такой приём используется во многих заставках Ninja Gaiden для создания драматических эффектов, например, волнуемого ветром поля или изображения замка вдалеке. Игра выполняет такие задачи, как воспроизведением музыки и ожидание ввода игрока, в начале рендеринга кадра, затем использует sprite0 для поиска первого разделения, а для всех остальных применяет timed loops.
Однако большинство игр не может позволить себе тратить время на ожидание в циклах, особенно в активных сценах, где время ЦП на вес золота. В таких случаях применяется установленное в картриджах специальное оборудование (называемое маппером, потому что оно использует собственное отображение в память (memory mapping)), способное получать уведомление о моменте рендеринга определённой растровой строки [13], что полностью избавляет от необходимости циклов ожидания. Код игры может выполнять любые свои задачи и в любой нужный момент времени, благодаря чему процессор используется оптимальнее. Большинство более современных игр для NES, в которых есть множество разделений экрана, используют мапперы таким образом.
Вот пример из Ninja Gaiden 2, который использует маппер для выполнения нескольких разделений и имитации параллаксного скроллинга, благодаря чему создаётся ощущение огромной скорости, несмотря на статичность самого уровня. Заметьте, что все отдельные движущиеся части занимают строго горизонтальные полосы; то есть ни один из слоёв фона не может накладываться на другой. Так происходит потому, что разделения на самом деле реализованы сменой скроллинга отдельных растровых строк.
Переключение банков
Мапперы могут выполнять и множество других функций, но самой распространённой из них является переключение банков. Это операция, при которой весь блок пространств адресов переназначается, чтобы указывать на другую часть памяти[14]. Переключение банков может выполняться с кодом программы (что позволяет создавать в играх много уровней и музыки), а также с данными CHR, благодаря чему можно мгновенно заменять тайлы, на которые ссылаются таблицы имён или спрайты. Если использовать переключение банков между кадрами, то можно за раз анимировать весь фон. Но при использовании в качестве растрового эффекта это позволяет рисовать в разных частях экрана совершенно различную графику. В играх серии Ninja Gaiden такой подход применяется во время игрового процесса для рендеринга интерфейса отдельно от уровня, а также во время заставок, что позволяет хранить текст и визуальные сцены в разных банках CHR.
Анимированный фон, созданный при помощи переключения банков
Повторение на фоне, каждая часть таблицы имён соответствует одинаковому паттерну тайлов
Верхняя половина заставки использует один банк CHR. Для рендеринга глаза используются спрайты, что обеспечивает большее количество цветов
В нижней части используется другой банк CHR. При переключении банков также сбрасывается значение скроллинга
Переключение банков также можно использовать для параллаксного скроллинга, в ограниченном (но всё же впечатляющем) виде. Если в сцене есть часть фона, составленная из короткого повторяющегося паттерна, то этот одинаковый паттерн может содержаться в нескольких банках со смещением на разную величину. Тогда этот паттерн можно скроллить на определённое значение, переключаясь на банк с соответствующим смещением. Такой приём можно использовать для параллаксного скроллинга даже с накладывающимся поверх фоном благодаря наличию тайлов, на которые не влияет переключение памяти[15]. Недостаток такого метода заключается в том, что в сумме во всех банках нужно занять много пространства CHR.
Metal Storm (1991 год) использует переключение банков для послойного скроллинга
Повторение таблицы имён позволяет создавать этот эффект
CHR с переключением банков — это очень мощный инструмент, но он имеет свои ограничения. Хотя он полезен для анимирования всего экрана, этот приём не очень подходит для замены только небольшой части экрана; для этого также требуются изменения таблиц имён. Кроме того, объём CHR в картридже ограничен, и чтобы переключиться на данные, они должны сначала существовать. Наконец, за исключением растровых эффектов на основе скроллинга, в игре всегда действует строгая сетка таблиц имён, что ограничивает динамический диапазон графических эффектов.
Другие примеры
Игра Vice: Project Doom (1991 год) создаёт этот эффект пламени, многократно задавая позицию скроллинга в каждой растровой строке. Персонаж на переднем плане создан из спрайтов, на которые не влияет скроллинг.
Sword Master (1990 год) использует переключение банков для выполнения скроллинга гор вдалеке, а также при разделении экрана для интерфейса и травы на переднем плане.
Благодарности
Я бы не смог сгенерировать всю эту графику для статьи без мощных функций отладки, предоставляемых эмулятором FCEUX. Кроме того, полезным источником информации о работе sprite0 стала вики сайта NesDev:
Примечания
[12] На самом деле, ситуация с Ninja Gaiden немного сложнее. В игре используются спрайты 8×16 sprites — специальный режим, предоставляемый PPU, который рендерит спрайты как вертикально наложенные пары. То есть sprite0 полностью прозрачен, а sprite1 имеет строку пикселей в самом низу. Также он задаёт z-слой этих спрайтов, чтобы они рендерились за чернотой интерфейса, делающей всё невидимым.
[13] Реализовано это довольно хитро. Код игры записывает нужную растровую строку в адресное пространство маппера. Затем маппер перехватывает запросы доступа PPU к памяти, подсчитывая, когда рендерится новая растровая строка. При достижении нужной растровой строки он генерирует программное прерывание (IRQ), во время которого выполняется код игры, делая то, что нужно во время этой конкретной растровой строки.
[14] Переключение выполняется отображением в память оборудования, перехватом операций доступа к памяти и переопределением физического места, из которого получаются данные. Результат получается мгновенно, но имеет крупную дробность, из-за чего интервалы адресов меняются по 4 КБ или 8 КБ.
[15] Единственный способ переключения банков CHR без воздействия на каждый тайл заключается в следующем: или дублировать данные тайлов между банками, или иметь маппер с меньшей зернистостью. При таком маппере можно переключить меньшую часть банка, например, только 1 КБ за раз, а всё прочее останется неизменным.
Источник