原文 译文

已翻译 Unity渲染教程(二十):视差贴图

2017-11-02 3 434

翻译作者

版权所有,禁止匿名转载;禁止商业使用;禁止个人使用。

  译者: 崔嘉艺(milan21   审校:王磊(未来的未来)

  • 根据视野方向来偏移纹理坐标。
  • 使用一个高度字段来创建有深度的错觉。
  • 通过一个高度字段来跟踪一条射线。
  • 近似或是搜索一个相交点

这是关于渲染基础的系列教程的第二十部分。在前面的部分里我们涉及了图形处理器的实例化。这一次,我们将添加迄今为止不支持的标准着色器功能的最后一部分,也就是视差贴图。

Unity渲染教程(二十):视差贴图

靠近的看单个四边形。

视差贴图

由于透视带来的效果,当我们调整我们的视角的时候,我们会看到事物被观察到的相对位置发生了变化。这种视觉上的现象被称为视差。在高速前进的时看向侧边是最明显的。当快速移动的时候,附近的东西看起来很大,而遥远的背景看起来很小,并且移动较慢。

至少在透视模式下使用相机的时候,我们已经在渲染的时候考虑到了透视带来的影响。 因此,几何体会变现出视差效果。

我们还使用法线贴图将表面不规则的错觉加到平滑的三角形上。这会影响到光照,但是不影响三角形表面的实际形状。因此,这个效果不会显示视差的效果。这限制了我们通过法线地图可以添加的深度上的错觉。

测试场景

下面是一张反照率图贴图和法线贴图,表明这里有许多高度上的差异。

Unity渲染教程(二十):视差贴图 Unity渲染教程(二十):视差贴图

反照率图贴图和法线贴图。

导入这些纹理,然后创建一个使用它们的材质和一个着色器My First Lighting Shader。 用单个四边形创建一个新的场景,旋转(9000),使其平放着,并给它赋予这个材质。

Unity渲染教程(二十):视差贴图Unity渲染教程(二十):视差贴图

有法线贴图的四边形和没有法线贴图的四边形的效果对比图。

没有法线贴图的,四边形显然是很平坦的。添加法线贴图使这个四边形看起来像是表面不规则的。然而,高度上的差异似乎很小。当从一个比较浅的视角观察四边形的时候,这变得明显。 如果高度上的差异较大,表面特征的相对视觉位置由于视差会发生很大的变化,但是在我们这种情况里没有发生这个效果。我们看到的视差是平坦的表面。

Unity渲染教程(二十):视差贴图

当从一个比较浅的视角观察四边形的时候看到的效果。

我们可以提高法线贴图的强度,但这并不会改变视差。 此外,当法线贴图变得太强烈的时候,看起来会很奇怪。光照的效果告诉我们这是一个陡峭的斜坡,而视差告诉我们它是平坦的。 所以法线贴图只适用于不会出现明显视差变化的小的变化。

Unity渲染教程(二十):视差贴图

当法线贴图变得太强烈的时候,依然很平坦。

为了获得真正的深度感,我们首先需要确定应该有多深。法线贴图不包含此信息。 所以我们需要一个高度图。有了这个高度图,我们就可以会创建假的视差,就像我们创建假的斜坡一样。下面是我们材质的贴图。它是灰度图,黑色代表最低点,白色表示最高点。 因为我们将使用这个贴图创建一个视差效果,它通常被称为视差贴图而不是高度贴图。

Unity渲染教程(二十):视差贴图

用于视差的高度贴图。

确保在导入的时候禁用sRGB(颜色纹理),这样在使用线性渲染的时候数据不会被弄乱。

视差的着色器参数

为了能够使用视差贴图,我们必须为MyFirst Lighting Shader.着色器添加一个属性。 就像对遮挡效果所做的事情一样,我们也要给它一个强度参数来缩放整个效果。因为视差效果相当强,我们将其范围设置为0-0.1

1
2
3
4
5
[NoScaleOffset] _ParallaxMap ("Parallax", 2D) = "black" {}
_ParallaxStrength ("Parallax Strength", Range(0, 0.1)) = 0
 
[NoScaleOffset] _OcclusionMap ("Occlusion", 2D) = "white" {}
_OcclusionStrength ("Occlusion Strength", Range(0, 1)) = 1

视差贴图是一个着色器功能,我们使用_PARALLAX_MAP关键字来启用。将所需的编译器指令添加到基础渲染通道,附加渲染通道和延迟渲染的渲染通道中。

1
2
#pragma shader_feature _NORMAL_MAP
#pragma shader_feature _PARALLAX_MAP

  

阴影投射渲染通道不需要视差处理么?

我们的视差效果会影响纹理。 当使用反照率图的Alpha通道中的不透明度的时候,纹理仅影响阴影。它很少与视差贴图结合起来使用。即使是这样,阴影贴图中的视差效果也很少会被注意到。 所以通常不值得花费额外的计算时间来这么做。但是,如果您想这么做的话,您可以把视差处理添加到阴影投射渲染通道,并相应地调整My Shadows 着色器。

要访问新的属性,请将相应的变量添加到 MyLighting

1
2
3
4
5
sampler2D _ParallaxMap;
float _ParallaxStrength;
 
sampler2D _OcclusionMap;
float _OcclusionStrength;

为了让材质可配置化,可以在MyLightingShaderGUI中添加DoParallax方法。 您可以复制它的DoOcclusion方法并更改属性名称,标签和关键字。像遮挡贴图一样,Unity的标准着色器希望将高度数据存储在纹理的G通道中。所以我们也会这样做,并在工具提示中指出这一点。

1
2
3
4
5
6
7
8
9
10
11
12
void DoParallax () {
    MaterialProperty map = FindProperty("_ParallaxMap");
    Texture tex = map.textureValue;
    EditorGUI.BeginChangeCheck();
    editor.TexturePropertySingleLine(
        MakeLabel(map, "Parallax (G)"), map,
        tex ? FindProperty("_ParallaxStrength") : null
    );
    if (EditorGUI.EndChangeCheck() && tex != map.textureValue) {
        SetKeyword("_PARALLAX_MAP", map.textureValue);
    }
}

DoMain中调用新的方法,在函数DoNormalsDoOcclusion之间。

1
2
3
4
5
6
7
void DoMain () {
    
    DoNormals();
    DoParallax();
    DoOcclusion();
    
}

现在可以为我们的材质分配视差贴图了。这样做以后,将其强度设为一个比较低的值,比如说是0.03

Unity渲染教程(二十):视差贴图

具有视差属性的材质。

调整纹理的坐标

为了应用视差效果,我们必须使表面的某些部分看起来像是在别的地方。这是通过调整片断程序中的纹理坐标来完成的。在My Lighting.MyFragmentProgram上方的某个地方创建一个ApplyParallax函数来做这个事情。这个函数将在需要的时候调整内插数据,因此请给它一个inout Interpolators参数。

1
2
void ApplyParallax (inout Interpolators i) {
}

在使用内插数据之前,应该在我们的片段程序中调用ApplyParallax函数。唯一例外的是 LOD的渐变,这是因为它取决于屏幕的位置。我们不会调整这些坐标。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
FragmentOutput MyFragmentProgram (Interpolators i) {
    UNITY_SETUP_INSTANCE_ID(i);
    #if defined(LOD_FADE_CROSSFADE)
        UnityApplyDitherCrossFade(i.vpos);
    #endif
     
    ApplyParallax(i);
 
    float alpha = GetAlpha(i);
    #if defined(_RENDERING_CUTOUT)
        clip(alpha - _Cutoff);
    #endif
 
    
}

首先,通过简单地将视差强度加到U坐标上来调整纹理坐标。只有在启用视差功能的时候才能做这一点。

1
2
3
4
5
void ApplyParallax (inout Interpolators i) {
    #if defined(_PARALLAX_MAP)
        i.uv.x += _ParallaxStrength;
    #endif
}

Unity渲染教程(二十):视差贴图

偏移U坐标。

改变视差强度现在会使纹理发生滚动。 增加U坐标将纹理移动到负U的方向。 这看起来不像视差效果,因为它是一个均匀的位移,而且它与视点的位置独立。

沿着视线方向偏移坐标

视差由相对于观察者的透视投影引起。所以我们必须考虑到这一点来偏移纹理坐标。这意味着我们必须根据视角方向来偏移坐标,这对于每个片段程序是不同的。

Unity渲染教程(二十):视差贴图

视角方向沿着表面会发生变化。

纹理坐标存在于切线空间中。为了调整这些坐标,我们需要在切线空间中知道视角的方向。这将需要一个空间变换,而空间变换意味着矩阵乘法。我们已经在片段着色器中有一个切线空间的矩阵,但是它们是从切线空间到世界空间的变换。在这种情况下,我们需要向另一个方向变换。我们可以将另一个矩阵传递给片段程序并在片段程序中使用这个矩阵,但是这么做会开销很大。

视角的方向被定义为从表面到相机的矢量,并且做了归一化。 我们可以在顶点程序中确定此向量,然后将其转换到片段程序。我们推迟归一化的执行时间,直到插值后才进行归一化,我们最终得到正确的方向。那么我们只需要将正切空间的视图方向加进来作为新的内插器就可以了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct InterpolatorsVertex {
    
 
    #if defined(_PARALLAX_MAP)
        float3 tangentViewDir : TEXCOORD8;
    #endif
};
 
struct Interpolators {
    
 
    #if defined(_PARALLAX_MAP)
        float3 tangentViewDir : TEXCOORD8;
    #endif
}; 

  

我们有空间来容纳第九个插值器么?

当目标是着色器模型3以及以上的硬件,是的,有足够的空间。但是目标是在着色器模型3之下的硬件,那么我们智能使用八个通用高精度插值器。当我们目标是着色器模型3以及以上的硬件的时候,我们可以使用TEXCOORD8。 不支持这种的硬件通常不是很强大,所以您不会想在这些硬件上使用视差贴图的。

我们可以在顶点程序中使用来自网格数据的原始顶点切线和法向量创建一个对象空间到切线空间的变换矩阵。因为我们只用它来转换一个向量 - 而不是一个位置 - 我们可以用3×3矩阵来满足要求。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
InterpolatorsVertex MyVertexProgram (VertexData v) {
    
 
    ComputeVertexLightColor(i);
 
    #if defined (_PARALLAX_MAP)
        float3x3 objectToTangent = float3x3(
            v.tangent.xyz,
            cross(v.normal, v.tangent.xyz) * v.tangent.w,
            v.normal
        );
    #endif
 
    return i;
}

接下来,我们需要在对象空间中的顶点位置到视点的方向,这样我们就可以使用ObjSpaceViewDir函数。变换会使用我们的矩阵,这样我们就有需要的信息。

1
2
3
4
5
6
7
8
#if defined (_PARALLAX_MAP)
    float3x3 objectToTangent = float3x3(
        v.tangent.xyz,
        cross(v.normal, v.tangent.xyz) * v.tangent.w,
        v.normal
    );
    i.tangentViewDir = mul(objectToTangent, ObjSpaceViewDir(v.vertex));
#endif

 

什么是ObjSpaceViewDir

ObjSpaceViewDir函数在UnityCG中定义。它将相机位置转换到对象空间中去,然后从其中减去所提供的顶点位置,这个位置是定义在对象空间中的。请注意,这会产生从顶点指向相机的向量。它还没有进行归一化。这正是我们想要的。

1
2
3
4
5
inline float3 ObjSpaceViewDir (float4 v) {
    float3 objSpaceCameraPos =
        mul(unity_WorldToObject, float4(_WorldSpaceCameraPos.xyz, 1)).xyz;
    return objSpaceCameraPos - v.xyz;
}

现在我们可以访问ApplyParallax中的切线空间的视角方向。首先,对它进行归一化,使其成为一个正确的方向向量。 然后,将其XY分量添加到由视差强度调制的纹理坐标上去。

1
2
3
4
5
6
void ApplyParallax (inout Interpolators i) {
    #if defined(_PARALLAX_MAP)
        i.tangentViewDir = normalize(i.tangentViewDir);
        i.uv.xy += i.tangentViewDir.xy * _ParallaxStrength;
    #endif
}

这样做有效地将视角方向投影到纹理表面上。当以90°的角度看直线的时候,切线空间中的视角方向等于表面法线(0,0,1),这导致不需要位移。视角越浅,投影越大,位移效应越大。

Unity渲染教程(二十):视差贴图

用于UV偏移的投影后的视图方向。

所有这一切的效果是,基于视差强度,表面看起来在切线空间中向上拉起,看起来高于实际的位置。

Unity渲染教程(二十):视差贴图

沿着投影后的视角方向便宜UV坐标。

基于高度的滑动

到目前为止,我们可以使表面看起来比实际更高,但它仍然是均匀的位移。 下一步是使用视差贴图来缩放位移。对贴图进行采样,使用其G通道作为高度,应用视差强度,并用它来调节位移。

1
2
3
4
i.tangentViewDir = normalize(i.tangentViewDir);
float height = tex2D(_ParallaxMap, i.uv.xy).g;
height *= _ParallaxStrength;
i.uv.xy += i.tangentViewDir.xy * height;

Unity渲染教程(二十):视差贴图

偏移由高度进行调制。

低的区域现在仍然在那里,而高的区域则被拉高。标准着色器抵消了这种影响,所以低的区域也向下移动,而中间区域仍然保留在它们原来的位置上。这是通过从原始高度数据中减去1/2来完成的。

1
2
3
float height = tex2D(_ParallaxMap, i.uv.xy).g;
height -= 0.5;
height *= _ParallaxStrength;

Unity渲染教程(二十):视差贴图

视差贴图在合理的强度的效果,以及超越合理的强度的效果。

这产生了我们正在寻找的视差效果,但它只能在低强度下工作。位移迅速变得太大而撕开了表面。

正确投影的偏移

我们目前使用的视差贴图技术被称为具有偏移限制的视差贴图。我们只是使用视角方向的XY部分,其最大长度为1。因此,纹理的偏移是有限的。这个效果可以得到一些体面的结果,但并不代表有正确的透视投影。

更精确的计算偏移量的方法是将高度场视为几何表面以下的体积,并通过它来发射一个视线。这个射线从相机射到表面,从表面的上方进入高度场体积,并继续前进,直到它击中由高度场所定义的表面为止。

如果高度场均匀为零,则射线将继续前进到达体积的底部。距离有多远取决于射线进入体积的角度。这个距离没有限制。角度越浅的话,距离越远。 最极端的情况是当视角接近零的时候,会使得使射线射向无限远的地方。

Unity渲染教程(二十):视差贴图

射线到达底部,在有限便宜和正确偏移下的效果对比。

为了找到适当的偏移量,我们必须对视角方向向量进行缩放,所以它的Z分量变为1,我们将它除以自己的Z分量来做到这一点。因为我们以后不需要使用Z分量,所以我们只需要把X分量和Y分量除以Z分量。

1
2
i.tangentViewDir = normalize(i.tangentViewDir);
i.tangentViewDir.xy /= i.tangentViewDir.z;

虽然这导致更正确的投影,但这么做会使得浅视角的效果产生瑕疵。标准着色器通过向Z分量添加偏差来减轻这一点,这里用 的偏差是0.42,所以它从不接近于零。这扭曲了透视,但是让这些瑕疵更易于管理。 让我们也加上这个偏移。

1
i.tangentViewDir.xy /= (i.tangentViewDir.z + 0.42);

 

Unity渲染教程(二十):视差贴图

类似标准着色器中的视差贴图效果。

我们的着色器现在支持与标准着色器相同的视差效果。虽然视差贴图可以应用于任何表面,但是投影假设切线空间是均匀的。曲面具有弯曲的切线空间,因此会产生不正确的结果。只要视差强度和曲率小,就可以避免这一点。

Unity渲染教程(二十):视差贴图

一个球体上的视差贴图效果。

此外,阴影坐标也不受视差贴图的影响。 结果就是,在强视差的情况下,阴影可以看起来很奇怪,似乎漂浮在表面的上方。

Unity渲染教程(二十):视差贴图

阴影不受视差贴图影响。

视差效果的配置

您是否赞同Unity将偏移设为0.42? 您想使用不同的值,还是让它在零? 还是想使用偏移来进行限制? 让我们来把这些内容变成可配置化!

当要使用偏移来进行限制的时候,请在着色器中定义PARALLAX_OFFSET_LIMITING。 否则,通过定义PARALLAX_BIAS设置要使用的偏移。调整ApplyParallax使之成为可能。

1
2
3
4
5
6
7
8
9
void ApplyParallax (inout Interpolators i) {
    #if defined(_PARALLAX_MAP)
        i.tangentViewDir = normalize(i.tangentViewDir);
        #if !defined(PARALLAX_OFFSET_LIMITING)
            i.tangentViewDir.xy /= (i.tangentViewDir.z + PARALLAX_BIAS);
        #endif
        
    #endif
}

当没有定义的时候,让我们使用0.42的默认偏移。我们可以通过在ApplyParallax中定义它来做到这一点,如果没有其他人这么做的话。 请注意,宏定义不关心函数范围,它们总是全局的。

1
2
3
4
5
6
#if !defined(PARALLAX_OFFSET_LIMITING)
    #if !defined(PARALLAX_BIAS)
        #define PARALLAX_BIAS 0.42
    #endif
    i.tangentViewDir.xy /= (i.tangentViewDir.z + PARALLAX_BIAS);
#endif

现在我们可以通过My FirstLighting Shader中的CGINCLUDE块来微调视差效果。 我添加了无偏移和偏移限制的选项,但将其转换为注释以遵守默认选项。

1
2
3
4
5
6
7
8
9
    CGINCLUDE
 
    #define BINORMAL_PER_FRAGMENT
    #define FOG_DISTANCE
     
//  #define PARALLAX_BIAS 0
//  #define PARALLAX_OFFSET_LIMITING
 
    ENDCG


细节UV

视差贴图与主贴图一起使用,但是我们还没有关注二级地贴图。我们还必须将纹理坐标的偏移应用于细节UV

首先,下面是一个包含网格图案的细节贴图。 它可以很容易地验证效果是否正确地应用于细节贴图上。

Unity渲染教程(二十):视差贴图

细节网格纹理。

使用这张纹理作为我们材质的细节反照率图。 将二级贴图的平铺设置为10×10。 这显示了细节UV确实还没有受到影响。

Unity渲染教程(二十):视差贴图
Unity渲染教程(二十):视差贴图

细节UV还没有受到影响。

标准着色器还将UV偏移量添加到细节UV上,这个数据存储在UV内插器的ZW分量中。让我们来做同样的事情。

1
2
3
4
5
6
float height = tex2D(_ParallaxMap, i.uv.xy).g;
height -= 0.5;
height *= _ParallaxStrength;
float2 uvOffset = i.tangentViewDir.xy * height;
i.uv.xy += uvOffset;
i.uv.zw += uvOffset;

细节可能会发生一些改变,但是它们绝对不符合视差效果。那是因为我们平铺了我们的二级贴图。这样可以将细节UV缩小10倍,使视差偏移十倍以上。 我们也必须将细节平铺应用于偏移上。标准着色器不考虑这一点。

1
i.uv.zw += uvOffset * _DetailTex_ST.xy;

实际上,缩放应该是相对于主UV平铺来进行,以防它被设置为1×1以外的其他东西。这样做的话可以确保它始终有效。

1
i.uv.zw += uvOffset * (_DetailTex_ST.xy / _MainTex_ST.xy);

 

Unity渲染教程(二十):视差贴图

正确的细节UV

偏移不应该和主贴图的平铺一起放缩么?

您可以这样做,而不是将细节偏移除以主贴图的平铺。 如果使用的是这种方法的话,视差强度将随着主贴图的平铺进行放缩。然而,当增加主贴图的平铺的时候,通常会想要较弱的视差效果。 所以让平铺影响效果是有意义的,这是通过不补偿来实现的。

项目工程文件下载地址:unitypackage

Raymarching

这里的主要思路是,我们的视差效应通过发射一条射线来通过高度体积并确定其在表面上的位置来计算。它是通过在光线进入体积的位置对高度图进行一次采样来实现的。但是当我们看着一个角度的时候,这并没有告诉我们射线实际上与高度场相交的高度。

Unity渲染教程(二十):视差贴图

正确的偏移和猜测的偏移的效果对比图。

我们目前的方法假设入射点的高度与交点处的高度相同。这只有在入射点和交点实际上具有相同的高度的时候才是正确的。当偏移量不大并且高度场没有太大变化的时候,它仍然可以工作得很好。然而,当偏移量变得太大或是高度变化太快的时候,我们最终会得到一个比较粗矿的猜测,这可能是错误的。 这是导致表面分裂的瑕疵。

如果我们可以找出射线实际与高度场相碰撞的位置,那么我们可以随时找到真实的可见表面点。这不能用单个纹理采样来完成。我们必须以小的步骤沿着视线移动,每次对高度场进行采样,直到我们到达表面为止。 这种技术被称为raymarching

Unity渲染教程(二十):视差贴图

沿着视线前进。

使用raymarching的视差贴图有各种各样的变体。最着名的是陡峭视差贴图,浮雕贴图和视差遮挡贴图。 他们的名字并没有告诉您他们做了什么,而是说明了他们想要实现什么。基本上,它是以三种方式来做同样的事情。 与使用单个纹理样本相比,它们通过使用高度场射线以创建更好的视差效果。此外,他们可以应用附加的渲染以及其他技术来改进算法。当我们正在做的事情匹配其中一种方法的时候,我会把它的名字列出来。

视差的函数

标准着色器仅支持简单的偏移视差贴图。 我们现在将为我们自己的着色器添加对视差raymarching方法的支持。但是我们还要继续支持简单的方法。这两种方法都需要采样高度字段,因此将采样代码行放在单独的GetParallaxHeight函数中。 此外,这两种方法的投影后的视线方向和偏移量的最终应用将是相同的。 所以把偏移计算也放在单独的函数中。它只需要原始的UV坐标和处理好的视线方向作为参数。 其结果是对UV进行偏移。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
float GetParallaxHeight (float2 uv) {
    return tex2D(_ParallaxMap, uv).g;
}
 
float2 ParallaxOffset (float2 uv, float2 viewDir) {
    float height = GetParallaxHeight(uv);
    height -= 0.5;
    height *= _ParallaxStrength;
    return viewDir * height;
}
     
void ApplyParallax (inout Interpolators i) {
    #if defined(_PARALLAX_MAP)
        i.tangentViewDir = normalize(i.tangentViewDir);
        #if !defined(PARALLAX_OFFSET_LIMITING)
            #if !defined(PARALLAX_BIAS)
                #define PARALLAX_BIAS 0.42
            #endif
            i.tangentViewDir.xy /= (i.tangentViewDir.z + PARALLAX_BIAS);
        #endif
         
        float2 uvOffset = ParallaxOffset(i.uv.xy, i.tangentViewDir.xy);
        i.uv.xy += uvOffset;
        i.uv.zw += uvOffset * (_DetailTex_ST.xy / _MainTex_ST.xy);
    #endif
}

现在,我们将使用PARALLAX_FUNCTION宏来替换ParallaxOffset的硬编码调用,来使得我们的视差方法更加灵活。 如果这个宏没有定义,我们将其设置为使用偏移方法。

1
2
3
4
5
6
7
8
9
10
11
12
void ApplyParallax (inout Interpolators i) {
    #if defined(_PARALLAX_MAP)
        
 
        #if !defined(PARALLAX_FUNCTION)
            #define PARALLAX_FUNCTION ParallaxOffset
        #endif
        float2 uvOffset = PARALLAX_FUNCTION(i.uv.xy, i.tangentViewDir.xy);
        i.uv.xy += uvOffset;
        i.uv.zw += uvOffset * (_DetailTex_ST.xy / _MainTex_ST.xy);
    #endif
}

为我们的raymarching方法创建一个新的函数。这个新的函数必须匹配ParallaxOffset的行为,所以给它相同的参数和返回类型。 最初它什么都不做,返回一个零偏移。

1
2
3
4
5
6
7
8
float2 ParallaxOffset (float2 uv, float2 viewDir) {
    
}
 
float2 ParallaxRaymarching (float2 uv, float2 viewDir) {
    float2 uvOffset = 0;
    return uvOffset;
}

现在可以通过定义PARALLAX_FUNCTION来改变My First Lighting Shader 中的视差方法。将其设置为ParallaxRaymarching

1
2
3
    #define PARALLAX_BIAS 0
//  #define PARALLAX_OFFSET_LIMITING
    #define PARALLAX_FUNCTION ParallaxRaymarching

在高度场中步进

为了找到视角射线与高度场发生碰撞的点,我们必须对射线上的多个点进行采样,并找出那个恰好在表面下方的位置。第一个采样点在顶部,我们输入的高度体积的位置,就像使用偏移方法一样。 最后一个采样点将是光线与体积底部相撞击的位置。我们将在这些点之间按照平均间隔来添加额外的采样点。

让我们对每个射线进行十个采样。 这意味着我们要对高度图进行十次采样,而不是一次,所以这不是一个开销低廉的效果。

因为我们使用十个采样,我们的步长是0.1。这是我们沿着视线移动的因素,最后成为我们UV的步进值。

1
2
3
4
5
6
float2 ParallaxRaymarching (float2 uv, float2 viewDir) {
    float2 uvOffset = 0;
    float stepSize = 0.1;
    float2 uvDelta = viewDir * stepSize;
    return uvOffset;
}

为了应用视差强度,我们可以调整每个步进值的采样高度。 但缩放UV的步进值具有相同的效果,我们只需要做一次就可以了。

1
float2 uvDelta = viewDir * (stepSize * _ParallaxStrength);

通过这样做,无论视差强度如何,我们都可以使用0-1作为高度场的范围。因此,射线上的第一个采样点的高度总是为1。低于或高于这个点的表面点的高度由高度场限定。

1
2
3
4
5
float stepSize = 0.1;
float2 uvDelta = viewDir * (stepSize * _ParallaxStrength);
 
float stepHeight = 1;
float surfaceHeight = GetParallaxHeight(uv);

现在我们必须沿着射线进行迭代。我们在每一步都会增加UV的偏移量。 视西角向量指向的是相机,但是我们正在朝向表面移动,所以我们实际上必须减去UV的偏移量。然后我们用步长减小每一步的高度。 然后我们再次对高度图进行采样。只要我们保持在表面上方的话,我们就会继续这样做,在第一次采样之后最多进行九次采样。 我们可以使用一个while循环来对这个事情进行编程。

1
2
3
4
5
6
7
8
float stepHeight = 1;
float surfaceHeight = GetParallaxHeight(uv);
 
while (stepHeight > surfaceHeight) {
    uvOffset -= uvDelta;
    stepHeight -= stepSize;
    surfaceHeight = GetParallaxHeight(uv + uvOffset);
}

当我们尝试编译这个代码的时候,我们得到了一个着色器编译器警告和错误。 警告告诉我们在循环中使用了渐变指令。这是指我们的循环中的纹理采样。 图形处理器必须确定要使用哪个mipmap级别,为此需要比较相邻片段所使用的UV坐标。只有当所有片段执行相同的代码的时候,才能做到这一点。这对于我们的循环来说是不可能的,因为它可以提前终止,这就导致了可以根据片段有所不同。所以编译器将展开循环,这意味着它将始终执行所有的九个步骤,无论我们的逻辑是否建议我们可以早点停止。相反,它使用确定性逻辑来选择最终结果。

编译失败是因为编译器无法确定我们循环的最大迭代次数。它不知道这最多有九个迭代。所以让我们把这个显式的表达出来,把我们的while循环变成一个有强制执行上限的for循环。

1
2
3
4
5
for (int i = 1; i < 10 && stepHeight > surfaceHeight; i++) {
    uvOffset -= uvDelta;
    stepHeight -= stepSize;
    surfaceHeight = GetParallaxHeight(uv + uvOffset);
}

 

Unity渲染教程(二十):视差贴图

一共10步的Raymarching方法的效果,没有偏移,没有上限。

图形处理器可以使用一个实际的循环么?

图形处理器可以使用一个实际的循环,但我们必须去掉渐变指令。 这可以通过自己来确定UV的导数并手动控制mipmap级别来实现。使用导数是本教程中不会介绍的高级主题。即使这样,片段也被并行处理。 基本上,一起计算的一批片段的性能由需要最多次迭代的片段所决定。所以任何潜在的性能增益都是可变的和不可预测的,并且会因图形处理器而异。因此需要进行广泛的测试来确定哪种方法对于特定的硬件来说是最佳的。

与简单偏移方法的区别是显而易见的。视差效应更显着。更高的区域现在也正确地阻止了我们对其背后的较低地区的视野。我们也有了明显的层次,总共十个层次。

使用更多步来逼近

这种基本的raymarching方法与陡峭视差贴图方法最贴近。效果的质量由我们采样的分辨率来决定。 基于视角的不同,一些方法会使用数量可变的步来逼近。更浅的角度需要更多步来逼近,因为射线更长。但是我们只限于固定数量的采样,所以我们不会这样做。

提高质量的明显方法是提高采样的数量,所以让我们把这个信息进行可配置化。 使用PARALLAX_RAYMARCHING_STEPS,默认值为10,而不是固定的步长和迭代次数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
float2 ParallaxRaymarching (float2 uv, float2 viewDir) {
    #if !defined(PARALLAX_RAYMARCHING_STEPS)
        #define PARALLAX_RAYMARCHING_STEPS 10
    #endif
    float2 uvOffset = 0;
    float stepSize = 1.0 / PARALLAX_RAYMARCHING_STEPS;
    float2 uvDelta = viewDir * (stepSize * _ParallaxStrength);
 
    float stepHeight = 1;
    float surfaceHeight = GetParallaxHeight(uv);
 
    for (
        int i = 1;
        i < PARALLAX_RAYMARCHING_STEPS && stepHeight > surfaceHeight;
        i++
    ) {
        uvOffset -= uvDelta;
        stepHeight -= stepSize;
        surfaceHeight = GetParallaxHeight(uv + uvOffset);
    }
 
    return uvOffset;
}

现在我们可以控制My FirstLighting Shader中的步数。 为了真正的高品质,将PARALLAX_RAYMARCHING_STEPS定义为100

1
2
3
4
    #define PARALLAX_BIAS 0
//  #define PARALLAX_OFFSET_LIMITING
    #define PARALLAX_RAYMARCHING_STEPS 100
    #define PARALLAX_FUNCTION ParallaxRaymarching

Unity渲染教程(二十):视差贴图

100步迭代的Raymarching方法的效果。.

这启迪了我们的一个想法,它可以做的更好,但是一般来说开销太昂贵了。 因此,将采样数设置回为10。我们仍然可以看到,视差效果看起来连续平滑。 然而,由视差遮挡引起的轮廓总是混叠的。 多重采样抗锯齿(MSAA)没有去掉这一点,因为它只适用于几何的边缘,而不是纹理的效果。只要不依赖于深度缓冲区,后处理抗锯齿技术将会起作用。

我们不能针对每个片段写入深度缓冲区吗?

这确实可以在足够高级的硬件上实现,使得其他几何与高度字段正确相交并应用上了阴影。虽然这么做开销并不低。

我们目前的做法是沿着射线进行步进,直到我们到达表面下方的一个点,或者在射线末端的最低点。然后我们使用UV偏移。 但是很可能这一点在表面之下,这就引入了一个错误。这是导致表面分裂成层的原因。

增加步数可以简单地减少最大误差。使用足够多的步数,误差变得更小,甚至小于一个可见的片段,以致于我们都不能再察觉它的存在。所以当一个表面总是从远处看到的时候,您可以用更少的步来处理这个表面。 您越接近,您的视角越小,您需要的采样数量就越多。

Unity渲染教程(二十):视差贴图

误差依赖于采样的分辨率。

在层间进行插值

提高质量的一个方法是通过对射线实际在表面射出的地方进行有根据的猜测。 会出现在某一步我们在表面以上,然后在下一步的时候,我们在表面的下面。在这两个步骤之间,射线必然碰到了表面。

射线点和表面点对定义了两个线段。 因为射线和表面相撞,这两条线段相交。 所以如果我们跟踪上一个步的话,我们可以在循环之后执行一个线段与线段的相交。我们可以使用这些信息来逼近真正的相交点。

Unity渲染教程(二十):视差贴图

执行一个线段与线段的相交。

在迭代过程中,我们必须跟踪以前的UV偏移,步长的高度和表面高度。在这个循环之前,这些初始值都等于第一个采样的样本值。

1
2
3
4
5
6
7
8
9
10
11
12
13
float2 prevUVOffset = uvOffset;
float prevStepHeight = stepHeight;
float prevSurfaceHeight = surfaceHeight;
 
for (
    
) {
    prevUVOffset = uvOffset;
    prevStepHeight = stepHeight;
    prevSurfaceHeight = surfaceHeight;
     
    
}

在循环之后,我们计算出线段相交的位置。 我们可以使用这个相交的位置在前一个UV偏移和后一个偏移之间进行插值。

1
2
3
4
5
6
7
8
for
 
float prevDifference = prevStepHeight - prevSurfaceHeight;
float difference = surfaceHeight - stepHeight;
float t = prevDifference / (prevDifference + difference);
uvOffset = lerp(prevUVOffset, uvOffset, t);
 
return uvOffset;

 

 

这里的数学是如何起作用的?

两个线段在两个采样步之间的空间内定义。我们将这个空间的宽度设为1。从上一个步到下一个步之间的线段由点(0a)和(1b)进行定义,其中a是前一步的高度,b是后一步的高度。因此,视线可以由线性函数 v(t)=a+(b−a)t进行定义。同样,表面线段由点(0c)、(1d)和函数st= c +d-ct进行定义。

交点存在于st= vt)中,那么t的值是多少?

