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

    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.  
  3. Рендеринг «плавників». Витягування геометрії ми будемо реалізовувати за допомогою геометричного шейдера. Щоб зрозуміти чи необхідно витягати ребро між двома полігонами, треба визначити чи є це ребро зовнішнім по відношенню до об'єкта. Ознакою цього будуть значення кутів між нормалями до полігонам і нормалізованим вектором зору. Якщо ці значення будуть мати різний знак, то ребро буде зовнішнім, і значить його потрібно витягати. Геометричні шейдери в 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();
    	}
    }
    

     
     
  4.  
  5. Рендеринг «панцирів». Очевидно, що для отримання належної кількості шарів хутра геометрію необхідно намалювати кілька разів. Для багаторазового малювання геометрії ми скористається апаратним інстансінгом. Для того щоб в шейдерам визначити, який саме шар хутра малюється, достатньо скористатися семантикою 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;
    }
    
  6.  
У результаті ми можемо отримати таких котиків.
 
 
 
 

Продуктивність

Алгоритм 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 — один з небагатьох способів реалізації хутра в інтерактивному рендеринге. Однак не можна сказати, що алгоритм необхідний переважному числу ігор. На сьогоднішній день схожий рівень якості (а, можливо, навіть більш високий) досягається за допомогою арту і умілих рук графічного дизайнера. Комбінація напівпрозорих площин з добре підібраними текстурами для представлення волосся та хутра — стандарт сучасних ігор, а розглянутий алгоритм і його варіації, швидше, доля ігор майбутнього.
    
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.