Урок 8

Версия игры, реализованная в предыдущих уроках, выводит графику "псевдоспрайтами" - ASCII-символами. Есть вполне успешные игры игры, основанные на подобном виде графики - например Dwarf Fortress - хотя, конечно, в 70-x, да и в 80-х годах прошлого века таких игр было несравненно больше, даже сам тетрис - изначально был создан c выводом графики символами алфавита, фигурки тетриса составлялись из буквы "Ш". Cуществовало и множество текстовых игр - но если текстовые игры не завязаны на особенности вывода изображения, и играть в них можно, к примеру, с использованием голосового синтезатора вместо монитора, игры с ASCII-графикой буквами именно отрисовывают картинку.
В ходе развития компьютерных игр появлялось множество различных вариантов отрисовки графики игр, главенствующим на насколько десятилетий закрепилась спрайтовая графика. Спрайтовая графика отличается от ASCII-графики тем, что ASCII использует набор символов: неизменных, не всегда подходящих и - зависящих от устройства вывода. Зачастую у игры нет возможности задать устройству вывода цвет, соотношение высоты буквы к её ширине, к примеру, и даже нет возможности проверить что все символы будут одинаковой ширины - а попытка вывести картинку символами различной ширины картинку разрушит непоправимо. Спрайтовая же графика позволяет контролировать спрайт (при условии, что пиксель на мониторе "квадратный", с шириной равной высоте - а это практически стандарт уже очень давно, и если на самом экране это вдруг не так - то само устройство вывода берет на себя заботу, чтобы изображение сохраняло правильные пропорции). Спрайты более приспособлены для создания двухмерных игр - и во времена расцвета спрайтовых игр компьютеры с большим трудом (трудом программистов, естественно) ещё могли обрабатывать трёхмерные миры. С развитием компьютеров и технологий компьютерной графики появлялось всё больше трёхмерных игр - довольно быстро основным способом построения трёхмерной графики стало составление изображений из полигонов-треугольников (хотя существовали и существуют другие подходы).
Но запрос на создание трёхмерных игр уже как минимум в 1974 году привёл к появлению игр MazeWar (псевдо-3D) и Spasim (полноценная трёхмерная игра). Трёхмерное изображение в Spasim (и в MazeWar, частично) отображается с помощью двухмерных отрезков. Сейчас в современных играх изображения составляются почти так же, на основании треугольных полигонов. Треугольный полигон - это просто закрашенный треугольник. Треугольник в современных играх отрисовывается заполненным, но в первых трёхмерных играх полигоны отрисовывались полыми, с видимыми гранями - и невидимыми поверхностями. Закрасить треугольник сложнее, чем нарисовать только линию, а грани треугольника и являются именно отрезками. Так что для создания трёхмерной игры в первую очередь стоит начать с вывода на экран отрезков.
Для отрисовки линий на графопостроителях (устройства вывода изображения на бумаге, наносящие изображения не точками - растром, как мы привыкли, а рисующие их линиями) был предложен очень эффективный по нагрузке на вычислительное устройство алгоритм Брезенхема. С готовым алгоритмом можно ознакомиться внизу урока, в течение урока показано, как можно прийти от наивной реализации к эффективной отрисовке линии с помощью Брезенхема. Для создания современных игр алгоритм не нужен вообще, так как отрисовкой треугольников (и линий) занимается видеокарта. Но пока что игра пишется с так называемым софтовым рендером, работающем на процессоре, а не на видеокарте - и все графические операции для которого предстоит реализовать самостоятельно.
В третьем уроке уже осуществлялась отрисовка линии, для этого не было выделено отдельной функции и отрисовка происходила непосредственно в main():
int main_cycle = 0; /* stop program if 0 */
int t = 0;
void init_graphics();
void draw_background();
void render_scene();
void finalize_graphics();
void finalize_world();
void draw_line(int x1, int y1, int x2, int y2, char sprite);
void quit_game(int dummy)
{
main_cycle = 0;
}
void init_player_input(void (*f)());
int main()
{
init_graphics();
init_player_input(quit_game);
main_cycle = 1; /* Initializing runprogram flag */
draw_background();
draw_line(10, 1, 70, 5, '*');
render_scene();
while(main_cycle)
{
;
}
finalize_graphics();
return 0;
}
...
void draw_line(int x1, int y1, int x2, int y2, char sprite)
{
for (int x = x1; x + x1 <= x2; x++)
{
int y = HEIGHT * x / WIDTH + y1;
draw_pixel(x, y, sprite);
}
}
...

