二十、D3D12学习笔记——环境光遮蔽

好多天都没有更新D3D12的学习笔记了,因为最近确实学得有点乏,所以换了个方向,总结了一下之前学习的OpenGL和一些图形渲染的高级话题。那么回到D3D的学习,今天我们要介绍一下龙书对于环境光遮蔽的讲解。

一、理解什么是环境光遮蔽

环境光遮蔽,我觉得这个名字不够直接,我愿意白话的称之为:环境光折减系数。也就是说我们之前在直接光照下,使用C_{A}=A_{L}\cdot M_{d}来表示环境光贡献,会导致无光照(无漫反射和高光)部分无差异,为了考虑这种差异性我们不得不针对不同的像素采用下式:
C_{A}=k_{f}A_{L}\cdot M_{d}

其中k_{f}就是不同像素环境光着色时使用的系数,由此体现差异性,那么结果就会如下图一样从无差异表现出差异:

不难看出,这种差异性来自于几何Mesh间的遮挡效果,那么遮挡越多k_{f}越小,色彩偏暗,反之k_{f}越大色彩越亮,这种差异会带来层次感。因此我们的任务就是根据遮挡关系估算k_{f}\epsilon [0,1]]的具体数值,这就叫环境光遮蔽,或者环境光折减系数。

二、3D场景投射光线实现环境光遮蔽

一定要强调是3D场景,因为这是基于投射光线与三角Mesh的相交来确定遮蔽关系的:

左图表示没有遮挡物挡住到的P的环境光照,那么自然 k_{f}=1。对于右图,由于复杂场景中,P点周围可能有其他Mesh的遮挡,导致了并非半球面所有方向都能向P投射光线,因而k_{f}< 1,那么这个k_{f}如何计算呢?一个很简单的思路,投射出N条光线,那么如果有M条不被遮挡,则:
k_{f}=\frac{M}{N}

知道这个简单原理,我们给k_{f}取名为可及率,对应的1-k_{f}就叫做遮蔽因子

如果你学习过我《拾取》这篇文章,就知道如何判断射线与三角形面片相交,那么这里我们就直接给一串伪代码表示我们的逻辑:

Func(input TMesh)//传入待计算环境光遮蔽的三角形几何信息
{
    for(each TMesh)
    {
        M=0;
        计算TMesh的法线
        for(从TMesh中心投射N条光线)
        {
            对单条光线随机偏移;
            判断光线在一定半径范围内是否与其他三角形Mesh相交
            if(不相交)
                M++;
        }
        当前TMesh环境光遮蔽系数M/N;
    }
    //将TMesh的环境光遮蔽系数反馈至顶点属性,在shader中作为环境光着色的系数使用
}

 具体的代码在龙书中有,它还使用了一个八叉树的结构来加速求交,但是这种在host端进行的求交一方面效率并不可观(如果TMesh数量很多),并且占用了顶点属性,增加了往GPU传输的任务。还有一点,通过投射光线N,如果数量不足实际是会产生噪声的,不过由于环境光自身变化频率本来也低所以似乎还不是很大的问题,不过后边我们会介绍如何处理。

既然host端存在效率问题,那么最好我们就在GPU上进行,因为这毕竟是确定各个像素的环境光,存在天然的并行,那么要这么实现呢——SSAO。

三、SSAO

我只能说大名鼎鼎,也许你不知道他的中文名——屏幕空间环境光遮蔽(Screen Space Ambient Occlusion),也应该知道SSAO。那么屏幕空间如何实现这件事?我们先从两个已知数据说起。

为了进行SSAO,我们需要两个已知数据:
1.自观察空间定义的各像素法线,渲染至纹理(供计算时采样);

2.各像素深度缓冲区DSV;

要得到上边两个数据,我们就应当将场景渲染一次。这里给出相关shader并讲解:

VertexOut VS(VertexIn vin)
{
	VertexOut vout = (VertexOut)0.0f;

	// 处理材质,为Md进行的.
	MaterialData matData = gMaterialData[gMaterialIndex];
    vout.NormalW = mul(vin.NormalL, (float3x3)gWorld);
	vout.TangentW = mul(vin.TangentU, (float3x3)gWorld);

    // 世界坐标位置与齐次坐标位置
    float4 posW = mul(float4(vin.PosL, 1.0f), gWorld);
    vout.PosH = mul(posW, gViewProj);
	
	// 纹理坐标
	float4 texC = mul(float4(vin.TexC, 0.0f, 1.0f), gTexTransform);
	vout.TexC = mul(texC, matData.MatTransform).xy;
	
    return vout;
}

 结合上下VS与PS代码看一下,我们实际上作为PS中直接服务于输出的只有在VS中计算的NormalW,但是我们还是要计算顶点等信息,实际是为了生成正确的DSV。那么题外话是不是可以不处理材质,放到下一次渲染场景的时候来处理。

