Рисуем мультяшный взрыв за 180 строчек голого C++

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

Как всегда, в нашем распоряжении только голый компилятор, никаких сторонних библитек использовать нельзя. Я не хочу заморачиваться с оконными менеджерами, обработкой мыши/клавиатуры и тому подобным. Результатом работы нашей программы будет простая картинка, сохранённая на диск. Я совершенно не гонюсь за скоростью/оптимизацией, моя цель — показать основные принципы.

Итого, как в таких условиях нарисовать вот такую картинку за 180 строчек кода?

052265365c5c2a1da16850f7e0cb6eb1.jpg

Давайте я даже анимированную гифку вставлю (шесть метров):

kaboom.gif

А теперь разобьём всю задачу на несколько этапов:


Да, именно так. Самым первым делом нужно прочитать предыдущую главу, которая рассказывает об основах трассировки лучей. Она совсем короткая, в принципе, всякие отражения-преломления можно не читать, но хотя бы до рассеянного освещения дочитать всё же рекомендую. Код достаточно простой, народ его даже на микроконтроллерах запускает:

cc70800d1c9d4263579068543833351d.gif


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

38449107fec46a30b60ea125a61180e7.jpg

Код смотреть здесь, но давайте я приведу основной прямо в тексте статьи:

#define _USE_MATH_DEFINES
#include 
#include 
#include 
#include 
#include 
#include 
#include "geometry.h"

const float sphere_radius   = 1.5;

float signed_distance(const Vec3f &p) {
    return p.norm() - sphere_radius;
}

bool sphere_trace(const Vec3f &orig, const Vec3f &dir, Vec3f &pos) {
    pos = orig;
    for (size_t i=0; i<128; i++) {
        float d = signed_distance(pos);
        if (d < 0) return true;
        pos = pos + dir*std::max(d*0.1f, .01f);
    }
    return false;
}

int main() {
    const int   width    = 640;
    const int   height   = 480;
    const float fov      = M_PI/3.;
    std::vector framebuffer(width*height);

#pragma omp parallel for
    for (size_t j = 0; j(255*framebuffer[i][j]))));
        }
    }
    ofs.close();

    return 0;
}

Класс векторов живёт в файле geometry.h, описывать я его здесь не буду: во-первых, там всё тривиально, простое манипулирование двух и трёхмерными векторами (сложение, вычитание, присваивание, умножение на скаляр, скалярное произвдение), а во-вторых, gbg его уже подробно описал в рамках курса лекций по компьютерной графике.

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

Итак, в функции main () у меня два цикла: второй цикл просто сохраняет картинку на диск, а первый цикл — проходит по всем пикселям картинки, испускает луч из камеры через этот пиксель, и смотрит, не пересекается ли этот луч с нашей сферой.

Внимание, основная идея статьи: если в прошлой статье мы аналитически считали пересечение луча и сферы, то сейчас я его считаю численно. Идея простая: сфера имеет уравнение вида x^2 + y^2 + z^2 — r^2 = 0;, но вообще функция f (x, y, z) = x^2 + y^2 + z^2 — r^2 определена во всём пространстве. Внутри сферы функция f (x, y, z) будет иметь отрицательные значения, а снаружи сферы положительные. То есть, функция f (x, y, z) задаёт для точки (x, y, z) расстояние (со знаком!) до нашей сферы. Поэтому мы просто будем скользить вдоль луча до тех пор, пока либо нам не надоест, либо функция f (x, y, z) станет отрицательной. Функция sphere_trace () именно это и делает.


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

7b5a6b9e6d05c103b630ffeb6f10018e.jpg

Как и в прошлой статье, для простоты чтения я сделал один этап = один коммит. Изменения можно смотреть тут.

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

Vec3f distance_field_normal(const Vec3f &pos) {
    const float eps = 0.1;
    float d = signed_distance(pos);
    float nx = signed_distance(pos + Vec3f(eps, 0, 0)) - d;
    float ny = signed_distance(pos + Vec3f(0, eps, 0)) - d;
    float nz = signed_distance(pos + Vec3f(0, 0, eps)) - d;
    return Vec3f(nx, ny, nz).normalize();
}

В принципе, конечно, поскольку мы рисуем сферу, то нормаль можно получить гораздо проще, но я сделал так с заделом на будущее.


А давайте нарисум какой-нибудь паттерн на нашей сфере, например, вот такой:

