Всем знаком эффект временной слепоты, когда вы входите в темное помещение из светлого. Согласно распространенному заблуждению, чувствительность зрения регулируется размером зрачка. На самом деле, изменение площади зрачка регулирует количество поступающего света всего лишь в 25 раз, а основную роль в адаптации играют сами клетки сетчатки.
Для имитации этого эффекта в играх используется механизм, называемый tonemapping.
tonemapping — процесс проекции всего бесконечного интервала яркостей (HDR, high dynamic range, от 0 и до бесконечности) на конечный интервал восприятия глаза/камеры/монитора (LDR, low dynamic range, ограничен с обоих сторон).
Для того, чтобы работать с HDR, нам понадобится соответствующий экранный буфер, поддерживающий значения больше единицы. Наша же задача будет состоять в правильной конвертации этих значений в диапазон [0..1].
Первым делом, мы должны как-то узнать общую яркость сцены. Для этого нужно вычислить среднее геометрическое значение яркости всех пикселей.
Впрочем, для нашей ночной сцены это слегка неразумно, так как большая часть площади изображения темная, даже если присутствует яркий источник света, и поэтому средняя яркость практически не изменяется. Так что возьмем максимальную яркость, и поделим ее пополам.
Ужмем нашу картинку до ближайшего квадрата со стороной, равной степени двойки и обесцветим ее. Затем будем каждый раз сжимать ее вдвое, пока не останется один пиксель:
Для сжатия картинки, будем брать четыре соседних пикселя и выбирать из них средний (для нашего случая — вместо него максимальный). Для ускоренного вычисления среднего геометрического воспользуемся формулой
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 читаем у Рейнхарда
Код
Шейдер
Автор: hardex