实时渲染常用纹理技术总结:视差映射

实时渲染常用纹理技术总结:视差映射

【USparkle专栏】如果你深怀绝技,爱“搞点研究”,乐于分享也博采众长,我们期待你的加入,让智慧的火花碰撞交织,让知识的传递生生不息!

一、概述

视差映射(Parallax Mapping)是一种类似于法线贴图的纹理技术,它们都能显著增强模型/纹理表面细节并赋予其凹凸感,但法线贴图所带来的凹凸感不会随着视角改变,也不会彼此阻挡。例如,如果你看真实的砖墙,在越垂直于墙面朝向的视角,你是看不到砖之间的缝隙的,砖墙的法线贴图永远不会显示这种类型的遮挡,因为它只会改变法线来影响直接光照的结果。


仅使用法线贴图、没有正确的遮挡关系的错误效果

所以最好让凹凸感实际影响表面上每个像素的位置,我们可以通过高度贴图来实现这个需求。


举例

最简单的方法莫过于使用大量顶点,然后根据从上图中采样的高度值去偏移顶点位置坐标——位移映射(Displayment Mapping),可以得到下图中左图的效果(顶点密度为100*100)。但是这样的顶点数量并非实时渲染的游戏所能承受(或者说值得优化),而顶点数量过少的话就会出现非常不平滑的块状现象,如下图中右图的效果(顶点密度为10*10)。于是就有聪明的人想出了可以偏移顶点纹理坐标——视差映射(Parallax Mapping),这样我们用一个Quad也能做出下图中左图的真实效果,先放上源码


不同顶点密度下位移映射技术的效果对比

二、原理

那怎么偏移纹理坐标来做出凹凸感呢?我们必须从观察到的现象入手:

假设我们真有这样一个粗糙、凹凸不平的表面(比如通过密集顶点偏移后得到),那么当我们以某一视线方向V看向表面时,我们应该看到的是B点(即视线和高度图的交点)。但我们前面也说了,我们用的是一个Quad,所以实际看到的应该是A点。视差映射的作用就是偏移A处的纹理坐标到B处的纹理坐标,这样即便我们看到的点是A,采样结果却是B处,从而模拟出高度差异,所以我们要解决的就是如何在A处获取B处的纹理坐标。


原理

仔细观察上图,其实A、B均在视线方向V所在的直线上,所以我们的偏移方向就是归一化的视线方向,偏移量则为A处采样高度图的结果H(A),所以偏移向量为图中P¯,并且我们需要沿着纹理坐标(UV)所在的平面偏移,所以偏移量为P¯在平面上的投影,那么实际向A点看到的是图中的H(P),这意味着我们得到的其实是近似B点的结果。

因为我们需要沿着纹理坐标(UV)所在的平面偏移,那么就有必要选择切线空间(也就是把视线方向转到切线空间再去偏移纹理坐标),这样我们也就不用担心模型有任何的旋转时偏移量不沿着UV平面上了。原理见法线贴图,这就是开头强烈建议你先了解法线贴图的原因。

对任意一点的纹理坐标P、归一化的视线方向V、高度贴图采样结果h得到的偏移结果Padj:

除以Z分量是为了归一化:因为当视线越垂直于平面时,Z分量越大。但是当视线接近平行于平面,Z分量很小,除以Z分量会使得偏移量过大,注意下图的缝隙处(使用的是最开始的高度贴图例图)。


当视线越接近平行于平面时偏移量越大

为了改善这个问题,我们可以限制偏移量,使其永远不大于实际的高度(如下图中偏移量本应是灰色箭头线表示的向量,而限制后则是黑色箭头线表示的向量)。方程为Padj=P+h*Vxy(也就是不除以Z分量,计算速度也更快)。


可以和上面偏移量过大的结果做对比

但是因为视线方向的XY分量仍会随着视线方向越平行于平面而变大,所以偏移量仍会变大。

