Рендеринг теней при помощи алгоритма Parallel-Split Shadow Mapping

в 6:51, , рубрики: direct3d 11, game development, Алгоритмы, Анимация и 3D графика, метки:

imageПривет! Мой предыдущий пост, посвященный программированию графики, был благодушно воспринят сообществом, и я отважился ещё на один. Сегодня я расскажу об алгоритме рендеринга теней Parallel-Split Shadow Mapping (PSSM), с которым я впервые столкнулся, когда возникла рабочая необходимость отображать тени на большом расстоянии от игрока. Тогда я был ограничен набором возможностей Direct3D 10, сейчас я реализовал алгоритм на Direct3D 11 и OpenGL 4.3. Подробнее алгоритм PSSM описывается в GPU Gems 3 как с математической точки зрения, так и с точки зрения реализации на Direct3D 9 и 10. За подробностями прошу под кат.

Демку можно найти здесь. Проект называется Demo_PSSM. Для сборки вам понадобятся Visual Studio 2012/2013 и CMake.

Shadow mapping

Оригинальный алгоритм теневых карт (shadow mapping) придумали уже достаточно давно. Принцип работы его заключается в следующем:

  1. Рисуем сцену в текстуру (теневую карту) из позиции источника света. Здесь важно отметить, что для разных типов источников света всё происходит немного по-разному.
    Направленные источники света (к таким в определённом приближении можно отнести солнечный свет) не имеют позиции в пространстве, однако для формирования карты теней эту позицию приходиться выбирать. Обычно её привязывают к положению наблюдателя, так чтобы в карту теней попадали объекты, находящиеся непосредственно в поле зрения наблюдателя. При рендеринге используют ортографическую проекцию.
    Проекционные источники света (лампы с непрозрачным абажуром, прожекторы) имеют определённое положение в пространстве и ограничивают распространение света определенными направлениями. При рендеринге карты теней в этом случае используется обычная перспективная проекционная матрица.
    Всенаправленные источники света (лампа накаливания, например) хоть и имеют определенное положение в пространстве, распространяют свет во всех направлениях. Чтобы корректно построить тени от такого источника света необходимо использовать кубические текстуры (cube maps), что, как правило, означает рисование сцены в карту теней 6 раз. Не всякая игра может позволить себе динамические тени от такого рода источников света, да и не всякой игре это необходимо. Если вам интересны принципы работы этого подхода, есть старая статья на эту тему.
    Кроме того, существует подкласс алгоритмов shadow mapping (LiSPSM, TSM, PSM и пр.), в которых используются нестандартные матрицы вида-проекции для улучшения качества теней и устранения недостатков оригинального подхода.
    Каким бы способом не формировалась карта теней, она неизменно содержит в себе расстояние от источника света до ближайшей видимой (из позиции источника света) точки или функцию от этого расстояния в более сложных разновидностях алгоритма.
  2. Рисуем сцену из основной камеры. Для того чтобы понять находится ли точка какого-либо объекта в тени, достаточно перевести координаты этой точки в пространство карты теней и произвести сравнение. Пространство карты теней определяется матрицей вида-проекции, которая использовалась при формировании этой карты. Переведя координаты точки объекта в это пространство и произведя преобразование координат из диапазона [-1;-1] в [0;1], получим текстурные координаты. Если полученные координаты получились вне диапазона [0;1], то эта точка не попала в карту теней, и её можно считать незатененной. Сделав выборку из карты теней по полученным текстурным координатам, мы получим расстояние между источником света и ближайшей к нему точкой какого-либо объекта. Если сравнить это расстояние с расстоянием между текущей точкой и источником света, то точка оказывается в тени, если значение в карте теней меньше. Это достаточно просто с логической точки зрения, если значение из карты теней меньше, значит, в этой точке есть какой-то объект, который находится ближе к источнику света, и мы находимся в его тени.

Shadow mapping на сегодняшний день является, наверное, самым распространенным алгоритмом для рендеринга динамических теней. Реализацию той или иной модификации алгоритма можно найти практически в любом графическом движке. Главным достоинством этого алгоритма является то, что он обеспечивает быстрое формирование теней от сколь угодно геометрически сложных объектов. Вместе с тем, существование широкого спектра вариаций алгоритма объясняется во многом его недостатками, которые могут приводить к очень неприятным графическим артефактам. Проблемы, характерные для PPSM, и пути их преодоления будут рассмотрены ниже.

