Мультиколор будет побеждён

Первая половина статьи была опубликована в последнем номере замечательного журнала ЗаРулём, а вторая написана по следам свежего релиза нашей новой игры GLUF. Но т.к. они дополняют друг друга я решил их объединить в одну. В ней нет ничего нового для мастеров кода и гуру из 90-х годов, это скорее поток мыслей на тему мультиколора, скрола и попытки их объединения который может быть будет кому-то интересен :)

После того как вышла наша новая игрушка Old Tower мне почти сразу же посыпались просьбы рассказать, как же удалось добиться такой плавной и яркой картинки на нашем старичке Speccy. Ну что же, берите чай и печенки и вперёд!



Прежде всего надо ещё раз напомнить, что же такое мультиколор и с чем его едят. Поэтому я процитирую свою старую статью 2016 года, где я сделал обзор существующих на тот момент мультиколорных движков. «Общеизвестно, что стандартный видеорежим спектрума не позволяет отображать более 2-х цветов в одном знакоместе экрана. Однако существует различные программные трюки, позволяющие обойти это ограничение. Один из них — это так называемый «Мультиколор», или «Rainbow Graphics». Он позволяет, уменьшит высоту знакоместа до блоков 8x1, 8x2, 8х4 с помощью идеальной синхронизации вывода атрибутов и луча, формирующего картинку на экране» Грубо говоря, микросхема ULA, отвечающая за формирование изображения на экране, читает значения цвета из атрибутов не один раз на блок 8 пикселей высотой, а каждую экранную линию, потому нужно только успеть поменять значение в памяти на новые и удача ваша. Это в теории. На практике ширина области мультиколора ограниченна и для блоков 8х1 так и не удалось сделать картинку шире 20 знакомест.

Стоит отметить что ещё в 1988 году в игре Action Force 2 применятся точно такая же технология что и в нашей игре, просто взгляните на картинку, и вы всё поймёте. Почему же они не пошли дальше и не сделали полноценную игру в таком режиме? Увы, нам остаётся только догадываться и сожалеть.

Но постойте, скажет внимательный читатель. Ограничение в ширину и куча цветов — это же идеальная среда чтобы перенести на спектрум какую-нибудь механику с мобилок! Да, именно так я и подумал, и решил, взяв за основу великолепную Tomb Of The Mask перенести её в свои традиционные головоломные уровни. Осталось просто добавить скролинг и дело в шляпе. Но не тут-то было.

Стоит отметить что мультиколор требуют к себе пристального внимания процессора и чем больше высота, тем больше процессорного времени он пожирает. В кадре у нас доступно около 70000 тактов процессора, мультиколор высотой во весь экран требует порядка 43000 тактов. В оставшиеся 27000 тактов нужно уместить скролл пикселей, отрисовку изменившихся тайлов, героя и всю логику игры. Становится понятно, что в один кадр это всё не уместить и нужно как-то разделить задачи на два кадра. Правда мультиколор нужно всё равно «держать» каждый кадр поэтому он сожрёт такты и во втором кадре. К тому же, чтобы он был стабилен, время выполнения программы до того, как луч дойдёт до мультиколорной зоны, должно быть постоянным, т.е. занимать всегда одно и то же количество тактов процессора каждый кадр.

Итак, нам нужно подобрать размер пиксельной и мультиколорной областей так, чтобы они влезали в один кадр, а во второй кадр попытаться впихнуть всё остальное. К тому же время выполнения всех процедур в верхнем бордюре (до того, как луч дойдёт до мультиколора) в обоих кадрах должно быть постоянным. В дополнении ко всему нам надо сделать отрисовку пикселей так, чтобы она не пересекалась с лучом, а для этого времени в верхнем бордюре недостаточно. Голова кругом.

После нескольких зарисовок на бумаге, нескольких часов поисков оптимальных размеров областей и двойной работы по подгонке мультиколора в двух кадрах, у меня получилась такая схема:

