Имитируем ночное зрение человека в 3D-игре

:

Сегодня мы будем заниматься постпроцессингом изображения в DirectX.

Писать будем под Unity3D Pro, в виде шейдера для постпроцессинга.

Как известно, в темноте зрение человека обеспечивается клетками-палочками сетчатки, высокая световая чувствительность которых достигается за счет потери цветочувствительности и остроты зрения (хотя палочек в сетчатке и больше, они распределены по гораздо большей площади, так что суммарное «разрешение» выходит меньше).

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

В результате мы получим что-то вроде следующего ( смотреть на весь экран!):

До: унылый польский шутер

После: финалист IGF и лауреат всех наград E3




Первым делом нужно определиться, какого эффекта мы хотим достичь. Я разбил всю обработку на следующие части:
  • Потеря цветовосприятия в зонах низкой освещенности
  • Потеря четкости зрения там же
  • Небольшой шум (там же)
  • Потеря четкости зрения на большом расстоянии при средней и низкой освещенности


Писать будем под Unity3D Pro, в виде шейдера для постпроцессинга.

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

using UnityEngine;

[ExecuteInEditMode]
public class HumanEye : MonoBehaviour
{
    public Shader Shader;
    public float LuminanceThreshold;
    public Texture Noise;
    public float NoiseAmount = 0.5f, NoiseScale = 2;

    private Camera mainCam;
    private Material material;

    private const int PASS_MAIN = 0;

    void Start ()
    {
        mainCam = camera;
        mainCam.depthTextureMode |= DepthTextureMode.DepthNormals;
        material = new Material (Shader);
    }

    void OnRenderImage (RenderTexture source, RenderTexture destination)
    {
        material.SetFloat("_LuminanceThreshold", LuminanceThreshold);
        material.SetFloat ("_BlurDistance", 0.01f);
        material.SetFloat ("_CamDepth", mainCam.far);
        material.SetTexture ("_NoiseTex", Noise);
        material.SetFloat ("_Noise", NoiseAmount);
        material.SetFloat ("_NoiseScale", NoiseScale);
        material.SetVector("_Randomness", Random.insideUnitSphere);
        Graphics.Blit (source, destination, material, PASS_MAIN);
    }  
}

Здесь мы всего лишь устанавливаем параметры шейдера на заданные пользователем и выполняем перерендеринг экранного буфера через наш шейдер.

Теперь займемся непосредственно делом.

Объявления переменных и констант:

sampler2D _CameraDepthNormalsTexture;
float4 _CameraDepthNormalsTexture_ST;

sampler2D _MainTex;
float4 _MainTex_ST;

sampler2D _NoiseTex;
float4 _NoiseTex_ST;
float4 _Randomness;

uniform float _BlurDistance, _LuminanceThreshold, _CamDepth, _Noise, _NoiseScale;

#define DEPTH_BLUR_START 3
#define FAR_BLUR_START 40
#define FAR_BLUR_LENGTH 20

Вершинный шейдер — стандартный и не выполняет никаких необычных преобразований. Самое интересное начинается в пиксельном шейдере.

Для начала выберем значение текущего пикселя, и в добавку к нему — «размытое» значение для того же пикселя:

struct v2f {
    float4 pos : POSITION;
    float2 uv : TEXCOORD0;
    float2 uv_depth : TEXCOORD1;
};

half4 main_frag (v2f i) : COLOR
{
    half4 cColor = tex2D(_MainTex, i.uv);
    half4 cBlurred = blur(_MainTex, i.uv, _BlurDistance);

Получение «размытого» значения выполняется функцией blur(), которая выполняет выборку нескольких пикселей по соседству с нашим и усредняет их значения:

inline half4 blur (sampler2D tex, float2 uv, float dist) {
    #define BLUR_SAMPLE_COUNT 16
        // сгенерированы абсолютно случайным броском float-кости!
    const float3 RAND_SAMPLES[16] = {
            float3(0.2196607,0.9032637,0.2254677),
                        .... еще 14 векторов ....
            float3(0.2448421,-0.1610962,0.1289366),
    };

    half4 result = 0;
    for (int s = 0; s < BLUR_SAMPLE_COUNT; ++s)
        result += tex2D(tex, uv + RAND_SAMPLES[s].xy * dist);
    result /= BLUR_SAMPLE_COUNT;
    return result;
}

Коэффициент неосвещенности пикселя будем определять через среднюю величину яркости по трем каналам. Коэффициент отсекается по заданному пограничному значению яркости (LuminanceThreshold), т.е. все пиксели светлее этого считаются «достаточно яркими», чтобы их не обрабатывать.

half kLum = (cColor.r + cColor.g + cColor.b) / 3;
kLum = 1 - clamp(kLum / _LuminanceThreshold, 0, 1);

Зависимость kLum от яркости будет выглядеть примерно так:

Значения kLum для нашей сцены выглядят так (белый — 1, черный — 0):

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

Расстояние от поверхности экрана до пикселя в метрах можно получить из  текстуры глубины (depth texture, Z-buffer), которая явно доступна при deferred-рендеринге.

float depth;
float3 normal;
DecodeDepthNormal(tex2D(_CameraDepthNormalsTexture, i.uv_depth), depth, normal);
depth *= _CamDepth; // depth in meters

Коэффициент kDepth будет определять степень размытия темных предметов вблизи, а kFarBlur — всех остальных вдали:

#define DEPTH_BLUR_START 3
#define FAR_BLUR_START 40
#define FAR_BLUR_LENGTH 20
half kDepth = clamp(depth - DEPTH_BLUR_START, 0, 1);
half kFarBlur = clamp((depth - FAR_BLUR_START) / FAR_BLUR_LENGTH, 0, 1);

Графики обоих коэффициентов от расстояния выглядят одинаково и различаются только масштабом:


Значения kFarBlur:

А теперь — магия! Рассчитываем общий коэффициент размытия пикселя, исходя из предыдущих трех:

half kBlur = clamp(kLum * kDepth + kFarBlur, 0, 1);

Темные пиксели будут размываться, начиная с расстояния в несколько метров (DEPTH_BLUR_START), а удаленные предметы — независимо от освещенности.

Степень потери цвета у нас будет равна степени «неосвещенности» (half kDesaturate = kLum).

Теперь осталось смешать обычный, размытый и черно-белый пиксель и расчитать итоговый цвет:

half kDesaturate = kLum;

half4 result = cColor;
result = (1 - kBlur) * result + kBlur * cBlurred;

half resultValue = result;
result = (1 - kDesaturate) * result + kDesaturate * resultValue;
return result;

Однако если посмотреть на картинку в динамике — то видно, что чего-то не хватает. Чего? Шумов!

half noiseValue = tex2D(_NoiseTex, i.uv * _NoiseScale + _Randomness.xy);
half kNoise = kLum * _Noise;

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

Полученное случайное значение подмешиваем в наш пиксель:

result *= (1 - kNoise + noiseValue * kNoise);

В качестве бонуса — небольшое видео и  сам шейдер:

Update: правильные, человеческие lens flares:
full