Имитируем адаптацию глаза к темноте в 3D, или HDR для чайников

  • Tutorial
Всем знаком эффект временной слепоты, когда вы входите в темное помещение из светлого.  Согласно распространенному заблуждению, чувствительность зрения регулируется размером зрачка. На самом деле, изменение площади зрачка регулирует количество поступающего света всего лишь в 25 раз, а основную роль в адаптации играют сами клетки сетчатки.

title

Для имитации этого эффекта в играх используется механизм, называемый tonemapping.

tonemapping — процесс проекции всего бесконечного интервала яркостей (HDR, high dynamic range, от 0 и до бесконечности) на конечный интервал восприятия глаза/камеры/монитора (LDR, low dynamic range, ограничен с обоих сторон).

Для того, чтобы работать с HDR, нам понадобится соответствующий экранный буфер, поддерживающий значения больше единицы. Наша же задача будет состоять в правильной конвертации этих значений в диапазон [0..1].



Первым делом, мы должны как-то узнать общую яркость сцены. Для этого нужно вычислить среднее геометрическое значение яркости всех пикселей.

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

Ужмем нашу картинку до ближайшего квадрата со стороной, равной степени двойки и обесцветим ее. Затем будем каждый раз сжимать ее вдвое, пока не останется один пиксель:

Downsampling

Для сжатия картинки, будем брать четыре соседних пикселя и выбирать из них средний (для нашего случая — вместо него максимальный). Для ускоренного вычисления среднего геометрического воспользуемся формулой

6c1baa17500174ff1745d50bdabc1399

Почему геометрическое? Потому что геометрическое среднее «тяготеет» к более высоким значениям, а значит будут выбираться более яркие пиксели (что нам и нужно, так как нас интересуют имеющиеся на картинке источники света).


RenderTextureFormat rtFormat = RenderTextureFormat.ARGBFloat;
if (lumBuffer == null) {
	lumBuffer = new RenderTexture (LuminanceGridSize, LuminanceGridSize, 0, rtFormat, RenderTextureReadWrite.Default);
}

RenderTexture currentTex = RenderTexture.GetTemporary (InitialSampling, InitialSampling, 0, rtFormat, RenderTextureReadWrite.Default);
Graphics.Blit (source, currentTex, material, PASS_PREPARE);

int currentSize = InitialSampling;
while (currentSize > LuminanceGridSize) {
	RenderTexture next = RenderTexture.GetTemporary (currentSize / 2, currentSize / 2, 0, rtFormat, RenderTextureReadWrite.Default);
	Graphics.Blit (currentTex, next, material, PASS_DOWNSAMPLE);
	RenderTexture.ReleaseTemporary (currentTex);
	currentTex = next;
	currentSize /= 2;
}


// Downsample pass
Pass {
	CGPROGRAM
		#pragma vertex vert
		#pragma fragment fragDownsample

		float4 fragDownsample(v2f i) : COLOR 
		{
			float4 v1 = tex2D(_MainTex, i.uv + _MainTex_TexelSize.xy * float2(-1,-1));		
			float4 v2 = tex2D(_MainTex, i.uv + _MainTex_TexelSize.xy * float2(1,1));		
			float4 v3 = tex2D(_MainTex, i.uv + _MainTex_TexelSize.xy * float2(-1,1));		
			float4 v4 = tex2D(_MainTex, i.uv + _MainTex_TexelSize.xy * float2(1,-1));	

			float mn = min(min(v1.x,v2.x), min(v3.x,v4.x));
			float mx = max(max(v1.y,v2.y), max(v3.y,v4.y));
			float avg = (v1.z+v2.z+v3.z+v4.z) / 4;

			return float4(mn, mx, avg, 1);
		}
	ENDCG
}

// Prepare pass
Pass {
	CGPROGRAM
		#pragma vertex vert
		#pragma fragment fragPrepare

		float4 fragPrepare(v2f i) : COLOR 
		{
			float v = tex2D(_MainTex, i.uv);
			float l = log(v + 0.001);
			return half4(l, l, l, 1);
		}
	ENDCG
}


Заметьте, что при логарифмировании исходной картинки мы прибавляем небольшую константу, чтобы избежать коллапса вселенной в случае полностью черного (0) пикселя.

На каждом шаге уменьшения в нашей текстуре хранится минимальное ( R ), максимальное ( G ) и среднелогарифмическое ( B ) значение яркости.

Далее следует небольшой трюк, который позволит избежать чтения текстуры и производить «адаптацию» глаза полностью на GPU: заведем постоянную текстуру размером в 1 пиксель и  на каждом кадре будем накладывать на нее новое значение яркости (тоже 1 пиксель) с небольшим alpha (прозрачностью). Таким образом сохраненное значение яркости будет постепенно приходить к текущему, что и требовалось.

