Программирование для Famicom/NES/Денди в Nesicide+ca65: спрайты (5)

Прежде чем перейти к следующему примеру — спрайтам я бы хотел немного обсудить то что мы увидели в примере с задним фоном.

Во первых — в эмуляторе внутри самого Nesicide можно не заметить одну крайне важную вещь. Он почему то её даже не пытается эмулировать.
Поэтому лучше для финальных проверок использовать другой эмулятор, например я использую FCEUX.
В нём сразу же можно увидеть одну важную вещь: когда параметры скроллинга выставлены в (0,0) первую горизонтальную полоску тайлов с '0000000...' на экране не видно. И это важный момент которому посвящена статья на Overscan (англ). на nesdev.com. Часть изображения в телевизионном формате NTSC просто выпадает за границы экрана.
Приведу картинку из этой статьи:

Красным обозначены строки которые в NTSC практически гарантированно выпадают за границы экрана и не появляются на экране.
Самая внутренняя серая граница по документации самой Nintendo гарантированно отобразится.
Жёлто-коричневым цветом обозначены границы зоны которую большинство телевизоров покажут хотя бы частично, но из-за частичности располагать тут важную информацию не стоит.
Синим обозначена зона которая была самой Nintendo обозначена как ненадёжная, но на практике все телевизоры её показывают.

Интересно, что в системе PAL эти границы все раздвинулись и лично я очень часто замечал в детстве играя на денди на обычном советском чёрно-белом телевизоре всевозможные тайловые артефакты сверху и снизу экрана. А тот же FCEUX в настройках содержит прямые настройки того сколько линий изображения пропускать отдельно для режимов NTSC и PAL.

Спрайты

Создадим новый проект так же как предыдущий создав его из шаблона CA65 Hello world, удалив файл nes.h и добавив наши файлы neslib.inc и neslib.s скопировав их из предыдущего урока. Скопируем оттуда так же header.s и nes.ini поверх тех файлов что среда создала (их полные тела есть так же в предыдущем уроке).
В neslib.inc в конце секции «PPU (ВИДЕО)» (сразу перед секцией «INPUT (ВВОД)») добавим новый код описывающий структуру спрайтов в консоли:

; *** SPRITE DESC - описание спрайта
; SPR_Y - смещение до координаты Y спрайта. Из-за того, что данные о спрайтах запаздывают на 
;         один сканлайн значение нужно уменьшить на 1.
SPR_Y			= 0
; SPR_TILE - смещение до номера тайла спрайта (0-255)
SPR_TILE		= 1
; SPR_ATTR - смещение до атрибутов спрайта (см. ниже)
SPR_ATTR		= 2
; SPR_X - смещение до координаты X спрайта
SPR_X			= 3
; *** Битовые флаги атрибутов спрайта (SPR_ATTR):
; Биты 0-1: палитра спрайта (0-3)
SPR_PAL_0		= %00000000
SPR_PAL_1		= %00000001
SPR_PAL_2		= %00000010
SPR_PAL_3		= %00000011
; Бит 5: приоритет - под задним фоном
SPR_BEHIND_BGR		= %00100000
; Бит 6: зеркалирование по горизонтали
SPR_FLIP_H		= %01000000
; Бит 7: зеркалирвоание по вертикали
SPR_FLIP_V		= %10000000


Спрайты хранятся в особой плашке DRAM в PPU и не входят в обычную VRAM доступную через PPU_ADDR/DATA. Память спрайтов называется OAM (Object Attributes Memory) имеет размер 256 байт и по идее должна была бы быть доступной через похожие по смыслу порты ввода-вывода OAM_ADDR и OAM_DATA. OAM_ADDR это однобайтовый адрес в OAM который можно выставить записью в порт этого байта, а при записи в OAM_DATA записываемый байт записывается в OAM по адресу OAM_ADDR, а сам OAM_ADDR автоматически увеличивается на 1.
Однако в первых ревизиях PPU запись в OAM_ADDR портила память в OAM! А есть обязательное условие: когда PPU начинает рисовать кадр после VBlank, то OAM_ADDR должен быть равен 0, PPU на это полагается в своей работе.
Выходит что единственным способом сделать всё правильно становится запись в OAM_ADDR нуля после чего в OAM_DATA записывается 256 байт всей таблицы спрайтов чтобы OAM_ADDR «провернулся» и после переполнения стал равен нулю.
Но именно это по смыслу делает DMA консоли — запись в порт OAM_DMA байта XX передаст из адресного пространства процессора 256 байт начиная с адреса $XX00 в память спрайтов PPU. Причём DMA сделает это очень быстро — примерно вчетверо быстрее чем код на процессоре.
Поэтому нам доступ к OAM_DATA не нужен совсем, а в OAM_ADDR мы будем только писать 0 перед активацией DMA.
Хранить копию таблицы спрайтов которую DMA будет записывать в OAM мы будем с адреса $0200 — в свободной области сразу после стека — это как раз то почему мы разместили сегмент RAM с адреса $0300 оставив место под таблицу спрайтов.