Итак, первый кадр. Начнём с момента, когда луч дошёл до места обозначенного зелёной линией. Это самый критичный момент в моём рендере. Как только луч оказался тут мы начинаем формировать новый для кадр нашей игры. Первым делом рисуем 50 нижних линий нашей пиксельной области, на схеме это белый прямоугольник поменьше. После этого у нас остаётся очень-очень мало времени до конца кадра, но нам нужно успеть ещё обновить мультиколор для наших врагов, это единственное место где можно это сделать безопасно и без глюков потому что чуть раньше, в области обозначенной «A» мы их уже отрисовали в пиксельный буфер и прямо сейчас уже нарисовали 50 линий с него.

Второй кадр, сразу же, без долгих раздумий начинаем рисовать 110 оставшихся линий нашего пиксельного буфера (белый прямоугольник побольше) пока луч не добежал до нашей игровой области. После их отрисовки у нас в экранной памяти получается законченная картинка, которая складывается из 50 линий нарисованных в прошлом кадре уже после луча и 110 линий, нарисованных перед лучом, которому ничего не остаётся как нарисовать полный буфер на экран. Так мы избежали подёргиваний и сечений лучом отрисовав линии таким хитрым образом. Количество этих линий рассчитано так что сразу после того как мы их отрисовали начинаются пляски с мультиколором, которые продолжаются всю область, обозначенную буквой «B», причём в обоих кадрах в этой области происходит одно и то же — рисуется мультиколор. А так как область мультиколора всего 12 знакомест шириной, то осталось ещё время нарисовать и стены башни одновременно с мультиколором.

И вот, когда мы попали в область, обозначенную буквой «С», мы попадаем в самое безопасное место в нашем рендере, тут находится вся логика игры, движения врагов, логика пушек, подсчёт очков, словом весь мозг игры. Спокойно занимаемся своими делами до конца кадра.

Снова первый кадр, у нас осталась последняя область в первом кадре обозначенная буквой «А». Как вы помните тут мы можем выполнять код который должен занимать постоянное количество тактов процессора чтобы наш мультиколор не уплыл. Поэтому тут происходит отрисовка крутящихся монеток, врагов, выстрелов и всего того, что можно легко сделать за постоянное количество тактов процессора. Нарисовать что-нибудь с постоянным временем достаточно просто. Например, если нет врага в списке мы просто отрисовываем его в область пзу и.т.д.

Вот так, не вдаваясь в кучу мелких деталей работает рендер в нашей игре. Было ещё очень много других проблем, но это уже совсем другая история.



После релиза Old Tower можно было и остановиться, но хотелось увеличить ширину игрового поля и увеличить размер тайлов и спрайтов. Поэтому я стал думать, как можно развить рендер с прошлой игры в нечто большее. Итак, нужно увеличить ширину мультиколорной области и максимально оптимизировать пиксельный и мультиколорный скроллинги. Так начинался GLUF.

За основу я взял рендер из Old Tower. Первым делом берёмся за мультиколор, чтобы он стал приемлемой ширины пришлось перейти в режим атрибутов 8х2 что тоже весьма красочно, тем более размеры спрайтов и тайлов тоже подрастут до 16х16, так что такой переход не критичен. Здесь не было никаких сложностей, в наше время написать мультиколор 8х2 это достаточно легко, нужно просто немного побегать с лучём наперегонки. Были заморочки с тем чтобы сделать вывод мультиколора из линейного буфера, ведь в таком случае можно его скролить лёгким движением руки, а ещё я немного заморочился и свернул мультиколор в цикл сэкономив приличный кусок памяти. Во время разработки OldTower меня дико раздражало ограничение на количество обновляемых областей мультиколора при восстановлении фона под динамическими элементами, поэтому я решил сделать двойной буфер для мультиколора и обновлять цвета в том буфере, который сейчас не виден на экране. В качестве бонуса получилось сделать эффект заливки красным цветом при смерти героя просто подменив мультиколорный буфер на другой – весь заполненный красным.

А дальше нужно было ускорить вывод пикселей с сохранением возможностью их лёгкого скролинга. После всех экспериментов я остановился на оптимальном размере игровой область в 24*16 знакомест. Отрисовка пикселей это достаточно затратно, поэтому я решил взять самый быстрый метод вывода изображения из буфера в простонародье известный как «лдпуш» он основан на постоянном повторении комбинации команд ld и push. Мы отводим огромный кусок памяти в который генерируем такой код


