前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >Unity基础教程系列(新)(三)——数学表面(Sculpting with Numbers)

Unity基础教程系列(新)(三)——数学表面(Sculpting with Numbers)

作者头像
放牛的星星
发布2021-02-12 16:03:03
1.4K0
发布2021-02-12 16:03:03
举报
文章被收录于专栏:壹种念头壹种念头

目录

 1 函数库

 1.1 Library类

 1.2 功能方法

 1.3 隐式的使用Type

 1.4 第二个函数

 1.5 在编辑器下选择函数

 1.6 Ripple 函数

 2 管理方法

 2.1 Delegates

 2.2 委托数组

 2.3 枚举

 3 使用另一个维度

 3.1 3D颜色值

 3.2 升级函数

 3.3 创建点组成的网格

 3.4 更好的视觉

 3.5 结合Z轴

 4 离开平面网格

 4.1 三维函数

 4.2 创建球

 4.3 扰动球体

 4.4 创建torus

本文重点内容: 1、创建函数库 2、使用Delegate和枚举类型 3、用格子展示2D函数 4、在3D空间定义表面

这是关于学习使用Unity的基础知识系列的第三个教程。这是上一章教程的延续,所以我们不会开始新的项目。这一次,我们将显示多个更复杂的函数。

本教程是CatLikeCoding系列的一部分,原文地址见文章底部。

本教程使用Unity 2019.4.10f1制作。

(结合不同的波形来创建复杂的表面)

1 函数库

完成上一教程后,我们将获得了一个由点组成的视图,该视图显示了在播放模式下的正弦波动画。当然也可以显示其他数学函数。你可以更改代码,功能也会随之更改。甚至可以在Unity编辑器处于播放模式时执行此操作。执行将暂停,保存当前游戏状态,然后再次编译脚本,最后重新加载游戏状态并恢复播放。这称为热重载。并非所有内容都能承受热重载,但我们的视图可以。它将切换到为新功能并添加动画效果,Unity不会意识到它们更改过。

虽然在播放模式下更改代码很方便,但在多种功能之间来回切换并不是一种方便的方法。如果我们可以通过视图的配置选项更改功能,那样更简单。

1.1 Library类

我们可以在Graph中声明多个数学函数,但是我们将该类专用于显示函数,而不用知道确切的数学方程式。这是专业化和关注点分离的示例。

创建一个新的FunctionLibrary C#脚本,并将其放在Graph旁边的Scripts文件夹中。你可以使用菜单选项来创建新资产,也可以复制并重命名Graph。无论哪种情况,都要清除文件内容,并从使用UnityEngine开始并声明一个空的FunctionLibrary类,该类不扩展任何内容。

此类不会成为组件类型。我们也不会创建它的对象实例。取而代之的是,我们将使用它来提供代表数学函数的公共可访问方法的集合,类似于Unity的Mathf。

为了表示不将该类用作对象模板,可以通过在类之前编写static关键字将其标记为static。

1.2 功能方法

我们的第一个功能将是Graph当前显示的正弦波。需要为其创建一个方法。这与创建Awake或Update方法相同,但我们将其命名为Wave。

默认情况下,方法是实例方法,这意味着必须在对象实例上调用它们。为了使它们直接在类层级工作,需要将其标记为Static,就像FunctionLibrary本身一样。

为了使其可公开访问,还应为其设置public access修饰符。

这个方法将会表示我们的数学函数f(x,t)=sin(π(x+t))这意味着它必须产生一个结果,该结果是一个浮点数。所以函数的返回类型需要为float,而不是void。

接下来,我们需要将两个参数添加到方法的参数列表中,就像数学函数一样。唯一的区别是我们需要在每个参数的前面编写类型,即float。

现在,我们可以使用其x和t参数将用于计算正弦波的代码放入方法中。

最后一步是明确指出该方法的结果。由于这是一个float方法,完成后必须返回一个float。我们通过写return以及结果应该是什么来表示这一点,这就是我们的数学计算。

