本教程介绍了
交互式地图以及如何使用着色器在Unity中创建它们。
这种效果可以作为更复杂技术的基础,例如全息投影或电影《黑豹》中的沙盘。
本教程的灵感来自
Baran Kahyaoglu发布的推文,显示了他为
Mapbox创建的
示例 。
该场景(不包括地图)取自Unity Visual Effect Graph Spaceship演示(请参见下文),可在
此处下载。
第1部分。顶点偏移
效果剖析
您会立即注意到的第一件事是地理地图是
平坦的 :如果将它们用作纹理,则它们将缺少对应地图区域的真实3D模型所具有的三维。
您可以应用此解决方案:创建游戏中所需区域的3D模型,然后将地图中的纹理应用于该模型。 这将有助于解决问题,但是需要花费很多时间,并且无法实现视频Baran Kahyaoglu中“滚动”的效果。
显然,最好使用技术含量更高的方法。 幸运的是,可以使用着色器来更改3D模型的几何形状。 在他们的帮助下,您可以将任何飞机变成我们需要的该地区的山谷和山脉。
在本教程中,我们使用智利
的Quillota地图
,该地图以其独特的丘陵而闻名。 下图显示了绘制在圆形网格上的区域的纹理。
尽管我们看到了丘陵和山脉,但它们仍然完全平坦。 这破坏了现实主义的幻想。
拉伸法线
使用着色器更改几何形状的第一步是称为
法线拉伸的技术。 她需要
一个顶点修改器 :可以操纵3D模型各个顶点的功能。
使用顶点修改器的方式取决于所使用着色器的类型。 在本教程中,我们将更改
Surface标准着色器 -您可以在Unity中创建的着色器类型之一。
有很多方法可以操纵3D模型的顶点。 大多数顶点着色器教程中介绍的最早的方法之一就是
拉伸法线 。 它包括将每个顶点“推出”(
拉伸 ),这使3D模型看起来更肿胀。 “外部”是指每个顶点沿法线方向移动。
对于光滑的表面,此方法效果很好,但是在顶点连接不良的模型中,此方法会产生奇怪的伪像。 在我的第一个教程之一《
着色器的温和介绍》中很好地解释了这种效果,在该教程中,我展示了如何
拉伸和
引入 3D模型。
将拉伸的法线添加到曲面着色器非常容易。 每个表面着色器都有一个
#pragma
,该
#pragma
用于传输其他信息和命令。 一个这样的命令是
vert
,这意味着
vert
函数将用于处理3D模型的每个顶点。
编辑的着色器如下:
#pragma surface surf Standard fullforwardshadows addshadow vertex:vert ... float _Amount; ... void vert(inout appdata_base v) { v.vertex.xyz += v.normal * _Amount; }
由于我们要更改顶点的位置,因此,如果我们希望模型正确在自身上投射阴影,则还需要使用
addshadow
。
什么是appdata_base?如您所见,我们添加了一个vertices修饰符(
vert
)函数,该函数将名为
appdata_base
的
结构作为参数。 此结构存储有关3D模型的每个单独顶点的信息。 它不仅包含
顶点位置 (
v.vertex
),还包含其他字段,例如
法线方向 (
v.normal
)和与该顶点关联的
纹理信息 (
v.texcoord
)。
在某些情况下,这还不够,我们可能需要其他属性,例如
顶点颜色 (
v.color
)和
切线方向 (
v.tangent
)。 可以使用多种其他
appdata_tan
结构(包括
appdata_tan
和
appdata_full
指定顶点修饰符,这些
appdata_tan
结构以较低的性能
appdata_full
为代价提供了更多信息。 您可以在
Unity3D Wiki中阅读有关
appdata
(及其变体)的更多信息。
vert如何返回值?top函数没有返回值。 如果您熟悉C#语言,则应该知道结构是通过值传递的,也就是说,当v.vertex
更改时v.vertex
这仅影响v
的副本,其范围受函数主体的限制。
但是, v
也被声明为inout
,这意味着它既用于输入又用于输出。 您所做的任何更改都会更改变量本身,该变量将传递给vert
。 关键字inout
和out
在计算机图形学中经常使用,它们可以与C#中的ref
和out
大致相关。
使用纹理拉伸法线
我们上面使用的代码可以正常工作,但远没有我们想要达到的效果。 原因是我们不想将所有顶点拉伸相同的数量。 我们希望3D模型的表面与相应地理区域的山谷和山脉相匹配。 首先,我们需要以某种方式存储和检索有关地图上每个点升高了多少的信息。 我们希望拉伸受纹理的影响,在纹理中对风景的高度进行编码。 这样的纹理通常称为
高度图 ,但根据上下文,通常也称为
深度图 。 收到有关高度的信息后,我们将能够基于高度图修改平面的拉伸。 如图所示,这将使我们能够控制区域的上升和下降。
查找您感兴趣的地理区域的卫星图像和相关的海拔图非常简单。 以下是本教程中使用的火星卫星图(上方)和高度图(下方):
我在另一系列教程
“内部的Facebook 3D照片:视差着色器” (
翻译为Habré)中详细讨论了深度图的概念。
在本教程中,我们将假定高度图以灰度图像形式存储,其中黑白分别对应于较低和较高的高度。 我们还需要这些值
线性缩放,即色差,例如
0.1
对应于
0
到
0.1
之间或
0.9
到
1.0
之间的高度差。 对于深度图,这并不总是正确的,因为许多深度图以
对数刻度存储深度信息。
要对纹理进行采样,需要两个信息元素:纹理本身和我们要采样
的点的
UV坐标 。 后者可以通过存储在
appdata_base
结构中的
texcoord
字段进行访问。 这是与当前正在处理的顶点关联的UV坐标。
表面函数中的纹理采样是使用
tex2D
完成的,但是当我们处于
,则需要
tex2Dlod
。
在下面的代码段中,称为
_HeightMap
的纹理用于修改对每个顶点执行的拉伸值:
sampler2D _HeightMap; ... void vert(inout appdata_base v) { fixed height = tex2Dlod(_HeightMap, float4(v.texcoord.xy, 0, 0)).r; vertex.xyz += v.normal * height * _Amount; }
为什么不能将tex2D用作顶点函数?
如果查看Unity为Standard Surface Shader生成的代码,您会注意到它已经包含了如何对纹理进行采样的示例。 特别是,它使用内置的
tex2D
函数在
表面函数 (称为
surf
)中采样
主要纹理 (称为
_MainTex
)。
实际上,
tex2D
用于从纹理中采样像素,而不管纹理中存储的内容,颜色或高度如何。 但是,您可能会注意到
tex2D
不能在顶点函数中使用。
原因是
tex2D
不仅从纹理读取像素。 她还根据与相机的距离来决定使用哪个版本的纹理。 该技术称为
mipmapping :它使您可以使用较小版本的单个纹理,并可以在不同距离自动使用该纹理。
在表面功能中,着色器已经知道
要使用哪个
MIP纹理 。 该信息可能在顶点功能中尚不可用,因此无法完全放心使用
tex2D
。 与此相反,可以向
tex2Dlod
函数传递两个附加参数,在本教程中,该参数可以为零。
结果在以下图像中清晰可见。
在这种情况下,可以略微简化。 我们之前回顾的代码可以适用于任何几何体。 但是,我们可以假定表面绝对平坦。 实际上,我们确实希望将此效果应用于飞机。
因此,您可以删除
v.normal
并将其替换为
float3(0, 1, 0)
:
void vert(inout appdata_base v) { float3 normal = float3(0, 1, 0); fixed height = tex2Dlod(_HeightMap, float4(v.texcoord.xy, 0, 0)).r; vertex.xyz += normal * height * _Amount; }
我们之所以可以这样做,是因为
appdata_base
中的所有坐标都存储在
模型空间中 ,也就是说,它们是相对于3D模型的中心和方向设置的。 Unity中的带有
变换的过渡,旋转和缩放可更改对象的位置,旋转和缩放,但不影响原始3D模型。
第2部分。滚动效果
我们上面所做的一切都运行良好。 在继续之前,我们将提取新顶点高度所需的代码提取到单独的
getVertex
函数中:
float4 getVertex(float4 vertex, float2 texcoord) { float3 normal = float3(0, 1, 0); fixed height = tex2Dlod(_HeightMap, float4(texcoord, 0, 0)).r; vertex.xyz += normal * height * _Amount; return vertex; }
然后整个函数
vert
将具有以下形式:
void vert(inout appdata_base v) { vertex = getVertex(v.vertex, v.texcoord.xy); }
我们这样做是因为在下面,我们需要计算几个点的高度。 由于此功能将是其自己的单独功能,因此代码将变得更加简单。
紫外线坐标计算
但是,这导致了另一个问题。
getVertex
函数不仅取决于当前顶点的位置(v.vertex),还取决于其UV坐标(
v.texcoord
)。
当我们要计算
vert
函数当前正在处理的顶点高度偏移时,两个数据元素在
appdata_base
结构中均可用。 但是,如果我们需要对相邻点的位置进行采样会怎样? 在这种情况下,我们可以知道xyz在
模型空间中的位置,但是我们无法访问其UV坐标。
这意味着现有系统仅能为当前顶点计算高度偏移。 这样的限制将使我们无法继续前进,因此我们需要找到解决方案。
最简单的方法是找到一种方法,在知道其顶点位置的情况下计算3D对象的UV坐标。 这是一项非常艰巨的任务,有多种解决方法(最流行的一种是
三边形投影 )。 但是在这种特殊情况下,我们不需要将UV与几何形状匹配。 如果我们假定将始终将着色器应用于平面网格,那么任务就变得微不足道了。
我们可以根据
顶点 (上图)的
位置计算
UV坐标 (下图),这是因为它们都线性地叠加在一个平面网格上。
这意味着为了解决我们的问题,我们需要
将顶点位置的
分量XZ转换为相应的
UV坐标 。
此过程称为
线性插值 。 我的网站上对此进行了详细讨论(例如:
颜色插值的秘密 )。
在大多数情况下,UV值在
之前
; 相反,每个顶点的坐标可能是无限的。 从数学的角度来看,从XZ转换为UV,我们只需要它们的极限值:
- ,
- ,
- ,
- ,
如下所示:
这些值取决于所使用的网格。 在Unity平面上,
UV坐标的范围为
之前
,并且
顶点的
坐标在的范围内
之前
。
将XZ转换为UV的公式为:
(1)
如何显示?如果您不熟悉线性插值的概念,那么这些方程式可能看起来很吓人。
但是,它们的显示非常简单。 让我们看一个例子。

。 我们有两个间隔:一个间隔的值来自
之前
来自另一个
之前
。 坐标输入数据
是正在处理的当前顶点的坐标,而输出将是坐标

用于采样纹理。
我们需要保持之间的比例属性
及其间隔,以及

及其间隔。 例如,如果
那么重要的是其间隔的25%

也将影响其间隔的25%。
下图显示了所有这些:
由此,我们可以推断出红色部分相对于粉红色的比例应与蓝色部分和蓝色之间的比例相同:
(2)
现在我们可以将上面显示的方程式转换为

:
并且该方程式具有与上式(1)完全相同的形式。
这些等式可以用以下代码实现:
float2 _VertexMin; float2 _VertexMax; float2 _UVMin; float2 _UVMax; float2 vertexToUV(float4 vertex) { return (vertex.xz - _VertexMin) / (_VertexMax - _VertexMin) * (_UVMax - _UVMin) + _UVMin; }
现在我们可以调用
getVertex
函数,而不必将
v.texcoord
传递
v.texcoord
:
float4 getVertex(float4 vertex) { float3 normal = float3(0, 1, 0); float2 texcoord = vertexToUV(vertex); fixed height = tex2Dlod(_HeightMap, float4(texcoord, 0, 0)).r; vertex.xyz += normal * height * _Amount; return vertex; }
然后整个函数
vert
采用以下形式:
void vert(inout appdata_base v) { v.vertex = getVertex(v.vertex); }
滚动效果
多亏了我们编写的代码,整个地图都显示在网格上。 如果要改善显示效果,则需要进行更改。
让我们再规范一些代码。 首先,我们可能需要放大地图的单独部分,而不是整体查看。
该区域可以由两个值定义:其大小(
_CropSize
)和在地图上的位置(
_CropOffset
),以
顶点空间 (从
_VertexMin
到
_VertexMax
)
_VertexMax
。
接收到这两个值后,我们可以再次使用线性插值,这样
getVertex
不会为3D模型顶部的当前位置调用
getVertex
,而是为缩放和转移点调用
getVertex
。
相关代码:
void vert(inout appdata_base v) { float2 croppedMin = _CropOffset; float2 croppedMax = croppedMin + _CropSize;
如果我们要滚动,那么通过脚本更新
_CropOffset
就足够了。 因此,截断区域将移动,实际上在整个景观中滚动。
public class MoveMap : MonoBehaviour { public Material Material; public Vector2 Speed; public Vector2 Offset; private int CropOffsetID; void Start () { CropOffsetID = Shader.PropertyToID("_CropOffset"); } void Update () { Material.SetVector(CropOffsetID, Speed * Time.time + Offset); } }
为此,
将所有纹理的“
环绕模式”设置为“
重复”非常重要。 如果不这样做,那么我们将无法循环纹理。
对于缩放/缩放效果,只需更改
_CropSize
。
第3部分。地形阴影
平面阴影
我们编写的所有代码都可以运行,但是存在严重问题。 对模型进行着色有点奇怪。 该表面是适当弯曲的,但是对光的反应就像是平坦的。
在下面的图片中可以很清楚地看到这一点。 顶部图像显示了现有的着色器; 底部显示了它的实际工作方式。
解决这个问题可能是一个很大的挑战。 但是首先,我们需要找出错误所在。
正常的挤出操作更改了我们最初使用的平面的总体几何形状。 但是,Unity仅更改了顶点的位置,而不更改了它们的法线方向。 顾名思义,顶点
法线的方向是指示垂直于表面的单位长度矢量(
方向 )。
法线是必需的,因为
法线在3D模型着色中起着重要作用。 所有曲面着色器都使用它们来计算应如何从3D模型的每个三角形反射光。 通常,这对于改善模型的三维度是必要的,例如,它使光从平面反射,就像它从曲面反射一样。 此技巧通常用于使低多边形表面看起来比实际表面更平滑(请参见下文)。
但是,在我们的情况下,情况恰恰相反。 几何形状是弯曲且平滑的,但是由于所有法线都指向上方,因此从模型反射的光就像是平坦的(请参见下文):
您可以在法线
贴图(凹凸贴图)上的文章中了解有关法线在对象着色中的作用的更多信息,该文章中,尽管使用相同的3D模型,但由于计算顶点法线的方法不同,相同的圆柱体看起来也非常不同(请参见下文)。
不幸的是,Unity和用于创建着色器的语言都没有内置的解决方案来自动重新计算法线。 这意味着您必须根据3D模型的局部几何形状手动更改它们。
正常计算
解决阴影问题的唯一方法是根据表面几何形状手动计算法线。 在“
顶点位移-融合着色器”第1部分的帖子中讨论了类似的任务,该任务用于模拟“
圆锥大战”中3D模型的融合。
尽管完成的代码将必须在3D坐标中工作,但是现在让我们将任务限制为仅二维。 想象一下,您需要计算与2D曲线上的点相对应
的法线方向 (下图中的蓝色大箭头)。
从几何角度来看,
法线 (蓝色大箭头)的方向是垂直于
切线的矢量,该
切线通过感兴趣点到达我们(一条细蓝线)。
切线可以表示为位于模型曲率上的线。
切向量是位于切线上的
单位向量 。
这意味着要计算法线,您需要执行两个步骤:首先,找到与所需点
相切的线; 然后计算与其垂直的向量(这将是
法线的必要
方向 )。
切线计算
为了得到
法线,我们首先需要计算
切线 。 可以通过在附近采样一个点并使用它在顶点附近建立一条线来近似。 线越小,值越准确。
需要三个步骤:
- 阶段1.在平面上移动少量
- 步骤2.计算新点的高度。
- 步骤3.使用当前点的高度计算切线
所有这些都可以在下图中看到:
为此,我们需要计算两个点而不是一个点的高度。 幸运的是,我们已经知道如何做到这一点。 在本教程的上一部分中,我们创建了一个函数,该函数根据网格点对景观的高度进行采样。 我们称它为
getVertex
。
我们可以在当前点取新的顶点值,然后在其他两个点取。 一种是切线,另一种是切线的两点。 在他们的帮助下,我们得到了正常。 如果用于创建效果的原始网格是平坦的(在我们的情况下是平坦的),那么我们就不需要访问
v.normal
,我们可以只使用
float3(0, 0, 1)
表示切线和切线到两个点
float3(0, 0, 1)
和
float3(1, 0, 0)
。 如果我们想做同样的事情,例如对于一个球体,那么要找到两个合适的点来计算切线和切到两个点的切线将要困难得多。
矢量图稿
获得合适的切线和切线向量到两点后,我们可以使用称为
向量积的运算来计算法线。 向量工作及其作用的定义和解释很多。
向量乘积接收两个向量并返回一个新的向量。 如果两个初始向量是单位(它们的长度等于1),并且它们的夹角为90度,则所得向量将相对于两个向量成90度角。
首先,这可能会造成混淆,但是可以用图形表示如下:两个轴的向量积创建第三个轴。 那是
而且
等等。
如果我们采取足够小的步长(在代码中为
offset
),则切线和切线与两个点的向量将成90度角。
它们与法线向量一起形成了沿模型表面定向的三个垂直轴。知道了这一点,我们可以编写所有必要的代码来计算和更新法线向量。 void vert(inout appdata_base v) { float3 bitangent = float3(1, 0, 0); float3 tangent = float3(0, 0, 1); float offset = 0.01; float4 vertexBitangent = getVertex(v.vertex + float4(bitangent * offset, 0) ); float4 vertex = getVertex(v.vertex); float4 vertexTangent = getVertex(v.vertex + float4(tangent * offset, 0) ); float3 newBitangent = (vertexBitangent - vertex).xyz; float3 newTangent = (vertexTangent - vertex).xyz; v.normal = cross(newTangent, newBitangent); v.vertex.y = vertex.y; }
全部放在一起
现在一切正常,我们可以返回滚动效果。 void vert(inout appdata_base v) {
至此,我们的效果终于完成了。接下来要去哪里
本教程可以成为更复杂效果的基础,例如全息投影或什至是电影《黑豹》中的沙盘副本。Unity包
可以在Patreon上下载本教程的完整软件包,其中包含发挥上述效果所需的所有资产。