8
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

STYLYAdvent Calendar 2023

Day 20

Vision OSでVRMアバターをノンコードで動かせるようにしてみた

Last updated at Posted at 2023-12-20

はじめに

Vision Proの発売が待ち遠しすぎてSwiftの勉強を始めたりUnityとC#をまた触り始めたSTYLYの会社の代表の山口です。

会社の成長に伴って仕事でコードを書くこともなくなりだいぶ腕が落ちてるんじゃないかって思ってた。今はAIの力があるので現役時代より何倍もスキルアップしたんじゃないかってくらいコード書くのが楽しい。自分で書いたクソコードも綺麗にしてくれるし、怖いくらいコード補完してくれるし、いい時代だ。

Vision Pro用のOSであるVisionOS用のUnity pluginであるPolySpatialも発表されたし今までの資産がそのまま流用できると思ったらそうはいかなかった。使えるシェーダーやエフェクトも限定されるし、Unity Editorでは動くがシミュレーターではなぜか動かなかったり、ネットにはまだまだ情報少ないし大変な状況だ。

今回はVision OS上でのVRMアバターの3Dモデルをノンコードで読み込んで表示する仕組みを作って公開してみたのでその知見を共有します。

※この記事で解説する方法で作ったコンテンツはSTYLYで動作する物ではありません。

実装方針

  • Vision OSシミュレーターで動くようにする
  • まずは必要最低限の機能のみを開発
  • VRMライブラリUniVRMのコードは一切修正しない
  • ノンコードプログラミングの実現にはUnity Visual Scriptingを利用
  • 利用しやすいようにOpenUPMで公開する

最終完成スクリーンショット

こんな感じで利用できるようになります。

Screenshot 2023-12-20 at 10.06.25.png

VRM_VisionOS.png

要対応箇所を探る

実際試す前はすんなり動くと思ってたんですが甘かった。

Unity 2022 + Polyspatialの環境でUniVRMを用いてVRMモデルを表示してみる。

うまく表示されない!UniVRM10はUniversal Render Pipeline(URP)に対応しているので、URP対応のシェーダーがそのまま動くと思ったらそうじゃない。Sharder Graphで書いたシェーダーで尚且つ対応しているノードが限定されているらしい。シェーダーなんて書いたことないので、標準のUnlitシェーダーのパラメーターを変更するだけにとどめてUniVRMに同梱されているMToonシェーダーの表現に近づける方針に決定。

Unlitシェーダーに置き換える

とはいえ、シェーダーのパラメーター調整なんてやったことないのでChatGPTに聞いたらいい感じのコードを作成することができた。

参考まで、私とChatGPTとの会話履歴

/// <summary>
/// Change all URP/MToon10 shaders of GameObject to URP/Unlit.
/// This method is intended to be used for VRM models on VisionOS.
/// </summary>
/// <param name="targetObject"></param>
public static void ChangeMtoon10ShaderToUnlitOfGameobject(GameObject targetObject)
{
    ChangeToUnlitMaterialsRecursive(targetObject.transform);
    static void ChangeToUnlitMaterialsRecursive(Transform target)
    {
        if (target.TryGetComponent<Renderer>(out var renderer))
        {
            var materialsCopy = renderer.materials;
            for (int i = 0; i < materialsCopy.Length; i++)
            {
                materialsCopy[i] = GetUnlitMaterialMadeByMToon10Shader(materialsCopy[i]);
            }
            renderer.materials = materialsCopy;
        }
        foreach (Transform child in target)
        {
            ChangeToUnlitMaterialsRecursive(child);
        }
    }
}

/// <summary>
/// Change URP/MToon10 shader to URP/Unlit shader.
/// </summary>
/// <param name="mat_original"></param>
static Material GetUnlitMaterialMadeByMToon10Shader(Material mat_original)
{
    if (mat_original.shader.name != "VRM10/Universal Render Pipeline/MToon10")
    {
        return mat_original; // Exit if the shader is not MToon10
    }

    // Create a temporary copy of the material to extract properties
    Material tempMat = new(mat_original);

    // Create a new material with the Unlit shader
    Material newMat = new(Shader.Find("Universal Render Pipeline/Unlit"));

    // Handle Surface Type (Opaque/Transparent)
    bool isTransparent = tempMat.GetFloat("_AlphaMode") > 0 || tempMat.GetFloat("_TransparentWithZWrite") > 0;

    if (isTransparent)
    {
        // Set surface type to Transparent and enable Alpha Clipping
        newMat.SetFloat("_Surface", 1.0f); // Set to Transparent
        newMat.SetFloat("_AlphaClip", 1.0f); // Enable Alpha Clipping

        // Set blend modes for transparent materials
        newMat.SetFloat("_SrcBlend", (float)UnityEngine.Rendering.BlendMode.SrcAlpha);
        newMat.SetFloat("_DstBlend", (float)UnityEngine.Rendering.BlendMode.OneMinusSrcAlpha);
        newMat.SetFloat("_ZWrite", 0.0f); // Typically, ZWrite is off for transparent materials
    }
    else
    {
        // Set surface type to Opaque and disable Alpha Clipping
        newMat.SetFloat("_Surface", 0.0f); // Set to Opaque
        newMat.SetFloat("_AlphaClip", 0.0f); // Disable Alpha Clipping
    }

    // Transfer texture and color properties
    if (tempMat.HasProperty("_MainTex") && newMat.HasProperty("_BaseMap"))
    {
        newMat.SetTexture("_BaseMap", tempMat.GetTexture("_MainTex"));
    }
    if (tempMat.HasProperty("_Color") && newMat.HasProperty("_BaseColor"))
    {
        newMat.SetColor("_BaseColor", tempMat.GetColor("_Color"));
    }

    // Transfer alpha cutoff property
    if (tempMat.HasProperty("_Cutoff"))
    {
        newMat.SetFloat("_Cutoff", tempMat.GetFloat("_Cutoff"));
    }

    //Dispose of the temporary material
    DestroyImmediate(tempMat);

    // Additional blend mode settings can be adjusted here if needed

    // Set shader again (I don't know why this is necessary, but it doesn't work without it)
    newMat.shader = Shader.Find("Universal Render Pipeline/Unlit");

    return newMat;
}

左がURP/MToonシェーダー、右がURP/Unlitシェーダー
VRM_MToon_Unlit.png

なかなかいい感じやん。やるな、オレ。(スゴいのはChatGPT)

VisionOSシミュレーターで試してみた。表示されない。。。Unity Editorではきっちり表示されているのに。。。

VRM_Pink.png

上記のコードではVRMモデルのマテリアルに利用されているMToonシェーダー全てを一つずつUnlitシェーダーに変えてパラメーターを変更するという手法をとっているのですが、どうやらUniVRMがMToonシェーダーをMaterialに適用するタイミングでVisonOSにMToonのシェーダーコードが対応しておらずエラーで落ちているっぽいことがわかった。

UniVRMがVRMモデルのUnityマテリアルを生成するタイミングでUnlitシェーダーを割り当てるしかなさそうだ。

サービス開発のエンジニアは知っている。「ライブラリやプラグインのコードを直接修正するな。ライブラリのバージョンアップ時のメンテナンスで死ぬぞ。」

ということで、UniVRM自体のコードの修正をおこなわない方針で実装していきます。幸い、UniVRM10はRender Pipelineに合わせてMaterial生成ロジックを指定することができる仕組みになっています。

公式サイトにある下記のコードサンプルではURP環境下でURP用のマテリアルを利用するように設定している。

Vrm10Instance vrmInstance = await Vrm10.LoadBytesAsync(
    VrmBytes,
    canLoadVrm0X: true,
    materialGenerator: GraphicsSettings.currentRenderPipeline is UniversalRenderPipelineAsset
        ? new UrpVrm10MaterialDescriptorGenerator() : null
);

コードを追ってみるとどうやらUrpVrm10MaterialDescriptorGenerator.csがURP環境下におけるマテリアルやシェーダーの選定や処理のフローを担っていて、実際のシェーダーのパラメーター生成はMThoonシェーダーの場合はMToonValidator.csが担っているとわかった。