draw_pixel(x1, y1, 'X');
draw_pixel(x2, y2, 'X');
for (int x = x1; x + x1 <= x2; x++)
{
int y = HEIGHT * x / WIDTH + y1;
draw_pixel(x, y, sprite);
}

x пробегает от x1 до x2, но изменение y зависит не от конечного положения отрезка, а от соотношения ширины и высоты экрана - тяжелое наследие старого кода. Стоит высчитывать позицию y на каждом шаге:
...
for (int x = 0; x + x1 <= x2; x++)
{
int y = (y2 - y1) / (x2 - x1) * x;
draw_pixel(x + x1, y + y1, sprite);
}

int y = (y2 - y1) / (x2 - x1) * x
(ну, кроме очевидного деления на ноль)?Проблема в целочисленном делении. В самой формуле (dy / dx) * x проблем нет, но результат деления при 0 < dx < dy будет менее 1: 0 < dx / dy < 1. При целочисленном делении /, соответственно, результат в таком случае округляется вниз и получается равен нулю. Решение простое - выполнять умножение до целочисленного деления (информация теряется на делении, выполнение деления последним позволяет исключать попытку использования утерянной информации в расчётах):
int y = x * (y2 - y1) / (x2 - x1)
:
...
for (int x = 0; x + x1 <= x2; x++)
{
int y = x * (y2 - y1) / (x2 - x1);
draw_pixel(x + x1, y + y1, sprite);
}

Длина горизонтальной ступени - это длина линии по X, делённая на количество ступеней - высоту линии по Y. Соответственно смещение линии будет равно половине этой величины:
int offset = (x2 - x1) / (y2 - y1);
offset = offset / 2;
int offset = (x2 - x1) / ((y2 - y1) * 2);
Простое решение - сдвинуть каждый пиксель изображения на величину offset не сработает:
...
for (int x = 0; x + x1 <= x2; x++)
{
int offset = (x2 - x1) / ((y2 - y1) * 2);
int y = x * (y2 - y1) / (x2 - x1);
draw_pixel(x + x1 - offset, y + y1, sprite);
}

Сдвигать координату x не нужно, нужно сдвигать только y по вертикали так, как будто x сдвинут - в расчёте y учитывать "сдвинутое" значение x, но для отрисовки использовать несдивинутую горизонтальную координату:
...
for (int x = 0; x + x1 <= x2; x++)
{
int offset = (x2 - x1) / ((y2 - y1) * 2);
int y = (x + offset) * (y2 - y1) / (x2 - x1);
draw_pixel(x + x1, y + y1, sprite);
}

