前言

前排说明:关于本教程目录导航以及说明:猫式教程

在本篇文章中你将会学到:

  • [x] 创建一个预制体(prefab
  • [x] 实例化(Instantiate)多个立方体
  • [x] 图形化一个数学函数
  • [x] 创建surface shader and shader graph
  • [x] 将图形赋予动画

这是有关学习和使用 Unity 基础知识系列教程的第二篇。这次我们将会使用基本的游戏对象(物体)来构建函数图形,我们可以通过图形来展示(图形化)数学函数。同时我们将会使用函数和时间进行关联创建对应的动画。

本篇教程使用Unity 2020.3.38f1c1 编写。

image-20220918103315837

创建一排”点”

对数学的理解是编程所必不可少的。从表面上来说,数学是对表示数值的符号(即未知数)的操作。求解方程归结为一组符号(即方程)。数学规则(即函数定义)决定了这种结果。

例如,我们使用函数 $F(x) = X +1$ ,现在我们使用数值 $3$ 来替代 $X$ 的位置,你就可以得到输出为 $F(x) = 3 + 1 = 4$ 。我们可以说通过函数 $F(x) = X +1$ 将数值 $3$ 映射到 数值 $4$ 上。当然,你可以通过如下的方式进行更短的缩写,例如:$(3,4)$ ;基于此我们可以写出更多的形式 $(X,F(x))$ 的映射,例如:$(5,6)$ 等。

对于函数 $F(x) = X +1$ 相对容易理解,但是对于函数 F(X)= $ (X-1)^ {4} $ + $ 5X^ {3} $ - $ 8X^ {2} $ +3X 理解起来会相对困难一些。当然我们也可以通过上述函数的方法,来写出其对应的映射对(即 $(X,F(X))$ 形式的映射结果),但是离散有限的映射对可能无法让我们很好的理解该函数。因此我们需要很多个点,来形成无数的映射对结果。相反的,我们也可以将这些映射转换成二维坐标 $[x,f(x)]$ 来展示。其中坐标的数值表示 $X$ 轴上的水平坐标,右边的数值表示 $Y$ 轴上的垂直坐标,我们可以通过大量的映射结果,来最终得到一条映射结果图,如下所示:

image-20220918105143493

图形由 Desmos 绘制

查看函数的图形可以让我们快速理解函数的行为结果。这是一个很方便的工具,现在来让我们在 Unity 中创建一个。现在我们将会从一个新项目开始创建。

创建预制体(Prefabs)

通过上面我们知道了可以在适当的坐标上来放置(生成)点来生成图形。所以我们需要一个可视化的“点”。为此,我们选择使用 Unity 中最基本的游戏对象(物体),即立方体。现在在场景中创建一个立方体并命名为 Point ,同时删除它的 BoxColoder 组件(component,因为我们不会使用它的物理特性。

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

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

我们将使用一个自定义的组件(脚本)来创建这个立方体的许多实例并正确的定位它们的位置。为了做到这一点,我们需要通过将立方体point从层次结构窗口(Hierarchy)拖动到项目(Project)面板中将其转换成一个游戏对象(物体)的模板。这将会创建一个新的资源对象(Assets),其被称为预制体Prefab)。它代表着已经预先制作好的游戏对象,属于这个项目中,而不单单是某个场景中。

image-20220918111102016

现在你会发现我们创建的预制体的游戏对象依旧存在于场景(Scene)中,但是它现在已经变成了一个预制体了。它在结构层次面板(Hierarchy)中变成了一个蓝色的图标,右侧存在一个箭头图标。其检查器面板(Inspector)标题栏部分也表明了该物体是一个预制体并且会多出一部分可选择按钮。位置(Position)和旋转(Rotation)现在也以粗体来显示,这表面实例对象(物体)的值覆盖了预制体的值。

image-20220918111640415

当你在项目资源面板(Project)中查看预制体的检查器面板(Inspector)将显示其根游戏对象和一个用于打开预制体(Open Prefab)的按钮。

image-20220918111843039

单击 【Open Prefab】 按钮会在场景(Scene)中显示一个只包含预制体(Prefab)的场景。你也可以通过点击层次结构面板(Hierarchy)预制体实例右侧的小箭头,或者双击项目资源面板(Project)中的预制体进入预制体(Prefab)场景。当我们制作复杂层次结构的预制体的时候,这会很有帮助,但是对于目前情况来说,并不需要。

image-20220918160846361

你可以通过上图左侧的箭头退出预制体(Prefab)场景

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

1
2
3
如果你是通过项目资源面板(Project)的预制体进入的预制体场景,则其默认是关闭天空盒(Skybox)的。
如果你是通过层次结构面板(Hierarchy)进入的预制体场景,则默认是有天空盒(Skybox)的
你也可以通过场景工具栏上,类似于堆栈上面有颗星星的图标进行设置

预制体(Prefab)是快速配置游戏对象的快捷方式。如果你修改了项目资源面板(Project)中的预制体,则场景中的所有实例都会以相同的方式进行修改。此外,你可以通过修改实例的值,来覆盖预制体(Prefab)的值

需要注意的是:在播放模式(Play)下,预制体和实例的影响关系会被暂时解除

接下来,我们将使用脚本来创建我们设置好的预制体(Prefab)实例,这意味着我们现在并不需要使用场景中的这个预制体(Prefab)实例了,所以可以删除它了。

创建图形组件(类)

现在我们需要创建一个C#脚本来使用我们创建的预制体来生成相关图形。创建脚本并命名为Graph

image-20220918162140215

我们从最简单的类(Class)开始,其继承自 MonoBehaviour ,这样它就可以作为组件附加到游戏对象上。现在来创建一个可序列化字段来保存对实例化点的预制体的引用,名称为pointPrefab。因为我们需要访问 Transform 组件来控制物体的位置,所以字段的类型为 Transform 。代码示例:

1
2
3
4
5
6
7
using UnityEngine;

public class Graph : MonoBehaviour
{
[SerializeField]
Transform pointPrefab; //序列化 Transform 类型的字段
}

现在在场景中添加一个空的游戏对象(Empty)并为其命名 Graph,确保它的位置(Postiion)和旋转(Rotation)为 0 ,且物体缩放(Scale)为 1 。现在将我们创建的脚本作为组件添加到这个对象上,然后将我们创建的预制体拖到 point Prefab 字段上,完成对预制体的引用。

image-20220918163300842

实例化预制体

实例化(Instantiate)游戏对象是通过 Object.Instantiate 来实现的。这是 Unity 为开发者提供的公开可用的方法,通过其基类为 Object 以及类继承自 MonoBehaviour (这段的意思是物体的基类是Object类型可以实例化,但是要调用实例化代码需要将类继承自 MonoBehaviour来完成组件化)。该方法会创建一个预制体实例对象在场景中。现在我们在组件唤醒的时候执行该操作,代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
using UnityEngine;

public class Graph : MonoBehaviour
{
[SerializeField]
Transform pointPrefab; //序列化 Transform 类型的字段

void Awake()
{
Object.Instantiate(pointPrefab); //实例化预制体
}
}

关于MonoBehaviour的完整继承关系是什么?

1
2
MonoBehaviour 继承自 Behaviour,而 Behaviour 继承自 Component
Component 继承自 Object

如果我们现在进行播放模式(Play)则在开始的时候在世界原点生成一个预制体实例。它的名称和预制体的名称相同,你可以在层次结构面板(Hierarchy)中带有(clone)名称标识找到它。

image-20220918164425639

要将该预制体实例移动到其他位置,我们需要调整实例的位置。Instantiate方法为我们提供了其创建的内容的引用。因为我们给它传入的参数类型是Transform类型的引用,所以其方法的返回值也是创建实例的Transform类型。现在我们来使用一个变量来获取到创建的实例的值。代码示例:

1
2
3
4
void Awake()
{
Transform point = Instantiate(pointPrefab);
}

注:我仅对修改代码的部分进行展示,未修改的代码和之前一样

在第一篇教程中,我们使用了localRotation将四元数分配给时针轴来改变时钟臂的旋转。现在同样的,不过不太一样的是我们需要使用localPosition来向其分配一个三维向量来改变位置。

三维向量使用 Vector3 类型创建。例如,现在我们将点的 X 坐标设置为 1,将其 Y 和 Z 设置为 0 。Vector3 类型有一个right属性给我们提供了这样的一个向量,现在我们来使用它设置点的位置。代码示例:

1
2
3
4
5
void Awake()
{
Transform point = Instantiate(pointPrefab);
point.localPosition = Vector3.right;
}

image-20220918170100345

现在进入播放模式后,我们依然会得到一个立方体,只是位置和之前有所不同。现在我们进行实例化第二个预制体对象。并将其向右移动两个单位。代码示例:

1
2
3
4
5
6
7
8
void Awake()
{
Transform point = Instantiate(pointPrefab);
point.localPosition = Vector3.right;

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

你会发现上述代码编译器会报错,这是因为我们尝试定义同一个变量两次,或者你可以理解为变量名称重复了。所以我们需要修改变量的名称来使用它,或者我们不使用该变量,继续使用之前定义的point变量。代码示例:

1
2
3
4
5
6
7
8
void Awake()
{
Transform point = Instantiate(pointPrefab);
point.localPosition = Vector3.right;

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

image-20220918170706876

循环创建

现在让我们创建更多的点一直到十个。我们可以通过重复上述代码 8 遍,但是这样是一种低效的编程,我们需要使用尽可能少的代码,让代码多次执行来完成。

现在使用while语句来使代码块重复执行。代码示例:

1
2
3
4
5
6
7
8
void Awake()
{
while ()
{
Transform point = Instantiate(pointPrefab);
point.localPosition = Vector3.right;
}
}

while关键词必须后面跟着圆括号内的表达式。只有圆括号内的表达式计算结果为真(True)时,才会执行后面代码块的内容。之后,程序会再次回到while语句,再次判断圆括号内的表达式结果,如果为真,则继续执行,如此重复。但是需要注意的是,要确保循环不会永远重复,即不会产生无限循环导致程序卡住,最安全的编译是使用关键词false,代码示例:

1
2
3
4
5
while (false)
{
Transform point = Instantiate(pointPrefab);
point.localPosition = Vector3.right;
}

当然,我们可以通过追踪代码的执行次数来限制其重复的次数。我们来创建一个int类型的变量命名为i,其初始值为 0 ,为了能够在while语句中迭代使用,我们需要将其定义在while语句的上面。代码示例:

1
2
3
4
5
6
7
8
9
10
void Awake()
{
int i = 0;
while (i<10)
{
i = i + 1;
Transform point = Instantiate(pointPrefab);
point.localPosition = Vector3.right;
}
}

现在我们进入播放模式(play)会得到十个立方体,但是它们的位置是相同的。现在我们将其排成一排正方体,来将其right属性乘以变量i,代码示例:

1
point.localPosition = Vector3.right * i;

image-20220918172011291

需要注意的是,这样创建的立方体,第一个的位置在 1 ,而最后一个立方体的位置在 10 。现在让我们对其做修改,来让其从 0 开始。代码示例:

1
2
3
4
5
6
7
int i = 0;
while (i<10)
{
Transform point = Instantiate(pointPrefab);
point.localPosition = Vector3.right*i;
i = i + 1;
}

简化代码

代码示例:

1
2
3
4
5
6
7
void Awake()
{
for (int i = 0; i < 10; i++){
Transform point = Instantiate(pointPrefab);
point.localPosition = Vector3.right*i;
}
}

更改实例化范围

现在我们实例化 X 的坐标范围为 $0 \sim 9$ 。通常使用函数来说,我们一般使用 $0 \sim 1$ 的范围,或者说以零为中心的范围,即 $-1 \sim 1$ 。现在来让我们重新定位我们的点。

如果我们在不改变立方体的大小的情况下,将十个立方体放置在 $0 \sim 2$ 的位置范围中,会导致它们相互重叠。为了解决这种情况,我们将其大小缩小。默认情况下,立方体的维度大小为 1 。将其映射到 2 的范围中,则其大小比例应该为 $\frac{2}{10} = \frac{1}{5}$ 。我们已通过将每个点的局部大小(通过属性Vector3.one)比例除以 5 来实现。代码示例:

1
2
3
4
5
for (int i = 0; i < 10; i++){
Transform point = Instantiate(pointPrefab);
point.localPosition = Vector3.right*i;
point.localScale = Vector3.one / 5f; //缩小5分之1
}

你可以通过切换场景(Scene)面板的正交透视模式来查看其相对位置。

image-20220918173512783

要将立方体重新聚合在一起(指并排排一排),需要将其位置也除以 5 。代码示例:

1
2
3
4
5
for (int i = 0; i < 10; i++){
Transform point = Instantiate(pointPrefab);
point.localPosition = Vector3.right*i / 5f; //位置也除以5
point.localScale = Vector3.one / 5f;
}

这样使得其生成范围为 $0 \sim 2$ 。要使其范围为 $-1 \sim 1$ ,则将其位置再 $-1$。代码示例:

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

现在第一个立方体的坐标为 -1 ,而最后一个立方体的坐标为 0.8 。这是因为立方体的大小为 0.2,即在其位置的左右各占 0.1 的宽度。所以只需要将其向右移动一般宽度距离即可,代码示例:

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

image-20220918174537522

优化代码结构

因为所有立方体最后是相同的大小比例,所以我们不需要每次都要计算新实例的大小。只需要计算一次即可,然后将其存储在变量scale中,循环使用它即可。代码示例:

1
2
3
4
5
6
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 的坐标即可。代码示例:

1
2
3
4
5
6
7
8
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); //计算点的 X 轴的位置
point.localPosition = position; //将实例物体位置调整到计算好的位置
point.localScale = scale;
}

我可以单独修改向量吗

1
Vector3 类型结构有三个浮点类型字段:x,y和z。这些字段是公开的(Public),因此我们可以单独更改它们。

如果你编译上述的代码,你会发现编译器给我们报错了,它告诉我们使用了未赋值的变量,这是因为我们只对变量position的 X 轴分量进行了赋值,而 Y 和 Z 轴的分量并未赋值,而编译器不知道应该给它们什么值。所以我们可以通过给变量position赋值零向量(即X,Y,Z全为0)来解决这个问题。代码示例:

1
2
3
4
5
6
7
8
var position = Vector3.zero;   //位置变量
var scale = Vector3.one / 5f;
for (int i = 0; i < 10; i++){
Transform point = Instantiate(pointPrefab);
position.x = ((i + 0.5f) / 5f - 1f); //计算点的 X 轴的位置
point.localPosition = position; //将实例物体位置调整到计算好的位置
point.localScale = scale;
}

使用 X 映射 Y 的值

和我们在开头说的那样,Y 可以通过 X 表达式来建立相关的映射关系。现在来让 Y 轴的映射结果和 X 轴的值相同,这样就会产生一条线性的线,代码示例:

1
2
3
4
5
6
7
8
9
10
11
var position = Vector3.zero;   //位置变量
var scale = Vector3.one / 5f;
for (int i = 0; i < 10; i++){
Transform point = Instantiate(pointPrefab);
position.x = ((i + 0.5f) / 5f - 1f); //计算点的 X 轴的位置

position.y = position.x; //Y 轴的值由 X 的值映射

point.localPosition = position; //将实例物体位置调整到计算好的位置
point.localScale = scale;
}

image-20220918184246912

现在来对代码做稍微改动,使其映射为 $F(X) = X^2$ ,这个映射的表达式表示的是一条抛物线,代码示例:

1
position.y = position.x*position.x;    //Y 轴新的映射

image-20220918184529915

创建更多的”点”

尽管我们现在有了一个函数的图形化,但是它看起来相当简陋。这是因为我们只使用了十个立方体(“点”),所以其结果看起来十分离散。如果我们使用更多更小的立方体的话结果会更加好看。

动态修改”点”数

现在我们为立方体的数目做字段序列化,而不是之前固定数量的立方体。现在定义一个int类型变量resolution默认值为 10 ,也就是目前定义的 10 个立方体。代码示例:

1
2
[SerializeField]
int resolution = 10; //立方体的数量

image-20220918191843839

现在我们可以通过检查器面板(Inspector)来修改”点”数了,但是并非所有的值都是有效的点数。所以我们需要为检查器面板增加一个数值范围,可以通过其 Range 属性附加来完成。代码示例:

1
2
[SerializeField,Range(10,100)]
int resolution = 10; //立方体的数量

image-20220918192642939

这能否保证resolution其值范围限制了 $10 \sim 100$ ?

1
2
Range属性仅仅检查我们在检查器面板(Inspector)的改动,它不会影响其以外的修改。
也就是说,如果你在代码中修改了变量的值是不会被Range属性限制的,但是我们不会这样做

实例化变量

现在为了能够使用我们创建的变量resolution,即让生成预制体物体,即”点”的数量和我们的变量值是一致的,我们需要修改循环次数,代码示例:

1
2
3
for (int i = 0; i < resolution; i++) {
//省略中间代码
}

因为迭代生成的预制体数量不同了(即点数目不同),为了能让其映射范围始终在 $-1 \sim 1$,需要对代码做一点更改。代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
var position = Vector3.zero;   //位置变量
var step = 2f / resolution; //计算应该怎么分配位置
var scale = Vector3.one * step;
for (int i = 0; i < resolution; i++){
Transform point = Instantiate(pointPrefab);
position.x = ((i + 0.5f) * step - 1f); //计算点的 X 轴的位置

position.y = position.x*position.x; //Y 轴的值由 X 的值映射

point.localPosition = position; //将实例物体位置调整到计算好的位置
point.localScale = scale;
}

image-20220918193640643

设置实例物体父对象

当我们设置 50 数量点时,场景中会出现很多实例化的立方体,它们会出现在层次结构面板(Hierarchy)中。

image-20220918194319307

它们排列在层次结构面板中,我们需要为其设置一个父对象,这样能够在结构面板中展示的更加有序。我们可以通过调用 Transform 组件的 SetParent方法,将要设置的父对象传给它。代码示例:

1
2
3
4
5
6
7
8
9
10
11
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; //计算点的 X 轴的位置
position.y = position.x*position.x; //Y 轴的值由 X 的值映射
point.localPosition = position; //将实例物体位置调整到计算好的位置
point.localScale = scale;
point.SetParent(transform); //设置父对象
}

