视差和法线、高度图回顾(翻译二十)
由于视角的原因,当调整摄像机位置时,观察到的事物的相对位置会发生变化,这种视觉现象称为视差。在坐火车高速行驶看窗外的景物,附近的物体看起来很大并且移动很快,而远处的背景看起来很小并且移动较慢。渲染时,相机使用透视模式时,也会出现视差。
视差纹理
之前翻译使用过法线贴图将表面不规则感添加到平滑表面。 它会影响照明,但不会影响表面的实际形状。 因此,该效果视差不明显,通过实现法线贴图基于视野深度的幻觉有许多限制。这一篇的目的就是解决该限制。
法线贴图效果回顾
下面给出许多albedo map 和 normal map差异对比:
如果没有法线贴图,表面看起来很平坦。 添加法线贴图会使它看起来好像具有不规则的表面。 但是,高度海拔差异看起来不明显。 当从入射视角与表面的夹角越趋于0,高度差越不明显。如果高程差异较大,则表面特征的相对视觉位置应由于视差而发生很大变化,但不会发生变化。 我们看到的视差是平坦的表面。
虽然可以增加法线贴图的强度,但这不会改变视差。同样,当法线贴图变得太强时,它会看起来很奇怪。它影响了平坦表面的光线的明暗变换,而视差效果它们确实是平的。所以法线贴图只适用于小的变化,但不会表现出明显的视差。
要获得真正的深度视差感,首先需要确定深度应该是多少。法线贴图不包含这些信息。所以我们需要一个高度图。这样,我们就可以创建一个基于高度信息的假视差效果,就像法线贴图创建一个假斜率一样。下面的贴图也称它是灰度图,黑色代表最低点,白色代表最高点。因为我们将使用这个贴图来创建一个视差效果,也称为视差图。
确保在导入时禁用sRGB(颜色纹理),这样在使用线性空间渲染时数据就不会被弄乱
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_关键字。将必需的编译器指令添加到base pass、additive pass和deferred pass。
1
2
#pragma shader_feature _NORMAL_MAP
#pragma shader_feature _PARALLAX_MAP
1
2
为什么不在ShadowCaster增加视差贴图?
当使用albedo贴图的alpha通道的透明度时,视差贴图只会影响阴影。即使是这样,在阴影贴图中的视差效果也很难被注意到。所以它通常不值得额外的计算时间。但是如果愿意,也可以将它添加到阴影施法者通道中。
为了访问新的属性,给我的照明添加相应的变量
1
2
3
4
5
sampler2D _ParallaxMap;
float _ParallaxStrength;
sampler2D _OcclusionMap;
float _OcclusionStrength;
为了能够自定义配置材质,在Extend ShaderGUI扩展中增加相应Enable与Disanble key的方法。
1
2
3
4
5
6
7
8
9
10
11
12
13
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);
}
}
坐标匹配
通过在fragment程序中调整纹理坐标,让平坦表面的某些部分看起来高低交错。创建一个应用视差函数,给它一个inout插值器参数。
1
2
void ApplyParallax (inout Interpolators i) {
}
在fragment程序使用插入的数据之前调用视差函数。_会有点异常是LOD衰落,_因为这取决于屏幕位置。先不调整这些坐标。
1
2
3
4
5
6
7
8
9
10
11
12
13
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
}
改变视差强度会导致纹理偏移。增加U坐标会使纹理向负的U方向移动,V坐标同理。这看起来不是视差效果,因为这是一个与视角无关的均匀位移。
随视角方向移动
视差是由相对于观察者的透视投影,所以必须改变纹理坐标。这意味着必须基于视图的方向来移动坐标,而视图的方向对于表面上每个片段都是不同的。
纹理坐标存在于切线空间中。为了调整这些坐标,需要知道视图在切线空间中的方向。这需要矩阵乘法对空间进行转换。在fragment-程序已经有了一个切线空间矩阵,但是它是用于从切线空间到世界空间的转换。在本例中,需要从对象空间转到切线空间。
视图方向向量定义为从表面到摄像机,需要归一化。我们可以在vertex程序中确定这个向量,转换它并将它传递给fragment程序。但是为了最终得到正确的方向,需要推迟归一化,直到插值完成后。添加切线空间视图方向作为一个新的插值成员变量。
1
2
3
4
5
6
7
8
9
10
11
struct InterpolatorsVertex {
#if defined(_PARALLAX_MAP)
float3 tangentViewDir : TEXCOORD8;
#endif
};
struct Interpolators {
#if defined(_PARALLAX_MAP)
float3 tangentViewDir : TEXCOORD8;
#endif
};
首先, 使用mesh网格数据中的原始顶点切向量和法向量,在顶点程序中创建一个从对象空间到切线空间的转换矩阵。因为我们只用它来变换一个向量而不是一个位置我们用一个3×3矩阵就足够了。
1
2
3
4
5
6
7
8
9
10
11
12
13
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
9
#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
1
2
3
4
5
6
7
8
//ObjSpaceViewDir内部实现?
//ObjSpaceViewDir函数是在UnityCG中定义的。它先将摄像机位置转换到对象空间,然后减去对象空间下顶点位置得到一个从顶点指向摄像机的向量,注意它还没有标准化.
inline float3 ObjSpaceViewDir (float4 v)
{
float3 objSpaceCameraPos = mul(unity_WorldToObject, float4(_WorldSpaceCameraPos.xyz, 1)).xyz;
return objSpaceCameraPos - v.xyz;
}
最后,我们可以在ApplyParallax函数使用切线空间视图方向了。首先,对它进行规格化normalize,把它变成一个合适的方向向量。然后,添加它的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),这不会导致位移。视角越浅,投影越大,位移效果也越大。
所有这一切的影响是表面似乎被拉向上的切线空间,看起来比它实际上更高,基于视差强度。
基于高度滑动
在基于高度这一点上,我们可以让表面看起来更高,但它仍然是一个均匀位移。下一步是使用视差贴图来缩放位移。采样贴图,使用它的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;
低的区域现在保持不变,而高的区域被向上拉。standard shader抵消了这种效果,所以低的区域也向下移动,而在中间的区域保持他们原来的位置。这是通过从原始高度数据中减去差值来实现的。
1
2
3
float height = tex2D(_ParallaxMap, i.uv.xy).g;
height -= 0.5;
height *= _ParallaxStrength;
这就产生了我们想要的视差效果,但它只在低强度下有效。不足的是位移位移变换的很快,会撕裂表面。
偏移视差映射算法
我们目前使用的视差映射技术被称为带偏移限制的视差映射。我们只是使用了视图方向的XY部分,它的最大长度是1。因此,纹理偏移量是有限的。这种效果不错,但不能代表正确的透视投影。
一个更精确的计算偏移量的物理方法是将高度场视为几何图形表面下的体积,并通过它拍摄一个视图射线。光线从相机发射到表面,从上面进入高度场体积,并持续发射直到它到达由场定义的表面。
如果高度场均匀为零,那么射线就会一直持续到体积的底部。它与物体的距离取决于光线进入物体时的角度。它没有限制。角度越浅,越远。最极端的情况是当视角趋于0时,光线射向无穷大。
为了找到合适的偏移量,我们必须缩放视图方向向量,通过除以它自己的Z分量来使它的Z分量变成1。因为我们以后不需要用Z,我们只需要用X和Y除以Z。
1
2
i.tangentViewDir = normalize(i.tangentViewDir);
i.tangentViewDir.xy /= i.tangentViewDir.z;
虽然这样可以得到一个更正确的投影,但它确实会使浅视角的视差效果恶化。standard着色器通过增加0.42偏差到Z减轻浅视角的视差效果恶化,所以它永远不会接近零。这扭曲了透视图,但使工件更易于管理。我们再加上这个偏差.
1
i.tangentViewDir.xy /= (i.tangentViewDir.z + 0.42);
通过上述多个步骤修正后, 现在我们的着色器与标准着色器支持同样的视差效果。视差映射可以应用于任何表面,投影假设切线空间是均匀的。曲面具有弯曲的切线空间,因此会产生物理上不正确的结果。只要视差强度和曲率很小,你就可以摆脱它。
同样,阴影坐标不会受到这个效果的影响。因此,阴影在强烈的视差的组合下看起来很奇怪,好像漂浮在表面上。
Parallax Configuration
你不同意Unity使用0.42的偏移值吗?或者你想使用一个不同的值,还是让它保持在0?或者你想用偏移限制代替吗?它是可以配置!
当你想使用偏移限制,定义PARALLAX_OFFSET_LIMITING在着色器。或者,通过定义PARALLAX_BIAS来设置要使用的偏差。
1
2
3
4
5
6
7
8
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
现在我们可以通过着色器的CGINCLUDE块来微调我们的视差效果。添加无偏差和限制偏移的选项,但将它们转换为注释,以坚持默认选项。
1
2
3
4
5
6
7
8
CGINCLUDE
#define BINORMAL_PER_FRAGMENT
#define FOG_DISTANCE
// #define PARALLAX_BIAS 0
// #define PARALLAX_OFFSET_LIMITING
ENDCG
Detail UV
视差贴图可以在主贴图上工作,但是我们还没有注意到副贴图。我们必须应用纹理坐标偏移到细节UV上。
首先,下面是一个包含网格模式的详细地图。它可以很容易地验证效果是否正确地应用于细节。
使用这个纹理作为材质的细节albedo贴图。设置二级贴图的平铺为10×10。这表明,细节紫外线确实仍然不受影响。
Standard也简单地添加了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);
Ray Marching-光线步进
然而,除了上述的偏移视差映射还有另外的视差算法:发射射线与高度场体积相交,确定其交点在表面上的位置,然后对该位置采样。 它通过在射线进入体积时的交点,对高度图进行一次采样。 但是,当看向任意一个角度时,这并不能准确告诉射线实际上与高度场相交的高度。
先假设入口点的高度与交点的高度相同,但这实际上只有在入口点和交点具有相同的高度时才是正确的。当偏移量不大且高度场变化不大时,它的效果仍然很好。但是,当偏移量太大或高度变化太快时,该算法就会出现问题,而这很可能是错误的。这就会造成表面撕裂。
如果我们能算出射线实际到达的高度场的位置,那么总能找到真正的可见表面点。这不能通过单个纹理样本来实现,我们必须沿着视图射线逐步移动,并每次都采样高度场,直到射线到达表面。该技术是RayMarching。
有各种不同的视差贴图使用raymarching。常见的是陡视差映射_Steep Parallax Mapping_、地形映射_Relief Mapping_和视差遮挡映射_Parallax Occlusion Mapping_。与使用单一纹理样本相比,它们能通过高度场来创建更好的视差效果。除此之外,它们还可以应用额外的阴影和技术来改进该算法。当我们做的匹配这些方法时,我会调用它。
自定义视差函数
标准着色器仅支持简单的偏移视差映射。 现在,我们要在自己的着色器中添加对视差光线Ray marching的支持。 但是,我们还要继续支持这种简单方法。 两者都需要采样height字段,因此将采样代码行放在单独的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
}
现在,我们将应用视差函数宏替换对视差偏移的硬编码调用,从而使视差方法更加灵活。如果没有定义它,我们将它设置为使用偏移量方法。
1
2
3
4
5
6
7
8
9
10
11
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
float2 ParallaxOffset (float2 uv, float2 viewDir) {
}
float2 ParallaxRaymarching (float2 uv, float2 viewDir) {
float2 uvOffset = 0;
return uvOffset;
}
现在可以通过定义_PARALLAX_FUNCTION_来改变着色器中的视差方法。
1
2
3
#define PARALLAX_BIAS 0
//#define PARALLAX_OFFSET_LIMITING
#define PARALLAX_FUNCTION ParallaxRaymarching
相交计算
为了找到视图射线到达高度场的点,我们需要对射线上的多个点进行采样并计算出在表面下方的位置。第一个采样点在顶部,我们在这里输入高度量,就像使用偏移方法一样。最后一个采样点就是射线到达体积底部的地方。我们会在这些端点之间均匀地添加额外的采样点。
假设每条射线进行10次采样。这意味着我们将对高度图采样10次而不是一次,所以这不是一个便宜计算方法。因为我们用了10个样本,所以步长是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 delta也有同样的效果,只需要计算一次。
1
float2 uvDelta = viewDir * (stepSize * _ParallaxStrength);
通过这种方式,无论视差强度如何,我们都可以继续使用0–1作为高度场的范围。 因此,射线的第一步高度始终为1。低于或高于该高度的表面点的高度由高度场定义。
1
2
3
4
float stepSize = 0.1;//步长
float2 uvDelta = viewDir * (stepSize * _ParallaxStrength);
float stepHeight = 1;//步高
float surfaceHeight = GetParallaxHeight(uv);
现在我们要沿着射线迭代。首先,每一步我们都会增加UV偏移量。视图向量指向摄像机,但我们是在向表面移动,所以我们需要减去UV delta。然后我们用步高来减小步长。然后我们再次对高度图采样。使用while循环重复上述步骤,直到采样完毕。
1
2
3
4
5
6
7
8
9
float stepHeight = 1;
float surfaceHeight = GetParallaxHeight(uv);
while (stepHeight > surfaceHeight)
{
uvOffset -= uvDelta;
stepHeight -= stepSize;
surfaceHeight = GetParallaxHeight(uv + uvOffset);
}
当编译时,会得到一个编译器警告和错误。这个警告告诉我们在循环中使用了梯度指令。这指的是循环中的纹理采样。GPU必须弄清楚使用哪个mipmap级别,它需要比较相邻片段使用的UV坐标。只有当所有片段执行相同的代码时,它才能对比。对于循环来说,这是不可能的,因为它可以提前终止,每个片段都可能不同。因此编译器将展开循环,这意味着它将一直执行所有9个步骤,而不管逻辑是否可以提前停止。相反,它随后使用确定性逻辑选择最终结果。
编译失败是因为编译器无法确定循环的最大迭代次数。它不知道这个最多是9。通过将while循环转换为执行限制的for循环来明确这一点。
1
2
3
4
5
6
for (int i = 1; i < 10 && stepHeight > surfaceHeight; i++)
{
uvOffset -= uvDelta;
stepHeight -= stepSize;
surfaceHeight = GetParallaxHeight(uv + uvOffset);
}
与简单的视差偏移方法相比,视差效果更加明显。较高的区域现在也正确地阻挡了我们后面较低区域的视野。我们还得到了明显的图层,总共10层。
更多步进
这个基本的光线行进方法最适合陡峭的视差贴图。效果的质量是由我们的样本分辨率决定的。一些方法根据视角使用可变的步骤。较浅的角度需要更多的步长,因为光线较长。但我们的样本量是固定的,所以我们不会这样做。
提高质量的明显方法是增加采样的次数,因此让其可配置。使用_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;
}
现在我们可以在着色器中控制步数。对于真正的高质量,将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
这让我们知道了它的效果能有多好,但它计算量太大了,一般不适合手机。所以把样本数设为10后,我们仍然可以看到视差效果看起来连续和平滑。然而,由视差遮挡引起的轮廓总是锯齿状的,MSAA并不能消除这一点,因为它只适用于几何图形的边缘,而不是纹理效果。只要不依赖深度缓冲区,后处理抗锯齿技术能解决。
我们当前的方法是沿着射线步进,直到到达表面以下的点,或者到达射线末端可能的最低点。然后我们用UV偏移处理那个点。但隐藏在表面之下的这个点,很可能会出现错误。这就是导致表面撕裂的原因。
增加步长数只会减少最大误差。使用足够的步骤,错误会变得更小,以至于我们无法再看到它。所以当一个表面总是从远处看,你可以用更少的步骤。距离越近,视角越小,需要的样本就越多。
步长之间插值
提高质量的一种方法是根据经验预测光线真正到达表面的位置。比如第一步在表面之上,下一步在表面之下。在这两步之间的某个点射线一定到达了表面。
两个射线点、和两个射线点到表面最近的点,能定义两条线段。因为光线和表面碰撞,这两条线段会相交。所以如果我们跟踪前面的步骤,我们可以在循环之后执行直线交叉。我们可以用这个信息来近似出真正的交点。
在for循环内,我们必须跟踪之前的UV偏移量、步长高度和表面高度。一般来说,这些等于循环之前的第一个样本。
1
2
3
float2 prevUVOffset = uvOffset;
float prevStepHeight = stepHeight;
float prevSurfaceHeight = surfaceHeight;
在循环之后,我们计算这些线的交点。我们可以使用这个插值之间的前点和后点的UV偏移。
1
2
3
4
5
6
float prevDifference = prevStepHeight - prevSurfaceHeight;
float difference = surfaceHeight - stepHeight;
float t = prevDifference / (prevDifference + difference);
uvOffset = lerp(prevUVOffset, uvOffset, t);
return uvOffset;
1
2
3
4
5
6
7
8
9
数学原理:
这两个线段定义在两个样本步骤之间的空间内。我们将这个空间的宽度设置为1。从前一步到最后一步的直线由点(0,a)和点(1,b)定义,其中a是前一步的高度,b是后一步的高度。因此,可以用线性函数'v(t) = a + (b - a)t'来定义视图线。同样地,面线由点(0,c)和(1,d)定义,函数's(t) = hlsl + (d - hlsl)t'。
交点存在于s(t) = v(t)'处。那么t的值是多少?
c + (d - c)t = a + (b - a)t
(d - c)t - (b - a)t = a - c
(a - c + d - b)t = a - c
t = (a - c) / (a - c + d - b)
注意:a - c是在t = 0处直线高度的绝对差。d - b是t = 1处的绝对高度差。
实际上,在这种情况下,我们可以使用插值器来缩放我们要添加到上一点上的UV偏移量。它可以归结为相同的东西,只是用了更少的数学。
1
2
float t = prevDifference / (prevDifference - difference);
uvOffset = prevUVOffset - uvDelta * t;
效果看起来好多了。我们现在假设表面在样本点之间是线性的,这可以防止最明显的分层假象。然而,它不能帮助我们检测我们是否错过了步骤之间的交集。我们仍然需要很多的样本来处理小的特征,轮廓和浅角度。
有了这个技巧,我们的方法类似于视差遮挡映射。虽然这是一个相对便宜的改进,但通过定义_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
在shader内定义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
步长搜索
通过在两个步长之间进行线性插值,我们假定表面在两个步长之间是笔直的。 但是,通常情况并非如此。 为了更好地处理不规则的高度场,我们必须在两个步长之间搜索实际的交点。 或至少接近它。
完成循环后,不要使用最后的偏移量,而是将偏移量调整到最后两个步长的中间位置。对该点的高度进行采样。如果我们结束在表面以下,向表面之上方向移动四分之一,并再次采样。如果我们在表面上结束,向表面之下方向移动四分之,并再次采样。不断重复这个过程。
上述方法是二分查找的一个应用。它与地形测绘方法最匹配。每走一步,路程减半,直到到达目的地。在我们的例子中,我们将简单地做固定次数,以达到预期的解决方案。一步,得到0.5。两步,得到0.25、0.75。三步,是0.125、0.375、0.625、0.875。注意,从第二步开始,每次采样提升分的辨率将翻倍。
为了控制是否使用此方法,我们定义_PARALLAX_RAYMARCHING_SEARCH_STEPS_。默认情况下将其设置为零,这意味着我们根本不进行搜索。如果它被定义为大于0,我们将不得不使用另一个循环。注意,这种方法与_PARALLAX_RAYMARCHING_INTERPOLATE_是不兼容的,因为我们不能再保证表面是交叉的最后两个步骤。当我们搜索的时候,禁用插值。
1
2
3
4
5
6
7
8
9
10
11
12
#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
uvDelta *= 0.5;
stepSize *= 0.5;
if (stepHeight < surfaceHeight) {
uvOffset += uvDelta;
stepHeight += stepSize;
}
else {
uvOffset -= uvDelta;
stepHeight -= stepSize;
}
surfaceHeight = GetParallaxHeight(uv + uvOffset);
调整着色器,所以它使用三个搜索步骤
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
结果看起来相当不错,但仍不完美。二分法搜索可以比简单的插值处理较浅的角度,但仍然需要相当多的搜索步骤,以摆脱分层。所以这是一个试验的问题,找出哪种方法在特定情况下最有效,需要多少步骤。
缩放对象和动态批处理
尽管我们的视差映射方法似乎可行,但存在一个隐藏的错误。 而且还把错误显示出来了。它显示了何时使用动态批处理来组合已缩放的对象。 例如,给我们的四边形一个像$(10,10,10)$的比例,然后复制它,将副本移到它下面一点。 假设在播放器设置中启用了此选项,这将触发Unity动态批处理四边形。
批处理开始时,视差效果将扭曲。 旋转相机时,这一点非常明显。 但是,这仅发生在游戏视图和构建中,而不发生在场景视图中。 请注意,standard着色器也存在此问题,但是当使用弱偏移视差效果时,您可能不会立即注意到它。
在批处理将它们合并到一个单一的网格中之后,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
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
































