[Unity]モデルをイジらずポスプロも要らず。ハードエッジをサクッと解決する背面法アウトラインの作り方

**[グレンジ Advent Calendar 2024](https://qiita.com/advent-calendar/2024/grenge)** 1日目担当の、flankidsです。
普段は操作、アニメーション、カメラワークなどなどで遊び心地を作ることを主にやっています。楽しいものを作るのが好きです！

今回は、ハードエッジ部分のアウトラインの問題を手軽に解決する機能について書きました。
**もしこの記事がお役に立てたら「いいね」を押してもらえると、とても励みになります！**

# ハードエッジのアウトライン

3Dモデルのアウトライン表現では、たびたびハードエッジの表示破綻が問題になります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184294/0d1fd6e6-ddd1-484a-2e95-b42dfd2c3da7.png)

▲ 曲線的な部分はイイ感じにアウトラインがついているけど、グローブのトゲトゲ辺りの線がちょっと汚い‥‥

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184294/0a3d10a3-583d-f2e1-6504-b3032e520938.png)

▲ キバ、トサカが全体的にアウトラインが上手くいっていないし、舌の辺りは特に変な感じに

シンプルな立方体のモデルでも発生するほどのメジャーな課題なので、これに対する解決法も既にたくさんのノウハウがあります。
しかしよくある解決方法では、3Dモデルを手直ししたり、アウトラインの描画をポストプロセス任せたりと、コスパや取り回しが悪くてちょっと手を出しづらいものが多い印象です。

もっとお手軽に解決できないものか‥‥
<br>
「お手軽」の条件として

- メジャーな背面法を使ったアウトラインシェーダー
- 3Dモデルを3DCGソフトなどで手直ししない
- ポストプロセスを使わない

この３つを満たしつつハードエッジ問題を解決する方法を探していたところ、中国のテクニカルアーティストの方が公開しているオープンソースが非常に有用だったので、紹介を兼ねて使い方をまとめました。


# キレイになりました

今回の方法を適用すると、こんな感じになります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184294/cff6f93f-851b-867b-8579-4e5d772b7d8d.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184294/1fff28f2-b210-ffb8-0bf8-2d4d236fc9d6.png)

普通の方法だとアウトラインが割れまくるトゲトゲした3Dモデルに対しても、一定の安定感を持ったアウトラインを付けることができました。


# どんな仕組み？

3Dモデルに対して、アウトライン用にスムース化した法線情報を自動生成して頂点カラーに書き込んでいます。
（頂点カラーではなく任意のUVチャンネルに書き込むことも可能）

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184294/77a3b9ab-258c-2fe2-da80-89ab169e7e07.png)

法線のスムース化は、公開されているオープンソースの`Outline Normal Smoother`というエディタ拡張を利用します。

**[【Job/Toon Shading Workflow】自动生成硬表面模型Outline Normal](https://zhuanlan.zhihu.com/p/107664564)**

https://github.com/JasonMa0012/OutlineNormalSmoother

AssetPostprocessorのコールバックによって実行される機能で、特定のフォルダ内やファイル名のモデルに働きます。

シェーダーについては頂点カラーに書き込んだアウトライン用の法線データを使う以外は一般的な背面法のアウトラインシェーダーでOKなので、応用的な実装もやりやすそうです。


# スムース法線アウトラインの生成

### 【手順１】Package Managerからパッケージをインポート

① Unity Registryから`Collections`をインポート

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184294/737b181e-5c56-4e1b-1fbe-b848a9d8452a.png)

<br>

② Add package from git URL...から`Outline Normal Smoother`をインポート
　　→ git URL: https://github.com/JasonMa0012/OutlineNormalSmoother.git

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184294/948626bf-c0bc-96c6-1277-523ccdbf0056.png)

<br>

`Outline Normal Smoother`は`Collections`と`Mathematics`と依存関係にあるため、先にそれらをインポートします。
`Collections`をインポートしたら関連して`Mathematics`もインポートされるはずですが、もしインポートされていなければそちらも手動でインポートしましょう。