Unity渲染教程(二十):视差贴图

注意,a-ct = 0时线段高度之间的绝对差,d-bt = 1时线段高度的绝对差。

Unity渲染教程(二十):视差贴图

线段与线段的相交关系。                           

实际上,在这种情况下,我们可以使用内插器来缩放我们必须添加到上一点的UV偏移量。可以归结为同样的事情,只是用较少的数学计算。

1
2
float t = prevDifference / (prevDifference - difference);
uvOffset = prevUVOffset - uvDelta * t;


Unity渲染教程(二十):视差贴图

10步加上内插后的效果。

结果看起来好多了。我们现在假设表面在采样点之间是线性的,这阻止了最明显的分层瑕疵。但是,它无法帮助我们检测何时错过了两个步骤之间的相交。我们仍然需要许多采样来处理小的特征、轮廓和浅角度。

有了这个技巧,我们的方法就像视差遮挡贴图一样。虽然这是一个相对廉价的改进,但让我们通过PARALLAX_RAYMARCHING_INTERPOLATE的定义使为成为可选。

1
2
3
4
5
6
#if defined(PARALLAX_RAYMARCHING_INTERPOLATE)
    float prevDifference = prevStepHeight - prevSurfaceHeight;
    float difference = surfaceHeight - stepHeight;
    float t = prevDifference / (prevDifference + difference);
    uvOffset = prevUVOffset - uvDelta * t;
