Как реализован рендеринг «Ведьмака 3»: молнии, ведьмачье чутьё и другие эффекты

Автор оригинала: Mateusz Nagórka
  • Перевод
image

Часть 1. Молнии


В этой части мы рассмотрим процесс рендеринга молний в Witcher 3: Wild Hunt.

Рендеринг молний выполняется немного позже эффекта занавес дождя, но всё равно происходит в проходе прямого рендеринга. Молнии можно увидеть на этом видео:


Они очень быстро исчезают, поэтому лучше просматривать видео на скорости 0.25.

Можно увидеть, что это не статичные изображения; со временем их яркость слегка меняется.

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


Сцена без молнии


Сцена с молнией

С точки зрения геометрии молнии в «Ведьмаке 3» — это древоподобные меши. Данный пример молнии представлен следующим мешем:


Он имеет UV-координаты и векторы нормалей. Всё это пригодится на этапе вершинного шейдера.

Вершинный шейдер


Давайте взглянем на ассемблерный код вершинного шейдера:

 vs_5_0  
    dcl_globalFlags refactoringAllowed  
    dcl_constantbuffer cb1[9], immediateIndexed  
    dcl_constantbuffer cb2[6], immediateIndexed  
    dcl_input v0.xyz  
    dcl_input v1.xy  
    dcl_input v2.xyz  
    dcl_input v4.xyzw  
    dcl_input v5.xyzw  
    dcl_input v6.xyzw  
    dcl_input v7.xyzw  
    dcl_output o0.xy  
    dcl_output o1.xyzw  
    dcl_output_siv o2.xyzw, position  
    dcl_temps 3  
   0: mov o0.xy, v1.xyxx  
   1: mov o1.xyzw, v7.xyzw  
   2: mul r0.xyzw, v5.xyzw, cb1[0].yyyy  
   3: mad r0.xyzw, v4.xyzw, cb1[0].xxxx, r0.xyzw  
   4: mad r0.xyzw, v6.xyzw, cb1[0].zzzz, r0.xyzw  
   5: mad r0.xyzw, cb1[0].wwww, l(0.000000, 0.000000, 0.000000, 1.000000), r0.xyzw  
   6: mov r1.w, l(1.000000)  
   7: mad r1.xyz, v0.xyzx, cb2[4].xyzx, cb2[5].xyzx  
   8: dp4 r2.x, r1.xyzw, v4.xyzw  
   9: dp4 r2.y, r1.xyzw, v5.xyzw  
  10: dp4 r2.z, r1.xyzw, v6.xyzw  
  11: add r2.xyz, r2.xyzx, -cb1[8].xyzx  
  12: dp3 r1.w, r2.xyzx, r2.xyzx  
  13: rsq r1.w, r1.w  
  14: div r1.w, l(1.000000, 1.000000, 1.000000, 1.000000), r1.w  
  15: mul r1.w, r1.w, l(0.000001)  
  16: mad r2.xyz, v2.xyzx, l(2.000000, 2.000000, 2.000000, 0.000000), l(-1.000000, -1.000000, -1.000000, 0.000000)  
  17: mad r1.xyz, r2.xyzx, r1.wwww, r1.xyzx  
  18: mov r1.w, l(1.000000)  
  19: dp4 o2.x, r1.xyzw, r0.xyzw  
  20: mul r0.xyzw, v5.xyzw, cb1[1].yyyy  
  21: mad r0.xyzw, v4.xyzw, cb1[1].xxxx, r0.xyzw  
  22: mad r0.xyzw, v6.xyzw, cb1[1].zzzz, r0.xyzw  
  23: mad r0.xyzw, cb1[1].wwww, l(0.000000, 0.000000, 0.000000, 1.000000), r0.xyzw  
  24: dp4 o2.y, r1.xyzw, r0.xyzw  
  25: mul r0.xyzw, v5.xyzw, cb1[2].yyyy  
  26: mad r0.xyzw, v4.xyzw, cb1[2].xxxx, r0.xyzw  
  27: mad r0.xyzw, v6.xyzw, cb1[2].zzzz, r0.xyzw  
  28: mad r0.xyzw, cb1[2].wwww, l(0.000000, 0.000000, 0.000000, 1.000000), r0.xyzw  
  29: dp4 o2.z, r1.xyzw, r0.xyzw  
  30: mul r0.xyzw, v5.xyzw, cb1[3].yyyy  
  31: mad r0.xyzw, v4.xyzw, cb1[3].xxxx, r0.xyzw  
  32: mad r0.xyzw, v6.xyzw, cb1[3].zzzz, r0.xyzw  
  33: mad r0.xyzw, cb1[3].wwww, l(0.000000, 0.000000, 0.000000, 1.000000), r0.xyzw  
  34: dp4 o2.w, r1.xyzw, r0.xyzw  
  35: ret

Здесь есть много сходств с вершинным шейдером занавес дождя, поэтому я не буду повторяться. Хочу показать вам важное отличие, которое есть в строках 11-18:

  11: add r2.xyz, r2.xyzx, -cb1[8].xyzx  
  12: dp3 r1.w, r2.xyzx, r2.xyzx  
  13: rsq r1.w, r1.w  
  14: div r1.w, l(1.000000, 1.000000, 1.000000, 1.000000), r1.w  
  15: mul r1.w, r1.w, l(0.000001)  
  16: mad r2.xyz, v2.xyzx, l(2.000000, 2.000000, 2.000000, 0.000000), l(-1.000000, -1.000000, -1.000000, 0.000000)  
  17: mad r1.xyz, r2.xyzx, r1.wwww, r1.xyzx  
  18: mov r1.w, l(1.000000)  
  19: dp4 o2.x, r1.xyzw, r0.xyzw

Во-первых, cb1[8].xyz — это позиция камеры, а r2.xyz позиция в мировом пространстве, то есть строка 11 вычисляет вектор из камеры к позиции в мире. Затем строки 12-15 вычисляют length( worldPos — cameraPos) * 0.000001.

v2.xyz — это вектор нормали входящей геометрии. Строка 16 расширяет его из интервала [0-1] до интервала [-1;1].

Затем вычисляется конечная позиция в мире:

finalWorldPos = worldPos + length( worldPos — cameraPos) * 0.000001 * normalVector
Фрагмент кода HLSL для этой операции будет примерно таким:

      ...  
      // final world-space position  
      float3 vNormal = Input.NormalW * 2.0 - 1.0;  
      float lencameratoworld = length( PositionL - g_cameraPos.xyz) * 0.000001;  
   
      PositionL += vNormal*lencameratoworld;  
   
      // SV_Posiiton   
      float4x4 matModelViewProjection = mul(g_viewProjMatrix, matInstanceWorld );   
      Output.PositionH = mul( float4(PositionL, 1.0), transpose(matModelViewProjection) );      
   
      return Output;

Эта операция приводит к небольшому «взрыву» меша (в направлении вектора нормали). Я поэкспериментировал, заменив 0.000001 на несколько других значений. Вот результаты:


0.000002


0.000005


0.00001


0.000025

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


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

 ps_5_0  
    dcl_globalFlags refactoringAllowed  
    dcl_constantbuffer cb0[1], immediateIndexed  
    dcl_constantbuffer cb2[3], immediateIndexed  
    dcl_constantbuffer cb4[5], immediateIndexed  
    dcl_input_ps linear v0.x  
    dcl_input_ps linear v1.w  
    dcl_output o0.xyzw  
    dcl_temps 1  
   0: mad r0.x, cb0[0].x, cb4[4].x, v0.x  
   1: add r0.y, r0.x, l(-1.000000)  
   2: round_ni r0.y, r0.y  
   3: ishr r0.z, r0.y, l(13)  
   4: xor r0.y, r0.y, r0.z  
   5: imul null, r0.z, r0.y, r0.y  
   6: imad r0.z, r0.z, l(0x0000ec4d), l(0.0000000000000000000000000000000000001)  
   7: imad r0.y, r0.y, r0.z, l(146956042240.000000)  
   8: and r0.y, r0.y, l(0x7fffffff)  
   9: round_ni r0.z, r0.x  
  10: frc r0.x, r0.x  
  11: add r0.x, -r0.x, l(1.000000)  
  12: ishr r0.w, r0.z, l(13)  
  13: xor r0.z, r0.z, r0.w  
  14: imul null, r0.w, r0.z, r0.z  
  15: imad r0.w, r0.w, l(0x0000ec4d), l(0.0000000000000000000000000000000000001)  
  16: imad r0.z, r0.z, r0.w, l(146956042240.000000)  
  17: and r0.z, r0.z, l(0x7fffffff)  
  18: itof r0.yz, r0.yyzy  
  19: mul r0.z, r0.z, l(0.000000001)  
  20: mad r0.y, r0.y, l(0.000000001), -r0.z  
  21: mul r0.w, r0.x, r0.x  
  22: mul r0.x, r0.x, r0.w  
  23: mul r0.w, r0.w, l(3.000000)  
  24: mad r0.x, r0.x, l(-2.000000), r0.w  
  25: mad r0.x, r0.x, r0.y, r0.z  
  26: add r0.y, -cb4[2].x, cb4[3].x  
  27: mad_sat r0.x, r0.x, r0.y, cb4[2].x  
  28: mul r0.x, r0.x, v1.w  
  29: mul r0.yzw, cb4[0].xxxx, cb4[1].xxyz  
  30: mul r0.xyzw, r0.xyzw, cb2[2].wxyz  
  31: mul o0.xyz, r0.xxxx, r0.yzwy  
  32: mov o0.w, r0.x  
  33: ret

Хорошая новость: код не такой длинный.

Плохая новость:

   3: ishr r0.z, r0.y, l(13)  
   4: xor r0.y, r0.y, r0.z  
   5: imul null, r0.z, r0.y, r0.y  
   6: imad r0.z, r0.z, l(0x0000ec4d), l(0.0000000000000000000000000000000000001)  
   7: imad r0.y, r0.y, r0.z, l(146956042240.000000)  
   8: and r0.y, r0.y, l(0x7fffffff)

… что это вообще такое?

Честно говоря, я не впервые вижу подобный кусок… ассемблерного кода в шейдерах «Ведьмака 3». Но когда я встретил его в первый раз, то подумал: «Что за фигня?»

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

 // For more details see: http://libnoise.sourceforge.net/noisegen/  
 float integerNoise( int n )  
 {  
      n = (n >> 13) ^ n;  
      int nn = (n * (n * n * 60493 + 19990303) + 1376312589) & 0x7fffffff;  
      return ((float)nn / 1073741824.0);  
 }

Как видите, в пиксельном шейдере он вызывается дважды. Пользуясь руководствами с этого веб-сайта, мы можем понять, как правильно реализуется плавный шум. Я вернусь к этому через минуту.

Посмотрите на строку 0 — здесь мы выполняем анимацию на основании следующей формулы:

animation = elapsedTime * animationSpeed + TextureUV.x
Эти значения, после округления в меньшую сторону (floor) (инструкция round_ni) в дальнейшем становятся входными точками для целочисленного шума. Обычно мы вычисляем значение шума для двух целых чисел, а затем вычисляем окончательное, интерполированное значение между ними (подробности см. на веб-сайте libnoise).

Ну ладно, это целочисленный шум, но ведь все ранее упомянутые значения (тоже округлённые в меньшую сторону) являются float!

Заметьте, что здесь нет инструкций ftoi. Я предполагаю, что программисты из CD Projekt Red воспользовались здесь внутренней функцией HLSL asint, которая выполняет преобразование «reinterpret_cast» значений с плавающей запятой и обрабатывает их как целочисленный паттерн.

Вес интерполяции для двух значений вычисляется в строках 10-11

interpolationWeight = 1.0 — frac( animation );
Такой подход позволяет нам выполнять интерполирование между значения с учётом времени.

Для создания плавного шума этот интерполятор передается функции SCurve:

 float s_curve( float x )  
 {  
   float x2 = x * x;  
   float x3 = x2 * x;  
     
   // -2x^3 + 3x^2  
   return -2.0*x3 + 3.0*x2;  
 }


Функция Smoothstep [libnoise.sourceforge.net]

Эта функция известна под названием «smoothstep». Но как видно из ассемблерного кода, это не внутренняя функция smoothstep из HLSL. Внутренняя функция применяет ограничения, чтобы значения были верными. Но поскольку мы знаем, что interpolationWeight всегда будет находиться в интервале [0-1], эти проверки можно спокойно пропустить.

При вычислении окончательного значения используется несколько операций умножения. Посмотрите, как может меняться окончательное выходное значение альфы в зависимости от значения шума. Это удобно, потому что будет влиять на непрозрачность отрендеренной молнии, совсем как в реальной жизни.