现在可以使用position.x和time作为其参数的参数在Graph.Update内部调用此方法。它的结果可以用来设置点的Y坐标,而不是显式的数学方程式。

1.3 隐式的使用Type

我们会在FunctionLibrary中大量使用Mathf.PI,Mathf.Sin和Mathf的其他方法。如果我们可以编写这些代码而不必一直明确提及类型,那会很快捷。通过在FunctionLibrary文件顶部添加另一个using语句来实现这一点,该语句带有额外的static关键字,后跟显式的UnityEngine.Mathf类型。这使得该类型的所有常量和静态成员都可用,而无需明确提及类型本身。

现在,我们可以省略Mathf来缩短Wave中的代码。

1.4 第二个函数

添加另一个函数方法。这次,我们将使用多个正弦波制作稍微复杂一点的函数。首先复制Wave方法并将其重命名为MultiWave。

我们将保留我们已经拥有的正弦函数,但要添加一些额外的功能。为了简化操作,在返回之前将当前结果分配给y变量。

给正弦波增加更多复杂度的最简单方法是添加另一个具有两倍频率的正弦波。这意味着它的改变速度快两倍,这是通过将正弦函数的参数乘以2来完成的。与此同时,我们将把该函数的结果减半。这样可以使新的正弦波的形状与旧的正弦波相同,但尺寸减半。

这给了我们一个新的数学函数

由于正弦函数的正极端和负极端均为1和-1,因此此新函数的最大值和最小值可能为1.5和-1.5。为了保证我们保持在-1~1范围内,我们应该将总和除以1.5。

除法运算比乘法运算需要更多的工作,因此根据经验法则,反过来的话,乘法运算胜于除法运算。但是,编译器已经将常量表达式(例如1f / 2f以及2f * Mathf.PI)简化为单个数字。因此,我们可以重写代码以仅在运行时使用乘法。我们需要确保首先使用操作顺序和括号将常量部分减少。

也可以直接写0.5f而不是1f / 2f,但是1.5的反数不能完全用十进制表示法编写,因此我们将继续使用2f / 3f,编译器会以最大精度将其简化为浮点表示形式。。

现在,使用此函数代替Graph.Update中的Wave,看看它是什么样子。

(两个正弦波的和)

你可以理解为一个较小的正弦波跟在一个较大的正弦波后面。我们也可以让较小的波沿较大的波滑动,例如把较大的波的时间减半。其结果将是一个不仅仅随着时间推移而滑动的函数,它还会改变其形状。现在重复这个模式需要4秒。

(变形波)

1.5 在编辑器下选择函数

接下来我们要做的是添加一些代码,以控制Graph使用哪种方法。我们可以使用滑块来完成此操作,就像图形的分辨率一样。由于有两个函数可供选择,因此我们需要一个范围为0~1的可序列化整数字段。将其命名为function,因此很明显的表明它控制的是什么。

(function 滑动条)

现在我们可以检查Update循环中的功能了。如果为零,则视图应显示Wave。为了做出选择,我们将使用if语句,后跟一个表达式和一个代码块。它的工作原理与while相同,但它不会循环返回,因此该块将被执行或跳过。在这种情况下,判断条件是函数是否等于零,可以使用==等于运算符完成。

我们可以在if块后面加上else和另一个块,在条件失败时执行它们。这个例子中,视图应该显示MultiWave。

这样,即使在播放模式下,也可以通过视图的检视器控制功能。

在播放模式下更改分辨率滑块是否有效果? 这将导致视图的分辨率值更改,但是Graph.Update不依赖于此,因此没有可见效果。在播放模式下更改points数量将需要删除和实例化点,但是在本次教程中我们将不再对此进行支持。

1.6 Ripple 函数

