精靈涂鴉效果在過去幾年逐漸流行起來,《GoNNER》和《Baba is You》等游戲大量使用了這種美術(shù)效果。
本文將展示在無需繪制多個(gè)不同圖像的情況下,如何實(shí)現(xiàn)精靈涂鴉效果。本文將介紹從Unity著色器編程的基礎(chǔ)到所應(yīng)用的數(shù)學(xué)原理等所有必要知識(shí)。
引言 本文會(huì)涉及一些比較高級(jí)的話題,包括:反向運(yùn)動(dòng)學(xué)的數(shù)學(xué)原理和大氣的瑞利散射效果。但是既對(duì)這些內(nèi)容感興趣,又有理解所需的必備技術(shù)知識(shí)的開發(fā)者其實(shí)并不多。 在游戲開發(fā)者Nick Kaman的一則推文中,他展示了如何在Unity實(shí)現(xiàn)涂鴉效果。 Nick Kaman:我想分享一個(gè)在Unity實(shí)現(xiàn)“涂鴉”效果的技巧: 我們不必繪制相同精靈的不同幀,我們可以把精靈放到網(wǎng)格,然后使用法線貼圖偏移頂點(diǎn)即可,該法線貼圖會(huì)每秒X次大幅進(jìn)行滾動(dòng)。 這篇推文獲得大量的點(diǎn)贊和轉(zhuǎn)發(fā),我們發(fā)現(xiàn),讓即使沒有著色器編程知識(shí)的人也可以理解的簡(jiǎn)單教程是很有必要的。 如果想要制作2D精靈動(dòng)畫的專業(yè)而高效的方法,并且需要完整的藝術(shù)級(jí)控制功能,你可以使用Doodle Studio 95!資源。 獲取Doodle Studio 95!: https://fernandoramallo.itch.io/doodle-studio-95 下面是使用Doodle Studio 95!時(shí)的動(dòng)畫圖片。 涂鴉效果的剖析 為了實(shí)現(xiàn)涂鴉效果,我們首先需要理解實(shí)現(xiàn)原理以及使用了哪些技術(shù)。 著色器效果 首先,我們想要涂鴉效果盡可能輕量,不使用任何額外腳本。我們可以通過著色器實(shí)現(xiàn)這種效果,指導(dǎo)Unity在屏幕上渲染3D模型或者平面模型 精靈著色器 Unity提供了多種著色器的類型,如果使用Unity提供的2D工具,開發(fā)者可能想要處理精靈。在這種情況下,你需要使用精靈(Sprite)著色器,它是一種特殊類型的著色器,與Unity的SpriteRenderer兼容。此外,你也可以使用較為傳統(tǒng)的Unlit著色器。 頂點(diǎn)替換 在手動(dòng)繪制精靈時(shí),不會(huì)有相同的兩個(gè)幀。我們想要通過使精靈進(jìn)行“搖晃”,模擬出這種效果。使用著色器有一種非常高效的實(shí)現(xiàn)方法,該方法需要使用頂點(diǎn)替換功能。這種方法可以修改3D對(duì)象的頂點(diǎn)位置。如果隨機(jī)變化這些位置,我們就可以實(shí)現(xiàn)想要的效果。 對(duì)齊時(shí)間 手繪動(dòng)畫通常有較低的幀率,如果我們想要模擬出諸如每秒5幀的畫面,我們需要每秒5次修改精靈的頂點(diǎn)位置。但是,Unity可能會(huì)在更高刷新速率下運(yùn)行游戲,可能會(huì)有每秒30幀或60幀的幀率。為了確保我們的精靈不以每秒60次的速度發(fā)生變化,我們需要處理動(dòng)畫的時(shí)間組件。 擴(kuò)展精靈著色器 如果想要在Unity創(chuàng)建新的著色器,我們可以使用Unlit Shader,盡管它不一定是特定應(yīng)用程序的最佳選擇。 如果想讓涂鴉著色器完全兼容Unity的SpriteRenderer,我們需要擴(kuò)展它的現(xiàn)有精靈著色器。但是,在Unity中無法直接獲取該著色器。 獲取該著色器的方法是:訪問Unity下載存檔頁面,下載正在使用Unity版本的Build in shaders資源包,該Zip壓縮文件包含特定Unity版本推出的所有著色器源代碼。 下載Build in shaders資源包: https://unity3d.com/get-unity/download/archive 下載完成后,提取文件,然后在builtin_shaders-2018.1.6f1\DefaultResourcesExtra文件夾內(nèi)找到Sprites-Diffuse.shader文件,它就是我們?cè)诒疚闹行枰褂玫奈募?/span> 如果Sprites-Diffuse文件不是默認(rèn)的精靈著色器,該怎么辦? 在創(chuàng)建新的精靈時(shí),默認(rèn)材質(zhì)使用的著色器名為Sprites-Default.shader,而不是Sprites-Diffuse.shader。 兩者的區(qū)別在于:前者是無光著色器,而后者會(huì)對(duì)場(chǎng)景的光線做出反應(yīng)。由于Unity的實(shí)現(xiàn)方法,相對(duì)無光著色器,漫反射著色器可以更簡(jiǎn)單地進(jìn)行編輯。 頂點(diǎn)替換功能 在Sprites-Diffuse.shader文件中,有一個(gè)稱為vert的函數(shù),它就是之前提到的頂點(diǎn)函數(shù)。它的名稱并不重要,只要它符合#pragma指令的“vertex: ”部分內(nèi)的名稱即可。 #pragma surface surf Lambert vertex:vert nofog nolightmap nodynlightmap keepalpha noinstancing 簡(jiǎn)單來說,頂點(diǎn)函數(shù)會(huì)在3D模型的每個(gè)頂點(diǎn)調(diào)用,并決定如何在2D屏幕空間進(jìn)行映射。對(duì)于本文而言,我們僅對(duì)理解如何替換對(duì)象感興趣。 參數(shù)appdata_full v包含名為vertex的字段,該字段包含對(duì)象空間中每個(gè)頂點(diǎn)的3D位置,修改它的數(shù)值會(huì)移動(dòng)頂點(diǎn)。 例如:下面的代碼會(huì)使用該著色器把對(duì)象沿著X軸平移一個(gè)單位。 void vert (inout appdata_full v, out Input o) { v.vertex = UnityFlipSprite(v.vertex, _Flip); v.vertex.x += 1; #if defined(PIXELSNAP_ON) v.vertex = UnityPixelSnap (v.vertex); #endif UNITY_INITIALIZE_OUTPUT(Input, o); o.color = v.color * _Color * _RendererColor; } 默認(rèn)情況下,使用Unity制作的2D游戲僅處理X軸和Y軸,因此我們需要修改v.vertex.xy,從而在2D平面上移動(dòng)精靈。 什么是對(duì)象空間? 結(jié)構(gòu)appdata_full的vertex字段包含著色器在對(duì)象空間處理的當(dāng)前頂點(diǎn)位置,如果對(duì)象處于游戲世界的中心點(diǎn)即(0,0,0)坐標(biāo),它就是該對(duì)象未經(jīng)過縮放和旋轉(zhuǎn)時(shí)頂點(diǎn)的位置。 相對(duì)地,在世界空間表示的頂點(diǎn)會(huì)反映頂點(diǎn)在Unity場(chǎng)景內(nèi)的實(shí)際位置。 為什么對(duì)象不會(huì)以每幀1米的速度移動(dòng)? 如果對(duì)C#腳本的Update方法內(nèi)transform.position的x部分加1,我們會(huì)看到對(duì)象以每幀1個(gè)單位速度飛行,換算的速度約為每小時(shí)216千米。 發(fā)生這種情況是因?yàn)镃#對(duì)位置的改動(dòng)會(huì)改變位置本身。在頂點(diǎn)函數(shù)中,這種情況不會(huì)發(fā)生,著色器僅會(huì)改變模型的視覺效果,但不會(huì)更新或改變模型上已保存的頂點(diǎn),因此給v.vertex.x添加+1僅會(huì)每次移動(dòng)對(duì)象1米的距離。 別忘了以Tight類型導(dǎo)入精靈。 該效果會(huì)替換精靈上的頂點(diǎn)。傳統(tǒng)情況下,精靈會(huì)作為四邊形(即下圖左側(cè))導(dǎo)入U(xiǎn)nity。這意味著精靈僅有4個(gè)頂點(diǎn)。如果是這樣,只有這些頂點(diǎn)可以進(jìn)行移動(dòng),從而會(huì)減少涂鴉效果的總體強(qiáng)度。 為了實(shí)現(xiàn)更為緊密和逼真的扭曲效果,我們應(yīng)該確保精靈以Mesh Type設(shè)為Tight的情況進(jìn)行導(dǎo)入,這樣會(huì)把精靈包裝為凸面外殼(即下圖右側(cè))。 這樣做會(huì)提高頂點(diǎn)的數(shù)量,雖然這不總是理想的選擇,但卻是我們所需要的。 隨機(jī)的替換效果 涂鴉效果會(huì)隨機(jī)改變每個(gè)頂點(diǎn)的位置。在著色器采樣隨機(jī)數(shù)字是一件需要技巧的事,這是由于GPU的無狀態(tài)架構(gòu),它使模擬大多數(shù)庫使用的相同算法變得更加困難和低效。 Nick Kaman提供的方法是使用噪聲紋理,該紋理在采樣時(shí)會(huì)得到隨機(jī)的感覺。對(duì)我們的情況而言,這種方法可能不是最高效的方法,因?yàn)樗鼤?huì)加倍著色器必須執(zhí)行的紋理查詢次數(shù)。 因此,許多著色器需要使用比較模糊和混亂的函數(shù),即使它們的效果是確定的,而且在我們看來沒有任何模式。 由于函數(shù)必須是無狀態(tài)的,每個(gè)隨機(jī)數(shù)必須通過其自帶的種子代碼來生成。這種方法的效果很好,因?yàn)槊總€(gè)頂點(diǎn)的位置都應(yīng)該是獨(dú)特的。我們可以使用它關(guān)聯(lián)每個(gè)頂點(diǎn)的隨機(jī)數(shù),我們會(huì)在后面討論這種隨機(jī)函數(shù)的實(shí)現(xiàn)方法,現(xiàn)在我們把該函數(shù)稱為random3。 我們可以使用random3函數(shù)生成每個(gè)頂點(diǎn)隨機(jī)的替換效果。在下面例子中,隨機(jī)數(shù)會(huì)通過_NoiseScale屬性調(diào)整,這樣可以控制替換效果的強(qiáng)度。 void vert (inout appdata_full v, out Input o) { ... float2 noise = random3(v.vertex.xyz).xy * _NoiseScale; v.vertex.xy += noise; ... } 現(xiàn)在我們要編寫random3函數(shù)的代碼。 著色器內(nèi)的隨機(jī)效果 著色器中最常用和最具標(biāo)志性的偽隨機(jī)函數(shù)來自W.J.J. Rey在1998年發(fā)表的論文。 float rand(float2 co) { return fract(sin(dot(co.xy ,float2(12.9898,78.233))) * 43758.5453); } 該函數(shù)是確定性的,也就是說它不是真正具有隨機(jī)效果,但是它的行為非常不規(guī)律,使它看起來完全是隨機(jī)的,這類函數(shù)被稱為偽隨機(jī)函數(shù)。對(duì)于本教程,我使用了Nikita Miropolskiy編寫的高級(jí)函數(shù)。 添加時(shí)間 通過使用已經(jīng)編寫好的代碼,我們現(xiàn)在可以實(shí)現(xiàn)每個(gè)點(diǎn)都會(huì)在每幀替換相同的次數(shù)。這樣會(huì)實(shí)現(xiàn)搖擺的精靈,而不是涂鴉效果。 為了解決該問題,我們需要找到隨時(shí)間改變效果的方法,最簡(jiǎn)單的方法是使用頂點(diǎn)位置和當(dāng)前時(shí)間來生成隨機(jī)數(shù)。 在這種情況下,我們添加了以秒為單位的當(dāng)前時(shí)間值_Time.y到頂點(diǎn)位置。 float time = float3(_Time.y, 0, 0); float2 noise = random3(v.vertex.xyz + time).xy * _NoiseScale; v.vertex.xy += noise; 更高級(jí)的效果需要更復(fù)雜的方法來集成時(shí)間到計(jì)算方程式中,但由于我們只想實(shí)現(xiàn)間隔的隨機(jī)效果,因此添加兩個(gè)數(shù)值就足夠了。 對(duì)齊時(shí)間 添加_Time.y的主要問題是:它會(huì)造成精靈在每幀都發(fā)生變化。這是不理想的效果,因?yàn)榇蠖鄶?shù)手繪的動(dòng)畫都有較低的幀率 時(shí)間組件不應(yīng)該有連續(xù)的效果,而是應(yīng)該變得離散化,這意味著如果我們想實(shí)現(xiàn)每秒5幀,它應(yīng)該僅在每秒改變5次。使用熟悉術(shù)語的話說,那就是:時(shí)間應(yīng)該“對(duì)齊”為一秒的五分之一。因此,可以使用的數(shù)值應(yīng)該為:0/5 = 0,1/5 = 0.2,2/5 = 0.4,3/5 = 0.6,4/5 = 0.8,5/5 = 1 ,以此類推。 下面的函數(shù)會(huì)接收數(shù)值x,對(duì)齊到Snap值的整數(shù)倍數(shù)。 inline float snap (float x, float snap) { return snap * round(x / snap); } 因此,我們可以更新為以下代碼: float time = snap(_Time.y, _NoiseSnap); float2 noise = random3(v.vertex.xyz + float3(time, 0.0, 0.0) ).xy * _NoiseScale; v.vertex.xy += noise; 大功告成,最后的效果如下圖所示。