关于视差算法的基本原理与拓展应用
伊底_1D
2022年07月25日 21:03
收录于文集
共16篇

前言

        本文是为了之后打算搞的一些原神里效果的实现做的铺垫。因为视差这个东西如果想明白原理就实在没啥可讲的了,所以在此基础上实现了一个带投影的视差算法作为拓展。

        视差算法应该算是目前低精度模型伪造高精度立体感的极限造假手段了(空间结构上)。相较于现在哪都能看到的法线贴图,视差算法解决的是空间结构的遮挡问题。但因为需要多次采样有着巨量的性能开销,所以移动端游戏往往不怎么用,或者说用的话,循环次数也会控制的比较低。比如原神里的蒙德,其中的地砖和城墙就有用到大约10次循环的视差贴图。

        这种边缘的阶梯状的锯齿就是视差的代表特征。除此之外,对于蒙德的城墙,到接缝处向侧面打平视角,还可以发现贴图会随视角变化产生一定的偏移,这同样是视差贴图的现象。

        下面是法线和视差的效果对比。

法线贴图

视差贴图

        可以发现,上面两个被选中的模型网格并没有发生变化,但却呈现出了金字塔状的立体结构。观察下面这只胡桃身前的这块石头也能发现,它一直都是在胡桃脚下的。

        如果觉得上面的效果还不够吊,凸起不够明显,可以看看下面这个带投影的山谷渲染效果。

        个人感觉,当视差算法的扫描精度(循环次数)拉的比较高时,其实不如用稍微高点面数的模型配合法线贴图来渲染(毕竟有多少次循环就有多少次片元阶段的图像采样,而采样这个操作开销还是比较大的- -相较之下高模只是增加了顶点阶段的消耗)。而且随着UE5那个叫Nanite的黑科技的出现,可能今后法线和视差什么的都会被逐渐淘汰。不过视差算法的核心,也就是RayMarching(光线步进,不是光追,不要搞混- -)这个东西的思想,个人觉得还是蛮有推广利用的潜力的,它的应用远不局限于视差算法,本文接下来也会围绕RayMarching的原理和思想简单来讲。


