Оглавление
Оглавление
Часть I: подготовка
- Введение
- 1. Краткая история NES
- 2. Фундаментальные понятия
- 3. Приступаем к разработке
- 4. Оборудование NES
- 5. Знакомство с языком ассемблера 6502
- 6. Заголовки и векторы прерываний
- 7. Зачем вообще этим заниматься?
- 8. Рефакторинг
Часть II: графика
10. Спрайтовая графика
Содержание:
- Спрайтовые данные
- Object Attribute Memory (OAM)
- Работа с NES Lightbox
- Отображение спрайтов в игре
Итак, мы рассмотрели устройство PPU на высоком уровне, и теперь готовы изучить подробности отрисовки спрайтов. К концу этой главы вы будете знать, как создавать тайлы спрайтов и отрисовывать их на экране.
Спрайтовые данные
Для хранения информации спрайтов внутри PPU используется 256 байтов памяти. Описание спрайта занимает четыре байта, отсюда и взялось ограничение на одновременное отображение не более чем 64 спрайтов. Эти четыре байта данных кодируют следующую информацию:
- Позицию по Y верхнего левого угла спрайта (0-255);
- Номер тайла из таблицы паттернов спрайтов (0-255);
- Особые флаги атрибутов (отзеркаливание по горизонтали/вертикали, номер палитры и т. д.);
- Позиция по X верхнего левого угла спрайта (0-255).
Восемь бит третьего байта (флаги атрибутов) в компактном формате хранят множество элементов информации. Восемь битов управляют следующими свойствами спрайта:
№ бита | Предназначение |
---|---|
7 | Отзеркаливает спрайт по вертикали (если «1») |
6 | Отзеркаливает спрайт по горизонтали (если «1») |
5 | Приоритет спрайта (ниже фона, если «1») |
4-2 | Не используются |
1-0 | Палитра для спрайта |
Object Attribute Memory (OAM)
Область в памяти PPU, где хранятся спрайтовые данные, называется «Object Attribute Memory», или «OAM». Эта область памяти отличается тем, что существуют особые MMIO-адреса, которые процессор может использовать для одновременного обновления всего содержимого OAM с высокой скоростью. Возможность быстрого обновления OAM необходима для игр с быстрым геймплеем, чтобы все 64 спрайта могли двигаться плавно в каждом кадре.
Для использования этого высокоскоростного копирования процессор должен подготовить все эти спрайтовые данные. разместив их в непрерывной странице памяти. (Страница (page) — это блок из 256 байт.) Обычно этот «спрайтовый буфер» размещается в адресах
$0200
-$02ff
памяти процессора.[Здесь стоит сказать несколько слов про образ распределения памяти. Во-первых, указано, что RAM находится по адресам
$0300
-$0800
. В реальности весь интервал $0000
-$0800
является RAM (2 КБ); область $0300
-$0800
— это часть RAM, обычно не распределённая для определённой цели. Область $0800
— $2000
в схеме распределения памяти — это пустое пространство, операции записи в эту область памяти сбрасываются, а операции считывания из этого интервала имеют неопределённое поведение. Также стоит обратить внимание на область $8000
-$FFFF
, относящуюся к чипу PRG-ROM на картридже, в том числе к ней относятся и шесть байтов, задающих местоположение обработчиков прерываний.]Стандартная схема памяти процессора NES. Хотя некоторые аспекты можно менять, например, местоположение стека или спрайтового буфера, такая схема является самой распространённой.
В спрайтовом буфере (и в самой OAM) каждые четыре байта задают один спрайт. То есть первые восемь байтов спрайтового буфера выглядят вот так:
Адрес памяти | Предназначение |
---|---|
$0200 | Позиция по Y спрайта 0 (первый спрайт) |
$0201 | Номер тайла спрайта 0 |
$0202 | Флаги атрибутов для спрайта 0 |
$0203 | Позиция по X спрайта 0 |
$0204 | Позиция по Y спрайта 1 (второй спрайт) |
$0205 | Номер тайла спрайта 1 |
$0206 | Флаги атрибутов для спрайта 1 |
$0207 | Позиция по X спрайта 1 |
$2003
: OAMADDR и $4014
: OAMDMA
Подготовив все спрайтовые данные, которые нам нужно передать, мы используем в коде два новых MMIO-адреса для отправки всех спрайтовых данных в PPU.
OAMADDR
используется для задания адреса в OAM, в который нужно выполнить запись; для всех наших проектов (и для большинства коммерческих игр) он всегда будет равен $00
— началу блока OAM. OAMDMA
инициирует передачу всей страницы памяти в OAM. Запись старшего байта адреса памяти вOAMDMA
передаст эту страницу.Хотя может показаться, что запись в OAM необходимо выполнять только когда что-то изменилось, на самом деле участок OAM памяти PPU реализован на основе «динамической ОЗУ», то есть он очень нестабилен и его нужно постоянно обновлять, даже если ничего не изменилось. На практике это означает, что нам нужно выполнять запись в OAM в каждом кадре графики (60 раз в секунду).
Немаскируемые прерывания (NMI)
К счастью, в NES есть простая в использовании система для выполнения кода в каждом кадре: немаскируемое прерывание (Non-Maskable Interrupt, NMI). NMI — один из трёх векторов прерываний, с которыми умеет работать 6502. Событие NMI срабатывает каждый раз, когда PPU переходит в «vblank», что происходит в конце каждого кадра графики. «Vblank» расшифровывается как «vertical blank»; существует похожий «Hblank», или «horizontal blank». Чтобы понять, что означают эти термины, нам нужно узнать, как работали телевизоры и мониторы с ЭЛТ того времени.
До роста популярности в середине 2000-х новых технологий наподобие плазмы и ЖК-дисплеев, в большинстве телевизоров использовалась технология под названием «электронно-лучевая трубка» (cathode-ray tube, CRT). ЭЛТ при помощи «электронной пушки» выстреливала пучок электронов, ударявшихся об внутреннюю часть люминесцентного экрана, поглощающего энергию электронов и преобразующего её в свет. Электронная пушка непрерывно проходит по экрану горизонтальными линиями сверху донизу, начиная с верхнего левого угла и заканчивая нижним правым, а потом начинает снова. Скорость таких проходов определяется видеосигналом, который должен отображать телевизор. Стандарт NTSC, который использовался в США и Японии, работал с частотой 60 кадров в секунду, а конкурирующий с ним стандарт PAL, использовавшийся в Европе, работал с частотой 50 кадров в секунду.
Когда электронная пушка возвращается, или в начало новой горизонтальной линии с левого края, или из нижнего правого угла в верхний левый, чтобы начать новый кадр, поток электронов временно прекращается, чтобы не создавать непреднамеренные графические проблемы. Такие «blanking periods» — единственные моменты, когда изображение на экране не меняется. «Hblank» происходит в конце каждой горизонтальной строки и он чрезвычайно короткий, всего 10,9 микросекунд (для формата NTSC). «Vblank» по сравнению с ним гораздо длиннее, но всё равно очень короткий: примерно 1250 микросекунд, или 0,00125 секунды.
Так как Vblank — единственный момент, когда на экран ничего не выводится, а Hblank слишком короток для выполнения существенного объёма работы, обычно большинство обновлений графики выполняется во время Vblank, т. е. в рамках обработчика NMI. На данном этапе обработчик NMI в нашем тестовом проекте выглядит так:
Как говорилось ранее,
RTI
— это опкод возврата из прерывания (Return from Interrupt). Давайте изменим обработчик NMI, чтобы при каждом выполнении он копировал память из диапазона $0200
-$02ff
в OAM:Теперь вкратце напомним команды языка ассемблера, которые мы изучили в Главе 5. В строке 2 мы загружаем в накопитель литеральное нулевое значение.
[На случай, если вы забыли: число, передаваемое команде наподобие
LDA
, по умолчанию является адресом памяти. LDA $00
означает «загрузить в накопитель значение, хранящееся в нулевом адресе памяти». Добавив "#", мы сообщаем ассемблеру, что это литеральное значение, а не адрес памяти.]В строке 3 мы сохраняем (записываем) этот ноль в адрес OAMADDR. Это приказывает PPU подготовиться к передаче в OAM, начиная с нулевого байта. Далее мы загружаем литеральное значение 2 в накопитель и записываем его в OAMDMA. Это приказывает PPU инициировать высокоскоростную передачу 256 байтов из $0200-$02ff в OAM.
Чтобы использовать
OAMADDR
и OAMDMA
в своём коде, мы должны дополнить файл констант, добавив в него эти новые константы. Вот как выглядит обновлённый файл constants.inc
:
Код в текстовом виде
PPUCTRL = $2000
PPUMASK = $2001
PPUSTATUS = $2002
PPUADDR = $2006
PPUDATA = $2007
OAMADDR = $2003
OAMDMA = $4014
Теперь у нас есть надёжный автоматизированный способ сохранения актуальности OAM. Но откуда берётся спрайтовая графика? Как говорилось в предыдущей главе, чип CHR-ROM картриджа содержит две таблицы паттернов, одна для спрайтов, вторая для фонов. Чтобы отображать спрайты на экране, нам нужно создать собственные таблицы паттернов. Для этого нам пригодится NES Lightbox.
Работа с NES Lightbox
Запустите NES Lightbox. Вы увидите нечто подобное:
Главный экран NES Lightbox.
Прежде чем приступать к созданию спрайтов, давайте вкратце разберёмся, как устроен NES Lightbox. Большая область в левой половине экрана — это своего рода холст, который можно использовать для рисования фонов при помощи тайлов из таблицы паттернов. Мы не будем использовать эту область, пока не дойдём до разговора о графике фона. Правая половина интерфейса примерно разделена на таблицы паттернов («Tileset») и палитры.
Область «Tileset».
Основным элементом области «Tileset» является поле таблицы паттернов. Под полем таблицы паттернов есть переключатели «Bank A / Bank B». Как говорилось ранее, обычно одна таблица спрайтов используется для спрайтов, а другая — для фонов. Переключатель A/B позволяет перемещаться между двумя таблицами паттернов. Также здесь есть кнопка «Grid», включающая и отключающая сетку, отображающую границы каждого тайла в таблице паттернов.
Кнопка «Edit» открывает отдельное окно редактора тайлов после того, как вы выберете тайл в поле «Tileset».
Область «Palettes».
Область «Palettes» позволяет просматривать, как выбор разных палитр влияет на тайлы. Здесь есть четыре пронумерованные палитры. При нажатии на любой цвет будет меняться внешний вид спрайтов в таблице паттернов в соответствии с той палитрой, к которой относится этот цвет. Можно менять цвет, нажимая на цвет в палитре, а затем выбирая цвет из набора всех возможных цветов в конце этого раздела. При наведении курсора на любой цвет во всплывающем окне отображается шестнадцатеричное значение, которое используется в NES для этого цвета; его можно использовать в своём коде.
При изменении первого цвета в любой палитре области палитр изменяется цвет фона в области большого холста, а также фон поля таблицы паттернов.
Создание тайловой графики
Чтобы вам было удобнее освоиться, я создал заготовку файла .chr, в котором находятся простые спрайты, а в таблице фона целиком сохранён шрифт. Скачайте graphics.chr и откройте его в NES Lightbox («Tilesets» → «Open CHR...» → выберите graphics.chr).
Для редактирования или создания тайлов нажмите на пространство в поле таблицы паттернов, соответствующее тайлу, который вы хотите изменить, а затем нажмите на кнопку «Edit». После этого откроется отдельное окно редактирования тайла.
Окно «Edit Tile» программы NES Lightbox.
Для редактирования тайла (или создания новых тайлов!) выберите индекс палитры из четырёх цветов под тайлом, а затем нажмите на пиксели в окне «Edit Tile», чтобы присвоить им этот индекс палитры.
[Стоит заметить, что нажатие на различные палитры в главном окне меняет все отображаемые цвета на цвета из соответствующей палитры. Это чрезвычайно полезно для проверки того, как будут выглядеть тайлы в палитрах, используемых в вашей игре.]
Кнопка «Grid» включает сетку из пунктирных линий, благодаря которой проще понять, где находится каждый отдельный пиксель тайла. Кнопки поворота и отзеркаливания позволяют легко редактировать множество пикселей одновременно. После того, как вы создали набор тайлов, с которым можно работать, сохраните свою работу, выбрав «Tileset» → «Save CHR As...».
Отображение спрайтов в своей игре
Скачайте полный исходный код этого примера: 10-spritegraphics.zip
Для отображения тайлов в игре нам сначала нужно загрузить содержащий их файл .chr. В нашем предыдущем проекте мы просто зарезервировали 8192 байт пустого места под чип CHR-ROM. Теперь, когда у нас есть настоящие тайлы, мы можем загрузить их непосредственно из файла .chr. Изменим раздел
.segment "CHR"
следующим образом:Как можно догадаться,
.incbin
— это новая директива ассемблера, приказывающая ca65 добавить «сырые» двоичные данные (в отличие от данных .include
, которые обрабатываются ассемблером). Разместив тайлы, можно что-нибудь нарисовать. Здесь мы используем четыре тайла «космического корабля» из graphics.chr, но вы можете использовать и свои тайлы.Далее нам нужно заполнить всю палитру, а не просто сделать первый цвет палитры зелёным (
$29
). Мы дополним .proc main
следующим образом:Заметьте, что нам достаточно задать адрес при помощи
PPUADDR
только один раз; каждый раз, когда мы выполняем запись в PPUDATA
, адрес памяти PPU автоматически увеличивается на один.Далее нам нужно сохранить данные наших спрайтов. Мы начнём с отрисовки одного спрайта — верхнего левого «угла» космического корабля. Как говорилось ранее, мы сохраним всю информацию спрайта в диапазон памяти
$0200-$02ff
и скопируем её в PPU при помощи DMA-передачи (в обработчике NMI). Давайте продолжим изменять .proc main
, чтобы скопировать спрайтовые данные в $0200-$02ff
:Наконец, нам нужно внести ещё одно изменение в
.proc main
. В наших предыдущих примерах после записи данных палитр мы включали экран, выполнив запись в PPUMASK
. Однако теперь, когда мы используем обработчик NMI, нам нужно приказать процессору генерировать события NMI. Мы можем это сделать, выполнив запись в PPUCTRL
[Все подробности различных вариантов того, что можно задать при помощи
PPUCTRL
и PPUMASK
, см. в NESDev wiki.]После этого наш код будет отрисовывать на экране один спрайт (верхний левый угол космического корабля) в координатах X
$80
и Y $70
(рядом с серединой экрана). Спрайтовые данные, которые мы записали в диапазон памяти процессора $0200-$02ff
, будут копироваться в память PPU раз в кадр нашим обработчиком NMI. Ассемблировав, скомпоновав и запустив этот код, мы получим следующий результат:Всё работает, однако наш код очень неэффективен. Для отрисовки таким образом всех 64 спрайтов нам понадобится 128 строк кода. Чтобы код спрайтов стал более удобным, мы будем хранить спрайтовые данные отдельно от кода, записывающего их в память, и использовать цикл для обхода данных. Для этого потребуется ещё несколько новых опкодов языка ассемблера, которые мы изучим в следующей главе.
Комментарии (5)
staticmain
24.03.2022 13:06Позицию по Y верхнего левого угла спрайта (0-255);
Вот тут я не уверен, что 0-255. Во всех эмулях что я менял\смотрел\патчил максимальная вертикальная координата была 240, по размеру области экрана.qideil
24.03.2022 15:14Если быть уж совсем точным, то максимальная вертикальная координата 238. Спрайты показываются только со следующей строки (первая строка спрайта на строке 0 показывается на экране на строке 1). Следовательно все спрайты с координатой > 238 будут вне экрана.
staticmain
24.03.2022 15:45За что купил за то продаю Mednafen хранит данные о неиспользуемых в данный момент спрайтах за пределами 240й координаты, поэтому когда писал бота, то при получении списка активных спрайтов пришлось написать так:
if (spr->x < 255 && spr->y < 240) {
alex_231
25.03.2022 02:16Всё правильно, при превышении значения 240 по Y спрайт уходит за пределы экрана, но всё также обрабатывается в памяти.
Radisto
Сколько раз в далеком детстве я смотрел на эту коробочку и думал: "вот вырасту, обязательно узнаю, как оно все работает, вот ужо я всем задам" папа_расскажи_о_своем_детстве.png