Реверс-инжиниринг рендеринга «Ведьмака 3»

в 8:45, , рубрики: tonemapping, ведьмак, виньетка, разработка игр, реверс-инжиниринг, хроматические аберрации

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

Реверс-инжиниринг рендеринга «Ведьмака 3» - 1

В этой статье я расскажу о решениях, использованных для рендеринга The Witcher 3. Она не будет такой всеобъемлющей, как анализ графики GTA V Адриана Корреже, по крайней мере, пока.

Мы начнём с реверс-инжиниринга тональной коррекции.

Часть 1: тональная коррекция

В большинстве современных AAA-игр одним из этапов рендеринга обязательно является тональная коррекция.

Напомню вам, что в реальной жизни существует довольно широкий диапазон яркости, в то время как у экранов компьютеров очень ограничен (8 бит на пиксель, что даёт нам 0-255). Именно здесь на помощь приходит тональная коррекция (tonemapping), позволяющая уместить в ограниченный интервал освещения более широкий. Обычно в этом процессе присутствуют два источника данных: HDR-изображение с плавающей запятой, значения цветов которого превышают 1.0, и средняя освещённость сцены (последнюю можно вычислить несколькими способами, даже с учётом адаптации глаза для имитации поведения человеческих глаз, но здесь это неважно).

Следующий (и последний) этап заключается в получении выдержки, вычислении цвета с выдержкой и его обработка с помощью кривой тональной коррекции. И здесь всё становится довольно запутанным, потому что появляются новые концепции, такие как «точка белого» (white point) и «средний серый цвет» (middle gray). Есть как минимум несколько популярных кривых, и некоторые из них рассматриваются в статье Мэтта Петтинео «A Closer Look at Tone Mapping».

Честно говоря, у меня всегда возникали проблемы с правильной реализацией тональной коррекции в собственном коде. В сети есть по крайней мере несколько различных примеров, которые оказались мне полезны… в какой-то степени. Некоторые из них учитывают HDR-яркость/точку белого/средний серый цвет, другие нет — поэтому они не особо помогают. Мне хотелось найти «проверенную в боях» реализацию.

Мы будем работать в RenderDoc с захватом этого кадра одного из основных квестов Новиграда. Все настройки поставлены на максимум:

Реверс-инжиниринг рендеринга «Ведьмака 3» - 2

Немного поискав, я нашёл вызов отрисовки для тональной коррекции! Как я упоминал выше, есть буфер HDR-цветов (текстура номер 0, полное разрешение) и средняя яркость сцены (текстура номер 1, 1x1, с плавающей точкой, вычисленная ранее compute-шейдером).

Реверс-инжиниринг рендеринга «Ведьмака 3» - 3

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

 ps_5_0  
    dcl_globalFlags refactoringAllowed  
    dcl_constantbuffer cb3[17], immediateIndexed  
    dcl_resource_texture2d (float,float,float,float) t0  
    dcl_resource_texture2d (float,float,float,float) t1  
    dcl_input_ps_siv v0.xy, position  
    dcl_output o0.xyzw  
    dcl_temps 4  
   0: ld_indexable(texture2d)(float,float,float,float) r0.x, l(0, 0, 0, 0), t1.xyzw  
   1: max r0.x, r0.x, cb3[4].y  
   2: min r0.x, r0.x, cb3[4].z  
   3: max r0.x, r0.x, l(0.000100)  
   4: mul r0.y, cb3[16].x, l(11.200000)  
   5: div r0.x, r0.x, r0.y  
   6: log r0.x, r0.x  
   7: mul r0.x, r0.x, cb3[16].z  
   8: exp r0.x, r0.x  
   9: mul r0.x, r0.y, r0.x  
  10: div r0.x, cb3[16].x, r0.x  
  11: ftou r1.xy, v0.xyxx  
  12: mov r1.zw, l(0, 0, 0, 0)  
  13: ld_indexable(texture2d)(float,float,float,float) r0.yzw, r1.xyzw, t0.wxyz  
  14: mul r0.xyz, r0.yzwy, r0.xxxx  
  15: mad r1.xyz, cb3[7].xxxx, r0.xyzx, cb3[7].yyyy  
  16: mul r2.xy, cb3[8].yzyy, cb3[8].xxxx  
  17: mad r1.xyz, r0.xyzx, r1.xyzx, r2.yyyy  
  18: mul r0.w, cb3[7].y, cb3[7].z  
  19: mad r3.xyz, cb3[7].xxxx, r0.xyzx, r0.wwww  
  20: mad r0.xyz, r0.xyzx, r3.xyzx, r2.xxxx  
  21: div r0.xyz, r0.xyzx, r1.xyzx  
  22: mad r0.w, cb3[7].x, l(11.200000), r0.w  
  23: mad r0.w, r0.w, l(11.200000), r2.x  
  24: div r1.x, cb3[8].y, cb3[8].z  
  25: add r0.xyz, r0.xyzx, -r1.xxxx  
  26: max r0.xyz, r0.xyzx, l(0, 0, 0, 0)  
  27: mul r0.xyz, r0.xyzx, cb3[16].yyyy  
  28: mad r1.y, cb3[7].x, l(11.200000), cb3[7].y  
  29: mad r1.y, r1.y, l(11.200000), r2.y  
  30: div r0.w, r0.w, r1.y  
  31: add r0.w, -r1.x, r0.w  
  32: max r0.w, r0.w, l(0)  
  33: div o0.xyz, r0.xyzx, r0.wwww  
  34: mov o0.w, l(1.000000)  
  35: ret

Здесь стоит заметить несколько моментов. Во-первых, загруженная яркость не обязательно должна равняться использованной, потому что она ограничивается (вызовы max/min) в пределах выбранных художниками значений (из буфера констант). Это удобно, потому что позволяет избежать слишком высокой или низкой выдержки сцены. Этот ход кажется довольно банальным, но раньше я никогда такого не делал. Во-вторых — тот, кто знаком с кривыми тональной коррекции, мгновенно узнают это значение «11.2», ведь по сути это значение точки белого из кривой тональной коррекции Uncharted2 Джона Хейбла.

Параметры A-F загружаются из cbuffer.

Так, у нас есть ещё три параметра: cb3_v16.x, cb3_v16.y, cb3_v16.z. Мы можем исследовать их значения:

Реверс-инжиниринг рендеринга «Ведьмака 3» - 4

Мои догадки:

Я считаю, что «x» — это некий «масштаб белого» или среднего серого цвета, потому что он умножается на 11.2 (строка 4), а после этого используется как числитель в вычислении настройки выдержки (строка 10).

«y» — я назвал его «множителем числителя u2», и скоро вы увидите, почему.

«z» — «параметр возведения в степень», потому что он используется в тройке log/mul/exp (по сути — при возведении в степень).

Но относитесь к этим названиям переменных с долей скептицизма!

Также:

cb3_v4.yz — значения min/max допустимой яркости,
cb3_v7.xyz — параметры A-C кривой Uncharted2,
cb3_v8.xyz — параметры D-F кривой Uncharted2.

Теперь приступим к сложному — напишем HLSL-шейдер, который даст нам точно такой же ассемблерный код.

Это может быть очень трудно, и чем длиннее шейдер, тем сложнее задача. К счастью, какое-то время назад я написал инструмент, позволяющий быстро просматривать hlsl->asm.

Леди и джентльмены… приветствуйте D3DShaderDisassembler!

Реверс-инжиниринг рендеринга «Ведьмака 3» - 5