Готовый пиксельный шейдер:

 cbuffer cbPerFrame : register (b0)  
 {  
   float4 cb0_v0;  
   float4 cb0_v1;  
   float4 cb0_v2;  
   float4 cb0_v3;  
 }  
   
 cbuffer cbPerFrame : register (b2)  
 {  
   float4 cb2_v0;  
   float4 cb2_v1;  
   float4 cb2_v2;  
   float4 cb2_v3;  
 }  
   
 cbuffer cbPerFrame : register (b4)  
 {  
   float4 cb4_v0;  
   float4 cb4_v1;  
   float4 cb4_v2;  
   float4 cb4_v3;  
   float4 cb4_v4;  
 }  
   
 struct VS_OUTPUT  
 {  
   float2 Texcoords : Texcoord0;  
   float4 InstanceLODParams : INSTANCE_LOD_PARAMS;  
   float4 PositionH : SV_Position;  
 };  
   
 // Shaders in TW3 use integer noise.  
 // For more details see: http://libnoise.sourceforge.net/noisegen/  
 float integerNoise( int n )  
 {  
   n = (n >> 13) ^ n;  
   int nn = (n * (n * n * 60493 + 19990303) + 1376312589) & 0x7fffffff;  
   return ((float)nn / 1073741824.0);  
 }  
   
 float s_curve( float x )  
 {  
   float x2 = x * x;  
   float x3 = x2 * x;  
   
   // -2x^3 + 3x^2  
   return -2.0*x3 + 3.0*x2;  
 }  
   
 float4 Lightning_TW3_PS( in VS_OUTPUT Input ) : SV_Target
 {  
   // * Inputs  
   float elapsedTime = cb0_v0.x;  
   float animationSpeed = cb4_v4.x;  
   
   float minAmount = cb4_v2.x;  
   float maxAmount = cb4_v3.x;  
   
   float colorMultiplier = cb4_v0.x;  
   float3 colorFilter = cb4_v1.xyz;  
   float3 lightningColorRGB = cb2_v2.rgb;  
   
   
   // Animation using time and X texcoord  
   float animation = elapsedTime * animationSpeed + Input.Texcoords.x;  
   
   // Input parameters for Integer Noise.  
   // They are floored and please note there are using asint.  
   // That might be an optimization to avoid "ftoi" instructions.  
   int intX0 = asint( floor(animation) );  
   int intX1 = asint( floor(animation-1.0) );  
   
   float n0 = integerNoise( intX0 );  
   float n1 = integerNoise( intX1 );    
   
   // We interpolate "backwards" here.  
   float weight = 1.0 - frac(animation);  
   
   // Following the instructions from libnoise, we perform  
   // smooth interpolation here with cubic s-curve function.  
   float noise = lerp( n0, n1, s_curve(weight) );  
   
   // Make sure we are in [0.0 - 1.0] range.  
   float lightningAmount = saturate( lerp(minAmount, maxAmount, noise) );  
   lightningAmount *= Input.InstanceLODParams.w;    // 1.0  
   lightningAmount *= cb2_v2.w;             // 1.0  
   
   // Calculate final lightning color   
   float3 lightningColor = colorMultiplier * colorFilter;  
   lightningColor *= lighntingColorRGB;  
   
   float3 finalLightningColor = lightningColor * lightningAmount;  
   return float4( finalLightningColor, lightningAmount );  
 }

Подведём итог


В этой части я описал способ рендеринга молний в «Ведьмаке 3».

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


Часть 2. Глупые трюки с небом


Эта часть будет немного отличаться от предыдущих. В ней я хочу показать вам некоторые аспекты шейдеров неба Witcher 3.

Почему «глупые трюки», а не весь шейдер? Ну, на то есть несколько причин. Во-первых, шейдер неба Witcher 3 — довольно сложная зверюга. Пиксельный шейдер из версии 2015 года содержит 267 строк ассемблерного кода, а шейдер из DLC «Кровь и вино» — уже 385 строк.

Более того, они получают множество входных данных, что не очень способствует реверс-инжинирингу полного (и читаемого!) кода на HLSL.

Поэтому я решил показать из этих шейдеров только часть трюков. Если я найду что-то новое, то дополню пост.

Различия между версией 2015 года и DLC (2016 год) сильно заметны. В том, числе в них входят различия в вычислении звёзд и их мерцания, разный подход к рендерингу Солнца… Шейдер «Крови и вина» даже вычисляет ночью Млечный путь.

Я начну с основ, а потом расскажу о глупых трюках.

Основы


Как и в большинстве современных игр, в Witcher 3 для моделирования неба используется skydome. Посмотрите на полусферу, которую использовали для этого в Witcher 3 (2015). Примечание: в данном случае ограничивающий параллелепипед этого меша находится в интервале от [0,0,0] до [1,1,1] (Z — это ось, направленная вверх) и имеет плавно распределённые UV. Позже мы их используем.


Идея в основе skydome схожа с идеей скайбокса (единственная разница заключается в используемом меше). На этапе вершинного шейдера мы преобразуем skydome относительно наблюдателя (обычно в соответствии с позицией камеры), что создаёт иллюзию того, что небо и в самом деле находится очень далеко — мы никогда до него не доберёмся.

Если вы читали предыдущие части этой серии статей, то знаете, что в «Ведьмаке 3» используется обратная глубина, то есть дальняя плоскость имеет значение 0.0f, а ближняя — 1.0f. Чтобы вывод skydome целиком выполнялся на дальней плоскости, в параметрах окна обзора мы задаём MinDepth то же значение, что и MaxDepth:


Чтобы узнать, как поля MinDepth и MaxDepth используются во время преобразования окна обзора, нажмите сюда (docs.microsoft.com).

Вершинный шейдер


Давайте начнём с вершинного шейдера. В Witcher 3 (2015 год) ассемблерный код шейдера имеет следующий вид:

 vs_5_0  
    dcl_globalFlags refactoringAllowed  
    dcl_constantbuffer cb1[4], immediateIndexed  
    dcl_constantbuffer cb2[6], immediateIndexed  
    dcl_input v0.xyz  
    dcl_input v1.xy  
    dcl_output o0.xy  
    dcl_output o1.xyz  
    dcl_output_siv o2.xyzw, position  
    dcl_temps 2  
   0: mov o0.xy, v1.xyxx  
   1: mad r0.xyz, v0.xyzx, cb2[4].xyzx, cb2[5].xyzx  
   2: mov r0.w, l(1.000000)  
   3: dp4 o1.x, r0.xyzw, cb2[0].xyzw  
   4: dp4 o1.y, r0.xyzw, cb2[1].xyzw  
   5: dp4 o1.z, r0.xyzw, cb2[2].xyzw  
   6: mul r1.xyzw, cb1[0].yyyy, cb2[1].xyzw  
   7: mad r1.xyzw, cb2[0].xyzw, cb1[0].xxxx, r1.xyzw  
   8: mad r1.xyzw, cb2[2].xyzw, cb1[0].zzzz, r1.xyzw  
   9: mad r1.xyzw, cb1[0].wwww, l(0.000000, 0.000000, 0.000000, 1.000000), r1.xyzw  
  10: dp4 o2.x, r0.xyzw, r1.xyzw  
  11: mul r1.xyzw, cb1[1].yyyy, cb2[1].xyzw  
  12: mad r1.xyzw, cb2[0].xyzw, cb1[1].xxxx, r1.xyzw  
  13: mad r1.xyzw, cb2[2].xyzw, cb1[1].zzzz, r1.xyzw  
  14: mad r1.xyzw, cb1[1].wwww, l(0.000000, 0.000000, 0.000000, 1.000000), r1.xyzw  
  15: dp4 o2.y, r0.xyzw, r1.xyzw  
  16: mul r1.xyzw, cb1[2].yyyy, cb2[1].xyzw  
  17: mad r1.xyzw, cb2[0].xyzw, cb1[2].xxxx, r1.xyzw  
  18: mad r1.xyzw, cb2[2].xyzw, cb1[2].zzzz, r1.xyzw  
  19: mad r1.xyzw, cb1[2].wwww, l(0.000000, 0.000000, 0.000000, 1.000000), r1.xyzw  
  20: dp4 o2.z, r0.xyzw, r1.xyzw  
  21: mul r1.xyzw, cb1[3].yyyy, cb2[1].xyzw  
  22: mad r1.xyzw, cb2[0].xyzw, cb1[3].xxxx, r1.xyzw  
  23: mad r1.xyzw, cb2[2].xyzw, cb1[3].zzzz, r1.xyzw  
  24: mad r1.xyzw, cb1[3].wwww, l(0.000000, 0.000000, 0.000000, 1.000000), r1.xyzw  
  25: dp4 o2.w, r0.xyzw, r1.xyzw  
  26: ret

В данном случае вершинный шейдер передаёт на выход только texcoords и позицию в мировом пространстве. В «Крови и вине» он также выводит нормализованный вектор нормали. Я буду рассматривать версию 2015 года, потому что она проще.

Посмотрите на буфер констант, обозначенный как cb2:


Здесь у нас есть матрица мира (однородное масштабирование на 100 и перенос относительно позиции камеры). Ничего сложного. cb2_v4 и cb2_v5 — это коэффициенты масштаба/отклонения, используемые для преобразования позиций вершин из интервала [0-1] в интервал [-1;1]. Но здесь эти коэффициенты «сжимают» ось Z (направленную вверх).


В предыдущих частях серии у нас были похожие вершинные шейдеры. Общий алгоритм заключается в передаче texcoords дальше, затем вычисляется Position с учётом коэффициентов масштаба/отклонения, затем вычисляется PositionW в мировом пространстве, потом рассчитывается окончательная позиция пространства отсечения перемножением матриц matWorld и matViewProj -> используется их произведение для умножения на Position, чтобы получить окончательную SV_Position.

Поэтому HLSL этого вершинного шейдера должен быть примерно таким:

 struct InputStruct {  
      float3 param0 : POSITION;  
      float2 param1 : TEXCOORD;  
      float3 param2 : NORMAL;  
      float4 param3 : TANGENT;  
 };  
   
 struct OutputStruct {  
      float2 param0 : TEXCOORD0;  
      float3 param1 : TEXCOORD1;  
      float4 param2 : SV_Position;  
 };  
   
 OutputStruct EditedShaderVS(in InputStruct IN)  
 {  
      OutputStruct OUT = (OutputStruct)0;  
        
      // Simple texcoords passing  
      OUT.param0 = IN.param1;  
        
        
      // * Manually construct world and viewProj martices from float4s:  
      row_major matrix matWorld = matrix(cb2_v0, cb2_v1, cb2_v2, float4(0,0,0,1) );  
      matrix matViewProj = matrix(cb1_v0, cb1_v1, cb1_v2, cb1_v3);  
   
      // * Some optional fun with worldMatrix  
      // a) Scale  
      //matWorld._11 = matWorld._22 = matWorld._33 = 0.225f;  
   
      // b) Translate  
      // X Y Z  
      //matWorld._14 = 520.0997;  
      //matWorld._24 = 74.4226;  
      //matWorld._34 = 113.9;  
   
      // Local space - note the scale+bias here!  
      //float3 meshScale = float3(2.0, 2.0, 2.0);  
      //float3 meshBias = float3(-1.0, -1.0, -0.4);  
      float3 meshScale = cb2_v4.xyz;  
      float3 meshBias = cb2_v5.xyz;  
   
      float3 Position = IN.param0 * meshScale + meshBias;  
        
      // World space  
      float4 PositionW = mul(float4(Position, 1.0), transpose(matWorld) );  
      OUT.param1 = PositionW.xyz;  
   
      // Clip space - original approach from The Witcher 3  
      matrix matWorldViewProj = mul(matViewProj, matWorld);  
      OUT.param2 = mul( float4(Position, 1.0), transpose(matWorldViewProj) );  
        
      return OUT;  
 }

Сравнение моего шейдера (слева) и оригинального (справа):


Отличным свойством RenderDoc является то, что он позволяет нам выполнить инъекцию собственного шейдера вместо оригинального, и эти изменения повлияют на конвейер до самого конца кадра. Как видите из кода HLSL, я предоставил несколько вариантов изменения масштаба и преобразования конечной геометрии. Можете поэкспериментировать с ними и получить очень забавные результаты:


Оптимизация вершинного шейдера


Вы заметили проблему оригинального вершинного шейдера? Повершинное перемножение матрицы на матрицу совершенно избыточно! Я обнаружил это по крайней мере в нескольких вершинных шейдерах (например, в шейдере занавес дождя в отдалении). Мы можем оптимизировать его, сразу же умножив PositionW на matViewProj!

Итак, мы можем заменить такой код на HLSL:

      // Clip space - original approach from The Witcher 3  
      matrix matWorldViewProj = mul(matViewProj, matWorld);  
      OUT.param2 = mul( float4(Position, 1.0), transpose(matWorldViewProj) );

