關於 web service, unity, blogger 等軟體工程筆記

Unity 根據美術需求客製化 Sprite shader,基於官方 shader 開始改起

Edit icon 沒有留言
客製化專屬的 Sprite shader

在社群中看到有人詢問 2D 遊戲上的特殊效果如何製作,從需求中得知這種要求,能直接改 shader 是最快了,畢竟 shader 可以決定每一個 Pixel 最終呈現顏色的可程式化著色器 (Programmable shader) 呢。因此就好奇跟作者 Leo Wang 拿到授權與美術圖來測試研究,作為本篇教學文章的成果展示。

取得官方的 Sprite shader

從頭開始寫 shader 實在是太辛苦了,而且 Unity 已經肥成這樣,要寫出一個能滿足 Unity 所有預設功能的 sprite shader,例如支援 instancing drawing 或者是 material block 這種加速繪圖的功能,或者是支援各式 sprite renderer 行為的功能,需要太多太多東西需要研究,學習曲線實在是有點大。

因此若能直接拿到 Unity 官方的 sprite shader 原始程式碼,然後再進行部分修改調整,是一個較為簡單又能享受所有 Unity 已實作的功能,不用擔心少了些什麼功能沒有實作,而且可以節省很多時間研究…。

Note: 使用 Unity 5.6.3 shaders 作為範例

從哪裡取得官方 shaders?在 Unity download archive 有提供 Build-in shaders 的下載載點,從中可以拿到所有的 shaders 原始碼。

解壓縮可以獲得許多官方內建的 shaders,哪一個會是預設的 sprite shader?從 Unity material shader 選擇器中,知道該 shader 名稱是 Sprites/Default,因此透過工具搜尋所有檔案 *.shader,尋找內容為 Sprites/Default,可以知道是 DefaultResourcesExtra/Sprites-Default.shader 這個檔案。

從該檔案找到以下的 sprite shader 實作:

// Unity built-in shader source. Copyright (c) 2016 Unity Technologies. MIT license (see license.txt)

Shader "Sprites/Default"
{
   Properties
   {
      [PerRendererData] _MainTex("Sprite Texture", 2D) = "white" {}
      _Color("Tint", Color) = (1,1,1,1)
      [MaterialToggle] PixelSnap("Pixel snap", Float) = 0
      [HideInInspector] _RendererColor("RendererColor", Color) = (1,1,1,1)
      [HideInInspector] _Flip("Flip", Vector) = (1,1,1,1)
      [PerRendererData] _AlphaTex("External Alpha", 2D) = "white" {}
      [PerRendererData] _EnableExternalAlpha("Enable External Alpha", Float) = 0
   }

   SubShader
   {
      Tags
      {
         "Queue"="Transparent"
         "IgnoreProjector"="True"
         "RenderType"="Transparent"
         "PreviewType"="Plane"
         "CanUseSpriteAtlas"="True"
      }

      Cull Off
      Lighting Off
      ZWrite Off
      Blend One OneMinusSrcAlpha

      Pass
      {
      CGPROGRAM
         #pragma vertex SpriteVert
         #pragma fragment SpriteFrag
         #pragma target 2.0
         #pragma multi_compile_instancing
         #pragma multi_compile _ PIXELSNAP_ON
         #pragma multi_compile _ ETC1_EXTERNAL_ALPHA
         #include "UnitySprites.cginc"
      ENDCG
      }
   }
}