Parallel-Split Shadow Mapping

Рассмотрим следующую задачу: необходимо рисовать динамические тени от объектов, находящихся на значительном удалении от игрока без ущерба для теней от близко расположенных объектов. Ограничимся направленным солнечным светом.
Задача такого рода может быть особенно актуальна в outdoor-играх, где в некоторых ситуациях игрок может видеть ландшафт на сотни метров перед собой. При этом, чем дальше мы хотим видеть тень, тем большее пространство должно попадать в теневую карту. Чтобы сохранить должное разрешение объектов в теневой карте, мы вынуждены увеличивать разрешение самой карты, что сначала приводит к снижению производительности, затем мы упираемся в ограничение на максимальный размер render target’а. В итоге, балансируя между производительностью и качеством тени, мы получим тени с хорошо заметным эффектом алиасинга, который плохо маскируется даже размытием. Понятно, что такое решение нас не может удовлетворить.
Для решения данной проблемы мы можем придумать такую матрицу проекции, чтобы близко расположенные к игроку объекты получали в карте теней площадь больше, чем объекты, которые расположены далеко. В этом заключается основная идея алгоритма Perspective Shadow Mapping (PSM) и ряда других алгоритмов. Главным преимуществом такого подхода является тот факт, что мы практически не изменили процесс рендеринга сцены, изменился лишь способ расчёта матрицы вида-проекции. Такой подход может быть легко встроен в существующую игру или движок без необходимости серьезных доработок последних. Главный недостаток такого рода подходов – граничные условия. Представим себе ситуацию, что мы рисуем тени от Солнца на закате. Когда Солнце приближается к горизонту, объекты в теневой карте начинают сильно перекрывать друг друга. В этом случае нетипичная проекционная матрица может усугубить ситуацию. Иными словами, алгоритмы класса PSM неплохо работают в определённых ситуациях, например, когда в игре рисуются тени от «неподвижного Солнца» близкого к зениту.
Принципиально другой подход предлагается в алгоритме PSSM. Некоторым данный алгоритм может быть известен под названием Cascaded Shadow Mapping (CSM). Формально, это разные алгоритмы, я бы даже сказал, что PSSM является частным случаем CSM. В этом алгоритме предлагается разделить пирамиду видимости (frustum) основной камеры на сегменты. В случае PSSM – с границами параллельными ближней и дальней плоскостям отсечения, в случае CSM – вид разделения жестко не регламентирован. Для каждого сегмента (split в терминологии алгоритма) строится своя теневая карта. Пример разделения приведен на рисунке ниже.
Рендеринг теней при помощи алгоритма Parallel Split Shadow Mapping
На рисунке можно видеть разбиение пирамиды видимости на 3 сегмента. Каждый из сегментов выделен ограничивающим прямоугольником (в трёхмерном пространстве будет параллелепипед, bounding box). Для каждой из этих ограниченных частей пространства будет строиться своя теневая карта. Внимательный читатель обратит внимание, что здесь я использовал выравненные по осям ограничивающие параллелепипеды. Можно использовать и невыравненные, это добавит дополнительную сложность в алгоритм отсечения объектов и несколько изменит способ формирования матрицы вида из позиции источника света. Так как пирамида видимости расширяется, площадь сегментов более близких к камере может быть существенно меньше площади более дальних. При одинаковом разрешении теневых карт это означает большее разрешение для тени от близко расположенных объектов. В упомянутой выше статье в GPU Gems 3 предложена следующая схема для вычисления расстояний разбиения пирамиды видимости:

Рендеринг теней при помощи алгоритма Parallel Split Shadow Mapping
Рендеринг теней при помощи алгоритма Parallel Split Shadow Mapping
Рендеринг теней при помощи алгоритма Parallel Split Shadow Mapping
где i – индекс разбиения, m – количество разбиений, n – расстояние до ближней плоскости отсечения, f – расстояние до дальней плоскости отсечения, λ – коэффициент, определяющий интерполяцию между логарифмической и равномерной шкалой разбиения.

Общее в реализации