#endif

My First LightingShader 中定义PARALLAX_RAYMARCHING_INTERPOLATE以使用它。

1
2
3
4
5
    #define PARALLAX_BIAS 0
//  #define PARALLAX_OFFSET_LIMITING
    #define PARALLAX_RAYMARCHING_STEPS 10
    #define PARALLAX_RAYMARCHING_INTERPOLATE
    #define PARALLAX_FUNCTION ParallaxRaymarching

在层之间进行搜索

通过在两个步骤之间进行线性内插,我们假设表面在这两个步骤之间是直的。不过,实际情况往往不是如此。为了更好地处理不规则的高度场,我们必须在两个步骤之间搜索实际的交点。或者至少要靠近这个实际的交点。

完成循环以后,不要使用最后一个偏移量,而是将偏移量调整到最后两步的中间位置。在最后两步的中间位置采样高度。如果我们最终到达表面以下,则将上一个点移回四分之一的偏移量并再次采样。 如果我们最终在表面上方,将上一个点向前移动四分之一的偏移量并再次采样。 再一次做同样的事情,但这一次是移动八分之一。继续重复此过程,直到您满意为止。

Unity渲染教程(二十):视差贴图

更靠近相交点。

上述方法是二进制搜索的应用。与浮雕贴图映射方法最匹配。每一步覆盖的距离减半,直到到达目的地为止。在我们这个例子中,我们会简单按照一个固定的次数来做这个事情,达到一个想要的精度。如果是一步的话,我们总是在最后两点之间的中间位置上,0.5。如果是两步的话,我们最终会停在0.250.75上。如果是三步的话,分别为0.1250.3750.6250.875 等等。 请注意,从第二步开始,每次采样的有效分辨率加倍。