image-20220918194737687

当设置了新的父对象,Unity默认其子对象还时保持在原来设置的位置,旋转和缩放。在我们目前的情况下,我们不需要如此,我们需要让子物体跟随父对象而变换,这个时候可以将SetParent方法第二个参数设置为false

1
point.SetParent(transform,false);

为图形添加颜色

白色的函数图形并不是很好看,虽然我们可以使用另外一种纯色,但是也不是很有趣。如果我们根据点的位置而设置不同的颜色,这会更加有趣。

调整每个立方体的颜色一种直接的方法是设置材质的颜色属性,我们可以通过循环中做到这一点。由于不同立方体都会有不同的颜色,这意味着我们会为每一个对象提供一个唯一的材质实例。这样做虽然有效,但是效率不高。如果我们可以使用一种直接使用位置作为颜色的单一材质就好了。很不幸,Unity 并没有这样的材质,我们需要自己去做。

创建表面着色器(Surface Shader)

GPU 运行着色器(Shader)程序来渲染 3D 对象。Unity 材质资源确定使用哪个着色器,并允许配置其属性。我们需要通过 Assets/Create/Shader/Standard Surface Shader 创建一个自定义着色器并命名为Point Surface来实现我们想要的功能。

image-20220918200138214

我们现在有了一个着色器(Shader)资源,你可以像脚本一样打开它。着色器文件包含定义表面着色器(Surface Shader)的代码,它使用的语法和C#不同。它默认包含一个表面着色器模板,但是我们将删除其所有内容并从头开始创建一个最小的着色器。

