Часть 1: Млечный путь
В предыдущем посте я рассказывал, как в «Ведьмаке 3» реализованы падающие звёзды. Этого эффекта нет в «Крови и вине». В посте я опишу эффект, который есть только в этом DLC: Млечный путь.
Вот видео, в котором показан Млечный путь.
И несколько скриншотов: (1) до вызова отрисовки купола неба, (2) только с цветом Млечного пути, (3) после вызова:
Готовый кадр только с одним Млечным путём (без цвета неба и звёзд) выглядит так:
Эффект Млечного пути, ставший одним из самых сильных отличий от версии игры 2015 года, вкратце упомянут в разделе «Глупые трюки с небом». Давайте разберёмся, как он реализован!
План изложения будет привычным: сначала мы вкратце объясним всё, относящееся к геометрии, а затем расскажем про пиксельный шейдер.
1. Геометрия
Давайте начнём с используемого меша купола неба. Есть два серьёзных отличия между куполом 2015 года (основная игра + DLC «Каменные сердца», я обычно называю их обе «игрой 2015 года») и куполом в DLC «Кровь и вино» (2016 год):
а) В «Крови и вине» меш намного более плотный,
б) В меше купола неба «КиВ» используются векторы нормалей.
Вот меш купола неба 2015 года — DrawIndexed(720)
Меш купола неба в «Ведьмаке 3» 2015 года — 720 индексов
А вот меш из «КиВ» — DrawIndexed(2640):
Меш купола неба DLC «Ведьмак 3: Кровь и вино» — 2640 индексов
Вот ещё раз меш из «КиВ»: я нарисовал, как распределены нормали — они направлены в «центр» меша.
Меш купола неба DLC «Кровь и вино» с нормалями
2. Вершинный шейдер
Вершинный шейдер купола неба довольно прост. Вот соответствующий ассемблерный код. Ради простоты я пропустил вычисление SV_Position:
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_input v2.xyz
dcl_output o0.xyzw
dcl_output o1.xyzw
dcl_output_siv o2.xyzw, position
dcl_temps 3
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 o0.z, r0.xyzw, cb2[0].xyzw
4: dp4 o0.w, r0.xyzw, cb2[1].xyzw
5: mad r1.xyz, v2.xyzx, l(2.000000, 2.000000, 2.000000, 0.000000), l(-1.000000, -1.000000, -1.000000, 0.000000)
6: dp3 r2.x, r1.xyzx, cb2[0].xyzx
7: dp3 r2.y, r1.xyzx, cb2[1].xyzx
8: dp3 r2.z, r1.xyzx, cb2[2].xyzx
9: dp3 r1.x, r2.xyzx, r2.xyzx
10: rsq r1.x, r1.x
11: mul o1.xyz, r1.xxxx, r2.xyzx
12: dp4 o1.w, r0.xyzw, cb2[2].xyzw
Входными данными из буфера вершин являются:
1) Позиция в локальном пространстве [0-1] — v0.xyz,
2) Texcoords — v1.xy,
3) Вектор нормали [0-1] — v2.xyz
Входящие данные из cbuffer:
1) Матрица мира (0-3) — классический подход: однородное масштабирование и преобразование по позиции камеры,
2) Масштаб и смещение для вершины (4-5) — трюк, используемый в течении игры для преобразования из локального пространства [0-1] в пространство [-1;1], и для потенциального «сплющивания» мешей.
А вот краткое описание того, что происходит в шейдере:
Шейдер начинается с простой передачи texcoords (строка 0). К позиции вершины в мире применяется масштаб и смещение (строка 1), а результат умножается на матрицу мира (строки 3-4, 12). Вектор нормали должен быть перенесён из интервала [0-1] в [-1;1] (строка 5), а затем он умножается на матрицу мира (строки 6-8) и в конце нормализуется (строки 9-11).
Готовые выходные данные имеют следующую схему:
3. Пиксельный шейдер
Вычисления Млечного пути — это просто одна часть шейдера неба. В «КиВ» он гораздо длиннее, чем в версии 2015 года. Он состоит из 385 строк на ассемблере, а вариант 2015 года — из 267 строк.
Давайте рассмотрим фрагмент ассемблерного кода, отвечающий за Млечный путь:
175: sample_indexable(texturecube)(float,float,float,float) r4.xyz, r2.xyzx, t0.xyzw, s0
176: mul r4.xyz, r4.xyzx, r4.xyzx
177: sample_indexable(texturecube)(float,float,float,float) r0.w, r2.xyzx, t1.yzwx, s0
178: dp3 r1.w, v1.xyzx, v1.xyzx
179: rsq r1.w, r1.w
180: mul r2.xyz, r1.wwww, v1.xyzx
181: dp3 r1.w, cb12[204].yzwy, cb12[204].yzwy
182: rsq r1.w, r1.w
183: mul r5.xyz, r1.wwww, cb12[204].yzwy
184: dp3 r1.w, r2.xyzx, r5.xyzx
185: mad_sat r0.w, r0.w, l(0.200000), r1.w
186: ge r1.w, l(0.497925), r0.w
187: if_nz r1.w
188: ge r1.w, l(0.184939), r0.w
189: mul r2.y, r0.w, l(5.407188)
190: min r2.z, r2.y, l(1.000000)
191: mad r2.w, r2.z, l(-2.000000), l(3.000000)
192: mul r2.z, r2.z, r2.z
193: mul r2.z, r2.z, r2.w
194: mul r5.xyz, r2.zzzz, l(0.949254, 0.949254, 0.949254, 0.000000)
195: mov r2.x, l(0.949254)
196: movc r2.xw, r1.wwww, r2.xxxy, l(0.000000, 0.000000, 0.000000, 0.500000)
197: not r4.w, r1.w
198: if_z r1.w
199: ge r1.w, l(0.239752), r0.w
200: add r5.w, r0.w, l(-0.184939)
201: mul r6.y, r5.w, l(18.243849)
202: mov_sat r5.w, r6.y
203: mad r6.z, r5.w, l(-2.000000), l(3.000000)
204: mul r5.w, r5.w, r5.w
205: mul r5.w, r5.w, r6.z
206: mad r5.w, r5.w, l(-0.113726), l(0.949254)
207: movc r5.xyz, r1.wwww, r5.wwww, r5.zzzz
208: and r7.xyz, r1.wwww, l(0.949254, 0.949254, 0.949254, 0.000000)
209: mov r6.x, l(0.835528)
210: movc r2.xw, r1.wwww, r6.xxxy, r2.xxxw
211: mov r2.xyzw, r2.xxxw
212: else
213: mov r7.xyz, l(0, 0, 0, 0)
214: mov r2.xyzw, r2.xxxw
215: mov r1.w, l(-1)
216: endif
217: not r5.w, r1.w
218: and r4.w, r4.w, r5.w
219: if_nz r4.w
220: ge r5.w, r0.w, l(0.239752)
221: ge r6.x, l(0.294564), r0.w
222: and r1.w, r5.w, r6.x
223: add r5.w, r0.w, l(-0.239752)
224: mul r6.w, r5.w, l(18.244175)
225: mov_sat r5.w, r6.w
226: mad r7.w, r5.w, l(-2.000000), l(3.000000)
227: mul r5.w, r5.w, r5.w
228: mul r5.w, r5.w, r7.w
229: mad r5.w, r5.w, l(0.015873), l(0.835528)
230: movc r5.xyz, r1.wwww, r5.wwww, r5.xyzx
231: movc r7.xyz, r1.wwww, l(0.835528, 0.835528, 0.835528, 0.000000), r7.xyzx
232: mov r6.xyz, l(0.851401, 0.851401, 0.851401, 0.000000)
233: movc r2.xyzw, r1.wwww, r6.xyzw, r2.xyzw
234: endif
235: not r5.w, r1.w
236: and r4.w, r4.w, r5.w
237: if_nz r4.w
238: ge r1.w, r0.w, l(0.294564)
239: add r0.w, r0.w, l(-0.294564)
240: mul r6.w, r0.w, l(4.917364)
241: mov_sat r0.w, r6.w
242: mad r4.w, r0.w, l(-2.000000), l(3.000000)
243: mul r0.w, r0.w, r0.w
244: mul r0.w, r0.w, r4.w
245: mad r0.w, r0.w, l(-0.851401), l(0.851401)
246: movc r5.xyz, r1.wwww, r0.wwww, r5.xyzx
247: movc r7.xyz, r1.wwww, l(0.851401, 0.851401, 0.851401, 0.000000), r7.xyzx
248: mov r6.xyz, l(0, 0, 0, 0)
249: movc r2.xyzw, r1.wwww, r6.xyzw, r2.xyzw
250: endif
251: else
252: mov r7.xyz, l(0, 0, 0, 0)
253: mov r2.xyzw, l(0.000000, 0.000000, 0.000000, 0.500000)
254: mov r1.w, l(0)
255: endif
256: mov_sat r2.w, r2.w
257: mad r0.w, r2.w, l(-2.000000), l(3.000000)
258: mul r2.w, r2.w, r2.w
259: mul r0.w, r0.w, r2.w
260: add r2.xyz, -r7.xyzx, r2.xyzx
261: mad r2.xyz, r0.wwww, r2.xyzx, r7.xyzx
262: movc r2.xyz, r1.wwww, r5.xyzx, r2.xyzx
263: mul r2.xyz, r2.xyzx, l(0.150000, 0.200000, 0.250000, 0.000000)
Довольно пугающе, не так ли? Когда я увидел его впервые (это было до того, как я увидел шейдер падающих звёзд), то подумал: «Что это за ад? Этот код невозможно реверсировать!»
Но есть один аспект — если вы читали пост про падающие звёзды, то можете легко узнать этот паттерн. Код работает очень похоже на отрисовку метеоритов! Скоро мы поговорим и о кривой.
Фрагмент начинается с сэмплирования кубической карты звёзд (строка 175), где направление сэмплирования хранится в r2.xyz. Как видите, в строке line 177 есть инструкция сэмплировпния ещё одной кубической карты. В отличие от шейдера 2015 года, в шейдере «КиВ» есть ещё одна текстура «кубическая карта шума», грани которой выглядят примерно так:
Прежде чем мы доберёмся до кривой, давайте найдём входящие данные для неё. Сначала вычисляется скалярное произведение (строка 184) между нормализованным вектором нормали купола неба (строки 178-180) и вектором света Луны (строки 181-183) — по сути, это N*L.
Вот визуализация скалярного произведения (в линейном пространстве):
Значение, используемое в качестве входящих данных для функции «кривой Млечного пути», получается в строке 185:
x = saturate( noise * 0.2 + Ndot );
А вот визуализация такого искажённого N*L, тоже в линейном пространстве:
Теперь давайте перейдём к функции Млечного пути! Она чуть сложнее, чем функция падающих звёзд. Как я говорил в предыдущем посте, мы начинаем со списка контрольных точек по оси x. Взглянув на ассемблерный код, мы сразу их увидим:
// Control points (x-axis)
float controlPoint0 = 0.0;
float controlPoint1 = 0.184939;
float controlPoint2 = 0.239752;
float controlPoint3 = 0.294564;
float controlPoint4 = 0.497925;
Откуда мы знаем, что первые контрольные точки равны нулю? Это довольно просто: в строке 189 нет инструкции «add».
Согласно посту о падающих звёздах, контрольные точки определяют количество сегментов, а далее нам нужно найти для них веса.
Для первого сегмента это довольно просто. Вес равен 0.949254:
194: mul r5.xyz, r2.zzzz, l(0.949254, 0.949254, 0.949254, 0.000000)
195: mov r2.x, l(0.949254)
Давайте попробуем найти их для второго и третьего сегментов:
206: mad r5.w, r5.w, l(-0.113726), l(0.949254)
207: movc r5.xyz, r1.wwww, r5.wwww, r5.zzzz
208: and r7.xyz, r1.wwww, l(0.949254, 0.949254, 0.949254, 0.000000)
209: mov r6.x, l(0.835528)
...
229: mad r5.w, r5.w, l(0.015873), l(0.835528)
230: movc r5.xyz, r1.wwww, r5.wwww, r5.xyzx
231: movc r7.xyz, r1.wwww, l(0.835528, 0.835528, 0.835528, 0.000000), r7.xyzx
232: mov r6.xyz, l(0.851401, 0.851401, 0.851401, 0.000000)
Именно на этом моменте я прекратил писать статью, потому что здесь что-то было не так (один из моментов, когда ты думаешь «хмм»). Посмотрите, всё не так легко, как простое умножение на один вес. Кроме того, откуда взялись значения наподобие -0.113726 и 0.015873?
Потом я понял, что эти значения просто являются разностями между максимальными возможными значениями в каждом сегменте ( 0.835528 — 0.949254 = -0.113726 и 0.851401 — 0.835528 = 0.015873)! Довольно очевидно (один из моментов, когда ты думаешь «эврика!»). Как оказалось, эти значения являются не весами, а просто координатами y точек, образующих кривую!
Это многое меняет и упрощает. Во-первых, мы можем избавиться от веса в функции, которую использовали в предыдущем посте
float getSmoothTransition(float cpLeft, float cpRight, float x)
{
return smoothstep( 0, 1, linstep(cpLeft, cpRight, x) );
}
И можем записать функцию Млечного пути следующим образом:
float milkyway_curve( float x )
{
// Define a set of 2D points which form the curve
// Of course, you can use a Point2D-like struct here
// Control points (x-axis)
float controlPoint0 = 0.0;
float controlPoint1 = 0.184939;
float controlPoint2 = 0.239752;
float controlPoint3 = 0.294564;
float controlPoint4 = 0.497925;
// Values at points (y-axis)
float value0 = 0.0;
float value1 = 0.949254;
float value2 = 0.835528;
float value3 = 0.851401;
float value4 = 0.0;
float function_value = 0.0;
[branch] if (x <= controlPoint4)
{
[branch] if (x <= controlPoint1)
{
float t = getSmoothTransition(controlPoint0, controlPoint1, x);
function_value = lerp(value0, value1, t);
}
[branch] if (x >= controlPoint1 && x <= controlPoint2)
{
float t = getSmoothTransition(controlPoint1, controlPoint2, x);
function_value = lerp(value1, value2, t);
}
[branch] if (x >= controlPoint2 && x <= controlPoint3)
{
float t = getSmoothTransition(controlPoint2, controlPoint3, x);
function_value = lerp(value2, value3, t);
}
[branch] if (x >= controlPoint3)
{
float t = getSmoothTransition(controlPoint3, controlPoint4, x);
function_value = lerp(value3, value4, t);
}
}
return function_value;
}
Это обобщённое решение для любого количества точек, образующих плавную кривую. Кроме того, оно объясняет происхождение «странных» значений контрольных точек — вероятно, разработчики для задания точек использовали какой-то визуальный редактор.
Разумеется, тот же принцип применим к коду падающих звёзд.
Вот график функции:
График функции Млечного пути.
Красное — значение функции,
Зелёное — координаты по x
Синее — координаты по y
Жёлтые точки — контрольные
Хорошо, но что дальше? В строке 263 мы умножаем значение из функции на синеватый цвет:
263: mul r2.xyz, r2.xyzx, l(0.150000, 0.200000, 0.250000, 0.000000)
Но это ещё не конец! Нам просто нужно выполнить гамма-коррекцию:
263: mul r2.xyz, r2.xyzx, l(0.150000, 0.200000, 0.250000, 0.000000)
264: mad r2.xyz, r4.xyzx, l(3.000000, 3.000000, 3.000000, 0.000000), r2.xyzx
...
269: log r2.xyz, r2.xyzx
270: mul r2.xyz, r2.xyzx, l(2.200000, 2.200000, 2.200000, 0.000000)
271: exp r2.xyz, r2.xyzx
Теперь интересная штука: я назначил разные цвета контрольным точкам по оси x:
float3 gradient0 = float3(1, 0, 0);
float3 gradient1 = float3(0, 1, 0);
float3 gradient2 = float3(0, 0, 1);
float3 gradient3 = float3(1, 1, 0);
float3 gradient4 = float3(0, 1, 1);
И вот что у меня получилось:
И на этом практически всё для Млечного пути сделано.
В строке 264 есть r4.xyz и…
4. Звёзды Туссента (бонус)
Я знаю, что эта часть статьи называется «Млечный путь», но не смог удержаться от того, чтобы не рассказать вкратце, как создаются звёзды Туссента. Они гораздо ярче, чем в Новиграде, на Скеллиге или в Велене.
В одном из предыдущих постов я рассказывал о звёздах 2015 года; настало время поговорить о звёздах 2016 года!
На самом деле, основная часть ассемблерного кода выглядит так:
175: sample_indexable(texturecube)(float,float,float,float) r4.xyz, r2.xyzx, t0.xyzw, s0
176: mul r4.xyz, r4.xyzx, r4.xyzx
...
264: mad r2.xyz, r4.xyzx, l(3.000000, 3.000000, 3.000000, 0.000000), r2.xyzx
...
269: log r2.xyz, r2.xyzx
270: mul r2.xyz, r2.xyzx, l(2.200000, 2.200000, 2.200000, 0.000000)
271: exp r2.xyz, r2.xyzx
...
302: add r0.z, -cb0[9].w, l(1.000000)
303: mul r2.xyz, r0.zzzz, r2.xyzx
304: add r2.xyz, r2.xyzx, r2.xyzx
На HLSL это можно записать так:
float3 stars = texStars.Sample(sampler, starsDir).rgb;
stars *= stars;
float3 milkyway = milkyway_func(noisePerturbed) * float3(0.15, 0.20, 0.25);
float3 skyContribution = milkyway + 3.0 * stars;
// gamma correction
skyContribution = pow(skyContribution, 2.2);
// starsOpacity - 0.0 during the day (so stars and the Milky Way are not visible then), 1.0 during the night
float starsOpacity = 1.0 - cb0_v9.w;
skyContribution *= starsOpacity;
skyContribution *= 2;
То есть сами звёзды просто умножаются на 3 (строка 264), а затем вместе с влиянием Млечного пути на 2 (строка 304) — олдскульный способ, но работает отлично!
Разумеется, позже происходит и кое-что ещё (например, мерцание звёзд при помощи целочисленного шума, и т.д.), но это уже не относится к теме статьи.
Заключение
В этой части я разобрался, как в «Ведьмаке 3: Кровь и вино» реализованы Млечный путь и звёзды.
Давайте заменим исходный шейдер кодом, который только написали. Готовый кадр выглядит вот так:
а с исходным шейдером кадр выглядит вот так:
Неплохо.
Часть 2: цветокоррекция
Один из эффектов постобработки, который можно встретить в «Ведьмаке 3» почти везде — это цветокоррекция. Её принцип заключается в использовании текстуры таблицы поиска (LUT) для преобразования одного множества цветов в другое.
Обычно процесс выглядит так: есть нейтральная (выходной цвет = входящему цвету) таблица поиска, которая редактируется в инструментах наподобие Adobe Photoshop — усиливается её контрастность/яркость/насыщенность/оттенок и т.д., то есть все модификации и изменения, которые достаточно затратны при вычислении в реальном времени. Благодаря LUT-ам эти операции можно заменить менее затратным поиском в текстуре.
Существует как минимум три известных мне цветовых таблиц LUT: трёхмерные, «длинные» двухмерные и «квадратные» двухмерные.
Нейтральная «длинная» двухмерная LUT
Нейтральная «квадратная» двухмерная LUT
Прежде чем мы перейдём к реализации цветокоррекции в «Ведьмаке 3», вот несколько полезных ссылок по этой технике:
Хорошая реализация на OpenGL с онлайн-демо
Исследование графики Metal Gear Solid V (хорошая статья в целом, есть раздел о цветокоррекции) [перевод на Хабре]
Цветокоррекция при помощи текстур поиска (LUT)
Статья из книги «GPU Gems 2» — цветокоррекция при помощи трёхмерных текстур
Документация UE4 о создании и использовании цветовых LUT
Давайте взглянем на пример LUT, использованной примерно в начале игры White Orchard — бОльшая часть зелёных цветов заменена на жёлтые:
В «Ведьмаке 3» используются двухмерные текстуры размером 512×512.
В общем случае ожидается, что цветокоррекция будет выполняться в пространстве LDR. Поэтому получается 2563 возможных входящих значений — больше 16 миллионов комбинаций, преобразуемых всего в 5122=262 144 значения. Чтобы покрыть весь интервал входящих значений, используется билинейное сэмплирование.
А вот скриншоты для сравнения: до и после прохода цветокоррекции.
Как видите, разница невелика, но заметна — небо имеет более оранжевый оттенок.
Что касается реализации в «Ведьмаке 3», и входящий, и выходной render targets являются полноэкранными текстурами с плавающей запятой (R11G11B10). Любопытно, что конкретно в этой сцене каналы самых ярких пикселей (рядом с Солнцем) имеют значения, превышающие 1.0f — даже почти до 2.0f!
Вот ассемблерный код пиксельного шейдера:
ps_5_0
dcl_globalFlags refactoringAllowed
dcl_constantbuffer cb3[2], immediateIndexed
dcl_sampler s0, mode_default
dcl_sampler s1, mode_default
dcl_resource_texture2d (float,float,float,float) t0
dcl_resource_texture2d (float,float,float,float) t1
dcl_input_ps linear v1.xy
dcl_output o0.xyzw
dcl_temps 5
0: max r0.xy, v1.xyxx, cb3[0].xyxx
1: min r0.xy, r0.xyxx, cb3[0].zwzz
2: sample_indexable(texture2d)(float,float,float,float) r0.xyzw, r0.xyxx, t0.xyzw, s0
3: log r1.xyz, abs(r0.xyzx)
4: mul r1.xyz, r1.xyzx, l(0.454545, 0.454545, 0.454545, 0.000000)
5: exp r1.xyz, r1.xyzx
6: mad r2.xyz, r1.xyzx, l(1.000000, 1.000000, 0.996094, 0.000000), l(0.000000, 0.000000, 0.015625, 0.000000)
7: min r2.xyz, r2.xyzx, l(1.000000, 1.000000, 1.000000, 0.000000)
8: min r2.z, r2.z, l(0.999990)
9: add r2.xy, r2.xyxx, l(0.007813, 0.007813, 0.000000, 0.000000)
10: mul r2.xyzw, r2.xyzz, l(0.996094, 0.996094, 64.000000, 8.000000)
11: max r2.xy, r2.xyxx, l(0.015625, 0.015625, 0.000000, 0.000000)
12: min r2.xy, r2.xyxx, l(0.984375, 0.984375, 0.000000, 0.000000)
13: round_ni r3.xz, r2.wwww
14: mad r2.z, -r3.x, l(8.000000), r2.z
15: round_ni r3.y, r2.z
16: mul r2.zw, r3.yyyz, l(0.000000, 0.000000, 0.125000, 0.125000)
17: mad r2.xy, r2.xyxx, l(0.125000, 0.125000, 0.000000, 0.000000), r2.zwzz
18: sample_l(texture2d)(float,float,float,float) r2.xyz, r2.xyxx, t1.xyzw, s1, l(0)
19: mul r2.w, r1.z, l(63.750000)
20: round_ni r2.w, r2.w
21: mul r1.w, r2.w, l(0.015625)
22: mad r1.z, r1.z, l(63.750000), -r2.w
23: min r1.xyw, r1.xyxw, l(1.000000, 1.000000, 0.000000, 1.000000)
24: min r1.w, r1.w, l(0.999990)
25: add r1.xy, r1.xyxx, l(0.007813, 0.007813, 0.000000, 0.000000)
26: mul r1.xy, r1.xyxx, l(0.996094, 0.996094, 0.000000, 0.000000)
27: max r1.xy, r1.xyxx, l(0.015625, 0.015625, 0.000000, 0.000000)
28: min r1.xy, r1.xyxx, l(0.984375, 0.984375, 0.000000, 0.000000)
29: mul r3.xy, r1.wwww, l(64.000000, 8.000000, 0.000000, 0.000000)
30: round_ni r4.xz, r3.yyyy
31: mad r1.w, -r4.x, l(8.000000), r3.x
32: round_ni r4.y, r1.w
33: mul r3.xy, r4.yzyy, l(0.125000, 0.125000, 0.000000, 0.000000)
34: mad r1.xy, r1.xyxx, l(0.125000, 0.125000, 0.000000, 0.000000), r3.xyxx
35: sample_l(texture2d)(float,float,float,float) r1.xyw, r1.xyxx, t1.xywz, s1, l(0)
36: add r2.xyz, -r1.xywx, r2.xyzx
37: mad r1.xyz, r1.zzzz, r2.xyzx, r1.xywx
38: log r1.xyz, abs(r1.xyzx)
39: mul r1.xyz, r1.xyzx, l(2.200000, 2.200000, 2.200000, 0.000000)
40: exp r1.xyz, r1.xyzx
41: mad r1.xyz, cb3[1].zzzz, r1.xyzx, -r0.xyzx
42: mad o0.xyz, cb3[1].yyyy, r1.xyzx, r0.xyzx
43: mov o0.w, r0.w
44: ret
В целом, разработчики «Ведьмака 3» не стали изобретать велосипед и использую много «надёжного» кода. Это логично, ведь это один из эффектов, в котором нужно быть чрезвычайно аккуратным с координатами текстур.
Тем не менее, требуется два запроса к LUT, это следствие использования 2D-текстуры — необходимо симулировать билинейное сэмплирование канала синего. В представленной по ссылке выше реализации на OpenGL слияние этих двух запросов зависит от дробной части канала синего.
Что мне показалось интересным, так это отсутствие в ассемблерном коде инструкций ceil (round_pi) и frac (frc). Однако в нём довольно много инструкций floor (round_ni).
Шейдер начинается с получения входящей текстуры цвета и извлечения из неё цвета в гамма-пространстве:
float3 LinearToGamma(float3 c) { return pow(c, 1.0/2.2); }
float3 GammaToLinear(float3 c) { return pow(c, 2.2); }
...
// Set range of allowed texcoords
float2 minAllowedUV = cb3_v0.xy;
float2 maxAllowedUV = cb3_v0.zw;
float2 samplingUV = clamp( Input.Texcoords, minAllowedUV, maxAllowedUV );
// Get color in *linear* space
float4 inputColorLinear = texture0.Sample( samplerPointClamp, samplingUV );
// Calculate color in *gamma* space for RGB
float3 inputColorGamma = LinearToGamma( inputColorLinear.rgb );
Допустимые координаты сэмплирования min и max берутся из cbuffer:
Этот конкретный кадр был захвачен в разрешении 1920×1080 — значения max равны: (1919/1920, 1079/1080)
Довольно легко заметить, что ассемблерный код шейдера содержит два довольно похожих блока, за которыми следует получение данных из LUT. Поэтому я создал вспомогательную функцию, которая вычисляет uv для LUT. Давайте сначала взглянем на соответствующий ассемблерный код:
7: min r2.xyz, r2.xyzx, l(1.000000, 1.000000, 1.000000, 0.000000)
8: min r2.z, r2.z, l(0.999990)
9: add r2.xy, r2.xyxx, l(0.007813, 0.007813, 0.000000, 0.000000)
10: mul r2.xyzw, r2.xyzz, l(0.996094, 0.996094, 64.000000, 8.000000)
11: max r2.xy, r2.xyxx, l(0.015625, 0.015625, 0.000000, 0.000000)
12: min r2.xy, r2.xyxx, l(0.984375, 0.984375, 0.000000, 0.000000)
13: round_ni r3.xz, r2.wwww
14: mad r2.z, -r3.x, l(8.000000), r2.z
15: round_ni r3.y, r2.z
16: mul r2.zw, r3.yyyz, l(0.000000, 0.000000, 0.125000, 0.125000)
17: mad r2.xy, r2.xyxx, l(0.125000, 0.125000, 0.000000, 0.000000), r2.zwzz
18: sample_l(texture2d)(float,float,float,float) r2.xyz, r2.xyxx, t1.xyzw, s1, l(0)
Здесь r2.xyz — это входящий цвет.
Первое, что происходит — проверка того, находятся ли входящие данные в интервале [0-1]. (строка 7). Это, например, используется для пикселей с компонентами > 1.0, как у вышеупомянутых пикселей Солнца.
Далее канал синего умножается на 0.99999 (строка 8), чтобы floor(color.b) возвращала значение в интервале [0-7].
Для вычисления координат LUT шейдер первым делом преобразует каналы красного и зелёного, чтобы «втиснуть» из в верхний левый сегмент. Канал синего [0-1] разрезается на 64 фрагмента, которые соответствуют всем 64 сегментам в текстуре поиска. На основании текущего значения канала синего выбирается соответствующий сегмент и вычисляется смещение для него.
Пример
Давайте, например, выберем (0.75, 0.5, 1.0). Каналы красного и зелёного преобразуются в верхний левый сегмент, что даёт нам:
float2 rgOffset = (0.75, 0.5) / 8 = (0.09375, 0.0625)
Далее мы проверяем, в каком из 64 сегментов расположено значение синего (1.0). Разумеется, в нашем случае это последний сегмент — 64.
Смещение выражается в сегментах (rowOffset, columnOffset):
float blue_rowOffset = 7.0;
float blue_columnOffset = 7.0;
float2 blueOffset =float2(blue_rowOffset, blue_columnOffset) / 8.0 = (0.875, 0.875)
В конце мы просто суммируем смещения:
float2 finalUV = rgOffset + blueOffset;
finalUV = (0.09375, 0.0625) + (0.875, 0.875) = (0.96875, 0.9375)
Это был просто короткий пример. Теперь давайте изучим подробности реализации.
Для каналов красного и зелёного (r2.xy) в строке 9 прибавляется смещение в полпикселя (0.5 / 64). Затем мы умножаем их на 0.996094 (строка 10) и ограничиваем их (clamp) особым интервалом (строки 11-12).
Необходимость смещения в полпикселя довольно очевидна — мы хотим выполнять сэмплирование из центра пикселя. Гораздо более загадочным аспектом является коэффициент масштабирования из строки 10 — он равен 63,75/64.0. Скоро мы расскажем о нём подробнее.
В конце координаты ограничиваются интервалом [1/64 — 63/64].
Зачем нам это нужно? Я не знаю точно, но похоже, это сделано для того, чтобы билинейное сэмплирование никогда не брало сэмплы за пределами сегмента.
Вот изображение с примером в виде сегмента 6×6, демонстрирующее, как работает эта операция ограничения (clamp):
Вот сцена без применения clamp — заметьте довольно серьёзное обесцвечивание вокруг Солнца:
Для простоты сравнения покажу ещё раз результат из игры:
Вот фрагмент кода для этой части:
// * Calculate red/green offset
// half-pixel offset to always sample within centre of a pixel
const float halfOffset = 0.5 / 64.0;
const float scale = 63.75/64.0;
float2 rgOffset;
rgOffset = halfOffset + color.rg;
rgOffset *= scale;
rgOffset.xy = clamp(rgOffset.xy, float2(1.0/64.0, 1.0/64.0), float2(63.0/64.0, 63.0/64.0) );
// place within the top left slice
rgOffset.xy /= 8.0;
Теперь пора найти смещение для канала синего.
Чтобы найти смещение строк канал синего делится на 8 частей, каждая из которых покрывает ровно одну строку в текстуре поиска.
// rows
bOffset.y = floor(color.b * 8);
Чтобы найти смещение столбца, полученное значение нужно ещё раз разделить на 8 меньших частей, что соответствует всем 8 сегментам строки. Уравнение из шейдера довольно запутанное:
// columns
bOffset.x = floor(color.b * 64 - 8*bOffset.y );
На этом этапе стоит заметить, что:
frac(x) = x — floor(x)
Поэтому уравнение можно переписать так:
bOffset.x = floor(8 * frac(color.b * 8) );
А вот фрагмент кода для этого:
// * Calculate blue offset
float2 bOffset;
// rows
bOffset.y = floor(color.b * 8);
// columns
bOffset.x = floor(color.b * 64 - 8*bOffset.y );
// or:
// bOffset.x = floor(8 * frac(color.b * 8) );
// at this moment bOffset stores values in [0-7] range, we have to divide it by 8.0.
bOffset /= 8.0;
float2 lutPos = rgOffset + bOffset;
return lutPos;
Таким образом мы получили функцию, возвращающую координаты текстуры для сэмплирования текстуры LUT. Давайте назовём эту функцию «getUV».
float2 getUV(in float3 color)
{
...
}
Теперь вернёмся к основной функции шейдера. Как говорилось выше, из-за использования двухмерной LUT для симуляции билинейного сэмплирования канала синего нужны два запроса к LUT (из двух соседних друг с другом сегментов).
Рассмотрим следующий фрагмент на HLSL:
// Part 1
float scale_1 = 63.75/64.0;
float offset_1 = 1.0/64.0; // 0.015625
float3 inputColor1 = inputColorGamma;
inputColor1.b = inputColor1.b * scale_1 + offset_1;
float2 uv1 = getUV(inputColor1);
float3 color1 = texLUT.SampleLevel( sampler1, uv1, 0 ).rgb;
// Part 2
float3 inputColor2 = inputColorGamma;
inputColor2.b = floor(inputColorGamma.b * 63.75) / 64;
float2 uv2 = getUV(inputColor2);
float3 color2 = texLUT.SampleLevel( sampler1, uv2, 0 ).rgb;
// frac(x) = x - floor(x);
//float blueInterp = inputColorGamma.b*63.75 - floor(inputColorGamma.b * 63.75);
float blueInterp = frac(inputColorGamma.b * 63.75);
// Final LUT-corrected color
const float lutCorrectedMult = cb3_v1.z;
float3 finalLUT = lerp(color2, color1, blueInterp);
finalLUT = lutCorrectedMult * GammaToLinear(finalLUT);
Принцип заключается в том, чтобы получать цвета из двух расположенных по соседству сегментов, а затем выполнять интерполяцию между ними — величина интерполяции зависит от дробной части входящего синего цвета.
Part 1 получает цвет из «дальнего» сегмента из-за явно заданного смещения синего ( + 1.0 / 64 );
Результат интерполяции хранится в переменной «finalLUT». Заметьте, что после этого результат снова возвращается в линейное пространство и умножается на lutCorrectedMult. В этом конкретном кадре его значение равно 1.00916. Это позволяет изменять яркость цвета LUT.
Очевидно, самой интригующей частью является «63.75» и «63.75 / 64». Я не совсем понимаю, откуда они берутся. Единственное объяснение, которое я нашёл: 63.75 / 64.0 = 510.0 / 512.0. Как говорилось выше существует ограничение (clamp) для каналов .rg, то есть при прибавлении смещения синего это по сути означает, что самые внешние строки и столбцы LUT не будут использоваться напрямую. Думаю, что цвета явным образом «втиснуты» в центр области текстуры поиска размером 510×510.
Давайте допустим, что inputColorGamma.b = 0.75 / 64.0.
Вот как это работает:
Здесь у нас есть первые четыре сегмента (1-4), которые покрывают канал синего с [0 — 4/64].
Судя по расположению пикселя, похоже, что каналы красного и зелёного примерно равны 0.75 и 0.5.
Мы дважды выполняем запрос к LUT — «Part 1» указывает на сегмент 2, а «Part 2» указывает на первый сегмент.
А интерполяция основана на дробной части цвета, которая равна 0.75.
То есть окончательный результат имеет 75% цвета из первого сегмента и 25% цвета из второго.
Мы почти закончили. Последним нужно сделать следующее:
// Calculate the final color
const float lutCorrectedInfluence = cb3_v1.y; // 0.20 in this frame
float3 finalColor = lerp(inputColorLinear.rgb, finalLUT, lutCorrectedInfluence);
return float4( finalColor, inputColorLinear.a );
Ха! В этом случае окончательный цвет состоит из 80% входящего цвета и 20% цвета LUT!
Давайте снова проведём краткое сравнение изображений: входящий цвет (то есть, по сути, с 0% цветокоррекции), окончательный кадр (20%) и полностью обработанное изображение (100% влияния цветокоррекции):
0% цветокоррекции
20% цветокоррекции (истинный шейдер)
100% цветокоррекции
Несколько LUT
В некоторых случаях «Ведьмак 3» использует несколько LUT.
Вот сцена, в которой используются два LUT:
До прохода цветокоррекции
После прохода цветокоррекции
Используемые LUT:
LUT 1 (texture1)
LUT 2 (texture2)
Давайте изучим ассемблерный фрагмент из этой версии шейдера:
18: sample_l(texture2d)(float,float,float,float) r3.xyz, r2.xyxx, t2.xyzw, s2, l(0)
19: sample_l(texture2d)(float,float,float,float) r2.xyz, r2.xyxx, t1.xyzw, s1, l(0)
...
36: sample_l(texture2d)(float,float,float,float) r4.xyz, r1.xyxx, t2.xyzw, s2, l(0)
37: sample_l(texture2d)(float,float,float,float) r1.xyw, r1.xyxx, t1.xywz, s1, l(0)
38: add r3.xyz, r3.xyzx, -r4.xyzx
39: mad r3.xyz, r1.zzzz, r3.xyzx, r4.xyzx
40: log r3.xyz, abs(r3.xyzx)
41: mul r3.xyz, r3.xyzx, l(2.200000, 2.200000, 2.200000, 0.000000)
42: exp r3.xyz, r3.xyzx
43: add r2.xyz, -r1.xywx, r2.xyzx
44: mad r1.xyz, r1.zzzz, r2.xyzx, r1.xywx
45: log r1.xyz, abs(r1.xyzx)
46: mul r1.xyz, r1.xyzx, l(2.200000, 2.200000, 2.200000, 0.000000)
47: exp r1.xyz, r1.xyzx
48: add r2.xyz, -r1.xyzx, r3.xyzx
49: mad r1.xyz, cb3[1].xxxx, r2.xyzx, r1.xyzx
50: mad r1.xyz, cb3[1].zzzz, r1.xyzx, -r0.xyzx
51: mad o0.xyz, cb3[1].yyyy, r1.xyzx, r0.xyzx
52: mov o0.w, r0.w
53: ret
К счастью, тут всё довольно просто. В соответствии с ассемблерным кодом мы получаем:
// Part 1
// ...
float2 uv1 = getUV(inputColor1);
float3 lut2_color1 = texture2.SampleLevel( sampler2, uv1, 0 ).rgb;
float3 lut1_color1 = texture1.SampleLevel( sampler1, uv1, 0 ).rgb;
// Part 2
// ...
float2 uv2 = getUV(inputColor2);
float3 lut2_color2 = texture2.SampleLevel( sampler2, uv2, 0 ).rgb;
float3 lut1_color2 = texture1.SampleLevel( sampler1, uv2, 0 ).rgb;
float blueInterp = frac(inputColorGamma.b * 63.75);
float3 lut2_finalLUT = lerp(lut2_color2, lut2_color1, blueInterp);
lut2_finalLUT = GammaToLinear(lut2_finalLUT);
float3 lut1_finalLUT = lerp(lut1_color2, lut1_color1, blueInterp);
lut1_finalLUT = GammaToLinear(lut1_finalLUT);
const float lut_Interp = cb3_v1.x;
float3 finalLUT = lerp(lut1_finalLUT, lut2_finalLUT, lut_Interp);
const float lutCorrectedMult = cb3_v1.z;
finalLUT *= lutCorrectedMult;
// Calculate the final color
const float lutCorrectedInfluence = cb3_v1.y;
float3 finalColor = lerp(inputColorLinear.rgb, finalLUT, lutCorrectedInfluence);
return float4( finalColor, inputColorLinear.a );
}
После получения двух цветов из LUT между ними выполняется интерполяция в lut_Interp. Всё остальное практически такое же, как в версии с одной LUT.
В этом случае единственной дополнительной переменной является lut_interp, сообщающая, как смешиваются две LUT.
Её значение в этом конкретном кадре примерно равно 0.96, то есть finalLUT содержит 96% цвета из LUT2 и 4% цвета из LUT1.
Однако это ещё не конец! Сцена, которую я изучал в части «Туман» использует три LUT!
Давайте взглянем!
До прохода цветокоррекции
После прохода цветокоррекции
LUT1 (texture1)
LUT2 (texture2)
LUT3 (texture3)
И снова ассемблерный фрагмент:
23: mad r2.yz, r2.yyzy, l(0.000000, 0.125000, 0.125000, 0.000000), r3.xxyx
24: sample_l(texture2d)(float,float,float,float) r3.xyz, r2.yzyy, t2.xyzw, s2, l(0)
...
34: mad r1.xy, r1.xyxx, l(0.125000, 0.125000, 0.000000, 0.000000), r1.zwzz
35: sample_l(texture2d)(float,float,float,float) r4.xyz, r1.xyxx, t2.xyzw, s2, l(0)
36: add r4.xyz, -r3.xyzx, r4.xyzx
37: mad r3.xyz, r2.xxxx, r4.xyzx, r3.xyzx
38: log r3.xyz, abs(r3.xyzx)
39: mul r3.xyz, r3.xyzx, l(2.200000, 2.200000, 2.200000, 0.000000)
40: exp r3.xyz, r3.xyzx
41: sample_l(texture2d)(float,float,float,float) r4.xyz, r1.xyxx, t1.xyzw, s1, l(0)
42: sample_l(texture2d)(float,float,float,float) r1.xyz, r1.xyxx, t3.xyzw, s3, l(0)
43: sample_l(texture2d)(float,float,float,float) r5.xyz, r2.yzyy, t1.xyzw, s1, l(0)
44: sample_l(texture2d)(float,float,float,float) r2.yzw, r2.yzyy, t3.wxyz, s3, l(0)
45: add r4.xyz, r4.xyzx, -r5.xyzx
46: mad r4.xyz, r2.xxxx, r4.xyzx, r5.xyzx
47: log r4.xyz, abs(r4.xyzx)
48: mul r4.xyz, r4.xyzx, l(2.200000, 2.200000, 2.200000, 0.000000)
49: exp r4.xyz, r4.xyzx
50: add r3.xyz, r3.xyzx, -r4.xyzx
51: mad r3.xyz, cb3[1].xxxx, r3.xyzx, r4.xyzx
52: mad r3.xyz, cb3[1].zzzz, r3.xyzx, -r0.xyzx
53: mad r3.xyz, cb3[1].yyyy, r3.xyzx, r0.xyzx
54: add r1.xyz, r1.xyzx, -r2.yzwy
55: mad r1.xyz, r2.xxxx, r1.xyzx, r2.yzwy
56: log r1.xyz, abs(r1.xyzx)
57: mul r1.xyz, r1.xyzx, l(2.200000, 2.200000, 2.200000, 0.000000)
58: exp r1.xyz, r1.xyzx
59: mad r1.xyz, cb3[2].zzzz, r1.xyzx, -r0.xyzx
60: mad r0.xyz, cb3[2].yyyy, r1.xyzx, r0.xyzx
61: mov o0.w, r0.w
62: add r0.xyz, -r3.xyzx, r0.xyzx
63: mad o0.xyz, cb3[2].wwww, r0.xyzx, r3.xyzx
64: ret
К сожалению, эта версия шейдера гораздо более запутанная, чем предыдущие две. Например, UV под названием «uv1» раньше встречались в ассемблерном коде перед «uv2» (сравните ассемблерный код шейдера всего с одной LUT). Но здесь всё не так — UV для «Part 1» вычисляются в строке 34, UV для «Part 2» — в строке 23.
Потратив гораздо больше ожидаемого времени на изучение того, что здесь происходит и недоумевая, почему Part2, похоже, поменялась местами с Part1, я написал фрагмент кода на HLSL для трёх LUT:
// Part 1
// ...
float2 uv1 = getUV(inputColor1);
float3 lut3_color1 = texture3.SampleLevel( sampler3, uv1, 0 ).rgb;
float3 lut2_color1 = texture2.SampleLevel( sampler2, uv1, 0 ).rgb;
float3 lut1_color1 = texture1.SampleLevel( sampler1, uv1, 0 ).rgb;
// Part 2
// ...
float2 uv2 = getUV(inputColor2);
float3 lut3_color2 = texture3.SampleLevel( sampler3, uv2, 0 ).rgb;
float3 lut2_color2 = texture2.SampleLevel( sampler2, uv2, 0 ).rgb;
float3 lut1_color2 = texture1.SampleLevel( sampler1, uv2, 0 ).rgb;
float blueInterp = frac(inputColorGamma.b * 63.75);
// At first compute linear color for LUT 2 [assembly lines 36-40]
float3 lut2_finalLUT = lerp(lut2_color2, lut2_color1, blueInterp);
lut2_finalLUT = GammaToLinear(lut2_finalLUT);
// Compute linear color for LUT 1 [assembly: 45-49]
float3 lut1_finalLUT = lerp(lut1_color2, lut1_color1, blueInterp);
lut1_finalLUT = GammaToLinear(lut1_finalLUT);
// Interpolate between LUT 1 and LUT 2 [assembly: 50-51]
const float lut12_Interp = cb3_v1.x;
float3 lut12_finalLUT = lerp(lut1_finalLUT, lut2_finalLUT, lut12_Interp);
// Multiply the LUT1-2 intermediate result with scale factor [assembly: 52]
const float lutCorrectedMult_LUT1_2 = cb3_v1.z;
lut12_finalLUT *= lutCorrectedMult;
// Mix LUT1-2 intermediate result with the scene color [assembly: 52-53]
const float lutCorrectedInfluence_12 = cb3_v1.y;
lut12_finalLUT = lerp(inputColorLinear.rgb, lut12_finalLUT, lutCorrectedInfluence_12);
// Compute linear color for LUT3 [assembly: 54-58]
float3 lut3_finalLUT = lerp(lut3_color2, lut3_color1, blueInterp);
lut3_finalLUT = GammaToLinear(lut3_finalLUT);
// Multiply the LUT3 intermediate result with the scale factor [assembly: 59]
const float lutCorrectedMult_LUT3 = cb3_v2.z;
lut3_finalLUT *= lutCorrectedMult_LUT3;
// Mix LUT3 intermediate result with the scene color [assembly: 59-60]
const float lutCorrectedInfluence3 = cb3_v2.y;
lut3_finalLUT = lerp(inputColorLinear.rgb, lut3_finalLUT, lutCorrectedInfluence3);
// The final mix between LUT1+2 and LUT3 influence [assembly: 62-63]
const float finalInfluence = cb3_v2.w;
float3 finalColor = lerp(lut12_finalLUT, lut3_finalLUT, finalInfluence);
return float4( finalColor, inputColorLinear.a );
}
После завершения всех запросов текстур сначала интерполируются результаты LUT1 и LUT2, затем они умножаются на коэффициент масштабирования, а далее комбинируются с линейным цветом основной сцены. Давайте назовём результат lut12_finalLUT.
Затем примерно то же самое происходит для LUT3 — умножаем на ещё один коэффициент масштабирования и комбинируем с цветом основной сцены, что даёт нам lut3_finalLUT.
В конце оба промежуточных результата снова интерполируются.
Вот значения из cbuffer:
Часть 3: порталы
Если вы достаточно долго играли в «Ведьмака 3», то знаете, что Геральт — не большой любитель порталов. Давайте разберёмся, действительно ли они так страшны.
В игре есть два типа порталов:
Синий портал
Огненный портал
Я объясню, как создаётся огненный. В основном потому, что его код проще, чем у синего 🙂
Вот как огненный портал выглядит в игре:
Самая важная часть — это, разумеется, огонь, вращающийся по направлению к центру, но сам эффект состоит не только из видимой части. Подробнее об этом позже.
План этой части довольно стандартен: сначала геометрия, потом вершинные и пиксельные шейдеры. Будет довольно много скриншотов и видео.
С точки зрения рендеринга порталы отрисовываются в прямом проходе со включенным смешиванием — довольно распространённый в игре приём; подробнее см. в части о падающих звёздах.
Ну что, приступим.
1. Геометрия
Вот как выглядит меш портала:
Локальное пространство — вид спереди
Локальное пространство — вид сбоку
Меш напоминает рог Гавриила. Вершинный шейдер сжимает его по одной оси, вот тот же меш после сжатия в виде сбоку (в мировом пространстве):
Меш портала после вершинного шейдера (вид сбоку)
Кроме позиции у каждой вершины есть дополнительные данные: важны для нас следующие (на этом этапе я буду демонстрировать визуализации из RenderDoc, а позже расскажу о них подробнее):
Texcoords (float2):
Tangent (float3):
Color (float3):
Все эти данные будут использованы позднее, но на этом этапе у нас уже слишком много данных для файла .obj, поэтому экспорт этого меша может вызвать проблемы. Я экспортировал каждый канал как отдельный файл .csv, а затем загрузил все файлы .csv в моё приложение на C++ и сборка меша на основании этих собранных данных выполняется во время выполнения.
2. Вершинный шейдер
Вершинный шейдер не особо интересен, но давайте всё равно взглянем на соответствующий фрагмент:
vs_5_0
dcl_globalFlags refactoringAllowed
dcl_constantbuffer cb1[7], immediateIndexed
dcl_constantbuffer cb2[6], immediateIndexed
dcl_input v0.xyz
dcl_input v1.xy
dcl_input v3.xyz
dcl_input v4.xyzw
dcl_input v6.xyzw
dcl_input v7.xyzw
dcl_input v8.xyzw
dcl_output o0.xyz
dcl_output o1.xyzw
dcl_output o2.xyz
dcl_output o3.xyz
dcl_output_siv o4.xyzw, position
dcl_temps 3
0: mov o0.xy, v1.xyxx
1: mul r0.xyzw, v7.xyzw, cb1[6].yyyy
2: mad r0.xyzw, v6.xyzw, cb1[6].xxxx, r0.xyzw
3: mad r0.xyzw, v8.xyzw, cb1[6].zzzz, r0.xyzw
4: mad r0.xyzw, cb1[6].wwww, l(0.000000, 0.000000, 0.000000, 1.000000), r0.xyzw
5: mad r1.xyz, v0.xyzx, cb2[4].xyzx, cb2[5].xyzx
6: mov r1.w, l(1.000000)
7: dp4 o0.z, r1.xyzw, r0.xyzw
8: mov o1.xyzw, v4.xyzw
9: dp4 o2.x, r1.xyzw, v6.xyzw
10: dp4 o2.y, r1.xyzw, v7.xyzw
11: dp4 o2.z, r1.xyzw, v8.xyzw
12: mad r0.xyz, v3.xyzx, l(2.000000, 2.000000, 2.000000, 0.000000), l(-1.000000, -1.000000, -1.000000, 0.000000)
13: dp3 r2.x, r0.xyzx, v6.xyzx
14: dp3 r2.y, r0.xyzx, v7.xyzx
15: dp3 r2.z, r0.xyzx, v8.xyzx
16: dp3 r0.x, r2.xyzx, r2.xyzx
17: rsq r0.x, r0.x
18: mul o3.xyz, r0.xxxx, r2.xyzx
Вершинный шейдер очень похож на прочие шейдеры, которые мы встречали ранее.
После краткого анализа и сравнения схемы входных данных я выяснил, что выходную struct можно записать так:
struct VS_OUTPUT
{
float3 TexcoordAndViewSpaceDepth : TEXCOORD0;
float3 Color : TEXCOORD1;
float3 WorldSpacePosition : TEXCOORD2;
float3 Tangent : TEXCOORD3;
float4 PositionH : SV_Position;
};
Я хотел продемонстрировать один аспект — как шейдер получает глубину в пространстве обзора (o0.z): это просто компонент .w переменной SV_Position.
На gamedev.net есть тема, в которой это объясняется чуть подробнее.
3. Пиксельный шейдер
Вот сцена примера непосредственно перед отрисовкой портала:
… и после отрисовки:
кроме того, в утилите просмотра текстур RenderDoc есть полезная опция оверлея «Clear Before Draw», при помощи которой мы со всей точностью можем увидеть отрисованный портал:
Первый интересный аспект заключается в том, что сам слой пламени отрисовывается только в центральной области меша.
Пиксельный шейдер состоит из 186 строк, для удобства я выложил его сюда. Как обычно, во время объяснения я буду приводить соответствующие фрагменты на ассемблере.
Также стоит заметить, что 100 из 186 строк относятся к вычислению тумана.
В начале на вход подаются 4 текстуры: огонь (t0), шум/дым (t1), цвет сцены (t6) и глубина сцены (t15):
Текстура огня
Текстура шума/дыма
Цвет сцены
Глубина сцены
Также есть отдельный буфер констант с 14 параметрами, которые управляют эффектом:
Входящие данные: позиция, tangent и texcoords — довольно понятные концепции, но давайте внимательнее приглядимся к каналу «Color». После нескольких экспериментов мне кажется, что это не цвет сам по себе, а три различные маски, которые шейдер использует, чтобы различать отдельные слои и понимать, где применять различные эффекты:
Color.r — маска теплового марева. Как понятно из названия, она используется для эффекта теплового искажения воздуха (подробнее о нём позже):
Color.g — внутренняя маска. В основном используется для эффекта огня.
Color.b — задняя маска. Используется для определения того, где находится «задняя» часть портала.
Я считаю, что в случае подобных эффектов лучше описать отдельные слои, а не анализировать ассемблерный код от начала до конца, как я делал раньше.
Итак, поехали:
3.1. Слой огня
Для начала давайте исследуем самую важную часть: слой огня. Вот видео с ним:
Основной принцип реализации такого эффекта заключается в использовании статичных texcoords из данных для каждой вершины и анимировании их при помощи переменной истекшего времени из буфера констант. Благодаря таким анимированным texcoords мы сэмплируем текстуру (в нашем случае огня) при помощи сэмплера искажения/повтора.
Интересно, что в этом конкретном эффекте сэмплируется только канал .r текстуры огня. Чтобы эффект был более правдоподобным, описанным выше способом получаются два слоя огня, которые затем комбинируются друг с другом.
Ну, давайте наконец посмотрим на код!
Начинаем мы с того, что делаем texcoords более динамичными, когда они достигают центра меша:
const float2 texcoords = Input.TextureUV;
const float uvSquash = cb4_v4.x; // 2.50
...
const float y_cutoff = 0.2;
const float y_offset = pow(texcoords.y - y_cutoff, uvSquash);
А вот то же самое, но на ассемблере:
21: add r1.z, v0.y, l(-0.200000)
22: log r1.z, r1.z
23: mul r1.z, r1.z, cb4[4].x
24: exp r1.z, r1.z
Затем шейдер получает texcoords для первого слоя огня и сэмплирует текстуру огня:
const float elapsedTimeSeconds = cb0_v0.x;
const float uvScaleGlobal1 = cb4_v2.x; // 1.00
const float uvScale1 = cb4_v3.x; // 0.15
...
// Sample fire1 - the first fire layer
float fire1; // r1.w
{
float2 fire1Uv;
fire1Uv.x = texcoords.x;
fire1Uv.y = uvScale1 * elapsedTimeSeconds + y_offset;
const float scaleGlobal = floor(uvScaleGlobal1); // 1.0
fire1Uv *= scaleGlobal;
fire1 = texFire.Sample(samplerLinearWrap, fire1Uv).x;
}
Вот соответствующий фрагмент на ассемблере:
25: round_ni r1.w, cb4[2].x
26: mad r2.y, cb4[3].x, cb0[0].x, r1.z
27: mov r2.x, v0.x
28: mul r2.xy, r1.wwww, r2.xyxx
29: sample_indexable(texture2d)(float,float,float,float) r1.w, r2.xyxx, t0.yzwx, s0
Вот как выглядит первый слой при elapsedTimeSeconds = 50.0:
Чтобы показать, что же делает y_cutoff, продемонстрируем ту же сцену, но с y_cutoff = 0.5:
Таким образом мы получили первый слой. Далее шейдер получает второй:
const float uvScale2 = cb4_v6.x; // 0.06
const float uvScaleGlobal2 = cb4_v7.x; // 1.00
...
// Sample fire2 - the second fire layer
float fire2; // r1.z
{
float2 fire2Uv;
fire2Uv.x = texcoords.x - uvScale2 * elapsedTimeSeconds;
fire2Uv.y = uvScale2 * elapsedTimeSeconds + y_offset;
const float fire2_scale = floor(uvScaleGlobal2);
fire2Uv *= fire2_scale;
fire2 = texFire.Sample(samplerLinearWrap, fire2Uv).x;
}
А вот соответствующий ассемблерный фрагмент:
144: mad r2.x, -cb0[0].x, cb4[6].x, v0.x
145: mad r2.y, cb0[0].x, cb4[6].x, r1.z
146: round_ni r1.z, cb4[7].x
147: mul r2.xy, r1.zzzz, r2.xyxx
148: sample_indexable(texture2d)(float,float,float,float) r1.z, r2.xyxx, t0.yzxw, s0
То есть, как вы видите, единственное отличие заключается в UV: теперь X тоже анимируется.
Второй слой выглядит так:
Получив два слоя внутреннего огня, мы можем их скомбинировать. Однако этот процесс чуть сложнее, чем обычное умножение, поскольку в нём участвует внутренняя маска:
const float innerMask = Input.Color.y;
const float portalInnerColorSqueeze = cb4_v8.x; // 3.00
const float portalInnerColorBoost = cb4_v9.x; // 188.00
...
// Calculate inner fire influence
float inner_influence; // r1.z
{
// innerMask and "-1.0" are used here to control where the inner part of a portal is.
inner_influence = fire1 * fire2 + innerMask;
inner_influence = saturate(inner_influence - 1.0);
// Exponentation to hide less luminous elements of inner portal
inner_influence = pow(inner_influence, portalInnerColorSqueeze);
// Boost the intensity
inner_influence *= portalInnerColorBoost;
}
Вот соответствующий ассемблерный код:
149: mad r1.z, r1.w, r1.z, v1.y
150: add_sat r1.z, r1.z, l(-1.000000)
151: log r1.z, r1.z
152: mul r1.z, r1.z, cb4[8].x
153: exp r1.z, r1.z
154: mul r1.z, r1.z, cb4[9].x
Получив inner_influence, которая является ни чем иным, как маской для внутреннего огня, мы можем просто умножить маску на цвет внутреннего огня:
// Calculate portal color
const float3 colorPortalInner = cb4_v5.rgb; // (1.00, 0.60, 0.21961)
...
const float3 portal_inner_final = pow(colorPortalInner, 2.2) * inner_influence;
Ассемблерный код:
155: log r2.xyz, cb4[5].xyzx
156: mul r2.xyz, r2.xyzx, l(2.200000, 2.200000, 2.200000, 0.000000)
157: exp r2.xyz, r2.xyzx
...
170: mad r2.xyz, r2.xyzx, r1.zzzz, r3.xyzx
Вот видео, в котором демонстрируются отдельные слои внутреннего огня. Порядок: первый слой, второй слой, внутреннее влияния и окончательный внутренний цвет:
3.2. Свечение
Создав внутренний огонь, переходим ко второму слою: свечению. Вот видео, демонстрирующее сначала только внутренний огонь, потом только свечение, а затем их сумму — готовый эффект огня:
Вот как шейдер вычисляет свечение. Аналогично созданию внутреннего огня, сначала генерируется маска, которая затем умножается на цвет свечения из буфера констант.
const float portalOuterGlowAttenuation = cb4_v10.x; // 0.30
const float portalOuterColorBoost = cb4_v11.x; // 1.50
const float3 colorPortalOuterGlow = cb4_v12.rgb; // (1.00, 0.61961, 0.30196)
...
// Calculate outer portal glow
float outer_glow_influence;
{
float outer_mask = (1.0 - backMask) * innerMask;
const float perturbParam = fire1*fire1;
float outer_mask_perturb = lerp( 1.0 - portalOuterGlowAttenuation, 1.0, perturbParam );
outer_mask *= outer_mask_perturb;
outer_glow_influence = outer_mask * portalOuterColorBoost;
}
// the final glow color
const float3 portal_outer_final = pow(colorPortalOuterGlow, 2.2) * outer_glow_influence;
// and the portal color, the sum of fire and glow
float3 portal_final = portal_inner_final + portal_outer_final;
Вот как выглядит outer_mask:
(1.0 — backMask) * innerMask
Свечение не имеет постоянного цвета. Чтобы оно выглядело интереснее, используется анимированный первый слой огня (в квадрате), поэтому заметны идущие к центру колебания:
Вот ассемблерный код, отвечающий за свечение:
158: add r2.w, -v1.z, l(1.000000)
159: mul r2.w, r2.w, v1.y
160: mul r1.w, r1.w, r1.w
161: add r3.x, l(1.000000), -cb4[10].x
162: add r3.y, -r3.x, l(1.000000)
163: mad r1.w, r1.w, r3.y, r3.x
164: mul r1.w, r1.w, r2.w
165: mul r1.w, r1.w, cb4[11].x
166: log r3.xyz, cb4[12].xyzx
167: mul r3.xyz, r3.xyzx, l(2.200000, 2.200000, 2.200000, 0.000000)
168: exp r3.xyz, r3.xyzx
169: mul r3.xyz, r1.wwww, r3.xyzx
170: mad r2.xyz, r2.xyzx, r1.zzzz, r3.xyzx
3.3. Марево
Когда я начал анализировать реализацию шейдера портала, мне было непонятно, почему в качестве одной из входящих текстур используется цвет сцены без портала. Я рассуждал так — «здесь мы используем смешение, поэтому достаточно возвращать пиксель с нулевым значением альфы, чтобы сохранить цвет фона».
Шейдер имеет небольшой, но красивый эффект марева (теплового искажения воздуха) — от портала исходят тепло и энергия, поэтому фон искажён.
Принцип заключается в смещении texcoords пикселя и сэмплировании текстуры цвета фона с новыми координатами — такую операцию невозможно выполнить простым смешением.
Вот видео с демонстрацией того, как это работает. Порядок: сначала полный эффект, потом марево из шейдера, а в конце я умножаю смещение на 10, чтобы усилить эффект.
Давайте посмотрим, как вычисляется смещение.
const float ViewSpaceDepth = Input.ViewSpaceDepth;
const float3 Tangent = Input.Tangent;
const float backgroundDistortionStrength = cb4_v1.x; // 0.40
// Fades smoothly from the outer edges to the back of a portal
const float heatHazeMask = Input.Color.x;
...
// The heat haze effect is view dependent thanks to tangent vectors in view space.
float2 heatHazeOffset = mul( normalize(Tangent), (float3x4)g_mtxView);
heatHazeOffset *= float2(-1, 1);
// Fade the effect as camera is further from a portal
const float heatHazeDistanceFade = backgroundDistortionStrength / ViewSpaceDepth;
heatHazeOffset *= heatHazeDistanceFade;
heatHazeOffset *= heatHazeMask;
// this is what animates the heat haze effect
heatHazeOffset *= pow(fire1, 0.2);
// Actually I don't know what's this :)
// It was 1.0 usually so I won't bother discussing this.
heatHazeOffset *= vsDepth2;
Соответствующий ассемблер разбросан по коду:
11: dp3 r1.x, v3.xyzx, v3.xyzx
12: rsq r1.x, r1.x
13: mul r1.xyz, r1.xxxx, v3.xyzx
14: mul r1.yw, r1.yyyy, cb12[2].xxxy
15: mad r1.xy, cb12[1].xyxx, r1.xxxx, r1.ywyy
16: mad r1.xy, cb12[3].xyxx, r1.zzzz, r1.xyxx
17: mul r1.xy, r1.xyxx, l(-1.000000, 1.000000, 0.000000, 0.000000)
18: div r1.z, cb4[1].x, v0.z
19: mul r1.xy, r1.zzzz, r1.xyxx
20: mul r1.xy, r1.xyxx, v1.xxxx
...
33: mul r1.xy, r1.xyxx, r2.xxxx
34: mul r1.xy, r0.zzzz, r1.xyxx
Мы вычислили смещение, так давайте его используем!
const float2 backgroundSceneMaxUv = cb0_v2.zw; // (1.0, 1.0)
const float2 invViewportSize = cb0_v1.zw; // (1.0 / 1920.0, 1.0 / 1080.0 )
// Obtain background scene color - we need to obtain it from texture
// for distortion effect
float3 sceneColor;
{
const float2 sceneUv_0 = pixelUv + backgroundSceneMaxUv*heatHazeOffset;
const float2 sceneUv_1 = backgroundSceneMaxUv - 0.5*invViewportSize;
const float2 sceneUv = min(sceneUv_0, sceneUv_1);
sceneColor = texScene.SampleLevel(sampler6, sceneUv, 0).rgb;
}
175: mad r0.xy, cb0[2].zwzz, r1.xyxx, r0.xyxx
176: mad r1.xy, -cb0[1].zwzz, l(0.500000, 0.500000, 0.000000, 0.000000), cb0[2].zwzz
177: min r0.xy, r0.xyxx, r1.xyxx
178: sample_l(texture2d)(float,float,float,float) r1.xyz, r0.xyxx, t6.xyzw, s6, l(0)
Итак, в конечном итоге мы получили sceneColor.
3.4. Цвет «цели» портала
Под цветом «цели» я подразумеваю центральную часть портала:
К сожалению, он весь чёрный. И причиной этого является туман.
Я уже рассказывал о том, как реализован туман в этой статье. В шейдере портала вычисления тумана находятся в строках [35-135] исходного ассемблерного кода.
HLSL:
struct FogResult
{
float4 paramsFog;
float4 paramsAerial;
};
...
FogResult fog;
{
const float3 CameraPosition = cb12_v0.xyz;
const float fogStart = cb12_v22.z; // near plane
fog = CalculateFog( WSPosition, CameraPosition, fogStart, false );
}
...
const float3 destination_color = fog.paramsFog.a * fog.paramsFog.rgb;
И таким образом мы получаем готовую сцену:
Дело в том, что камера в кадре находится так близко к порталу, что вычисляемый destination_color равен нулю, то есть чёрный центр портала на самом деле является туманом (или, строго говоря, его отсутствием).
Так как при помощи RenderDoc мы можем инъектировать в игру шейдеры, давайте попробуем сместить камеру вручную:
const float3 CameraPosition = cb12_v0.xyz + float3(100, 100, 0);
И вот результат:
Ха!
Итак, хотя в этом конкретном случае очень мало смысла использовать вычисления тумана, теоретически ничто не мешает нам использовать в качестве destination_color . например, ландшафт из другого мира (возможно, потребуется дополнительная пара texcoords, но, тем не менее, это вполне реализуемо).
Использование тумана может быть полезным в случае огромного портала, который игрок может увидеть с большого расстояния.
3.5. Смешение цвета сцены (с наложенным маревом) с «целью»
Я раздумывал, куда поместить этот раздел — в «Цвет „цели“» или «Собираем всё вместе», но решил создать новый подраздел.
Итак, на этом этапе у нас есть описанный в 3.3 sceneColor, уже содержащий эффект марева (теплового искажения), а также есть destination_color из раздела 3.4.
Они интерполируются при помощи:
178: sample_l(texture2d)(float,float,float,float) r1.xyz, r0.xyxx, t6.xyzw, s6, l(0)
179: mad r3.xyz, r4.wwww, r4.xyzx, -r1.xyzx
180: mad r0.xyw, r0.wwww, r3.xyxz, r1.xyxz
Что за значение, которое их интерполирует (r0.w)?
Здесь применяется текстура шума/дыма.
Она используется для создания того, что я называю «маской цели портала».
Вот видео (сначала полный эффект, затем маска цели, затем интерполированные подвергнутый мареву цвет сцены и цвет цели):
Взгляните на этот фрагмент на HLSL:
// Determines the back part of a portal
const float backMask = Input.Color.z;
const float ViewSpaceDepth = Input.TexcoordAndViewSpaceDepth.z;
const float viewSpaceDepthScale = cb4_v0.x; // 0.50
...
// Load depth from texture
float hardwareDepth = texDepth.SampleLevel(sampler15, pixelUv, 0).x;
float linearDepth = getDepth(hardwareDepth);
// cb4_v0.x = 0.5
float vsDepthScale = saturate( (linearDepth - ViewSpaceDepth) * viewSpaceDepthScale );
float vsDepth1 = 2*vsDepthScale;
....
// Calculate 'portal destination' mask - maybe we would like see a glimpse of where a portal leads
// like landscape from another planet - the shader allows for it.
float portal_destination_mask;
{
const float region_mask = dot(backMask.xx, vsDepth1.xx);
const float2 _UVScale = float2(4.0, 1.0);
const float2 _TimeScale = float2(0.0, 0.2);
const float2 _UV = texcoords * _UVScale + elapsedTime * _TimeScale;
portal_destination_mask = texNoise.Sample(sampler0, _UV).x;
portal_destination_mask = saturate(portal_destination_mask + region_mask - 1.0);
portal_destination_mask *= portal_destination_mask; // line 143, r0.w
}
Маска цели портала в целом получается так же, как огонь — при помощи анимированных координат текстуры. Для настройки местоположения эффекта используется переменная «region_mask«.
Для получения region_mask используется ещё одна переменная под названием vsDepth1. Чуть подробнее я опишу её в следующем разделе. Впрочем, она имеет незначительное влияние на маску цели.
Ассемблерный код маски цели выглядит так:
137: dp2 r0.w, v1.zzzz, r0.zzzz
138: mul r2.xy, cb0[0].xxxx, l(0.000000, 0.200000, 0.000000, 0.000000)
139: mad r2.xy, v0.xyxx, l(4.000000, 1.000000, 0.000000, 0.000000), r2.xyxx
140: sample_indexable(texture2d)(float,float,float,float) r2.x, r2.xyxx, t1.xyzw, s0
141: add r0.w, r0.w, r2.x
142: add_sat r0.w, r0.w, l(-1.000000)
143: mul r0.w, r0.w, r0.w
3.6. Соединяем всё вместе
Фух, почти закончили.
Давайте сначала получим цвет портала:
// Calculate portal color
float3 portal_final;
{
const float3 portal_inner_color = pow(colorPortalInner, 2.2) * inner_influence;
const float3 portal_outer_color = pow(colorPortalOuterGlow, 2.2) * outer_glow_influence;
portal_final = portal_inner_color + portal_outer_color;
portal_final *= vsDepth1; // fade the effect to avoid harsh artifacts due to depth test
portal_final *= portalFinalColorFilter; // this was (1,1,1) - so not relevant
}
Единственный аспект, который я хочу здесь обсудить — это vsDepth1.
Вот как выглядит эта маска:
В предыдущем подразделе я показал, как она получается; по сути, это «линейный буфер глубин», используемый для уменьшения цвета портала так, чтобы не было резкой границы из-за теста глубин.
Рассмотрим ещё раз готовую сцену, с умножением и без умножения на vsDepth1.
После создания portal_final получить готовый цвет просто:
const float finalPortalAmount = cb2_v0.x; // 0.99443
const float3 finalColorFilter = cb2_v2.rgb; // (1.0, 1.0, 1.0)
const float finalOpacityFilter = cb2_v2.a; // 1.0
...
// Alpha component for blending
float opacity = saturate( lerp(cb2_v0.x, 1, cb4_v13.x) );
// Calculate the final color
float3 finalColor;
{
// Mix the scene color (with heat haze effect) with the 'destination color'.
// In this particular example fog is used as destination (which is black where camera is nearby)
// but in theory there is nothing which stops us from putting here a landscape from another world.
const float3 destination_color = fog.paramsFog.a * fog.paramsFog.rgb;
finalColor = lerp( sceneColor, destination_color, portal_destination_mask );
// Add the portal color
finalColor += portal_final * finalPortalAmount;
// Final filter
finalColor *= finalColorFilter;
}
opacity *= finalOpacityFilter;
return float4(finalColor * opacity, opacity);
Вот и всё. Есть ещё одна переменная finalPortalAmount, определяющая, сколько пламени видит игрок. Я не стал тестировать её подробно, но предполагаю, что она используется, когда портал появляется и исчезает — на короткий промежуток времени игрок не видит огня, зато видит всё остальное — свечение, цвет цели и т.п.
4. Подведём итог
Готовый шейдер на HLSL выложен здесь. Мне пришлось поменять местами несколько строк, чтобы получить тот же ассемблерный код, что и у оригинала, но это не мешает общему потоку выполнения. Шейдер готов к использованию с RenderDoc, все cbuffers присутствуют и т.д, поэтому вы можете его инъектировать и поэкспериментировать самостоятельно.
Надеюсь, вам понравилось, спасибо за прочтение!