habrahabr

Рендеринг меха при помощи алгоритма Shells and Fins

  • воскресенье, 6 июля 2014 г. в 03:10:45
http://habrahabr.ru/post/228753/

imageПривет, Хабр! Мой сегодняшний пост по программированию графики будет не таким объемным, как предыдущие. Почти в любом сложном деле иногда есть место несерьезному, и сегодня мы будем рендерить котиков. Точнее я хочу рассказать о реализации алгоритма рендеринга меха Shells and Fins (SAF) традиционно для Direct3D 11 и OpenGL 4. За подробностями прошу под кат.

Алгоритм рендеринга меха SAF, как нетрудно догадаться из названия, состоит из 2 частей: рендеринг «панцирей» (shells) и рендеринг «плавников» (fins). Возможно, некоторым покажутся забавными эти наименования, но они в полной отражают то, что создается алгоритмом для создания иллюзии ворсистой поверхности. Подробнее с реализацией алгоритма для Direct3D 10 можно ознакомиться в статье и демке NVidia, мою демку для Direct3D 11 и OpenGL 4 можно найти здесь. Проект называется Demo_Fur. Для сборки вам понадобятся Visual Studio 2012/2013 и CMake.

Алгоритм Shells and Fins


Мех состоит из огромного количества волосков, нарисовать каждый из которых в отдельности на текущий момент не представляется возможным в реальном времени, хотя определённые попытки были у NVidia. Для того, чтобы создать иллюзию ворсистой поверхности применяется технология, чем-то напоминающая воксельный рендеринг. Заготавливается трёхмерная текстура, представляющая собой небольшой участок меховой поверхности. Каждый воксель в ней определяет вероятность прохождения ворсинок через себя, что с графической точки зрения определяет значение прозрачности в той или иной точке при рендеринге. Такую трехмерную текстуру можно сгенерировать (один из способов описан здесь). Возникает закономерный вопрос, как эту текстуру рендерить. Для этого вокруг геометрии рисуются «панцири», т.е. копии исходной геометрии, формируемые путем масштабирования этой геометрии на небольшие значения. Получается своеобразная матрешка, на каждый слой которой накладывается слой из трехмерной текстуры меха. Слои рисуются последовательно с включенным альфа-блендингом, что в результате дает некоторую иллюзию ворсистости. Однако, этого не достаточно, чтобы материал напоминал мех. Для достижения цели необходимо выбрать правильную модель освещения.
Мех относится к категории ярко выраженных анизотропных материалов. Классические модели освещения (например, модель Блинна-Фонга) рассматривают поверхности как изотропные, т.е. свойства поверхности не зависят от ее ориентации. На практике это означает, что при повороте плоскости вокруг ее нормали характер освещения не меняется. Модели освещения такого класса для вычисления затененности используют величину угла между нормалью и направлением падения света. Анизотропные модели освещения используют тангенты (вектора перпендикулярные нормалям, которые вместе с нормалями и бинормалями образуют базис) для вычисления освещенности. Подробнее про анизотропное освещение можно прочитать здесь.
Анизотропное освещение вычисляется отдельно для каждого слоя меха. Значения тангента в той или иной точке поверхности определяется при помощи карты тангентов. Карта тангентов формируется практически так же как широко известная карта нормалей. В случае текстуры меха вектором тангента будет являться нормализованное направление ворсинки. Таким образом, трёхмерная текстура меха будет содержать 4 канала. В RGB будет храниться упакованный вектор тангента, в альфа-канале будет содержаться вероятность прохождения ворсинки через эту точку. Добавим к этому учет самозатенения меха и получим достаточно реалистично выглядящий материал.
Иллюзия будет нарушена, если человек внимательно посмотрит на внешние рёбра объекта. При определенных углах между гранями в ситуации, когда одна грань видна, а другая нет, слои меха могут быть невидимы для наблюдателя. Во избежание этой ситуации на таких рёбрах формируется дополнительная геометрия, которая вытягивается вдоль их нормалей. Результат несколько напоминает плавники у рыб, что и обусловило вторую часть названия алгоритма.

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