;у нас будет 320 линий

dup 320

;код для вывода одной линии

ld sp, адрес конца линии экрана 

dup 12 : ld de, 2 байта данных : push de : edup

edup


Это и будет наш пиксельный экран, он статичен. Он просто выводит данные на экран которые в нём же и хранятся. Причем выводит он их задом наперёд, т.к. команда push уменьшает указатель стека, который в нашем случае является адресом куда мы кидаем данные. Таким образом, вместо того чтобы рисовать напрямую в экран мы, будем рисовать прямо в код! Т.е. нам нужно будет каким то образом преобразовывать реальные игровые координаты в адрес нужного нам байта в этом куске кода. К тому же нужно учитывать, что всё будет зеркально, т.к. рисуется задом наперёд и вдобавок ко всему, чтобы двигать горизонтально плавно спрайты шириной 16 пикселей по экрану нам необходимо перерисовывать 3 знакоместа в ширину, а значит вычисление соседнего знакоместе будет совсем не тривиальным. Не стоит забывать, что под спрайтами ещё нужно сохранять и восстанавливать фон. К тому же нужно как-то скролить это всё прыгая на нужную строчку кода и выпрыгивать с неё и ещё обновлять адреса линий куда всё будет выводится. Список проблем рискует перерасти критическую массу, после которой уже опускаются руки.

Но засучив рукава и разбив задачу на более мелкие удалось всё победить. Всё оказалось не столь страшным как казалось изначально.

Начал я с простого, скролинг делается тривиально – начало строки вычислить легко, к тому же каждая «линия» экрана занимает 51 байт кода что облегчает нам все вычисления. Итак, на экране показывается 128 пиксельных линий, точка входа в этот могучий кусок кода вычисляется простым умножением на 51 номера линии, с которой мы хотим вывести картинку, а выход вычисляется прибавлением 128*51. Чтобы сделать выход мы просто патчим код и вклиниваем команду jp (hl) в нужное нам место, не забыв при вызове поместить в hl адрес куда нам нужно вернуться, а после возврата вернуть туда что там было до этого. Всё это старый и всем известный трюк. Потом нам ещё надо обновить все адреса 128-ми линий которые мы будем выводит и дело в шляпе.

Я в принципе не очень хороший кодер, а на z80 и подавно. Поэтому процедуры, которые выводят 5 динамических спрайтов с фиксированным числом тактов в верхнем бордюре второго фрейма и запоминают адреса в «буферах донорах» для восстановления фона дались мне слишком тяжело. И уже сейчас, спустя месяц как я их написал я, смотрю на них и нечего почти там не помню. Поэтому детально описать что там происходит уже не получится. Единственное что могу сказать, что я пожертвовал ещё одним куском памяти в последней быстрой странице чтобы хранить в ней пиксели для восстановления фона. Хранятся они там столбцами и не строками, чтобы максимально быстро их восстанавливать, т.к. вся отрисовка спрайтов построена на выводе столбцами из-за нетривиального перехода к соседнему столбцу.

В конце всей это рутины у меня на саму игру осталось жалкое количество тактов в нижнем бордере второго фрейма. Но тут я уже почувствовал себя в своей тарелке, это уже стало даже привычным, и я даже стал забывать, как это оно, когда у тебя куча тактов. Чтобы стало немножко проще было принято решение забить первый фрейм максимально плотно, а музыку и звуки играть один раз в два фрейма. Переход звука в 25гц благодаря мастерству Олега вышел безболезненным, в музыке появился даже некий шарм.

Вот собственно и всё, дальше было просто дело техники.



Если вы вдруг ещё не играли в наши новые игры, то хочу вам напомнить — скачать и даже поиграть онлайн можно на нашем сайте www.retrosouls.net

Играйте в спектрум, смотрите демки, поддерживайте авторов, пытайтесь сами что-нибудь делать и просто получайте удовольствие от любимого хобби!