Визуально картинка соответсвует ожиданиям! Но внимание должно цепляться за две операции деления, причём одну из них можно (и нужно!) вынести из цикла:
...
int offset = (x2 - x1) / ((y2 - y1) * 2);
for (int x = 0; x + x1 <= x2; x++)
{
int y = (x + offset) * (y2 - y1) / (x2 - x1);
draw_pixel(x + x1, y + y1, sprite);
}
Чтобы исключить лишние деления можно, в первую очередь, заметить, что деление используется для подсчёта смещения по горизонтали на каждом шаге - и каждый шаг смещение увеличивается на одинаковую величину. Но так как эта "одинаковая величина" получается меньше единицы, её можно расчитать заранее только в вещественных числах - и cохранить значение изменения нужно в переменной с плавающей точкой. В начале урока уже приводилась ситуация, когда целочисленное смещение, меньшее единицы - просто округлялось до нуля, и никакого смещения линии не происходило. Так что при расчёте смещения по вертикали нужно не забывать про накапливающуюся дробную часть числа - пусть и отбрасываемую при присваивании целочисленной переменной y. А саму величину смещения на шаг dy нужно объявить переменной с плавающей точкой:
...
int offset = (x2 - x1) / ((y2 - y1) * 2);
float dy = (float)(y2 - y1) / (float)(x2 - x1);
for (int x = 0; x + x1 <= x2; x++)
{
int y = (x + offset) * dy;
draw_pixel(x + x1, y + y1, sprite);
}
Число с плавающей точкой используется для переменной dy потому, что в переменной y накапливается смещение - каждый шаг в дробную, отбрасываемую часть вычисления x * dy
добавляется смещение dy, пока не сменится целая часть. Как дробная часть достигнет максимума, целая часть сменится и произойдёт переход на следующую строку. В предыдущем варианте функции дробная, отбрасываемая часть y неявно используется как аккумулятор ошибки, целая же часть - считает, сколько раз аккумулятор был переполнен. Можно периписать код так, чтобы аккумулятор был указан явно. Смысл нового варианта функции не столько в явном указании величины накопления ошибки, а избавление от умножения с плавающей точкой - замена умножения на сложение, так как цикл пробегает все значения x от 0 до x2-x1
, все значения можно получить не умножением dy на 0, 1, 2 и т.д., а просто последовательно прибавлять dy. Такой вариант функции хоть и повышает быстродействие - сложение всё же быстрее умножения, но не избавляет тем не менее пока что от вычислений с использованием вещественных чисел:
...
int offset = (x2 - x1) / ((y2 - y1) * 2);
float dy = (float)(y2 - y1) / (float)(x2 - x1);
float y = offset * dy;
for (int x = 0; x + x1 <= x2; x++, y += dy)
{
draw_pixel(x + x1, y + y1, sprite);
}
...
int offset = (x2 - x1) / ((y2 - y1) * 2);
float dy = (float)(y2 - y1) / (float)(x2 - x1);
float y = offset * dy;
for (int x = 0; x + x1 <= x2; x++)
{
draw_pixel(x + x1, y + y1, sprite);
y += dy;
}
...
int offset = (x2 - x1) / ((y2 - y1) * 2);
float dy = (float)(y2 - y1) / (float)(x2 - x1);
int y_integer_part = offset * dy;
float y_fractional_part = offset * dy;
for (int x = 0; x + x1 <= x2; x++)
{
draw_pixel(x + x1, y_integer_part + y1, sprite);
y_fractional_part += dy;
if (y_fractional_part > 1.0)
{
y_fractional_part -= 1.0;
y_integer_part++;
}
}
...
int offset = (x2 - x1) / ((y2 - y1) * 2);
float dy = (float)(y2 - y1) / (float)(x2 - x1);
float y_fraction_limit = 1.0;
int y_integer_part = offset * dy;
float y_fractional_part = offset * dy;
for (int x = 0; x + x1 <= x2; x++)
{
draw_pixel(x + x1, y_integer_part + y1, sprite);
y_fractional_part += dy;
if (y_fractional_part > y_fraction_limit)
{
y_fractional_part -= y_fraction_limit;
y_integer_part++;
}
}
Теперь можно заметить, что значение переменной offset используется только для вычисления значения выражения offset * dy
. Это выражение, с учётом выражений, используемых для используемых переменных, можно расписать:
offset * dy
оказывается равно просто 0.5. Эта "половина" - половина от значения лимита, тогда y_fractional_part = y_fraction_limit / 2
. В результате же присваивания вещественного значения 0.5 целочисленной переменной y_integer_part вещественное значение округляется вниз, и в переменной оказывается просто 0. Переменная offset в результате упрощения более не нужна:
...
float dy = (float)(y2 - y1) / (float)(x2 - x1);
float y_fraction_limit = 1.0;
int y_integer_part = 0;
float y_fractional_part = y_fraction_limit / 2;
for (int x = 0; x + x1 <= x2; x++)
{
draw_pixel(x + x1, y_integer_part + y1, sprite);
y_fractional_part += dy;
if (y_fractional_part > y_fraction_limit)
{
y_fractional_part -= y_fraction_limit;
y_integer_part++;
}
}
Но величина dy обзавелась дробной частью в результате деления на
x2-x1
. То есть, если умножить на x2-x1
как dy, так и limit - система не изменится, но dy и y_fractional_part перестанут иметь дробную часть:
...
float dy = y2 - y1;
float y_fraction_limit = x2 - x1;
int y_integer_part = 0;
float y_fractional_part = y_fraction_limit / 2;
for (int x = 0; x + x1 <= x2; x++)
{
draw_pixel(x + x1, y_integer_part + y1, sprite);
y_fractional_part += dy;
if (y_fractional_part > y_fraction_limit)
{
y_fractional_part -= y_fraction_limit;
y_integer_part++;
}
}
Теперь переменные с плавающего типа можно преобразоватьв целочисленные переменные:
...
int dy = y2 - y1;
int y_fraction_limit = x2 - x1;
int y_integer_part = 0;
int y_fractional_part = y_fraction_limit / 2;
for (int x = 0; x + x1 <= x2; x++)
{
draw_pixel(x + x1, y_integer_part + y1, sprite);
y_fractional_part += dy;
if (y_fractional_part > y_fraction_limit)
{
y_fractional_part -= y_fraction_limit;
y_integer_part++;
}
}
Так же (для упрощения читаемости кода) можно заметить, что значения переменных x и y всегда используются в паре с начальными координатами отрезка - и значит могут быть проинициализированы не нулями, а началом отрезка.
...
int dy = y2 - y1;
int y_fraction_limit = x2 - x1;
int y_integer_part = y1;
int y_fractional_part = y_fraction_limit / 2;
for (int x = x1; x <= x2; x++)
{
draw_pixel(x, y_integer_part, sprite);
y_fractional_part += dy;
if (y_fractional_part > y_fraction_limit)
{
y_fractional_part -= y_fraction_limit;
y_integer_part++;
}
}
...
int delta_error = y2 - y1;
int delta_x = x2 - x1;
int error = delta_x / 2;
for (; x1 <= x2; x1++)
{
draw_pixel(x1, y1, sprite);
error += delta_error;
if (error > delta_x)
{
error -= delta_x;
y1++;
}
}
Теперь последнее оставшееся деление. Оно, хоть и будет соптимизировано компилятором (почти наверняка!), но всё же оно несёт с собой проблему потери точности. Так, для разных значений delta_x, к примеру 2
и 3
, значение error будет одинаковым - 1. Переменная error используется для сравнения с величиний delta_x, и инкрементируется (или декрементируется) переменными delta_x и delta_y. Значит, для избавления от деления на 2 можно вместо этого использовать удвоенное значение error, равное просто delta_x, не делённое на 2, и взаимодействовать с error с помощью увеличенных в два раза delta_x и delta_y
Заодно можно заметить, что после использования для итерирования непосредственно параметра x1 вместо переменной x - цикл for()
можно заменить на цикл while()
(и вынести итерирование x1++
в явном виде из условия цикла). Ещё - не значимый сейчас момент, но в будущем пригодится перенос инкремента error после if()
. В таком случае меняется и инициализирующее значение переменной, чтобы компенсировать первый инкремент:
...
int delta_error = y2 - y1;
int delta_x = x2 - x1;
int error = delta_x + delta_error * 2;
while(x1 <= x2)
{
draw_pixel(x1, y1, sprite);
if (error > delta_x * 2)
{
error -= delta_x * 2;
y1++;
}
error += delta_error * 2;
x1++;
}
Функция работает в предположении, что начальная координата отрезка не больше конечной, (слева направо), а проекция отрезка на ось ординат меньше, чем проекция на ось абсцисс (линия полога). В общем случае это конечно же не так, и функция должна уметь отрисовывать отрезок между двумя любыми точками:
...
int main()
{
int x = 19;
int y = 2;
init_graphics();
init_player_input(quit_game);
t = 1;
main_cycle = 1; /* Initializing runprogram flag */
/* Main cycle */
while(main_cycle)
{
draw_line(x, y, 00, 00, '1');
draw_line(x, y, 19, 00, '2');
draw_line(x, y, 39, 00, '3');
draw_line(x, y, 59, 00, '4');
draw_line(x, y, 79, 00, '5');
draw_line(x, y, 79, 12, '6');
draw_line(x, y, 79, 23, '7');
draw_line(x, y, 59, 23, '8');
draw_line(x, y, 39, 23, '9');
draw_line(x, y, 19, 23, 'a');
draw_line(x, y, 00, 23, 'b');
draw_line(x, y, 00, 13, 'c');
draw_line(x, y, 00, 03, 'd');
render_scene();
t++;
}
finalize_graphics();
return 0;
}

