Catlike Coding Unity教程系列 中文翻译 Basics篇(三)Mathematical Surfaces

数学曲面

用数字雕塑

原文地址:https://catlikecoding.com/unity/tutorials/basics/mathematical-surfaces/

创建一个函数库。

使用委托和枚举类型。

使用网格显示2D函数。

在3D空间中定义曲面。

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

本教程使用Unity 2020.3.6f1制作。

在这里插入图片描述

组合一些波来创造一个复杂的表面。

1. 函数库

在完成上一个教程后,我们有一个点图,在播放模式中显示一个动画正弦波。也可以显示其他数学函数。您可以更改代码,函数也会随之更改。你甚至可以在Unity编辑器处于播放模式时这样做。暂停执行,保存当前的游戏状态,然后重新编译脚本,最后重新加载游戏状态,继续播放。这就是所谓的热重新加载。不是所有东西都能在热加载中存活下来,但我们的图可以。它会切换到新函数的动画,而不会意识到有什么变化。

虽然在播放模式中更改代码很方便,但在多个功能之间来回切换并不方便。如果我们可以通过图形的配置选项来改变函数,那就更好了。

1.1 库类

我们可以在Graph中声明多个数学函数,但是让我们将这个类专门用于显示函数,而不让它知道具体的数学方程。这是专门化和关注点分离的一个例子。

创建一个新的FunctionLibrary c#脚本,并把它放在Scripts文件夹,在Graph的旁边。您可以使用一个菜单选项来创建一个新的资产,或者复制并重命名Graph。在这两种情况下,清除文件内容,并以using UnityEngine;和声明一个空的FunctionLibrary类为开始,不扩展任何东西。

using UnityEngine;

public class FunctionLibrary {}

这个类不是组件类型。我们也不会创建它的对象实例。相反,我们将使用它来提供一组公共可访问的方法来表示数学函数,类似于Unity的Mathf

public static class FunctionLibrary {}
1.2 函数方法

我们的第一个函数是和Graph显示的一样的正弦波。我们需要为它创建一个方法。这与创建Awake或Update方法是一样的,只不过我们将其命名为Wave。

public static class FunctionLibrary {

	void Wave () {}
}

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

	static void Wave () {}

为了让它可以公开访问给它一个public访问修饰符。

	public static void Wave () {}

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

	public static float Wave () {}

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

	public static float Wave (float x, float t) {}

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

	public static float Wave (float x, float t) {
		Mathf.Sin(Mathf.PI * (x + t));
	}

最后一步是显式地指出方法的结果是什么。因为这是一个float方法,它必须在完成时返回一个float。我们通过在return后面加上我们的数学计算得到的预期的结果来表示这一点。

	public static float Wave (float x, float t) {
		return Mathf.Sin(Mathf.PI * (x + t));
	}

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

	void Update () {
		float time = Time.time;
		for (int i = 0; i < points.Length; i++) {
			Transform point = points[i];
			Vector3 position = point.localPosition;
			position.y = FunctionLibrary.Wave(position.x, time);
			point.localPosition = position;
		}
	}
1.3 隐式使用类型

我们将用Mathf.PI,Mathf.Sin和Mathf中的其他方法在FunctionLibrary中大量使用。如果我们在写这些的时候不需要一直明确地提到类型,那就太好了。我们可以通过在FunctionLibrary文件的顶部添加另一个using语句,加上额外的static关键字,后面跟着UnityEngine.Mathf来实现。这使得该类型的所有常量和静态成员都可以使用,而无需显式提及该类型本身。

using UnityEngine;

using static UnityEngine.Mathf;

public static class FunctionLibrary {}

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

	public static float Wave (float x, float z, float t) {
		return Sin(PI * (x + t));
	}
1.4 第二个函数

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

	public static float Wave (float x, float t) {
		return Sin(PI * (x + t));
	}

	public static float MultiWave (float x, float t) {
		return Sin(PI * (x + t));
	}

我们保留已经有的正弦函数,但要添加一些额外的东西。为了简单起见,在返回y变量之前将当前结果赋值给它。

	public static float MultiWave (float x, float t) {
		float y = Sin(PI * (x + t));
		return y;
	}

增加正弦波复杂性的最简单方法是增加另一个频率翻倍的正弦波。这意味着它的变化速度是原来的两倍,这是通过将正弦函数的参数乘以2来实现的。同时,我们将这个函数的结果减半。这样一来,新正弦波的形状就和旧正弦波一样,只是大小减半了。

		float y = Sin(PI * (x + t));
		y += Sin(2f * PI * (x + t)) / 2f;
		return y;