还有一个问题:在大多情况下,我们上面的做法都能得到好的结果,但是当高度快速变化时结果可能不尽如人意:得到的结果H(P)与B点(蓝点)相差甚远。

三、实现

1. 视差映射
我们可以根据前一个Part所讲的基本原理来进行简单的尝试,这里我们仍会使用法线贴图,因为我在总结法线贴图的文章中也说过,法线贴图经常根据高度贴图计算得到,但法线贴图影响的是法线,通过光照来表现凹凸细节,而视差映射是利用偏移纹理坐标来获取其他位置的采样结果来表现高度,所以二者配合就好像双剑合璧,威力大增。

float2 ParallaxMapping(float2 uv, half3 viewDir)
{
    float height = tex2D(_HeightMap, uv).r * _HeightScale;
    float2 offset = 0;
#if _OFFSETLIMIT //为了对比是否限制偏移量的效果
    offset = viewDir.xy;
#else 
    offset = viewDir.xy / viewDir.z;
#endif
    float2 p = offset * height; 
    return uv - p;
}

half3 viewDirWS = normalize(UnityWorldSpaceViewDir(positionWS));
float2 uv = i.uv.xy;
#ifdef _PARALLAXMAPPING
    half3 viewDirTS = normalize(mul(viewDirWS, float3x3(i.T2W0.xyz, i.T2W1.xyz, i.T2W2.xyz)));
    uv = ParallaxMapping(uv, viewDirTS);
#endif
//然后用偏移后的纹理坐标采样各种贴图即可


左图为仅使用法线贴图的效果,右图为加入视差映射的效果

你可以看到在偏移纹理坐标后可能在边缘的位置出现问题,因为边缘偏移后可能会超出0到1的范围,对于Quad来说,可以简单地丢弃超出范围的部分,但是对于其他复杂模型简单丢弃可能并不能解决问题。

if (uv.x > 1 || uv.y > 1 || uv.x < 0 || uv.y < 0)
    discard;


丢弃后干净了许多

在Unity的Shader源码中也为我们提供了视差映射的函数:

// Calculates UV offset for parallax bump mapping
inline float2 ParallaxOffset( half h, half height, half3 viewDir )
{
    h = h * height - height/2.0;
    float3 v = normalize(viewDir);
    v.z += 0.42;
    return h * (v.xy / v.z);
}

虽然现在的效果已经足够好了,但是在上一个Part最后提出的两个问题仍然存在,在Real Time Rendering第四版的6.8.1节,提供了大量解决这些问题的参考资料,我们下面总结的其中最常见的,想更深层次了解的话推荐大家去阅读原文。

2. 陡峭视差映射
出现上一个Part最后所提到的两个问题的根本原因都是偏移量过大导致的,所以我们可以效仿Ray Marching,使用逐步逼近的方式寻找到合适的偏移量,但这样就势必要多次采样,性能消耗更大,最初采用这种思想的就是陡峭视差映射(Steep Parallax Mapping)。

如下图,将深度范围(0(平面位置)->1(最大采样深度))划分为具有相同深度h的多个层(下图层深h=0.2),求出层深h对应的纹理偏移量huv,然后从上到下遍历每一层:用huv偏移纹理坐标,对高度贴图进行采样,如果当前层的深度值小于采样的值,我们就继续向下进行,直到当前层的深度大于高度图的采样结果,这意味着我们找到了低于表面的第一个层(即认为检测到视线和高度图的相交位置,尽管是近似的)。


T表示遍历次数,紫色点为当前层深度值,浅蓝色点为采样的深度值

