Auto Exposure

​ 自动曝光是指模拟人眼对于光线的调节和感知。人从暗处到亮处,瞳孔会逐渐缩小,视觉会有突然变亮然后逐渐变暗的感受,相反的,从亮处到暗处,瞳孔会逐渐变大,视觉会有突然变暗然后逐渐变暗的感受。为了模拟这一现象,需要通过曝光值来调整整个屏幕的亮暗,这个曝光值由当前屏幕的亮暗程度决定。要获取当前屏幕的亮暗情况的准确值,需要遍历屏幕的每个像素,获取每个像素的亮度取平均来获得平均亮度值,但是对于一个1920 * 1080分辨率的屏幕来说,每帧都需要进行1920 * 1080次GetPixel,如此庞大的CPU计算量显然会影响到帧率,即使是进行降采样获取近似亮度值也是治标不治本,因此需要将这部分计算转移到ComputeShader中,以下以Unity中预设的Auto Exposure功能来说明实现方案。

​ 首先说明下各个输入参数的含义,后续说明都会使用到

参数名 含义
Filtering 过滤区间
Minimun(EV) 最小平均亮度(对数形式)
Maximun(EV) 最大平均亮度(对数形式)
Exposure Compensation 曝光补偿值
Type Progressive为模拟人眼渐变,Fixed为直接变化为计算值
Speed Up 亮度下降速率,模拟人眼适应光亮的速度
Speed Down 亮度上升速率,模拟人眼适应黑暗的速度

​ 由于屏幕中一些亮度极大的像素会影响屏幕的平均亮度,需要过滤一部分像素,为了方便过滤,屏幕中像素的亮度信息是通过直方图 (Histogram)来进行统计的,直方图的取值范围为2^-9到2^9的亮度区间,亮度在实际存储中使用了对数方式,即[-9,9]的亮度区间,在这个区间中一个使用128个Bin来进行区分,每个桶的坐标对于其亮度值,而桶存储的是由屏幕图形采样获取到的权重值

​ 对于一个采样点,其处理代码如下:

    uint weight = 1u;
    float2 sspos = ipos / _ScaleOffsetRes.zw;
    //屏幕中心权重更高
    #if USE_VIGNETTE_WEIGHTING
    {
        float2 d = abs(sspos - (0.5).xx);
        float vfactor = saturate(1.0 - dot(d, d));
        vfactor *= vfactor;
        weight = (uint)(64.0 * vfactor);
    }
    #endif
    //通过Bilinear来采样
    float3 color = _Source.SampleLevel(sampler_LinearClamp, sspos, 0.0).xyz; // Bilinear downsample 2x
    float luminance = Luminance(color);
    float logLuminance = GetHistogramBinFromLuminance(luminance, _ScaleOffsetRes.xy);
    //根据logLuminance后的值来确定在哪个桶
    uint idx = (uint)(logLuminance * (HISTOGRAM_BINS - 1u));
    //往对于桶增加权值
    InterlockedAdd(gs_histogram[idx], weight);

可以看到,权重有两种模式,一种是按照与屏幕中心的距离来计算,一种是直接都取1,此时一个桶存储的权重和就是采样点的总个数,其中GetHistogramBinFromLuminance函数为 saturate(log2(value) * scaleOffset.x + scaleOffset.y),其中scaleOffset.x 为 1/18, scaleOffset.y为 1/2,相当于取值[2^-9,2^9]的亮度 -> [-9,9] * 1/18 + 0.5 -> bin[0,1],最后乘上(HISTOGRAM_BINS - 1u)即127就能获取到桶坐标,这样就能将所有采样点的亮度分配到128个桶中,虽然这样会是存储的亮度小于等于实际亮度,但是误差不会很大。

此外,此处采样点也不代表所有的像素点,在过程中会进行降采样

//C#部分的启动函数
cmd.DispatchCompute(compute, kernel,
    Mathf.CeilToInt(scaleOffsetRes.z / 2f / threadX),
    Mathf.CeilToInt(scaleOffsetRes.w / 2f / threadY),
    1
);
//computeShader中的ipos计算
float2 ipos = float2(dispatchThreadId) * 2.0;

从以上代码可以看出这边有进行1/4的降采样

