【Unity】探索数学世界:使用Unity构建可视化数学函数图形(一)(上)

975 阅读18分钟

构建可视化数学函数图形

  •  创建预制件。
  •  实例化多个多维数据集。
  •  显示数学函数。
  • 创建表面着色器和着色器图。
  •  为图形添加动画效果。

在这个教程中,我们将带你进入数学世界,向你展示如何使用Unity构建可视化数学函数图形。你将学习如何创建预制件和多维数据集,并使用表面着色器和着色器图显示数学函数。我们还将向你展示如何使用脚本控制图形的动画效果,使你的图形更加生动。 这次我们将使用游戏对象来构建图形,以便我们可以显示数学公式。我们还将使函数与时间相关,从而创建一个动画图。

本教程是使用 Unity 2020.3.6f1 制作的。

使用立方体显示正弦波。

 创建多维数据集行

编程时,对数学的良好理解至关重要。在最基本的层面上,数学是对代表数字的符号的操作。求解方程归结为重写一组符号,使其成为另一组(通常更短)的符号集。数学规则决定了如何进行这种重写。

例如,我们有函数 f ( x ) = x + 1 。我们可以用一个数字代替它的 x 参数,比如 3。这导致 f ( 3 ) = 3 + 1 = 4 。我们提供了 3 作为输入参数,最终以 4 作为输出。我们可以说函数将 3 映射到 4。更短的编写方法是作为输入输出对,如 (3,4)。我们可以创建许多形式的 ( x , f ( x ) ) 的对,例如 (5,6) 和 (8,9) 以及 (1,2) 和 (6,7)。但是当我们按输入数字对配对进行排序时,更容易理解该函数。(1,2)和(2,3)和(3,4)等。

函数 f ( x ) = x + 1 很容易理解。 f ( x ) = ( x − 1 ) 4 + 5 x 3 − 8 x 2 + 3 x 更难。我们可以写下一些输入输出对,但这可能不会让我们很好地掌握它所代表的映射。我们需要很多点,靠得很近。这最终将变成一个难以解析的数字海洋。相反,我们可以将这些对解释为形式为 [ x f ( x ) ] 的二维坐标。这是一个 2D 矢量,其中顶部数字表示 X 轴上的水平坐标,底部数字表示 Y 轴上的垂直坐标。换句话说, y = f ( x )。我们可以在表面上绘制这些点。 如果我们使用足够多的非常接近的点,我们最终会得到一条线。结果是一个图形。

在 −2 和 2 之间使用 x 的图形,用 Desmos 制作。

查看图形可以快速让我们了解函数的行为方式。这是一个方便的工具,所以让我们在 Unity 中创建一个。我们将从一个新项目开始。

预制件