float2 ParallaxMapping(float2 uv, float3 viewDir)
{
    // 优化:根据视角来决定分层数(因为视线方向越垂直于平面,纹理偏移量较少,不需要过多的层数来维持精度)
    float layerNum = lerp(_MaxLayerNum, _MinLayerNum, abs(dot(float3(0,0,1), viewDir)));//层数
    float layerDepth = 1 / layerNum;//层深
    float2 deltaTexCoords = 0;//层深对应偏移量
#if _OFFSETLIMIT //建议使用偏移量限制,否则视线方向越平行于平面偏移量过大,分层明显
    deltaTexCoords = viewDir.xy / layerNum * _HeightScale;
#else
    deltaTexCoords = viewDir.xy / viewDir.z / layerNum * _HeightScale;
#endif
    float2 currentTexCoords = uv;//当前层纹理坐标
    float currentDepthMapValue = tex2D(_HeightMap, currentTexCoords).w;//当前纹理坐标采样结果
    float currentLayerDepth = 0;//当前层深度
    // unable to unroll loop, loop does not appear to terminate in a timely manner
    // 上面这个错误是在循环内使用tex2D导致的,需要加上unroll来限制循环次数或者改用tex2Dlod
    // [unroll(100)]
    while(currentLayerDepth < currentDepthMapValue)
    {
        currentTexCoords -= deltaTexCoords;
        // currentDepthMapValue = tex2D(_HeightMap, currentTexCoords).r;
        currentDepthMapValue = tex2Dlod(_HeightMap, float4(currentTexCoords, 0, 0)).r;
        currentLayerDepth += layerDepth;
    }
    return currentTexCoords;
}

现在的效果就几近真实了:


右图为陡峭视差映射,目前最小层数为10,最大层数为30

但是当我们以越平行与表面的角度去看时,即便层数会随视角增加,但仍有很明显的分层现象:

最简单的方法就是继续增加层数,但这势必会大大影响性能(事实上,现在已经很严重了)。有些旨在修复这个问题的方法:不用低于表面的第一个层,而是在相交前后的深度层之间(高于表面的最后一个层和低于表面的第一个层之间)进行插值找出更匹配的相交位置。两种最流行的解决方法叫做浮雕视差映射(Relief Parallax Mapping)和视差遮挡映射(Parallax Occlusion Mapping),Relief Parallax Mapping更精确一些,但是比Parallax Occlusion Mapping性能开销更多,我们来看看这两种方案。

3. 视差遮挡映射
以相交前后的深度层的高度贴图采样值与两层的深度值之间的距离作为线性插值的权重,然后对前后两层对应的纹理坐标进行线性插值即可。如下图的H(T3)和H(T2),两个分别由蓝线、紫线、黄线的相似三角形,蓝线的长度即高度贴图采样值和对应层深度的距离,这样我们就可以根据相似三角形得到紫线之间的比例,直接可以对应到纹理坐标偏移结果(即Tp对应的偏移量,故更加接近相交点)。

// 陡峭视差映射的代码
//......
// get texture coordinates before collision (reverse operations)
float2 prevTexCoords = currentTexCoords + deltaTexCoords;
// get depth after and before collision for linear interpolation
float afterDepth  = currentDepthMapValue - currentLayerDepth;
float beforeDepth = tex2D(_HeightMap, prevTexCoords).r - currentLayerDepth + layerDepth;
// interpolation of texture coordinates
float weight = afterDepth / (afterDepth - beforeDepth);
float2 finalTexCoords = prevTexCoords * weight + currentTexCoords * (1.0 - weight);

return finalTexCoords;  


非常完美,没有分层现象

4. 浮雕视差映射
在说浮雕视差映射之前我们先来看看浮雕映射:我们不像陡峭视差映射那样分层,而是通过二分法在深度范围(0->1)之间寻找最佳值:


二分法

如图,我们取AB的中点1,用1替换掉B,再取1和A之间的中点2,用2替掉A,再取1和2的中点3,即我们想要的视线和高度图的交点,这就是二分法的流程。但是在某些情况下,可能出现问题:


视线和高度图可能有多个交点

在图中视线方向,我们使用二分法就会得到3,但是实际上3已经被遮挡了,我们得到的应该是上面那个蓝点。这时我们可以利用陡峭视差映射的结果:如下图,先通过陡峭视差映射找到低于表面的第一个层(3),再和A做二分查找,这就是为什么被称为浮雕视差映射。

