UE自动曝光(眼部适应)

UE文档(见文末参考)中描述具体参数已经很详细了, 本文主要对代码实现补充说明

UE里有两种方式计算场景亮度Auto Exposure Basic& Auto Exposure Histogram, 区别在于计算场景亮度的方式

  • Auto Exposure Basic算法使用场景亮度对数值平均来计算Target曝光值.
  • Auto Exposure Histogram模式首先计算场景亮度对数值的直方图, 然后用该直方图分析来决定平均亮度值.

拿一张渲染_MainRT分辨率为1214x650的Scene Color举例

1. 降至分辨率

Scene Color分辨率一般过高, 为此需额外降半分辨率做亮度计算, 渲染至半分辨率_Scene0RT上, 其分辨率为607x325

2. Auto Exposure Histogram

2.1. 按块生成直方亮度图

GPU每个线程处理8x8像素, 按一个线程组8x4个线程数量划分, 一个线程组就能处理(8x8)x(8x4)=64x32个像素
分辨率为607x325的_Scene0RT, 先用单像素来补齐奇数分辨率, 方便后续双线性并行计算

// 先定义一些线程常量
static readonly int LoopSizeX = 8; // 每线程处理循环的X维度
static readonly int LoopSizeY = 8; // 每线程处理循环的Y维度
static readonly int HistogramSize = 64; // 将亮度的对数值, 映射到直方亮度0~63范围
static readonly int ThreadGroupSizeX = 8;
static readonly int ThreadGroupSizeY = 4;
static readonly int HistogramTexelCount = HistogramSize / 4; // 直方亮度图_HistogramRT每纹素可存储4通道值, 将直方图64范围数量存放至分辨率宽为16的4通道上
static readonly Vector2Int TexelsPerThreadGroup = new Vector2Int(ThreadGroupSizeX * LoopSizeX, ThreadGroupSizeY * LoopSizeY);

// 并行计算实际使用的_Scene0RT分辨率
w = (607 + 607 % 2) / 2;
h = (325 + 325 % 2) / 2;

// 线程组数量&直方亮度图纹理分辨率
histogramThreadGroupCount.x = Mathf.CeilToInt(w / (float)TexelsPerThreadGroup.x); // = 608 / (8*8) = 10, 其中, TexelsPerThreadGroup.x = 8*8
histogramThreadGroupCount.y = Mathf.CeilToInt(h / (float)TexelsPerThreadGroup.y); // = 608 / (8*4) = 11, 其中, TexelsPerThreadGroup.y = 8*4
histogramThreadGroupCountTotal = histogramThreadGroupCount.x * histogramThreadGroupCount.y; // = 10 * 11 = 110
_HistogramRT.w = HistogramTexelCount; // = 16
_HistogramRT.h = histogramThreadGroupCountTotal; // = 110, 线程组个数, 为RT的行数, 即每一列为所有线程组
_HistogramRT.colorFormat = RenderTextureFormat.ARGBHalf; // 4通道

平均亮度计算
简单说就是累加每个像素亮度的对数值然后取幂后平均

基于亮度值计算其在直方图位置

// inverse of ComputeLuminanceFromHistogramPosition
// is executed more often than ComputeLuminanceFromHistogramPosition()
// @param Luminance
// @return HistogramPosition 0..1
// EyeAdaptation_HistogramScale = 1.0f / (histogramLogMax - histogramLogMin);
// EyeAdaptation_HistogramBias  = -histogramLogMin * EyeAdaptation_HistogramScale;

float ComputeHistogramPositionFromLuminance(float Luminance)
{
	return log2(Luminance) * EyeAdaptation_HistogramScale + EyeAdaptation_HistogramBias;
}

每个线程组均分配一个三维数组gs_sharedHistogram

#define THREADGROUP_SIZEX 8
#define THREADGROUP_SIZEY 4
#define HISTOGRAM_SIZE 64
// THREADGROUP_SIZEX*THREADGROUP_SIZEY histograms of the size HISTOGRAM_SIZE
groupshared float gs_sharedHistogram[HISTOGRAM_SIZE][THREADGROUP_SIZEX][THREADGROUP_SIZEY];

先将_Scene0RT按每线程处理8x8循环, 计算好每个线程组上, 每个线程覆盖的8x8范围亮度的对数值累加

// Extract luminance.
float LuminanceVal = max(dot(SceneColor.xyz, float3(1.0f, 1.0f, 1.0f)/3.0f), EyeAdaptation_LuminanceMin);

// Compute histogram position.
float LogLuminance = ComputeHistogramPositionFromLuminance(LuminanceVal);

