屏幕后处理(三)
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 | float GetAverageLuminance(StructuredBuffer<uint> buffer, float4 params, float maxHistogramValue, float2 scaleOffset) |
1 | void FilterLuminance(StructuredBuffer<uint> buffer, uint i, float maxHistogramValue, float2 scaleOffset, inout float4 filter) |
1 | float GetBinValue(StructuredBuffer<uint> buffer, uint index, float maxHistogramValue) |
其中GetBinValue就是对于桶的权重除以最大权重,在最终计算的时候实际上除以的最大权重会被抵消掉,这边要除最大权重很可能是在0-1区间内的浮点数计算效率更高,因此我们可以理解为获得的就是桶的权重。
按这个思路走,初始的fileter中的xy分别存储的是总权重乘以过滤百分比下限与上限,在循环遍历每个桶执行FilterLuminance的过程中,会将低于下限的权重和高于上限的权重过滤掉,将过滤后的桶数据通过GetLuminanceFromHistogramBin进行桶到亮度的映射即GetHistogramBinFromLuminance的反向操作,最终亮度与权重的积的总和与权重总和相除即为平均亮度,最终的平均亮度会通过Clamp函数取在设置的最小和最大亮度之间
在获得平均亮度后,我们需要通过平均亮度计算曝光值,代码如下:
1 | float GetExposureMultiplier(float avgLuminance) |
可以看到,Unity目前的方式就是将我们输入的曝光补偿值(EC)除以亮度来得到曝光值,获得曝光值后我们会根据选择的模式来调整最终返回的曝光值,如果是Progressive则进行插值,如果是Fixed的则直接返回
1 | #if PROGRESSIVE |
插值代码如下,依据设置的SpeedUp和SpeedDown来调整变化速率
1 | float InterpolateExposure(float newExposure, float oldExposure) |
最终exposure被存在了一张1x1的贴图的R通道中,在渲染时会将R通道的数值与当前颜色相乘
1 | half autoExposure = SAMPLE_TEXTURE2D(_AutoExposureTex, sampler_AutoExposureTex, uv).r; |
FrameDebug中看到的实际流程如下:
首先在每次计算之前情况一次数据,CS分配为 8组线程组,每组16个线程
然后遍历屏幕图像,CS分配60 X 34个线程组(1920 /2/16 = 60, 1080 /2/16 = 34) ,16 * 16个线程,获得直方图
最后统计直方图数据,渲染到1 * 1的贴图R通道
最终渲染时将RGB与该贴图R通道数值相乘即可