следующим:

      // Clip space - optimized version  
      OUT.param2 = mul( matViewProj, PositionW );

Оптимизированная версия даёт нам следующий ассемблерный код:

    vs_5_0  
    dcl_globalFlags refactoringAllowed  
    dcl_constantbuffer CB1[4], immediateIndexed  
    dcl_constantbuffer CB2[6], immediateIndexed  
    dcl_input v0.xyz  
    dcl_input v1.xy  
    dcl_output o0.xy  
    dcl_output o1.xyz  
    dcl_output_siv o2.xyzw, position  
    dcl_temps 2  
   0: mov o0.xy, v1.xyxx  
   1: mad r0.xyz, v0.xyzx, cb2[4].xyzx, cb2[5].xyzx  
   2: mov r0.w, l(1.000000)  
   3: dp4 r1.x, r0.xyzw, cb2[0].xyzw  
   4: dp4 r1.y, r0.xyzw, cb2[1].xyzw  
   5: dp4 r1.z, r0.xyzw, cb2[2].xyzw  
   6: mov o1.xyz, r1.xyzx  
   7: mov r1.w, l(1.000000)  
   8: dp4 o2.x, cb1[0].xyzw, r1.xyzw  
   9: dp4 o2.y, cb1[1].xyzw, r1.xyzw  
  10: dp4 o2.z, cb1[2].xyzw, r1.xyzw  
  11: dp4 o2.w, cb1[3].xyzw, r1.xyzw  
  12: ret

Как видите, мы уменьшили количество инструкций с 26 до 12 — довольно значительное изменение. Я не знаю, насколько широко распространена эта проблема в игре, но ради бога, CD Projekt Red, может, выпустите патч? :)

И я не шучу. Можете вставить мой оптимизированный шейдер вместо оригинального RenderDoc и вы увидите, что эта оптимизация визуально ни на что не влияет. Честно говоря, я не понимаю, зачем CD Projekt Red решила выполнять повершинное умножение матрицы на матрицу…

Солнце


В «Ведьмаке 3» (2015 год) вычисление атмосферного рассеяния и Солнца состоит из двух отдельных вызовов отрисовки:


Witcher 3 (2015) — до


Witcher 3 (2015) — с небом


Witcher 3 (2015) — с небом + Солнце

Рендеринг Солнца в версии 2015 года очень похож на рендеринг Луны с точки зрения геометрии и состояний смешивания/глубин.

С другой стороны, в «Крови и вине» небо с Солнцем рендерятся за один проход:


Ведьмак 3: Кровь и вино (2016 год) — до неба


Ведьмак 3: Кровь и вино (2016 год) — с небом и Солнцем

Как бы вы не рендерили Солнце, на каком-то этапе вам всё равно понадобится (нормализованное) направление солнечного света. Наиболее логичный способ получить этот вектор — использовать сферические координаты. По сути, нам нужно всего два значения, обозначающие два угла (в радианах!): фи и тета. Получив их, можно допустить, что r = 1, таким образом сократив его. Тогда для декартовых координат с направленной вверх осью Y можно написать следующий код на HLSL:

 float3 vSunDir;  
 vSunDir.x = sin(fTheta)*cos(fPhi);  
 vSunDir.y = sin(fTheta)*sin(fPhi);  
 vSunDir.z = cos(fTheta);  
 vSunDir = normalize(vSunDir);

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

Получив направление солнечного света, мы можем углубиться в ассемблерный код пиксельного шейдера «Крови и вина»

  ...   
  100: add r1.xyw, -r0.xyxz, cb12[0].xyxz  
  101: dp3 r2.x, r1.xywx, r1.xywx  
  102: rsq r2.x, r2.x  
  103: mul r1.xyw, r1.xyxw, r2.xxxx  
  104: mov_sat r2.xy, cb12[205].yxyy  
  105: dp3 r2.z, -r1.xywx, -r1.xywx  
  106: rsq r2.z, r2.z  
  107: mul r1.xyw, -r1.xyxw, r2.zzzz  
  ...

Итак, во-первых, cb12[0].xyz — это позиция камеры, а в r0.xyz мы храним позицию вершины (это выходные данные из вершинного шейдера). Следовательно, строка 100 вычисляет вектор worldToCamera. Но взгляните на строки 105-107. Мы можем записать их как normalize( -worldToCamera), то есть мы вычисляем нормализованный вектор cameraToWorld.

  120: dp3_sat r1.x, cb12[203].yzwy, r1.xywx

Затем мы вычисляем скалярное произведение векторов cameraToWorld и sunDirection! Помните, что они должны быть нормализованными. Также мы насыщаем это полное выражение, чтобы ограничить его интервалом [0-1].

Отлично! Это скалярное произведение хранится в r1.x. Давайте посмотрим, где оно применяется дальше…

  152: log r1.x, r1.x  
  153: mul r1.x, r1.x, cb12[203].x  
  154: exp r1.x, r1.x  
  155: mul r1.x, r2.y, r1.x

Троица «log, mul, exp» — это возведение в степень. Как видите, мы возводим наш косинус (скалярное произведение нормализованных векторов) в какую-то степень. Вы можете спросить зачем. Таким образом мы можем создать градиент, имитирующий Солнце. (И строка 155 влияет на непрозрачность этого градиента, чтобы мы, например, обнулить его, чтобы полностью скрыть Солнце). Вот несколько примеров:


exponent = 54


exponent = 2400

Имея этот градиент, мы используем его для выполнения интерполяции между skyColor и sunColor! Чтобы избежать появления артефактов, нужно насытить значение в строке 120.

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

Готовый код на HLSL может походить на следующий фрагмент:

 float3 vCamToWorld = normalize( PosW – CameraPos );  
   
 float cosTheta = saturate( dot(vSunDir, vCamToWorld) );  
 float sunGradient = pow( cosTheta, sunExponent );  
   
 float3 color = lerp( skyColor, sunColor, sunGradient );

Движение звёзд


Если сделать таймлапс чистого ночного неба Witcher 3, то можно заметить, что звёзды не статичны — они немного движутся по небу! Я заметил это почти случайно и захотел узнать, как это реализовано.

Давайте начнём с того факта, что звёзды в Witcher 3 представлены как кубическая карта размером 1024x1024x6. Если подумать, то можно понять, что это очень удобное решение, которое позволяет с лёгкостью привязывать направления для сэмплирования кубической карты.

Давайте рассмотрим следующий ассемблерный код:

  159: add r1.xyz, -v1.xyzx, cb1[8].xyzx  
  160: dp3 r0.w, r1.xyzx, r1.xyzx  
  161: rsq r0.w, r0.w  
  162: mul r1.xyz, r0.wwww, r1.xyzx  
  163: mul r2.xyz, cb12[204].zwyz, l(0.000000, 0.000000, 1.000000, 0.000000)  
  164: mad r2.xyz, cb12[204].yzwy, l(0.000000, 1.000000, 0.000000, 0.000000), -r2.xyzx  
  165: mul r4.xyz, r2.xyzx, cb12[204].zwyz  
  166: mad r4.xyz, r2.zxyz, cb12[204].wyzw, -r4.xyzx  
  167: dp3 r4.x, r1.xyzx, r4.xyzx  
  168: dp2 r4.y, r1.xyxx, r2.yzyy  
  169: dp3 r4.z, r1.xyzx, cb12[204].yzwy  
  170: dp3 r0.w, r4.xyzx, r4.xyzx  
  171: rsq r0.w, r0.w  
  172: mul r2.xyz, r0.wwww, r4.xyzx  
  173: sample_indexable(texturecube)(float,float,float,float) r4.xyz, r2.xyzx, t0.xyzw, s0

Чтобы вычислить конечный вектор сэмплирования (строка 173), мы начинаем с вычисления нормализованного вектора worldToCamera (строки 159-162).

Затем мы вычисляем два векторных произведения (163-164, 165-166) с moonDirection, а позже рассчитываем три скалярных произведения, чтобы получить конечный вектор сэмплирования. Код на HLSL:

 float3 vWorldToCamera = normalize( g_CameraPos.xyz - Input.PositionW.xyz );  
 float3 vMoonDirection = cb12_v204.yzw;  
   
 float3 vStarsSamplingDir = cross( vMoonDirection, float3(0, 0, 1) );  
 float3 vStarsSamplingDir2 = cross( vStarsSamplingDir, vMoonDirection );  
   
 float dirX = dot( vWorldToCamera, vStarsSamplingDir2 );  
 float dirY = dot( vWorldToCamera, vStarsSamplingDir );  
 float dirZ = dot( vWorldToCamera, vMoonDirection);  
 float3 dirXYZ = normalize( float3(dirX, dirY, dirZ) );  
   
 float3 starsColor = texNightStars.Sample( samplerAnisoWrap, dirXYZ ).rgb;

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

Примечание для читателей: если вы знаете больше об этой операции, то расскажите мне!

Мерцающие звёзды


Ещё один интересный трюк, который бы я хотел исследовать подробнее — это мерцание звёзд. Например, если вы будете бродить в окрестностях Новиграда при ясной погоде, то заметите, что звёзды мерцают.

Мне было любопытно, как это реализовано. Оказалось, что разница между версией 2015 года и «Кровью и вином» довольно велика. Для простоты я буду рассматривать версию 2015 года.

Итак, мы начинаем сразу после сэмплирования starsColor из предыдущего раздела:

  174: mul r0.w, v0.x, l(100.000000)  
  175: round_ni r1.w, r0.w  
  176: mad r2.w, v0.y, l(50.000000), cb0[0].x  
  177: round_ni r4.w, r2.w  
  178: bfrev r4.w, r4.w  
  179: iadd r5.x, r1.w, r4.w  
  180: ishr r5.y, r5.x, l(13)  
  181: xor r5.x, r5.x, r5.y  
  182: imul null, r5.y, r5.x, r5.x  
  183: imad r5.y, r5.y, l(0x0000ec4d), l(0.0000000000000000000000000000000000001)  
  184: imad r5.x, r5.x, r5.y, l(146956042240.000000)  
  185: and r5.x, r5.x, l(0x7fffffff)  
  186: itof r5.x, r5.x  
  187: mad r5.y, v0.x, l(100.000000), l(-1.000000)  
  188: round_ni r5.y, r5.y  
  189: iadd r4.w, r4.w, r5.y  
  190: ishr r5.z, r4.w, l(13)  
  191: xor r4.w, r4.w, r5.z  
  192: imul null, r5.z, r4.w, r4.w  
  193: imad r5.z, r5.z, l(0x0000ec4d), l(0.0000000000000000000000000000000000001)  
  194: imad r4.w, r4.w, r5.z, l(146956042240.000000)  
  195: and r4.w, r4.w, l(0x7fffffff)  
  196: itof r4.w, r4.w  
  197: add r5.z, r2.w, l(-1.000000)  
  198: round_ni r5.z, r5.z  
  199: bfrev r5.z, r5.z  
  200: iadd r1.w, r1.w, r5.z  
  201: ishr r5.w, r1.w, l(13)  
  202: xor r1.w, r1.w, r5.w  
  203: imul null, r5.w, r1.w, r1.w  
  204: imad r5.w, r5.w, l(0x0000ec4d), l(0.0000000000000000000000000000000000001)  
  205: imad r1.w, r1.w, r5.w, l(146956042240.000000)  
  206: and r1.w, r1.w, l(0x7fffffff)  
  207: itof r1.w, r1.w  
  208: mul r1.w, r1.w, l(0.000000001)  
  209: iadd r5.y, r5.z, r5.y  
  210: ishr r5.z, r5.y, l(13)  
  211: xor r5.y, r5.y, r5.z  
  212: imul null, r5.z, r5.y, r5.y  
  213: imad r5.z, r5.z, l(0x0000ec4d), l(0.0000000000000000000000000000000000001)  
  214: imad r5.y, r5.y, r5.z, l(146956042240.000000)  
  215: and r5.y, r5.y, l(0x7fffffff)  
  216: itof r5.y, r5.y  
  217: frc r0.w, r0.w  
  218: add r0.w, -r0.w, l(1.000000)  
  219: mul r5.z, r0.w, r0.w  
  220: mul r0.w, r0.w, r5.z  
  221: mul r5.xz, r5.xxzx, l(0.000000001, 0.000000, 3.000000, 0.000000)  
  222: mad r0.w, r0.w, l(-2.000000), r5.z  
  223: frc r2.w, r2.w  
  224: add r2.w, -r2.w, l(1.000000)  
  225: mul r5.z, r2.w, r2.w  
  226: mul r2.w, r2.w, r5.z  
  227: mul r5.z, r5.z, l(3.000000)  
  228: mad r2.w, r2.w, l(-2.000000), r5.z  
  229: mad r4.w, r4.w, l(0.000000001), -r5.x  
  230: mad r4.w, r0.w, r4.w, r5.x  
  231: mad r5.x, r5.y, l(0.000000001), -r1.w  
  232: mad r0.w, r0.w, r5.x, r1.w  
  233: add r0.w, -r4.w, r0.w  
  234: mad r0.w, r2.w, r0.w, r4.w  
  235: mad r2.xyz, r0.wwww, l(0.000500, 0.000500, 0.000500, 0.000000), r2.xyzx  
  236: sample_indexable(texturecube)(float,float,float,float) r2.xyz, r2.xyzx, t0.xyzw, s0  
  237: log r4.xyz, r4.xyzx  
  238: mul r4.xyz, r4.xyzx, l(2.200000, 2.200000, 2.200000, 0.000000)  
  239: exp r4.xyz, r4.xyzx  
  240: log r2.xyz, r2.xyzx  
  241: mul r2.xyz, r2.xyzx, l(2.200000, 2.200000, 2.200000, 0.000000)  
  242: exp r2.xyz, r2.xyzx  
  243: mul r2.xyz, r2.xyzx, r4.xyzx