为了控制是否使用这种方法,我们来定义PARALLAX_RAYMARCHING_SEARCH_STEPS。让这个值默认为零,这意味着我们根本不搜索。如果定义的值高于零,我们将不得不使用另一个循环。 请注意,这种方法与PARALLAX_RAYMARCHING_INTERPOLATE不兼容,因为我们无法再保证表面在最后两步之间是相交的。所以当我们搜索的时候,禁用插值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
for
 
#if !defined(PARALLAX_RAYMARCHING_SEARCH_STEPS)
    #define PARALLAX_RAYMARCHING_SEARCH_STEPS 0
#endif
#if PARALLAX_RAYMARCHING_SEARCH_STEPS > 0
    for (int i = 0; i < PARALLAX_RAYMARCHING_SEARCH_STEPS; i++) {
    }
#elif defined(PARALLAX_RAYMARCHING_INTERPOLATE)
    float prevDifference = prevStepHeight - prevSurfaceHeight;
    float difference = surfaceHeight - stepHeight;
    float t = prevDifference / (prevDifference + difference);
    uvOffset = prevUVOffset - uvDelta * t;
#endif

该循环还执行与原始循环相同的基本工作。调整偏移和步长,然后对高度场进行采样。

1
2
3
4
5
for (int i = 0; i < PARALLAX_RAYMARCHING_SEARCH_STEPS; i++) {
    uvOffset -= uvDelta;
    stepHeight -= stepSize;
    surfaceHeight = GetParallaxHeight(uv + uvOffset);
}