非常簡潔有力,必須分析該段程式碼才能知道如何去修改它:

  • Line 3:著色器命名,之後必須改成自己命名的 shader 名稱
  • Line 5-14:該 shader 可控制額外參數,特別注意到幾個 attributes 宣告
    • HideInInspector: 該屬性在 Editor 中隱藏
    • PerRendererData: MaterialPropertyBlock,減少每次繪製物件的繪製狀態更改次數,能有效增進繪製效能
  • Line 16:SubShader 宣告,開始定義如何繪製
  • Line 18-24:Unity 專用的 Tags,定義繪製順序以及繪製屬性等資訊
  • Line 27-30:繪製狀態的宣告,如何剔除 (Culling),景深資訊的寫入 (ZWrite),與 frame buffer 顏色的混合模式 (Blend) 等等
  • Line 32:Pass 宣告,終於來到關注的部分,這裡會定義要使用哪一組 vertex shader 以及 pixel shader,能程式化修改的部分
  • Line 34/42:CGPROGRAM/ ENDCG 宣告,表示這是 Cg shader (不過似乎 Unity 目前只能用這個?)
  • Line 35:定義 vertex shader 為 SpriteVert 函數
  • Line 36:定義 pixel shader 為 SpriteFrag 函數
  • Line 37:定義這是 shader 2.0
  • Line 38-40:這應該是 Unity shader compiler 所使用到的定義,先忽略
  • Line 41:引用另外一個檔案 UnitySprites.cginc

從這份 shader 實際上沒有看到 SpriteFrag 函數的宣告,這次目標要修改的函數,考慮到 #include (C++ 常用啊),八成是在該檔案內定義,果不其然開啟 UnitySprites.cginc 可以找到這次的目標。

// Unity built-in shader source. Copyright (c) 2016 Unity Technologies. MIT license (see license.txt)
#ifndef UNITY_SPRITES_INCLUDED
#define UNITY_SPRITES_INCLUDED
#include "UnityCG.cginc"

#ifdef UNITY_INSTANCING_ENABLED
   UNITY_INSTANCING_CBUFFER_START(PerDrawSprite)
      // SpriteRenderer.Color while Non-Batched/Instanced.
      fixed4 unity_SpriteRendererColorArray[UNITY_INSTANCED_ARRAY_SIZE];
      // this could be smaller but that's how bit each entry is regardless of type
      float4 unity_SpriteFlipArray[UNITY_INSTANCED_ARRAY_SIZE];
   UNITY_INSTANCING_CBUFFER_END
   #define _RendererColor unity_SpriteRendererColorArray[unity_InstanceID]
   #define _Flip unity_SpriteFlipArray[unity_InstanceID]
#endif // instancing

CBUFFER_START(UnityPerDrawSprite)

#ifndef UNITY_INSTANCING_ENABLED
   fixed4 _RendererColor;
   float4 _Flip;
#endif

   float _EnableExternalAlpha;
CBUFFER_END

// Material Color.
fixed4 _Color;

struct appdata_t
{
   float4 vertex   : POSITION;
   float4 color   : COLOR;
   float2 texcoord : TEXCOORD0;
   UNITY_VERTEX_INPUT_INSTANCE_ID
};

struct v2f
{
   float4 vertex   : SV_POSITION;
   fixed4 color   : COLOR;
   float2 texcoord : TEXCOORD0;
   UNITY_VERTEX_OUTPUT_STEREO
};

v2f SpriteVert(appdata_t IN)
{
   v2f OUT;
   UNITY_SETUP_INSTANCE_ID (IN);
   UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(OUT);

#ifdef UNITY_INSTANCING_ENABLED
   IN.vertex.xy *= _Flip.xy;
#endif

   OUT.vertex = UnityObjectToClipPos(IN.vertex);
   OUT.texcoord = IN.texcoord;
   OUT.color = IN.color * _Color * _RendererColor;

   #ifdef PIXELSNAP_ON
   OUT.vertex = UnityPixelSnap (OUT.vertex);
   #endif

   return OUT;
}

sampler2D _MainTex;
sampler2D _AlphaTex;

fixed4 SampleSpriteTexture (float2 uv)
{
   fixed4 color = tex2D(_MainTex, uv);

#if ETC1_EXTERNAL_ALPHA
   fixed4 alpha = tex2D(_AlphaTex, uv);
   color.a = lerp(color.a, alpha.r, _EnableExternalAlpha);
#endif

   return color;
}

fixed4 SpriteFrag(v2f IN) : SV_Target
{
   fixed4 c = SampleSpriteTexture (IN.texcoord) * IN.color;
   c.rgb *= c.a;
   return c;
}