Хм. Давайте взглянем в конец этого достаточно длинного ассемблерного кода.

После сэмплирования starsColor в строке 173 мы вычисляем какое-то значение offset. Это offset используется для искажения первого направления сэмплирования (r2.xyz, строка 235), а затем снова сэмплируем кубическую карту звёзд, выполняем гамма-коррекцию этих двух значений (237-242) и перемножаем их (243).

Просто, не правда ли? Ну, не совсем. Давайте немного подумаем об этом offset. Это значение должно быть разным на протяжении всего skydome — одинаково мерцающие звёзды выглядели бы очень нереалистично.

Чтобы offset было как можно более разнообразным, мы воспользуемся тем, что UV растянуты на skydome (v0.xy) и применим прошедшее время, хранящееся в буфере констант (cb[0].x).

Если вам незнакомы эти пугающие ishr/xor/and, то в части про эффект молний прочитайте об целочисленном шуме.

Как видите, целочисленный шум вызывается здесь четыре раза, но он отличается от того, который используется для молний. Чтобы сделать результаты ещё более случайными, входящее целое число для шума является суммой (iadd) и с ним выполняется инвертирование битов (внутренняя функция reversebits; инструкция bfrev).

Так, а теперь помедленнее. Давайте начнём с самого начала.

У нас есть 4 «итерации» целочисленного шума. Я проанализировал ассемблерный код, вычисления всех 4 итераций выглядят так:

 int getInt( float x )
 {
     return asint( floor(x) );
 }
 
 int getReverseInt( float x )
 {
     return reversebits( getInt(x) );
 }

 // * Inputs - UV and elapsed time in seconds  
 float2 starsUV;  
 starsUV.x = 100.0 * Input.TextureUV.x;       
 starsUV.y = 50.0  * Input.TextureUV.y + g_fTime;  
             
 // * Iteration 1  
 int iStars1_A = getReverseInt( starsUV.y );
 int iStars1_B = getInt( starsUV.x );            
   
 float fStarsNoise1 = integerNoise( iStars1_A + iStars1_B );  
             
   
 // * Iteration 2  
 int iStars2_A = getReverseInt( starsUV.y );  
 int iStars2_B = getInt( starsUV.x - 1.0 ); 
   
 float fStarsNoise2 = integerNoise( iStars2_A + iStars2_B );  
        
   
 // * Iteration 3  
 int iStars3_A = getReverseInt( starsUV.y - 1.0 );
 int iStars3_B = getInt( starsUV.x );
   
 float fStarsNoise3 = integerNoise( iStars3_A + iStars3_B );  
             
   
 // * Iteration 4  
 int iStars4_A = getReverseInt( starsUV.y - 1.0 ); 
 int iStars4_B = getInt( starsUV.x - 1.0 ); 
   
 float fStarsNoise4 = integerNoise( iStars4_A + iStars4_B );

Конечные выходные данные всех 4 итераций (чтобы найти их, проследите за инструкциями itof):

Итерация 1 — r5.x,

Итерация 2 — r4.w,

Итерация 3 — r1.w,

Итерация 4 — r5.y

После последней itof (строка 216) мы имеем:

  217: frc r0.w, r0.w   
  218: add r0.w, -r0.w, l(1.000000)   
  219: mul r5.z, r0.w, r0.w   
  220: mul r0.w, r0.w, r5.z   
  221: mul r5.xz, r5.xxzx, l(0.000000001, 0.000000, 3.000000, 0.000000)   
  222: mad r0.w, r0.w, l(-2.000000), r5.z   
  223: frc r2.w, r2.w   
  224: add r2.w, -r2.w, l(1.000000)   
  225: mul r5.z, r2.w, r2.w   
  226: mul r2.w, r2.w, r5.z   
  227: mul r5.z, r5.z, l(3.000000)   
  228: mad r2.w, r2.w, l(-2.000000), r5.z

Эти строки вычисляют значения S-образной кривой для весов на основании дробной части UV, как и в случае с молниями. Итак:

  float s_curve( float x )   
  {   
    float x2 = x * x;   
    float x3 = x2 * x;   
      
    // -2x^3 + 3x^2   
    return -2.0*x3 + 3.0*x2;   
  }  
   
 ...  
 
 // lines 217-222
 float weightX = 1.0 - frac( starsUV.x );  
 weightX = s_curve( weightX );  
   
 // lines 223-228
 float weightY = 1.0 - frac( starsUV.y );  
 weightY = s_curve( weightY );

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

  229: mad r4.w, r4.w, l(0.000000001), -r5.x   
  230: mad r4.w, r0.w, r4.w, r5.x   
  float noise0 = lerp( fStarsNoise1, fStarsNoise2, weightX );  
   
  231: mad r5.x, r5.y, l(0.000000001), -r1.w   
  232: mad r0.w, r0.w, r5.x, r1.w   
  float noise1 = lerp( fStarsNoise3, fStarsNoise4, weightX );  
   
  233: add r0.w, -r4.w, r0.w   
  234: mad r0.w, r2.w, r0.w, r4.w   
  float offset = lerp( noise0, noise1, weightY );            
   
  235: mad r2.xyz, r0.wwww, l(0.000500, 0.000500, 0.000500, 0.000000), r2.xyzx   
  236: sample_indexable(texturecube)(float,float,float,float) r2.xyz, r2.xyzx, t0.xyzw, s0   
  float3 starsPerturbedDir = dirXYZ + offset * 0.0005;  
    
  float3 starsColorDisturbed = texNightStars.Sample( samplerAnisoWrap, starsPerturbedDir ).rgb;

Вот небольшая визуализация вычисленного offset:


После вычисления starsColorDisturbed самая сложная часть завершена. Ура!

Следующий этап — выполнение гамма-коррекции и для starsColor, и для starsColorDisturbed, после чего они перемножаются:

  starsColor = pow( starsColor, 2.2 );  
  starsColorDisturbed = pow( starsColorDisturbed, 2.2 );  
   
  float3 starsFinal = starsColor * starsColorDisturbed;

Звёзды — финальные штрихи


У нас есть starsFinal in r1.xyz. В конце обработки звёзд происходит следующее:

  256: log r1.xyz, r1.xyzx  
  257: mul r1.xyz, r1.xyzx, l(2.500000, 2.500000, 2.500000, 0.000000)  
  258: exp r1.xyz, r1.xyzx  
  259: min r1.xyz, r1.xyzx, l(1.000000, 1.000000, 1.000000, 0.000000)  
  260: add r0.w, -cb0[9].w, l(1.000000)  
  261: mul r1.xyz, r0.wwww, r1.xyzx  
  262: mul r1.xyz, r1.xyzx, l(10.000000, 10.000000, 10.000000, 0.000000)

Это гораздо проще по сравнению с мерцающими и движущимися звёздами.

Итак, мы начинаем с возведения starsFinal в степень 2.5 — это позволяет нам контролировать плотность звёзд. Довольно умно. Затем мы делаем так, чтобы максимальный цвет звёзд был равен float3(1, 1, 1).

cb0[9].w используется для управления общей видимостью звёзд. Поэтому можно ожидать, что в дневное время это значение равно 1.0 (что даёт умножение на ноль), а ночью — 0.0.

В конце мы увеличиваем видимость звёзд на 10. И на этом всё!

Часть 3. Ведьмачье чутьё (объекты и карта яркости)


Почти все описанные ранее эффекты и техники на самом деле не были связаны с Witcher 3. Такие вещи, как тональная коррекция, виньетирование или вычисление средней яркости присутствуют практически в каждой современной игре. Даже эффект опьянения распространён довольно широко.

Именно поэтому я решил внимательнее присмотреться к механикам рендеринга «ведьмачьего чутья». Геральт — ведьмак, а потому его чувства гораздо острее, чем у обычного человека. Следовательно, он может видеть и слышать больше, чем другие люди, что сильно помогает ему в расследованиях. Механика ведьмачьего чутья позволяет игроку визуализировать такие следы.

Вот демонстрация эффекта:


И ещё одна, с освещением получше:


Как видите, есть два типа объектов: те, с которыми Геральт может взаимодействовать (жёлтый контур) и следы, связанные с расследованием (красный контур). После того, как Геральт исследует красный след, он может превратиться в жёлтый (первое видео). Заметьте, что весь экран становится серее и добавляется эффект «рыбьего глаза (второе видео).

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

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

Выбор объектов


Как я и говорил, существует два типа объектов, и нам нужно их различать. В Witcher 3 это реализовано с помощью стенсил-буфера. При генерации мешей GBuffer, которые должны быть помечены как „следы“ (красные), они рендерятся со stencil = 8. Меши, помеченные жёлтым цветом как „интересные“ объекты, рендерятся со stencil = 4.

Например, следующие две текстуры показывают пример кадра с видимым ведьмачьим чутьём и соответствующий стенсил-буфер:



Вкратце о стенсил-буфере


Стенсил-буфер довольно часто используется в играх для пометки мешей. Определённым категориям мешей назначается одинаковый ID.

Идея заключается в том, чтобы использовать функцию Always с оператором Replace, если стенсил-тест оказался успешным, и с оператором Keep во всех остальных случаях.

Вот как это реализуется с помощью D3D11:

 D3D11_DEPTH_STENCIL_DESC depthstencilState;  
 // Set depth parameters....  
   
 // Enable stencil  
 depthstencilState.StencilEnable = TRUE;  
   
 // Read & write all bits  
 depthstencilState.StencilReadMask = 0xFF;  
 depthstencilState.StencilWriteMask = 0xFF;  
   
 // Stencil operator for front face  
 depthstencilState.FrontFace.StencilFunc = D3D11_COMPARISON_ALWAYS;  
 depthstencilState.FrontFace.StencilDepthFailOp = D3D11_STENCIL_OP_KEEP;  
 depthstencilState.FrontFace.StencilFailOp = D3D11_STENCIL_OP_KEEP;  
 depthstencilState.FrontFace.StencilPassOp = D3D11_STENCIL_OP_REPLACE;  
   
 // Stencil operator for back face.  
 depthstencilState.BackFace.StencilFunc = D3D11_COMPARISON_ALWAYS;  
 depthstencilState.BackFace.StencilDepthFailOp = D3D11_STENCIL_OP_KEEP;  
 depthstencilState.BackFace.StencilFailOp = D3D11_STENCIL_OP_KEEP;  
 depthstencilState.BackFace.StencilPassOp = D3D11_STENCIL_OP_REPLACE;  
   
 pDevice->CreateDepthStencilState( &depthstencilState, &m_pDS_AssignValue );

Значение стенсила, которое нужно записать в буфер, передаётся как StencilRef в вызове API:

 // from now on set stencil buffer values to 8  
 pDevCon->OMSetDepthStencilState( m_pDS_AssignValue, 8 );  
 ...  
 pDevCon->DrawIndexed( ... );

Яркость рендеринга


В этом проходе с точки зрения реализации есть одна полноэкранная текстура в формате R11G11B10_FLOAT, в которую интересные объекты и следы сохраняются в каналы R и G.

Зачем это нужно нам с точки зрения яркости? Оказывается, что чутьё Геральта имеет ограниченный радиус, поэтому объекты получают контуры, только когда игрок находится достаточно близко к ним.

Посмотрите на этот аспект в действии:



Мы начинаем с очистки текстуры яркости, заливая её чёрным цветом.

Затем выполняются два полноэкранных вызова отрисовки: первый для „следова“, второй — для интересных объектов:


Первый вызов отрисовки выполняется для следов — зелёный канал:


Второй вызов выполняется для интересных объектов — красный канал:


Ну ладно, но как нам определить, какие пиксели нужно учитывать? Придётся воспользоваться стенсил-буфером!

