你的第一个空间着色器

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

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

空间着色器比CanvasItem着色器有更多的内置功能. 对空间着色器的期望是:Godot为常见的用例提供了功能, 用户仅需在着色器中设置适当的参数. 这对于PBR(基于物理的渲染)工作流来说尤其如此.

这是一个两部分的教程. 在第一部分中, 我们将学习如何在顶点函数中使用高度图的顶点位移来制作一个简单的地形. 在 第二部分 中, 我们将采用本教程中的概念, 通过编写一个海洋水着色器, 讲解如何在片段着色器中设置自定义材质.

注解

这个教程假定你对着色器有一些基本的了解, 例如类型( vec2 , float , sampler2D ), 和函数. 如果你对这些概念摸不着头脑, 那么你在完成这个教程之前, 最好先从 着色器之书 <https://thebookofshaders.com/?lan=ch> 获取一些基本知识.

在何处设定材质

在3D中, 对象是使用 Meshes 绘制的.Mesh是一种资源类型, 它以 "表面(surface)" 为单位存储几何体(对象的形状)和材质(对象的颜色和对光线的反应). 一个Mesh可以有多个表面, 也可以只有一个. 通常情况下, 你会从另一个程序(如Blender)导入一个Mesh. 但是Godot也有一些 PrimitiveMeshes 允许你在不导入Mesh的情况下为场景添加基本几何体.

你可以使用多种节点类型可以用来绘制Mesh. 主要的是 MeshInstance, 但你也可以使用 Particles, MultiMeshes (与 MultiMeshInstance 一起使用), 或其他.

通常情况下, 一个材质是与Mesh中的一个给定表面相关联的, 但有些节点, 如MeshInstance, 允许你覆盖一个特定的表面或所有表面的材质.

如果你在表面或Mesh本身上设置了材质, 那么所有共享该Mesh的MeshInstance都共享该材质. 但是, 如果你想在多个Mesh实例中重用同一个Mesh, 但每个实例具有不同的材质, 那么你应该在Meshinstance上设置材质.

在本教程中, 我们将材质设置在Mesh自身上, 不使用MeshInstance覆盖材质的功能.

设置

向场景添加一个新的 MeshInstance 节点.

在 "Mesh" 旁边的检查器选项卡中点击 "[空]", 选择 "新增 PlaneMesh". 然后点击出现的平面的图像.

这会在场景中添加一个 PlaneMesh .

然后, 在视图中, 单击左上角的[透视]按钮. 会出现一个菜单. 在菜单中间找到如何显示场景的选项. 选择 "显示线框".

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

../../../_images/plane.png

现在将 Subdivide WidthSubdivide Depth 设置为 32 .

../../../_images/plane-sub-set.png

可以看到现在 Mesh 中有了更多的三角形. 这将为我们提供更多顶点, 便于添加更多细节.

../../../_images/plane-sub.png

类似PlaneMesh, PrimitiveMeshes 仅有一个表面, 因此, 也仅有一个材质而非一个材质数组. 点击 "材质" 旁边的" [空]", 然后选择" 新建着色器材质". 然后点击出现的球体.

现在点击 "着色器" 旁边写着 "[空]" 的地方, 选择 "新增着色器".

现在将弹出一个着色器编辑器, 你已经准备好编写你的第一个空间着色器了!

着色器魔术

../../../_images/shader-error.png

注意到已经出现错误了吗? 这是因为着色器编辑器会自动重新加载着色器. Godot着色器首先需要声明它们是什么类型的着色器. 因此, 我们将变量 shader_type 设置为 spatial , 因为它是一个空间着色器.

shader_type spatial;

接下来我们将定义 vertex() 函数. vertex() 函数决定你的 Mesh 在最终场景中的顶点位置. 我们用它来偏移每个顶点的高度, 使我们的平面看起来像一个小地形.

我们像这样定义顶点着色器:

void vertex() {

}

vertex() 函数中没有任何内容,Godot将使用其默认的顶点着色器. 我们可以简单地通过添加一行进行更改:

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

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

../../../_images/cos.png

好, 我们来解读一下.``VERTEX`` 的 y 值正在增加. 我们将 VERTEXxz 分量作为参数传递给 cossin ;这样就得到了在 xz 轴上呈现出波浪状的图像.

我们想要实现的是小山丘的外观. 而 cossin 已经有点像山丘了. 我们便可以通过缩放 cossin 函数的输入来实现.

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

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

噪声高度图

噪声是一种非常流行的伪造地形的工具. 可以认为它和余弦函数一样生成重复的小山, 只是在噪声的影响下每个小山都拥有不同的高度.

Godot提供了 噪声纹理 资源, 可以生成从着色器访问的噪声纹理.

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

uniform sampler2D noise;

这将允许你向着色器发送一个噪声纹理. 现在看看材质下方的属性面板. 你会看到名为 "着色器参数" 的区域. 如果展开该区域, 就会看到一个叫 "噪音" 的部分.