#endif // UNITY_SPRITES_INCLUDED
  • Line 6-15:關於 Instancing 的實作內容,說如果要從頭開始寫這個…頭應該會很大
  • Line 17-25:Constant buffer 宣告,包含部分 Instancing 所使用的資料
  • Line 28:_Color 的宣告,MainColor,可以在 Properties 找到一樣的宣告
  • Line 30-36:Unity 餵給 GPU 的應用程式資料,比較常見是以下幾種:
    • POSITION: vertex 世界座標
    • COLOR: vertex 原始顏色,不過這年頭大家都直接抓貼圖了,不會特地在 3d max 或是 maya 等建模工具中指定顏色 (除非是特別的效果)
    • TEXCOORD0: 貼圖主 UV,UV0
    • NORMAL: vertex 法向量,通常是 3d 物件才會有
    • TANGENT: tangent vector,用於 normal map 技術,通常也是只有 3d 物件才會有
  • Line 38-44:vertex shader 處理完後要給 pixel shader 的資料結構定義,看需求而有所不同,v2f 命名規則猜測是 vertex to fragment
  • Line 46-65:vertex shader 實作,Unity 功能多這個就越來越肥…(所以手機的極致繪圖優化也可以從這裡下手,沒用到的功能全部砍一砍)
  • Line 67-68:貼圖資源的宣告,_MainTex 主貼圖,_AlphaTex 透明貼圖 for ETC1 on android
  • Line 70-80:從貼圖中取得顏色的函數 SampleSpriteTexture
  • Line 82-87:pixel shader 實作,從貼圖取得顏色後,再乘上 color (from vertex)…

根據只要調整 pixel shader 的需求,重新整理這兩份程式碼實作成以下,剪下貼上需要的部分:

// Unity built-in shader source. Copyright (c) 2016 Unity Technologies. MIT license (see license.txt)

Shader "Custom/MySprite"
{
   Properties
   {
      [PerRendererData]_MainTex("Sprite Texture", 2D) = "white" {}
      _Color("Tint", Color) = (1,1,1,1)
      [MaterialToggle] PixelSnap("Pixel snap", Float) = 0
      [HideInInspector] _RendererColor("RendererColor", Color) = (1,1,1,1)
      [HideInInspector] _Flip("Flip", Vector) = (1,1,1,1)
      [PerRendererData] _AlphaTex("External Alpha", 2D) = "white" {}
      [PerRendererData] _EnableExternalAlpha("Enable External Alpha", Float) = 0
   }

   SubShader
   {
      Tags
      {
         "Queue"="Transparent"
         "IgnoreProjector"="True"
         "RenderType"="Transparent"
         "PreviewType"="Plane"
         "CanUseSpriteAtlas"="True"
      }

      Cull Off
      Lighting Off
      ZWrite Off
      Blend One OneMinusSrcAlpha

      Pass
      {
      CGPROGRAM
         #pragma vertex SpriteVert
         #pragma fragment MySpriteFrag
         #pragma target 2.0
         #pragma multi_compile_instancing
         #pragma multi_compile _ PIXELSNAP_ON
         #pragma multi_compile _ ETC1_EXTERNAL_ALPHA
         #include "UnityCG.cginc"
         #include "UnitySprites.cginc"
         
         fixed4 MySampleSpriteTexture(float2 uv)
         {
            fixed4 color = tex2D(_MainTex, uv);
         #if ETC1_EXTERNAL_ALPHA
            fixed4 alpha = tex2D(_AlphaTex, uv);
            color.a = lerp(color.a, alpha.r, _EnableExternalAlpha);
         #endif
            return color;
         }

         fixed4 MySpriteFrag(v2f IN) : SV_Target
         {
            fixed4 c = MySampleSpriteTexture(IN.texcoord) * IN.color;
            c.rgb *= c.a;
            return c;
         }
      ENDCG
      }
   }
}
  • Line 3:改成自己合適的 shader 名稱
  • Line 5-Line14:根據需求再增加新的參數,可以是以下幾種:
    • 貼圖:_NewTex(“New Texture”, 2D) = “white” {}
    • 顏色:_NewColor(“New color”, Color) = (1,1,1,1)
    • 整數:_NewInt(“New int”, Int) = 0
    • 浮點數:_NewFloat(“New float”, Float) = 0.0
    • 範圍浮點數:_NewRange(“New range”, Range (0.01, 1)) = 0.078125
  • Line 44-59:修改 pixel shader 實作,來完成美術想要的效果
    • Line46:從貼圖 _MainTex 取得顏色
    • Line47-50:Android ETC1 Alpha 貼圖的實作
    • Line56:貼圖顏色與 MainColor 混色
    • Line57:Alpha 透明度的額外處理