В норме спрайт отображает один тайл, т.е. имеет размер 8x8 пикселей. В таблице из 256 байт описания спрайтов располагаются последовательно, каждое описание занимает 4 байта и таким образом всего получается возможно 64 спрайта. Первый и последний байты описания содержат координаты Y и X спрайта соответственно. С координатой Y надо учитывать, что PPU когда рисует текущий сканлайн считывает данные о спрайтах для следующего сканлайна. Из-за этого есть такой артефакт, что в первом сканлайне спрайты вообще не рисуются — из-за отсутствия предыдущего сканлайна данные о спрайтах в нём еще не считаны. Более того — PPU из-за этого как бы опаздывает на одну строку по спрайтам и они отображаются сдвинутыми на 1 пиксель вниз. Т.е. координату Y надо уменьшать на 1 чтобы спрайт оказался в нужной позиции по Y.
Второй байт содержит номер отображаемого тайла, а третий байт — битовый набор атрибутов. Атрибуты это выбор палитры (0-3) спрайта и два бита зеркалирования по горизонтали и вертикали которые могут быть включены одновременно.
Обязательно надо иметь ввиду то, что в одном сканлайне видеочип не может нарисовать больше восьми спрайтов — остальные будут просто проигнорированы. При этом видеочип в самом начале каждого кадра очищает в регистре PPU_STATUS бит PPU_STAT_OVERFLOW, а когда встречает более восьми спрайтов в строке, то взводит его — так что процессор может проверить наступило такое событие или нет. Однако и тут инженеры немножко накосячили и из-за бага в железе на этот бит нельзя полагаться на все 100% — могут возникнуть как ложные срабатывания так и неправильные несрабатывания. Спрайты отрисовываются и заслоняют друг друга в порядке своих позиций в таблице — т.е. нулевой спрайт всегда нарисуется первым и заслонит собой все остальные спрайты если они окажутся под ним и так далее. Поэтому популярной методикой борьбы с лимитом количества спрайтов в строке является постоянная смена их позиций — тогда в одних кадрах исчезнут одни спрайты, а в других — другие. Спрайты начинают тогда характерно перемигиваться, но их хотя бы видно все хоть и в разные моменты времени.
В норме спрайты выводятся как бы над задним фоном, но если зажечь бит SPR_BEHIND_BGR в поле SPR_ATTR спрайта, то он будет отображаться как бы за задним фоном и тут станет важен факт прозрачности пикселей в тайлах заднего фона.

В PPU_CTRL можно включить режим спрайтов 8x16. При этом нижний бит номера тайла в описании спрайта начинает означать какой банк тайловых данных будет использован как источник (т.е. флаг PPU_SPR_TBL_1000 в PPU_CTRL перестаёт играть какую либо роль) тайлов для этого спрайта. Номер тайла внутри таблицы поэтому получается всегда чётным числом и тайл с этим номером используется как верхние 8x8 пикселей составного тайла, а тайл со следующим (нечётным) номером — как нижние. Зеркалируются тайлы 8x16 как единое целое.

Теперь перейдём в main.s, сотрём там всё старое и начнём писать новый код:
main.s — начало

; Подключаем заголовок библиотеки Famicom/NES/Денди
.include "src/neslib.inc"

; Сегмент векторов прерываний и сброса/включения - находится в самых
; последних шести байтах адресного пространства процессора ($FFFA-FFFF)
; и содержит адреса по которым процессор переходит при наступлении события
.segment "VECTORS"	
	.addr nmi	; Вектор прерывания NMI (процедура nmi ниже)
	.addr reset	; Вектор сброса/включения (процедура reset ниже)
	.addr irq	; Вектор прерывания IRQ (процедура irq ниже)

