Catlike Coding Unity教程系列 中文翻译 Basics篇(二)Building a Graph

建立一个函数图像

可视化的数学

原文地址:https://catlikecoding.com/unity/tutorials/basics/building-a-graph/

本次教程的主要内容:

创建一个预制件。

实例化多个方块。

显示一个数学函数。

创建一个表面着色器和着色器图。

动画化图像。

这是关于学习使用Unity的基础知识系列的第二篇教程。这次我们将使用游戏对象来构建曲线图,这样我们就可以展示数学公式。我们还将使函数与时间相关,创建一个动画图像。

本教程使用Unity 2020.3.6f1制作。

在这里插入图片描述

用立方体来显示正弦波。

1. 创建一个由多个立方体组成的线条

在编程时,对数学的良好理解是必不可少的。在最基本的层面上,数学是对表示数字的符号的操作。解一个方程可以归结为重写一组符号,这样它就变成了另一组——通常是更短的符号集。数学规则规定了如何改写。

例如函数 f ( x ) = x + 1 {f(x) = x+1} f(x)=x+1,我们可以用一个数代替它的x参数,比如3,得到 f ( 3 ) = 3 + 1 = 4 {f(3)=3+1=4} f(3)=3+1=4。我们提供了3作为输入参数,最终以4作为输出参数。我们可以说函数把3映射到4。更简单的写法是输入-输出对,比如(3,4)。我们可以创建许多对 ( x , f ( x ) ) {(x,f(x))} (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} f(x)=x+1是容易理解的。 f ( x ) = ( x − 1 ) 4 + 5 x 3 − 8 x 2 + 3 x {f(x)=(x-1)^4+5x^3-8x^2+3x} f(x)=(x1)4+5x38x2+3x理解上会比较困难。我们可以写下一些输入-输出对,但这可能无法让我们很好地理解它所代表的映射。我们需要很多点,靠在一起。这最终会变成数字的海洋,难以解析。相反,我们可以将这些对解释为二维坐标 [ x f ( x ) ] \begin{bmatrix}x \\ f(x)\end{bmatrix} [xf(x)]。这是一个二维向量,上面的数字表示横坐标,在X轴上,下面的数字表示纵坐标,在Y轴上。换句话说,就是 y = f ( x ) {y=f(x)} y=f(x)。我们可以在曲面上画出这些点。如果我们用足够多的非常接近的点,就会得到一条直线。结果是一个图表。

在这里插入图片描述

x在-2到2之间的图像

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

1.1 预制件

通过在适当的坐标上放置点来创建图形。要做到这一点,我们需要一个点的三维可视化。我们将简单地使用Unity的默认立方体游戏对象。添加一个到场景并命名为Point。移除它的BoxCollider组件,因为我们不会使用物理系统。

立方体是可视化图形的最佳方式吗?

你也可以使用粒子系统或线段,但是单独的立方体是最容易使用的。

我们将使用一个自定义组件来创建这个立方体的许多实例,并正确地定位它们。为了做到这一点,我们将把立方体变成一个游戏对象模板。将立方体从层次结构窗口拖动到项目窗口。这将创建一个新的资产,称为预制件。它是存在于项目中的预制游戏对象,而不是在场景中。
在这里插入图片描述
在这里插入图片描述

Point预制件资产,一栏和两栏布局。

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

在这里插入图片描述
在这里插入图片描述

Point的预制件的实例。

当选择预制资产时,它的检查器将显示它的根游戏对象和一个大按钮来打开预制。

在这里插入图片描述

预制件资产的检查器。

点击 Open Prefab 按钮将使场景窗口显示一个只包含预制对象层次结构的场景。你也可以通过一个实例的 Open 按钮,在层级窗口中一个实例旁边的右箭头,或者双击项目窗口中的资产。当预制件具有复杂的层次结构时,这是很有用的,但对于我们简单的点预制件并不是这样。

在这里插入图片描述

预制件的层级窗口。

你可以通过层级窗口中名字左边的箭头退出预制场景。

为什么预制场景的背景是统一的深蓝色?

如果你打开一个预制件的实例,它是场景的一部分,那么场景窗口将根据窗口顶部显示的上下文设置显示它的周围环境。如果你打开预制件资产,那么就没有上下文了。在打开资产的情况下,天空框在预制场景中是默认禁用的,以及一些其他的东西。您可以通过场景窗口的工具栏进行配置,就像配置普通场景窗口一样。天空框可以通过下拉菜单切换,它看起来像一个堆栈,上面有一颗星星。当你跳出预置资产模式的时候,注意场景工具栏的设置如何改变。

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

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

1.2 图像组件

我们需要一个C#脚本来生成我们的点预制件的图像。创建一个命名为Graph

在这里插入图片描述

Scripts文件夹中的Graph C# 。

我们从一个扩展MonoBehaviour的简单类开始,这样它就可以用作游戏对象的组件。给它一个可序列化的字段,用于保存一个用于实例化点的预制件的引用,这个预制件名为 pointPrefab 。我们需要访问Transform组件来定位这些点,所以要设置字段的类型。

using UnityEngine;

public class Graph : MonoBehaviour {

	[SerializeField]
	Transform pointPrefab;
}