開始撰寫客製化的 shader

從提問者的問題了解到,需求是要下圖左邊變成右邊:

需求示意圖,左邊是原始圖像,右邊是目標效果

需求示意圖,左邊是原始圖像,右邊是目標效果

經過一些溝通,大致可以猜得出效果的實作方式,但最好的做法莫過於是美術在 Photoshop 上建立特效所要使用多層濾鏡 (Multiple layer filters),便可以從這些濾淨功能中找到如何實作的方法。當然為了效能而優化,那又是另一件很長的故事了…。

這個需求分析後其實很簡單,只要加上另外一個顏色就可以滿足 (亮度調整),因此修改上節的 sprite shader template,先加入另一個顏色參數,讓開發者可以在 Unity Editor 調整而不用改動 shader code:

Properties
{
   [PerRendererData]_MainTex("Sprite Texture", 2D) = "white" {}
   _Color("Tint", Color) = (1,1,1,1)
   [MaterialToggle] PixelSnap("Pixel snap", Float) = 0
   [HideInInspector] _RendererColor("RendererColor", Color) = (1,1,1,1)
   [HideInInspector] _Flip("Flip", Vector) = (1,1,1,1)
   [PerRendererData] _AlphaTex("External Alpha", 2D) = "white" {}
   [PerRendererData] _EnableExternalAlpha("Enable External Alpha", Float) = 0
   _AddColor("Add", Color) = (1,1,1,0)
}

然後在 CGPROGRAM 內宣告其參數,讓 pixel shader 可以使用該參數,Unity 會處理把值從編輯器設定塞到 GPU 內使用,並且修改原先的顏色處理實作,在貼圖顏色與主顏色 (_MainColor) 混合後,再加上這額外的顏色 _AddColor,且利用 _AddColor.a 作為強度值設定:

Pass
{
CGPROGRAM
fixed4 MySampleSpriteTexture(float2 uv)
{
   fixed4 color = tex2D(_MainTex, uv);
#if ETC1_EXTERNAL_ALPHA
   fixed4 alpha = tex2D(_AlphaTex, uv);
   color.a = lerp(color.a, alpha.r, _EnableExternalAlpha);
#endif
   return color;
}

fixed4 _AddColor; // Must decalre this to use
fixed4 MySpriteFrag(v2f IN) : SV_Target
{
   fixed4 c = MySampleSpriteTexture(IN.texcoord) * IN.color;
   c.rgb += _AddColor.rgb * _AddColor.a;
   c.rgb *= c.a;
   return c;
}
ENDCG
}
Note: Gif 示意圖均有壓縮造成部分失真 (lossy compression),在眼睛部分可能會不正確的閃爍…

其參數調整效果最後會像是這樣:

額外加顏色 (亮度) 的效果展示

額外加顏色 (亮度) 的效果展示

在 C# 程式碼中可以使用以下方式直接調整顏色參數 (當然也可以使用 Animation curve 來控制):

var r = this.GetComponent<SpriteRenderer>();
r.material.SetColor("_AddColor", new Color(1, 1, 1, .5f));

完成這需求的實作 shader,基於 Unity sprite shader 程式碼,只需要短短幾行便可完工,而且能保留原先的實作功能與特性。

各種嘗試的範例

之後嘗試以下幾種常用的修改範例,以方便未來直接拿來使用。

貼圖混合 Texture Blending

蠻常用於 2d 遊戲中想要有線性內插切換貼圖的效果,例如環境天氣的改變。給予兩張貼圖 (_MainTex and_MainTex2) 以及混合參數的 shader 實作,這只是另一個簡單的 lerp 函數來混色,若像地形 (terrain) 那種使用貼圖 alpha 的混色模式將可以做出更強大的效果:

