game-engine / devlog / растеризация-треугольников

Софтверная растеризация: быстрый и точный алгоритм растеризации треугольников на C


Translation of this article in English

Основа трехмерной графики - растеризация треугольников. В этой статье я объясню как работает алгоритм софтверной растеризации треугольников в моём игровом движке.

Сперва я представлю сам алгоритм в его конечном виде, а затем мы разберём его поподробнее:


static void SwapFloat(floatafloatb) { float c = *a; *= *b; *c; }


void DrawTriangle(
    
float axfloat ayfloat azfloat aufloat av,
    
float bxfloat byfloat bzfloat bufloat bv,
    
float cxfloat cyfloat czfloat cufloat cv
) {
    
// For every vertex:
    // x, y - screen coordinates
    // z    - depth
    // u, v - texture coordinates

    // Sort vertices by y-coordinate ascending (y1 <= y2 <= y3)
    
if (ay by) {
        
// swap(a, b)
        
SwapFloat(&ax, &bx);
        
SwapFloat(&ay, &by);
        
SwapFloat(&az, &bz);
        
SwapFloat(&au, &bu);
        
SwapFloat(&av, &bv);
    }
    if (
ay cy) {
        
// swap(a, c)
        
SwapFloat(&ax, &cx);
        
SwapFloat(&ay, &cy);
        
SwapFloat(&az, &cz);
        
SwapFloat(&au, &cu);
        
SwapFloat(&av, &cv);
    }
    if (
by cy) {
        
// swap(b, c)
        
SwapFloat(&bx, &cx);
        
SwapFloat(&by, &cy);
        
SwapFloat(&bz, &cz);
        
SwapFloat(&bu, &cu);
        
SwapFloat(&bv, &cv);
    }

    
// Round y-coordinates and clamp to screen bounds
    
int y_min max(    (int)ceilf(ay 0.5f), 0);
    
int y_mid min(max((int)ceilf(by 0.5f), 0), screen_height);
    
int y_max min(    (int)ceilf(cy 0.5f),     screen_height);

    
// Prepare values for perspective-correct interpolation
    
az 1.0f az;
    
bz 1.0f bz;
    
cz 1.0f cz;

    
au *= az;
    
av *= az;

    
bu *= bz;
    
bv *= bz;

    
cu *= cz;
    
cv *= cz;

    
// Rasterize triangle scanlines
    
for (int y y_miny_maxy++) {
        
float pixel_center = (float)0.5f;

        
float ltlxlzlulv// left edge
        
float rtrxrzrurv// right edge

        // Calculate left edge
        
lt = (pixel_center ay) / (cy ay);
        
lx ax + (cx ax) * lt;
        
lz az + (cz az) * lt;
        
lu au + (cu au) * lt;
        
lv av + (cv av) * lt;

        
// Determine if we are in the first or the second half of the triangle
        
if (y_mid) {
            
// Calculate right edge (first half)
            
rt = (pixel_center ay) / (by ay);
            
rx ax + (bx ax) * rt;
            
rz az + (bz az) * rt;
            
ru au + (bu au) * rt;
            
rv av + (bv av) * rt;
        } else {
            
// Calculate right edge (second half)
            
rt = (pixel_center by) / (cy by);
            
rx bx + (cx bx) * rt;
            
rz bz + (cz bz) * rt;
            
ru bu + (cu bu) * rt;
            
rv bv + (cv bv) * rt;
        }

        
// Ensure "l" is always to the left of "r"
        
if (lx rx) {
            
// swap(l, r)
            
SwapFloat(&lx, &rx);
            
SwapFloat(&lz, &rz);
            
SwapFloat(&lu, &ru);
            
SwapFloat(&lv, &rv);
        }

        
// Round x-coordinates and clamp to screen bounds
        
int ilx = (int) ceilf(lx 0.5f); // top-left rule: ceil to include left edge
        
int irx = (int)floorf(rx 0.5f); // top-left rule: floor to exclude right edge
        
int x_min max(ilx0);
        
int x_max min(irx 1screen_width);

        
// Loop over the horizontal range of the current scanline
        
for (int x x_minx_maxx++) {
            
int i = (screen_width) + x;

            
float t = (float)(ilx) / (rx lx);
            
float z lz + (rz lz) * t;
            if (
screen_depth[i]) {

                
float u = (lu + (ru lu) * t) / z;
                
float v = (lv + (rv lv) * t) / z;

                
screen_color[i] = SampleTextureColor(textureuv);
                
screen_depth[i] = z;
            }
        }
    }
}