.segment "ZPAGE": zp	; Сегмент zero page, это надо пометить через ": zp"
vblank_counter:	.byte 0	; Счётчик прерываний VBlank
cur_sprite:	.byte 0	; Нижний байт адреса текущего спрайта в таблцие спрайтов

.segment "RAM"		; Сегмент неинициализированных данных в RAM

.segment "ROM_L"	; Сегмент данных в ПЗУ картриджа (страницы $C000-$FFFF)
palettes:		; Подготовленные наборы палитр (для фона и для спрайтов)
	; Повторяем наборы 2 раза - первый для фона и второй для спрайтов
	.repeat 2	
	.byte $0F, $00, $10, $20	; Черный, серый, светло-серый, белый
	.byte $0F, $16, $1A, $11	; -, красный, зеленый, синий
	.byte $0F, $1A, $11, $16	; -, зеленый, синий, красный
	.byte $0F, $11, $16, $1A	; -, синий, красный, зеленый
	.endrep
  
.segment "ROM_H"	; Сегмент кода в ПЗУ картриджа (страницы $C000-$FFFF)

; irq - процедура обработки прерывания IRQ
; Пока сразу же возвращается из прерывания как заглушка.
.proc irq
	rti		; Инструкция возврата из прерывания
.endproc

; nmi - процедура обработки прерывания NMI
; Обрабатывает наступление прерывания VBlank от PPU (см. процедуру wait_nmi)
.proc nmi
	inc vblank_counter	; Просто увеличим vblank_counter
	rti			; Возврат из прерывания
.endproc

; wait_nmi - ожидание наступления прерывания VBlank от PPU
; Согласно статье https://wiki.nesdev.com/w/index.php/NMI ожидание VBlank
; опросом верхнего бита PPU_STATUS в цикле может пропускать целые кадры из-за
; специфической гонки состояний, поэтому правильнее всего перехватывать прерывание,
; в нём наращивать счётчик (процедура nmi выше) и ожидать его изменения как в коде ниже.
.proc wait_nmi
	lda vblank_counter
notYet:	cmp vblank_counter
	beq notYet
	rts
.endproc

; fill_palettes - заполнить все наборы палитр данными из адреса в памяти
; вход:
;	arg0w - адрес таблицы с набором палитр (2 * 4 * 4 байта)
.proc fill_palettes
	fill_ppu_addr $3F00	; палитры в VRAM находятся по адресу $3F00
	ldy # 0			; зануляем счётчик и одновременно индекс
loop:
	lda (arg0w), y		; сложный режим адресации - к слову лежащему в zero page
				; по однобайтовому адресу arg0w прибавляется Y и 
				; в A загружается байт из полученного адреса
	sta PPU_DATA		; сохраняем в VRAM
	iny			; инкрементируем Y
	cpy # 2 * 4 * 4		; проверяем на выход за границу цикла
	bne loop		; и зацикливаемся если она еще не достигнута
	rts			; выходим из процедуры
.endproc

; reset - стартовая точка всей программы - диктуется вторым адресом в сегменте 
; VECTORS оформлена как процедура, но вход в неё происходит при включении консоли 
; или сбросу её по кнопке RESET, поэтому ей некуда "возвращаться" и она 
; принудительно инициализирует память и стек чтобы работать с чистого листа.
.proc reset
	; ***********************************************************
	; * Первым делом нужно привести систему в рабочее состояние *
	; ***********************************************************
	sei			; запрещаем прерывания
	ldx # $FF		; чтобы инициализировать стек надо записать $FF в X
	txs			; и передать его в регистр вершины стека командой 
				; Transfer X to S (txs)
	; Теперь можно пользоваться стеком, например вызывать процедуры
	jsr warm_up		; вызовем процедуру "разогрева" (см. neslib.s)
	
	store_addr arg0w, palettes	; параметр arg0w = адрес наборов палитр
	jsr fill_palettes	; вызовем процедуру копирования палитр в PPU


По сравнению с предыдущим примером тут исчезли переменные нужные в нём, а появилась только одна нужная для этого урока — cur_sprite.
Так же оказались немного подправлены данные для палитр — теперь все 4 субпалитры отличаются друг от друга.
Начало тоже стандартное — запретили прерывания, настроили стек, вызвали процедуру разогрева и залили в PPU палитры.
Отличия начинаются ниже:
main.s — вторая часть