Алгоритм PSSM в реализации на Direct3D 11 и OpenGL имеет много общего. Для реализации алгоритма необходимо подготовить следующее:

  1. Несколько теневых карт (по числу разбиений). На первый взгляд, кажется, что для получения нескольких теневых карт необходимо нарисовать объекты несколько раз. На самом деле, делать это явным образом не обязательно, мы воспользуемся механизмом аппаратного инстансинга. Для этого нам потребуется так называемый массив текстур для рендеринга и простой геометрический шейдер.
  2. Механизм отсечения объектов. Объекты игрового мира могут быть разной геометрической формы и иметь разное положение в пространстве. Протяженные объекты могут быть видны в нескольких теневых картах, небольшие объекты – только в одной. Объект может оказаться прямо на границе соседних сегментов и должен быть нарисован минимум в 2 теневые карты. Таким образом, необходим механизм для определения, в какое подмножество теневых карт попадает тот или иной объект.
  3. Механизм для определения оптимального числа разбиений. Рендеринг теневых карт для каждого сегмента на каждый кадр может быть излишней тратой вычислительных ресурсов. Во многих ситуациях игрок видит перед собой лишь небольшой участок игрового мира (например, он смотрит себе под ноги, или его взгляд уперся в стену перед собой). Понятно, что это сильно зависит от вида обзора в игре, но иметь такую оптимизацию было бы неплохо.

В итоге, получим следующий алгоритм формирования матриц вида-проекции для рендеринга карт теней:

  1. Вычисляем расстояния для разбиения пирамиды видимости для наихудшего случая. Наихудший случай здесь – мы видим тени до дальней плоскости отсечения камеры.
    Код

    	void calculateMaxSplitDistances()
    	{
    		float nearPlane = m_camera.getInternalCamera().GetNearPlane();
    		float farPlane = m_camera.getInternalCamera().GetFarPlane();
    		for (int i = 1; i < m_splitCount; i++)
    		{
    			float f = (float)i / (float)m_splitCount;
    			float l = nearPlane * pow(farPlane / nearPlane, f);
    			float u = nearPlane + (farPlane - nearPlane) * f;
    			m_maxSplitDistances[i - 1] = l * m_splitLambda + u * (1.0f - m_splitLambda);
    		}
    
    		m_farPlane = farPlane + m_splitShift;
    	}
    
  2. Определяем расстояние между камерой и наиболее удаленной видимой точкой объекта, отбрасывающего тень. Здесь важно отметить, что объекты могут отбрасывать и не отбрасывать тени. Например, равнинно-холмистый ландшафт можно сделать не отбрасывающим тени, за затенение в этом случае может отвечать алгоритм освещения. В карту теней будут рисоваться только отбрасывающие тени объекты.
    Код

    	float calculateFurthestPointInCamera(const matrix44& cameraView)
    	{
    		bbox3 scenebox;
    		scenebox.begin_extend();
    		for (size_t i = 0; i < m_entitiesData.size(); i++)
    		{
    			if (m_entitiesData[i].isShadowCaster)
    			{
    				bbox3 b = m_entitiesData[i].geometry.lock()->getBoundingBox();
    				b.transform(m_entitiesData[i].model);
    				scenebox.extend(b);
    			}
    		}
    		scenebox.end_extend();
    
    		float maxZ = m_camera.getInternalCamera().GetNearPlane();
    		for (int i = 0; i < 8; i++)
    		{
    			vector3 corner = scenebox.corner_point(i);
    			float z = -cameraView.transform_coord(corner).z;
    			if (z > maxZ) maxZ = z;
    		}
    		return std::min(maxZ, m_farPlane);
    	}
    
  3. На основе полученных на шагах 1 и 2 значений определяем количество сегментов, которое нам действительно необходимо и расстояния разбиения для них.
    Код

    	void calculateSplitDistances()
    	{
    		// calculate how many shadow maps do we really need
    		m_currentSplitCount = 1;
    		if (!m_maxSplitDistances.empty())
    		{
    			for (size_t i = 0; i < m_maxSplitDistances.size(); i++)
    			{
    				float d = m_maxSplitDistances[i] - m_splitShift;
    				if (m_furthestPointInCamera >= d) m_currentSplitCount++;
    			}
    		}
    
    		float nearPlane = m_camera.getInternalCamera().GetNearPlane();
    		for (int i = 0; i < m_currentSplitCount; i++)
    		{
    			float f = (float)i / (float)m_currentSplitCount;
    			float l = nearPlane * pow(m_furthestPointInCamera / nearPlane, f);
    			float u = nearPlane + (m_furthestPointInCamera - nearPlane) * f;
    			m_splitDistances[i] = l * m_splitLambda + u * (1.0f - m_splitLambda);
    		}
    
    		m_splitDistances[0] = nearPlane;
    		m_splitDistances[m_currentSplitCount] = m_furthestPointInCamera;
    	}
    
  4. Для каждого сегмента (границы сегмента определяются ближним и дальним расстояниями) вычисляем ограничивающий параллелепипед.
    Код

    	bbox3 calculateFrustumBox(float nearPlane, float farPlane)
    	{
    		vector3 eye = m_camera.getPosition();
    		vector3 vZ = m_camera.getOrientation().z_direction();
    		vector3 vX = m_camera.getOrientation().x_direction();
    		vector3 vY = m_camera.getOrientation().y_direction();
    		float fov = n_deg2rad(m_camera.getInternalCamera().GetAngleOfView());
    		float aspect = m_camera.getInternalCamera().GetAspectRatio();
    
    		float nearPlaneHeight = n_tan(fov * 0.5f) * nearPlane;
    		float nearPlaneWidth = nearPlaneHeight * aspect;
    		float farPlaneHeight = n_tan(fov * 0.5f) * farPlane;
    		float farPlaneWidth = farPlaneHeight * aspect;
    		vector3 nearPlaneCenter = eye + vZ * nearPlane;
    		vector3 farPlaneCenter = eye + vZ * farPlane;
    
    		bbox3 box;
    		box.begin_extend();
    		box.extend(vector3(nearPlaneCenter - vX * nearPlaneWidth - vY * nearPlaneHeight));
    		box.extend(vector3(nearPlaneCenter - vX * nearPlaneWidth + vY * nearPlaneHeight));
    		box.extend(vector3(nearPlaneCenter + vX * nearPlaneWidth + vY * nearPlaneHeight));
    		box.extend(vector3(nearPlaneCenter + vX * nearPlaneWidth - vY * nearPlaneHeight));
    		box.extend(vector3(farPlaneCenter - vX * farPlaneWidth - vY * farPlaneHeight));
    		box.extend(vector3(farPlaneCenter - vX * farPlaneWidth + vY * farPlaneHeight));
    		box.extend(vector3(farPlaneCenter + vX * farPlaneWidth + vY * farPlaneHeight));
    		box.extend(vector3(farPlaneCenter + vX * farPlaneWidth - vY * farPlaneHeight));
    		box.end_extend();
    		return box;
    	}
    
  5. Вычисляем теневую матрицу вида-проекции для каждого сегмента.
    Код

    	matrix44 calculateShadowViewProjection(const bbox3& frustumBox)
    	{
    		const float LIGHT_SOURCE_HEIGHT = 500.0f;
    
    		vector3 viewDir = m_camera.getOrientation().z_direction();
    		vector3 size = frustumBox.size();
    		vector3 center = frustumBox.center() - viewDir * m_splitShift;
    		center.y = 0;
    
    		auto lightSource = m_lightManager.getLightSource(0);
    		vector3 lightDir = lightSource.orientation.z_direction();
    
    		matrix44 shadowView;
    		shadowView.pos_component() = center - lightDir * LIGHT_SOURCE_HEIGHT;
    		shadowView.lookatRh(shadowView.pos_component() + lightDir, lightSource.orientation.y_direction());
    		shadowView.invert_simple();
    
    		matrix44 shadowProj;
    		float d = std::max(size.x, size.z);
    		shadowProj.orthoRh(d, d, 0.1f, 2000.0f);
    
    		return shadowView * shadowProj;
    	}
    

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