但是依然能优化,因为陡峭视差映射已经能得到相交前后的深度层了(高于表面的最后一个层和低于表面的第一个层,比如上图中2、3),那我们直接在这两个深度层之间进行二分查找即可:通过代码足以理解,其实就是更细分了,所以比视差遮挡映射更精确。依然有轻微的分层,但基本看不见了。并且因为相邻两层之间深度差异就是层深,所以也不用像视差遮挡映射一样计算高于表面的最后一个位置,不过显然后者不需要再细分而是插值,所以性能要更好。

// 陡峭视差映射的代码
//......
// 二分查找
float2 halfDeltaTexCoords = deltaTexCoords / 2;
float halfLayerDepth = layerDepth / 2;
currentTexCoords += halfDeltaTexCoords;
currentLayerDepth += halfLayerDepth;

int numSearches = 5; // 5次基本上就最好了,再多也看不出来了
for(int i = 0; i < numSearches; i++)
{
halfDeltaTexCoords = halfDeltaTexCoords / 2;
halfLayerDepth = halfLayerDepth / 2;
currentDepthMapValue = tex2D(_HeightMap, currentTexCoords).r;
if(currentDepthMapValue > currentLayerDepth)
{
currentTexCoords -= halfDeltaTexCoords;
currentLayerDepth += halfLayerDepth;
}
else
{
currentTexCoords += halfDeltaTexCoords;
currentLayerDepth -= halfLayerDepth;
}
}

return currentTexCoords;

5. 加入阴影
最能表现遮挡的莫过于阴影了,而且也是非常必要的,目前我们使用的砖墙因为偏移深度较小,所以没有自遮挡的阴影看上去也很好,但是加入阴影后效果要更棒(当然更适用于偏移深度较大的情况):


为了让阴影更明显我加大了高度/深度缩放以及阴影的强度

做阴影的思路更简单了,我们可以利用视差遮挡映射的结果,反过来向上找相交点,如果有则意味着被遮挡了,并且阴影的强度可以根据相交点个数决定,因为越深越容易被遮挡,相交点个数越多,阴影就越强,这样可以做出明暗平滑过渡的阴影。

// 输入的initialUV和initialHeight均为视差遮挡映射的结果
float ParallaxShadow(float3 lightDir, float2 initialUV, float initialHeight)
{
float shadowMultiplier = 1; //默认没有阴影
if(dot(float3(0, 0, 1), lightDir) > 0) //Lambert
{
                //根据光线方向决定层数(道理和视线方向一样)
float numLayers = lerp(_MaxLayerNum, _MinLayerNum, abs(dot(float3(0, 0, 1), lightDir)));
float layerHeight = 1 / numLayers; //层深
float2 texStep = 0; //层深对应偏移量
        #if _OFFSETLIMIT
        texStep = _HeightScale * lightDir.xy / numLayers;
        #else
                texStep = _HeightScale * lightDir.xy / lightDir.z / numLayers;
        #endif
                // 继续向上找是否还有相交点
float currentLayerHeight = initialHeight - layerHeight; //当前相交点前的最后层深
float2 currentTexCoords = initialUV + texStep;
float heightFromTexture = tex2D(_HeightMap, currentTexCoords).r;
                float numSamplesUnderSurface = 0; //统计被遮挡的层数
while(currentLayerHeight > 0) //直到达到表面
{
if(heightFromTexture <= currentLayerHeight) //采样结果小于当前层深则有交点
numSamplesUnderSurface += 1; 

currentLayerHeight -= layerHeight;
currentTexCoords += texStep;
heightFromTexture = tex2Dlod(_HeightMap, float4(currentTexCoords, 0, 0)).r;
}
shadowMultiplier = 1 - numSamplesUnderSurface / numLayers; //根据被遮挡的层数来决定阴影强度
}
return shadowMultiplier;
}


但是现在的阴影偏硬,且有分层效果

软阴影的做法:优化都在注释中,可以和上面的代码对比。重点是不根据相交层数决定阴影的强度!!!