SPR_TBL	= $0200			; Создадим символ для таблицы спрайтов в RAM
	; ********************************************************
	; * Инициализируем все 64 спрайта выстроив их 'лесенкой' *
	; ********************************************************
	store arg0b, 0		; arg0b будет хранить нарастающую координату
	store arg1b, # $30	; arg1b будет хранит ASCII-код символа начиная с '0'
	store arg2b, 0		; arg2b будет хранить нарастающий номер палитры 0-4
	ldx # 0			; занулим X
	; Для того чтобы в макрос store передать параметр содержащий запятую нужно
	; заключить его в фигурные скобки - это нужно для режима адресации ADDR+x
loop:	store { SPR_TBL, x }, arg0b	; в поле SPR_Y сохраним arg0b (координату)
	inx				; переходим к следующему полю
	store { SPR_TBL, x }, arg1b	; в поле SPR_TILE сохраним номер тайла
	inx				; переходим к следующему полю
	store { SPR_TBL, x }, arg2b	; в поле атрибутов сохраним нарастающий номер палитры
	inx				; переходим к следующему полю
	store { SPR_TBL, x }, arg0b	; и, наконец, сохраняем координату в SPR_X
	inx				; переходим к следующему тайлу
	
	inc arg0b
	inc arg0b
	inc arg0b			; нарастающую координату увеличим на 3
	inc arg1b			; номер тайла увеличим на 1
	inc arg2b			; номер палитры увеличим на 1
	lda arg2b			; но т.к. он не должен выходить за пределы двух бит
	and # %011			; то грузим его в аккумулятор и сбрасываем остальные биты
	sta arg2b			; и сохраняем обратно
	cpx # 0				; проверим не провернулся ли x в ноль (значит вся таблица пройдена)
	bne loop			; и если нет, то идём на следующую итерацию

	; **********************************************
	; * Стартуем видеочип и запускаем все процессы *
	; **********************************************
	; Включим генерацию прерываний по VBlank и источником тайлов для спрайтов
	; сделаем второй банк видеоданных где у нас находится шрифт.
	store PPU_CTRL, # PPU_VBLANK_NMI | PPU_SPR_TBL_1000
	; Включим отображение спрайтов и то что они отображаются в левых 8 столбцах пикселей
	store PPU_MASK, # PPU_SHOW_SPR | PPU_SHOW_LEFT_SPR | PPU_SHOW_LEFT_BGR
	cli			; Разрешаем прерывания


Перво наперво мы создаём синоним для начала области $0200 — SPR_TBL.
Далее используя arg0-2b заполняем таблицу спрайтов так чтобы они выстроились все лесенкой начиная с координаты (0,0) увеличивая обе на 3 с каждым новым спрайтом и получили при этом тайлы символов алфавита по порядку начиная с символа '0'. Кроме того каждый новый спрайт будет получать новый номер палитры циклически от 0 до 3.
Далее стартуем видеочип — при этом надо указать что спрайты находятся в той таблице тайлов где у нас расположены символы алфавита (флаг PPU_SPR_TBL_1000). В PPU_MASK включаем отображение спрайтов и левой полоски пикселей для них, об этом как раз поговорим ниже.
Флага PPU_SHOW_LEFT_BGR — т.е. левая полоска пикселей для фонов тут вообще быть не должно, но похоже что эмулятор встроенный в Nesicide сейчас его неправильно обрабатывает, поэтому здесь его лучше включить чтобы в нём всё выглядело правильно. Об этих вещах мы поговорим ниже.

Идём дальше — в игровой цикл:
main.s — третья часть

	; ***************************
	; * Основной цикл программы *
	; ***************************
main_loop:
	jsr wait_nmi		; ждём наступления VBlank

	; Чтобы обновить таблицу спрайтов в видеочипе надо записать в OAM_ADDR ноль
	store OAM_ADDR, # 0
	; И активировать DMA записью верхнего байта адреса страницы с описаниями
	store OAM_DMA, # >SPR_TBL

	; ********************************************************
	; * После работы с VRAM можно заняться другими вещами... *
	; ********************************************************

	jsr update_keys		; Обновим состояние кнопок опросив геймпады
	
	; Теперь можно обновить спрайты в SPR_TBL
	ldx cur_sprite		; Загрузим в X адрес (нижний байт) текущего спрайта
	inx			
	inx			; увеличим X на 2, т.е. перейдём к полю SPR_ATTR
	lda SPR_TBL, x		; Загрузим SPR_ATTR текущего спрайта в A
	clc			; Перед сложением надо очистить Carry
	adc # %01000000		; Сложив с $01000000 мы будем циклически изменять
				; все 4 варианта зеркалирования спрайта в верхих битах
	sta SPR_TBL, x		; Сохраним полученные атрибуты спрайта обратно
	inx
	inx			; Увеличиваем X на 2 чтобы перейти с следующему спрайту
	stx cur_sprite		; Сохраним указатель на новый текущий спрайт в cur_sprite
	
	inc SPR_TBL + 4 * 62 + SPR_X	; в предпоследнем спрайте увеличим X
	inc SPR_TBL + 4 * 63 + SPR_Y	; в последнем спрайте увеличим Y
	
	jmp main_loop		; И уходим ждать нового VBlank в бесконечном цикле