在场景中添加一个空的游戏对象,并将其命名为Graph。确保它的位置和旋转为0,缩放为1。将我们的Graph组件添加到这个对象。然后将我们的预制资产拖到图形的Point prefab字段上。它现在持有一个对预制的Transform组件的引用。

在这里插入图片描述

带有预制件引用的Graph游戏对象 。

1.3 实例化预制件

游戏对象的实例化是通过Object.Instantiate来完成的。这是一个Unity的Object类型的public方法,它是Graph通过扩展MonoBehaviour间接继承得到的。这个Instantiate方法克隆Unity对象,通过把这个对象作为参数传递给它。在传递一个预制件的情况下,它将导致一个实例被添加到当前场景。让我们在Graph组件被唤醒时执行此操作。

public class Graph : MonoBehaviour {

	[SerializeField]
	Transform pointPrefab;
	
	void Awake () {
		Instantiate(pointPrefab);
	}
}
MonoBehaviour的完整继承链是什么?

MonoBehaviour继承了Behaviour,Behaviour继承了Component,而Component又继承了Object。

如果我们现在进入游戏模式,一个Point预制件的实例将在世界原点产生。它的名字与预制件相同,并在后面附加一个(clone)。

在这里插入图片描述

实例化预制件,在场景窗口沿着Z轴负方向看。

你能在游戏模式下打开场景窗口吗?

是的,但是Unity总是在进入游戏模式时强制游戏窗口到前台。如果游戏窗口与场景窗口共享一个面板,那么该场景窗口将被隐藏。但你仍然可以在游戏模式切换回场景窗口。此外,你还可以配置编辑器布局,让一个或多个游戏和场景窗口同时可见。记住,Unity必须渲染所有这些窗口,所以你打开的越多,速度越慢。

要将点放置到其他地方,我们需要调整实例的位置。Instantiate方法为我们提供了对它创建的任何内容的引用。因为我们给了它一个Transform组件的引用,这就是我们得到的返回出来的东西。我们用一个变量来记录它。

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

在之前的教程中,我们通过给时针枢纽的Transform的localRotation属性分配四元数来旋转时针。改变位置的方法与此相同,只是我们需要将一个3D向量赋给localPosition属性。

3D向量是用Vector3结构类型创建的。例如,让我们设置点的X坐标为1,让它的Y和Z坐标为0。Vector3有一个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;
	}
我们可以将结构体和数字相乘吗?

通常不能,但是可以定义这样的功能。这是通过创建一个具有特殊语法的方法来完成的,因此可以像调用乘法一样调用它。在这种情况下,看似简单的乘法实际上是一个方法调用,比如Vector3.multiply(Vector3.right, 2f)。结果等于right向量的所有分量都翻倍的一个向量。

如果能够像使用简单操作一样使用方法,那么编写代码就会更快、更容易阅读。它不是必需的,但是拥有它是很好的,就像能够隐式地使用名称空间一样。这种方便的语法被称为语法糖。

尽管如此,只有在方法与操作符的原始含义严格匹配的情况下,才应该将方法用作操作符。在向量的情况下,一些数学运算符有良好的定义,所以对于它们是好的。

这段代码将产生编译器错误,因为我们试图定义两次point变量。如果我们想使用另一个变量,我们必须给它一个不同的名字。或者,我们可以重用已有的变量。在完成第一个点之后,我们不需要保留对它的引用,所以将新点赋值给相同的变量。

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

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

在这里插入图片描述

X坐标为1和2的两个立方体实例。

1.4 代码循环

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

while语句可用于使一段代码重复。将它应用于我们的方法的前两个语句,并删除其他语句。

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

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

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

	while (false) {
		Transform point = Instantiate(pointPrefab);
		point.localPosition = Vector3.right;
	}
我们可以在循环中定义point吗?

是的。尽管代码上看起来是重复的,但我们只定义了变量一次。它在每次循环迭代中都得到重用,就像我们之前手动做的那样。
也可以在循环之前定义point。这会允许你能在循环之外使用变量。否则,它的作用域仅限于while循环的块。

可以通过跟踪代码重复了多少次来限制循环。我们可以用一个整数变量来记录。它的类型是int。它将包含循环的迭代次数,所以我们把它命名为i。它的初始值为0。为了能够在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是0,在第二次迭代之前是1,以此类推。所以在第十次迭代之后,i是10。此时,我们想要停止循环,因此它的表达式应该计算为false。换句话说,只要i小于10,我们就应该继续。数学上表示为 i < 10 {i<10} i<10.在代码中也是一样的,使用<小于操作符。

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

进入游戏模式后,我们会得到十个方块。但它们最终都在同一个位置。通过right向量乘以i把它们沿着X轴排成一行。

	point.localPosition = Vector3.right * i;

在这里插入图片描述

沿着X轴排成一行的十个立方体。

注意,目前第一个立方体的X坐标为1,最后一个立方体的X坐标为10。我们改变一下,从0开始,把第一个立方体定位在原点。我们可以通过向右乘以(i - 1)而不是i来将所有的点向左移动一个单位。然而,我们可以跳过额外的减法,在块的末尾,在乘法之后,而不是在开始时增加i。

	while (i < 10) {
//		i = i + 1;
		Transform point = Instantiate(pointPrefab);
		point.localPosition = Vector3.right * i;
		i = i + 1;
	}