Разбор алгоритма

Начнем с того, что наметим план действий. Наша цель - самым эффективным образом закрасить пиксели внутри треугольника, избежав избыточных вычислений.

Сразу отметим, что рисовать пиксели внутри треугольника нам стоит строка за строкой, начиная с верхнего-левого угла, потому что таким образом пиксели хранятся в оперативной памяти. Мы хотим избежать "прыжков" по оперативке и проходиться по буферу линейно - это залог хорошей оптимизации.

Мы должны рассчитать переменные для цикла, по которым мы будем двигаться вдоль оси y и заливать пиксели между гранями треугольника, но сперва нужно подготовить сами вершины.

Шаг 1: Отсортируем вершины по y-координате


if (ay by) { SwapPoint(&a, &b); }

if (
ay cy) { SwapPoint(&a, &c); }
if (
by cy) { SwapPoint(&b, &c); }

Сортировка нужна, чтобы упростить проход по треугольнику. Мы будем предполагать, что a - верхняя вершина, b - средняя, c - нижняя. Это позволит разбить треугольник на две части: верхнюю и нижнюю - так нам проще будет рассчитывать начало и конец горизонтального отрезка, поскольку левая и правая грани будут строго прямыми.

Шаг 2: Top-left filling convention (правило верхнего левого угла)

Очень важный момент, который иногда упускается из внимания: правило растеризации. Дело в том, что если мы хотим избежать "щелей" между соседними треугольниками, то важно придерживаться единого принципа, по которому мы будем округлять координаты. В DirectX и Vulkan принято правило: пиксель считается покрытым, если центр пикселя лежит внутри треугольника. В нашем алгоритме мы тоже будем его придерживаться. Для этого мы округляем y - 0.5 вверх, чтобы учесть центр пикселя:


int y_min = (int)ceilf(ay 0.5f);

int y_mid = (int)ceilf(by 0.5f);
int y_max = (int)ceilf(cy 0.5f);

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


int ilx = (int) ceilf(lx 0.5f); // top-left rule: ceil to include left edge

int irx = (int)floorf(rx 0.5f); // top-left rule: floor to exclude right edge

Придерживаясь таких правил мы избавимся от зазоров между полигонами. Особенно важно это учитывать, если вы собираетесь использовать софтверный растеризатор для отсечения окклюзий (например при реализации алгоритма Hierarchical Depth Buffer).

Шаг 3: Подготовка к интерполяции с учетом перспективы


az 1.0f az;

bz 1.0f bz;
cz 1.0f cz;

Инверсия z-координат позволит нам выполнить перспективно-корректную интерполяцию. Если мы не будем этого делать, то при вычислении UV-координат получим эффект растягивающихся текстур с PS1 - если цель достичь именно такого стилистичего эффекта, то можно намеренно пропустить этот шаг.

Шаг 4: Проход по сканлайнам (вдоль координаты y)


for (int y y_miny_maxy++) {

    ...
}

В этом цикле мы пойдем сверху вниз вдоль каждой строки, рассчитаем начало и конец горизонтального отрезка, и отрисуем его.

Шаг 5: Вычисление начала и конца горизонтального отрезка

В каждой строке вычисляем координаты x для начала и конца отрезка:


lt = (pixel_center ay) / (cy ay);

lx ax + (cx ax) * lt;
lz az + (cz az) * lt;

lt - это параметр линейной интерполяции вдоль левой грани. Левая грань всегда a → c


if (y_mid) {

    
rt = (center_y ay) / (by ay);
    
rx ax + (bx ax) * rt;
    
rz az + (bz az) * rt;
    
// ...
} else {
    
rt = (center_y by) / (cy by);
    
rx bx + (cx bx) * rt;
    
rz bz + (cz bz) * rt;
    
// ...
}

rt - это параметр линейной интерполяции вдоль правой грани. Правая грань может быть либо a → b (верхняя половина треугольника), либо b → c (нижняя половина). Это определяется по y < y_mid.