float2 ScreenUV = (TexelPos.xy - _ViewportMin) * InvViewSize;
float ScreenWeight = AdaptationWeightTexture(ScreenUV);

// Map the normalized histogram position into texels.
float fBucket = saturate(LogLuminance) * (HISTOGRAM_SIZE - 1);// * 0.9999f;

// Find two discrete buckets that straddle the continuous histogram position.
uint Bucket0 = (uint)(fBucket);
uint Bucket1 = Bucket0 + 1;

Bucket0 = min(Bucket0, uint(HISTOGRAM_SIZE - 1));
Bucket1 = min(Bucket1, uint(HISTOGRAM_SIZE - 1));

// Weighted blend between the two buckets.
float Weight1 = frac(fBucket);
float Weight0 = 1.0f - Weight1;

// Accumulate the weight to the nearby history buckets.
gs_sharedHistogram[Bucket0][GroupThreadId.x][GroupThreadId.y] += Weight0 * ScreenWeight;
gs_sharedHistogram[Bucket1][GroupThreadId.x][GroupThreadId.y] += Weight1 * ScreenWeight;

按直方亮度值, 统计当前线程组上所有线程亮度对应的权重值。共计64个亮度值, 按16组每组4个统计, 16即对应_HistogramRT的宽。
目的是为了将当前线程组所覆盖的所有像素亮度, 映射至直方图64块亮度值上。 直方亮度图每一行即表示一个线程组所覆盖的范围(8x8x8x4)在该行上的亮度分布。

// Ensure that all threads in the current thread group have processed 8*8 pixels in advance.
GroupMemoryBarrierWithGroupSync();

// Accumulate all histograms into one.
if (GroupIndex < HISTOGRAM_SIZE / 4) 
{
    float4 Sum = 0;

    [loop] 
    for (uint y = 0; y < THREADGROUP_SIZEY; ++y)
    {
        [loop] 
        for (uint x = 0; x < THREADGROUP_SIZEX; ++x)
        {
            Sum += float4(
                gs_sharedHistogram[GroupIndex * 4 + 0][x][y],
                gs_sharedHistogram[GroupIndex * 4 + 1][x][y],
                gs_sharedHistogram[GroupIndex * 4 + 2][x][y],
                gs_sharedHistogram[GroupIndex * 4 + 3][x][y]);
        }
    }

    float2 MaxExtent = _ViewportSize;
    float Area = MaxExtent.x * MaxExtent.y;

    // Fixed to include borders.
    float NormalizeFactor = 1.0f / Area;

    // Output texture with one histogram per line, x and y unwrapped into all the lines
    _RWHistogramTexture[uint2(GroupIndex, GroupId.x + GroupId.y * _ThreadGroupCount.x)] = Sum * NormalizeFactor; // _RWHistogramTexture = _HistogramRT
}
Histogram纹理

2.2. 累加直方亮度图

得到分辨率为16x110的亮度分布纹理_HistogramRT后, 由于每一行都是各线程组覆盖范围的亮度分布, 为此需将各覆盖范围的亮度对数值合起来

先分配累加结果的_ReduceRT纹理

// histogramreduce texture.
_ReduceRT.w = histogramThreadGroupCountTotal; // = 110
_ReduceRT.h = 2;
_ReduceRT.colorFormat = RenderTextureFormat.ARGBFloat;

可使用硬件支持的双线性插值, 加速累加计算
_ReduceRT存储两行数据, 第一行数据为_HistogramRT每列累加的结果, 第二行数据为上帧_EyeAdaptationRT的结果

// accumulate all histograms into a single one
// could be optimized using bilinear filtering (half sample count)
// _LoopSize = histogramThreadGroupCountTotal = 110
// _ColorTexture = _HistogramRT
for (uint y = 0; y < _LoopSize/2; ++y)
{
    SceneColor += SAMPLE_TEXTURE2D_X(_ColorTexture, sampler_LinearClamp, float2(UV.x, (float(2*y + 1) * _ExtentInverse.y)));
}

if(input.positionCS.y < 1.0f)
{
    // line 0: histogram, we're not going to divide by the number of lines because it gets normalized later anyways
    return SceneColor;
}
else
{
    // line 1: store 4 channels of EyeAdaptationTexture (copied over so we can read the value in EyeAdaptation pass which is writing to eye adaptation)
    // second line first pixel in the texture has the ExposureScale from last frame
    float4 OldExposureScale = _EyeAdaptationTexture.Load(int3(0, 0, 0));
    return OldExposureScale;
}
Reduce纹理

