Mirrativ Tech Blog

株式会社ミラティブの開発者(バックエンド,iOS,Android,Unity,機械学習,インフラ, etc.)によるブログです

シェーダーでアニメーションカーブを使う

こんにちは、Unityエンジニアのrioil(@rioil_dev)です。 Mirrativの3Dアバター(エモモ)の描画には、カスタム実装されたシェーダーを使用しています。 カスタムシェーダーにテクスチャシートを用いたアニメーション機能を追加するにあたって、シェーダーでアニメーションカーブを使えるようにしたため、今回はその方法を解説します。

検討した方法

アニメーションカーブをシェーダーで使う方法として、以下の2つの方法を検討しました。

  1. 毎フレーム、スクリプトでアニメーションカーブの値を計算して、マテリアルのプロパティにセットする
  2. アニメーションカーブをテクスチャにベイクして、シェーダーで参照する

1つ目の方法は、アニメーションカーブを MonoBehaviour に持たせて、毎フレーム Material.SetFloat メソッドで値をセットする方法です。 この方法はシンプルで簡単に実装できますが、マテリアルのプロパティを毎フレーム更新するため、GPUへのデータ転送が発生しパフォーマンスに影響を与える可能性があります。

2つ目の方法は、アニメーションカーブをテクスチャにベイクして、シェーダーで参照する方法です。 この方法の場合は、シェーダーで全ての処理が完結するため、毎フレームのGPUへのデータ転送が不要になり、パフォーマンス上のメリットがあります。

アニメーションカーブをテクスチャにベイクする処理の実装が必要になりますが、パフォーマンス上のメリットがあり、シェーダーのみの改修で実現できるため、2つ目の方法を採用することにしました。

ベイクの例

テクスチャにどんなデータを保存するか

アニメーションカーブはキーフレームの配列で表現されています。スクリプトから使用する際は AnimationCurve.Evaluate(float time) メソッドで、指定した時間のカーブの値を取得できます。

今回追加するアニメーション機能では、アニメーション1周の長さをフレーム単位で指定する仕様となっています。各フレームでのカーブの値を予め計算することができるため、テクスチャには計算済みの値を保存します。

また、アニメーションカーブを後から編集できるようにするために、キーフレームの情報もテクスチャに保存します。

テクスチャにデータをどうやって保存するか

シェーダーの実装を簡単にするために、アニメーションカーブの値は1フレームをテクスチャの1ピクセルに対応させて保存します。RGBの3つのチャンネルにはカーブの値を書き込み、Aチャンネルは常に255として不透明にします。

アニメーションカーブのキーフレームの情報はシェーダーから参照することはないため、シェーダーでの扱いやすさは考慮せず、スクリプトで扱いやすい形式で保存します。 具体的には、キーフレームのプロパティの値をシリアライズして4バイトを1ピクセルに対応させて保存します。

テクスチャの作成とデータの書き込み

シェーダーのエディタ拡張にアニメーションカーブをテクスチャにベイクする機能を実装します。 スクリプトで Texture2D を作成し、アニメーションカーブの値とキーフレームの情報をテクスチャに書き込みます。

処理の流れは、

  1. テクスチャのサイズを計算
  2. テクスチャを作成
  3. アニメーションカーブの値を計算してテクスチャに書き込む
  4. メタデータとキーフレームの情報をコピー
  5. テクスチャをアセットとして保存
  6. インポート設定を更新

となっています。これらの処理を GenerateTexture メソッドに実装します。

const int MetadataPixels = 2;
const int FrameCountIndexFromTail = 1;
const int KeyCountIndexFromTail = 2;

// target: このシェーダーを使用しているマテリアル
// frameCount: アニメーション1周のフレーム数
Texture2D GenerateTexture(UnityEngine.Object target, AnimationCurve curve, int frameCount);

まず、テクスチャのサイズを計算します。 キーフレームの情報を保存するのに必要なピクセル数を計算するために、アニメーションカーブのキーフレームの情報は最初にシリアライズしておきます。 1辺のピクセル数が2の累乗となるように、テクスチャの幅と高さを計算します。

