Всем знаком эффект временной слепоты, когда вы входите в темное помещение из светлого.  Согласно распространенному заблуждению, чувствительность зрения регулируется размером зрачка. На самом деле, изменение площади зрачка регулирует количество поступающего света всего лишь в 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.
Стоит ли писать еще статьи по шейдерам и пост-обработке?
96.72%Да1357
3.28%Нет46
1403 users voted. 142 users abstained.