また、Collectionsは`ver2.1.0`以上、Mathematicsは`ver1.2,0`以上が必要です。

### 【手順２】スムース法線を使うアウトラインシェーダーを割り当て

以下のシェーダーをマテリアルに設定してください。

<details><summary><b>----------------------------------------<br> BuiltInRP用: SmoothOutline.shader（折りたたみ）<br> ------------------------------------------</b></summary><div>

```hlsl:SmoothOutline.shader
Shader "Custom/SmoothOutline" {
    Properties {
        _MainTex ("Texture", 2D) = "white" {}
        _OutlineColor( "Outline Color", Color ) = ( 0, 0, 0, 0 )
        _OutlineWidth( "Outline Width", Range(0, 1) ) = 0.025
    }
    SubShader {
        Tags { "RenderType"="Opaque" }
        LOD 100

        Pass {
            Name "BASE"

            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            // make fog work
            #pragma multi_compile_fog

            #include "UnityCG.cginc"

            struct appdata {
                float4 vertex : POSITION;
                float2 uv : TEXCOORD0;
            };

            struct v2f {
                float2 uv : TEXCOORD0;
                UNITY_FOG_COORDS(1)
                float4 vertex : SV_POSITION;
            };

            sampler2D _MainTex;
            float4 _MainTex_ST;

            v2f vert (appdata v) {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.uv = TRANSFORM_TEX(v.uv, _MainTex);
                UNITY_TRANSFER_FOG(o,o.vertex);
                return o;
            }

            fixed4 frag (v2f i) : SV_Target {
                // sample the texture
                fixed4 col = tex2D(_MainTex, i.uv);
                // apply fog
                UNITY_APPLY_FOG(i.fogCoord, col);
                return col;
            }
            ENDCG
        }
        
        Pass {
            Name "OUTLINE"

            //描画面を裏側にする
            Cull Front

            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            float4 _OutlineColor;
            float _OutlineWidth;

            struct appdata {
                float4 vertex : POSITION;
                float4 color : COLOR;
                float3 normal : NORMAL;
                float4 tangent : TANGENT;
            };

            struct v2f {
                float4 vertex : SV_POSITION;
            };

            v2f vert (appdata v) {
                v2f o;

                // 頂点カラーにベイクされた法線を格納（タンジェント空間）
                float3 smoothNormalTS = v.color.xyz * 2 - 1;

                // オブジェクト空間の情報
                float3 normalOS = v.normal;
                float3 tangentOS = v.tangent.xyz;
                float3 binormalOS = cross(normalOS, tangentOS) * v.tangent.w * unity_WorldTransformParams.w;

                // オブジェクト空間 → タンジェント空間 の変換行列
                float3x3 objectToTangentMatrix = float3x3(tangentOS.xyz, binormalOS, normalOS);
                // タンジェント空間 → オブジェクト空間 の変換行列
                float3x3 tangentToObjectMatrix = transpose(objectToTangentMatrix);

                // タンジェント空間のベクトルをオブジェクト空間に変換
                float3 smoothNormalOS = mul(tangentToObjectMatrix, smoothNormalTS);

                // 頂点座標をスムース法線の方向に押し出す
                float3 vertexOS = v.vertex.xyz + smoothNormalOS * _OutlineWidth;
                o.vertex = UnityObjectToClipPos(vertexOS);

                return o;
            }

            fixed4 frag(v2f i) : SV_Target {
                return _OutlineColor;
            }
            
            ENDCG
        }
    }
}
```
</div></details>

シェーダーを適用するとアウトラインが表示されますが、まだスムース法線の生成が済んでいないため下図のように荒れた線になっています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184294/81577a07-cff7-183c-28fc-268f575befc1.png)

### 【手順３】スムース法線を頂点カラーに書き込み・確認

