本篇将基于基础篇实现一个较为完整的基于直方图的自动曝光功能

算法流程

  • 拿到Scene Color
  • 计算Avg Luminance
    • 基于每个Pixel Color→计算Luminance
    • 直方图分成64个部分,每部分视作一个桶,将Luminance换算成”挡位”,丢进64个桶
    • 最暗与最亮的桶,不纳入后续的计算
    • 剩下的桶,加权平均
    • 还原成Luminance,得到Luminance_{Avg}
  • $Luminance_{Avg} = Clamp[Luminance_{Avg}, Luminance_{Min}, Luminance_{Max}]$
  • 计算曝光系数

    Exposure Scale = MiddleGrey * exp2(EC) / Luminance_{Avg}
    其中,EC意味手动控制的曝光强度

算法实现

整体可以分成三个Pass:

  • Clear Histogram
  • Build Histogram
  • Compute Exposure Scale

Build Histogram

核心部分:丢进64个桶

groupshared uint GSLocalHistogram[HISTOGRAM_SIZE];

[branch]
if (GroupIndex < HISTOGRAM_SIZE)
{
    GSLocalHistogram[GroupIndex] = 0;
}
GroupMemoryBarrierWithGroupSync();

uint2 SourceSize = (uint2)g_SourceSize.xy;
if (SamplePos.x < SourceSize.x && SamplePos.y < SourceSize.y)
{
    float3 SourceColor = LoadTexture2D(g_SourceTexIndex, SamplePos);
    float Luminance = AutoExposureLuminance(SourceColor);
    uint BinIndex = LuminanceToHistogramBin(Luminance, g_HistogramScale, g_HistogramBias);
    InterlockedAdd(GSLocalHistogram[BinIndex], 1);
}
GroupMemoryBarrierWithGroupSync();

[branch]
if (GroupIndex < HISTOGRAM_SIZE)
{
    InterlockedAdd(HistogramBuffer[GroupIndex], GSLocalHistogram[GroupIndex]);
}

Compute Exposure Scale

核心部分:计算曝光系数

groupshared uint GSGlobalHistogram[HISTOGRAM_SIZE];

if (groupIndex < HISTOGRAM_SIZE)
{
    HistogramRT[UINT2(groupIndex, 0)] = GSGlobalHistogram[groupIndex] = HistogramBuffer[groupIndex];
}
GroupMemoryBarrierWithGroupSync();

uint SumBin = 0;
[unroll]
for (UINT i = 0;i < HISTOGRAM_SIZE; i++)
{
    SumBin += GSGlobalHistogram[i];
}

float MinBin = (float)SumBin * saturate(g_LowPercent);
float MaxBin = (float)SumBin * saturate(max(g_HighPercent, g_LowPercent));

float TotalEv = 0;
float TotalCount = 0;
[unroll]
for (UINT i = 0; i < HISTOGRAM_SIZE; i++)
{
    float CurrBin = GSGlobalHistogram[i];
    float CurrSub = min(CurrBin, MinBin);

    CurrBin -= CurrSub;
    MinBin -= CurrSub;
    MaxBin -= CurrSub;

    CurrBin = min(CurrBin, MaxBin);
    MaxBin -= CurrBin;

    float HistogramPos = float(i) / (float(HISTOGRAM_SIZE) - 1);
    TotalEv += ComputeLogLuminanceFromHistogramPosition(HistogramPos, g_HistogramScale, g_HistogramBias) * (float)CurrBin;
    TotalCount += (float)CurrBin;
}

float AverEV = TotalEv * rcp(max(TotalCount, 1e-5));
float AverLuminance = ComputeLuminanceFromLogLuminance(AverEV);
float ClampAverLuminance = clamp(AverLuminance, g_MinAverageLuminance, g_MaxAverageLuminance);

float MiddleGreyLuminance = g_MiddleGrey * ComputeLuminanceFromLogLuminance(g_ExposureCompensationEV);

float ExposureScale = MiddleGreyLuminance / max(ClampAverLuminance, 1e-5);

如何正确应用曝光系数

在考虑这个问题时,需要搞清楚渲染管线中,曝光系数会对哪些后效,产生很严重的影响(大大影响其本来的效果,将其带偏),没错就是”Tonemap”

也就是说,需要考虑是在Tonemap之前、之中、之后,计算、应用曝光系数

答案是在Tonemap之前计算曝光系数,之前应用曝光系数

为什么是Tonemap之前计算曝光系数

因为tonemap曲线非线性,会压暗部、亮部,且计算自动曝光的时候,亮度会和中灰对比

经过非线性映射后亮度全部都被映射到[0,1],整体不会亮暗不会被区分开