Пока!

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

avatar
вставьте видосы геймплея обоих игор в пост!
avatar
fixed
avatar
Клэшинг уже корчится, топчи его, топчи!

Про мультиколор 8х1 ты немного отстал: Gasman в Ultraviolet сделал 8х1 мультиколор на 22 знакоместа шириной. Но вообще, очень здорово, что ты написал такую заметку, мало кто понимает как планируется кадр для мультиколорного рендера. А ты объяснил всё очень просто и понятно, может быть это поможет кому-то ещё сделать другие игры и демки с мультиколором, да и не только.
avatar
Про 20 знакомест это потому что оригинальная статья была написано до этого :) И до BIFROST*2 который тоже 22 знакоместа да ещё и на 48К
avatar
я наврал, BIFROST*2 ENGINE выводит 20 знакомест! вива гасман
avatar
мало кто понимает как планируется кадр для мультиколорного рендера

Мне кажется наоборот — уже слишком много людей понимают, как это делается. Чересчур много! Пора бы уже начать распонимать обратно :)
avatar
Господи боже мой, Аве Мария! Наконец-то кто-то, впервые за 30 лет, написал хорошую, годную, без авторского самолюбования, понятную обычному человеку статью про мультиколор!

Денис, шапки долой! Замечательная статья!
  • sq
  • +5
avatar
Умножение на 51 табличное или есть какая то хитрая последовательность инструкций?
avatar
x*51 = x*32+x*16+x*2+x*1. Это недорого. Денис, я угадал?
avatar
Таблица 128*2 — 256 байт «всего», что ровно и аккуратно ложится на 8-битные инструкции, а уже одно x*32 это 5 16-битных ADD HL,HL, а еще складывать надо, поэтому и интересно.
avatar
Оптимизация не делается ради оптимизации. Да, таблица работает быстрее умножения на константу, но выполнять это умножение Денису нужно раз за фрейм (даже и раз за два фрейма, если уж совсем точно). Т.е. мы тратим лишнюю сотню тактов на фрейм, 100/70000 — это меньше 0.2% общего времени. Думаю, что у Дениса были более эффективные способы потратить 256 байт на ускорение программы.
avatar
А вообще да — искать базу умножением на 51 надо нечасто: для определения опорной строки раз на кадр и еще для спрайтов по разу. Кроме того таблица должна быть не 128, а 320, рендер ведь идёт в этот бэк-буффер. Тем не менее меня всё-равно смущает фраза «что облегчает нам все вычисления», потому что такое разложение на степени двойки как по мне так ничего не облегчает — четыре члена это почти любое число можно покрыть от 0 до 256, кроме того вставив в концы строк JR можно попросту округлить их до 64 байт, а вот это уже действительно «многое облегчает». В общем послушаем что скажет Денис.
avatar
P.S. А, вспомнил, что даже лучше не JR, а JP, JR же хоть и меньше, но дольше выполняется.
avatar
В какой-то момент у меня оставалось мало памяти и я сделал умножение как написал introspec, но потом я свернул мультиколор в цикл и появилось лишних 3кб, поэтому сейчас по таблице. Считать приходится почаще т.к. ещё спрайты рисовать надо. Но в целом там была какая-то мизерная разница :) я честно не помню почему я перешёл на табличку, кажется не влазили спрайты чуть-чуть в верхний бордер и этот мизер пришелся ко двору.

Вставлять в концы строк jr и округлять до 64 байт это не вариант, т.к. неизбежно потеряется скорость и уменьшиться количество линий которые в 16кб влазят, а там всё впритык и так. В первом фрейме на классике тактов 400 остаётся всего.