Для корректной отрисовки всех секторов можно отзеркалить все варианты направления линии. Сейчас отрезок рисуется только в четвёртой четверти и только если он пологий. Для отрисовки крутой линии в четвёртой четверти нужно итерироваться не по X, а по Y. Тогда остальные четверти можно отрисовывать либо разменивая начальные точки постороения, либо меня направление обхода отрезка из начальной точки. Для возможности итерироваться по Y стоит заметить, что delta_x и delta_error по сути несут одну и ту же функцию - проекция отрезка на абсцисс и на ординат. Тогда для симметрии стоит переименовать delta_error в delta_y. (И спрятать, заодно, умножения в определения переменных)
draw_pixel(x1, y1, 'X');
draw_pixel(x2, y2, 'X');
int delta_x = x2 - x1;
int delta_y = y2 - y1;
int error = delta_x + delta_y * 2;
delta_x *= 2;
delta_y *= 2;
while(x1 <= x2)
{
draw_pixel(x1, y1, sprite);
if (error > delta_x)
{
error -= delta_x;
y1++;
}
error += delta_y;
x1++;
}
Теперь в функции draw_line() можно реализовать два случая: для варианта, когда длина отрезка по X больше длины по Y, и для парного случая:
draw_pixel(x1, y1, 'X');
draw_pixel(x2, y2, 'X');
int delta_x = x2 - x1;
int delta_y = y2 - y1;
if (delta_x > delta_y)
{
int error = delta_x + 2 * delta_y;
delta_x *= 2;
delta_y *= 2;
while(x1 <= x2)
{
draw_pixel(x1, y1, sprite);
if (error > delta_x)
{
error -= delta_x;
y1++;
}
error += delta_y;
x1++;
}
}
else
{
int error = delta_y + 2 * delta_x;
delta_x *= 2;
delta_y *= 2;
while(y1 <= y2)
{
draw_pixel(x1, y1, sprite);
if (error > delta_y)
{
error -= delta_y;
x1++;
}
error += delta_x;
y1++;
}
}