スムース法線を生成したい3Dモデルのファイル名の末尾に`_Outline`または`_outline`を付けると、自動的に頂点カラーにスムース法線が書き込まれます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184294/e8345b06-27f4-7cf3-dd39-e18f6f95ff93.png)

これで完了です！

## 補足

### ◆ 対象ファイルのファイル名・ファイルパスルールの設定

デフォルトではファイル名の末尾に`_Outline`または`_outline`を付けることで処理対象になりますが、下記のように`OutlineNormalImporter.shouldBakeOutlineNormal`のデリゲートを上書きすることでルールを変更できます。

```cs:CustomOutlineNormalImporter.cs
using System.Diagnostics.CodeAnalysis;
using System.IO;
using OutlineNormalSmoother;
using UnityEditor;

internal static class CustomOutlineNormalImporter {
    [InitializeOnLoadMethod]
    private static void RegisterEvent() {
        OutlineNormalImporter.shouldBakeOutlineNormal = ShouldBakeOutlineNormal;
    }

    private static bool ShouldBakeOutlineNormal(string assetPath, [MaybeNull] AssetPostprocessor assetPostprocessor) {
        bool shouldBakeOutlineNormal = false;

        // ファイル名の末尾が"_SmoothOutline"か否か
        shouldBakeOutlineNormal |= Path.GetFileNameWithoutExtension(assetPath).EndsWith("_SmoothOutline");

        // パスに"/SmoothOutline/"が含まれるか否か（場所を問わず"SmoothOutline"フォルダ内であるか否か）
        shouldBakeOutlineNormal |= assetPath.Contains("/SmoothOutline/");
        
        return shouldBakeOutlineNormal;
    }
}
```

InitializeOnLoadMethodのAttributeで、エディタ起動時に`OutlineNormalImporter.shouldBakeOutlineNormal`のデリゲートを上書きしています。
このデリゲートはファイルパスのstringとAssetPostprocessorを引数に持ち、返り値がtrueのときに対象のファイルをスムース法線生成の処理対象にします。

上記では新しく定義した`ShouldBakeOutlineNormal`関数を登録していて、ファイル名の末尾が"_SmoothOutline"であるか、場所を問わず"SmoothOutline"フォルダ内にあるファイルがスムース法線生成の処理対象となるように設定しています。

### ◆ 頂点カラー以外にスムース法線を格納するには

下記のように`OutlineNormalBacker.onSaveToMesh`のデリゲートを上書きすることでスムース法線の書き込み先を変更できます。

```cs:CustomOutlineNormalImporter.cs
using OutlineNormalSmoother;
using Unity.Collections;
using UnityEditor;
using UnityEngine;

internal static class CustomOutlineNormalImporter {
    [InitializeOnLoadMethod]
    private static void RegisterEvent() {
        OutlineNormalBacker.onSaveToMesh = SaveOutlineNormalToMesh;
    }

    private static void SaveOutlineNormalToMesh(Mesh mesh, ref NativeArray<Color> bakedColors, ref NativeArray<Vector3> smoothedNormalTangentSpace) {
        var smoothedNormalTS = smoothedNormalTangentSpace.ToArray();
        // UV2にスムージングされた法線を保存
        mesh.SetUVs(2, smoothedNormalTS);
    }
}
```

この例では、頂点カラーではなく`UVチャンネル2`にスムース化した法線を格納しています。

:::note warn
注意
スムース法線の格納先を変えた場合、シェーダーも編集が必要です
:::

```diff_hlsl:SmoothOutline.shader
struct appdata {
     float4 vertex : POSITION;
-    float4 color : COLOR;
+    float4 uv2 : TEXCOORD2;
     float3 normal : NORMAL;
     float4 tangent : TANGENT;
};
```

```diff_hlsl:SmoothOutline.shader
v2f vert (appdata v) {
     v2f o;

-    // 頂点カラーにベイクされた法線を格納（タンジェント空間）
-    float3 smoothNormalTS = v.color.xyz * 2 - 1;
+    // UV2にベイクされた法線を格納（タンジェント空間）
+    float3 smoothNormalTS = v.uv2.xyz;
```