通过在适当的坐标处放置点来创建图形。为此,我们需要一个点的 3D 可视化。为此,我们将简单地使用 Unity 的默认立方体游戏对象。向场景添加一个并命名为 Point 。删除其 [BoxCollider](http://docs.unity3d.com/Documentation/ScriptReference/BoxCollider.html) 组件,因为我们不会使用物理引擎。

我们将使用自定义组件来创建此多维数据集的许多实例并正确定位它们。为此,我们将立方体转换为游戏对象模板。将多维数据集从层次结构窗口拖到项目窗口中。这将创建一个称为预制件的新资源。它是存在于项目中的预制游戏对象,而不是存在于场景中。

one column
two column

点预制件资源,一列和两列布局。

我们用于创建预制件的游戏对象仍然存在于场景中,但现在是一个预制件实例。它在层次结构窗口中有一个蓝色图标,右侧有一个箭头。其检查器的标题还指示它是一个预制件,并显示更多控件。位置和旋转现在以粗体文本显示,表示实例的值覆盖预制件的值。您对实例所做的任何其他更改也将以这种方式指示。

hierarchy
inspector

 点预制件实例。

选择预制件资源时,其检查器将显示其根游戏对象和一个用于打开预制件的大按钮。

 预制件资源检查器。

单击 Open Prefab 按钮将使场景窗口显示一个只包含预制件对象层次结构的场景。您还可以通过实例的 Open 按钮、层次结构窗口中实例旁边的向右箭头或双击项目窗口中的资源来到达那里。当预制件具有复杂的层次结构时,这很有用,但对于我们的简单点预制件来说,情况并非如此。

 预制件的层次结构窗口。

您可以通过层次结构窗口中预制件名称左侧的箭头退出预制件的场景。

预制件是配置游戏对象的便捷方法。如果更改预制件资源,则任何场景中的所有实例都将以相同的方式更改。例如,更改预制件的比例也会更改仍在场景中的立方体的比例。但是,每个实例都使用自己的位置和旋转。此外,还可以修改游戏对象实例,这将覆盖预制件的值。请注意,预制件和实例之间的关系在播放模式下会中断。

我们将使用脚本来创建预制件的实例,这意味着我们不再需要场景中当前存在的预制件实例。因此,请通过 Edit / Delete ,指示的键盘快捷键或其层次结构窗口中的上下文菜单将其删除。

图形脚本

我们需要一个 C# 脚本来生成带有点预制件的图形。创建一个并将其命名为 **Graph**

“脚本”文件夹中的图形 C# 资源。

我们从扩展 [MonoBehaviour](http://docs.unity3d.com/Documentation/ScriptReference/MonoBehaviour.html) 的简单类开始,以便它可以用作游戏对象的组件。为其提供一个可序列化的字段,以保存对用于实例化点的预制件的引用,名为 pointPrefab 。我们需要访问 [Transform](http://docs.unity3d.com/Documentation/ScriptReference/Transform.html) 组件来定位点,因此请将其设置为字段的类型。

using UnityEngine;

public class Graph : MonoBehaviour {

[SerializeField]
Transform pointPrefab;
}

在场景中添加一个空的游戏对象,并将其命名为 Graph 。确保其位置和旋转为零,并且其比例为 1。将我们的 **Graph** 组件添加到此对象。然后将我们的预制件资源拖到图形的 Point Prefab 字段上。它现在包含对预制件的 [Transform](http://docs.unity3d.com/Documentation/ScriptReference/Transform.html) 组件的引用。

参考预制件绘制游戏对象。

 实例化预制件

实例化游戏对象是通过 [Object](http://docs.unity3d.com/Documentation/ScriptReference/Object.html).[Instantiate](http://docs.unity3d.com/Documentation/ScriptReference/Object.Instantiate.html) 方法完成的。这是 Unity 的 [Object](http://docs.unity3d.com/Documentation/ScriptReference/Object.html) 类型的公开可用方法, **Graph** 通过扩展 [MonoBehaviour](http://docs.unity3d.com/Documentation/ScriptReference/MonoBehaviour.html) 间接继承了该方法。 [Instantiate](http://docs.unity3d.com/Documentation/ScriptReference/Object.Instantiate.html) 方法克隆作为参数传递给它的任何 Unity 对象。对于预制件,它将导致将实例添加到当前场景中。让我们在 **Graph** 组件唤醒时执行此操作。

public class Graph : MonoBehaviour {

[SerializeField]
Transform pointPrefab;

void Awake () {
Instantiate(pointPrefab);
}
}

如果我们现在进入播放模式, Point 预制件的单个实例将在世界原点生成。它的名称与预制件的名称相同,并附加了 (Clone)

实例化的预制件,在场景窗口中向下查看 Z 轴。

要将点放置在其他地方,我们需要调整实例的位置。 [Instantiate](http://docs.unity3d.com/Documentation/ScriptReference/Object.Instantiate.html) 方法为我们提供了对它创建的任何内容的引用。因为我们给了它一个 [Transform](http://docs.unity3d.com/Documentation/ScriptReference/Transform.html) 组件的引用,这就是我们得到的回报。让我们用一个变量来跟踪它。

void Awake () {
Transform point = Instantiate(pointPrefab);
}

在前面的教程中,我们通过为枢轴的 [Transform](http://docs.unity3d.com/Documentation/ScriptReference/Transform.html)localRotation 属性分配一个四元数来旋转时钟臂。更改位置的工作方式相同,只是我们必须将 3D 矢量分配给 localPosition 属性。

使用 [Vector3](http://docs.unity3d.com/Documentation/ScriptReference/Vector3.html) 结构类型创建 3D 矢量。例如,让我们将点的 X 坐标设置为 1,将其 Y 和 Z 坐标保留为零。 [Vector3](http://docs.unity3d.com/Documentation/ScriptReference/Vector3.html) 有一个 right 属性,它为我们提供了这样一个向量。使用它来设置点的位置。

Transform point = Instantiate(pointPrefab);
point.localPosition = Vector3.right;

现在进入播放模式时,我们仍然会得到一个立方体,只是位置略有不同。让我们实例化第二个,并将其放置在右侧的附加步骤。这可以通过将右向量乘以 2 来完成。重复实例化和定位,然后将乘法添加到新代码中。

void Awake () {
Transform point = Instantiate(pointPrefab);
point.localPosition = Vector3.right;

Transform point = Instantiate(pointPrefab);
point.localPosition = Vector3.right * 2f;
}

此代码将产生编译器错误,因为我们尝试定义 point 变量两次。如果我们想使用另一个变量,我们必须给它一个不同的名称。或者,我们重用已有的变量。一旦我们完成了对第一个点的引用,我们就不需要保留对它的引用,因此将新点分配给相同的变量。

Transform point = Instantiate(pointPrefab);
point.localPosition = Vector3.right;


point = Instantiate(pointPrefab);
point.localPosition = Vector3.right * 2f;

两个实例,X 坐标为 1 和 2。

循环代码

让我们创建更多的点,直到我们有十个点。我们可以再重复八次相同的代码,但这将是非常低效的编程。理想情况下,我们只编写一个点的代码,并指示程序多次执行它,略有不同。

**while** 语句可用于使代码块重复。将其应用于方法的前两个语句,并删除其他语句。

void Awake () {
while {
Transform point = Instantiate(pointPrefab);
point.localPosition = Vector3.right;
}


}

**while** 关键字后必须跟圆括号内的表达式。 **while** 后面的代码块只有在表达式的计算结果为 true 时才会执行。之后,程序将循环回 **while** 语句。如果此时表达式再次计算为 true,则将再次执行代码块。重复此操作,直到表达式的计算结果为 false。然后程序跳过 **while** 语句后面的代码块,并继续在其下方。

所以我们必须在 **while** 之后添加一个表达式。我们必须小心确保循环不会永远重复。无限循环会导致程序卡住,需要用户手动终止。编译的最安全的表达式只是 **false**

while (false) {
Transform point = Instantiate(pointPrefab);
point.localPosition = Vector3.right;
}

限制循环可以通过跟踪我们重复代码的次数来完成。我们可以使用整数变量来跟踪这一点。它的类型是 **int** 。它将包含循环的迭代编号,因此将其命名为 i 。它的初始值为零。为了能够在 **while** 表达式中使用它,必须在它上面定义它。

int i = 0;
while (false) {
Transform point = Instantiate(pointPrefab);
point.localPosition = Vector3.right;
}

每次迭代,通过将数字设置为自身加 1,将数字增加 1。

int i = 0;
while (false) {
i = i + 1;
Transform point = Instantiate(pointPrefab);
point.localPosition = Vector3.right;
}

现在, i 在第一次迭代开始时变为 1,在第二次迭代开始时变为 2,依此类推。但是 **while** 表达式在每次迭代之前都会被计算。因此,在第一次迭代之前 i 为零,在第二次迭代之前为1,依此类推。所以在第十次迭代之后 i 是十。此时,我们希望停止循环,因此其表达式的计算结果应为 false。换句话说,只要 i 小于 10,我们就应该继续。在数学上,这表示为 i < 10 i < 10。它在代码中编写相同,使用 < 小于运算符。

int i = 0;
while (i < 10) {
i = i + 1;
Transform point = Instantiate(pointPrefab);
point.localPosition = Vector3.right;
}

现在我们将在进入游戏模式后获得十个立方体。但他们最终都处于相同的位置。要沿 X 轴将它们排成一行,请将 right 向量乘以 i

point.localPosition = Vector3.right * i;

沿 X 轴连续创建十个立方体。

请注意,目前第一个立方体的 X 坐标为 1,最后一个立方体的 X 坐标为 10。让我们改变这一点,以便我们从零开始,将第一个立方体定位在原点。我们可以将所有点向左移动一个单位,方法是将 right 乘以 (i - 1) 而不是 i .但是,我们可以通过在块末尾、乘法后而不是开头增加 i 来跳过额外的减法。

while (i < 10) {

Transform point = Instantiate(pointPrefab);
point.localPosition = Vector3.right * i;
i = i + 1;
}

简洁的语法使用

由于循环一定次数非常普遍,因此保持循环代码简洁很方便。一些句法糖可以帮助我们解决这个问题。

首先,让我们考虑增加迭代次数。当执行 x = x * y 形式的操作时,可以将其缩短为 x *= y 。这适用于所有作用于两个操作数的运算符。


i += 1;

更进一步,当将数字递增或递减 1 时,可以缩短为 ++x--x


++i;

赋值语句的一个属性是它们也可以用作表达式。这意味着您可以编写类似 y = (x += 3) 的内容。这会将 x 增加 3,并将结果也分配给 y 。这表明我们可以在 **while** 表达式中增加 i ,从而缩短代码块。

while (++i < 10) {
Transform point = Instantiate(pointPrefab);
point.localPosition = Vector3.right * i;

}

但是,现在我们在比较之前而不是之后递增 i ,这将导致更少的迭代。特别是对于这种情况,递增和递减运算符也可以放在变量之后,而不是变量之前。该表达式的结果是更改之前的原始值。


while (i++ < 10) {
Transform point = Instantiate(pointPrefab);
point.localPosition = Vector3.right * i;
}

尽管 **while** 语句适用于所有类型的循环,但还有一种替代语法特别适合遍历范围。它是 **for** 循环。它的工作方式类似于 **while** ,只是迭代器变量声明及其比较都包含在圆括号中,用分号分隔。



for (int i = 0; i++ < 10) {
Transform point = Instantiate(pointPrefab);
point.localPosition = Vector3.right * i;
}

这将产生编译器错误,因为还有第三部分用于在另一个分号之后递增迭代器,使其与比较分开。此部分在每次迭代结束时执行。


for (int i = 0; i < 10; i++) {
Transform point = Instantiate(pointPrefab);
point.localPosition = Vector3.right * i;
}

 更改域

目前,我们的点被赋予 X 坐标 0 到 9。使用函数时,这不是一个方便的范围。通常,0–1 的范围用于 X。或者,当使用以零为中心的函数时,范围为 −1–1。让我们相应地重新定位我们的观点。

沿两个单位长的线段放置十个立方体将导致它们重叠。为了防止这种情况,我们将缩小它们的规模。默认情况下,每个立方体在每个维度中的大小为 1,因此为了使它们适合,我们必须将它们的比例减小到 1/5 。我们可以通过将每个点的局部比例设置为 [Vector3](http://docs.unity3d.com/Documentation/ScriptReference/Vector3.html).one 属性除以 5 来做到这一点。除法是使用 / 斜杠运算符完成的。

for (int i = 0; i < 10; i++) {
Transform point = Instantiate(pointPrefab);
point.localPosition = Vector3.right * i;
point.localScale = Vector3.one / 5f;
}

通过将场景窗口切换到忽略透视的正交投影,可以更好地了解立方体的相对位置。单击场景窗口右上角轴微件下的标注可在正交模式和透视模式之间切换。如果通过场景窗口工具栏关闭天空盒,则白色立方体也更容易看到。

小立方体,在没有天空盒的正交场景窗口中看到。

要使立方体再次组合在一起,请将它们的位置也除以五。

point.localPosition = Vector3.right * i / 5f;

这使得它们覆盖 0-2 范围。要将其转换为 −1–1 范围,请在缩放向量之前减去 1。使用圆括号指示数学表达式的操作顺序。

point.localPosition = Vector3.right * (i / 5f - 1f);

 从 −1 到 0.8。

现在第一个立方体的 X 坐标为 −1,而最后一个立方体的 X 坐标为 0.8。但是,立方体大小为 0.2。由于立方体以它的位置为中心,第一个立方体的左侧为 −1.1,而最后一个立方体的右侧为 0.9。为了用我们的立方体整齐地填充 −1–1 范围,我们必须将它们向右移动半个立方体。这可以通过在除法之前将 0.5 添加到 i 来完成。

point.localPosition = Vector3.right * ((i + 0.5f) / 5f - 1f);

 填充 −1–1 范围。

将向量提升到循环之外

尽管所有立方体都具有相同的比例,但我们在循环的每次迭代中都会再次计算它。我们不必这样做,规模是不变的。相反,我们可以在循环之前计算一次,将其存储在 scale 变量中,然后在循环中使用它。

void Awake () {
var scale = Vector3.one / 5f;
for (int i = 0; i < 10; i++) {
Transform point = Instantiate(pointPrefab);
point.localPosition = Vector3.right * ((i + 0.5f) / 5f - 1f);
point.localScale = scale;
}
}

我们还可以为循环之前的位置定义一个变量。当我们沿着 X 轴创建一条线时,我们只需要调整循环内位置的 X 坐标。所以我们不再需要乘以 [Vector3](http://docs.unity3d.com/Documentation/ScriptReference/Vector3.html).right

Vector3 position;
var scale = Vector3.one / 5f;
for (int i = 0; i < 10; i++) {
Transform point = Instantiate(pointPrefab);

position.x = (i + 0.5f) / 5f - 1f;
point.localPosition = position;
point.localScale = scale;
}

这将导致编译器错误,抱怨使用未赋值的变量。发生这种情况是因为我们正在将 position 分配给某些内容,而我们尚未设置其 Y 和 Z 坐标。我们可以通过最初将 position 设置为零向量,通过将 [Vector3](http://docs.unity3d.com/Documentation/ScriptReference/Vector3.html).zero 分配给它来解决此问题。


var position = Vector3.zero;
var scale = Vector3.one / 5f;

 使用 X 定义 Y

这个想法是,我们的立方体的位置被定义为 [ x f ( x ) 0 ],所以我们可以使用它们来显示函数。此时 Y 坐标始终为零,表示平凡函数 f ( x ) = 0。为了显示不同的函数,我们必须确定循环内的 Y 坐标,而不是在它之前。让我们首先使 Y 等于 X,表示函数 f ( x ) = x。

for (int i = 0; i < 10; i++) {
Transform point = Instantiate(pointPrefab);
position.x = (i + 0.5f) / 5f - 1f;
position.y = position.x;
point.localPosition = position;
point.localScale = scale;
}

 Y 等于 X。

一个不太明显的函数是 f ( x ) = x 的2次方 ,它定义了一条抛物线,其最小值为零。

position.y = position.x * position.x;

 Y 等于 X 的平方。

 创建更多多维数据集

虽然我们在这一点上有一个功能图,但它很丑陋。因为我们只使用十个立方体,所以建议的行看起来非常块状和离散。如果我们使用更多更小的立方体,看起来会更好。

 可配置不同分辨率

我们可以使其可配置,而不是使用固定数量的多维数据集。要实现这一点,请将分辨率的可序列化整数字段添加到 **Graph** 。给它一个默认值 10,这就是我们现在使用的。

[SerializeField]
Transform pointPrefab;

[SerializeField]
int resolution = 10;

 可配置的分辨率。

现在,我们可以通过检查器更改图形的分辨率来调整图形的分辨率。但是,并非所有整数都是有效的分辨率。至少他们必须是积极的。我们可以指示检查员强制执行我们的解决方案范围。这是通过将 [Range](http://docs.unity3d.com/Documentation/ScriptReference/RangeAttribute.html) 属性附加到它来完成的。我们可以将 resolution 的两个属性放在它们自己的方括号之间,或者组合在一个逗号分隔的属性列表中。让我们做后者。

[SerializeField, Range]
int resolution = 10;

检查器检查字段是否附加了 [Range](http://docs.unity3d.com/Documentation/ScriptReference/RangeAttribute.html) 属性。如果是这样,它将约束该值并显示一个滑块。但是,要执行此操作,它需要知道允许的范围。因此, [Range](http://docs.unity3d.com/Documentation/ScriptReference/RangeAttribute.html) 需要两个参数(如方法)来表示最小值和最大值。让我们使用 10 和 100。

[SerializeField, Range(10, 100)]
int resolution = 10;

 分辨率滑块设置为 50。

 变量实例化

要使用配置的分辨率,我们必须更改实例化的多维数据集数量。现在,迭代量由 resolution 而不是始终 10 约束,而不是在 [Awake](http://docs.unity3d.com/Documentation/ScriptReference/MonoBehaviour.Awake.html) 中循环固定次数。因此,如果分辨率设置为 50,我们将在进入播放模式后获得 50 个立方体。

for (int i = 0; i < resolution; i++) {
…
}

我们还必须调整立方体的比例和位置,以使它们保持在 −1-1 域内。我们现在每次迭代必须执行的每个步骤的大小除以分辨率。将此值存储在变量中,并使用它来计算立方体的比例及其 X 坐标。

float step = 2f / resolution;
var position = Vector3.zero;
var scale = Vector3.one * step;
for (int i = 0; i < resolution; i++) {
Transform point = Instantiate(pointPrefab);
position.x = (i + 0.5f) * step - 1f;
…
}

 设置父级

以分辨率 50 进入播放模式后,许多实例化的立方体会显示在场景中,因此也会显示在项目窗口中。

    点是根对象。

这些点当前是根对象,但它们是图形对象的子对象是有意义的。我们可以在实例化点后建立这种关系,方法是调用其 [Transform](http://docs.unity3d.com/Documentation/ScriptReference/Transform.html) 组件的 SetParent 方法,向其传递所需的父级 [Transform](http://docs.unity3d.com/Documentation/ScriptReference/Transform.html) 。我们可以通过 **Graph**transform 属性获取图形对象的 [Transform](http://docs.unity3d.com/Documentation/ScriptReference/Transform.html) 组件,它继承自 [Component](http://docs.unity3d.com/Documentation/ScriptReference/Component.html) 。在循环块的末尾执行此操作。

for (int i = 0; i < resolution; i++) {
…
point.SetParent(transform);
}

点是图形的子项。

设置新的父对象后,Unity 将尝试将对象保持在原始世界位置、旋转和缩放。在我们的案例中,我们不需要这个。我们可以通过将 **false** 作为第二个参数传递给 SetParent 来发出信号。

point.SetParent(transform, false);

上篇