但是UV增量和步长大小每次迭代减半。

1
2
3
4
5
6
7
8
for (int i = 0; i < PARALLAX_RAYMARCHING_SEARCH_STEPS; i++) {
    uvDelta *= 0.5;
    stepSize *= 0.5;
 
    uvOffset -= uvDelta;
    stepHeight -= stepSize;
    surfaceHeight = GetParallaxHeight(uv + uvOffset);
}

此外,如果我们已经在表面以下了,我们必须向相反的方向移动。

1
2
3
4
5
6
7
8
9
10
11
12
uvDelta *= 0.5;
stepSize *= 0.5;
 
if (stepHeight < surfaceHeight) {
    uvOffset += uvDelta;
    stepHeight += stepSize;
}
else {
    uvOffset -= uvDelta;
    stepHeight -= stepSize;
}
surfaceHeight = GetParallaxHeight(uv + uvOffset);

让我们调整下My FirstLighting Shader ,以便它使用三个搜索步骤,看看是什么效果。

1
2
3
4
5
6
    #define PARALLAX_BIAS 0
//  #define PARALLAX_OFFSET_LIMITING
    #define PARALLAX_RAYMARCHING_STEPS 10
    #define PARALLAX_RAYMARCHING_INTERPOLATE
    #define PARALLAX_RAYMARCHING_SEARCH_STEPS 3
    #define PARALLAX_FUNCTION ParallaxRaymarching

 