Игра достаточно расточительна и съедает почти всю быструю память. Одна быстрая страница целиков под ldpush, во второй килобайт 7 пиксельный буфер для восстановления фона, третья быстрая под четыре мультиколорных буфера, базовый для восстановления цветов под спрайтами, два рабочих которые переключаются реаализуй double buffer и третий залитый красным для эффекта смерти героя. В целом свободно только 6кб из всей быстрой памяти :)
avatar
Техника ldpush вообще очень понравилась (безотносительно мультиколора). Спектрум хоть и был первым моим ПК и дал импульс к судьбе программиста, но хардкорным спектрумистом я не стал и плаваю по верхушкам. Так что если она и широко известна в узких кругах, то я лично не знал.
Сейчас чем больше про неё думаю, тем больше нравится — если всё-таки сделать JP NEXT, то можно закольцевать в бесконечную ленту и сделать вертикальный скроллер бесконечным, дорисовывая медленно только одну полоску пикселей на кадр. Интересно еще попробовать всё-таки как то ускорить LD SP,END_OF_LINE, чтобы тоже минимизировать перевычисления в кольце.
avatar
для бесконечного скрола достаточно будет как у меня, буфера в два экрана высотой. просто дорисовывай строчку тайлов из массива карты. примерно как на нес скролинг делается в боянистой гифке с марио :) я так изначально делал в old tower в раннем прототипе без мультиколора.
avatar
гифки не робят но поди и так видел :)
Можно ускорить если сделать только один раз ld sp и выводить в «неленейную видеопамять» %) но там другие ньюансы сразу вылезут
avatar
я использовал уже GPL-ную (или creative commons, типа того) версию этой гифки в своей статье тут про NES как раз тут. у меня цикл статей тоже «по верхушкам» самых разных систем и техник ранних консолей и ПК.
но, насколько я понимаю, с техникой ldpush, которая быстро перекидывает на экран теневой буфер, для вертикалкьного скроллинга достаточно иметь всего один избыточный сканлайн, который и будет перерисовываться и два экрана не надо. два экрана это нужно было на тайловых чипах где проворачивание удобно привязать к степени двойки. а у вертикалки+лдпуш+кольцо это не требуется, по крайней мере если не связываться с multycolor, иначе не уверен.
avatar
ну если просто по одному пиксели скролить то да, один сканлайн и мультиколор тут не помешает :) циклы статей по врехушкам тоже нужны, если нужно быстро вникнуть в тему, тем более на русском в основном только по спеку инфа.
avatar
Не смог всё-таки устоять перед искушением и на выходных реализовал технику LD:PUSH+кольцо для плавного скроллинга первых двух третей экрана. На эмуляторе получился фпс близкий к 128, то есть больше, чем период обновления экрана в два раза!

А ведь когда-то я считал, что на спектруме плавный скроллинг физически невозможен.
avatar
Как ты фпс считал? :) он не может быть быстрее чем период обновления экрана в два раза, т.к. даже чистая пара ldpush тратит 10.5 тактов на байт, значит 4096байт*10.5 = 43008 а это уже больше чем полфрейма, а это без учёта медленной памяти и вспомогательных вычислений
avatar
секундомером просто замерил сколько раз зацикленный в двух третях экрана паттерн делает полный проворот за 10 секунд — получилось 10 раз.
avatar
P.S.
Перепроверил на Spectaculator — тот показал в два раза примерно меньший фпс. Хм, видимо эмулятор Unreal у меня где то в настройках разогнан, так что всё скромнее на самом деле.
avatar
чисто исторически мы меряем всё фреймами, успевает в один фрейм (в среднем 70000тактов) = 50фпс, в два 50/2 и.т.д что там эмуляторы показывают одному автору эмулятора известно.

время выполнения обычно меряется бордюрчиком


;ждём начала кадра
halt
;синий бордюрчик
ld a,1 : out (254),a
;что надо померять
call megaDraw
;белый бордюрчик
ld a,7 : out (254),a