0616cd67af48498b5ef3876f9b3e9754.jpg

Для этого в предыдущем коде я изменил всего две строчки!

Как я это сделал? Разумеется, у меня нет никаких текстур. Я просто взял функцию g (x, y, z) = sin (x) * sin (y) * sin (z); она опять же определена во всём пространстве. Когда мой луч пересекает сферу в какой-то точке, то значение функции g (x, y, z) в этой точке мне задаёт цвет пикселя.

Кстати, обратите внимание на концентрические круги по сфере — это артефакты моего численного подсчёта пересечения.


Для чего я захотел нарисовать этот паттерн? А он мне поможет нарисовать вот такого ёжика:
fae28b583626fc4b44706a465dc3f328.jpg

Там, где мой паттерн давал чёрный цвет, я хочу продавить ямку на нашей сфере, а где он был белым, там наоборот, вытянуть горбик.

Чтобы это сделать, достаточно изменить три строчки в нашем коде:

float signed_distance(const Vec3f &p) {
    Vec3f s = Vec3f(p).normalize(sphere_radius);
    float displacement = sin(16*s.x)*sin(16*s.y)*sin(16*s.z)*noise_amplitude;
    return p.norm() - (sphere_radius + displacement);
}

То есть, я изменил расчёт расстояния до нашей поверхности, определив его как x^2+y^2+z^2 — r^2 — sin (x)*sin (y)*sin (z). По факту, мы определили неявную функцию.


А почему это я оцениваю произведение синусов только для точек, лежащих на поверхности нашей сферы? Давайте переопределим нашу неявную функцию вот так:

float signed_distance(const Vec3f &p) {
    float displacement = sin(16*p.x)*sin(16*p.y)*sin(16*p.z)*noise_amplitude;
    return p.norm() - (sphere_radius + displacement);
}

Разница с предыдущим кодом совсем маленькая, лучше посмотреть дифф. Вот что получится в итоге:

f72da1e29ba32fb8cbd7df9acfdd4058.jpg

Таким образом мы можем определять несвязные компоненты в нашем объекте!


Предыдущая картинка уже начинает отдалённо напоминать взрыв, но произведение синусов имеет слишком регулярный паттерн. Нам бы какую-нибудь боолее «рваную», более «случайную» функцию… На помощь нам придёт шум Перлина. Вот что-нибудь такое нам бы подошло гораздо лучше произведения синусов:

0f8b7a677e99281f724edb3cb94bf9d0.png

Как генерировать подобный шум — немного оффтоп, но вот основная идея: нужно сгенерировать случайных картинок с разными разрешениями, сгладить их, чтобы получить примерно такой набор:

24b577f9e194d834d2e243f4dc6d2e3a.png

А потом просто их просуммировать:

bd0e8d172b8179a5f9cd30abdcfa3ae7.jpg

Подробнее прочитать можно здесь и здесь.

Давайте добавим немного кода, генерирующего этот шум, и получим такую картинку:

175da880a5e746fd8582906bd6c2829e.jpg

Обратите внимание, что в коде рендеринга я не изменил вообще ничего, изменилась только функция, которая «мнёт» нашу сферу.


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

Vec3f palette_fire(const float d) {
    const Vec3f   yellow(1.7, 1.3, 1.0); // note that the color is "hot", i.e. has components >1
    const Vec3f   orange(1.0, 0.6, 0.0);
    const Vec3f      red(1.0, 0.0, 0.0);
    const Vec3f darkgray(0.2, 0.2, 0.2);
    const Vec3f     gray(0.4, 0.4, 0.4);

    float x = std::max(0.f, std::min(1.f, d));
    if (x<.25f)
        return lerp(gray, darkgray, x*4.f);
    else if (x<.5f)
        return lerp(darkgray, red, x*4.f-1.f);
    else if (x<.75f)
        return lerp(red, orange, x*4.f-2.f);
    return lerp(orange, yellow, x*4.f-3.f);
}

Это простой линейный градиент между пятью ключевыми цветами. Ну, а вот картинка!

052265365c5c2a1da16850f7e0cb6eb1.jpg


Эта техника трассировки лучей называется ray marching. Домашнее задание простое: скрестить предыдущий рейтрейсер с блэкджеком и отражениями с нашим взрывом, да так, чтобы взрыв ещё и освещал всё вокруг!

© Habrahabr.ru