你的第一个 3D 着色器

你已经决定开始编写一个自定义 Spatial 着色器。或许你在网上看到一个很酷的着色器技巧,或许你发现 StandardMaterial3D 并不能完全满足你的需求。总之,你决定写一个自己的,你想弄清楚从哪里开始。

这个教程将说明如何编写 Spatial 着色器,并将涵盖比 CanvasItem 更多的主题。

Spatial 着色器相比 CanvasItem 着色器内置了更多功能。其设计思路是,由 Godot 为常见用例提供现成功能,用户只需在着色器中设置合适的参数即可。这一点在 PBR(基于物理的渲染)工作流中尤为明显。

这个教程分为两个部分。在第一部分中,我们会使用在 vertex 函数中根据高度图进行顶点位移,从而制作地形。在第二部分中,我们会使用这个脚本中涉及的概念在片段着色器中设置自定义材质,编写海洋水体着色器。

备注

这个教程假定你对着色器中的类型(vec2floatsampler2D)和函数等基础知识有一定的了解。如果你对这些概念摸不着头脑,那么在完成这个教程之前,你最好先从《着色器之书》获取一些基本知识。

在何处设置我的材质

在 3D 中,物体是使用 Meshes(网格) 来绘制的。网格是一种资源类型,它以称为“表面(surfaces)”的单元来存储几何体(物体的形状)和材质(颜色以及物体对光的反应)。一个网格可以拥有多个表面,也可以只有一个。通常,你会从其他程序(例如 Blender)中导入网格。但 Godot 也提供了一些 PrimitiveMeshes ,允许你无需导入网格即可向场景中添加基础几何体。

你可以使用多种节点类型来绘制网格。其中最主要的是 MeshInstance3D ,但你也可以使用 GPUParticles3DMultiMeshes (需配合 MultiMeshInstance3D 使用)或其他节点类型。

通常,材质会与网格中的某个给定表面关联,但有些节点,例如 MeshInstance3D,允许你覆写特定表面或所有表面的材质。

如果你在表面或网格本身上设置了材质,那么所有共享该网格的 MeshInstance3D 都共享该材质。但是如果你想在多个网格实例中重用同一个网格,而每个实例又要具有不同的材质,那么你就应该在 MeshInstance3D 上设置材质。

在本教程中,我们将把材质直接设置于网格本身,而非利用 MeshInstance3D 可覆盖材质的功能。

设置

向场景添加一个新的 MeshInstance3D 节点。

在检查器选项卡中,通过点击 <空> 并选择 新建 PlaneMesh,将 MeshInstance3D 的 Mesh 属性设置为一个新的 PlaneMesh 资源。然后点击出现的平面图像以展开该资源。

这将在我们的场景中添加一个平面。

然后,单击视口左上角的透视按钮。在出现的菜单中,选择显示线框

这将允许你查看构成平面的三角形.

../../../_images/plane.webp

现在将 PlaneMeshSubdivide WidthSubdivide Depth 设置为 32

../../../_images/plane-sub-set.webp

现在你可以看到,在 MeshInstance3D 中有了更多的三角形。这将为我们提供更多可操作的顶点,从而能够添加更多细节。

../../../_images/plane-sub.webp

PrimitiveMeshes,比如平面网格(PlaneMesh),仅包含一个表面,因此材质不是数组形式而是只有一个。将 Material 设置为新的 ShaderMaterial,然后点击出现的球体以展开材质设置。

备注

继承自 Material 资源的材质,例如 StandardMaterial3DParticleProcessMaterial,可以转换为 ShaderMaterial,并且它们现有的属性将被转换为附带的文本着色器。要执行此操作,请在文件系统面板中右键单击材质并选择**转换为 ShaderMaterial**。你也可以通过在检查器中右键单击任何持有材质引用的属性来完成此操作。

现在通过点击 <空> 并选择新建着色器...,将材质的 Shader 设置为新的着色器。保留默认设置,为你的着色器命名,然后点击创建

点击检查器中的着色器,着色器编辑器将会弹出。现在你已准备好开始编写第一个 Spatial 着色器了!

着色器魔术

../../../_images/shader-editor.webp

新的着色器已生成,包含一个 shader_type 变量、一个 vertex() 函数以及一个 fragment() 函数。Godot 着色器首先需要声明其类型。在本例中,由于这是一个 Spatial 着色器,shader_type 被设为 spatial