这就得到了数学函数 f ( x , t ) = sin ⁡ ( π ( x + t ) ) + s i n ( 2 π ( x + t ) ) 2 f(x,t)=\sin(\pi(x+t))+\frac{sin(2\pi(x+t))}{2} f(x,t)=sin(π(x+t))+2sin(2π(x+t))。由于正弦函数的正极值和负极值都是1和−1,所以这个新函数的最大值和最小值可以是1.5和−1.5。为了保证我们在−1-1范围内,我们应该将总和除以1.5。

		return y / 1.5f;

除法比乘法需要更多的工作,所以选择乘法而不是除法是一个经验法则。而常量表达式,比如1f / 2f和2f * Mathf.PI已经被编译器简化为一个数字。因此,我们可以重写代码,只在运行时使用乘法。我们必须确保首先减少常数部分,使用操作顺序和括号。

		y += Sin(2f * PI * (x + t)) * (1f / 2f);
		return y * (2f / 3f);

我们也可以直接写0.5f代替1f / 2f,但是1.5的倒数不能精确地用十进制表示,所以我们将继续使用2f / 3f,编译器将其简化为具有最大精度的浮点表示。

		y += 0.5f * Sin(2f * PI * (x + t));

现在使用这个函数来代替Graph.Update的Wave并看看它是什么样子的。

			position.y = FunctionLibrary.MultiWave(position.x, time);

两个正弦波的和。

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

		float y = Sin(PI * (x + 0.5f * t));
		y += 0.5f * Sin(2f * PI * (x + t));

变形的波。

1.5 在编辑器中选择函数

接下来,我们可以添加一些代码来控制Graph使用的方法。我们可以用滑动条来实现这一点,就像Graph的分辨率一样。因为有两个函数可供选择,所以需要一个范围为0-1的可序列化整数字段。把它命名为function,这样它控制什么就很明显了。

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

在这里插入图片描述

函数滑动条。

现在我们可以检查Update循环中的函数。如果它是零,那么图形应该显示Wave。为了做出这个选择,我们将使用if语句,后面跟着一个表达式和一个代码块。它的工作原理类似于while,只不过它不会回循环,所以代码块要么被执行要么被跳过。在这种情况下,测试是函数是否等于零,这可以通过==相等操作符来完成。

	void Update () {
		float time = Time.time;
		for (int i = 0; i < points.Length; i++) {
			Transform point = points[i];
			Vector3 position = point.localPosition;
			if (function == 0) {
				position.y = FunctionLibrary.Wave(position.x, time);
			}
			point.localPosition = position;
		}
	}

我们可以在if代码块后面加上else和另一个代码块,如果测试失败,则执行该代码块。在这种情况下,图形应该显示MultiWave。

			if (function == 0) {
				position.y = FunctionLibrary.Wave(position.x, time);
			}
			else {
				position.y = FunctionLibrary.MultiWave(position.x, time);
			}

这使得我们能够通过图表的检查器来控制函数,在游戏模式中也是可以的。

在播放模式中改变分辨率滑块有任何影响吗?

这将导致图的分辨率值改变,但是graph。更新不依赖于它,所以没有可见的影响。在游戏模式中改变点数的数量需要删除和实例化点数,但我们不打算在本教程中支持这一点。

1.6 波纹函数

让我们向库中添加第三个函数,它可以产生类似涟漪的效果。我们让正弦波远离原点,而不是一直沿着同一个方向运动。我们可以基于到中心的距离,即x的绝对值来做,在一个新的FunctionLibrary.Ripple的方法中使用Mathf.Abs计算这个值。将距离存储在d变量中,然后返回它。

	public static float Ripple (float x, float t) {
		float d = Abs(x);
		return d;
	}

为了显示它,把Graph.function的范围增加到2,并在Update中为Wave方法添加另一个块。我们可以通过在else后面直接写入另一个if块来链接多个条件块,这样它就变成了一个else-if块,当function等于1时应该执行它。然后为ripple添加一个新的else块。

	[SerializeField, Range(0, 2)]void Update () {
		float time = Time.time;
		for (int i = 0; i < points.Length; i++) {
			Transform point = points[i];
			Vector3 position = point.localPosition;
			if (function == 0) {
				position.y = FunctionLibrary.Wave(position.x, time);
			}
			else if (function == 1) {
				position.y = FunctionLibrary.MultiWave(position.x, time);
			}
			else {
				position.y = FunctionLibrary.Ripple(position.x, time);
			}
			point.localPosition = position;
		}
	}