Поэкспериментировав с кодом, я получил готовый HLSL тональной коррекции The Witcher 3:

 cbuffer cBuffer : register (b3)  
 {  
   float4 cb3_v0;  
   float4 cb3_v1;  
   float4 cb3_v2;  
   float4 cb3_v3;  
   float4 cb3_v4;  
   float4 cb3_v5;  
   float4 cb3_v6;  
   float4 cb3_v7;  
   float4 cb3_v8;  
   float4 cb3_v9;  
   float4 cb3_v10;  
   float4 cb3_v11;  
   float4 cb3_v12;  
   float4 cb3_v13;  
   float4 cb3_v14;  
   float4 cb3_v15;  
   float4 cb3_v16, cb3_v17;  
 }  
   
 Texture2D     TexHDRColor          : register (t0);  
 Texture2D     TexAvgLuminance     : register (t1);  
   
 struct VS_OUTPUT_POSTFX  
 {  
   float4 Position : SV_Position;  
 };  
   
 float3 U2Func( float A, float B, float C, float D, float E, float F, float3 x )  
 {  
      return ((x*(A*x+C*B)+D*E)/(x*(A*x+B)+D*F)) - E/F;  
 }  
   
 float3 ToneMapU2Func( float A, float B, float C, float D, float E, float F, float3 color, float numMultiplier )  
 {  
      float3 numerator =  U2Func( A, B, C, D, E, F, color );  
      numerator = max( numerator, 0 );  
      numerator.rgb *= numMultiplier;  
   
      float3 denominator = U2Func( A, B, C, D, E, F, 11.2 );  
      denominator = max( denominator, 0 );  
   
      return numerator / denominator;  
 }  
   
   
   
 float4 ToneMappingPS( VS_OUTPUT_POSTFX Input) : SV_Target0  
 {  
      float avgLuminance = TexAvgLuminance.Load( int3(0, 0, 0) );  
      avgLuminance = clamp( avgLuminance, cb3_v4.y, cb3_v4.z );  
      avgLuminance = max( avgLuminance, 1e-4 );  
   
      float scaledWhitePoint = cb3_v16.x * 11.2;  
   
      float luma = avgLuminance / scaledWhitePoint;  
      luma = pow( luma, cb3_v16.z );  
   
      luma = luma * scaledWhitePoint;  
      luma = cb3_v16.x / luma;  
   
      float3 HDRColor = TexHDRColor.Load( uint3(Input.Position.xy, 0) ).rgb;  
   
      float3 color = ToneMapU2Func( cb3_v7.x, cb3_v7.y, cb3_v7.z, cb3_v8.x, cb3_v8.y,   
         cb3_v8.z, luma*HDRColor, cb3_v16.y);  
   
      return float4(color, 1);  
 }

Скриншот из моей утилиты, чтобы это подтвердить:

Реверс-инжиниринг рендеринга «Ведьмака 3» - 6

Вуаля!

Полагаю, это достаточно точная реализация тональной коррекции TW3, по крайней мере, с точки зрения ассемблерного кода. Я уже применил его в своём фреймворке и он работает отлично!

Я сказал «достаточно», потому что понятия не имею, почему denominator в ToneMapU2Func становится максимальным при нуле. При делении на 0 должно ведь получаться undefined?

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

Реверс-инжиниринг рендеринга «Ведьмака 3» - 7

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

 ps_5_0  
    dcl_globalFlags refactoringAllowed  
    dcl_constantbuffer cb3[18], immediateIndexed  
    dcl_resource_texture2d (float,float,float,float) t0  
    dcl_resource_texture2d (float,float,float,float) t1  
    dcl_input_ps_siv v0.xy, position  
    dcl_output o0.xyzw  
    dcl_temps 5  
   0: ld_indexable(texture2d)(float,float,float,float) r0.x, l(0, 0, 0, 0), t1.xyzw  
   1: max r0.y, r0.x, cb3[9].y  
   2: max r0.x, r0.x, cb3[4].y  
   3: min r0.x, r0.x, cb3[4].z  
   4: min r0.y, r0.y, cb3[9].z  
   5: max r0.xy, r0.xyxx, l(0.000100, 0.000100, 0.000000, 0.000000)  
   6: mul r0.z, cb3[17].x, l(11.200000)  
   7: div r0.y, r0.y, r0.z  
   8: log r0.y, r0.y  
   9: mul r0.y, r0.y, cb3[17].z  
  10: exp r0.y, r0.y  
  11: mul r0.y, r0.z, r0.y  
  12: div r0.y, cb3[17].x, r0.y  
  13: ftou r1.xy, v0.xyxx  
  14: mov r1.zw, l(0, 0, 0, 0)  
  15: ld_indexable(texture2d)(float,float,float,float) r1.xyz, r1.xyzw, t0.xyzw  
  16: mul r0.yzw, r0.yyyy, r1.xxyz  
  17: mad r2.xyz, cb3[11].xxxx, r0.yzwy, cb3[11].yyyy  
  18: mul r3.xy, cb3[12].yzyy, cb3[12].xxxx  
  19: mad r2.xyz, r0.yzwy, r2.xyzx, r3.yyyy  
  20: mul r1.w, cb3[11].y, cb3[11].z  
  21: mad r4.xyz, cb3[11].xxxx, r0.yzwy, r1.wwww  
  22: mad r0.yzw, r0.yyzw, r4.xxyz, r3.xxxx  
  23: div r0.yzw, r0.yyzw, r2.xxyz  
  24: mad r1.w, cb3[11].x, l(11.200000), r1.w  
  25: mad r1.w, r1.w, l(11.200000), r3.x  
  26: div r2.x, cb3[12].y, cb3[12].z  
  27: add r0.yzw, r0.yyzw, -r2.xxxx  
  28: max r0.yzw, r0.yyzw, l(0, 0, 0, 0)  
  29: mul r0.yzw, r0.yyzw, cb3[17].yyyy  
  30: mad r2.y, cb3[11].x, l(11.200000), cb3[11].y  
  31: mad r2.y, r2.y, l(11.200000), r3.y  
  32: div r1.w, r1.w, r2.y  
  33: add r1.w, -r2.x, r1.w  
  34: max r1.w, r1.w, l(0)  
  35: div r0.yzw, r0.yyzw, r1.wwww  
  36: mul r1.w, cb3[16].x, l(11.200000)  
  37: div r0.x, r0.x, r1.w  
  38: log r0.x, r0.x  
  39: mul r0.x, r0.x, cb3[16].z  
  40: exp r0.x, r0.x  
  41: mul r0.x, r1.w, r0.x  
  42: div r0.x, cb3[16].x, r0.x  
  43: mul r1.xyz, r1.xyzx, r0.xxxx  
  44: mad r2.xyz, cb3[7].xxxx, r1.xyzx, cb3[7].yyyy  
  45: mul r3.xy, cb3[8].yzyy, cb3[8].xxxx  
  46: mad r2.xyz, r1.xyzx, r2.xyzx, r3.yyyy  
  47: mul r0.x, cb3[7].y, cb3[7].z  
  48: mad r4.xyz, cb3[7].xxxx, r1.xyzx, r0.xxxx  
  49: mad r1.xyz, r1.xyzx, r4.xyzx, r3.xxxx  
  50: div r1.xyz, r1.xyzx, r2.xyzx  
  51: mad r0.x, cb3[7].x, l(11.200000), r0.x  
  52: mad r0.x, r0.x, l(11.200000), r3.x  
  53: div r1.w, cb3[8].y, cb3[8].z  
  54: add r1.xyz, -r1.wwww, r1.xyzx  
  55: max r1.xyz, r1.xyzx, l(0, 0, 0, 0)  
  56: mul r1.xyz, r1.xyzx, cb3[16].yyyy  
  57: mad r2.x, cb3[7].x, l(11.200000), cb3[7].y  
  58: mad r2.x, r2.x, l(11.200000), r3.y  
  59: div r0.x, r0.x, r2.x  
  60: add r0.x, -r1.w, r0.x  
  61: max r0.x, r0.x, l(0)  
  62: div r1.xyz, r1.xyzx, r0.xxxx  
  63: add r0.xyz, r0.yzwy, -r1.xyzx  
  64: mad o0.xyz, cb3[13].xxxx, r0.xyzx, r1.xyzx  
  65: mov o0.w, l(1.000000)  
  66: ret

