Линии. Как ни странно, но в трёхмерной графике они используются достаточно редко. Исключением может служить разве что wireframe-рендеринг, однако даже он порой обходится без, непосредственно, рисования линий. Но так или иначе линии относятся к теме растеризации и я хочу рассказать про них в том числе. Бóльшая часть статьи посвещена описанию разных алгоритмов, но дабы сделать её более информативной, в конце я напишу бенчмарк и сравню алгоритмы по производительности. Пойдём от простого к сложному.
Я не знаю какое название ему придумать для отражения всей сути, но это самый очевидный алгоритм, который приходит мне в голову:
void DrawLine_Dumb(Surface* surface, int x0, int y0, int x1, int y1, u32 color)
{
int dx = x1 - x0; /* - расстояние между точками (дельта) */
int dy = y1 - y0;
f32 length = sqrt((dx * dx) + (dy * dy)); /* - длина линии */
f32 angle = atan2(dy, dx); /* - угол между точками */
f32 s = sin(angle); /* - синус/косинус угла */
f32 c = cos(angle);
for (int i = 0; i < length; ++i) { /* идем вдоль длины линии */
int x = x0 + (int)(i * c); /* считаем координаты x y */
int y = y0 + (int)(i * s);
DrawPoint(surface, x, y, color);
}
}
Этот код очень медленный и имеет заметные погрешности в виду ограниченной точности стандартных тригонометрических функций. Его можно оптимизировать, пойдя на разные хитрости и создав lookup table для синусов и косинусов, однако у меня нет желания заниматься оптимизацией изначально плохого алгоритма. Лучше пойдем дальше и посмотрим, какие варианты у нас есть ещё.
Этот метод похож на предыдущий, но теперь мы не используем тригонометрические функции и заранее просчитываем инкрименанты:
void DrawLine_DDA(Surface* surface, int x0, int y0, int x1, int y1, u32 color)
{
int dx = x1 - x0;
int dy = y1 - y0;
int length = max(abs(dx), abs(dy));
if (length > 0) {
f32 ix = (f32)dx / length;
f32 iy = (f32)dy / length;
f32 x = x0 + 0.5;
f32 y = y0 + 0.5;
for (int i = 0; i < length; ++i) {
DrawPoint(surface, floor(x), floor(y), color);
x += ix;
y += iy;
}
}
}
Данный код на порядок производительнее предыдущего, но в нем всё ещё присутсвует много манипуляций с двоичными числами. Полностью решить эту проблему поможет Алгоритм Брезенхема.
Пожалуй самый известный и наиболее часто используемый:
void DrawLine_Bresenham(Surface* surface, int x0, int y0, int x1, int y1, u32 color)
{
int dx = ABS(x1 - x0);
int dy = ABS(y1 - y0);
int sx = x0 < x1 ? 1 : -1;
int sy = y0 < y1 ? 1 : -1;
int ed = (dx > dy ? dx : -dy) / 2;
while (x0 != x1 || y0 != y1){
DrawPoint(surface, x0, y0, color);
int e = ed;
if (e >-dx) { ed -= dy; x0 += sx; }
if (e < dy) { ed += dx; y0 += sy; }
}
}
Может быть моя реализация не самая каноничная, в угоду меньшему колличеству строк, но идея в том, что каждый шаг мы вычисляем ошибку - расстояние между координатой и пикселем, сравнивая которую с дельтой решаем, какую из координат инкременировать. Одно из приемуществ этого алгоритма - это отсутствие манипуляций с двоичными числами. Однако теперь во время цикла происходит много операций сравнения.
Название говорит само за себя. Вычислений относительно мало и все они находятся за циклом:
void DrawLine_EFLA(Surface* surface, int x0, int y0, int x1, int y1, u32 color)
{
int dy = y1 - y0;
int dx = x1 - x0;
int yLonger = ABS(dy) > ABS(dx);
if (yLonger) { SWAP(dx, dy); }
int length = dx;
int di;
if (dx < 0) { di = -1; dx = -dx; } else { di = 1; }
f64 fi = (f64)dy;
if (dx != 0) { fi /= dx; }
f64 j = 0;
if (yLonger) {
for (int i = 0; i != length; i += di, j += fi) {
DrawPoint(surface, x0 + (int)j, y0 + i, color);
}
} else {
for (int i = 0; i != length; i += di, j += fi) {
DrawPoint(surface, x0 + i, y0 + (int)j, color);
}
}
}
Принцип отличается от предыдущих алгоритмов. В первую очередь мы определеяем, по какой оси линия длиннее. Если ось "y" длинне, меняем между собой значения dx и dy - таким образом dx всегда будет длиннее dy. Далее находим целочисленный и двоичный инкременант, и в зависимости от того, какую из осей мы определили длиннейшей, по такой оси и будет целочистенный инкрименант - идем вдоль него и рисуем точки.
Я написал программу для тестирования, исходники которой прикладываю ниже:
int main(int argc, char** argv)
{
const int c = 5000000;
for (int test = 0; test < 4; ++test)
{
u32 start = GetTickCount();
int w = framebuffer.width / 2;
int h = framebuffer.height / 2;
int x = w * (test / 2);
int y = h * (test % 2);
int i = c;
static const char* name = "";
switch (test) {
case 0: name = "DrawLine_Dumb";
while (--i) DrawLine_Dumb(&framebuffer,
x + (i / w) % w, y + (i / h) % h, x + (i % w), y + (i % h), i);
break;
case 1: name = "DrawLine_DDA";
while (--i) DrawLine_DDA(&framebuffer,
x + (i / w) % w, y + (i / h) % h, x + (i % w), y + (i % h), i);
break;
case 2: name = "DrawLine_Bresenham";
while (--i) DrawLine_Bresenham(&framebuffer,
x + (i / w) % w, y + (i / h) % h, x + (i % w), y + (i % h), i);
break;
case 3: name = "DrawLine_EFLA";
while (--i) DrawLine_EFLA(&framebuffer,
x + (i / w) % w, y + (i / h) % h, x + (i % w), y + (i % h), i);
break;
}
u32 end = GetTickCount();
printf("%*s draw %d lines @ %.3f seconds\n", 20, name, c, (f64)(end - start) / 1000.0);
}
return 0;
}
В этом тесте мы рисуем пять миллионов линий каждым из алгоритмов и замеряем затраченное на это время.
Тест запускался на машине Windows 10, Intel Core i5-4210M @ 2.60 GHz, компилятор MSVC 14.10.25017 x64:

DrawLine_Dumb draw 5000000 lines @ 8.016 seconds
DrawLine_DDA draw 5000000 lines @ 4.093 seconds
DrawLine_Bresenham draw 5000000 lines @ 4.188 seconds
DrawLine_EFLA draw 5000000 lines @ 4.009 seconds
В заключение хотелось бы сказать, что результат тестов меня удивил, поскольку в моей реализации алгоритм DDA на разных компиляторах и машинах оказывался чуть быстрее EFLA. Вероятно это связано с архитектурой совмеренных процессоров и микро-оптимизациями, которые происходят у них внутри.
Это была первая статья из цикла по разрабоке софтверного рендерера. В дальнейшем я расскажу про рисование треугольников и матричные преобразования.