在这里插入图片描述

X的绝对值。

回到FunctionLibrary.Ripple,我们使用距离作为正弦函数的输入并使其成为结果。特别地,我们用 y = sin ⁡ ( 4 π d ) y=\sin(4\pi d) y=sin(4πd),其中 d = ∣ x ∣ d= |x| d=x. 在图形的域中,波纹会上下多次。

	public static float Ripple (float x, float t) {
		float d = Abs(x);
		float y = Sin(4f * PI * d);
		return y;
	}

在这里插入图片描述

距离的正弦。

结果在视觉上很难理解,因为Y变化太多。我们可以通过减小波的振幅来减小它。但波纹的振幅不是固定的,它随着距离的增加而减小。我们把函数变成 y = sin ⁡ ( 4 π d ) 1 + 10 d y=\frac{\sin(4\pi d)}{1+10d} y=1+10dsin(4πd).

		float y = Sin(4f * PI * d);
		return y / (1f + 10f * d);

最后一个动作是动画化波纹。为了让它向外流动,我们必须从传递给正弦函数的值中减去时间。让我们使用 π t \pi t πt,所以最终的函数是 y = sin ⁡ ( π ( 4 d − t ) ) 1 + 10 d y=\frac{\sin(\pi (4d-t))}{1+10d} y=1+10dsin(π(4dt)).

		float y = Sin(PI * (4f * d - t));
		return y / (1f + 10f * d);

动画化波纹。

2. 管理方法

一个条件块序列适用于两个或三个函数,但是当试图支持更多函数时,它很快就变得笨拙。如果我们可以根据某些标准向库请求方法的引用,然后重复调用它,那将会方便得多。

2.1 委托

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

要创建委托类型,复制Wave函数,将其重命名为function,并用分号替换其代码块。这定义了一个没有实现的方法签名。然后,我们将static关键字替换为delegate,从而将其转换为委托类型。

public static class FunctionLibrary {
	
	public delegate float Function (float x, float t);}

现在我们可以引入一个GetFunction方法,它返回一个给定索引参数的Function,使用与我们在循环中使用的相同的if-else逻辑,只是在每个块中我们返回对应的方法,而不是调用它。

	public delegate float Function (float x, float t);
	
	public static Function GetFunction (int index) {
		if (index == 0) {
			return Wave;
		}
		else if (index == 1) {
			return MultiWave;
		}
		else {
			return Ripple;
		}
	}

接下来,我们使用这个方法在Graph.Update的开头获得一个function委托,并将其存储在一个变量中。因为这段代码不在FunctionLibrary中,所以必须将嵌套的委托类型引用为FunctionLibrary. function。

	void Update () {
		FunctionLibrary.Function f = FunctionLibrary.GetFunction(function);}

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

		for (int i = 0; i < points.Length; i++) {
			Transform point = points[i];
			Vector3 position = point.localPosition;
			//if (function == 0) {
			//	position.y = FunctionLibrary.Wave(position.x, time);
			//}
			//else if (function == 1) {
			//	position.y = FunctionLibrary.MultiWave(position.x, time);
			//}
			//else {
			//	position.y = FunctionLibrary.Ripple(position.x, time);
			//}
			position.y = f(position.x, time);
			point.localPosition = position;
		}
2.2 委托数组

我们简化 Graph.Update了很多,但我们只把if-else代码移到了FunctionLibrary.GetFunction中。我们可以通过索引数组来替换这段代码。首先为functions数组添加一个静态字段到FunctionLibrary。这个数组仅供内部使用,所以不要将其公开。

	public delegate float Function (float x, float t);

	static Function[] functions;

	public static Function GetFunction (int index) {}

我们总是会在这个数组中放入相同的元素,所以我们可以显式地将其内容定义为其声明的一部分。这是通过在花括号之间分配一个逗号分隔的数组元素序列来完成的。最简单的是空列表。

	static Function[] functions = {};

这意味着我们立即获得一个数组实例,但它是空的。更改它,使它将包含对我们的方法的委托,与之前的顺序相同。

	static Function[] functions = { Wave, MultiWave, Ripple };

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

	public static Function GetFunction (int index) {
		return functions[index];
	}
为什么我们不把数组设为公开呢?

这使得任何代码都可以更改数组。通过对库保持私有,我们保证它永远不会改变。

2.3 枚举