2.3. 生成1x1曝光纹理

生成分辨率1x1的_EyeAdapatationRT, 存放四个值

x: 隐式的存储了最终计算的亮度值 (Current)
y: 隐式的存储了需要达到的亮度值(Target)
z: 当前场景的平均亮度, 已还原至非对数值的亮度
w: …

// _HistogramTexture => _ReduceRT
const float AverageSceneLuminance = ComputeEyeAdaptationExposure(_HistogramTexture);

const float TargetAverageLuminance = clamp(AverageSceneLuminance, EyeAdaptation_MinAverageLuminance, EyeAdaptation_MaxAverageLuminance);

// White point luminance is target luminance divided by 0.18 (18% grey).
const float TargetExposure = TargetAverageLuminance / 0.18;

const float OldExposureScale = _HistogramTexture.Load(int3(0, 1, 0)).x;
const float MiddleGreyExposureCompensation = EyeAdaptation_ExposureCompensationSettings * EyeAdaptation_ExposureCompensationCurve; // we want the average luminance remapped to 0.18, not 1.0
const float OldExposure = MiddleGreyExposureCompensation / (OldExposureScale != 0 ? OldExposureScale : 1.0f);

// eye adaptation changes over time
const float EstimatedExposure = ComputeEyeAdaptation(OldExposure, TargetExposure, unity_DeltaTime.x);//EyeAdaptation_DeltaWorldTime);

// maybe make this an option to avoid hard clamping when transitioning between different exposure volumes?
const float SmoothedExposure = clamp(EstimatedExposure, EyeAdaptation_MinAverageLuminance/.18f, EyeAdaptation_MaxAverageLuminance/.18f);

const float SmoothedExposureScale = 1.0f / max(0.0001f, SmoothedExposure);
const float TargetExposureScale =   1.0f / max(0.0001f, TargetExposure);

OutColor.x = MiddleGreyExposureCompensation * SmoothedExposureScale;
OutColor.y = MiddleGreyExposureCompensation * TargetExposureScale;
OutColor.z = AverageSceneLuminance;
OutColor.w = MiddleGreyExposureCompensation;

return OutColor;

3. Auto Exposure Basic

3.1. Scene Color亮度计算

先将半分辨率纹理_Scene0RT, 预处理下 => 再降半分辨率至304x163的_Scene5RT, 映射至参数设置的亮度对数值范围, 并存至颜色的w通道


float4 FragBasicEyeAdaptationSetup(Varyings input)
	// noperspective float4 UVAndScreenPos : TEXCOORD0,
	// out float4 OutColor : SV_Target0)
{
    float4 OutColor = 0;

	float2 UV = input.texcoord.xy;
	OutColor = SAMPLE_TEXTURE2D_X(_ColorTexture, sampler_LinearClamp, UV);
	// return OutColor;
	// Use max to ensure intensity is never zero (so the following log is well behaved)
	const float Intensity = max(dot(OutColor.xyz, float3(1.0f, 1.0f, 1.0f)/3.0f), EyeAdaptation_LuminanceMin);
	const float LogIntensity = clamp(log2(Intensity), -10.0f, 20.0f);

	// Store log intensity in the alpha channel: scale to 0,1 range.
	OutColor.w = EyeAdaptation_HistogramScale * LogIntensity + EyeAdaptation_HistogramBias; 
	return OutColor;
}

3.2. 生成1x1曝光纹理

根据WeightMask纹理, 计算亮度对数值的平均值

float ComputeWeightedTextureAverageAlpha(
	Texture2D Texture,
	uint2 RectMin,
	uint2 RectMax)
{
	// The inverse of the Region of Interest size.
	const float InvRectWidth = 1.0f / float(RectMax.x - RectMin.x);
	const float InvRectHeight = 1.0f / float(RectMax.y - RectMin.y);

	// use product of linear weight in x and y.
	float Average = 0.0f;
	float WeightTotal = 0.0f;

	for (uint i = RectMin.x; i < RectMax.x; ++i)
	{
		for (uint j = RectMin.y; j < RectMax.y; ++j)
		{
			float2 ScreenUV = float2(float(i) * InvRectWidth, float(j) * InvRectHeight);

			float Weight = max(AdaptationWeightTexture(ScreenUV), 0.05f);

			WeightTotal += Weight;

			// Accumulate values from alpha channel.
			float Sample = Texture.Load(int3(i, j, 0)).w;
			Average += Weight * Sample;
		}
	}

	Average /= WeightTotal;
	return Average;
}

