Aqua Voice / VRChat用の非破壊変更ツールをつくった / OffersHUDのv1.9.0をリリースした

やったことを書く

Aqua Voice

この記事は、Aqua Voiceというツールを使って音声によってテキスト入力をして、それをあとから手直しすることで作成されている。僕がよく見ているr7kamuraさんのブログで紹介されていたツールで、いい感じにSpeechToTextをやってくれる。詳しいところは調べてもらったり、それこそr7kamuraさんの記事のリンクを貼るので、気になったらそっちを見てみてね。

r7kamura.com

ただ、僕はあまり口が回らないので、一発でいい感じの文章にはならなかった。話題があちこちに分散する。でも、言いたいこと自体は文字として存在するようになるので、後処理としてちょっと入れ替えたり書き換えたりするだけで記事になる。入れ替えもClaudeに投げたりしてやってもらった。とても楽だった。体験が良い。

もし気になったら、以下のリンクから登録してみてね

https://aquavoice.com/share?code=DZ-PSDR

VRChat用の非破壊変更ツールをつくった

VRChat用のUnity Editor拡張を初めて作ってみた。

一応公開してるけど他人の環境で動くのかマジでわからん

github.com

アバターのメッシュとアクセサリーのメッシュが重なっているような場合、どちらかをへこませて自然にアクセサリーがついているようにしたいことがある。おそらく正攻法はPhysBoneを使ってコライダーでへこませる方法なのだけど、そこまでするのも手間だった。もっと簡単に処理したい場合もある。

そのための解決策を探っていた。すでに使われている適切なツールがあればいいな~と思ってたのだけど、うまく見つけられなかった*1。なので、自分で作ってみることにした。作ったツールは MeshDent という名前で、これが僕が初めてUnity Editor上で動くものとなる。

このツールはUnity Editor上のオブジェクトに付与するコンポーネントとして振る舞ってくれる。コンポーネントとエディタ上で球体を操作し、へこませたい場所とへこませかたを指定するという流れ。ニッチかもしれないけど自分が望んだ要件にマッチしていい感じ。

今回の場合、ビルド時に何らかを行う/プレビューもエディター上で行うことが必要だった。変更時にMeshに対して変更を加えることなく(≒非破壊に)これを実現するためには、NDMFというライブラリを使うのが簡単そうだった。改変について調べるときによく見るやつで、ようやくこれをライブラリとして使うことができて嬉しかった。これに依存していい感じにコードを書くことで、Unityのエディター上ではそのままリアルタイムにプレビューされるし、ビルド時には変更が入った結果のMeshが使用される。

僕はあんまりゲーム機上で動くような計算、今回で言えば3Dでへこませるみたいなところは得意じゃなくて、多分良い処理は書けない。なので、その部分についてはClaude Codeに書いてもらった。それ以外の部分については、僕が構造を理解するために自分で書いてみた。具体的には、AAOのRemoveMeshByBoxというコンポーネントの実装を参考にして、どうやって画面上にGizmoを表示させるかとか、どうやってメッシュに対して干渉するのかという部分だけを自分で用意した。あとはその上で、さっき言った通りの3Dの計算、今回はどのように凹ませるかみたいなCG的な計算を書いてもらった。あと、AAOにあったMeshInfo2が便利で、これに依存してBlendShapeを考慮したあとのMeshに処理を加えられるようにした。

実際にUnity Editor上でちゃんと機能するコンポーネントとして用意するには、単に1個のクラスだけではなくていくつか用意する必要があった。ざっくり構成を説明すると:

  • Runtime/MeshDent:エントリーポイント/コンポーネント。ユーザーからの入力をシリアライズして保持する
  • Editor/MeshDentPlugin:プラグインとしてNDMFに登録する
  • Editor/MeshDentPreview:エディター上でリアルタイムにプレビューする
  • Editor/MeshDentEditor:凹ませる範囲を球体で表現するのだけど、その球体をエディター上で直接触れるようにする

例としていくつかあげるてみる。