Поначалу код может выглядеть пугающе, но на самом деле не всё так плохо. После краткого анализа можно заметить, что тут есть два вызова функции Uncharted2 с различными наборами входных данных (A-F, min/max яркость...). Такого решения я раньше не встречал.

И HLSL:

 cbuffer cBuffer : register (b3)  
 {  
   float4 cb3_v0;  
   float4 cb3_v1;  
   float4 cb3_v2;  
   float4 cb3_v3;  
   float4 cb3_v4;  
   float4 cb3_v5;  
   float4 cb3_v6;  
   float4 cb3_v7;  
   float4 cb3_v8;  
   float4 cb3_v9;  
   float4 cb3_v10;  
   float4 cb3_v11;  
   float4 cb3_v12;  
   float4 cb3_v13;  
   float4 cb3_v14;  
   float4 cb3_v15;  
   float4 cb3_v16, cb3_v17;  
 }  
   
 Texture2D     TexHDRColor     : register (t0);  
 Texture2D     TexAvgLuminance     : register (t1);  
   
 float3 U2Func( float A, float B, float C, float D, float E, float F, float3 x )  
 {  
      return ((x*(A*x+C*B)+D*E)/(x*(A*x+B)+D*F)) - E/F;  
 }  
   
 float3 ToneMapU2Func( float A, float B, float C, float D, float E, float F, float3 color, float numMultiplier )  
 {  
      float3 numerator =  U2Func( A, B, C, D, E, F, color );  
      numerator = max( numerator, 0 );  
      numerator.rgb *= numMultiplier;  
   
      float3 denominator = U2Func( A, B, C, D, E, F, 11.2 );  
      denominator = max( denominator, 0 );  
   
      return numerator / denominator;  
 }  
   
 struct VS_OUTPUT_POSTFX  
 {  
   float4 Position : SV_Position;  
 };  
   
 float getExposure(float avgLuminance, float minLuminance, float maxLuminance, float middleGray, float powParam)  
 {  
      avgLuminance = clamp( avgLuminance, minLuminance, maxLuminance );  
      avgLuminance = max( avgLuminance, 1e-4 );  
   
      float scaledWhitePoint = middleGray * 11.2;  
   
      float luma = avgLuminance / scaledWhitePoint;  
      luma = pow( luma, powParam);  
   
      luma = luma * scaledWhitePoint;  
      float exposure = middleGray / luma;  
      return exposure;  
 }  
   
 float4 ToneMappingPS( VS_OUTPUT_POSTFX Input) : SV_Target0  
 {  
      float avgLuminance = TexAvgLuminance.Load( int3(0, 0, 0) );  
     
   
      float exposure1 = getExposure( avgLuminance, cb3_v9.y, cb3_v9.z, cb3_v17.x, cb3_v17.z);  
      float exposure2 = getExposure( avgLuminance, cb3_v4.y, cb3_v4.z, cb3_v16.x, cb3_v16.z);  
   
        
      float3 HDRColor = TexHDRColor.Load( uint3(Input.Position.xy, 0) ).rgb;  
   
      float3 color1 = ToneMapU2Func( cb3_v11.x, cb3_v11.y, cb3_v11.z, cb3_v12.x, cb3_v12.y,   
         cb3_v12.z, exposure1*HDRColor, cb3_v17.y);  
   
      float3 color2 = ToneMapU2Func( cb3_v7.x, cb3_v7.y, cb3_v7.z, cb3_v8.x, cb3_v8.y,   
         cb3_v8.z, exposure2*HDRColor, cb3_v16.y);  
      
      float3 finalColor = lerp( color2, color1, cb3_v13.x ); 
      return float4(finalColor, 1);  
 }

То есть по сути у нас есть два набора контрольных параметров, мы вычисляем два цвета с тональной коррекцией, а в конце интерполируем их. Умное решение!

Часть 2: адаптация глаза

Вторая часть будет гораздо проще.

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

Но постойте, что же такое адаптация глаза и зачем она нам нужна? Википедия знает о ней всё, но я объясню: представьте, что вы находитесь в тёмной комнате (вспомним Life is Strange) или в пещере, и выходите наружу, где светло. Например, основным источником освещения может быть солнце.

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

Это изменение не происходит мгновенно. Глаз должен адаптироваться к изменениям яркости. Именно поэтому мы выполняем адаптацию глаза при рендеринге в реальном времени.

Хороший пример того, когда заметно отсутствие адаптации глаза — это HDRToneMappingCS11 из DirectX SDK. Резкие смены средней яркости довольно неприятны и неестественны.

Давайте приступим! Ради последовательности мы будем анализировать тот же кадр из Новиграда.

Реверс-инжиниринг рендеринга «Ведьмака 3» - 8

Теперь мы углубимся в захват кадра программой RenderDoc. Адаптация глаза обычно выполняется прямо перед тональной коррекцией, и «Ведьмак 3» в этом не исключение.

Реверс-инжиниринг рендеринга «Ведьмака 3» - 9

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

Реверс-инжиниринг рендеринга «Ведьмака 3» - 10

У нас есть два источника входных данных — 2 текстуры, R32_FLOAT, 1x1 (один пиксель). texture0 содержит среднюю яркость сцены из предыдущего кадра. texture1 содержит среднюю яркость сцены из текущего кадра (вычисленную непосредственно перед этим compute-шейдером — я пометил это синим цветом).

Вполне ожидаемо, что есть одни выходные данные — R32_FLOAT, 1x1. Давайте посмотрим на пиксельный шейдер.

 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_output o0.xyzw  
    dcl_temps 1  
   0: sample_l(texture2d)(float,float,float,float) r0.x, l(0, 0, 0, 0), t1.xyzw, s1, l(0)  
   1: sample_l(texture2d)(float,float,float,float) r0.y, l(0, 0, 0, 0), t0.yxzw, s0, l(0)  
   2: ge r0.z, r0.y, r0.x  
   3: add r0.x, -r0.y, r0.x  
   4: movc r0.z, r0.z, cb3[0].x, cb3[0].y  
   5: mad o0.xyzw, r0.zzzz, r0.xxxx, r0.yyyy  
   6: ret

Ого, какой простой! Всего 7 строк ассемблерного кода. Что здесь происходит? Объясню каждую строку:

0) Получаем среднюю яркость текущего кадра.
1) Получаем среднюю яркость предыдущего кадра.
2) Выполняем проверку: текущая яркость меньше или равна яркости предыдущего кадра?
Если да, то яркость снижается, если нет, то яркость увеличивается.
3) Вычисляем разность: difference = currentLum — previousLum.
4) Эта условная пересылка (movc) назначает коэффициент скорости из буфера констант. В зависимости от результата проверки из строки 2 может быть назначено два различных значения. Это умный ход, ведь так можно получить разные скорости адаптации и для снижения, и для повышения яркости. Но в исследуемом кадре оба значения одинаковы и изменяются в пределах от 0.11 до 0.3.
5) Окончательное вычисление адаптированной яркости: adaptedLuminance = speedFactor * difference + previousLuminance.
6) Конец шейдера