URP/Unlit用のMaterialDescriptorGeneratorを用意して下記の処理を行うことにした

  1. 一旦MToon用のシェーダーパラメーターを含むマテリアル情報であるMaterialDescriptorを生成してもらう
  2. 実際にぞの情報をもとにマテリアルを生成する前に、ChatGPTが考えてくれたコードをもとにUnlitシェーダーを用いた設定に置き換える。

具体的には

  • シェーダーパラメーターの置き換え
  • シェーダープロパティ名の置き換え(_MainTex => _BaseMap)
  • Unlitで対応していないTextureやColorの破棄(_EmissionMap_MatcapTexなど)

を行っている。

// UrpUnlitMaterialDescriptorGenerator.cs

using System.Collections.Generic;
using UnityEngine;
using VRMShaders;
using UniVRM10;
using UniGLTF;
using System;

// This code is based on Packages/com.vrmc.vrm/Runtime/IO/Material/URP/Import/UrpVrm10MaterialDescriptorGenerator.cs:
namespace VisualScriptingNodes
{
    /// <summary>
    /// VRM MaterialDescriptorGenerator for URP Unlit
    /// This will be used on VisionOS where MToon shader is not compatible 
    /// </summary>
    public sealed class UrpUnlitMaterialDescriptorGenerator : IMaterialDescriptorGenerator
    {
        public MaterialDescriptor Get(GltfData data, int i)
        {
            // mtoon
            if (UrpVrm10MToonMaterialImporter.TryCreateParam(data, i, out var matDesc))
            {
                // Change MaterialDescriptor based on MaterialDescriptor for MToon
                var matDesc_replaced_with_Unlit = new MaterialDescriptor(
                    matDesc.Name,
                    Shader.Find("Universal Render Pipeline/Unlit"),
                    null,
                    replaceTextureSlots(matDesc.TextureSlots),
                    matDesc.FloatValues,
                    replaceColors(matDesc.Colors),
                    matDesc.Vectors,
                    new Action<Material>[]
                    {
                        material =>
                        {
                            new UnlitValidator(material).Validate();
                        }
                    });

                return matDesc_replaced_with_Unlit;
            }
            // unlit
            if (BuiltInGltfUnlitMaterialImporter.TryCreateParam(data, i, out matDesc)) return matDesc;
            // pbr
            if (UrpGltfPbrMaterialImporter.TryCreateParam(data, i, out matDesc)) return matDesc;

            // fallback
            Debug.LogWarning($"material: {i} out of range. fallback");
            return new MaterialDescriptor(
                GltfMaterialImportUtils.ImportMaterialName(i, null),
                UrpGltfPbrMaterialImporter.Shader,
                null,
                new Dictionary<string, TextureDescriptor>(),
                new Dictionary<string, float>(),
                new Dictionary<string, Color>(),
                new Dictionary<string, Vector4>(),
                new Action<Material>[] { });
        }

        public MaterialDescriptor GetGltfDefault()
        {
            return UrpGltfDefaultMaterialImporter.CreateParam();
        }

        /// <summary>
        /// Replace "_MainTex" with "_BaseMap". Discard other texture slots such as "_BumpMap", "_EmissionMap", "_ShadeTex", "_MatcapTex" etc.
        /// </summary>
        /// <param name="TextureSlots"></param>
        /// <returns></returns>
        IReadOnlyDictionary<string, TextureDescriptor> replaceTextureSlots(IReadOnlyDictionary<string, TextureDescriptor> TextureSlots)
        {
            var UnlitTextureSlots = new Dictionary<string, TextureDescriptor>();
            foreach (var textureSlot in TextureSlots)
            {
                if (textureSlot.Key == "_MainTex")
                {
                    UnlitTextureSlots.Add("_BaseMap", textureSlot.Value);
                }
            }
            return UnlitTextureSlots;
        }