现在,向库中添加第三个函数,该函数会产生类似波纹的效果。我们通过使正弦波远离原点移动而不是始终沿相同方向传播来创建它。通过以距中心的距离(X的绝对值)为基础来进行此操作。首先,在Mathf.Abs的帮助下,使用新的FunctionLibrary.Ripple方法进行计算。将距离存储在d变量中,然后将其返回。

为了显示它,将Graph.function的范围增加到2,并在Update中为Wave方法添加另一个块。通过在else后面直接写另一个if来链接多个条件块,因此它成为if-else块,应在函数等于1时执行。然后为波纹添加新的else块。

(绝对值X)

回到FunctionLibrary.Ripple,我们用距离作为正弦函数的输入,并使其成为结果。具体地说,我们将使用y=sin(4πd)和d=|x|,这样波纹会在视图的范围内出现多次上下波动。

(距离正弦波)

由于Y的变化太大,因此在视觉上难以呈现有意义的结果。可以通过减小波的振幅来减小这种情况。但是纹波没有固定的幅度,而是随距离而减小。所以让我们把功能变成

画龙点睛之笔是激起涟漪。为了让它向外流动我们需要从传递给正弦函数的值中减去时间。使用πt,所以最终的函数是

(连漪动画)

2 管理方法

有条件的块序列可用于两个或三个函数,但是在尝试支持更多条件块时,它很快变得非常笨拙。如果我们可以根据某些标准要求我们的库对方法的引用,然后重复调用它,将会更加方便。

2.1 Delegates

可以通过使用委托(Delegate)获得对方法的引用。委托是一种特殊的类型,它定义了某种事物可以引用的方法。我们的数学函数方法没有标准的委托类型,但是我们可以自己定义。因为它是一种类型,所以我们可以在自己的文件中创建它,但是由于它是专门针对我们库的方法,因此我们将在FunctionLibrary类中定义它,使其成为内部或嵌套类型。

若要创建与Wave函数重复的委托类型,请将其重命名为Function并将其代码块替换为分号。这定义了没有实现的方法签名。然后,通过将static关键字替换为委托,将其转换为委托类型。

现在,我们可以介绍一个GetFunction方法,该方法使用与循环中相同的if-else逻辑返回给定索引参数的Function,不同之处在于,在每个块中,我们都返回适当的方法而不是调用它。

接下来,我们使用此方法在Graph.Update的开头基于函数获取函数委托,并将其存储在变量中。因为此代码不在FunctionLibrary中,所以我们必须将嵌套的委托类型称为FunctionLibrary.Function。

然后在循环中调用委托变量而不是显式方法。

2.2 委托数组

我们已经简化了Graph.Update了很多,但是我们只将if-else代码移动到FunctionLibrary.GetFunction。我们可以通过用索引数组替换它。首先将一个用于函数数组的静态字段添加到FunctionLibrary。

我们总是将相同的元素放在此数组中,因此我们可以在其声明中明确定义其内容。这可以通过在大括号之间分配逗号分隔的数组元素序列来完成。最简单的是一个空列表。

GetFunction方法现在可以简单地索引数组以返回适当的委托。

2.3 枚举

整数滑块有效,但0表示波动函数等并不明显。如果我们有一个包含函数名称的下拉列表,将会更加清楚。可以使用枚举来实现。

可以通过定义枚举类型来创建枚举。我们再次在FunctionLibrary中进行此操作,这次将其命名为FunctionName。在这种情况下,类型名称后跟大括号内的标签列表。我们可以使用数组元素列表的副本,但不使用分号。请注意,这些是简单的标签,尽管它们遵循与类型名称相同的规则,但它们未引用任何内容。保持两个列表相同是我们的责任。

现在,将GetFunction的index参数替换为FunctionName类型的名称参数。这表明参数必须是有效的函数名称。

枚举可以被视为语法糖。默认情况下,枚举的每个标签代表一个整数。第一个标签对应于0,第二个标签对应于1,依此类推。因此,我们可以使用名称来索引数组。但是,编译器会报错,提示无法将枚举隐式转换为整数。我们需要明确执行此强制转换。