Properties
{
   // ...
   _MainTex2("Tint2", 2D) = "white" {}
   _BlendFactor("BlendFactor", Range(0,1)) = 0
}

sampler2D  _MainTex2;
fixed _BlendFactor;

fixed4 MySampleSpriteTexture(float2 uv)
{
   fixed4 color = lerp(tex2D(_MainTex, uv), tex2D(_MainTex2, uv), _BlendFactor);
#if ETC1_EXTERNAL_ALPHA
   fixed4 alpha = tex2D(_AlphaTex, uv);
   color.a = lerp(color.a, alpha.r, _EnableExternalAlpha);
#endif
   return color;
}
貼圖混合的效果展示

貼圖混合的效果展示

色相、飽和、亮度與對比 Hue, saturation, brightness and contrast

要完全了解這個,可能需要一點影像處理 (Image processing) 的知識,但別人已經實作好了,直接複製過來改一改就是了 (咦?),另外可以注意到 _Brightness 功效,其實就能符合一開始的需求 :

Properties
{
   // ...
   _Hue("Hue", Range(0, 1.0)) = 0
   _Saturation("Saturation", Range(0, 1.0)) = 0.5
   _Brightness("Brightness", Range(0, 1.0)) = 0.5
   _Contrast("Contrast", Range(0, 1.0)) = 0.5
}

fixed _Hue, _Saturation, _Brightness, _Contrast;

inline float3 ApplyHue(float3 c, float h)  // See HSV color space
{
   float angle = radians(h);
   float3 k = float3(0.57735, 0.57735, 0.57735);
   float cosAngle = cos(angle);
   return c * cosAngle + cross(k, c) * sin(angle) + k * dot(k, c) * (1 - cosAngle);
}

inline fixed4 ApplyHSBC(float4 c) {
   float hue = 360 * _Hue;
   float saturation = _Saturation * 2;
   float brightness = _Brightness * 2 - 1;
   float contrast = _Contrast * 2;
   c.rgb = ApplyHue(c.rgb, hue);
   c.rgb = (c.rgb - 0.5f) * contrast + 0.5f;
   c.rgb = c.rgb + brightness;
   float3 intensity = dot(c.rgb, float3(0.2999, 0.587, 0.114));  // RGB to intensity (YCbCr)
   c.rgb = lerp(intensity, c.rgb, saturation);
     
   return c;
}

fixed4 MySpriteFrag(v2f IN) : SV_Target
{
   fixed4 c = MySampleSpriteTexture(IN.texcoord) * IN.color;
   c = ApplyHSBC(c);
   c.rgb *= c.a;
   return c;
}
HSBC 特效的效果展示

HSBC 特效的效果展示

灰階效果 Gray scale

將彩色顏色轉換成黑白灰階,可以從網路上找到幾種實作方式,不同的權重表示不同 Color space 灰階定義:

// Average
R' = G' = B' = 0.333R + 0.333G + 0.333B

// Luma, Y component
R' = G' = B' = 0.2126R + 0.7152G + 0.0722B

// YCbCr, Y component
R' = G' = B' = 0.2999R + 0.587G + 0.114B

以下使用 YCbCr 公式,使用參數 _GrayScale 來做灰階效果的線性內插的 shader 實作:

Properties
{
   // ...
   _GrayScale("GrayScale", Range(0, 1)) = 0
}

fixed _GrayScale;

fixed4 MySpriteFrag(v2f IN) : SV_Target
{
   fixed4 c = MySampleSpriteTexture(IN.texcoord) * IN.color;
   fixed y = dot(c, float3(0.2999, 0.587, 0.114));
   c.rgb = lerp(c.rgb, fixed3(y, y, y), _GrayScale);
   c.rgb *= c.a;
   return c;
}
灰階特效的效果展示

灰階特效的效果展示

其他

待補,看需求來撰寫適當又有效率的 shader code。也許寫了一個很酷炫的 shader effect,但因為目標平台硬體上的限制,最後優化得捨棄複雜的 shader code 也是有可能的。

Reference

沒有留言: