Spark AR 實作 MSDF Shader (下)

Lastor
Code 隨筆放置場
10 min readJun 25, 2020
Make MSDF shader in Spark AR

繼上一篇 「Spark AR 實作 SDF Shader (上)」 之後,接著來聊聊 MSDF。

MSDF 即多通道的 SDF (Multi-Channel Signed Distance Field)。原本 SDF 僅用灰階色單通道來計算,在圖片小於 32 x 32 之後,效果就明顯變差了。

多通道的 MSDF 是 SDF 加強版。依靠 RGB 三通道來交互判斷,就能判定的更加精確,但相比 SDF 前置準備也更為麻煩。原理的部分也比 SDF 更加複雜,所以個人直接放棄理解。

反正大方向只要知道,MSDF 是把原始的 SVG 向量圖檔加密變成一張混有 RGB 三色的謎之圖像,要使用時再透過演算法把圖像給解密回來,大概是這樣的概念。

生成 MSDF 圖檔

與 SDF 不同的是,要生成 MSDF 就僅能透過程式手段生成。

一樣透過 msdfgen 來操作 command line,將 SVG 圖檔餵給程式後即可生成 MSDF 圖檔。輸入的指令,只需將 mode 從 sdf 改成 msdf 即可。

$ msdfgen.exe msdf -svg "C:\source.svg" -o output.png -size 64 64 -pxrange 4 -autoframe

設定上的說明可以參考上一篇 SDF 文章的說明。這邊簡單在 Photoshop 畫了一個 SVG 來生成 MDSF。

SVG to MSDF

另外,也有些大神製作了 Web 版的 Generator 供大家使用。如果只是要生成文字的話,可以使用這個 MSDF font generator

如果是要自己製作 SVG 幾何圖來轉換的話,可以使用這個 MSDF GUI by Data Sapiens

實作 MSDF Shader (Script)

這個部分比起 SDF 要複雜的多,主要是將 msdfgen 上的使用範例,改寫成 Spark AR 的 JavaScript。

GLSL 使用範例:

in vec2 pos;
out vec4 color;
uniform sampler2D msdf;
uniform float pxRange;
uniform vec4 bgColor;
uniform vec4 fgColor;

float median(float r, float g, float b) {
return max(min(r, g), min(max(r, g), b));
}

void main() {
vec2 msdfUnit = pxRange/vec2(textureSize(msdf, 0));
vec3 sample = texture(msdf, pos).rgb;
float sigDist = median(sample.r, sample.g, sample.b) - 0.5;
sigDist *= dot(msdfUnit, 0.5/fwidth(pos));
float opacity = clamp(sigDist + 0.5, 0.0, 1.0);
color = mix(bgColor, fgColor, opacity);
}

不過個人還不會 OpenGL,這一串 GLSL 實在看不太懂。於是就 Google 找找看有沒有 Unity 版本的,一查就查到了一篇日本文章,有分享 Unity C# 的改寫版。

UnityでMSDFを使ったShaderによるベクター画像の描画について

這篇文章前面簡單介紹了 MSDF 是個甚麼概念,後面就單純的講如何在 Unity 裡面進行實作。

這邊節錄該文章中的部分 C# 代碼,首先開頭 Properties 部分,是對應 UI 面板的 Input 屬性,讓 User 可以自行輸入。

分別開了 4 個變數作為 Input 接口,可以輸入 MSDF 貼圖檔、前景色、背景色以及最後一個是啟用的開關。

// Unity C#
Properties
{
[NoScaleOffset]_MainTex("MSDF Texture", 2D) = "white" {}
[HDR]_Color_0("Color 0", Color) = (1,1,1,0)
[HDR]_Color_1("Color 1", Color) = (0,0,0,1)
[Toggle]_Show_Original_Texture("Show Original Texture", Float) = 0
}

中間則是 Unity 本身的一些設定,直接跳過。重點是後面 CGPROGRAM 開始的地方。