1.5 简洁的语法

因为循环一定次数是很常见的,所以保持循环代码简洁是很方便的。一些语法糖可以帮助我们做到这一点。

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

//		i = i + 1;
		i += 1;

更进一步,当一个数字增加或减少1时,它可以缩短为++x或–x。

//		i += 1;
		++i;

赋值语句的一个属性是,它们也可以用作表达式。这意味着你可以写一些像y = (x += 3)这样的东西。这会使x增加3,并将结果赋给y。这表明我们可以在while表达式中增加i,从而缩短代码块。

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

然而,现在我们在比较之前,而不是之后,增加i,这会减少一次迭代。特别是在这种情况下,递增和递减操作符也可以放在变量之后,而不是在它之前。该表达式的结果是修改前的原始值。

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

虽然while语句适用于所有类型的循环,但是有一种替代语法特别适合在范围内迭代。它是for循环。它的工作原理与while类似,只是迭代器变量声明和比较都包含在圆括号中,用分号分隔。

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

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

//	for (int i = 0; i++ < 10) {
	for (int i = 0; i < 10; i++) {
		Transform point = Instantiate(pointPrefab);
		point.localPosition = Vector3.right * i;
	}
为什么在for循环中使用i++而不是++i ?

由于增量表达式不用于其他任何事情,所以使用哪个版本并不重要。我们也可以用i += 1或者i = i + 1。
经典的for循环的形式为for (int i = 0; i < someLimit; i++)。您将在许多程序和脚本中遇到该代码片段。

1.6 更改定义域

目前,我们的点的X坐标是0到9。在处理函数时,这不是一个方便的范围。通常,x使用0-1的范围,或者处理以0为中心,使用−1-1的范围的函数。让我们相应地调整点的位置。

沿着两个单位长的线段放置十个立方体会使它们重叠。为了防止这种情况发生,我们要缩小它们的规模。默认情况下,每个立方体的每个维度的大小都是1,所以为了让它们合适,我们必须将它们的规模缩小到 2 10 = 1 5 {\frac{2}{10}} = {\frac{1}{5}} 102=51.我们可以通过将每个点的局部缩放设置为Vector3.one 除以5来实现。除法是用/斜杠操作符完成的。

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

您可以通过将场景窗口切换为正交投影(它忽略了透视)来更好地查看立方体的相对位置。点击场景窗口右上角的坐标轴小部件下的标签,就可以在正投影模式和透视模式之间切换。如果你通过场景窗口工具栏关闭天空盒,白色的立方体也更容易看到。
在这里插入图片描述

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

为了让立方体再次聚集在一起,也要将它们的位置除以5。

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

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

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

在这里插入图片描述

从-1.0到0.8的小立方体。

现在第一个立方体的X坐标为−1,而最后一个的X坐标为0.8。但是,立方体大小是0.2。由于立方体以它的位置为中心,第一个立方体的左边在−1.1,最后一个立方体的右边在0.9。为了整齐地填充−1-1范围的立方体,我们必须将它们向右移动半个立方体。可以先给i加0.5再除以5。

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

在这里插入图片描述

填充-1~1范围的小立方体。

1.7 把矢量从循环中提出

虽然所有的立方体都有相同的缩放,但我们在每次循环迭代中都要重新计算一次。我们不需要这么做,缩放是不变的。相反,我们可以在循环之前计算一次,将其存储在一个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.right.

		Vector3 position;
		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);
			position.x = (i + 0.5f) / 5f - 1f;
			point.localPosition = position;
			point.localScale = scale;
		}
我们可以单独改变一个矢量的分量吗?

Vector3结构有三个浮点字段:x、y和z。这些字段是public的,所以我们可以更改它们。
因为struct的行为类似于简单值,所以其思想是它们应该是不可变的。一旦构建,它们就不应该改变。如果你想使用一个不同的值,给字段或变量分配一个新的结构,就像我们对数字做的那样。如果我们让 x = 3.再让x=5,我们给x赋了一个不同的数字。我们没有把数字3本身修改成5。然而,Unity的矢量类型是可变的。这样做既是为了方便也是为了性能,因为单个矢量组件通常是独立操作的。
为了了解如何使用可变向量,可以考虑使用Vector3来代替使用三个独立的浮点值。您可以独立地访问它们,也可以将它们作为一个组进行复制和分配。

这将导致编译器错误,因为使用了未赋值的变量。这是因为我们给某个东西赋了位置而我们还没有设置它的Y和Z坐标。我们可以通过用Vector3.zero赋值position,将其初始化为0向量,来解决这个问题。

		//Vector3 position;
		var position = Vector3.zero;
		var scale = Vector3.one / 5f;
1.8 用X定义Y

我们的立方体的位置被定义为 [ x f ( x ) 0 ] \begin{bmatrix}x \\f(x) \\0 \end{bmatrix} xf(x)0 ,所以我们可以用它们来显示函数。在这一点,Y坐标总是0,表示简单的函数 f ( x ) = 0 {f(x)=0} f(x)=0.为了展示一个不同的函数,我们必须确定循环内部的Y坐标,而不是在循环之前。首先让Y = X,表示函数 f ( x ) = x {f(x)=x} f(x)=x.