计算平均的亮度函数的相关函数如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
float GetAverageLuminance(StructuredBuffer<uint> buffer, float4 params, float maxHistogramValue, float2 scaleOffset)
{
uint i;
float totalSum = 0.0;

UNITY_UNROLL
for (i = 0; i < HISTOGRAM_BINS; i++)
totalSum += GetBinValue(buffer, i, maxHistogramValue);

float4 filter = float4(0.0, 0.0, totalSum * params.xy);

UNITY_UNROLL
for (i = 0; i < HISTOGRAM_BINS; i++)
FilterLuminance(buffer, i, maxHistogramValue, scaleOffset, filter);

return clamp(filter.x / max(filter.y, EPSILON), params.z, params.w);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
void FilterLuminance(StructuredBuffer<uint> buffer, uint i, float maxHistogramValue, float2 scaleOffset, inout float4 filter)
{
float binValue = GetBinValue(buffer, i, maxHistogramValue);
//过滤暗区
float offset = min(filter.z, binValue);
binValue -= offset;
filter.zw -= offset.xx;
//过滤亮区
binValue = min(filter.w, binValue);
filter.w -= binValue;
float luminance = GetLuminanceFromHistogramBin(float(i) / float(HISTOGRAM_BINS), scaleOffset);
filter.xy += float2(luminance * binValue, binValue);
}
1
2
3
4
float GetBinValue(StructuredBuffer<uint> buffer, uint index, float maxHistogramValue)
{
return float(buffer[index]) * maxHistogramValue;
}

其中GetBinValue就是对于桶的权重除以最大权重,在最终计算的时候实际上除以的最大权重会被抵消掉,这边要除最大权重很可能是在0-1区间内的浮点数计算效率更高,因此我们可以理解为获得的就是桶的权重。

按这个思路走,初始的fileter中的xy分别存储的是总权重乘以过滤百分比下限与上限,在循环遍历每个桶执行FilterLuminance的过程中,会将低于下限的权重和高于上限的权重过滤掉,将过滤后的桶数据通过GetLuminanceFromHistogramBin进行桶到亮度的映射即GetHistogramBinFromLuminance的反向操作,最终亮度与权重的积的总和与权重总和相除即为平均亮度,最终的平均亮度会通过Clamp函数取在设置的最小和最大亮度之间

在获得平均亮度后,我们需要通过平均亮度计算曝光值,代码如下:

1
2
3
4
5
6
7
8
9
float GetExposureMultiplier(float avgLuminance)
{
avgLuminance = max(EPSILON, avgLuminance);
//这段被注释的代码是依据亮度自动调整EC值
//float keyValue = 1.03 - (2.0 / (2.0 + log2(avgLuminance + 1.0)));
float keyValue = _Params2.z;
float exposure = keyValue / avgLuminance;
return exposure;
}

可以看到,Unity目前的方式就是将我们输入的曝光补偿值(EC)除以亮度来得到曝光值,获得曝光值后我们会根据选择的模式来调整最终返回的曝光值,如果是Progressive则进行插值,如果是Fixed的则直接返回

1
2
3
4
5
6
7
8
9
10
11
#if PROGRESSIVE
float avgLuminance = GetAverageLuminance(_HistogramBuffer, _Params1, maxValue, _ScaleOffsetRes.xy);
float exposure = GetExposureMultiplier(avgLuminance);
float prevExposure = _Source[uint2(0u, 0u)].x;
exposure = InterpolateExposure(exposure, prevExposure);
_Destination[uint2(0u, 0u)].x = exposure.x;
#else
float avgLuminance = GetAverageLuminance(_HistogramBuffer, _Params1, maxValue, _ScaleOffsetRes.xy);
float exposure = GetExposureMultiplier(avgLuminance);
_Destination[uint2(0u, 0u)].x = exposure.x;
#endif

插值代码如下,依据设置的SpeedUp和SpeedDown来调整变化速率

1
2
3
4
5
6
7
float InterpolateExposure(float newExposure, float oldExposure)
{
float delta = newExposure - oldExposure;
float speed = delta > 0.0 ? _Params2.x : _Params2.y;
float exposure = oldExposure + delta * (1.0 - exp2(-_Params2.w * speed));
return exposure;
}

最终exposure被存在了一张1x1的贴图的R通道中,在渲染时会将R通道的数值与当前颜色相乘

1
2
3
half autoExposure = SAMPLE_TEXTURE2D(_AutoExposureTex, sampler_AutoExposureTex, uv).r;

color.rgb *= autoExposure;

FrameDebug中看到的实际流程如下:

首先在每次计算之前情况一次数据,CS分配为 8组线程组,每组16个线程

然后遍历屏幕图像,CS分配60 X 34个线程组(1920 /2/16 = 60, 1080 /2/16 = 34) ,16 * 16个线程,获得直方图

最后统计直方图数据,渲染到1 * 1的贴图R通道

最终渲染时将RGB与该贴图R通道数值相乘即可