Это реализуется в HLSL довольно просто:

 // The Witcher 3 eye adaptation shader  
   
 cbuffer cBuffer : register (b3)  
 {  
   float4 cb3_v0;  
 }
  
 struct VS_OUTPUT_POSTFX  
 {  
   float4 Position                                             : SV_Position;  
 };  
  
 SamplerState samplerPointClamp : register (s0);  
 SamplerState samplerPointClamp2 : register (s1);  
   
 Texture2D TexPreviousAvgLuminance  : register (t0);  
 Texture2D TexCurrentAvgLuminance  : register (t1);  
   
 float4 TW3_EyeAdaptationPS(VS_OUTPUT_POSTFX Input) : SV_TARGET  
 {  
   // Get current and previous luminance.  
   float currentAvgLuminance = TexCurrentAvgLuminance.SampleLevel( samplerPointClamp2, float2(0.0, 0.0), 0 );  
   float previousAvgLuminance = TexPreviousAvgLuminance.SampleLevel( samplerPointClamp, float2(0.0, 0.0), 0 );  
     
   // Difference between current and previous luminance.  
   float difference = currentAvgLuminance - previousAvgLuminance;  
   
   // Scale factor. Can be different for both falling down and rising up of luminance.  
   // It affects speed of adaptation.  
   // Small conditional test is performed here, so different speed can be set differently for both these cases.  
   float adaptationSpeedFactor = (currentAvgLuminance <= previousAvgLuminance) ? cb3_v0.x : cb3_v0.y;  
   
   // Calculate adapted luminance.  
   float adaptedLuminance = adaptationSpeedFactor * difference + previousAvgLuminance;  
   return adaptedLuminance;  
 }

Эти строки дают нам такой же ассемблерный код. Я бы только предложил заменить тип выводимых данных с float4 на float. Нет нужды в бессмысленной трате полосы пропускания. Вот так в Witcher 3 реализована адаптация глаза. Довольно просто, правда?

PS. Огромное спасибо Балдуру Карлссону (Twitter: @baldurk ) за RenderDoc. Программа просто отличная.

Часть 3: хроматическая аберрация

Хроматическая аберрация — это эффект, в основном встречающийся у дешёвых объективов. Он возникает, потому что объективы имеют разный коэффициент преломления для разных длин видимого света. В результате него появляется видимое искажение. Однако оно нравится не всем. К счастью, в Witcher 3 этот эффект очень малозаметен, а потому не раздражает при игровом процессе (меня, по крайней мере). Но при желании его можно отключить.

Давайте внимательно посмотрим на пример сцены с хроматической аберрацией и без неё:

Реверс-инжиниринг рендеринга «Ведьмака 3» - 11

Хроматическая аберрация включена

Реверс-инжиниринг рендеринга «Ведьмака 3» - 12

Хроматическая аберрация отключена

Вы замечаете какие-нибудь отличия рядом с краями? Я тоже нет. Давайте попробуем другую сцену:

Реверс-инжиниринг рендеринга «Ведьмака 3» - 13

Хроматическая аберрация включена. Заметьте небольшое «красное» искажение в обозначенной области.

Ага, намного лучше! Здесь контраст между тёмными и светлыми областями сильнее, и в углу мы видим небольшое искажение. Как видно, этот эффект очень слаб. Тем не менее, мне было интересно, как он реализован. Давайте перейдём к самой любопытной части: к коду!

Реализация

Первое, что нужно сделать — это найти нужный вызов отрисовки с пиксельным шейдером. На самом деле хроматическая аберрация является частью большого пиксельного шейдера «финальной постобработки», которая состоит из хроматической аберрации, виньетирования и гамма-коррекции. Всё это находится внутри одного пиксельного шейдера. Давайте внимательнее приглядимся к ассемблерному коду пиксельного шейдера:

 ps_5_0  
    dcl_globalFlags refactoringAllowed  
    dcl_constantbuffer cb3[18], immediateIndexed  
    dcl_sampler s1, mode_default  
    dcl_resource_texture2d (float,float,float,float) t0  
    dcl_input_ps_siv v0.xy, position  
    dcl_input_ps linear v1.zw  
    dcl_output o0.xyzw  
    dcl_temps 4  
   0: mul r0.xy, v0.xyxx, cb3[17].zwzz  
   1: mad r0.zw, v0.xxxy, cb3[17].zzzw, -cb3[17].xxxy  
   2: div r0.zw, r0.zzzw, cb3[17].xxxy  
   3: dp2 r1.x, r0.zwzz, r0.zwzz  
   4: sqrt r1.x, r1.x  
   5: add r1.y, r1.x, -cb3[16].y  
   6: mul_sat r1.y, r1.y, cb3[16].z  
   7: sample_l(texture2d)(float,float,float,float) r2.xyz, r0.xyxx, t0.xyzw, s1, l(0)  
   8: lt r1.z, l(0), r1.y  
   9: if_nz r1.z  
  10:  mul r1.y, r1.y, r1.y  
  11:  mul r1.y, r1.y, cb3[16].x  
  12:  max r1.x, r1.x, l(0.000100)  
  13:  div r1.x, r1.y, r1.x  
  14:  mul r0.zw, r0.zzzw, r1.xxxx  
  15:  mul r0.zw, r0.zzzw, cb3[17].zzzw  
  16:  mad r0.xy, -r0.zwzz, l(2.000000, 2.000000, 0.000000, 0.000000), r0.xyxx  
  17:  sample_l(texture2d)(float,float,float,float) r2.x, r0.xyxx, t0.xyzw, s1, l(0)  
  18:  mad r0.xy, v0.xyxx, cb3[17].zwzz, -r0.zwzz  
  19:  sample_l(texture2d)(float,float,float,float) r2.y, r0.xyxx, t0.xyzw, s1, l(0)  
  20: endif  
 ...

И к значениям cbuffer:

Реверс-инжиниринг рендеринга «Ведьмака 3» - 14

Так, попробуем понять, что здесь происходит. По сути, cb3_v17.xy является центром хроматической аберрации, поэтому первые строки вычисляют 2d-вектор из координат текселов (cb3_v17.zw = величина, обратная размеру вьюпорта) до «центра хроматической аберрации» и его длину, затем выполняет другие вычисления, проверку и ветвление. При применении хроматической аберрации мы вычисляем смещения с использованием неких значений из буфера констант и искажаем каналы R и G. В общем случае, чем ближе к краям экрана, тем сильнее эффект. Довольно интересна строка 10, потому что она заставляет пиксели «придвинуться ближе», особенно когда мы преувеличиваем аберрацию. С удовольствием поделюсь с вами моей реализацией эффекта. Как обычно, воспринимайте названия переменных с (солидной) долей скептицизма. И учтите, что эффект применяется до гамма-коррекции.

 void ChromaticAberration( float2 uv, inout float3 color )  
 {  
   // User-defined params  
   float2 chromaticAberrationCenter = float2(0.5, 0.5);  
   float chromaticAberrationCenterAvoidanceDistance = 0.2;  
   float fA = 1.25;  
   float fChromaticAbberationIntensity = 30;  
   float fChromaticAberrationDistortionSize = 0.75;  
   
   // Calculate vector  
   float2 chromaticAberrationOffset = uv - chromaticAberrationCenter;  
   chromaticAberrationOffset = chromaticAberrationOffset / chromaticAberrationCenter;  
     
   float chromaticAberrationOffsetLength = length(chromaticAberrationOffset);  
    
   // To avoid applying chromatic aberration in center, subtract small value from  
   // just calculated length.  
   float chromaticAberrationOffsetLengthFixed = chromaticAberrationOffsetLength - chromaticAberrationCenterAvoidanceDistance;  
   float chromaticAberrationTexel = saturate(chromaticAberrationOffsetLengthFixed * fA);  
   
   float fApplyChromaticAberration = (0.0 < chromaticAberrationTexel);  
   if (fApplyChromaticAberration)  
   {  
     chromaticAberrationTexel *= chromaticAberrationTexel;  
     chromaticAberrationTexel *= fChromaticAberrationDistortionSize;  
   
     chromaticAberrationOffsetLength = max(chromaticAberrationOffsetLength, 1e-4);  
       
     float fMultiplier = chromaticAberrationTexel / chromaticAberrationOffsetLength;  
   
     chromaticAberrationOffset *= fMultiplier;  
     chromaticAberrationOffset *= g_Viewport.zw;  
     chromaticAberrationOffset *= fChromaticAbberationIntensity;  
   
     float2 offsetUV = -chromaticAberrationOffset * 2 + uv;  
     color.r = TexColorBuffer.SampleLevel(samplerLinearClamp, offsetUV, 0).r;  
   
     offsetUV = uv - chromaticAberrationOffset;  
     color.g = TexColorBuffer.SampleLevel(samplerLinearClamp, offsetUV, 0).g;  
   }  
 }

