
記錄一下Godot中基于深度紋理的3D物體外輪廓描邊效果的實現過程,這種描邊適合用來高亮單個物體或多個物體的組合,同時不會占用物體內部空間,在較遠的距離、較粗的描邊情況下也有不錯的效果。
實現方式基本參考這篇問答,有部分修改,回復里只給了最終的結果,沒有說明具體實現步驟和原理,本篇筆記將對此做一個較為詳細的拆解,修復一些問題,同時記錄踩到的坑。
整體思路:
- 將需要描邊的物體放在一個單獨的圖層,使用視口與后處理著色器將它們渲染到一張描邊深度紋理
- 主相機下同樣使用后處理著色器,讀取描邊深度紋理,與當前深度紋理作比較來檢測邊緣并描邊
當前版本Godot 4.4。閱讀需要一些著色器基礎,如果對Godot中的著色器不太了解,可以先閱讀著色器簡介、你的第一個著色器。
渲染描邊深度紋理
準備場景
首先搭一個測試場景,用MeshInstance3D顯示一些簡單的圖元網格,例如方塊、球體之類,物體之間有一些前后遮擋方便測試描邊效果。

圖中綠色的方塊和粉色的面包圈是需要描邊的物體,在它們的Inspector中,將Layers修改到另一個單獨的圖層,圖層編號隨意,不是默認的1就行,這里修改為11。之后如果要在運行時動態(tài)開啟/關閉物體的描邊,只需要在腳本中修改物體所在的圖層,layers屬性值為1時關閉,為 1 << 10 時開啟。

注意先不要往場景里添加任何WorldEnvironment,會影響描邊的顯示,后面會說明不影響描邊的世界環(huán)境如何設置。
深度紋理
深度紋理是一張包含畫面中像素點與相機距離信息的紋理,離相機越近,深度值越接近于1,反之越遠越接近于0。這里通過視口來渲染一張自定義的用于描邊的深度紋理。
在場景根節(jié)點下添加一個SubViewport節(jié)點,其下添加一個相機,相機下再添加一個MeshInstance3D節(jié)點,并重命名。

OutlineSubViewport就是渲染描邊物體圖層的視口,描邊深度紋理從這個視口獲取,OutlineCamera做具體的渲染操作,OutlineDepth用于顯示一個全屏四邊形,它上面將會有一個后處理著色器用于獲取并處理當前視口的深度紋理。
視口
OutlineSubViewport節(jié)點的Inspector中,勾選Transparent BG、取消勾選Handle Input Locally、勾選Use HDR 2D。

然后在OutlineSubViewport節(jié)點上掛載一個腳本,作用是在窗口大小變化時改變自身大小與窗口大小一致:
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節(jié)點放在相機前方1米左右的位置。在它的Inspector中,Mesh屬性下創(chuàng)建一個新的Quad Mesh,將Size改為2x2,勾選Flip Faces。

Godot中裁剪空間左下角坐標為(-1, -1),右上角坐標為(1, 1),所以面片的大小設置為2x2。
在FileSystem中創(chuàng)建一個新的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。
在OutlineDepth的Inspector中,Meterial Override屬性下新建一個ShaderMaterial,Shader屬性設置為剛才創(chuàng)建的outline_depth.gdshader,并將Render Priority改為-1讓它靠后渲染。

按照官方文檔-高級后處理的說法,OutlineDepth是相機的子節(jié)點,所以它在運行時不會被裁剪。如果希望在編輯器中也能看到效果,可以給Extra Cull Margin屬性設置一個非常大的值,例如16384.0,但實測有個副作用是會導致場景里的Gizmos顯示不正常。
同時將圖層設置為12。

繼續(xù)完善outline_depth.gdshader,在fragment函數中采樣當前片元的深度紋理,給它加上一個較小的值0.00001,這樣在后續(xù)做邊緣檢測時方便做深度值比較。將處理后的深度值乘以一個較大的值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中可以看到預覽效果。