CGPROGRAM
#pragma vertex vert_img
#pragma fragment frag
#pragma shader_feature _SHOW_ORIGINAL_TEXTURE_ON#include "UnityCG.cginc"uniform sampler2D _MainTex;
uniform float4 _Color_0, _Color_1;
float median(float3 col)
{
return max(min(col.r, col.g), min(max(col.r, col.g), col.b));
}
float fwidth(float2 p)
{
return abs(ddx(p)) + abs(ddy(p));
}
float4 frag(v2f_img i) : SV_Target
{
float3 tex = tex2D(_MainTex, i.uv);
float dist = median(tex) - .5;
float sigDist = fwidth(dist);
float opacity = smoothstep(-sigDist, sigDist, dist);
float4 o;
#ifdef _SHOW_ORIGINAL_TEXTURE_ON
o = float4(tex, 1);
#else
o = lerp(_Color_0, _Color_1, opacity);
#endif
return o;
}

雖然個人不會 C#,但查一些資料之後大概可以知道關鍵在於 line 3 的這句。

#pragma fragment frag

這句是告訴 Unity,fragment shader 的 function 名稱叫做 frag。也就是這支程式的主 function 是 frag()。其他兩個 median() 與 fwidth() 則是輔助計算用的 function。

看懂之後,就可以開始改寫成 JavaScript 了。這邊用 Spark AR v90 開始提供的 ES7 async function 來寫,因為內容比較長,建議轉貼到 VSCode 之類的編輯器中觀看。(省略 require module)

// JavaScript
function median(tex) {
// get rgb signal from texture
const { x: r, y: g, z: b } = tex.signal
const min = Reactive.min.bind(Reactive)
const max = Reactive.max.bind(Reactive)
return max(min(r, g), min(max(r, g), b))
}
function fWidth(dist) {
return Reactive.dot(dist, dist)
}
async function init() {
try {
// query material and texture from Scene
const assets = await Promise.all([
Materials.findFirst('msdf_script_mat'),
Textures.findFirst('svg_icon')
])
const [mat, tex] = assets
// specifies background and foreground color
const bgColor = Reactive.pack4(1, 1, 1, 0)
const fgColor = Reactive.pack4(0, 0, 0, 1)
// msdf shader
const dist = median(tex).sub(0.5)
const sigDist = fWidth(dist)
const opacity = Reactive.smoothStep(
dist, sigDist.mul(-1), sigDist
)
const shader = Reactive.mix(bgColor, fgColor, opacity)
// assign shader to material
mat.setTextureSlot('DIFFUSE', shader)
} catch (e) {
Diagnostics.log(e.message)
}
}
init()

基本上就是直接把 Unity C# 的版本再翻譯成 SparkAR JavaScript。其中多出了 query 場景內容的部分。

比較要注意的是,fWidth() 的內容,Unity 那邊是分別用了 ddx() 跟 ddy() 然後取絕對值。但我不是很理解 ddx、ddy 背後是怎麼計算的。回頭去看 GLSL 的版本,發現這部分有做一個算內積 (Dot Product) 的動作。

於是,嘗試直接把 dist 代入做內積,沒想到居然成功了......

最後呈現這樣的效果。

左:最後輸出結果,右:MSDF 64 x 64原圖
放很大也很清晰

使用 Patches Editor 來實作

既然 Script 能成功做出來,使用的也都是 Patches 有提供的 Node,理所當然可以直接轉移到 Patches 上面,拯救程式苦手的眾生。

我這邊將最終完成的 Patch 給打包成這樣的感覺。

然後一步一步看,首先是要計算 dist 的部分,也就是 median() 這一塊。這邊單純的就是將 RGB 訊號取一波最大最小值,也就是 script 的這一塊。

function median(tex) {
// get rgb signal from texture
const { x: r, y: g, z: b } = tex.signal
const min = Reactive.min.bind(Reactive)
const max = Reactive.max.bind(Reactive)
return max(min(r, g), min(max(r, g), b))
}
const dist = median(tex).sub(0.5)

接下來將 dist 的結果,算內積後求出 sigDist,再一併將 dist、負 sigDist 與正 sigDist 輸出給 Smooth Step,來求 opacity。

const sigDist = fWidth(dist)
const opacity = Reactive.smoothStep(dist, sigDist.mul(-1), sigDist)

所需的值都拿到之後,最後再完成 script 的最後一段,

const shader = Reactive.mix(bgColor, fgColor, opacity)
mat.setTextureSlot('DIFFUSE', shader)

接給 Mix Patch 就大功告成了 !!

--

--

Lastor
Code 隨筆放置場

Web Frontend / 3D Modeling / Game and Animation. 設計本科生,前遊戲業 3D Artist,專擅日本動畫與遊戲相關領域。現在轉職為前端工程師,以專業遊戲美術的角度涉足 Web 前端開發。