Код

	void updateShadowVisibilityMask(const bbox3& frustumBox, const std::shared_ptr<framework::Geometry3D>& entity, EntityData& entityData, int splitIndex)
	{
		bbox3 b = entity->getBoundingBox();
		b.transform(entityData.model);

		// shadow box computation
		auto lightSource = m_lightManager.getLightSource(0);
		vector3 lightDir = lightSource.orientation.z_direction();
		float shadowBoxL = fabs(lightDir.z) < 1e-5 ? 1000.0f : (b.size().y / -lightDir.z);
		bbox3 shadowBox;
		shadowBox.begin_extend();
		for (int i = 0; i < 8; i++)
		{
			shadowBox.extend(b.corner_point(i));
			shadowBox.extend(b.corner_point(i) + lightDir * shadowBoxL);
		}
		shadowBox.end_extend();

		if (frustumBox.clipstatus(shadowBox) != bbox3::Outside)
		{
			int i = entityData.shadowInstancesCount;
			entityData.shadowIndices[i] = splitIndex;
			entityData.shadowInstancesCount++;
		}
	}

Теперь рассмотрим процесс рендеринга и специфичные для Direct3D 11 и OpenGL 4.3 части.

Реализация на Direct3D 11

Для реализации алгоритма на Direct3D 11 нам понадобятся:

  1. Массив текстур для рендеринга теневых карт. Для создания такого рода объекта в структуре D3D11_TEXTURE2D_DESC есть поле ArraySize. Таким образом, в коде на C++ у нас не будет ничего похожего на ID3D11Texture2D* array[N]. С точки зрения Direct3D API массив текстур слабо отличается от единственной текстуры. Важной особенностью при использовании такого массива в шейдере является то, что мы можем определить, в какую именно текстуру в массиве будем рисовать тот или иной объект (семантика SV_RenderTargetArrayIndex в HLSL). В этом заключается и главное отличие этого подхода от MRT (multiple render targets), при котором один объект рисуется сразу во все заданные текстуры. Для объектов, которые необходимо нарисовать сразу в несколько теневых карт, мы воспользуемся аппаратным инстансингом, позволяющим клонировать объекты на уровне GPU. При этом объект может быть нарисован в одну текстуру в массиве, а его клоны в другие. В картах теней мы будем хранить только значение глубины, поэтому воспользуемся текстурным форматом DXGI_FORMAT_R32_FLOAT.
  2. Специальный текстурный сэмплер. В Direct3D API можно задать специальные параметры для выборки из текстуры, которые позволят производить сравнение значения в текстуре с заданным числом. Результатом в этом случае будет 0 или 1, а переход между этими значениями может быть сглажен линейным или анизотропным фильтром. Для создания сэмплера в структуре D3D11_SAMPLER_DESC зададим следующие параметры:
    samplerDesc.Filter = D3D11_FILTER_COMPARISON_MIN_MAG_LINEAR_MIP_POINT;
    samplerDesc.ComparisonFunc = D3D11_COMPARISON_LESS;
    samplerDesc.AddressU = D3D11_TEXTURE_ADDRESS_BORDER;
    samplerDesc.AddressV = D3D11_TEXTURE_ADDRESS_BORDER;
    samplerDesc.BorderColor[0] = 1.0f;
    samplerDesc.BorderColor[1] = 1.0f;
    samplerDesc.BorderColor[2] = 1.0f;
    samplerDesc.BorderColor[3] = 1.0f;
    

    Таким образом, у нас будет билинейная фильтрация, сравнение функцией «меньше», а выборка из текстуры по координатам вне диапазона [0;1] вернет 1 (т.е. отсутствие тени).

