乐趣区

关于游戏开发:实时渲染常用纹理技术总结视差映射

【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 重量会使得偏移量过大,留神下图的缝隙处(应用的是最开始的高度贴图例图)。

当眼帘越靠近平行于立体时偏移量越大

为了改善这个问题,咱们能够限度偏移量,使其永远不大于理论的高度(如下图中偏移量本应是灰色箭头线示意的向量,而限度后则是彩色箭头线示意的向量)。方程为 P adj=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 对应的纹理偏移量 h uv,而后从上到下遍历每一层:用 h uv偏移纹理坐标,对高度贴图进行采样,如果以后层的深度值小于采样的值,咱们就持续向下进行,直到以后层的深度大于高度图的采样后果,这意味着咱们找到了低于外表的第一个层(即 认为检测到眼帘和高度图的相交地位,只管是近似的)。

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. 地形裂缝:手绘
https://www.youku.com/video/XNTk3MjAyOTAyMA==
基于视差映射的地形裂缝

3. 动静云雾模仿:利用噪声
https://www.youku.com/video/XNTk3NDEyMTEyNA==
应用视差映射的动静云雾

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


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

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

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

退出移动版