http://habrahabr.ru/post/238425/
Как я и обещал – публикую вторую статью о некоторых моментах разработки игр в трех измерениях. Сегодня расскажу об одной технике, которая используется почти любом проекте
ААА-класса. Имя ей —
HDR Rendering. Если интересно — добро пожаловать под хабракат.
Но сначала нужно поговорить. Исходя из
прошлой статьи — понял, что и аудитория хабрахабра похоронила технологию
Microsoft XNA. Делать, якобы, с помощью его что-то — все равно, что писать игры на
ZX Spectrum. В пример мне привели: “
Есть же ведь SharpDX, SlimDX, OpenTK!”, даже приводили в пример
Unity. Но остановимся на первых трех, все это —
чистой воды врапперы DX’a под .NET, а
Unity так вообще
движок-песочница. Что вообще из себя представляет
DirectX10+? Ведь его нет и не будет в
XNA. Так вот, подавляющие кол-во эффектов, фишек и технологий реализуется именно на базе
DirectX9c. А
DirectX10+ вводит лишь дополнительный функционал (
SM4.0, SM5.0).
Взять, например,
Crysis 2:
В этих двух скриншотах
нет никакого
DirextX10 и DirectX11. Так отчего люди думают, что делать что-то на
XNA — заниматься некрофилией? Да,
Microsoft перестала поддерживать
XNA, но запаса того, что там есть — хватит на 3 года точно. Более того, сейчас существует
monogame, он опенсорсный, кроссплатформенный
(win, unix, mac, android, ios, etc) и сохраняет всю ту же архитектуру
XNA. Кстати,
FeZ из
прошлой статьи написан с использованием
monogame. Ну и напоследок — статьи направленные в целом то на компьютерную графику в трех измерениях (все эти положения справедливы и для
OpenGL, и для
DirectX), а не
XNA — как можно подумать.
XNA в нашем случае всего-лишь инструмент.
Ладно, поехали
Обычно в играх используется
LDR (Low Dynamic Range) рендеринг. Это означает, что цвет бэк-буфера ограничен в пределах 0…1. Где на каждый канал уделяется по 8 бит, а это 256 градаций. К примеру: 255, 255, 255 — белый цвет, все три канала (RGB) равны максимальной градации. Понятие
LDR несправедливо применять к понятию реалистичного рендеринга, т.к. в реальном мире цвет задается далеко не нулем и единицей. На помощь к нам приходит такая технология, как
HDRR. Для начала, что такое
HDR?
High Dynamic Range Rendering, иногда просто «
High Dynamic Range» — графический эффект, применяемый в компьютерных играх для более выразительного рендеринга изображения при контрастном освещении сцены. В чем заключается суть этого подхода? В том, что мы рисуем нашу геометрию (и освещение) не ограничиваясь нулем и единицей: один источник света может дать яркость пикселя в 0.5 единиц, а другой в 100 единиц. Но как можно заменить на первый взгляд, то наш экран воспроизводит как раз тот самый
LDR формат. И если мы все значения цвета бэк-буфера разделим на максимальную яркость в сцене — получится тот же
LDR, а источник света в 0.5 единиц почти не будет виден на фоне второго. И как раз для этого был придуман особый метод называемый
Tone Mapping. Суть этого подхода, что мы приводим динамический диапазон к
LDR в зависимости от средней яркости сцены. И для того, чтобы понять о чем я, рассмотрим сцену: две комнаты, одна комната
indoor, другая
outdoor. Первая комната — имеет искусственный источник света, вторая комната — имеет источник света в виде солнца. Яркость солнца на порядок выше, чем яркость искусственного источника света. И в реальном мире, при нахождении в первой комнате — мы адаптируемся к этому освещению, при входе в другую комнату мы адаптируемся к другому уровню освещения. При взгляде из первой комнаты во вторую — она будет казаться нам чрезмерно яркой, а при взгляде из второй в первую — черной.
Еще один пример: одна
outdoor комната. В этой комнате — есть само солнце и рассеянный свет от солнца. Яркость солнца на порядок выше, чем его рассеянный свет. В случае
LDR значения яркости света были бы равны. Поэтому, используя
HDR можно добиться реалистичных бликов с различных поверхностей. Это очень заметно на воде:
Или на бликах с сурфейса:
Ну и контрастность сцены в целом (слева HDR, справа LDR):
Вместе с
HDR принято применять и технологию
Bloom, яркие области размываются и накладываются поверх основного изображения:
Это делает освещение еще мягче.
Так же, в виде бонуса — расскажу про
Color Grading. Этот поход повсеместно применяется в играх ААА-класса.
Color Grading
Очень часто в играх сцена должна иметь свой цветовой тон, этот цветовой тон может быть общим как для всей игры, так и для отдельных участков сцены. И чтобы каждый раз не иметь по сто шейдеров-постпроцессоров — используют подход Color Grading. В чем суть этого подхода?
Знаменитые буквы
RGB — цветовое трехмерное пространство, где каждый канал это своеобразная координата. В случае формата R8G8B8: 255 градаций на каждый канал. Так вот, что будет, если мы применим обычные операции обработки (например, кривые или контрастность) к этому пространству? Наше пространство изменится и в будущем мы можем назначить любому пикселю — пиксель из этого пространства.
Создадим простое
RGB пространство (хочу заменить, что берем мы каждый 8-ой пиксель, т.к. если будем брать все 256 градаций, то размер текстуры будет очень большим):
Это трехмерная текстура, где на каждую ось — свой канал.
И возьмем какую-нибудь сцену, которую нужно модифицировать (добавив при этом на изображение наше пространство):
Проводим нужные нам трансформации (на глаз):
И извлекаем наше модифицируемое пространство:
Теперь, по этому пространству — мы можем применить все модификации с цветом к любому изображению. Просто сопоставляя оригинальный цвет с измененным пространством цвета.
Реализация
Ну и кратко по реализации
HDR в
XNA. В
XNA формат бэк-буфера задается (в основном)
R8G8B8A8, т.к. рендеринг прямо на экран не может поддерживать
HDR априори. Для этого обхода — нам нужно создать новый RenderTarget (ранее я описывал работу онных
тут) с особым форматом:
HalfVector4*. Этот формат поддерживает плавающие значения у
RenderTarget.
* — в
XNA есть такой формат — как
HDRBlendable, это все тот же
HalfVector4 — но сам RT занимает меньше места (т.к. на альфа канал нам не нужен
floating-point).
Заведем нужный
RenderTarget:
private void _makeRenderTarget()
{
// Use regular fp16
_sceneTarget = new RenderTarget2D(GraphicsDevice,
GraphicsDevice.PresentationParameters.BackBufferWidth,
GraphicsDevice.PresentationParameters.BackBufferHeight,
false,
SurfaceFormat.HdrBlendable, DepthFormat.Depth24Stencil8,
0,
RenderTargetUsage.DiscardContents);
}
Создаем новый RT с размерами бэк-буфера (разрешением экрана) с отключенным mipmap (т.к. эта текстура будет рисоваться на экранном кваде) с форматом сурфейса —
HdrBlendable (или
HalfVector4) и 24-ех битным буфером глубины / стенсил-буферов 8 бит. Так же отключим
multisampling.
У этого
RenderTarget важно включить буфер глубины (в отличии от обычного post-process RT), т.к. мы будем рисовать туда нашу геометрию.
Далее — все как в
LDR, мы рисуем сцену, только теперь не нужно ограничиваться рисованием яркости [0...1].
Добавим skybox с номинальной яркостью умноженной на три и классический чайник Юта с
DirectionalLight-освещением и
Reflective-поверхностью.
Сцена создана и теперь нам нужно как-нибудь формат
HDR привести к
LDR. Возьмем самый простой
ToneMapping — разделим все эти величины на условное значение max.
float3 _toneSimple(float3 vColor, float max)
{
return vColor / max;
}
Покрутим камерой и поймем, что сцена все равно обладает статичностью и подобную картинку можно с легкостью добиться, применив контрастность к изображению.
В реальной же жизни — наш глаз адаптируется к нужному освещению: в плохо освещенной комнате мы все равно видим, но до тех пор, пока перед нашими глазами нет яркого источника света. Это называется световой адаптацией. И самое крутое то, что
HDR и цветовая адаптация идеально сочетается друг с другом.
Теперь нам нужно вычислить среднее значение цвета на экране. Это довольно проблематично, т.к. формат с плавающим значением не поддерживает фильтрацию. Поступим следующим образом: создадим N-ое кол-во RT, где каждый следующий меньше предыдущего:
int cycles = DOWNSAMPLER_ADAPATION_CYCLES;
float delmiter = 1f / ((float)cycles+1);
_downscaleAverageColor = new RenderTarget2D[cycles];
for (int i = 0; i < cycles; i++)
{
_downscaleAverageColor[(cycles-1)-i] = new RenderTarget2D(_graphics, (int)((float)width * delmiter * (i + 1)), (int)((float)height * delmiter * (i + 1)), false, SurfaceFormat.HdrBlendable, DepthFormat.None);
}
И будем рисовать каждый предыдущий RT в следующий RT применяя некоторое размытие. После этих циклов у нас получится текстура
1x1, которая собственно и будет содержать средний цвет.
Если это все сейчас запустить, то цветовая адаптация действительно будет, но она будет моментальной, а так не бывает. Нам нужно, чтобы при взгляде с резко темной области на резко светлую — сначала чувствовали слепоту (в виде повышенной яркости), а затем все приходило в норму. Для этого достаточно завести еще один RT
1x1, который и будет отвечать за текущее значение адаптации, при этом, каждый кадр мы приближаем текущую адаптацию к рассчитанному в данный момент цвету. Причем, значение этого приближения должно быть завязано на все том же
gameTime.ElapsedGameTime, чтобы кол-во
FPS не влияло на скорость адаптации.
Ну и теперь в качестве параметра max для _toneSimple можно передавать наш средний цвет.
Существует масса формул
ToneMapping'a, вот некоторые из них:
Reinhardfloat3 _toneReinhard(float3 vColor, float average, float exposure, float whitePoint)
{
// RGB -> XYZ conversion
const float3x3 RGB2XYZ = {0.5141364, 0.3238786, 0.16036376,
0.265068, 0.67023428, 0.06409157,
0.0241188, 0.1228178, 0.84442666};
float3 XYZ = mul(RGB2XYZ, vColor.rgb);
// XYZ -> Yxy conversion
float3 Yxy;
Yxy.r = XYZ.g; // copy luminance Y
Yxy.g = XYZ.r / (XYZ.r + XYZ.g + XYZ.b ); // x = X / (X + Y + Z)
Yxy.b = XYZ.g / (XYZ.r + XYZ.g + XYZ.b ); // y = Y / (X + Y + Z)
// (Lp) Map average luminance to the middlegrey zone by scaling pixel luminance
float Lp = Yxy.r * exposure / average;
// (Ld) Scale all luminance within a displayable range of 0 to 1
Yxy.r = (Lp * (1.0f + Lp/(whitePoint * whitePoint)))/(1.0f + Lp);
// Yxy -> XYZ conversion
XYZ.r = Yxy.r * Yxy.g / Yxy. b; // X = Y * x / y
XYZ.g = Yxy.r; // copy luminance Y
XYZ.b = Yxy.r * (1 - Yxy.g - Yxy.b) / Yxy.b; // Z = Y * (1-x-y) / y
// XYZ -> RGB conversion
const float3x3 XYZ2RGB = { 2.5651,-1.1665,-0.3986,
-1.0217, 1.9777, 0.0439,
0.0753, -0.2543, 1.1892};
return mul(XYZ2RGB, XYZ);
}
Exposurefloat3 _toneExposure(float3 vColor, float average)
{
float T = pow(average, -1);
float3 result = float3(0, 0, 0);
result.r = 1 - exp(-T * vColor.r);
result.g = 1 - exp(-T * vColor.g);
result.b = 1 - exp(-T * vColor.b);
return result;
}
Я же использую собственную формулу:
Exposure2float3 _toneDefault(float3 vColor, float average)
{
float fLumAvg = exp(average);
// Calculate the luminance of the current pixel
float fLumPixel = dot(vColor, LUM_CONVERT);
// Apply the modified operator (Eq. 4)
float fLumScaled = (fLumPixel * g_fMiddleGrey) / fLumAvg;
float fLumCompressed = (fLumScaled * (1 + (fLumScaled / (g_fMaxLuminance * g_fMaxLuminance)))) / (1 + fLumScaled);
return fLumCompressed * vColor;
}
Ну и следующий этап это
Bloom (частично я его описывал
тут) и
Color Grading:
Использование
Color Grading:
Любое цветовое значение пикселя (RGB) после
ToneMapping'a лежит в пределах от 0 до 1. Наше цветовое пространство
Color Grading тоже условно лежит в пределах от 0 до 1. Поэтому, мы можем заменить текущее значение цвета пикселя на цвет пикселя в цветовом пространстве. При этом, фильтрация сэмплера произведет линейное интерполирование между нашими 32 значениями на карте
Color Grading. Т.е. мы «как-бы»
подменяем эталонное цветовое пространство — нашим измененным.
Для
Color Grading нужно ввести следующую функцию:
float3 gradColor(float3 color)
{
return tex3D(ColorGradingSampler, float3(color.r, color.b, color.g)).rgb;
}
где
ColorGradingSampler — трехмерный сэмплер.
Ну и LDR/HDR сравнение:
LDR:HDR:Заключение
Этот простой подход — одна из фишек
3D AAA-игр. И как видите — реализован он может быть и на старом-добром
DirectX9c, причем реализация в
DirectX10+ принципиально ничем отличатся. Больше информации вы найдете в
исходниках.
Так же стоит отличать друг от друга
HDRI (используется в фотографии) и
HDRR (используется в рендеринге).
Заключение 2
К сожалению, когда я писал в 2012 статьи по геймдеву — откликов и оценок было куда больше, сейчас же мои ожидания слегка не оправдались. Я не гонюсь за оценкой топика. Не хочу, чтобы он искусственно имел высокую оценку или низкую. Я хочу, чтобы он был оценен: не обязательно как: «Хорошая статья!», но и с «Статья на мой взгляд неполная, с %item% ситуация осталась непонятной.». Я рад даже негативной, но конструктивной оценке. А как итог — я публикую статью, а она кое-как собирает пару комментариев и оценок. И с учетом того, что хабрахабр это саморегулируемое сообщество напрашивается вывод: статья не интересна -> публиковать подобное смысла не имеет. P.S. все мы люди и делаем ошибки и поэтому, если найдете ошибку в тексте — напишите мне личным сообщением, а не спешите писать гневный комментарий!