При каждом из этих вызовов выполняется стенсил-тест, и принимаются только те пиксели, которые были ранее помечены как „8“ (первый вызов отрисовки) или „4“.

Визуализация стенсил-теста для следов:


… и для интересных объектов:


Как в этом случае выполняется тест? Об основах стенсил-тестирования можно узнать в хорошем посте. В общем виде формула стенсил-теста имеет следующий вид:

 if (StencilRef & StencilReadMask OP StencilValue & StencilReadMask)  
   accept pixel  
 else  
   discard pixel

где:
StencilRef — значение, передаваемое вызовом API,

StencilReadMask — маска, используемая для чтения значения стенсила (учтите, что она присутствует и на левой, и на правой части),

OP — оператор сравнения, задаётся через API,

StencilValue — значение стенсил-буфера в текущем обрабатываемом пикселе.

Важно понимать, что для вычисления операндов мы используем двоичные AND.

Познакомившись с основами, давайте посмотрим, как эти параметры используются в данных вызовах отрисовки:


Состояние стенсила для следов


Состояние стенсила для интересных объектов

Ха! Как мы видим, единственное отличие заключается в ReadMask. Давайте проверим это! Подставим эти значения в уравнение стенсил-теста:

 Let StencilReadMask = 0x08 and StencilRef = 0:  
   
 For a pixel with stencil = 8:  
 0 & 0x08 < 8 & 0x08  
 0 < 8
 TRUE  
   
 For a pixel with stencil = 4:  
 0 & 0x08 < 4 & 0x08  
 0 < 0  
 FALSE

Умно. Как видите, в этом случае мы сравниваем не значение стенсила, а проверяем задан ли определённый бит стенсил-буфера. Каждый пиксель стенсил-буфера имеет формат uint8, поэтому интервал значений составляет [0-255].

Примечание: все вызовы DrawIndexed(36) связаны с рендерингом отпечатков ног как следов, поэтому в этом конкретном кадре карта яркости имеет следующий окончательный вид:


Но перед стенсил-тестом есть пиксельный шейдер. И 28738, и 28748 используют одинаковый пиксельный шейдер:

 ps_5_0  
    dcl_globalFlags refactoringAllowed  
    dcl_constantbuffer cb0[2], immediateIndexed  
    dcl_constantbuffer cb3[8], immediateIndexed  
    dcl_constantbuffer cb12[214], immediateIndexed  
    dcl_sampler s15, mode_default  
    dcl_resource_texture2d (float,float,float,float) t15  
    dcl_input_ps_siv v0.xy, position  
    dcl_output o0.xyzw  
    dcl_output o1.xyzw  
    dcl_output o2.xyzw  
    dcl_output o3.xyzw  
    dcl_temps 2  
   0: mul r0.xy, v0.xyxx, cb0[1].zwzz  
   1: sample_indexable(texture2d)(float,float,float,float) r0.x, r0.xyxx, t15.xyzw, s15  
   2: mul r1.xyzw, v0.yyyy, cb12[211].xyzw  
   3: mad r1.xyzw, cb12[210].xyzw, v0.xxxx, r1.xyzw  
   4: mad r0.xyzw, cb12[212].xyzw, r0.xxxx, r1.xyzw  
   5: add r0.xyzw, r0.xyzw, cb12[213].xyzw  
   6: div r0.xyz, r0.xyzx, r0.wwww  
   7: add r0.xyz, r0.xyzx, -cb3[7].xyzx  
   8: dp3 r0.x, r0.xyzx, r0.xyzx  
   9: sqrt r0.x, r0.x  
  10: mul r0.y, r0.x, l(0.120000)  
  11: log r1.x, abs(cb3[6].y)  
  12: mul r1.xy, r1.xxxx, l(2.800000, 0.800000, 0.000000, 0.000000)  
  13: exp r1.xy, r1.xyxx  
  14: mad r0.zw, r1.xxxy, l(0.000000, 0.000000, 120.000000, 120.000000), l(0.000000, 0.000000, 1.000000, 1.000000)  
  15: lt r1.x, l(0.030000), cb3[6].y  
  16: movc r0.xy, r1.xxxx, r0.yzyy, r0.xwxx  
  17: div r0.x, r0.x, r0.y  
  18: log r0.x, r0.x  
  19: mul r0.x, r0.x, l(1.600000)  
  20: exp r0.x, r0.x  
  21: add r0.x, -r0.x, l(1.000000)  
  22: max r0.x, r0.x, l(0)  
  23: mul o0.xyz, r0.xxxx, cb3[0].xyzx  
  24: mov o0.w, cb3[0].w  
  25: mov o1.xyzw, cb3[1].xyzw  
  26: mov o2.xyzw, cb3[2].xyzw  
  27: mov o3.xyzw, cb3[3].xyzw  
  28: ret

Этот пиксельный шейдер выполняет запись только в один render target, поэтому строки 24-27 избыточны.

Первое, что здесь происходит — сэмплирование глубины (точечным сэмплером с ограничением значений), строка 1. Это значение используется для воссоздания позиции в мире умножением на специальную матрицу с последующим перспективным делением (строки 2-6).

Взяв позицию Геральта (cb3[7].xyz — учтите, что это не позиция камеры!), мы вычисляем расстояние от Геральта до этой конкретной точки (строки 7-9).

В этом шейдере важны следующие входные данные:

— cb3[0].rgb — цвет вывода. Он может иметь формат float3(0, 1, 0) (следы) или float3(1, 0, 0) (интересные объекты),
— cb3[6].y — коэффициент масштабирования расстояния. Непосредственно влияет на радиус и яркость финальных выходных данных.

Позже у нас идут довольно хитрые формулы для вычисления яркости в зависимости от расстояния между Геральтом и объектом. Могу предположить, что все коэффициенты подобраны экспериментально.

Финальными выходными данными являются color*intensity.

Код на HLSL будет выглядеть примерно так:

 struct FSInput  
 {  
      float4 param0 : SV_Position;  
 };  
   
 struct FSOutput  
 {  
      float4 param0 : SV_Target0;  
      float4 param1 : SV_Target1;  
      float4 param2 : SV_Target2;  
      float4 param3 : SV_Target3;  
 };  
   
 float3 getWorldPos( float2 screenPos, float depth )  
 {  
   float4 worldPos = float4(screenPos, depth, 1.0);  
   worldPos = mul( worldPos, screenToWorld );  
     
   return worldPos.xyz / worldPos.w;  
 }  
   
 FSOutput EditedShaderPS(in FSInput IN)  
 {  
   // * Inputs    
   // Directly affects radius of the effect  
   float distanceScaling = cb3_v6.y;  
     
   // Color of output at the end  
   float3 color = cb3_v0.rgb;  
        

   // Sample depth  
   float2 uv = IN.param0.xy * cb0_v1.zw;  
   float depth = texture15.Sample( sampler15, uv ).x;  
     
   // Reconstruct world position  
   float3 worldPos = getWorldPos( IN.param0.xy, depth );  
   
   // Calculate distance from Geralt to world position of particular object  
   float dist_geraltToWorld = length( worldPos - cb3_v7.xyz );  
     
   // Calculate two squeezing params  
   float t0 = 1.0 + 120*pow( abs(distanceScaling), 2.8 );  
   float t1 = 1.0 + 120*pow( abs(distanceScaling), 0.8 );  
     
   // Determine nominator and denominator  
   float2 params;  
   params = (distanceScaling > 0.03) ? float2(dist_geraltToWorld * 0.12, t0) : float2(dist_geraltToWorld, t1);  
     
   // Distance Geralt <-> Object  
   float nominator = params.x;   
     
   // Hiding factor  
   float denominator = params.y;  
     
   // Raise to power of 1.6  
   float param = pow( params.x / params.y, 1.6 );  
     
   // Calculate final intensity  
   float intensity = max(0.0, 1.0 - param );   
     
     
   // * Final outputs.  
   // *  
   // * This PS outputs only one color, the rest  
   // * is redundant. I just added this to keep 1-1 ratio with  
   // * original assembly.  
   FSOutput OUT = (FSOutput)0;  
   OUT.param0.xyz = color * intensity;  
     
   // == redundant ==  
   OUT.param0.w = cb3_v0.w;  
   OUT.param1 = cb3_v1;  
   OUT.param2 = cb3_v2;  
   OUT.param3 = cb3_v3;  
   // ===============  
   
   return OUT;  
 }

Небольшое сравнение оригинального (слева) и моего (справа) ассемблерного кода шейдера.


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

Часть 4. Ведьмачье чутьё (карта контуров)


Ещё раз взглянем на исследуемую нами сцену:


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

У нас есть одна полноэкранная текстура формата R11G11B10_FLOAT, которая может выглядеть вот так:


Зелёный канал обозначает „следы“, красный — интересные объекты, с которыми может взаимодействовать Геральт.

Получив эту текстуру, мы можем переходить к следующему этапу — я назвал его „карта контуров“.


Это немного странная текстура формата 512x512 R16G16_FLOAT. Здесь важно то, что она реализована в стиле „пинг-понг“. Карта контуров из предыдущего кадра является входящими данными (наряду с картой яркости) для генерации новой карты контуров в текущем кадре.

Буферы „пинг-понга“ можно реализовать множеством способов, но лично мне больше всего нравится следующий (псевдокод):

 // Declarations  
 Texture2D m_texOutlineMap[2];  
 uint m_outlineIndex = 0;  
   
 // Rendering  
 void Render()  
 {  
   pDevCon->SetInputTexture( m_texOutlineMap[m_outlineIndex] );  
   pDevCon->SetOutputTexture( m_texOutlineMap[!m_outlineIndex] );  
   ...  
   pDevCon->Draw(...);  
   
   // after draw  
   m_outlineIndex = !m_outlineIndex;  
 }

Такой подход, при котором на входе всегда [m_outlineIndex], а на выходе всегда [!m_outlineIndex], обеспечивает гибкость в отношении использования дальнейших постэффектов.