Рендеринг будем осуществлять по следующей схеме:

  1. Очистка массива карт теней. Так как в карте теней хранится наименьшее расстояние между объектами и источником света, очищать будем значением FLT_MAX.
  2. Рендерим сцену в массив карт теней. Для этого в шейдеры передаем массив теневых матриц вида-проекции и массив индексов карт теней для каждого объекта. Объекты, которые необходимо рисовать в несколько карт теней, рисуем с инстансингом при помощи метода DrawIndexedInstanced. Шейдеры на HLSL для формирования карт теней приведены ниже.
    Вершинный шейдер

    #include <common.h.hlsl>
    
    struct VS_OUTPUT
    {
        float4 position : SV_POSITION;
    	float depth : TEXCOORD0;
    	uint instanceID : SV_InstanceID;
    };
    
    VS_OUTPUT main(VS_INPUT input, unsigned int instanceID : SV_InstanceID)
    {
    	VS_OUTPUT output;
    	float4 pos = mul(float4(input.position, 1), model);
        output.position = mul(pos, shadowViewProjection[shadowIndices[instanceID]]);
        output.depth = output.position.z;
    	output.instanceID = instanceID;
    	
    	return output;
    }
    

    Геометрический шейдер

    #include <common.h.hlsl>
    
    struct GS_INPUT
    {
        float4 position : SV_POSITION;
    	float depth : TEXCOORD0;
    	uint instanceID : SV_InstanceID;
    };
    
    struct GS_OUTPUT
    {
        float4 position : SV_POSITION;
    	float depth : TEXCOORD0;
    	uint index : SV_RenderTargetArrayIndex;
    };
    
    [maxvertexcount(3)]
    void main(triangle GS_INPUT pnt[3], inout TriangleStream<GS_OUTPUT> triStream)
    {
    	GS_OUTPUT p = (GS_OUTPUT)pnt[0];
    	p.index = shadowIndices[pnt[0].instanceID];
    	triStream.Append(p);
    
    	p = (GS_OUTPUT)pnt[1];
    	p.index = shadowIndices[pnt[1].instanceID];
    	triStream.Append(p);
    
    	p = (GS_OUTPUT)pnt[2];
    	p.index = shadowIndices[pnt[2].instanceID];
    	triStream.Append(p);
    	triStream.RestartStrip();
    }
    

    Пиксельный шейдер

    struct PS_INPUT
    {
        float4 position : SV_POSITION;
    	float depth : TEXCOORD0;
    	uint index : SV_RenderTargetArrayIndex;
    };
    
    float main(PS_INPUT input) : SV_TARGET
    {
        return input.depth;
    }
    

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

    Рендеринг теней при помощи алгоритма Parallel Split Shadow Mapping

  3. Рендерим сцену из основной камеры. Для того чтобы тень получилась немного размытой воспользуемся алгоритмом Percentage Closer Filtering. При расчете затенённости точки будем делать выборки из всех теневых карт и смешивать результат. В результате мы получим float-значение, где 0.0 будет означать полностью затенённую точку, а 1.0 – полностью не затенённую. Функции на HLSL для расчёта затенённости приведены ниже.
    float3 getShadowCoords(int splitIndex, float3 worldPos)
    {
    	float4 coords = mul(float4(worldPos, 1), shadowViewProjection[splitIndex]);
    	coords.xy = (coords.xy / coords.ww) * float2(0.5, -0.5) + float2(0.5, 0.5);
    	return coords.xyz;
    }
    
    float sampleShadowMap(int index, float3 coords, float bias) 
    { 
    	if (coords.x < 0 || coords.x > 1 || coords.y < 0 || coords.y > 1) return 1.0f;
    	float3 uv = float3(coords.xy, index); 
    	float receiver = coords.z;
    
    	float sum = 0.0;
    	const int FILTER_SIZE = 3;
    	const float HALF_FILTER_SIZE = 0.5 * float(FILTER_SIZE);
    	for (int i = 0; i < FILTER_SIZE; i++)
    	{
    		for (int j = 0; j < FILTER_SIZE; j++)
    		{
    			float3 offset = float3(shadowBlurStep * (float2(i, j) - HALF_FILTER_SIZE) / HALF_FILTER_SIZE, 0);
    			sum += shadowMap.SampleCmpLevelZero(shadowMapSampler, uv + offset, receiver - bias);
    		}
    	}
    	return sum / (FILTER_SIZE * FILTER_SIZE);
    }
    
    float shadow(float3 worldPos)
    {
    	float shadowValue = 0;
    
    	[unroll(MAX_SPLITS)]
    	for (int i = 0; i < splitsCount; i++)
    	{
    		float3 coords = getShadowCoords(i, worldPos);
    		shadowValue += (1.0 - sampleShadowMap(i, coords, SHADOW_BIASES[i]));
    	}
    		
    	return 1.0 - saturate(shadowValue);
    }
    

    Наконец, необходимо учесть тот факт, что помимо теней существует еще затенение от алгоритма освещения (я использовал модель освещения Блинна-Фонга). Чтобы не происходило двойного затенения, я добавил в пиксельный шейдер следующий код.

    float shadowValue = shadow(input.worldPos);
    shadowValue = lerp(1, shadowValue, ndol);
    

    Здесь преимущество отдается модели освещения, т.е. там, где темно согласно модели Блинна-Фонга, тень дополнительно накладываться не будет. Решение не претендует на идеальность, но проблему в некоторой степени устраняет.
    В результате, мы получим следующую картинку.

    Рендеринг теней при помощи алгоритма Parallel Split Shadow Mapping

