如何使用 Oasis 快速實現描邊效果
前言
描邊可以分為
外描邊、內描邊、後處理描邊
。
外描邊
只顯示整個模型的外輪廓,模型的內部細節是無法描邊的,比如眼睛,眉毛這些內部元素;
內描邊
不僅可以顯示外輪廓,還能夠顯示眼睛、眉毛、腰帶這種內部輪廓;
後處理描邊
其實是檢測顏色的突變,無論模型是否有凹凸細節,只要有顏色發生變化,都會被當作輪廓繪製。
本文介紹兩種描邊方法:一種是先渲染想要描邊的模型,然後沿法線撐大模型,將描邊效果(如純黑)作為該模型的材質再渲染一遍,其中可以利用模版測試和正面剔除來達到第二遍的pass只渲染描邊;另一種是影象後處理,利用2D圖形演算法針對整幅畫面進行邊緣檢測。在 Oasis 中,都有相應的元件能夠方便快速地實現。
使用多渲染器實現內外描邊
引擎截至v0.6還不支援 Shader 多 Pass,所以本文介紹透過多渲染器的方式來達到同樣的目的
在 Oasis 中 3D 模型的渲染可以用渲染器元件來實現,渲染器元件由網格和材質組成,
網格
負責儲存頂點、法線等元資料,
材質
負責畫素著色。我們一般不需要手動設定渲染器,透過載入 glTF 模型 會自帶渲染器。當我們渲染描邊的時候,因為多個渲染器元件可以共享同一個網格或者材質,所以我們只需要新增一個渲染器元件,網格從 glTF 裡面獲取,材質參考自定義材質教程建立一個
純色的描邊材質
就行。
新增渲染器的程式碼如下:
const
renderers
:
MeshRenderer
[]
=
[];
// 從 glTF 中獲取所有渲染器元件
rootEntity
。
getComponentsIncludeChildren
(
MeshRenderer
,
renderers
);
// 新增描邊渲染器元件
renderers
。
forEach
((
renderer
)
=>
{
const
entity
=
renderer
。
entity
;
// 新增渲染器元件
const
borderRenderer
=
entity
。
addComponent
(
MeshRenderer
);
// 網格 - 共享網格資料
borderRenderer
。
mesh
=
renderer
。
mesh
;
// 設定描邊材質,具體的會在下文中介紹
borderRenderer
。
setMaterial
(
borderMaterial
);
});
外描邊
外描邊可以參考模板測試教程來實現,根本原理就是透過比較
模版緩衝區
的值來決定是否渲染該片元。
1. 填入模板
在 Oasis 中操作渲染狀態只需要對材質的 renderState 賦值即可。按照上面的思路,我們先開啟原渲染器的模板狀態,並強制寫入 1,因為 canvas 的預設模版值為 0 ,所以第一次渲染,保證了渲染的畫素位置模版值為1,其餘的地方為0 :
const material = renderer。getBorderMaterial();
const stencilState = material。renderState。stencilState;
// 開啟模版測試
stencilState。enabled = true;
// 設定參考值為 1
stencilState。referenceValue = 1;
// 透過時將“參考值”寫入模版緩衝。因為預設“總是透過”,所以強制寫入1
stencilState。passOperationFront = StencilOperation。Replace;
如下圖的黑色部分表示模版值為1,白色部分為預設0:
2. 撐大
接下來,我們需要將模型的所有頂點沿著法線方向撐大,可以參考自定義材質教程的寫法,在
頂點著色器
中對
gl_Position
進行縮放:
const
vertex
=
`
attribute
vec3
POSITION
;
attribute
vec3
NORMAL
;
uniform
float
u_width
;
uniform
mat4
u_MVPMat
;
uniform
mat4
u_modelMat
;
uniform
mat4
u_viewMat
;
uniform
mat4
u_projMat
;
uniform
mat4
u_normalMat
;
void
main
()
{
vec4
mPosition
=
u_modelMat
*
vec4
(
POSITION
,
1。0
);
// 得到世界座標系下的歸一化法線向量
vec3
mNormal
=
normalize
(
mat3
(
u_normalMat
)
*
NORMAL
);
// 將模型位置沿著法線方向縮放 u_width
mPosition
。
xyz
+=
mNormal
*
u_width
;
gl_Position
=
u_projMat
*
u_viewMat
*
mPosition
;
}
`
;
描邊材質只需要在片元著色器中設定純色即可:
const
fragment
=
`
uniform
vec3
u_color
;
void
main
(){
gl_FragColor
=
vec4
(
u_color
,
1
);
}
`
;
建立完描邊材質後,我們將它繫結到新增的渲染器元件上,我們會發現縮放的渲染器元件生效了,但是會擋在原來的渲染器前面,如下圖:
這是因為我們還沒有對描邊材質配置
模版狀態
,這時候生效的是深度測試,因為撐大後的模型離螢幕更近,所以覆蓋了原來的渲染器。
3. 模板測試
第一個渲染器已經將 1 填入了模板緩衝,我們可以在渲染撐大的模型的時候,只渲染那些不等於1的畫素,即將模板測試的比較函式設定為
不相等
。
如下圖所示,利用模板測試只渲染了撐大的部分,即描邊部分:
描邊材質的模板測試程式碼如下:
// 保證最後再渲染描邊材質
material
。
renderQueueType
=
RenderQueueType
。
Transparent
+
1
;
// 雙面渲染防止一些破面
material
。
renderState
。
rasterState
。
cullMode
=
CullMode
。
Off
;
const
stencilState
=
material
。
renderState
。
stencilState
;
// 開啟模版測試
stencilState
。
enabled
=
true
;
// 設定比較值
stencilState
。
referenceValue
=
1
;
// 透過條件:referenceValue != 模板緩衝值
stencilState
。
compareFunctionFront
=
CompareFunction
。
NotEqual
;
stencilState
。
compareFunctionBack
=
CompareFunction
。
NotEqual
;
最終達到的外描邊效果:
內描邊
因為模板測試只能渲染撐大的那一部分,所以無法顯示內輪廓,比如腰帶等等,我們一般使用第二遍 shader pass 採用正面剔除的方法來實現內描邊。
1. 渲染正面
正常渲染模型,不需要設定任何模版狀態或者深度狀態。
2. 撐大
撐大部分的程式碼還是和前面一樣,沿著法線縮放,參考前面即可。
3. 渲染背面
// 保證最後渲染描邊材質
material。renderQueueType = RenderQueueType。Transparent + 1;
// 剔除正面,只渲染背面
material。renderState。rasterState。cullMode = CullMode。Front;
渲染撐大後的背面,能夠保證撐大的模型不會因為深度測試擋住本來的模型,也不會因為模板測試只渲染模型的外輪廓,內描邊效果如下圖,可以看到,模型的內部輪廓也顯示了:
使用後處理實現描邊
在前言裡面提到過,除了使用
多渲染器方案
實現內外描邊效果外,還可以使用
後處理方案
對影象進行邊緣檢測。
後處理的優點:
在某些場景下(如模型面數非常多)能夠提高效能。
能夠整體處理描邊效果,解決多模型描邊重合的問題。
能夠解決模型撐大後破面的問題(如正方體撐大後因為法線
拐角超過90度
,會導致破面)。
1. 渲染離屏紋理
Oasis 使用 RenderTarget 來渲染離屏紋理,即不渲染到螢幕,而是渲染到一張紋理上,然後利用這張離屏紋理來實現各種後處理特效,比如描邊。
const { width, height } = engine。canvas;
const renderColorTexture = new RenderColorTexture(engine, width, height);
const renderTarget = new RenderTarget(engine, width, height, renderColorTexture);
2.新增指令碼
RenderTarget 是過程式,我們需要利用指令碼的生命週期將整個渲染管線串起來。
我們可以給相機建立一個指令碼,在渲染場景前設定 renderTarget ,代表我們想將場景渲染到紋理上;在渲染後,我們將 renderTarget 清空,並設定了和全屏平面一樣的 Layer1,代表我們想將離屏紋理繪製到螢幕上,並進行描邊後處理:
class
PostScript
extends
Script
{
renderTarget
:
RenderTarget
;
onBeginRender
(
camera
:
Camera
)
:
void
{
// 繪製場景到紋理上
camera
。
renderTarget
=
this
。
renderTarget
;
camera
。
cullingMask
=
Layer
。
Layer0
;
}
onEndRender
(
camera
:
Camera
)
:
void
{
camera
。
renderTarget
=
null
;
// 繪製到螢幕上,並在screenMaterial裡面進行描邊後處理
camera
。
cullingMask
=
Layer
。
Layer1
;
// 再繪製一遍,只繪製全屏紋理
camera
。
render
();
}
}
// 建立全屏 Plane, 用來實現全屏後處理特效。
const
screen
=
rootEntity
。
createChild
(
“screen”
));
const
screenRenderer
=
screen
。
addComponent
(
MeshRenderer
);
screen
。
layer
=
Layer
。
Layer1
;
screenRenderer
。
mesh
=
PrimitiveMesh
。
createPlane
(
engine
,
2
,
2
);
// 自定義材質,shader 裡面執行邊緣檢測演算法。
const
material
=
this
。
getScreenMaterial
(
engine
);
screenRenderer
。
setMaterial
(
material
);
const
renderColorTexture
=
new
RenderColorTexture
(
engine
,
width
,
height
);
const
renderTarget
=
new
RenderTarget
(
engine
,
width
,
height
,
renderColorTexture
);
// 傳入預設管線得到的離屏紋理。
material
。
shaderData
。
setTexture
(
“u_texture”
,
renderColorTexture
);
// 新增指令碼
const
screenRenderer
=
screen
。
addComponent
(
MeshRenderer
);
screenRenderer
。
renderTarget
=
renderTarget
;
可以看到,我們建立了一個掩碼為 Layer1 的平面, 只用來渲染一個
全屏平面
,如果直接渲染離屏紋理,看到的效果就是場景直接渲染在螢幕上,但是我們想要描邊效果,就需要對這張紋理做一點演算法處理。
3. sobel 邊緣檢測
邊緣檢測的方法很多,這裡用 sobel 卷積因子來舉例。我們把後處理中的每一個畫素亮度分別進行縱向、橫向的卷積運算,即每個畫素的周邊9個畫素亮度分別乘以下圖這些權重:
G_x
G_y
黃色和紅色為分別對應的權重,可以看到,如果一個畫素的周邊訊號(如亮度)變化越陡峭,則卷積結果越大,我們可以利用這個特性來檢測邊緣,因為邊緣那幾個畫素的亮度變化都是比較陡峭的。
4. shader 程式碼
基於 sobel 的邊緣檢測演算法,我們可以透過離屏紋理,繪製描邊效果,shader 程式碼如下:
// 描邊顏色
uniform
vec3
u_color
;
// 離屏紋理
uniform
sampler2D
u_texture
;
// 紋素大小
uniform
vec2
u_texSize
;
varying
vec2
v_uv
;
// 對應畫素的亮度
float
luminance
(
vec4
color
)
{
return
0。2125
*
color
。
r
+
0。7154
*
color
。
g
+
0。0721
*
color
。
b
;
}
// 前面提到的 sobel 卷積運算,得到陡峭程度。
float
sobel
()
{
// sobel 卷積因子
float
Gx
[
9
]
=
float
[](
-
1。0
,
0。0
,
1。0
,
-
2。0
,
0。0
,
2。0
,
-
1。0
,
0。0
,
1。0
);
float
Gy
[
9
]
=
float
[](
-
1。0
,
-
2。0
,
-
1。0
,
0。0
,
0。0
,
0。0
,
1。0
,
2。0
,
1。0
);
float
texColor
;
float
edgeX
=
0。0
;
float
edgeY
=
0。0
;
vec2
uv
[
9
];
// 周邊的9個畫素的紋理座標。
uv
[
0
]
=
v_uv
+
u_texSize
。
xy
*
vec2
(
-
1
,
-
1
);
uv
[
1
]
=
v_uv
+
u_texSize
。
xy
*
vec2
(
0
,
-
1
);
uv
[
2
]
=
v_uv
+
u_texSize
。
xy
*
vec2
(
1
,
-
1
);
uv
[
3
]
=
v_uv
+
u_texSize
。
xy
*
vec2
(
-
1
,
0
);
uv
[
4
]
=
v_uv
+
u_texSize
。
xy
*
vec2
(
0
,
0
);
uv
[
5
]
=
v_uv
+
u_texSize
。
xy
*
vec2
(
1
,
0
);
uv
[
6
]
=
v_uv
+
u_texSize
。
xy
*
vec2
(
-
1
,
1
);
uv
[
7
]
=
v_uv
+
u_texSize
。
xy
*
vec2
(
0
,
1
);
uv
[
8
]
=
v_uv
+
u_texSize
。
xy
*
vec2
(
1
,
1
);
for
(
int
i
=
0
;
i
<
9
;
i
++
)
{
// 計算下對應紋素的亮度
texColor
=
luminance
(
texture2D
(
u_texture
,
uv
[
i
]));
// 橫向卷積
edgeX
+=
texColor
*
Gx
[
i
];
// 縱向卷積
edgeY
+=
texColor
*
Gy
[
i
];
}
return
abs
(
edgeX
)
+
abs
(
edgeY
);
}
void
main
(){
float
sobelFactor
=
sobel
();
// sobel 越大,說明陡峭程度越大,越接近描邊顏色。
gl_FragColor
=
mix
(
texture2D
(
u_texture
,
v_uv
),
vec4
(
u_color
,
1。0
),
sobelFactor
);
}
得到渲染結果如下:
結語
本篇所有相關程式碼和效果可以透過 Playground 檢視。
截至 Oasis 0。6,Oasis 還沒有封裝相應的全屏後處理元件和 shader 多 pass 能力,使用者需要建立多個渲染器,或者建立離屏紋理、指令碼、全屏Plane ,稍微有點不方便,我們在未來會封裝相應的能力,使使用者能夠專注於演算法實現,簡化流程步驟。
描邊實現還有很多方法和細節,比如鏡頭空間的
描邊粗細
,描邊的
鋸齒
問題,描邊的
破面
問題等等,一般可以針對具體的應用場景選擇適合的方法進行實現或者定製。
最後
為了給廣大的 Oasis 引擎使用者提供更高效、更便捷的工作流,編輯器對外開放是我們 Oasis 團隊的核心重點之一。為了更好的服務大家,我們準備了一份關於 “互動圖形引擎調研” 的問卷 (
幸運使用者將獲得我們準備的精美獎品
),期待您的反饋~
上一篇:日光很厲害,暴曬要謹慎!
下一篇:聊一下關於創作的瓶頸