Attention: Here be dragons

This is the latest (unstable) version of this documentation, which may document features not available in or compatible with released stable versions of Godot.

使用 MultiMeshInstance3D 动画化成千上万条鱼

本教程探讨了游戏 ABZU 中使用的一项技术,该技术利用顶点动画和静态网格实例化来渲染并动画化成千上万条鱼。

在 Godot 中,这可以通过自定义 ShaderMultiMeshInstance3D 来实现。使用以下技术,即使在低端硬件上,你也可以渲染数千个动画对象。

首先我们让一条鱼动画化。接着,我们将看看如何将该动画扩展到数千条鱼。

动画化一条鱼

我们将从单条鱼开始。将鱼模型加载到 MeshInstance3D 中,然后添加一个新的 ShaderMaterial

这是我们用于示例图片的鱼,你可以使用任何你喜欢的鱼模型。

../../../_images/fish.png

备注

本教程中的鱼模型由 QuaterniusDev 制作,并以知识共享许可发布。CC0 1.0 通用 (CC0 1.0) 公共领域贡献 https://creativecommons.org/publicdomain/zero/1.0/

通常,你会使用骨骼和 Skeleton3D 来为对象制作动画。然而,骨骼动画是在 CPU 上计算的,因此每帧都需要进行数千次运算,这使得同时处理数千个物体变得不可能。通过在顶点着色器中使用顶点动画,你可以避免使用骨骼,而是完全在 GPU 上用几行代码来计算完整的动画。

动画由四个关键动作组成:

  1. 左右摆动

  2. 绕鱼中心的枢轴运动

  3. 平移波浪运动

  4. 平移扭转运动

所有动画代码都将位于顶点着色器中,并通过 uniform 控制运动量。通过 uniform 控制运动强度使你可以在编辑器中调整动画并实时看到结果,而无需重新编译着色器。

所有动作都将使用应用于模型空间中 VERTEX 的余弦波来实现。我们希望顶点处于模型空间,这样运动就总是相对于鱼的方向。例如,左右摆动将始终使鱼在其左右方向上来回移动,而不是沿世界方向中的 x 轴移动。

为了控制动画速度,我们首先使用 TIME 定义自己的时间变量。

//time_scale is a uniform float
float time = TIME * time_scale;

我们将实现的第一个动作是左右摆动。它可以通过用 TIMEcos 值偏移 VERTEX.x 来实现。每次渲染网格时,所有顶点都会向侧面移动 cos(time) 的距离。

//side_to_side is a uniform float
VERTEX.x += cos(time) * side_to_side;

生成的动画应该看起来像这样:

../../../_images/sidetoside.gif

接下来,我们添加枢轴。因为鱼以 (0, 0) 为中心,我们所要做的只是将 VERTEX 乘以旋转矩阵,使其围绕鱼的中心旋转。

我们按如下方式构造一个旋转矩阵:

//angle is scaled by 0.1 so that the fish only pivots and doesn't rotate all the way around
//pivot is a uniform float
float pivot_angle = cos(time) * 0.1 * pivot;
mat2 rotation_matrix = mat2(vec2(cos(pivot_angle), -sin(pivot_angle)), vec2(sin(pivot_angle), cos(pivot_angle)));

然后我们将其乘以 VERTEX.xz,应用到 xz 轴上。

VERTEX.xz = rotation_matrix * VERTEX.xz;

仅应用枢轴时,你应该看到类似这样的效果:

../../../_images/pivot.gif

接下来的两个动作需要沿着鱼的脊柱平移。为此,我们需要一个新变量 bodybody 是一个浮点数,在鱼尾处为 0 ,在鱼头处为 1

float body = (VERTEX.z + 1.0) / 2.0; //for a fish centered at (0, 0) with a length of 2

下一个动作是沿鱼身长度方向移动的余弦波。为了让它沿着鱼的脊柱移动,我们用脊柱的位置来偏移 cos 的输入值,也就是我们在上面定义的变量 body

//wave is a uniform float
VERTEX.x += cos(time + body) * wave;

这看起来很像我们上面定义的左右摆动,但在这个动作中,通过使用 body 来偏移 cos,沿着脊柱的每个顶点在波浪中都有不同的位置,使它看起来像是沿着鱼身移动的波浪。

../../../_images/wave.gif

最后一个动作是扭转,也就是沿着脊柱的滚动。与枢轴类似,我们首先构造一个旋转矩阵。

//twist is a uniform float
float twist_angle = cos(time + body) * 0.3 * twist;
mat2 twist_matrix = mat2(vec2(cos(twist_angle), -sin(twist_angle)), vec2(sin(twist_angle), cos(twist_angle)));

我们在 xy 轴上应用旋转,使鱼看起来绕着它的脊柱滚动。要做到这一点,鱼的脊柱需要以 z 轴为中心。

VERTEX.xy = twist_matrix * VERTEX.xy;

这是应用扭转后的鱼:

../../../_images/twist.gif

如果我们依次应用所有这些动作,就会得到类似水母般的流畅运动。

../../../_images/all_motions.gif

正常的鱼主要使用身体的后半部分游泳,所以我们需要将平移运动限制在鱼的后半部分。为此,我们创建一个新变量 mask(遮罩)。

