【Unity】游戏数学:使用Unity构建可视化数学函数图形(二)(下)

577 阅读14分钟

数学曲面

  •  创建函数库。
  • 使用委托和枚举类型。
  • 使用网格显示2D函数。
  •  在三维空间中定义曲面。

沿续【Unity】游戏数学:使用Unity构建可视化数学函数图形(二)(上) - 掘金 (juejin.cn)

 添加另一个维

到目前为止,我们的图只包含一行点。我们将1维值映射到其他1D值,但如果考虑时间,它实际上是将2D值映射到1D值。因此,我们已经将高维输入映射到1D值。就像我们添加时间一样,我们也可以添加额外的空间维度。

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

颜色三维化

当Z不再是常数时,更改我们的 Point Surface 着色器,以便它也修改蓝色反照率分量,方法是从赋值中删除 .**rg**.**xy** 代码。

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

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

  调整了乘法和加法节点输入。

 功能升级

为了支持函数的第二个非时间输入,在 **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](http://docs.unity3d.com/Documentation/ScriptReference/MonoBehaviour.Update.html) 中的函数时,也添加 position.z 作为参数。

position.y = f(position.x, position.z, time);

 创建点网格

为了显示Z维,我们必须将点的线变成点的网格。我们可以通过创建多条线来实现这一点,每条线沿着Z偏移一步。我们将使用与X相同的Z范围,因此我们将创建与当前点一样多的线。这意味着我们必须平方点的数量。调整 [Awake](http://docs.unity3d.com/Documentation/ScriptReference/MonoBehaviour.Awake.html)points 数组的创建,使其足够大以包含所有点。

points = new Transform[resolution * resolution];

当我们基于分辨率在 [Awake](http://docs.unity3d.com/Documentation/ScriptReference/MonoBehaviour.Awake.html) 中的循环的每次迭代中增加X坐标时,简单地创建更多的点将导致一条长线。我们必须调整初始化循环以考虑第二维。

 由2500个点构成的线

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

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

每次我们完成一行,我们都必须将 x 重置为零。当 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** 块。然后像设置X坐标一样设置位置的Z坐标,使用 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维,所以看起来原始点已经被挤出成线。

图形网格

更好的视觉效果

因为我们的图形现在是3D的,所以从现在开始,我将使用游戏窗口从透视视角来查看它。要快速选择一个好的摄像机位置,您可以在播放模式下在场景窗口中找到一个好的视点,退出播放模式,然后使游戏的摄像机与视点匹配。您可以通过选中 Main Camera 且场景窗口可见的 GameObject / Align With View 来执行此操作。我让它看起来大致在XZ对角线上。然后我将 Directional Light 的Y旋转从-30改为30,以改善该视角的照明。

除此之外,我们可以稍微调整阴影的质量。使用默认渲染管道时,阴影可能看起来已经可以接受,但它们被配置为在我们近距离查看图形时看得很远。

您可以通过转到 Quality 项目设置并选择一个预配置的级别来为默认渲染管道选择视觉质量级别。默认下拉列表控制默认情况下独立应用程序使用的级别。

质量水平

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

quality
game

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

URP不使用这些设置,而是通过 URP 资源的检查器配置其阴影。默认情况下,它已仅渲染一次定向阴影,但“阴影/最大距离”可以减少到10。此外,要匹配默认渲染管道的标准 Ultra 质量,请启用 Shadows / Soft Shadows 并将 Lighting 下的 Lighting / Main Light / Shadow Resolution 增加到4096。

inspector
game

  URP的阴影设置。

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

为游戏窗口启用。

结合Z轴

Wave 函数中使用Z的最简单方法是使用X和Z的和,而不是仅使用X。这将创建一个斜波。

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

04 00_00_00-00_00_30.gif

斜波

MultiWave 最直接的变化是让每个波使用一个单独的维度。让我们用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);
}

05 00_00_00-00_00_30.gif

 两个不同维度的波

我们还可以添加沿着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);

注意,第一波和第三波将以规则的间隔彼此抵消。

06 00_00_00-00_00_30.gif

最后,为了使涟漪在XZ平面上的所有方向上传播,我们必须计算两个维度上的距离。我们可以使用勾股定理,并借助 [Mathf](http://docs.unity3d.com/Documentation/ScriptReference/Mathf.html).Sqrt 方法。

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);
}

07 00_00_00-00_00_30.gif

XZ平面上的波纹

 离开网格

通过使用X和Z来定义Y,我们能够创建描述各种曲面的函数,但它们总是链接到XZ平面。没有两个点可以具有相同的X和Z坐标,同时具有不同的Y坐标。这意味着曲面的曲率是有限的。它们的斜面不能垂直,也不能向后折叠。为了实现这一点,我们的函数不仅要输出Y,还要输出X和Z。

 三维函数

如果我们的函数是输出3D位置而不是1D值,我们可以使用它们来创建任意曲面。例如,函数 f(x,z)= x 0 z(𝑓𝑥,𝑧)= [𝑥 0 𝑧 ]描述XZ平面,而函数 f(x,z)= x z 0(𝑓𝑥,𝑧)= [𝑥 𝑧 0 ]描述XY平面。

因为这些函数的输入参数不再需要与最终的X和Z坐标相对应,所以将它们命名为 x和 不再合适𝑥。相反,它们用于创建参数曲面,通常命名为 u𝑢和 v𝑣。所以我们会得到像这样的函数 f(u,v)= u sin(π(u + v)) v(𝑓𝑢,𝑣)= [𝑢 sin(𝜋(𝑢+𝑣)) 𝑣 ].