一个整数滑块可以工作,但是0代表Wave函数等等看起来不明显。如果我们有一个包含函数名的下拉列表,就会更清楚。我们可以使用枚举来实现这一点。

枚举可以通过定义enum类型创建。同样,我们将在函数库中进行操作,这次将其命名为FunctionName。在这种情况下,类型名称后面是花括号内的标签列表。我们可以使用数组元素列表的副本,但不带分号。请注意,这些是简单的标签,它们不引用任何东西,尽管它们遵循与类型名称相同的规则。我们负责保持两份清单相同。

	public delegate float Function (float x, float t);

	public enum FunctionName { Wave, MultiWave, Ripple }

	static Function[] functions = { Wave, MultiWave, Ripple };

现在用FunctionName类型的name参数替换GetFunction的index参数。这表明参数必须是一个有效的函数名。

	public static Function GetFunction (FunctionName name) {
		return functions[name];
	}

枚举可以被认为是语法糖。默认情况下,枚举的每个标签表示一个整数。第一个标签对应于0,第二个标签对应于1,依此类推。所以我们可以使用数组名来索引数组。但是,编译器会提示枚举不能隐式转换为整数。我们必须显式地执行这种类型转换。

		return functions[(int)name];

最后一步是更改Graph.function字段到FunctionLibrary.FunctionName。并移除其Range属性。

	//[SerializeField, Range(0, 2)]
	[SerializeField]
	FunctionLibrary.FunctionName function;

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

在这里插入图片描述

下拉列表的函数。

3. 添加另一个维度

到目前为止,我们的图只包含一条点线。我们把一维的值映射到一维的值,不过如果你考虑到时间,它实际上是把二维的值映射到一维的值。我们已经把高维的输入映射到一维的值了。就像我们增加了时间,我们也可以增加额外的空间维度。

目前,我们使用X维作为函数的空间输入。Y维度用于显示输出。这就使得Z成为第二个用于输入的空间维度。添加Z作为输入将我们的直线升级为一个正方形网格。

3.1 3D颜色

Z不再是常量,改变我们的Point Surface着色器,通过从赋值中移除.rg和.xy代码,使它可以修改蓝色的反照率分量。

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

并调整我们的Point URP着色器图,使Z和X,Y相同。

在这里插入图片描述

调整了Multiply和Add节点输入。

3.2 升级函数

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

	public delegate float Function (float x, float z, float t);

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

	public static float Wave (float x, float z, float t) {}
	
	public static float MultiWave (float x, float z, float t) {}
	
	public static float Ripple (float x, float z, float t) {}

调用Graph.Update中的函数时,也增加了 position.z 作为参数。

			position.y = f(position.x, position.z, time);
3.3 创建点的网格

为了显示Z维度,我们必须把点的直线变成点的网格。我们可以通过创建多条线来做到这一点,每条线沿Z偏移一步。我们将使用Z和X相同的范围,所以我们将创建与当前有点一样多的线。这意味着我们需要将点的数量平方。在Awake中调整points数组的创建,使其足够大,以包含所有的点。

		points = new Transform[resolution * resolution];

当我们在Awake中基于分辨率增加循环迭代的X坐标时,只是简单地创建更多的点,从而产生一条长长的直线。我们必须调整初始化循环,把第二个维度考虑进去。

在这里插入图片描述

2500个点的长线。

首先,让我们明确地跟踪X坐标。通过在for循环中声明一个x变量和一个i迭代器变量并自增来实现这一点。for语句的第三部分可以转换为逗号分隔的列表。

		points = new Transform[resolution * resolution];
		for (int i = 0, x = 0; i < points.Length; i++, x++) {}

每次我们完成一行,我们必须将x重置为0。当x等于resolution时,一行就结束了,所以我们可以在循环的顶部使用if块来处理这个问题。然后用x代替i来计算x坐标。

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

接下来,每一行必须沿Z维偏移。这也可以通过在for循环中添加z变量来实现。这个变量不能在每次迭代中递增。相反,只有当我们移动到下一行时,它才会递增,对于下一行,我们已经有了一个if块。然后设置位置的Z坐标,就像它的X坐标一样,使用Z代替X。

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

现在,我们创建了一个由点组成的正方形网格,而不是一条直线。因为我们的函数仍然只使用X维,它看起来就像原始的点被挤压成直线。

在这里插入图片描述

网格图。

3.4 更好的视觉效果