表面着色器(Surface Shader)如何工作的?

1
2
3
Unity 提供了一个框架来快速生成执行默认光照计算的着色器,你可以通过修改它的某些值来影响它的结果。
这种着色器成为表面着色器(Surface Shader)。
不幸的是,它仅仅适用于默认渲染管道。后面我们将会介绍通用渲染管道。

Unity 有着自己的着色器语法,总体上大致类似于C#,但是它是不同语言的混合。它以关键字Shader作为开头,后面跟着定义着色器在菜单栏显示的名称字符串。字符串写在双引号内。我们使用字符串 “Graph/Point Surface“,最后以花括号结束,花括号其中包含着色器内容代码。

1
Shader "Graph/Point Surface"{}

着色器可以拥有多个子着色器,每个子着色器使用关键字SubShader来定义,后面也是跟着一个代码块(花括号)。我们目前只需要一个。

1
2
3
Shader "Graph/Point Surface"{
SubShader {}
}

在子着色器下方,我们还需要为标准漫反射着色器(Standard diffuse shader)添加一个FallBack

1
2
3
4
Shader "Graph/Point Surface"{
SubShader {}
FallBack "DIFFUSE"
}

表面着色器的子着色器需要使用CGHLSL这两种着色器语言混合编写代码部分。此代码部分必须使用关键字CGPROGRAMENDCG括起来,即代码编写的内容必须处于这两个关键词之间。

