Вернуться назад...

Разработка софтверного растеризатора на Си

Я разработал софтверный растеризатор на Си, способный рисовать полноценную 3D-графику без использования GPU, и хочу поделиться своими наработками со всеми теми, кто, так же как и я, желает разобраться в фундаментальных принципах, лежащих в основе работы видеокарт и графических API, таких как DirectX или OpenGL.

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

В ходе разработки я убедился, что в этом нет ничего сложного, и что современные CPU способны справляться с отрисовкой 3D-графики не хуже стареньких GPU, при этом полный контроль над алгоритмами растеризации открывает широкий простор для экспериментов, а отвязка от графических библиотек делает проекты портативными и компактными. Что я и намерен продемонстрировать далее на практике.

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

Оглавление


Часть 1. Приготовление

You give me a framebuffer, I can run Doom on it

— Джон Кармак

Нам понадобится создать окно, в котором мы сможем рисовать пиксели. Я пишу свой проект под Windows и не хочу использовать ничего лишнего, кроме системного API, но вы вольны использовать SDL или другие библиотеки для мультиплатформенности.
Всё, что нам сейчас нужно - это экранный буфер.

Исходный код программы под Windows, которая инициализирует окно где можно рисовать пиксели (нажмите, чтобы раскрыть)
main.c
#include <windows.h>

int screen_width = 640;
int screen_height = 480;
DWORD *screen_buffer = NULL;

void Draw()
{
    // Draw single white pixel at the center of the screen
    int pixel_x = screen_width / 2;
    int pixel_y = screen_height / 2;
    screen_buffer[(pixel_y * screen_height) + pixel_x] = 0xFFFFFFFF;
}

BITMAPINFO bmi; // information about the screen buffer

LRESULT CALLBACK Window_Process(HWND window, UINT message, WPARAM w_param, LPARAM l_param)
{
    switch (message) {

        case WM_CREATE: {
            // Allocate the screen buffer
            screen_buffer = HeapAlloc(GetProcessHeap(), 0, sizeof(WORD) * screen_width * screen_height);
        
            // Initialize BMI structure
            ZeroMemory(&bmi, sizeof(BITMAPINFO));
            bmi.bmiHeader.biSize = sizeof(BITMAPINFOHEADER);
            bmi.bmiHeader.biWidth = screen_width;
            bmi.bmiHeader.biHeight = -screen_height;
            bmi.bmiHeader.biPlanes = 1;
            bmi.bmiHeader.biBitCount = 32;
            bmi.bmiHeader.biCompression = BI_RGB;
            
            // Set timer that will emmit WM_TIMER at fixed interval
            SetTimer(window, 0, 1000 / 60, NULL); // ~60 FPS
            return 0;
        }

        case WM_DESTROY: {
            PostQuitMessage(0);
            return 0;
        }
        
        case WM_TIMER: {
            Draw();
            // Tell system that the entire window rect is invalid and needs to be redrawn (emmit WM_PAINT)
            InvalidateRect(window, NULL, FALSE);
            return 0;
        }

        case WM_PAINT: {
            PAINTSTRUCT ps;
            BeginPaint(window, &ps);
            StretchDIBits(
                ps.hdc,
                0, 0, screen_width, screen_height,
                0, 0, screen_width, screen_height,
                screen_buffer, &bmi, DIB_RGB_COLORS, SRCCOPY);
            EndPaint(window, &ps);
            return 0;
        }

        default:
            return DefWindowProc(window, message, w_param, l_param);
    }
}

int WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nShowCmd)
{
    // Register window class
    WNDCLASS window_class;
    ZeroMemory(&window_class, sizeof(WNDCLASS));
    window_class.lpfnWndProc = Window_Process;
    window_class.hInstance = hInstance;
    window_class.hIcon = LoadIcon(NULL, IDI_APPLICATION);
    window_class.hCursor = LoadCursor(NULL, IDC_ARROW);
    window_class.lpszClassName = "MAIN_WINDOW";
    if (!RegisterClass(&window_class)) {
        return 1;
    }

    // Define window styles
    DWORD window_style = WS_OVERLAPPEDWINDOW & ~WS_MAXIMIZEBOX & ~WS_SIZEBOX;

    // Calculate window size and position
    RECT window_rect = { 0, 0, screen_width, screen_height };
    if (AdjustWindowRect(&window_rect, window_style, FALSE)) {
        window_rect.right -= window_rect.left;
        window_rect.bottom -= window_rect.top;
        window_rect.left = (GetSystemMetrics(SM_CXSCREEN) - window_rect.right) / 2;
        window_rect.top = (GetSystemMetrics(SM_CYSCREEN) - window_rect.bottom) / 2;
    }

    // Create window
    LPCTSTR window_title = TEXT("Software rasterizer");
    HWND window = CreateWindowEx(
        WS_EX_COMPOSITED, window_class.lpszClassName, window_title, window_style,
        window_rect.left, window_rect.top, window_rect.right, window_rect.bottom,
        NULL, NULL, hInstance, NULL
    );
    if (window == INVALID_HANDLE_VALUE) {
        return 1;
    }
    ShowWindow(window, nShowCmd);

    return 0;
}

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

Часть 2. Основа трехмерной графики - перспективное преобразование

Представьте, что вы стоите на железной дороге и смотрите как пути уходят вдаль, и чем ближе они к линии горизонта, тем сильнее и сильнее они сужаются. Это и есть перспективное преобразование. Чем дальше объект от наблюдателя - тем ближе он к точке схождения перспективы.

Представим теперь точку в трехмерном пространстве, положение которой определяется координатами XYZ. Координата Z будет отвечать за её удаление от камеры (глубину).
Нам нужно сделать так, чтобы из трехмерных коодинат мы получили координаты пикселя на экране. Т.е. сделать проекцию точки из трехмерного пространства в пространство нашего двухмерного экрана.
И делается это невероятно просто. Сперва определимся, что (0,0) в экранных координатах - это центр экрана.
Далее всё, что нам нужно, это просто взять координаты точки и разделить XY на Z. Чем дальше по координате Z от нас точка, тем на большее число мы делим, и тем ближе к центру смыкаются XY.

Теперь в виде кода:

// 3D point coordinates
float x = 1.0f;
float y = 0.0f;
float z = 5.0f;

// Perspective divide
x = x / z;
y = y / z;

// Screen-space transform (remap from [-0.5, 0.5] to [0, screen_dimension])
int pixel_x = (0.5f + x) * screen_width;
int pixel_y = (0.5f + y) * screen_height;

SetPixelColor(pixel_x, pixel_y, 0xFFFFFF);