### ◆ スムース法線生成の結果はgit差分として出てこない

エディタ拡張でMeshに書き込まれた情報はfbxやmetaファイルではなく、エディタのキャッシュとして保存されているっぽいです。
そのため、ファイルに対しての差分は出てこないし、エディタを閉じるたびに揮発してしまうようです。

AssetPostprocessorで機能が実装してあるのはそのためで、これならインポートのコールバックで都度処理が施されるので安心です。たぶん

逆に [MenuItem] のAttributeを付けた関数を右クリックメニューから呼んで、ファイルパスやファイル名ではなく選択したファイルに処理を施すような仕組みにしてしまうと、生成結果が揮発してしまうためNGだと思います。

### ◆ SmoothOutline.shaderについて

このシェーダーのポイントは、2パス目の"OUTLINE"のパスです。

```hlsl:SmoothOutline.shader - vert関数
v2f vert (appdata v) {
    v2f o;

    // 頂点カラーにベイクされた法線を格納（タンジェント空間）
    float3 smoothNormalTS = v.color.xyz * 2 - 1;

    // オブジェクト空間の情報
    float3 normalOS = v.normal;
    float3 tangentOS = v.tangent.xyz;
    float3 binormalOS = cross(normalOS, tangentOS) * v.tangent.w * unity_WorldTransformParams.w;

    // オブジェクト空間 → タンジェント空間 の変換行列
    float3x3 objectToTangentMatrix = float3x3(tangentOS.xyz, binormalOS, normalOS);
    // タンジェント空間 → オブジェクト空間 の変換行列
    float3x3 tangentToObjectMatrix = transpose(objectToTangentMatrix);

    // タンジェント空間のベクトルをオブジェクト空間に変換
    float3 smoothNormalOS = mul(tangentToObjectMatrix, smoothNormalTS);

    // 頂点座標をスムース法線の方向に押し出す
    float3 vertexOS = v.vertex.xyz + smoothNormalOS * _OutlineWidth;
    o.vertex = UnityObjectToClipPos(vertexOS);

    return o;
}
```

`appdata`でCOLORセマンティクスから取得した頂点カラーを、法線として扱っています。
この法線はタンジェント空間での法線になっているため、変換行列を作ってオブジェクト空間に変換した後、頂点押し出しに使用しています。

※ パッケージにサンプルとして添付されている`Packages/Outline Normal Smoother/Test`内のShaderGraphに準拠して作っています

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184294/2c69aef7-6f4e-a033-ac3f-4809744fcf75.png)

# 所感

ハードエッジのアウトライン破綻問題はたびたび困っていたものの、今回一つ手段が持てたことで心の余裕ができた気がします。よかった

`Outline Normal Smoother`は下記の講演アーカイブで知りました。

https://learning.unity3d.jp/8933/

さらに、ちょうど今年の10月末に上記講演が参照していた`Outline Normal Smoother`がブラッシュアップ・オープンソース化されたようで、ありがたかったです。感謝‥‥！

<br>
調べる過程で、モデルの手直しもポスプロも使わない他の方法があることも知りました。

- **[ハードエッジでも縁崩れしない理想的なアウトラインシェーダを作ってみた](https://qiita.com/yoship1639/items/ec323111ade3a77671e0)**
    - GrabPassを使った境界判定によるアウトライン描画。ポスプロによる手法に考え方が近い？
- **[Outline shader techniques in Unity](https://www.spiria.com/en/blog/desktop-software/outline-shader-techniques-unity/)**
    - ワイヤーフレームと角に球をくっつけて表現するアウトライン描画

現時点では手法として枯れている背面法に乗っかれる`Outline Normal Smoother`がベターだと感じていますが、工夫次第でアウトラインはいろんな手段があるんだなと改めて感じました。