Недавно я начал разбираться с рендерингом «Ведьмака 3». В этой игре есть потрясающие приёмы рендеринга. Кроме того, она великолепна с точки зрения сюжета/музыки/геймплея.
В этой статье я расскажу о решениях, использованных для рендеринга 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 с захватом этого кадра одного из основных квестов Новиграда. Все настройки поставлены на максимум:
Немного поискав, я нашёл вызов отрисовки для тональной коррекции! Как я упоминал выше, есть буфер HDR-цветов (текстура номер 0, полное разрешение) и средняя яркость сцены (текстура номер 1, 1x1, с плавающей точкой, вычисленная ранее compute-шейдером).
Давайте взглянем на ассемблерный код пиксельного шейдера:
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. Мы можем исследовать их значения:
Мои догадки:
Я считаю, что «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!
Поэкспериментировав с кодом, я получил готовый 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);
}
Скриншот из моей утилиты, чтобы это подтвердить:
Вуаля!
Полагаю, это достаточно точная реализация тональной коррекции TW3, по крайней мере, с точки зрения ассемблерного кода. Я уже применил его в своём фреймворке и он работает отлично!
Я сказал «достаточно», потому что понятия не имею, почему denominator в ToneMapU2Func становится максимальным при нуле. При делении на 0 должно ведь получаться undefined?
На этом можно было бы закончить, но почти случайно я обнаружил в этом кадре ещё один вариант шейдера тональной коррекции TW3, используемый для красивого заката (интересно, что он применяется при минимальных настройках графики!)
Давайте его проверим. Для начала ассемблерный код шейдера:
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. Резкие смены средней яркости довольно неприятны и неестественны.
Давайте приступим! Ради последовательности мы будем анализировать тот же кадр из Новиграда.
Теперь мы углубимся в захват кадра программой RenderDoc. Адаптация глаза обычно выполняется прямо перед тональной коррекцией, и «Ведьмак 3» в этом не исключение.
Посмотрим на состояние пиксельного шейдера:
У нас есть два источника входных данных — 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 этот эффект очень малозаметен, а потому не раздражает при игровом процессе (меня, по крайней мере). Но при желании его можно отключить.
Давайте внимательно посмотрим на пример сцены с хроматической аберрацией и без неё:
Хроматическая аберрация включена
Хроматическая аберрация отключена
Вы замечаете какие-нибудь отличия рядом с краями? Я тоже нет. Давайте попробуем другую сцену:
Хроматическая аберрация включена. Заметьте небольшое «красное» искажение в обозначенной области.
Ага, намного лучше! Здесь контраст между тёмными и светлыми областями сильнее, и в углу мы видим небольшое искажение. Как видно, этот эффект очень слаб. Тем не менее, мне было интересно, как он реализован. Давайте перейдём к самой любопытной части: к коду!
Реализация
Первое, что нужно сделать — это найти нужный вызов отрисовки с пиксельным шейдером. На самом деле хроматическая аберрация является частью большого пиксельного шейдера «финальной постобработки», которая состоит из хроматической аберрации, виньетирования и гамма-коррекции. Всё это находится внутри одного пиксельного шейдера. Давайте внимательнее приглядимся к ассемблерному коду пиксельного шейдера:
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:
Так, попробуем понять, что здесь происходит. По сути, 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:
Вот и всё! Надеюсь, вам понравилась эта часть.
Часть 4: виньетирование
Виньетирование — один из самых распространённых эффектов постобработки, используемых в играх. Он популярен и в фотосъёмке. Немного затенённые углы могут создавать красивый эффект. Существует несколько типов виньетирования. Например, в Unreal Engine 4 используется естественный. Но вернёмся к The Witcher 3. Нажмите сюда, чтобы увидеть интерактивное сравнение кадров с виньетированием и без него. Сравнение взято из руководства NVIDIA по производительности The Witcher 3.
Скриншот из «Ведьмака 3» со включенным виньетированием.
Заметьте, что верхний левый угол (небо) не так затенён, как другие части изображения. Позже мы вернёмся к этому.
Подробности реализации
Во первых, существует незначительное различие между виньетированием, использованным в оригинальной версии «Ведьмака 3» (которая была выпущена 19 мая 2015 года) и в «Ведьмак 3: Кровь и вино». В первой «обратный градиент» вычисляется внутри пиксельного шейдера, а в последней он заранее вычисляется в 2D-текстуру размером 256x256:
Текстура 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) — очень интересный параметр. Я всегда видел «плоские» виньетки, например такие:
Типичная маска виньетирования
Но с помощью весов (строка 52) можно получать очень интересные результаты:
Маска виньетирования TW3, вычисленная с использованием весов
Веса близки к 1.0. Посмотрите на данные буфера констант одного кадра из «Крови и вина» (магического мира с радугой): вот почему на яркие пиксели упомянутого выше неба не повлияло виньетирование.
Код
Вот моя реализация виньетирования 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...), а затем… вычисляем многочлен! Потрясающе.
Часть 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
Здесь использованы два отдельных буфера констант. Давайте проверим их значения:
Нам интересны некоторые из них:
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 градусам):
Сэмплирование, связанное с зумом
Вторая часть эффекта опьянения в 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