shader_type spatial;

vertex() 函数决定了 MeshInstance3D 的顶点在最终场景中的位置。我们将通过它来偏移每个顶点的高度,使我们的平面呈现出类似小型地形的外观。

vertex() 函数内没有任何内容时,Godot 将使用其默认的顶点着色器。我们可以通过添加一行代码开始进行修改:

void vertex() {
  VERTEX.y += cos(VERTEX.x) * sin(VERTEX.z);
}

添加此行后, 你应该会得到类似下方的图像.

../../../_images/cos.webp

好的,我们来解读一下这段代码。这里将 VERTEXy 值进行了增加。并且,我们将 VERTEXxz 分量作为参数传递给了 cos()sin() 函数;这会在 xz 轴方向上产生一种波浪状的外观。

毕竟,我们想要实现的是小山丘的效果。而 cos()sin() 就已经有点像山丘了。我们可以通过缩放传入 cos()sin() 函数的输入参数来实现这一效果。

void vertex() {
  VERTEX.y += cos(VERTEX.x * 4.0) * sin(VERTEX.z * 4.0);
}
../../../_images/cos4.webp

看起来效果好些了,但波形仍然过于尖锐和重复,让我们把它变得更有趣一点。

噪声高度图

噪声是一种非常流行的模拟地形的工具。可以将它想作类似于能产生重复山丘图案的余弦函数,只是在噪声的影响下每个小山都拥有不同的高度。

Godot提供了 NoiseTexture2D 资源,用于生成可在着色器中访问的噪声纹理。

要在着色器中访问纹理,请在着色器顶部附近、vertex() 函数外部添加以下代码。

uniform sampler2D noise;

这将允许你将噪声纹理发送给着色器。现在查看材质下面的检查器面板,你应该会看到一个名为 Shader Parameters 的区域。如果你展开它,就会看到一个叫做 “Noise” 的参数。

将这个 Noise 参数设置为一个新的 NoiseTexture2D。接着在你的 NoiseTexture2D 中,将 Noise 属性设置为一个新的 FastNoiseLite。FastNoiseLite 类被 NoiseTexture2D 用于生成高度图。

设置好后,看起来应该像这样。

../../../_images/noise-set.webp

现在,通过 texture() 函数访问噪声纹理:

void vertex() {
  float height = texture(noise, VERTEX.xz / 2.0 + 0.5).x;
  VERTEX.y += height;
}

texture() 函数以纹理作为第一个参数,以表示纹理上采样位置的 vec2 作为第二个参数。我们使用 VERTEXxz 通道来确定在纹理上的采样位置。

由于 PlaneMesh 的坐标范围是 [-1.0, 1.0](对应尺寸为 2.0),而纹理坐标范围是 [0.0, 1.0],我们将坐标除以 PlaneMesh 的尺寸 2.0 并加上 0.5 来将坐标进行重映射。

texture() 函数会返回采样位置处包含 r, g, b, a 通道的 vec4 向量。由于噪声纹理是灰度图,所有通道的值均相同,因此我们可以使用任一通道作为高度值。本例中我们将使用 rx 通道。

备注

在 GLSL 中,xyzwrgba 是等效的,因此,除了上面使用的 texture().x,我们也可以使用 texture().r。更多细节请参阅 OpenGL 文档

使用此代码后,你可以看到纹理创建了随机外观的山峰。

../../../_images/noise.webp

目前山峰还是很尖锐,我们需要稍微柔化一下。为此,我们将使用 uniform 变量。 你在之前已经使用了 uniform 来传递噪声纹理,现在让我们来学习一下其中的工作原理。

Uniform

Uniform 变量 允许你将数据从游戏传递到着色器中。它们对于控制着色器效果非常有用。几乎所有在着色器中可用的数据类型都可以用作 uniform。要使用 uniform,你需要在 Shader 中通过 uniform 关键字来声明它。

让我们创建一个改变地形高度的 uniform。

uniform float height_scale = 0.5;

Godot 允许你使用值来初始化 uniform 变量;在这里, height_scale 被设置为 0.5。你可以在 GDScript 中通过调用与该着色器对应材质上的 set_shader_parameter() 函数来设置 uniform。从 GDScript 传递的值将优先于着色器中用于初始化该 uniform 的值。

# called from the MeshInstance3D
mesh.material.set_shader_parameter("height_scale", 0.5)

备注