1
2
3
4
5
6
7
8
Shader "Graph/Point Surface"{
SubShader {
CGPROGRAM

ENDCG
}
FallBack "DIFFUSE"
}

第一个语句是编译器指令,称为pragma。它的使用方法是#pragma后面跟着指令内容。我们需要使用#pragma surface ConfigureSurface Standard fullforwardshadows,它指示着色器编译器生成具有标准照明和完全支持阴影的表面着色器(standard lighting and full support for shadows)。

1
2
3
4
5
6
7
8
Shader "Graph/Point Surface"{
SubShader {
CGPROGRAM
#pragma surface ConfigureSurface Standard fullforwardshadows
ENDCG
}
FallBack "DIFFUSE"
}

pragma是什么意思?

1
2
pragma 这个词来自于希腊语,指的是一个动作,或者是需要做的事情
它在许多编程语言中用于表示特殊的编译器指令

接下来我们使用指令#pragma target 3.0 来为着色器的级别和质量(the shader’s target level and quality)设置最小值。

1
2
3
4
5
6
7
8
9
Shader "Graph/Point Surface"{
SubShader {
CGPROGRAM
#pragma surface ConfigureSurface Standard fullforwardshadows
#pragma target 3.0
ENDCG
}
FallBack "DIFFUSE"
}

