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

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

Внимание: просто рассматривать мой код, равно как и просто читать эту статью с чашкой чая в руке, смысла не имеет. Эта статья рассчитана на то, что вы возьмётесь за клавиатуру и напишете ваш собственный движок. Он наверняка будет лучше моего. Ну или просто смените язык программирования!

Итак, сегодня я покажу, как отрисовывать подобные картинки:



Этап первый: сохранение картинки на диск


Я не хочу заморачиваться с оконными менеджерами, обработкой мыши/клавиатуры и тому подобным. Результатом работы нашей программы будет простая картинка, сохранённая на диск. Итак, первое, что нам нужно уметь, это сохранить картинку на диск. Вот здесь лежит код, который позволяет это сделать. Давайте я приведу его основной файл:

#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 его уже подробно описал в рамках курса лекций по компьютерной графике.

Картинку я сохраняю в формате ppm; это самый простой способ сохранения изображений, хотя и не всегда самый удобный для дальнейшего просматривания. Если хотите сохранять в других форматах, то рекомендую всё же подключить стороннюю библиотеку, например, stb. Это прекрасная библиотека: достаточно в проект включить один заголовочный файл stb_image_write.h, и это позволит сохранять хоть в png, хоть в jpg.

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



Этап второй, самый сложный: непосредственно трассировка лучей


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



Для удобства в моём репозитории по одному коммиту на каждый этап; Github позволяет очень удобно просматривать внесённые изменения. Вот, например, что изменилось во втором коммите по сравнению с первым.

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

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) с нашей сферой. Детальное описание алгоритма проверки пересечения луча и сферы можно прочитать тут, очень рекомендую это сделать и проверить мой код.

Как работает трассировка лучей? Очень просто. На первом этапе мы просто замели картинку градиентом:

    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);
        }
    }

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



Если пересечения со сферой нет, то мы поставим цвет1, иначе цвет2:

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);
        }
    }
?	[...]
}

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

  • ширина картинки, width
  • высота картинки, height
  • угол обзора, fov
  • расположение камеры, Vec3f(0,0,0)
  • направление взора, вдоль оси z, в направлении минус бесконечности

Этап третий: добавляем ещё сфер


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



Этап четвёртый: освещение


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

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 вместо постоянного цвета будем возвращать цвет с учётом источников освещения:

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;
}

Изменения смотреть тут, а вот результат работы программы:



Этап пятый: блестящие поверхности


Трюк со скалярным произведением между нормальным вектором и вектором света неплохо приближает освещение матовых поверхностей, в литературе называется диффузным освещением. Что же делать, если мы хотим гладкие да блестящие? Я хочу получить вот такую картинку:



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

Эта гимнастика с освещением матовых и блестящих поверхностей известна как модель Фонга. В вики есть довольно детальное описание этой модели освещения, она хорошо читается при параллельном сравнении с моим кодом. Вот ключевая для понимания картинка:


Этап шестой: тени


А почему это у нас есть свет, но нет теней? Непорядок! Хочу вот такую картинку:



Всего шесть строчек кода позволяют этого добиться: при отрисовке каждой точки мы просто убеждаемся, не пересекает ли луч точка-источник света объекты нашей сцены, и если пересекает, то пропускам текущий источник света. Тут есть только маленькая тонкость: я самую малость сдвигаю точку в направлении нормали:

Vec3f shadow_orig = light_dir*N < 0 ? point - N*1e-3 : point + N*1e-3;

Почему? Да просто наша точка лежит на поверхности объекта, и (исключаяя вопрос численных погрешностей) любой луч из этой точки будет пересекать нашу сцену.

Этап седьмой: отражения


Это невероятно, но чтобы добавить отражения в нашу сцену, нам достаточно добавить только три строчки кода:

    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);