// 输入的initialUV和initialHeight均为视差遮挡映射的结果
float ParallaxShadow(float3 lightDir, float2 initialUV, float initialHeight)
{
    float shadowMultiplier = 0;
    if (dot(float3(0, 0, 1), lightDir) > 0) //只算正对阳光的面
    {
        // 根据光线方向决定层数(道理和视线方向一样)
float numLayers = lerp(_MaxLayerNum, _MinLayerNum, abs(dot(float3(0, 0, 1), lightDir)));
float layerHeight = initialHeight / numLayers; //从当前点开始计算层深(没必要以整个范围)
        float2 texStep = 0; //层深对应偏移量
    #if _OFFSETLIMIT
texStep = _HeightScale * lightDir.xy / numLayers;
    #else
        texStep = _HeightScale * lightDir.xy / lightDir.z / numLayers;
    #endif
        // 继续向上找是否有相交点
float currentLayerHeight = initialHeight - layerHeight; //当前相交点前的最后层深
float2 currentTexCoords = initialUV + texStep;
float heightFromTexture = tex2D(_HeightMap, currentTexCoords).r;
int stepIndex = 1; //向上查找次数
        float numSamplesUnderSurface = 0; //统计被遮挡的层数
while(currentLayerHeight > 0) //直到达到表面
{
    if(heightFromTexture < currentLayerHeight) //采样结果小于当前层深则有交点
            {
numSamplesUnderSurface += 1;              
                float atten = (1 - stepIndex / numLayers); //阴影的衰减值:越接近顶部(或者说浅处),阴影强度越小
                // 以当前层深到高度贴图采样值的距离作为阴影的强度并乘以阴影的衰减值
float newShadowMultiplier = (currentLayerHeight - heightFromTexture) * atten;
shadowMultiplier = max(shadowMultiplier, newShadowMultiplier);
    }

    stepIndex += 1;
    currentLayerHeight -= layerHeight;
    currentTexCoords += texStep;
    heightFromTexture = tex2Dlod(_HeightMap, float4(currentTexCoords, 0, 0)).r;
}

if(numSamplesUnderSurface < 1) //没有交点,则不在阴影区域
    shadowMultiplier = 1;
else 
    shadowMultiplier = 1 - shadowMultiplier;
    }
    return shadowMultiplier;
}


十分完美的软阴影

四、制作方法

1. 程序纹理的灰度值
可以利用大量程序生成纹理的技术(噪声、SDF、计算几何.......)

2. 通过明暗关系计算
我们使用的颜色贴图(Albedo/Diffuse)中常常包含了很丰富的明暗细节,如Photo Shop>滤镜>3D>生成凹凸(高度)图,可以利用的一个信息是自带的明暗关系(比如下图墙壁的缝隙是黑的)

3. 手绘+使用图像处理

4. 用高精度模型生成
我们前面说了在游戏开发中常见的做法是在建模软件中制作高精度的模型,调好效果后简化成低精度网格导入引擎使用,而高精度模型本身就使用了大量顶点表现细节,可以使用雕刻工具做出,可以展UV后把修改量写入一张贴图作为高度贴图导出。

五、应用

视差映射是提升场景细节非常好的技术,可以寻求难以置信的效果,但是使用的时候还是要考虑到它会带来一点不自然,所以大多数时候视差映射用在地面和墙壁表面,这些情况下查明表面的轮廓并不容易,同时观察方向往往趋向于垂直于表面。这样视差映射的不自然也就很难能被注意到了。

1. 墙面:PS生成


颜色贴图-法线贴图-视差映射

2. 地形裂缝:手绘


基于视差映射的地形裂缝

3. 动态云雾模拟:利用噪声


使用视差映射的动态云雾

4. 地形上的轨迹:动态生成


这是侑虎科技第1403篇文章,感谢作者别看着我笑了供稿。欢迎转发分享,未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:465082844)

作者主页:https://www.zhihu.com/people/LuoXiaoC

再次感谢别看着我笑了的分享,如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:465082844)