// 1. テクスチャのサイズを計算
var curveData = SerializeCurve(curve).ToArray();
var totalPixels = frameCount + curveData.Length + MetadataPixels;
var textureWidth = (int)Mathf.Pow(2, Mathf.CeilToInt(Mathf.Log(Mathf.Sqrt(totalPixels), 2)));
var textureHeight = textureWidth * (textureWidth / 2) >= totalPixels ? (textureWidth / 2) : textureWidth;

次に、テクスチャを作成します。Unityの Texture2D クラスを使用して、指定した幅と高さでテクスチャを作成します。 テクスチャにデータを書き込むために、ピクセルデータを格納する Color32 の配列も用意します。

// 2. テクスチャを作成
var texture = new Texture2D(textureWidth, textureHeight);
var data = new Color32[textureWidth * textureHeight];

続いて、アニメーションカーブの値を計算してテクスチャに書き込みます。 アニメーションカーブの値は、0から1の範囲をとるため、0から255の範囲に変換して格納します。

// 3. アニメーションカーブの値を計算してテクスチャに書き込む
for (int frame = 0; frame < frameCount; frame++)
{
    float t = frame / (float)(frameCount - 1);
    var value = (byte)(curve.Evaluate(t) * byte.MaxValue);
    // MEMO: 実際に使用されるのはRedのみ(後述)
    data[frame] = new Color32(value, value, value, byte.MaxValue);
}

アニメーション1周の長さとキーフレームの数をメタデータとしてテクスチャの最後の2ピクセルに保存します。 キーフレームの情報は、1.でシリアライズしたデータをテクスチャの最後の部分にコピーします。

// 4. メタデータとキーフレームの情報をコピー
data[^FrameCountIndexFromTail] = Color32Serializer.Serialize(frameCount);
data[^KeyCountIndexFromTail] = Color32Serializer.Serialize(curveData.Length);
Array.Copy(curveData, 0, data, data.Length - curveData.Length - MetadataPixels, curveData.Length);

texture.SetPixels32(data);
texture.Apply(false);

作成したテクスチャをアセットとして保存します。 保存場所やファイル名は、エディタのダイアログを使用してユーザーに選択してもらいます。

// 5. アセットとして保存する
var path = EditorUtility.SaveFilePanelInProject("title", "filename", "png", "message", "/path/to/save");
if (string.IsNullOrEmpty(path)) return null;
var png = texture.EncodeToPNG();
File.WriteAllBytes(path, png);

最後に、インポート設定を更新して、テクスチャの形式を適切に設定します。 テクスチャのインポート設定については後述します。

// 6. インポート設定を更新
AssetDatabase.ImportAsset(path, ImportAssetOptions.ForceUpdate);
UpdateTextureSettings(path, textureWidth);

return AssetDatabase.LoadAssetAtPath<Texture2D>(path);

アニメーションカーブのキーフレームの情報をシリアライズする SerializeCurve は以下のような実装になっています。 カーブを復元するのに必要なキーフレームのプロパティを Color32 にシリアライズしています。

IEnumerable<Color32> SerializeCurve(AnimationCurve curve)
{
    foreach (var key in curve.keys)
    {
        yield return Color32Serializer.Serialize(key.time);
        yield return Color32Serializer.Serialize(key.value);
        yield return Color32Serializer.Serialize(key.inTangent);
        yield return Color32Serializer.Serialize(key.outTangent);
        yield return Color32Serializer.Serialize(key.inWeight);
        yield return Color32Serializer.Serialize(key.outWeight);
        yield return Color32Serializer.Serialize((int)key.weightedMode);
    }
}
static class Color32Serializer
{
    public static Color32 Serialize(int value)
    {
        var bytes = BitConverter.GetBytes(value);
        if (!BitConverter.IsLittleEndian)
        {
            bytes = bytes.Reverse().ToArray();
        }
        return new Color32(bytes[0], bytes[1], bytes[2], bytes[3]);
    }