Убедитесь в этом сами: при пересечении с объектом мы просто считаем отражённый луч (функция из подсчёта отбесков пригодилась!) и рекурсивно вызываем функцию cast_ray в направлении отражённого луча. Обязательно поиграйте с глубиной рекурсии, я её поставил равной четырём, начните с нуля, что будет изменяться на картинке? Вот мой результат с работающим отражением и глубиной четыре:



Этап восьмой: преломление


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



Этап девятый: добавляем ещё объекты


А чего это мы всё без молока, да без молока. До этого момента мы рендерили только сферы, поскольку это один из простейших нетривиальных математических объектов. А давайте добавим кусок плоскости. Классикой жанра является шахматная доска. Для этого нам вполне достаточно десятка строчек в функции, которая считает пересечение луча со сценой.

Ну и вот результат:



Как я и обещал, ровно 256 строчек кода, посчитайте сами!

Этап десятый: домашнее задание


Мы прошли довольно долгий путь: научились добавлять объекты в сцену, считать довольно сложное освещение. Давайте я оставлю два задания в качестве домашки. Абсолютно вся подготовительная работа уже сделана в ветке homework_assignment. Каждое задание потребует максимум десять строчек кода.

Задание первое: Environment map


На данный момент, если луч не пересекает сцену, то мы ему просто ставим постоянный цвет. А почему, собственно, постоянный? Давайте возьмём сферическую фотографию (файл envmap.jpg) и используем её в качестве фона! Для облегчения жизни я слинковал наш проект с библиотекой stb для удобства работы со жпегами. Должен получиться вот такой рендер:



Задание второе: кря!


Мы умеем рендерить и сферы, и плоскости (см. шахматную доску). Так давайте добавим отрисовку триангулированных моделей! Я написал код, позволяющий читать сетку треугольников, и добавил туда функцию пересечения луч-треугольник. Теперь добавить утёнка нашу сцену должно быть совсем тривиально!



Заключение


Моя основная задача — показать проекты, которые интересно (и легко!) программировать, очень надеюсь, что у меня это получается. Это очень важно, так как я убеждён, что программист должен писать много и со вкусом. Не знаю как вам, но лично меня бухучёт и сапёр, при вполне сравнимой сложности кода, не привлекают совсем.

Двести пятьдесят строчек рейтрейсинга реально написать за несколько часов. Пятьсот строчек софтверного растеризатора можно осилить за несколько дней. В следующий раз разберём по полочкам рейкастинг, и заодно я покажу простейшие игры, которые пишут мои студенты-первокурсники в рамках обучения программированию на С++. Stay tuned!

