|
在本章第一节中,我们提到Unreal的ShadingMode机制是其PBR框架非常重要的部分,这篇我们就来重新R&D一下其各个ShadingModel是怎样设计的。
一,ShadingModel的工作机制与封装思路
1.1,工作机制
截止到427.UE4在PC端已经集成了13种不同的ShadingModel:
UE427内置ShadingModel
给4.25以后版本加过ShadingModel的朋友都知道在我们改完C++部分后,Shader第一件事就是在ShadingCommon.ush定义一个表明掩码ID的宏,然后在Sha'dingModelMaterials里将这个宏与HLSLMaterialTranslator.cpp中定义的宏相关联,同时在对应静态分支将CustomData指认给GBuffer.CustomData的通道。我们知道UE4的PC端Deferred是7个RT的方案:
UE4 PC端GBuffer的7RT方案
GBufferB的A通道存储着ShadingModelID号,对应CustomData存在GBufferD中,通过ID号来区分不同着色模型所需要的额外数据,4个通道意味着最多自定义4个PIN接口,如果需要更多可以“借”,对于一个ShaidngModel来说一般不会全部占满20个PIN接口。通过这样的机制,引擎能够解决经典延迟渲染材质表现的限制,并且Shader中都是通过宏区分的静态分支,可以按照一定规范进行代码的集成与封装。
所以ShadingModelMaterials.ush这个文件就是按照这样一个统一写法将GBufferData与FMaterialPixelParameters中的数据依据ID的不同写入GBuffer结构,进而通过DeferredShadingCommon中定义的EncodeGBuffer功能在BasePass阶段将GBuffer数据MRT至渲染目标,然后在LightingPass阶段DecodeGbuffer重建场景数据通过DeferredLightingCommon中封装好的DynamicLighting完成直接光计算。对于环境光则区分StaticMesh与DynamicMesh采用Lightmap或VLM(老版本使用两种ILC采样环境漫射光)的不同策略,然后叠加上IBL算出的环境反射,这样层层叠加完写入缓冲区进行输出。
这就是UE4所提出的ShadingModel(着色模型)的工作机制。大家可以按照本章第一节:《Disney原则的PBR在UE4中的应用》中给出的“UE427核心内建着色器源码层级表”梳理上述过程,可以比较清晰的得知什么阶段有哪些文件里定义的什么功能起到了怎样的作用。
另外上面只是简述PC端Deferred的工作流程,对于其它管线思路依旧,不过需要注意的是在PC端的延迟架构中Branch了一条Forward+的管线,这是早些时候给VR用的,现在看样子已经停止维护了。所以需要看的代码又少一部分~。
当然这个过程涉及到复杂的数据流组织,其中FGBufferData这个结构体肯定存的是写入读出GBuffer的那些数据,这是一个很关键的数据存储结构。另外2两个Pixel阶段比较重要的数据存储结构,一是FPixelMaterialInput,它临时存入了我们通过蓝图的PIN接口传入内建着色器框架的数据,像BaseColor,Metalc这些;另一个就是刚刚提到的FMaterialPixelParameters,这里面存储存了两部分数据,其中一部分来自FPixelMaterialInput,另一部分来自Vertex阶段传过来的数据,比如WorldNormal,VertexColor,这样一来一些必须在Pixel阶段完成的计算也能拿到顶点数据,所以FMaterialPixelPrameters提供了PBR计算所有的初始数据。同样的,在Vertex阶段也有一些非常重要的结构体,比如FVertexFactoryInput【从DCC fetch的数据通过这个结构体传入Vertex阶段】,比如类似Pixel阶段的FMaterialVertexParameters【顶点运算数据的存储结构】,比如FVertexFactoryImediate【存储vertexFactory的数据进行过渡】,比如FVerextFactoryIntepolateVSToPS,FBasePassVSToPS【存储最终用于写入Varying传到pixel阶段的数据】等等。通过查看这些关键结构体的成员我们能够比较清晰的了解整条渲染流水线中的数据是如何流动的,当然每一环节都有配套的函数将数据从一个结构体传入下一个环节的数据存储结构。
Vertex阶段因为UE4增加了一套“顶点工厂”的策略,因此数据流动会更复杂一些。程序设计复杂度变高的同时好处就是能够支持非常多类型的Mesh结构与非常丰富的顶点运算表现需要。
1.2,封装思路
说完了工作机制,接下来从代码的角度看下封装思路,在ShadingModel.ush中可以找到
因为高光样式对PBR的质感表现影响非常大,所以对于不同ShadingModel来说主要的差别还是直接光照部分的高光计算,也就是BXDF【虽然部分ShadingModel的环境反射方案也存在差别,比如ClearCoat,但直接高光几乎是不同Model所特有的】,因此可以看到引擎也是针对不同Model进行了不同类型BXDF的封装,这些BXDF共享一套基础的BxDFContent,这是一个结构体,存储了计算所需要的几何光学矢量与相关中间值。然后在UE4中更为复杂的模型被认为是基于基础的BRDF加上SSS模拟的结果,这里的基础BRDF在PC端使用完整的,在移动端会进行简化然后使用Filament Engine中拟合的GGX分布替换原有的NDF,因此在同样的材质参数下,ES3.1默认DeafaultLit效果会比SM5更“油腻”一些。
研究Unreal的ShadingModel主要工作就是研究不同的BXDF是怎么R&D出来的,下面我们逐个分析。
二,ShadingModel的R&D
2.1,ClearCoat
ClearCoat中文译作“透明涂层材料”,故名思意这类模型模拟了那些表面覆有一层薄薄透明涂层的材料,最典型的就是车漆【实际上这个模型绝大多数情况下也被用来模拟车漆材质】,所以我们就以车漆为原型看下这套模型是怎么R&D出来的,这是一个非常有意思的过程。
2.1.1,物理模型
真实的车漆分层如下:
真实车漆分层
从下至上分别为:
- 电泳层:保护金属板,为中涂层提供良好的附着环境
- 中涂层:保护电泳层,加强抗腐蚀性,为色漆层提供附着环境
- 色漆层:主要的颜色表现层
- 清漆层:保护色漆层,增强抗紫外线,提高抗刮擦,并增加车漆质感【主要是高光表现】
真实的车漆材料一般分成如下几类
- 素色漆:比较廉价,主要为合成材质包括树脂、颜料和添加剂。最常见的颜色有白色、大红色和黄色。一般便宜车都是采用这类漆,色彩很纯,没有层次感
- 金属漆:在色漆中加有细微金属粒子(如铝粉)的一种涂料,增加车漆硬度,光线射到金属颗粒上后,又被透过漆膜反射出来,亮晶晶的比较美观。
- 珠光漆:加入了云母粒,其反光的方向性导致了色彩斑斓的视觉效果。抗氧化能力强
对比如下:
三种常见类型车漆的质感对比
2.1.2,渲染建模
从真实的车漆材料可以提炼出一些关键特性进行渲染建模。
首先车漆是分层的,基础的颜色如果按照PBR的逻辑来讲,BaseColor相当于色涂层,这样素色漆与金属漆完全可以通过调控BaseColor与Metalic,Roughness去模拟,如果是素色漆因为主要原料是树脂,所以BaseColor为非金属反射色并且带有较低的金属度,反之要模拟金属颗粒感只需要在纯色基础上再叠一层噪声,同样处理Metallic与Roughness即可。对于珠光漆来说就要着重表现各向异性。既然渲染是为了好看,肯定金属,各项异性都得要。
其次最上层的清漆层也对材质的光照表现提供了很大的贡献,灯光下车身表面的高光线条感就是清漆层的缘故,同时这一层也对高光的分布,形状,样式产生了很大的影响【同样需要考虑NDF】,并且受视角的影响【同样需要考虑菲尼尔】。同时它是接近透明的,总会有一部分光线穿过从色漆层反射出【需要考虑层间的透射,折射与吸收效应】
再者,我们知道工作室灯光与室外两种照明环境下同一辆车的质感表现可能存在很大的差异。也就是说要同时考虑直接光与环境光。
按照上面的分析,我们做出来的模型应该是这样的:
因此,可以得出结论:
- 既然渲染就是为了看,那只需要两层即可(只考虑色涂层,与清漆层)
- 色涂层与清漆层材质可能完全不同,且都要考虑直接照明与环境照明,即至少要算双层高光,双层IBL
- 清漆层是透明的,因此层间透射,吸收,折射与菲涅尔必须考虑
现在我们得到了光照模型的核心特性,车漆分类如上所述可以完全通过调控BaseColor,Metallic,Roughness等区分开来,因此这是设计师该考虑的事情不应该算入光照模型的范畴。无论是Unreal,Substance,Filament等等,大家可以观察几乎所有的类似模型都包含上述特性。
2.1.3,Unreal中的R&D
本着上述结论,我们来看下UE4的代码实现。4.25以后,引擎提供了两套ClearCoat,移动端是在PC的基础上省略掉一些特性进行性能优化的简化版,直接看PC版本的。这个模型随着引擎更新逐渐变的越来越物理,从4.26【还是27来着】支持了光线追踪,所以大家直接看最新版就好了。
先来看最重要的直接光高光,上层高光的计算如下:
直接光的上层高光
BRDF的Fresnel项使用Shclik模型,NDF项在GGX分布的基础上乘上了一个能量贡献系数,粗糙度使用清漆层独有的(与底层区分开独立控制,如果要减少一个CustomData Channel的存储消耗可以写死0.12,这是一个比较通用的透明涂层粗糙度数值),至于遮挡项原型出自《Heitz 2014, "Understanding the Masking-Shadowing Function in Microfacet-Based BRDFs"》[1],但是用了一个近似版。这个近似版只有在粗糙粗与摄像机视线比较极端时才会出现明显的偏差。Top层的BRDF系数乘上NoL后会再乘ClearCoat,这是该模型另外一份CustomData Channel,本质是一张灰度mask用来控制清漆层高光贡献的强度。
然后看Bottom层的高光:
Bottom层的材质要复复杂一些,考虑了各项异性GGX分布与“双法线”,还要考虑涂层对光线的折射效应
ClearCoat直接光照Bottom层高光
NDF项根据bHasAnisotropy的值来决定是计算各项同性的GGX还是各项异性GGX分布,遮挡项同样使用约化Smiith函数,然后乘上NoL能量贡献系数。但是因为涂层的缘故,涂层折射&菲涅尔&层间透射(吸收率)都要考虑进去。RefractClearCoatContext会把BXDFContent内容【也就是NoV,NoL,VoH,VoL这些中间光照量】Clamp到一个新的范围以考虑光线折射的影响,这步计算是依据测量与经验值拟合出来的,并不完全物理但绝对够用【满足肉眼辨查需要】;然后CalcThinTransmission会计算层间透射,透射率取决了涂层厚度;接着Fresnel的计算被区分开来,因为考虑折射后上层与底层的VoH是不同的,因此上层原始的Fresnel会与考虑了透射吸收与折射的底层BottomFresnel依据ClearCoat进行线性插值【FresnelCoeff = 1 - Fresnel,作为一个比例系数乘在底层Fresnel上】。最后两层高光相加
接着再来看下Diffuse项,也就是上图中圈绿色的部分,单独拿出来:
ClearCoat直接光的Diffuse部分
直接光漫射同样也要算两层,然后依旧ClearCoat做插值。上层就是经典“兰伯特照明”,底层是在兰伯特基础上乘上吸收与菲涅尔的影响。因为该模型所针对的材料几乎不会展现出SSS的影响因此Lighting的Transmission项被忽略。
最后就是次重要的环境反射项,因为这类材料粗糙度一般比较低,所以环境照明对质感的影响也会非常大。所以应该去ReflectionEnvironmentPixelShader.usf中找,文件里就定义了5个函数,显然逻辑在RefectionEnvironment中:
ClearCoat的IBL采样
同样IBL也要算两层,Top层与DeafualtLit模型的IBL计算一致,找到GatherRadianceG函数,这里面调用了CompositeReflectionCapturesAndSkylight函数实现球形探针反射,盒型探针反射(如果有),天光组件反射的采样与混合
共用的环境反射混合功能封装
这个“组装”函数可以在ReflectionEnvironmentComposite中找到,会发现它调用了ReflectionEnvironmentShared中定义的基础功能,同样的大家可以对照着本章第一节:《Disney原则的PBR在UE4中的应用》中给出的“UE427核心内建着色器源码层级表”梳理上述过程。
Bottom层的IBL同样因为涂层的缘故要额外考虑,它采用了一种类似BRDF半球积分的预计算LUT方式采样环境过滤图然后乘上层衰减,最后两层环境反射叠加。
注意在BasePassPixelShader.usf中也有调用一个GetImageBasedReflectionLighting函数计算IBL的代码,但这个是给PC端的Forward+管线用的,可以在ForwardLightingCommon.ush中找到封装实现。
2.2,Subsurface Profile
Unreal基于屏幕空间的Separable SSS,技术原型出Jorge Jimenez的《Separable Subsurface Scattering》【2】,核心思想是6个【实际用了5个】高斯分布拟合3个Dipole曲线,因为高斯分布的可分离性与对称性能再加上StencilTest能够比较好的优化性能表现,这是Unreal 3套次表面模型中画面质量最好的一个,当然也是最耗的一个,UE4所有的Digitial Human全部用的这套ShadingModel。
本专栏在第五章:Digitial Huamn解决方案中的5.1,5.2节分别给出了理论原型与引擎实现的分析,可参考:
这里不再赘述。
2.3,PreIntegrated Skin
预计算的皮肤渲染,一种基于视觉观察结论总结出的渲染方法
这套ShadingModel模拟的关键点有3个:
该方案没什么理论上的推导,完全就是视觉感官提炼出的算法:次表面散射主要发生在曲率位置较大的地方,类似耳廓,鼻尖等,皮肤表面的细节凹凸也属于微观曲率变化较大的地方所以也会有次表面现象,于是就有了下面这张预计算的图:
该方案同样在本专栏第五章,5.1,5.2节有详细说明,故此不再赘述。
2.4,Subsurface
这是比较早的一套SSS,定位到SubsurfaceBxDF
SSS就是在DefaultLit基础上加入了Transmission项,此时的DirectionLight = Diffuse + Specular + Transmission,重点看下Transmission是怎么拟合出来的。这个模型有两个CustomData Channel,一个是颜色,一个是Opacity显然物料的Opaciy越小(越晶莹剔透)散射作用就越强。散射的计算分为两部分,一是内散射光,强行power12;一是背面散射光,也是拟合出来的。然后按照内散射比例做lerp再乘上散射色作为Transmission项。
这套模型拟合的非常简单粗暴,所以我们可以抛弃掉自己重新写一套SSS model出来,再顺便支持下光线追踪,也是挺有意思的一个课题。
2.5,TwoSideFoliage
用于树叶或者植被的双面叶子模型
植被叶子也是有SSS的,所以也是DefaultLit + Transmission,与SUBSURFACE MODEL的区别在于Transmission项的拟合上:
概括起来Transmission项的计算是一个“包裹兰伯特光” 【提高光照包裹,营造比较亮的照明氛围】* “拟合的GGX分布”【low版微表面模型】 * “次表面颜色” 【前面有亮度,这里再乘上颜色】 * “衰减系数”。这个拟合思路也是“稍有用心”,WarpNoL的结果是仅考虑直接照明的环境下相较于兰伯特模型几何体会有更大范围的照明面积,所以比较亮【可惜提出这个经验模型的作者的Blog链接挂了】,Scatter的GGX分布写死了粗糙度为0.6,同时用VoL取代原有的NoH【感觉只是为了降低运算量】。
其实这个模型并不是PBR的,从效果上讲可以添加亿点点细节进去让它更真实,但是关于怎么做更真实的植被照明文章比较少,另外也可以自己封装一个ToonFoliage用来提供卡通渲染的树叶光照,这两方面都是非常有R&D价值的方向。
2.6,Hair
Hair Model是Brian Karis在SIIGRAPH 2016[3]中分享的UE4基于Marschner ,d'Eon, Pixar的工作拟合出的用于实时渲染领域的光照模型,在4.27中移动端也可以看到该模型的代码引用,但似乎并没有做什么优化。
在本专栏的第五章第三,四节分两篇文章就光照模型的物理数学建模过程与工程上的拟合代码实现进行了非常详细的分析:
这里只针对HairBsdf的实现给出一份注释版,大家也可以对着Talk的PPT看代码
///////////////////////////////////////////////////////////////////////////////////////////////////
// Weta数码的d'Eon提出的M项对于实时计算过于昂贵,所以对于R路径使用;
// 至于TT,TRT路径,M使用高斯分布替代(如果头发考虑为绝对光滑则可以使用狄拉克分布)
float Hair_g(float B, float Theta)
{
return exp(-0.5 * Pow2(Theta) / (B * B)) / (sqrt(2 * PI) * B);
}
// Shlick的菲涅尔函数
float Hair_F(float CosTheta)
{
// 人类发丝测量折射率写死1.55
const float n = 1.55;
const float F0 = Pow2((1 - n) / (1 + n));
return F0 + (1 - F0) * Pow5(1 - CosTheta);
}
// Kajiya推导的解析解
float3 KajiyaKayDiffuseAttenuation(FGBufferData GBuffer, float3 L, float3 V, half3 N, float Shadow)
{
// Use soft Kajiya Kay diffuse attenuation
float KajiyaDiffuse = 1 - abs(dot(N, L));
float3 FakeNormal = normalize(V - N * dot(V, N));
//N = normalize( DiffuseN + FakeNormal * 2 );
N = FakeNormal;
// Hack approximation for multiple scattering.
float Wrap = 1;
float NoL = saturate((dot(N, L) + Wrap) / Square(1 + Wrap));
float DiffuseScatter = (1 / PI) * lerp(NoL, KajiyaDiffuse, 0.33) * GBuffer.Metallic;
float Luma = Luminance(GBuffer.BaseColor);
float3 ScatterTint = pow(GBuffer.BaseColor / Luma, 1 - Shadow);
return sqrt(GBuffer.BaseColor) * DiffuseScatter * ScatterTint;
}
float3 EvaluateHairMultipleScattering(
const FHairTransmittanceData TransmittanceData,
const float Roughness,
const float3 Fs)
{
return TransmittanceData.GlobalScattering * (Fs + TransmittanceData.LocalScattering) * TransmittanceData.OpaqueVisibility;
}
///////////////////////////////////////////////////////////////////////////////////////////////////
// Hair BSDF Reference code
#define HAIR_REFERENCE 0
#if HAIR_REFERENCE
struct FHairTemp
{
float SinThetaL;
float SinThetaV;
float CosThetaD;
float CosThetaT;
float CosPhi;
float CosHalfPhi;
float VoL;
float n_prime;
};
// 第一类修正贝塞尔函数
float I0(float x)
{
x = abs(x);
float a;
if (x < 3.75)
{
float t = x / 3.75;
float t2 = t * t;
a = +0.0045813;
a = a * t2 + 0.0360768;
a = a * t2 + 0.2659732;
a = a * t2 + 1.2067492;
a = a * t2 + 3.0899424;
a = a * t2 + 3.5156229;
a = a * t2 + 1.0;
}
else
{
float t = 3.75 / x;
a = +0.00392377;
a = a * t - 0.01647633;
a = a * t + 0.02635537;
a = a * t - 0.02057706;
a = a * t + 0.00916281;
a = a * t - 0.00157565;
a = a * t + 0.00225319;
a = a * t + 0.01328592;
a = a * t + 0.39894228;
a *= exp(x) * rsqrt(x);
}
return a;
}
// d&#39;Eon的纵向散射函数:M_p(θ_i,θ_r), v = B*B(即原论文中的v = β^2)
float LongitudinalScattering(float B, float SinThetaL, float SinThetaV)
{
float v = B * B;
float CosThetaL2 = 1 - SinThetaL * SinThetaL;
float CosThetaV2 = 1 - SinThetaV * SinThetaV;
float Mp = 0;
if (v < 0.1)
{
float a = sqrt(CosThetaL2 * CosThetaV2) / v;
float b = -SinThetaL * SinThetaV / v;
float logI0a = a > 12 ? a + 0.5 * (-log(2 * PI) + log(1 / a) + 0.125 / a) : log(I0(a));
Mp = exp(logI0a + b - rcp(v) + 0.6931 + log(0.5 / v));
}
else
{
Mp = rcp(exp(2 / v) * v - v) * exp((1 - SinThetaL * SinThetaV) / v) * I0(sqrt(CosThetaL2 * CosThetaV2) / v);
}
return Mp;
}
// 用于拟合版R,TT,TRT路径中M使用的高斯分布
float GaussianDetector(float Bp, float Phi)
{
float Dp = 0;
for (int k = -4; k <= 4; k++)
{
// TODO use symmetry and detect for both Phi and -Phi
Dp += Hair_g(Bp, Phi - (2 * PI) * k);
}
return Dp;
}
// d&#39;Eon的吸收项定义为A(η,cosφ/2)
float3 Attenuation(uint p, float h, float3 Color, FHairTemp HairTemp)
{
float3 A;
if (p == 0)
{
//A = F( cos( 0.5 * acos( HairTemp.VoL ) ) );
A = Hair_F(sqrt(0.5 + 0.5 * HairTemp.VoL));
}
else
{
// ua is absorption
// ua = pe*Sigma_ae + pp*Sigma_ap
float3 Sigma_ae = { 0.419, 0.697, 1.37 };
float3 Sigma_ap = { 0.187, 0.4, 1.05 };
//float3 ua = 0.25 * Sigma_ae + 0.25 * Sigma_ap;
float3 ua = -0.25 * log(Color);
float3 ua_prime = ua / HairTemp.CosThetaT;
//float3 ua_prime = ua / sqrt( 1 - Pow2( HairTemp.CosThetaD ) / 2.4 );
float yi = asin(h);
float yt = asin(h / HairTemp.n_prime);
float f = Hair_F(HairTemp.CosThetaD * sqrt(1 - h * h)); // (14)
//float3 T = exp( -2 * ua_prime * ( 1 + cos(2*yt) ) );
float3 T = exp(-2 * ua_prime * cos(yt));
if (p == 1)
A = Pow2(1 - f) * T; // (13)
else
A = Pow2(1 - f) * f * T * T; // (13)
}
return A;
}
float Omega(uint p, float h, FHairTemp HairTemp)
{
float yi = asin(h);
float yt = asin(h / HairTemp.n_prime);
return 2 * p * yt - 2 * yi + p * PI;
}
// 方位散射函数
float3 AzimuthalScattering(uint p, float Bp, float3 Color, FHairTemp HairTemp, uint2 Random)
{
float Phi = acos(HairTemp.CosPhi);
// Np = 0.5 * Integral_-1^1( A(p,h) * Dp( Phi - Omega(p,h) ) * dh )
float Offset = float(Random.x & 0xffff) / (1 << 16);
uint Num = 16;
float3 Np = 0;
for (uint i = 0; i < Num; i++)
{
float h = ((float)(i + Offset) / Num) * 2 - 1;
Np += Attenuation(p, h, Color, HairTemp) * GaussianDetector(Bp, Phi - Omega(p, h, HairTemp));
}
Np *= 2.0 / Num;
return 0.5 * Np;
}
// [d&#39;Eon et al. 2011, &#34;An Energy-Conserving Hair Reflectance Model&#34;]
// [d&#39;Eon et al. 2014, &#34;A Fiber Scattering Model with Non-Separable Lobes&#34;]
float3 HairShadingRef(FGBufferData GBuffer, float3 L, float3 V, half3 N, uint2 Random, uint HairComponents)
{
// to prevent NaN with decals
// OR-18489 HERO: IGGY: RMB on E ability causes blinding hair effect
// OR-17578 HERO: HAMMER: E causes blinding light on heroes with hair
float ClampedRoughness = clamp(GBuffer.Roughness, 1 / 255.0f, 1.0f);
float n = 1.55;
FHairTemp HairTemp;
// N is the vector parallel to hair pointing toward root
HairTemp.VoL = dot(V, L);
HairTemp.SinThetaL = dot(N, L);
HairTemp.SinThetaV = dot(N, V);
// SinThetaT = 1/n * SinThetaL
HairTemp.CosThetaT = sqrt(1 - Pow2((1 / n) * HairTemp.SinThetaL));
HairTemp.CosThetaD = cos(0.5 * abs(asin(HairTemp.SinThetaV) - asin(HairTemp.SinThetaL)));
float3 Lp = L - HairTemp.SinThetaL * N;
float3 Vp = V - HairTemp.SinThetaV * N;
HairTemp.CosPhi = dot(Lp, Vp) * rsqrt(dot(Lp, Lp) * dot(Vp, Vp));
HairTemp.CosHalfPhi = sqrt(0.5 + 0.5 * HairTemp.CosPhi);
HairTemp.n_prime = sqrt(n * n - 1 + Pow2(HairTemp.CosThetaD)) / HairTemp.CosThetaD;
float Shift = 0.035;
float Alpha[] =
{
-Shift * 2,
Shift,
Shift * 4,
};
float B[] =
{
Pow2(ClampedRoughness),
Pow2(ClampedRoughness) / 2,
Pow2(ClampedRoughness) * 2,
};
float3 S = 0;
UNROLL for (uint p = 0; p < 3; p++)
{
if (p == 0 && (HairComponents & HAIR_COMPONENT_R) == 0) continue;
if (p == 1 && (HairComponents & HAIR_COMPONENT_TT) == 0) continue;
if (p == 2 && (HairComponents & HAIR_COMPONENT_TRT) == 0) continue;
float SinThetaV = HairTemp.SinThetaV;
float Bp = B[p];
if (p == 0)
{
Bp *= sqrt(2.0) * HairTemp.CosHalfPhi;
float sa, ca;
sincos(Alpha[p], sa, ca);
SinThetaV -= 2 * sa * (HairTemp.CosHalfPhi * ca * sqrt(1 - SinThetaV * SinThetaV) + sa * SinThetaV);
}
else
{
SinThetaV = sin(asin(SinThetaV) - Alpha[p]);
}
float Mp = LongitudinalScattering(Bp, HairTemp.SinThetaL, SinThetaV);
float3 Np = AzimuthalScattering(p, B[p], GBuffer.BaseColor, HairTemp, Random);
float3 Sp = Mp * Np;
S += Sp;
}
return S;
}
#endif
///////////////////////////////////////////////////////////////////////////////////////////////////
// 毛发BSDF
// 依据以下论文中的方案进行曲线拟合
// [Marschner et al. 2003, &#34;Light Scattering from Human Hair Fibers&#34;]
// [Pekelis et al. 2015, &#34;A Data-Driven Light Scattering Model for Hair&#34;]
float3 HairShading( FGBufferData GBuffer, float3 L, float3 V, half3 N, float Shadow, FHairTransmittanceData HairTransmittance, float InBacklit, float Area, uint2 Random )
{
// to prevent NaN with decals
// OR-18489 HERO: IGGY: RMB on E ability causes blinding hair effect
// OR-17578 HERO: HAMMER: E causes blinding light on heroes with hair
float ClampedRoughness = clamp(GBuffer.Roughness, 1/255.0f, 1.0f);
//const float3 DiffuseN = OctahedronToUnitVector( GBuffer.CustomData.xy * 2 - 1 );
const float Backlit = min(InBacklit, HairTransmittance.bUseBacklit ? GBuffer.CustomData.z : 1);
// 这里的策略是如果HAIR_REFERENCE宏启用就按照d&#39;Eon的方案去做,否则就使用Karis拟合的那套方案
#if HAIR_REFERENCE
// todo: ClampedRoughness is missing for this code path
float3 S = HairShadingRef( GBuffer, L, V, N, Random );
//float3 S = HairShadingMarschner( GBuffer, L, V, N );
#else
const float VoL = dot(V,L);
const float SinThetaL = clamp(dot(N,L), -1.f, 1.f);
const float SinThetaV = clamp(dot(N,V), -1.f, 1.f);
float CosThetaD = cos( 0.5 * abs( asinFast( SinThetaV ) - asinFast( SinThetaL ) ) );
//CosThetaD = abs( CosThetaD ) < 0.01 ? 0.01 : CosThetaD;
const float3 Lp = L - SinThetaL * N;
const float3 Vp = V - SinThetaV * N;
const float CosPhi = dot(Lp,Vp) * rsqrt( dot(Lp,Lp) * dot(Vp,Vp) + 1e-4 );
const float CosHalfPhi = sqrt( saturate( 0.5 + 0.5 * CosPhi ) );
//const float Phi = acosFast( CosPhi );
/**
* η&#39;的拟合
* 原型:η&#39; = sqrt( η * η - 1 + CosThetaD^2) / CosThetaD;
* float n_prime = sqrt( n*n - 1 + Pow2( CosThetaD ) ) / CosThetaD;
* 拟合思路:η即人类发丝折射率写死为1.55, 拟合后的η&#39;如下:
* η&#39; = 1.19 / CosThetaD + 0.36 * CosThetaD;
*/
float n = 1.55;
float n_prime = 1.19 / CosThetaD + 0.36 * CosThetaD;
float Shift = 0.035;
float Alpha[] =
{
-Shift * 2,
Shift,
Shift * 4,
};
float B[] =
{
Area + Pow2(ClampedRoughness),
Area + Pow2(ClampedRoughness) / 2,
Area + Pow2(ClampedRoughness) * 2,
};
/**
* R路径
* M:使用高斯分布近似,d&#39;Eon的分布项实时计算运算量太大了:M_p(θ_i, θ_r) ≈ 1 / β * sqrt(2Π) * e^{- (sinθ_i + sinθ_r - α_p)^2 / (2 * β^2)}
* N:N_R(θ_i, θ_r, φ) = (0.25 * cosφ/2) * A(0,h)
* = (0.25 * cosφ/2) * F(η,sqrt(1/2 + 1/2(w_i . w_r)))
*/
float3 S = 0;
if (HairTransmittance.ScatteringComponent & HAIR_COMPONENT_R)
{
const float sa = sin(Alpha[0]);
const float ca = cos(Alpha[0]);
float Shift = 2 * sa * (ca * CosHalfPhi * sqrt(1 - SinThetaV * SinThetaV) + sa * SinThetaV);
float BScale = HairTransmittance.bUseSeparableR ? sqrt(2.0) * CosHalfPhi : 1;
float Mp = Hair_g(B[0] * BScale, SinThetaL + SinThetaV - Shift);
float Np = 0.25 * CosHalfPhi;
float Fp = Hair_F(sqrt(saturate(0.5 + 0.5 * VoL)));
S += Mp * Np * Fp * (GBuffer.Specular * 2) * lerp(1, Backlit, saturate(-VoL));
}
/**
* TT路径
* M:依旧使用高斯分布
* N:TT路径的N项比较复杂,基于Weta’s d&#39;Eon与Pixar的工作分别针对&#34;h,η,D,T&#34;进行拟合来简化
*/
if (HairTransmittance.ScatteringComponent & HAIR_COMPONENT_TT)
{
float Mp = Hair_g( B[1], SinThetaL + SinThetaV - Alpha[1] );
/**
* Step1: 对h的拟合
* h的原型计算公式如下:
* float h = CosHalfPhi * rsqrt( 1 + a*a - 2*a * sqrt( 0.5 - 0.5 * CosPhi ) );
* float h = CosHalfPhi * ( ( 1 - Pow2( CosHalfPhi ) ) * a + 1 );
*
* 最终曲线拟合完的h_tt如下:
*/
float a = 1 / n_prime;
float h = CosHalfPhi * ( 1 + a * ( 0.6 - 0.8 * CosPhi ) );
/**
* Step2:η&#39;的拟合
* 原型:η&#39; = sqrt( η * η - 1 + CosThetaD^2) / CosThetaD;
* 拟合思路:η即人类发丝折射率写死为1.55, 拟合后的η&#39;如下:
* η&#39; = 1.19 / CosThetaD + 0.36 * CosThetaD;
* 代码往上翻
*/
float f = Hair_F( CosThetaD * sqrt( saturate( 1 - h*h ) ) );
float Fp = Pow2(1 - f);
/**
* Step3:对于吸收项T的拟合:选择Pixar的方案但没有直接用,还是做了拟合
*
* T与γ_t的计算原型如下:
* T(θ,φ) = e^{-2 * μ_a * (1 + cos(2γ_t)) / (cosθt)},其中γt = sin^-1(h / η&#39;)
* 代码实现:float yi = asinFast(h); float yt = asinFast(h / n_prime);
*
* 参考Pixar的实现:
* T(θ,φ) = e^{-epsilo(C) * cosγt / cosθd}
* 代码实现:float3 Tp = pow( GBuffer.BaseColor, 0.5 * ( 1 + cos(2*yt) ) / CosThetaD );
*/
float3 Tp = 0;
if (HairTransmittance.bUseLegacyAbsorption)
{
// T(θ,φ) = C^{(sqrt(1 - h^2 * a^2)) / (2 * cosθd)}
Tp = pow(GBuffer.BaseColor, 0.5 * sqrt(1 - Pow2(h * a)) / CosThetaD);
}
else
{
// 考虑多散射的情况下
const float3 AbsorptionColor = HairColorToAbsorption(GBuffer.BaseColor);
Tp = exp(-AbsorptionColor * 2 * abs(1 - Pow2(h * a) / CosThetaD));
}
/**
* Step4: 对分布项D的拟合
* 技术原型:Pixar&#39;s Logistic Distribution Function
* D(φ,s, μ) = (e^{(φ - μ) / s}) / (s^{1 + e^{(φ - μ) / s}}^2)
*
* 考虑s_tt实际上贡献很小,因此近似如下:
* D_TT(φ) = D(φ,0.35,Π) ≈ e^{-3.65cosφ - 3.98}
*/
float Np = exp( -3.65 * CosPhi - 3.98 );
/**
* float t = asin( 1 / n_prime );
* float d = ( sqrt(2) - t ) / ( 1 - t );
* float s = -0.5 * PI * (1 - 1 / n_prime) * log( 2*d - 1 - 2 * sqrt( d * (d - 1) ) );
* float s = 0.35;
* float Np = exp( (Phi - PI) / s ) / ( s * Pow2( 1 + exp( (Phi - PI) / s ) ) );
* float Np = 0.71 * exp( -1.65 * Pow2(Phi - PI) );
*/
S += Mp * Np * Fp * Tp * Backlit;
}
/**
* TRT路径
* M:高斯分布
* N: 尽可能拟合为常数,D_trt = e^{17cosφ - 16.78}
*/
if (HairTransmittance.ScatteringComponent & HAIR_COMPONENT_TRT)
{
float Mp = Hair_g( B[2], SinThetaL + SinThetaV - Alpha[2] );
/**
* Step1 :对h的拟合
* h_trt = sqrt(3) / 2
* float h = 0.75;
*/
float f = Hair_F( CosThetaD * 0.5 );
float Fp = Pow2(1 - f) * f;
/**
* Step2:对于吸收项T的拟合
* T_TRT(θ,φ) = C^{0.8 / cosθd}
*/
float3 Tp = pow( GBuffer.BaseColor, 0.8 / CosThetaD );
/**
* Step3:对于分布项D的拟合;
* 技术原型:float Np = 0.75 * exp( Phi / s ) / ( s * Pow2( 1 + exp( Phi / s ) ) );
*
* 拟合 s_TRT写死为0.15,拟合后的计算如下:
* D_TRT(φ) = 0.75 * D(φ,0.15,0) ≈ e^{17cosφ - 16.78}
*/
float Np = exp( 17 * CosPhi - 16.78 );
S += Mp * Np * Fp * Tp;
}
#endif
// 叠加上散射(该项设计的不是很好,叠不叠加基本都看不出来)
if (HairTransmittance.ScatteringComponent & HAIR_COMPONENT_MULTISCATTER)
{
S = EvaluateHairMultipleScattering(HairTransmittance, ClampedRoughness, S);
S += KajiyaKayDiffuseAttenuation(GBuffer, L, V, N, Shadow);
}
S = -min(-S, 0.0);
return S;
}2.7,Cloth
布料类材质不能直接使用默认BRDF,因为GGX分布虽然有相对较长的拖尾,但高光还是呈现集中样式,这会导致布料看起来像塑料。其他一些布料的纤维表面存在不可忽略的散射现象,而另外一些则呈现不同样式的镜面高光。UE4参考了《教团1886》中布料渲染的办法[4],
首先NDF,改造GGX分布到InvGGX
然后改造遮挡项:
最后与标准BRDF依据Cloth进行插值作为最终的高光部分,漫射项依旧是Lambert光照,在UE4的方案中没有考虑Transmission项。从其模型计算过程可知它只适合模拟皮革,纺布,亚麻等有限布料,对于丝绸等材质的模拟是欠缺的。
2.8,Eye
眼睛是一类复杂的多层模型
根据实际需要的不同可以增加不同的Feature来实现不同细节与拟真程度的渲染,从光照反馈方式来看UE4的ShadingModel实现如下:
区别还是在Transmission项上,考虑了虹膜法线对光线的扰动,然后与NoL做Lerp,底层(虹膜层)菲涅尔系数为整体减去外层,再乘上兰伯特照明。至于环境光照部分引擎内暂未给出区分,主要的工作还是集中在模型设计与材质设计上。
2.9,SingleLayerWater
4,26引入了一套单层水模型,这也是UE4封装最与众不同的一类模型。
2.9.1,直接光计算
首先,直接光计算:
从SM的分支来看,单层水的直接光计算使用的是与Forward+共同的功能封装——GetForwardDirectLightingSplit,这个函数被定义在ForwardLightingCommon中,分“直接光与禁用本地灯光列表”两条分支计算直接光。虽然从这里看是Color += (ForwardDirectLighting.DiffuseLighting.rgb + ForwardDirectLighting.SpecularLighting.rgb),但直接光依旧是三项,Transmission在封装的函数内完成了叠加。
继续追踪代码,在ForwardLightingCommon中找到GetForwardDirectLightingSplit的定义,关注HasDirectionalLight分支,可以知道如果暂且不关注初始化灯光数据,阴影处理等辅助功能,核心函数是调用的GetDynamicLightingSplit。而该函数实际共用了桌面端Deferred的功能封装。。。,因此我们可以在DeferredLightingCommon中找到该函数的定义:
这里就是分两次调用“光照累加器”,把Diffuse,Specular,Transimission三项在了一起。BRDF从IntegrateBxDF来看同样使用DefualtLit的,并没有做特殊处理。
2.9.2,环境光计算
然后看下环境光计算,让我们先回到BasePassPixelShader,
找到DiffuseIndirectLighting的数据来源可以知道它里面存的就是天光组件提球谐光照或者VLM,或点采样的ILC,或均匀体积采样的ILC,或HQ编码的Lightmap【当然与这里分析的单层水没什么关系了】。然后这个DiffuseIndirectLighting乘上一个亮度系数,再加上次表面项,最后统一乘上SSAO。由此可以得知DiffuseColor这个局部变量存储了PC端计算出的环境漫射光【好家伙,比移动端多了这么多调教,这效果能不好嘛】。
紧接着一个单层水的分支,给DiffuseColor乘上了BaseMaterialCoverageOverWater【其实就是Opacity,因为水有岸边过渡】作为SingleLayerWater的环境漫射Modulate。那么环境反射呢,IBL呢?我们知道做PBR的水,IBL对质感的影响同样仅次于直接高光。但接着往下找
???,!MATERIAL_SHADINGMODEL_SINGLELAYERWATER。上面这段调用是Forward+累加环境反射的逻辑【为Forward+单独封装的IBL采样,逻辑在ForwardLightingCommon中】,按理说水这种光照模型延迟管线下不好处理确实应该放在Forward架构下计算,然而这段静态分支条件确是“非”。
只能去Deferred下的环境反射那个文件去找找了,然后发现还是没有相应的分支。。。
其实SingleLayerWater的IBL采样被单独部署在了SingleLayerWaterComposite.usf中,单层水照明累加至颜色缓冲的最后一步就是把IBL叠上去。这个文件里还有条ComputeShader的分支,我们暂且不去管它,找到SingleLayerWaterComposite函数,核心逻辑如下:
它也调用了ReflectionEnvironmentComposite中封装好的反射混合逻辑,所以也是支持天光反射,球形反射,盒型反射混合的,同样也支持SSR。与环境漫射光同样,环境反射也考虑了岸边过渡。
UE4给SingleLayerWater这种照明设计方式起了一个名字“复合管道”。
这就完了嘛?并没有,水还有非常多的特性
2.9.3,水的体积光
再次返回BasePassPixelShader,可以找到两套EvaluateWaterVolumeLighting模拟了水的体积散射与吸收。
函数实现在SingleLayerWaterShading.ush中,我们就直接看最复杂的那一条分支,实现过程如下
2.9.4,平台化的水系统
附带SingleLayerWater的ShadingModel,从426开始引擎提供了一套平台化的水解决方案。
这套水系统设计的非常完整,不仅是在渲染层面提供新的ShadingModel,混合渲染管线支持以及配套材质,也考虑大中小型水面的形态模型,同时搭配了一套易用的工具链:
- 质感模拟:封装了一套ShadingModel,支持实时网格,UV以及光子溅射【PM的一类变种】三种焦散模拟手段,还以插件内容形式给了一堆官方做好的材质【后面会提到】,支持Foam
- 形态模拟:河流类的水域可以使用Flowmap,一些小水塘可以用普通的Normal Distorsion,对于更大面积的水域可以选用更合适的Gerstner波
- 配套工具链:封装好的水网格体组件【有单独的顶点工厂】以及曲线编辑工具,易于美术操作
ShadingModel上面已经分析了,其余功能都置于Water插件中:
这里只提下渲染实现,官方给了两个主材质实现,这两个父材质调用了Functions路径下的如上这么多材质函数,可以实现以下效果:
单纯从效果来看还是蛮惊艳的,很多上文提到的特性从材质球上就可以看出来,其实这两个材质都带焦散功能,左边是完全基于Gersterner波的实时网格焦散,右边是用的UV焦散。左边那个就是官方给出的材质模板:
每个代码块实现了什么功能都在上图中进行了标注,哪些东西启用是通过宏开关控制的,除了调用上面那一坨材质函数外,好多功能是在内建着色器完成的(比如Gerstner波)然后在Custom Node中调用相关函数,整套流程的数据组织与传递非常复杂。我大概用了2天时间把上面的蓝图全部人工翻译成了HLSL代码,针对特定功能重新进行了数据与函数封装。
在进行UE4的Gerstner Wave实现分析前,先给出这套水方案的应用结论:
- 工程设计非常全面,比较有参考价值
- 游戏项目无论是PC端还是Mobile端都没办法直接用。。。。
全载荷模式下至少要求API在DirectX 11及以上,而且计算量非常大,采样数非常可怕,需要调整的参数(有用的没用的)非常多,不能直接拿来用。好处就是结论一,TA可以参考着它的思路自己苟一套适合PC端实时高效运行的或者是适配到移动端。它这效果是挺好的,可是不能直接用也挺蛋疼。。
接下来给出UE4的Gerstner Wave实现。Flowmap,Foam那些东西大家随便写写效果就差不多了,完全没必要按照它的算法来。
材质蓝图中使用Gerstner Wave直接CustomNode调用GetAllGerstnerWavesNew就好了,它的功能实现可以在Water插件的GerstnerWaveFunction.ush中找到
struct WaveOutput
{
float3 Normal;
float3 WPO;
};
WaveOutput GetAllGerstnerWavesNew(int InWaterBodyIndex, float InTime, float2 InWorldPos)
{
WaveOutput OutWaves = (WaveOutput)0;
WaveOutput CurrentWave;
const FWaterBodyHeader WaterBodyHeader = DecodeWaterBodyHeader(View.WaterIndirection[InWaterBodyIndex]);
for (int i = 0; i < WaterBodyHeader.NumWaves; i++)
{
CurrentWave = GetSingleGerstnerWaveNew(i, WaterBodyHeader, InTime, InWorldPos);
OutWaves = AddWavesNew(OutWaves, CurrentWave);
}
//The Normal B channel must be inverted after combining waves
OutWaves.Normal = FinalizeNormalNew(OutWaves.Normal);
return OutWaves;
}GetAllGerstnerWavesNew这个函数负责计算单层Gerstner波的顶点与法线,然后根据FWaterBodyHeader中的NumWaves值进行循环叠加,并输出最终的顶点与法线。 这个函数需要传入三个变量:
- InWaterBodyIndex : 水体渲染数据数组索引
- Intime:引擎时间
- InWorldPos:世界坐标
在这个函数体内,DecodeWaterBodyHeader负责把InDataToDecode的xyz数据解码到水体数据结构体各个成员变量中
FWaterBodyHeader DecodeWaterBodyHeader(float4 InDataToDecode)
{
FWaterBodyHeader OutWaterBodyHeader;
OutWaterBodyHeader.DataIndex = InDataToDecode.x;
OutWaterBodyHeader.NumWaves = InDataToDecode.y;
OutWaterBodyHeader.TargetWaveMaskDepth = InDataToDecode.z;
return OutWaterBodyHeader;
}GetSingleGersterWaveNew函数就是计算单层波形的形态,实现如下:
// 波浪的物理属性,包括方向,波长,振幅以及格斯特纳波特有的陡度
struct FWaveParams
{
float2 Direction;
float Wavelength;
float Amplitude;
float Steepness;
};
// 解码波形属性存入结构体
FWaveParams GetWaveDataNew(int InWaveIndex, FWaterBodyHeader InWaterBodyHeader)
{
FWaveParams OutWaveParams;
const int AbsoluteWaveDataIndex = InWaterBodyHeader.DataIndex + (InWaveIndex * PER_WAVE_DATA_SIZE);
float4 Data0 = View.WaterData[AbsoluteWaveDataIndex];
float4 Data1 = View.WaterData[AbsoluteWaveDataIndex + 1];
return DecodeWaveParams(Data0, Data1);
}
// 格斯特纳波的代码实现
// 世界坐标偏移与法线计算要单独处理
WaveOutput GetSingleGerstnerWaveNew(int InWaveIndex, FWaterBodyHeader InWaterBodyHeader, float InTime, float2 InWorldPos)
{
WaveOutput OutWave;
FWaveParams CurrentWave = GetWaveDataNew(InWaveIndex, InWaterBodyHeader);
// 波色散,即频率
float dispersion = 2 * PI / CurrentWave.Wavelength;
// 波矢量 = 波方向 * 频率
float2 wavevector = CurrentWave.Direction * dispersion;
// 《GPU Gems》中给出的水的色散关系式
// 这里应该是要模拟定向波,风场系数写死1.0,完整计算应该是 s = windSpeed * sqrt(9.8 * 2 * PI / λ)
float wavespeed = sqrt(dispersion * Gravity);
// 波在时间线上的运动
float wavetime = wavespeed * InTime;
float wavepos = dot(InWorldPos, wavevector) - wavetime;
float wavesin = sin(wavepos);
float wavecos = cos(wavepos);
// Q值,水的波峰的抖度因子
// 这里缺省了waveCount,而是以多道单层波叠加的方式完成
// 原型计算式为: Q = 1.0 / (A * w * waveCount);
float wKA = CurrentWave.Amplitude * dispersion;
float q = CurrentWave.Steepness / wKA;
OutWave.Normal.xy = wavesin * wKA * CurrentWave.Direction;
#if SOLVE_NORMAL_Z
OutWave.Normal.z = wavecos * CurrentWave.Steepness * saturate( (CurrentWave.Amplitude * SteepnessThreshold) / CurrentWave.Wavelength );
//OutWave.Normal.z = wavecos * wKA * (q/MaxWaves);
#else
OutWave.Normal.z = 0;
#endif
OutWave.WPO.xy = -q * wavesin * CurrentWave.Direction * CurrentWave.Amplitude;
OutWave.WPO.z = wavecos * CurrentWave.Amplitude;
return OutWave;
}单层Gerster Wave从算法实现上可以看到几乎就是《GPU Gems》中给出的版本。这只是一层,Gerstner波可以叠加好多层,所以接着会调用AddNewWave&FinalizeNormalNew这两个函数完成循环叠加与最终的归一化处理:
WaveOutput AddWavesNew(WaveOutput inWaveA, WaveOutput inWaveB)
{
inWaveA.Normal += inWaveB.Normal;
inWaveA.WPO += inWaveB.WPO;
return inWaveA;
}
float3 FinalizeNormalNew(float3 inNormal)
{
return normalize(float3(inNormal.xy, 1-inNormal.z));
}这就是UE4内置Gerstner Wave的实现。
2.10,Thin Translucent
最后一个模型,薄层材料,用于解决薄半透明材质的表现
这个模型从对比图来看确实更真实质量更好一些,从代码实现来看直接高光计算还是用的默认BRDF,透明薄层材料来说DiffuseColor 是Forward+的直接光计算出的Diffuse加上PC端的环境漫射【上文分析过】,对于直接高光Specular与环境反射都存入了可分离高光项中,且环境反射也是用的Forward+那套封装来采样IBL。
Reference:
[1] Heitz 2014, &#34;Understanding the Masking-Shadowing Function in Microfacet-Based BRDFs&#34;
https://jcgt.org/published/0003/02/03/paper.pdf
[2]《Separable Subsurface Scattering》http://www.iryoku.com/separable-sss/downloads/Separable-Subsurface-Scattering.pdf
http://www.iryoku.com/
[3] 《Physically Based Hair Shading in Unreal》
https://blog.selfshadow.com/publications/s2016-shading-course/karis/s2016_pbs_epic_hair.pdf
[4] SIGRAPH 2013《Crafting a Next-Gen Material Pipeline for The Order:1886》
https://blog.selfshadow.com/publications/s2013-shading-course/rad/s2013_pbs_rad_slides.pdf
这里还有份Talk的中文翻译版【不过有些地方不太严谨】:https://www.cnblogs.com/jaffhan/p/9804845.html
[5] https://www.cnblogs.com/timlly/p/11144950.html |
本帖子中包含更多资源
您需要 登录 才可以下载或查看,没有账号?立即注册
×
|