美文网首页
Urho3D源码分析(三):阴影及PBR渲染

Urho3D源码分析(三):阴影及PBR渲染

作者: 奔向火星005 | 来源:发表于2020-03-28 00:38 被阅读0次

    前言

    上一篇文章分析了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等文件中,有兴趣可以看看。

    相关文章

      网友评论

          本文标题:Urho3D源码分析(三):阴影及PBR渲染

          本文链接:https://www.haomeiwen.com/subject/vbhzyhtx.html