Для избавления от дублирования сначал абсолютно то же самое, чуть перегруппировав операторы внутри блоков
пологости\крутизны
:
draw_pixel(x1, y1, 'X');
draw_pixel(x2, y2, 'X');
int delta_x = x2 - x1;
int delta_y = y2 - y1;
int error;
if (delta_x > delta_y)
{
error = delta_x + 2 * delta_y;
}
else
{
error = delta_y + 2 * delta_x;
}
delta_x *= 2;
delta_y *= 2;
while(x1 <= x2 && y1 <= y2)
{
draw_pixel(x1, y1, sprite);
if (delta_x > delta_y)
{
if (error > delta_x)
{
error -= delta_x;
y1++;
}
error += delta_y;
x1++;
}
else
{
if (error > delta_y)
{
error -= delta_y;
x1++;
}
error += delta_x;
y1++;
}
}
В обоих вариантах ветки if происходит по сути одно и то же, но - иксы и игреки меняются местами. Немного подумав, можно придумать хитрый вариант, когда для X и для Y переменная error будет меняться в разных направлениях по числовой оси:
...
if (delta_x > delta_y)
{
error = 3 * delta_x - 2 * delta_y;
}
else
{
error = -3 * delta_y + 2 * delta_x;
}
delta_x *= 2;
delta_y *= 2;
while(x1 <= x2 && y1 <= y2)
{
draw_pixel(x1, y1, sprite);
if (delta_x > delta_y)
{
if (error < delta_x)
{
error += delta_x;
y1++;
}
error -= delta_y;
x1++;
}
else
{
if (error > -delta_y)
{
error -= delta_y;
x1++;
}
error += delta_x;
y1++;
}
}
Сделан этот небольшой трюк с разным направлением изменения error сделан для того, чтобы две ветки if объединить весьма элегантным образом:
...
while(x1 <= x2 && y1 <= y2)
{
draw_pixel(x1, y1, sprite);
int error_tmp = error;
if (error_tmp < delta_x)
{
error += delta_x;
y1++;
}
if (error_tmp > -delta_y)
{
error -= delta_y;
x1++;
}
}
3 * delta_x - 2 * delta_y
, или, что то же самое, delta_x + 2 * (delta_x - delta_y)
появилась оттого, что при манипуляции с избавлением от деления удваивались значения delta_x и delta_x. При этом переменные всё время используются в "удвоенном" значении, а "половинное" значение нужно только для инициализации переменной error . Можно избавиться от "удвоенных" дельт (и сравнивать, соответсвенно, с удвоенным значением error).
...
int error;
if (delta_x > delta_y)
{
error = delta_x - delta_y;
}
else
{
error = -delta_y + delta_x;
}
while(x1 <= x2 && y1 <= y2)
{
draw_pixel(x1, y1, sprite);
int error_tmp = error * 2;
if (error_tmp < delta_x)
{
error += delta_x;
y1++;
}
if (error_tmp > -delta_y)
{
error -= delta_y;
x1++;
}
}
...
int error = delta_x - delta_y;
...
Теперь функция может отрисовывать корректно любую линию из четвёртого квадранта, и отрисовывать любой другой квадрант можно просто меняя направление инкрементации x и y. При этом, правда, нужно будет ещё и изменить условие окончания цикла:
...
void draw_line(int x1, int y1, int x2, int y2, char sprite)
{
int sign_x = x2 > x1 ? 1 : -1;
int delta_x = (x2 - x1) * sign_x;
int sign_y = y2 > y1 ? 1 : -1;
int delta_y = (y2 - y1) * sign_y;
int error = delta_x - delta_y;
while(x1 != x2 || y1 != y2)
{
draw_pixel(x1, y1, sprite);
int error_tmp = error * 2;
if (error_tmp < delta_x)
{
error += delta_x;
y1 += sign_y;
}
if (error_tmp > -delta_y)
{
error -= delta_y;
x1 += sign_x;
}
}
draw_pixel(x2, y2, sprite);
}
...