Я добавил «fChromaticAberrationIntensity», чтобы увеличить размер смещения, а значит и силу эффекта, как следует из названия (TW3 = 1.0). Intensity = 40:

Реверс-инжиниринг рендеринга «Ведьмака 3» - 15

Вот и всё! Надеюсь, вам понравилась эта часть.

Часть 4: виньетирование

Виньетирование — один из самых распространённых эффектов постобработки, используемых в играх. Он популярен и в фотосъёмке. Немного затенённые углы могут создавать красивый эффект. Существует несколько типов виньетирования. Например, в Unreal Engine 4 используется естественный. Но вернёмся к The Witcher 3. Нажмите сюда, чтобы увидеть интерактивное сравнение кадров с виньетированием и без него. Сравнение взято из руководства NVIDIA по производительности The Witcher 3.

Реверс-инжиниринг рендеринга «Ведьмака 3» - 16

Скриншот из «Ведьмака 3» со включенным виньетированием.

Заметьте, что верхний левый угол (небо) не так затенён, как другие части изображения. Позже мы вернёмся к этому.

Подробности реализации

Во первых, существует незначительное различие между виньетированием, использованным в оригинальной версии «Ведьмака 3» (которая была выпущена 19 мая 2015 года) и в «Ведьмак 3: Кровь и вино». В первой «обратный градиент» вычисляется внутри пиксельного шейдера, а в последней он заранее вычисляется в 2D-текстуру размером 256x256:

Реверс-инжиниринг рендеринга «Ведьмака 3» - 17

Текстура 256x256, используемая как «обратный градиент» в дополнении «Кровь и вино».

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

 ...  
  44: log r0.xyz, r0.xyzx  
  45: mul r0.xyz, r0.xyzx, l(0.454545, 0.454545, 0.454545, 0.000000)  
  46: exp r0.xyz, r0.xyzx  
  47: mul r1.xyz, r0.xyzx, cb3[9].xyzx  
  48: sample_indexable(texture2d)(float,float,float,float) r0.w, v1.zwzz, t2.yzwx, s2  
  49: log r2.xyz, r1.xyzx  
  50: mul r2.xyz, r2.xyzx, l(2.200000, 2.200000, 2.200000, 0.000000)  
  51: exp r2.xyz, r2.xyzx  
  52: dp3 r1.w, r2.xyzx, cb3[6].xyzx  
  53: add_sat r1.w, -r1.w, l(1.000000)  
  54: mul r1.w, r1.w, cb3[6].w  
  55: mul_sat r0.w, r0.w, r1.w  
  56: mad r0.xyz, -r0.xyzx, cb3[9].xyzx, cb3[7].xyzx  
  57: mad r0.xyz, r0.wwww, r0.xyzx, r1.xyzx  
 ...

Интересно! Похоже, что для вычисления виньетирования используется и гамма (строка 46), и линейные пространства (строка 51). В строке 48 мы сэмплируем текстуру «обратного градиента». cb3[9].xyz не связан с виньетированием. В каждом проверенном кадре ему присваивается значение float3(1.0, 1.0, 1.0), то есть он, вероятно, является финальным фильтром, используемым в эффектах постепенно затемнения/осветления экрана (fade-in / fade-out). Для виньетирования в TW3 есть три основных параметра:

  • Непрозрачность (cb3[6].w) — влияет на силу виньетирования. 0 — нет виньетирования, 1 — максимальное виньетирование. По моим наблюдениям, в базовом The Witcher 3 он примерно равен 1.0, а в «Крови и вине» колеблется в районе 0.15.
  • Цвет (cb3[7].xyz) — отличная особенность виньетирования TW3 заключается в возможности изменения его цвета. Оно не обязано быть чёрным, но на практике… Обычно оно имеет значения float3( 3.0 / 255.0, 4.0 / 255.0, 5.0 / 255.0 ) и так далее — в общем случае это величины, кратные 0.00392156 = 1.0/255.0
  • Веса (cb3[6].xyz) — очень интересный параметр. Я всегда видел «плоские» виньетки, например такие:
Реверс-инжиниринг рендеринга «Ведьмака 3» - 18

Типичная маска виньетирования

Но с помощью весов (строка 52) можно получать очень интересные результаты:

Реверс-инжиниринг рендеринга «Ведьмака 3» - 19

Маска виньетирования TW3, вычисленная с использованием весов

Веса близки к 1.0. Посмотрите на данные буфера констант одного кадра из «Крови и вина» (магического мира с радугой): вот почему на яркие пиксели упомянутого выше неба не повлияло виньетирование.

Реверс-инжиниринг рендеринга «Ведьмака 3» - 20

Код

Вот моя реализация виньетирования TW3 на HLSL.

GammaToLinear = pow(color, 2.2)

 /*  
 // The Witcher 3 vignette.  
 //  
 // Input color is in gamma space  
 // Output color is in gamma space as well.  
 */  
 float3 Vignette_TW3( in float3 gammaColor, in float3 vignetteColor, in float3 vignetteWeights,  
                      in float vignetteOpacity, in Texture2D texVignette, in float2 texUV )  
 {  
      // For coloring vignette  
      float3 vignetteColorGammaSpace = -gammaColor + vignetteColor;  
   
      // Calculate vignette amount based on color in *LINEAR* color space and vignette weights.  
      float vignetteWeight = dot( GammaToLinear( gammaColor ), vignetteWeights );  
   
      // We need to keep vignette weight in [0-1] range  
      vignetteWeight = saturate( 1.0 - vignetteWeight );  
   
      // Multiply by opacity  
      vignetteWeight *= vignetteOpacity;  
   
      // Obtain vignette mask (here is texture; you can also calculate your custom mask here)  
      float sampledVignetteMask = texVignette.Sample( samplerLinearClamp, texUV ).x;  
   
      // Final (inversed) vignette mask  
      float finalInvVignetteMask = saturate( vignetteWeight * sampledVignetteMask );  
   
      // final composite in gamma space  
      float3 Color = vignetteColorGammaSpace * finalInvVignetteMask + gammaColor.rgb;  
   
      // * uncomment to debug vignette mask:  
      // return 1.0 - finalInvVignetteMask;  
   
      // Return final color  
      return Color;  
 }

Надеюсь, вам понравилось. Также можете попробовать мой HLSLexplorer, который очень помог мне в понимании ассемблерного кода HLSL.

Как и раньше, воспринимайте названия переменных с долей скептицизма — шейдеры TW3 обработаны D3DStripShader, поэтому я по сути почти ничего о них не знаю, мне остаётся только гадать. Кроме того, я не несу никакой ответственности за урон, нанесённый вашему оборудованию этим шейдером ;)

Бонус: вычисление градиента

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

  35: add r2.xy, v1.zwzz, l(-0.500000, -0.500000, 0.000000, 0.000000)  
  36: dp2 r1.w, r2.xyxx, r2.xyxx  
  37: sqrt r1.w, r1.w  
  38: mad r1.w, r1.w, l(2.000000), l(-0.550000)  
  39: mul_sat r2.w, r1.w, l(1.219512)  
  40: mul r2.z, r2.w, r2.w  
  41: mul r2.xy, r2.zwzz, r2.zzzz  
  42: dp4 r1.w, l(-0.100000, -0.105000, 1.120000, 0.090000), r2.xyzw  
  43: min r1.w, r1.w, l(0.940000)