根据Target及Old的曝光值, 结合由明到暗或由暗到明的速度参数, 来确定Current的曝光值。
EyeAdaptation_StartDistance为从线性切换到指数的距离。例如,在StartDistance=1.5时,当线性距离命中目标1.5档光圈时,切换到指数。

float ComputeEyeAdaptation(float OldExposure, float TargetExposure, float FrameTime)
{
	const float LogTargetExposure = log2(TargetExposure);
	const float LogOldExposure = log2(OldExposure);

	const float LogDiff = LogTargetExposure - LogOldExposure;

	const float AdaptionSpeed = (LogDiff > 0) ? EyeAdaptation_ExposureSpeedUp : EyeAdaptation_ExposureSpeedDown;
	const float M = (LogDiff > 0) ? EyeAdaptation_ExponentialUpM : EyeAdaptation_ExponentialDownM;

	const float AbsLogDiff = abs(LogDiff);

	// blended exposure
	const float LogAdaptedExposure_Exponential = ExponentialAdaption(LogOldExposure, LogTargetExposure, FrameTime, AdaptionSpeed, M);
	const float LogAdaptedExposure_Linear = LinearAdaption(LogOldExposure, LogTargetExposure, FrameTime, AdaptionSpeed);

	const float LogAdaptedExposure = AbsLogDiff > EyeAdaptation_StartDistance ? LogAdaptedExposure_Linear : LogAdaptedExposure_Exponential;

	// Note: no clamping here. The target exposure should always be clamped so if we are below the min or above the max,
	// instead of clamping, we will gradually transition to the target exposure. If were to clamp, the then we would have a harsh transition
	// when going from postFX volumes with different min/max luminance values.
	const float AdaptedExposure = exp2(LogAdaptedExposure);

	// for manual mode or camera cuts, just lerp to the target
	const float AdjustedExposure = lerp(AdaptedExposure,TargetExposure,EyeAdaptation_ForceTarget);
	
	return AdjustedExposure;
}

生成分辨率1x1的_EyeAdapatationRT, 存放四个值

x: 隐式的存储了最终计算的亮度值 (Current)
y: 隐式的存储了需要达到的亮度值(Target)
z: 当前场景的平均亮度, 已还原至非对数值的亮度
w: …

// _ColorTexture = _Scene5RT
float LogLumAve = ComputeWeightedTextureAverageAlpha(_ColorTexture, _ViewportMin, _ViewportMax);

// Correct for [0,1] scaling
LogLumAve = (LogLumAve - EyeAdaptation_HistogramBias) / EyeAdaptation_HistogramScale;

// Convert LogLuminanceAverage to Average Intensity
const float AverageSceneLuminance = OneOverPreExposure * exp2(LogLumAve);

const float MiddleGreyExposureCompensation = EyeAdaptation_ExposureCompensationSettings * EyeAdaptation_ExposureCompensationCurve * EyeAdaptation_GreyMult;// we want the average luminance remapped to 0.18, not 1.0

const float LumAve = AverageSceneLuminance; 

const float ClampedLumAve = clamp(LumAve, EyeAdaptation_MinAverageLuminance, EyeAdaptation_MaxAverageLuminance);

// The Exposure Scale (and thus intensity) used in the previous frame
const float ExposureScaleOld = _EyeAdaptationTexture.Load(int3(0, 0, 0)).x;
const float LuminanceAveOld = MiddleGreyExposureCompensation / (ExposureScaleOld != 0 ? ExposureScaleOld : 1.0f);

// Time-based expoential blend of the intensity to allow the eye adaptation to ramp up over a few frames.
const float EstimatedLuminance = ComputeEyeAdaptation(LuminanceAveOld, ClampedLumAve, EyeAdaptation_DeltaWorldTime);

// maybe make this an option to avoid hard clamping when transitioning between different exposure volumes?
const float SmoothedLuminance = clamp(EstimatedLuminance, EyeAdaptation_MinAverageLuminance, EyeAdaptation_MaxAverageLuminance);

const float SmoothedExposureScale = 1.0f / max(0.0001f, SmoothedLuminance);
const float TargetExposureScale   = 1.0f / max(0.0001f, ClampedLumAve);

// Output the number that will rescale the image intensity
OutColor.x = MiddleGreyExposureCompensation * SmoothedExposureScale;
// Output the target value
OutColor.y = MiddleGreyExposureCompensation * TargetExposureScale;
OutColor.z = AverageSceneLuminance;
OutColor.w = MiddleGreyExposureCompensation / EyeAdaptation_GreyMult;

4. 曝光纹理

眼部适应纹理

5. 参考

1.UE4自动曝光
2.UE5自动曝光