我们将根据物体的世界位置为我们的点着色。我们为着色器定义输入结构struct Input。其格式后面必须跟着一个代码块,然后是一个分号结束。在代码块中我们声明了一个结构字段float3 worldPos,它代表着被渲染物体的世界位置,其类型是着色器结构体的等价类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
Shader "Graph/Point Surface"{
SubShader {
CGPROGRAM
#pragma surface ConfigureSurface Standard fullforwardshadows
#pragma target 3.0

struct Input {
float3 worldPos;
};
ENDCG
}
FallBack "DIFFUSE"
}

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

1
2
3
4
是的,使用这种方法,只要物体离开 Graph 物体的位置就会变化

需要注意的是,这个位置是按着顶点来确定的。在我们这个例子中,是根据每个立方体的四个顶点。
颜色会在立方体的面上进行插值计算。立方体的体积越大,这种颜色过度越明显。

接下来我们定义ConfigureSurface方法,尽管在着色器的情况下它总是被称为函数,而不是方法。它是一个返回值为void类型且带有两个传入参数的函数。其第一个参数,它是我们刚刚定义的Input类型,第二个参数是表面配置数据(surface configuration data),其类型为SurfaceOutputStandard

1
2
3
4
5
struct Input {
float3 worldPos;
};

void ConfigureSurface(Input input,SurfaceOutputStandard surface){}

第二个参数必须在其类型前面加上关键字inout,这表面它即传递给函数参数又作用于函数的结果。

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

现在我们有了一个正常工作的着色器,为它创建一个材质并命名为Point Surface,通过在材质的检查器面板(Insepector)标题栏的Shader 选项下拉选择我们创建的Graph/Point Surface

你也可以选中我们编辑好的表面着色器资源,然后右键创建材质即可直接创建对应材质

image-20220925153259662

目前该材质是纯色哑光黑。我们可以通过在配置函数中设置surface.Smoothness = 0.5 来使其看起来更加相近于默认材质。

