當前位置:
首頁 > 科技 > 基於2D SDF的體積字實現

基於2D SDF的體積字實現

原標題:基於2D SDF的體積字實現



這是侑虎科技第438篇文章,感謝作者燃野供稿。歡迎轉發分享,未經作者授權請勿轉載。如果您有任何獨到的見解或者發現也歡迎聯繫我們,一起探討。(QQ群465082844)


作者主頁:https://www.zhihu.com/people/yesbaba/posts


作者也是U Sparkle活動參與者,UWA歡迎更多開發朋友加入U Sparkle開發者計劃,這個舞台有你更精彩!

概述

筆者很久之前就想做體積字,並認為在VR中應該是標配,但直到今天也沒流行起來,不過倒是看到了Clay Book、Nex Machina這種基於3D SDF體渲染的炫酷遊戲。筆者最近為項目加入TextMeshPro,正好基於TMP嘗試一下,直觀效果見上面的題圖。文字形狀不是基於字形Mesh的,而是Signed Distance Field的2D圖,模型只是Cube,用於提供Pixel Shader的執行來通過Sphere Ray Marching的方式計算屏幕像素是否在體積字內。優點是對顯存的佔用很低,一個字形只用幾K位元組,64x64的A8是4K位元組,32x32的效果也是可接受的,只計算形狀並對正面側面分別指定顏色的話計算量不大,且主要消耗在文字側面的計算上,降低文字厚度和調整視角可以減少計算量。複雜的費性能的效果也可以往上加,可以用Triplanar mapping的方式給前後面和側面加貼圖,通過SDF下降梯度能算出側面法線,整個體積字可以進行光照計算,正面和側面可以進行圓角的形狀和法線過渡,更進一步計算任一像素的切線空間可以加入法線貼圖,之後就支持各種常規表面材質了。


TMP Shader的作者之一(好像是負責寫Shader)sschaem在2014年就做過的體積字效果,社區反響不強烈,加之別的功能需求更重要一直沒加進去,效果更牛的VRTFX (像是VR Text FX的意思)一直沒見發布,參考:


https://forum.unity.com/threads/wip-vrtfx-volumetric-rendering-titling-effects.440048


現在TMP組件里雖然有「Enable Volumetric Setup」,也只是把2D片變成了Cube,仍然沒有Shader支持。其實在現有TMP的基礎上實現體積字還是挺容易的。本文主要講述形狀計算、字體法線、表面紋理、形狀法線和法線紋理,並討論下相關問題,歡迎大家指正。瀏覽本文前最好對SDF和raymarching有所了解,比如看這2篇文章:


Volumetric Rendering - Alan Zucconi


https://www.alanzucconi.com/2016/07/01/volumetric-rendering


Ray Marching and Signed Distance Functions


http://jamie-wong.com/2016/07/15/ray-marching-signed-distance-functions

一、形狀計算

1.1 SDF數據


文字輪廓用SDF 2D貼圖存儲,裡面每個Texel存的是到最近的文字輪廓的距離,無論是什麼方向,輪廓外為負值,輪廓內為正值,運行時對於任意UV通過雙線性插值仍然能算出比較精確的距離。在TMP中採用A8格式存儲,輪廓是0.5,小於0.5是外側,從0到0.5對應Padding個像素範圍,Padding越大則基於邊緣的效果範圍就越大,生成字體文件時也就更費計算。


如上圖,TMP自帶SDF字體是用1024x1024,每個字形精度是86像素,padding是9,算下來就是每個字元有86+9x2的像素精度。



灰色漸變部分表示的就是距離


1.2 體和坐標系


2D SDF拉出一個厚度能表示一個3D場,可以對應一個Cube模型,長寬高可以不相等,「顯然」於模型內的任一點都能算出是否屬於體積字之內。TMP生成的2D片局部坐標都是在XY平面上的,正面朝向負Z,見下圖:



坐標系,順便看下PointSize 29, Padding 2的漢字效果


在局部坐標系正Z的方向上加入字元的另外4個點,組成Cube,請記住這個坐標系便於以後理解,XYZ對應紅綠藍。


1.3 大概演算法

