[Перевод] Реверс-инжиниринг рендеринга «Ведьмака 3»: различные эффекты неба
[Предыдущие части анализа: первая и вторая и третья.]
Часть 1. Перистые облака
Когда действие игры происходит на открытых пространствах, одним из факторов, определяющих правдоподобность мира, является небо. Задумайтесь об этом — бОльшую часть времени небо в буквальном смысле занимает примерно 40–50% всего экрана. Небо — это намного больше, чем красивый градиент. На нём есть звёзды, солнце, луна и, наконец, облака.
Хотя современные тенденции, похоже, заключаются в объёмном рендеринге облаков при помощи raymarching-а (см. эту статью), облака в «Ведьмаке 3» полностью основаны на текстурах. Я уже рассматривал их ранее, но оказалось, что с ними всё сложнее, чем я изначально ожидал. Если вы следили за моей серией статей, то знаете, что есть разница между DLC «Кровь и вино» и остальной игрой. И, как можно догадаться, в DLC есть некоторые изменения и в работе с облаками.
В «Ведьмаке 3» есть несколько слоёв облаков. В зависимости от погоды это могут быть только перистые облака, высококучевые облака, возможно, немного облаков из семейства слоистых облаков (например, во время бури). В конце концов, облаков может не быть вовсе.
Некоторые слои отличаются с точки зрения текстур и шейдеров, используемых для их рендеринга. Очевидно, что это влияет на сложность и длину ассемблерного кода пиксельного шейдера.
Несмотря на всё это разнообразие, существуют некие общие паттерны, которые можно наблюдать при рендеринге облаков в Witcher 3. Во-первых, все они рендерятся в упреждающем проходе, и это совершенно правильный выбор. Все они используют смешивание (см. ниже). Благодаря этому гораздо проще управлять тем, как отдельный слой покрывает небо — на это влияет значение альфы из пиксельного шейдера.
Что более интересно, некоторые слои рендерятся дважды с одинаковыми параметрами.
После просмотра кода я выбрал самый короткий шейдер, чтобы (1) с наибольшей вероятностью выполнить его полный реверс-инжиниринг, (2) разобраться во всех его аспектах.
Я внимательнее присмотрелся в перистым облакам из Witcher 3: Blood and Wine.
Вот пример кадра:
До рендеринга
После первого прохода рендеринга
После второго прохода рендеринга
В этом конкретном кадре перистые облака — это первый слой при рендеринге. Как вы видите, он рендерится дважды, что повышает его яркость.
Геометрический и вершинный шейдер
Перед пиксельным шейдером вкратце расскажем об использованном геометрическом и вершинном шейдере. Меш для отображения облаков немного похож на обычный купол неба:
Все вершины находятся в интервале [0–1], поэтому чтобы центрировать меш на точке (0,0,0), перед преобразованием в worldViewProj используются масштабирование и отклонение (нам уже знаком этот паттерн из предыдущих частей серии). В случае облаков меш сильно растягивается вдоль плоскости XY (ось Z направлена вверх), чтобы закрыть больше пространства, чем пирамида видимости. Результат получается таким:
Кроме этого, меш имеет векторы нормалей и касательных. Также вершинный шейдер векторным произведением вычисляет вектор бикасательной — все три выводятся в нормализованном виде. Ещё присутствует повершинное вычисление тумана (его цвет и яркость).
Пиксельный шейдер
Ассемблерный код пиксельного шейдера выглядит так:
ps_5_0
dcl_globalFlags refactoringAllowed
dcl_constantbuffer cb0[10], immediateIndexed
dcl_constantbuffer cb1[9], immediateIndexed
dcl_constantbuffer cb12[238], immediateIndexed
dcl_constantbuffer cb4[13], immediateIndexed
dcl_sampler s0, mode_default
dcl_resource_texture2d (float,float,float,float) t0
dcl_resource_texture2d (float,float,float,float) t1
dcl_input_ps linear v0.xyzw
dcl_input_ps linear v1.xyzw
dcl_input_ps linear v2.w
dcl_input_ps linear v3.xyzw
dcl_input_ps linear v4.xyz
dcl_input_ps linear v5.xyz
dcl_output o0.xyzw
dcl_temps 4
0: mul r0.xyz, cb0[9].xyzx, l(1.000000, 1.000000, -1.000000, 0.000000)
1: dp3 r0.w, r0.xyzx, r0.xyzx
2: rsq r0.w, r0.w
3: mul r0.xyz, r0.wwww, r0.xyzx
4: mul r1.xy, cb0[0].xxxx, cb4[5].xyxx
5: mad r1.xy, v1.xyxx, cb4[4].xyxx, r1.xyxx
6: sample_indexable(texture2d)(float,float,float,float) r1.xyzw, r1.xyxx, t0.xyzw, s0
7: add r1.xyz, r1.xyzx, l(-0.500000, -0.500000, -0.500000, 0.000000)
8: add r1.xyz, r1.xyzx, r1.xyzx
9: dp3 r0.w, r1.xyzx, r1.xyzx
10: rsq r0.w, r0.w
11: mul r1.xyz, r0.wwww, r1.xyzx
12: mul r2.xyz, r1.yyyy, v3.xyzx
13: mad r2.xyz, v5.xyzx, r1.xxxx, r2.xyzx
14: mov r3.xy, v1.zwzz
15: mov r3.z, v3.w
16: mad r1.xyz, r3.xyzx, r1.zzzz, r2.xyzx
17: dp3_sat r0.x, r0.xyzx, r1.xyzx
18: add r0.y, -cb4[2].x, cb4[3].x
19: mad r0.x, r0.x, r0.y, cb4[2].x
20: dp2 r0.y, -cb0[9].xyxx, -cb0[9].xyxx
21: rsq r0.y, r0.y
22: mul r0.yz, r0.yyyy, -cb0[9].xxyx
23: add r1.xyz, -v4.xyzx, cb1[8].xyzx
24: dp3 r0.w, r1.xyzx, r1.xyzx
25: rsq r1.z, r0.w
26: sqrt r0.w, r0.w
27: add r0.w, r0.w, -cb4[7].x
28: mul r1.xy, r1.zzzz, r1.xyxx
29: dp2_sat r0.y, r0.yzyy, r1.xyxx
30: add r0.y, r0.y, r0.y
31: min r0.y, r0.y, l(1.000000)
32: add r0.z, -cb4[0].x, cb4[1].x
33: mad r0.z, r0.y, r0.z, cb4[0].x
34: mul r0.x, r0.x, r0.z
35: log r0.x, r0.x
36: mul r0.x, r0.x, l(2.200000)
37: exp r0.x, r0.x
38: add r1.xyz, cb12[236].xyzx, -cb12[237].xyzx
39: mad r1.xyz, r0.yyyy, r1.xyzx, cb12[237].xyzx
40: mul r2.xyz, r0.xxxx, r1.xyzx
41: mad r0.xyz, -r1.xyzx, r0.xxxx, v0.xyzx
42: mad r0.xyz, v0.wwww, r0.xyzx, r2.xyzx
43: add r1.x, -cb4[7].x, cb4[8].x
44: div_sat r0.w, r0.w, r1.x
45: mul r1.x, r1.w, cb4[9].x
46: mad r1.y, -cb4[9].x, r1.w, r1.w
47: mad r0.w, r0.w, r1.y, r1.x
48: mul r1.xy, cb0[0].xxxx, cb4[11].xyxx
49: mad r1.xy, v1.xyxx, cb4[10].xyxx, r1.xyxx
50: sample_indexable(texture2d)(float,float,float,float) r1.x, r1.xyxx, t1.xyzw, s0
51: mad r1.x, r1.x, cb4[12].x, -cb4[12].x
52: mad_sat r1.x, cb4[12].x, v2.w, r1.x
53: mul r0.w, r0.w, r1.x
54: mul_sat r0.w, r0.w, cb4[6].x
55: mul o0.xyz, r0.wwww, r0.xyzx
56: mov o0.w, r0.w
57: ret
На вход подаются две бесшовные текстуры. Одна из них содержит карту нормалей (каналы xyz) и форму облака (канал a). Вторая — это шум для искажения формы.
Карта нормалей, собственность CD Projekt Red
Форма облака, собственность CD Projekt Red
Текстура шума, собственность CD Projekt Red
Основной буфер констант с параметрами облаков — это cb4. Для данного кадра он имеет следующие значения:
Кроме этого, используются другие значения из других cbuffer-ов. Не волнуйтесь, их мы тоже рассмотрим.
Инвертированное по оси Z направление солнечного света
Первое, что происходит в шейдере — вычисление нормализованного направления солнечного света, инвертированного по оси Z:
0: mul r0.xyz, cb0[9].xyzx, l(1.000000, 1.000000, -1.000000, 0.000000)
1: dp3 r0.w, r0.xyzx, r0.xyzx
2: rsq r0.w, r0.w
3: mul r0.xyz, r0.wwww, r0.xyzx
float3 invertedSunlightDir = normalize(lightDir * float3(1, 1, -1) );
Как говорилось ранее, ось Z направлена вверх, а cb0[9] — это направление солнечного света. Этот вектор направлен на Солнце — это важно! Вы можете убедиться в этом, написав простой вычислительный шейдер, выполняющий простое NdotL, и вставив его в проход отложенного затенения.
Сэмплирование текстуры облаков
Следующий шаг — это вычисление texcoords для сэмплирования текстуры облаков, распаковка вектора нормали и его нормализация.
4: mul r1.xy, cb0[0].xxxx, cb4[5].xyxx
5: mad r1.xy, v1.xyxx, cb4[4].xyxx, r1.xyxx
6: sample_indexable(texture2d)(float,float,float,float) r1.xyzw, r1.xyxx, t0.xyzw, s0
7: add r1.xyz, r1.xyzx, l(-0.500000, -0.500000, -0.500000, 0.000000)
8: add r1.xyz, r1.xyzx, r1.xyzx
9: dp3 r0.w, r1.xyzx, r1.xyzx
10: rsq r0.w, r0.w
// Calc sampling coords
float2 cloudTextureUV = Texcoords * textureScale + elapsedTime * speedFactors;
// Sample texture and get data from it
float4 cloudTextureValue = texture0.Sample( sampler0, cloudTextureUV ).rgba;
float3 normalMap = cloudTextureValue.xyz;
float cloudShape = cloudTextureValue.a;
// Unpack normal and normalize it
float3 unpackedNormal = (normalMap - 0.5) * 2.0;
unpackedNormal = normalize(unpackedNormal);
Давайте постепенно с этим разбираться.
Чтобы получить движение облаков, нам необходимо прошедшее время в секундах (cb[0].x) умноженное на коэффициент скорости, влияющий на то, как быстро облака движутся по небу (cb4[5].xy).
Как я говорил ранее, UV растянуты по геометрии купола неба, и нам также нужны коэффициенты масштабирования текстур, влияющие на размер облаков (cb4[4].xy).
Окончательная формула имеет вид:
samplingUV = Input.TextureUV * textureScale + time * speedMultiplier;
После сэмплирования всех 4 каналов у нас есть карта нормалей (каналы rgb) и форма облака (канал a).
Для распаковки карты нормалей из интервала [0; 1] в интервал [-1; 1] мы используем следующую формулу:
unpackedNormal = (packedNormal - 0.5) * 2.0;
Также можно использовать такую:
unpackedNormal = packedNormal * 2.0 - 1.0;
И наконец мы нормализуем распакованный вектор нормали.
Наложение нормалей
Имея векторы нормали, касательной и бикасательной из вершинного шейдера и вектор нормали из карты нормалей, мы обычным способом выполняем наложение нормалей.
11: mul r1.xyz, r0.wwww, r1.xyzx
12: mul r2.xyz, r1.yyyy, v3.xyzx
13: mad r2.xyz, v5.xyzx, r1.xxxx, r2.xyzx
14: mov r3.xy, v1.zwzz
15: mov r3.z, v3.w
16: mad r1.xyz, r3.xyzx, r1.zzzz, r2.xyzx
// Perform bump mapping
float3 SkyTangent = Input.Tangent;
float3 SkyNormal = (float3( Input.Texcoords.zw, Input.param3.w ));
float3 SkyBitangent = Input.param3.xyz;
float3x3 TBN = float3x3(SkyTangent, SkyBitangent, SkyNormal);
float3 finalNormal = (float3)mul( unpackedNormal, (TBN) );
Яркость засветов (1)
В следующем шаге применяется вычисление NdotL и это влияет на величину засветки определённого пикселя.
Рассмотрим следующий ассемблерный код:
17: dp3_sat r0.x, r0.xyzx, r1.xyzx
18: add r0.y, -cb4[2].x, cb4[3].x
19: mad r0.x, r0.x, r0.y, cb4[2].x
Вот визуализация NdotL на рассматриваемом кадре:
Это скалярное произведение (с насыщенностью) используется для выполнения интерполяции между minIntensity и maxIntensity. Благодаря этому части облаков, освещённые солнечным светом, будут более яркими.
// Calculate cosine between normal and up-inv lightdir
float NdotL = saturate( dot(invertedSunlightDir, finalNormal) );
// Param 1, line 19, r0.x
float intensity1 = lerp( param1Min, param1Max, NdotL );
Яркость засветов (2)
Есть ещё один фактор, влияющий на яркость облаков.
Облака, находящиеся в той части неба, где есть солнце, должны быть более подсвеченными. Для этого мы вычисляем градиент на основании плоскости XY.
Этот градиент используется для вычисления линейной интерполяции между значениями min/max, аналогично тому, что происходит в части (1).
То есть теоретически мы можем попросить затемнить облака, находящиеся на противоположной от солнца стороне, но в данном конкретном кадре этого не происходит, потому что param2Min и param2Max (cb4[0].x и cb4[1].x) присвоено значение 1.0f.
20: dp2 r0.y, -cb0[9].xyxx, -cb0[9].xyxx
21: rsq r0.y, r0.y
22: mul r0.yz, r0.yyyy, -cb0[9].xxyx
23: add r1.xyz, -v4.xyzx, cb1[8].xyzx
24: dp3 r0.w, r1.xyzx, r1.xyzx
25: rsq r1.z, r0.w
26: sqrt r0.w, r0.w
27: add r0.w, r0.w, -cb4[7].x
28: mul r1.xy, r1.zzzz, r1.xyxx
29: dp2_sat r0.y, r0.yzyy, r1.xyxx
30: add r0.y, r0.y, r0.y
31: min r0.y, r0.y, l(1.000000)
32: add r0.z, -cb4[0].x, cb4[1].x
33: mad r0.z, r0.y, r0.z, cb4[0].x
34: mul r0.x, r0.x, r0.z
35: log r0.x, r0.x
36: mul r0.x, r0.x, l(2.200000)
37: exp r0.x, r0.x
// Calculate normalized -lightDir.xy (20-22)
float2 lightDirXY = normalize( -lightDir.xy );
// Calculate world to camera
float3 vWorldToCamera = ( CameraPos - WorldPos );
float worldToCamera_distance = length(vWorldToCamera);
// normalize vector
vWorldToCamera = normalize( vWorldToCamera );
float LdotV = saturate( dot(lightDirXY, vWorldToCamera.xy) );
float highlightedSkySection = saturate( 2*LdotV );
float intensity2 = lerp( param2Min, param2Max, highlightedSkySection );
float finalIntensity = pow( intensity2 *intensity1, 2.2);
В самом конце мы перемножаем обе яркости и возводим результат в степень 2.2.
Цвет облаков
Вычисление цвета облаков начинается с получения из буфера констант двух значений, обозначающих цвет облаков рядом с солнцем и облаков на противоположной части неба. Между ними выполняется линейная интерполяция на основании highlightedSkySection.
Затем результат умножается на finalIntensity.
А в конце результат смешивается с туманом (из соображений производительности он был вычислен вершинным шейдером).
38: add r1.xyz, cb12[236].xyzx, -cb12[237].xyzx
39: mad r1.xyz, r0.yyyy, r1.xyzx, cb12[237].xyzx
40: mul r2.xyz, r0.xxxx, r1.xyzx
41: mad r0.xyz, -r1.xyzx, r0.xxxx, v0.xyzx
42: mad r0.xyz, v0.wwww, r0.xyzx, r2.xyzx
float3 cloudsColor = lerp( cloudsColorBack, cloudsColorFront, highlightedSunSection );
cloudsColor *= finalIntensity;
cloudsColor = lerp( cloudsColor, FogColor, FogAmount );
Делаем так, чтобы перистые облака были сильнее заметны на горизонте
На кадре этого не очень заметно, но на самом деле этот слой более видим рядом с горизонтом, чем над головой Геральта. Вот как это делается.
Можно было заметить, что при вычислении второй яркости мы вычислили длину вектора worldToCamera:
23: add r1.xyz, -v4.xyzx, cb1[8].xyzx
24: dp3 r0.w, r1.xyzx, r1.xyzx
25: rsq r1.z, r0.w
26: sqrt r0.w, r0.w
Давайте найдём следующие вхождения этой длины в коде:
26: sqrt r0.w, r0.w
27: add r0.w, r0.w, -cb4[7].x
...
43: add r1.x, -cb4[7].x, cb4[8].x
44: div_sat r0.w, r0.w, r1.x
Ого, что это тут у нас?
cb[7].x и cb[8].x имеют значения 2000.0 и 7000.0.
Оказывается, что это результат применения функции linstep.
Она получает три параметра: min/max — интервал и v — значение.
Это работает следующим образом: если v находится в интервале [min-max], то функция возвращает линейную интерполяцию в интервале [0.0 — 1.0]. С другой стороны, если v находится вне интервала, то linstep возвращает 0.0 или 1.0.
Простой пример:
linstep( 1000.0, 2000.0, 999.0) = 0.0
linstep( 1000.0, 2000.0, 1500.0) = 0.5
linstep( 1000.0, 2000.0, 2000.0) = 1.0
То есть она довольно похожа на smoothstep из HLSL, за исключением того, что в этом случае вместо эрмитовой интерполяции выполняется линейная.
Функции Linstep нет в HLSL, но она очень полезна. Стоит иметь её в своём инструментарии.
// linstep:
//
// Returns a linear interpolation between 0 and 1 if t is in the range [min, max]
// if "v" is <= min, the output is 0
// if "v" i >= max, the output is 1
float linstep( float min, float max, float v )
{
return saturate( (v - min) / (max - min) );
}
Вернёмся к Witcher 3: после вычисления этого показателя, сообщающего, насколько далеко конкретная часть неба находится от Геральта, мы используем его для ослабления яркости облаков:
45: mul r1.x, r1.w, cb4[9].x
46: mad r1.y, -cb4[9].x, r1.w, r1.w
47: mad r0.w, r0.w, r1.y, r1.x
float distanceAttenuation = linstep( fadeDistanceStart, fadeDistanceEnd, worldToCamera_distance );
float fadedCloudShape = closeCloudsHidingFactor * cloudShape;
cloudShape = lerp( fadedCloudShape, cloudShape, distanceAttenuation );
cloudShape — это канал .a из первой текстуры, а closeCloudsHidingFactor — значение из буфера констант, управляющее уровнем видимости облаков над головой Геральта. Во всех протестированных мной кадрах оно было равно 0.0, что равносильно отсутствию облаков. Когда distanceAttenuation приближается к 1.0 (расстояние от камеры до купола неба увеличивается), облака становятся всё более видимыми.
Сэмплирование текстуры шума
Вычисление координат сэмплирования текстуры шума аналогично вычислениям для текстуры облаков, за исключением того, что используется другой набор textureScale и speedMultiplier.
Разумеется, для сэмплирования всех этих текстур используется сэмплер со включенным режимом адресации wrap.
48: mul r1.xy, cb0[0].xxxx, cb4[11].xyxx
49: mad r1.xy, v1.xyxx, cb4[10].xyxx, r1.xyxx
50: sample_indexable(texture2d)(float,float,float,float) r1.x, r1.xyxx, t1.xyzw, s0
// Calc sampling coords for noise
float2 noiseTextureUV = Texcoords * textureScaleNoise + elapsedTime * speedFactorsNoise;
// Sample texture and get data from it
float noiseTextureValue = texture1.Sample( sampler0, noiseTextureUV ).x;
Соединяем всё это вместе
Получив значение шума, мы должны скомбинировать его с cloudShape.
У меня возникли некоторые проблемы с пониманием этих строк, где есть param2.w (который всегда равен 1.0) и noiseMult (имеет значение 5.0, взятое из буфера констант).
Как бы то ни было, самое важное здесь — это окончательное значение generalCloudsVisibility, влияющее на степень видимости облаков.
Взгляните также на окончательное значение шума. Выходной цвет cloudsColor умножается на окончательный шум, который тоже выводится в альфа-канал.
51: mad r1.x, r1.x, cb4[12].x, -cb4[12].x
52: mad_sat r1.x, cb4[12].x, v2.w, r1.x
53: mul r0.w, r0.w, r1.x
54: mul_sat r0.w, r0.w, cb4[6].x
55: mul o0.xyz, r0.wwww, r0.xyzx
56: mov o0.w, r0.w
57: ret
// Sample noise texture and get data from it
float noiseTextureValue = texture1.Sample( sampler0, noiseTextureUV ).x;
noiseTextureValue = noiseTextureValue * noiseMult - noiseMult;
float noiseValue = saturate( noiseMult * Input.param2.w + noiseTextureValue);
noiseValue *= cloudShape;
float finalNoise = saturate( noiseValue * generalCloudsVisibility);
return float4( cloudsColor*finalNoise, finalNoise );
Итог
Готовый результат выглядит очень правдоподобным.
Можете сравнить. Первая картинка — мой шейдер, вторая — шейдер игры:
Если вам любопытно, то шейдер выложен здесь.
Часть 2. Туман
Туман можно реализовать различными способами. Однако времена, когда мы могли наложить простой зависящий от расстояния туман и покончить с этим, остались навсегда в прошлом (скорее всего). Жизнь в мире программируемых шейдеров открыла нам двери к новым безумным, но, что более важно, — физически точным и визуально реалистичным решениям.
Современные тенденции в рендеринге тумана основаны на вычислительных шейдерах (подробности см. в этой презентации Барта Вронски).
Несмотря на то, что эта презентация появилась в 2014 году, а «Ведьмак 3» был выпущен в 2015/2016 годах, туман в последней части приключений Геральта полностью зависит от экрана и реализован как типичная постобработка.
Прежде чем мы начнём очередную сессию реверс-инжиниринга, должен сказать, что за прошлый год я пытался разобраться в тумане Witcher 3 по крайней мере пять раз, и каждый раз терпел неудачу. Ассемблерный код, как вы скоро увидите, довольно сложен, и это делает процесс создания удобочитаемого шейдера тумана на HLSL почти невозможным.
Однако мне удалось найти в Интернете шейдер тумана, который сразу же привлёк моё внимание благодаря своей схожести с туманом «Ведьмака 3» с точки зрения имён переменных и общего порядка инструкций. Этот шейдер не был точно таким же, как в игре, поэтому мне пришлось его немного переработать. Я хочу этим сказать, что основная часть кода на HLSL, который вы здесь увидите, была, за двумя исключениями, создана/проанализирована не мной. Помните об этом.
Вот ассемблерный код пиксельного шейдера тумана — стоит заметить, что он одинаков для всей игры (основной части 2015 года и обеих DLC):
ps_5_0
dcl_globalFlags refactoringAllowed
dcl_constantbuffer cb3[2], immediateIndexed
dcl_constantbuffer cb12[214], immediateIndexed
dcl_resource_texture2d (float,float,float,float) t0
dcl_resource_texture2d (float,float,float,float) t1
dcl_resource_texture2d (float,float,float,float) t2
dcl_input_ps_siv v0.xy, position
dcl_output o0.xyzw
dcl_temps 7
0: ftou r0.xy, v0.xyxx
1: mov r0.zw, l(0, 0, 0, 0)
2: ld_indexable(texture2d)(float,float,float,float) r1.x, r0.xyww, t0.xyzw
3: mad r1.y, r1.x, cb12[22].x, cb12[22].y
4: lt r1.y, r1.y, l(1.000000)
5: if_nz r1.y
6: utof r1.yz, r0.xxyx
7: mul r2.xyzw, r1.zzzz, cb12[211].xyzw
8: mad r2.xyzw, cb12[210].xyzw, r1.yyyy, r2.xyzw
9: mad r1.xyzw, cb12[212].xyzw, r1.xxxx, r2.xyzw
10: add r1.xyzw, r1.xyzw, cb12[213].xyzw
11: div r1.xyz, r1.xyzx, r1.wwww
12: ld_indexable(texture2d)(float,float,float,float) r2.xyz, r0.xyww, t1.xyzw
13: ld_indexable(texture2d)(float,float,float,float) r0.x, r0.xyzw, t2.xyzw
14: max r0.x, r0.x, cb3[1].x
15: add r0.yzw, r1.xxyz, -cb12[0].xxyz
16: dp3 r1.x, r0.yzwy, r0.yzwy
17: sqrt r1.x, r1.x
18: add r1.y, r1.x, -cb3[0].x
19: add r1.zw, -cb3[0].xxxz, cb3[0].yyyw
20: div_sat r1.y, r1.y, r1.z
21: mad r1.y, r1.y, r1.w, cb3[0].z
22: add r0.x, r0.x, l(-1.000000)
23: mad r0.x, r1.y, r0.x, l(1.000000)
24: div r0.yzw, r0.yyzw, r1.xxxx
25: mad r1.y, r0.w, cb12[22].z, cb12[0].z
26: add r1.x, r1.x, -cb12[22].z
27: max r1.x, r1.x, l(0)
28: min r1.x, r1.x, cb12[42].z
29: mul r1.z, r0.w, r1.x
30: mul r1.w, r1.x, cb12[43].x
31: mul r1.zw, r1.zzzw, l(0.000000, 0.000000, 0.062500, 0.062500)
32: dp3 r0.y, cb12[38].xyzx, r0.yzwy
33: add r0.z, r0.y, cb12[42].x
34: add r0.w, cb12[42].x, l(1.000000)
35: div_sat r0.z, r0.z, r0.w
36: add r0.w, -cb12[43].z, cb12[43].y
37: mad r0.z, r0.z, r0.w, cb12[43].z
38: mul r0.w, abs(r0.y), abs(r0.y)
39: mad_sat r2.w, r1.x, l(0.002000), l(-0.300000)
40: mul r0.w, r0.w, r2.w
41: lt r0.y, l(0), r0.y
42: movc r3.xyz, r0.yyyy, cb12[39].xyzx, cb12[41].xyzx
43: add r3.xyz, r3.xyzx, -cb12[40].xyzx
44: mad r3.xyz, r0.wwww, r3.xyzx, cb12[40].xyzx
45: movc r4.xyz, r0.yyyy, cb12[45].xyzx, cb12[47].xyzx
46: add r4.xyz, r4.xyzx, -cb12[46].xyzx
47: mad r4.xyz, r0.wwww, r4.xyzx, cb12[46].xyzx
48: ge r0.y, r1.x, cb12[48].y
49: if_nz r0.y
50: add r0.y, r1.y, cb12[42].y
51: mul r0.w, r0.z, r0.y
52: mul r1.y, r0.z, r1.z
53: mad r5.xyzw, r1.yyyy, l(16.000000, 15.000000, 14.000000, 13.000000), r0.wwww
54: max r5.xyzw, r5.xyzw, l(0, 0, 0, 0)
55: add r5.xyzw, r5.xyzw, l(1.000000, 1.000000, 1.000000, 1.000000)
56: div_sat r5.xyzw, r1.wwww, r5.xyzw
57: add r5.xyzw, -r5.xyzw, l(1.000000, 1.000000, 1.000000, 1.000000)
58: mul r1.z, r5.y, r5.x
59: mul r1.z, r5.z, r1.z
60: mul r1.z, r5.w, r1.z
61: mad r5.xyzw, r1.yyyy, l(12.000000, 11.000000, 10.000000, 9.000000), r0.wwww
62: max r5.xyzw, r5.xyzw, l(0, 0, 0, 0)
63: add r5.xyzw, r5.xyzw, l(1.000000, 1.000000, 1.000000, 1.000000)
64: div_sat r5.xyzw, r1.wwww, r5.xyzw
65: add r5.xyzw, -r5.xyzw, l(1.000000, 1.000000, 1.000000, 1.000000)
66: mul r1.z, r1.z, r5.x
67: mul r1.z, r5.y, r1.z
68: mul r1.z, r5.z, r1.z
69: mul r1.z, r5.w, r1.z
70: mad r5.xyzw, r1.yyyy, l(8.000000, 7.000000, 6.000000, 5.000000), r0.wwww
71: max r5.xyzw, r5.xyzw, l(0, 0, 0, 0)
72: add r5.xyzw, r5.xyzw, l(1.000000, 1.000000, 1.000000, 1.000000)
73: div_sat r5.xyzw, r1.wwww, r5.xyzw
74: add r5.xyzw, -r5.xyzw, l(1.000000, 1.000000, 1.000000, 1.000000)
75: mul r1.z, r1.z, r5.x
76: mul r1.z, r5.y, r1.z
77: mul r1.z, r5.z, r1.z
78: mul r1.z, r5.w, r1.z
79: mad r5.xy, r1.yyyy, l(4.000000, 3.000000, 0.000000, 0.000000), r0.wwww
80: max r5.xy, r5.xyxx, l(0, 0, 0, 0)
81: add r5.xy, r5.xyxx, l(1.000000, 1.000000, 0.000000, 0.000000)
82: div_sat r5.xy, r1.wwww, r5.xyxx
83: add r5.xy, -r5.xyxx, l(1.000000, 1.000000, 0.000000, 0.000000)
84: mul r1.z, r1.z, r5.x
85: mul r1.z, r5.y, r1.z
86: mad r0.w, r1.y, l(2.000000), r0.w
87: max r0.w, r0.w, l(0)
88: add r0.w, r0.w, l(1.000000)
89: div_sat r0.w, r1.w, r0.w
90: add r0.w, -r0.w, l(1.000000)
91: mul r0.w, r0.w, r1.z
92: mad r0.y, r0.y, r0.z, r1.y
93: max r0.y, r0.y, l(0)
94: add r0.y, r0.y, l(1.000000)
95: div_sat r0.y, r1.w, r0.y
96: add r0.y, -r0.y, l(1.000000)
97: mad r0.y, -r0.w, r0.y, l(1.000000)
98: add r0.z, r1.x, -cb12[48].y
99: mul_sat r0.z, r0.z, cb12[48].z
100: else
101: mov r0.yz, l(0.000000, 1.000000, 0.000000, 0.000000)
102: endif
103: log r0.y, r0.y
104: mul r0.w, r0.y, cb12[42].w
105: exp r0.w, r0.w
106: mul r0.y, r0.y, cb12[48].x
107: exp r0.y, r0.y
108: mul r0.yw, r0.yyyw, r0.zzzz
109: mad_sat r1.xy, r0.wwww, cb12[189].xzxx, cb12[189].ywyy
110: add r5.xyz, -r3.xyzx, cb12[188].xyzx
111: mad r5.xyz, r1.xxxx, r5.xyzx, r3.xyzx
112: add r0.z, cb12[188].w, l(-1.000000)
113: mad r0.z, r1.y, r0.z, l(1.000000)
114: mul_sat r5.w, r0.z, r0.w
115: lt r0.z, l(0), cb12[192].x
116: if_nz r0.z
117: mad_sat r1.xy, r0.wwww, cb12[191].xzxx, cb12[191].ywyy
118: add r6.xyz, -r3.xyzx, cb12[190].xyzx
119: mad r3.xyz, r1.xxxx, r6.xyzx, r3.xyzx
120: add r0.z, cb12[190].w, l(-1.000000)
121: mad r0.z, r1.y, r0.z, l(1.000000)
122: mul_sat r3.w, r0.z, r0.w
123: add r1.xyzw, -r5.xyzw, r3.xyzw
124: mad r5.xyzw, cb12[192].xxxx, r1.xyzw, r5.xyzw
125: endif
126: mul r0.z, r0.x, r5.w
127: mul r0.x, r0.x, r0.y
128: dp3 r0.y, l(0.333000, 0.555000, 0.222000, 0.000000), r2.xyzx
129: mad r1.xyz, r0.yyyy, r4.xyzx, -r2.xyzx
130: mad r0.xyw, r0.xxxx, r1.xyxz, r2.xyxz
131: add r1.xyz, -r0.xywx, r5.xyzx
132: mad r0.xyz, r0.zzzz, r1.xyzx, r0.xywx
133: else
134: mov r0.xyz, l(0, 0, 0, 0)
135: endif
136: mov o0.xyz, r0.xyzx
137: mov o0.w, l(1.000000)
138: ret
Честно говоря, шейдер довольно длинный. Вероятно, слишком длинный для эффективного процесса обратной разработки.
Вот пример закатной сцены с туманом:
Давайте взглянем на входящие данные:
Что касается текстур, то у нас есть буфер глубин, Ambient Occlusion и буфер HDR-цветов.
Входящий буфер глубин
Входящее ambient occlusion
Входящий буфер HDR-цвета
…, а результат применения шейдера тумана в этой сцене выглядит так:
HDR-текстура после применения тумана
Буфер глубин используется для воссоздания позиции в мире. Это стандартный паттерн для шейдеров Witcher 3.
Наличие данных ambient occlusion (если они включены) позволяет нам затемнить туман. Очень умная идея, возможно, очевидная, но я никогда не думал об этом таким образом. Позже я вернусь к этому аспекту.
Шейдер начинается с определения того, находится ли пиксель на небе. В случае, если пиксель лежит на небе (depth == 1.0), шейдер возвращает чёрный цвет. Если пиксель находится в сцене (depth < 1.0), то мы воссоздаём позицию в мире при помощи буфера глубин (строки 7-11) и продолжаем вычисление тумана.
Проход тумана происходит вскоре после процесса отложенного затенения. Вы можете заметить, что некоторые относящиеся к упреждающему проходу элементы пока отсутствуют. В этой конкретной сцене были применены отложенные объёмы освещения, а после этого мы отрендерили волосы/лицо/глаза Геральта.
Первое, что нужно знать о тумане в «Ведьмаке 3»: он состоит из двух частей — «цвета тумана» и «цвета атмосферы».
struct FogResult
{
float4 paramsFog; // RGB: color, A: influence
float4 paramsAerial; // RGB: color, A: influence
};
Для каждой части есть три цвета: передний, средний и задний. То есть в буфере констант есть такие данные, как «FogColorFront», «FogColorMiddle», «AerialColorBack» и т.п… Посмотрим на входящие данные:
// *** Inputs *** //
float3 FogSunDir = cb12_v38.xyz;
float3 FogColorFront = cb12_v39.xyz;
float3 FogColorMiddle = cb12_v40.xyz;
float3 FogColorBack = cb12_v41.xyz;
float4 FogBaseParams = cb12_v42;
float4 FogDensityParamsScene = cb12_v43;
float4 FogDensityParamsSky = cb12_v44;
float3 AerialColorFront = cb12_v45.xyz;
float3 AerialColorMiddle = cb12_v46.xyz;
float3 AerialColorBack = cb12_v47.xyz;
float4 AerialParams = cb12_v48;
Перед вычислением окончательных цветов нам нужно вычислить векторы и скалярные произведения. Шейдер имеет доступ к позиции пикселя в мире, позиции камеры (cb12[0].xyz) и направлению тумана/освещения (cb12[38].xyz). Это позволяет нам вычислить скалярное произведение вектора вида и направления тумана.
float3 frag_vec = fragPosWorldSpace.xyz - customCameraPos.xyz;
float frag_dist = length(frag_vec);
float3 frag_dir = frag_vec / frag_dist;
float dot_fragDirSunDir = dot(GlobalLightDirection.xyz, frag_dir);
Для вычисления смешивающегося градиента нужно использовать квадрат абсолютного скалярного произведения, а затем снова умножить результат на какой-то параметр, зависящий от расстояния:
float3 curr_col_fog;
float3 curr_col_aerial;
{
float _dot = dot_fragDirSunDir;
float _dd = _dot;
{
const float _distOffset = -150;
const float _distRange = 500;
const float _mul = 1.0 / _distRange;
const float _bias = _distOffset * _mul;
_dd = abs(_dd);
_dd *= _dd;
_dd *= saturate( frag_dist * _mul + _bias );
}
curr_col_fog = lerp( FogColorMiddle.xyz, (_dot>0.0f ? FogColorFront.xyz : FogColorBack.xyz), _dd );
curr_col_aerial = lerp( AerialColorMiddle.xyz, (_dot>0.0f ? AerialColorFront.xyz : AerialColorBack.xyz), _dd );
}
Этот блок кода чётко даёт нам понять, откуда же взялись эти 0.002 и -0.300. Как мы видим, скалярное произведение между векторами вида и освещения отвечают за выбор между «передним» и «задним» цветами. Умно!
Вот визуализация получившегося итогового градиента (_dd).
Однако вычисление влияния атмосферы/тумана гораздо сложнее. Как видите, у нас гораздо больше параметров, чем просто цвета rgb. В них входят, например, плотность сцены. Мы используем raymarching (16 шагов, и именно поэтому цикл можно развернуть) для определения величины тумана и коэффициента масштаба:
Имея вектор [камера ---> мир], мы можем разделить все его компоненты на 16 — это будет один шаг raymarching-а. Как мы видим ниже, в вычислениях участвует только компонента .z (высота) (curr_pos_z_step).
Подробнее почитать о тумане, реализованном raymarching-ом, можно, например, здесь.
float fog_amount = 1;
float fog_amount_scale = 0;
[branch]
if ( frag_dist >= AerialParams.y )
{
float curr_pos_z_base = (customCameraPos.z + FogBaseParams.y) * density_factor;
float curr_pos_z_step = frag_step.z * density_factor;
[unroll]
for ( int i=16; i>0; --i )
{
fog_amount *= 1 - saturate( density_sample_scale / (1 + max( 0.0, curr_pos_z_base + (i) * curr_pos_z_step ) ) );
}
fog_amount = 1 - fog_amount;
fog_amount_scale = saturate( (frag_dist - AerialParams.y) * AerialParams.z );
}
FogResult ret;
ret.paramsFog = float4 ( curr_col_fog, fog_amount_scale * pow( abs(fog_amount), final_exp_fog ) );
ret.paramsAerial = float4 ( curr_col_aerial, fog_amount_scale * pow( abs(fog_amount), final_exp_aerial ) );
Величина тумана очевидно зависит от высоты (компоненты .z), в конце величина тумана возводится в степень тумана/атмосферы.
final_exp_fog and final_exp_aerial берутся из буфера констант; они позволяют управлять тем, как цвета тумана и атмосферы влияют на мир с повышением высоты.
Переопределение тумана
В найденном мной шейдере не было следующего фрагмента ассемблерного кода:
109: mad_sat r1.xy, r0.wwww, cb12[189].xzxx, cb12[189].ywyy
110: add r5.xyz, -r3.xyzx, cb12[188].xyzx
111: mad r5.xyz, r1.xxxx, r5.xyzx, r3.xyzx
112: add r0.z, l(-1.000000), cb12[188].w
113: mad r0.z, r1.y, r0.z, l(1.000000)
114: mul_sat r5.w, r0.w, r0.z
115: lt r0.z, l(0.000000), cb12[192].x
116: if_nz r0.z
117: mad_sat r1.xy, r0.wwww, cb12[191].xzxx, cb12[191].ywyy
118: add r6.xyz, -r3.xyzx, cb12[190].xyzx
119: mad r3.xyz, r1.xxxx, r6.xyzx, r3.xyzx
120: add r0.z, l(-1.000000), cb12[190].w
121: mad r0.z, r1.y, r0.z, l(1.000000)
122: mul_sat r3.w, r0.w, r0.z
123: add r1.xyzw, -r5.xyzw, r3.xyzw
124: mad r5.xyzw, cb12[192].xxxx, r1.xyzw, r5.xyzw
125: endif
Судя по тому, что мне удалось понять, это походит на переопределение цвета и влияния тумана:
Большую часть времени выполняется только одно переопределение (cb12_v192.x равно 0.0), но в этом конкретном случае его значение равно ~0.22, поэтому мы выполняем второе переопределение.
#ifdef OVERRIDE_FOG
// Override
float fog_influence = ret.paramsFog.w; // r0.w
float override1ColorScale = cb12_v189.x;
float override1ColorBias = cb12_v189.y;
float3 override1Color = cb12_v188.rgb;
float override1InfluenceScale = cb12_v189.z;
float override1InfluenceBias = cb12_v189.w;
float override1Influence = cb12_v188.w;
float override1ColorAmount = saturate(fog_influence * override1ColorScale + override1ColorBias);
float override1InfluenceAmount = saturate(fog_influence * override1InfluenceScale + override1InfluenceBias);
float4 paramsFogOverride;
paramsFogOverride.rgb = lerp(curr_col_fog, override1Color, override1ColorAmount ); // ***r5.xyz
float param1 = lerp(1.0, override1Influence, override1InfluenceAmount); // r0.x
paramsFogOverride.w = saturate(param1 * fog_influence ); // ** r5.w
const float extraFogOverride = cb12_v192.x;
[branch]
if (extraFogOverride > 0.0)
{
float override2ColorScale = cb12_v191.x;
float override2ColorBias = cb12_v191.y;
float3 override2Color = cb12_v190.rgb;
float override2InfluenceScale = cb12_v191.z;
float override2InfluenceBias = cb12_v191.w;
float override2Influence = cb12_v190.w;
float override2ColorAmount = saturate(fog_influence * override2ColorScale + override2ColorBias);
float override2InfluenceAmount = saturate(fog_influence * override2InfluenceScale + override2InfluenceBias);
float4 paramsFogOverride2;
paramsFogOverride2.rgb = lerp(curr_col_fog, override2Color, override2ColorAmount); // r3.xyz
float ov_param1 = lerp(1.0, override2Influence, override2InfluenceAmount); // r0.z
paramsFogOverride2.w = saturate(ov_param1 * fog_influence); // r3.w
paramsFogOverride = lerp(paramsFogOverride, paramsFogOverride2, extraFogOverride);
}
ret.paramsFog = paramsFogOverride;
#endif
Вот наша готовая цена без переопределения тумана (первое изображение), с одним переопределением (второе изображение) и двойным переопределением (третье изображение, окончательный результат):
Регулирование ambient occlusion
В найденном мной шейдере также совершенно не использовалось ambient occlusion. Давайте снова взглянем на текстуру AO и на код, который нам интересен:
13: ld_indexable(texture2d)(float,float,float,float) r0.x, r0.xyzw, t2.xyzw
14: max r0.x, r0.x, cb3[1].x
15: add r0.yzw, r1.xxyz, -cb12[0].xxyz
16: dp3 r1.x, r0.yzwy, r0.yzwy
17: sqrt r1.x, r1.x
18: add r1.y, r1.x, -cb3[0].x
19: add r1.zw, -cb3[0].xxxz, cb3[0].yyyw
20: div_sat r1.y, r1.y, r1.z
21: mad r1.y, r1.y, r1.w, cb3[0].z
22: add r0.x, r0.x, l(-1.000000)
23: mad r0.x, r1.y, r0.x, l(1.000000)
Возможно, эта сцена — не лучший пример, потому что мы не видим деталей на далёком острове. Тем не менее, давайте взглянем на буфер констант, который используется для задания значения ambient occlusion:
Мы начинаем с загрузки AO из текстуры, затем выполняем инструкцию max. В этой сцене cb3_v1.x очень высоко (0.96888), из-за чего AO становится очень слабым.
Следующая часть кода вычисляет расстояние между позициями камеры и пикселей в мире.
Я считаю, что код иногда говорит сам за себя, поэтому посмотрим на HLSL, выполняющий основную часть этой настройки:
float AdjustAmbientOcclusion(in float inputAO, in float worldToCameraDistance)
{
// *** Inputs *** //
const float aoDistanceStart = cb3_v0.x;
const float aoDistanceEnd = cb3_v0.y;
const float aoStrengthStart = cb3_v0.z;
const float aoStrengthEnd = cb3_v0.w;
// * Adjust AO
float aoDistanceIntensity = linstep( aoDistanceStart, aoDistanceEnd, worldToCameraDistance );
float aoStrength = lerp(aoStrengthStart, aoStrengthEnd, aoDistanceIntensity);
float adjustedAO = lerp(1.0, inputAO, aoStrength);
return adjustedAO;
}
Вычисленное расстояние между камерой и миром используется для функции linstep. Мы уже знаем эту функцию, она появлялась в шейдере перистых облаков.
Как вы видите, в буфере констант у нас есть значения расстояний начала/конца AO. Выходные данные linstep влияют на силу AO (а также из cbuffer), а сила влияет на выходящее значение AO.
Краткий пример: пиксель находится далеко, допустим, расстояние равно 500.
linstep возвращает 1.0;
aoStrength равно aoStrengthEnd;
Это приводит к возврату AO, которое составляет примерно 77% (конечная сила) входящего значения.
Входящее AO для этой функции предварительно было подвергнуто операции max.
Соединяем всё вместе
Получив цвет и влияние для цвета тумана и цвета атмосферы, можно их окончательно объединить.
Мы начинаем с ослабления влияния с помощью полученного AO:
...
FogResult fog = CalculateFog( worldPos, CameraPosition, fogStart, ao, false );
// Apply AO to influence
fog.paramsFog.w *= ao;
fog.paramsAerial.w *= ao;
// Mix fog with scene color
outColor = ApplyFog(fog, colorHDR);
Вся магия творится в функции ApplyFog:
float3 ApplyFog(FogResult fog, float3 color)
{
const float3 LuminanceFactors = float3(0.333f, 0.555f, 0.222f);
float3 aerialColor = dot(LuminanceFactors, color) * fog.paramsAerial.xyz;
color = lerp(color, aerialColor, fog.paramsAerial.w);
color = lerp(color, fog.paramsFog.xyz, fog.paramsFog.w);
return color.xyz;
}
Сначала мы вычисляем «светимость» пикселей:
Затем мы умножаем её на цвет атмосферы:
Затем мы комбинируем HDR-цвет с цветом атмосферы:
Последний этап заключается в комбинировании промежуточного результата с цветом тумана:
Вот и всё!
Несколько отладочных скриншотов
Влияние атмосферы
Цвет атмосферы
Влияние тумана
Цвет тумана
Готовая сцена без тумана
Готовая сцена только с туманом атмосферы
Готовая сцена — только основной туман
Снова готовая сцена со всем туманом для простоты сравнения
Итог
Думаю, вы сможете разобраться во многом изложенном, если взглянете на шейдер, он находится здесь.
С удовольствием могу сказать, что этот шейдер в точности такой же, как оригинальный — это очень меня радует.
В общем случае, готовый результат сильно зависит от значений передаваемых шейдеру. Это не «волшебное» решение, дающее идеальные цвета на выходе, оно требует множества итераций и работы художников, чтобы окончательный результат выглядел достойно. Думаю, это может быть долгий процесс, но после того, как вы его выполните, результат будет очень убедительным, точно как эта закатная сцена.
В шейдере неба «Ведьмака 3» тоже используются вычисления тумана для создания плавного перехода цветов возле горизонта. Однако шейдеру неба передаётся другой набор коэффициентов плотности.
Напомню — бОльшая часть этого шейдера была создана/проанализирована не мной. Все благодарности следует направлять CD PROJEKT RED. Поддержите их, они делают отличную работу.
Часть 3. Падающие звёзды