// Runtime/MeshDent.cs
[AddComponentMenu("MeshDent/Mesh Dent")]
[RequireComponent(typeof(SkinnedMeshRenderer))]
public class MeshDent : MonoBehaviour
{
    [Serializable]
    public class DentSphere
    {
        public bool enabled = true;
        public Vector3 localPosition;
        public float radius = 0.1f;
        public float strength = 1.0f;
        public float falloff = 1.0f;
    }

    public DentSphere[] dentSpheres = new DentSphere[0];

    // Gizmo表示(シーンビューで球体を可視化)
    void OnDrawGizmosSelected()
    {
        foreach (var sphere in dentSpheres)
        {
            Vector3 worldPos = transform.TransformPoint(sphere.localPosition);
            Gizmos.DrawWireSphere(worldPos, sphere.radius);
        }
    }
}
// Editor/MeshDentPlugin.cs - NDMFにプラグインとして登録 / 実際のロジック
[assembly: ExportsPlugin(typeof(MeshDentPlugin))]

public class MeshDentPlugin : Plugin<MeshDentPlugin>
{
    public override string DisplayName => "Mesh Dent";

    protected override void Configure()
    {
        InPhase(BuildPhase.Transforming)
            .Run(MeshDentPass.Instance)
            .PreviewingWith(new MeshDentPreview());
    }
}

public class MeshDentPass : Pass<MeshDentPass>
{
    protected override void Execute(BuildContext context)
    {
        foreach (var meshDent in context.AvatarRootObject
            .GetComponentsInChildren<MeshDent>(true))
        {
            // メッシュにへこみ処理を適用
            MeshDentProcessor.ApplyDent(...);
            // 処理後はコンポーネントを削除(非破壊)
            Object.DestroyImmediate(meshDent);
        }
    }
}
// Editor/MeshDentPreview.cs - エディタ上でリアルタイムプレビュー
public class MeshDentPreview : IRenderFilter
{
    public ImmutableList<RenderGroup> GetTargetGroups(ComputeContext context)
    {
        // シーン内のMeshDentコンポーネントを監視対象として登録
        foreach (var meshDent in context.GetComponentsByType<MeshDent>())
        {
            context.Observe(meshDent);  // 変更を監視
            targets.Add(RenderGroup.For(meshDent.GetComponent<SkinnedMeshRenderer>()));
        }
        return targets.ToImmutableList();
    }

    public void OnFrame(Renderer original, Renderer proxy)
    {
        // BakeMeshで現在の変形状態を取得し、へこみ処理を適用
        originalSmr.BakeMesh(bakedMesh);
        MeshDentProcessor.ApplyDent(previewMesh, bakedMesh.vertices, ...);
        proxySmr.sharedMesh = previewMesh;
    }
}
// Editor/MeshDentEditor.cs - シーンで球体を直接操作
[CustomEditor(typeof(MeshDent))]
public class MeshDentEditor : Editor
{
    private int selectedSphereIndex = -1;

    private void OnSceneGUI()
    {
        var meshDent = (MeshDent)target;

        for (int i = 0; i < meshDent.dentSpheres.Length; i++)
        {
            var sphere = meshDent.dentSpheres[i];
            Vector3 worldPos = meshDent.transform.TransformPoint(sphere.localPosition);

            // シーン上でドラッグして位置を変更できるハンドル
            EditorGUI.BeginChangeCheck();
            Vector3 newWorldPos = Handles.PositionHandle(worldPos, Quaternion.identity);
            if (EditorGUI.EndChangeCheck())
            {
                Undo.RecordObject(meshDent, "Move Dent Sphere");
                sphere.localPosition = meshDent.transform.InverseTransformPoint(newWorldPos);
            }
        }
    }
}

……という感じで、初めてUnity Editor上のコンポーネント拡張を作ったのだけど、自分が思っていた以上にUnity Editorに手を出すハードルは低かった。こういうプラグインって、エディター上にプラグインを管理するための項目があって、そこにフォルダを追加してようやく読み込めるみたいなものを想像していた。でもUnity Editorはその周辺の統合がうまくできていて、単にコンポーネント1個表示させるだけなら、.csファイルを1個書いてそれをUnity Editorにドラッグ&ドロップするだけだった。例えばこういうやつ↓

using UnityEngine;

public class MyComponent : MonoBehaviour
{
    public string message = "Hello!";
}

