[Перевод] Трассировщик лучей с нуля за 100 строчек Python
В этом посте мы заглянем под капот алгоритмов компьютерной графики, пошагово разберем основные принципы трассировки лучей и напишем ее простую реализацию на Python. Никаких сторонних графических библиотек — только NumPy и голый код в компиляторе.
Примечание: Эта статья ни в коем случае не является полным руководством/объяснением трассировки лучей, поскольку эта тема слишком обширна, а скорее просто введением для любопытствующих.
Предпосылки
Нам понадобится только самая базовая векторная геометрия:
Пусть у нас есть две точки A и B — независимо от размерности: 1, 2, 3, …, n, — тогда вектор, идущий от A к B, может быть найден путем вычисления B — A (поэлементно);
Длину вектора — независимо от его размерности — можно найти, вычислив квадратный корень из суммы возведенных в квадрат компонентов. Длина вектора v обозначается ||v||;
Единичный вектор — это вектор длины 1: ||v|| = 1;
Для данного вектора другой вектор, идущий в том же направлении, но имеющий длину 1, может быть найден путем деления каждого компонента первого вектора на его длину — это называется нормализацией: u = v/||v||;
Точечное произведение для векторов вычисляется как:
= ||v||².
Алгоритм трассировки лучей
Трассировка лучей — это метод рендеринга, который имитирует путь света и пересечения с объектами и позволяет создавать изображения с высокой степенью реализма. Более оптимизированные варианты этого алгоритма используются в видеоиграх.
Чтобы объяснить работу этого алгоритма, сначала нужно настроить сцену:
Трехмерное пространство (мы собираемся использовать три координаты для позиционирования объектов в пространстве);
Объекты в этом пространстве (поскольку мы собираемся воспроизвести рис. 1, то возьмем в качестве объектов сферы);
Источник света (в нашем случае это одна точка, излучающая свет во всех направлениях);
Камера для наблюдения за сценой;
Экран, через который камера смотрит на объекты (четыре точки для четырех углов прямоугольного экрана).
Экран — это некая определенная вами геометрическая фигура (например, прямоугольник 3×2). Но сами по себе цифры 3 и 2 ни о чем нам не говорят и действительно начинают приобретать какое-то значение только при сравнении их с размерами других объектов. Здесь важно то, как вы разделите ваш прямоугольник на более мелкие квадраты (пиксели), как на рисунке выше. Это определит размер конечного изображения. Другими словами, можно взять прямоугольник 3×2 и разделить его на 300×200 пикселей.
Напишем алгоритм трассировки лучей с учетом заданной сцены:
Рисунок 3для каждого пикселя p(x, y, z) экрана:
ассоциировать черный цвет с p
если луч (линия), начинающийся от камеры и идущий к точке p, пересекает любой объект сцены, то:
вычислить точку пересечения с ближайшим объектом
если между точкой пересечения и источником света нет объекта, тогда:
рассчитать цвет точки пересечения
сопоставить цвет точки пересечения с p
Обратите внимание, что этот процесс на деле оказывается обратным реальному освещению. Ведь реальный свет выходит из источника во всех направлениях, отражается от объектов и попадает в камеру. Однако, поскольку не все лучи, выходящие из источника света, попадут в камеру, трассировка лучей выполняет обратный процесс для экономии времени вычислений (отслеживает лучи от камеры обратно к источнику света).
Настройка сцены
Перед тем, как начать писать код, нам нужно настроить сцену. В первую очередь определимся, где расположены камера и экран. Для этой цели примем некоторые упрощения, совместив объекты с координатными осями.
Рисунок 4Допустим, камера расположена в точке (x = 0, y = 0, z = 1), а экран является частью плоскости, образованной осями x и y. Теперь мы можем написать скелет нашего кода.
Посмотреть кодimport numpy as np
import matplotlib.pyplot as plt
width = 300
height = 200
camera = np.array([0, 0, 1])
ratio = float(width) / height
screen = (-1, 1 / ratio, 1, -1 / ratio) # слева, сверху, справа, снизу
image = np.zeros((height, width, 3))
for i, y in enumerate(np.linspace(screen[1], screen[3], height)):
for j, x in enumerate(np.linspace(screen[0], screen[2], width)):
# image[i, j] = ...
print("progress: %d/%d" % (i + 1, height))
plt.imsave('image.png', image)
Камера — это просто точка, имеющая три координаты;
С другой стороны, экран определяется четырьмя числами (или двумя точками): слева, сверху, справа, снизу. Он находится в диапазоне от -1 до 1 в направлении x и от -1/ratio до 1/ratio в направлении y, где ratio — ширина изображения, деленная на его высоту. Это вытекает из того, что мы хотим, чтобы экран имел такое же соотношение сторон, что и фактическое изображение. При такой настройке экрана будет получено соотношение сторон (ширина к высоте): 2 /(2/ratio) = ratio, которое и является соотношением сторон желаемого изображения 300×200;
Цикл состоит из разделения экрана на точки в направлениях x и y, затем вычисляется цвет текущего пикселя;
Полученный код создаст — как и ожидалось на данном этапе — черное изображение.
Пересечение лучей
Следующий шаг алгоритма: если луч (линия), начинающийся от камеры и проходящий к точке p, пересекает объект сцены, тогда…
Разобьем его на две части. И начнем с определения того, какой луч (линия) начинается от камеры и идет к точке p?
Определение луча
Когда мы говорим »луч», на самом деле мы имеем в виду скорее »линию». Всякий раз при работе с геометрией лучше отдать предпочтение векторам, чем реальным линейным уравнениям: с ними легче работать, и они гораздо менее подвержены ошибкам, таким как деление на ноль.
Итак, поскольку луч начинается от камеры и идет в направлении текущего целевого пикселя, мы можем определить единичный вектор, указывающий в том же направлении. Поэтому мы определяем наш луч следующим уравнением:
Помните, что камера и пиксель — это 3D-точки. При t = 0 вы окажетесь в положении камеры, но с увеличением t будете удаляться от нее в направлении пикселя. Это параметрическое уравнение, которое даст точку вдоль линии для любого t.
Конечно, точно так же мы можем переписать уравнение и определить луч, который начинается в исходной точке (O) и идет к месту назначения (D) как:
Для удобства определим d как вектор направления.
Теперь мы можем добавить к нашему коду вычисление луча:
Посмотреть кодimport numpy as np
import matplotlib.pyplot as plt
def normalize(vector):
return vector / np.linalg.norm(vector)
width = 300
height = 200
camera = np.array([0, 0, 1])
ratio = float(width) / height
screen = (-1, 1 / ratio, 1, -1 / ratio) # слева, сверху, справа, снизу
image = np.zeros((height, width, 3))
for i, y in enumerate(np.linspace(screen[1], screen[3], height)):
for j, x in enumerate(np.linspace(screen[0], screen[2], width)):
pixel = np.array([x, y, 0])
origin = camera
direction = normalize(pixel - origin)
# image[i, j] = ...
print("progress: %d/%d" % (i + 1, height))
plt.imsave('image.png', image)
Мы добавили в код функцию normalize (vector), которая возвращает… собственно, нормализованный вектор;
Также мы добавили вычисление исходной точки и направления, которые вместе определяют луч. Обратите внимание, что пиксель имеет координату z = 0, поскольку он лежит на экране, который находится в плоскости, образованной осями x и y;
Теперь перейдем ко второй части, где луч пересекает объект сцены. Для простоты будем использовать только сферы.
Определение сферы
Сфера — довольно простой математический объект. Она определяется как набор точек, находящихся на одинаковом расстоянии r (радиус) от заданной точки (центра).
Следовательно, с учетом центра C сферы и ее радиуса r произвольная точка X лежит на сфере тогда, когда:
Для удобства возведем обе стороны в квадрат, чтобы избавиться от квадратного корня, обусловленного величиной X — C:
После этого мы сможем определить некоторые сферы сразу после объявления экрана:
objects = [
{ 'center': np.array([-0.2, 0, -1]), 'radius': 0.7 },
{ 'center': np.array([0.1, -0.3, 0]), 'radius': 0.1 },
{ 'center': np.array([-0.3, 0, 0]), 'radius': 0.15 }
]
Теперь вычислим пересечение луча и сферы.
Пересечение со сферой
Мы знаем уравнение лучей и знаем, какому условию должна удовлетворять точка, чтобы она лежала на сфере. Все, что нам нужно сделать, это подставить одно уравнение в другое и решить его относительно t. То есть, найти ответ на вопрос: для какого t точка луча ray (t) окажется на сфере?
Это обычное квадратное уравнение, которое просто решается относительно t. Мы будем вызывать коэффициенты, связанные с t², t¹, t⁰, a, b и c, соответственно. Вычислим дискриминант этого уравнения:
Поскольку направление d является единичным вектором, получим a = 1. После вычисления дискриминанта у нас есть три варианта:
Рисунок 5Для обнаружения пересечений будем использовать только третий случай. Запишем функцию, отвечающую за обнаружение пересечения луча и сферы. Она возвращает расстояние от начала луча до ближайшей точки пересечения, если луч действительно пересекает сферу, иначе возвращает None:
Посмотреть кодdef sphere_intersect(center, radius, ray_origin, ray_direction):
b = 2 * np.dot(ray_direction, ray_origin — center)
c = np.linalg.norm(ray_origin — center) ** 2 — radius ** 2
delta = b ** 2 — 4 * c
if delta > 0:
t1 = (-b + np.sqrt(delta)) / 2
t2 = (-b — np.sqrt(delta)) / 2
if t1 > 0 and t2 > 0:
return min(t1, t2)
return None
Обратите внимание, что мы возвращаем только ближайшее пересечение из двух тогда, когда оба t1 и t2 положительны. Это связано с тем, что ответ уравнения может быть отрицательным, и в таком случае луч, пересекающий сферу, будет иметь не d в качестве вектора направления, а -d (например, если сфера находится за камерой и экраном).
Ближайший пересекаемый объект
Пока мы все еще не выполнили инструкцию из псевдокода: если луч (линия), начинающийся от камеры и идущий к точке p, пересекает любой объект сцены, то […]. Теперь нам нужно вычислить точку пересечения с ближайшим объектом.
Напишем функцию, которая использует sphere_intersect () для поиска ближайшего объекта, с которым пересекается луч, если он существует. Просто перебираем все сферы, ищем пересечения и сохраняем ближайшую сферу:
Посмотреть кодdef nearest_intersected_object(objects, ray_origin, ray_direction):
distances = [sphere_intersect(obj['center'], obj['radius'], ray_origin, ray_direction) for obj in objects]
nearest_object = None
min_distance = np.inf
for index, distance in enumerate(distances):
if distance and distance < min_distance:
min_distance = distance
nearest_object = objects[index]
return nearest_object, min_distance
При вызове функции, если nearest_object = None, луч не пересекает никакого объекта, иначе его значением является ближайший пересекаемый объект, и мы получаем min_distance — расстояние от начала луча до точки пересечения.
Точка пересечения
Чтобы вычислить точку пересечения, используем предыдущую функцию:
nearest_object, distance = nearest_intersected_object(objects, o, d)
if nearest_object:
intersection_point = o + d * distance
В результате получаем следующий код:
Посмотреть кодimport numpy as np
import matplotlib.pyplot as plt
def normalize(vector):
return vector / np.linalg.norm(vector)
def sphere_intersect(center, radius, ray_origin, ray_direction):
b = 2 * np.dot(ray_direction, ray_origin — center)
c = np.linalg.norm(ray_origin — center) ** 2 — radius ** 2
delta = b ** 2 — 4 * c
if delta > 0:
t1 = (-b + np.sqrt(delta)) / 2
t2 = (-b — np.sqrt(delta)) / 2
if t1 > 0 and t2 > 0:
return min(t1, t2)
return None
def nearest_intersected_object(objects, ray_origin, ray_direction):
distances = [sphere_intersect(obj['center'], obj['radius'], ray_origin, ray_direction) for obj in objects]
nearest_object = None
min_distance = np.inf
for index, distance in enumerate(distances):
if distance and distance < min_distance:
min_distance = distance
nearest_object = objects[index]
return nearest_object, min_distance
width = 300
height = 200
camera = np.array([0, 0, 1])
ratio = float(width) / height
screen = (-1, 1 / ratio, 1, -1 / ratio) # слева, сверху, справа, снизу
objects = [
{ 'center': np.array([-0.2, 0, -1]), 'radius': 0.7 },
{ 'center': np.array([0.1, -0.3, 0]), 'radius': 0.1 },
{ 'center': np.array([-0.3, 0, 0]), 'radius': 0.15 }
]
image = np.zeros((height, width, 3))
for i, y in enumerate(np.linspace(screen[1], screen[3], height)):
for j, x in enumerate(np.linspace(screen[0], screen[2], width)):
pixel = np.array([x, y, 0])
origin = camera
direction = normalize(pixel — origin)
# проверка пересечений
nearest_object, min_distance = nearest_intersected_object(objects, origin, direction)
if nearest_object is None:
continue
# вычисления пересечений между лучом и ближайшим объектом
intersection = origin + min_distance * direction
# image[i, j] = ...
print("%d/%d" % (i + 1, height))
plt.imsave('image.png', image)
Пересечения света
Итак, мы нашли прямую линию, идущую от камеры к объекту, и знаем, что это за объект, а также на какую часть объекта мы смотрим. Но мы пока не знаем, освещена ли вообще эта конкретная точка. Возможно, свет не попадает конкретно на нее, и нет необходимости производить расчеты дальше, потому что мы ее не видим. Следовательно, следующий шаг — проверить, нет ли никаких посторонних объектов между точкой пересечения и источником света.
У нас уже есть функция, которая нам может помочь: near_intersected_object (). И мы хотим знать, пересекает ли луч, который начинается в точке пересечения и идет к свету, объект сцены перед тем, как пересечь свет. Это практически та же задача, что мы решали раньше: нам просто нужно изменить начало и направление луча. Во-первых, нам нужно определить свет. Можно сделать это сразу вместе с объявлением объектов:
light = { 'position': np.array([5, 5, 5]) }
Чтобы проверить, затеняет ли объект точку пересечения, мы должны пропустить луч, который начинается в точке пересечения и идет к свету, и посмотреть, действительно ли ближайший возвращенный объект оказывается ближе, чем свет, к точке пересечения (другими словами, находится ли он между ними).
# ...
intersection = origin + min_distance * direction
intersection_to_light = normalize(light['position'] — intersection)
_, min_distance = nearest_intersected_object(objects, intersection, intersection_to_light)
intersection_to_light_distance = np.linalg.norm(light['position'] — intersection)
is_shadowed = min_distance < intersection_to_light_distance
Что ж, это так не сработает. Нужно сделать небольшую корректировку. Если мы используем точку пересечения в качестве источника нового луча, мы можем в конечном итоге обнаружить сферу, на которой мы сейчас находимся, как объект между точкой пересечения и источником света. Быстрое и широко используемое решение этой проблемы — сделать небольшой шаг, который уводит нас от поверхности сферы. Обычно для этого вычисляется вектор нормали к поверхности и производится отступ в направлении этой нормали.
Рисунок 6Этот трюк используется не только для сфер, но и для любых объектов.
Следовательно, правильный код будет таким:
Посмотреть код# ...
intersection = origin + min_distance * direction
normal_to_surface = normalize(intersection — nearest_object['center'])
shifted_point = intersection + 1e-5 * normal_to_surface
intersection_to_light = normalize(light['position'] — shifted_point)
_, min_distance = nearest_intersected_object(objects, shifted_point, intersection_to_light)
intersection_to_light_distance = np.linalg.norm(light['position'] — intersection)
is_shadowed = min_distance < intersection_to_light_distance
if is_shadowed:
continue
Модель отражения Блинна-Фонга
Итак, мы знаем, что луч света попал на объект, а отражение луча — прямо в камеру. Вопрос: что увидит камера? На него и пытается ответить модель Блинна-Фонга.
Модель Блинна-Фонга — это приближение к модели Фонга, требующее меньших вычислительных затрат.
Согласно этой модели, любой материал имеет четыре свойства:
Фоновый цвет (Ambient color): цвет, который имеет объект в отсутствие света;
Рассеянный цвет (Diffuse color): цвет, наиболее близкий к тому, что мы себе представляем;
Зеркальный цвет (Specular color): цвет блестящей части объекта, когда свет попадает на нее. В большинстве случаев это белый цвет;
Блеск (Shininess): коэффициент, показывающий, насколько блестящим является объект.
Примечание: Все цвета представлены в RGB в диапазоне 0 — 1.
Рисунок 7Добавим эти свойства к сферам:
objects = [
{ 'center': np.array([-0.2, 0, -1]), 'radius': 0.7, 'ambient': np.array([0.1, 0, 0]), 'diffuse': np.array([0.7, 0, 0]), 'specular': np.array([1, 1, 1]), 'shininess': 100 },
{ 'center': np.array([0.1, -0.3, 0]), 'radius': 0.1, 'ambient': np.array([0.1, 0, 0.1]), 'diffuse': np.array([0.7, 0, 0.7]), 'specular': np.array([1, 1, 1]), 'shininess': 100 },
{ 'center': np.array([-0.3, 0, 0]), 'radius': 0.15, 'ambient': np.array([0, 0.1, 0]), 'diffuse': np.array([0, 0.6, 0]), 'specular': np.array([1, 1, 1]), 'shininess': 100 }
]
В нашем примере сферы имеют цвета красный, пурпурный и зеленый, соответственно.
Модель Блинн-Фонга утверждает, что свет также имеет три цветовых свойства: фоновый цвет, рассеянный и зеркальный. Их тоже добавим к модели:
light = { 'position': np.array([5, 5, 5]), 'ambient': np.array([1, 1, 1]), 'diffuse': np.array([1, 1, 1]), 'specular': np.array([1, 1, 1]) }
Учитывая эти свойства, модель Блинна-Фонга рассчитывает освещенность точки следующим образом:
где
ka, kd, ks — фоновое, рассеянное, зеркальное свойства объекта;
ia, id, is — фоновое, рассеянное, зеркальное свойства света;
L — единичный вектор направления от точки пересечения к свету;
N — единичный вектор нормали к поверхности объекта в точке пересечения;
V — единичный вектор направления от точки пересечения к камере;
α — блеск объекта.
# ...
if is_shadowed:
break
# RGB
illumination = np.zeros((3))
# ambiant
illumination += nearest_object['ambient'] * light['ambient']
# diffuse
illumination += nearest_object['diffuse'] * light['diffuse'] * np.dot(intersection_to_light, normal_to_surface)
# specular
intersection_to_camera = normalize(camera — intersection)
H = normalize(intersection_to_light + intersection_to_camera)
illumination += nearest_object['specular'] * light['specular'] * np.dot(normal_to_surface, H) ** (nearest_object['shininess'] / 4)
image[i, j] = np.clip(illumination, 0, 1)
Обратите внимание, что в конце мы определяем цвет между 0 и 1, чтобы убедиться, что он находится в правильном диапазоне.
Запускаем код
Увеличим ширину и высоту для получения более высокого разрешения (ценой увеличения времени вычислений).
Рисунок 8Можно заметить две вещи, которые отличают результат от первого изображения, показанного в начале:
Серый пол отсутствует;
Отсутствие отражений.
Фейковая плоскость
В идеале мы должны создать другой тип объекта — плоскость, но поскольку мы достаточно ленивы, то можем просто добавить другую сферу. Ведь если вы стоите на сфере, имеющей бесконечно большой радиус (по сравнению с вашим размером), тогда вам будет казаться, что вы стоите на плоской поверхности.
Добавим эту сферу в список объектов и снова выполним рендеринг:
{ 'center': np.array([0, -9000, 0]), 'radius': 9000 — 0.7, 'ambient': np.array([0.1, 0.1, 0.1]), 'diffuse': np.array([0.6, 0.6, 0.6]), 'specular': np.array([1, 1, 1]), 'shininess': 100 }
Отражение
Сейчас мы рендерим лучи, которые выходят из источника света, ударяются о поверхность объекта и отражаются в камеру. Но что, если луч попадет в несколько объектов, прежде чем попасть в камеру? Появится отражение.
Каждый объект имеет коэффициент отражения в диапазоне от 0 до 1. Здесь 0 означает, что объект матовый, 1 — что объект зеркальный. Добавим свойство отражения ко всем сферам:
{ 'center': np.array([-0.2, 0, -1]), ..., 'reflection': 0.5 }
{ 'center': np.array([0.1, -0.3, 0]), ..., 'reflection': 0.5 }
{ 'center': np.array([-0.3, 0, 0]), ..., 'reflection': 0.5 }
{ 'center': np.array([0, -9000, 0]), ..., 'reflection': 0.5 }
Алгоритм
Чтобы включить вычисление отражений, нужно отследить отраженный луч после того, как произошло пересечение, и учесть цветовой вклад каждой точки этого пересечения. Повторяем этот процесс несколько раз.
Рисунок 9Расчет цвета
Чтобы получить цвет пикселя, нужно просуммировать вклад каждой точки пересечения луча:
где
c — конечный цвет пикселя;
i — освещенность, рассчитанная по модели Блинна-Фонга для точки пересечения;
r — отражение от пересекаемого объекта.
Когда прекратить вычисление этой суммы (и отслеживание отраженных лучей, соответственно), решаете вы сами.
Отраженный луч
Прежде чем мы сможем все это записать в виде кода, нам нужно определить направление отраженного луча. Можно вычислить его следующим образом:
где
R — нормализованный отраженный луч;
L — единичный вектор направления отражаемого луча;
N — единичный вектор направления нормали к поверхности хода луча.
Добавим этот метод в начало кода вместе с функцией normalize ():
def reflected(vector, axis):
return vector — 2 * np.dot(vector, axis) * axis
Код
Посмотреть код# глобальные переменные
max_depth = 3
# нужные данные для цикла
color = np.zeros((3))
reflection = 1
for k in range(max_depth):
nearest_object, min_distance = # ...
# ...
illumination += # ...
# отражение
color += reflection * illumination
reflection *= nearest_object['reflection']
# начальная точка и направление нового луча
origin = shifted_point
direction = reflected(direction, normal_to_surface)
image[i, j] = np.clip(color, 0, 1)
Важно: теперь, когда мы поместили пересечения в другой цикл для отражений, нужно добавить операторы break во избежание бесполезных вычислений.
Вот и все. Запускаем код и наблюдаем результат:
Рисунок 11Окончательный код
Итоговый код состоит из всего порядка сотни строк:
Посмотреть кодimport numpy as np
import matplotlib.pyplot as plt
def normalize(vector):
return vector / np.linalg.norm(vector)
def reflected(vector, axis):
return vector - 2 * np.dot(vector, axis) * axis
def sphere_intersect(center, radius, ray_origin, ray_direction):
b = 2 * np.dot(ray_direction, ray_origin - center)
c = np.linalg.norm(ray_origin - center) ** 2 - radius ** 2
delta = b ** 2 - 4 * c
if delta > 0:
t1 = (-b + np.sqrt(delta)) / 2
t2 = (-b - np.sqrt(delta)) / 2
if t1 > 0 and t2 > 0:
return min(t1, t2)
return None
def nearest_intersected_object(objects, ray_origin, ray_direction):
distances = [sphere_intersect(obj['center'], obj['radius'], ray_origin, ray_direction) for obj in objects]
nearest_object = None
min_distance = np.inf
for index, distance in enumerate(distances):
if distance and distance < min_distance:
min_distance = distance
nearest_object = objects[index]
return nearest_object, min_distance
width = 300
height = 200
max_depth = 3
camera = np.array([0, 0, 1])
ratio = float(width) / height
screen = (-1, 1 / ratio, 1, -1 / ratio) # left, top, right, bottom
light = { 'position': np.array([5, 5, 5]), 'ambient': np.array([1, 1, 1]), 'diffuse': np.array([1, 1, 1]), 'specular': np.array([1, 1, 1]) }
objects = [
{ 'center': np.array([-0.2, 0, -1]), 'radius': 0.7, 'ambient': np.array([0.1, 0, 0]), 'diffuse': np.array([0.7, 0, 0]), 'specular': np.array([1, 1, 1]), 'shininess': 100, 'reflection': 0.5 },
{ 'center': np.array([0.1, -0.3, 0]), 'radius': 0.1, 'ambient': np.array([0.1, 0, 0.1]), 'diffuse': np.array([0.7, 0, 0.7]), 'specular': np.array([1, 1, 1]), 'shininess': 100, 'reflection': 0.5 },
{ 'center': np.array([-0.3, 0, 0]), 'radius': 0.15, 'ambient': np.array([0, 0.1, 0]), 'diffuse': np.array([0, 0.6, 0]), 'specular': np.array([1, 1, 1]), 'shininess': 100, 'reflection': 0.5 },
{ 'center': np.array([0, -9000, 0]), 'radius': 9000 - 0.7, 'ambient': np.array([0.1, 0.1, 0.1]), 'diffuse': np.array([0.6, 0.6, 0.6]), 'specular': np.array([1, 1, 1]), 'shininess': 100, 'reflection': 0.5 }
]
image = np.zeros((height, width, 3))
for i, y in enumerate(np.linspace(screen[1], screen[3], height)):
for j, x in enumerate(np.linspace(screen[0], screen[2], width)):
# экран в начальной точке
pixel = np.array([x, y, 0])
origin = camera
direction = normalize(pixel - origin)
color = np.zeros((3))
reflection = 1
for k in range(max_depth):
# проверка пересечений
nearest_object, min_distance = nearest_intersected_object(objects, origin, direction)
if nearest_object is None:
break
intersection = origin + min_distance * direction
normal_to_surface = normalize(intersection - nearest_object['center'])
shifted_point = intersection + 1e-5 * normal_to_surface
intersection_to_light = normalize(light['position'] - shifted_point)
_, min_distance = nearest_intersected_object(objects, shifted_point, intersection_to_light)
intersection_to_light_distance = np.linalg.norm(light['position'] - intersection)
is_shadowed = min_distance < intersection_to_light_distance
if is_shadowed:
break
illumination = np.zeros((3))
# ambiant
illumination += nearest_object['ambient'] * light['ambient']
# diffuse
illumination += nearest_object['diffuse'] * light['diffuse'] * np.dot(intersection_to_light, normal_to_surface)
# specular
intersection_to_camera = normalize(camera - intersection)
H = normalize(intersection_to_light + intersection_to_camera)
illumination += nearest_object['specular'] * light['specular'] * np.dot(normal_to_surface, H) ** (nearest_object['shininess'] / 4)
# reflection
color += reflection * illumination
reflection *= nearest_object['reflection']
origin = shifted_point
direction = reflected(direction, normal_to_surface)
image[i, j] = np.clip(color, 0, 1)
print("%d/%d" % (i + 1, height))
plt.imsave('image.png', image)
Что дальше?
Это очень упрощенная программа, предназначенная для ознакомления с основами трассировки лучей. Есть много способов улучшить ее и реализовать другие функции. Например:
Можно создать классы и выяснить, что является специфическим для сфер, а что нет, определить базовый класс и реализовать другие объекты, такие как плоскости или треугольники;
То же самое и со светом. Добавить сюда POO и сделать так, чтобы можно было добавить несколько источников света в сцену;
Отделить свойства материала от геометрических свойств, чтобы иметь возможность применять один материал (например, синий матовый) к любому типу объектов;
Найти способ правильно расположить экран при любом положении и направлении камеры;
Смоделировать свет по-другому. В настоящее время это одна точка, поэтому тени от объектов «жесткие» или четко очерченные. Чтобы получить «мягкие» тени , нужно смоделировать источник света как 2D- или 3D-объект: диск или сферу.
Бонус
Ниже приведена анимация трассировки лучей. По сути это просто несколько раз отрендеренная сцена с камерой в разных положениях:
Код написан на Kotlin (можно оценить, насколько медленный по сравнению с ним Python) и доступен на GitHub.