К счастью для нас, он довольно прост. На HLSL он будет выглядеть примерно так:

 float TheWitcher3_2015_Mask( in float2 uv )  
 {  
      float distanceFromCenter = length( uv - float2(0.5, 0.5) );  
   
      float x = distanceFromCenter * 2.0 - 0.55;  
      x = saturate( x * 1.219512 );          // 1.219512 = 100/82  
   
      float x2 = x * x;  
      float x3 = x2 * x;  
      float x4 = x2 * x2;  
   
      float outX = dot( float4(x4, x3, x2, x), float4(-0.10, -0.105, 1.12, 0.09) );  
      outX = min( outX, 0.94 );  
   
      return outX;  
 }

То есть мы просто вычисляем расстояние от центра до текстела, творим с ним некую магию (умножение, saturate...), а затем… вычисляем многочлен! Потрясающе.

Реверс-инжиниринг рендеринга «Ведьмака 3» - 21

Часть 5: эффект опьянения

Давайте посмотрим, как в игре «Ведьмак 3: Дикая охота» реализован эффект опьянения. Если вы в неё ещё не играли, то бросайте всё, покупайте и играйте посмотрите видео:

Вечер:

Ночь:

Сначала мы видим двоящееся и кружащееся изображение, часто возникающее, когда выпьешь в реальной жизни. Чем дальше пиксель от центра изображения, тем сильнее эффект вращения. Я намеренно выложил второе видео с ночью, потому что можно чётко увидеть это вращение на звёздах (видите 8 отдельных точек?)

Вторая часть эффекта опьянения, возможно, не сразу заметная, — это небольшое изменение зума. Оно заметно рядом с центром.

Наверно очевидно, что этот эффект является типичной постобработкой (пиксельным шейдером). Однако не таким очевидным может быть его расположение в конвейере рендеринга. Оказывается, эффект опьянения применяется сразу после тональной коррекции и прямо перед motion blur («пьяное» изображение является входными данными для motion blur).

Давайте начнём игры с ассемблерным кодом:

 ps_5_0  
    dcl_globalFlags refactoringAllowed  
    dcl_constantbuffer cb0[2], immediateIndexed  
    dcl_constantbuffer cb3[3], immediateIndexed  
    dcl_sampler s0, mode_default  
    dcl_resource_texture2d (float,float,float,float) t0  
    dcl_input_ps_siv v1.xy, position  
    dcl_output o0.xyzw  
    dcl_temps 8  
   0: mad r0.x, cb3[0].y, l(-0.100000), l(1.000000)  
   1: mul r0.yz, cb3[1].xxyx, l(0.000000, 0.050000, 0.050000, 0.000000)  
   2: mad r1.xy, v1.xyxx, cb0[1].zwzz, -cb3[2].xyxx  
   3: dp2 r0.w, r1.xyxx, r1.xyxx  
   4: sqrt r1.z, r0.w  
   5: mul r0.w, r0.w, l(10.000000)  
   6: min r0.w, r0.w, l(1.000000)  
   7: mul r0.w, r0.w, cb3[0].y  
   8: mul r2.xyzw, r0.yzyz, r1.zzzz  
   9: mad r2.xyzw, r1.xyxy, r0.xxxx, -r2.xyzw  
  10: mul r3.xy, r0.xxxx, r1.xyxx  
  11: mad r3.xyzw, r0.yzyz, r1.zzzz, r3.xyxy  
  12: add r3.xyzw, r3.xyzw, cb3[2].xyxy  
  13: add r2.xyzw, r2.xyzw, cb3[2].xyxy  
  14: mul r0.x, r0.w, cb3[0].x  
  15: mul r0.x, r0.x, l(5.000000)  
  16: mul r4.xyzw, r0.xxxx, cb3[0].zwzw  
  17: mad r5.xyzw, r4.zwzw, l(1.000000, 0.000000, -1.000000, -0.000000), r2.xyzw  
  18: sample_indexable(texture2d)(float,float,float,float) r6.xyzw, r5.xyxx, t0.xyzw, s0  
  19: sample_indexable(texture2d)(float,float,float,float) r5.xyzw, r5.zwzz, t0.xyzw, s0  
  20: add r5.xyzw, r5.xyzw, r6.xyzw  
  21: mad r6.xyzw, r4.zwzw, l(0.707000, 0.707000, -0.707000, -0.707000), r2.xyzw  
  22: sample_indexable(texture2d)(float,float,float,float) r7.xyzw, r6.xyxx, t0.xyzw, s0  
  23: sample_indexable(texture2d)(float,float,float,float) r6.xyzw, r6.zwzz, t0.xyzw, s0  
  24: add r5.xyzw, r5.xyzw, r7.xyzw  
  25: add r5.xyzw, r6.xyzw, r5.xyzw  
  26: mad r6.xyzw, r4.zwzw, l(0.000000, 1.000000, -0.000000, -1.000000), r2.xyzw  
  27: mad r2.xyzw, r4.xyzw, l(-0.707000, 0.707000, 0.707000, -0.707000), r2.xyzw  
  28: sample_indexable(texture2d)(float,float,float,float) r7.xyzw, r6.xyxx, t0.xyzw, s0  
  29: sample_indexable(texture2d)(float,float,float,float) r6.xyzw, r6.zwzz, t0.xyzw, s0  
  30: add r5.xyzw, r5.xyzw, r7.xyzw  
  31: add r5.xyzw, r6.xyzw, r5.xyzw  
  32: sample_indexable(texture2d)(float,float,float,float) r6.xyzw, r2.xyxx, t0.xyzw, s0  
  33: sample_indexable(texture2d)(float,float,float,float) r2.xyzw, r2.zwzz, t0.xyzw, s0  
  34: add r5.xyzw, r5.xyzw, r6.xyzw  
  35: add r2.xyzw, r2.xyzw, r5.xyzw  
  36: mul r2.xyzw, r2.xyzw, l(0.062500, 0.062500, 0.062500, 0.062500)  
  37: mad r5.xyzw, r4.zwzw, l(1.000000, 0.000000, -1.000000, -0.000000), r3.zwzw  
  38: sample_indexable(texture2d)(float,float,float,float) r6.xyzw, r5.xyxx, t0.xyzw, s0  
  39: sample_indexable(texture2d)(float,float,float,float) r5.xyzw, r5.zwzz, t0.xyzw, s0  
  40: add r5.xyzw, r5.xyzw, r6.xyzw  
  41: mad r6.xyzw, r4.zwzw, l(0.707000, 0.707000, -0.707000, -0.707000), r3.zwzw  
  42: sample_indexable(texture2d)(float,float,float,float) r7.xyzw, r6.xyxx, t0.xyzw, s0  
  43: sample_indexable(texture2d)(float,float,float,float) r6.xyzw, r6.zwzz, t0.xyzw, s0  
  44: add r5.xyzw, r5.xyzw, r7.xyzw  
  45: add r5.xyzw, r6.xyzw, r5.xyzw  
  46: mad r6.xyzw, r4.zwzw, l(0.000000, 1.000000, -0.000000, -1.000000), r3.zwzw  
  47: mad r3.xyzw, r4.xyzw, l(-0.707000, 0.707000, 0.707000, -0.707000), r3.xyzw  
  48: sample_indexable(texture2d)(float,float,float,float) r4.xyzw, r6.xyxx, t0.xyzw, s0  
  49: sample_indexable(texture2d)(float,float,float,float) r6.xyzw, r6.zwzz, t0.xyzw, s0  
  50: add r4.xyzw, r4.xyzw, r5.xyzw  
  51: add r4.xyzw, r6.xyzw, r4.xyzw  
  52: sample_indexable(texture2d)(float,float,float,float) r5.xyzw, r3.xyxx, t0.xyzw, s0  
  53: sample_indexable(texture2d)(float,float,float,float) r3.xyzw, r3.zwzz, t0.xyzw, s0  
  54: add r4.xyzw, r4.xyzw, r5.xyzw  
  55: add r3.xyzw, r3.xyzw, r4.xyzw  
  56: mad r2.xyzw, r3.xyzw, l(0.062500, 0.062500, 0.062500, 0.062500), r2.xyzw  
  57: mul r0.x, cb3[0].y, l(8.000000)  
  58: mul r0.xy, r0.xxxx, cb3[0].zwzz  
  59: mad r0.z, cb3[1].y, l(0.020000), l(1.000000)  
  60: mul r1.zw, r0.zzzz, r1.xxxy  
  61: mad r1.xy, r1.xyxx, r0.zzzz, cb3[2].xyxx  
  62: mad r3.xy, r1.zwzz, r0.xyxx, r1.xyxx  
  63: mul r0.xy, r0.xyxx, r1.zwzz  
  64: mad r0.xy, r0.xyxx, l(2.000000, 2.000000, 0.000000, 0.000000), r1.xyxx  
  65: sample_indexable(texture2d)(float,float,float,float) r1.xyzw, r1.xyxx, t0.xyzw, s0  
  66: sample_indexable(texture2d)(float,float,float,float) r4.xyzw, r0.xyxx, t0.xyzw, s0  
  67: sample_indexable(texture2d)(float,float,float,float) r3.xyzw, r3.xyxx, t0.xyzw, s0  
  68: add r1.xyzw, r1.xyzw, r3.xyzw  
  69: add r1.xyzw, r4.xyzw, r1.xyzw  
  70: mad r2.xyzw, -r1.xyzw, l(0.333333, 0.333333, 0.333333, 0.333333), r2.xyzw  
  71: mul r1.xyzw, r1.xyzw, l(0.333333, 0.333333, 0.333333, 0.333333)  
  72: mul r0.xyzw, r0.wwww, r2.xyzw  
  73: mad o0.xyzw, cb3[0].yyyy, r0.xyzw, r1.xyzw  
  74: ret

