Введение: Зачем писать собственный софтварный рендерер в эпоху RTX?

Пока видеокарты с тензорными ядрами и трассировкой лучей в реальном времени рендерят фотореалистичные миры, многие из нас продолжают воспринимать графический пайплайн как бездонный черный ящик. Вы дергаете за ручки современных API вроде Vulkan или DirectX 12, инициализируете тысячи строк шаблонного кода — и молитесь, чтобы драйвер не упал. Но что если в пятничный вечер отбросить костыли экосистемы, выключить GPU и заставить процессор честно, пиксель за пикселем, посчитать геометрию? Написание софтварного рендерера с нуля на чистом C++ — это лучший способ перестать бояться тригонометрии, понять суть буферов глубины и прокачать инженерное мышление до уровня создателя игровых движков.

Современные графические API, такие как Vulkan, DirectX 12 и OpenGL, предоставляют разработчикам беспрецедентный контроль над графическим процессором (GPU). Мы можем манипулировать буферами, писать сложные шейдеры и выжимать максимум производительности из кремния. Однако за эту невероятную мощь приходится платить чудовищным усложнением кодовой базы. Современный пайплайн рендеринга — это черный ящик с тысячами строк инициализации, управления памятью и синхронизации потоков (хотя иногда кажется, что всё держится исключительно на силе воли и Stack Overflow).

Но что если сделать шаг назад? Что если отказаться от аппаратного ускорения, видеокарт и тяжеловесных библиотек, чтобы понять саму суть трехмерной графики? Написание софтварного рендерера (software renderer) с нуля на чистом C++ — это лучший способ демистифицировать процесс создания изображений. В этой статье мы разберем, как спроектировать и реализовать базовый пайплайн отрисовки 3D-моделей, который умещается примерно в 500 строк кода, не используя ничего, кроме стандартной библиотеки C++ и простого буфера пикселей.

Архитектура проекта и базовый буфер кадров

Любая графическая система начинается с холста. Поскольку мы пишем софтварный рендерер, у нас нет аппаратного экрана — есть лишь оперативная память. Наша первая задача — создать структуру данных для хранения пикселей (framebuffer) и механизм сохранения или вывода этого буфера на экран.

Для простоты мы не будем привязываться к конкретным библиотекам вроде SDL или GLFW в самом ядре рендерера, а представим наш холст как простой одномерный массив байтов. Каждый пиксель будет кодироваться четырьмя байтами в формате RGBA (Red, Green, Blue, Alpha), а также добавим Z-буфер (depth buffer) для корректного разрешения видимости поверхностей (Z-буфер — это как соседи сверху: кто ближе к вам, тот и перекрывает обзор).

Давайте определим базовую структуру для нашего холста и буфера глубины:

#include <vector>
#include <iostream>
#include <fstream>
#include <cmath>
#include <algorithm>

struct Color {
    uint8_t r, g, b, a;
};

class Framebuffer {
private:
    int width, height;
    std::vector<Color> pixels;
    std::vector<float> zbuffer;

public:
    Framebuffer(int w, int h) : width(w), height(h), pixels(w * h, {0, 0, 0, 255}), zbuffer(w * h, 1.0f) {}

    void clear() {
        std::fill(pixels.begin(), pixels.end(), Color{15, 15, 20, 255});
        std::fill(zbuffer.begin(), zbuffer.end(), 1.0f);
    }

    void setPixel(int x, int y, const Color& c, float depth) {
        if (x < 0 || x >= width || y < 0 || y >= height) return;
        int idx = x + y * width;
        if (zbuffer[idx] > depth) {
            zbuffer[idx] = depth;
            pixels[idx] = c;
        }
    }

    int getWidth() const { return width; }
    int getHeight() const { return height; }
    
    // Метод для сохранения кадра в простой формат PPM
    void savePPM(const std::string& filename) {
        std::ofstream file(filename, std::ios::binary);
        file << "P6\n" << width << " " << height << "\n255\n";
        file.write(reinterpret_cast<const char*>(pixels.data()), pixels.size() * sizeof(Color));
    }
};

Имея на руках этот скромный массив байтов, мы уже контролируем каждый отде