В першій частині я розказував про апаратне забезпечення Atari 2600, і щоб стаття була більш цілісною, трохи повернемося до технічної сторони консолі.
Television Interface Adapter (TIA)
В Atari 2600 за відображення графіки відповідає мікросхема Television Interface Adapter (TIA). Ніякого відеобуфера в консолі не було, все програмування зображення здійснюється через регістри TIA.
Регістри бувають трьох типів:
Регістри даних, в які треба покласти байт інформації. Наприклад,
COLUBK
з попередньої статті, записуємо туди значення кольору з палітри і TIA починає малювати бекграунд заданим кольором.Стробові регістри, це ті, в які можна записати будь які дані, але сам факт запису в регістр запускає певну дію. Наприклад,
WSYNC
. Запис в цей регістр призводить до того, що TIA приспить центральний процесор (сформує сигналHALT
), та розбудить його на початку сканлайну. Це дозволяє синхронізувати роботу процесора та TIA.Бітові регістри, це ті, в яких кожен біт має своє значення, незалежно від сусідніх. З ними ми сьогодні познайомимося більш детально.
Повний перелік регістрів можна знайти в файлі vcs.h
Щоб малювати щось на екрані, нам треба періодично писати щось в регістри TIA. Оскільки TIA постійно малює строки (scanline) на екрані, треба враховувати, в який саме час ми маємо щось туди писати, тобто, слідкувати за променем. Я трішки перемалював діаграму, яка пояснює, скільки процесорного часу в нас є для маніпуляцій з регістрами.
Процесор працює втричі повільніше за TIA, кожен машинний цикл процесора займає 3 такти. Іншими словами, за один цикл TIA встигає намалювати 3 ‘пікселі’ на екрані. Кожна команда процесора виконується 2 або 3 цикли (в залежності від команди). Тобто, навіть протягом виконання процесором команди NOP (no operation, нічого не робити), TIA встигне пробігти по екрану цілих 6 пікселів. Отакі перегони.
Ми можемо записати якісь дані в регістри TIA, але вони будуть однаковими для всього кадру. Якщо ж ми хочемо щось змінювати в кадрі, треба перепрограмовувати TIA ’на ходу’. Один сканлайн TIA ‘пролітає’ за 76 машинних циклів. Хочемо, щоб кожна строка зображення відрізнялася від попередньої? Треба встигнути переписати регістри за цей час. Доволі екстремальні умови, чи не так?
Я зробив шаблон, який робить базові налаштування та чистить пам’ять. В ньому є 4 блоки.
Перший блок в нас необмежений в часі, в ньому можна розмістити код, який має виконуватись до початку малювання чогось на екрані. Тут може бути, наприклад, процедурна генерація рівня 🙂
В другому блоці можна робити операції, актуальні для всього кадру, наприклад, розраховувати колізії, обчислювати адресу наступного спрайту анімації і так далі. Тут в нас є 3040 машинних циклів, поки TIA знаходиться в невидимій частині екрану Vertical Blank.
В третьому блоці маємо робити якісь зміни, щоб наступний сканлайн відрізнявся від попереднього. Тут часу обмаль, лише 76 процесорних циклів.
В четвертому блоці можна розміщувати якісь статичні дані. Наприклад, масиви графіки, таблиці для швидких обчислень і так далі.
Спрайти
Повернемося до заголовку публікації — малюванню спрайтів. Термін Спрайт вперше з’явився в керівництві розробника для Commodore 64 у 1982-му році, його відеочіп дійсно підтримує апаратні спрайти. В TIA за ‘спрайти’ відповідають лише однобайтові регістри:
GRP0 - Graphics Register Player 0
GRP1 - Graphics Register Player 1
COLUP0 - Color Player 0
COLUP1 - Color Player 1
Давайте пограємося з ними в шаблоні.
; Блок 1 - Необмежений по часу. Тут можна робити глобальну ініціалізацію.
lda #$C6 ; записуємо в акумулятор С6 що відповідає зеленому кольору палітри NTSC
sta COLUP0 ; зберігаємо це значення в регістр COLUP0 - Color-Luminance Player 0
lda #%01110011 ; записуємо в акумулятор патерн 01110011, це наша графіка
sta GRP0 ; зберігаємо в регістр GRP0 - Graphics Register Player 0
Якщо ми в першому блоці запишемо в регістри якісь дані, TIA намалює наш патерн на всіх 192 сканлайнах, бо ми ніколи його не змінюємо.
Давайте спробуємо поміняти значення GRP0
в блоці 2. Використаємо для цього індексний регістр Y. В реальній програмі, скоріш за все, вам не вистачить регістрів, щоб зберігати все в них, для цього в нас є ще 128 байтів оперативної пам’яті, але поки ми можемо собі це дозволити. В першому блоці запишемо в Y нуль, а в другому — будемо збільшувати його на одиницю і писати в регістр GRP0
.
startFrame:
;------------------------------------------------------------------------------------
; Блок 2 - Маємо 3040 циклів, поки TIA дійде до видимої частини екрану.
; Тут можна робити зміни, однакові для всього кадру.
iny ; будемо збільшувати індексний регістр кожен кадр, при переповненні знову буде 0
sty GRP0 ; збережемо значення регістру Y в регістр TIA GRP0
В результаті в нас вийде така собі анімація, коли кожен кадр ми будемо мати новий патерн, але він так само буде однаковий для всіх 192 ліній, бо ми робимо це один раз на початку кадру.
При старті емулятора Stella можна нажати тільду, щоб перейти в режим дебагу, і натискаючи кнопку Frame, подивитись, як кожен новий кадр на екрані буде малюватись новий патерн графіки. Також можна дивитись стан регістру Р0.
Що ж, давайте тепер спробуємо перенести цей наш код в блок 3 і подивимося, що з цього вийде.
;---------------------------------------------------------------------------------------------------------------
; Блок 3 - 76 циклів на зміну сканлайну
iny ; будемо збільшувати індескний регістр кожен кадр, при переповненні значення знову буде 0
sty GRP0 ; збережемо значення регістру Y в регістр TIA GRP0
;----------------------------------------------------------------------------------------------------------------
Тепер патерн змінюється не кожен кадр, а кожен сканлайн, і ми маємо цікавий візерунок.
Які з цього можна зробити висновки:
Щоб намалювати реальний спрайт, нам треба розмістити десь в пам’яті картриджа графіку для нього і кожен сканлайн переносити відповідний патерн в регістр.
Спрайт має лише 8 пікселів завширшки, бо це розмір регістру
GRP0
, але може бути хоч на весь екран (192 пікселі) у висоту, аби вистачило пам’яті в картриджі.Щоб задати положення спрайту по вертикалі, треба просто пропустити потрібну кількість сканлайнів і почати малювати, наприклад, з 10-го сканлайну.
Спробуймо щось більш складне
Для створення спрайта можна використати один з онлайн редакторів. Максимальна роздільна здатність екрану — 160 пікселів на 192 сканлайни. При пропорціях 4:3 очевидно, що пікселі в нас прямокутні. Щоб зображення не виглядало розтягнутим, можна робити спрайт 8×16. Намалюймо малий тризуб
Розмістимо згенерований масив в 4-му блоці шаблону під міткою Sprite_Triade
; Блок 3 - 76 циклів на зміну сканлайну
lda Sprite_Triade,x ; перевикористаємо поки наш регістр Х як вказівник на строку,
; ця команда запише в акумулятор значення з адреси Sprite_Triade+Х
sta GRP0 ; запишемо значення акумулятора в регістр GRP0
В 3-му блоці можна поки перевикористати індексний регістр Х щоб задати зсув всередені таблиці графіки, але наш тризуб буде намальовано зверху. Поки маємо отримати такий результат (колір в регістрі COLUP0
було замінено на жовтий 1E
):
Бачимо очевидну проблему, після тризубу в нас поки малюється суцільна смуга. Це пов’язано з тим, що порожнє місце в картриджі заповнене FF, але якщо там будуть якісь інші дані, то TIA виведе і їх. Наприклад, якщо скопіювати дані тризубу ще раз, надрукує 2 версії, то ж потрібна перевірка на розмір спрайту.
Отакий код надрукує нам один тризуб в верхній позиції екрану
drawSprite:
; Блок 3 - 76 циклів на зміну сканлайну
lda Sprite_Triade,x ; перевикористаємо регістр Х як вказівник на строку,
; ця команда запише в акумулятор значення з адреси Sprite_Triade+Х
sta GRP0 ; запишемо значення акумулятора в регістр GRP0
sta WSYNC
inx ; збільшуємо індекс
cpx #16 ; перевіряємо, надрукували весь спрайт чи ні
bne drawSprite
lda #0 ; якщо весь, записуємо в регістр графіки 0 щоб почистити все
sta GRP0
scanLine:
;---------------------------------------------------------------------------------------------------------------
sta WSYNC ; поки нічого не робимо, чекаємо на наступний сканлайн
inx ; збільшуємо X на 1
cpx #192-16 ; проходимо решту 192-16 сканлайнів
bne scanLine
;---------------------------------------------------------------------------------------------------------------
Хочемо, щоб було не в верхній, треба певну кількість сканлайнів пропустити, а це знову порівняння. В нас і так небагато часу, то ж можна трішки зекономити його. Порівняння оперує з регістром переповнення, то ж якщо робити віднімання замість додавання з порівнянням, отримаємо той самий результат. Майже 🙂
ldx #15 ; записали в Х висоту спрайту-1, бо в нас пост-умова
drawSprite:
; Блок 3 - 76 циклів на зміну сканлайну
lda Sprite_Triade,x ; Х як вказівник на строку
sta GRP0 ; запишемо значення акумулятора в регістр GRP0
sta WSYNC
dex ; віднімаємо замість додавання з порівнянням, економимо 2 цикли
bne drawSprite
stx GRP0 ; також по завершені віднімання Х містить 0, можна одразу записати його в GRP0
В цьому прикладі ми одразу заносимо в індексний регістр 15, а в циклі зменшуємо його. Це економить нам 2 процесорні цикли на операцію порівняння, до того ж, по завершенню, регістр Х має значення 0, яке одразу можна записати в GRP0
щоб далі TIA нічого не малювала.
Але спрайт в нас тепер до гори дригом. Щоб вирішити цю проблему, достатньо ‘перевернути’ наші дані. В редакторі спрайтів це робиться в один клік. Саме такий підхід і використовували розробники, щоб зекономити машинні цикли. Але нам ще треба рухати спрайт по вертикалі і бажано зберігати його позицію десь в змінній. Це ще додаткові операції читання та порівняння. Плюс тепер треба розуміти, скільки сканлайнів пропустити після того, як ми намалювали спрайт. А якщо спрайтів 2 (в нас все ж таки 2 регістри), та ще й кольорові (можна задати окремий колір спрайту на кожний сканлайн), та ще й ми хочемо малювати ігрове поле (окремі регістри Playfield, про це, мабуть, в 3-й статті вже буде).
76 циклів на все це замало. Більшість розробників в ті часи робили просто черезстрочну розгортку. Тобто вони пропускали кожен другий сканлайн щоб встигнути зробити всі потрібні операції з регістрами.
Враховуючі такі суттєві обмеження, більшість оптимальних алгоритмів для Atari 2600 вже знайдені. Один з них належить Томасу Еншу, автору 16 нових ігор для Atari 2600.
Цей алгоритм займає лише 22 процесорних цикли, не найшвидший в реалізації, але не потребує складної карти пам’яті та використання недокументованих команд асемблера. Якщо хочеться більше деталей, в цьому треді на AtariAge розписані всі можливі варіанти малювання спрайта.
Окей, з вертикальним позиціюванням спрайта більш менш зрозуміло, а що до горизонтального? І тут знову все не просто 🙂
Горизонтальне позиціонування спрайта
На початку статті я згадував про стробові регістри. Це ті, які спрацьовують під час запису в них будь-якого значення. Один з таких регістрів — RESP0 - Reset Player 0.
Як тільки ми в нього щось записуємо, TIA починає малювати спрайт на екрані. Причому, це можна зробити 1 раз протягом кадру, навіть за межами видимої частини та малювання сканлайну, щоб не витрачати дорогоцінні процесорні цикли.
Загальна ідея наступна. Дочекалися початку сканлайну, почекали, поки TIA доведе промінь до потрібного місця на екрані, записали строб в RESP0.
Але, якщо б все було так просто, це була б не Atari 2600. Один процесорний цикл — це три пікселі TIA, а елементарна операція NOP
виконується за 2 цикли або 6 пікселів. Можна гратися з якимись командами, які виконуються 3 цикли, але навіть в такому випадку позиціювання по горизонталі буде +/- 3 пікселі. І от щоб розв’язати цю проблему, є ще один регістр, HMP0 - Horizontal Motion Player 0
, який, в залежності від вмісту, може перемістити спрайт на 0-7 пікселів вліво або вправо. І щоб все це застосувалося, треба також записати щось в стробовий регістр HMOVE - Apply Horizontal Motion.
Наступний код дозволяє позиціювати спрайт по горизонталі з точністю до пікселя.
sta WSYNC ; дочекалися сканлайну
nop ; 2 машинних цикли, 6 пікселів, пропускаємо невидиму частину екрану.
nop ; +2
nop ; +2
nop ; +2
nop ; +2
nop ; +2 проходимо horizontal blank
nop ; нарешті вилезли у видиму частину екрану
nop ; кожен nop це 2 цикли або 6 пікселів
nop ; 12 пікселів
LDA #%01010000 ; -5 пікселів, це значення, яке треба буде записати в HMP0 щоб повернутися на 5 пікселів назад
sta HMP0 ; записуємо його в регістр
sta RESP0 ; цей регістр стробовий, що писати - не має значення, важливий сам факт запису
sta WSYNC ; ще один стробовий регістр щоб дочекатися наступного сканлайну
sta HMOVE ; останній стробовий регістр активує зсув зображення по горизонталі
Звісно, щоб робити це більш ефективно, є готові процедури, наприклад, така:
PosObject SUBROUTINE
sta WSYNC ;
sec
.divideby15
sbc #15 ; Waste the necessary amount
bcs .divideby15
tay
lda fineAdjustTable,y ;
sta HMP0,x
sta RESP0,x
rts
Перед викликом треба покласти в акумулятор бажану координату, спочатку вона зробить грубий зсув по 15 пікселів просто потративши час у циклі, а потім візьме значення з таблиці (додаткові 15 байт в ROM) і запише його в регістр HMP0
, щоб зробити точне позиціювання.
В цю величезну бочку з дьогтем розробники TIA додали таки ложку меду. Є ще 2 цікавих бітових регістри REFP0
та NUSIZ0
, які дозволяють маніпулювати з графікою.
Перший відповідає за віддзеркалювання спрайта по вертикалі, причому в цьому регістрі має сенс лише 3-й біт. Всі інші ні на що не впливають. Це може знадобитись, коли ваш спрайт має рухатись в зворотній бік, і щоб не малювати його віддзеркалену копію, можна повернути його лише за 5 циклів процесора.
lda #%00001000 ; 3-й біт відповідає за віддзеркалювання спрайту
sta REFP0
Другий регістр більш цікавий, за його допомогою можна збільшити розмір спрайта в 2 або 4 рази, а також клонувати його 2 чи 3 рази навіть вказавши відстань між копіями. Це дозволяє робити страшних та великих босів або навалу монстрів всього за декілька циклів процесора.
Ось приклад використання цих регистрів. Відредагував трішки спрайт, щоб він не був симетричним і можна було перевірити віддзеркалення.
;lda #%00000101 ; спрайт подвійної ширини
lda #%00000110 ; три копії спрайту
sta NUSIZ0
Також на початку публікації я згадував регістр COLUP0
, який відповідає за колір спрайту. Якщо змінювати його кожен сканлайн, можна зробити спрайт різноколірним. Для потрібен другий масив з кольорами на кожну лінію спрайта, але на зміну регістру треба закласти ресурси в дуже обмеженому часі малювання одного сканлайну.
Для другої статті, мабуть, досить. Це й так зайняло біля 12 годин часу. Більш-менш працюючий код з прикладами доступний по посиланню на гітхабі. З форматуванням в самому гітхабі якась бідуся, але в VSCode виглядає норм. Якщо хтось знає, як це пофіксити, напишіть будь ласка.
Всі ці складнощі та незрозумілості (наприклад, чому саме 3-й біт в REFP0 має сенс) пояснюються складністю технологічних процесів того часу. Перший прототип TIA з’явився майже 50 років тому, і з точки зору програмування нема різниці, поставити 3-й біт чи перший, а з точки зору топології кристалу це могло бути єдине можливе рішення.
Планую продовжити цей цикл публікацій, бо мене дуже надихає, як розробникам вдавалося знайти оригінальні рішення й вижати з настільки обмеженого за можливостями чипа щось цікаве.
Я веду блог в телеграм, Software & Computer Museum Lab, присвячений ретро комп’ютерам та консолям, заходьте почитати.