Здесь использованы два отдельных буфера констант. Давайте проверим их значения:

Реверс-инжиниринг рендеринга «Ведьмака 3» - 22

Реверс-инжиниринг рендеринга «Ведьмака 3» - 23

Нам интересны некоторые из них:

cb0_v0.x -> прошедшее время (в секундах)
cb0_v1.xyzw — размер вьюпорта и величина, обратная размеру вьюпорта (она же «размер пикселя»)
cb3_v0.x — вращение вокруг пикселя, всегда имеет значение 1.0.
cb3_v0.y — величина эффекта опьянения. После его включения не срабатывает в полную силу, а постепенно возрастает с 0.0 до 1.0.
cv3_v1.xy — смещения пикселей (подробнее об этом ниже). Это пара sin/cos, поэтому при желании можно использовать в шейдере sincos(time).
cb3_v2.xy — центр эффекта, обычно float2( 0.5, 0.5 ).
Здесь мы хотим сосредоточиться на понимании происходящего, а не просто вслепую переписывать шейдер.

Мы начнём с первых строк:

 ps_5_0  
   0: mad r0.x, cb3[0].y, l(-0.100000), l(1.000000)  
   1: mul r0.yz, cb3[1].xxyx, l(0.000000, 0.050000, 0.050000, 0.000000)  
   2: mad r1.xy, v1.xyxx, cb0[1].zwzz, -cb3[2].xyxx  
   3: dp2 r0.w, r1.xyxx, r1.xyxx  
   4: sqrt r1.z, r0.w

Строку 0 я называю «коэффициентом зума», и скоро вы поймёте, почему. Сразу после неё (строка 1), мы вычисляем «смещения поворота». Это просто входная пара данных sin/cos, умноженная на 0.05.

Строки 2-4: сначала мы вычисляем вектор из центра эффекта до UV-координат текстуры. Затем мы вычисляем квадрат расстояния (3) и простое расстояние (4) (от центра до тексела)

Текстурные координаты с зумом

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

   8: mul r2.xyzw, r0.yzyz, r1.zzzz  
   9: mad r2.xyzw, r1.xyxy, r0.xxxx, -r2.xyzw  
  10: mul r3.xy, r0.xxxx, r1.xyxx  
  11: mad r3.xyzw, r0.yzyz, r1.zzzz, r3.xyxy  
  12: add r3.xyzw, r3.xyzw, cb3[2].xyxy  
  13: add r2.xyzw, r2.xyzw, cb3[2].xyxy

Поскольку они упакованы таким образом, мы можем проанализировать только одну пару float.

Для начала, r0.yz являются «смещениями поворота», r1.z — это расстояние от центра до тексела, r1.xy — это вектор от центра до тексела, r0.x — это «коэффициент зума».

Чтобы понять это, примем пока, что zoomFactor = 1.0, то есть можно записать следующее:

   8: mul r2.xyzw, r0.yzyz, r1.zzzz  
   9: mad r2.xyzw, r1.xyxy, r0.xxxx, -r2.xyzw  
  13: add r2.xyzw, r2.xyzw, cb3[2].xyxy 

r2.xy = (texel - center) * zoomFactor - rotationOffsets * distanceFromCenter + center;

Но zoomFactor = 1.0:

  r2.xy = texel - center - rotationOffsets * distanceFromCenter + center;
  r2.xy = texel - rotationOffsets * distanceFromCenter;

Аналогично для r3.xy:

  10: mul r3.xy, r0.xxxx, r1.xyxx  
  11: mad r3.xyzw, r0.yzyz, r1.zzzz, r3.xyxy  
  12: add r3.xyzw, r3.xyzw, cb3[2].xyxy

  r3.xy = rotationOffsets * distanceFromCenter + zoomFactor * (texel - center) + center

Но zoomFactor = 1.0:

r3.xy = rotationOffsets * distanceFromCenter + texel - center + center r3.xy = texel + rotationOffsets * distanceFromCenter

Отлично. То есть в данный момент у нас по сути есть текущая TextureUV (texel) ± смещения поворота, но что насчёт zoomFactor? Посмотрите на строку 0. По сути, zoomFactor = 1.0 — 0.1 * drunkAmount. Для максимального drunkAmount значение zoomFactor должно быть равно 0.9, а текстурные координаты с зумом вычисляются теперь так:

  baseTexcoordsA = 0.9 * texel + 0.1 * center + rotationOffsets * distanceFromCenter
  baseTexcoordsB = 0.9 * texel + 0.1 * center - rotationOffsets * distanceFromCenter

Возможно, более интуитивно понятным будет такое объяснение: это просто линейная интерполяция на какой-то коэффициент между нормализованными текстурными координатами и центром. Это «приближенное зумом» изображение. Чтобы понять это, лучше всего поэкспериментировать со значениями. Вот ссылка на Shadertoy, где можно посмотреть эффект в действии.

Смещение текстурных координат

Весь фрагмент на ассемблерном коде:

   2: mad r1.xy, v1.xyxx, cb0[1].zwzz, -cb3[2].xyxx
   3: dp2 r0.w, r1.xyxx, r1.xyxx  
   5: mul r0.w, r0.w, l(10.000000)  
   6: min r0.w, r0.w, l(1.000000)  
   7: mul r0.w, r0.w, cb3[0].y  
  14: mul r0.x, r0.w, cb3[0].x  
  15: mul r0.x, r0.x, l(5.000000)           // texcoords offset intensity
  16: mul r4.xyzw, r0.xxxx, cb3[0].zwzw     // texcoords offset

создаёт некий градиент, назовём его «маской интенсивности смещения». На самом деле, он даёт два значения. Первое в r0.w (мы используем его позже) и второе, в 5 раз сильнее, в r0.x (строка 15). Последнее на самом деле служит в качестве множителя размера текселов, поэтому влияет на силу смещения.

Сэмплирование, связанное с вращением