最后一步是将Graph.function字段的类型更改为FunctionLibrary.FunctionName并删除其Range属性。

Graph的检视器现在显示一个包含函数名称的下拉列表,并在大写单词之间添加空格。

(函数下拉列表)

3 使用另一个维度

到目前为止,我们的视图仅包含由点组成的单条线。我们将一维值映射到其他一维值,但是如果考虑到时间,它实际上是将二维值映射到一维值。因此,我们已经将高维输入映射到一维值。就像我们增加时间一样,我们也可以增加其他空间尺寸。

当前,我们将X维度用作函数的空间输入。Y尺寸用于显示输出。留下Z作为第二空间维度以用于输入。在输入上加上Z会将我们的线升级为正方形网格。

3.1 3D颜色值

在Z不再恒定的情况下,更改点Point Surface,通过从赋值中删除.rg和.xy代码来修改蓝色反照率分量。

(调整 shader graph)

3.2 升级函数

为了支持我们函数的第二个非时间输入,请在FunctionLibrary.Function委托类型的x参数后面添加一个z参数。

这要求我们还将参数添加到我们的三个函数方法中。

并且在调用Graph.Update中的函数时还要添加position.z作为参数。

3.3 创建点组成的网格

为了表示Z维,我们需要将直线上的点转换成网格上的点。可以通过创建多条线来实现这一点,每条线沿着Z偏移一步。我们将对Z使用相同的范围,就像我们对X使用的范围一样,所以我们将创建与当前拥有的点一样多的线。这意味着我们需要对点的数量进行平方。在Awake中调整点数组的创建,使它足够大,以包含所有的点。

当我们根据分辨率在Awake中循环的每次迭代增加X坐标时,简单地创建更多点将产生一条长线。我们还需要调整初始化循环以将第二维考虑在内。

(2500点做组成的长线)

首先,让我们明确地跟踪X坐标。为此,需要在for循环内声明和递增x变量以及i迭代器变量。可以将for语句的第三部分转换为以逗号分隔的列表。

每次完成一行时,我们都必须将x重置为零。当x等于分辨率时,一行就结束了,因此我们可以在循环的顶部使用if块来解决这一问题。然后使用x代替i来计算X坐标。

现在,我们创建一个点的正方形网格,而不是一条直线。由于我们的函数仍仅使用X维度,因此看起来原始点已被挤压成线。

(grid视图)

3.4 更好的视觉

因为我们的图形现在是3D,所以从现在开始,我将使用游戏窗口从透视图的角度查看它。若要快速选择一个好的摄像机位置,你可以在处于播放模式的场景窗口中找到一个好的视点,退出播放模式,然后使游戏的摄像机与该视点匹配。您可以通过GameObject /Align With View with Main Camera来实现。我使它在XZ对角线上大致向下看。然后,我将Directional Light的Y旋转从−30更改为30,以改善该视角的照明。

除此之外,我们可以稍微调整阴影质量。使用默认的渲染管线时,阴影已经看起来可以接受了,但是现在阴影的表现为近距离查看视图时,阴影投射的很远。

可以通过转到Quality项目设置并选择一个预配置的级别来为默认渲染管线选择质量级别。默认下拉列表控制默认情况下独立应用程序使用哪个级别。

通过转到下面的Shadows部分并将Shadow Distance减小为10,并将Shadow Cascades设置为No Cascades,我们可以进一步调整阴影的性能和精度。默认设置最多可渲染四次阴影,这对我们来说是过大了。

(默认渲染管线的阴影设置)

阴影级联和距离控制是什么? Unity和大多数游戏引擎将阴影投射器渲染为纹理,然后对它们进行采样以创建阴影。这些阴影贴图具有固定的分辨率。如果它们必须覆盖大面积,则各个像素也会变大,从而导致块状阴影。