为什么是Tonemap之前应用曝光系数

假设是Tonemap后(亮度被映射到[0,1]),应用曝光系数,亮暗不会拉开,自动曝光的收益不高

而应用Tonemap前(亮度范围很宽),此时应用曝光系数,亮暗对比会被拉的很开,效果很明显

目前的效果

开启自动曝光:

未开启自动曝光:

额外的功能

预曝光

  • 方案:创建一个Buffer,存储两个float,一个是历史帧的预曝光,另一个是当前帧的预曝光
    • 为什么要两个?

      因为渲染器包含TAA,历史帧也需要乘预曝光,所以也就是说,Scene Color Lighting乘上当前帧的预曝光后,后续在TAA Pass,计算历史帧 Color时,还需要乘上历史帧的预曝光 / 当前帧的预曝光

    • 为什么不都在TAA阶段应用,而是Scene Color Lighting 与历史帧

      因为Scene Color Lighting Pass计算时有32精度,但存储之后只有16精度,后续在TAA读到的可能是有误的数据

    • 如何更新

      在计算曝光系数时,用当前帧的预曝光更新历史帧的预曝光,再用计算得到的曝光系数更新当前帧的预曝光

    • 还需要考虑Skybox Pass

时间平滑

  • 方案:无需额外Buffer,直接使用存储非Log版的exposure scale的值,作为上一帧;再计算当前帧的平均亮度,再计算档位,最后插值平滑
  • 实现

    float AvgLuminance = ComputeAvgLuminance(SumBin);
    float OldExposureScaleWithoutEV = GetExposureScaleWithoutEV(ExposureScaleBuffer);
    float4 ExposureData = ComputeTemporalSmooth(AvgLuminance, OldExposureScaleWithoutEV);
    
    

    计算档位

    float MiddleGrey = max(g_MiddleGrey, 1e-5);
    float TargetAvg = clamp(AvgLuminance, g_MinAverageLuminance, g_MaxAverageLuminance);
    float TargetExposure = TargetAvg / MiddleGrey;
    float OldExposure = rcp(max(OldExposureScaleWithoutEV, 1e-5));
    
    float AutoExposureAdapt(float OldExposure, float TargetExposure, float FrameTime,
                            float SpeedUp, float SpeedDown, float StartDistance, float ForceTarget)
    {
        OldExposure = max(OldExposure, 1e-5f);
        TargetExposure = max(TargetExposure, 1e-5f);
        FrameTime = clamp(FrameTime, 1e-4f, 0.1f);
    
        float LogTarget = log2(TargetExposure);
        float LogOld = log2(OldExposure);
        float LogDiff = LogTarget - LogOld;
        float Speed = max((LogDiff > 0.0f) ? SpeedUp : SpeedDown, 0.0f);
        float M = AutoExposureExponentialM(Speed, StartDistance);
    
        float LogExp = AutoExposureExponentialAdapt(LogOld, LogTarget, FrameTime, Speed, M);
        float LogLin = AutoExposureLinearAdapt(LogOld, LogTarget, FrameTime, Speed);
        float LogAdapted = abs(LogDiff) > StartDistance ? LogLin : LogExp;
        float Adapted = exp2(LogAdapted);
        return lerp(Adapted, TargetExposure, saturate(ForceTarget));
    }
    

    处理线性到指数的切变:

    先设定一个距离,档位差小于等于这个距离,从线性衰减切换到指数衰减(每次走剩余距离的百分比)

    需要注意:由于从线性衰减切换到指数衰减,肯定会有切变,UE做了对应处理,在差不多等于距离时,执行了个换算,将指数衰减的值变换到线性衰减(同一衰减距离),防止切变

    (这里直接copy的ue的代码)

    float AutoExposureExponentialM(float Speed, float StartDistance)
    {
        const float kFrameTimeEps = 1.0f / 60.0f;
        float StartTime = StartDistance / max(Speed, 1e-3f);
        float Denom = (1.0f - exp2(-kFrameTimeEps * Speed)) * StartTime;
        return kFrameTimeEps / max(Denom, 1e-5f);
    }
    
    float AutoExposureExponentialAdapt(float Current, float Target, float FrameTime, float Speed, float M)
    {
        float Factor = 1.0f - exp2(-FrameTime * Speed);
        return Current + (Target - Current) * Factor * M;
    }
    
    float AutoExposureLinearAdapt(float Current, float Target, float FrameTime, float Speed)
    {
        float Offset = FrameTime * Speed;
        return (Current < Target) ? min(Target, Current + Offset) : max(Target, Current - Offset);
    }
    

他们曾如此骄傲的活过,贯彻始终