.endproc


Здесь мы как и полагается ждём NMI и сразу же с помощью DMA заливает таблицу спрайтов в PPU. Просто? Очень. В OAM_DMA надо записать верхний байт адреса страницы в адресном пространстве консоли и DMA шустренько зальёт все байты из этой страницы в OAM_DATA.

А дальше мы обновляем таблицу спрайтов «бегущей» на каждом кадре волной: текущему спрайту в cur_sprite мы циклически изменяем вариант зеркалирования и переводим cur_sprite на следующий спрайт. В конце он сам провернётся в 0 и волна обновления побежит снова с начала.
Кроме того последним двум спрайтам в таблице увеличиваются координаты X и Y соответственно. Адреса полей с этими координатами высчитываются выражением во время компиляции SPR_TBL + 4 * i даёт начало данных спрайта с номером i в таблице и прибавляя смещения полей внутри мы получаем адрес нужного атрибута.

Смотрим что получилось:



Из-за того что верхние 8 строк не отображаются спрайт с номером 0 почти не видно, но иногда в процессе зеркалирования белая полоска пикселей от него всё-таки появляется скраю экрана. Это следствие того, что спрайты как бы сдвинуты на 1 пиксель вниз — имея координаты (0,0) в атрибутах по факту спрайт отображается как будто имеет координаты (0,1). Это верно для всех спрайтов.

Со спрайтом который двигается вдоль горизонтали можно заметить одну неприятность. Размер экрана по горизонтали в пикселях — 256. Но и координате спрайта по горизонтали выделен 1 байт, т.е. диапазон возможных значений тоже 0-255. Когда спрайт подходит к правой границе экрана он начинает скрываться за ней плавно. Но на левой границе он появляется сразу весь целиком и плавно скрыться за ней неспособен — здесь не существует отрицательных координат чтобы такое провернуть. В некоторых играх (например Contra) спрайты врагов действительно плавно выбежав из-за правого края экрана и пробежав по экрану налево сразу исчезают как только коснутся левой границы экрана — зная всё это сей фактик можно легко заметить. Но иногда художник видит спрайты плавно исчезающими как за правой так и за левой границами экрана и вот чтобы это сделать и появились два битовых флага в регистре PPU_MASK — PPU_SHOW_LEFT_BGR и PPU_SHOW_LEFT_SPR. Если они не включены, то 8 самых левых колонок пикселей экрана не рисуются для фонов и спрайтов соответственно и ценой исчезновения этой полоски можно сделать плавное появление спрайта из-за левой границы экрана.
Для координаты по вертикали аналогичного флага нет, т.к. восемь первых сканлайнов вообще не должны отображаються на экране телевизора. Более того — самый правильный способ скрыть неиспользованный спрайт — это вывести его за нижнюю границу экрана записав в его координату Y число из диапазона $EF-$FF, ведь даже если его попытаться скрыть назначив полностью прозрачный тайл, то он продолжает влиять на лимит спрайтов в строке и может этим помешать.

Еще стоит заметить, что память OAM в оригинальных PPU выполнена по технологии DRAM, т.е. реализована на динамической памяти которую надо обновлять периодически в так называемом процессе регенерации. Но автоматического регенератора у неё нет — если отображение спрайтов включено, то PPU считывая OAM каждый кадр как бы сам производит регенерацию. Но если отображение спрайтов выключить, то уже через пару кадров OAM начнёт деградировать и её содержимое будет портится, поэтому никогда не надо полагаться на то что спрайты в OAM сохранятся надолго выключая их рендеринг.

В первую часть (оглавление)...

0 комментариев

Только зарегистрированные и авторизованные пользователи могут оставлять комментарии.