你的第一个 3D 着色器

You have decided to start writing your own custom Spatial shader. Maybe you saw a cool trick online that was done with shaders, or you have found that the StandardMaterial3D isn’t quite meeting your needs. Either way, you have decided to write your own and now you need to figure out where to start.

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

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

This is a two-part tutorial. In this first part we will create terrain using vertex displacement from a heightmap in the vertex function. In the second part we will take the concepts from this tutorial and set up custom materials in a fragment shader by writing an ocean water shader.

备注

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

在何处设定材质

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

There are multiple node types that you can use to draw a mesh. The main one is MeshInstance3D, but you can also use GPUParticles3D, MultiMeshes (with a MultiMeshInstance3D), or others.

Typically, a material is associated with a given surface in a mesh, but some nodes, like MeshInstance3D, allow you to override the material for a specific surface, or for all surfaces.

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

For this tutorial we will set our material on the mesh itself rather than taking advantage of the MeshInstance3D’s ability to override materials.

设置

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

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

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

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

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

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

现在将 PlaneMeshSubdivide WidthSubdivide Depth 设置为 32

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

You can see that there are now many more triangles in the MeshInstance3D. This will give us more vertices to work with and thus allow us to add more detail.

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

PrimitiveMeshes, like PlaneMesh, only have one surface, so instead of an array of materials there is only one. Click beside “Material” where it says “[empty]“ and select “New ShaderMaterial”. Then click the sphere that appears.

现在点击“Shader”旁边写着“[空]”的地方,选择“新建 Shader”。

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

着色器魔术

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

The new shader is already generated with a shader_type variable and the fragment() function. The first thing Godot shaders need is a declaration of what type of shader they are. In this case the shader_type is set to spatial because this is a spatial shader.

  1. shader_type spatial;

For now ignore the fragment() function and define the vertex() function. The vertex() function determines where the vertices of your MeshInstance3D appear in the final scene. We will be using it to offset the height of each vertex and make our flat plane appear like a little terrain.

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

  1. void vertex() {
  2. }

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

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

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

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

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

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

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

../../../_images/cos4.png

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

噪声高度图

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

Godot provides the NoiseTexture2D resource for generating a noise texture that can be accessed from a shader.

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

  1. uniform sampler2D noise;

你可以用它将噪声纹理发送给着色器。现在看看检查器中的材质。你应该会看到一个名为“Shader Params”(着色器参数)的区域。如果展开该区域,就会看到一个叫“noise”的部分。

Click beside it where it says “[empty]“ and select “New NoiseTexture2D”. Then in your NoiseTexture2D click beside where it says “Noise” and select “New FastNoiseLite”.

备注

NoiseTexture2D 使用 FastNoiseLite 来生成高度图。

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

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

Now, access the noise texture using the texture() function. texture() takes a texture as the first argument and a vec2 for the position on the texture as the second argument. We use the x and z channels of VERTEX to determine where on the texture to look up. Note that the PlaneMesh coordinates are within the [-1,1] range (for a size of 2), while the texture coordinates are within [0,1], so to normalize we divide by the size of the PlaneMesh by 2.0 and add 0.5. texture() returns a vec4 of the r, g, b, a channels at the position. Since the noise texture is grayscale, all of the values are the same, so we can use any one of the channels as the height. In this case we’ll use the r, or x channel.

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

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

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

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

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

Uniform

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

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

  1. uniform float height_scale = 0.5;

Godot lets you initialize a uniform with a value; here, height_scale is set to 0.5. You can set uniforms from GDScript by calling the function set_shader_parameter() on the material corresponding to the shader. The value passed from GDScript takes precedence over the value used to initialize it in the shader.

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

备注

Changing uniforms in Spatial-based nodes is different from CanvasItem-based nodes. Here, we set the material inside the PlaneMesh resource. In other mesh resources you may need to first access the material by calling surface_get_material(). While in the MeshInstance3D you would access the material using get_surface_material() or material_override.

Remember that the string passed into set_shader_parameter() must match the name of the uniform variable in the Shader. You can use the uniform variable anywhere inside your Shader. Here, we will use it to set the height value instead of arbitrarily multiplying by 0.5.

  1. VERTEX.y += height * height_scale;

现在它看起来好多了.

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

Using uniforms, we can even change the value every frame to animate the height of the terrain. Combined with Tweens, this can be especially useful for animations.

与光交互

首先关闭线框显示。再次点击视口左上角的“透视”字样,选择“显示标准”。

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

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

First, we will add an OmniLight3D to the scene.

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

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

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

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

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

  1. uniform sampler2D normalmap;

Set this second uniform texture to another NoiseTexture2D with another FastNoiseLite. But this time, check As Normalmap.

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

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

  1. void fragment() {
  2. }

When we have normals that correspond to a specific vertex we set NORMAL, but if you have a normalmap that comes from a texture, set the normal using NORMAL_MAP. This way Godot will handle the wrapping of texture around the mesh automatically.

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

vertex() 上面定义一个 vec2 叫做 tex_position . 在 vertex() 函数中, 将 VERTEX.xz 分配给 tex_position .

  1. varying vec2 tex_position;
  2. void vertex() {
  3. ...
  4. tex_position = VERTEX.xz / 2.0 + 0.5;
  5. float height = texture(noise, tex_position).x;
  6. ...
  7. }

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

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

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

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

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

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

以下是本教程的完整代码. 你可以看到,Godot会为你处理大多数繁琐的事情, 本教程篇幅不会太长.

  1. shader_type spatial;
  2. uniform float height_scale = 0.5;
  3. uniform sampler2D noise;
  4. uniform sampler2D normalmap;
  5. varying vec2 tex_position;
  6. void vertex() {
  7. tex_position = VERTEX.xz / 2.0 + 0.5;
  8. float height = texture(noise, tex_position).x;
  9. VERTEX.y += height * height_scale;
  10. }
  11. void fragment() {
  12. NORMAL_MAP = texture(normalmap, tex_position).xyz;
  13. }

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