Реализация на OpenGL 4.3

Для реализации алгоритма на OpenGL 4.3 нам понадобится все то же самое, что и для Direct3D 11, однако есть тонкости. В OpenGL мы можем делать совмещённую со сравнением выборку только для текстур, содержащих значение глубины (например, в формате GL_DEPTH_COMPONENT32F). Следовательно, рендеринг мы будем осуществлять только в буфер глубины, а запись в цвет уберём (точнее, привяжем к framebuffer’у только массив текстур для хранения буфера глубины). Это, с одной стороны, немного сэкономит нам видеопамять и облегчит графический конвейер, с другой, вынудит работать с нормализованными значениями глубины.
Параметры выборки в OpenGL можно привязать прямо к текстуре. Они будут идентичны тем, что были рассмотрены ранее для Direct3D 11.

const float BORDER_COLOR[] = { 1.0f, 1.0f, 1.0f, 1.0f };
glBindTexture(m_shadowMap->getTargetType(), m_shadowMap->getDepthBuffer());
glTexParameteri(m_shadowMap->getTargetType(), GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
glTexParameteri(m_shadowMap->getTargetType(), GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(m_shadowMap->getTargetType(), GL_TEXTURE_COMPARE_MODE, GL_COMPARE_REF_TO_TEXTURE);
glTexParameteri(m_shadowMap->getTargetType(), GL_TEXTURE_COMPARE_FUNC, GL_LESS);
glTexParameteri(m_shadowMap->getTargetType(), GL_TEXTURE_WRAP_S, GL_CLAMP_TO_BORDER);
glTexParameteri(m_shadowMap->getTargetType(), GL_TEXTURE_WRAP_T, GL_CLAMP_TO_BORDER);
glTexParameterfv(m_shadowMap->getTargetType(), GL_TEXTURE_BORDER_COLOR, BORDER_COLOR);
glBindTexture(m_shadowMap->getTargetType(), 0);

Интересен процесс создания массива текстур, который внутри OpenGL представлен трёхмерной текстурой. Для его создания не сделали специальной функции, и то и другое создается при помощи glTexStorage3D. Аналогом SV_RenderTargetArrayIndex в GLSL является встроенная переменная gl_Layer.
Схема рендеринга также осталась прежней:

  1. Очищаем массив карт теней. Так как цветового канала во framebuffer’е нет, будем очищать только буфер глубины. Для нормализованного буфера глубины максимальное значение будет 1.0.
  2. Рендерим карты теней. Так как формируются только буферы глубины, нам не потребуется фрагментный шейдер.
    Вершинный шейдер

    #version 430 core
    
    const int MAX_SPLITS = 4;
    
    layout(location = 0) in vec3 position;
    layout(location = 1) in vec3 normal;
    layout(location = 2) in vec2 uv0;
    layout(location = 3) in vec3 tangent;
    layout(location = 4) in vec3 binormal;
    
    out VS_OUTPUT
    {
    	float depth;
    	flat int instanceID;
    } vsoutput;
    
    uniform mat4 modelMatrix;
    uniform mat4 shadowViewProjection[MAX_SPLITS];
    uniform int shadowIndices[MAX_SPLITS];
    
    void main()
    {
    	vec4 wpos = modelMatrix * vec4(position, 1);
    	vec4 pos = shadowViewProjection[shadowIndices[gl_InstanceID]] * vec4(wpos.xyz, 1);
        
    	gl_Position = pos;
    	vsoutput.depth = pos.z;
    	vsoutput.instanceID = gl_InstanceID;
    }
    

    Геометрический шейдер

    #version 430 core
    
    const int MAX_SPLITS = 4;
    
    layout(triangles) in;
    layout(triangle_strip, max_vertices = 3) out;
    
    in VS_OUTPUT
    {
    	float depth;
    	flat int instanceID;
    } gsinput[];
    
    uniform int shadowIndices[MAX_SPLITS];
    
    void main()
    {
    	for (int i = 0; i < gl_in.length(); i++)
    	{
    		gl_Position = gl_in[i].gl_Position;
    		gl_Layer = shadowIndices[gsinput[i].instanceID];
    		EmitVertex();
    	}
    	EndPrimitive();
    }
    

  3. Рендерим сцену из основной камеры. При расчёте затенённости главное не забыть перевести расстояние между текущей точкой и источником света (coords.z в коде) в диапазон [0;1].
    vec3 getShadowCoords(int splitIndex, vec3 worldPos)
    {
    	vec4 coords = shadowViewProjection[splitIndex] * vec4(worldPos, 1);
    	coords.xyz = (coords.xyz / coords.w) * vec3(0.5) + vec3(0.5);
    	return coords.xyz;
    }
    

    В результате получим слабо отличающуюся от Direct3D 11 картину (ради интереса покажу с другого ракурса).

    Рендеринг теней при помощи алгоритма Parallel Split Shadow Mapping

Проблемы

Проблем у алгоритма shadow mapping и его модификаций много. Зачастую алгоритм приходится тщательно настраивать под конкретную игру или даже конкретную сцену. Список наиболее частых проблем и путей их решения можно найти здесь. При реализации PSSM я столкнулся со следующим:

  1. Исчезновение теней от объектов, находящих позади наблюдателя. Карты теней мы позиционируем так, чтобы их покрытие максимально соответствовало пирамиде видимости из основной камеры. Соответственно, объекты, расположенные позади наблюдателя, просто не будут в них попадать. Я сгладил этот артефакт, введя в алгоритм расчёта теневых матриц вида-проекции сдвиг в направлении противоположном вектору зрения. Хотя это, безусловно, не решает проблему в случае длинных теней на закате или теней от высоких объектов.
  2. Усечение теней объектов. Если объект достаточно крупный, то он может частично отсекаться при рендеринге карт теней первых сегментов. Решать это можно настройкой положения камеры, из которой рендерится тень, и сдвигом из предыдущего пункта. При неудачных настройках можно видеть такой артефакт.

    Рендеринг теней при помощи алгоритма Parallel Split Shadow Mapping

  3. Ошибочное самозатенение. Наверное, самый известный графический артефакт, возникающих при использовании теневых карт. Эта проблема достаточно успешно решается путём введения погрешности при сравнении значения в теневой карте с вычисленным значением. На практике мне пришлось использовать индивидуальные значения погрешностей для каждой карты теней. На рисунке ниже слева показан неудачный вариант выбора погрешностей, справа – удачный.

    Рендеринг теней при помощи алгоритма Parallel Split Shadow Mapping

  4. К сожалению, устранение ошибочного самозатенения путем введения погрешности при сравнении глубин приводит к другому артефакту, который называется Peter Panning (на русский можно примерно перевести как «эффект Питера Пэна»). Для тех, кто не помнит книжку, тень Питера Пэна жила своей жизнью и часто убегала от хозяина. Выглядит это следующим образом (тень на углу дома немного сдвинута).

    Рендеринг теней при помощи алгоритма Parallel Split Shadow Mapping
    Данный артефакт иногда достаточно сложно заметить, особенно на объектах сложной формы, но он почти всегда есть.

Производительность

Замеры производительности велись на компьютере следующей конфигурации: AMD Phenom II X4 970 3.79GHz, 16Gb RAM, AMD Radeon HD 7700 Series, под управлением ОС Windows 8.1.

Среднее время кадра. Direct3D 11 / 1920x1080 / MSAA 8x / полный экран / маленькая сцена (~12к полигонов в кадре, ~20 объектов)

Кол-во разбиений / Размер карты теней (N x N пикселей) 1024 2048 4096
2 4.5546ms 5.07555ms 7.1661ms
3 5.50837ms 6.18023ms 9.75103ms
4 6.00958ms 7.23269ms 12.1952ms

Среднее время кадра. OpenGL 4.3 / 1920x1080 / MSAA 8x / полный экран / маленькая сцена (~12к полигонов в кадре, ~20 объектов)

Кол-во разбиений / Размер карты теней (N x N пикселей) 1024 2048 4096
2 3.2095ms 4.05457ms 6.06558ms
3 3.9968ms 4.87389ms 8.65781ms
4 4.68831ms 5.93709ms 10.4345ms

Среднее время кадра. 4 разбиения / 1920x1080 / MSAA 8x / полный экран / большая сцена (~1000к полигонов в кадре, ~1000 объектов, ~500 инстансов объектов)

API/ Размер карты теней (N x N пикселей) 1024 2048 4096
Direct3D 11 29.2031ms 33.3434ms 40.5429ms
OpenGL 4.3 21.0032ms 26.4095ms 41.8098ms

Результаты показали, что на больших и малых сценах реализация на OpenGL 4.3 работает, в целом, быстрее. С увеличением нагрузки на графический конвейер (увеличение количества объектов и их инстансов, увеличение размера карт теней) разница по скорости работы между реализациями сокращается. Преимущество реализации на OpenGL я связываю с отличным от Direct3D 11 способом формирования карты теней (мы использовали только буфер глубины без записи в цвет). Ничего нам не мешает сделать то же самое на Direct3D 11, смирившись при этом с использованием нормализованных значений глубины. Однако такой подход будет работать только до тех пор, пока мы не захотим хранить в карте теней какие дополнительные данные или функцию от значения глубины вместо значения глубины. И некоторые улучшения алгоритма (например, Variance Shadow Mapping) окажутся для нас сложно реализуемыми.

Выводы

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

Автор: rokuz

Источник

Поделиться

* - обязательные к заполнению поля