在这里插入图片描述

y = x

一个不太明显的函数是 f ( x ) = x 2 {f(x)=x^2} f(x)=x2,它定义了最小值为零的抛物线。

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

在这里插入图片描述

y 等于 x 的平方

2. 创建更多的立方体

虽然我们现在有一个函数图,但它很难看。因为我们只使用了10个立方体,所以建议的线看起来非常块状和离散。如果我们使用更多更小的立方体,看起来会更好。

2.1 分辨率变量

代替使用固定数量的立方体,我们可以使其可配置。要实现这一点,添加一个可序列化的整数字段,作为Graph的分辨率。默认为10,这就是我们现在使用的。

	[SerializeField]
	Transform pointPrefab;

	[SerializeField]
	int resolution = 10;

在这里插入图片描述

可配置的分辨率

现在我们可以通过检查器来调整图形的分辨率。然而,并非所有整数都是有效的。至少它们应该是正数。我们可以告诉检查器为我们的分辨率划定一个范围。这是通过附加Range属性来完成的。我们既可以将两个分辨率属性放在各自的方括号中,也可以将它们组合在一个逗号分隔的属性列表中。让我们选择后者。

	[SerializeField, Range]
	int resolution = 10;

检查器检查一个字段是否附加了一个Range属性。如果是这样,它将约束数值,并显示滑动条。然而,要做到这一点,它需要知道允许的范围。因此Range需要两个参数——就像一个方法一样——来确定最小值和最大值。我们用10和100。

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

在这里插入图片描述

分辨率滑东条设置为50。

这能保证分辨率被限制在10-100吗?

Range属性所做的就是指示检查器使用一个带有该范围的滑块。它不会以任何其他方式影响分辨率。所以我们可以写代码给它赋一个超出范围的值,但我们不会那样做。

2.2 变量实例化

为了使用配置的分辨率,我们必须更改实例化的立方体的数量。在Awake中,不再循环固定的次数,而是由 resolution 限制迭代次数,而不是总是10次。如果分辨率设置为50,我们会在进入游戏模式后看到50个立方体。

		for (int i = 0; i < resolution; i++) {}
是什么意思?

这表明我省略了一些没有更改的代码

我们还必须调整立方体的比例和位置,以保持它们在-1~1的范围内。每次迭代所需要的每个步长的大小现在是2 / resolution。将这个值存储在一个变量中,并使用它来计算立方体的缩放及其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;}
2.3 设置父对象

在进入分辨率为50的游戏模式后,许多实例化的立方体会出现在场景中,因此也会出现在项目窗口中。

在这里插入图片描述

每个点都是根对象。

这些点目前是根对象,对于它们来说成为Graph对象的子对象是有意义的。我们可以在实例化一个点之后,通过调用它的Transform组件的SetParent方法,向它传递所需的父Transform,来建立这种关系。我们可以通过Graph继承自Component的Transform属性来获得Graph对象的Transform组件。在循环的末尾执行此操作。

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

在这里插入图片描述

点是Graph的子结点。

当一个新的父对象被设置时,Unity将试图保持该对象在其在原始世界的位置,旋转和缩放。我们的例子不需要这个。我们可以通过将false作为第二个参数传递给SetParent来表示这一点。

			point.SetParent(transform, false);

3. 给图像上色

白色图表不好看。我们可以用另一种纯色,但那也不是很有趣。使用点的位置来确定它的颜色更有趣。

调整每个立方体颜色的一个简单方法是设置其材质的颜色属性。我们可以在循环中做。因为每个立方体都将获得不同的颜色,这意味着我们最终将为每个对象提供一个独特的材质实例。当我们之后进行图表动画时,我们也必须一直调整这些材料。虽然这种方法有效,但效率不是很高。如果我们可以使用一种材料,直接使用位置作为它的颜色,那就更好了。不幸的是,Unity没有这样的材料。所以让我们自己做吧。

3.1 创建一个表面着色器

GPU运行着色程序来渲染3D对象。Unity的材质资产决定使用哪个着色器,并允许配置它的属性。我们需要创建一个自定义着色器来获得我们想要的功能。通过Assets / Create / Shader / Standard Surface Shader创建,并命名为Point Surface
在这里插入图片描述
在这里插入图片描述

在Point文件夹下和预制件在一组的着色器,分别在一列和两列布局。

我们现在有一个着色器资产,你可以像打开脚本一样打开它。我们的着色器文件包含了定义表面着色器的代码,它使用了不同于C#的语法。它包含一个表面着色器模板,但我们将删除一切,从头开始创建一个最简单的着色器。

表面着色器如何工作?

Unity提供了一个框架来快速生成执行默认光照计算的着色器,你可以通过调整某些值来影响它。这样的着色器被称为表面着色器。不幸的是,它们只适用于默认的渲染管道。我们将在后面介绍通用渲染管道。

Unity有自己的着色器的语法,总体上大致像C#,但它是不同语言的混合。它以Shader关键字开始,后面跟着一个为Shader定义菜单项的字符串。字符串写在双引号内。我们将使用Graph/Point Surface。之后是着色器内容的代码块。