Далее выполняется серия сэмплирования текстур. На самом деле используются 2 серии на 8 выборок, по одной на каждую «сторону». На HLSL можно записать это следующим образом:

   static const float2 pointsAroundPixel[8] =
    {
        float2(1.0, 0.0),
        float2(-1.0, 0.0),
        float2(0.707,  0.707),
        float2(-0.707, -0.707),
        float2(0.0,  1.0),
        float2(0.0, -1.0),
        float2(-0.707, 0.707),
        float2(0.707, -0.707)
    };

    float4 colorA = 0;
    float4 colorB = 0;

    int i=0;
    [unroll] for (i = 0; i < 8; i++)
    {
        colorA += TexColorBuffer.Sample( samplerLinearClamp, baseTexcoordsA + texcoordsOffset * pointsAroundPixel[i] );
    }
    colorA /= 16.0;

    [unroll] for (i = 0; i < 8; i++)
    {
        colorB += TexColorBuffer.Sample( samplerLinearClamp, baseTexcoordsB + texcoordsOffset * pointsAroundPixel[i] );
    }
    colorB /= 16.0;

    float4 rotationPart = colorA + colorB;

Хитрость в том, что мы прибавляем к baseTexcoordsA/B дополнительное смещение, лежащее на единичной окружности, умноженное на упомянутую ранее «интенсивность смещения текстурных координат». Чем дальше от центра находится пиксель, тем больше радиус окружности вокруг пикселя — мы сэмплируем его 8 раз, что хорошо заметно на звёздах. Значения pointsAroundPixel (кратные 45 градусам):

Реверс-инжиниринг рендеринга «Ведьмака 3» - 24

Единичная окружность

Сэмплирование, связанное с зумом

Вторая часть эффекта опьянения в The Witcher 3 — это зум с приближением и отдалением. Давайте посмотрим на выполняющий эту задачу ассемблерный код:

  56: mad r2.xyzw, r3.xyzw, l(0.062500, 0.062500, 0.062500, 0.062500), r2.xyzw  // the rotation part is stored in r2 register

  57: mul r0.x, cb3[0].y, l(8.000000)
  58: mul r0.xy, r0.xxxx, cb3[0].zwzz
  59: mad r0.z, cb3[1].y, l(0.020000), l(1.000000)
  60: mul r1.zw, r0.zzzz, r1.xxxy
  61: mad r1.xy, r1.xyxx, r0.zzzz, cb3[2].xyxx
  62: mad r3.xy, r1.zwzz, r0.xyxx, r1.xyxx
  63: mul r0.xy, r0.xyxx, r1.zwzz
  64: mad r0.xy, r0.xyxx, l(2.000000, 2.000000, 0.000000, 0.000000), r1.xyxx
  65: sample_indexable(texture2d)(float,float,float,float) r1.xyzw, r1.xyxx, t0.xyzw, s0
  66: sample_indexable(texture2d)(float,float,float,float) r4.xyzw, r0.xyxx, t0.xyzw, s0
  67: sample_indexable(texture2d)(float,float,float,float) r3.xyzw, r3.xyxx, t0.xyzw, s0
  68: add r1.xyzw, r1.xyzw, r3.xyzw
  69: add r1.xyzw, r4.xyzw, r1.xyzw

Мы видим, что здесь есть три отдельных вызова текстуры, то есть три разные координаты текстур. Давайте проанализируем, как из них вычисляются текстурные координаты. Но сначала покажем входные данные для этой части:

  float  zoomInOutScalePixels = drunkEffectAmount * 8.0; // line 57
  float2 zoomInOutScaleNormalizedScreenCoordinates = zoomInOutScalePixels * texelSize.xy; // line 58
  float  zoomInOutAmplitude = 1.0 + 0.02*cos(time); // line 59
  float2 zoomInOutfromCenterToTexel = zoomInOutAmplitude * fromCenterToTexel; // line 60

Пара слов о входных данных. Мы вычисляем смещение в текселах (например, 8.0 * размер тексела), которое затем прибавляется к базовым uv-координатам. Амплитуда просто колеблется в интервале от 0.98 и 1.02, чтобы придать ощущение зума, как и zoomFactor в части, выполняющей вращение.

Давайте начнём с первой пары — r1.xy (строка 61)

  r1.xy = fromCenterToTexel * amplitude + center
  r1.xy = (TextureUV - Center) * amplitude + Center // you can insert here zoomInOutfromCenterToTexel
  r1.xy = TextureUV * amplitude - Center * amplitude + Center
  r1.xy = TextureUV * amplitude + Center * 1.0 - Center * amplitude
  r1.xy = TextureUV * amplitude + Center * (1.0 - amplitude)
  
  r1.xy = lerp( TextureUV, Center, amplitude);

То есть:

float2 zoomInOutBaseTextureUV = lerp(TextureUV, Center, amplitude);

То есть:

  float2 zoomInOutBaseTextureUV = lerp(TextureUV, Center, amplitude);

Давайте проверим вторую пару — r3.xy (строка 62)

  r3.xy = (amplitude * fromCenterToTexel) * zoomInOutScaleNormalizedScreenCoordinates
        + zoomInOutBaseTextureUV

То есть:

  float2 zoomInOutAddTextureUV0 = zoomInOutBaseTextureUV
                      + zoomInOutfromCenterToTexel*zoomInOutScaleNormalizedScreenCoordinates;

Давайте проверим третью пару — r0.xy (строки 63-64)

  r0.xy = zoomInOutScaleNormalizedScreenCoordinates * (amplitude * fromCenterToTexel) * 2.0 + zoomInOutBaseTextureUV

То есть:

  float2 zoomInOutAddTextureUV1 = zoomInOutBaseTextureUV
  + 2.0*zoomInOutfromCenterToTexel*zoomInOutScaleNormalizedScreenCoordinates

Все три запроса текстур складываются вместе, и результат хранится в регистре r1. Стоит заметить, что этот пиксельный шейдер использует сэмплер с ограниченной адресацией.

Соединяем всё вместе

Итак, на данный момент у нас есть результат вращения в регистре r2 и три сложенных запроса зума в регистре r1. Давайте посмотрим на последние строки ассемблерного кода:

  70: mad r2.xyzw, -r1.xyzw, l(0.333333, 0.333333, 0.333333, 0.333333), r2.xyzw  
  71: mul r1.xyzw, r1.xyzw, l(0.333333, 0.333333, 0.333333, 0.333333)  
  72: mul r0.xyzw, r0.wwww, r2.xyzw  
  73: mad o0.xyzw, cb3[0].yyyy, r0.xyzw, r1.xyzw  
  74: ret

О дополнительных входных данных: r0.w берётся из строки 7, это наша маска интенсивности, а cb3[0].y — это величина эффекта опьянения.

Давайте разберёмся, как это работает. Мой первый подход заключался в «брутфорсе»:

  float4 finalColor = intensityMask * (rotationPart - zoomingPart);
  finalColor = drunkIntensity * finalColor + zoomingPart;
  
  return finalColor;

Но какого чёрта, никто так не пишет шейдеры. Я взял карандаш с бумагой и написал такую формулу:

  finalColor = effectAmount * [intensityMask * (rotationPart - zoomPart)] + zoomPart
  finalColor = effectAmount * intensityMask * rotationPart - effectAmount * intensityMask * zoomPart + zooomPart

Где t = effectAmount * intensityMask

Итак, у нас получается:

  finalColor = t * rotationPart - t * zoomPart + zoomPart
  finalColor = t * rotationPart + zoomPart - t * zoomPart
  finalColor = t * rotationPart + (1.0 - t) * zoomPart
  finalColor = lerp( zoomingPart, rotationPart, t )

И мы приходим к следующему:

  finalColor = lerp(zoomingPart, rotationPart, intensityMask * drunkIntensity);

Да, эта часть статьи оказалась очень подробной, но мы наконец-то закончили! Лично я научился кое-чему в процессе написания, надеюсь, и вы тоже!

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

Автор: PatientZero

Источник

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


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js