體積字的每一個像素點都包含在Cube中,所以從Cube表面每個像素點對應的坐標開始,延視線方向進行RayMarching,步長是跟當前坐標在SDF圖裡的對應值相關的,SDF里記錄了到最近輪廓的距離,但沒有方向,如果距離輪廓足夠近則認為到達體積字表面,否則採用新的步長繼續Marching。



上圖為Sphere raymarching示意,居然是GPU Gem2 Ch8的圖,好久遠。


如果已經March到了Cube Bound外部,就認為是在體積字外部的。判斷內外部的時候最好在cube的局部坐標系裡,平移後使左下角頂點為原點,其他點都在XYZ的正方向,對於任一點p,p * (bound - p).xyz有小於0的分量就是在外部,需要被Clip掉。一個TMP組件里可能包含多個文字,它們都在一個局部坐標系裡,每個字元都得轉到字元局部的坐標系計算。


如果用光了所有步長還不夠接近,也認為是到達體積字表面,一般是因為視角近似平行於輪廓表面,造成步長太短,起始已經很接近輪廓了。


對於位置p需要轉到為SDF貼圖的UV來採樣距離值,假設Cube右上、左下點的坐標和uv分別為posTR, posBL, uvTR, uvBL,只關心xy,不關心z


那麼uv = (p.xy - posBL) / (posTR - posBL) * (uvTR - uvBL) + uvBL;



模型坐標p到SDF uv的轉換示意圖


定義sdfUvScale = (posTR - posBL) * (uvTR - uvBL)

在得到SDF距離後需要轉換為cube中的距離,看TMPshander中padding + 1個像素對應的是0-0.5的SDF距離值,padding+1被定義為_GradientScale,那麼SDF距離可以轉換為uv範圍,進一步轉換模型距離:


sdf2len = 2 * _GradientScale / (max(_TextureWidth * sdfUvScale.x, _TextureHeight * sdfUvScale.y))


1.4 Shader中所需的單個字形的數據


在高版本的OpenGL和DirectX中可以為每個頂點指定InstanceId,一個字形Cube的8頂點對應一個實例,字形數據存在Instance相關的buffer中。如果不支持這種特性就得每個頂點都存儲字形數據,通過Normal、Tangent、UV通道傳到Shader中,本文就是這麼做的。


採樣SDF貼圖需要對LocalPos.xy縮放和偏移,共4個Float。採樣Diffuse等紋理貼圖(TMP叫face)也需要4個Float,同時希望少改點TMP代碼,通過Vertex, Normal, Tangent, Color, uv0, uv1把所需數據全傳過去,就會發現不夠用了,必須得把2個Float Pack到一個Float里,Float是7位有效數字,表示UV或字元像素寬高是足夠的,我的做法是sdfUvScale轉為Texel寬高Pack進tangent.z,face uv scale只Pack UV寬高到tangent.w,在VS進行Unpack。另外uv1.x是被TMP pack的UV,uv1.y是TMP用的Scale,我還沒用到,為了兼容性留著。


在VS中要做的事情有:


把頂點轉換到字元局部坐標系,存在cuboidLocalPos里,經過光柵化插值後成為RayMarching的起點。


unpack posBLAndUvFactor,算出sdfUvScaleOffset ,給定字元局部坐標p,可以得到sdfUV = p.xy * sdfUvScaleOffset.xy + sdfUvScaleOffset.zw。FaceUV同理。


計算視線方向,PS用插值結果。這裡遇到一個問題是想同時支持透視和正交相機,Unity並沒有提供這樣的函數,UnityCG.cginc里的UnityWorldSpaceViewDir只能算透視viewDir。我覺得應該頂點局部坐標在Clip Space下在近平面的投影轉回局部坐標系做差值算方向,但是發現又沒有InvMVP矩陣,現算Inverse矩陣感覺有點費,索性就把2個方向都算出來在用01插值吧,如果有好的方法請您告訴我。


VS還是比較簡單的,一個字元8個頂點,計算量也不大,PS里的重複計算可以移動到VS里來算。



1.5 PS里算RayMarching


根據上面提到的演算法寫RayMarching即可,input.cuboidLocalPos已經是字元局部坐標系下的點了,字元Cube的左下角在局部坐標系的原點。這裡對於文字的正反面,在第一次採樣SDF後就能Return,不會進行多次for。對於側面先走步長再Clip,盡量提前剔除掉,對於視角比較接近正面,厚度不大的情況下只用執行少量幾次。_outlineEpsilon是用於微調到達輪廓的條件,_outline表示邊界的SDF值,默認0.5。最後用執行for循環的次數給文字上灰度色,顏色越深用的循環次數越多。