因为我们的图像是3D的,所以从现在开始我将使用游戏窗口从透视视角去看它。为了快速选择一个好的镜头位置,你可以在游戏模式中在场景窗口中找到一个不错的视点,退出游戏模式,然后让游戏的镜头与视点匹配。你可以当Main Camera被选中和场景窗口可见时,通过 GameObject / Align With View 来做到这一点。我让它大致沿着XZ对角线向下。然后我将Directional Light 的Y轴旋转从−30改为30,以改善该视角的照明。

除此之外,我们还可以稍微调整阴影的质量。阴影可能已经接受当使用默认渲染管道,但当我们在近距离观察图表时,它们被配置为看远处。

您可以选择视觉质量水平为默认渲染管道通过Quality项目设置和选择的一个预配置水平。默认下拉菜单控制独立应用默认使用哪个级别。

在这里插入图片描述

质量等级。

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

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

默认渲染管道的阴影设置。

什么是阴影级联和距离控制?

Unity(以及大多数游戏引擎)会将阴影投射器渲染到纹理中,然后采样它们来创建阴影。这些阴影地图有固定的分辨率。如果它们必须覆盖一大片区域,单个像素也会变大,从而产生块状阴影。
通过减少最大阴影距离,我们减少了阴影贴图必须覆盖的区域,从而提高了阴影质量,但代价是丢失了远处的阴影。
阴影级联通过使用基于距离的多个地图来进一步实现方向光,所以附近的阴影最终会有一个有效的更高分辨率。

URP不使用这些设置,相反,它的阴影是通过我们的URP资产的检查器配置的。默认情况下,它只渲染一次定向阴影,但Shadows / Max Distance可以减少到10。另外,为了匹配默认渲染管道的标准Ultra质量,启用Shadows / Soft Shadows,并在Lighting下增加Lighting / Main Light / Shadow Resolution到4096。

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

URP的阴影设置。

最后,在播放模式下,你可能会注意到视觉撕裂。通过游戏窗口工具栏左侧的第二个下拉菜单,启用*VSync(Game view only)*可以防止这种情况发生在游戏窗口中。启用后,新帧的呈现与显示刷新速率同步。这只在同时没有场景窗口可见的情况下可靠地工作。垂直同步是通过质量设置的Other部分为独立的应用程序配置。
在这里插入图片描述

在游戏窗口开启VSnyc。

为什么帧率下降了?

与之前的单线相比,网格包含了更多的点。在分辨率为50时,它有2500个点。在分辨率为100时,它有10,000个点。为了获得最佳表现,只能让一个场景或游戏窗口始终可见。另外,确保Graph的对象层次结构在层次结构窗口中是折叠的,因此不需要列出任何点。
在下一篇教程中,我们将进一步研究性能。

3.5 结合Z坐标

在Wave函数中使用Z的最简单的方法是使用X和Z的和,而不是只使用X,这会产生一个对角波。

	public static float Wave (float x, float z, float t) {
		return Sin(PI * (x + z + t));
	}


对角波。

MultiWave最直接的改变是让每个wave使用一个单独的维度。我们用Z来表示较小的那个。

	public static float MultiWave (float x, float z, float t) {
		float y = Sin(PI * (x + 0.5f * t));
		y += 0.5f * Sin(2f * PI * (z + t));
		return y * (2f / 3f);
	}

两个不同维度的波。

我们还可以加上第三个波沿着XZ对角线传播。我们使用和Wave相同的波,除了时间减慢四分之一。然后将结果除以2.5,使其保持在−1~1范围内。

		float y = Sin(PI * (x + 0.5f * t));
		y += 0.5f * Sin(2f * PI * (z + t));
		y += Sin(PI * (x + z + 0.25f * t));
		return y * (1f / 2.5f);

请注意,第一个波和第三个波会以一定的间隔相互抵消。

ShadyBabyishCricket-mobile

三个波合成。

最后,为了使波纹在XZ平面上向各个方向扩散,我们可以用勾股定理,借助Mathf.Sqrt方法,计算两个维度上的距离。

什么是勾股定理?

毕达哥拉斯定理指出 a 2 + b 2 = c 2 a^2+b^2=c^2 a2+b2=c2,其中 c c c直角三角形斜边的长度, a a a b b b是它其他两条边的长度。
在这里插入图片描述

使用勾股定理。

XZ平面上的2D点这样一个三角形的斜边对应着原点和该点之间的直线,它的X和Z坐标为其他两条边的长度。因此每个点和原点之间的距离是 x 2 + z 2 \sqrt {x^2+z^2} x2+z2

	public static float Ripple (float x, float z, float t) {
		float d = Sqrt(x * x + z * z);
		float y = Sin(PI * (4f * d - t));
		return y / (1f + 10f * d);
	}