        /// <summary>
        /// Replace "_Color" with "_BaseColor". Discard other colors such as "_EmissionColor", "_ShadeColor", "_RimColor" etc.
        /// </summary>
        /// <param name="Colors"></param>
        /// <returns></returns>
        IReadOnlyDictionary<string, Color> replaceColors(IReadOnlyDictionary<string, Color> Colors)
        {
            var UnlitColors = new Dictionary<string, Color>();
            foreach (var color in Colors)
            {
                if (color.Key == "_Color")
                {
                    UnlitColors.Add("_BaseColor", color.Value);
                }
            }
            return UnlitColors;
        }
    }
}
// UnlitValidator.cs

using System;
using UnityEngine;
using VRMShaders.VRM10.MToon10.Runtime;

// This code is based on Packages/com.vrmc.vrmshaders/VRM10/MToon10/Runtime/MToonValidator.cs
namespace VisualScriptingNodes
{
    /// <summary>
    /// Validator for URP/Unlit
    /// This will be used on VisionOS where MToon shader is not compatible 
    /// </summary>
    public sealed class UnlitValidator
    {
        private readonly Material _material;

        public UnlitValidator(Material material)
        {
            _material = material;
        }

        public void Validate()
        {
            var alphaMode = (MToon10AlphaMode)_material.GetInt(MToon10Prop.AlphaMode);
            var zWriteMode = (MToon10TransparentWithZWriteMode)_material.GetInt(MToon10Prop.TransparentWithZWrite);
            var renderQueueOffset = _material.GetInt(MToon10Prop.RenderQueueOffsetNumber);
            var doubleSidedMode = (MToon10DoubleSidedMode)_material.GetInt(MToon10Prop.DoubleSided);
            SetUnityShaderPassSettings(_material, alphaMode, zWriteMode, renderQueueOffset, doubleSidedMode);
        }

        private static void SetUnityShaderPassSettings(Material material, MToon10AlphaMode alphaMode, MToon10TransparentWithZWriteMode zWriteMode, int renderQueueOffset, MToon10DoubleSidedMode doubleSidedMode)
        {
            // Handle Surface Type (Opaque/Transparent)
            bool isTransparent = alphaMode > 0 || zWriteMode > 0;

            if (isTransparent)
            {
                // Set surface type to Transparent and enable Alpha Clipping
                material.SetFloat("_Surface", 1.0f); // Set to Transparent
                material.SetFloat("_AlphaClip", 1.0f); // Enable Alpha Clipping

                // Set blend modes for transparent materials
                material.SetFloat("_SrcBlend", (float)UnityEngine.Rendering.BlendMode.SrcAlpha);
                material.SetFloat("_DstBlend", (float)UnityEngine.Rendering.BlendMode.OneMinusSrcAlpha);
                material.SetFloat("_ZWrite", 0.0f); // Typically, ZWrite is off for transparent materials
            }
            else
            {
                // Set surface type to Opaque and disable Alpha Clipping
                material.SetFloat("_Surface", 0.0f); // Set to Opaque
                material.SetFloat("_AlphaClip", 0.0f); // Disable Alpha Clipping
            }

            switch (doubleSidedMode)
            {
                case MToon10DoubleSidedMode.Off:
                    material.SetFloat("_Cull", 2.0f);
                    break;
                case MToon10DoubleSidedMode.On:
                    material.SetFloat("_Cull", 0.0f);
                    break;
                default:
                    throw new ArgumentOutOfRangeException(nameof(doubleSidedMode), doubleSidedMode, null);
            }

            // I don't know why this is needed, but it doesn't work without it
            material.shader = Shader.Find("Universal Render Pipeline/Unlit");
        }
    }

}

これでうまく表示できるようになったので、次はノンコードで利用する部分に移る

Visual Scripting用カスタムノードの開発

Unity 2021以降からノンコードでロジックを作ることができる仕組みUnity Visual Scripting (旧Bolt)が無料で利用できる。

さまざまなノードが標準で用意されているが、標準で用意されていない機能はC#のコードを書くことでVisual Scriptingからノンコードで利用できるようになる。

