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

:

Как я и обещал – публикую вторую статью о некоторых моментах разработки игр в трех измерениях. Сегодня расскажу об одной технике, которая используется почти любом проекте ААА-класса. Имя ей — 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, вот некоторые из них:

Reinhard
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);
}


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:
Любое цветовое значение пикселя (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. все мы люди и делаем ошибки и поэтому, если найдете ошибку в тексте — напишите мне личным сообщением, а не спешите писать гневный комментарий!