[Перевод] Трассировщик лучей с нуля за 100 строчек Python

Рисунок 1Рисунок 1

В этом посте мы заглянем под капот алгоритмов компьютерной графики, пошагово разберем основные принципы трассировки лучей и напишем ее простую реализацию на Python. Никаких сторонних графических библиотек — только NumPy и голый код в компиляторе.

Примечание: Эта статья ни в коем случае не является полным руководством/объяснением трассировки лучей, поскольку эта тема слишком обширна, а скорее просто введением для любопытствующих.

Предпосылки

Нам понадобится только самая базовая векторная геометрия:

  • Пусть у нас есть две точки A и B — независимо от размерности: 1, 2, 3, …, n, — тогда вектор, идущий от A к B, может быть найден путем вычисления B — A (поэлементно);

  • Длину вектора — независимо от его размерности — можно найти, вычислив квадратный корень из суммы возведенных в квадрат компонентов. Длина вектора v обозначается ||v||;

  • Единичный вектор — это вектор длины 1: ||v|| = 1;

  • Для данного вектора другой вектор, идущий в том же направлении, но имеющий длину 1, может быть найден путем деления каждого компонента первого вектора на его длину — это называется нормализацией: u = v/||v||;

  • Точечное произведение для векторов вычисляется как: = ||v||².

Алгоритм трассировки лучей

Трассировка лучей — это метод рендеринга, который имитирует путь света и пересечения с объектами и позволяет создавать изображения с высокой степенью реализма. Более оптимизированные варианты этого алгоритма используются в видеоиграх.

Чтобы объяснить работу этого алгоритма, сначала нужно настроить сцену:

  • Трехмерное пространство (мы собираемся использовать три координаты для позиционирования объектов в пространстве);

  • Объекты в этом пространстве (поскольку мы собираемся воспроизвести рис. 1, то возьмем в качестве объектов сферы);

  • Источник света (в нашем случае это одна точка, излучающая свет во всех направлениях);

  • Камера для наблюдения за сценой;

  • Экран, через который камера смотрит на объекты (четыре точки для четырех углов прямоугольного экрана).

Рисунок 2Рисунок 2

Экран — это некая определенная вами геометрическая фигура (например, прямоугольник 3×2). Но сами по себе цифры 3 и 2 ни о чем нам не говорят и действительно начинают приобретать какое-то значение только при сравнении их с размерами других объектов. Здесь важно то, как вы разделите ваш прямоугольник на более мелкие квадраты (пиксели), как на рисунке выше. Это определит размер конечного изображения. Другими словами, можно взять прямоугольник 3×2 и разделить его на 300×200 пикселей.

Напишем алгоритм трассировки лучей с учетом заданной сцены:

для каждого пикселя p(x, y, z) экрана:

   ассоциировать черный цвет с p

   если луч (линия), начинающийся от камеры и идущий к точке p, пересекает любой объект сцены, то:

       вычислить точку пересечения с ближайшим объектом

       если между точкой пересечения и источником света нет объекта, тогда:

           рассчитать цвет точки пересечения

           сопоставить цвет точки пересечения с p

Рисунок 3Рисунок 3

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

Настройка сцены

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

Рисунок 4Рисунок 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?

Определение луча

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

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

1627a47fdcfdd604bd30c97656823d1c.png

Помните, что камера и пиксель — это 3D-точки. При t = 0 вы окажетесь в положении камеры, но с увеличением t будете удаляться от нее в направлении пикселя. Это параметрическое уравнение, которое даст точку вдоль линии для любого t.

Конечно, точно так же мы можем переписать уравнение и определить луч, который начинается в исходной точке (O) и идет к месту назначения (D) как:

8f195534634dd508cb0997ad5fcd001b.png

Для удобства определим 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 лежит на сфере тогда, когда:

16d6b0423fd9e1742526671d03033f5a.png

Для удобства возведем обе стороны в квадрат, чтобы избавиться от квадратного корня, обусловленного величиной X — C:

1a2821e801deaed49177423000bf52d7.png

После этого мы сможем определить некоторые сферы сразу после объявления экрана:

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) окажется на сфере?

c53b9e190246848d06c29aa6fd3cd195.png

Это обычное квадратное уравнение, которое просто решается относительно t. Мы будем вызывать коэффициенты, связанные с t², t¹, t⁰, a, b и c, соответственно. Вычислим дискриминант этого уравнения:

cdf1c42826aa6655ca506d375f827db6.png

Поскольку направление d является единичным вектором, получим a = 1. После вычисления дискриминанта у нас есть три варианта:

Рисунок 5Рисунок 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Рисунок 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Рисунок 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]) }

Учитывая эти свойства, модель Блинна-Фонга рассчитывает освещенность точки следующим образом:

ab8a0260467e198f065b16710a935d46.png

где

  • 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Рисунок 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Рисунок 9

Расчет цвета

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

a7fe7adda3fccb6938d2e1c8682589ee.png

где

  • c — конечный цвет пикселя;

  • i — освещенность, рассчитанная по модели Блинна-Фонга для точки пересечения;

  • r — отражение от пересекаемого объекта.

Когда прекратить вычисление этой суммы (и отслеживание отраженных лучей, соответственно), решаете вы сами.

Отраженный луч

Прежде чем мы сможем все это записать в виде кода, нам нужно определить направление отраженного луча. Можно вычислить его следующим образом:

где

  • R — нормализованный отраженный луч;

  • L — единичный вектор направления отражаемого луча;

  • N — единичный вектор направления нормали к поверхности хода луча.

Рисунок 10Рисунок 10

Добавим этот метод в начало кода вместе с функцией 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Рисунок 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.

© Habrahabr.ru