Шаг 6: Гарантия порядка лево-право


if (lx rx) { SwapFloat(&lx, &rx); SwapFloat(&lz, &rz); }

В треугольнике есть как длинная грань (a → c), так и две других, через которые проходит линия разделения по y_mid. Мы заранее не определяли с какой стороны находится длинная грань, потому что это не имеет значения при вычислении координат, но имеет при отрисовке. Поэтому после того, как мы вычислили координаты для левой и правой грани, мы просто убедимся, что координаты l всегда левее r.

Шаг 7: Растеризация строки

Теперь проходим по пикселям от lx до rx (округляя их по правилам растеризации) и выполняем линейную интерполяцию глубины:


for (int x x_minx_maxx++) {

    
int i = (screen_width) + x;

    
float t = (float)(ilx) / (rx lx); // фактор по x (0.0 - начало отрезка, 1.0 - конец)

    
float z lz + (rz lz) * t// значение глубины (интерполяция между левым и правым краем)

    
if (screen_depth[i]) { // пиксель закрашивается, если он ближе к камере, чем предыдущий
        
screen_color[i] = 0xFFFFFF;
        
screen_depth[i] = z;
    }
}

Шаг 8: Дополнительные параметры вершин

В этой статье вершины имели 5 параметров: x, y, z, u, v.

x y - для положения в экранном пространстве

z - для глубины

u v - для текстурных координат

Но ничего не мешает расширить этот метод и добавить новые параметры вершин. К примеру, вы можете добавить параметр l, который будет отвечать за затухание цвета текстуры - это позволит реализовать повертексное освещение. Вы можете добавить ещё одну пару uv координат для мультитекстурирования или лайтпамов. Всё это делается всего лишь добавлением новых параметров, по аналогии с z или uv.

Этот алгоритм расширяемый и все вычисления в нём работают по одинаковому принципу.

Шаг 9: Оптимизация

Я намеренно упростил код, представленный в начале статьи, чтобы улучшить его читабельность в угоду производительности. Если вы дочитали до этого момента и вас интересует дальнейшая оптимизация, то ниже вы найдете финальную версию алгоритма для реального рендерера:


static void DrawTriangle_Span(

    const 
Texture *texture,
    
int y,
    
float lxfloat lzfloat lafloat lbfloat lc,
    
float rxfloat rzfloat rafloat rbfloat rc
) {
    
// Ensure "l" is always to the left of "r"
    
if (lx rx) {
        
SwapFloat(&lx, &rx);
        
SwapFloat(&lz, &rz);
        
SwapFloat(&la, &ra);
        
SwapFloat(&lb, &rb);
        
SwapFloat(&lc, &rc);
    }

    
// Precalculate spans
    
float span_x rx lx;
    if (
span_x <= 0.0f) {
        return; 
// skip if no pixels to draw
    
}
    
float span_z rz lz;
    
float span_a ra la;
    
float span_b rb lb;
    
float span_c rc lc;

    
// Round and x-coordinates to screen bounds
    
int ilx = (int) ceilf(lx 0.5f); // top-left rule: ceil to include left edge
    
int irx = (int)floorf(rx 0.5f); // top-left rule: floor to exclude right edge
    
int nlx max(ilx0);
    
int nrx min(irx 1screen_width);

    
// Get scanline pointers
    
int i = (screen_width y) + nlx;
    
WORD  *color_ptr = &screen_color[i];
    
float *depth_ptr = &screen_depth[i];

    
// Loop over the horizontal range of the current scanline
    
for (int x nlxnrxx++) {

        
// Interpolate z
        
float t = (float)(ilx) / span_x;
        
float z lz span_z t;

        
// Depth test
        
if (> *depth_ptr) {

            
// Perspective-correct interpolation
            
float u = (la span_a t) / z;
            
float v = (lb span_b t) / z;
            
float l = (lc span_c t) / z;

            
// Sample texture color
            
WORD color SampleTexture(textureuv);

            
// Transparency-bit test
            
if (color COLOR16_MASK_A) {
                
                
// Apply lighting
                
color Color_ApplyLighting(colorl);
            
                *
color_ptr color;
                *
depth_ptr z;
            }
        }

        
// Step scanline pointers
        
color_ptr++;
        
depth_ptr++;
    }
}