float4 PS(VertexOut pin) : SV_Target
{
	// 为处理材质,Md进行的准备
	MaterialData matData = gMaterialData[gMaterialIndex];
	float4 diffuseAlbedo = matData.DiffuseAlbedo;
	uint diffuseMapIndex = matData.DiffuseMapIndex;
	uint normalMapIndex = matData.NormalMapIndex;
    diffuseAlbedo *= gTextureMaps[diffuseMapIndex].Sample(gsamAnisotropicWrap, pin.TexC);

#ifdef ALPHA_TEST
    clip(diffuseAlbedo.a - 0.1f);
#endif

	// 插值之后可能不是规范化的
    pin.NormalW = normalize(pin.NormalW);	

    // 将世界空间法线变换到观察空间
    float3 normalV = mul(pin.NormalW, (float3x3)gView);
    return float4(normalV, 0.0f);
}

 至此,我们得到了观察空间的法线normalV ,并且渲染到了纹理中存储起来,同步的DSV也生成了。

那么如何使用这两个数据生成环境光遮蔽的数值呢?见下图:

对照着这幅图,我们先把确定场景中P点环境光遮蔽的逻辑梳理清楚:
1.自观察点到近平面构建向量v;

2.p=tv,根据DSV的得到深度信息p_{z}t=\frac{p_{z}}{v_{z}}重构p点在3D场景中的位置;

3.以p为中心,在固定半径上生成随机向量pq,取得q点在3D场景中的位置;

4.从观察点看向q,获取此单位向量,并根据近平面与场景深度r_{z}重构r位置(同2);

5.取得\left | r_{z}-p_{z} \right |< radius(距离过远不考虑或遮蔽效果很少)并且(r-p)\cdot n> 0(避免成为90°及负角度构成非遮蔽)表示可计算遮蔽;

6.根据\left | r_{z}-p_{z} \right |在最大距离与最小距离之间实现线性遮蔽值计算。

基础逻辑我们说完了,现在来看一下具体的shader实现:
1.使用由两个三角形表示的四边形顶点数据,将其等价于纹理坐标数据进行处理,纹理坐标经过变换可覆盖每个像素:

static const float2 gTexCoords[6] =
{
    float2(0.0f, 1.0f),
    float2(0.0f, 0.0f),
    float2(1.0f, 0.0f),
    float2(0.0f, 1.0f),
    float2(1.0f, 0.0f),
    float2(1.0f, 1.0f)
};

2.根据纹理坐标生成近平面向量v:

VertexOut VS(uint vid : SV_VertexID)
{
    VertexOut vout;
    vout.TexC = gTexCoords[vid];
    // 转化到NDC坐标空间
    vout.PosH = float4(2.0f*vout.TexC.x - 1.0f, 1.0f - 2.0f*vout.TexC.y, 0.0f, 1.0f); 
    // 从NDC空间到观察空间
    float4 ph = mul(vout.PosH, gInvProj);
    // 变换至近平面
    vout.PosV = ph.xyz / ph.w;
    //作为输出,自动插值到PS
    return vout;
}

3.像素着色器中逐像素求解环境光遮蔽:

float4 PS(VertexOut pin) : SV_Target
{
	// 采样法线纹理与深度纹理取得已知参数  
    float3 n = normalize(gNormalMap.SampleLevel(gsamPointClamp, pin.TexC, 0.0f).xyz);
    float pz = gDepthMap.SampleLevel(gsamDepthMap, pin.TexC, 0.0f).r;
    pz = NdcDepthToViewDepth(pz);

	// 构建场景p坐标
	float3 p = (pz/pin.PosV.z)*pin.PosV;
	
	// 获取随机向量
	float3 randVec = 2.0f*gRandomVecMap.SampleLevel(gsamLinearWrap, 4.0f*pin.TexC, 0.0f).rgb - 1.0f;
    // 初始化
	float occlusionSum = 0.0f;
	
	// 采集均匀分布的点q
	for(int i = 0; i < gSampleCount; ++i)
	{
		// 随机向量关于均匀分布的向量对称获得均匀分布的随机向量offset 
        // 作为对称轴的均匀分布向量使用立方体8个交点和6个面中心与立方体中心构建
		float3 offset = reflect(gOffsetVectors[i].xyz, randVec);
	
		// 翻转处于pn平面后方的向量
		float flip = sign( dot(offset, n) );
		
		// 计算得到点q位置
		float3 q = p + flip * gOcclusionRadius * offset;
		
		// 生成q对应的像素坐标
		float4 projQ = mul(float4(q, 1.0f), gProjTex);
		projQ /= projQ.w;

		// 采样DSV,生成点r
		float rz = gDepthMap.SampleLevel(gsamDepthMap, projQ.xy, 0.0f).r;
        rz = NdcDepthToViewDepth(rz);
		float3 r = (rz / q.z) * q;
		
		// 根据点深度距离,计算环境光遮蔽数值		
		float distZ = p.z - r.z;
		float dp = max(dot(n, normalize(r - p)), 0.0f);
        float occlusion = dp*OcclusionFunction(distZ);
        // 累加当前采样点遮蔽数值
		occlusionSum += occlusion;
	}
	// 计算平均遮蔽数值
	occlusionSum /= gSampleCount;
	// 计算可及率
	float access = 1.0f - occlusionSum;
	// 使用幂函数,使之变化锐利
	return saturate(pow(access, 6.0f));
}

