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

в 17:45, , рубрики: game development, hdr, nvidia-cg, unity3d, Анимация и 3D графика, метки: , ,

Всем знаком эффект временной слепоты, когда вы входите в темное помещение из светлого.  Согласно распространенному заблуждению, чувствительность зрения регулируется размером зрачка. На самом деле, изменение площади зрачка регулирует количество поступающего света всего лишь в 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 читаем у Рейнхарда
Код
Шейдер

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

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.

Проголосовало 5 человек. Воздержавшихся нет.

Автор: hardex

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js