前言
Kena这个游戏出来有一段时间了,可能是由于开发商的原班人马主要来自于影视动画体系,游戏中的迪士尼风格的卡通渲染和出众的美术表现一经面世就广受好评,正巧前不久闲暇时间充裕,我尝试用GPA截了几帧游戏帧渲染数据下来,怀着好奇艰难的啃起了DXBC代码,于是有了这第篇分享。
简述
Kena采用的是延迟渲染管线,在数据准备阶段在图元光栅化后的片元着色阶段会通过MRT技术指定并输出结果到多张RenderTexture中,以备后续光照计算。 而对于一些特殊图元(如茅草屋顶)还可能会安排执行多此片元着色,以便于处理和生成一些比较特殊的数据纹理。 此外在进行全屏光照渲染前,我们还能找到一些专门的 Draw Call 用于生成诸如: 屏幕空间环境光遮蔽(SSAO),高光遮蔽(Specular Mask),半分辨率全屏法线+深度纹理(Half-Res Normals)等。 总的来说所有被 GI_uber_pass 使用到的纹理可以在 附录1 中找到。本文着重围绕反解出来的全局漫反射光照函数展开,会首先以伪代码形式勾勒一下 GI_uber_pass 的总体结构和主要的逻辑走向,之后再针对一些容易识别出的具体渲染算法和处理方案进行个人理解上的解析。 Anyway,如有谬误,望不吝赐教!
着色器代码总体结构
这部分以伪代码形式大体梳理了下shader的逻辑和结构,参考如下:
half4 frag (v2f IN) : SV_Target
{
// Part_0:数据准备阶段
//(一)涉及深度和法线采样,世界空间坐标重建,计算视方向,采样漫反射纹理等常规操作
//(二)从特定纹理的通道中解码控制位“Flag”,参与不同"材质"的独特处理
//(三)4次采样半分辨率的法线+深度贴图,获得当前像素周围16领域中每4个像素的平均法线和深度,通过矩阵运算加总后获得法线 d_norm -> 平滑后的法线
//(四)基于NdotV和Frenel调整出最初的 GI_Diffuse
// Part_1:计算 GI_Diffuse
if(Flag 在 #1 ~ #15 之中) //既除了#0号外
{
//基于d_norm与normal之间的点积,计算AO_1
//基于d_norm的模长,计算AO_2
//基于采样SSAO贴图获得AO_3
//基于采样艺术家绘制的纹理,获得AO_4
//通过调配上述AO的比例获得 AO_Final
//通过将d_norm朝向norm做lerp,获取bias_norm
//基于bias_norm,常量变换矩阵以及常量系数(推测是直接光源的颜色和强度),计算出 AO_Scale
//分支
if(Flag == #7)
{
//基于折射角和视方向点积的结果,经过复杂的运算覆写了 GI_Diffuse
//此时 GI_Diffuse 中已经包含了类似 GI_Spec 的成分
}
//最终通过合并 AO_Final,AO_Scale和GI_Diffuse 获得完整的全局光漫反射
GI_Diffuse *= AO_Final * AO_Scale
}
// Part_2: 计算 GI_Specular
if(Flag 在 #1 ~ #6 以及 #8 ~ #15之中) //既排除了#0和#7
{
//计算视方向的反射方向: VR
//计算VR到norm中间的一个向量:VR_Lift,由物体材质粗糙控制幅度,既材质越粗糙,VR_Lift越接近norm
//采样纹理获得 Spec_add:这是对环境光高光的额外附加量,例如金属扣环,法杖宝珠上会有数值
Func{//基于IBL和SkyBox的环境光高光重建函数
//基于ao,NdotV,粗糙度以及作为缩放因子的Spec_add.w,计算获得高光强度:spec_intensity
//基于一些不重要的规则,计算获取环境光贴图阵列的索引序列 idx_table,知道如何获取到正确的IBL纹理
//基于 d_norm 的模长,计算光滑度 smoothness
//基于 1-smoothness 和颜色常量,计算环境光高光底色 gi_spec_base
//基于粗糙度计算出 IBL 采样的 Lod 等级
for(uint i = 0; i < idx_table.size && spec_intensity > 0.001; i++)
{
//从CBO中获取当前探针probe的位置和作用半径等参数
if(当前像素到探针的距离小于探针的作用半径)
{
//基于 VR_Lift 和 像素到探针的方向矢量,求解 sampling_vector
//基于像素到探针的距离,求出距离缩放因子: d_rate_factor
//使用 sampling_vector 采样IBL,获得返回值 ibl_raw
ibl_spec_output += ibl_raw.rgb * d_rate_factor * spec_intensity * smoothness //更新环境光高光输出
spec_intensity *= 1 - d_rate_factor * ibl_raw.a //更新 spec_intensity,刷新循环的退出条件
}
}
//直接使用 VR_Lift 采样 SkyBox,返回:sky_raw
gi_spec_base += sky_raw * 阳光的常量颜色 * smoothness //更新环境光高光底色,引入天空盒的颜色
}
//整合数据,获取环境光BRDF公式中,入射光线的积分值,既Lc值
prefilter_Specular = (ibl_spec_output + gi_spec_base * spec_intensity) * 某个常量缩放因子 + spec_add
//使用NdotV和纹理T9中记录的粗糙度作为UV,采样IBL_LUT预计算纹理,获得<scale1, bias1>
//基于<scale1, bias1>和之前求得的Lc(prefilter_Specular),计算求得环境光高光最终值:gi_spec_final_1
if(Flag == #4) //推测为环境光中的第二高光波瓣
{
//使用NdotV和纹理T11中记录的粗糙度作为UV,采样IBL_LUT预计算纹理,获得<scale2, bias2>
//基于常数0.04(替代Lc之用),<scale2, bias2>,正常求解出 gi_spec_final_2
//通过对gi_spec_final_1和gi_spec_final_2插值更新 gi_spec_final_2
Func{
//该函数仍然用于基于IBL和SkyBox的环境光高光重建
//但是有以下几点变换
//(一)计算spec_intensity的缩放因子从Spec_add.w替换成了gi_spec_final_2
//(二)计算sampling_vector中用到的VR_Lift被替换成了VR(既纯粹的视方向反射向量)
}
//利用第二套采样和计算的结果,更新 gi_spec_final_2
gi_spec_final_1 += gi_spec_final_2 //融合到第一套采样和计算的结果中去
}
}
// Part_3: 合并 GI_Diffuse 和 GI_Specular
output = GI_Diffuse + gi_spec_final_1
}
摘要
简单提炼了一些可能有用的技术点,归纳如下:
(一) 一种柔滑的全局光强度遮罩
形成漫反射的全局入射光一般认为来自物体表面的整个半球空域,但是受到微表面粗糙度影响,其掠视位置的漫反射光强会受到一定程度的衰减。 相较于简单通过 NdotV 来获取这项衰减因子的方法而言,Kena使用了如下所示的公式计算全局光因视角变换形成的漫反射衰减系数,其结果基于NdotV,同时又考虑到了不同材质的微表面粗糙度,从最终结果来看,这种经验公式形成的衰减更加自然柔和,为后面多次叠加类似的光强遮罩提供了便利。
//计算NoV_nearOne,相比于 NoV_sat 而言色调差异小,明暗过渡柔和
half NoV = dot(norm, viewDir);
half NoV_sat = saturate(NoV);
//a大体上在[-1, 0]区间上成二次曲线分布,N和V垂直得-1
half a = (NoV_sat * 0.5 + 0.5) * NoV_sat - 1.0;
//rifr.w是采样自纹理的一种漫反射相关粗糙度,经过缩放和偏移后返回值b与粗糙度w大小成反比关系
half b = saturate(1.25 - 1.25 * rifr.w);
F0.w = a * b;
//数值转换到 [0, 1] 区间,经过粗糙度处理,整体类似提亮后的NoV
half NoV_nearOne = F0.w + 1.0; //rifr.w越小则亮度低对比度高,反之亦然
title | NoV_nearone | NoV_sat |
---|---|---|
img |
(二) 第二种柔滑的全局光强度遮罩
Kena中应用的另一种环境光漫反射强度,参考如下经验公式。 具体来说是联合上面提及的 NoV_nearOne 项一起作用到采样的Diffuse color上。
//更新Diffuse颜色 -> 按照视角的大小,进一步表现出由暗到明的过渡(边缘暗中间亮)
half3 R12 = R10.xyz * 1.111111; //R10可以认为是采样的Diffuse颜色
half3 NoV_soft = 0.85 * (NoV_sat - 1) * (1 - (R12 - 0.78 * (R12 * R12 - R12))) + 1; //类似col - t*(col^2 - col) -> 这种模式的颜色操作等效于对原始颜色进行 "非线性提亮"
R12 = R12 * NoV_soft; //将基于NoV并修改过的另一种强度遮罩作用到Diffuse颜色上 -> 从而让边缘压暗,中间相对提亮
NoV_soft输出可参考如下,相对标准的NoV而言,同样具有过渡柔和,整体偏亮的特点。
(三) 第三种应用与全局光强度的遮罩
这次是借用了Fresnel公式返回值与1
的互补数,从而构造了类似NoV的强度遮罩:(1 - fresnel)
//借用Fresnel返回值与1的互补数,构造tmp_col,使之具有类似R12的修正效果,但相比略亮一些
float p5 = pow5(1 - NoV_sat);
float fresnel = lerp(p5, 1, 0.04);
tmp_col = R10.xyz * (1 - frxx_condi.x * fresnel); //此处的frxx_condi.x是来自纹理的遮罩,当前案例中只在人物+草叶等物件上有数值
上式中的tmp_col是与经过前两种强度遮罩修饰后的Diffuse颜色R12有相似效果,但是总体会更加明亮一些(Fresnel的特性导致)
Kena使用如下公式混合了tmp_col和R12颜色,混合比例主要由材质的粗糙度控制,同时也由该材质是否启用了Fresnel特性有关,具体而效果言如下:
- 如果物体材质粗糙度越小,则使用明亮的结果(tmp_col);反之则使用较暗沉的结果(R12)
R12 = lerp(tmp_col, R12, frxx_condi.x * factor_RoughOrZero);
- 如果物体材质具有Fresnel特性,那么比例由粗糙度控制;如果没有开启,那么统一使用相对明亮的tmp_col(届时恒等于采样后的diffuse颜色)作为GI_Diffuse
我们看到Kena使用了3重强度遮罩,构造了2张互有区别的漫反射基础色,最后通过遮罩开关和纹理记录的粗糙度来在颜色间做选择和取舍,具有很高的灵活性和控制力。
(四) 基于当前像素点周围16领域范围内深度变化的插值法线
做法是将当前正常分辨率下的像素点在平面上的uv坐标转换到半像素纹理上,并推算出田字四领域(上,右和右上)的新坐标 uv',以及它们到田字左下位置的距离作为影响因子(权重)。执行4次采样获取到4个方向上的半分辨率纹理返回值,这里的每一个返回值都是正常分辨率下4个像素的法线平均值以及深度平均值。这些数值首先经过权重的平衡,接着使用如下类似矩阵乘法的方式求取合成矢量d_norm:
half3 d_norm = g_norm_ld.xyz * depth4.xxx + g_norm_rd.xyz * depth4.yyy + g_norm_lu.xyz * depth4.zzz + g_norm_ru.xyz * depth4.www;
首先说明一下,式中的depth4.xyzw
取自4个返回值最后一维(深度),其次是这种类似矩阵运算的过程,我认为本质是求取屏幕空间法线的一种计算方式,可以这样理解: depth4.xyzw
代表了以当前像素点为中心周围4个方向的一种"梯度",通过连续与对应方向的采样法线相乘和累加,相当于是将梯度值大小视为权,对4周法线加权求和。这样最终结果 d_norm 应当兼具了法线走势,又具有较好连续性,且能够体现边缘处的变化。
//求半分辨率下的UV
half2 _suv = min(suv, cb0_6.xy); //正常分辨率下的 UV
half2 half_scr_pixels = floor(screen_param.xy * 0.5); //半分辨率下,屏幕的长宽对应像素个数
half2 one_over_half_pixels = 1.0 / half_scr_pixels; //半分辨率下,一个像素对应 UV 的跨度
//下式将全分辨率 UV 转换到了 半分辨率对应的新 UV' -> 新UV'的值朝原点靠拢
//特点1: 正常分辨率下的“偶”数像素 UV 值,经变换后减少了 0.5*(1/原始长宽像素个数)
//特点2: 正常分辨率下的“奇”数像素 UV 值,经变换后减少了 1.5*(1/原始长宽像素个数)
half2 half_cur_uv = floor(_suv * half_scr_pixels - 0.5) / half_scr_pixels + one_over_half_pixels * 0.5;
half2 uv_delta = _suv - half_cur_uv; //UV - UV' -> (0.5或1.5)*(1/原始长宽像素个数)
//半分辨率下,(UV - UV')占一个像素多少百分比(注:此时像素面积膨胀为原来4倍,长宽膨胀为原来2倍)
half2 delta_half_pixels = uv_delta * half_scr_pixels; //推算下来,占用了(0.25或0.75)个大像素点长度
//多次采样GlobalNormal
half4 tmp_uv = half_cur_uv.xyxy + half4(one_over_half_pixels.x, 0, 0, one_over_half_pixels.y);
half4 g_norm_ld = SAMPLE_TEXTURE2D(_GNorm, sampler_GNorm, tmp_uv.zy); //左下
half4 g_norm_rd = SAMPLE_TEXTURE2D(_GNorm, sampler_GNorm, tmp_uv.xy); //右下
half4 g_norm_lu = SAMPLE_TEXTURE2D(_GNorm, sampler_GNorm, tmp_uv.zw); //左上
half4 g_norm_ru = SAMPLE_TEXTURE2D(_GNorm, sampler_GNorm, tmp_uv.xw); //右上
//利用(全局法线 & 深度)的差异做扰动,求颜色 R13
//首先下面基于屏幕像素索引的“奇偶”性,组合出网格状屏幕空间纹理
//从功能上看,scr_pat.xyzw 分别对应田字中左下,右,上和右上4个方位上像素(深度)的衰减值(或者叫权重)
tmp2 = (half2(1, 1) - delta_half_pixels).yx;
half4 scr_pat = half4(tmp2.x * tmp2.y,
(delta_half_pixels * tmp2).xy,
delta_half_pixels.x * delta_half_pixels.y);
//组合4次采样的深度
half4 depth4 = half4(g_norm_ld.w, g_norm_rd.w, g_norm_lu.w, g_norm_ru.w); //注,这里的w通道存放单位为里面的距离
depth4 = 1.0 / (abs(depth4 - d) + 0.0001) * scr_pat; //[田的4个方位与中心点距离差的倒数 (差异越大数值越小)] * [4个方位的不同衰减幅度]
half g_depth_factor = 1.0 / dot(depth4, half4(1.0, 1.0, 1.0, 1.0)); //求和depth4的4个通道后取倒数 -> 作为求平均的乘子
//d_norm -> depth-based normal:基于深度和4领域插值的世界空间法向量
//d_norm -> 还没归一化,其模长正比于物体表面的平坦程度: 既越平坦,模长越大 -> 主要归因于上式中 1/abs(depth4 - d) 部分 -> 越平坦数值越大
half3 d_norm = g_norm_ld.xyz * depth4.xxx + g_norm_rd.xyz * depth4.yyy + g_norm_lu.xyz * depth4.zzz + g_norm_ru.xyz * depth4.www;
//1/0.0001667 = 6000 -> 推测是编码距离时使用的极大值,20000推测是缩放系数
//整体来说:当d>20000时scale恒为0; 当14000<d<20000时scale在[0,1]区间上线性分布; 当d<14000时scale横为1
half scale = saturate((20000 - d) * 0.00016666666); //Scale, 靠近摄像机->1,远离->0
d_norm = lerp(norm, d_norm * g_depth_factor, scale); //这张基于4邻域深度差扰动后的d_norm看起来与_GNorm很像(可能略微模糊了一点?)
d_norm具有纹理采样norm所不具备的连续性,同时对某些特殊材质(如屋顶)会参数格栅状纹理。
title | d_norm | norm |
---|---|---|
img |
举例d_norm在后续着色计算中的应用:
- 计算AO:
half RNoN = dot(RN, norm); //基于深度的法线 RN 与纹理法线 norm 之间的相似度
half AO_from_RN = lerp(RNoN, 1, RN_Len); //推测为AO -> 完全平坦时总是1,崎岖陡峭处返回RNoN -> 此时这个值也会很小
对AO定义的补充说明:考虑到RN计算自于屏幕空间,我们不能简单将RN与norm的关系类比于BRDF中的宏观表面法线与微表面法线之间的关系。准确的说当RN与norm出现较大差异时,更可能是由于RN做代表的区域具有复杂的几何结构,使合成法线的朝向(RN)不再指向几何结构中任意的一个位面朝向(norm)。我们知道越复杂的局部结构,越可能出现对入射光线的完全或部分自遮挡,形成自阴影或遮蔽。
- 计算Smoothness
half RN_raw_Len = sqrt(dot(d_norm, d_norm));
smoothness = RN_raw_Len;
对Smoothness定义的补充说明:通过连续采样半分辨率纹理重建的d_norm可以理解为是该区域多个norm的合成值,如果参与合成的norms方差较大会导致合力分散,反应到d_norm上就是模长较小,同样的道理反之亦然。举个例子,当一块区域内的norm朝向四面八方,它们的合理将会非常小,光滑度也就很低;当一块区域所有的norms都朝向一个方向,合力自然非常大,光滑度也就越高。
(五) 快速高效获取世界空间下视方向单位矢量(viewDirWS)
核心思想是先在NDC空间下构造“视向量”,然后使用变换矩阵Matrix_VP的逆矩阵Matrix_Inv_VP将这个矢量变换到世界空间下再归一化即可:
half2 coord = (IN.vertex.xy * screen_param.zw - 0.5) * IN.vertex.w * 2.0; //coord是区分范围在[-1, +1]的处于NDC空间中当前屏幕像素所在"点"(不包含z)
//与矩阵M_Inc_VP的前3X3部分相乘
half3 camToPixelDirRaw2 = V_CB1_48.xyz * coord.xxx;
camToPixelDirRaw2 = V_CB1_49.xyz * coord.yyy + camToPixelDirRaw2;
camToPixelDirRaw2 = V_CB1_50.xyz * half3(1, 1, 1) + camToPixelDirRaw2;
//归一化并取反得到视方向
half3 camToPxlDir2 = normalize(camToPixelDirRaw2);
half3 viewDir2 = -camToPxlDir2;
好处是在某些不需要像素点世界空间坐标的情况下,只通过简单的计算获得viewDirWS。
(六) 一些DXBC中看起来很奇怪的计算,其实是运行在shader中的快速三角函数拟合函数
收集如下:
- acos
static float acos(float a) {
float a2 = a * a; // a squared
float a3 = a * a2; // a cubed
if (a >= 0) {
return (float)sqrt(1.0 - a) * (1.5707288 - 0.2121144 * a + 0.0742610 * a2 - 0.0187293 * a3);
}
return 3.14159265358979323846
- (float)sqrt(1.0 + a) * (1.5707288 + 0.2121144 * a + 0.0742610 * a2 + 0.0187293 * a3);
}
- asin
static float asin(float a) {
float a2 = a * a; // a squared
float a3 = a * a2; // a cubed
if (a >= 0) {
return 1.5707963267948966
- (float)sqrt(1.0 - a) * (1.5707288 - 0.2121144 * a + 0.0742610 * a2 - 0.0187293 * a3);
}
return -1.5707963267948966 + (float)sqrt(1.0 + a) * (1.5707288 + 0.2121144 * a + 0.0742610 * a2 + 0.0187293 * a3);
}
DXBC中理解相关逻辑的心得:
- 留意特别的数字,特别是那些不像艺术家会调配出来的值
- 留意多项式求和计算,特别是阶数递增的多项式,很可能是拟合公式
- 联系上下文,比如可能的入参具有怎样的性质等...
(七) 利用表面法线某2个维度,计算具有方向感的全局光照
计算中主要利用了bias_N的x和y两个维度进行运算,参与对常量颜色缩放。
bias_N是重构的d_norm与norm直接的某个插值,这里近似认为就是normal即可。
half4 biasN = half4(bias_N.xyz, 1.0); //测试调整为bias_N.xzy 与 bias_N.xyz对比
half3 bias_biasN = mul(M_CB1_181, biasN);
half4 mixN = biasN.yzzx * biasN.xyzz;
half3 bias_mixN = mul(M_CB1_184, mixN); //值域小于0,查看时使用 -bias_mixN
//base_disturb * scale + bias
half3 virtual_light = V_CB1_187 * (biasN.x * biasN.x - biasN.y * biasN.y) + (bias_biasN + bias_mixN);
virtual_light = V_CB1_180 * max(virtual_light, half3(0, 0, 0)); //经过V_CB1_180缩放后,返回值可能会大于1.0
参考下图,我调整了上式第一行的bias_N.xyz输出顺序,会产生完全不同方向光感的效果
title | bias_N.xyz | bias_N.xzy |
---|---|---|
img |
(八) 采样IBL时用到的两种方向矢量
其一是常用的View_Reflection,既视方向的反射方向,其计算公式简单参考如下:
half3 VR = (NoV + NoV) * norm + cameraToPixelDir; //View_Reflection -> VR:视线反射方向
其二是一种相较于View_Reflection更加趋向与自身Normal的向量,我管它角 View_Reflection_Lift,或“上抬视反”
half roughSquare = rifr.w* rifr.w; //rifr.w 存放的是roughness
half rate = (roughSquare + sqrt(1 - roughSquare)) * (1 - roughSquare);
half3 VR_lift = lerp(norm, VR, rate);
其中计算插值比例rate的部分最为重要,可以看出它是关于粗糙度的4次函数,在[-1,1]区间中的函数曲线可以参考下图:
图中很坐标代表“粗糙度”,纵坐标是返回值rate,看起来很像开口朝向的二次曲线,过y轴正1,同时与x轴正负1相交。
简言之,这个View_Reflection_Lift 或“上抬视反”(注:没有归一化) ,具体粗糙度越高,反射视线越接近法线朝向;粗糙度很低(光滑)时,反射方向与镜面反射一致的特性。
(九) 对IBL采样结果进行遮罩处理
该过程分为2部分,首先是从rifr纹理的第二个通道中采样 spec_intensity 作为不同材质的高光强度基底,然后依据材质的粗糙组(这部分存放在了rifr的第一个通道中)进行插值,所得结果可参考下面截图,这是用于控制全局环境光高光的主要遮罩。
half spec_base_intensity = rifr.y * 0.08; //rifr.y代表了一种作用在IBL采样结果上的高光强度
half factor_RoughOrZero = matCondi.x ? 0 : rifr.x; //#9号通道时为0, 其余情况使用贴图输出的rifr.x值(粗糙度:rough1)
//从‘spec_base_intensity’到 diffuse纹理所记载的漫反射颜色进行插值
//另一方面roughness越大,spec_power_mask越大,反之接近于0
half4 spec_power_mask = half4( lerp(spec_base_intensity.xxx, df.xyz, factor_RoughOrZero).xyz, 0 );
接下来就是如何运用spec_power_mask来对IBL输出值进行强度遮罩了,参考如下代码段:
//完成第一组环境光高光
half2 lut_uv_1 = half2(NoV_sat, rifr.w);//这是第一组lut_uv,rifr.w->对应粗糙度rough2
half2 lut_raw_1 = SAMPLE_TEXTURE2D(_LUT, sampler_LUT, lut_uv_1);
half shifted_lut_bias = saturate(spec_power_mask.y * 50.0) * lut_raw_1.y * (1.0 - frxx_condi.x);
//第一组 GI_Spec 中的预积分 brdf输出值
half3 gi_spec_brdf_1 = spec_power_mask.xyz * lut_raw_1.x + shifted_lut_bias; //将这里的spec_power_mask.xyz 替换为 常量 half(1,1,1) 可以看到明显对比
half3 gi_spec_1 = prefilter_Specular * gi_spec_brdf_1; //这是利用预积分技术重构出的 GI_Spec
Kena是用spec_power_mask代替了prefilter_Specular先于Lut采样结果作用,而后所得的结果再与 prefilter_Specular相乘。可见在实时渲染中时常将乘积的积分替换为积分的乘积。
title | gi_spec not apply mask | gi_spec apply mask |
---|---|---|
img |
title | spec_power_mask |
---|---|
img |
(十) 输出与输入diffuse对比
title | input Diffuse | output Diffuse |
---|---|---|
img |
后续工作
- 推进 GI_Specual shader的阅读和理解
- 参考Kena原生shader的计算方案,在Unity的前向+后处理pass中分批重构部分材质的渲染逻辑