Shader "Graph/Point Surface" {}

着色器可以有多个子着色器,每个子着色器由SubShader关键字后跟一个代码块定义。我们只需要一个。

Shader "Graph/Point Surface" {

	SubShader {}
}

在子着色器下面,我们还想添加一个回退到标准的漫反射着色器,写上 FallBack “Diffuse”.

Shader "Graph/Point Surface" {
	
	SubShader {}
	
	FallBack "Diffuse"
}

表面着色器的子着色器需要一个混合CG和HLSL(两种着色器语言)编写的代码部分。此代码必须包含CGPROGRAM和ENDCG关键字。

	SubShader {
		CGPROGRAM
		ENDCG
	}

第一个需要的语句是编译器指令,称为pragma。它写为#pragma,后跟一个指令。在这种情况下,我们需要#pragma surface Configusurface Standard fullforwardshadows,它指示着色器编译器生成一个标准照明和完全支持阴影的表面着色器。Configuresface指的是一个用于配置着色器的方法,我们必须创建它。

		CGPROGRAM
		#pragma surface ConfigureSurface Standard fullforwardshadows
		ENDCG
pragma是什么意思?

pragma这个词来自希腊语,指的是行动或需要做的事情。许多编程语言都使用它来发出特殊的编译器指令。

我们接着使用#pragma target 3.0指令,它为着色器的目标版本和质量设置了一个最小值。

		CGPROGRAM
		#pragma surface ConfigureSurface Standard fullforwardshadows
		#pragma target 3.0
		ENDCG

我们要根据他们在世界上的位置来给我们的点上色。为了在表面着色器中工作,我们必须定义配置函数的输入结构体。它叫struct Input,后面跟着一个代码块,然后是分号。在块中,我们声明了一个结构字段,具体来说就是float3 worldPos。它将包含被渲染的世界位置。float3类型是与Vector3结构体等价的着色器。

		CGPROGRAM
		#pragma surface ConfigureSurface Standard fullforwardshadows
		#pragma target 3.0

		struct Input {
			float3 worldPos;
		};
		ENDCG

这是否意味着移动图形会影响它的颜色?

是的。使用这种方法,只有当我们离开Graph对象的位置时,着色才会是正确的:在世界原点,没有旋转,比例为1。
还要注意,这个位置是根据每个顶点确定的。在我们的例子中,这是立方体的每个角。颜色将被插值到立方体的各个面。立方体越大,颜色过渡就越明显。

下面我们定义了ConfiguSurface方法,尽管在着色器下,它总是被称为一个函数,而不是一个方法。它是一个带两个参数的void函数。第一个是输入参数,它具有我们刚刚定义的input类型。第二个参数是表面配置数据,类型为SurfaceOutputStandard。

		struct Input {
			float3 worldPos;
		};

		void ConfigureSurface (Input input, SurfaceOutputStandard surface) {}

第二个形参必须将inout关键字写在它的类型前面,这表明它既被传递给函数,又被用作函数的结果。

		void ConfigureSurface (Input input, inout SurfaceOutputStandard surface) {}

现在我们有了一个功能着色器,为它创建一个材质,命名为Point Surface。在它的检查器头部的 Shader 下拉列表中选择Graph / Point Surface,设置它使用我们的着色器。

在这里插入图片描述

Point Surface材质。

该材料目前是固体哑光黑色。我们可以在我们的配置函数中设置surface.Smoothness为0.5,使它看起来更像默认材质。当编写着色器代码时,我们不需要为浮点值添加f后缀。

		void ConfigureSurface (Input input, inout SurfaceOutputStandard surface) {
			surface.Smoothness = 0.5;
		}

现在这种材料不再是完全哑光的。你可以在检查器头部的小材质预览中看到,或者在底部可调整大小的预览中看到。

在这里插入图片描述

中等平滑度的材质预览。

我们还可以对平滑进行配置,就像为它添加一个字段并在函数中使用它一样。默认的样式是用下划线和大写下一个字母作为着色器配置选项的前缀,所以我们将使用_Smoothness。

		float _Smoothness;

		void ConfigureSurface (Input input, inout SurfaceOutputStandard surface) {
			surface.Smoothness = _Smoothness;
		}

为了让这个配置选项出现在编辑器中,我们必须在着色器的顶部添加一个Properties块,在子着色器的上方。在这里写入_Smoothness,然后是(“Smoothness”, Range(0,1)) = 0.5。这给了它 Smoothness 标签,暴露它作为一个带有范围0~1的滑动条,并设置其默认值为0.5。

在这里插入图片描述

可配置的平滑度。

让我们的Cube预制件资产使用这个材料,而不是默认的一个。这会让点变成黑色。

在这里插入图片描述

黑点。

3.2 基于世界位置着色

为了调整点的颜色,我们需要修改 surface.Albedo。由于反照率和世界位置都有三个分量,我们可以直接使用位置作为反照率。

		void ConfigureSurface (Input input, inout SurfaceOutputStandard surface) {
			surface.Albedo = input.worldPos;
			surface.Smoothness = _Smoothness;
		}

在这里插入图片描述

彩色的点。

