Skip to main content

Основы алгоритма трассировки лучей. Вторая часть.

Продолжение первой части статьи про трассировку лучей.

Текстурирование

Что-то наскучило все одноцветное. Давайте сделаем мир прекрасней! Покроем наши объекты текстурами.

Пример использования текстур
Использование текстур


Что такое текстура? Когда на объект наложена какая то текстура — это значит, что мы берем цвет в точке исходя не из свойств объекта, а исходя из цвета точки на текстуре. Отдельным вопросом стоит, как именно накладывать текстуру на объект, ведь если посмотреть на рисунок ниже — одинаковый способ подходит не для всех объектов:

Пример использования текстур - плохое наложение
Использование текстур: плохое наложение

Как видно, способ наложения текстуры на сферу, представленный на предыдущей картинке, выглядит гораздо приятней. Итак, какие же способы текстурирования мы рассмотрим:

  1. Планарный;
  2. Цилиндрический;
  3. Сферический.

Код для всех трех способов будет разом ниже.

Планарный

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

Цилиндрический

Самое простое объяснение — это как свернуть картину в трубочку. Только не в рулон, а так чтобы концы соединить (опять же с учетом тайлинга).
По координате y (u) изображение линейно соответствует координате z цилиндра. Вторая координата равномерно наматывается по радиусу 1.

Сферический

Тут намотка идет сразу по двум координатам, с учетом тайлинга. Только по одной оси нам надо наматывать от 0 до 360 градусов (по долготе), а по другой от -90 до 90 (по широте).

vertex2d rtObject::texCoord(vertex3d oldpoint) const
{
    vertex3d point = (oldpoint - position).rotate(rb1,rb2,rb3);
    vertex2d result(0,0);
    switch (mapping){
    case rtPlanarMap:
        result.u = point.x();
        result.v = point.y();
        break;
    case rtCubicMap:
        break;
    case rtCylinderMap:
        point.normalize();
        result.v = (1 + point.z())/2;
        result.u = (1 + qAtan2(point.x(),point.y())/(pi/2))/2;
        break;
    case rtSphericalMap:
        point.normalize();
        double
                phi = qAcos( -point.z() );
        result.v = 1 - phi / pi;
        double
                theta = (
                        qAcos(
                                point.y() / qSin( phi )
                                )
                        ) / ( 2 * pi);
        if ( point.x() > 0 )
            result.u = theta;
        else
            result.u = 1 - theta;
        break;
    }
    return result;
}
Пример использования текстур
Использование текстур

Карты нормалей

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

Какая поверхность у вас вызывает больше доверия (картинки кликабельны) ? Эта:

Пример использования Normal mappng
Использование Normal Mapping

Или эта:
Пример отсутствия  normal mappng
Без Normal Mapping

Если первая картинка производит на вас большее впечатление, тогда мы идем к вам!

Normal Mapping

За счет чего можно добиться эффекта объема на плоской поверхности? За счет искусственной игры со светом и тенью. Для этого я использую Normal Mapping. Его идея заключается в том, чтобы подменять нормали на поверхности объекта так, как будто в этом месте идет рельефный изгиб. Физически контур объекта меняться не будет, например шар по контуру все так же будет идеальным шаром, на за счет игры света и тени можно создать эффект рельефа или какой-либо текстуры (например кожи), которая не требует большой и заметной глубины искажений.

Вот пример двух одинаковых сфер. На левой ничего нет, на правой включена карта нормалей.

сравнение
Сравнение

Можно заменить, что форма и тень на правом шаре не исказилась, не смотря на то, что визуально там идет неровная поверхность. Normal map оказывает свое действие только там, где просчитываются элементы с использованием нормали — световые модели Ламберта, Фонга, отражение и преломление лучей ( об этом позже ).

Как же устроен Normal Map?

Довольно просто. Есть текстура, представляющая собой поле нормалей в формате (x,y,z). Так как текстура представляется обычно беззнаковым байтом, то 0 у меня соответствует минус максимальное значению, 255 — + максимальное значению, а 127 — нулевому значению. Таким образом карта, которая не меняет поле нормалей хранится как серо-синяя (127,127,255) картинка.

Когда мы рассчитываем нормаль в точке приземления луча, мы рассчитываем так же текстурную координату в этой точке, восстанавливаем по ней новую нормаль и сдвигаем новую нормаль в это положение. Ничего сложного. Так же есть такой параметр, как bump value, которые выступает в качестве мультипликатора значения сдвига.

	if (objectList[nearestObjectID]->material()->useBump())
	{
		// Довольно кривой способ сдвига, но тем не менее он работает. По крайней мере на плоскостях. На сфере он работает хуже, но тоже работает.
		// Способ крив тем, что он вычисляет поворот в плоскости xOy, тогда как должен вычислять его в 
		//тангенциальной плоскости старой нормали. Если это поменять (для этого надо просто найти 
		//тангенциальную плоскость, что делается двумя, а можно и одним) скалярным произведением), 
		//то метод будет работать прекрасно.
		Color3 bumpmap = objectList[nearestObjectID]->material()->bump(texturePoint); // Получаем вектор сдвига как цвет на карте нормалей
		double bValue = objectList[nearestObjectID]->material()->bumpValue(); //  bump value, но я его не использую
		vertex3d //Преобразуем цветовой вектор в координатный
				bumpVector(
						(bumpmap.r - 0.5f)*2.0f
						,(bumpmap.g - 0.5f)*2.0f
						,bValue*(bumpmap.b - 0.5f)*2.0f
						);
		bumpVector.normalize();
		vertex3d rV = zDirection.vector_mult(surfaceNormal); // Находим вектор, перпендикулярный вертикалью и новой одновременно.
		rV.normalize();
		double
				CaX = zDirection.dot_product_norm(surfaceNormal), // Находим косинус угла между вертикальной нормалью и новой нормалью
				SaX	= qSqrt(1-CaX*CaX);
		double
				xx=rV.x()*rV.x(),
				xy=rV.y()*rV.x(),
				yy=rV.y()*rV.y(),
				d=1-CaX;
		vertex3d // Вычисление преобразования поворота, можно посмотреть на Википедии. через вектор и угол.
				r1 (
						CaX + d*xx	,
						d*xy	,
						-SaX*rV.y()
						),
				r2(
						d*xy	,
						CaX + d*yy,
						SaX*rV.x()
						),
				r3(
						SaX*rV.y(),
						-SaX*rV.x(),
						CaX
						);
		vertex3d newNormal = // получили новый вектор. слегка кривой правда. 
				r1*bumpVector.x() +
				r2*bumpVector.y() +
				r3*bumpVector.z();
 
		surfaceNormal = newNormal; 
		surfaceNormal.normalize();
	}