if (!lumBuffer.IsCreated ()) {
	Debug.Log ("Luminance map recreated");
	lumBuffer.Create ();
	// если текстура только что создалась, явно установим ее значение
	Graphics.Blit (currentTex, lumBuffer); 
} else {
	material.SetFloat ("_Adaptation", AdaptationCoefficient);
	Graphics.Blit (currentTex, lumBuffer, material, PASS_UPDATE);
}


AdaptationCoefficient — коэффициент порядка 0.005, который определяет скорость адаптации к яркости.

Осталось взять наши две текстуры (исходное изображение и яркость) и «подкрутить» экспозицию в первой, используя значение из второй.

material.SetTexture ("_LumTex", lumBuffer);
material.SetFloat ("_Key", Key);
material.SetFloat ("_White", White);
material.SetFloat ("_Limit", Limit);
Graphics.Blit (source, destination, material, PASS_MAIN);


// Main pass
Pass {
	CGPROGRAM
		#pragma vertex vert
		#pragma fragment frag

		float4 frag(v2f i) : COLOR 
		{
			half4 cColor = tex2D(_MainTex, i.uv);		
			float4 cLum = tex2D(_LumTex, i.uv);	
			float lMin = exp(cLum.x);
			float lMax = exp(cLum.y);
			float lAvg = exp(cLum.z);
			lAvg = max(lMax / 2, _Limit); // force override for dark scene

			float lum = max(0.000001, Luminance(cColor.rgb));

			float scaled = _Key / lAvg * lum;
			scaled *= (1 + scaled / _White / _White) / (1+scaled);
			return scaled * cColor;
		}
	ENDCG
}


Здесь мы восстанавливаем значение яркости из логарифма, вычисляем коэффициент масштабирования (scaled), и делаем поправку на уровень белого (_White).

Используемые параметры:
  • Key — регулирует общую яркость сцены, которая считается «нормальной»
  • Limit — ограничивает максимальную светочувствительность глаза, не позволяя видеть, как  Хищник
  • White — регулирует ширину диапазона, указывая, какая яркость будет считаться «белой» на изображении

Результат:



Можно получить интересный результат, уменьшая текстуру яркости не до одного пикселя, а останавливаясь за несколько шагов (увеличив LuminanceGridSize). Тогда отдельные области экрана будут «привыкать» независимо. Кроме того, получится эффект «темного пятная», когда одна область сетчаки засвечивается, если смотреть прямо на лампу. Однако в большинстве случаев мозг автоматически прячет эффект засветки, и на мониторе он смотрится неестественно и непривычно.

Подробнее о дневном tonemapping'e читаем у Рейнхарда
Код
Шейдер

Only registered users can participate in poll. Log in, please.

Стоит ли писать еще статьи по шейдерам и пост-обработке?

Share post

Similar posts