今回は

  • VRMモデルの表示(URL指定)
  • VRMモデルのMeta情報の読み込み
    を実装してみた。

なお、VRMモデルへのアニメーションの適用はVisual Scriptingに標準で用意されているノードを用いて実現することができた。

Visual Scriptingのカスタムノードの作成方法は、Unityの公式のドキュメント参考にするのが良い。ただ、実装していて気がついたのだがマニュアルに書いてないことが多い。そして、ネット上にも情報が少ない。いや、ネット上に情報ゼロの記述方法も存在するぞ。

結局一番参考になったのは、Visual Scriptingに標準で用意されている公式ノードの実装コードだった。Visual Scriptingが有効になっていると、各ノードを含めて関連するソースコードが /Library/PackageCache/com.unity.visualscripting@1.x.x に格納されますので、特定の公式ノードで実現されてる仕組みをどうやって実装するんだと気になった場合は参考にしましょう。

カスタムノードではコルーチンの利用も可能で、その場合は処理の基点になるOnStartイベントノードのCoroutineチェックをOnにして利用する。

下記はVRM読み込みノードのコードです。非同期処理の記述にはUniTaskを用いてます。

コードの殆どはカスタムノードのポートの定義などで、実際の処理を書いているのは下記の2つ。

  • Enter(Flow flow):
    • LoadVrm関数の呼び出し
    • インスタンス作成
    • ノードの出力値へ設定
  • LoadVrm(string URL):
    • URLをもとにVRMのダウンロード
    • VRMインスタンスの作成
using UnityEngine;
using Unity.VisualScripting;
using UniVRM10;
using UnityEngine.Networking;
using Cysharp.Threading.Tasks;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;
using System.Collections;
using VisualScriptingNodes;

namespace VrmVisualScriptingNodes
{
    [UnitShortTitle("Load VRM")]
    [UnitTitle("Load VRM")]
    [UnitCategory("VRM")]
    [UnitSubtitle("Load VRM with URL")]
    public class LoadVRM : Unit
    {
        [DoNotSerialize]
        public ControlInput inputTrigger;

        [DoNotSerialize]
        public ControlOutput outputTrigger;

        [DoNotSerialize]
        public ValueInput VrmURL;

        [DoNotSerialize]
        public ValueOutput result;

        private GameObject resultValue;
        protected override void Definition()
        {
            inputTrigger = ControlInputCoroutine("inputTrigger", Enter);
            outputTrigger = ControlOutput("outputTrigger");

            VrmURL = ValueInput<string>("VRM URL", "");
            result = ValueOutput<GameObject>("Game Object", (flow) => resultValue);

        }

        private IEnumerator Enter(Flow flow)
        {
            string url = flow.GetValue<string>(VrmURL);
            Vrm10Instance vrmInstance = null;

            UniTask.Create(async () => { vrmInstance = await LoadVrm(url); }).Forget();
            yield return new WaitUntil(() => vrmInstance);
            resultValue = vrmInstance.gameObject;

            yield return outputTrigger;
        }

        /// <summary>
        /// Load VRM from URL
        /// </summary>
        /// <param name="URL"></param>
        /// <returns></returns>
        private async UniTask<Vrm10Instance> LoadVrm(string URL)
        {
            byte[] VrmBytes = null;
            UnityWebRequest request = UnityWebRequest.Get(URL);
            await request.SendWebRequest();
            if (request.result == UnityWebRequest.Result.Success)
            {
                VrmBytes = request.downloadHandler.data;
            }

            if (!Utils.IsVisionOS())
            {
                Vrm10Instance vrmInstance = await Vrm10.LoadBytesAsync(
                    VrmBytes,
                    canLoadVrm0X: true,
                    materialGenerator: GraphicsSettings.currentRenderPipeline is UniversalRenderPipelineAsset
                        ? new UrpVrm10MaterialDescriptorGenerator() : null
                );
                return vrmInstance;
            }
            else
            {
                Vrm10Instance vrmInstance = await Vrm10.LoadBytesAsync(
                    VrmBytes,
                    canLoadVrm0X: true,
                    materialGenerator: GraphicsSettings.currentRenderPipeline is UniversalRenderPipelineAsset
                        ? new UrpUnlitMaterialDescriptorGenerator() : null
                );
                return vrmInstance;
            }
            
        }
    }
}