注:在编写着色器代码的时候,不需要在 float 类型的值后面加 f

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

现在材质就不再是纯色的了,你可以在检查器面板(inspector)的材质预览中查看其预览效果。

image-20220925154511542

我们还可以使材质的平滑度(smoothness)可配置,如同为其添加一个字段并在函数中使用它。默认格式是在其配置选项前加上下划线并讲第一个字符进行大写,我们使用_Smoothness,如下所示:

1
2
3
4
5
float _Smoothness;

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

为了能够让我们在检查器面板(Inspector)中可以配置该变量,我们必须在着色器顶部,即子着色器上方添加一个新的“块”。将变量_Smoothness写在其中,并将("Smoothness",Range(0,1)) = 0.5写在其后。这语句意味着将Smoothness作为标签/字段(label)显示在检查器面板(Inspector)中并将其滑块范围限定在 $0 \sim 1$ 中,其默认值为 0.5 。代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
Shader "Graph/Point Surface"{
Properties{
_Smoothness ("Smoothness",Range(0,1)) = 0.5
}

SubShader {
CGPROGRAM
#pragma surface ConfigureSurface Standard fullforwardshadows
#pragma target 3.0

struct Input {
float3 worldPos;
};

float _Smoothness;

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

ENDCG
}
FallBack "DIFFUSE"
}

image-20220925155608099

现在来设置我们的“点”,即立方体的材质为我们设置好的材质而不是默认材质。

image-20220925155739921

基于世界坐标开始着色

要修改“点”的颜色,我们必须修改其surface.Albedo参数。因为漫反射(albedo)和世界坐标都是三个参数的结构体类型,所以我们可以直接将世界坐标作为参数传递给漫反射(albedo)。

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

image-20220925160100193

漫反射(Albedo)是什么意思?

1
2
Albedo(漫反射)在拉丁语中是白色的意思。它是衡量有多少光被表面漫反射的度量。
如果漫反射不是全白,那么部分的光能会被吸收而不是反射。

现在世界坐标 X 轴位置控制点的红色分量,Y 位置控制绿色分量,Z 位置控制蓝色分量。由于我们的图形定义域(即 X 轴域)限定在了 $-1 \sim 1$ 中,负颜色分量是没有意义的。所以我们必须将位置减半,然后添加 $\frac{1}{2}$ 使颜色处于合适的域中。我们可以一次对所有三个维度执行此操作。

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

为了更好的确定颜色变化是否正确,现在对函数进行修改,使得其展示 $F(X) = X ^3 $ 的图形。

image-20220925160830921

结果发现其颜色整体偏蓝,这是因为立方体面的 Z 坐标都是 0 ,这是使得其蓝色分量为 0.5 。我们可以通过设置漫反射只包括红色和绿色通道来消除蓝色。这样就可以使得蓝色分量保持为 0 。

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

由于红色+绿色= 黄色,这使得左下角开始接近于黑色,随着 Y 增加快于 X 轴的增加而变成绿色,随着 X 的追赶变为黄色,随着 X 增加更快变成橙色,最后接近明亮结束右上角的黄色。

image-20220925161530507