_loopCount為10,_outlineEpsilon為0的情況



_loopCount為10,_outlineEpsilon為0.01的情況

調一下_outlineEpsilon立馬好很多,黑色部分都是用完for循環也沒到達邊緣,都是視角跟切線比較接近的情況。


這裡有一個性能相關的問題沒想清楚,在PS里執行分支提前Return、Clip,在GPU執行的時候並不是每個像素的PS執行獨立的分支,而是一組一起執行,如果condition相同那就不用執行另一個分支了,如果condition不同就得把if else都執行了,甚至空等某個PS的執行。這裡的組是2x2的像素,還是GPU實現的Warp什麼的?對我寫分支該如何取捨,比如是把A、B都算出來用01 lerp進行取捨好,還是用if else好呢?



_loopCount為20,_outlineEpsilon為0.01的情況



正交反面加厚不加大_loopCount為20,_outlineEpsilon為0.01的情況。左下角好像兒時見過的鋁合金門窗


現在最簡單的體積字就算完了,再基於局部z值上個色。



_loopCount為10,_outlineEpsilon為0.01的情況

二、字體法線

要有光照計算的話必須得有法線,正反面法線很簡單就是負Z和正Z,側面法線就是SDF值的下降梯度。


z方向沒變化為0,只算xy方向SDF值的梯度即可,_SideNormalSampleDelta用於微調計算梯度的Delta。




帶法線的BlinnPhong光照,正面和側面交接處有鋸齒


2.1 字形法線的圓角過渡


正面和側面交界處法線是不連續的,所以光照下有鋸齒,那麼對法線做一個圓角過渡應該就可以了,用下圖做圓角的分析。



俯視圖,X是向輪廓內部


俯視圖,OCD是正面,OBA是側面,O點是交界處,字形輪廓的點都在ox和oz軸上,還沒做字形的圓角過渡。現在希望進行半徑R的法線過渡。因為已經有了正面和側面法線,算出一個權重進行過渡比較好,假設要算B點的法線,B的坐標(0.5R,R)表示Z方向上距離過渡邊界A點是0.5R,輪廓方向上距離過渡邊界的D點的距離是R,AD和BE的焦點是G,理論上用DG/AD做側面法線的權重最好,但是要算各種三角函數,或者泰勒級數展開取前幾項,麻煩又費計算。用BED的夾角做權重,看似挺好,其實不對,EA向量和ED向量的線性插值結果的終點都在AD線段上,角度線性變化對應DG長度並不是線性變化的,在兩頭變化快,在中間變化慢,這是被否定的方法。

最簡單直觀,也是我一開是就想到的是用B.y / (B.x + B.y)來做權重,效果如下:



發現過渡的效果,紅框內已經沒有鋸齒了


遠看效果還湊合,近看會看到過渡不自然,如下圖左側,是因為B.y / (B.x + B.y)的過渡也不是線性的,同樣兩邊變化快,中間變化慢。比如(0,1)到(0.1,1)變化0.1/1.1≈0.091, (0.9,1)到(1,1)變化0.026。簡單的方法是對B求平方之後在算權重,雖然仍然不是線性變化,但是兩頭變化慢,中間變化快,效果還是很不錯的,見下圖右側。



發現過渡對比




從左到右加大圓角,出現瑕疵


圓角設置太大會出現瑕疵,一是因為Padding不夠大,而是因為2倍半徑超過文字筆觸寬度。採用大Padding的英文會好一些。

三、表面紋理

前後面的表面紋理很簡單,跟採樣SDF一樣,就是多了個tiling&offset。



側面要加紋理得用Triplanar Mapping方式,上下面用localPox.xz做uv採樣,左右面用localPos.yz,最後用側面法線做權重。




triplanar diffuse mapping


6面紋理看起來還算不錯,但是在正面和側面邊界有很硬的過渡,因為形狀上還沒做圓角過渡。


四、形狀的圓角過渡

IQ大神的這篇文章里有各種形狀的SDF公式:


https://iquilezles.org/www/articles/distfunctions/distfunctions.htm


其中:




算出正數表示在外部,在b表示的長方體邊界向外擴展,在8個角會擴張為球面,這個公式的意思是說把p中在b外部的分量拿出來求到b邊界的距離,大於r算外部。


對於體積字來說沒法向外擴展,只能把邊界往裡算一些,擴張到當前邊界。現在_outline的意思就相當於上面公式的b,並且有模型半徑R = _outline * bound.w, bound.w是把SDF值轉為模型距離的係數。看下圖考慮A、B、C三點對應上面公式中abs(p)-b,是什麼


A點都在兩條邊界外側,A = (R, R - z) = (R, z到中間Z的距離-R到中間Z的距離)=


(R, abs(z - halfZ) - (halfZ - R)),這是為了支持反面圓角的情況。


B在x方向屬於外側,在z方向屬於內側。B = (R, R - z) = (R, abs(z - halfZ) - (halfZ - R))


C在x方向屬於內側,在z方向屬於外側,C = (-C.x到outline的距離, R) = ((_outline - sdfValue) * bound.w, R)


D都屬於外側,D = ((_outline - sdfValue) * bound.w, R)



俯視圖



此時viewDir不用除以xy屏幕投影長度,距離直接當步長。新的raymarching如下:




此時採樣diffuse貼圖就沒有硬邊了,下圖右邊是圓角過渡的:


五、法線紋理

法線紋理的數據是在切線空間下的,需要轉換到字元局部空間。想了解切線空間和法線可以看這篇文章:


https://catlikecoding.com/unity/tutorials/rendering/part-6


對於文字正面在局部坐標系中,法線N是(0, 0, -1),切線T用正X方向(1, 0, 0) 也是uv.x的方向, binormal B用正Y方向(0, 1, 0)也是uv.y的方向,切線空間的法線X轉換到局部坐標系就是:



對於側面的情況,因為用localPos.yz做uv,所以N=(1, 0, 0), T= (0, 1, 0), B=(0, 0, 1);轉換到局部坐標系:



對於上下面用xz做uv,同理:



用跟Diffuse一樣的方式,用模型的sideNormal混合上下和左右的紋理法線,再跟前後面做混合,得到看似正確的結果,這種混合似乎跟Ground Truth還有差距,目前先這樣。效果如下:


六、其他問題

1、抗鋸齒:由於MSAA是基於光柵化的,三角形邊界並不是文字形狀的邊界,所以無效。基於後期的AA應該是可以的。還設想過識別文字形狀周圍的1像素做alpha混合來實現AA,外輪廓效果應該可以,如下圖綠框中的。但就跟透明有類似的排序問題了,且文字之間的邊緣原沒法處理,如下圖紅框中的:



2、遮擋:因為像素的深度值是文字Cube的邊界,並不是模型的,一旦有模型穿插到文字體內會不正確,如下圖,藍色部分可見白色cube已經很接近文字cube的前面,但是紅框中文字卻沒被遮擋。PS里寫深度、RayMarching里每步測深度、調整渲染順序應該能解決。



3、陰影:ShadowMap仍然能適用,需要Shadow Caster的Shader也用SDF raymarching的寫法的到輪廓。一個TMP文字專門的Shader Receiver可以同樣實現Soft Shadow,但很難同時通用的處理多個文字的陰影。


4、字形過渡:SDF一大特性是能做一個形狀到另一個形狀的過渡,就是對SDF距離進行過渡,對於本例的文字來說要額外指定另一個字元的UV信息,如果文字Cube大小不一樣也需要變化。


5、斜體:目前的機制沒處理好斜體,如何變換到字元局部坐標系的問題。



6、下劃線也不支持,還沒研究TMP是怎麼弄的。


文末,再次感謝燃野的分享,如果您有任何獨到的見解或者發現也歡迎聯繫我們,一起探討。(QQ群:465082844)。


也歡迎大家來積极參与U Sparkle開發者計劃,簡稱"US",代表你和我,代表UWA和開發者在一起!

喜歡這篇文章嗎?立刻分享出去讓更多人知道吧!

本站內容充實豐富,博大精深,小編精選每日熱門資訊,隨時更新,點擊「搶先收到最新資訊」瀏覽吧!


請您繼續閱讀更多來自 侑虎科技 的精彩文章:

UWA助力小米VR打造內容生態
CSV配置文件的優化策略

TAG:侑虎科技 |