记录一下Godot中基于深度纹理的3D物体外轮廓描边效果的实现过程,这种描边适合用来高亮单个物体或多个物体的组合,同时不会占用物体内部空间,在较远的距离、较粗的描边情况下也有不错的效果。
实现方式基本参考这篇帖子,有部分修改,帖子中只给了最终的结果,没有说明具体实现步骤和原理,本篇笔记将对此做一个较为详细的拆解,修复一些问题,同时记录踩到的坑。
整体思路:
- 将需要描边的物体放在一个单独的图层,使用视口与后处理着色器将它们渲染到一张描边深度纹理
- 主相机下同样使用后处理着色器,读取描边深度纹理,与当前深度纹理作比较来检测边缘并描边
当前版本Godot 4.4。阅读需要一些着色器基础,如果对Godot中的着色器不太了解,可以先阅读着色器简介、你的第一个着色器。
渲染描边深度纹理
准备场景
首先搭一个测试场景,用MeshInstance3D
显示一些简单的图元网格,例如方块、球体之类,物体之间有一些前后遮挡方便测试描边效果。
图中绿色的方块和粉色的甜甜圈是需要描边的物体,在它们的Inspector中,将Layers修改到另一个单独的图层,图层编号随意,不是默认的1就行,这里修改为11。之后如果要在运行时动态开启/关闭物体的描边,只需要在脚本中修改物体所在的图层,layers属性值为1时关闭,为 1 << 10 时开启。
注意先不要往场景里添加任何WorldEnvironment
,会影响描边的显示,后面会说明不影响描边的世界环境如何设置。
深度纹理
深度纹理是一张包含画面中像素点与相机的距离信息的纹理,Godot中离相机越近,深度值越接近于1,反之越远越接近于0。这里通过视口来渲染一张自定义的用于描边的深度纹理。
在场景根节点下添加一个SubViewport
节点,其下添加一个相机,相机下再添加一个MeshInstance3D
节点,并重命名。
OutlineSubViewport就是渲染描边物体图层的视口,描边深度纹理从这个视口获取,OutlineCamera做具体的渲染操作,OutlineDepth用于显示一个全屏四边形,它上面将会有一个后处理着色器用于获取并处理当前视口的深度纹理。
视口
OutlineSubViewport节点的Inspector中,勾选Transparent BG、取消勾选Handle Input Locally、勾选Use HDR 2D。
然后在OutlineSubViewport节点上挂载一个脚本,作用是在窗口大小变化时改变自身大小与窗口大小一致:
viewport_fitter.gd
extends SubViewport
func _ready() -> void:
_match_root_viewport()
get_tree().get_root().size_changed.connect(_match_root_viewport)
func _match_root_viewport() -> void:
size = get_tree().get_root().size
相机
在OutlineCamera的Inspector中,将Cull Mask修改为11与12,11是之前设置的描边物体所在的图层,12则是处理描边深度纹理的全屏四边形所在的图层。
其他的参数如FOV、角度位置等等按情况调整,但需要跟之后的主相机保持一致。
全屏四边形
将OutlineDepth节点放在相机前方1米左右的位置。在它的Inspector中,Mesh属性下创建一个新的Quad Mesh,将Size改为2x2,勾选Flip Faces。
Godot中裁剪空间左下角坐标为(-1, -1),右上角坐标为(1, 1),所以面片的大小设置为2x2。
在FileSystem中创建一个新的Resource,类型为Shader,命名为outline_depth.gdshader,在vertex
函数中将当前顶点坐标赋值给POSITION
输出,POSITION
被写入后将会覆盖裁剪空间下的最终顶点位置,从而使这个面片覆盖全屏。
outline_depth.gdshader
shader_type spatial;
// 设置渲染模式:禁用剔除、不计算光照、禁用阴影、禁用雾
render_mode cull_disabled, unshaded, shadows_disabled, fog_disabled;
void vertex() {
POSITION = vec4(VERTEX.xy, 1.0, 1.0);
}
从Godot 4.3开始改为使用反向Z(Reversed-Z)的深度缓冲,即近处深度为1.0远处为0.0,所以这里的w分量填1.0。反向Z的优势可以参考这篇文章
在OutlineDepth的Inspector中,Meterial Override属性下新建一个ShaderMaterial,Shader属性设置为刚才创建的outline_depth.gdshader,并将Render Priority改为-1让它优先渲染。
按照官方文档-高级后处理的说法,OutlineDepth是相机的子节点,所以它在运行时不会被裁剪。如果希望在编辑器中也能看到效果,可以给Extra Cull Margin属性设置一个非常大的值,例如16384.0,但实测有个副作用是会导致场景里的Gizmos显示不正常。
同时将图层设置为12。
继续完善outline_depth.gdshader,在fragment
函数中采样当前片元的深度纹理,给它加上一个较小的值0.00001,这样在后续做边缘检测时方便做深度值比较。将处理后的深度值乘以一个较大的值2048,整数部分放在r通道,小数部分放在g通道,这样可以保持精度,将这个颜色赋值给ALBEDO
输出。
outline_depth.gdshader
shader_type spatial;
render_mode cull_disabled, unshaded, shadows_disabled, fog_disabled;
uniform sampler2D depth_tex : hint_depth_texture, repeat_disable, filter_nearest;
void vertex() {
POSITION = vec4(VERTEX.xy, 1.0, 1.0);
}
void fragment() {
float depth = texture(depth_tex, SCREEN_UV).r + 0.00001;
ALBEDO = vec3(floor(depth * 2048.0), fract(depth * 2048.0), 0.0);
}
这一步之后OutlineSubViewport的Inspector中可以看到预览效果。
描边
主相机
有了描边深度纹理之后开始做主相机的描边显示,在场景根节点下添加一个Node3D
作为主相机的根节点,在其下添加一个相机,相机之下添加一个RemoteTransform3D
节点与一个MeshInstance3D
节点。
主相机渲染除OutlineDepth外的所有图层,即取消勾选图层12。
FOV和位置视情况调整,但要和描边相机保持一致。
RemoteTransform3D
节点用来将描边相机和主相机的变换做同步,这样不管主相机的位置、旋转如何变化,描边相机都会一同变化。
Godot里这种贴心小功能比较多,虽然很简单也可以自己实现,但有开箱即用的多少可以省点时间。
全屏四边形
Outline节点和上面的OutlineDepth节点一样,都是全屏四边形,区别在于着色器不同、所在的图层不同。先依葫芦画瓢做好全屏四边形,接下来编写描边的着色器。
在FileSystem中创建一个新的Resource,类型为Shader,命名为outline.gdshader,先定义一些参数和变量。
outline.gdshader
shader_type spatial;
// 设置渲染模式:禁用剔除、不计算光照、禁用阴影、禁用雾
render_mode cull_disabled, unshaded, shadows_disabled, fog_disabled;
// 描边宽度
uniform int outline_width = 2;
// 物体内部高亮颜色,与物体颜色做透明度混合
uniform vec4 inner_color : source_color = vec4(1.0, 1.0, 1.0, 0.2);
// 物体描边颜色
uniform vec4 outline_color : source_color = vec4(1.0, 1.0, 1.0, 1.0);
// 描边深度纹理
uniform sampler2D outline_depth_tex : repeat_disable;
// 当前的深度纹理
uniform sampler2D depth_tex : hint_depth_texture, repeat_disable, filter_nearest;
// 屏幕纹理
uniform sampler2D screen_tex : hint_screen_texture, repeat_disable, filter_nearest;
void vertex() {
POSITION = vec4(VERTEX.xy, 1.0, 1.0);
}
void fragment() {
// ...
}
将outline.gdshader设置到Material Override中,图层保持默认的1。
可以临时在fragment
函数中给ALBEDO
赋值一个颜色看是否正常。
void fragment() {
ALBEDO = vec3(0.2, 0.8, 0.9);
}
此时运行如果看到的不是纯色而是场景内容,说明Outline节点不在相机视角内或与相机重合,调整它的位置到相机前方,同时也检查一下OutlineDepth节点的位置是否正确。
读取描边深度纹理
接下来给着色器的描边深度纹理参数赋值,点击Outline Depth Text属性,选择New ViewportTexture新建一个视口纹理,这时会有提示我们要先勾选Resource下的Local to Scene,勾选后再次创建,弹出的对话框中选择OutlineSubViewport,可以看到纹理预览。
前面在outline_depth.gdshader中,我们将深度值乘以了2048,然后把整数和小数部分分别放到了rg通道,这里用相反的操作还原深度值。
outline.gdshader
// ...
void fragment() {
// 采样描边深度纹理
vec4 outline_depth_color = texture(outline_depth_tex, SCREEN_UV);
// 还原深度值
float outline_depth = outline_depth_color.r / 2048.0 + outline_depth_color.g / 2048.0;
// 临时显示
ALBEDO = vec3(outline_depth);
}
运行后不出意外的话是这样,说明成功读取到了描边深度纹理:
同样可以在outline.gdshader中采样当前的深度纹理depth_tex
并显示,看看它长什么样:
内部检测
在当前的深度纹理中,每个片元的深度信息大致可以这样表示,浅绿色物体是需要描边的物体,浅紫色物体是不需要描边的物体,由于它在浅绿色物体前方,所以它的深度值更大,而背景的深度值则是0。
在描边深度纹理中,我们给深度值加了一个很小的值0.00001,它的深度信息是这样:
如果当前深度值小于描边纹理中的深度值,并且屏幕像素的透明度大于0,则说明该片元在物体内部,可以混合内部颜色让物体内部高亮。
outline.gdshader
// ...
void fragment() {
vec2 screen_uv = SCREEN_UV;
// 采样描边深度纹理
vec4 outline_depth_color = texture(outline_depth_tex, screen_uv);
// 还原深度值
float outline_depth = outline_depth_color.r / 2048.0 + outline_depth_color.g / 2048.0;
// 采样当前深度纹理
float depth = texture(depth_tex, screen_uv).r;
// 屏幕颜色
vec4 screen_color = texture(screen_tex, screen_uv);
// 是否处于描边物体内部
bool is_inner = depth < outline_depth && screen_color.a > 0.0;
// 混合内部高亮颜色
screen_color.rgb = mix(screen_color.rgb, inner_color.rgb, is_inner ? inner_color.a : 0.0);
// 输出
ALBEDO = screen_color.rgb;
}
临时调整一下内部颜色,运行可以看到效果:
边缘检测
由于要实现的是外轮廓描边,对于物体内部跳过检测。对于物体外部,按描边宽度检测当前片元周围是否存在描边物体,例如描边宽度为2,分别看周围距离为2的一圈、距离为1的一圈片元内是否存在描边物体,如果存在则当前片元是描边的一部分,输出描边颜色。
所有片元运算一遍后:
根据这个思路完善着色器代码:
outline.gdshader
// ...
void fragment() {
vec2 screen_uv = SCREEN_UV;
// 采样描边深度纹理
vec4 outline_depth_color = texture(outline_depth_tex, screen_uv);
// 还原深度值
float outline_depth = outline_depth_color.r / 2048.0 + outline_depth_color.g / 2048.0;
// 采样当前深度纹理
float depth = texture(depth_tex, screen_uv).r;
// 屏幕颜色
vec4 screen_color = texture(screen_tex, screen_uv);
// 是否处于描边物体内部
bool is_inner = depth < outline_depth && screen_color.a > 0.0;
// 是否处于描边上
bool is_outline = false;
if (!is_inner) {
// 计算纹素大小
vec2 texel_size = 1.0 / vec2(VIEWPORT_SIZE.xy);
// 以当前位置为中心,判断周围的点是否存在描边物体,如果存在则提前跳出循环
for (int x = -outline_width; x <= outline_width && !is_outline; ++x) {
for (int y = -outline_width; y <= outline_width && !is_outline; ++y) {
if (y == 0 && x == 0) {
continue;
}
// 周围点的uv
vec2 neighbor_uv = screen_uv - vec2(texel_size.x * float(x), texel_size.y * float(y));
// 如果该点的屏幕颜色为透明则跳过
if (texture(screen_tex, neighbor_uv).a <= 0.0) {
continue;
}
// 用同样的逻辑(是否处于物体内部)来判断
float neighbor_depth = texture(depth_tex, neighbor_uv).r;
vec4 neighbor_outline_depth_color = texture(outline_depth_tex, neighbor_uv);
float neighbor_outline_depth = neighbor_outline_depth_color.r / 2048.0 + neighbor_outline_depth_color.g / 2048.0;
is_outline = neighbor_depth < neighbor_outline_depth;
}
}
}
// 混合内部高亮颜色
screen_color.rgb = mix(screen_color.rgb, inner_color.rgb, is_inner ? inner_color.a : 0.0);
// 混合描边颜色并输出
ALBEDO = mix(screen_color.rgb, outline_color.rgb, is_outline ? outline_color.a : 0.0);
}
到这里描边便完成了,运行效果:
描边方法不是唯一的,这里的方法也不一定是最好的,总之只要能获取到描边深度纹理,之后的做法就多种多样了。
一些坑
世界环境
前面提到先不要往场景里添加WorldEnvironment
节点,会影响描边的显示,正确的方法是添加到主相机的Environment属性上。
导入的模型
对于外部导入的模型,需要注意其MeshInstance3D
路径,双击模型文件打开导入设置查看。
可以编写脚本,在编辑器内填写路径,获取到MeshInstance3D
节点控制它的图层。
extends Node3D
@export var outline_enabled: bool
@export var mesh_path: NodePath
var mesh: MeshInstance3D
func _ready() -> void:
mesh = get_node(mesh_path)
toggle_outline(outline_enabled)
func toggle_outline(is_enabled: bool):
outline_enabled = is_enabled
if is_enabled:
mesh.layers = 1 << 10
else:
mesh.layers = 1
景深模糊
相机开启景深模糊的情况下,如果描边物体后方有被模糊的物体或背景,描边也会被模糊。
猜测原因是景深模糊位于内置后处理中(未确认),比描边着色器后执行,先描边再模糊导致描边也被模糊。
一种解决思路是,把描边处理放到内置后处理之后执行,先模糊再描边。在Godot中,也有类似于Unity URP的RendererFeature的功能,叫做合成器,支持在渲染管线的不同阶段插入额外逻辑,但遗憾的是目前似乎不支持插入到内置后处理之后(CompositorEffect.EffectCallbackType),和URP的RenderPassEvent相比少了很多插入点,另外考虑到时间关系就没有去尝试了。
另一种思路是,描边被模糊,说明它所处片元的深度被景深模糊判断在了需要模糊的区间内,从上图中也可以看出,被模糊的部分的深度值都较小。那么只要修改这些地方的深度,让它们等于物体内部的深度,就不会被模糊了。
按这个思路对outline.gdshader进行修改,首先在渲染模式里加上一条depth_draw_always
让它总是写入深度:
outline.gdshader
shader_type spatial;
// 设置渲染模式:禁用剔除、不计算光照、禁用阴影、禁用雾、总是写入深度
render_mode cull_disabled, unshaded, shadows_disabled, fog_disabled, depth_draw_always;
// ...
在fragment
函数中,先写入当前深度到DEPTH
,注意DEPTH
一旦被写入,函数中的所有判断分支都需要确保DEPTH
被写入:
// ...
void fragment() {
// ...
// 是否处于描边物体内部
bool is_inner = depth < outline_depth && screen_color.a > 0.0;
// 是否处于描边上
bool is_outline = false;
// 写入深度
DEPTH = depth;
// ...
}
在是否为描边的判断中,如果当前是描边,并且当前深度要小于物体内部深度,则将当前深度改为物体内部深度:
// ...
void fragment() {
//...
// 写入深度
DEPTH = depth;
if (!is_inner) {
// ...
// 以当前位置为中心,判断周围的点是否存在描边物体,如果存在则提前跳出循环
for (int x = -outline_width; x <= outline_width && !is_outline; ++x) {
for (int y = -outline_width; y <= outline_width && !is_outline; ++y) {
//...
is_outline = neighbor_depth < neighbor_outline_depth;
DEPTH = is_outline && depth < neighbor_depth ? neighbor_depth : depth;
}
}
}
//...
}
描边不再被模糊了,但有些地方还是不太完美,之后有时间再完善了:
其他未解决问题
以下问题由于暂时没有相关需求,所以暂时没有处理,如果您知道解决方法,或者有更好的实现方式,欢迎在评论区留言:
- 半透明物体的描边
- 开启TAA时描边会抖动
- 只测试了Windows平台下使用Forward+渲染器的情况,其他平台未测试
完整代码
不包含景深模糊的处理。
outline_depth.gdshader
shader_type spatial;
render_mode cull_disabled, unshaded, shadows_disabled, fog_disabled, depth_draw_never;
uniform sampler2D depth_tex : hint_depth_texture, repeat_disable, filter_nearest;
void vertex() {
POSITION = vec4(VERTEX.xy, 1.0, 1.0);
}
void fragment() {
float depth = texture(depth_tex, SCREEN_UV).r + 0.00001;
ALBEDO = vec3(floor(depth * 2048.0), fract(depth * 2048.0), 0.0);
}
outline.gdshader
shader_type spatial;
// 设置渲染模式:禁用剔除、不计算光照、禁用阴影、禁用雾
render_mode cull_disabled, unshaded, shadows_disabled, fog_disabled;
// 描边宽度
uniform int outline_width = 2;
// 物体内部高亮颜色,与物体颜色做透明度混合
uniform vec4 inner_color : source_color = vec4(1.0, 1.0, 1.0, 0.2);
// 物体描边颜色
uniform vec4 outline_color : source_color = vec4(1.0, 1.0, 1.0, 1.0);
// 描边深度纹理
uniform sampler2D outline_depth_tex : repeat_disable;
// 当前的深度纹理
uniform sampler2D depth_tex : hint_depth_texture, repeat_disable, filter_nearest;
// 屏幕纹理
uniform sampler2D screen_tex : hint_screen_texture, repeat_disable, filter_nearest;
void vertex() {
POSITION = vec4(VERTEX.xy, 1.0, 1.0);
}
void fragment() {
vec2 screen_uv = SCREEN_UV;
// 采样描边深度纹理
vec4 outline_depth_color = texture(outline_depth_tex, screen_uv);
// 还原深度值
float outline_depth = outline_depth_color.r / 2048.0 + outline_depth_color.g / 2048.0;
// 采样当前深度纹理
float depth = texture(depth_tex, screen_uv).r;
// 屏幕颜色
vec4 screen_color = texture(screen_tex, screen_uv);
// 是否处于描边物体内部
bool is_inner = depth < outline_depth && screen_color.a > 0.0;
// 是否处于描边上
bool is_outline = false;
if (!is_inner) {
// 计算纹素大小
vec2 texel_size = 1.0 / vec2(VIEWPORT_SIZE.xy);
// 以当前位置为中心,判断周围的点是否存在描边物体,如果存在则提前跳出循环
for (int x = -outline_width; x <= outline_width && !is_outline; ++x) {
for (int y = -outline_width; y <= outline_width && !is_outline; ++y) {
if (y == 0 && x == 0) {
continue;
}
// 周围点的uv
vec2 neighbor_uv = screen_uv - vec2(texel_size.x * float(x), texel_size.y * float(y));
// 如果该点的屏幕颜色为透明则跳过
if (texture(screen_tex, neighbor_uv).a <= 0.0) {
continue;
}
// 用同样的逻辑(是否处于物体内部)来判断
float neighbor_depth = texture(depth_tex, neighbor_uv).r;
vec4 neighbor_outline_depth_color = texture(outline_depth_tex, neighbor_uv);
float neighbor_outline_depth = neighbor_outline_depth_color.r / 2048.0 + neighbor_outline_depth_color.g / 2048.0;
is_outline = neighbor_depth < neighbor_outline_depth;
}
}
}
// 混合内部高亮颜色
screen_color.rgb = mix(screen_color.rgb, inner_color.rgb, is_inner ? inner_color.a : 0.0);
// 混合描边颜色并输出
ALBEDO = mix(screen_color.rgb, outline_color.rgb, is_outline ? outline_color.a : 0.0);
}