HDR vs LDR, реализация HDR Rendering

f58ed0b3c3a14742ae76c99fd22e2ee9.jpg Как я и обещал — публикую вторую статью о некоторых моментах разработки игр в трех измерениях. Сегодня расскажу об одной технике, которая используется почти любом проекте ААА-класса. Имя ей — HDR Rendering. Если интересно — добро пожаловать под хабракат.

Но сначала нужно поговорить. Исходя из прошлой статьи — понял, что и аудитория хабрахабра похоронила технологию Microsoft XNA. Делать, якобы, с помощью его что-то — все равно, что писать игры на ZX Spectrum. В пример мне привели: «Есть же ведь SharpDX, SlimDX, OpenTK!», даже приводили в пример Unity. Но остановимся на первых трех, все это — чистой воды врапперы DX«a под .NET, а Unity так вообще движок-песочница. Что вообще из себя представляет DirectX10+? Ведь его нет и не будет в XNA. Так вот, подавляющие кол-во эффектов, фишек и технологий реализуется именно на базе DirectX9c. А DirectX10+ вводит лишь дополнительный функционал (SM4.0, SM5.0).

Взять, например, Crysis 2:

25ee5e1d1710406dacce3d0a0b683f40.jpg 9dad88d9635b488d95e3e692cd25de4b.jpg В этих двух скриншотах нет никакого 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 можно добиться реалистичных бликов с различных поверхностей. Это очень заметно на воде:

344e7b18831d4ffdaa294016fc0f2a88.png Или на бликах с сурфейса:

c4d2991963454e719c08be683c52a672.png Ну и контрастность сцены в целом (слева HDR, справа LDR):

9938d9a73a324078b100989ab3329a80.jpg Вместе с HDR принято применять и технологию Bloom, яркие области размываются и накладываются поверх основного изображения:

a53347c508b04eaeaaefcb19f7c190d6.jpg Это делает освещение еще мягче.

Так же, в виде бонуса — расскажу про Color Grading. Этот поход повсеместно применяется в играх ААА-класса.

Color Grading Очень часто в играх сцена должна иметь свой цветовой тон, этот цветовой тон может быть общим как для всей игры, так и для отдельных участков сцены. И чтобы каждый раз не иметь по сто шейдеров-постпроцессоров — используют подход Color Grading. В чем суть этого подхода? Знаменитые буквы RGB — цветовое трехмерное пространство, где каждый канал это своеобразная координата. В случае формата R8G8B8: 255 градаций на каждый канал. Так вот, что будет, если мы применим обычные операции обработки (например, кривые или контрастность) к этому пространству? Наше пространство изменится и в будущем мы можем назначить любому пикселю — пиксель из этого пространства.

Создадим простое RGB пространство (хочу заменить, что берем мы каждый 8-ой пиксель, т.к. если будем брать все 256 градаций, то размер текстуры будет очень большим): fbd499e7154f4a5fa08b6719838b3824.png

Это трехмерная текстура, где на каждую ось — свой канал.

И возьмем какую-нибудь сцену, которую нужно модифицировать (добавив при этом на изображение наше пространство):

e5ea9dd5c09244bdbb76c0eb0927bb08.png Проводим нужные нам трансформации (на глаз):

a2477fa7ce1a451e962733782e0bf910.png И извлекаем наше модифицируемое пространство: a0e256d0ceef446fadc8033067a0b959.png

Теперь, по этому пространству — мы можем применить все модификации с цветом к любому изображению. Просто сопоставляя оригинальный цвет с измененным пространством цвета.

Реализация Ну и кратко по реализации 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 1×1, который и будет отвечать за текущее значение адаптации, при этом, каждый кадр мы приближаем текущую адаптацию к рассчитанному в данный момент цвету. Причем, значение этого приближения должно быть завязано на все том же gameTime.ElapsedGameTime, чтобы кол-во FPS не влияло на скорость адаптации.

Ну и теперь в качестве параметра max для _toneSimple можно передавать наш средний цвет.

Существует масса формул ToneMapping’a, вот некоторые из них:

Reinhard float3 _toneReinhard (float3 vColor, float average, float exposure, float whitePoint) { // RGB → XYZ conversion const float3×3 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 float3×3 XYZ2RGB = { 2.5651,-1.1665,-0.3986, -1.0217, 1.9777, 0.0439, 0.0753, -0.2543, 1.1892};

return mul (XYZ2RGB, XYZ); } Exposure 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; } Я же использую собственную формулу:

Exposure2 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: 3330fe33bbf04860881ae48c932efca9.png

HDR: 716b28ec4f4f4ce6a8d89126f16cf19e.png

Заключение Этот простой подход — одна из фишек 3D AAA-игр. И как видите — реализован он может быть и на старом-добром DirectX9c, причем реализация в DirectX10+ принципиально ничем отличаться. Больше информации вы найдете в исходниках.Заключение 2 К сожалению, когда я писал в 2012 статьи по геймдеву — откликов и оценок было куда больше, сейчас же мои ожидания слегка не оправдались. Я не гонюсь за оценкой топика. Не хочу, чтобы он искусственно имел высокую оценку или низкую. Я хочу, чтобы он был оценен: не обязательно как: «Хорошая статья!», но и с «Статья на мой взгляд неполная, с %item% ситуация осталась непонятной.». Я рад даже негативной, но конструктивной оценке. А как итог — я публикую статью, а она кое-как собирает пару комментариев и оценок. И с учетом того, что хабрахабр это саморегулируемое сообщество напрашивается вывод: статья не интересна → публиковать подобное смысла не имеет.P.S. все мы люди и делаем ошибки и поэтому, если найдете ошибку в тексте — напишите мне личным сообщением, а не спешите писать гневный комментарий!

© Habrahabr.ru