Основы трассировки лучей
Одна из самых интересных вещей в компьютерной графике (а возможно, и самая интересная) — отрисовка графики на экране. Чтобы как можно скорее приступить к ней, мы для начала внесём небольшие упрощения, чтобы уже
сейчас
вывести что-нибудь на экран. Разумеется, такие упрощения предполагают некоторые ограничения наших возможных действий, но в последующих главах мы шаг за шагом избавимся от этих ограничений.
Во-первых, мы будем считать, что точка обзора фиксирована. Точка обзора — это место, в котором располагается глаз в нашей аналогии, и оно обычно называется положением камеры; давайте назовём его
Во-вторых, мы будем считать, что ориентация камеры тоже фиксирована, то есть камера всегда направлена в одно и то же место. Будем считать, что она смотрит вниз по положительной оси Z, положительная ось Y направлена вверх, а положительная ось X — вправо:
Положение и ориентация камеры теперь фиксированы. Но у нас всё ещё нет «рамки» из предложенной нами аналогии, через которую мы смотрим на сцену. Будем считать, что рамка имеет размеры окном просмотра (viewport). В сущности, мы будем рисовать на холсте всё то, что видим через окно просмотра. Важно, что размер окна просмотра и расстояние до камеры определяют угол видимости из камеры, называемый областью видимости (field of view) или для краткости FOV. У людей FOV по горизонтали составляет почти
Давайте вернёмся к «алгоритму», представленному в предыдущем разделе, обозначим его шаги цифрами:
Разместить глаз и рамку в нужных местах (1) Для каждого пикселя холста Определить квадрат сетки, соответствующий этому пикселю (2) Определить цвет, видимый сквозь этот квадрат (3) Закрасить пиксель этим цветом (4)
Мы уже выполнили шаг 1 (или, если точнее, избавились от него на время). Шаг 4 тривиален (
canvas.PutPixel(x, y, color)
). Давайте вкратце рассмотрим шаг 2, а затем сосредоточимся на гораздо более сложных способах реализации шага 3.
Глубина и представление цвета
Как объяснено в предыдущем разделе, мониторы могут создавать цвета из различных сочетаний красного, зелёного и синего.
Насколько разной может быть их яркость? Хотя напряжение — это непрерывная переменная, мы будем обрабатывать цвета с помощью компьютера, использующего дискретные значения. Чем больше различных оттенков красного, зелёного и синего мы можем задать, тем больше цветов мы сможем создать.
В самом популярном сегодня формате используется по 8 бит на основной цвет (также называемый цветовым каналом). 8 бит на канал дают нам 24 бита на пискель, то есть всего глубину цвета 24 бит.
Однако он ни в коем случае не является единственным возможным форматом. Не так давно для экономии памяти были популярны 15-битные и 16-битные форматы, назначавшие по 5 бит на канал или 5 бит для красного, 6 для зелёного и 5 для синего (этот формат был известен как 565).
16 бит дают нам отображению изображений, хранение изображений с более широким диапазоном — это совершенно другой вопрос, который мы рассмотрим в главе «Освещение».)Для представления цвета мы будем использовать три байта, в каждом из которых будет содержаться значение 8-битного цветового канала. В тексте мы обозначим цвета как
Из холста в окно просмотра
На шаге 2 нам нужно »
Определить квадрат сетки, соответствующий этому пикселю
«. Мы знаем координаты пикселя на холсте (мы рисуем их все) — давайте назовём их
. Заметьте, как удобно мы расположили окно просмотра — его оси соответствуют ориентации осей холста, а его центр соответствует центру окна просмотра. То есть перейти от координат холста к координатам пространства можно простым изменением масштаба!
Есть ещё одна тонкость. Хотя окно просмотра двухмерно, оно встроено в трёхмерное пространство. Мы указали, что оно находится на расстоянии d от камеры. У каждой точки в этой плоскости (называемой
плоскостью проекции
) по определению
. Следовательно,
И на этом мы закончили шаг 2. Для каждого пикселя
холста мы можем определить соответствующую точку окна просмотра
. На шаге 3 нам нужно определить, через какой цвет проходит свет
с точки зрения обзора камеры
Луч встречается со сферой
Теперь у нас есть два уравнения, одно из которых описывает точки сферы, а другое — точки луча:
Точка P, в которой луч падает на сферу, является одновременно и точкой луча, и точкой на поверхности сферы, поэтому она должна удовлетворять обоим уравнениям одновременно. Заметьте, что единственная переменная в этих уравнениях — это параметр t, потому что O,
, C и r заданы, а P — это точка, которую нам нужно найти.
Поскольку P — это одна и та же точка в обоих уравнениях, мы можем заменить P в первом на выражение для P во втором. Это даёт нам
Какие значения t удовлетворяют этому уравнению?
В его текущей форме уравнение довольно громоздкое. Давайте преобразуем его, чтобы посмотреть, что из него можно получить.
Во-первых, обозначим
Затем мы разложим скалярное произведение на его компоненты, воспользовавшись его дистрибутивностью:
Преобразовав его немного, получим
Переместив параметр t из скалярных произведений, а
в другую часть уравнения, получим
Это ничто иное, как старое доброе квадратное уравнение. Его решение даёт нам значения параметра t, при которых луч пересекается со сферой:
К счастью, это имеет геометрический смысл. Как вы можете помнить, квадратное уравнение может не иметь решений, иметь одно двойное решение или два разных решения, в зависимости от значения дискриминанта
. Это точно соответствует случаям, когда луч не пересекает сферу, луч касается сферы и луч входит и выходит из сферы:
Если мы возьмём значение t и вставим его в уравнение луча, то наконец получим точку пересечения P, соответствующее этому значению t.
Моделирование «зеркального» отражения
Помните, как ранее я упоминал о том, что не все модели основаны на физических моделях? Ну, вот один из примеров этого. Представленная ниже модель является произвольной, но её используют, потмоу что она проста в вычислении и хорошо выглядит.
Давайте возьмём
Но нам не хватает ещё одной детали. В такой формулировке все объекты блестят одинаково. Как изменить уравнение для получения различных степеней блеска?
Не забывайте, что этот блеск — мера того, насколько быстро функция отражения уменьшается при увеличении показателем отражения, и он является свойством поверхности. Поскольку модель не основана на физической реальности, значения
Подставляя полученные ранее выражения, мы получим
и немного упростив, получаем
Моделирование диффузного отражения
Итак, луч света с направлением
и яркостью
падает на поверхность с нормалью
. Какая часть
отражается обратно сцену как функция от
Поскольку технически луч света не имеет ширины, поэтому мы будем считать, что всё происходит на бесконечно малом плоском участке поверхности. Даже если это поверхность сферы, то рассматриваемая область настолько бесконечно мала, что она почти плоская относительно размера сферы, так же как Земля выглядит плоской при малых масштабах.
Луч света с шириной
что преобразуется в
Мы почти закончили.
— это угол между
, то есть
можно выразить как
И наконец
Итак, мы получили очень простое уравнение, связывающее отражённую часть света с углом между нормалью к поверхности и направлением света.
Заметьте, что при углах больше вычитающие свет. Это не имеет никакого физического смысла; угол больше
задней части поверхности, и не вносит свой вклад в освещение освещаемой точки. То есть если
Направленные источники
Если точечный источник является хорошей аппроксимацией лампы накаливания, то что может служить аппроксимацией Солнца?
Это хитрый вопрос, и ответ зависит от того, что вы хотите отрендерить.
В масштабах Солнечной системы Солнце можно приблизительно считать точечным источником. В конце концов, оно испускает свет из точки (хотя и довольно большой) и испускает его во всех направлениях, то есть подходит под оба требования.
Однако если в вашей сцене действие происходит на Земле, то это не слишком хорошее приближение. Солнце находится так далеко, что каждый луч света будет на самом деле иметь одинаковое направление (Примечание: эта аппроксимация сохраняется в масштабе города, но не на более дальних расстояниях — на самом деле. древние греки смогли с удивительной точностью вычислить радиус Земли на основании разных направлений солнечного света в различных местах.).
Для таких случаев мы зададим направленные источники освещения. Как и точечные источники, направленный источник имеет яркость, но в отличие от них, у него нет позиции. Вместо неё у него есть направление. Можно воспринимать его как бесконечно удалённый точечный источник, светящий в определённом направлении.
В случае точечных источников нам нужно вычислять новый световой вектор
Нормали сферы
Здесь только отсутствует единственная мелочь: откуда берутся нормали?
Этот вопрос намного хитрее, чем кажется, как мы увидим во второй части статьи. К счастью, для разбираемого нами случая есть очень простое решение: вектор нормали любой точки сферы лежит на прямой, проходящей через центр сферы. То есть если центр сферы — это
Это представляет в основном теоретический интерес, потому что записанное выше уравнение освещения содержит деление на
, но хорошим подходом будет создание «истинных» нормалей; это упростит нам работу в дальнейшем.
Окружающее освещение
Можно ли смоделировать любое освещение реального мира как точечный или направленный источник? Почти всегда да (Примечание: но это необязательно будет просто; зональное освещение (представьте источник за рассеивателем) можно аппроксимировать множеством точечных источников на его поверхности, но это сложно, более затратно по вычислениям, а результаты оказываются неидеальными.). Достаточно ли этих двух типов источников для наших целей? К сожалению, нет.
Представьте, что происходит на Луне. Единственным значимым источником освещения поблизости является Солнце. То есть «передняя половина» Луны относительно Солнца получает всё освещение, а «задняя половина» находится в полной темноте. Мы видим это с разных углов на Земле, и этот эффект создаёт то, что мы называем «фазами» Луны.
Однако ситуация на Земле немного отличается. Даже точки, не получающие освещения непосредственно от источника освещения, не находятся полностью в темноте (просто посмотрите на пол под столом). Как лучи света достигают этих точек, если «обзор» на источники освещения чем-то перекрыт?
Как я упомянул в разделе Цветовые модели, когда свет падает на объект, часть его поглощается, но остальная часть рассеивается в сцене. Это значит, что свет может поступать не только от источников освещения, но и от других объектов, получающих его от источников освещения и рассеивающих его обратно.
Но зачем останавливаться на этом? Рассеянное освещение в свою очередь падает на какой-нибудь другой объект, часть его поглощается, а часть снова рассеивается в сцене. При каждом отражении свет теряет часть своей яркости, но теоретически можно продолжать ad infinitum (Примечание: на самом деле нет, потому что свет имеет квантовую природу, но достаточно близко к этому.).
Это значит, что нужно считать источником освещения каждый объект. Как можно представить, это сильно увеличивает сложность нашей модели, поэтому мы не пойдём таким путём (Примечание: но вы можете хотя бы загуглить Global Illumination и посмотреть на прекрасные изображения.).
Но мы всё равно не хотим, чтобы каждый объект был или освещён напрямую, или был полностью тёмным (если только мы не рендерим модель Солнечной системы). Чтобы преодолеть эту преграду, мы зададим третий тип источников освещения, называемый окружающим освещением, которое характеризуется только яркостью.
Отражение
У нас появились блестящие объекты. Но можно ли создать объекты, которые на самом деле ведут себя как зеркала? Конечно, и на самом деле их реализация в трассировщике лучей очень проста, но поначалу может показаться запутанной.
Давайте посмотрим, как работают зеркала. Когда мы смотрим в зеркало, то видим лучи света, отражающиеся от зеркала. Лучи света отражаются симметрично относительно нормали поверхности:
Допустим, мы трассируем луч и ближайшим пересечением оказывается зеркало. Какой цвет имеет луч света? Очевидно, то не цвет зеркала, а любой цвет, который имеет отражённый луч. Всё, что нам нужно — вычислить направление отражённого луча и выяснить, каким был цвет света, падающего из этого направления. Вот бы у нас была функция, возвращающая для заданного луча цвет света, падающего из этого направления…
О, постойте, у нас же она есть: она называется TraceRay.
Итак, мы начинаем с основного цикла TraceRay, чтобы увидеть, что «видит» луч, испущенный из камеры. Если TraceRay определяет, что луч видит отражающий объект, то он просто должен вычислить направление отражённого луча и вызвать… сам себя.
На этом этапе, я предлагаю вам перечитать последние три параграфа, пока вы их не поймёте. Если вы впервые читаете о рекурсивной трассировке лучей, то возможно вам понадобится перечитать пару раз, и немного подумать, прежде чем вы действительно поймёте.
Не торопитесь, я подожду.
Теперь, когда эйфория от этого прекрасного момента эврика! немного спала, давайте немного это формализируем.
Самое важное во всех рекурсивных алгоритмах — предотвратить бесконечный цикл. В этом алгоритме есть очевидное условие выхода: когда луч или падает на неотражающий объект, или когда он ни на что не падает. Но есть простой случай, в котором мы можем угодить в бесконечный цикл: эффект бесконечного коридора. Он проявляется, когда вы ставите зеркало напротив другого зеркала и видите в них бесконечные копии самого себя!
Есть множество способов предотвращения этой проблемы. Мы введём предел рекурсии алгоритма; он будет контролировать «глубину», на которую он сможет уйти. Давайте назовём его и отражения некоторых отражений некоторых объектов. И так далее. В общем случае, нет особого смысла уходить вглубь больше чем на 2-3 уровня, потому что на этом этапе разница уже едва заметна.Мы создадим ещё одно разграничение. «Отражаемость» не должна иметь значение «есть или нет» — объекты могут быть частично отражающими и частично цветными. Мы назначим каждой поверхности число от
TraceRay? Луч начинается с поверхности объекта, точки TraceRay у нас есть
Отражение от гладкой поверхности
Теперь мы обратим своё внимание на «блестящие» объекты. В отличие от «матовых» объектов, «блестящие» меняют свой внешний вид, когда смотришь на них под разными углами.
Возьмём бильярдный шар или только что вымытый автомобиль. В таких объектах проявляется особый шаблон распространения света, обычно с яркими областями, которые как будто движутся, когда вы ходите вокруг них. В отличие от матовых объектов, то, как вы воспринимаете поверхность этих объектов, на самом деле зависит от точки обзора.
Заметьте, что красные бильярдные шары остаются красными, если вы отойдёте на пару шагов назад, но яркое белое пятно, дающее им «блестящий» вид, похоже, двигается. Это значит, что новый эффект не заменяет диффузное отражение, а дополняет его.
Почему это происходит? Мы можем начать с того, почему это не происходит на матовых объектах. Как мы видели в предыдущем разделе, когда луч света падает на поверхнось матового объекта, он равномерно рассеивается назад в сцену во всех направлениях.
Но что будет, если поверхность не настолько неровная? Давайте возьмём другую крайность — идеально отполированное зеркало. Когда луч света падает на зеркало, он отражается в единственном направлении, которое симметрично углу падения относительно нормали зеркала. Если мы назовём направление отражённого света на источник света, то получим такую ситуацию:
В зависимости от степени «отполированности» поверхности, она более или менее похожа на зеркало; то есть мы получаем «зеркальное» отражение (specular reflection, от латинского «speculum», то есть «зеркало»).
Для идеально отполированного зеркала падающий луч света
Преломление
В реальной жизни, когда луч света проходит через прозрачный объект, он меняет направление (поэтому при погружении соломинки в стакан с водой она выглядит «сломанной»). Смена направления зависит от
коэффициента преломления
каждого материала в соответствии со следующим уравнением:
Где
— это углы между лучом и нормалью до и после пересечения поверхности, а
— коэффициенты преломления материала снаружи и внутри объектов.
Например, 
Остановитесь на мгновение и осознайте: если реализовать конструктивную блочную геометрию и прозрачность, то можно смоделировать увеличительное стекло (пересечение двух сфер), которое будет вести себя как физически правильное увеличительное стекло!
Произвольная камера
В самом начале обсуждения трассировки лучей мы сделали два важных допущения: камера фиксирована в
и направлена в
, а направлением «вверх» является
. В этом разделе мы избавимся от этих ограничений, чтобы можно было располагать камеру в любом месте сцены и направлять её в любом направлении.
Давайте начнём с положения. Вы наверно заметили, что единственное, что нужно сделать — это использовать другое значение для
Влияет ли изменение положения на направление лучей? Ни в коей мере. Направление лучей — это вектор, проходящий из камеры на плоскость проекции. Когда мы перемещаем камеру, плоскость проекции двигается вместе с камерой, то есть их относительные положения не изменяются.
Давайте теперь обратим внимание на направление. Допустим, у нас есть матрица поворота, которая поворачивает Положение камеры не меняется, если вы просто вращаете камеру вокруг. Но направление меняется, оно просто подвергается тому же повороту, что и вся камера. То есть если у нас есть направление
Меняется только функция верхнего уровня:
for x in [-Cw/2, Cw/2] { for y in [-Ch/2, Ch/2] { D = camera.rotation * CanvasToViewport(x, y) color = TraceRay(camera.position, D, 1, inf) canvas.PutPixel(x, y, color) }
}Вот как выглядит наша сцена при наблюдении из другого положения и при другой ориентации:
Исходный код и рабочее демо >>
Псевдокод трассировщика лучей
Ниже представлена полная версия псевдокода, созданного нами в главах о трассировке лучей:
CanvasToViewport(x, y) { return (x*Vw/Cw, y*Vh/Ch, d)
}
ReflectRay(R, N) { return 2*N*dot(N, R) - R;
}
ComputeLighting(P, N, V, s) { i = 0.0 for light in scene.Lights { if light.type == ambient { i = light.intensity } else { if light.type == point { L = light.position - P t_max = 1 } else { L = light.direction t_max = inf } # Проверка теней shadow_sphere, shadow_t = ClosestIntersection(P, L, 0.001, t_max) if shadow_sphere != NULL continue # Диффузность n_dot_l = dot(N, L) if n_dot_l > 0 i = light.intensity*n_dot_l/(length(N)*length(L)) # Блеск if s != -1 { R = ReflectRay(L, N) r_dot_v = dot(R, V) if r_dot_v > 0 i = light.intensity*pow(r_dot_v/(length(R)*length(V)), s) } } } return i
}
ClosestIntersection(O, D, t_min, t_max) { closest_t = inf closest_sphere = NULL for sphere in scene.Spheres { t1, t2 = IntersectRaySphere(O, D, sphere) if t1 in [t_min, t_max] and t1 < closest_t closest_t = t1 closest_sphere = sphere if t2 in [t_min, t_max] and t2 < closest_t closest_t = t2 closest_sphere = sphere } return closest_sphere, closest_t
}
TraceRay(O, D, t_min, t_max, depth) { closest_sphere, closest_t = ClosestIntersection(O, D, t_min, t_max) if closest_sphere == NULL return BACKGROUND_COLOR # Вычисление локального цвета P = O closest_t*D # Вычисление точки пересечения N = P - closest_sphere.center # Вычисление нормали сферы в точке пересечения N = N / length(N) local_color = closest_sphere.color*ComputeLighting(P, N, -D, sphere.specular) # Если мы достигли предела рекурсии или объект не отражающий, то мы закончили r = closest_sphere.reflective if depth <= 0 or r <= 0: return local_color # Вычисление отражённого цвета R = ReflectRay(-D, N) reflected_color = TraceRay(P, R, 0.001, inf, depth - 1) return local_color*(1 - r) reflected_color*r
}
for x in [-Cw/2, Cw/2] { for y in [-Ch/2, Ch/2] { D = camera.rotation * CanvasToViewport(x, y) color = TraceRay(camera.position, D, 1, inf) canvas.PutPixel(x, y, color) }
}А вот сцена, использованная для рендеринга примеров:
viewport_size = 1 x 1
projection_plane_d = 1
sphere { center = (0, -1, 3) radius = 1 color = (255, 0, 0) # Красный specular = 500 # Блестящий reflective = 0.2 # Немного отражающий
}
sphere { center = (-2, 1, 3) radius = 1 color = (0, 0, 255) # Синий specular = 500 # Блестящий reflective = 0.3 # Немного более отражающий
}
sphere { center = (2, 1, 3) radius = 1 color = (0, 255, 0) # Зелёный specular = 10 # Немного блестящий reflective = 0.4 # Ещё более отражающий
}
sphere { color = (255, 255, 0) # Жёлтый center = (0, -5001, 0) radius = 5000 specular = 1000 # Очень блестящий reflective = 0.5 # Наполовину отражающий
}
light { type = ambient intensity = 0.2
}
light { type = point intensity = 0.6 position = (2, 1, 0)
}
light { type = directional intensity = 0.2 direction = (1, 4, 4)
}Рендеринг наших первых сфер
Подведём итог — для каждого пикселя на холсте мы можем вычислить соответствующую точку в окне просмотра. Зная положение камеры, мы можем выразить уравнение луча, который исходит из камеры и проходит через заданную точку окна просмотра. Имея сферу, мы можем вычислить точку, в которой луч пересекает эту сферу.
То есть нам достаточно только вычислить пересечения луча и каждой сферы, сохранить ближайшие к камере точки и закрасить пиксель на холсте соответствующим цветом. Мы почти готовы рендерить наши первые сферы!
Однако стоит уделить особое внимание параметру t. Вернёмся к уравнению луча:
Поскольку исходная точка и направление луча постоянны, меняя t во множестве действительных чисел, мы получим каждую точку P на этом луче. Заметьте, что при
мы получим
, а при
мы получим
. При отрицательных числах мы получим точки в
противоположном
направлении, то есть
за камерой
. То есть мы можем разделить область параметров на три части:
Вот схема области параметров:

Заметьте, что ничего в уравнении пересечения не говорит, что сфера должна быть перед камерой; уравнение совершенно без проблем даёт решения и для пересечений за камерой. Очевидно, что нам этого не нужно; поэтому нам нужно игнорировать все решения при за плоскостью проекции.С другой стороны, нам не нужно устанавливать верхний предел значения t; мы хотим видеть объекты перед камерой, вне зависимости от того, насколько они далеко. Поскольку на более поздних этапах мы будем ограничивать длину луча, то нам всё-таки нужно добавить эту формальность и ограничить t верхним значением
Теперь мы можем формализовать всё то, что сделали в псевдокоде. В общем случае я буду предполагать, что код имеет доступ к любым нужным ему данным, поэтому мы не будем явным образом передавать параметры, за исключением самых необходимых.
Основной метод теперь выглядит так:
O = <0, 0, 0>
for x in [-Cw/2, Cw/2] { for y in [-Ch/2, Ch/2] { D = CanvasToViewport(x, y) color = TraceRay(O, D, 1, inf) canvas.PutPixel(x, y, color) }
}Функция
CanvasToViewport
очень проста:
CanvasToViewport(x, y) { return (x*Vw/Cw, y*Vh/Ch, d)
}
В этом фрагменте кода
d
— это расстояние до плоскости проекции.
Метод TraceRay вычисляет пересечение луча с каждой сферой, и возвращает цвет сферы в ближайшей точке пересечения, которая находится в требуемом интервале t:
TraceRay(O, D, t_min, t_max) { closest_t = inf closest_sphere = NULL for sphere in scene.Spheres { t1, t2 = IntersectRaySphere(O, D, sphere) if t1 in [t_min, t_max] and t1 < closest_t closest_t = t1 closest_sphere = sphere if t2 in [t_min, t_max] and t2 < closest_t closest_t = t2 closest_sphere = sphere } if closest_sphere == NULL return BACKGROUND_COLOR return closest_sphere.color
}O
в этом фрагменте кода — это исходная точка луча; хотя мы испускаем лучи из камеры, которая расположена в точке начала координат, на более поздних этапах она может быть расположена в другом месте, поэтому это значение должно быть параметром. То же относится к
t_mint_max
Заметьте, что когда луч не пересекает сферу, то он всё равно должен возвращать какой-то цвет — в большинстве примеров я выбрал для этого белый.
И, наконец, IntersectRaySphere просто решает квадратное уравнение:
IntersectRaySphere(O, D, sphere) { C = sphere.center r = sphere.radius oc = O - C k1 = dot(D, D) k2 = 2*dot(OC, D) k3 = dot(OC, OC) - r*r discriminant = k2*k2 - 4*k1*k3 if discriminant < 0: return inf, inf t1 = (-k2 sqrt(discriminant)) / (2*k1) t2 = (-k2 - sqrt(discriminant)) / (2*k1) return t1, t2
}Давайте зададим очень простую сцену:
На псевдоязыке сцены она будет задана примерно так:
viewport_size = 1 x 1
projection_plane_d = 1
sphere { center = (0, -1, 3) radius = 1 color = (255, 0, 0) # Красный
}
sphere { center = (2, 0, 4) radius = 1 color = (0, 0, 255) # Синий
}
sphere { center = (-2, 0, 4) radius = 1 color = (0, 255, 0) # Зелёный
}Тогда если мы запустим алгоритм для этой сцены, то в будем наконец вознаграждены видом невероятной сцены, полученной трассировкой лучей:
Исходный код и рабочее демо >>
Я знаю, это немного разочаровывает. Где отражения, тени и красивый внешний вид? Мы всё это получим, ведь мы пока только начали. Но это хорошее начало — сферы выглядят как круги, а это лучше, чем если бы они выглядели как кошки. Они не выглядят как сферы потому, что мы упустили важный компонент, позволяющий человеку определять форму объекта — то, как он взаимодействует со светом.
Рендеринг с «зеркальными» отражениями
Давайте добавим в сцену «зеркальные» отражения, над которыми мы сейчас работали. Во-первых, внесём некоторые изменения в саму сцену:
sphere { center = (0, -1, 3) radius = 1 color = (255, 0, 0) # Красный specular = 500 # Блестящий
}
sphere { center = (-2, 1, 3) radius = 1 color = (0, 0, 255) # Синий specular = 500 # Блестящий
}
sphere { center = (2, 1, 3) radius = 1 color = (0, 255, 0) # Зелёный specular = 10 # Немного блестящий
}
sphere { color = (255, 255, 0) # Жёлтый center = (0, -5001, 0) radius = 5000 specular = 1000 # Очень блестящий
}В коде нам нужно изменить
ComputeLighting
, чтобы он при необходимости вычислял значение «зеркальности» и прибавлял его к общему освещению. Заметьте, что теперь ему требуются
ComputeLighting(P, N, V, s) { i = 0.0 for light in scene.Lights { if light.type == ambient { i = light.intensity } else { if light.type == point L = light.position - P else L = light.direction # Диффузность n_dot_l = dot(N, L) if n_dot_l > 0 i = light.intensity*n_dot_l/(length(N)*length(L)) # Зеркальность if s != -1 { R = 2*N*dot(N, L) - L r_dot_v = dot(R, V) if r_dot_v > 0 i = light.intensity*pow(r_dot_v/(length(R)*length(V)), s) } } } return i
}
И наконец нам нужно изменить
TraceRay
, чтобы он передавал новые параметры
ComputeLighting
очевиден; он берётся из данных сферы. Но как насчёт
— это вектор, указывающий от объекта в камеру. К счастью, в
TraceRay
у нас уже есть вектор, направленный из камеры к объекту — это
, направление трассируемого луча! То есть
— это просто
Вот новый код TraceRay с «зеркальным» отражением:
TraceRay(O, D, t_min, t_max) { closest_t = inf closest_sphere = NULL for sphere in scene.Spheres { t1, t2 = IntersectRaySphere(O, D, sphere) if t1 in [t_min, t_max] and t1 < closest_t closest_t = t1 closest_sphere = sphere if t2 in [t_min, t_max] and t2 < closest_t closest_t = t2 closest_sphere = sphere } if closest_sphere == NULL return BACKGROUND_COLOR P = O closest_t*D # Вычисление пересечения N = P - closest_sphere.center # Вычисление нормали сферы в точке пересечения N = N / length(N) return closest_sphere.color*ComputeLighting(P, N, -D, sphere.specular)
}И вот наша награда за всё это жонглирование векторами:
Исходный код и рабочее демо >>
Рендеринг с диффузным отражением
Давайте переведём всё это в псевдокод. Во-первых, давайте добавим в сцену пару источников освещения:
light { type = ambient intensity = 0.2
}
light { type = point intensity = 0.6 position = (2, 1, 0)
}
light { type = directional intensity = 0.2 direction = (1, 4, 4)
}Заметьте, что яркость удобно суммируется в
, потому что из уравнения освещения следует, что никакая точка не может иметь яркость света выше, чем единица. Это значит, что у нас не получатся области со «слишком большой выдержкой».
Уравнение освещения довольно просто преобразовать в псевдокод:
ComputeLighting(P, N) { i = 0.0 for light in scene.Lights { if light.type == ambient { i = light.intensity } else { if light.type == point L = light.position - P else L = light.direction n_dot_l = dot(N, L) if n_dot_l > 0 i = light.intensity*n_dot_l/(length(N)*length(L)) } } return i
}
И единственное, что осталось — использовать
ComputeLightingTraceRay
. Мы заменим строку, возвращающую цвет сферы
return closest_sphere.color
на этот фрагмент:
P = O closest_t*D # вычисление пересечения N = P - closest_sphere.center # вычисление нормали сферы в точке пересечения N = N / length(N) return closest_sphere.color*ComputeLighting(P, N)
Просто ради интереса давайте добавим большую жёлтую сферу:
sphere { color = (255, 255, 0) # Yellow center = (0, -5001, 0) radius = 5000
}
Мы запускаем рендерер, и узрите — сферы наконец начали выглядеть как сферы!
Исходный код и рабочее демо >>
Но постойте, как большая жёлтая сфера превратилась в плоский жёлтый пол?
Этого и не было, просто она настолько велика относительно других трёх, а камера настолько к ней близка, что она выглядит плоской. Так же, как наша планета выглядит плоской, когда мы стоим на её поверхности.
Рендеринг с отражением
Давайте добавим к коду трассировщика лучей отражение.
Как и ранее, в первую очередь мы изменяем сцену:
sphere { center = (0, -1, 3) radius = 1 color = (255, 0, 0) # Красный specular = 500 # Блестящий reflective = 0.2 # Немного отражающий
}
sphere { center = (-2, 1, 3) radius = 1 color = (0, 0, 255) # Синий specular = 500 # Блестящий reflective = 0.3 # Немного более отражающий
}
sphere { center = (2, 1, 3) radius = 1 color = (0, 255, 0) # Зелёный specular = 10 # Немного блестящий reflective = 0.4 # Ещё более отражающий
}
sphere { color = (255, 255, 0) # Жёлтый center = (0, -5001, 0) radius = 5000 specular = 1000 # Очень блестящий reflective = 0.5 # Наполовину отражающий
}
Мы используем формулу «луча отражения» в паре мест, поэтому может избавиться от неё. Она получает луч
и нормаль
, возвращая
, отражённый относительно
ReflectRay(R, N) { return 2*N*dot(N, R) - R;
}
Единственным изменением в
ComputeLighting
является замена уравнения отражения на вызов этого нового
ReflectRay
В основной метод внесено небольшое изменение — нам нужно передать TraceRay верхнего уровня предел рекурсии:
color = TraceRay(O, D, 1, inf, recursion_depth)
Константе
recursion_depth
можно задать разумное значение, например, 3 или 5.
Единственные важные изменения происходят ближе к концу TraceRay, где мы рекурсивно вычисляем отражения:
TraceRay(O, D, t_min, t_max, depth) { closest_sphere, closest_t = ClosestIntersection(O, D, t_min, t_max) if closest_sphere == NULL return BACKGROUND_COLOR # Вычисление локального цвета P = O closest_t*D # Вычисление точки пересечения N = P - closest_sphere.center # Вычисление нормали к сфере в точке пересечения N = N / length(N) local_color = closest_sphere.color*ComputeLighting(P, N, -D, sphere.specular) # Если мы достигли предела рекурсии или объект не отражающий, то мы закончили r = closest_sphere.reflective if depth <= 0 or r <= 0: return local_color # Вычисление отражённого цвета R = ReflectRay(-D, N) reflected_color = TraceRay(P, R, 0.001, inf, depth - 1) return local_color*(1 - r) reflected_color*r
}
Пусть результаты говорят сами за себя:
Исходный код и рабочее демо >>
Чтобы лучше понять предел глубины рекурсии, давайте ближе рассмотрим рендер с
Как вы видите, разница заключается в том, видим ли мы отражения отражений отражений объектов, или только отражения объектов.
Рендеринг с тенями
Давайте превратим это в псевдокод.
В предыдущей версии TraceRay вычислял ближайшее пересечение луч-сфера, а затем вычислял освещение в пересечении. Нам нужно извлечь код ближайшего пересечения, поскольку мы хотим использовать его снова для вычисления теней:
ClosestIntersection(O, D, t_min, t_max) { closest_t = inf closest_sphere = NULL for sphere in scene.Spheres { t1, t2 = IntersectRaySphere(O, D, sphere) if t1 in [t_min, t_max] and t1 < closest_t closest_t = t1 closest_sphere = sphere if t2 in [t_min, t_max] and t2 < closest_t closest_t = t2 closest_sphere = sphere } return closest_sphere, closest_t
}В результате
TraceRay
получается гораздо проще:
TraceRay(O, D, t_min, t_max) { closest_sphere, closest_t = ClosestIntersection(O, D, t_min, t_max) if closest_sphere == NULL return BACKGROUND_COLOR P = O closest_t*D # Compute intersection N = P - closest_sphere.center # Compute sphere normal at intersection N = N / length(N) return closest_sphere.color*ComputeLighting(P, N, -D, sphere.specular)
}
Теперь нам нужно добавить в
ComputeLighting
проверку тени:
ComputeLighting(P, N, V, s) { i = 0.0 for light in scene.Lights { if light.type == ambient { i = light.intensity } else { if light.type == point { L = light.position - P t_max = 1 } else { L = light.direction t_max = inf } # Проверка тени shadow_sphere, shadow_t = ClosestIntersection(P, L, 0.001, t_max) if shadow_sphere != NULL continue # Диффузность n_dot_l = dot(N, L) if n_dot_l > 0 i = light.intensity*n_dot_l/(length(N)*length(L)) # Зеркальность if s != -1 { R = 2*N*dot(N, L) - L r_dot_v = dot(R, V) if r_dot_v > 0 i = light.intensity*pow(r_dot_v/(length(R)*length(V)), s) } } } return i
}Вот как будет выглядеть наша заново отрендеренная сцена:

Исходный код и рабочее демо >>
Теперь у нас уже что-то получается.
Системы координат
Холст имеет определённую ширину и высоту в пикселях, которые мы назовём
. Для работы с его пикселями можно использовать любую систему координат. У большинства экранов компьютеров точка начала координат находится в верхнем левом углу
увеличивается вправо, а
— вниз:
Это очень естественная система координат с учётом организации видеопамяти, но для людей она не очень привычна. Вместо неё мы будем использовать систему координат, обычно применяемую для отрисовки графиков на бумаге: начало координат в центре
В примерах холст будет рисоваться на экране, поэтому необходимо выполнять преобразования из одной системы координат в другую. Если предположить, что экран имеет тот же размер, что и холст, то уравнения преобразования будут простыми:
Управление цветом
Для управления цветами мы используем несколько операций (Примечание: если вы знаете линейную алгебру, то воспринимайте цвета как векторы в трёхмерном цветовом пространстве. В статье я познакомлю вас с операциями, которые мы будем использовать для читателей, незнакомых с линейной алгеброй.).
Мы можем увеличить яркость цвета, увеличив каждый цветовой канал на константу:
Мы можем сложить два цвета, сложив отдельно цветовые каналы:
Например, если у нас есть красновато-фиолетовый
и мы хотим получить точно такой же оттенок, но в три раза менее яркий, то мы умножаем каждый канал на
и получаем
. Если мы хотим объединить красный
и зелёный
, то складываем каналы и получаем
, то есть жёлтый.
Внимательный читатель может сказать, что при таких операциях мы можем получить неверные значения: например, удвоив яркость
Уравнение диффузного отражения
Теперь мы можем сформулировать уравнение для вычисления полного количества света, полученного точкой
с нормалью
в сцене с окружающим освещением яркостью
точечных или направленных источников света с яркостью
и световыми векторами
или известными (для направленных источников), или вычисленными для P (для точечных источников):
Стоит снова повторить, что члены, в которых
не должны прибавляться к освещённости точки.
Часть i: трассировка лучей
Представим, что вы находитесь в каком-то экзотическом месте и наслаждаетесь потрясающим видом, настолько потрясающим, что вы просто
обязаны
запечатлеть его на картине.

Швейцарский ландшафт
У вас есть бумага и маркеры, но совершенно нет художественного таланта. Неужели всё потеряно?
Не обязательно. Может у вас и нет таланта, но есть методичность.
Можно сделать наиболее очевидную вещь: взять сетку от насекомых и поместить её в прямоугольную раму, присоединив раму к палке. Затем посмотреть на ландшафт сквозь эту сетку, выбрать наилучший ракурс и поставить ещё одну палку точно там, где должна быть голова, чтобы получить точно такую же точку обзора:
Вы ещё не начали рисовать, но по крайней мере, у вас есть фиксированная точка обзора и фиксированная рама, сквозь которую вы видите ландшафт. Более того, эта фиксированная рама разделена на мелкие квадраты. И теперь мы приступаем к методичной части. Нарисуем на бумаге сетку с тем же количеством квадратов, что и в сетке от насекомых.
Теперь посмотрим на левый верхний квадрат сетки. Какой цвет является в нём доминирующим? Небесно-синий. Поэтому мы рисуем в левом верхнем квадрате бумаги небесно-синим цветом. Повторяем то же самое для каждого квадрата и довольно скоро мы получим довольно хорошую картину ландшафта, как будто видимую из окна:

Грубая аппроксимация ландшафта
Если задуматься, то компьютер, в сущности, является очень методичной машиной, у которой совершенно отсутствуют художественные таланты. Если мы заменим квадраты на бумаге пикселями на экране, то сможем описать процесс рендеринга сцены следующим образом:
Для каждого пикселя холста Закрасить его нужным цветом
Очень просто!
Однако такой код слишком абстрактен для реализации непосредственно в компьютере. Поэтому мы можем немного углубиться в детали:
Разместить глаз и рамку в нужных местах Для каждого пикселя холста Определить квадрат сетки, соответствующий этому пикселю Определить цвет, видимый сквозь этот квадрат Закрасить пиксель этим цветом
Это по-прежнему слишком абстрактно, но уже начинает походить на алгоритм. Удивительно, но это и есть высокоуровневое описание всего алгоритма трассировки лучей. Да, всё настолько просто.
Разумеется, дьявол скрывается в деталях. В следующих главах мы подробнее рассмотрим все эти этапы.