VRMのURLを渡すだけでVRMが表示されます。すごく簡単。

VS_VRM_Simple.png

Visual Scriptingでは基本的に処理される順番を矢印で繋いでいくので処理が一本道でわかりやすいので私は好きです。

次に動きをつけてみましょう。

次の2つはVisual Scriptingに標準で用意されているノードを利用します

  • Set Root motion
  • Set Animator

VS_VRM_Animation.png

アニメーションも動きました。

OpenUPMでUnity Packageを公開してみる

せっかくなので、開発したコードを再利用しやすいように公開してみます。UnityのUPM(Unity Package Manager)には下記の特徴があります。

  1. パッケージの追加と更新: 依存関係を考慮しながら、簡単にパッケージを追加、更新できます。
  2. カスタムパッケージのサポート: ユーザーが独自に作成したパッケージも管理できます。
  3. 依存関係の自動解決: パッケージ間の依存関係を自動的に解決し、互換性を保ちます。
  4. GUIとコマンドラインのサポート: UnityエディタのGUIやコマンドラインを通じてパッケージ管理ができます。
  5. オープンソースとサードパーティのサポート: オープンソースやサードパーティ製のパッケージも利用可能です。
  6. バージョン管理: パッケージのバージョンを管理し、必要に応じて以前のバージョンに戻すことができます。
  7. プロジェクトの軽量化: 必要なパッケージのみをインポートすることで、プロジェクトのサイズを軽量化します。

また、インポート時には/Assetsディレクトリ以下ではなく、一時的に /Libraryディレクトリ以下に格納されるためgitでパッケージソースそのものを管理対象に含めなくてよくなります。ちなみにUPMでインポートしたパッケージのコードを /Library以下で手修正しようとしても反映されない仕組みになっているので依存関係のあるライブラリのコードを直接修正したくなる衝動を抑える効果もあります。

UPMは非常に良くできた仕組みですが、Unity公式以外のパッケージをインストールしたい場合、明示的にプロジェクトに対して Scoped Registories をホワイトリスト登録する必要があります。これは、悪意のあるパッケージがバージョンアップ時などに依存関係の深いところに悪意のあるコードを開発者に気づかれないように埋め込むことを防止するための仕様だと思われます。

UPM_ScopedRegistory.png

UPM形式で配布するには下記のような構造でPackageを用意します。パッケージの情報を記述したpakage.jsonとassembly definitionが必要です。

\VRM_VisualScriptingNodes\
    \Packages
        \com.from2001.spectrum-visualscripting-nodes
            package.json
            \Runtime
                assembly definition
                C#コード
            \Samples~
                サンプル

Githubへソースコードを公開し、タグを設定してリリースするとOpenUPMが認識してくれます。

Screenshot 2023-12-20 at 10.02.18.png

OpenUPMでは、Githubへ公開したUPM形式のパッケージを検索・Unityプロジェクトへ追加する仕組みを提供していて、上記Scoped Registories への追加も自動的に行ってくれます。

UPM_Register.png

無事OpenUPMへの登録が完了するとこのようにOpenUPMサイト内でページが生成され、サイト内の検索窓や openupm search コマンド検索可能になります。

OpenUPM経由でUPMパッケージをプロジェクトにインストールするには下記のようにコマンドを実行します。

# Install openupm-cli
npm install -g openupm-cli

# Go to your unity project directory
cd YOUR_UNITY_PROJECT_DIR

# Install package:
openupm add com.from2001.vrm-visualscripting-nodes

ノードの利用方法などのサンプルもUPMからインポートできます。

UPM_Sample.png

みなさんぜひ試してみてくださいね

宣伝

XRプラットフォームであるSTYLYを開発する株式会社Psychic VR Labでは絶賛エンジニア採用中です!

8
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?