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.
Checking the stable version of the documentation...
高级后期处理
介绍
本教程描述了一种在 Godot 中进行后期处理的高级方法。值得注意的是,它将解释如何编写使用深度缓冲区的后期处理着色器。你应该已经熟悉后期处理,特别是使用自定义后期处理教程中介绍的方法。
全屏四边形
实现自定义后处理特效的一种方法是使用视口(Viewport)。不过,使用 Viewport 主要有两个缺点:
无法访问深度缓冲区
在编辑器中看不到后期处理着色器的效果
为了绕过直接使用深度缓冲区的限制,我们可以用一个 QuadMesh (四边形网格)基元来创建一个 MeshInstance3D 。这样做的好处是,我们既可以使用自定义着色器,又能获取到整个场景的深度纹理(depth texture)。接下来,我们需要在顶点着色器(vertex shader)里做点手脚,让这个四边形始终铺满整个屏幕。这样一来,后处理特效就能一直保持生效状态,甚至包括在编辑器里也能实时看到效果。
首先,新建一个 MeshInstance3D,然后把它的网格(mesh)设置为 QuadMesh(四边形网格)。这样就会在 (0, 0, 0) 的位置创建一个宽度和高度默认都是 1 的四边形。接下来,请把宽度和高度都改成 2 ,并开启 Flip Faces(翻转面) 选项。目前,这个四边形是固定在世界空间原点的一个物体。但是,我们的目标是让它能跟随摄像机移动,从而始终覆盖整个屏幕。为了实现这一点,我们需要绕过 Godot 自动将顶点在各个坐标空间之间转换的常规流程,直接把这些顶点当作已经处于裁剪空间(clip space)来处理。
顶点着色器期望输出的坐标是裁剪空间(clip space)坐标,也就是屏幕左侧和底部为 -1 ,顶部和右侧为 1 的坐标范围。这也正是为什么我们的 QuadMesh(四边形网格)需要把宽度和高度都设为 2 。Godot 会在幕后自动处理从模型空间到视图空间,再到裁剪空间的坐标变换,所以我们需要想办法抵消掉 Godot 这些自带变换带来的影响。解决办法就是把内置变量 POSITION 设置成我们想要的坐标。 POSITION 会直接绕过 Godot 内置的变换流程,直接把顶点位置设定在裁剪空间里。
shader_type spatial;
// Prevent the quad from being affected by lighting and fog. This also improves performance.
render_mode unshaded, fog_disabled;
void vertex() {
POSITION = vec4(VERTEX.xy, 1.0, 1.0);
}
备注
在 Godot 4.3 之前的版本中,这段代码建议写成 POSITION = vec4(VERTEX, 1.0); ;,这其实是默认假设了裁剪空间(clip-space)的近裁剪面(near plane)位于 0.0 。但现在这段代码已经不正确了,在 4.3 及更高版本中它将无法正常工作,因为我们现在使用了 "反向 Z(reversed-z)" 深度缓冲区,近裁剪面已经被改到了 1.0 。
即使有了这样的顶点着色器,这个四边形仍然会不断消失。这是因为视锥剔除是在 CPU 上完成的。视锥体剔除在将网格传递给 GPU 之前 ,利用摄像机矩阵和网格的 AABB 来确定该网格是否可见。CPU 不知道我们对顶点做了什么,所以它假设指定的坐标指的是世界坐标,而不是裁剪空间的坐标,这就导致了当我们视线离开场景中心时,Godot 会剔除这个四边形。为了防止四边形被剔除,有几种选择:
将 QuadMesh 作为子节点添加到相机,这样相机就会始终指向它
在 QuadMesh 中将几何属性
extra_cull_margin设置得尽可能大
第二个选项会确保四边形在编辑器中可见,而第一个选项能够保证即使摄像机移出剔除边缘也它仍可见。你也可以同时使用这两个选项。
深度纹理
要从深度纹理中读取数据,我们首先需要使用 hint_depth_texture 创建一个绑定到深度缓冲区的纹理 uniform。
uniform sampler2D depth_texture : hint_depth_texture;
定义之后,深度纹理可以从 texture() 函数中读取。
float depth = texture(depth_texture, SCREEN_UV).x;
备注
与访问屏幕纹理类似,访问深度纹理只有在从当前视口读取时才能进行。深度纹理不能从你已经渲染的另一个视口中访问。
depth_texture (深度纹理)返回的数值范围在 1.0 到 0.0 之间(分别对应近裁剪面和远裁剪面,这是因为使用了 "反向 Z / reverse-z" 深度缓冲区),而且这些数值是非线性的。正因为这种非线性,如果你直接把 depth_texture 里的深度值拿来显示,画面看起来几乎会是一片漆黑,除非物体离摄像机特别近。为了让深度值能够和世界坐标或模型坐标对齐,我们需要把这个值线性化。当我们对顶点位置应用投影矩阵时,z 值会被处理成非线性的,所以要把它变回线性,就需要乘以投影矩阵的逆矩阵。在 Godot 中,我们可以通过 INV_PROJECTION_MATRIX 这个内置变量来获取它。
首先,获取屏幕空间的坐标,并将它们转换成归一化设备坐标(NDC)。当你使用 Vulkan 后端时,NDC 在 x 和 y 方向上的取值范围是 -1.0 到 1.0 ,而在 x 方向上的取值范围是 0.0 到 1.0 。接下来,我们需要利用 SCREEN_UV 来获取 x 和 y 轴的数据,再结合深度值(depth value)来获取 z 轴的数据,从而重新构建出 NDC。
void fragment() {
float depth = texture(depth_texture, SCREEN_UV).x;
vec3 ndc = vec3(SCREEN_UV * 2.0 - 1.0, depth);
}
备注
本教程默认使用的是 Forward+(前向+)或 Mobile(移动端)渲染器,它们都使用 Z 轴范围为 [0.0, 1.0] 的 Vulkan NDC(标准化设备坐标)。相比之下,Compatibility(兼容)渲染器使用的是 Z 轴范围为 [-1.0, 1.0] 的 OpenGL NDC。因此,如果你使用的是 Compatibility 渲染器,请改用下面的代码来计算 NDC:
vec3 ndc = vec3(SCREEN_UV, depth) * 2.0 - 1.0;
你也可以使用 CURRENT_RENDERER 和 RENDERER_COMPATIBILITY 这两个内置的宏定义(defines),来编写一个能够适配所有渲染器的着色器。
#if CURRENT_RENDERER == RENDERER_COMPATIBILITY
vec3 ndc = vec3(SCREEN_UV, depth) * 2.0 - 1.0;
#else
vec3 ndc = vec3(SCREEN_UV * 2.0 - 1.0, depth);
#endif
通过将NDC乘以 INV_PROJECTION_MATRIX , 将NDC转换成视图空间. 回顾一下, 视图空间给出了相对于相机的位置, 所以 z 值将给我们提供到该点的距离.
void fragment() {
...
vec4 view = INV_PROJECTION_MATRIX * vec4(ndc, 1.0);
view.xyz /= view.w;
float linear_depth = -view.z;
}
因为摄像机是朝向 z 轴的负方向, 所以坐标会有负的 z 值。为了得到可用的深度值, 我们必须对 view.z 取反。
利用下面的代码,就可以根据深度缓冲区(depth buffer)来构建出世界坐标。这里用到了 INV_VIEW_MATRIX (逆视图矩阵),它的作用就是把坐标从视图空间(view space)转换回世界空间(world space)。
void fragment() {
...
vec4 world = INV_VIEW_MATRIX * INV_PROJECTION_MATRIX * vec4(ndc, 1.0);
vec3 world_position = world.xyz / world.w;
}
示例着色器
一旦我们加上一行代码把数据输出到 ALBEDO (反照率/基础颜色),我们就得到了一个完整的着色器,看起来大概就像下面这样。这个着色器可以让你直观地查看线性深度(linear depth)或者世界空间坐标(world space coordinates),具体显示哪个,取决于你把哪一行代码给注释掉了。
shader_type spatial;
// Prevent the quad from being affected by lighting and fog. This also improves performance.
render_mode unshaded, fog_disabled;
uniform sampler2D depth_texture : hint_depth_texture;
void vertex() {
POSITION = vec4(VERTEX.xy, 1.0, 1.0);
}
void fragment() {
float depth = texture(depth_texture, SCREEN_UV).x;
vec3 ndc = vec3(SCREEN_UV * 2.0 - 1.0, depth);
vec4 view = INV_PROJECTION_MATRIX * vec4(ndc, 1.0);
view.xyz /= view.w;
float linear_depth = -view.z;
vec4 world = INV_VIEW_MATRIX * INV_PROJECTION_MATRIX * vec4(ndc, 1.0);
vec3 world_position = world.xyz / world.w;
// Visualize linear depth
ALBEDO.rgb = vec3(fract(linear_depth));
// Visualize world coordinates
//ALBEDO.rgb = fract(world_position).xyz;
}
优化
你可以使用单个大三角形而不是使用全屏四边形. 解释的原因在 这里 . 但是, 这种好处非常小, 只有在运行特别复杂的片段着色器时才有用.
在 MeshInstance3D 中将 Mesh 设置为 ArrayMesh。ArrayMesh 是一个工具,允许你轻松地通过顶点、法线、颜色等数组来构建 Mesh。
现在,给这个 MeshInstance3D 节点挂上一个脚本,然后使用下面的代码:
extends MeshInstance3D
func _ready():
# Create a single triangle out of vertices:
var verts = PackedVector3Array()
verts.append(Vector3(-1.0, -1.0, 0.0))
verts.append(Vector3(3.0, -1.0, 0.0))
verts.append(Vector3(-1.0, 3.0, 0.0))
# Create an array of arrays.
# This could contain normals, colors, UVs, etc.
var mesh_array = []
mesh_array.resize(Mesh.ARRAY_MAX) #required size for ArrayMesh Array
mesh_array[Mesh.ARRAY_VERTEX] = verts #position of vertex array in ArrayMesh Array
# Create mesh from mesh_array:
mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, mesh_array)
备注
这个三角形是用归一化设备坐标(NDC)来指定的。回想一下,NDC 在 x 和 y 方向上的取值范围都是从 -1.0 到 1.0 。这意味着整个屏幕的宽度是 2 个单位,高度是 2 个单位。为了用一个三角形就覆盖整个屏幕,需要使用一个宽 4 个单位、高 4 个单位的三角形,也就是将其宽度和高度都设为屏幕的两倍。
从上面分配相同的顶点着色器, 所有内容应该看起来完全相同.
使用 ArrayMesh 相比于使用 QuadMesh(四边形网格),唯一的一个缺点就是:ArrayMesh 在编辑器里是看不见的,因为那个三角形要等到场景实际运行起来之后才会被构建出来。想要解决这个问题也很简单,你可以去建模软件里直接建一个简单的三角形 Mesh,然后把它放到 MeshInstance3D 里来代替使用。