4.处理噪声

由于我们进行计算时使用的是均匀分布的随机向量,大概是14个,采样不足往往导致噪声的产生,为了处理噪声,一个常规的操作操作就是模糊。但是这里要使用的是保留边界的模糊(边界使用返现与深度图来判断),以保证边界便于界定,基本思路还是非边界部分进行周围像素环境光遮蔽值的加权。

VS.Shader

VertexOut VS(uint vid : SV_VertexID)
{
    VertexOut vout;
    vout.TexC = gTexCoords[vid];
    // 计算在NDC空间的纹理采样坐标
    vout.PosH = float4(2.0f*vout.TexC.x - 1.0f, 1.0f - 2.0f*vout.TexC.y, 0.0f, 1.0f);
    return vout;
}

PS.Shader

float4 PS(VertexOut pin) : SV_Target
{
    // 设置模糊权重
    float blurWeights[12] =
    {
        gBlurWeights[0].x, gBlurWeights[0].y, gBlurWeights[0].z, gBlurWeights[0].w,
        gBlurWeights[1].x, gBlurWeights[1].y, gBlurWeights[1].z, gBlurWeights[1].w,
        gBlurWeights[2].x, gBlurWeights[2].y, gBlurWeights[2].z, gBlurWeights[2].w,
    };
    // 设置相对中心像素的偏移值,进行采样
	float2 texOffset;
	if(gHorizontalBlur)
	{
		texOffset = float2(gInvRenderTargetSize.x, 0.0f);
	}
	else
	{
		texOffset = float2(0.0f, gInvRenderTargetSize.y);
	}

	// gInputMap就是环境光遮蔽纹理
    // 采集像素本身取值
	float4 color      = blurWeights[gBlurRadius] * gInputMap.SampleLevel(gsamPointClamp, pin.TexC, 0.0);
	float totalWeight = blurWeights[gBlurRadius];
	// 获得法线与深度信息计算边界 
    float3 centerNormal = gNormalMap.SampleLevel(gsamPointClamp, pin.TexC, 0.0f).xyz;
    float  centerDepth = NdcDepthToViewDepth(
        gDepthMap.SampleLevel(gsamDepthMap, pin.TexC, 0.0f).r);
    // 在模糊半径内采样
	for(float i = -gBlurRadius; i <=gBlurRadius; ++i)
	{
		// 已记录中心像素信息
		if( i == 0 )
			continue;
        // 偏移采样坐标
		float2 tex = pin.TexC + i*texOffset;
        // 获得法线与深度信息计算边界 
		float3 neighborNormal = gNormalMap.SampleLevel(gsamPointClamp, tex, 0.0f).xyz;
        float  neighborDepth  = NdcDepthToViewDepth(
            gDepthMap.SampleLevel(gsamDepthMap, tex, 0.0f).r);

		// 当前像素法线与中心像素法线差异过大认定为边界 
		// 当前像素深度与中心像素深度差异过大认定为边界 
        // 否则累加统计像素环境光遮蔽贡献
		if( dot(neighborNormal, centerNormal) >= 0.8f &&
		    abs(neighborDepth - centerDepth) <= 0.2f )
		{
            float weight = blurWeights[i + gBlurRadius];

			// Add neighbor pixel to blur.
			color += weight*gInputMap.SampleLevel(
                gsamPointClamp, tex, 0.0);
		
			totalWeight += weight;
		}
	}
	// 最后进行平均
    return color / totalWeight;
}

5.使用环境光遮蔽

此时经过计算与模糊的环境光遮蔽已经逐像素存储为纹理,因此对场景再次进行渲染,设置深度检测为相等,然后仅对环境光项进行环境光遮蔽采样并加权:

float ambientAccess = gSsaoMap.Sample(gsamLinearClamp, pin.SsaoPosH.xy, 0.0f).r;
float4 ambient = ambientAccess*gAmbientLight*diffuseAlbedo;

OK,环境光遮蔽话题介绍完了。

对此,我有一点新的构思,那就是距离场,其实我们针对每个物体是可以生成距离场的,对某一个TMesh的中心点,完全可以直接统计周围N个点中有M个点小于阈值radius,就能得到遮蔽项。这个方式很像在3D空间投射光线,但是并不需要进行相交检测,只需要预处理之时存储距离场即可。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值