URP不使用这些设置,而是通过我们的URP资产的检查器配置其阴影。默认情况下,它已经不使用任何级联,但是其阴影距离可以减小到10。此外,为了匹配默认渲染管线的标准Ultra质量,启用Soft Shadows并将Lighting 下Shadow Resolution增加到4096。

(URP的阴影设置)

最后,你现在可以在播放模式下看到明显的视觉撕裂。通过从游戏窗口工具栏左侧的第二个下拉菜单启用VSync(仅游戏视图),可以防止在游戏窗口中发生这种情况。启用后,新帧的显示将与显示刷新率同步。仅当同时看不到任何场景窗口时,这才可靠地起作用。通过质量设置的Other部分为独立应用程序配置了VSync。

(垂直同步开启)

为什么帧率下降了? 与之前的一排点相比,网格包含更多的点。在分辨率为50的时候,它有2500点。在分辨率100下,它具有10,000点。为了获得最佳性能,最好同时只看到一个场景或游戏窗口。另外,请确保Graph的对象层次结构在层次结构窗口中折叠,因此不必列出所有点。

3.5 结合Z轴

在Wave函数中使用Z的最简单方法是使用X和Z的总和而不是X。这将产生斜波。

(斜波)

对于MultiSine,最直接的更改是使每个wave使用一个单独的维度。让我们较小的一个使用Z。

(两个不同维度的波)

我们还可以添加沿XZ对角线传播的第三波。让我们使用与Wave相同的wave,除了时间减慢到四分之一。然后将结果除以2.5,以将结果保持在-1–1域内。

请注意,第一波和第三波将以规则的时间间隔互相抵消。

(三个波)

最后,要使波纹在XZ平面上的所有方向上扩散,我们必须计算两个方向上的距离。我们可以在Mathf.Sqrt方法的帮助下使用勾股定理。

勾股定理是什么?

(XZ平面的涟漪)

4 离开平面网格

通过使用X和Z定义Y,我们可以创建描述多种表面的函数,但是它们始终与XZ平面链接。即使有不同的Y坐标,两个点也不会出现相同X和Z。这意味着我们表面的曲率受到一定的限制。它们的坡度不能变为垂直,也不能向后折叠。为了使之成为可能,我们的函数不仅必须输出Y,还必须输出X和Z。

4.1 三维函数

如果我们的函数是输出3D位置而不是1D值,则可以使用它们来创建任意表面。例如

描述了XZ平面。而

描述了Y平面。

由于这些函数的输入参数不再对应于最终的X和Z坐标,因此不再适合为其命名x 和 z。 取而代之的是,它们被用来创建参数化曲面,通常被命名为u和v。所以我们会得到像这样的函数:

调整我们的函数委托类型以支持这种新方法。唯一需要做的更改是将其浮点返回类型替换为Vector3,但还要重命名其参数。

现在,我们需要相应地调整函数方法。将U和V直接用于X和Z。不需要调整参数名称-只需调整其名称即可匹配委托,但让我们保持一致。如果你的代码编辑器支持它,则可以通过菜单或上下文菜单选项快速重构-重命名参数和其他内容,以便在一次使用的所有位置将其重命名。

从Wave开始。让它最初声明一个Vector3变量,然后设置其组件,然后返回它。我们不必给向量一个初始值,因为我们在返回它之前设置了它的所有字段。

然后对MultiWave和Ripple进行相同的处理。

由于点的X和Z坐标不再恒定,因此我们也不再依赖于Graph.Update中的初始值。可以通过将Update中的循环替换为Awake中使用的循环来解决此问题,但现在我们可以直接将函数结果分配给该点的位置。

请注意,当z更改时,我们仅需要重新计算v。这确实需要我们在循环开始之前设置其初始值。

还要注意,由于Update现在使用的是resolution,因此在播放模式下更改分辨率会使视图变形,将网格拉伸或压缩为矩形。