mask 是一个浮点数,从鱼头的 0 过渡到鱼尾的 1,我们用 smoothstep 来控制从 01 的过渡位置。

//mask_black and mask_white are uniforms
float mask = smoothstep(mask_black, mask_white, 1.0 - body);

下面是将 COLOR 设置为 mask 后这条鱼的样子:

../../../_images/mask.png

对于波浪运动,我们将运动乘以 mask 就可以把动作限制在后半部分。

//wave motion with mask
VERTEX.x += cos(time + body) * mask * wave;

为了将遮罩应用于扭转,我们使用 mixmix 允许在完全旋转的顶点和未旋转的顶点之间混合顶点位置。我们需要使用 mix 而不是将 mask 乘以旋转后的 VERTEX,因为我们不是将运动添加到 VERTEX 上,而是用旋转后的版本替换 VERTEX。如果将其乘以 mask,就会把鱼缩小。

//twist motion with mask
VERTEX.xy = mix(VERTEX.xy, twist_matrix * VERTEX.xy, mask);

将四个动作组合在一起,就得到了最终的动画效果。

../../../_images/all_motions_mask.gif

继续调整 uniform 的值来改变鱼的游泳周期。你会发现,你可以用这四个动作创造出各种各样的游泳方式。

制作一群鱼

Godot 使用 MultiMeshInstance3D 节点可以轻松渲染数千个相同的对象。

MultiMeshInstance3D 节点的创建和使用方式与 MeshInstance3D 节点相同。在本教程中,我们将把 MultiMeshInstance3D 节点命名为 School,因为它将包含一群鱼。

有了 MultiMeshInstance3D 后,添加一个 MultiMesh,然后使用上面的着色器向该 MultiMesh 添加你的 Mesh

MultiMesh 使用三个额外的每实例属性来绘制 Mesh:变换(旋转、平移、缩放)、颜色和自定义。自定义用于使用 Color 传入 4 个多用途变量。

instance_count 指定要绘制的网格的实例数量。现在,将 instance_count 保留为 0,因为当 instance_count 大于 0 时,你不能更改任何其他参数。我们稍后将在 GDScript 中设置 instance_count

transform_format 指定使用的变换是 3D 还是 2D。对于本教程,请选择 3D。

对于 color_formatcustom_data_format,你可以在 NoneByteFloat 之间选择。None 意味着你不会将这些数据(无论是每实例的 COLOR 变量还是 INSTANCE_CUSTOM)传递给着色器。Byte 意味着组成你传入的颜色的每个数字将以 8 位存储,而 Float 意味着每个数字将以浮点数(32 位)存储。Float 速度较慢但更精确,Byte 占用内存较少、速度较快,但你可能会看到一些视觉伪影。

现在,将 instance_count 设置为你想要的鱼的数量。

接下来,我们需要设置每实例的变换。

有两种方法可以为 MultiMesh 设置每实例变换。第一种方法完全在编辑器中完成,并在《MultiMeshInstance3D 教程》中进行了描述。

第二种方法是遍历所有实例,并在代码中设置它们的变换。下面,我们使用 GDScript 遍历所有实例,并将它们的变换设置为随机位置。

for i in range($School.multimesh.instance_count):
  var position = Transform3D()
  position = position.translated(Vector3(randf() * 100 - 50, randf() * 50 - 25, randf() * 50 - 25))
  $School.multimesh.set_instance_transform(i, position)

运行该脚本后,鱼将被放置在 MultiMeshInstance3D 位置周围框体内的随机位置。

备注

如果性能对你来说是个问题,可以尝试使用较少的鱼来运行场景。

你应该已经注意到所有鱼的游泳动作都是同步的吧?这样看起来非常机械。下一步我们要让每条鱼处于游泳周期的不同位置,这样整个鱼群看起来会更自然。

动画化鱼群

使用 cos 函数给鱼做动画的一个好处是,它们只需要一个 time 参数。为了让每条鱼在游泳周期中处于独特的位置,我们只需要偏移 time

为此,我们将每实例的自定义值 INSTANCE_CUSTOM 添加到 time 中。

float time = (TIME * time_scale) + (6.28318 * INSTANCE_CUSTOM.x);

接下来,我们需要向 INSTANCE_CUSTOM 传递一个值。通过在上面的 for 循环中添加一行来实现这一点。在 for 循环中,为每个实例分配一组四个随机浮点数来使用。

$School.multimesh.set_instance_custom_data(i, Color(randf(), randf(), randf(), randf()))

现在这些鱼在游泳周期中都有各自独特的位置。你可以通过使用 INSTANCE_CUSTOM 乘以 TIME 让它们游得更快或更慢,从而让它们更具个性。

//set speed from 50% - 150% of regular speed
float time = (TIME * (0.5 + INSTANCE_CUSTOM.y) * time_scale) + (6.28318 * INSTANCE_CUSTOM.x);

你甚至可以像更改每实例的自定义值一样,尝试更改每实例的颜色。

此时你会遇到的一个问题是,鱼虽然有动画,但它们并没有移动。你可以通过每帧更新每条鱼的每实例变换来移动它们。虽然这样做比每帧移动数千个 MeshInstance3D 要快,但速度可能仍然很慢。

在下一个教程中,我们将介绍如何使用 GPUParticles3D 来利用 GPU 单独移动每条鱼,同时还能获得实例化的好处。