XZ平面上的波纹。

4. 离开网格

通过使用X和Z来定义Y,我们能够创建函数来描述各种各样的曲面,但它们总是与XZ平面相连。没有两个点Y坐标不同,X坐标和Z坐标相同。这意味着我们表面的曲率是有限的。它们的斜率不能变成垂直的,也不能向后折叠。为做到这一点,我们的函数必须不仅输出Y,还有X和Z。

4.1 三维函数

如果我们的函数输出的是3D位置而不是1D值,我们就可以用它们来创建任意的曲面。例如,函数 f ( x , z ) = [ x 0 z ] f(x,z)=\begin{bmatrix}x\\0\\z \end{bmatrix} f(x,z)= x0z ,描述XZ平面。而函数 f ( x , z ) = [ x z 0 ] f(x,z)=\begin{bmatrix}x\\z\\0 \end{bmatrix} f(x,z)= xz0 描述XY平面。

因为这些函数的输入参数不再对应于最终的X和Z坐标,所以将它们命名为 x x x z z z不再合适,相反,它们被用来创建一个参数曲面,并经常被命名 u u u v v v。我们会得到像 f ( u , v ) = [ u s i n ( π ( u + v ) ) 0 ] f(u,v)=\begin{bmatrix}u\\sin(\pi(u+v))\\0 \end{bmatrix} f(u,v)= usin(π(u+v))0 这样的函数。

调整我们的Function委托类型来支持这个新方法。唯一需要的更改是用Vector3替换它的float返回类型,但我们还要重命名它的参数。

	public delegate Vector3 Function (float u, float v, float t);

我们还必须相应地调整函数方法。我们将直接对X和Z使用U和V。调整参数名称不是必要的,只有它们的类型需要匹配委托—但是我们这样做以保持一致。如果你的代码编辑器支持它,你可以快速重构-重命名参数和其他东西,所以通过菜单或上下文菜单选项,它在任何地方都可以被重命名。

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

	public static Vector3 Wave (float u, float v, float t) {
		Vector3 p;
		p.x = u;
		p.y = Sin(PI * (u + v + t));
		p.z = v;
		return p;
	}

然后给MultiWave和Ripple同样的处理。

	public static Vector3 MultiWave (float u, float v, float t) {
		Vector3 p;
		p.x = u;
		p.y = Sin(PI * (u + 0.5f * t));
		p.y += 0.5f * Sin(2f * PI * (v + t));
		p.y += Sin(PI * (u + v + 0.25f * t));
		p.y *= 1f / 2.5f;
		p.z = v;
		return p;
	}

	public static Vector3 Ripple (float u, float v, float t) {
		float d = Sqrt(u * u + v * v);
		Vector3 p;
		p.x = u;
		p.y = Sin(PI * (4f * d - t));
		p.y /= 1f + 10f * d;
		p.z = v;
		return p;
	}

因为点的X和Z坐标不再是常数,我们也不能再依赖于它们在Graph.Update中的初始值。我们可以通过将Update中的循环替换为Awake中的循环来解决这个问题,只不过我们现在可以直接将函数结果赋给点的位置。

	void Update () {
		FunctionLibrary.Function f = FunctionLibrary.GetFunction(function);
		float time = Time.time;
		float step = 2f / resolution;
		for (int i = 0, x = 0, z = 0; i < points.Length; i++, x++) {
			if (x == resolution) {
				x = 0;
				z += 1;
			}
			float u = (x + 0.5f) * step - 1f;
			float v = (z + 0.5f) * step - 1f;
			points[i].localPosition = f(u, v, time);
		}
	}

注意,我们只需要在z发生变化时重新计算v。这需要我们在循环开始之前设置它的初始值。

		float v = 0.5f * step - 1f;
		for (int i = 0, x = 0, z = 0; i < points.Length; i++, x++) {
			if (x == resolution) {
				x = 0;
				z += 1;
				v = (z + 0.5f) * step - 1f;
			}
			float u = (x + 0.5f) * step - 1f;
			//float v = (z + 0.5f) * step - 1f;
			points[i].localPosition = f(u, v, time);
		}

另外需要注意的是,因为Update现在使用了分辨率,所以在播放模式中改变分辨率将会使图形变形,将网格拉伸或挤压成一个矩形。

为什么不使用嵌套的双循环呢?