Unity渲染教程(二十):视差贴图

10步再加上3次二进制搜索的效果。

结果看起来还不错,虽然还不完美。与简单插值相比,二进制搜索可以更好地处理浅角度,但是您仍然需要相当多的搜索步骤来摆脱分层。所以这是一个通过实践才能解决的问题,找出哪种方法在特定情况下最有效,需要多少步骤。

缩放对象和动态批次合并

虽然我们的视差贴图方法似乎起作用了,但是存在一个隐藏的问题。当动态批次合并用于组合缩放的对象的时候,这个隐藏的问题就会体现出来。举个简单的例子来说,给我们一个四方体,比如像是(10,10,10),并复制它,然后将副本稍微移动一下。 假设在播放器设置中启用了此选项,这将触发Unity动态批次合并这些四方体。

当使用批次合并的时候,视差效果将变得扭曲。旋转相机的时候这一点非常明显。然而,这只会发生在游戏视图以及构建中,而不会发生在场景视图中。请注意,标准着色器也有这个问题,但是当使用弱偏移的视差效果的时候,您可能不会马上注意到这个问题。

Unity渲染教程(二十):视差贴图

动态批次合并会产生奇怪的结果。

问题在于,Unity在将它们组合成单个网格之后,Unity不会对批次合并的几何的正交和切线向量进行归一化。 因此,顶点数据正确的假设不再成立。

