构建可视化数学函数图形
- 创建预制件。
- 实例化多个多维数据集。
- 显示数学函数。
- 创建表面着色器和着色器图。
- 为图形添加动画效果。
在这个教程中,我们将带你进入数学世界,向你展示如何使用Unity构建可视化数学函数图形。你将学习如何创建预制件和多维数据集,并使用表面着色器和着色器图显示数学函数。我们还将向你展示如何使用脚本控制图形的动画效果,使你的图形更加生动。
沿续【Unity】探索数学世界:使用Unity构建可视化数学函数图形(一)(上) - 掘金 (juejin.cn)
Coloring the Graph 为图形着色
白色图形并不好看。我们可以使用另一种纯色,但这也不是很有趣。使用点的位置来确定其颜色更有趣。
调整每个立方体颜色的一种简单方法是设置其材质的颜色属性。我们可以在循环中做到这一点。由于每个立方体都会获得不同的颜色,这意味着我们最终会为每个对象提供一个唯一的材质实例。当我们稍后为图形制作动画时,我们还必须一直调整这些材质。虽然有效,但它不是很有效。如果我们可以使用直接使用该位置作为其颜色的单一材料,那就更好了。不巧的是,Unity 没有这样的材料。所以让我们自己做。
Creating a Surface Shader 创建曲面着色器
GPU 运行着色器程序来渲染 3D 对象。Unity 的材质资源确定使用哪个着色器,并允许配置其属性。我们需要创建一个自定义着色器来获得我们想要的功能。通过 Assets / Create / Shader / Standard Surface Shader 创建一个并将其命名为 Point Surface 。
着色器与点文件夹中的预制件分组,一列和两列布局。
我们现在有一个着色器资源,您可以像脚本一样打开它。我们的着色器文件包含用于定义表面着色器的代码,该着色器使用与 C# 不同的语法。它包含一个表面着色器模板,但我们将删除所有内容并从头开始创建一个最小的着色器。
Unity 有自己的着色器资源语法,总体上大致类似于 C#,但它是不同语言的混合体。它以 **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 surface ConfigureSurface Standard fullforwardshadows ,它指示着色器编译器生成具有标准照明和完全支持阴影的表面着色器。 ConfigureSurface 是指用于配置着色器的方法,我们必须创建该方法。
CGPROGRAM
#pragma surface ConfigureSurface Standard fullforwardshadows
ENDCG
我们遵循 #pragma target 3.0 指令,该指令为着色器的目标级别和质量设置了最小值。
CGPROGRAM
#pragma surface ConfigureSurface Standard fullforwardshadows
#pragma target 3.0
ENDCG
我们将根据他们的世界排名为我们的积分着色。为了在表面着色器中执行此操作,我们必须为配置函数定义输入结构。它必须写成 **struct** **Input** ,后跟一个代码块,然后是一个分号。在块中,我们声明了一个结构字段,特别是 **float3** worldPos 。它将包含渲染内容的世界位置。 **float3** 类型是 [Vector3](http://docs.unity3d.com/Documentation/ScriptReference/Vector3.html) 结构的着色器等效项。
CGPROGRAM
#pragma surface ConfigureSurface Standard fullforwardshadows
#pragma target 3.0
struct Input {
float3 worldPos;
};
ENDCG
下面我们定义我们的 ConfigureSurface 方法,尽管在着色器的情况下,它总是被称为函数,而不是方法。它是一个具有两个参数的 **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 。
点表面材料。
该材料目前为实心哑光黑色。我们可以通过在配置函数中将 surface.Smoothness 设置为 0.5 来使其看起来更像默认材料。编写着色器代码时,我们不必将 f 后缀添加到 **float** 值。
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。
Shader "Graph/Point Surface" {
Properties {
_Smoothness ("Smoothness", Range(0,1)) = 0.5
}
SubShader {
…
}
}
可配置的平滑度。
使我们的 Cube 预制件资源使用此材质,而不是默认材质。这会把这些点变成黑色。
Coloring Based on World Position 基于世界位置的着色
要调整点的颜色,我们必须修改 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](http://docs.unity3d.com/Documentation/ScriptReference/MonoBehaviour.Awake.html) ,以便我们显示函数 f ( x ) = x 3 f ( x ) = x 3,这使得Y也从-1变为1。
position.y = position.x * position.x * position.x;
X立方体,蓝色。
结果是蓝色的,因为所有立方体面的 Z 坐标都接近于零,这会将其蓝色分量设置为接近 0.5。我们可以通过在设置反照率时仅包含红色和绿色通道来消除蓝色。这可以通过仅分配给 surface.Albedo.**rg** 并仅使用 input.worldPos.**xy** 在着色器中完成。这样,蓝色分量将保持为零。
surface.Albedo.rg = input.worldPos.xy * 0.5 + 0.5;
由于红色加绿色导致黄色,这将使点在左下角接近黑色时开始,当 Y 最初比 X 增加得更快时变为绿色,当 X 赶上时变成黄色,随着 X 增加得更快而略微变成橙色,最后在右上角接近亮黄色时结束。
X立方体,从绿色到黄色。
Universal Render Pipeline 通用渲染管线
除了默认渲染管线外,Unity 还具有通用和高清渲染管线,简称 URP 和 HDRP。两种渲染管线具有不同的功能和限制。当前默认渲染管线仍可正常工作,但其功能集已冻结。几年后,URP可能会成为默认值。因此,让我们的图形也适用于 URP。
如果您尚未使用 URP,请转到包管理器并安装针对您的 Unity 版本验证的最新 Universal RP 包。就我而言,这是 10.4.0。
已安装 URP 软件包。
这不会自动使 Unity 使用 URP。我们首先必须通过 Assets / Create / Rendering / Universal Render Pipeline / Pipeline Asset (Forward Renderer) 为其创建一个资源。我把它命名为 URP 。这也将自动为渲染器创建另一个资源,在我的例子中名为 URP_Renderer 。
URP 资源位于单独的文件夹中,单列和两列布局。
Next, go to the Graphics section of the project settings and assign the URP asset to the Scriptable Renderer Pipeline Settings field.
接下来,转到项目设置的 Graphics 部分,并将 URP 资源分配给 Scriptable Renderer Pipeline Settings 字段。
使用 URP。
要稍后切换回默认渲染管线,只需将 Scriptable Renderer Pipeline Settings 设置为 None 即可。这只能在编辑器中完成,渲染管线不能在构建的独立应用中更改。
Creating a Shader Graph 创建着色器图
我们当前的材质仅适用于默认渲染管线,不适用于 URP。因此,当使用URP时,它被替换为Unity的错误材料,即实心洋红色。
立方体已经变成了洋红色。
我们必须为 URP 创建一个单独的着色器。我们可以自己编写一个,但这目前非常困难,并且在升级到较新的 URP 版本时可能会中断。最好的方法是使用 Unity 的着色器图形包来直观地设计着色器。URP 依赖于此包,因此它与 URP 包一起自动安装。
通过 Assets / Create / Shader / Universal Render Pipeline / Lit Shader Graph 创建一个新的着色器图并将其命名为 Point URP 。
点 URP 着色器图形资源,单列和双列布局。
可以通过在项目窗口中双击其资源或按检查器中的 Open Shader Editor 按钮来打开图形。这将为其打开一个着色器图形窗口,该窗口可能会被多个节点和面板弄乱。这些是黑板、图形检查器和主预览面板,可以调整大小,也可以通过工具栏按钮隐藏。还有两个链接节点:顶点节点和片段节点。这两个用于配置着色器图的输出。
默认的发光着色器图,所有内容都可见。
着色器图由表示数据或操作的节点组成。目前,片段节点的 Smoothness 值设置为 0.5。要使其成为可配置的着色器属性,请按 Point URP 背板面板上的加号按钮,选择 Float ,然后将新条目命名为 Smoothness。这会向表示属性的黑板添加一个圆角按钮。选择它并将图形检查器切换到其“节点设置”选项卡以查看此属性的配置。
具有默认设置的平滑度属性。
引用是属性在内部已知的名称。这与我们在表面着色器代码中命名属性字段_Smoothness的方式相对应,因此让我们在这里也使用相同的内部名称。然后将其下方的默认值设置为 0.5。确保其 Exposed 切换选项已启用,因为这控制材质是否为其获取着色器属性。最后,要使其显示为滑块,请将其 Mode 更改为 Slider 。
配置平滑度属性。
接下来,将圆角的 Smoothness 按钮从黑板拖动到图形中的空白区域。这将向图形添加一个平滑度节点。通过从其中一个点拖动到另一个点,将其连接到 PRB Master 节点的 Smoothness 输入。这会在它们之间创建链接。
平滑度连接。
现在,您可以通过 Save Asset 工具栏按钮保存图表,并创建一个名为 Point URP 的材料来使用它。着色器的菜单项为 Shader Graphs / Point URP 。然后使 Point 预制件使用该材料而不是 Point Surface 。
使用我们的着色器图的 URP 材质。
Programming with Nodes 使用节点编程
要为点着色,我们必须从位置节点开始。通过在图形的空白部分打开上下文菜单并从中选择 New Node 来创建一个。选择 Input / Geometry / Position 或仅搜索 Position 。
世界位置节点。
我们现在有一个位置节点,默认情况下设置为世界空间。您可以通过按将光标悬停在其上时显示的向上箭头来折叠其预览可视化效果。
使用相同的方法创建 Multiply 节点和 Add 节点。使用它们将位置的 XY 分量缩放 0.5,然后添加 0.5,同时将 Z 设置为零。这些节点根据它们所连接的内容调整其输入类型。因此,首先连接节点,然后填写其常量输入。然后将结果连接到片段的 Base Color 输入。
彩色着色器图。
如果将鼠标悬停在“乘法”和“添加”节点上,则可以通过按右上角显示的箭头来压缩节点的视觉大小。这将隐藏未连接到花药节点的所有输入和输出。这消除了很多混乱。您还可以通过顶点和片段节点的上下文菜单删除其组件。通过这种方式,您可以隐藏保留其默认值的所有内容。
压缩着色器图。
保存着色器资源后,我们现在在播放模式下获得与使用默认渲染管线时相同的色点。除此之外,调试更新程序在播放模式下出现在单独的 DontDestroyOnLoad 场景中。这是用于调试 URP,可以忽略。
运行模式下的 URP 调试更新程序。
此时,您可以使用默认渲染管线或 URP。从一个切换到另一个后,您还必须更改 Point 预制件的材料,否则它将是洋红色的。如果您对从图形生成的着色器代码感到好奇,可以通过图形检查器的 View Generated Shader 按钮访问它。
Animating the Graph 为图形添加动画效果
显示静态图形很有用,但移动图形更有趣。因此,让我们添加对动画函数的支持。这是通过将时间作为附加函数参数来完成的,使用形式为 f ( x , t ) f ( x , t) 的函数,而不仅仅是 f ( x ) f ( x),其中 是时间。
跟踪关键点
要使图形动画化,我们必须随着时间的推移调整其点。我们可以通过删除所有点并在每次更新时创建新点来做到这一点,但这是一种低效的方法。最好继续使用相同的点,每次更新都会调整它们的位置。为了实现这一点,我们将使用一个字段来保留对点的引用。将 points 字段添加到类型 [Transform](http://docs.unity3d.com/Documentation/ScriptReference/Transform.html) 的 **Graph** 。
[SerializeField, Range(10, 100)]
int resolution = 10;
Transform points;
此字段允许我们引用单个点,但我们需要访问所有这些点。我们可以通过将空方括号放在其类型后面来将字段转换为数组。
Transform[] points;
points 字段现在是对数组的引用,其元素的类型为 [Transform](http://docs.unity3d.com/Documentation/ScriptReference/Transform.html) 。数组是对象,而不是简单的值。我们必须显式创建这样一个对象并使我们的字段引用它。这是通过编写 **new** 后跟数组类型来完成的,因此在我们的例子中为 **new** [Transform](http://docs.unity3d.com/Documentation/ScriptReference/Transform.html)[] 。在我们的循环之前,在 [Awake](http://docs.unity3d.com/Documentation/ScriptReference/MonoBehaviour.Awake.html) 中创建数组,并将其分配给 points 。
points = new Transform[];
for (int i = 0; i < resolution; i++) {
…
}
创建数组时,我们必须指定其长度。这定义了它有多少个元素,这些元素在创建后无法更改。构造数组时,长度写在方括号内。使其等于图形的分辨率。
points = new Transform[resolution];
现在我们可以用对点的引用来填充数组。访问数组元素是通过在数组引用后面的方括号之间写入其索引来完成的。数组索引从第一个元素的零开始,就像我们循环的迭代计数器一样。因此,我们可以使用它来分配给适当的数组元素。
points = new Transform[resolution];
for (int i = 0; i < resolution; i++) {
Transform point = Instantiate(pointPrefab);
points[i] = point;
…
}
如果我们连续多次分配相同的内容,我们可以将这些赋值链接在一起,因为赋值表达式的结果就是分配的结果。
Transform point = points[i] = Instantiate(pointPrefab);
我们现在正在循环浏览我们的点数组。因为数组的长度与分辨率相同,所以我们也可以用它来约束我们的循环。每个数组都有一个 Length 属性用于此目的,因此让我们使用它。
points = new Transform[resolution];
for (int i = 0; i < points.Length; i++) {
…
}
更新坐标点
要调整每帧的图形,我们需要在 [Update](http://docs.unity3d.com/Documentation/ScriptReference/MonoBehaviour.Update.html) 方法中设置点的Y坐标。所以我们不再需要在 [Awake](http://docs.unity3d.com/Documentation/ScriptReference/MonoBehaviour.Awake.html) 中计算它们。我们仍然可以在这里设置 X 坐标,因为我们不会更改它们。
for (int i = 0; i < points.Length; i++) {
Transform point = points[i] = Instantiate(pointPrefab);
position.x = (i + 0.5f) * step - 1f;
…
}
添加一个带有 **for** 循环的 [Update](http://docs.unity3d.com/Documentation/ScriptReference/MonoBehaviour.Update.html) 方法,就像 [Awake](http://docs.unity3d.com/Documentation/ScriptReference/MonoBehaviour.Awake.html) 一样,但在其块中没有任何代码。
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;
}
现在我们可以像之前一样,根据 X 设置位置的 Y 坐标。
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;
}
显示正弦波形
从现在开始,在播放模式下,我们图形的点每帧都会定位。我们还没有注意到这一点,因为它们总是在相同的位置结束。我们必须将时间合并到函数中才能使其发生变化。但是,简单地添加时间会导致功能上升并迅速消失在视野中。为了防止这种情况发生,我们必须使用一个变化但保持在固定范围内的函数。正弦函数是理想的选择,所以我们使用 f ( x ) = sin ( x ) 。我们可以使用 [Mathf](http://docs.unity3d.com/Documentation/ScriptReference/Mathf.html).Sin 方法来计算它。
position.y = Mathf.Sin(position.x);
X 的正弦,从 −1 到 1。
正弦波在−1和1之间振荡。它每 2π(发音为两个饼图)单位重复一次,这意味着它的周期约为 6.28。由于我们图的 X 坐标介于 −1 和 1 之间,我们目前看到的重复模式不到三分之一。为了完整地看到它,X 乘以 π 所以我们最终得到 f ( x ) = sin ( π x ) f ( x ) = sin ( π x )。我们可以使用 [Mathf](http://docs.unity3d.com/Documentation/ScriptReference/Mathf.html).PI 常量作为π的近似值。
position.y = Mathf.Sin(Mathf.PI * position.x);
πX 的正弦。
要对此函数进行动画处理,请在计算正弦函数之前将当前游戏时间添加到 X。它是通过 [Time](http://docs.unity3d.com/Documentation/ScriptReference/Time.html).time 找到的。如果我们将时间也按π缩放,则该函数将每两秒重复一次。所以使用 f ( x , t ) = sin ( π ( x + t ) ) ,其中Time.time 是经过的游戏时间。随着时间的推移,这将推进正弦波,将其向负X方向移动。
position.y = Mathf.Sin(Mathf.PI * (position.x + Time.time));
动画正弦波。
因为 [Time](http://docs.unity3d.com/Documentation/ScriptReference/Time.html).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;
}
Clamping the Colors 夹紧颜色
正弦波的振幅为 1,这意味着我们的点达到的最低和最高位置是 −1 和 1。但是,由于这些点是具有一定大小的立方体,因此它们略微超出此范围。因此,我们可以获得绿色分量为负或大于 1 的颜色。虽然这并不明显,但让我们正确并夹紧颜色以确保它们保持在 0-1 范围内。
我们可以通过将生成的颜色传递给 [saturate](http://developer.download.nvidia.com/cg/saturate.html) 函数来为我们的表面着色器执行此操作。这是一个将所有组件固定为 0–1 的特殊功能。这是着色器中的常见操作,称为饱和度,因此得名。
surface.Albedo.rg = saturate(input.worldPos.xy * 0.5 + 0.5);
在着色器图中使用 Saturate 节点也可以执行相同的操作。
着色器图中的饱和颜色。
【Unity】探索数学世界:使用Unity构建可视化数学函数图形(一)(上) - 掘金 (juejin.cn)
完结