1
0
mirror of https://github.com/ssloy/tinyraytracer.git synced 2025-03-15 11:19:38 +01:00

Updated Home (markdown)

Dmitry V. Sokolov 2019-01-20 17:45:03 +01:00
parent 5b24d0fabb
commit 44cde3d491

237
Home.md

@ -1 +1,236 @@
Welcome to the tinyraytracer wiki!
# 256 lines of C++ code: implementing a ray tracing algorithm
Публикую очередную главу из моего <a href="https://github.com/ssloy/tinyrenderer">курса лекций по компьютерной графике</a> (вот <a href="https://habr.com/ru/post/249139/">тут можно читать</a> оригинал на русском, хотя английская версия новее). На сей раз тема разговора - отрисовка сцен при помощи трассировки лучей. Как обычно, я стараюсь избегать сторонних библиотек, так как это заставляет студентов заглянуть под капот.
Подобных проектов в интернете уже море, но практически все они показывают законченные программы, в которых разобраться крайне непросто. Вот, например, очень известная <a href="https://www.taylorpetrick.com/blog/post/business-rt">программа рендеринга, влезающая на визитку</a>. Очень впечатляющий результат, однако разобраться в этом коде очень непросто. Моей целью является не показать как я могу, а детально рассказать, как подобное воспроизвести. Более того, мне кажется, что конкретно эта лекция полезна даже не столь как учебный материал по комьпютерной графике, но скорее как пособие по программированию. Я последовательно покажу, как прийти к конечному результату, начиная с самого нуля: как разложить сложную задачу на элементарно решаемые этапы.
<i>Внимание: просто рассматривать мой код, равно как и просто читать эту статью с чашкой чая в руке, смысла не имеет. Эта статья рассчитана на то, что вы возьмётесь за клавиатуру и напишете ваш собственный движок. Он наверняка будет лучше моего. Ну или просто смените язык программирования!</i>
Итак, сегодня я покажу, как отрисовывать подобные картинки:
<img src="https://raw.githubusercontent.com/ssloy/tinyraytracer/homework_assignment/out-envmap-duck.jpg"/>
<habracut/>
<h1>Этап первый: сохранение картинки на диск</h1>
Я не хочу заморачиваться с оконными менеджерами, обработкой мыши/клавиатуры и тому подобным. Результатом работы нашей программы будет простая картинка, сохранённая на диск. Итак, первое, что нам нужно уметь, это сохранить картинку на диск. <a href="https://github.com/ssloy/tinyraytracer/tree/bd36c9857305b3cbd06f5b768bb48a92df9ae68b">Вот здесь</a> лежит код, который позволяет это сделать. Давайте я приведу его основной файл:
```c++
#include <limits>
#include <cmath>
#include <iostream>
#include <fstream>
#include <vector>
#include "geometry.h"
void render() {
const int width = 1024;
const int height = 768;
std::vector<Vec3f> framebuffer(width*height);
for (size_t j = 0; j<height; j++) {
for (size_t i = 0; i<width; i++) {
framebuffer[i+j*width] = Vec3f(j/float(height),i/float(width), 0);
}
}
std::ofstream ofs; // save the framebuffer to file
ofs.open("./out.ppm");
ofs << "P6\n" << width << " " << height << "\n255\n";
for (size_t i = 0; i < height*width; ++i) {
for (size_t j = 0; j<3; j++) {
ofs << (char)(255 * std::max(0.f, std::min(1.f, framebuffer[i][j])));
}
}
ofs.close();
}
int main() {
render();
return 0;
}
```
В функции main вызывается только функция render(), больше ничего. Что же внутри функции render()? Перво-наперво я определяю картинку как одномерный массив framebuffer значений типа Vec3f, это простые трёхмерные векторы, которые дают нам цвет (r,g,b) для каждого пикселя.
Класс векторов живёт в файле geometry.h, описывать я его здесь не буду: во-первых, там всё тривиально, простое манипулирование двух и трёхмерными векторами (сложение, вычитание, присваивание, умножение на скаляр, скалярное произвдение), а во-вторых, @gbg его уже <a href="https://habr.com/ru/post/248909/">подробно описал</a> в рамках курса лекций по компьютерной графике.
Картинку я сохраняю в <a href="https://en.wikipedia.org/wiki/Netpbm_format">формате ppm</a>; это самый простой способ сохранения изображений, хотя и не всегда самый удобный для дальнейшего просматривания. Если хотите сохранять в других форматах, то рекомендую всё же подключить стороннюю библиотеку, например, <a href="https://github.com/nothings/stb">stb</a>. Это прекрасная библиотека: достаточно в проект включить один заголовочный файл stb_image_write.h, и это позволит сохранять хоть в png, хоть в jpg.
Итого, целью данного этапа является убедиться, что мы можем а) создать картинку в памяти и записывать туда разные значения цветов б) сохранить результат на диск, чтобы можно было его просмотреть в сторонней программе. Вот результат:
<img src="https://raw.githubusercontent.com/ssloy/tinyraytracer/bd36c9857305b3cbd06f5b768bb48a92df9ae68b/out.jpg"/>
<h1>Этап второй, самый сложный: непосредственно трассировка лучей</h1>
Это самый важный и сложный этап из всей цепочки. Я хочу определить в моём коде одну сферу и показать её на экране, не заморачиваясь ни материалами, ни освещением. Вот так должен выглядеть наш результат:
<img src="https://raw.githubusercontent.com/ssloy/tinyraytracer/5806eb45e93dab225ab335824cbc3f537d511b28/out.jpg"/>
Для удобства в моём репозитории по одному коммиту на каждый этап; Github позволяет очень удобно просматривать внесённые изменения. <a href="https://github.com/ssloy/tinyraytracer/commit/5806eb45e93dab225ab335824cbc3f537d511b28">Вот, например</a>, что изменилось во втором коммите по сравнению с первым.
Для начала: что нам нужно, чтобы в памяти компьютера представить сферу? Нам достаточно четырёх чисел: трёхмерный вектор с центром сферы и скаляр, описывающий радиус:
```c++
struct Sphere {
Vec3f center;
float radius;
Sphere(const Vec3f &c, const float &r) : center(c), radius(r) {}
bool ray_intersect(const Vec3f &orig, const Vec3f &dir, float &t0) const {
Vec3f L = center - orig;
float tca = L*dir;
float d2 = L*L - tca*tca;
if (d2 > radius*radius) return false;
float thc = sqrtf(radius*radius - d2);
t0 = tca - thc;
float t1 = tca + thc;
if (t0 < 0) t0 = t1;
if (t0 < 0) return false;
return true;
}
};
```
Единственная нетривиальная вещь в этом коде - это функция, которая позволяет проверить, пересекается ли заданный луч (исходящий из orig в направлении dir) с нашей сферой. Детальное описание алгоритма проверки пересечения луча и сферы можно <a href="http://www.lighthouse3d.com/tutorials/maths/ray-sphere-intersection/">прочитать тут</a>, очень рекомендую это сделать и проверить мой код.
Как работает трассировка лучей? Очень просто. На первом этапе мы просто замели картинку градиентом:
```c++
for (size_t j = 0; j<height; j++) {
for (size_t i = 0; i<width; i++) {
framebuffer[i+j*width] = Vec3f(j/float(height),i/float(width), 0);
}
}
```
Теперь же мы для каждого пикселя сформируем луч, идущий из центра координат, и проходящий через наш пиксель, и проверим, не пересекает ли этот луч нашу сферу.
<img src="https://upload.wikimedia.org/wikipedia/commons/8/83/Ray_trace_diagram.svg"/>
Если пересечения со сферой нет, то мы поставим цвет1, иначе цвет2:
```c++
Vec3f cast_ray(const Vec3f &orig, const Vec3f &dir, const Sphere &sphere) {
float sphere_dist = std::numeric_limits<float>::max();
if (!sphere.ray_intersect(orig, dir, sphere_dist)) {
return Vec3f(0.2, 0.7, 0.8); // background color
}
return Vec3f(0.4, 0.4, 0.3);
}
void render(const Sphere &sphere) {
[...]
for (size_t j = 0; j<height; j++) {
for (size_t i = 0; i<width; i++) {
float x = (2*(i + 0.5)/(float)width - 1)*tan(fov/2.)*width/(float)height;
float y = -(2*(j + 0.5)/(float)height - 1)*tan(fov/2.);
Vec3f dir = Vec3f(x, y, -1).normalize();
framebuffer[i+j*width] = cast_ray(Vec3f(0,0,0), dir, sphere);
}
}
[...]
}
```
На этом месте рекомендую взять карандаш и проверить на бумаге все вычисления, как пересечение луча со сферой, так и заметание картинки лучами. На всякий случай, наша камера определяется следующими вещами:
<ul>
<li>ширина картинки, width</li>
<li>высота картинки, height</li>
<li>угол обзора, fov</li>
<li>расположение камеры, Vec3f(0,0,0)</li>
<li>направление взора, вдоль оси z, в направлении минус бесконечности</li>
</ul>
<h1>Этап третий: добавляем ещё сфер</h1>
Всё самое сложное уже позади, теперь наш путь безоблачен. Если мы умеем нарисовать одну сферу. то явно добавить ещё несколько труда не составит. <a href="https://github.com/ssloy/tinyraytracer/commit/c19c430151cb659372b4988876173b022164e371">Вот тут</a> смотреть изменения в коде, а вот так выглядит результат:
<img src="https://raw.githubusercontent.com/ssloy/tinyraytracer/c19c430151cb659372b4988876173b022164e371/out.jpg"/>
<h1>Этап четвёртый: освещение</h1>
Всем хороша наша картинка, да вот только освещения не хватает. На протяжении всей оставшейся статьи мы об этом только и будем разговаривать. Добавим несколько точечных источников освещения:
```c++
struct Light {
Light(const Vec3f &p, const float &i) : position(p), intensity(i) {}
Vec3f position;
float intensity;
};
```
Считать настоящее освещение - это очень и очень непростая задача, поэтому, как и все, мы будем обманывать глаз, рисуя совершенно нефизичные, но максимально возможно правдоподобные результаты. Первое замечание: почему зимой холодно, а летом жарко? Потому что нагрев поверхности земли зависит от угла падения солнечных лучей. Чем выше солнце над горизонтом, тем ярче освещается поверхность. И наоборот, чем ниже над горизонтом, тем слабее. Ну а после того, как солнце сядет за горизонт, до нас и вовсе фотоны не долетают. Применительно к нашим сферам: вот наш луч, испущенный из камеры (никакого отношения к фотонам, обратите внимание!) пересёкся со сферой. Как нам понять, как освещена точка пересечения? Можно просто посмотреть на угол между нормальным вектором в этой точке и вектором, описывающим направление света. Чем меньше угол, тем лучше освещена поверхность. Чтобы считать было ещё удобнее, можно просто взять скалярное произвдение между вектором нормали и вектором освещения. Напоминаю, что скалярное произвдение между двумя векторами a и b равно произведению норм векторов на косинус угла между векторами: a*b = |a| |b| cos(alpha(a,b)). Если взять векторы единичной длины, то простейшее скалярное произведение даст нам интенсивность освещения поверхности.
Таким образом, в функции cast_ray вместо постоянного цвета будем возвращать цвет с учётом источников освещения:
```c++
Vec3f cast_ray(const Vec3f &orig, const Vec3f &dir, const Sphere &sphere) {
[...]
float diffuse_light_intensity = 0;
for (size_t i=0; i<lights.size(); i++) {
Vec3f light_dir = (lights[i].position - point).normalize();
diffuse_light_intensity += lights[i].intensity * std::max(0.f, light_dir*N);
}
return material.diffuse_color * diffuse_light_intensity;
}
```
Измениия <a href="https://github.com/ssloy/tinyraytracer/commit/9a728fff2bbebb1eedd86e1ac89f657d43191609">смотреть тут</a>, а вот результат работы программы:
<img src="https://raw.githubusercontent.com/ssloy/tinyraytracer/9a728fff2bbebb1eedd86e1ac89f657d43191609/out.jpg"/>
<h1>Этап пятый: блестящие поверхности</h1>
Трюк со скалярным произведением между нормальным вектором и вектором света неплохо приближает освещение матовых поверхностей, в литературе называется диффузным освещением. Что же делать, если мы хотим гладкие да блестящие? Я хочу получить вот такую картинку:
<img src="https://raw.githubusercontent.com/ssloy/tinyraytracer/f5ec45c2541feb86b6a30cc3bb04917d60d13e9b/out.jpg"/>
Посмотрите, <a href="https://github.com/ssloy/tinyraytracer/commit/f5ec45c2541feb86b6a30cc3bb04917d60d13e9b">насколько мало</a> нужно было сделать изменений. Если вкратце, то отсветы на блестящих поверхностях тем ярче, чем меньше угол между направлением взгляда и направлением <i>отражённого</i> света. Ну а углы, понятно, мы будем считать через скалярные произведения, ровно как и раньше.
Эта гимнастика с освещением матовых и блестящих поверхностей известна как <a href="https://en.wikipedia.org/wiki/Phong_reflection_modell">модель Фонга</a>. В вики есть довольно детальное описание этой модели освещения, она хорошо читается при параллельном сравнении с моим кодом. Вот ключевая для понимания картинка:
<img src="https://upload.wikimedia.org/wikipedia/commons/6/6b/Phong_components_version_4.png"/>
<h1>Этап шестой: тени</h1>
А почему это у нас есть свет, но нет теней? Непорядок! Хочу вот такую картинку:
<img src="https://raw.githubusercontent.com/ssloy/tinyraytracer/ef70d1356169dacb3183ad4fcb4c23f1d7003e1b/out.jpg"/>
<a href="https://github.com/ssloy/tinyraytracer/commit/ef70d1356169dacb3183ad4fcb4c23f1d7003e1b">Всего шесть строчек кода</a> позволяют этого добиться: при отрисовке каждой точки мы просто убеждаемся, не пересекает ли луч точка-источник света объекты нашей сцены, и если пересекает, то пропускам текущий источник света. Тут есть только маленькая тонкость: я самую малость сдвигаю точку в направлении нормали:
```c++
Vec3f shadow_orig = light_dir*N < 0 ? point - N*1e-3 : point + N*1e-3;
```
Почему? Да просто наша точка лежит на поверхности объекта, и (исключаяя вопрос численных погрешностей) любой луч из этой точки будет пересекать нашу сцену.
<h1>Этап седьмой: отражения</h1>
Это невероятно, но чтобы добавить отражения в нашу сцену, нам достаточно добавить только три строчки кода:
```c++
Vec3f reflect_dir = reflect(dir, N).normalize();
Vec3f reflect_orig = reflect_dir*N < 0 ? point - N*1e-3 : point + N*1e-3; // offset the original point to avoid occlusion by the object itself
Vec3f reflect_color = cast_ray(reflect_orig, reflect_dir, spheres, lights, depth + 1);
```
<a href="https://github.com/ssloy/tinyraytracer/commit/c80479d1d22fe98f41b584972affeb43422a23a6">Убедитесь в этом сами:</a> при пересечении с объектом мы просто считаем отражённый луч (функция из подсчёта отбесков пригодилась!) и рекурсивно вызываем функцию cast_ray в направлении отражённого луча. Обязательно поиграйте с <a href="https://github.com/ssloy/tinyraytracer/blob/c80479d1d22fe98f41b584972affeb43422a23a6/tinyraytracer.cpp#L65">глубиной рекурсии</a>, я её поставил равной четырём, начните с нуля, что будет изменяться на картинке? Вот мой результат с работающим отражением и глубиной четыре:
<img src="https://raw.githubusercontent.com/ssloy/tinyraytracer/c80479d1d22fe98f41b584972affeb43422a23a6/out.jpg"/>
<h1>Этап восьмой: преломление</h1>
Научившись считать отражения, <a href="https://github.com/ssloy/tinyraytracer/commit/b69793bf6e8be54973cad1b18185a67dbf11bad1">преломления считаются ровно так же</a>. Одна функция позволяющая посчитать направление преломившегося луча (<a href="https://en.wikipedia.org/wiki/Snell%27s_law">по закону Снеллиуса</a>), и три строчки кода в нашей рекурсивной функции cast_ray. Вот результат, в котором ближайший шарик стал "стеклянным", он и преломляет, и немного отражает:
<img src="https://raw.githubusercontent.com/ssloy/tinyraytracer/b69793bf6e8be54973cad1b18185a67dbf11bad1/out.jpg"/>
<h1>Этап девятый: добавляем ещё объекты</h1>
А чего это мы всё без молока, да без молока. До этого момента мы рендерили только сферы, поскольку это один из простейших нетривиальных математических объектов. А давайте добавим кусок плоскости. Классикой жанра является шахматная доска. Для этого нам вполне достаточно <a href="https://github.com/ssloy/tinyraytracer/commit/5e0da1f09fdbc585caa16df4c7b2f527d61536ef">десятка строчек</a> в функции, которая считает пересечение луча со сценой.
Ну и вот результат:
<img src="https://raw.githubusercontent.com/ssloy/tinyraytracer/5e0da1f09fdbc585caa16df4c7b2f527d61536ef/out.jpg"/>
Как я и обещал, ровно 256 строчек кода, <a href="https://github.com/ssloy/tinyraytracer">посчитайте сами</a>!
<h1>Этап десятый: домашнее задание</h1>
Мы прошли довольно долгий путь: научились добавлять объекты в сцену, считать довольно сложное освещение. Давайте я оставлю два задания в качестве домашки. Абсолютно вся подготовительная работа уже сделана в ветке <a href="https://github.com/ssloy/tinyraytracer/tree/homework_assignment">homework_assignment</a>. Каждое задание потребует максимум десять строчек кода.
<h3>Задание первое: Environment map</h3>
На данный момент, если луч не пересекает сцену, то мы ему просто ставим постоянный цвет. А почему, собственно, постоянный? Давайте возьмём сферическую фотографию (файл <a href="https://raw.githubusercontent.com/ssloy/tinyraytracer/homework_assignment/envmap.jpg">envmap.jpg</a>) и используем её в качестве фона! Для облегчения жизни я слинковал наш проект с библиотекой stb для удобства работы со жпегами. Должен получиться вот такой рендер:
<img src="https://raw.githubusercontent.com/ssloy/tinyraytracer/homework_assignment/out-envmap.jpg"/>
<h3>Задание второе: кря!</h3>
Мы умеем рендерить и сферы, и плоскости (см. шахматную доску). Так давайте добавим отрисовку триангулированных моделей! Я написал код, позволяющий читать сетку треугольников, и добавил туда функцию пересечения луч-треугольник. Теперь добавить утёнка нашу сцену должно быть совсем тривиально!
<img src="https://raw.githubusercontent.com/ssloy/tinyraytracer/homework_assignment/out-envmap-duck.jpg"/>
<h1>Заключение</h1>
Моя основная задача - показать проекты, которые интересно (и легко!) программировать, очень надеюсь, что у меня это получается. Это очень важно, так как я убеждён, что программист должен писать много и со вкусом. Не знаю как вам, но лично меня бухучёт и сапёр, при вполне сравнимой сложности кода, не привлекают совсем.
Двести пятьдесят строчек рейтрейсинга реально написать за несколько часов. <a href="https://github.com/ssloy/tinyrenderer/wiki">Пятьсот строчек</a> софтверного растеризатора можно осилить за несколько дней. В следующий раз разберём по полочкам <a href="https://ru.wikipedia.org/wiki/Ray_casting">рейкастинг</a>, и заодно я покажу простейшие игры, которые пишут мои студенты-первокурсники в рамках обучения программированию на С++. Stay tuned!