描邊
主相機
有了描邊深度紋理之后開始做主相機的描邊顯示,在場景根節(jié)點下添加一個Node3D作為主相機的根節(jié)點,在其下添加一個相機,相機之下添加一個RemoteTransform3D節(jié)點與一個MeshInstance3D節(jié)點。

主相機渲染除OutlineDepth外的所有圖層,即取消勾選圖層12。

FOV和位置視情況調整,但要和描邊相機保持一致。
RemoteTransform3D節(jié)點用來將描邊相機和主相機的變換做同步,這樣不管主相機的位置、旋轉如何變化,描邊相機都會一同變化。

Godot里這種貼心小功能比較多,雖然很簡單也可以自己實現,但有開箱即用的多少可以省點時間。
全屏四邊形
Outline節(jié)點和上面的OutlineDepth節(jié)點一樣,都是全屏四邊形,區(qū)別在于著色器不同、所在的圖層不同。先依葫蘆畫瓢做好全屏四邊形,接下來編寫描邊的著色器。
在FileSystem中創(chuàng)建一個新的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節(jié)點不在相機視角內或與相機重合,調整它的位置到相機前方,同時也檢查一下OutlineDepth節(jié)點的位置是否正確。
讀取描邊深度紋理
接下來給著色器的描邊深度紋理參數賦值,點擊Outline Depth Text屬性,選擇New ViewportTexture新建一個視口紋理,這時會有提示我們要先勾選Resource下的Local to Scene,勾選后再次創(chuàng)建,彈出的對話框中選擇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);
// 以當前位置為中心,判斷周圍的點是否存在描邊物體,如果存在則提前跳出循環(huán)
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);
}
到這里描邊便完成了,運行效果:

描邊方法不是唯一的,這里的方法也不一定是最好的,總之只要能獲取到描邊深度紋理,之后的做法就多種多樣了。
一些坑
世界環(huán)境
前面提到先不要往場景里添加WorldEnvironment節(jié)點,會影響描邊的顯示,正確的方法是添加到主相機的Environment屬性上。


導入的模型
對于外部導入的模型,需要注意其MeshInstance3D路徑,雙擊模型文件打開導入設置查看。

可以編寫腳本,在編輯器內填寫路徑,獲取到MeshInstance3D節(jié)點控制它的圖層。
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


景深模糊
相機開啟景深模糊的情況下,如果描邊物體后方有被模糊的物體或背景,描邊也會被模糊。


猜測原因是景深模糊位于內置后處理中(未確認),比描邊著色器后執(zhí)行,先描邊再模糊導致描邊也被模糊。
一種解決思路是,把描邊處理放到內置后處理之后執(zhí)行,先模糊再描邊。在Godot中,也有類似于Unity URP的RendererFeature的功能,叫做合成器,支持在渲染管線的不同階段插入額外邏輯,但遺憾的是目前似乎不支持插入到內置后處理之后(CompositorEffect.EffectCallbackType),和URP的RenderPassEvent相比少了很多插入點,另外考慮到時間關系就沒有去嘗試了。
另一種思路是,描邊被模糊,說明它所處片元的深度被景深模糊判斷在了需要模糊的區(qū)間內,從上圖中也可以看出,被模糊的部分的深度值都較小。那么只要修改這些地方的深度,讓它們等于物體內部的深度,就不會被模糊了。
按這個思路對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) {
// ...
// 以當前位置為中心,判斷周圍的點是否存在描邊物體,如果存在則提前跳出循環(huán)
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;
}
}
}
//...
}
描邊不再被模糊了,但有些地方還是不太完美,之后有時間再完善了:

其他未解決問題
以下問題由于暫時沒有相關需求,所以暫時沒有處理,如果您知道解決方法,或者有更好的實現方式,歡迎在評論區(qū)留言:
- 半透明物體的描邊
- 開啟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);
// 以當前位置為中心,判斷周圍的點是否存在描邊物體,如果存在則提前跳出循環(huán)
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);
}