前言
上一篇文章分析了Urho3D的渲染流程,本文尝试分析Urho3D中一些高级的渲染实现,即阴影和PBR渲染。无论是阴影还是PBR渲染,在图形学中都是极其庞大和复杂的话题,限于篇幅(其实是本人太菜),本文只能泛泛的分析Urho3D在流程上如何对它们进行支持,而忽略许多技术细节,因技术能力有限,如有错误欢迎斧正。
分析中所用的sample
本文的分析主要基于Urho3D的官方demo,42_PBRMaterials这个例子,平台选用MacOS,主要是发现在移动端Urho3D渲染PBR时有bug。
PBR的模型和材质在Urho3D中都是高度可配置的,该sample中的PBR模型和材质配置都在PBRExample.xml文件中,用户只需要加载PBRExample.xml这个文件即可。如下:
void PBRMaterials::CreateScene()
{
//...
SharedPtr<File> file = cache->GetFile("Scenes/PBRExample.xml");
scene_->LoadXML(*file);
//...
}
我们可以自己去修改xml文件来选择我们需要加载的模型,比如在xml中除了配置必须的skybox和光源之外,我只添加一个球和一个平面,如下:
//前面还有光源和天空盒的配置忽略了...
<node id="89">
<attribute name="Is Enabled" value="true" />
<attribute name="Name" value="Sphere" />
<attribute name="Tags" />
<attribute name="Position" value="-1.57986 3.32226 -3.50626" />
<attribute name="Rotation" value="8.9407e-08 0 1 0" />
<attribute name="Scale" value="1 1 1" />
<attribute name="Variables" />
<component type="StaticModel" id="98">
<attribute name="Model" value="Model;Models/Sphere.mdl" />
<attribute name="Material" value="Material;Materials/PBR/MetallicRough10.xml" />
<attribute name="Cast Shadows" value="true" />
</component>
</node>
<node id="445">
<attribute name="Is Enabled" value="true" />
<attribute name="Name" value="Plane" />
<attribute name="Tags" />
<attribute name="Position" value="0.16697 2.35842 -2.11643" />
<attribute name="Rotation" value="1 0 0 0" />
<attribute name="Scale" value="3 1 3" />
<attribute name="Variables" />
<component type="StaticModel" id="465">
<attribute name="Model" value="Model;Models/Plane.mdl" />
<attribute name="Material" value="Material;Materials/PBR/Roughness3.xml" />
</component>
</node>
那么加载出来的画面是这样的:
PSSMs原理
Urho3D中支持,并行分割的阴影贴图(Parallel-split shadow maps, PSSMs)。首先简要说下阴影贴图技术(详细原理可以参考《LearnOpenGL CN》高级光照中的阴影部分),阴影贴图相当于把Camera放置在光源位置,将光源视锥体(类似Camera视锥体)中的物体渲染到一张深度贴图。深度贴图中保存了离光源最近的物体表面的z值(暂且称为zclose)。当渲染真实场景的时候,首先求出该像素相对于光源的距离z,然后用它和保存在深度贴图中的zclose进行比较,如果它小于或等于zclose,说明它是离光源最近的表面,否则就是处于阴影中。
而PSSMs技术,详细原理可以查看《GPU Gems3》第10章),因感觉书中说的稍简略,我在此加上自己的理解。它是一种阴影抗锯齿技术。它将Camera视锥体平行分割成多个小的视锥体,并对每个视锥体单独渲染一张深度贴图。这样可以提高深度贴图的采样频率,以此减弱锯齿。如下《GPU Gems3》中的一个图
要理解为什么对视锥体分割并产生多个深度贴图可以抗锯齿,首先要理解锯齿来源于何处。如下图:
光源为方向光,它对整个Camera视锥体进行投影,图中的Normalized Shadow Plane就是一张深度贴图。假设场景中的一个小表面(图中的橙色斜面),它投影到深度贴图的大小正好为ds,它在世界坐标系的z轴长度为dz。可以看到,如果光源方向垂直于z轴,那么ds正好等于dz。该平面投影到Camera的近平面的大小dp=dy/(ztanΦ),(注意近平面的y的范围为-1到1,2Φ为视锥体的观察范围,所以才能推出这个公式)。
另外有,dy≈dzcos Φ / cosθ,Φ是该平面法线和z轴的夹角(主要它不是视锥体的观察范围),θ是平面法线和光方向的夹角。需要注意的是,当光源方向垂直于z轴时dy=dzcos Φ / cosθ,而之所以说近似是因为光源方向不一定垂直于z轴,如下:
对于小的平面,锯齿的误差为
如何理解这个误差呢,从上面我们知道,dp代表我们最终渲染的image的pixel,而ds代表深度贴图的texel。如果dp/ds很大,那就代表我们最终的image会有多个pixel对应深度贴图的一个texel,即多个pixel的z值都是一样的,这会导致精度不足,或者说采样不足。
举个例子,如下图,场景中一个斜面,它投影到近平面上正好占三个pixel,而它投影到深度贴图中则只占一个texel,而如果在它和光源之间正好有一个球,球刚好挡住了1.5个pixel的光,正确的渲染应该是斜面有一半是阴影,一半非阴影,但是因为斜面在深度贴图中只有一个texel,那么它只能记录一个值,所以近平面上(image)看到的斜面,要么是全部阴影,要么就是全部非阴影,这就是误差的根源。
要减小误差,那就是要把dp/ds尽量小。我们可以把dp/ds分成两部分:透视锯齿dz/zds和投影锯齿cos Φ / cosθ,一般投影锯齿是很难分析的,它依赖于场景中的物体和光的朝向,所以我们针对透视锯齿,也就是dz/zds来优化。
理论上,透视锯齿最优分布是让dz/zds在整个深度范围内保持不变。但实际上却并非如此,有兴趣的可以看下《GPU Gems3》中的推导(我没怎么看懂),可以用下面的图来概括:
而在Urho3D中,用户是可以自己配置分割视锥体的。
阴影渲染步骤
对于PSSMs的步骤,《GPU Gems3》中已有概括,大概分为:
1.使用分割平面{Ci}将视锥体分割为m个部分;
2.对每个分割出来的Vi,计算光照的观察投影变换矩阵;
3.以分辨率res对所有分割出的部分{Vi}产生PSSMs{Ti};
4.在场景中对阴影进行综合。
阴影渲染的准备工作
在Urho3D中,也大致按照上面的步骤来进行。首先我们看下Urho3D中与阴影渲染相关的类:
如图,View中的sceneResults,lightQueryResults_,lightQueues_都是用来存储渲染流程中的相关对象的,在上一篇文章的前向渲染流程分析中有讲过。阴影相关的主要是LightQueryResult和LightBatchQueue中阴影相关的成员如下的红色字体部分。
以方向光为例,我们先看Urho3D渲染前的准备工作,大致如下:
简单画了个调用堆栈图:
因代码太长这里就不贴出来分析了,仅贴一下SetupDirLightShadowCamera函数的代码:
void View::SetupDirLightShadowCamera(Camera* shadowCamera, Light* light, float nearSplit, float farSplit)
{
Node* shadowCameraNode = shadowCamera->GetNode();
Node* lightNode = light->GetNode();
float extrusionDistance = Min(cullCamera_->GetFarClip(), light->GetShadowMaxExtrusion());
const FocusParameters& parameters = light->GetShadowFocus();
//因为是方向光,所以在一个离观察摄像头很远的位置假定了一个光源
Vector3 pos = cullCamera_->GetNode()->GetWorldPosition() - extrusionDistance * lightNode->GetWorldDirection();
//将shadowCamera的位置挪到光源位置
shadowCameraNode->SetTransform(pos, lightNode->GetWorldRotation());
farSplit = Min(farSplit, cullCamera_->GetFarClip());
if (parameters.focus_) {
//minZ_ 和maxZ_是场景中的drawables在z轴的最小和最大值,这样处理可以进一步减小视锥体的大小
nearSplit = Max(minZ_, nearSplit);
farSplit = Min(maxZ_, farSplit);
}
//根据nearSplit, farSplit定义一个视锥体
Frustum splitFrustum = cullCamera_->GetSplitFrustum(nearSplit, farSplit);
Polyhedron frustumVolume;
frustumVolume.Define(splitFrustum);
if (parameters.focus_) {
//省略....
}
const Matrix3x4& lightView = shadowCamera->GetView();
frustumVolume.Transform(lightView); //将frustumVolume变换到光源(shadowCamera)空间
BoundingBox shadowBox;
if (!parameters.nonUniform_)
shadowBox.Define(Sphere(frustumVolume));
else
shadowBox.Define(frustumVolume);
shadowCamera->SetOrthographic(true);
shadowCamera->SetAspectRatio(1.0f);
shadowCamera->SetNearClip(0.0f);
shadowCamera->SetFarClip(shadowBox.max_.z_);
// Center shadow camera on the bounding box. Can not snap to texels yet as the shadow map viewport is unknown
//根据shadowBox大小确定shadowCamera的正交视锥体的大小
QuantizeDirLightShadowCamera(shadowCamera, light, IntRect(0, 0, 0, 0), shadowBox);
}
优化策略
简单讲一下阴影分割的优化策略(详情在《GPU Gems3》中),如下图:
首先我们应该尽量把光源的视锥体(蓝色区域)尽可能的集中在场景的对象中,这样可以最大化的利用深度贴图的分辨率。像上面未经优化的左图,光源视锥体包含大量的空闲区域,会浪费深度贴图的像素;另外一点优化对于观察视锥体外的对象(右图中的Caster),我们只需要用他们来产生阴影,而不应该把他们放到正常的渲染队列中。这些优化在Urho3D中都有实现。
渲染阴影
在上面将的准备步骤完成后,场景中对阴影产生影响的drawable对应的shadowbatch会存储在lightQueues_.shadowSplits_(类型为ShadowBatchQueue)中。然后正常渲染之前,会先根据shadowbatch渲染深度贴图,然后再用深度贴图来渲染场景中的对象以及阴影。代码简略如下:
void View::ExecuteRenderPathCommands()
{
for (unsigned i = 0; i < renderPath_->commands_.Size(); ++i)
{
switch (command.type_)
case CMD_FORWARDLIGHTS:
//每个Iterator对应一个光源
for (Vector<LightBatchQueue>::Iterator i = actualView->lightQueues_.Begin(); i != actualView->lightQueues_.End(); ++i) {
if (renderer_->GetReuseShadowMaps() && NeedRenderShadowMap(*i) {
RenderShadowMap(*i); //1.先渲染深度贴图
}
//2.渲染受光照影响的drawable
i->litBaseBatches_.Draw(this, camera_, false, false, allowDepthWrite);
//3.渲染不受光照影响的drawable
i->litBatches_.Draw(this, camera_, false, true, allowDepthWrite);
}
}
}
RenderShadowMap()函数的工作大致如下:
需要注意的是,Urho3D并没有为每个split单独创建一个深度贴图,而是4个split共用一个深度贴图,在这次的sample中,深度贴图的分辨率为2048x2048,而每个split仅占用四分之一,也就是1024x1024,通过在渲染深度贴图前设置viewport来做到。
另外要注意的是,因为每个shadowBatch中存储的Camera是之前的ShadowCamera,因此投影矩阵会使用对应的ShadowCamera的投影矩阵,也就是光源投影矩阵来正确渲染到深度贴图中。
有了深度贴图,我们就可以利用深度贴图在最终的渲染中渲染出阴影效果。详细的步骤就不分析了,可以看下最终的着色器代码中相关的阴影代码,顶点着色器相关代码:
#define NUMCASCADES 4
uniform mat4 cLightMatrices[4];
varying vec4 vShadowPos[NUMCASCADES];
vec4 GetShadowPos(int index, vec3 normal, vec4 projWorldPos)
{
return projWorldPos * cLightMatrices[index];
}
void main()
{
//省略...
for (int i = 0; i < NUMCASCADES; i++)
vShadowPos[i] = GetShadowPos(i, vNormal, projWorldPos);
}
片元着色器相关代码:
float GetDirShadow(const vec4 iShadowPos[NUMCASCADES], float depth)
{
vec4 shadowPos;
if (depth < cShadowSplits.x)
shadowPos = iShadowPos[0];
else if (depth < cShadowSplits.y)
shadowPos = iShadowPos[1];
else if (depth < cShadowSplits.z)
shadowPos = iShadowPos[2];
else
shadowPos = iShadowPos[3];
return GetDirShadowFade(GetShadow(shadowPos), depth);
}
float GetShadow(vec4 shadowPos)
{
#if defined(SIMPLE_SHADOW)
//省略...
#elif defined(PCF_SHADOW)
vec2 offsets = cShadowMapInvSize;
return cShadowIntensity.y + cShadowIntensity.x * (textureProj(sShadowMap, shadowPos) +
textureProj(sShadowMap, vec4(shadowPos.x + offsets.x, shadowPos.yzw)) +
textureProj(sShadowMap, vec4(shadowPos.x, shadowPos.y + offsets.y, shadowPos.zw)) +
textureProj(sShadowMap, vec4(shadowPos.xy + offsets.xy, shadowPos.zw)));
#elif defined(VSM_SHADOW)
//省略...
#endif
}
void main()
{
//省略...
shadow = GetShadow(vShadowPos, vWorldPos.w);
//省略...
finalColor.rgb = BRDF * lightColor * (atten * shadow) / M_PI;
//省略...
}
因为比较简单就不再分析了,有一点需注意的是Urho3D默认使用PCF(percentage-closer filtering)技术来作为阴影抗锯齿,原理也比较简单,即通过对深度贴图采样点附近多采样一些样本然后平均,来达到柔和阴影的效果。
PBR渲染
后面简要分析一下Urho3D对PBR(Physically Based Rendering)渲染的支持。PBR的理论方面可以阅读《Learn OpenGL CN》及《Realtime Rendering》等相关章节,本文不再细说。虽然PBR背后的理论涉及的知识非常多,但Urho3D中的实现却并不复杂。因为PBR渲染本质上是一种基于物理的表面光照模型,或者说表面着色技术,只影响引擎的材质部分,也就是说,PBR渲染除了材质部分(着色器代码中的光照反射部分),其他的流程和普通的基于经验的光照模型的流程是一模一样的。
Urho3D的PBR的文件配置
前面也说过,Urho3D的材质是高度可配置的。对于用户来说,PBR渲染也只是配置对应的xml文件即可。PBR中最重要的两个参数是Roughness和Metallic,及粗糙度和金属度。 Urho3D中有许多默认的配置文件,例如MetallicRough3.xml文件:
<?xml version="1.0"?>
<material>
<technique name="Techniques/PBR/PBRNoTexture.xml" quality="0" loddistance="0" />
<parameter name="UOffset" value="1 0 0 0" />
<parameter name="VOffset" value="0 1 0 0" />
<parameter name="MatDiffColor" value="0 0.1 0.5 1" />
<parameter name="MatEmissiveColor" value="0 0 0" />
<parameter name="MatEnvMapColor" value="1 1 1" />
<parameter name="MatSpecColor" value="1 1 1 1" />
<parameter name="Roughness" value="0.3" />
<parameter name="Metallic" value="1" />
<cull value="ccw" />
<shadowcull value="ccw" />
<fill value="solid" />
<depthbias constant="0" slopescaled="0" />
<alphatocoverage enable="false" />
<lineantialias enable="false" />
<renderorder value="128" />
<occlusion enable="true" />
</material>
除了Roughness和Metallic,还可以配置材质表面的漫反射颜色MatDiffColor;其他如MatEmissiveColor(发光体颜色),MatEnvMapColor(环境贴图的颜色),MatSpecColor(镜面反射颜色),这些也可以通过配置改变。另外看下technique的子元素是PBRNoTexture.xml文件。我们也可以看下:
<technique vs="PBRLitSolid" ps="PBRLitSolid" vsdefines="NOUV" psdefines="PBR IBL">
<pass name="base" />
<pass name="light" depthtest="equal" depthwrite="false" blend="add" />
<pass name="prepass" psdefines="PREPASS" />
<pass name="material" psdefines="MATERIAL" depthtest="equal" depthwrite="false" />
<pass name="deferred" psdefines="DEFERRED" />
<pass name="depth" vs="Depth" ps="Depth" />
<pass name="shadow" vs="Shadow" ps="Shadow" />
</technique>
technique对应的xml文件是配置renderpass及着色器的。可以看到PBRNoTexture.xml中定义的着色器代码是PBRLitSolid,并在着色器中添加了宏定义PBR和IBL。technique下包含了多个pass,每个pass可以理解为一个drawcall。
PBR渲染流程
最简单的PBR渲染大致如下步骤:
PBR在实时渲染中一般为通过直接光和间接光的效果叠加来实现。
而直接光照,则直接利用BRDF公式计算出反射辐射率来得到。
注意上面其实是加上了gamma校正后的效果,因为PBR的光辐射率的计算都是在线性空间的,要在最后把它加上gamma校正才能在屏幕上正常显示。
在引擎中,实现上面步骤主要通过下面几个renderpass:
当然,Urho3D的sample中还加了FXAA2(抗锯齿)和AutoExposure(自动曝光)的后处理。因为它们对于PBR渲染来说不是必须的,因此没有列出来。
IBL的实现
IBL(Image based lighting),基于图像的光照,是一种模拟环境间接反射光的技术。它是通过环境贴图(一般为立方体贴图)来实现,间接光又分为漫反射部分和镜面反射部分。Urho3D中使用6张HDR纹理组成立方体纹理,它是mipmap纹理,不同的mipmap层级对应不同的粗糙度。
IBL在着色器中的相关代码如下:
//PBRLitSolid.glsl,片元着色器
void main()
{
//省略...
#ifdef IBL
vec3 iblColor = ImageBasedLighting(reflection, normal, toCamera, diffColor.rgb, specColor.rgb, roughness, cubeColor);
float gamma = 0.0;
finalColor.rgb += iblColor;
#endif
//省略...
}
vec3 ImageBasedLighting(vec3 reflectVec, vec3 wsNormal, vec3 toCamera, vec3 diffColor, vec3 specColor, float roughness, inout vec3 reflectionCubeColor)
{
roughness = max(roughness, 0.08);
reflectVec = GetSpecularDominantDir(wsNormal, reflectVec, roughness);
float ndv = clamp(dot(-toCamera, wsNormal), 0.0, 1.0);
// PMREM Mipmapmode https://seblagarde.wordpress.com/2012/06/10/amd-cubemapgen-for-physically-based-rendering/
//float GlossScale = 16.0;
//float GlossBias = 5.0;
float mipSelect = GetMipFromRoughness(roughness); //exp2(GlossScale * roughness * roughness + GlossBias) - exp2(GlossBias);
vec3 cube = textureLod(sZoneCubeMap, FixCubeLookup(reflectVec), mipSelect).rgb;
vec3 cubeD = textureLod(sZoneCubeMap, FixCubeLookup(wsNormal), 9.0).rgb;
// Fake the HDR texture
float brightness = clamp(cAmbientColor.a, 0.0, 1.0);
float darknessCutoff = clamp((cAmbientColor.a - 1.0) * 0.1, 0.0, 0.25);
const float hdrMaxBrightness = 5.0;
vec3 hdrCube = pow(cube + darknessCutoff, vec3(max(1.0, cAmbientColor.a)));
hdrCube += max(vec3(0.0), hdrCube - vec3(1.0)) * hdrMaxBrightness;
vec3 hdrCubeD = pow(cubeD + darknessCutoff, vec3(max(1.0, cAmbientColor.a)));
hdrCubeD += max(vec3(0.0), hdrCubeD - vec3(1.0)) * hdrMaxBrightness;
vec3 environmentSpecular = EnvBRDFApprox(specColor, roughness, ndv);
vec3 environmentDiffuse = EnvBRDFApprox(diffColor, 1.0, ndv);
return (hdrCube * environmentSpecular + hdrCubeD * environmentDiffuse) * brightness;
}
从注释中看到,sample中的Cubemap是用一个amd-cubemapgen的工具生成的,可以到https://seblagarde.wordpress.com/2012/06/10/amd-cubemapgen-for-physically-based-rendering/中查看生成的详情(现在那个工具貌似下载不了了)。
着色器代码中,cube代表的是环境光的镜面反射部分,它根据粗糙度,采样对应的mipmap层级;cubeD代表的是环境光的漫反射部分,它采样的是mipmap的最高层级(9),也就是最模糊的环境贴图。
直接光照计算
直接光照的着色器代码如下:
//PBRLitSolid.glsl,片元着色器
void main()
{
//省略...
vec3 BRDF = GetBRDF(vWorldPos.xyz, lightDir, lightVec, toCamera, normal, roughness, diffColor.rgb, specColor);
finalColor.rgb = BRDF * lightColor * (atten * shadow) / M_PI;
//省略...
}
vec3 GetBRDF(vec3 worldPos, vec3 lightDir, vec3 lightVec, vec3 toCamera, vec3 normal, float roughness, vec3 diffColor, vec3 specColor)
{
vec3 Hn = normalize(toCamera + lightDir); //半向量
float vdh = clamp(dot(toCamera, Hn), M_EPSILON, 1.0); //观察方向与半向量的夹角余弦
float ndh = clamp(dot(normal, Hn), M_EPSILON, 1.0); //法线与半向量的夹角余弦
float ndl = clamp(dot(normal, lightVec), M_EPSILON, 1.0); //法线与光照向量的夹角余弦
float ldh = clamp(dot(lightVec, Hn), M_EPSILON, 1.0); //光照向量与半向量的夹角余弦
float ndv = abs(dot(normal, toCamera)) + 1e-5; //法线与观察方向的夹角余弦
vec3 diffuseFactor = Diffuse(diffColor, roughness, ndv, ndl, vdh); //漫反射部分
vec3 specularFactor = vec3(0.0, 0.0, 0.0);
vec3 fresnelTerm = Fresnel(specColor, vdh, ldh) ; //菲涅尔效应
float distTerm = Distribution(ndh, roughness); //微表面分布函数
float visTerm = Visibility(ndl, ndv, roughness); //几何系数
specularFactor = fresnelTerm * distTerm * visTerm / M_PI; //镜面反射部分
return diffuseFactor + specularFactor;
}
直接光照的公式就是大名鼎鼎的Cook-Torrance BRDF模型。当然Urho3D中的公式和原始公式有差异,个人猜测这是根据实际效果和做的调节,毕竟Cook-Torrance BRDF模型也只是一种近似,并不绝对的对错之分。具体的原理可以参考《LearnOpenGL CN》或其他文献,在此不再详述。
结尾
因为关于PBR渲染的原理和实现是一个非常庞大的话题,且有非常多好的资料,自己主要目的也只是想看下一个轻量级引擎对它是如何支持的,在此就不再展开分析了,大部分光照实现都在PBRLitSolid.glsl,PBR.glsl等文件中,有兴趣可以看看。
网友评论