点击旁边写着 "[空]" 的地方, 选择 "新建噪声纹理 ". 在你的噪声纹理中, 点击旁边的" 噪声 ", 然后选择" 新建开放式简单噪声".

开放式简单噪声 可从噪声纹理生成高度图.

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

../../../_images/noise-set.png

现在, 使用 texture() 函数获取噪声纹理. texture() 将一个纹理作为第一个参数, 将在纹理上的位置 vec2 作为第二个参数. 我们用 VERTEXxz 通道来确定在纹理上的位置. 请注意 PlaneMesh 坐标在 [-1,1] 范围内 (大小为 2的情况下), 而纹理坐标在 [0,1] 范围内, 所以为了规范化, 我们将PlaneMesh的大小除以2.0并加上 0.5. texture() 返回一个当前位置 r, g, b, a 通道的 vec4 . 由于噪声纹理是灰度的, 所有的值都相同, 所以我们可以使用任意一个通道作为高度. 本例中, 我们将使用 r , 或者说 x 通道.

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

注意: xyzw 和GLSL中的 rgba 是相同的, 所以我们可以用 texture().x 代替上面的 texture().r . 详情请参见 OpenGL 文档 .

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

../../../_images/noise.png

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

Uniforms

uniform值变量允许你把游戏的变量传递到着色器. 它们对于控制着色器效果非常有用. 几乎所有在着色器中使用的数据类型都可以作为uniform值. 要使用uniform值, 请在 Shader 中使用关键字 uniform 声明它.

让我们做一个改变地形高度的uniform.

uniform float height_scale = 0.5;

Godot让你用一个值来初始化uniform;这里, height_scale 被设置为 0.5 . 你可以通过在着色器对应的材质上调用函数 set_shader_param() 来从GDScript设置uniform . 从GDScript传来的值优先于在着色器中用于初始化的值.

# called from the MeshInstance
mesh.material.set_shader_param("height_scale", 0.5)

注解

更改uniform值时, 基于空间的节点与基于CanvasItem的节点使用的方法不同. 在这里, 我们在PlaneMesh资源内设置材质. 在其他mesh资源中, 你可能要先调用 surface_get_material() 来获取材质. 而在MeshInstance中, 则是用 get_surface_material()material_override 获取材质.

请记住, 传入 set_shader_param() 的字符串必须与 Shader 中的uniform变量名称相匹配. 你可以在 Shader 中的任何地方使用这个uniform变量. 在这里, 我们将用它来设置高度值, 而不是任意地乘以 0.5 .

VERTEX.y += height * height_scale;

现在看起来好多了.

../../../_images/noise-low.png

使用uniforms, 我们甚至可以在每一帧改变数值, 以动画化地形的高度. 结合 Tweens , 这对简单的动画特别有用.

与光交互

首先, 关闭线框显示. 若恢复显示, 再次点击视窗左上方的 "透视" 按钮, 选择 "正常显示".

../../../_images/normal.png

注意网格颜色是如何变得平滑的. 这是因为它的光线是平滑的. 让我们加一盏灯吧!

首先, 我们将在场景中添加一个 OmniLight .

../../../_images/light.png

你会看到光线影响了地形, 但这看起来很奇怪. 问题是光线对地形的影响就像在平面上一样. 这是因为光着色器使用 网格 中的法线来计算光.

法线存储在网格中, 但是我们在着色器中改变网格的形状, 所以法线不再正确. 为了解决这个问题, 我们可以在着色器中重新计算法线, 或者使用与我们的噪声相对应的法线纹理.Godot让这一切变得很简单.

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

相反, 我们将再次依靠噪声来计算法线. 我们通过传入第二个噪声纹理来做到这一点.

uniform sampler2D normalmap;

将这第二个uniform纹理设置为另一个OpenSimplexNoise的NoiseTexture. 但这次要勾选 As Normalmap .

../../../_images/normal-set.png

现在, 因为这是一个法线贴图, 而不是每个顶点的法线, 我们将在 fragment() 函数中分配它. fragment() 函数将在本教程的下一部分中详细解释.

void fragment() {
}

当我们有法线对应于一个特定顶点时, 我们设置 "法线 ", 但如果你有一个来自纹理的法线贴图, 要使用" 法线贴图"设置法线. 这样,Godot将自动处理环绕网格的纹理.

最后, 为了确保我们从噪声纹理和法线图纹理的相同位置读取数据, 我们将把 vertex() 函数中的 VERTEX.xz 坐标传递给 fragment() 函数. 我们用variings来做这个.

vertex() 上面定义一个 vec2 叫做 tex_position . 在 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;
  ...
}

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

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

法线就位后, 光线就会对网格的高度做出动态反应.

../../../_images/normalmap.png

我们甚至可以把灯拖来拖去, 灯光会自动更新.

../../../_images/normalmap2.png

以下是本教程的完整代码. 您可以看到,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() {
  NORMALMAP = texture(normalmap, tex_position).xyz;
}

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