Магия графики на ZX Spectrum: основы, которые должен знать каждый спектрумист
Привет, друзья! (Или просто читатели, кому интересно заглянуть под капот старых добрых ретро-игр).
Прошлая статья про расчёт угла в целых числах была положительно воспринята, мне написало несколько человек, кто-то задавал вопросы, кто-то предлагал свои варианты алгоритмов, выяснилось, что любителей попрограммировать на ZX Spectrum довольно много – это увлекательно, и вот ради чего всё это делается!
В новой своей статье я хотел подробно рассказать про отрисовку линий и прочих примитивов на Спекки, но внезапно осознал, что кто-то вероятно не знаком с тем, как вообще устроен экран спека, а ведь без этого абсолютно нельзя двигаться дальше. Это как пытаться строить дом, не зная, из чего сделан кирпич и как устроена кладка!
Поэтому давайте разложим на части этот непростой, прямо скажем, момент. Выясним, как же классический спектрум рисует что-то на экране, ну и, конечно, нарисуем что-то несложное, но просветляющее! Готовьте свои виртуальные осциллографы и паяльники (или просто эмуляторы)!
Часть 1: Секреты экрана: почему Спекки рисует именно так
Итак, когда мы включаем наш любимый Спекки, перед нами появляется то самое магическое чёрное окошко в белой рамке, по которому мелькают красные полоски и потом всё становится белое и надпись внизу. И если мы переключим фон экрана в чёрный (например, в Бейсике командой "PAPER 0: INK 7`), то увидим черное поле размером 256 пикселей по горизонтали и 192 пикселя по вертикали. Это наше основное "полотно" для творчества!
Казалось бы, что тут сложного? Это же просто сетка пикселей, как в Paint! Хочешь нарисовать точку в координатах (X, Y) – просто обращаешься к "ячейке" с этими координатами в видеопамяти, засовываешь туда нужный цвет, и готово.
Но вот тут-то и кроется первая хитрость Спектрума! Его видеопамять устроена не так прямолинейно, как можно было бы подумать. Она разделена на две большие части:
-
Пиксельная область: Здесь хранятся данные о том, включен или выключен каждый конкретный пиксель. "Включен" (значение "1") – значит, он рисуется цветом INK, "выключен" (значение "0") – цветом PAPER. Каждый пиксель представлен всего одним битом!
-
Область атрибутов: Здесь хранятся данные о том, какой именно цвет INK, какой PAPER, и какие эффекты (ЯРКОСТЬ/МИГАНИЕ) будут использоваться для группы пикселей.
Самый важный момент: эта "группа пикселей" – это не отдельный пиксель! Это блок размером 8 пикселей по горизонтали и 8 пикселей по вертикали. То есть, весь экран 256×192 разбит на сетку таких атрибутных блоков: 256 / 8 = 32 блока по горизонтали и 192 / 8 = 24 блока по вертикали. Всего 32 * 24 = 768 атрибутных блоков. Где-то в литературе эти "блоки" могут называться "символами", где-то "квадратиками".
Для каждого такого блока (8×8 пикселей) в отдельной области памяти хранится один байт атрибута. Этот байт содержит информацию:
-
Младшие 3 бита (с 0 по 2): Цвет INK (один из 8 стандартных цветов: 0 = черный, 1 = синий, 2 = красный, 3 = пурпурный, 4 = зеленый, 5 = голубой, 6 = желтый, 7 = белый).
-
Следующие 3 бита (с 3 по 5): Цвет PAPER (тоже один из тех же 8 цветов).
-
Бит 6: Флаг BRIGHT (делает цвета INK и PAPER ярче). Важно понимать, что ярче становятся оба цвета – и чернила и бумага, так что мы не можем рисовать, например, тёмно-красным по ярко-белому.
-
Бит 7: Флаг FLASH (заставляет цвета INK и PAPER этого блока постоянно меняться местами). При этом возникает забавная аппаратная анимация – весь блок пикселей "мигает" с частотой примерно 2 Гц. Эта аппаратная анимация занимает 0 тактов CPU и (гордость!) поэтому часто использовалась в ранних играх на ZX Spectrum.
Понимаете, в чем прикол? Внутри одного 8×8 блока все "включенные" пиксели будут одного цвета INK, а все "выключенные" – одного цвета PAPER, который задан байтом атрибута для этого блока. Нельзя в одном атрибутном блоке нарисовать один пиксель красным INK, а соседний – зеленым INK! Оба они будут использовать цвет, определенный атрибутом этого блока.
Это порождает знаменитый "клэшинг аттрибутов" (Attribute Clash) – когда движущийся спрайт (картинка) размером больше 8×8 пикселей проходит через блоки с разными атрибутами, части спрайта внезапно меняют свои цвета, потому что они попадают под действие атрибутов тех блоков, в которых оказались. Это особенность Спектрума, которая придавала играм уникальный вид (и головную боль разработчикам!).
Итак, чтобы нарисовать цветной пиксель в координатах (X, Y) на Спекки, нужно сделать две вещи:
-
Найти байт в пиксельной области видеопамяти, который соответствует этому пикселю, и установить нужный бит.
-
Найти байт в области атрибутов, который соответствует блоку 8×8, содержащему этот пиксель (его координаты будут (X/8, Y/8), округленные вниз), и установить нужные цвета INK/PAPER и флаги BRIGHT/FLASH.
Самое сложное здесь – это первый пункт! Пиксельная область памяти устроена не просто строка за строкой. Она хитро перемешана, чтобы упростить работу схемы вывода изображения на телевизор. Но это мы разберем подробнее в следующей части.
Для начала, давайте научимся находить адрес и нужный бит для одного пикселя и попробуем его "включить".
Часть 2: Зажигаем звезду (рисуем точку)!
Представьте, что вы хотите нарисовать всего лишь одну-единственную точку на экране Спекки. Пусть это будет точка с координатами X=50, Y=50.
На первый взгляд кажется: ну, экран 256×192. Каждая строка – 256 пикселей. Каждый пиксель – 1 бит. Значит, строка занимает 256 / 8 = 32 байта. Экран – 192 строки * 32 байта/строка = 6144 байта. Адрес точки (X, Y) должен быть начальный_адрес + Y * 32 + X / 8
, а нужный бит в этом байте – X % 8
. Логично же?
Ан нет! ZX Spectrum не был бы собой, если бы всё было так просто! Его видеопамять (пиксельная часть) устроена очень специфически, чтобы упростить работу аппаратного видеоконтроллера ULA (который, по сути, там очень примитивный). Память экрана начинается с адреса $4000 (16384 десятичное). И дальше она идет не подряд строка за строкой!
Адрес байта, содержащего пиксель (X, Y), рассчитывается по довольно хитрой формуле, но прежде чем я её напишу, давайте проведём мысленный эксперимент.
Представьте, что вы идёте от адреса $4000 вниз и заполняете за собой все пиксели (пишете значение $FF во все пройденные байты). Первые 31 байта вы будете видеть заполнение нулевой строки слева направо. Но как только выйдете за правый край, то окажетесь не на строке 1, а на строке 8! Вот так смещение +32 соответствует строке 8. Если будете идти дальше, то через ещё 32 байта появитесь на строке 16. И так далее до строки 64 (итого вам придётся пройти 256 (или $100) байтов. Но вопреки ожиданиям далее вы окажетесь не на строке 72, а на строке 1!
Огого! Выходит, чтобы сместиться на строку ниже, нам нужно будет добавить к адресу +$100. Неизвестно, почему сэр Клайв Синклер выбрал именно такой порядок, но он оказался очень удобным для вывода простых символов, которые занимают ровно один пиксельный блок! Ведь для увеличения на $100 двухбайтного регистра нужно выполнить одну короткую команду на ассемблере, например INC H
, если адрес хранится в регистре HL.
Вот так мы пройдём $800 байтов (2048 штук) и заполним ровно одну треть экрана – 64 верхние строки.
И только после этого начнём заполнять вторую треть, а потом и третью треть.
Вам ничего это не напоминает? Да-да, все мы помним как с кассеты загружается заставка любой игры – странными линиями. Это именно оно!
Итак, мы можем схематично нарисовать формулу для расчёта адреса байта.
В ней видно, что для смещения вниз на 1 линию нужно прибавить $0100, для смещения на 8 линий – прибавить $0020, а для смещения на 64 линии нужно прибавить $0800.
Вот так будет выглядеть формула на Javascript (я буду использовать Javascript для макетирования наших идей, потому что он очень простой и его можно запустить прямо в браузере!)
Adr = 0x4000 + (Y & 0xc0 << 5) + (Y & 0x38 << 2) + (Y & 0x07 << 8) + (X & 0xF8 >> 3)
Ну что же, давайте по старой традиции напишем код и для Z80 Asm!
; Пусть X будет в регистре L, а Y в регистре H.
; Будем считать, что значение Y уже находится в допустимом диапазоне (от 0 до 191)
; и не будем его проверять - для ускорения
; На выходе адрес будет в HL.
; Процедура использует и "портит" регистры A и B.
GetScrAddr
ld a, $38
and h ; выкусываем из Y небольшой кусочек в 3 бита
rlca
rlca ; двигаем его влево на 2 бита
ld b, a ; и временно сохраняем в B
ld a, $f8 ; выкусываем старшие 5 битов у X
and l
rrca ; и двигаем его на 3 бита вправо
rrca
rrca
or b ; склеиваем с B
ld l, a ; вычисление L закончено! Теперь вычисляем H
ld a, $c0
and h ; выкусываем 2 старших бита из Y
rrca
rrca
rrca ; двигаем их вправо на 3 бита
ld b, a ; пока сохраним в B
ld a, $07 ; выкусываем 3 младших бита из Y
and h
or b ; их двигать не нужно - они уже на месте. Просто склеиваем их с B
or $40 ; и приклеиваем $40, чтобы получить адрес $4000-$57ff
ld h, a ; H готов!
ret
Фух! Выдохнули. Теперь мы знаем адрес байта. Но этого мало! В этом байте 8 пикселей. Какой из них наш? Нужный пиксель (X, Y) – это бит номер X % 8
(остаток от деления на 8) (или X & $07
) в этом байте. Причем, бит 7 – это самый левый пиксель из восьми, бит 0 – самый правый. То есть, нам нужен бит с номером 7 - (X & $07)
.
Осталось только "включить" этот бит, то есть установить его в 1
, не трогая остальные 7 пикселей в этом байте. Для этого мы читаем байт из видеопамяти, делаем побитовую операцию ИЛИ (OR
) с маской, в которой установлен только наш нужный бит, и записываем результат обратно. Маска для бита b
– это 1 << b
. Значит, для бита 7 - (X & $07)
маска будет 1 << (7 - (X & $07))
.
Теперь давайте попробуем рассчитать на пальцах адрес для X=50, Y=50:
Y = 50
. В двоичном:00110010
.X = 50
. В двоичном:00110010
.
Расчет адреса байта:
Y & $C0
(00110010
&11000000
) =00000000
. Сдвиг << 5 = 0.Y & $38
(00110010
&00111000
) =00110000
(0x30). Сдвиг << 2 =11000000
(0xc0).Y & $07
(00110010
&00000111
) =00000010
(2). Сдвиг << 8 =10 0000000
(0x0200).X >> 3
(00110010
>> 3) =00000110
(6).
Всё суммируем и получаем адрес = $4000
(16384) + 0 + 192 + 512 + 6 = 16384 + 710 = 17094 ($42C6).
Расчет нужного бита:
X & $07
(00110010
&00000111
) =00000010
(2).- Нужный бит =
7 - 2 = 5
. - Маска для бита 5:
1 << 5
=00100000
($20).
Теперь, чтобы нарисовать точку (50, 50), нам нужно:
- Прочитать байт по адресу $42C6 – но мы можем опустить этот шаг для простоты, если у нас пустой экран.
- Сделать побитовое ИЛИ с маской $20 (чтобы установить бит 5).
- Записать измененный байт обратно по адресу $42C6.
А что с цветом? Мы же еще не задали цвет! Для этого нужно обратиться к области атрибутов.
Адрес байта атрибута для точки (X, Y) считается гораздо проще:
Адрес_атрибута = $5800 + (Y >> 3) * 32 + (X >> 3)
Для X=50, Y=50:
Y / 8
(50 / 8 = 6 с остатком) = 6.X / 8
(50 / 8 = 6 с остатком) = 6.
Адрес атрибута = $5800
(22528) + 6 * 32 + 6 = 22528 + 192 + 6 = 22726 ($58C6).
По адресу $58C6 лежит байт атрибута для блока 8×8 пикселей, который содержит точку (50, 50). Если мы хотим, чтобы наша точка рисовалась ярко-красным (BRIGHT=1, INK=2
), а фон был черным (PAPER=0
), этот байт должен быть:
BRIGHT=1 (бит 6), FLASH=0 (бит 7), PAPER=0 (биты 3-5), INK=2 (биты 0-2)
.
Двоичное: 0 1 000 010
= %01000010
= $42.
Итак, чтобы нарисовать ярко-красную точку (50, 50) на черном фоне:
- По адресу $58C6 записываем байт $42.
- По адресу $42C6 читаем байт, делаем ИЛИ с $20, записываем обратно.
Вот и наша точка! Посмотрите на наш рисунок ниже: там чёрный квадрат, а внутри маааленькая красная точка!
Вы можете посмотреть код и поэкспериментировать с ним, нажав на иконку в углу этого рисунка и далее выбрав "CODE".
Часть 3: оживляем байты: рисуем смешную рожицу 8×8
Итак, в прошлой части мы с боями пробились к пониманию того, как найти адрес одного-единственного пикселя в этой хитроумной видеопамяти Спекки и как его "зажечь", установив нужный бит в нужном байте по нужному адресу. Фух! Но ведь картинка – это не одна точка, это много-много точек!
Самый базовый графический примитив после точки – это, по сути, символ. На Спектруме символы (буквы, цифры, значки) имеют стандартный размер 8 пикселей в ширину и 8 пикселей в высоту. Как они хранятся? Очень просто: каждая строка такого символа – это один байт!
Почему? Потому что 8 пикселей – это ровно 8 бит. А байт – это 8 бит! То есть, байт 10110010
означает, что в этой строке символа включены (INK) пиксели на позициях 7, 5, 4 и 1 (считая слева направо от 7 до 0), а остальные выключены (PAPER).
Бит: 7 6 5 4 3 2 1 0 (позиция пикселя слева направо)
Байт: 1 0 1 1 0 0 1 0 -> # . # # . . # .
Так вот, наш символ 8×8 хранится как последовательность из восьми байтов. Первый байт – это верхняя строка символа, второй – следующая, и так до восьмого байта – самой нижней строки.
Мы хотим нарисовать смешную рожицу. Вот ее "матрица" из восьми байтов:
$3c, $42, $81, $a5, $81, $99, $42, $3c
(Кто-то запоминает наизусть стихи и названия столиц, у нас, у спектрумистов – особый дар, мы запоминаем цифры и коды. Я, например, помню точные байты для рисования этой рожицы ещё с 1993 года.)
Давайте переведем эти байты в бинарный вид и посмотрим, как они выглядят, если представить 1
как #
(цвет INK) и 0
как .
(цвет PAPER):
0x3c
=00111100
->..####..
0x42
=01000010
->.#....#.
0x81
=10000001
->#......#
0xa5
=10100101
->#.#..#.#
0x81
=10000001
->#......#
0x99
=10011001
->#..##..#
0x42
=01000010
->.#....#.
0x3c
=00111100
->..####..
Как нарисовать такой 8×8 блок на экране, начиная с координат (X, Y)?
Мы знаем, как найти адрес байта для пикселя (X, Y). Назовем его Базовый адрес
. Это адрес байта, который будет содержать первую строку нашей рожицы (0x3c
). Но где находятся байты для следующих строк (Y+1, Y+2, …, Y+7)?
Как мы выяснили в прошлой части, из-за хитрого устройства видеопамяти Спектрума это не так! Адреса байтов для последовательных строк (в пределах одного 8-строчного блока Y) отстоят друг от друга на 256 байт.
(Конечно, это верно, пока Y и Y+7 находятся внутри одного 8-строчного блока по Y, то есть Y % 8 + 7 < 8
. Но для рисования 8×8 символа мы обычно выбираем Y так, чтобы Y было кратно 8, например Y=0, 8, 16… В этом случае весь символ точно попадает в один такой 8-строчный блок по Y, и это правило 256-байтного шага работает для всех 8 строк символа).
Кроме пиксельных байтов, нам, конечно, нужен и атрибут! Весь наш символ 8×8 попадает ровно в один атрибутный блок. Значит, все пиксели этой рожицы будут использовать одни и те же цвета INK/PAPER и те же флаги BRIGHT/FLASH, которые мы запишем в байт атрибута для блока, содержащего (X, Y). Адрес этого атрибутного байта мы тоже умеем считать из прошлой части: $5800 + (Y / 8) * 32 + (X / 8)
.
Итак, алгоритм рисования 8×8 символа/рожицы в координатах (X, Y) такой:
- Выбрать желаемый байт атрибута (например, синий INK на белом PAPER =
%0 0 111 001
= $39). - Рассчитать адрес атрибутного байта для блока (X, Y):
AttrAddress(X, Y)
. - Записать выбранный байт атрибута по адресу
AttrAddress(X, Y)
. - Рассчитать базовый адрес пиксельного байта для (X, Y): при этом мы возьмём Y и X не такие, какие брали для точки, а округлим их до 8. Это будет X = 48 и Y = 48.
- Пишем последовательно 8 байт нашего изображения, увеличивая адрес на $100.
Давайте посмотрим на код.
// Это массив, в котором мы храним "графические данные" нашего смайлика
const smiley_data = [
0x3c, 0x42, 0x81, 0xa5, 0x81, 0x99, 0x42, 0x3c
];
let adr = 0x40c6; // Это адрес верхнего (нулевого) байта места, куда мы собираемся вывести смайлик
for (let i = 0; i < 8; i ++) {
ram[adr] = smiley_data[i]; // В цикле 8 раз перебрасываем данные из массива в видеопамять
adr = adr + 0x100; // смещаемся на следующую линию
}
ram[0x58c6] = 0x39; // Адрес атрибута для нашей рожицы и её цвет
И по традиции, код на Asm:
DrawSmile:
ld hl, $40c6 ; HL - адрес в видеоОЗУ
ld de, .smile_data ; DE указывает на массив данных смайла
ld b, 8 ; B будет считать до 8
.loop
ld a, (de) ; берём байт из DE
ld (hl), a ; и кладём в HL
inc de ; смещаемся на следующий байт смайла
inc h ; линию вниз на экране (это всё равно, что HL = HL + $0100, только быстрее, ведь L не меняется, а H увеличивается на 1)
djnz .loop ; это зацикливает нашу программу 8 раз, чтобы повторить всё то же самое для всех 8 байтов
ret
.smile_data
db $3c, $42, $81, $a5, $81, $99, $42, $3c
Итак, мы научились брать 8 байт данных, представляющих картинку 8×8 пикселей, находить нужный атрибутный блок и записывать туда цвет, и, самое главное, записывать эти 8 байт в правильные места пиксельной памяти, учитывая 256-байтный шаг между строками. Это очень важный навык для любого, кто хочет рисовать на Спектруме что-то сложнее одной точки!
В следующей части мы углубимся в то, как можно оптимизировать расчет адреса пикселя, чтобы не делать все эти сложные битовые операции каждый раз "с нуля".
Часть 4: Пробиваемся сквозь память: оптимизируем расчет адресов
Мы научились находить адрес одного байта пиксельной памяти для точки (X, Y), адрес байта атрибута для блока (X, Y) и даже записывать 8 байт для символа, зная, что строки отстоят друг от друга на 256 байт. Это здорово! Но если присмотреться к формуле расчета адреса пиксельного байта:
Адрес = $4000 + ((Y & $C0) << 5) + ((Y & $38) << 2) + ((Y & $07) << 8) + (X >> 3)
Видно, что она довольно громоздкая. Каждый раз для каждой точки, для каждого байта примитива (линии, спрайта) выполнять все эти битовые операции, сдвиги и сложения на Z80 – это будет отнимать драгоценное время процессора. Если мы хотим рисовать быстро движущиеся объекты или много деталей, нам нужно что-то пошустрее!
Как обычно, есть несколько путей оптимизации.
Способ 1: Двигаемся постепенно (инкрементальный расчет)
А что, если вместо того, чтобы считать адрес с нуля $4000
каждый раз, мы будем переходить от уже известного адреса к соседнему? Как спускаться вниз на одну строку мы уже разобрались – просто прибавляем $100! Но это не будет работать везде. Ведь когда мы достигнем 7й линии в пиксельном блоке 8*8, то нам придётся вернуться на $700 (7 строк вверх) и потом увеличить адрес на $20! Но давайте от простого к сложному.
-
Сдвиг на пиксель вправо (+1 к X): Если речь идёт о выводе одного пикселя, то мы просто сдвигаем маску байта на 1 бит вправо. Например, если у нас была маска $20 для координаты X= 50, то чтобы напечатать точку с координатами X=51 нам нужно просто крутануть байт на 1 бит вправо, при этом адрес видеоозу даже не меняется! Однако, если наша точка уже прижата к правому краю байта (значение $01), то нам всё-таки придётся увеличить адрес на 1. Но это ведь так просто!
-
Сдвиг на пиксель влево (-1 к X): Тут всё полностью аналогично сдвигу вправо, но наоборот. Если бит не прижат к левому краю (значение не $80), то двигаем байт влево на 1 бит. Иначе уменьшаем адрес видеопамяти.
-
Сдвиг на строку вниз (+1 к Y): Вот тут начинаются сложности! Если мы находимся по адресу
Addr(X, Y)
и хотим перейти кAddr(X, Y+1)
. Мы уже знаем, что байты последовательных строк в пределах одного 8-строчного блока отстоят на 256 байт. Значит, еслиY % 8
не равен 7, адрес следующей строки простоAddr(X, Y) + 256
! Это быстрое сложение на Z80 (ADD HL, $0100
).
Но еслиY % 8
равен 7 (мы в последней строке 8-строчного блока), переход на Y+1 означает пересечение границы блока. И тут смещение уже не 256! Оно зависит от того, в каком блоке и какой "трети" экрана мы были. Расчет адреса для (X, Y+1) после пересечения границы потребует либо полного пересчета адреса с нуля по сложной формуле, либо другой хитрости. -
Сдвиг на строку вверх (-1 к Y): Аналогично, если
Y % 8
не равен 0, адрес предыдущей строкиAddr(X, Y) - 256
. ЕслиY % 8
равен 0 (мы в первой строке 8-строчного блока), снова пересекаем границу и нужен полный пересчет или хитрость. Замечу, что движение вверх при отрисовке графики на ZX Spectrum применяется крайне редко, но оно также имеет место быть.
Вот какая хитрость используется. Мы можем проверять текущий адрес и на основе него делать вывод о том – сколько прибавить к адресу. Это алгоритм также широко известен как "LINEDOWN_HL Algorithm".
; Вход: HL = Текущий адрес, из которого мы хотим попасть на следующую линию
; Выход: HL = Адрес следующей линии на экране
; Использует: регистр A.
LineDown:
inc h ; пробуем вниз на 1 линию
ld a, h
and 7 ; проверяем, что мы не перепрыгнули через границу блока 8*8 (например, было $47, стало $48)
ret nz ; если младшие 3 бита не нулевые, значит всё в пределах блока, конец.
ld a, l ; если да, то надо исправить, потому что мы явно пришли не туда
add a, 32 ; смещаемся на 32 байта (попадаем в следующую линию) - например, было $48e5, стало $4805 и перенос
ld l, a
ret c ; если при суммировании произошло переполнение, значит мы правильно попали в следующую треть ($4805 - это как раз тот случай)
ld a, h ; Ну а если нет, значит мы промахнулись и нужно скорректировать H - вернуться в предыдущую треть (например, было $47c5, ошибочно
sub 8 ; Попали сперва в $48c5, потом в $48e5 - нет переноса.
ld h, a ; Уменьшаем H на 8, чтобы получить $40e5 - правильный адрес.
ret
Но всё это требует времени. Расчёт начального адреса, потом переход вниз для отрисовки каждого байта… неужели нельзя быстрее?
Можно! Причём КАРДИНАЛЬНО БЫСТРЕЕ.
Способ 2: Справочник на все случаи (табличный метод)
Самый, наверное, популярный способ быстро находить адрес для любых координат (X, Y) – это использовать заранее рассчитанную таблицу.
-
Таблица адресов строк: Создаем в памяти таблицу (массив 16-битных слов), которая для каждого Y (от 0 до 191) хранит адрес первого байта (
X=0
) в этой строке. Таблица будет иметь 192 элемента, каждый по 2 байта (адрес занимает 16 бит). Общий размер: 192 * 2 = 384 байта.
Как получить адрес для (X, Y) с такой таблицей?- Берем Y.
- Используем Y как индекс для нашей таблицы
RowStartTable
. Получаембазовый_адрес = RowStartTable[Y]
. Это адрес байта для (0, Y). - Прибавляем смещение по X:
Адрес = базовый_адрес + (X >> 3)
(X / 8).
Обращение к таблице (загрузка 16-битного значения по индексу) и одно 16-битное сложение – это очень быстро на Z80! Это, пожалуй, самый быстрый способ получить адрес пиксельного байта для произвольных координат (X, Y).
Таблицу для расчёта адреса в атрибутной области мы не будем тут приводить, наверняка вы поняли логику и сможете сделать её по аналогии. Ради справедливости замечу, что атрибутный адрес рассчитать намного проще и как правило это делают без табличек, просто сдвигами регистров.
Таблицы занимают память, но дают огромный выигрыш в скорости, когда нужно часто обращаться к произвольным координатам.
Давайте же напишем код для расчёта адреса табличным методом (например, для нашего пикселя из первой части) и проверим его работу!
// Самое главное - это вот эта таблица!
const scr_adr = [
0x4000, 0x4100, 0x4200, 0x4300, 0x4400, 0x4500, 0x4600, 0x4700,
0x4020, 0x4120, 0x4220, 0x4320, 0x4420, 0x4520, 0x4620, 0x4720,
0x4040, 0x4140, 0x4240, 0x4340, 0x4440, 0x4540, 0x4640, 0x4740,
0x4060, 0x4160, 0x4260, 0x4360, 0x4460, 0x4560, 0x4660, 0x4760,
0x4080, 0x4180, 0x4280, 0x4380, 0x4480, 0x4580, 0x4680, 0x4780,
0x40a0, 0x41a0, 0x42a0, 0x43a0, 0x44a0, 0x45a0, 0x46a0, 0x47a0,
0x40c0, 0x41c0, 0x42c0, 0x43c0, 0x44c0, 0x45c0, 0x46c0, 0x47c0,
0x40e0, 0x41e0, 0x42e0, 0x43e0, 0x44e0, 0x45e0, 0x46e0, 0x47e0,
0x4800, 0x4900, 0x4a00, 0x4b00, 0x4c00, 0x4d00, 0x4e00, 0x4f00,
0x4820, 0x4920, 0x4a20, 0x4b20, 0x4c20, 0x4d20, 0x4e20, 0x4f20,
0x4840, 0x4940, 0x4a40, 0x4b40, 0x4c40, 0x4d40, 0x4e40, 0x4f40,
0x4860, 0x4960, 0x4a60, 0x4b60, 0x4c60, 0x4d60, 0x4e60, 0x4f60,
0x4880, 0x4980, 0x4a80, 0x4b80, 0x4c80, 0x4d80, 0x4e80, 0x4f80,
0x48a0, 0x49a0, 0x4aa0, 0x4ba0, 0x4ca0, 0x4da0, 0x4ea0, 0x4fa0,
0x48c0, 0x49c0, 0x4ac0, 0x4bc0, 0x4cc0, 0x4dc0, 0x4ec0, 0x4fc0,
0x48e0, 0x49e0, 0x4ae0, 0x4be0, 0x4ce0, 0x4de0, 0x4ee0, 0x4fe0,
0x5000, 0x5100, 0x5200, 0x5300, 0x5400, 0x5500, 0x5600, 0x5700,
0x5020, 0x5120, 0x5220, 0x5320, 0x5420, 0x5520, 0x5620, 0x5720,
0x5040, 0x5140, 0x5240, 0x5340, 0x5440, 0x5540, 0x5640, 0x5740,
0x5060, 0x5160, 0x5260, 0x5360, 0x5460, 0x5560, 0x5660, 0x5760,
0x5080, 0x5180, 0x5280, 0x5380, 0x5480, 0x5580, 0x5680, 0x5780,
0x50a0, 0x51a0, 0x52a0, 0x53a0, 0x54a0, 0x55a0, 0x56a0, 0x57a0,
0x50c0, 0x51c0, 0x52c0, 0x53c0, 0x54c0, 0x55c0, 0x56c0, 0x57c0,
0x50e0, 0x51e0, 0x52e0, 0x53e0, 0x54e0, 0x55e0, 0x56e0, 0x57e0,
];
// А это собственно расчёт адреса!
let x = 50;
let y = 50;
let adr = scr_adr[y] + ((x & 0xF8) >> 3);
// Это всё! Так просто!
Но самое интересное – реализация на асме!
; Условия точно такие же, как в первом варианте - на входе H = Y, L = X,
; На выходе в HL будет адрес.
; Процедура портит регистр DE и регистр A.
GetScrAdr_Table:
ld a, $f8 ; отбрасываем 3 младших бита, они не нужны
rrca ; двигаем вправо 3 раза - чтобы получить X/8
rrca
rrca
ld e, h ; Y - это индекс в таблице
ld d, 0
ex de, hl
add hl, hl ; так мы умножаем смещение в таблице на 2. ведь у нас 2 байта на каждый элемент
ld de, .scrtab
add hl, de ; прибавляем адрес таблицы, чтобы получить адрес элемента в ней
or (hl) ; склеиваем первый байт со смещением от X/8
ld e, a ; результат пока пишем в E
inc hl ; переходим к следующему байту таблицы
ld d, (hl) ; читаем из него старшую половину адреса
ex de, hl ; теперь ловко меняем DE и HL и результат у нас теперь в HL!
ret
.scrtab
; тут нам подходит таблица из нашего примера на js
dw 0x4000, 0x4100, 0x4200, 0x4300, 0x4400, 0x4500, 0x4600, 0x4700
dw 0x4020, 0x4120, 0x4220, 0x4320, 0x4420, 0x4520, 0x4620, 0x4720
dw 0x4040, 0x4140, 0x4240, 0x4340, 0x4440, 0x4540, 0x4640, 0x4740
dw 0x4060, 0x4160, 0x4260, 0x4360, 0x4460, 0x4560, 0x4660, 0x4760
dw 0x4080, 0x4180, 0x4280, 0x4380, 0x4480, 0x4580, 0x4680, 0x4780
dw 0x40a0, 0x41a0, 0x42a0, 0x43a0, 0x44a0, 0x45a0, 0x46a0, 0x47a0
dw 0x40c0, 0x41c0, 0x42c0, 0x43c0, 0x44c0, 0x45c0, 0x46c0, 0x47c0
dw 0x40e0, 0x41e0, 0x42e0, 0x43e0, 0x44e0, 0x45e0, 0x46e0, 0x47e0
dw 0x4800, 0x4900, 0x4a00, 0x4b00, 0x4c00, 0x4d00, 0x4e00, 0x4f00
dw 0x4820, 0x4920, 0x4a20, 0x4b20, 0x4c20, 0x4d20, 0x4e20, 0x4f20
dw 0x4840, 0x4940, 0x4a40, 0x4b40, 0x4c40, 0x4d40, 0x4e40, 0x4f40
dw 0x4860, 0x4960, 0x4a60, 0x4b60, 0x4c60, 0x4d60, 0x4e60, 0x4f60
dw 0x4880, 0x4980, 0x4a80, 0x4b80, 0x4c80, 0x4d80, 0x4e80, 0x4f80
dw 0x48a0, 0x49a0, 0x4aa0, 0x4ba0, 0x4ca0, 0x4da0, 0x4ea0, 0x4fa0
dw 0x48c0, 0x49c0, 0x4ac0, 0x4bc0, 0x4cc0, 0x4dc0, 0x4ec0, 0x4fc0
dw 0x48e0, 0x49e0, 0x4ae0, 0x4be0, 0x4ce0, 0x4de0, 0x4ee0, 0x4fe0
dw 0x5000, 0x5100, 0x5200, 0x5300, 0x5400, 0x5500, 0x5600, 0x5700
dw 0x5020, 0x5120, 0x5220, 0x5320, 0x5420, 0x5520, 0x5620, 0x5720
dw 0x5040, 0x5140, 0x5240, 0x5340, 0x5440, 0x5540, 0x5640, 0x5740
dw 0x5060, 0x5160, 0x5260, 0x5360, 0x5460, 0x5560, 0x5660, 0x5760
dw 0x5080, 0x5180, 0x5280, 0x5380, 0x5480, 0x5580, 0x5680, 0x5780
dw 0x50a0, 0x51a0, 0x52a0, 0x53a0, 0x54a0, 0x55a0, 0x56a0, 0x57a0
dw 0x50c0, 0x51c0, 0x52c0, 0x53c0, 0x54c0, 0x55c0, 0x56c0, 0x57c0
dw 0x50e0, 0x51e0, 0x52e0, 0x53e0, 0x54e0, 0x55e0, 0x56e0, 0x57e0
Но существуют и другие варианты решений с таблицей для Z80 Asm! Например, можно разместить таблицу иначе и тем самым сэкономить несколько тактов на расчёте адреса.
; Условия точно такие же, как в первом варианте - на входе H = Y, L = X,
; На выходе в HL будет адрес.
; Процедура портит регистр DE и регистр A.
GetScrAdr_Table:
ex de, hl ; теперь D = Y, E = X
ld l, d ; мы записываем в L младший байт адреса таблицы - смещение
ld h, HIGH(.scrtab) ; а в H - старший байт адреса таблицы
ld a, $f8
and e
rrca
rrca
rrca
or (hl) ; Получаем младший байт адреса и сразу склеиваем его с частью, полученной из X
inc h ; смещаемся на вторую половинку таблицы, где хранятся старшие байты адреса
ld h, (hl) ; получаем старший байт адреса и сразу записываем его на место - в регистр H
ld l, a ; В L записываем полученное смещение
ret
align 256 ; это гарантирует нам, что таблица будет размещена с адреса, кратного 256 - нам это важно
.scrtab
; младшие половинки адресов
db $00, $00, $00, $00, $00, $00, $00, $00, $20, $20, $20, $20, $20, $20, $20, $20
db $40, $40, $40, $40, $40, $40, $40, $40, $60, $60, $60, $60, $60, $60, $60, $60
db $80, $80, $80, $80, $80, $80, $80, $80, $a0, $a0, $a0, $a0, $a0, $a0, $a0, $a0
db $c0, $c0, $c0, $c0, $c0, $c0, $c0, $c0, $e0, $e0, $e0, $e0, $e0, $e0, $e0, $e0
db $00, $00, $00, $00, $00, $00, $00, $00, $20, $20, $20, $20, $20, $20, $20, $20
db $40, $40, $40, $40, $40, $40, $40, $40, $60, $60, $60, $60, $60, $60, $60, $60
db $80, $80, $80, $80, $80, $80, $80, $80, $a0, $a0, $a0, $a0, $a0, $a0, $a0, $a0
db $c0, $c0, $c0, $c0, $c0, $c0, $c0, $c0, $e0, $e0, $e0, $e0, $e0, $e0, $e0, $e0
db $00, $00, $00, $00, $00, $00, $00, $00, $20, $20, $20, $20, $20, $20, $20, $20
db $40, $40, $40, $40, $40, $40, $40, $40, $60, $60, $60, $60, $60, $60, $60, $60
db $80, $80, $80, $80, $80, $80, $80, $80, $a0, $a0, $a0, $a0, $a0, $a0, $a0, $a0
db $c0, $c0, $c0, $c0, $c0, $c0, $c0, $c0, $e0, $e0, $e0, $e0, $e0, $e0, $e0, $e0
db 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 ; данные для смещений 192 и больше забиваем нулями,
db 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 ; потому что при Y >= 192 ничего не должно выводиться
db 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
db 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
; теперь - старшие половинки адресов
db $40, $40, $40, $40, $40, $40, $40, $40, $40, $40, $40, $40, $40, $40, $40, $40
db $40, $40, $40, $40, $40, $40, $40, $40, $40, $40, $40, $40, $40, $40, $40, $40
db $40, $40, $40, $40, $40, $40, $40, $40, $40, $40, $40, $40, $40, $40, $40, $40
db $40, $40, $40, $40, $40, $40, $40, $40, $40, $40, $40, $40, $40, $40, $40, $40
db $48, $48, $48, $48, $48, $48, $48, $48, $48, $48, $48, $48, $48, $48, $48, $48
db $48, $48, $48, $48, $48, $48, $48, $48, $48, $48, $48, $48, $48, $48, $48, $48
db $48, $48, $48, $48, $48, $48, $48, $48, $48, $48, $48, $48, $48, $48, $48, $48
db $48, $48, $48, $48, $48, $48, $48, $48, $48, $48, $48, $48, $48, $48, $48, $48
db $50, $50, $50, $50, $50, $50, $50, $50, $50, $50, $50, $50, $50, $50, $50, $50
db $50, $50, $50, $50, $50, $50, $50, $50, $50, $50, $50, $50, $50, $50, $50, $50
db $50, $50, $50, $50, $50, $50, $50, $50, $50, $50, $50, $50, $50, $50, $50, $50
db $50, $50, $50, $50, $50, $50, $50, $50, $50, $50, $50, $50, $50, $50, $50, $50
db 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 ; данные для смещений 192 и больше забиваем нулями,
db 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 ; потому что при Y >= 192 ничего не должно выводиться
db 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
db 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
Ещё одно преимущество табличного метода в том, что адреса в таблице идут друг за другом, так что у нас исчезает необходимость использовать алгоритмы вроде LineDown для перехода на следующую линию. Один раз высчитав адрес в таблице мы просто берём из неё адреса один за другим и выходит, что мы смещаемся вниз, линия за линией!
; Предположим, что нам надо вывести некий очень высокий объект, высотой больше 8 пикселей (например, 24)
; Тогда алгоритм мог бы выглядеть как-то так
; H = Y, L = X - как обычно.
DrawTallObject:
ld a, $f8 ; отбрасываем 3 младших бита, они не нужны
rrca ; двигаем вправо 3 раза - чтобы получить X/8
rrca
rrca
ld c, a ; отправляем смещение адреса по X в постоянное хранилище - регистр BC
ld b, 0
ld e, h ; Y - это индекс в таблице
ld d, 0
ex de, hl
add hl, hl ; так мы умножаем смещение в таблице на 2. ведь у нас 2 байта на каждый элемент
ld de, .scrtab
add hl, de ; прибавляем адрес таблицы, чтобы получить адрес элемента в ней
ld e, (hl)
inc hl
ld d, (hl)
inc hl
ex de, hl ; сохраняем адрес таблицы в DE, а в HL будет адрес начала строки
add hl, bc
; теперь в HL у нас нужный байт - выводим строку объекта сюда
; .......
; теперь переходим на следующую строку, это будет просто!
ex de, hl ; вернули в HL адрес таблицы
; повторяем чтение адреса из таблицы (это нужно будет делать 24 раза, для каждой строки высокого объекта)
ld e, (hl)
inc hl
ld d, (hl)
inc hl
ex de, hl ; сохраняем адрес таблицы в DE, а в HL будет адрес начала строки
add hl, bc
; теперь у нас есть адрес на строку ниже, считать почти ничего не пришлось!
; ....
ret
В общем и целом, алгоритмов вывода графики на спектруме очень и очень много. И мы обязательно постараемся рассмотреть их в будущих статьях.
Сравнение Методов:
-
Прямой расчет формулы: Значительно медленнее, чем табличный поиск. Не требует памяти для таблиц (кроме $4000). Хорош, если места под таблицы нет, а произвольный доступ нужен.
-
Табличный: Самый быстрый способ получить адрес для любых произвольных координат (X, Y) или (X, Y/8). Требует минимум 384 байт для пиксельных строк или 48 байт для атрибутных строк.
На практике, в играх часто используют комбинацию: таблицы для быстрого получения начала строки или блока, а затем инкрементальные методы для движения внутри строки или блока.
Понимание всех этих способов расчета адреса – ключик к быстрой и эффективной графике на ZX Spectrum! Зная, как найти нужный байт и нужный бит, можно переходить к более сложным вещам – рисованию линий, окружностей и, конечно, спрайтов!
Заключение и выводы
Ну что, друзья! Мы с вами проделали немалый путь в этом посте. От эмоционального вступления о магии ретро-игр и проблемах Z80 мы углубились прямо в самое сердце – или, вернее, в "мозги" – графической подсистемы ZX Spectrum!
Мы узнали, что экран Спекки – это не просто однородная сетка пикселей, а хитрое сочетание двух областей памяти: пиксельной, которая говорит, светится пиксель или нет, и области атрибутов, которая для целых блоков 8×8 пикселей определяет цвета (INK/PAPER) и эффекты (BRIGHT/FLASH). Поняли, почему возникает знаменитый "клэшинг атрибутов" и выяснили, что это не баг, это – фича!
Затем, вооружившись этим знанием, мы бросились на штурм адресной арифметики. Разобрались (или, по крайней мере, увидели), насколько непросто рассчитать адрес всего лишь одного пикселя из его координат (X, Y) из-за своеобразного расположения данных в пиксельной памяти. Но мы нашли и формулы, и JS код, и даже примерный Z80 код, который показывает, как это сделать.
От одной точки перешли к 8×8 символу – базовому строительному блоку многих ретро-игр. Поняли, как данные символа (8 байт, каждый описывает строку из 8 пикселей) ложатся в видеопамять, и что байты последовательных строк символа (в пределах 8-строчного блока по Y) отстоят на 256 байт друг от друга.
И, наконец, заглянули в арсенал ретро-оптимизаций, рассмотрев разные способы быстрого расчета адресов: от инкрементальных сдвигов (хорошо для движений внутри блока) до скоростного табличного поиска (отлично для произвольного доступа) и хитрой прямой битовой гимнастики в ассемблере.
Понимание того, как устроен экран Спекки, как пиксели и цвета живут в памяти, и как быстро находить нужные адреса – это абсолютный фундамент для любой графики на этой платформе. Без этого ни линии не нарисуешь эффективно, ни спрайт быстро не переместишь, ни красивый фон не создашь.
Это знание – ваш ключик к раскрытию потенциала Спекки. Это часть той самой "магии", когда, зная тонкости железа, можно заставить его делать вещи, которые кажутся невозможными на первый взгляд. И пусть расчет адреса пикселя выглядит пугающе, освоив его, вы почувствуете себя настоящим повелителем байтов!
Я очень надеюсь, что эта статья "просветила" вас в устройстве графики ZX Spectrum и дала пищу для размышлений. Самое лучшее, что вы можете сделать сейчас – это взять эмулятор или реальное железо, взять приведенные примеры кода (особенно ассемблерный, после адаптации под ваш любимый ассемблер!) и попробовать сами! Нарисуйте точку, нарисуйте рожицу, попробуйте нарисовать их в разных местах, разных цветов. Поиграйте с битами атрибутов. Посмотрите, как это выглядит. Это самый лучший способ закрепить материал.
И, конечно же, не стесняйтесь делиться своими успехами, трудностями, вопросами или, может быть, своими собственными, еще более крутыми способами расчета адресов в комментариях под этой статьей! Обмен знаниями – это именно то, что движет нашим увлечением ретро-программированием.
Спасибо огромное за то, что дочитали до конца и разделили со мной это погружение в мир графики ZX Spectrum! До новых встреч!
С любовью к Спекки и пикселям,