...
void init_screen();
float fake_sin(int x)
{
int sign = 1;
x %= 360;
if (x > 180)
{
sign = -1;
x = 360 - x;
}
float numerator = 4 * x * (180 - x);
float denominator = 40500 - x * (180 - x);
return sign * numerator / denominator;
}
int main()
{
int x = 0;
int y = 0;
init_graphics();
init_player_input(quit_game);
t = 1;
main_cycle = 1; /* Initializing runprogram flag */
/* Main cycle */
while(main_cycle)
{
x = 1 + 39 * (1 + fake_sin(t / 300 + 0 ));
y = 1 + 11 * (1 + fake_sin(t / 300 + 90));
init_screen();
draw_line(x, y, 00, 00, '1');
draw_line(x, y, 19, 00, '2');
draw_line(x, y, 39, 00, '3');
draw_line(x, y, 59, 00, '4');
draw_line(x, y, 79, 00, '5');
draw_line(x, y, 79, 12, '6');
draw_line(x, y, 79, 23, '7');
draw_line(x, y, 59, 23, '8');
draw_line(x, y, 39, 23, '9');
draw_line(x, y, 19, 23, 'a');
draw_line(x, y, 00, 23, 'b');
draw_line(x, y, 00, 13, 'c');
draw_line(x, y, 00, 03, 'd');
render_scene();
t++;
}
finalize_graphics();
return 0;
}
Самостоятельная работа:
- Растеризация. Почему без неё никак, и как можно бы было без неё?
- В чём проблема алгоритма Брезенхема? Anti-Aliasing.
- История появления видеокарт. Зачем? Отличие видеокарты от видеоадаптера.