这也是可能的,也是常用的在二维上循环的方法。然而,这种方法主要是在点上而不是维度上循环。它总是更新所有的点,即使在游戏模式中分辨率发生变化。

我们不再需要在Awake中初始化位置,因此我们可以使该方法简单得多。我们只需要设置点的缩放和父元素就足够了。

	void Awake () {
		float step = 2f / resolution;
		var scale = Vector3.one * step;
		//var position = Vector3.zero;
		points = new Transform[resolution * resolution];
		for (int i = 0; i < points.Length; i++) {
			//if (x == resolution) {
			//	x = 0;
			//	z += 1;
			//}
			Transform point = points[i] = Instantiate(pointPrefab);
			//position.x = (x + 0.5f) * step - 1f;
			//position.z = (z + 0.5f) * step - 1f;
			//point.localPosition = position;
			point.localScale = scale;
			point.SetParent(transform, false);
		}
	}
4.2 创建一个球体

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

	public enum FunctionName { Wave, MultiWave, Ripple, Sphere }

	static Function[] functions = { Wave, MultiWave, Ripple, Sphere };public static Vector3 Sphere (float u, float v, float t) {
		Vector3 p;
		p.x = 0f;
		p.y = 0f;
		p.z = 0f;
		return p;
	}

创建一个球体的第一步是描述平放在XZ平面上的一个圆。我们可以使用 f ( x , z ) = [ sin ⁡ ( π u ) 0 cos ⁡ ( π u ) ] f(x,z)=\begin{bmatrix}\sin(\pi u)\\0\\ \cos(\pi u) \end{bmatrix} f(x,z)= sin(πu)0cos(πu) ,只依赖参数u。

		p.x = Sin(PI * u);
		p.y = 0f;
		p.z = Cos(PI * u);

在这里插入图片描述

一个圆。

现在我们有了多个完美重叠的圆。我们可以基于v沿着Y轴拉伸它们,这就得到了一个没有盖子的圆柱体。

		p.x = Sin(PI * u);
		p.y = v;
		p.z = Cos(PI * u);

在这里插入图片描述

一个圆柱体。

我们可以通过将X和Z乘以某个值r来调整圆柱体的半径,让 r = cos ⁡ ( π 2 v ) r=\cos(\frac{\pi}{2}v) r=cos(2πv)然后圆柱体的顶部和底部就会坍塌为单个点。

		float r = Cos(0.5f * PI * v);
		Vector3 p;
		p.x = r * Sin(PI * u);
		p.y = v;
		p.z = r * Cos(PI * u);

在这里插入图片描述

有塌缩半径的圆柱。

这让我们接近一个球体,但圆柱半径的缩小还不是圆的。这是因为一个圆是由正弦和余弦组成的,我们只用余弦来表示它在这一点的半径。方程的另一部分是Y,它目前仍然等于v。为了完成这个圆,我们需要让 y = sin ⁡ ( π 2 v ) y=\sin(\frac{\pi}{2}v) y=sin(2πv)

		p.y = Sin(PI * 0.5f * v);

在这里插入图片描述

一个球。

结果就是一个球体,它的图案通常被称为UV-sphere。虽然这种方法创建了一个正确的球体,但注意点的分布不是均匀的,因为球体是由不同半径的圆圈叠加而成的。或者,我们可以认为它由多个绕Y轴旋转的半圆组成。

4.3 扰乱球体

让我们调整球体的表面,使它更有趣。为了做到这一点,我们必须稍微调整一下公式。让 f ( u , v ) = [ s sin ⁡ ( π u ) r sin ⁡ ( π 2 v ) s cos ⁡ ( π u ) ] f(u,v)=\begin{bmatrix}s\sin(\pi u)\\r\sin(\frac{\pi}{2} v)\\ s\cos(\pi u) \end{bmatrix} f(u,v)= ssin(πu)rsin(2πv)scos(πu) ,其中 s = r cos ⁡ ( π 2 v ) s=r\cos(\frac{\pi}{2} v) s=rcos(2πv) r r r是半径。这使得动画化半径成为可能。例如,我们可以使用 r = 1 + sin ⁡ ( π t ) 2 r=\frac{1+\sin(\pi t)}{2} r=21+sin(πt),基于时间缩放半径来对它动画化。

		float r = 0.5f + 0.5f * Sin(PI * t);
		float s = r * Cos(0.5f * PI * v);
		Vector3 p;
		p.x = s * Sin(PI * u);
		p.y = r * Sin(0.5f * PI * v);
		p.z = s * Cos(PI * u);