Давайте взглянем на пиксельный шейдер:

 ps_5_0  
    dcl_globalFlags refactoringAllowed  
    dcl_constantbuffer cb3[1], immediateIndexed  
    dcl_sampler s0, mode_default  
    dcl_sampler s1, mode_default  
    dcl_resource_texture2d (float,float,float,float) t0  
    dcl_resource_texture2d (float,float,float,float) t1  
    dcl_input_ps linear v2.xy  
    dcl_output o0.xyzw  
    dcl_temps 4  
   0: add r0.xyzw, v2.xyxy, v2.xyxy  
   1: round_ni r1.xy, r0.zwzz  
   2: frc r0.xyzw, r0.xyzw  
   3: add r1.zw, r1.xxxy, l(0.000000, 0.000000, -1.000000, -1.000000)  
   4: dp2 r1.z, r1.zwzz, r1.zwzz  
   5: add r1.z, -r1.z, l(1.000000)  
   6: max r2.w, r1.z, l(0)  
   7: dp2 r1.z, r1.xyxx, r1.xyxx  
   8: add r3.xyzw, r1.xyxy, l(-1.000000, -0.000000, -0.000000, -1.000000)  
   9: add r1.x, -r1.z, l(1.000000)  
  10: max r2.x, r1.x, l(0)  
  11: dp2 r1.x, r3.xyxx, r3.xyxx  
  12: dp2 r1.y, r3.zwzz, r3.zwzz  
  13: add r1.xy, -r1.xyxx, l(1.000000, 1.000000, 0.000000, 0.000000)  
  14: max r2.yz, r1.xxyx, l(0, 0, 0, 0)  
  15: sample_indexable(texture2d)(float,float,float,float) r1.xyzw, r0.zwzz, t1.xyzw, s1  
  16: dp4 r1.x, r1.xyzw, r2.xyzw  
  17: add r2.xyzw, r0.zwzw, l(0.003906, 0.000000, -0.003906, 0.000000)  
  18: add r0.xyzw, r0.xyzw, l(0.000000, 0.003906, 0.000000, -0.003906)  
  19: sample_indexable(texture2d)(float,float,float,float) r1.yz, r2.xyxx, t1.zxyw, s1  
  20: sample_indexable(texture2d)(float,float,float,float) r2.xy, r2.zwzz, t1.xyzw, s1  
  21: add r1.yz, r1.yyzy, -r2.xxyx  
  22: sample_indexable(texture2d)(float,float,float,float) r0.xy, r0.xyxx, t1.xyzw, s1  
  23: sample_indexable(texture2d)(float,float,float,float) r0.zw, r0.zwzz, t1.zwxy, s1  
  24: add r0.xy, -r0.zwzz, r0.xyxx  
  25: max r0.xy, abs(r0.xyxx), abs(r1.yzyy)  
  26: min r0.xy, r0.xyxx, l(1.000000, 1.000000, 0.000000, 0.000000)  
  27: mul r0.xy, r0.xyxx, r1.xxxx  
  28: sample_indexable(texture2d)(float,float,float,float) r0.zw, v2.xyxx, t0.zwxy, s0  
  29: mad r0.w, r1.x, l(0.150000), r0.w  
  30: mad r0.x, r0.x, l(0.350000), r0.w  
  31: mad r0.x, r0.y, l(0.350000), r0.x  
  32: mul r0.yw, cb3[0].zzzw, l(0.000000, 300.000000, 0.000000, 300.000000)  
  33: mad r0.yw, v2.xxxy, l(0.000000, 150.000000, 0.000000, 150.000000), r0.yyyw  
  34: ftoi r0.yw, r0.yyyw  
  35: bfrev r0.w, r0.w  
  36: iadd r0.y, r0.w, r0.y  
  37: ishr r0.w, r0.y, l(13)  
  38: xor r0.y, r0.y, r0.w  
  39: imul null, r0.w, r0.y, r0.y  
  40: imad r0.w, r0.w, l(0x0000ec4d), l(0.0000000000000000000000000000000000001)  
  41: imad r0.y, r0.y, r0.w, l(146956042240.000000)  
  42: and r0.y, r0.y, l(0x7fffffff)  
  43: itof r0.y, r0.y  
  44: mad r0.y, r0.y, l(0.000000001), l(0.650000)  
  45: add_sat r1.xyzw, v2.xyxy, l(0.001953, 0.000000, -0.001953, 0.000000)  
  46: sample_indexable(texture2d)(float,float,float,float) r0.w, r1.xyxx, t0.yzwx, s0  
  47: sample_indexable(texture2d)(float,float,float,float) r1.x, r1.zwzz, t0.xyzw, s0  
  48: add r0.w, r0.w, r1.x  
  49: add_sat r1.xyzw, v2.xyxy, l(0.000000, 0.001953, 0.000000, -0.001953)  
  50: sample_indexable(texture2d)(float,float,float,float) r1.x, r1.xyxx, t0.xyzw, s0  
  51: sample_indexable(texture2d)(float,float,float,float) r1.y, r1.zwzz, t0.yxzw, s0  
  52: add r0.w, r0.w, r1.x  
  53: add r0.w, r1.y, r0.w  
  54: mad r0.w, r0.w, l(0.250000), -r0.z  
  55: mul r0.w, r0.y, r0.w  
  56: mul r0.y, r0.y, r0.z  
  57: mad r0.x, r0.w, l(0.900000), r0.x  
  58: mad r0.y, r0.y, l(-0.240000), r0.x  
  59: add r0.x, r0.y, r0.z  
  60: mov_sat r0.z, cb3[0].x  
  61: log r0.z, r0.z  
  62: mul r0.z, r0.z, l(100.000000)  
  63: exp r0.z, r0.z  
  64: mad r0.z, r0.z, l(0.160000), l(0.700000)  
  65: mul o0.xy, r0.zzzz, r0.xyxx  
  66: mov o0.zw, l(0, 0, 0, 0)  
  67: ret

Как видите, выходная карта контуров разделена на четыре равных квадрата, и это первое, что нам нужно изучить:

   0: add r0.xyzw, v2.xyxy, v2.xyxy  
   1: round_ni r1.xy, r0.zwzz  
   2: frc r0.xyzw, r0.xyzw  
   3: add r1.zw, r1.xxxy, l(0.000000, 0.000000, -1.000000, -1.000000)  
   4: dp2 r1.z, r1.zwzz, r1.zwzz  
   5: add r1.z, -r1.z, l(1.000000)  
   6: max r2.w, r1.z, l(0)  
   7: dp2 r1.z, r1.xyxx, r1.xyxx  
   8: add r3.xyzw, r1.xyxy, l(-1.000000, -0.000000, -0.000000, -1.000000)  
   9: add r1.x, -r1.z, l(1.000000)  
  10: max r2.x, r1.x, l(0)  
  11: dp2 r1.x, r3.xyxx, r3.xyxx  
  12: dp2 r1.y, r3.zwzz, r3.zwzz  
  13: add r1.xy, -r1.xyxx, l(1.000000, 1.000000, 0.000000, 0.000000)  
  14: max r2.yz, r1.xxyx, l(0, 0, 0, 0)

Мы начинаем с вычисления floor( TextureUV * 2.0 ), что даёт нам следующее:


Для определения отдельных квадратов используется небольшая функция:

 float getParams(float2 uv)  
 {  
      float d = dot(uv, uv);  
      d = 1.0 - d;  
      d = max( d, 0.0 );  
   
      return d;  
 }

Заметьте, что функция возвращает 1.0 при входных данных float2(0.0, 0.0).

Этот случай возникает в левом верхнем углу. Чтобы получить ту же ситуацию в верхнем правом углу, нужно вычесть из округлённых texcoords float2(1, 0), для зелёного квадрата вычесть float2(0, 1), а для жёлтого — float2(1.0, 1.0).

Итак:

   float2 flooredTextureUV = floor( 2.0 * TextureUV );  
   ...
     
   float2 uv1 = flooredTextureUV;  
   float2 uv2 = flooredTextureUV + float2(-1.0, -0.0);   
   float2 uv3 = flooredTextureUV + float2( -0.0, -1.0);  
   float2 uv4 = flooredTextureUV + float2(-1.0, -1.0);  
   
   float4 mask;  
   mask.x = getParams( uv1 );  
   mask.y = getParams( uv2 );  
   mask.z = getParams( uv3 );  
   mask.w = getParams( uv4 );

Каждый из компонентов mask равен или нулю, или единице, и ответственен за один квадрат текстуры. Например, mask.r и mask.w:


mask.r


mask.w

Мы получили mask, давайте двигаться дальше. Строка 15 сэмплирует карту яркости. Учтите, что текстура яркости имеет формат R11G11B10_FLOAT, хотя мы сэмплируем все компоненты rgba. В этой ситуации подразумевается, что .a равно 1.0f.

Используемые для этой операции Texcoords можно вычислить как frac( TextureUV * 2.0 ). Поэтому результат этой операции может, например, выглядеть вот так:


Видите сходство?

Следующий этап очень умён — выполняется четырёхкомпонентное скалярное произведение (dp4):

  16: dp4 r1.x, r1.xyzw, r2.xyzw

Таким образом в верхнем левом углу остаётся только красный канал (то есть только интересные объекты), в верхнем правом — только зелёный канал (только следы), а в нижнем правом — всё (потому что компоненту яркости .w косвенно присвоено значение 1.0). Великолепная идея. Результат скалярного произведения выглядит так:


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

Вот что происходит: мы сэмплируем четыре тексела рядом с текущим обрабатываемым текселом (важно: в этом случае размер тексела равен 1.0/256.0!) и вычисляем максимальные абсолютные разности для красного и зелёного каналов:

   float fTexel = 1.0 / 256;  
     
   float2 sampling1 = TextureUV + float2( fTexel, 0 );  
   float2 sampling2 = TextureUV + float2( -fTexel, 0 );  
   float2 sampling3 = TextureUV + float2( 0, fTexel );  
   float2 sampling4 = TextureUV + float2( 0, -fTexel );  
     
   float2 intensity_x0 = texIntensityMap.Sample( sampler1, sampling1 ).xy;  
   float2 intensity_x1 = texIntensityMap.Sample( sampler1, sampling2 ).xy;  
   float2 intensity_diff_x = intensity_x0 - intensity_x1;  
     
   float2 intensity_y0 = texIntensityMap.Sample( sampler1, sampling3 ).xy;  
   float2 intensity_y1 = texIntensityMap.Sample( sampler1, sampling4 ).xy;  
   float2 intensity_diff_y = intensity_y0 - intensity_y1;  
     
   float2 maxAbsDifference = max( abs(intensity_diff_x), abs(intensity_diff_y) );  
   maxAbsDifference = saturate(maxAbsDifference);

Теперь если мы перемножим filter на maxAbsDifference


Очень просто и эффективно.

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

Затем, чтобы получить „призрачный“ эффект, мы берём часть параметров, вычисленных на текущем проходе, и значения из карты контуров.

Поздоровайтесь с нашим старым другом — целочисленным шумом. Он присутствует и здесь. Параметры анимации (cb3[0].zw) берутся из буфера констант и со временем изменяются.

   float2 outlines = masterFilter * maxAbsDifference;  
     
   // Sample outline map  
   float2 outlineMap = texOutlineMap.Sample( samplerLinearWrap, uv ).xy;  
     
   // I guess it's related with ghosting   
   float paramOutline = masterFilter*0.15 + outlineMap.y;  
   paramOutline += 0.35 * outlines.r;  
   paramOutline += 0.35 * outlines.g;  
     
   // input for integer noise  
   float2 noiseWeights = cb3_v0.zw;
   float2 noiseInputs = 150.0*uv + 300.0*noiseWeights;  
   int2 iNoiseInputs = (int2) noiseInputs;  
     
   float noise0 = clamp( integerNoise( iNoiseInputs.x + reversebits(iNoiseInputs.y) ), -1, 1 ) + 0.65; // r0.y

Примечание: если вы захотите реализовать ведьмачье чутьё самостоятельно, то рекомендую ограничить целочисленный шум интервалом [-1;1] (как и сказано на его веб-сайте). В оригинальном шейдере TW3 ограничения не было, но без него я получал ужасные артефакты и вся карта контуров была нестабильной.

Затем мы сэмплируем карту контуров тем же способом, что и карту яркости ранее (на этот раз тексел имеет размер 1.0/512.0), и вычисляем среднее значение компонента .x:

  // sampling of outline map  
   fTexel = 1.0 / 512.0;  
     
   sampling1 = saturate( uv + float2( fTexel, 0 ) );  
   sampling2 = saturate( uv + float2( -fTexel, 0 ) );  
   sampling3 = saturate( uv + float2( 0, fTexel ) );  
   sampling4 = saturate( uv + float2( 0, -fTexel ) );  
     
   float outline_x0 = texOutlineMap.Sample( sampler0, sampling1 ).x;  
   float outline_x1 = texOutlineMap.Sample( sampler0, sampling2 ).x;  
   float outline_y0 = texOutlineMap.Sample( sampler0, sampling3 ).x;  
   float outline_y1 = texOutlineMap.Sample( sampler0, sampling4 ).x;  
   float averageOutline = (outline_x0+outline_x1+outline_y0+outline_y1) / 4.0;

Затем, судя по ассемблерному коду, вычисляется разность между средним и значением этого конкретного пикселя, после чего выполняется искажение целочисленным шумом:

   // perturb with noise  
   float frameOutlineDifference = averageOutline - outlineMap.x;  
   frameOutlineDifference *= noise0;

Следующим шагом будет искажение значения из „старой“ карты контуров с помощью шума — это основная линия, придающая выходной текстуре ощущение блочности.

Дальше идут другие вычисления, после чего, в самом конце, вычисляется „затухание“.

   // the main place with gives blocky look of texture  
   float newNoise = outlineMap.x * noise0;  
     
   float newOutline = frameOutlineDifference * 0.9 + paramOutline;  
   newOutline -= 0.24*newNoise;  
     
   // 59: add r0.x, r0.y, r0.z  
   float2 finalOutline = float2( outlineMap.x + newOutline, newOutline);  
     
   // * calculate damping  
   float dampingParam = saturate( cb3_v0.x );  
   dampingParam = pow( dampingParam, 100 );    
     
   float damping = 0.7 + 0.16*dampingParam;  
   
   
   // * final multiplication  
   float2 finalColor = finalOutline * damping;  
   return float4(finalColor, 0, 0);

Вот небольшое видео, демонстрирующее в действии карту контуров:


Если вам интересен полный пиксельный шейдер, то он выложен здесь. Шейдер совместим с RenderDoc.

Интересно (и, если честно, слегка раздражает) то, что несмотря на идентичность ассемблерного кода с оригинальным шейдером из Witcher 3, окончательный внешний вид карты контуров в RenderDoc меняется!

Примечание: в последнем проходе (см. следующую часть) вы увидите, что используется только канал .r карты контуров. Зачем же тогда нам нужен канал .g? Думаю, что это какой-то буфер „пинг-понга“ в одной текстуре — заметьте, что .r содержит канал .g + какое-то новое значение.