void DrawTriangle(
    const 
Texture *texture,
    
float x1float y1float z1float a1float b1float c1,
    
float x2float y2float z2float a2float b2float c2,
    
float x3float y3float z3float a3float b3float c3
) {
    
// Prepare the triangle vertices for perspective-correct texture interpolation
    
z1 1.0f z1;
    
z2 1.0f z2;
    
z3 1.0f z3;

    
a1 *= z1;
    
b1 *= z1;
    
c1 *= z1;

    
a2 *= z2;
    
b2 *= z2;
    
c2 *= z2;

    
a3 *= z3;
    
b3 *= z3;
    
c3 *= z3;

    
// Sort vertices by y-coordinate ascending (y1 <= y2 <= y3)
    
if (y1 y2) {
        
SwapFloat(&x1, &x2);
        
SwapFloat(&y1, &y2);
        
SwapFloat(&z1, &z2);
        
SwapFloat(&a1, &a2);
        
SwapFloat(&b1, &b2);
        
SwapFloat(&c1, &c2);
    }
    if (
y1 y3) {
        
SwapFloat(&x1, &x3);
        
SwapFloat(&y1, &y3);
        
SwapFloat(&z1, &z3);
        
SwapFloat(&a1, &a3);
        
SwapFloat(&b1, &b3);
        
SwapFloat(&c1, &c3);
    }
    if (
y2 y3) {
        
SwapFloat(&x2, &x3);
        
SwapFloat(&y2, &y3);
        
SwapFloat(&z2, &z3);
        
SwapFloat(&a2, &a3);
        
SwapFloat(&b2, &b3);
        
SwapFloat(&c2, &c3);
    }

    
// Round y-coordinates and clamp to screen bounds
    
int y_min max(    (int)ceilf(y1 0.5f), 0);
    
int y_mid min(max((int)ceilf(y2 0.5f), 0), screen_height);
    
int y_max min(    (int)ceilf(y3 0.5f),     screen_height);

    
// Precalculate edges
    
float dy13 y3 y1;
    
float dx13 x3 x1;
    
float dz13 z3 z1;
    
float da13 a3 a1;
    
float db13 b3 b1;
    
float dc13 c3 c1;

    
float dy12 y2 y1;
    
float dx12 x2 x1;
    
float dz12 z2 z1;
    
float da12 a2 a1;
    
float db12 b2 b1;
    
float dc12 c2 c1;

    
float dy23 y3 y2;
    
float dx23 x3 x2;
    
float dz23 z3 z2;
    
float da23 a3 a2;
    
float db23 b3 b2;
    
float dc23 c3 c2;

    
#pragma omp parallel
    
{
        
// Draw the top part of the triangle
        #pragma omp for
        
for (int y y_miny_midy++) {
        
float pixel_center = (float)0.5f;
            
float lt = (pixel_center y1) / dy13;
            
float rt = (pixel_center y1) / dy12;
            
DrawTriangle_Span(
                
texturey,
                
x1 dx13 lt,
                
z1 dz13 lt,
                
a1 da13 lt,
                
b1 db13 lt,
                
c1 dc13 lt,
                
x1 dx12 rt,
                
z1 dz12 rt,
                
a1 da12 rt,
                
b1 db12 rt,
                
c1 dc12 rt
            
);
        }

        
// Draw the bottom part of the triangle
        #pragma omp for
        
for (int y y_midy_maxy++) {
        
float pixel_center = (float)0.5f;
            
float lt = (pixel_center y1) / dy13;
            
float rt = (pixel_center y2) / dy23;
            
DrawTriangle_Span(
                
texturey,
                
x1 dx13 lt,
                
z1 dz13 lt,
                
a1 da13 lt,
                
b1 db13 lt,
                
c1 dc13 lt,
                
x2 dx23 rt,
                
z2 dz23 rt,
                
a2 da23 rt,
                
b2 db23 rt,
                
c2 dc23 rt
            
);
        }
    }
}

Спасибо за уделённое внимание! Буду рад, если вы найдете применение этому алгоритму в своих проектах.

Софтверная растеризация Оптимизация Графика