Реализации на обоих API в целом идентична, отличаются лишь незначительные детали. Рендеринг будем производить по следующей схеме:
  1. Рендеринг не покрытых мехом частей сцены. В моей демке для таких частей используется стандартная модель освещения Блинна-Фонга.
  2. Рендеринг «плавников». Вытягивание геометрии мы будем реализовывать при помощи геометрического шейдера. Чтобы понять необходимо ли вытягивать ребро между двумя полигонами, надо определить является ли это ребро внешним по отношению к объекту. Признаком этого будут являться значения углов между нормалями к полигонам и нормализованным вектором зрения. Если эти значения будут иметь разный знак, то ребро будет внешним, и значит его нужно вытягивать. Геометрические шейдеры в Direct3D и OpenGL умеют работать с ограниченным числом примитивов. Нам необходимо одновременно обрабатывать 2 смежных полигона с одним общим ребром. Для представления этой структуры минимально необходимо 4 вершины, что наглядно изображено на рисунке ниже слева.

    На правой части рисунка показано вытягивание общего ребра 1-2 и образование двух новых полигонов 1-5-6 и 1-6-2.
    Примитивом, который состоит из 4 вершин, является D3D_PRIMITIVE_TOPOLOGY_LINELIST_ADJ (GL_LINES_ADJACENCY в OpenGL). Для того чтобы его использовать, нам необходимо подготовить специальный индексный буфер. Такой буфер достаточно просто построить, если есть данные о смежности треугольников в 3D-модели. Индексный буфер будет содержать группы по 4 индекса, описывающие 2 смежных треугольника.
    Здесь важно отметить, что не для всякой модели можно легко получить данные о смежности. Для большинства сглаженных моделей это не представляет проблемы, однако на границах групп сглаживания вершины, как правило, дублируются для достижения правильного освещения. Это означает фактическое отсутствие смежности по индексному буферу при наличии визуальной смежности. В таком случае необходимо искать смежные треугольники, руководствуясь не только индексами, но и фактическим расположением вершин в пространстве. Это задача уже не столь тривиальна, поскольку в этом случае на одну грань могут делить сколько угодно много треугольников.
    Геометрические шейдеры для вытягивания «плавников» приведены ниже под спойлерами.

    Геометрический шейдер на HLSL для Direct3D 11
    #include <common.h.hlsl>
    
    struct GS_INPUT
    {
        float4 position : SV_POSITION;
    	float2 uv0 : TEXCOORD0;
    	float3 normal : TEXCOORD1;
    };
    
    struct GS_OUTPUT
    {
        float4 position : SV_POSITION;
    	float3 uv0 : TEXCOORD0;
    };
    
    texture2D furLengthMap : register(t0);
    SamplerState defaultSampler : register(s0);
    
    [maxvertexcount(6)]
    void main(lineadj GS_INPUT pnt[4], inout TriangleStream<GS_OUTPUT> triStream)
    {
    	float3 c1 = (pnt[0].position.xyz + pnt[1].position.xyz + pnt[2].position.xyz) / 3.0f;
    	float3 c2 = (pnt[1].position.xyz + pnt[2].position.xyz + pnt[3].position.xyz) / 3.0f;
    	float3 viewDirection1 = -normalize(viewPosition - c1);
    	float3 viewDirection2 = -normalize(viewPosition - c2);
    	float3 n1 = normalize(cross(pnt[0].position.xyz - pnt[1].position.xyz, pnt[2].position.xyz - pnt[1].position.xyz));
    	float3 n2 = normalize(cross(pnt[1].position.xyz - pnt[2].position.xyz, pnt[3].position.xyz - pnt[2].position.xyz));
    	float edge = dot(n1, viewDirection1) * dot(n2, viewDirection2);
    
    	float furLen = furLengthMap.SampleLevel(defaultSampler, pnt[1].uv0, 0).r * FUR_LENGTH;
    	if (edge > 0 && furLen > 1e-3)
    	{
    		GS_OUTPUT p[4];
    		p[0].position = mul(pnt[1].position, modelViewProjection);
    		p[0].uv0 = float3(pnt[1].uv0, 0);
    		p[1].position = mul(pnt[2].position, modelViewProjection);
    		p[1].uv0 = float3(pnt[2].uv0, 0);
    		p[2].position = mul(float4(pnt[1].position.xyz + pnt[1].normal * furLen, 1), modelViewProjection);
    		p[2].uv0 = float3(pnt[1].uv0, 1);
    		p[3].position = mul(float4(pnt[2].position.xyz + pnt[2].normal * furLen, 1), modelViewProjection);
    		p[3].uv0 = float3(pnt[2].uv0, 1);
    
    		triStream.Append(p[2]);
    		triStream.Append(p[1]);
    		triStream.Append(p[0]);
    		triStream.RestartStrip();
    	
    		triStream.Append(p[1]);
    		triStream.Append(p[2]);
    		triStream.Append(p[3]);
    		triStream.RestartStrip();
    	}
    }
    

    Геометрический шейдер на GLSL для OpenGL 4.3
    #version 430 core
    
    layout(lines_adjacency) in;
    layout(triangle_strip, max_vertices = 6) out;
    
    in VS_OUTPUT
    {
    	vec2 uv0;
    	vec3 normal;
    } gsinput[];
    
    out vec3 texcoords;
    
    const float FUR_LAYERS = 16.0f;
    const float FUR_LENGTH = 0.03f;
    
    uniform mat4 modelViewProjectionMatrix;
    uniform sampler2D furLengthMap;
    uniform vec3 viewPosition;
    
    void main()
    {
    	vec3 c1 = (gl_in[0].gl_Position.xyz + gl_in[1].gl_Position.xyz + gl_in[2].gl_Position.xyz) / 3.0f;
    	vec3 c2 = (gl_in[1].gl_Position.xyz + gl_in[2].gl_Position.xyz + gl_in[3].gl_Position.xyz) / 3.0f;
    	vec3 viewDirection1 = -normalize(viewPosition - c1);
    	vec3 viewDirection2 = -normalize(viewPosition - c2);
    	vec3 n1 = normalize(cross(gl_in[0].gl_Position.xyz - gl_in[1].gl_Position.xyz, gl_in[2].gl_Position.xyz - gl_in[1].gl_Position.xyz));
    	vec3 n2 = normalize(cross(gl_in[1].gl_Position.xyz - gl_in[2].gl_Position.xyz, gl_in[3].gl_Position.xyz - gl_in[2].gl_Position.xyz));
    	float edge = dot(n1, viewDirection1) * dot(n2, viewDirection2);
    
    	float furLen = texture(furLengthMap, gsinput[1].uv0).r * FUR_LENGTH;
    	
    	vec4 p[4];
    	vec3 uv[4];
    	if (edge > 0 && furLen > 1e-3)
    	{
    		p[0] = modelViewProjectionMatrix * vec4(gl_in[1].gl_Position.xyz, 1);
    		uv[0] = vec3(gsinput[1].uv0, 0);
    		p[1] = modelViewProjectionMatrix * vec4(gl_in[2].gl_Position.xyz, 1);
    		uv[1] = vec3(gsinput[2].uv0, 0);
    		p[2] = modelViewProjectionMatrix * vec4(gl_in[1].gl_Position.xyz + gsinput[1].normal * furLen, 1);
    		uv[2] = vec3(gsinput[1].uv0, FUR_LAYERS - 1);
    		p[3] = modelViewProjectionMatrix * vec4(gl_in[2].gl_Position.xyz + gsinput[2].normal * furLen, 1);
    		uv[3] = vec3(gsinput[2].uv0, FUR_LAYERS - 1);
    
    		gl_Position = p[2]; texcoords = uv[2];
    		EmitVertex();
    		gl_Position = p[1]; texcoords = uv[1];
    		EmitVertex();
    		gl_Position = p[0]; texcoords = uv[0];
    		EmitVertex();
    		EndPrimitive();
    	
    		gl_Position = p[1]; texcoords = uv[1];
    		EmitVertex();
    		gl_Position = p[2]; texcoords = uv[2];
    		EmitVertex();
    		gl_Position = p[3]; texcoords = uv[3];
    		EmitVertex();
    		EndPrimitive();
    	}
    }
    


  3. Рендеринг «панцирей». Очевидно, что для получения должного количества слоев меха геометрию необходимо нарисовать несколько раз. Для многократного рисования геометрии мы воспользуется аппаратным инстансингом. Для того чтобы в шейдере определить, какой именно слой меха рисуется, достаточно воспользоваться семантикой SV_InstanceID в Direct3D и переменной gl_InstanceID в OpenGL.
    Для освещения меха я использовал анизотропную модель Каджия-Кэя (Kajiya-Kay). Важной деталью стало использование специальной текстуры для задания длины меха. Такая текстура необходима для предотвращения появления длинного меха в неожиданных местах (например, вокруг глаз кота). Пиксельный и фрагментный шейдеры для расчёта освещения меха приведены ниже под спойлерами.

    Пиксельный шейдер на HLSL для Direct3D 11
    #include <common.h.hlsl>
    
    struct PS_INPUT
    {
    	float4 position : SV_POSITION;
    	float3 uv0 : TEXCOORD0;
    	float3 tangent : TEXCOORD1;
    	float3 normal : TEXCOORD2;
    	float3 worldPos : TEXCOORD3;
    };
    
    texture2D diffuseMap : register(t1);
    texture3D furMap : register(t2);
    SamplerState defaultSampler : register(s0);
    
    float4 main(PS_INPUT input) : SV_TARGET
    {
    	const float specPower = 30.0;
    
    	float3 coords = input.uv0 * float3(FUR_SCALE, FUR_SCALE, 1.0f);
    	float4 fur = furMap.Sample(defaultSampler, coords);
    	clip(fur.a - 0.01);
    
    	float4 outputColor = float4(0, 0, 0, 0);
    	outputColor.a = fur.a * (1.0 - input.uv0.z);
    
    	outputColor.rgb = diffuseMap.Sample(defaultSampler, input.uv0.xy).rgb;
    
    	float3 viewDirection = normalize(input.worldPos - viewPosition);
    	
    	float3x3 ts = float3x3(input.tangent, cross(input.normal, input.tangent), input.normal);
    	float3 tangentVector = normalize((fur.rgb - 0.5f) * 2.0f);
    	tangentVector = normalize(mul(tangentVector, ts));
    
    	float TdotL = dot(tangentVector, light.direction);
    	float TdotE = dot(tangentVector, viewDirection);
    	float sinTL = sqrt(1 - TdotL * TdotL);
    	float sinTE = sqrt(1 - TdotE * TdotE);
    	outputColor.xyz = light.ambientColor * outputColor.rgb +
    					  light.diffuseColor * (1.0 - sinTL) * outputColor.rgb +
    					  light.specularColor * pow(abs((TdotL * TdotE + sinTL * sinTE)), specPower) * FUR_SPECULAR_POWER;
    
    	float shadow = input.uv0.z * (1.0f - FUR_SELF_SHADOWING) + FUR_SELF_SHADOWING;
    	outputColor.rgb *= shadow;
    
    	return outputColor;
    }
    

    Фрагментный шейдер на GLSL для OpenGL 4.3
    #version 430 core
    
    in VS_OUTPUT
    {
    	vec3 uv0;
    	vec3 normal;
    	vec3 tangent;
    	vec3 worldPos;
    } psinput;
    
    out vec4 outputColor;
    
    const float FUR_LAYERS = 16.0f;
    const float FUR_SELF_SHADOWING = 0.9f;
    const float FUR_SCALE = 50.0f;
    const float FUR_SPECULAR_POWER = 0.35f;
    
    // lights
    struct LightData
    {
    	vec3 position;
    	uint lightType;
    	vec3 direction;
    	float falloff;
    	vec3 diffuseColor;
    	float angle;
    	vec3 ambientColor;
    	uint dummy;
    	vec3 specularColor;
    	uint dummy2;
    };
    layout(std430) buffer lightsDataBuffer
    {
        LightData lightsData[];
    };
    
    uniform sampler2D diffuseMap;
    uniform sampler2DArray furMap;
    uniform vec3 viewPosition;
    
    void main()
    {
    	const float specPower = 30.0;
    
    	vec3 coords = psinput.uv0 * vec3(FUR_SCALE, FUR_SCALE, 1.0);
    	vec4 fur = texture(furMap, coords);
    	if (fur.a < 0.01) discard;
    
    	float d = psinput.uv0.z / FUR_LAYERS;
    	outputColor = vec4(texture(diffuseMap, psinput.uv0.xy).rgb, fur.a * (1.0 - d));
    
    	vec3 viewDirection = normalize(psinput.worldPos - viewPosition);
    	
    	vec3 tangentVector = normalize((fur.rgb - 0.5) * 2.0);
    	mat3 ts = mat3(psinput.tangent, cross(psinput.normal, psinput.tangent), psinput.normal);
    	tangentVector = normalize(ts * tangentVector);
    
    	float TdotL = dot(tangentVector, lightsData[0].direction);
    	float TdotE = dot(tangentVector, viewDirection);
    	float sinTL = sqrt(1 - TdotL * TdotL);
    	float sinTE = sqrt(1 - TdotE * TdotE);
    	outputColor.rgb = lightsData[0].ambientColor * outputColor.rgb +
    					  lightsData[0].diffuseColor * (1.0 - sinTL) * outputColor.rgb +
    					  lightsData[0].specularColor * pow(abs((TdotL * TdotE + sinTL * sinTE)), specPower) * FUR_SPECULAR_POWER;
    
    	float shadow = d * (1.0 - FUR_SELF_SHADOWING) + FUR_SELF_SHADOWING;
    	outputColor.rgb *= shadow;
    }
    

