[Перевод] Как реализован рендеринг «Ведьмака 3»: молнии, ведьмачье чутьё и другие эффекты
Часть 1. Молнии
В этой части мы рассмотрим процесс рендеринга молний в Witcher 3: Wild Hunt.
Рендеринг молний выполняется немного позже эффекта занавес дождя, но всё равно происходит в проходе прямого рендеринга. Молнии можно увидеть на этом видео:
Они очень быстро исчезают, поэтому лучше просматривать видео на скорости 0.25.
Можно увидеть, что это не статичные изображения; со временем их яркость слегка меняется.
С точки зрения нюансов рендеринга здесь есть очень много сходств с отрисовкой занавес дождя в отдалении, например, такие же состояния смешивания (аддитивное смешивание) и глубины (проверка включена, запись глубин не выполняется).
Сцена без молнии
Сцена с молнией
С точки зрения геометрии молнии в «Ведьмаке 3» — это древоподобные меши. Данный пример молнии представлен следующим мешем:
Он имеет UV-координаты и векторы нормалей. Всё это пригодится на этапе вершинного шейдера.
Вершинный шейдер
Давайте взглянем на ассемблерный код вершинного шейдера:
vs_5_0
dcl_globalFlags refactoringAllowed
dcl_constantbuffer cb1[9], immediateIndexed
dcl_constantbuffer cb2[6], immediateIndexed
dcl_input v0.xyz
dcl_input v1.xy
dcl_input v2.xyz
dcl_input v4.xyzw
dcl_input v5.xyzw
dcl_input v6.xyzw
dcl_input v7.xyzw
dcl_output o0.xy
dcl_output o1.xyzw
dcl_output_siv o2.xyzw, position
dcl_temps 3
0: mov o0.xy, v1.xyxx
1: mov o1.xyzw, v7.xyzw
2: mul r0.xyzw, v5.xyzw, cb1[0].yyyy
3: mad r0.xyzw, v4.xyzw, cb1[0].xxxx, r0.xyzw
4: mad r0.xyzw, v6.xyzw, cb1[0].zzzz, r0.xyzw
5: mad r0.xyzw, cb1[0].wwww, l(0.000000, 0.000000, 0.000000, 1.000000), r0.xyzw
6: mov r1.w, l(1.000000)
7: mad r1.xyz, v0.xyzx, cb2[4].xyzx, cb2[5].xyzx
8: dp4 r2.x, r1.xyzw, v4.xyzw
9: dp4 r2.y, r1.xyzw, v5.xyzw
10: dp4 r2.z, r1.xyzw, v6.xyzw
11: add r2.xyz, r2.xyzx, -cb1[8].xyzx
12: dp3 r1.w, r2.xyzx, r2.xyzx
13: rsq r1.w, r1.w
14: div r1.w, l(1.000000, 1.000000, 1.000000, 1.000000), r1.w
15: mul r1.w, r1.w, l(0.000001)
16: mad r2.xyz, v2.xyzx, l(2.000000, 2.000000, 2.000000, 0.000000), l(-1.000000, -1.000000, -1.000000, 0.000000)
17: mad r1.xyz, r2.xyzx, r1.wwww, r1.xyzx
18: mov r1.w, l(1.000000)
19: dp4 o2.x, r1.xyzw, r0.xyzw
20: mul r0.xyzw, v5.xyzw, cb1[1].yyyy
21: mad r0.xyzw, v4.xyzw, cb1[1].xxxx, r0.xyzw
22: mad r0.xyzw, v6.xyzw, cb1[1].zzzz, r0.xyzw
23: mad r0.xyzw, cb1[1].wwww, l(0.000000, 0.000000, 0.000000, 1.000000), r0.xyzw
24: dp4 o2.y, r1.xyzw, r0.xyzw
25: mul r0.xyzw, v5.xyzw, cb1[2].yyyy
26: mad r0.xyzw, v4.xyzw, cb1[2].xxxx, r0.xyzw
27: mad r0.xyzw, v6.xyzw, cb1[2].zzzz, r0.xyzw
28: mad r0.xyzw, cb1[2].wwww, l(0.000000, 0.000000, 0.000000, 1.000000), r0.xyzw
29: dp4 o2.z, r1.xyzw, r0.xyzw
30: mul r0.xyzw, v5.xyzw, cb1[3].yyyy
31: mad r0.xyzw, v4.xyzw, cb1[3].xxxx, r0.xyzw
32: mad r0.xyzw, v6.xyzw, cb1[3].zzzz, r0.xyzw
33: mad r0.xyzw, cb1[3].wwww, l(0.000000, 0.000000, 0.000000, 1.000000), r0.xyzw
34: dp4 o2.w, r1.xyzw, r0.xyzw
35: ret
Здесь есть много сходств с вершинным шейдером занавес дождя, поэтому я не буду повторяться. Хочу показать вам важное отличие, которое есть в строках 11–18:
11: add r2.xyz, r2.xyzx, -cb1[8].xyzx
12: dp3 r1.w, r2.xyzx, r2.xyzx
13: rsq r1.w, r1.w
14: div r1.w, l(1.000000, 1.000000, 1.000000, 1.000000), r1.w
15: mul r1.w, r1.w, l(0.000001)
16: mad r2.xyz, v2.xyzx, l(2.000000, 2.000000, 2.000000, 0.000000), l(-1.000000, -1.000000, -1.000000, 0.000000)
17: mad r1.xyz, r2.xyzx, r1.wwww, r1.xyzx
18: mov r1.w, l(1.000000)
19: dp4 o2.x, r1.xyzw, r0.xyzw
Во-первых, cb1[8].xyz — это позиция камеры, а r2.xyz позиция в мировом пространстве, то есть строка 11 вычисляет вектор из камеры к позиции в мире. Затем строки 12–15 вычисляют length (worldPos — cameraPos) * 0.000001.
v2.xyz — это вектор нормали входящей геометрии. Строка 16 расширяет его из интервала [0–1] до интервала [-1;1].
Затем вычисляется конечная позиция в мире:
finalWorldPos = worldPos + length (worldPos — cameraPos) * 0.000001 * normalVector
Фрагмент кода HLSL для этой операции будет примерно таким:
...
// final world-space position
float3 vNormal = Input.NormalW * 2.0 - 1.0;
float lencameratoworld = length( PositionL - g_cameraPos.xyz) * 0.000001;
PositionL += vNormal*lencameratoworld;
// SV_Posiiton
float4x4 matModelViewProjection = mul(g_viewProjMatrix, matInstanceWorld );
Output.PositionH = mul( float4(PositionL, 1.0), transpose(matModelViewProjection) );
return Output;
Эта операция приводит к небольшому «взрыву» меша (в направлении вектора нормали). Я поэкспериментировал, заменив 0.000001 на несколько других значений. Вот результаты:
0.000002
0.000005
0.00001
0.000025
Пиксельный шейдер
Отлично, мы разобрались с вершинным шейдером, теперь пора взяться за ассемблерный код пиксельного шейдера!
ps_5_0
dcl_globalFlags refactoringAllowed
dcl_constantbuffer cb0[1], immediateIndexed
dcl_constantbuffer cb2[3], immediateIndexed
dcl_constantbuffer cb4[5], immediateIndexed
dcl_input_ps linear v0.x
dcl_input_ps linear v1.w
dcl_output o0.xyzw
dcl_temps 1
0: mad r0.x, cb0[0].x, cb4[4].x, v0.x
1: add r0.y, r0.x, l(-1.000000)
2: round_ni r0.y, r0.y
3: ishr r0.z, r0.y, l(13)
4: xor r0.y, r0.y, r0.z
5: imul null, r0.z, r0.y, r0.y
6: imad r0.z, r0.z, l(0x0000ec4d), l(0.0000000000000000000000000000000000001)
7: imad r0.y, r0.y, r0.z, l(146956042240.000000)
8: and r0.y, r0.y, l(0x7fffffff)
9: round_ni r0.z, r0.x
10: frc r0.x, r0.x
11: add r0.x, -r0.x, l(1.000000)
12: ishr r0.w, r0.z, l(13)
13: xor r0.z, r0.z, r0.w
14: imul null, r0.w, r0.z, r0.z
15: imad r0.w, r0.w, l(0x0000ec4d), l(0.0000000000000000000000000000000000001)
16: imad r0.z, r0.z, r0.w, l(146956042240.000000)
17: and r0.z, r0.z, l(0x7fffffff)
18: itof r0.yz, r0.yyzy
19: mul r0.z, r0.z, l(0.000000001)
20: mad r0.y, r0.y, l(0.000000001), -r0.z
21: mul r0.w, r0.x, r0.x
22: mul r0.x, r0.x, r0.w
23: mul r0.w, r0.w, l(3.000000)
24: mad r0.x, r0.x, l(-2.000000), r0.w
25: mad r0.x, r0.x, r0.y, r0.z
26: add r0.y, -cb4[2].x, cb4[3].x
27: mad_sat r0.x, r0.x, r0.y, cb4[2].x
28: mul r0.x, r0.x, v1.w
29: mul r0.yzw, cb4[0].xxxx, cb4[1].xxyz
30: mul r0.xyzw, r0.xyzw, cb2[2].wxyz
31: mul o0.xyz, r0.xxxx, r0.yzwy
32: mov o0.w, r0.x
33: ret
Хорошая новость: код не такой длинный.
Плохая новость:
3: ishr r0.z, r0.y, l(13)
4: xor r0.y, r0.y, r0.z
5: imul null, r0.z, r0.y, r0.y
6: imad r0.z, r0.z, l(0x0000ec4d), l(0.0000000000000000000000000000000000001)
7: imad r0.y, r0.y, r0.z, l(146956042240.000000)
8: and r0.y, r0.y, l(0x7fffffff)
… что это вообще такое?
Честно говоря, я не впервые вижу подобный кусок… ассемблерного кода в шейдерах «Ведьмака 3». Но когда я встретил его в первый раз, то подумал: «Что за фигня?»
Нечто подобное можно найти в некоторых других шейдерах TW3. Не буду описывать свои приключения с этим фрагментом, и просто скажу, что ответ заключается в целочисленном шуме:
// For more details see: http://libnoise.sourceforge.net/noisegen/
float integerNoise( int n )
{
n = (n >> 13) ^ n;
int nn = (n * (n * n * 60493 + 19990303) + 1376312589) & 0x7fffffff;
return ((float)nn / 1073741824.0);
}
Как видите, в пиксельном шейдере он вызывается дважды. Пользуясь руководствами с этого веб-сайта, мы можем понять, как правильно реализуется плавный шум. Я вернусь к этому через минуту.
Посмотрите на строку 0 — здесь мы выполняем анимацию на основании следующей формулы:
animation = elapsedTime * animationSpeed + TextureUV.x
Эти значения, после округления в меньшую сторону (floor) (инструкция round_ni) в дальнейшем становятся входными точками для целочисленного шума. Обычно мы вычисляем значение шума для двух целых чисел, а затем вычисляем окончательное, интерполированное значение между ними (подробности см. на веб-сайте libnoise).
Ну ладно, это целочисленный шум, но ведь все ранее упомянутые значения (тоже округлённые в меньшую сторону) являются float!
Заметьте, что здесь нет инструкций ftoi. Я предполагаю, что программисты из CD Projekt Red воспользовались здесь внутренней функцией HLSL asint, которая выполняет преобразование «reinterpret_cast» значений с плавающей запятой и обрабатывает их как целочисленный паттерн.
Вес интерполяции для двух значений вычисляется в строках 10–11
interpolationWeight = 1.0 — frac (animation);
Такой подход позволяет нам выполнять интерполирование между значения с учётом времени.
Для создания плавного шума этот интерполятор передается функции SCurve:
float s_curve( float x )
{
float x2 = x * x;
float x3 = x2 * x;
// -2x^3 + 3x^2
return -2.0*x3 + 3.0*x2;
}
Функция Smoothstep [libnoise.sourceforge.net]
Эта функция известна под названием «smoothstep». Но как видно из ассемблерного кода, это не внутренняя функция smoothstep из HLSL. Внутренняя функция применяет ограничения, чтобы значения были верными. Но поскольку мы знаем, что interpolationWeight всегда будет находиться в интервале [0–1], эти проверки можно спокойно пропустить.
При вычислении окончательного значения используется несколько операций умножения. Посмотрите, как может меняться окончательное выходное значение альфы в зависимости от значения шума. Это удобно, потому что будет влиять на непрозрачность отрендеренной молнии, совсем как в реальной жизни.
Готовый пиксельный шейдер:
cbuffer cbPerFrame : register (b0)
{
float4 cb0_v0;
float4 cb0_v1;
float4 cb0_v2;
float4 cb0_v3;
}
cbuffer cbPerFrame : register (b2)
{
float4 cb2_v0;
float4 cb2_v1;
float4 cb2_v2;
float4 cb2_v3;
}
cbuffer cbPerFrame : register (b4)
{
float4 cb4_v0;
float4 cb4_v1;
float4 cb4_v2;
float4 cb4_v3;
float4 cb4_v4;
}
struct VS_OUTPUT
{
float2 Texcoords : Texcoord0;
float4 InstanceLODParams : INSTANCE_LOD_PARAMS;
float4 PositionH : SV_Position;
};
// Shaders in TW3 use integer noise.
// For more details see: http://libnoise.sourceforge.net/noisegen/
float integerNoise( int n )
{
n = (n >> 13) ^ n;
int nn = (n * (n * n * 60493 + 19990303) + 1376312589) & 0x7fffffff;
return ((float)nn / 1073741824.0);
}
float s_curve( float x )
{
float x2 = x * x;
float x3 = x2 * x;
// -2x^3 + 3x^2
return -2.0*x3 + 3.0*x2;
}
float4 Lightning_TW3_PS( in VS_OUTPUT Input ) : SV_Target
{
// * Inputs
float elapsedTime = cb0_v0.x;
float animationSpeed = cb4_v4.x;
float minAmount = cb4_v2.x;
float maxAmount = cb4_v3.x;
float colorMultiplier = cb4_v0.x;
float3 colorFilter = cb4_v1.xyz;
float3 lightningColorRGB = cb2_v2.rgb;
// Animation using time and X texcoord
float animation = elapsedTime * animationSpeed + Input.Texcoords.x;
// Input parameters for Integer Noise.
// They are floored and please note there are using asint.
// That might be an optimization to avoid "ftoi" instructions.
int intX0 = asint( floor(animation) );
int intX1 = asint( floor(animation-1.0) );
float n0 = integerNoise( intX0 );
float n1 = integerNoise( intX1 );
// We interpolate "backwards" here.
float weight = 1.0 - frac(animation);
// Following the instructions from libnoise, we perform
// smooth interpolation here with cubic s-curve function.
float noise = lerp( n0, n1, s_curve(weight) );
// Make sure we are in [0.0 - 1.0] range.
float lightningAmount = saturate( lerp(minAmount, maxAmount, noise) );
lightningAmount *= Input.InstanceLODParams.w; // 1.0
lightningAmount *= cb2_v2.w; // 1.0
// Calculate final lightning color
float3 lightningColor = colorMultiplier * colorFilter;
lightningColor *= lighntingColorRGB;
float3 finalLightningColor = lightningColor * lightningAmount;
return float4( finalLightningColor, lightningAmount );
}
Подведём итог
В этой части я описал способ рендеринга молний в «Ведьмаке 3».
Я очень доволен тем, что получившийся из моего шейдера ассемблерный код полностью совпадает с оригинальным!
Часть 2. Глупые трюки с небом
Эта часть будет немного отличаться от предыдущих. В ней я хочу показать вам некоторые аспекты шейдеров неба Witcher 3.
Почему «глупые трюки», а не весь шейдер? Ну, на то есть несколько причин. Во-первых, шейдер неба Witcher 3 — довольно сложная зверюга. Пиксельный шейдер из версии 2015 года содержит 267 строк ассемблерного кода, а шейдер из DLC «Кровь и вино» — уже 385 строк.
Более того, они получают множество входных данных, что не очень способствует реверс-инжинирингу полного (и читаемого!) кода на HLSL.
Поэтому я решил показать из этих шейдеров только часть трюков. Если я найду что-то новое, то дополню пост.
Различия между версией 2015 года и DLC (2016 год) сильно заметны. В том, числе в них входят различия в вычислении звёзд и их мерцания, разный подход к рендерингу Солнца… Шейдер «Крови и вина» даже вычисляет ночью Млечный путь.
Я начну с основ, а потом расскажу о глупых трюках.
Основы
Как и в большинстве современных игр, в Witcher 3 для моделирования неба используется skydome. Посмотрите на полусферу, которую использовали для этого в Witcher 3 (2015). Примечание: в данном случае ограничивающий параллелепипед этого меша находится в интервале от [0,0,0] до [1,1,1] (Z — это ось, направленная вверх) и имеет плавно распределённые UV. Позже мы их используем.
Идея в основе skydome схожа с идеей скайбокса (единственная разница заключается в используемом меше). На этапе вершинного шейдера мы преобразуем skydome относительно наблюдателя (обычно в соответствии с позицией камеры), что создаёт иллюзию того, что небо и в самом деле находится очень далеко — мы никогда до него не доберёмся.
Если вы читали предыдущие части этой серии статей, то знаете, что в «Ведьмаке 3» используется обратная глубина, то есть дальняя плоскость имеет значение 0.0f, а ближняя — 1.0f. Чтобы вывод skydome целиком выполнялся на дальней плоскости, в параметрах окна обзора мы задаём MinDepth то же значение, что и MaxDepth:
Чтобы узнать, как поля MinDepth и MaxDepth используются во время преобразования окна обзора, нажмите сюда (docs.microsoft.com).
Вершинный шейдер
Давайте начнём с вершинного шейдера. В Witcher 3 (2015 год) ассемблерный код шейдера имеет следующий вид:
vs_5_0
dcl_globalFlags refactoringAllowed
dcl_constantbuffer cb1[4], immediateIndexed
dcl_constantbuffer cb2[6], immediateIndexed
dcl_input v0.xyz
dcl_input v1.xy
dcl_output o0.xy
dcl_output o1.xyz
dcl_output_siv o2.xyzw, position
dcl_temps 2
0: mov o0.xy, v1.xyxx
1: mad r0.xyz, v0.xyzx, cb2[4].xyzx, cb2[5].xyzx
2: mov r0.w, l(1.000000)
3: dp4 o1.x, r0.xyzw, cb2[0].xyzw
4: dp4 o1.y, r0.xyzw, cb2[1].xyzw
5: dp4 o1.z, r0.xyzw, cb2[2].xyzw
6: mul r1.xyzw, cb1[0].yyyy, cb2[1].xyzw
7: mad r1.xyzw, cb2[0].xyzw, cb1[0].xxxx, r1.xyzw
8: mad r1.xyzw, cb2[2].xyzw, cb1[0].zzzz, r1.xyzw
9: mad r1.xyzw, cb1[0].wwww, l(0.000000, 0.000000, 0.000000, 1.000000), r1.xyzw
10: dp4 o2.x, r0.xyzw, r1.xyzw
11: mul r1.xyzw, cb1[1].yyyy, cb2[1].xyzw
12: mad r1.xyzw, cb2[0].xyzw, cb1[1].xxxx, r1.xyzw
13: mad r1.xyzw, cb2[2].xyzw, cb1[1].zzzz, r1.xyzw
14: mad r1.xyzw, cb1[1].wwww, l(0.000000, 0.000000, 0.000000, 1.000000), r1.xyzw
15: dp4 o2.y, r0.xyzw, r1.xyzw
16: mul r1.xyzw, cb1[2].yyyy, cb2[1].xyzw
17: mad r1.xyzw, cb2[0].xyzw, cb1[2].xxxx, r1.xyzw
18: mad r1.xyzw, cb2[2].xyzw, cb1[2].zzzz, r1.xyzw
19: mad r1.xyzw, cb1[2].wwww, l(0.000000, 0.000000, 0.000000, 1.000000), r1.xyzw
20: dp4 o2.z, r0.xyzw, r1.xyzw
21: mul r1.xyzw, cb1[3].yyyy, cb2[1].xyzw
22: mad r1.xyzw, cb2[0].xyzw, cb1[3].xxxx, r1.xyzw
23: mad r1.xyzw, cb2[2].xyzw, cb1[3].zzzz, r1.xyzw
24: mad r1.xyzw, cb1[3].wwww, l(0.000000, 0.000000, 0.000000, 1.000000), r1.xyzw
25: dp4 o2.w, r0.xyzw, r1.xyzw
26: ret
В данном случае вершинный шейдер передаёт на выход только texcoords и позицию в мировом пространстве. В «Крови и вине» он также выводит нормализованный вектор нормали. Я буду рассматривать версию 2015 года, потому что она проще.
Посмотрите на буфер констант, обозначенный как cb2:
Здесь у нас есть матрица мира (однородное масштабирование на 100 и перенос относительно позиции камеры). Ничего сложного. cb2_v4 и cb2_v5 — это коэффициенты масштаба/отклонения, используемые для преобразования позиций вершин из интервала [0–1] в интервал [-1;1]. Но здесь эти коэффициенты «сжимают» ось Z (направленную вверх).
В предыдущих частях серии у нас были похожие вершинные шейдеры. Общий алгоритм заключается в передаче texcoords дальше, затем вычисляется Position с учётом коэффициентов масштаба/отклонения, затем вычисляется PositionW в мировом пространстве, потом рассчитывается окончательная позиция пространства отсечения перемножением матриц matWorld и matViewProj → используется их произведение для умножения на Position, чтобы получить окончательную SV_Position.
Поэтому HLSL этого вершинного шейдера должен быть примерно таким:
struct InputStruct {
float3 param0 : POSITION;
float2 param1 : TEXCOORD;
float3 param2 : NORMAL;
float4 param3 : TANGENT;
};
struct OutputStruct {
float2 param0 : TEXCOORD0;
float3 param1 : TEXCOORD1;
float4 param2 : SV_Position;
};
OutputStruct EditedShaderVS(in InputStruct IN)
{
OutputStruct OUT = (OutputStruct)0;
// Simple texcoords passing
OUT.param0 = IN.param1;
// * Manually construct world and viewProj martices from float4s:
row_major matrix matWorld = matrix(cb2_v0, cb2_v1, cb2_v2, float4(0,0,0,1) );
matrix matViewProj = matrix(cb1_v0, cb1_v1, cb1_v2, cb1_v3);
// * Some optional fun with worldMatrix
// a) Scale
//matWorld._11 = matWorld._22 = matWorld._33 = 0.225f;
// b) Translate
// X Y Z
//matWorld._14 = 520.0997;
//matWorld._24 = 74.4226;
//matWorld._34 = 113.9;
// Local space - note the scale+bias here!
//float3 meshScale = float3(2.0, 2.0, 2.0);
//float3 meshBias = float3(-1.0, -1.0, -0.4);
float3 meshScale = cb2_v4.xyz;
float3 meshBias = cb2_v5.xyz;
float3 Position = IN.param0 * meshScale + meshBias;
// World space
float4 PositionW = mul(float4(Position, 1.0), transpose(matWorld) );
OUT.param1 = PositionW.xyz;
// Clip space - original approach from The Witcher 3
matrix matWorldViewProj = mul(matViewProj, matWorld);
OUT.param2 = mul( float4(Position, 1.0), transpose(matWorldViewProj) );
return OUT;
}
Сравнение моего шейдера (слева) и оригинального (справа):
Отличным свойством RenderDoc является то, что он позволяет нам выполнить инъекцию собственного шейдера вместо оригинального, и эти изменения повлияют на конвейер до самого конца кадра. Как видите из кода HLSL, я предоставил несколько вариантов изменения масштаба и преобразования конечной геометрии. Можете поэкспериментировать с ними и получить очень забавные результаты:
Оптимизация вершинного шейдера
Вы заметили проблему оригинального вершинного шейдера? Повершинное перемножение матрицы на матрицу совершенно избыточно! Я обнаружил это по крайней мере в нескольких вершинных шейдерах (например, в шейдере занавес дождя в отдалении). Мы можем оптимизировать его, сразу же умножив PositionW на matViewProj!
Итак, мы можем заменить такой код на HLSL:
// Clip space - original approach from The Witcher 3
matrix matWorldViewProj = mul(matViewProj, matWorld);
OUT.param2 = mul( float4(Position, 1.0), transpose(matWorldViewProj) );
следующим:
// Clip space - optimized version
OUT.param2 = mul( matViewProj, PositionW );
Оптимизированная версия даёт нам следующий ассемблерный код:
vs_5_0
dcl_globalFlags refactoringAllowed
dcl_constantbuffer CB1[4], immediateIndexed
dcl_constantbuffer CB2[6], immediateIndexed
dcl_input v0.xyz
dcl_input v1.xy
dcl_output o0.xy
dcl_output o1.xyz
dcl_output_siv o2.xyzw, position
dcl_temps 2
0: mov o0.xy, v1.xyxx
1: mad r0.xyz, v0.xyzx, cb2[4].xyzx, cb2[5].xyzx
2: mov r0.w, l(1.000000)
3: dp4 r1.x, r0.xyzw, cb2[0].xyzw
4: dp4 r1.y, r0.xyzw, cb2[1].xyzw
5: dp4 r1.z, r0.xyzw, cb2[2].xyzw
6: mov o1.xyz, r1.xyzx
7: mov r1.w, l(1.000000)
8: dp4 o2.x, cb1[0].xyzw, r1.xyzw
9: dp4 o2.y, cb1[1].xyzw, r1.xyzw
10: dp4 o2.z, cb1[2].xyzw, r1.xyzw
11: dp4 o2.w, cb1[3].xyzw, r1.xyzw
12: ret
Как видите, мы уменьшили количество инструкций с 26 до 12 — довольно значительное изменение. Я не знаю, насколько широко распространена эта проблема в игре, но ради бога, CD Projekt Red, может, выпустите патч? :)
И я не шучу. Можете вставить мой оптимизированный шейдер вместо оригинального RenderDoc и вы увидите, что эта оптимизация визуально ни на что не влияет. Честно говоря, я не понимаю, зачем CD Projekt Red решила выполнять повершинное умножение матрицы на матрицу…
Солнце
В «Ведьмаке 3» (2015 год) вычисление атмосферного рассеяния и Солнца состоит из двух отдельных вызовов отрисовки:
Witcher 3 (2015) — до
Witcher 3 (2015) — с небом
Witcher 3 (2015) — с небом + Солнце
Рендеринг Солнца в версии 2015 года очень похож на рендеринг Луны с точки зрения геометрии и состояний смешивания/глубин.
С другой стороны, в «Крови и вине» небо с Солнцем рендерятся за один проход:
Ведьмак 3: Кровь и вино (2016 год) — до неба
Ведьмак 3: Кровь и вино (2016 год) — с небом и Солнцем
Как бы вы не рендерили Солнце, на каком-то этапе вам всё равно понадобится (нормализованное) направление солнечного света. Наиболее логичный способ получить этот вектор — использовать сферические координаты. По сути, нам нужно всего два значения, обозначающие два угла (в радианах!): фи и тета. Получив их, можно допустить, что r = 1, таким образом сократив его. Тогда для декартовых координат с направленной вверх осью Y можно написать следующий код на HLSL:
float3 vSunDir;
vSunDir.x = sin(fTheta)*cos(fPhi);
vSunDir.y = sin(fTheta)*sin(fPhi);
vSunDir.z = cos(fTheta);
vSunDir = normalize(vSunDir);
Обычно направление солнечного света вычисляется в приложении, а затем передаётся в буфер констант для дальнейшего использования.
Получив направление солнечного света, мы можем углубиться в ассемблерный код пиксельного шейдера «Крови и вина»…
...
100: add r1.xyw, -r0.xyxz, cb12[0].xyxz
101: dp3 r2.x, r1.xywx, r1.xywx
102: rsq r2.x, r2.x
103: mul r1.xyw, r1.xyxw, r2.xxxx
104: mov_sat r2.xy, cb12[205].yxyy
105: dp3 r2.z, -r1.xywx, -r1.xywx
106: rsq r2.z, r2.z
107: mul r1.xyw, -r1.xyxw, r2.zzzz
...
Итак, во-первых, cb12[0].xyz — это позиция камеры, а в r0.xyz мы храним позицию вершины (это выходные данные из вершинного шейдера). Следовательно, строка 100 вычисляет вектор worldToCamera. Но взгляните на строки 105–107. Мы можем записать их как normalize (-worldToCamera), то есть мы вычисляем нормализованный вектор cameraToWorld.
120: dp3_sat r1.x, cb12[203].yzwy, r1.xywx
Затем мы вычисляем скалярное произведение векторов cameraToWorld и sunDirection! Помните, что они должны быть нормализованными. Также мы насыщаем это полное выражение, чтобы ограничить его интервалом [0–1].
Отлично! Это скалярное произведение хранится в r1.x. Давайте посмотрим, где оно применяется дальше…
152: log r1.x, r1.x
153: mul r1.x, r1.x, cb12[203].x
154: exp r1.x, r1.x
155: mul r1.x, r2.y, r1.x
Троица «log, mul, exp» — это возведение в степень. Как видите, мы возводим наш косинус (скалярное произведение нормализованных векторов) в какую-то степень. Вы можете спросить зачем. Таким образом мы можем создать градиент, имитирующий Солнце. (И строка 155 влияет на непрозрачность этого градиента, чтобы мы, например, обнулить его, чтобы полностью скрыть Солнце). Вот несколько примеров:
exponent = 54
exponent = 2400
Имея этот градиент, мы используем его для выполнения интерполяции между skyColor и sunColor! Чтобы избежать появления артефактов, нужно насытить значение в строке 120.
Стоит заметить, что этот трюк можно использовать для имитации венцов Луны (при низких значениях exponent). Для этого нам понадобится вектор moonDirection, который легко можно вычислить с помощью сферических координат.
Готовый код на HLSL может походить на следующий фрагмент:
float3 vCamToWorld = normalize( PosW – CameraPos );
float cosTheta = saturate( dot(vSunDir, vCamToWorld) );
float sunGradient = pow( cosTheta, sunExponent );
float3 color = lerp( skyColor, sunColor, sunGradient );
Движение звёзд
Если сделать таймлапс чистого ночного неба Witcher 3, то можно заметить, что звёзды не статичны — они немного движутся по небу! Я заметил это почти случайно и захотел узнать, как это реализовано.
Давайте начнём с того факта, что звёзды в Witcher 3 представлены как кубическая карта размером 1024×1024x6. Если подумать, то можно понять, что это очень удобное решение, которое позволяет с лёгкостью привязывать направления для сэмплирования кубической карты.
Давайте рассмотрим следующий ассемблерный код:
159: add r1.xyz, -v1.xyzx, cb1[8].xyzx
160: dp3 r0.w, r1.xyzx, r1.xyzx
161: rsq r0.w, r0.w
162: mul r1.xyz, r0.wwww, r1.xyzx
163: mul r2.xyz, cb12[204].zwyz, l(0.000000, 0.000000, 1.000000, 0.000000)
164: mad r2.xyz, cb12[204].yzwy, l(0.000000, 1.000000, 0.000000, 0.000000), -r2.xyzx
165: mul r4.xyz, r2.xyzx, cb12[204].zwyz
166: mad r4.xyz, r2.zxyz, cb12[204].wyzw, -r4.xyzx
167: dp3 r4.x, r1.xyzx, r4.xyzx
168: dp2 r4.y, r1.xyxx, r2.yzyy
169: dp3 r4.z, r1.xyzx, cb12[204].yzwy
170: dp3 r0.w, r4.xyzx, r4.xyzx
171: rsq r0.w, r0.w
172: mul r2.xyz, r0.wwww, r4.xyzx
173: sample_indexable(texturecube)(float,float,float,float) r4.xyz, r2.xyzx, t0.xyzw, s0
Чтобы вычислить конечный вектор сэмплирования (строка 173), мы начинаем с вычисления нормализованного вектора worldToCamera (строки 159–162).
Затем мы вычисляем два векторных произведения (163–164, 165–166) с moonDirection, а позже рассчитываем три скалярных произведения, чтобы получить конечный вектор сэмплирования. Код на HLSL:
float3 vWorldToCamera = normalize( g_CameraPos.xyz - Input.PositionW.xyz );
float3 vMoonDirection = cb12_v204.yzw;
float3 vStarsSamplingDir = cross( vMoonDirection, float3(0, 0, 1) );
float3 vStarsSamplingDir2 = cross( vStarsSamplingDir, vMoonDirection );
float dirX = dot( vWorldToCamera, vStarsSamplingDir2 );
float dirY = dot( vWorldToCamera, vStarsSamplingDir );
float dirZ = dot( vWorldToCamera, vMoonDirection);
float3 dirXYZ = normalize( float3(dirX, dirY, dirZ) );
float3 starsColor = texNightStars.Sample( samplerAnisoWrap, dirXYZ ).rgb;
Примечание для себя: это очень хорошо продуманный код, и мне стоит исследовать его подробнее.
Примечание для читателей: если вы знаете больше об этой операции, то расскажите мне!
Мерцающие звёзды
Ещё один интересный трюк, который бы я хотел исследовать подробнее — это мерцание звёзд. Например, если вы будете бродить в окрестностях Новиграда при ясной погоде, то заметите, что звёзды мерцают.
Мне было любопытно, как это реализовано. Оказалось, что разница между версией 2015 года и «Кровью и вином» довольно велика. Для простоты я буду рассматривать версию 2015 года.
Итак, мы начинаем сразу после сэмплирования starsColor из предыдущего раздела:
174: mul r0.w, v0.x, l(100.000000)
175: round_ni r1.w, r0.w
176: mad r2.w, v0.y, l(50.000000), cb0[0].x
177: round_ni r4.w, r2.w
178: bfrev r4.w, r4.w
179: iadd r5.x, r1.w, r4.w
180: ishr r5.y, r5.x, l(13)
181: xor r5.x, r5.x, r5.y
182: imul null, r5.y, r5.x, r5.x
183: imad r5.y, r5.y, l(0x0000ec4d), l(0.0000000000000000000000000000000000001)
184: imad r5.x, r5.x, r5.y, l(146956042240.000000)
185: and r5.x, r5.x, l(0x7fffffff)
186: itof r5.x, r5.x
187: mad r5.y, v0.x, l(100.000000), l(-1.000000)
188: round_ni r5.y, r5.y
189: iadd r4.w, r4.w, r5.y
190: ishr r5.z, r4.w, l(13)
191: xor r4.w, r4.w, r5.z
192: imul null, r5.z, r4.w, r4.w
193: imad r5.z, r5.z, l(0x0000ec4d), l(0.0000000000000000000000000000000000001)
194: imad r4.w, r4.w, r5.z, l(146956042240.000000)
195: and r4.w, r4.w, l(0x7fffffff)
196: itof r4.w, r4.w
197: add r5.z, r2.w, l(-1.000000)
198: round_ni r5.z, r5.z
199: bfrev r5.z, r5.z
200: iadd r1.w, r1.w, r5.z
201: ishr r5.w, r1.w, l(13)
202: xor r1.w, r1.w, r5.w
203: imul null, r5.w, r1.w, r1.w
204: imad r5.w, r5.w, l(0x0000ec4d), l(0.0000000000000000000000000000000000001)
205: imad r1.w, r1.w, r5.w, l(146956042240.000000)
206: and r1.w, r1.w, l(0x7fffffff)
207: itof r1.w, r1.w
208: mul r1.w, r1.w, l(0.000000001)
209: iadd r5.y, r5.z, r5.y
210: ishr r5.z, r5.y, l(13)
211: xor r5.y, r5.y, r5.z
212: imul null, r5.z, r5.y, r5.y
213: imad r5.z, r5.z, l(0x0000ec4d), l(0.0000000000000000000000000000000000001)
214: imad r5.y, r5.y, r5.z, l(146956042240.000000)
215: and r5.y, r5.y, l(0x7fffffff)
216: itof r5.y, r5.y
217: frc r0.w, r0.w
218: add r0.w, -r0.w, l(1.000000)
219: mul r5.z, r0.w, r0.w
220: mul r0.w, r0.w, r5.z
221: mul r5.xz, r5.xxzx, l(0.000000001, 0.000000, 3.000000, 0.000000)
222: mad r0.w, r0.w, l(-2.000000), r5.z
223: frc r2.w, r2.w
224: add r2.w, -r2.w, l(1.000000)
225: mul r5.z, r2.w, r2.w
226: mul r2.w, r2.w, r5.z
227: mul r5.z, r5.z, l(3.000000)
228: mad r2.w, r2.w, l(-2.000000), r5.z
229: mad r4.w, r4.w, l(0.000000001), -r5.x
230: mad r4.w, r0.w, r4.w, r5.x
231: mad r5.x, r5.y, l(0.000000001), -r1.w
232: mad r0.w, r0.w, r5.x, r1.w
233: add r0.w, -r4.w, r0.w
234: mad r0.w, r2.w, r0.w, r4.w
235: mad r2.xyz, r0.wwww, l(0.000500, 0.000500, 0.000500, 0.000000), r2.xyzx
236: sample_indexable(texturecube)(float,float,float,float) r2.xyz, r2.xyzx, t0.xyzw, s0
237: log r4.xyz, r4.xyzx
238: mul r4.xyz, r4.xyzx, l(2.200000, 2.200000, 2.200000, 0.000000)
239: exp r4.xyz, r4.xyzx
240: log r2.xyz, r2.xyzx
241: mul r2.xyz, r2.xyzx, l(2.200000, 2.200000, 2.200000, 0.000000)
242: exp r2.xyz, r2.xyzx
243: mul r2.xyz, r2.xyzx, r4.xyzx
Хм. Давайте взглянем в конец этого достаточно длинного ассемблерного кода.
После сэмплирования starsColor в строке 173 мы вычисляем какое-то значение offset. Это offset используется для искажения первого направления сэмплирования (r2.xyz, строка 235), а затем снова сэмплируем кубическую карту звёзд, выполняем гамма-коррекцию этих двух значений (237–242) и перемножаем их (243).
Просто, не правда ли? Ну, не совсем. Давайте немного подумаем об этом offset. Это значение должно быть разным на протяжении всего skydome — одинаково мерцающие звёзды выглядели бы очень нереалистично.
Чтобы offset было как можно более разнообразным, мы воспользуемся тем, что UV растянуты на skydome (v0.xy) и применим прошедшее время, хранящееся в буфере констант (cb[0].x).
Если вам незнакомы эти пугающие ishr/xor/and, то в части про эффект молний прочитайте об целочисленном шуме.
Как видите, целочисленный шум вызывается здесь четыре раза, но он отличается от того, который используется для молний. Чтобы сделать результаты ещё более случайными, входящее целое число для шума является суммой (iadd) и с ним выполняется инвертирование битов (внутренняя функция reversebits; инструкция bfrev).
Так, а теперь помедленнее. Давайте начнём с самого начала.
У нас есть 4 «итерации» целочисленного шума. Я проанализировал ассемблерный код, вычисления всех 4 итераций выглядят так:
int getInt( float x )
{
return asint( floor(x) );
}
int getReverseInt( float x )
{
return reversebits( getInt(x) );
}
// * Inputs - UV and elapsed time in seconds
float2 starsUV;
starsUV.x = 100.0 * Input.TextureUV.x;
starsUV.y = 50.0 * Input.TextureUV.y + g_fTime;
// * Iteration 1
int iStars1_A = getReverseInt( starsUV.y );
int iStars1_B = getInt( starsUV.x );
float fStarsNoise1 = integerNoise( iStars1_A + iStars1_B );
// * Iteration 2
int iStars2_A = getReverseInt( starsUV.y );
int iStars2_B = getInt( starsUV.x - 1.0 );
float fStarsNoise2 = integerNoise( iStars2_A + iStars2_B );
// * Iteration 3
int iStars3_A = getReverseInt( starsUV.y - 1.0 );
int iStars3_B = getInt( starsUV.x );
float fStarsNoise3 = integerNoise( iStars3_A + iStars3_B );
// * Iteration 4
int iStars4_A = getReverseInt( starsUV.y - 1.0 );
int iStars4_B = getInt( starsUV.x - 1.0 );
float fStarsNoise4 = integerNoise( iStars4_A + iStars4_B );
Конечные выходные данные всех 4 итераций (чтобы найти их, проследите за инструкциями itof):
Итерация 1 — r5.x,
Итерация 2 — r4.w,
Итерация 3 — r1.w,
Итерация 4 — r5.y
После последней itof (строка 216) мы имеем:
217: frc r0.w, r0.w
218: add r0.w, -r0.w, l(1.000000)
219: mul r5.z, r0.w, r0.w
220: mul r0.w, r0.w, r5.z
221: mul r5.xz, r5.xxzx, l(0.000000001, 0.000000, 3.000000, 0.000000)
222: mad r0.w, r0.w, l(-2.000000), r5.z
223: frc r2.w, r2.w
224: add r2.w, -r2.w, l(1.000000)
225: mul r5.z, r2.w, r2.w
226: mul r2.w, r2.w, r5.z
227: mul r5.z, r5.z, l(3.000000)
228: mad r2.w, r2.w, l(-2.000000), r5.z
Эти строки вычисляют значения S-образной кривой для весов на основании дробной части UV, как и в случае с молниями. Итак:
float s_curve( float x )
{
float x2 = x * x;
float x3 = x2 * x;
// -2x^3 + 3x^2
return -2.0*x3 + 3.0*x2;
}
...
// lines 217-222
float weightX = 1.0 - frac( starsUV.x );
weightX = s_curve( weightX );
// lines 223-228
float weightY = 1.0 - frac( starsUV.y );
weightY = s_curve( weightY );
Как и можно ожидать, эти коэффициенты используются для плавной интерполяции шума и генерации окончательного смещения для координат сэмплирования:
229: mad r4.w, r4.w, l(0.000000001), -r5.x
230: mad r4.w, r0.w, r4.w, r5.x
float noise0 = lerp( fStarsNoise1, fStarsNoise2, weightX );
231: mad r5.x, r5.y, l(0.000000001), -r1.w
232: mad r0.w, r0.w, r5.x, r1.w
float noise1 = lerp( fStarsNoise3, fStarsNoise4, weightX );
233: add r0.w, -r4.w, r0.w
234: mad r0.w, r2.w, r0.w, r4.w
float offset = lerp( noise0, noise1, weightY );
235: mad r2.xyz, r0.wwww, l(0.000500, 0.000500, 0.000500, 0.000000), r2.xyzx
236: sample_indexable(texturecube)(float,float,float,float) r2.xyz, r2.xyzx, t0.xyzw, s0
float3 starsPerturbedDir = dirXYZ + offset * 0.0005;
float3 starsColorDisturbed = texNightStars.Sample( samplerAnisoWrap, starsPerturbedDir ).rgb;
Вот небольшая визуализация вычисленного offset:
После вычисления starsColorDisturbed самая сложная часть завершена. Ура!
Следующий этап — выполнение гамма-коррекции и для starsColor, и для starsColorDisturbed, после чего они перемножаются:
starsColor = pow( starsColor, 2.2 );
starsColorDisturbed = pow( starsColorDisturbed, 2.2 );
float3 starsFinal = starsColor * starsColorDisturbed;
Звёзды — финальные штрихи
У нас есть starsFinal in r1.xyz. В конце обработки звёзд происходит следующее:
256: log r1.xyz, r1.xyzx
257: mul r1.xyz, r1.xyzx, l(2.500000, 2.500000, 2.500000, 0.000000)
258: exp r1.xyz, r1.xyzx
259: min r1.xyz, r1.xyzx, l(1.000000, 1.000000, 1.000000, 0.000000)
260: add r0.w, -cb0[9].w, l(1.000000)
261: mul r1.xyz, r0.wwww, r1.xyzx
262: mul r1.xyz, r1.xyzx, l(10.000000, 10.000000, 10.000000, 0.000000)
Это гораздо проще по сравнению с мерцающими и движущимися звёздами.
Итак, мы начинаем с возведения starsFinal в степень 2.5 — это позволяет нам контролировать плотность звёзд. Довольно умно. Затем мы делаем так, чтобы максимальный цвет звёзд был равен float3(1, 1, 1).
cb0[9].w используется для управления общей видимостью звёзд. Поэтому можно ожидать, что в дневное время это значение равно 1.0 (что даёт умножение на ноль), а ночью — 0.0.
В конце мы увеличиваем видимость звёзд на 10. И на этом всё!
Часть 3. Ведьмачье чутьё (объекты и карта яркости)
Почти все описанные ранее эффекты и техники на самом деле не были связаны с Witcher 3. Такие вещи, как тональная коррекция, виньетирование или вычисление средней яркости присутствуют практически в каждой современной игре. Даже эффект опьянения распространён довольно широко.
Именно поэтому я решил внимательнее присмотреться к механикам рендеринга «ведьмачьего чутья». Геральт — ведьмак, а потому его чувства гораздо острее, чем у обычного человека. Следовательно, он может видеть и слышать больше, чем другие люди, что сильно помогает ему в расследованиях. Механика ведьмачьего чутья позволяет игроку визуализировать такие следы.
Вот демонстрация эффекта:
И ещё одна, с освещением получше:
Как видите, есть два типа объектов: те, с которыми Геральт может взаимодействовать (жёлтый контру) и следы, связанные с расследованием (красный контур). После того, как Геральт исследует красный след, он может превратиться в жёлтый (первое видео). Заметьте, что весь экран становится серее и добавляется эффект «рыбьего глаза (второе видео).
Этот эффект довольно сложен, поэтому я решил разделить его исследование на три части.
В первой я расскажу о выборе объектов, во второй — о генерации контура, а в третьей — о финальном объединении всего этого в одно целое.
Выбор объектов
Как я и говорил, существует два типа объектов, и нам нужно их различать. В Witcher 3 это реализовано с помощью стенсил-буфера. При генерации мешей GBuffer, которые должны быть помечены как «следы» (красные), они рендерятся со stencil = 8. Меши, помеченные жёлтым цветом как «интересные» объекты, рендерятся со stencil = 4.
Например, следующие две текстуры показывают пример кадра с видимым ведьмачьим чутьём и соответствующий стенсил-буфер:
Вкратце о стенсил-буфере
Стенсил-буфер довольно часто используется в играх для пометки мешей. Определённым категориям мешей назначается одинаковый ID.
Идея заключается в том, чтобы использовать функцию Always с оператором Replace, если стенсил-тест оказался успешным, и с оператором Keep во всех остальных случаях.
Вот как это реализуется с помощью D3D11:
D3D11_DEPTH_STENCIL_DESC depthstencilState;
// Set depth parameters....
// Enable stencil
depthstencilState.StencilEnable = TRUE;
// Read & write all bits
depthstencilState.StencilReadMask = 0xFF;
depthstencilState.StencilWriteMask = 0xFF;
// Stencil operator for front face
depthstencilState.FrontFace.StencilFunc = D3D11_COMPARISON_ALWAYS;
depthstencilState.FrontFace.StencilDepthFailOp = D3D11_STENCIL_OP_KEEP;
depthstencilState.FrontFace.StencilFailOp = D3D11_STENCIL_OP_KEEP;
depthstencilState.FrontFace.StencilPassOp = D3D11_STENCIL_OP_REPLACE;
// Stencil operator for back face.
depthstencilState.BackFace.StencilFunc = D3D11_COMPARISON_ALWAYS;
depthstencilState.BackFace.StencilDepthFailOp = D3D11_STENCIL_OP_KEEP;
depthstencilState.BackFace.StencilFailOp = D3D11_STENCIL_OP_KEEP;
depthstencilState.BackFace.StencilPassOp = D3D11_STENCIL_OP_REPLACE;
pDevice->CreateDepthStencilState( &depthstencilState, &m_pDS_AssignValue );
Значение стенсила, которое нужно записать в буфер, передаётся как StencilRef в вызове API:
// from now on set stencil buffer values to 8
pDevCon->OMSetDepthStencilState( m_pDS_AssignValue, 8 );
...
pDevCon->DrawIndexed( ... );
Яркость рендеринга
В этом проходе с точки зрения реализации есть одна полноэкранная текстура в формате R11G11B10_FLOAT, в которую интересные объекты и следы сохраняются в каналы R и G.
Зачем это нужно нам с точки зрения яркости? Оказывается, что чутьё Геральта имеет ограниченный радиус, поэто