Часть 5: Ведьмачье чутьё (»рыбий глаз" и окончательный результат)


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

Мы подошли к последнему этапу. Всё это нужно объединить! Последний проход — это полноэкранный четырёхугольник. Входные данные: буфер цветов, карта контуров и карта яркости.

До:



После:


Ещё раз покажу видео с применённым эффектом:


Как видите, кроме наложения контуров на объекты, которые может увидеть или услышать Геральт, ко всему экрану применяется эффект «рыбьего глаза», и весь экран (особенно углы) становится сероватым, чтобы передать ощущение реального охотника за чудовищами.

Полный ассемблерный код пиксельного шейдера:

 ps_5_0  
    dcl_globalFlags refactoringAllowed  
    dcl_constantbuffer cb0[3], immediateIndexed  
    dcl_constantbuffer cb3[7], immediateIndexed  
    dcl_sampler s0, mode_default  
    dcl_sampler s2, mode_default  
    dcl_resource_texture2d (float,float,float,float) t0  
    dcl_resource_texture2d (float,float,float,float) t2  
    dcl_resource_texture2d (float,float,float,float) t3  
    dcl_input_ps_siv v0.xy, position  
    dcl_output o0.xyzw  
    dcl_temps 7  
   0: div r0.xy, v0.xyxx, cb0[2].xyxx  
   1: mad r0.zw, r0.xxxy, l(0.000000, 0.000000, 2.000000, 2.000000), l(0.000000, 0.000000, -1.000000, -1.000000)  
   2: mov r1.yz, abs(r0.zzwz)  
   3: div r0.z, cb0[2].x, cb0[2].y  
   4: mul r1.x, r0.z, r1.y  
   5: add r0.zw, r1.xxxz, -cb3[2].xxxy  
   6: mul_sat r0.zw, r0.zzzw, l(0.000000, 0.000000, 0.555556, 0.555556)  
   7: log r0.zw, r0.zzzw  
   8: mul r0.zw, r0.zzzw, l(0.000000, 0.000000, 2.500000, 2.500000)  
   9: exp r0.zw, r0.zzzw  
  10: dp2 r0.z, r0.zwzz, r0.zwzz  
  11: sqrt r0.z, r0.z  
  12: min r0.z, r0.z, l(1.000000)  
  13: add r0.z, -r0.z, l(1.000000)  
  14: mov_sat r0.w, cb3[6].x  
  15: add_sat r1.xy, -r0.xyxx, l(0.030000, 0.030000, 0.000000, 0.000000)  
  16: add r1.x, r1.y, r1.x  
  17: add_sat r0.xy, r0.xyxx, l(-0.970000, -0.970000, 0.000000, 0.000000)  
  18: add r0.x, r0.x, r1.x  
  19: add r0.x, r0.y, r0.x  
  20: mul r0.x, r0.x, l(20.000000)  
  21: min r0.x, r0.x, l(1.000000)  
  22: add r1.xy, v0.xyxx, v0.xyxx  
  23: div r1.xy, r1.xyxx, cb0[2].xyxx  
  24: add r1.xy, r1.xyxx, l(-1.000000, -1.000000, 0.000000, 0.000000)  
  25: dp2 r0.y, r1.xyxx, r1.xyxx  
  26: mul r1.xy, r0.yyyy, r1.xyxx  
  27: mul r0.y, r0.w, l(0.100000)  
  28: mul r1.xy, r0.yyyy, r1.xyxx  
  29: max r1.xy, r1.xyxx, l(-0.400000, -0.400000, 0.000000, 0.000000)  
  30: min r1.xy, r1.xyxx, l(0.400000, 0.400000, 0.000000, 0.000000)  
  31: mul r1.xy, r1.xyxx, cb3[1].xxxx  
  32: mul r1.zw, r1.xxxy, cb0[2].zzzw  
  33: mad r1.zw, v0.xxxy, cb0[1].zzzw, -r1.zzzw  
  34: sample_indexable(texture2d)(float,float,float,float) r2.xyz, r1.zwzz, t0.xyzw, s0  
  35: mul r3.xy, r1.zwzz, l(0.500000, 0.500000, 0.000000, 0.000000)  
  36: sample_indexable(texture2d)(float,float,float,float) r0.y, r3.xyxx, t2.yxzw, s2  
  37: mad r3.xy, r1.zwzz, l(0.500000, 0.500000, 0.000000, 0.000000), l(0.500000, 0.000000, 0.000000, 0.000000)  
  38: sample_indexable(texture2d)(float,float,float,float) r2.w, r3.xyxx, t2.yzwx, s2  
  39: mul r2.w, r2.w, l(0.125000)  
  40: mul r3.x, cb0[0].x, l(0.100000)  
  41: add r0.x, -r0.x, l(1.000000)  
  42: mul r0.xy, r0.xyxx, l(0.030000, 0.125000, 0.000000, 0.000000)  
  43: mov r3.yzw, l(0, 0, 0, 0)  
  44: mov r4.x, r0.y  
  45: mov r4.y, r2.w  
  46: mov r4.z, l(0)  
  47: loop  
  48:  ige r4.w, r4.z, l(8)  
  49:  breakc_nz r4.w  
  50:  itof r4.w, r4.z  
  51:  mad r4.w, r4.w, l(0.785375), -r3.x  
  52:  sincos r5.x, r6.x, r4.w  
  53:  mov r6.y, r5.x  
  54:  mul r5.xy, r0.xxxx, r6.xyxx  
  55:  mad r5.zw, r5.xxxy, l(0.000000, 0.000000, 0.125000, 0.125000), r1.zzzw  
  56:  mul r6.xy, r5.zwzz, l(0.500000, 0.500000, 0.000000, 0.000000)  
  57:  sample_indexable(texture2d)(float,float,float,float) r4.w, r6.xyxx, t2.yzwx, s2  
  58:  mad r4.x, r4.w, l(0.125000), r4.x  
  59:  mad r5.zw, r5.zzzw, l(0.000000, 0.000000, 0.500000, 0.500000), l(0.000000, 0.000000, 0.500000, 0.000000)  
  60:  sample_indexable(texture2d)(float,float,float,float) r4.w, r5.zwzz, t2.yzwx, s2  
  61:  mad r4.y, r4.w, l(0.125000), r4.y  
  62:  mad r5.xy, r5.xyxx, r1.xyxx, r1.zwzz  
  63:  sample_indexable(texture2d)(float,float,float,float) r5.xyz, r5.xyxx, t0.xyzw, s0  
  64:  mad r3.yzw, r5.xxyz, l(0.000000, 0.125000, 0.125000, 0.125000), r3.yyzw  
  65:  iadd r4.z, r4.z, l(1)  
  66: endloop  
  67: sample_indexable(texture2d)(float,float,float,float) r0.xy, r1.zwzz, t3.xyzw, s0  
  68: mad_sat r0.xy, -r0.xyxx, l(0.800000, 0.750000, 0.000000, 0.000000), r4.xyxx  
  69: dp3 r1.x, r3.yzwy, l(0.300000, 0.300000, 0.300000, 0.000000)  
  70: add r1.yzw, -r1.xxxx, r3.yyzw  
  71: mad r1.xyz, r0.zzzz, r1.yzwy, r1.xxxx  
  72: mad r1.xyz, r1.xyzx, l(0.600000, 0.600000, 0.600000, 0.000000), -r2.xyzx  
  73: mad r1.xyz, r0.wwww, r1.xyzx, r2.xyzx  
  74: mul r0.yzw, r0.yyyy, cb3[4].xxyz  
  75: mul r2.xyz, r0.xxxx, cb3[5].xyzx  
  76: mad r0.xyz, r0.yzwy, l(1.200000, 1.200000, 1.200000, 0.000000), r2.xyzx  
  77: mov_sat r2.xyz, r0.xyzx  
  78: dp3_sat r0.x, r0.xyzx, l(1.000000, 1.000000, 1.000000, 0.000000)  
  79: add r0.yzw, -r1.xxyz, r2.xxyz  
  80: mad o0.xyz, r0.xxxx, r0.yzwy, r1.xyzx  
  81: mov o0.w, l(1.000000)  
  82: ret

82 строки — значит, нам предстоит много работы!

Для начала взглянем на входящие данные:

   // *** Inputs       
     
   // * Zoom amount, always 1  
   float zoomAmount = cb3_v1.x;  
     
   // Another value which affect fisheye effect  
   // but always set to float2(1.0, 1.0).  
   float2 amount = cb0_v2.zw;  
     
   // Elapsed time in seconds  
   float time = cb0_v0.x;  
     
   // Colors of witcher senses  
   float3 colorInteresting = cb3_v5.rgb;  
   float3 colorTraces = cb3_v4.rgb;  
     
   // Was always set to float2(0.0, 0.0).  
   // Setting this to higher values  
   // makes "grey corners" effect weaker.  
   float2 offset = cb3_v2.xy;  
     
   // Dimensions of fullscreen  
   float2 texSize = cb0_v2.xy;  
   float2 invTexSize = cb0_v1.zw;  
   
   // Main value which causes fisheye effect [0-1]  
   const float fisheyeAmount = saturate( cb3_v6.x );

Основное значение, ответственное за величину эффекта — это fisheyeAmount. Думаю, оно постепенно повышается с 0.0 до 1.0, когда Геральт начинает использовать своё чутьё. Остальные значения почти не меняются, но я подозреваю, что некоторые из них отличались бы, если бы пользователь отключил в опциях эффект fisheye (я это не проверял).

Первое, что здесь происходит — шейдер вычисляет маску, отвечающую за серые углы:

   0: div r0.xy, v0.xyxx, cb0[2].xyxx   
   1: mad r0.zw, r0.xxxy, l(0.000000, 0.000000, 2.000000, 2.000000), l(0.000000, 0.000000, -1.000000, -1.000000)   
   2: mov r1.yz, abs(r0.zzwz)   
   3: div r0.z, cb0[2].x, cb0[2].y   
   4: mul r1.x, r0.z, r1.y   
   5: add r0.zw, r1.xxxz, -cb3[2].xxxy   
   6: mul_sat r0.zw, r0.zzzw, l(0.000000, 0.000000, 0.555556, 0.555556)   
   7: log r0.zw, r0.zzzw   
   8: mul r0.zw, r0.zzzw, l(0.000000, 0.000000, 2.500000, 2.500000)   
   9: exp r0.zw, r0.zzzw   
  10: dp2 r0.z, r0.zwzz, r0.zwzz   
  11: sqrt r0.z, r0.z   
  12: min r0.z, r0.z, l(1.000000)   
  13: add r0.z, -r0.z, l(1.000000)

На HLSL мы можем записать это следующим образом:

   // Main uv  
   float2 uv = PosH.xy / texSize;  
     
   // Scale at first from [0-1] to [-1;1], then calculate abs  
   float2 uv3 = abs( uv * 2.0 - 1.0);   
        
   // Aspect ratio  
   float aspectRatio = texSize.x / texSize.y;  
        
   // * Mask used to make corners grey  
   float mask_gray_corners;  
   {  
     float2 newUv = float2( uv3.x * aspectRatio, uv3.y ) - offset;  
     newUv = saturate( newUv / 1.8 );  
     newUv = pow(newUv, 2.5);  
       
     mask_gray_corners = 1-min(1.0, length(newUv) );  
   }

Сначала вычисляется интервал [-1; 1] UV и их абсолютные значения. Затем имеет место хитрое «сжимание». Готовая маска выглядит следующим образом:


Позже я вернусь к этой маске.

Сейчас я намеренно пропущу несколько строк кода и внимательнее изучу код, отвечающий за эффект «зума».

  22: add r1.xy, v0.xyxx, v0.xyxx   
  23: div r1.xy, r1.xyxx, cb0[2].xyxx   
  24: add r1.xy, r1.xyxx, l(-1.000000, -1.000000, 0.000000, 0.000000)   
  25: dp2 r0.y, r1.xyxx, r1.xyxx   
  26: mul r1.xy, r0.yyyy, r1.xyxx   
  27: mul r0.y, r0.w, l(0.100000)   
  28: mul r1.xy, r0.yyyy, r1.xyxx   
  29: max r1.xy, r1.xyxx, l(-0.400000, -0.400000, 0.000000, 0.000000)   
  30: min r1.xy, r1.xyxx, l(0.400000, 0.400000, 0.000000, 0.000000)   
  31: mul r1.xy, r1.xyxx, cb3[1].xxxx   
  32: mul r1.zw, r1.xxxy, cb0[2].zzzw   
  33: mad r1.zw, v0.xxxy, cb0[1].zzzw, -r1.zzzw

Сначала вычисляются «удвоенные» координаты текстур и выполняется вычитание float2(1, 1):

   float2 uv4 = 2 * PosH.xy;  
   uv4 /= cb0_v2.xy;  
   uv4 -= float2(1.0, 1.0);

Такие texcoord можно визуализировать так:


Затем вычисляется скалярное произведение dot(uv4, uv4), что даёт нам маску:


которая используется для умножения на вышеупомянутые texcoords:


Важно: в верхнем левом углу (чёрные пиксели) значения отрицательны. Они отображаются чёрным (0.0) из-за ограниченной точности формата R11G11B10_FLOAT. У него нет знакового бита, поэтому в нём нельзя хранить отрицательные значения.

Затем вычисляется коэффициент затухания (как я говорил выше, fisheyeAmount изменяется от 0.0 до 1.0).

   float attenuation = fisheyeAmount * 0.1;  
   uv4 *= attenuation;

Затем выполняется ограничение (max/min) и одно умножение.

Таким образом вычисляется смещение. Для вычисления конечных uv, которые будут использоваться для сэмплирования текстуры цвета, мы просто выполняем вычитание:

float2 colorUV = mainUv — offset;

Выполняя сэмплирование входной текстуры цвета colorUV, мы получаем рядом с углами искажённое изображение:


Контуры


Следующий этап — сэмплирование карты контуров для нахождения контуров. Это довольно просто, сначала мы находим texcoords для сэмплирования контуров интересных объектов, а затем то же самое делаем для следов:

   // * Sample outline map  
        
   // interesting objects (upper left square)  
   float2 outlineUV = colorUV * 0.5;  
   float outlineInteresting = texture2.Sample( sampler2, outlineUV ).x; // r0.y  
        
   // traces (upper right square)  
   outlineUV = colorUV * 0.5 + float2(0.5, 0.0);  
   float outlineTraces = texture2.Sample( sampler2, outlineUV ).x; // r2.w  
        
   outlineInteresting /= 8.0; // r4.x  
   outlineTraces /= 8.0; // r4.y


Интересные объекты из карты контуров


Следы из карты контуров

Стоит заметить, что мы сэмплируем из карты контуров только канал .x и учитываем только верхние квадраты.

Движение


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

Заметьте, что мы только разделили найденные контуры на 8.0.

Так как мы находимся в пространстве текстурных координат [0-1]2, то наличие круга радиусом 1 для обведения отдельного пикселя создаст неприемлемые артефакты:


Поэтому прежде чем двигаться дальше, давайте узнаем, как вычисляется этот радиус. Для этого нам нужно вернуться к пропущенным строкам 15-21. Небольшая проблем с вычислением этого радиуса заключается в том, что его вычисление разбросано по шейдеру (возможно, из-за оптимизаций шейдера компилятором). Поэтому вот первая часть (15-21) и вторая (41-42):

  15: add_sat r1.xy, -r0.xyxx, l(0.030000, 0.030000, 0.000000, 0.000000)  
  16: add r1.x, r1.y, r1.x  
  17: add_sat r0.xy, r0.xyxx, l(-0.970000, -0.970000, 0.000000, 0.000000)  
  18: add r0.x, r0.x, r1.x  
  19: add r0.x, r0.y, r0.x  
  20: mul r0.x, r0.x, l(20.000000)  
  21: min r0.x, r0.x, l(1.000000)  
  ...  
  41: add r0.x, -r0.x, l(1.000000)  
  42: mul r0.xy, r0.xyxx, l(0.030000, 0.125000, 0.000000, 0.000000)

Как видите, мы рассматриваем только текселы из [0.00 — 0.03] рядом с каждой поверхностью, суммируем их значения, умножаем 20 и насыщаем. Вот как они выглядят после строк 15-21:


А вот как после строки 41:


В строке 42 мы умножаем это на 0.03, это значение является радиусом круга для всего экрана. Как видите, ближе к краям экрана радиус становится меньше.

Теперь мы можем посмотреть на ассемблерный код, отвечающий за движение:

  40: mul r3.x, cb0[0].x, l(0.100000)  
  41: add r0.x, -r0.x, l(1.000000)  
  42: mul r0.xy, r0.xyxx, l(0.030000, 0.125000, 0.000000, 0.000000)  
  43: mov r3.yzw, l(0, 0, 0, 0)  
  44: mov r4.x, r0.y  
  45: mov r4.y, r2.w  
  46: mov r4.z, l(0)  
  47: loop  
  48:  ige r4.w, r4.z, l(8)  
  49:  breakc_nz r4.w  
  50:  itof r4.w, r4.z  
  51:  mad r4.w, r4.w, l(0.785375), -r3.x  
  52:  sincos r5.x, r6.x, r4.w  
  53:  mov r6.y, r5.x  
  54:  mul r5.xy, r0.xxxx, r6.xyxx  
  55:  mad r5.zw, r5.xxxy, l(0.000000, 0.000000, 0.125000, 0.125000), r1.zzzw  
  56:  mul r6.xy, r5.zwzz, l(0.500000, 0.500000, 0.000000, 0.000000)  
  57:  sample_indexable(texture2d)(float,float,float,float) r4.w, r6.xyxx, t2.yzwx, s2  
  58:  mad r4.x, r4.w, l(0.125000), r4.x  
  59:  mad r5.zw, r5.zzzw, l(0.000000, 0.000000, 0.500000, 0.500000), l(0.000000, 0.000000, 0.500000, 0.000000)  
  60:  sample_indexable(texture2d)(float,float,float,float) r4.w, r5.zwzz, t2.yzwx, s2  
  61:  mad r4.y, r4.w, l(0.125000), r4.y  
  62:  mad r5.xy, r5.xyxx, r1.xyxx, r1.zwzz  
  63:  sample_indexable(texture2d)(float,float,float,float) r5.xyz, r5.xyxx, t0.xyzw, s0  
  64:  mad r3.yzw, r5.xxyz, l(0.000000, 0.125000, 0.125000, 0.125000), r3.yyzw  
  65:  iadd r4.z, r4.z, l(1)  
  66: endloop

Давайте остановимся здесь на минуту. В строке 40 мы получаем временной коэффициент — просто elapsedTime * 0.1. В строке 43 у нас буфер для текстуры цвета, получаемой внутри цикла.

r0.x (строки 41-42) — это, как мы теперь знаем, радиус круга. r4.x (строка 44) — это контур интересных объектов, r4.y (строка 45) — контур следов (ранее разделённый на 8!), а r4.z (строка 46) — счётчик цикла.

Как можно ожидать, цикл имеет 8 итераций. Мы начинаем с вычисления угла в радианах i * PI_4, что даёт нам 2*PI — полный круг. Угол со временем искажается.

С помощью sincos мы определяем точку сэмплирования (единичный круг) и изменяем радиус с помощью умножения (строка 54).

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

   float timeParam = time * 0.1;  
     
   // adjust circle radius  
   circle_radius = 1.0 - circle_radius;  
   circle_radius *= 0.03;  
        
   float3 color_circle_main = float3(0.0, 0.0, 0.0);  
        
   [loop]  
   for (int i=0; 8 > i; i++)  
   {  
      // full 2*PI = 360 angles cycle  
      const float angleRadians = (float) i * PI_4 - timeParam;  
             
      // unit circle  
      float2 unitCircle;  
      sincos(angleRadians, unitCircle.y, unitCircle.x); // unitCircle.x = cos, unitCircle.y = sin  
             
      // adjust radius  
      unitCircle *= circle_radius;  
             
      // * base texcoords (circle) - note we also scale radius here by 8  
      // * probably because of dimensions of outline map.  
      // line 55  
      float2 uv_outline_base = colorUV + unitCircle / 8.0;  
                       
      // * interesting objects (circle)  
      float2 uv_outline_interesting_circle = uv_outline_base * 0.5;  
      float outline_interesting_circle = texture2.Sample( sampler2, uv_outline_interesting_circle ).x;  
      outlineInteresting += outline_interesting_circle / 8.0;  
             
      // * traces (circle)  
      float2 uv_outline_traces_circle = uv_outline_base * 0.5 + float2(0.5, 0.0);  
      float outline_traces_circle = texture2.Sample( sampler2, uv_outline_traces_circle ).x;  
      outlineTraces += outline_traces_circle / 8.0;

      // * sample color texture (zooming effect) with perturbation
      float2 uv_color_circle = colorUV + unitCircle * offsetUV;  
      float3 color_circle = texture0.Sample( sampler0, uv_color_circle ).rgb;
      color_circle_main += color_circle / 8.0;  
   }

Сэмплирование цвета выполнятся почти так же, но к базовому colorUV мы прибавляем смещение, умноженное на «единичный» круг.

Яркости


После цикла мы сэмплируем карту яркости и изменяем финальные значения яркости (потому что карта яркости ничего не знает о контурах):

  67: sample_indexable(texture2d)(float,float,float,float) r0.xy, r1.zwzz, t3.xyzw, s0  
  68: mad_sat r0.xy, -r0.xyxx, l(0.800000, 0.750000, 0.000000, 0.000000), r4.xyxx

Код на HLSL:

   // * Sample intensity map  
   float2 intensityMap = texture3.Sample( sampler0, colorUV ).xy;  
     
   float intensityInteresting = intensityMap.r;  
   float intensityTraces = intensityMap.g;  
        
   // * Adjust outlines  
   float mainOutlineInteresting = saturate( outlineInteresting - 0.8*intensityInteresting );  
   float mainOutlineTraces = saturate( outlineTraces - 0.75*intensityTraces );

Серые углы и финальное объединение всего


Серый цвет ближе к углам вычисляется с помощью скалярного произведения (ассемблерная строка 69):

   // * Greyish color  
   float3 color_greyish = dot( color_circle_main, float3(0.3, 0.3, 0.3) ).xxx;


Затем идут две интерполяции. Первая комбинирует серый цвет с «цветом в круге» при помощи описанной мной первой маски, поэтому углы становятся серыми. Кроме того, существует коэффициент 0.6, снижающий насыщенность финального изображения:


Вторая сочетает первый цвет с приведённым выше, используя fisheyeAmount. Это означает, что экран становится постепенно темнее (благодаря умножению на 0.6) и серее по углам! Гениально.

HLSL:

   // * Determine main color.  
   // (1) At first, combine "circled" color with gray one.  
   // Now we have have greyish corners here.  
   float3 mainColor = lerp( color_greyish, color_circle_main, mask_gray_corners ) * 0.6;  
     
   // (2) Then mix "regular" color with the above.  
   // Please note this operation makes corners gradually gray (because fisheyeAmount rises from 0 to 1)
   // and gradually darker (because of 0.6 multiplier).  
   mainColor = lerp( color, mainColor, fisheyeAmount );

Теперь мы можем перейти к добавлению контуров объектов.

Цвета (красный и жёлтый) берутся из буфера констант.

   // * Determine color of witcher senses  
   float3 senses_traces = mainOutlineTraces * colorTraces;  
   float3 senses_interesting = mainOutlineInteresting * colorInteresting;  
   float3 senses_total = 1.2 * senses_traces + senses_interesting;


Фух! Мы почти у финишной черты!

У нас есть окончательный цвет, есть цвет ведьмачьего чутья… осталось их каким-то образом скомбинировать!

И для этого не подойдёт простое сложение. Сначала мы вычисляем скалярное произведение:

  78: dp3_sat r0.x, r0.xyzx, l(1.000000, 1.000000, 1.000000, 0.000000)  
   
  float dot_senses_total = saturate( dot(senses_total, float3(1.0, 1.0, 1.0) ) );

которое выглядит вот так:


И эти значения в самом конце используются для интерполяции между цветом и (насыщенным) ведьмачьим чутьём:

  76: mad r0.xyz, r0.yzwy, l(1.200000, 1.200000, 1.200000, 0.000000), r2.xyzx  
  77: mov_sat r2.xyz, r0.xyzx  
  78: dp3_sat r0.x, r0.xyzx, l(1.000000, 1.000000, 1.000000, 0.000000)  
  79: add r0.yzw, -r1.xxyz, r2.xxyz  
  80: mad o0.xyz, r0.xxxx, r0.yzwy, r1.xyzx  
  81: mov o0.w, l(1.000000)  
  82: ret  
   
   float3 senses_total = 1.2 * senses_traces + senses_interesting;   
     
   // * Final combining  
   float3 senses_total_sat = saturate(senses_total);  
   float dot_senses_total = saturate( dot(senses_total, float3(1.0, 1.0, 1.0) ) );  
        
   float3 finalColor = lerp( mainColor, senses_total_sat, dot_senses_total );  
   return float4( finalColor, 1.0 );


И на этом всё.

Полный шейдер выложен здесь.

Сравнение моего (слева) и оригинального (справа) шейдеров:


Надеюсь, вам понравилась эта статья! В механиках «ведьмачьего чутья» есть множество блестящих идей, а готовый результат очень правдоподобен.

[Предыдущие части анализа: первая и вторая.]
  • +48
  • 23,6k
  • 2
Поддержать автора
Поделиться публикацией

Комментарии 2

    +1
    Поражаюсь усидчивости автора разбирать код шейдеров. Проделана отличная работа.
      0
      После прочтения подобных статей всегда жутко интересовал вопрос — что по специалистам? Насколько востребованы люди, которые могут создавать подобное? Насколько сильно за ними охотятся крупные игровые конторы?

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

      Самое читаемое