一、切线空间

        正式讲解原理前先简单讲一下什么是切线空间。简单来说就是在贴图UV(不知道什么是UV点这里​)的基础上再插个Z轴。对于贴图本身来说,切空间长下面这个模样。

        UV分别对应XY方向。

        在世界空间里,因为贴图很可能会沿着模型的表面发生各种扭曲,所以切空间本身也会变形。此时,模型的每个三角面都是切空间中的一个局部,且因为面的扭曲,切空间的UV轴在世界空间下可能不再互相垂直且等长。但其中的Z轴方向是确定的,即模型的法线。所以在世界空间下,模型的每个顶点都确定一个切空间。

        上图的向量TBN分别对应切空间的UVZ轴方向,这几个向量的世界坐标构成的矩阵就是在法线采样时总是会用到的TBN矩阵。(Tangent-BiTangent-Normal。用于把法线贴图的向量信息转为世界坐标。法线图中RGB一般是高模的切空间法线坐标经过[-1,1]到[0,1]的映射后的值


二、直线与任意高度场求交点

        实际上,视差算法本质上就是个切空间下的直线与曲面求交点的问题。而在切空间下计算也使得视差算法在模型比较“扭曲”时会出现明显的错误。例如下面在球体上使用视差时,越靠近边缘产生的拉伸越严重的现象。

        但即便如此也只能用切空间计算。至于为何如此- -可以先看完下面的数学过程再想想,将类似的过程直接运用在世界空间下能不能行得通(反正我是没想出来。因为GPU的并行化,感觉一些需要的条件拿不到- -)。

二维情况

        为方便理解先看二维的情况。

        假想我们正在看x轴,L是视线方向,h(x)是一个高度函数,它构成了一座山脉。如果不考虑遮挡,那渲染结果将会以x=a进行计算;否则应当以视线与山脉的第一个交点,即x=b进行计算。图中除了b之外都是已知的,则b需要通过下面这个关系求解:

        h(b)%3D%5Cfrac%7Bl_%7Bh%7D%7D%7Bl_%7Bx%7D%7D(b-a)%20

        因为h(x)在实际情况中往往是一张保存了高度场(即一个uv只确定唯一一个高度值,可以写成z=h(x,y)的形式)信息的灰度图,没有具体的表达式,所以b的解也就无法写成解析形式。又因为没有给出解的范围,所以也没法上来就用二分查找或者牛顿迭代等操作来求数值解。所以接下来就祭出RayMarching大法- -

        RayMarching其实原本是一种,沿着已知起点的一条射线的方向,每隔一小段距离就进行某些处理的方法,多用于体积云之类的体渲染相关的计算,这里拿来判断解的情况。

        如上图,从视线起点开始沿着视方向走。高度每变化dh我们就算一下那些蓝点的坐标,进而得到射线上的高度hL以及高度场h(x),然后进行比较。

        在题设情况下有这么个关系。当hL<=h(x)时,说明射线穿进了山的内部。(当然,如果dh太大也会导致一些较小的凸起被跳过)此时就可以拿本次与上次的hL和h(x),用三角相似或者二分法之类的近似算出b,甚至干脆直接拿本次的hL作为近似解也行(这次的shader就是这么写的)。

优化

        在此基础上,考虑到程序实现,上述过程其实就是个循环次数为相机高度Lh/dh的循环体。显然,Lh是不确定的。如果Lh很大,则会有很多性能浪费。所以可以做如下优化,将射线的起点定为该射线与高度场上界hMax的交点,同时指定循环次数为一个公开的可控参数step,通过hMax/step来求出单次步进的dh,以保证最大循环次数不会超过step。

三维情况

        至于三维的情况其实与二维几乎相同,只需把上面的x对应uv坐标,h(x)对应一张高度图来理解即可。

        将上述过程推广到“已知起点的射线与高度场求交点”,并考虑高度场的缩放平移有以下方法。

代码块
clike
自动换行
复制代码
float3 GetRayMarchingCrossPoint(float3 scale, float3 offset, uint marchStep, sampler2D heightMap, float3 rayPosTS, float3 rayDirTS)
            {
                float3 deltaDir = (rayDirTS / abs(rayDirTS.z)) * (abs(scale.z) / marchStep);//单次步进的变化向量。加绝对值保证输入方向不变
                float3 currentPos = rayPosTS;//当前步进的坐标
                float sampleH = currentPos.z - 1;//当前步进的采样高度
                
                for (uint idx = 0; idx < marchStep; idx++)
                {
                    if (sampleH < currentPos.z)//采样高度小于射线上的高度,说明射线还没有进入高度场内部)
                    {
                        currentPos += deltaDir;
                        sampleH = scale.z * tex2Dlod(heightMap, float4(scale.xy * currentPos.xy + offset.xy, 0.0, 0.0)) + offset.z;
                    }
                    else
                    {
                        break;
                    }
                }
                return currentPos;
            }
复制成功


三、投影实现

        因为视差算法是一种在低精度模型上造假的障眼法,所以一般的shadowmap,lightmap甚至上次讲的平面投影​等需要模型信息的投影实现方式就都行不通了。这里我基于上面的求交点方法推广了一下实现了投影效果。虽然效果上还行,但因为还是需要循环采样,高精度情况下的性能负担可想而知,所以可能也就仅限于自己私下玩玩了-_-

        如上图所示,其实投影就是在之前求出的蓝点坐标基础上,以蓝点为新起点,光向量为方向再进行一次RayMarching。如果最终结果的高度小于高度场上界,则说明存在至少一个交点挡在了蓝点前面,即该点在投影内,否则在投影外,进而算出一个投影范围的二值遮罩,然后就可以为所欲为了。


四、总结

边缘扭曲

        对于视差算法的边缘扭曲现象,从数学过程上来看,应该是在步进时采用的切空间视向量没有变化所导致的。实际情况是,片元阶段的每个像素所计算的TBN矩阵及视向量都不同。但因为GPU的并行计算,这种误差是难以抵消的。但可以通过拉低高度场在切空间中的整体高度来削弱这种现象。

分层锯齿

        除了性能开销大,在大曲率弧面上有明显扭曲等缺陷外,视差算法还有个肉眼可见的缺点是,在循环次数比较低且高度差较大时,渲染效果会出现明显的分层现象,即一开始提到的视差算法的特征。

        层数和指定的循环次数相等。这种分层正是由RayMarching的近似取数值解导致的。解决方法,要不拉高循环次数,要不压低凸起程度(凹凸大到这个份上还用视差作甚- -直接上模型+法线吧)。

        最后附上完整的shader代码。

代码块
clike
自动换行
复制代码
//URP
Shader "Scene/Parallax"
{
    Properties
    {
        [Header(Texture)]
        [NoScaleOffset]_HeightMap ("高度图", 2D) = "black" {}
        [NoScaleOffset]_NormalMap ("法线图", 2D) = "bump" {}
        _NormalInt ("法线强度", Range(0, 10)) = 1.0
        
        [Space(20)]
        [Header(Shape)]
        _ScaleParams ("缩放系数", vector) = (1.0, 1.0, 0.1, 0.0)
        _OffsetParams ("平移系数", vector) = (0.0, 0.0, 0.0, 0.0)
        _MarchStep ("扫描层数", range(1, 255)) = 20

        [Space(20)]
        [Header(Shadow)]
        _ShadowCol ("阴影颜色", color) = (0.5, 0.5, 0.5, 1.0)
        _ShadowStep ("投影精度", range(1, 255)) = 5
    }
    SubShader
    {
        Tags 
        {
            "RenderPipeline" = "UniversalPipeline" 
            "RenderType" = "Opaque"
            "Queue" = "Geometry"
        }

        Pass
        {
            Cull back
            ZWrite On

            HLSLPROGRAM

            #pragma vertex vert
            #pragma fragment frag

            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl"

            CBUFFER_START(UnityPerMaterial)
                //贴图
                sampler2D _HeightMap;
                sampler2D _NormalMap;
                half _NormalInt;

                //高度场系数
                half3 _ScaleParams;
                half3 _OffsetParams;
                uint _MarchStep;

                //投影
                half3 _ShadowCol;
                uint _ShadowStep;
            CBUFFER_END

            //计算切空间下,射线与高度场的首个交点
            float3 GetRayMarchingCrossPoint(float3 scale, float3 offset, uint marchStep, sampler2D heightMap, float3 rayPosTS, float3 rayDirTS)
            {
                float3 deltaDir = (rayDirTS / abs(rayDirTS.z)) * (abs(scale.z) / marchStep);//单次步进的变化向量。加绝对值保证输入方向不变
                float3 currentPos = rayPosTS;//当前步进的坐标
                float sampleH = currentPos.z - 1;//当前步进的采样高度
                
                for (uint idx = 0; idx < marchStep; idx++)
                {
                    if (sampleH < currentPos.z)//采样高度小于射线上的高度,说明射线还没有进入高度场内部)
                    {
                        currentPos += deltaDir;
                        sampleH = scale.z * tex2Dlod(heightMap, float4(scale.xy * currentPos.xy + offset.xy, 0.0, 0.0)) + offset.z;
                    }
                    else
                    {
                        break;
                    }
                }
                return currentPos;
            }

            struct a2v
            {
                float4 posOS    : POSITION;
                float3 nDirOS   : NORMAL;
                float4 tDirOS   : TANGENT;
                float2 uv       : TEXCOORD0;
            };

            struct v2f
            {
                float4 posCS    : SV_POSITION;
                float2 uv0       : TEXCOORD0;
                half3 nDirWS    : TEXCOORD1;
                half3 tDirWS    : TEXCOORD2;
                half3 bDirWS    : TEXCOORD3;
                half3 vDirWS    : TEXCOORD4;
            };

            v2f vert (a2v i)
            {
                v2f o;
                
                //坐标
                o.posCS = TransformObjectToHClip(i.posOS.xyz);

                //向量
                o.nDirWS = TransformObjectToWorldNormal(i.nDirOS);
                o.tDirWS = TransformObjectToWorldDir(i.tDirOS.xyz);
                o.bDirWS = cross(o.nDirWS, o.tDirWS) * i.tDirOS.w;
                o.vDirWS = GetCameraPositionWS() - TransformObjectToWorld(i.posOS);

                //UV
                o.uv0 = i.uv;

                return o;
            }

            half4 frag (v2f i) : SV_Target
            {
                //视差UV偏移
                half3x3 TBN = half3x3(normalize(i.tDirWS), normalize(i.bDirWS), normalize(i.nDirWS));
                half3 vDirWS = i.vDirWS;
                half3 vDirTS = normalize(TransformWorldToTangent(vDirWS, TBN));
                float3 origin;
                origin.z = _ScaleParams.z + _OffsetParams.z;
                origin.xy = origin.z / vDirTS.z * vDirTS.xy + i.uv0;
                float3 crossPosTS = GetRayMarchingCrossPoint(_ScaleParams, _OffsetParams, _MarchStep, _HeightMap, origin, -vDirTS);

                //高度采样
                float2 uv = _ScaleParams.xy * crossPosTS.xy + _OffsetParams.xy;
                half height = (crossPosTS.z - _OffsetParams.z) / _ScaleParams.z;
                
                //法线转换
                half3 nDirTS = UnpackNormal(tex2D(_NormalMap, uv));
                nDirTS.xy *= _NormalInt;

                //向量准备
                half3 nDirWS = normalize(TransformTangentToWorld(nDirTS, TBN));
                half3 lDirWS = GetMainLight().direction;

                //投影采样
                float3 shadowCrossPosTS = GetRayMarchingCrossPoint(_ScaleParams, _OffsetParams, _ShadowStep, _HeightMap, crossPosTS, TransformWorldToTangent(lDirWS, TBN));
                half shadowMask = step(shadowCrossPosTS.z, origin.z);

                //混合输出
                half3 finalCol = saturate(dot(nDirWS, lDirWS)) * height;
                finalCol = shadowMask * finalCol * _ShadowCol + (1.0 - shadowMask) * finalCol;
                return half4(finalCol, 1.0);
            }
            ENDHLSL
        }
    }
    FallBack "Hidden/Universal Render Pipeline/FallbackError"
}
复制成功