Comments 31

    +11
    Круто, конечно стоит! Про гейм-девелопмент всегда интересно :)
      +17
      Интересно, но, на текущем этапе(судя по видео), больше похоже на автоподстройку яркости у фото\видео камеры.
        0
        Однако и глазам нужно время на подстройку :)
          +1
          Время на подстройку — не самая важная часть, это достаточно легко настраивается. Главное — сам шейдер. Кроме того тонмапинг используется в основном не для того, чтобы делать эффект перехода из тени в свет и наоборот, а для выравнивания яркости картинки, убирая пересвеченные и слишком затененные места, добавляя реалистичности общего вида картинки.
            0
            Я понимаю, но делал акцент — что глаз переходит из света в тень — и наоборот аналогичным способом. И более того — думаю можно попробовать сделать эффект «временного ослепления» при выходе из тени. Такие эффекты уже применяются в играх.
        +2
        Спасибо, интересные статьи.
        П.с. на последних кадрах видео какое-то неестественное освежение местности фонарем, очень резкий переход на земле между темными и светлыми местами… (имхо)
          +1
          А шейдеры можно на GLSL? Конечно можно и самим переписать но если не сильно в этом разбираешься то трудновато.
            0
            Для того, чтобы этот cg-шейдер заработал под GLSL достаточно переименовать vert() и frag(), а также написать функцию Luminance (=(r+g+b)/3), ну и еще unity-матрицы заменить на соответствующие glState
              +1
              Можно кстати не переписывать, а просто скомпилировать из CG в GLSL компилятором от nVidia (cgc)
                0
                cg не поддерживает некоторые вкусные вещи.
                  0
                  Зато шейдер в юнити написанный на CG, компилится подо все возможные платформы, в отличии от GLSL
                    +1
                    Просто не все используют Unity.
                0
                То есть Вы считаете, что не разобравшись можно вот так скопировать GLSL код, и оно заработает?
                  0
                  Нет конечно. Но это всё же сэкономит время.
                0
                Насколько я понимаю, человек ослепляется не просто от яркого света, а именно от источника света, направленного в глаза. Т.е. если мы посмотрим на траву, освещённую фарами, то остальная местность для нас не затемнится. А вот если фары будут бить в глаза, то затемнится. У вас заметен эффект даже когда фары не бьют в глаза, просто яркоосвещенный участок в кадре.
                  +3
                  Вообще, совершенно не важно, прямой свет или отраженный. В любом случае это один и тот же пучок фотонов.

                  Зачастую просто отраженный свет в разы слабее прямого, так как освещаются не зеркальные поверхности.
                    +1
                    Когда Вы находитесь в освещенной комнате ночью, то даже если не смотреть на люстру — в окне на улицу мало чего увидите. Свет он такой, имеет свойство отражаться)

                    upd: в следующий раз я буду обновлять комментарии
                    +2
                    Мне показалось что как-то уж слишком сильно заужен динамический диапазон который видит человек. Все тени потеряли детали, как и яркие света.
                      +2
                      Прочитал ссылки как «Кот» и «Шредер» — пора завязывать с бездной баш.орг'а…
                        +4
                        Как то все не то. Человек в сумраке видит намного меньше цветов. + шумы и более низкое разрешение. Наверняка замечали, что читать в далеком свете фонаря тяжело — буквы видно, но они размытые. Плюс ко всему свет от фар (например от машины) прямо в глаза сразу же ослепил бы человека, ну не в прямом смысле, а то, что человек сразу видит просто вокруг белое, зажмуривается (такой эффект тоже можно сделать), а потом постепенно переходит зрение в норму, однако то что более темное чем освещение фар будет очень тусклым. Все кроме звезд.

                        И прямо резкого перехода нет — человек может адаптироваться к темной комнате в течении минуты. Вот выключите свет ночью в комнате. Поначалу будут шумы от изменения освещения, а позже глаза будут привыкать. Через минуту-две уже можно ходить без света. Имеется в виду конечно, что единственный источник света, это фонари на улице. А у вас свет исчез и тут же видно траву четко.
                        • UFO just landed and posted this here
                            +4
                            Вы только не забывайте, что игроку вряд ли захочется сидеть минуту в комнате и ждать пока его глаза привыкнут к темноте.
                            • UFO just landed and posted this here
                                0
                                Привыкание к яркому свету занимает до трех минут, а вот полное зрение в темноте восстанавливается почти час.
                          +1
                          Это не хдр, а простая адаптация к освещению.
                          Нормальный хдр — это когда значения яркости в кадре имеют приближённые к реальным пропорции, т.е. Солнце в тысячи раз ярче лампочки, например. Это используется при той же адаптации, а также критично для реалистичных оптических эффектов вроде моушен блюра и DoF. Обычно для этого используют float значения, рендеря в FP рендер таргет, либо хаками пакуя флоуты в целочисленные RGB.
                            +2
                            В данном случае для обработки изображения используется HDR буфер, где содержится расширенная цветовая картина экрана. Tonemaping в общем смысле используется для того чтобы ужать расширенную картину к обычной, здесь же расширенный Tonemaping, который позволяет добиться эффекта перехода из тени в свет.
                            0
                            Даешь глобальное освещение!
                            Здесь есть статья, и прототип на юнити (и под макось).

                            Ну, и по-прежнему хочу увидеть синеватые оттенки для слабо освещенных участков :)
                              +2
                              Огромное спасибо за ваши статьи. Жаль, два плюса в карму ставить нельзя :)
                                0
                                Кстати, думаю, есть резон вынести "_Key / _White / _White" в отдельную константу и не считать ее на каждом проходе шейдера.
                                  0
                                  Теперь еще осталось нойс добавить для пущей «атмосферности».
                                    0
                                    Почему геометрическое? Потому что геометрическое среднее «тяготеет» к более высоким значениям....

                                    На этой фразе у меня появилось сильное чувство, что нас обманывают. Среднее арифметическое намного больше тяготеет к высоким значениям, чем среднее геометрическое. Читайте неравенство о средних.

                                    Ну а кому лень читать — можете проверить на пальцах. Берём числа 1 и 4. Арифметическое среднее даёт 2.5, а геометрическое 2. Так какое же из них тяготеет к высоким значеням больше?

                                    Only users with full accounts can post comments. Log in, please.