在基于 Spatial 的节点中修改 uniform 与基于 CanvasItem 的节点有所不同。在这里,我们是在 PlaneMesh 资源内部设置材质的。对于其他网格资源,你可能需要先通过调用 surface_get_material() 来访问材质。而对于 MeshInstance3D,你则需要使用 get_surface_material()material_override 来访问材质。

请记住,传入 set_shader_parameter() 的字符串必须与着色器中 uniform 变量的名称完全一致。你可以在着色器内的任意位置使用 uniform 变量。在这里,我们将用它来设置高度值,而不是随意地乘以 0.5

VERTEX.y += height * height_scale;

现在它看起来好多了。

../../../_images/noise-low.webp

利用 uniform 变量,我们甚至可以在每帧修改数值来实现地形的动态高度变化。结合使用 Tweens ,对实现动画效果尤为实用。

与光交互

首先,关闭线框显示。再次打开视口左上角的透视菜单,选择显示标准。同时在 3D 场景工具栏中关闭预览阳光功能。

../../../_images/normal.webp

注意到网格颜色变得平坦了吗?这是因为当前的光照效果是均匀的。让我们来添加一个光源吧!

首先,我们在场景中添加一个 OmniLight3D ,并将它向上拖动至地形上方。

../../../_images/light.webp

你会看到光照已对地形产生了影响,但这看起来很奇怪。问题在于光照作用于地形时仍将其视为平面处理,这是因为光照着色器使用的是 网格 中的法线信息来计算光照。

法线信息存储于网格数据中,但是我们在着色器中改变了网格的形状,所以法线数据不再准确。为了解决这个问题,我们可以在着色器中重新计算法线,或使用与噪声纹理对应的法线纹理。Godot 为这两种方案都提供了便捷的实现方式。

你可以在顶点函数中手动计算新的法线,然后只需设置法线 NORMAL。设置好 NORMAL 后,Godot 将为我们完成所有困难的光照计算。我们将在本教程的下一部分介绍这种方法,现在我们将从纹理中读取法线。

这次我们将再次利用 NoiseTexture 来为我们计算法线。具体做法是传入第二张噪声纹理。

uniform sampler2D normalmap;

将第二个 uniform 纹理设置为另一个包含 FastNoiseLiteNoiseTexture2D,但这次,勾选 As Normal Map 选项。

../../../_images/normal-set.webp

当我们需要设置与特定顶点对应的法线时,使用 NORMAL;但如果你有一个来自纹理的法线贴图,则应在 fragment() 函数中通过 NORMAL_MAP 来设置法线。这样 Godot 会自动处理纹理在网格表面的映射。

最后,为了确保从噪声纹理和法线贴图的相同位置进行采样,我们将 VERTEX.xz 坐标从 vertex() 函数传递到 fragment() 函数。我们可以通过 varying 来实现。

vertex() 函数上方定义一个名为 tex_positionvarying vec2 变量。然后在 vertex() 函数内部将 VERTEX.xz 赋值给 tex_position

varying vec2 tex_position;

void vertex() {
  tex_position = VERTEX.xz / 2.0 + 0.5;
  float height = texture(noise, tex_position).x;
  VERTEX.y += height * height_scale;
}

现在我们可以从 fragment() 函数中访问 tex_position

void fragment() {
  NORMAL_MAP = texture(normalmap, tex_position).xyz;
}

法线贴图就位后,光照现在能够根据网格的高度产生动态响应效果。

../../../_images/normalmap.webp

我们甚至可以把光源拖来拖去,光照效果会自动更新。

../../../_images/normalmap2.webp

完整代码

以下是本教程的完整代码。你可以看到代码并不太长,因为 Godot 已为你处理了大部分繁琐的工作。

shader_type spatial;

uniform float height_scale = 0.5;
uniform sampler2D noise;
uniform sampler2D normalmap;

varying vec2 tex_position;

void vertex() {
  tex_position = VERTEX.xz / 2.0 + 0.5;
  float height = texture(noise, tex_position).x;
  VERTEX.y += height * height_scale;
}

void fragment() {
  NORMAL_MAP = texture(normalmap, tex_position).xyz;
}

这就是这部分的全部内容。希望你现在已了解Godot中顶点着色器的基本知识。在本教程的下一部分中,我们将编写一个片段函数来配合这个顶点函数,并且我们将介绍一种更高级的技术来将这个地形转换成一个移动的波浪海洋。