为什么不使用嵌套双循环? 这也是可以的,并且是遍历二维的常用方法。但是,这种方法主要是遍历点而不是维度。即使在播放模式下更改分辨率时,它最终更新的也是所有点。

我们不再需要在Awake中初始化位置,因此可以使该方法更加简单。我们只需设置缩放值和父节点即可。

4.2 创建球

为了说明我们确实不再局限于每个(X,Z)坐标对一个点,让我们创建一个定义球体的函数。为此,将一个Sphere方法添加到FunctionLibrary中。还要为其添加一个条目到FunctionName枚举和functions数组中。从始终返回原点开始。

创建球体的第一步是描述一个在XZ平面上平放的圆。我们可以用

来完成,依靠参数U。

(圆)

我们现在有个完美重叠的圆。现在可以根据v沿着Y轴分散它们,得到一个圆柱体。

(圆柱体)

我们可以通过将X和Z缩放到某个值来调整圆柱体的半径r。如果我们用

那么圆柱的顶部和底部就会折叠到同一个点。

(具有收敛半径的圆柱)

这已经很接近了,但是圆柱体半径减小,最终还不是圆的。这是因为圆是由sin和cos组成的,我们现在只使用cos。方程的另一部分是Y,它现在仍然等于v,为了完成这个圆,我们需要使用

(一个球)

结果是一个球体,该球体具有通常称为UV球体的视图。尽管此方法可以创建正确的球体,但请注意,点的分布并不均匀,因为该球体是通过堆叠不同半径的圆来创建的。或者,我们可以认为它由围绕Y轴旋转的多个半圆组成。

4.3 扰动球体

为了能够控制球体的半径,我们需要稍微调整一下公式。使用

,其中

,并且半径为r。

这种方法可以对半径进行动画处理。例如,我们可以使用

来让半径基于时间缩放。

(缩放球体)

我们不需要使用统一的半径。我们可以根据u 来改变它,就像

(带垂线的球体,分辨率为100)

这使得球体看起来有垂线。我们可以用v 代替u来切换到水平波段。

(带有横纹的球)

通过同时使用两者,可以得到条纹带。增加时间可以使它们旋转,最后

(旋转的条纹球)

4.4 创建torus

在FunctionLibrary中添加一个圆环表面。复制Sphere,将其重命名为Torus并将其半径设置为1。还要更新名称和函数数组。

通过将球体的垂直半圆彼此拉开,然后将它们变成完整的圆,可以将球体变形为圆环。先从

开始。

(球被扯开)

现在我们得到了半个环面,只有环的外部分。为了完成环面,我们需要用v来描述一个完整的圆,而不是半个圆。这可以通过在s和y中使用πv取代πv/2来实现。

(自相交的主轴环)

因为我们把球体分开了半个单位,这就产生了一个自交的形状,被称为主轴环面。如果我们把它分开一个单位,我们会得到一个不会自相交的环面,但也不会有洞,这被称为角环面。所以我们把球体拉开的距离会影响环面的形状。具体地说,它定义了环面的主要半径。另一个半径是小半径,它决定了环的厚度。让我们把主半径定义为r1,把另一个重命名为r2。现在

然后将0.75用作大半径,将0.25用作小半径,以将点保持在-1~1域内。

(环面)

现在我们可以使用两个半径来制作一个更有趣的圆环。例如,我们可以通过使用

同时也使用

来实现有趣的效果。

(扭力环)

你现在已经具有使用数学公式来描述表面的经验,以及如何可视化它们。接下来可以尝试写出自己的功能,以更好地了解其工作原理。可以用几个正弦波创建许多看似复杂的表面,试试看。

欢迎扫描二维码,查看更多精彩内容。点击 阅读原文 可以跳转原教程。

本文翻译自 Jasper Flick的系列教程

原文地址:

https://catlikecoding.com/unity/tutorials

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2021-02-01,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 壹种念头 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档