现在,世界X位置控制点的红色分量,Y位置控制绿色分量,Z位置控制蓝色分量。但是我们的图像的X域是- 1-1,而负的颜色分量是没有意义的。所以我们要将位置减半然后加上1 / 2使颜色符合定义域。我们可以同时对所有的三个分量这样做。

			surface.Albedo = input.worldPos * 0.5 + 0.5;

为了更好地了解颜色是否正确,让我们改变Graph.Awake ,所以我们显示函数 f ( x ) = x 3 {f(x)=x^3} f(x)=x3,这让Y也从- 1到1。

在这里插入图片描述

x的立方,带些蓝色。

结果是蓝色的,因为所有立方体面的Z坐标都接近于0,这使得它们的蓝色分量接近于0.5。在设置反照率时,我们可以通过只包括红色和绿色通道来消除蓝色。这可以在着色器中通过只分配surface.Albedo.rg和只使用input. worldposs .xy来完成。这样蓝色的分量就保持为零。

			surface.Albedo.rg = input.worldPos.xy * 0.5 + 0.5;

在这里插入图片描述

x的立方,从绿到黄。

3.3 通用的渲染管道

除了默认的渲染管道,Unity还有通用和高清渲染管道,简称为URP和HDRP。两种渲染管道都有不同的特性和限制。当前默认的渲染管道仍然有效,但是它的特性集被冻结了。在几年内URP将可能成为默认。让我们的图也适用于URP。

如果你还没有使用URP,去包管理器并安装最新的Universal RP包,验证你的Unity版本,在我这里是10.4.0。
在这里插入图片描述

已安装的URP包。

在包管理器的哪里可以找到URP ?

确保你已经将包过滤器设置为 Unity Registry 而不是 In Project。然后搜索 universal 或向下滚动列表,直到你找到它。

这并不会让Unity自动使用URP。我们首先必须为它创建一个资产,点击 Assets / Create / Rendering / Universal Render Pipeline / Pipeline Asset (Forward Renderer)。我把它命名为URP。这也将自动为渲染器创建另一个资产,在我的例子中名为URP_Renderer。

在这里插入图片描述
在这里插入图片描述

URP资产在单独的文件夹中,一列和两列布局。

接下来,转到项目设置的Graphics部分,并将URP资产分配给Scriptable Renderer Pipeline settings字段。

在这里插入图片描述

使用URP。

稍后要切换回默认的渲染管道,只需将Scriptable Renderer管道设置设置为None。这只能在编辑器中完成,渲染管道不能在构建的独立应用中更改。

HDRP呢?

HDRP是一个更加复杂的渲染管道。我不会在我的教程中介绍它。

3.4 创建着色图

我们当前的材质只适用于默认的渲染管道,而不是URP。所以当URP被使用时,它被Unity的错误材料所取代,这是固体洋红色的。

在这里插入图片描述

立方体变成了洋红色。

我们必须为URP创建一个单独的着色器。我们可以自己编写一个,但目前这非常困难,而且在升级到更新的URP版本时很可能会中断。最好的方法是使用Unity的着色器图形包来可视化地设计一个着色器。URP依赖于这个包,所以它会自动与URP包一起安装。

Assets / Create / Shader / Universal Render Pipeline / Lit Shader Graph 创建一个新的着色器图,命名为Point URP

在这里插入图片描述
在这里插入图片描述

Point URP着色器图资产,一列和两列布局。

这个图形可以通过在项目窗口中双击它的资产或者在它的检查器中按下Open Shader Editor按钮来打开。这将为它打开一个着色器图窗口,它可能被多个节点和面板混乱。这些是黑板、图形检查器和主预览面板,可以调整大小,也可以通过工具栏按钮隐藏。还有两个链接节点:Vertex节点和Fragment节点。这两个是用来配置着色器图形的输出。

在这里插入图片描述

默认照明着色器图。

一个着色图由表示数据或操作的节点组成。目前Fragment节点的Smoothness值设置为0.5。要使它成为一个可配置的着色器属性,请按下Point URP背板面板上的加号按钮,选择Float,并将新条目命名为Smoothness。这将向黑板添加一个表示属性的圆形按钮。选择它并将图形检查器切换到它的Node Settings选项卡,以查看该属性的配置。

在这里插入图片描述

带有默认设置的Smoothness属性。

Reference是在内部知道该属性的名称。这与我们在表面着色器代码中将属性字段命名为_Smoothness的方式相对应,所以让我们在这里使用相同的内部名称。然后将其以下的默认值设置为0.5。确保它的Exposed切换选项是启用的,因为这控制了材质是否为它获得一个着色器属性。最后,要使它显示为滑动条,请将其Mode更改为Slider。

在这里插入图片描述

接下来,将圆角Smoothness按钮从黑板拖动到图形中的空白区域。这将为图添加一个Smoothness节点。通过从它们的一个点拖拽到另一个点,将它连接到PRB Master节点的平滑输入。这在它们之间创建了一个链接。
在这里插入图片描述

Smoothness连接。

现在您可以通过 Save Asset 工具栏按钮保存图形,并创建一个名为Point URP的材质来使用它。着色器的菜单项是Shader Graphs / Point URP。然后让Point预制使用的材料,代替之前的Point Surface

在这里插入图片描述

URP的材质使用我们的着色图。