通用渲染线管(Universal Render Pipeline

除了默认的渲染线管(default render pipeline),Unity 还包含了通用渲染线管(Universal)和高清渲染线管(High-Definition),简称 URP 和 HDRP 。两种渲染管线都有不同的功能和限制。当前默认渲染管线依然有效,但是其特性功能已经不被使用了(its feature set is frozen)。或许几年后,URP 会称为默认的渲染管线设置。因此,现在来让我们的图形也适用于 URP 。

如果你还没有使用 URP ,现在打开包管理器并安装最新的 URP,就我而言我使用的是版本 10.4.0

image-20220925162132964

在查找 URP 的时候确保您的包过滤(package filter)设置为:Unity Registry 而不是 In Project

在完成安装后这并不会使得 Unity 自动的使用 URP 。我们首先需要通过 Assets / Create / Rendering / Universal Render Pipeline / Pipeline Asset (Forward Renderer) 为其创建一个资源(Asset),我给其命名为 URP。同时 Unity 也会自动给渲染器创建另一个资源(Asset),在本例中其被命名为URP_Renderer

image-20220925162800578

接下来,前往项目的设置中Graphics的部分,将 URP 资源(Asset)分配给 Scriptable Renderer Pipeline Settings

image-20220925163002691

要是希望再切换回默认渲染管线,则只需在设置中将 Scriptable Renderer Pipeline Settings 改为 none 即可。当然这些操作只能在编辑器中完成,渲染管线不可以在构建的独立应用程序中更改。

HDRP 呢?

1
HDRP 是一个更复杂的渲染管线,我并不会我的教程中介绍它。

创建图形化着色器(Shader Graph

我们前面制作的材质只适用于默认渲染管线,而不适用于 URP。因此当我们使用 URP 的时候,它会被 Unity 的错误材质取代,即纯洋红色。

image-20220925163402874

我们现在必须为 URP 创建一个单独的着色器,当然我们可以自己编写一个,但是目前来说非常困难,并且在升级到较新的 URP 版本的时候可能会失效(Break)。最好的办法是使用 Unity 提供的着色器图形包(shader graph package)来可视化的设置着色器。URP 依赖于这个包,因此它会和 URP 包安装的时候一起安装。

我们可以通过 Assets / Create / Shader / Universal Render Pipeline / Lit Shader Graph 创建一个新的着色器,并命名其为 Point URP

image-20220925163754152

可以通过在项目窗口双击其资源或者点击检查器面板中的Open Shader Editor按钮来打开一个图形化的着色器面板/窗口。其中包含”黑板”(Blackboard),图形检查器面板(Graph Insoector)和主预览面板(Main Preview),你可以调整其大小或者通过工具来选择是否隐藏。另外在该图形化窗口中,还包含两个可以链接的节点:一个顶点(Vertex)节点和一个”面”(Fragment)节点。这两个节点用于配置着色器的输出

image-20220925164658793

URP 图形化着色器由表示数据或者操作的节点组成。现在”面”(Fragment)节点的Smoothness值为 0.5 。要使其成为可配置的着色器属性,需要在左边的 Ponit URP “黑板”(Blackboard)点击加号按钮,选择Float类型,并将其命名为Smoothness(这个过程是添加变量)

image-20220925165247066

引用(Reference)是表示和内部变量属性的链接。因此我们需要使用其内部属性名称_Smoothness(我们的表面着色器代码中属性的名称字段),然后设置其默认值(Default)为 0.5 。确保其 Exposed 选项已启动,因为其控制材质是否为能获取着色器的属性。最后,如果要使其显示为滑块,着将模式(Mode)修改为 滑块(Slider)。

image-20220925165806430

接下来,拖动左侧”黑板”上的Smoothness圆形按钮到窗口的空白处。这将为图形窗口添加一个Smoothness节点,将其连接到Smoothness的输入。这样两者就建立了链接。

image-20220925170202128

现在你可以点击图形化着色器面板工具栏左侧的保存资源(Save Asset)来保存修改后的图形化着色器,接着创建一个对应的材质来替代之前的表面着色器的材质,将新创建的材质命名为Point URP

image-20220925170834422

使用节点编程

要为“点”着色,我们需要从位置节点开始。我们可以通过右键图形化着色器的空白处,再弹出的菜单中,选择创建节点(Create Node),然后在其中选择 Input / Geometry / Position 或者说直接搜索 Position

image-20220925171319000

我们现在有一个位置节点,默认其设置为世界空间坐标。你可以通过单击将光标悬停在其上方时出现的上箭头来折叠其可视化预览。

现在来使用相同的方法创建一个乘法(Multiply)和加分(Add)节点,然后将其位置 XY 的分量缩放 0.5 ,然后加 0.5 ,同时将 Z 设置为 0 。最后,将结果链接到“面”(Fragment)的 Base Color 作为输入。

image-20220925172718931

你可以通过将鼠标悬停在节点上方,单击出现的箭头来隐藏其视觉大小。这样将较少一定程度上的视觉混乱,你也可以通过删除顶点(Vertex)和“面”(Fragment)节点的组件来减少视觉混乱。

image-20220925173039442

保存图形化着色器的资源后,现在在播放模式(Play)下获得的渲染结果和默认渲染管道时是相同的结果颜色。除此之外,你还会发现在层次结构面板(Hierarchy)出现了 DontDestroyOnLoad 场景。这是为了调试 URP,可以忽略

image-20220925173355119

这个时候,你可以选择使用默认渲染线管或者 URP。切换到另一种渲染线管后,需要将指定的材质进行更替,否则其会显示成洋红色的错误材质。如果你对图形化着色器的代码感到好奇,你可以通过检查器面板(Inspetor)中的 View Generated Shader 按钮进行查看。

为图形添加动画

显示静态图形很有用,但是显示动态图形会更加有趣。因此,我们将会为图形添加动画。这是通过将时间作为附件函数参数来完成的,其格式为 F(X,t)t 表示时间。

获取“点”引用

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

1
2
3
4
[SerializeField,Range(10,100)]
int resolution = 50; //立方体的数量

Transform points;

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

1
Transform[] points;

points字段现在是对数组的引用,其元素类型为 Transform。数组是对象,而不是简单的值。我们必须显式的创建一个这样的对象并让字段获取对其的引用。这是通过关键字new来实现的。

1
2
3
void Awake(){
points = new Transform[];
}

创建数组的时候,我们必须指定其长度,这意味着它包含了多少个元素,并且在创建后无法进行更改。构造数组时,长度写在方括号内。我们使其等于图形的“点”数。

1
points = new Transform[resolution];

现在我们可以将实例化的物体和创建的数组元素进行引用链接。访问数组元素是通过数组引用后面方括号之间写下它的索引来完成的。第一个元素的数组索引是从零开始的,就像迭代的计数器一样。

1
2
3
4
5
6
points = new Transform[resolution]; //创建数组对象
for (int i = 0; i < resolution; i++)
{
Transform point = Instantiate(pointPrefab); //获取实例化对象的引用
points[i] = point; //填充数组
}

如果我们多次引用/赋值相同的东西,我们可以使用连等号来使其链接在一起,代码示例:

1
2
3
4
5
points = new Transform[resolution]; //创建数组对象
for (int i = 0; i < resolution; i++)
{
Transform point = points[i] = Instantiate(pointPrefab); //获取实例化对象的引用并填充数组
}

现在我们正在循环我们的“点”数组。因为数组长度和“点”数相同,所以我们可以使用数组长度来约束我们的循环。每个数组都有一个Lenth属性,所以我们来使用它。

1
2
3
4
for (int i = 0; i < points.Length; i++)
{
Transform point = points[i] = Instantiate(pointPrefab); //获取实例化对象的引用并填充数组
}

更新“点”

要调整每一帧的图形,我们需要在 Update方法中设置点的 Y 坐标。所以我们不需要在Awake中计算它们的值。但是我们任然可以在这里设置 X 的坐标,因为我们不会更改它们的值。

1
2
//这句不需要了
position.y = position.x*position.x* position.x; //Y 轴的值由 X 的值映射

Awake一样在Updat添加一个带有for循环的代码,但是它的块中代码还暂时是空的。

1
2
3
4
5
6
7
void Update()
{
for (int i = 0; i < points.Length; i++)
{
//这里啥也没有
}
}

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

1
2
3
4
5
6
7
void Update()
{
for (int i = 0; i < points.Length; i++)
{
Transform point = points[i];
}
}

之后,我们将“点”的本地位置存储在一个变量中。

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

现在我们可以根据 X 的位置,调整 Y 坐标,和之前做的那样。

1
2
3
Transform point = points[i];
Vector3 position = point.localPosition;
position.y = position.x * position.x * position.x;

现在我们进行是对变量position做了计算处理,我们要将其结果作用于该“点”,我们需要再次设置其值。

1
2
3
4
Transform point = points[i];    //获取实例对象
Vector3 position = point.localPosition; //获取对象的位置
position.y = position.x * position.x * position.x; //计算新的位置
point.localPosition = position; //新位置赋值给对象

显示正弦图形

现在在播放模式下,我们的图形的“点”每帧都会进行计算定位。但是我们还没有注意到这一点,因为它们每次开始和结束的位置是相同的。我们必须将时间引用到函数中才能使其发生变化。但是,简单的添加时间会使其不断上升然后消失在我们视野之中。为了防止这种情况,我们必须使用一个能够改变但是保持在一定范围内的函数。正弦函数(sine)是理想的选择。所以我们可以通过Mathf.Sin来使其函数变为 $F(X) = sin(X)$ 。

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

image-20220925193641494

什么是Mathf ?

1
2
它是 UnityEngine 命名空间中一个结构,包含了数学函数和常量的集合。
因为它使用浮点数,所以它的类型名称给与了 f 的后缀。

正弦波形在 $-1 \sim 1$ 之间震荡。它每 $2 \pi$ 个单位重复一次,这意味着它的周期约为 6.28 。我们的坐标介于 $-1 \sim 1$ 之间,也就是说看到的不足其图形的 $\frac{1}{3}$ 。为了能够查看到其整体的图形,我们需要将我们的左右边界乘以 $\pi$ ,这样使得我们的范围映射到了 $- \pi \sim \pi$ 之间,正好是其一个周期。我们可以使用Mathf.PI来表示 $\pi$ 的近似值。

1
position.y = Mathf.Sin(Mathf.PI * position.x);  //计算新的位置

image-20220925195019920

要将函数图形设置动画,需要在计算函数之前将游戏时间添加到 X 坐标数值。即通过使用Time.time来获取时间。

1
position.y = Mathf.Sin(Mathf.PI * (position.x + Time.time));  //计算新的位置

因为Time.time循环每次迭代的值是相同的,所以我们可以将其调用到循环之外。

1
2
3
4
5
6
7
8
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; //新位置赋值给对象
}

限制颜色范围

因为正弦的幅度是 $-1 \sim 1$ ,所以其最高点为 $1$ ,最低点是 $1$ 。但是因为我们的“点”是立方体,这就意味着它们的大小会导致其超出这个 $-1 \sim 1$ 的范围。因此,我们会获得低于 -1 或者大于 1 的绿色分量颜色,虽然这个并不明显,但是我们需要来正确的限制颜色在 0 -1 的范围中。

我们可以通过将生成的颜色传递给 Saturate函数来为我们的着色器执行此操作。这是一项特殊功能,它可以将所有分量限制在 0-1 之间。这是着色器的常见操作,称为“饱和度”(saturation)。

关于“饱和度”(saturation)的说明

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

同样的,你也可以在图形化着色器中使用Saturate节点来实现。

image-20220925200807685

End

下一个教程:数学曲面( Mathematical Surfaces