Комментарии (66)


  1. mdma_xtc
    20.01.2019 20:31
    +6

    Оче круто! Спасибо!


    1. orcy
      21.01.2019 12:30
      -2

      Было бы еще круче, если бы трассировало в compile time на шаблонах!


      1. masai
        21.01.2019 21:34

        Вот, пожалуйста – tcbrindle/raytracer.hpp.


        1. orcy
          22.01.2019 06:04

          Generating the above 512x512 image at compile time took around 45 minutes with Clang 4.0 on my Macbook Pro.


          Огонь! Интересно что он дальше пишет что в рантайме эта же задача выполняется за пол-секунды, т.е. компилятор не очень хорошо трассирует лучи. Вариант по ссылке в основном основан на constexpr (т.е. код вполне понимаемый), но еще дальше он пишет что вариант трассировки лучей написанный чисто на шаблонах работает еще медленней. Интересно, никогда не думал о таком аспекте constexpr как ускорение компиляции.


  1. Zet_Roy
    20.01.2019 22:08

    Моя основная задача — показать проекты, которые интересно (и легко!) программировать

    Да что бы такое програмировать это нужно быть гением.


    1. haqreu Автор
      20.01.2019 22:21
      +5

      Совсем нет, или студенты нашего провинциального университета поголовно гении. Достаточно немного усидчивости и последовательности. Конечно, хорошие методички лишними не бывают.


      1. EvilBeaver
        21.01.2019 11:32

        Полагаю, имелось в виду, «чтобы программировать такое ЛЕГКО — надо или хорошо набить руку на этом, или быть гением, чтобы делать такое сходу с нуля.»


      1. masai
        21.01.2019 20:03
        +2

        студенты нашего провинциального университета

        Судя по почте на github, это Universite de Lorraine. Ну, не такой он провинциальный. 201-300 место в ARWU2018. Тот же МФТИ – 410-500 место. Студенты, я подозреваю, тоже не самые простые.


        1. haqreu Автор
          21.01.2019 20:47

          Рекомендую посмотреть, как именно составляются рейтинги вузов. Тот факт, что университет Лотарингии впереди, например, СПбГУ по рейтингу, совершенно не обозначает того, что там лучше студенты. Будучи причастным к обоим, могу сравнивать.

          Ещё один простой пример: Ecole Polytechnique, школа с сильнейшими французским студентами, находится на 401-500 в этом же самом рейтинге. Неужто она хуже провинциального университета (я по-прежнему про Universite de Lorraine), который на 201-300? Нет, она меньше. Бюджет важен для рейтинга. Кстати, следите за руками. Университет Лотарингии был создан в 2012м году путём слияния шести (!) различных вузов, близких друг к другу географически. Для чего это было сделано? Подсказка: см. слова «бюджет», «количество студентов».


          1. haqreu Автор
            21.01.2019 21:19

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

            Скрытый текст


          1. masai
            21.01.2019 21:29

            Я совсем не об этом. Не знаю, как у других, но у меня слова «провинциальный вуз» ассоциируются с вузом, который вообще ни в какие рейтинги не входит, и в котором студентам-программистам на паре по компьютерной графике показывают пиратский Corel Draw, а сами студенты если и разбираются в программировании, то не благодаря вузу, а вопреки. К сожалению, такие вузы существуют. Я просто как-то смотрел учебные планы и рабочие программы небольших региональных вузов, и там всё не так хорошо, как хотелось бы. Может, я ошибаюсь, и таких вузов почти и не осталось, в которых ООП на TurboPascal на третьем курсе учат (условно).
            Извините за оффтопик, но раз уж заговорили. Вообще, я заметил по другим публикациям, что для вас характерна «чрезмерная» скромность. Вы пишете, что не специалист и сами плохо в чём-то разбираетесь, а на самом деле разбираетесь достаточно хорошо. Что это всё легко понять студентам обычного провинциального вуза, но вуз не такой уж и обычный, а вполне себе хороший. Что какую-то тему вы прошли ещё в школе, но при этом оказывается, что это специализированная физмат-школа (извините, если ошибаюсь, я мог и перепутать).
            Наверняка у вас благие намерения. Вы хотите мотивировать других людей, показать им, что не боги горшки обжигают. Но если материал для кого-то сложный, то слова о том, что он простой и школьного уровня, могут лишь заставить человека подумать: «Ну вот, школьники понимают, а я не понимаю. Видать, я недотёпа». Самооценка падает, ничего делать не хочется.
            Так получилось, что я довольно долго проработал в вузе и параллельно вёл кружок в местном лицее. Вуз маленький, конкурсы небольшие, разброс в подготовке студентов очень большой. А в лицее на физмате много талантливых ребят. И иногда так получалось, что я одну и ту же тему рассказывал восьмиклассникам в кружке и студентам (например, линейные корректирующие коды). И бывало, что восьмиклассники схватывали быстрее. Но если бы я сказал студентам, что это школьная тема – что недалеко от истины, в общем-то, – вряд ли бы это подняло их мотивацию. Это просто звучало бы обидно.
            Это всё вовсе не критика, у вас замечательные статьи, с удовольствием их читаю. Просто наблюдение. Вы, безусловно, возразите мне, что это статьи, которые может понять школьник, а не обязан их понять, и что не стоит «читать их за чашкой чая». И будете правы. Но я не о формальной стороне, а о коннотации. Просто учитывайте, что аудитория на хабре с более широким разбросом уровня подготовки.


            1. haqreu Автор
              21.01.2019 21:44

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

              Что касается хабра, то, конечно, я вынужден признать, что в силу критического недостатка времени я тут не всегда пишу самодостаточные тексты. Обычно я пишу либо для себя самого, когда разбираюсь в новой теме, либо (как конкретно эта статья, обратите внимание, что я уже опубликовал её перевод) в качестве записей для своих студентов, но, в отличие от хабра, они дополнительно ещё имеют цирк со мной у доски.

              А что абсолютно всё, о чём я пишу, это просто — так ведь это правда. Если я не сумел написать доступно — ну я так себе лектор. В комментариях ведь можно задавать вопросы, правда? Достаточно иметь огонь в глазах :)


        1. Alex_ME
          21.01.2019 22:54

          Для примера напишу про свой — Волгоградский Государственный Технический Университет, опорный ВУЗ, между прочим :)


          На первом курсе был матан, линейная алгебра, дискретка и прочие годные вещи и Кумир. Программистская часть программы была составлена с учетом абсолютно нулевых начальных знаний в этой области у студентов.


          1. haqreu Автор
            21.01.2019 22:57

            Пример интересный, но не могли бы вы сказать, какую именно мысль он иллюстрирует? Я запутался слегка.


            1. Alex_ME
              21.01.2019 23:00

              Просто ответил masai к обсуждению провинциальности университетов. И соглашаюсь с ним, что Universite de Lorraine несколько не совсем провинциальный.


    1. QtRoS
      20.01.2019 23:30
      +1

      На самом делье это вполне реально, как действительно заметил haqreu доступно даже студентам. Я откопал скрин своего студенческого (в рамках курса www.edx.org/course/computer-graphics от небезыизвестного Ravi Ramamoorthi) рейтрейсера. Тут нет отражений, так как они не очень симпатичные у меня получились почему-то, но общую картину о рейтресинге дает.

      Картинка 640x480
      image


      1. haqreu Автор
        20.01.2019 23:42

        А код остался где-нибудь?


        1. QtRoS
          21.01.2019 00:38

          Думаю, если поискать, и его удастся найти. А какая цель? Посмотреть на возможную ошибку в расчете отражений? На С#, кстати.


          1. haqreu Автор
            21.01.2019 00:40
            +2

            Профдеформация. Люблю смотреть на чужой код :)


      1. Areredify
        21.01.2019 00:40

        Мы в школе делали рт (обратный, правда), даже


    1. samodum
      21.01.2019 00:06
      +1

      Ого как. Обычный рейтрейсинг — это уже гении?
      Лично я при изучении нового языка программирования практически всегда пишу рейтрейсер, т.к. только так я могу погрузиться в новый язык с интересом и пользой.
      По-вашему, я гений в n-й степени, где n-количество языков, которые я изучил?
      Спасибо.


      1. haqreu Автор
        21.01.2019 00:08
        +2

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


        1. samodum
          21.01.2019 11:19

          Верно


  1. Klenov_s
    20.01.2019 23:59

    Я во время своей учебы в вузе (конец 90х) покупал книжку с таким же движком. Было красиво, но долго )


    1. EvilBeaver
      21.01.2019 11:33

      Мы на борланд-BGI такое рисовали в ВУЗе. Да, было медленно, но интересно.


  1. kovserg
    21.01.2019 01:04

    Еще можно сохранять не в ppm, а в tiff. Он поддерживается почти всеми, в отличии от ppm. Минимальный код примерно такой:

    tiff-rgb8
    int save_tiff_rgb(const char* filename,void* data,int w,int h,int bpl) {
        enum { dpi=96, o_bps=8, o_attr=14, n_attr=15, hdr_size=o_attr+6+12*n_attr };
        FILE *f; int y,res=1,dbpl=w*3,sz=h*dbpl;
        const char hdr[hdr_size]={
            0x49,0x49,0x2A,0, o_attr,0,0,0,
            8,0, 8,0, 8,0,                       // o_bps=@8 bit per sample
            n_attr,0,                            // o_attr=@14 AttrCount=15
            0xFE,0, 4,0, 1,0,0,0, 0,0,0,0,       // NewSubfileType=0
            0,1, 3,0, 1,0,0,0, w,w>>8,0,0,       // ImageWidth=w
            1,1, 3,0, 1,0,0,0, h,h>>8,0,0,       // ImageLength=h
            2,1, 3,0, 3,0,0,0, o_bps,0,0,0,      // BitPerSample={8,8,8}
            3,1, 3,0, 1,0,0,0, 1,0,0,0,          // Compression=none
            6,1, 3,0, 1,0,0,0, 2,0,0,0,          // PhotometricInterpretation=2
            0x11,1, 4,0, 1,0,0,0, hdr_size,0,0,0,// StripOffset=@200
            0x12,1, 3,0, 1,0,0,0, 1,0,0,0,       // Orientation=1 from top-left
            0x15,1, 3,0, 1,0,0,0, 3,0,0,0,       // SamplesPerPixel=3
            0x16,1, 3,0, 1,0,0,0, h,h>>8,0,0,    // RowsPerStrip=h
            0x17,1, 4,0, 1,0,0,0, sz,sz>>8,sz>>16,sz>>24, // StripBytesCounts
            0x1A,1, 3,0, 1,0,0,0, dpi,dpi>>8,0,0,// XResolution
            0x1B,1, 3,0, 1,0,0,0, dpi,dpi>>8,0,0,// YResolution
            0x1C,1, 3,0, 1,0,0,0, 1,0,0,0,       // PlanarConfiguration=1 RGBRGB...
            0x28,1, 3,0, 1,0,0,0, 2,0,0,0,       // ResulutionUnit=inch
            0,0,0,0                              // Next IFD offset
        };
        f=fopen(filename,"wb+"); if (!f) return 1;
        fwrite(hdr,1,hdr_size,f);
        for(y=0;y<h;y++) fwrite((char*)data+y*bpl,1,dbpl,f);
        fclose(f);
        return 0;
    }
    


    1. haqreu Автор
      21.01.2019 01:10

      По-моему, проще подключить stb_image_write.h и писать сразу .jpg. Я пишу ppm только потому, что это кратчайший способ сохранить картинку без привлечения стороннего кода.


    1. LorHobbit
      21.01.2019 21:57

      Плюс ppm в том, что его ВООБЩЕ не надо поддерживать. Тупо в цикле вывод массива пикселей в файл, перед которым маленький текстовый заголовок. При этом его просмотр поддерживают вполне реальные популярные смотрелки, например, IrfanView.

      Для отладочного вывода в учебной программе самое то. :) Да и не только в учебной. Недавно писал обработку видеопотока, надо было найти место, где кадры бьются — отладочный вывод в ppm-ки быстро решил задачу!

      А tiff да, красота, и для законченных систем наверняка лучше, но он и куда сложнее, как и все форматы-контейнеры

      Автору спасибо за статью, прочитал с удовольствием.


  1. ivanrt
    21.01.2019 04:16

    Я тоже на волне интереса к rtx решил разобраться с этим делом. В моем варианте был ещё вывод на экран с аккумуляцией, опциональный рендеринг на видеокарте который давай на порядок большую скорость, глубина резкости, антиалиасинг, ааbb tree, kd tree, optix для rtx рендеринга. С материалами долго возился. Делать не сложно если делать всё по шагам. Знание и рендерер строится как пирамидка.


    1. iOrange
      21.01.2019 19:34
      +2

      Если захотите (ну а вдруг) разобраться с RTX напрямую, без посредников в лице OptiX, скромно порекомендую свои статьи:

      1) gamedev.ru/code/articles/vulkan_raytracing
      2) gamedev.ru/code/articles/vulkan_raytracing_part2


  1. Scrayer
    21.01.2019 08:12

    Я тут проходил мимо, но решил попробовать, с плюсами знаком плохо и вопрос закономерный:
    без #include algorithm ругается на std::max, std::min.
    С подключенными алгоритмами ругается на все остальное (vector, vec3f, framebuffer, ofs).
    Using namespace std эффекта не возымела.
    MSVS2017Community, подскажите как настроить или в чем искать проблему?


    1. demp
      21.01.2019 09:24

      del


      1. Scrayer
        21.01.2019 09:38

        проблема была в прекомпилированных заголовках


    1. Keyten
      21.01.2019 12:36

      Можно просто писать на другом языке же


  1. Saamm
    21.01.2019 09:16

    Красивая задача и лаконичное решение


  1. Alex_ME
    21.01.2019 10:16

    Следующий этап — path tracing :)
    По-идее, не сильно сложнее, нужно только рандомно отскок луча делать и цикл по сэмплам.


    1. Xapu3ma-NN
      21.01.2019 11:49

      Ну а потом bidirectional path tracing :)


  1. IGR2014
    21.01.2019 11:55
    +1

    Такие люди как Вы вызывают не только интерес к компьютерной графике, но и желание всё «покрутить» самому. Спасибо огромное за статью, читал одновременно с интересом и удивлением насколько всё просто (в плане реализации). Будет интересно сегодня вечером попробовать соорудить что-нибудь простенькое.


  1. fediatherobot
    21.01.2019 12:02

    Тут есть некоторая путаница в терминологии.Прямая трассировка — считаем лучи от источника в надежде что он попадет в экран(почти не используется).
    А автор привел обратную трассировку(т.н. ray casting) когда считаем лучи от камеры к источнику света


    1. haqreu Автор
      21.01.2019 12:04

      Мне кажется, что вы путаете ray casting и ray tracing. Оба работают от камеры к источнику, но в данном случае у меня именно ray tracing.


      1. fediatherobot
        21.01.2019 16:38

        Да, похоже.В свое время писал на паскале рэйтрейсер под впечатлением этого мануала

        Обратная трассировка лучей (она же рэйкастинг, raycasting) — простой, хотя и довольно медленный, метод получения высокореалистичных изображений. Этот метод часто путают с прямой трассировкой лучей (рэйтрэйсинг, raytracing), которая, на самом деле, практически никогда и никем не используется из-за своей редкостной неэффективности. Впрочем, эти два термина уже практически и не различают.

        Вики тогда не было и мне почему-то показалось что прямая так называется т.к. моделирует свет как он идет на самом деле — от источника к глазу.Впрочем насколько я понял из современного описания рэйкастинг используется скорее для определения видимости.Из Вики:
        Эта особенность делает невозможным точный рендеринг отражений, преломлений и естественной проекции теней с помощью рейкастинга.

        Тогда не ясно что имели в виду авторы demo design faq.Буду благодарен если просветите.


        1. dom1n1k
          21.01.2019 17:03

          Классика из классики! Учился по этому же мануалу году эдак в 1999.


        1. haqreu Автор
          21.01.2019 19:16

          На самом деле, разделение этих терминов произошло относительно недавно, раньше они были взаимозаменяемыми. Ray casting находит первое пересечение и сразу же определяет конечный цвет пикселя, в то время как ray tracing идёт дальше, позволяя рендерить отражения и тому подобное.

          иллюстрация


  1. KongEnGe
    21.01.2019 14:23

    25 лет назад с таким же восторгом читал четырехтомник Аммерала по графике.


  1. Dave_by
    21.01.2019 15:02

    Код в статье надо подправить чтобы на MSVC работал. Во-первых добавить #include , во вторых открывать выводной поток в режиме std::ios::binary


    1. haqreu Автор
      21.01.2019 15:03

      С удовольствием. Присылайте пулл-реквест, у меня нет под рукой msvc, чтобы проверить.


    1. Alex_ME
      21.01.2019 23:02

      Или отключить в настройках проекта предкомпилированные заголовки.


  1. domix32
    21.01.2019 15:04

    А ray marching и фракталы будут?


    1. haqreu Автор
      21.01.2019 15:05

      А я не знаю, почему бы и нет. Времени только мало…


  1. ViacheslavMezentsev
    21.01.2019 17:11

    Не могли бы вы подсказать исходники, которые могли бы отображать пересечение поверхностей? Желательно также с нуля. Давно ищу что-то несложное, чтобы добавить компонент рисования 3D графики в один математический пакет.

    Сейчас это выглядит так:
    image

    Здесь не используется полное вычисление картинки, т.к. это вероятно долго будет вычисляться на c#, а просто рисуется линиями и полигонами. Я так понимаю, что этим способом пересечения не нарисовать?

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


    1. rogoz
      21.01.2019 17:46

      Картинка не картинка
      en.smath.com/forum/resource.ashx?a=29878&b=2
      «Please do not link directly to this resource. You must have a session in the forum.»


  1. Lexx918
    21.01.2019 17:52

    Вспомнилось как было похожее на JS в 2017 js1k.com/2017-magic/details/2648
    Не стоит открывать демку на слабой машине)


    1. ferocactus
      22.01.2019 18:01

      Не стоит вскрывать эту тему...


  1. ktod
    21.01.2019 18:02
    +1

    Открывал статью с опаской встретить чего ни будь сложно воспринимаемое после рабочего дня, наполненного тестами и отладкой. Однако, тема оказалась изложена крайне просто и понятно. Спасибо за материал!


  1. masai
    21.01.2019 21:50

    Если вдруг кто-то заинтересовался упомянутой библиотекой stb, вот репозиторий – nothings/stb. Обратите внимание на список подобных однофайловых библиотечек.


    1. haqreu Автор
      21.01.2019 21:54

      Да! Это stb — это прекрасная билиотека, всё отдано в public domain. И, помимо отличной лицензии, автор сделал очень сильный упор на простоту подключения модулей. Я за повсеместное распространение однофайловых библиотек.


      1. Dark_Daiver
        21.01.2019 22:28

        Я за повсеместное распространение однофайловых библиотек.

        При всем уважении, это очень скользкая дорожка. Подключать то их может и удобно, но ведь иногда надо и их код читать.


        1. haqreu Автор
          21.01.2019 22:34

          А если в два файла, то не надо? ;)


          1. Dark_Daiver
            21.01.2019 22:37

            А если библиотека хорошо разбита на файлы, то как правило ее удобней читать.


            1. haqreu Автор
              21.01.2019 22:39

              Разве нормальная среда разработки не делает чтение одинаково удобным в обоих случаях?


              1. Dark_Daiver
                21.01.2019 22:44

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


                1. haqreu Автор
                  21.01.2019 22:49

                  (подумав) да, пожалуй, согласен.


  1. fey
    22.01.2019 09:33
    +1

    Спасибо за интересный туториал. Сейчас изучаю Rust, так что сделал на нём: github.com/feymartynov/tinyraytracer.


    1. haqreu Автор
      22.01.2019 09:50

      Ого, быстро у вас получилось. Очень рекомендую выкладывать скриншоты в репозиторий. У меня, например, компилятора rust под рукой нет :(


  1. badgerdash
    22.01.2019 14:36

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


  1. VioletGiraffe
    22.01.2019 16:19
    +1

    Жаль, что у меня ни на первом, ни на пятом курсе профильного ВУЗа ничего подобного не было :)