3.5 用节点编程

为了给点上色,我们必须从位置节点开始。通过在图的空白部分打开上下文菜单并从中选择New Node来创建一个节点。选择 Input / Geometry / Position 或搜索Position

在这里插入图片描述

世界位置节点。

现在我们有一个位置节点,默认情况下它被设置为世界空间。您可以通过按光标悬停在其上时出现的向上箭头来折叠其预览显示。
使用相同的方法创建一个Multiply和一个Add节点。使用这些来将位置的XY分量缩放0.5,然后添加0.5,同时将Z设置为0。这些节点根据所连接的对象调整其输入类型。因此,首先连接节点,然后填入它们的常量输入。然后将结果连接到Fragment的Base Color。

在这里插入图片描述

彩色的着色图。

当鼠标悬停在Multiply节点和Add节点上时,可以通过按下右上角出现的箭头来缩小它们的视觉大小。这就隐藏了所有没有连接到另一个节点的输入和输出。这消除了很多杂乱。你也可以通过上下文菜单删除VertexFragment节点的组件。通过这种方式,您可以隐藏所有保持默认值的内容。

在这里插入图片描述

在保存着色器资产后,我们现在在播放模式中获得了与使用默认渲染管道时相同的颜色点。除此之外,一个调试更新器会出现在一个独立的DontDestroyOnLoad场景中。这是用于调试URP的,可以忽略。

在这里插入图片描述

在播放模式中的URP调试更新器。

从这里开始,您可以使用默认的渲染管线或URP。从一个切换到另一个后,你还必须改变Point预制件的材质,否则它将是品红色。如果你对图形生成的着色器代码感到好奇,你可以通过图形检查器的View Generated Shader按钮来获取它。

4. 让图像产生动画

显示静态图很有用,但是动态图看起来更有趣。所以让我们添加对动画函数的支持。这是通过让时间作为一个额外的函数参数来实现的,用函数 f ( x , t ) {f(x,t)} f(x,t)代替 f ( x ) {f(x)} f(x),t表示时间。

4.1 记录点的时间

为了使图形动画化,我们必须随着时间的推移调整它的点。我们可以通过删除所有点并在每次更新时创建新的点来实现这一点,但这是一种低效的方法。最好保持使用相同的点,每次更新都调整它们的位置。为了实现这一点,我们将使用一个字段来保持对我们的点的引用。在Graph中添加一个名字是points的,类型是Transform的字段。

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

该字段允许我们引用单个点,但我们需要访问所有点。通过在字段类型后面加上空方括号,可以将字段转换为数组。

	Transform[] points;

points字段现在是对数组的引用,数组的元素为Transform类型。数组是多个对象,而不是简单的值。我们必须显式地创建这样一个对象,并使字段引用它。这是在数组类型后面写入new来完成的,因此在我们的示例中是new Transform[]。在Awake中循环之前创建数组,并将其赋值给points。

		points = new Transform[];
		for (int i = 0; i < resolution; i++) {}

在创建数组时,我们必须指定它的长度。这定义了它有多少元素,这些元素在创建后不能更改。在构造数组时,将长度写入方括号内。让它等于图形的分辨率。

		points = new Transform[resolution];

现在我们可以用对点的引用填充数组。访问数组元素是通过在数组引用后面的方括号中写入其索引来完成的。数组第一个元素的下标从0开始,就像循环的迭代计数器一样。我们可以用它来赋值给相应的数组元素。

		points = new Transform[resolution];
		for (int i = 0; i < resolution; i++) {
			Transform point = Instantiate(pointPrefab);
			points[i] = point;}

如果我们在一行中多次赋值相同的东西,我们可以将这些赋值链接在一起,因为赋值表达式的结果就是赋值的内容,就像在上一课中解释的那样。

			Transform point = points[i] = Instantiate(pointPrefab);
			//points[i] = point;

现在循环遍历点数组。因为数组的长度与分辨率相同,我们也可以使用它来约束我们的循环。每个数组都有一个Length属性,我们来使用它。

		points = new Transform[resolution];
		for (int i = 0; i < points.Length; i++) {}
4.2 更新点