我们不需要使用均匀半径,我们可以根据u改变它,比如 r = 9 + sin ⁡ ( 8 π u ) 10 r=\frac{9+\sin(8\pi u)}{10} r=109+sin(8πu)

		float r = 0.9f + 0.1f * Sin(8f * PI * u);

在这里插入图片描述

具有垂直带的球体;分辨率100。

这使球体具有垂直条纹的外观。我们可以用v替换u切换到水平带。

		float r = 0.9f + 0.1f * Sin(8f * PI * v);

在这里插入图片描述
通过使用这两种方法,我们得到了扭曲的带子。让我们加上时间让它们旋转,最终得到 r = 9 + sin ⁡ ( π ( 6 u + 4 v + t ) ) 10 r=\frac{9+\sin(\pi(6u+4v+t))}{10} r=109+sin(π(6u+4v+t))

		float r = 0.9f + 0.1f * Sin(PI * (6f * u + 4f * v + t));

旋转扭曲的球体。

4.4 创建一个环面

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

	public enum FunctionName { Wave, MultiWave, Ripple, Sphere, Torus }

	static Function[] functions = { Wave, MultiWave, Ripple, Sphere, Torus };public static Vector3 Torus (float u, float v, float t) {
		float r = 1f;
		float s = r * Cos(0.5f * PI * v);
		Vector3 p;
		p.x = s * Sin(PI * u);
		p.y = r * Sin(0.5f * PI * v);
		p.z = s * Cos(PI * u);
		return p;
	}

我们可以把球面变成一个环面,把垂直的半圆彼此拉开,然后把它们变成完整的圆。让我们从 s = 1 2 + r cos ⁡ ( π 2 v ) s=\frac{1}{2}+r\cos(\frac{\pi}{2}v) s=21+rcos(2πv)开始。

		float s = 0.5f + r * Cos(0.5f * PI * v);

在这里插入图片描述

球体拉开。

这给了我们半个环面,只有环的外部部分。为了完成环面,我们要用v描述一个完整的圆而不是半个圆。那可以通过使用 π v \pi v πv代替 s s s y y y中的 π 2 v \frac{\pi}{2} v 2πv来完成.

		float s = 0.5f + r * Cos(PI * v);
		Vector3 p;
		p.x = s * Sin(PI * u);
		p.y = r * Sin(PI * v);
		p.z = s * Cos(PI * u);

自相交的纺锤环面。

因为我们把球体分开了半个单位,这就产生了一个自相交的形状,称为纺锤环面。如果我们把它分开一个单位,我们会得到一个不自交的环面,但也没有洞,这被称为角环面。所以我们把球拉开的距离会影响环面的形状。具体来说,它定义了环面的主半径。另一个半径是次半径,它决定了环的厚度。我们定义主半径为 r 1 r_1 r1另一个重命名为 r 2 r_2 r2,所以 s = r 2 cos ⁡ ( π v ) + r 1 s=r_2\cos(\pi v) + r_1 s=r2cos(πv)+r1.然后使用0.75为主半径和0.25为次半径,以保持点在−1~1范围内。

		//float r = 1f;
		float r1 = 0.75f;
		float r2 = 0.25f;
		float s = r1 + r2 * Cos(PI * v);
		Vector3 p;
		p.x = s * Sin(PI * u);
		p.y = r2 * Sin(PI * v);
		p.z = s * Cos(PI * u);

在这里插入图片描述

圆环环面。

现在我们有两个半径来做一个更有趣的环面。例如,我们可以把它变成一个旋转的星星图案通过让 r 1 = 7 + sin ⁡ ( π ( 6 u + t 2 ) ) 10 r_1=\frac{7+\sin(\pi(6u+\frac{t}{2}))}{10} r1=107+sin(π(6u+2t)).同时也通过使用 r 2 = 3 + sin ⁡ ( π ( 8 u + 4 v + 2 t ) ) 20 r_2=\frac{3+\sin(\pi(8u+4v+2t))}{20} r2=203+sin(π(8u+4v+2t))扭转环。

		float r1 = 0.7f + 0.1f * Sin(PI * (6f * u + 0.5f * t));
		float r2 = 0.15f + 0.05f * Sin(PI * (8f * u + 4f * v + 2f * t));

现在您已经有了一些使用描述曲面的非平凡函数的经验,以及如何将它们可视化。您可以尝试使用自己的函数来更好地理解它的工作原理。有许多看似复杂的参数曲面可以用一些正弦波来创建。

下一个教程是测量性能。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值