Как я и обещал – публикую вторую статью о некоторых моментах разработки игр в трех измерениях. Сегодня расскажу об одной технике, которая используется почти любом проекте ААА-класса. Имя ей — 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, вот некоторые из них:
float3 _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);
}
float3 _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;
}
Я же использую собственную формулу:
float3 _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 нужно ввести следующую функцию:
float3 gradColor(float3 color)
{
return tex3D(ColorGradingSampler, float3(color.r, color.b, color.g)).rgb;
}
где ColorGradingSampler — трехмерный сэмплер. И кстати, для функции gradColor — параметр color должен лежать в пределах от 0 до 1, поэтому её мы применяем после ToneMapping'а.
Ну и сравнение:
LDR:
HDR:
Заключение
Этот простой подход одна из фишек 3D AAA-игр и как видите — реализован он может быть и на старом-добром DirectX9c, причем реализация в DirectX10+ принципиально ничем отличаться. Больше информации вы найдете в исходниках.
Заключение 2
К сожалению, когда я писал в 2012 статьи по геймдеву — откликов и оценок было куда больше, сейчас же мои ожидания слегка не оправдались. Я не гонюсь за оценкой топика. Не хочу, чтобы он искусственно имел высокую оценку или низкую. Я хочу, чтобы он был оценен: не обязательно как: «Хорошая статья!», но и с «Статья на мой взгляд неполная, с %item% ситуация осталась непонятной.». Я рад даже негативной, но конструктивной оценке. А как итог — я публикую статью, а она кое-как собирает пару комментариев и оценок. И с учетом того, что хабрахабр это саморегулируемое сообщество напрашивается вывод: статья не интересна -> публиковать подобное смысла не имеет.
P.S. все мы люди и делаем ошибки и поэтому, если найдете ошибку в тексте — напишите мне личным сообщением, а не спешите писать гневный комментарий!
Автор: ForhaxeD