はじめに
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で公開する
最終完成スクリーンショット
こんな感じで利用できるようになります。
要対応箇所を探る
実際試す前はすんなり動くと思ってたんですが甘かった。
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シェーダー
なかなかいい感じやん。やるな、オレ。(スゴいのはChatGPT)
VisionOSシミュレーターで試してみた。表示されない。。。Unity Editorではきっちり表示されているのに。。。
上記のコードでは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を用意して下記の処理を行うことにした
- 一旦MToon用のシェーダーパラメーターを含むマテリアル情報であるMaterialDescriptorを生成してもらう
- 実際にぞの情報をもとにマテリアルを生成する前に、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が表示されます。すごく簡単。
Visual Scriptingでは基本的に処理される順番を矢印で繋いでいくので処理が一本道でわかりやすいので私は好きです。
次に動きをつけてみましょう。
次の2つはVisual Scriptingに標準で用意されているノードを利用します
- Set Root motion
- Set Animator
アニメーションも動きました。
OpenUPMでUnity Packageを公開してみる
せっかくなので、開発したコードを再利用しやすいように公開してみます。UnityのUPM(Unity Package Manager)には下記の特徴があります。
- パッケージの追加と更新: 依存関係を考慮しながら、簡単にパッケージを追加、更新できます。
- カスタムパッケージのサポート: ユーザーが独自に作成したパッケージも管理できます。
- 依存関係の自動解決: パッケージ間の依存関係を自動的に解決し、互換性を保ちます。
- GUIとコマンドラインのサポート: UnityエディタのGUIやコマンドラインを通じてパッケージ管理ができます。
- オープンソースとサードパーティのサポート: オープンソースやサードパーティ製のパッケージも利用可能です。
- バージョン管理: パッケージのバージョンを管理し、必要に応じて以前のバージョンに戻すことができます。
- プロジェクトの軽量化: 必要なパッケージのみをインポートすることで、プロジェクトのサイズを軽量化します。
また、インポート時には/Assetsディレクトリ以下ではなく、一時的に /Libraryディレクトリ以下に格納されるためgitでパッケージソースそのものを管理対象に含めなくてよくなります。ちなみにUPMでインポートしたパッケージのコードを /Library以下で手修正しようとしても反映されない仕組みになっているので依存関係のあるライブラリのコードを直接修正したくなる衝動を抑える効果もあります。
UPMは非常に良くできた仕組みですが、Unity公式以外のパッケージをインストールしたい場合、明示的にプロジェクトに対して Scoped Registories
をホワイトリスト登録する必要があります。これは、悪意のあるパッケージがバージョンアップ時などに依存関係の深いところに悪意のあるコードを開発者に気づかれないように埋め込むことを防止するための仕様だと思われます。
UPM形式で配布するには下記のような構造でPackageを用意します。パッケージの情報を記述したpakage.json
とassembly definitionが必要です。
\VRM_VisualScriptingNodes\
\Packages
\com.from2001.spectrum-visualscripting-nodes
package.json
\Runtime
assembly definition
C#コード
\Samples~
サンプル
Githubへソースコードを公開し、タグを設定してリリースするとOpenUPMが認識してくれます。
OpenUPMでは、Githubへ公開したUPM形式のパッケージを検索・Unityプロジェクトへ追加する仕組みを提供していて、上記Scoped Registories
への追加も自動的に行ってくれます。
無事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からインポートできます。
みなさんぜひ試してみてくださいね
宣伝
XRプラットフォームであるSTYLYを開発する株式会社Psychic VR Labでは絶賛エンジニア採用中です!