и получаем синенькую полосочку чтобы визуально оценить скока процедура жрёт от всего кадра :)
avatar
ну эмуляторы которые хотят тот же мультиколор правильно отобразить таки должны крайне скурпулёзно высчитывать тайминги, поэтому я им весьма доверяю в этом вопросе. в Spectaculator Old Tower точно работает правильно, поэтому ему верю. а вот почему UnrealSpeccy показал в два раза больше для меня неожиданность — подозреваю, что у меня просто выбрана какая то разогнанная модель, но т.к. в нём настройки все и хоткеи более чем неинтуитивны, то даже не могу понять из конфига что именно там выбрано. судя по менюшкам — скорпион какой то, но не очень понятно.
avatar
P.S.
Да, наверное просто включен турбо-режим скорпиона, а где он включен даже и не пойму.
avatar
Воспользовался советами и получил такую картинку.
В принципе весьма прикольно, регулируя область можно выходить и на плавные скроллеры с 50fps. А если рисовать в бэк-буфер и выводить его каждый второй кадр, то в стабильных 25fps можно наверное наворотить вообще огого.
avatar
Написание фреймовых скроллеров было типичной забавой во времена популярности электронной прессы на ZX — каждый старался сделать фреймовую читалку. См., например, BornDead, где к последним номерам появилась плавная попиксельная цветная листалка на весь экран (на Pentagon). Правда, в подобных листалках часто хитрили, не выводя пустые строки пикселей между строками текста.
avatar
"… можно наверное наворотить вообще огого"
Дошло, что я это говорю человеку, который как раз воротит «огого!» и не раз и не два и с такими изощрениями, что «огогого!». :D Что-то как то совсем за всеми этими восторгами от техник забыл выразить своё почтение!
avatar
И еще — по ссылке выше про «получил такую картинку» на том форуме возникла неопределенность в следующем вопросе — когда ULA генерирует прерывание, то в одних документациях по ZX Spectrum 48 мы видим, что оно генерируется ровно за 64 «виртуальных» строки (каждая — 224 T-state-а) до первой строки изображения с адреса 16384.
Но судя по другим источникам так делают множественные клоны спектрума, а оригинальная модель 48k запаздывала с наступлением прерывания на 16 строк. И даже поэтому в клоны специально апгрейды встраивают в виде схемы задержки наступления прерывания, например тут: zxpress.ru/article.php?id=11847
Странная ситуация когда в разных местах написано разное — а так как multycolor при таких несоответствиях будет просто неправильно работать, то, думаю, не ошибусь если попрошу навести ясность в этой теме.
avatar
Это очень плохая ссылка, написанная человеком, который видимо не вполне разбирается в теме. И фикс им предложенный реально грязный, что собственно видно даже по его экспериментам с играми.

Оригинальная модель 48К на то и оригинальная, что никуда не опаздывает. У неё действительно прерывание происходит за 64 строки по 224 такта до начала отрисовки экрана. Можно сказать, что верхний бордер у 48К спектрума содержит 64 строки (64*224=14336 машинных тактов). На самом экране строк 192, а на нижнем бордере их ещё 56. Итого 64+192+56 = 312 строк, 312*224=69888 тактов.

128К модели воспроизвели этот расклад только приблизительно, т.к. у них другое число тактов в строке. У серого +2 например на верхнем бордере 63 строки по 228 тактов, а внизу — опять 56, т.е. всего 311 строк, 311*228=70908 тактов на кадр. В итоге верхний бордер почти такой же как на 48К (63*228=14364 тактов, на 28 тактов больше).

А вот на отечественных клонах этот промежуток времени от прерывания до начала отрисовки экрана не выдерживался даже приблизительно. Самый вопиющий пример — Ленинград, у которого кадровое прерывание приходило сразу после отрисовки самой нижней строки экрана, т.е. на Ленинграде нижнего бордера по сути не было вообще, только верхний. На Пентагоне на верхнем бордере фактически 80 строк по 224 такта, т.е. 80*224=17920 тактов, зато на нижнем бордере строк всего 48. В итоге нетривиально рассчитанные эффекты, типа мультиколорного скролла описанного выше Денисом, приходится по сути рассчитывать индивидуально для каждого из клонов.
avatar
Круто! Спасибо за подробное описание всех плясок с мультиколором и экраном!
avatar
Чуть подчистил исходники Глуфа, если кому интересно то скачать тут
Код переменного качества от среднего до ужасного, но от этого более понятый новичкам :)
avatar
Залил исходники Old Tower Скачать тут
avatar
Спасибо!
Только зарегистрированные и авторизованные пользователи могут оставлять комментарии.