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

Мне стало интересно понять как именно происходит отрисовка всего, что я вижу в играх, пиксель за пикселем. Мотивацией для создания софтверного растеризатора мне послужила тяга к пониманию работы тех технологий, которые я использую в своих проектах.
В ходе разработки я убедился, что в этом нет ничего сложного, и что современные CPU способны справляться с отрисовкой 3D-графики не хуже стареньких GPU, при этом полный контроль над алгоритмами растеризации открывает широкий простор для экспериментов, а отвязка от графических библиотек делает проекты портативными и компактными. Что я и намерен продемонстрировать далее на практике.
Вы можете ознакомиться с демками моего растеризатора на отдельной странице, перейдя по этой ссылке.
Оглавление
- Часть 1. Приготовление
- Часть 2. Основа трехмерной графики - перспективное преобразование
- Часть 3. Рисуем куб!
Часть 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);