そうすると勝手にコンパイルされて、もうその段階でコンポーネントとして使用できるようになる。何らかこっちで変更を加ると、次にUnity Editorのウィンドウがアクティブになったときにリロードされて実装が動くようになる。

拡張を作る側として、このくらい簡単に動かせるっていうのはありがたい。VRChat以外でも、Unity上でいろんなプロジェクトで共通の処理を用意したくなったとき、思いついた機構を簡単に取り入れる仕組みができているなと思った。これまでUnityは単なるアバターきせかえツールだと思っていたけど、こういうのを見るとやっぱりちゃんとしたゲームのための開発環境なんだな〜とか思った。

OffersHUDのv1.9.0をリリースした

自分が作っているOffersHUDの新しいバージョンをリリースした。v1.9.0!

modrinth.com

昔この記事にも書いたのだけど、OffersHUDという名前のMODを自分で制作している。カーソルを当てた村人の取引状況を、右クリックせずとも画面上にHUDとして表示するMOD。

今回の変更点は以下の3つ。

  1. HUDの位置・スケール設定が効かなくなっていたバグの修正
  2. 1.21.8〜1.21.11への対応
  3. 他のMODに影響を与えていた不要なメソッドキャンセルの削除

HUDの位置・スケール設定のバグ修正

HUDの表示で、translateを呼ぶことでHUDの位置をずらしたりスケールをいじったりする機能があった。Minecraftのバージョンアップに追従するとき、雑に対応したせいで意図しない形のAPIの呼び方をしており、この機能がうまく動かなくなっていた。偶然エゴサーチしているときにこのバグを発見して、ようやく対応することができた。きっとこれがなかったらずっとそのまま放置されていたんだろうなという気がする。

1.21.8〜1.21.11への対応

このMODはstonecutterという名前のライブラリを使って複数バージョンに対応している。今は1.20.4から1.21.11までのバージョンに対応している。このMODが依存しているMinecraftの実装が変わらないのであれば同じ実装が使えるのだけど、バージョンが上がるたびに、依存している実装のうちどれかがランダムに変わっていく。

なので、実装が変わるたびにMOD側の実装も変える必要がある。stonecutterではコメントによるアノテーションで、バージョンごとにどのコードを使うのか?の処理を分岐させる。発想としてはプリプロセッサーのようなもので、とてもシンプルにできている。ただ、全部のバージョンについての関心が一つのファイルに書かれるので、出し分けが結構大変になってくる。やろうと思えばバージョンごとに別々のクラスを用意することもできるのだけど、まだその規模ではないと判断して、一つのファイルに書いている。これをうまくいなせるような設計ができるかどうかがきっと腕の見せどころなのだろうけど、今はマイクラについての理解がそこまで深くないので、それができていない状況だ。

不要なメソッドキャンセルの削除

Fabricでは任意のメソッドに対して、自分の好きな条件で後続処理をキャンセルさせることができる。これは便利な機能なのだけど、キャンセルしたメソッドに依存する別のMODがある場合、そのMODの処理も止めてしまう可能性がある*2。Minecraftの実装に依存して実装しているのは他のMODも同じなので、実行順序によっては他のMODのメソッドがそれ以降実行されないということが起きる。

OffersHUDのv1.8.2でもこれを使っていたのだけど、別のHUD系のMODに影響を与えていたらしい。そのキャンセル処理自体はむかしは必要だったんだけど、実装の移り変わりによってもう必要なさそうだった。今回はキャンセル部分をそのまま削除して、うまく動くかどうかを自分の手で確認した。おそらく必要なさそうだったので、その部分を削除した。


今回、バージョンアップ対応とメソッドコールのキャンセルについてはそれぞれ別の人が関わってくれた。バージョンアップに関しては大部分が他人のPRによってすでに対応されていたし、メソッドのコールキャンセルについてはissueで問題点をまとめてくれていた。まじでありがたい。自分がこのissueやPRに気づくのが遅れてしまったせいで、対応がかなり遅れてしまった!すまんね(;_;)

*1:多分すでにある。もっといいものが欲しいし。

*2:メソッドがキャンセルされることで、本来そのメソッドが最後まで処理されたあとに実行されるべき処理が呼び出されない。など