为什么Unity不归一化这些向量?

这可能是一个有意的行为,因为要是对这些向量进行归一化的话,动态批次合并可能变得开销过于昂贵而不实用。

顶点法线和正切向量不归一化只是我们的一个问题,因为我们将视图向量转换为顶点程序中的切线空间。对于其他一切,使用前数据将被归一化。

解决方案是在我们构建对象空间到切线空间的矩阵之前对向量进行归一化。 因为这只对于那些需要动态批次合并的缩放几何是必需的。所以让我们把它设为一个可选项,具体是否使用取决于是否定义了PARALLAX_SUPPORT_SCALED_DYNAMIC_BATCHING

1
2
3
4
5
6
7
8
9
10
11
12
#if defined (_PARALLAX_MAP)
    #if defined(PARALLAX_SUPPORT_SCALED_DYNAMIC_BATCHING)
        v.tangent.xyz = normalize(v.tangent.xyz);
        v.normal = normalize(v.normal);
    #endif
    float3x3 objectToTangent = float3x3(
        v.tangent.xyz,
        cross(v.normal, v.tangent.xyz) * v.tangent.w,
        v.normal
    );
    i.tangentViewDir = mul(objectToTangent, ObjSpaceViewDir(v.vertex));
#endif

现在我们可以使My FirstLighting Shader来正确的使用动态批次合并了。

1
2
3
4
5
6
7
    #define PARALLAX_BIAS 0
//  #define PARALLAX_OFFSET_LIMITING
    #define PARALLAX_RAYMARCHING_STEPS 10
    #define PARALLAX_RAYMARCHING_INTERPOLATE
    #define PARALLAX_RAYMARCHING_SEARCH_STEPS 3
    #define PARALLAX_FUNCTION ParallaxRaymarching
    #define PARALLAX_SUPPORT_SCALED_DYNAMIC_BATCHING

 

Unity渲染教程(二十):视差贴图

结果正确的动态批次合并。

关于渲染基础的系列教程就到此结束了。您现在应该对Unity的渲染管道如何工作以及标准着色器如何执行有了一个清楚的概念。从现在开始,我们可以转向更先进的渲染和着色技术。新的系列教程的第一篇文章将将于201710月发布。

工程文件下载地址:unitypackage

PDF下载地址:PDF


【版权声明】

原文作者未做权利声明,视为共享知识产权进入公共领域,自动获得授权。

分类: 标签: Renderin
举报 分享

想免费获取内部独家PPT资料库? 观看行业大牛直播?

立即扫码加群

评论(3)

3个评论

GAD译馆

更多