В результате мы можем получить таких котиков.




Для сравнения на картинке справа приведен рендеринг котика при помощи модели Блинна-Фонга с использованием карт нормалей.


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


Алгоритм SAF достаточно прост в реализации, однако может существенно усложнить жизнь видеокарте. Каждая модель будет рисоваться несколько раз для получения заданного количества слоёв меха (я использовал 16 слоев). В случае сложной геометрии это может дать существенную просадку производительности. В использованной модели кота покрытая мехом часть занимает примерно 3000 полигонов, следовательно, для рендеринга шкуры будет нарисовано порядка 48000 полигонов. При рисовании «плавников» используется не самый простой геометрический шейдер, что тоже может сказаться в случае высоко детализированной модели.
Замеры производительности велись на компьютере следующей конфигурации: AMD Phenom II X4 970 3.79GHz, 16Gb RAM, AMD Radeon HD 7700 Series, ОС Windows 8.1.

Среднее время кадра. 1920x1080 / MSAA 8x / полный экран
API / Количество котиков 1 25 100
Direct3D 11 2.73615ms 14.3022ms 42.8362ms
OpenGL 4.3 2.5748ms 13.4807ms 34.2388ms

Итого, реализация на OpenGL 4 примерно соответствует реализации на Direct3D 11 по производительности на среднем и малом количестве объектов. На большом количестве объектов реализация на OpenGL работает несколько быстрее.

Заключение


Алгоритм SAF – один из немногих способов реализации меха в интерактивном рендеринге. Однако нельзя сказать, что алгоритм необходим подавляющему числу игр. На сегодняшний день схожий уровень качества (а, возможно, даже более высокий) достигается при помощи арта и умелых рук графического дизайнера. Комбинация полупрозрачных плоскостей с хорошо подобранными текстурами для представления волос и меха – стандарт современных игр, а рассмотренный алгоритм и его вариации, скорее, удел игр будущего.