    public static Color32 Serialize(float value)
    {
        var bytes = BitConverter.GetBytes(value);
        if (!BitConverter.IsLittleEndian)
        {
            bytes = bytes.Reverse().ToArray();
        }
        return new Color32(bytes[0], bytes[1], bytes[2], bytes[3]);
    }

    // Deserializeのメソッドは省略...
}

テクスチャのインポート設定

作成したテクスチャはRGBA32形式のPNGファイルとして保存されます。 各フレームのアニメーションカーブの値を保存した部分の値が変わると困るため、テクスチャを不可逆圧縮することはできませんが、インポート設定を適切に行うことで実行時の消費メモリを削減することができます。

テクスチャの中でアニメーションカーブの値が保存されている部分は、RGBの3つのチャンネルに同じ値を設定しているため、いずれか1チャンネルあれば十分です。また、Aチャンネルに関しては常に255で完全に不要です。そこで、AndroidとiPhoneのインポート設定では、テクスチャの形式をRチャンネルのみの R8 形式に設定します。これでメモリ消費を4分の1に削減できます。

キーフレームの情報を保存した部分はRGBAのすべてのチャンネルを使用しているため、 R8 形式に設定すると一部のデータが失われることになりますが、インポート設定はディスク上に保存されている元のPNGファイルには影響しません。そのため、Unityエディタでのカーブの編集機能には影響を与えずに、実行時に不要なデータを安全に省くことができます。

static void UpdateTextureSettings(string path, int textureSize)
{
    var textureImporter = AssetImporter.GetAtPath(path) as TextureImporter;
    if (textureImporter != null)
    {
        textureImporter.textureType = TextureImporterType.Default;
        textureImporter.filterMode = FilterMode.Point;
        textureImporter.wrapMode = TextureWrapMode.Clamp;
        textureImporter.textureCompression = TextureImporterCompression.Uncompressed;
        SetPlatformTextureSettings(textureImporter, "Android", textureSize);
        SetPlatformTextureSettings(textureImporter, "iPhone", textureSize);
        textureImporter.SaveAndReimport();
    }

    static void SetPlatformTextureSettings(TextureImporter importer, string platform, int textureSize)
    {
        importer.SetPlatformTextureSettings(new TextureImporterPlatformSettings
        {
            name = platform,
            maxTextureSize = Math.Max(32, textureSize),
            // 圧縮するとデータが失われるので圧縮しない
            // データ量を減らすために R8 を使用する、テクスチャシートのフレーム数が256を超える場合は精度が不足するので注意
            format = TextureImporterFormat.R8,
            textureCompression = TextureImporterCompression.Uncompressed,
            overridden = true
        });
    }
}

シェーダーの実装

シェーダー側の実装はとてもシンプルで、テクスチャからアニメーションカーブの値をサンプリングして、シェーダー内で使用するだけです。 カーブのテクスチャを _CurveTex としてシェーダーのプロパティに定義すると、以下のようにしてカーブの値を取得できます。

float2 curveTexUV = float2(0, 0);
float curveValue = tex2D(_CurveTex, curveTexUV).r;

まとめ

アニメーションカーブをシェーダーで使うために、アニメーションカーブをテクスチャにベイクして、シェーダーで参照する方法を紹介しました。 比較的簡単に実装できるため、アニメーションカーブをシェーダーで使いたくなった時はぜひ試してみてください。

参考リンク

We are hiring!

ミラティブでは一緒に開発してくれるエンジニアを募集しています!少しでも興味を持っていただいた方はお話を聞いていただくだけでも結構ですので、気軽にご連絡ください。 また、ミラティブの技術関連の情報は公式Xアカウント(@mirrativ_tech)にて随時発信していまますので、ぜひフォローいただけると嬉しいです。

www.mirrativ.co.jp

speakerdeck.com

mirrativ.notion.site