调整我们的 **Function** 委托类型以支持此新方法。唯一需要的更改是将其 **float** 返回类型替换为 [Vector3](http://docs.unity3d.com/Documentation/ScriptReference/Vector3.html) ,但我们也要重命名其参数。

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

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

Wave 开始。让它首先声明一个 [Vector3](http://docs.unity3d.com/Documentation/ScriptReference/Vector3.html) 变量,然后设置它的组件,然后返回它。我们不必给予向量一个初始值,因为我们在返回之前设置了它的所有字段。

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;
}

然后给予 MultiWaveRipple 相同的治疗。

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](http://docs.unity3d.com/Documentation/ScriptReference/MonoBehaviour.Update.html) 中的初始值。我们可以通过将 [Update](http://docs.unity3d.com/Documentation/ScriptReference/MonoBehaviour.Update.html) 中的循环替换为 [Awake](http://docs.unity3d.com/Documentation/ScriptReference/MonoBehaviour.Awake.html) 中使用的循环来解决这个问题,除了我们现在可以直接将函数结果分配给点的位置。

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;

points[i].localPosition = f(u, v, time);
}

还要注意,因为 [Update](http://docs.unity3d.com/Documentation/ScriptReference/MonoBehaviour.Update.html) 现在使用分辨率,在播放模式下更改它会使图形变形,将网格拉伸或挤压成矩形。

我们不再需要初始化 [Awake](http://docs.unity3d.com/Documentation/ScriptReference/MonoBehaviour.Awake.html) 中的位置,因此我们可以使该方法简单得多。我们只需要设置点的比例和父对象就足够了。

void Awake () {
float step = 2f / resolution;
var scale = Vector3.one * step;

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




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



point.localScale = scale;
point.SetParent(transform, false);
}
}

 创建球体

为了证明我们确实不再局限于每个(X,Z)坐标对一个点,让我们创建一个定义球体的函数。为此目的,将 Sphere 方法添加到 **FunctionLibrary** 。同时将它的条目添加到 **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平面上画一个圆。我们可以使用

[sinπu0cosπu]\begin{bmatrix} sin(π u)\\ 0\\ cos(π u)\\ \end{bmatrix}

,仅依赖于 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=cosπ2vr = cos(\frac{π}{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,它目前仍然等于#0 #v𝑣。为了在所有地方完成圆,我们必须使用

r=sinπ2vr = sin(\frac{π}{2}v)
p.y = Sin(PI * 0.5f * v);

球体

结果是一个球体,使用通常称为UV球体的图案创建。虽然这种方法创建了一个正确的球体,但请注意,点的分布并不均匀,因为球体是通过堆叠具有不同半径的圆来创建的。或者,我们可以认为它由绕Y轴旋转的多个半圆组成。

扰乱球体

让我们调整球体的表面,使其更有趣。要做到这一点,我们必须稍微调整我们的公式。我们将使用

fuv=[ssinπursin(πv/2)cosπu]f(u,v)= \begin{bmatrix} s sin(π u)\\ r sin(πv/2)\\ cos(π u)\\ \end{bmatrix}

,其中

s=rcos(π2)s=rcos(\frac{π}{2})

,并且r是半径。这样就可以设置半径的动画。例如,我们可以使用

r=1+sin(Πt2r=\frac{1+sin(Πt)}{2}

来根据时间缩放它。

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);

08 00_00_00-00_00_30.gif

缩放球体

我们不必使用统一的半径。我们可以根据 u改变它𝑢,比如

r=9+sin8πu10r=\frac{9 + sin(8 π u)}{10}
float r = 0.9f + 0.1f * Sin(8f * PI * u);

带有垂直带的球体

这使球体看起来具有垂直带。我们可以通过使用 v而不是 u来切换到水平波段𝑣𝑢。

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

  带有水平条带的球体

两种方法都可以得到扭曲的带子。让我们也添加时间来使它们旋转,最后得到

r=9+sin(Π(6u+4v+t))10r=\frac{9+sin(Π(6u+4v+t))}{10}
float r = 0.9f + 0.1f * Sin(PI * (6f * u + 4f * v + t));

09 00_00_00-00_00_30.gif

 旋转扭曲的球体。

 创建圆环体

让我们通过向 **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=12+rcos(π2v)s=\frac{1}{2}+rcos(\frac{π}{2}v)
float s = 0.5f + r * Cos(0.5f * PI * v);

  球体被撕裂。

这给了我们半个环面,只考虑了它的环的外部部分。为了完成环面,我们必须使用 v𝑣来描述整个圆而不是半个圆。这可以通过在 和 y中使用 π v而不是 πv/2来实现𝑦。

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);

自相交的纺锤体环面

因为我们已经把球体拉开了半个单位,这就产生了一个自相交的形状,称为纺锤环面。如果我们把它分开一个单位,我们会得到一个不自相交的环面,但也没有洞,这就是所谓的角环面。所以我们把球体拉开的程度会影响环面的形状。具体来说,它定义了圆环的主半径。另一个半径是小半径,并确定环的厚度。让我们将长半径定义为 r1,并将另一个重命名为 r2,因此 s = r2 cos(π v)+r1。然后使用0.75作为长半径,使用0.25作为短半径,以使点保持在−1-1范围内。


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);

  环形环面。

现在我们有两个半径来做一个更有趣的环面。例如,我们可以通过使用

r1=(7+sin(Π(6u+t2)))/10r1=(7+sin(Π(6u+\frac{t}{2})))/10

将其变成旋转的星星图案,同时还可以通过使用

r2=3+sin(Π(8u+4v+2t))20r2=\frac{3+sin(Π(8u+4v+2t))}{20}

扭曲环。

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));

10 00_00_00-00_00_30.gif

扭曲环

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

【Unity】游戏数学:使用Unity构建可视化数学函数图形(二)(上) - 掘金 (juejin.cn)

完结