为了调整每一帧图像,我们需要在Update方法中设置点的Y坐标。所以我们不再需要在Awake中计算它们。我们仍然可以在这里设置X坐标因为我们不会改变它们。

		for (int i = 0; i < points.Length; i++) {
			Transform point = points[i] = Instantiate(pointPrefab);
			position.x = (i + 0.5f) * step - 1f;
//			position.y = position.x * position.x * position.x;}

像Awake一样添加一个带有for循环的Update方法,但在其块中还没有任何代码。

	void Awake () {}
	
	void Update () {
		for (int i = 0; i < points.Length; i++) {}
	}

我们将通过获取当前数组元素的引用并将其存储在一个变量中来开始每次循环迭代。

		for (int i = 0; i < points.Length; i++) {
			Transform point = points[i];
		}

之后,我们检索点的局部位置,并将其存储在一个变量中。

		for (int i = 0; i < points.Length; i++) {
			Transform point = points[i];
			Vector3 position = point.localPosition;
		}

现在我们可以设置位置的Y坐标,基于X,就像我们之前做的那样。

		for (int i = 0; i < points.Length; i++) {
			Transform point = points[i];
			Vector3 position = point.localPosition;
			position.y = position.x * position.x * position.x;
		}

因为位置是一个结构体,所以我们只调整了局部变量的值。要把它应用到这个点上,我们必须重新设置它的位置。

		for (int i = 0; i < points.Length; i++) {
			Transform point = points[i];
			Vector3 position = point.localPosition;
			position.y = position.x * position.x * position.x;
			point.localPosition = position;
		}
我们不能直接设置point.localPosition.y吗?

如果localPosition是一个公共字段,那么我们就可以直接设置点位置的Y坐标。但是,localPosition是一个属性。它将vector值的副本传递给我们,或者复制我们赋给它的值。所以我们最终会调整一个局部矢量值,这根本不会影响点的位置。由于我们没有首先显式地将其存储在变量中,因此该操作将是没有意义的,并将产生编译器错误。

4.4 显示正弦波

从现在开始,当在游戏模式中,我们的图形的点将在每一帧中被定位。我们还没有注意到这一点,因为它们总是在相同的位置结束。我们必须把时间融入到函数中以使它发生变化。然而,简单地增加时间会导致函数上升并迅速消失在视野之外。为了防止这种情况发生,我们必须使用一个变化但保持在固定范围内的函数。正弦函数是最理想的,因此我们使用 f ( x ) = sin ⁡ ( x ) {f(x)=\sin(x)} f(x)=sin(x)。 我们可以用Mathf.Sin方法来计算。

			position.y = Mathf.Sin(position.x);

在这里插入图片描述

Mathf是什么?

它是UnityEngine命名空间中的一个结构,包含数学函数和常量的集合。当它处理浮点数时,它的类型名称被赋予了f后缀。

正弦波在−1和1之间振荡。它每2π重复一次,这意味着它的周期约为6.28。由于我们的图的X坐标在−1到1之间,我们目前看到的重复样式不到三分之一。将X放大π倍,得到 f ( x ) = sin ⁡ ( π x ) f(x) = \sin(\pi x) f(x)=sin(πx).我们可以用 Mathf.PI常数作为π的近似。

			position.y = Mathf.Sin(Mathf.PI * position.x);

在这里插入图片描述

sin和π是什么?

sin是一个三角函数,作用于一个角度。在我们的例子中,最有用的例子是半径为1的圆,单位圆。圆上的每一点都有一个角度θ-theta,和它相关,以及一个2D位置。定义这些位置坐标的一种方法是 [ sin ⁡ ( θ ) sin ⁡ ( θ + π 2 ) ] \begin{bmatrix} \sin(\theta) \\ \sin(\theta +\frac{\pi}{2}) \end{bmatrix} [sin(θ)sin(θ+2π)],这表示从圆的顶部开始顺时针绕行。代替 sin ⁡ ( θ + π 2 ) {\sin(\theta + \frac{\pi}{2})} sin(θ+2π),你也可以用cos,得出 [ sin ⁡ ( θ ) cos ⁡ ( θ ) ] \begin{bmatrix} \sin(\theta) \\ \cos(\theta) \end{bmatrix} [sin(θ)cos(θ)].

在这里插入图片描述

πX的正弦和余弦。

角θ用弧度表示,对应的是沿着单位圆外围移动的距离。在移动了半圆时,距离等于π,大概是3.14。所以整个周长是2π。换句话说,π是圆的周长和直径的比值。

要使该函数动画化,请在计算正弦函数之前将当前游戏时间添加到X。游戏时间通过Time.time得到的。如果我们将时间乘以π,这个函数将每两秒重复一次。所以用 f ( x , t ) = sin ⁡ ( π ( x + t ) ) f(x,t)=\sin(\pi(x+t)) f(x,t)=sin(π(x+t)),其中t是经过的游戏时间。这将使正弦波随着时间的推移向前移动,向负X方向移动。

			position.y = Mathf.Sin(Mathf.PI * (position.x + Time.time));

WeepyAgreeableBoutu-mobile

动画正弦波。

因为Time.time每次循环迭代的时间是相同的,我们可以将属性调用提到循环之外。

		float time = Time.time;
		for (int i = 0; i < points.Length; i++) {
			Transform point = points[i];
			Vector3 position = point.localPosition;
			position.y = Mathf.Sin(Mathf.PI * (position.x + time));
			point.localPosition = position;
		}
4.4 限定颜色

正弦波的振幅是1,这意味着我们的点所达到的最低和最高位置是−1和1。但是,由于这些点是具有大小的立方体,所以它们的范围稍微超出了这个范围。因此,我们可以得到绿色分量为负或大于1的颜色。尽管这是不明显的,我们还是要限定颜色,以确保他们保持在0-1的范围。

我们可以将生成的颜色通过saturate函数传递给我们的表面着色器。这是一个特殊的函数,它将所有组件限定在0-1之间。这是着色器中一个常见的操作,称为饱和度,因此得名。

			surface.Albedo.rg = saturate(input.worldPos.xy * 0.5 + 0.5);

同样的操作也可以在着色器图中使用Saturate节点来完成。

在这里插入图片描述

着色器图中的饱和颜色。

下一个教程是数学曲面。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值