Сгенерировать карту нормалей можно воспользовавшись черно-белым изображением в качестве карты высоты. Для этого надо пройти по нему оператором Собеля для получения горизонтальных градиентов, и транспонированным оператором Собеля для получения вертикальных градиентов. После этого сопоставить эти градиенты с нашим пространством (0..255) и сгенерить картинку из трех каналов. Так же можно сгладить карту высоты с помощью ядра Гаусса соответствующего для получения более плавных градиентов.

Вот несколько примеров используемых мной текстур и их карт нормалей:

Кирпичи
Кирпичи

Кирпичи: карта нормалей
Кирпичи: карта нормалей

Плитка
Плитка

Плитка: карта нормалей
Плитка: карта нормалей

Преломления и отражения

Осталось насытить наш маленький мир разными плюшками. Сначала добавим отражения предметов. Самый простой способ сделать это — использовать Её Величество Рекурсию. Для этого первым делом надо в список параметров трассировки луча ввести текущий уровень рекурсии и максимальный (или сделать парамтр «запас глубины рекурсии», который уменьшать и сравнивать с нулем). Таким образом мы обеспечим себе будущее без вечных циклов.

Отражения

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

Схема отражения
Схема отражения
	Color3 objReflColor = objectList[nearestObjectID]->material()->reflection(texturePoint);
 
	Color3 reflectionColor =
			(objReflColor == colorBlack) ?
			colorBlack :
			sendRay(
					rayd(
							intersectionPoint,
							(ray.direction - surfaceNormal*2*(ray.direction*surfaceNormal) )),
					level+1);
Отражения
Отражения

Преломления

Схема преломления
Схема преломления

С преломлениями немного сложнее. Есть два способа — честный(сложный и потенциально красивый), и простой и быстрый.
Первый основан на том, что мы посылаем луч через стенку объекта, а дальше уже рассматриваем его как абсолютно самостоятельный луч, с переотражениями внутри объекта, с повторным преломлением и прочими вкусностями. Это красиво, круто. Но алгоритмически непросто и вычислительно сложно. Поэтому этот способ мы опустим и перейдем к другому.
Второй способ основывается на том, что мы вручную построим путь луча внутри объекта, и выпустим в свободное плавание его уже с другой стороны. Соответственно никаких переотражений и прочего. Предположения вычислений основываются на том, что толщина поверхности больше 2х zeroTreshold. Иначе мы считаем пластинку тонкой и она проходит объект без преломления ( что вобщем то соотвествует первоначальной модели ).

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

	// Попытка реализовать таки хороший метод. Вышло кривовато.
	Color3 objRefrColor = objectList[nearestObjectID]->material()->refraction(texturePoint);
	Color3 refractionColor = colorBlack;
	if (objRefrColor != colorBlack)
	{
		// Via http://www.cs.unc.edu/~rademach/xroads-RT/RTarticle.html
		// Reflections and Refractions
		vertex3d
				sn = surfaceNormal*ray.direction < 0 ? surfaceNormal : surfaceNormal*(-1),
				rd = ray.direction;
		sn.normalize();
		rd.normalize();
		double
				inC1	= -( sn * rd ),
				n1		= 1, //index of refraction of original medium
				n2		= objectList[nearestObjectID]->material()->ior(), //index of refraction of new medium
				inN		= (inC1 > 0) ? n1 / n2 : n2 / n1,
				//				outN	= n2 / n1,
				inC2	= qSqrt( qMax (1 - inN*inN * (1 - inC1*inC1),double (0)) );
		//				crossDistanse;
 
		rayd ingoingRay(
				ingoingRay.point = intersectionPoint,
				ingoingRay.direction = (ray.direction * inN) +  surfaceNormal * (inN * inC1 - inC2)
									   );
		ingoingRay.direction.normalize();
		refractionColor = sendRay(ingoingRay,level+1);
Преломления
Преломления

Твики

Полупрозрачность

Небольшие твики в алгоритме расчета освещения я сделал для того, чтобы не рассчитывать каустики у прозрачных объектов. Я решил, что объект с коэффициентом прозрачности 1.0f просто не будут участвовать в блокировке (затенении) светового луча. Таким образом я смог получить прозрачные объекты, внутри которых могли находится другие объекты сцены.

Полупрозрачность
Полупрозрачность

Повороты несимметричных объектов

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

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

Продолжение

В третьей части я опишу неиспользованные мной прикольные плюшки, типа SkyLight ( Ambient Occlusion), VolumeRendering, AA, мягкие тени и прочее.