BevyでもVRMとVRMAを使いたい!
少しデスクトップマスコット感が増しました
現在Bevyを使ってデスクトップマスコットを製作しており、VRMとVRMAを使う必要が出てきました。
ただ、公式では対応しておらず、Bevyの最新バージョン(v0.16)に対応しているクレートが無さそうだったので勉強がてら自作してみました!
bevy_vrm1というクレート名で公開しているため良かったら試してみて下さい。
ちなみにVRM1.0のみ対応しています。
この記事では実装において大変だったことなどを紹介していきます。
ちなみに、スクラップでも進捗を綴ってます。
Look At
ターゲットオブジェクトを設定するとVRMがそのターゲットを見つめるようになります。
私の作っているデスクトップマスコットツールではマウスカーソルを見つめさせたかったので、enumにしてオブジェクトとカーソルの両方を指定できるようにしました。
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "reflect", derive(Reflect, Serialize, Deserialize))]
#[cfg_attr(feature = "reflect", reflect(Component, Serialize, Deserialize))]
pub enum LookAt {
Cursor { camera: Option<Entity> },
Target(Entity),
}
カーソルの場合はカメラのRenderTarget
を元にウィンドウを取得してそのウィンドウのカーソル位置を取得しています。
ここで少し詰まったところがあり、Camera::viewport_to_world
を使ってスクリーン座標からワールド座標に変換しようとしたのですが、Ray3d
が返ってきてしまい、そこからどうやってワールド座標に変換するのかわからず悩みました。
最終的にUnofficial Bevy Cheat Bookに書いてあった方法を参考にし、カメラの後ろ方向を法線にした無限平面(InfinitePlane3d
)を作成してRay3d::intersection_plane
で平面までの距離を求めた後にRay3d::get_point(distance)
で求めることが出来ました。
また、InfinitePlane3d
には法線の他に位置を指定する必要があります。最初は頭の位置+カメラの後ろ方向の単位ベクトルを指定していましたが、カメラとキャラの位置が近いと上手く視線が制御できなかったので、単位ベクトルの代わりに頭からカメラまでのベクトルを0.5倍したベクトルを加算するようにしたら上手くいきました。
カーソルを目で追うアリシア
ちなみにLookAtにはBoneとExpressionの2種類があるらしく、このクレートではBoneのみ対応しています。
Expressionはモーフターゲットを使うのかなと思っていたのですが、UniVRMの実装を見たらTextureTransformBindingsを使っていました。
一応MToonMaterialにuvTransformというフィールドを用意しているのでこれを使ってWGSL側でUVを変形させれば実装できると思います。
Spring Bones
髪の毛とか胸のような揺れものを表現するための機能です。
Discord経由で以前実装したことがある人からコードを共有してもらったりしたのでそこまで苦労しなかったです。 先人に感謝です。
MToon Shader
グラフィックス系の知識がほぼゼロの状態からのスタートだったのでぶっちぎりで一番苦労しました。
最初はJunie(Jetbrains系のIDEで使えるAIエージェント 最近RustRoverに対応しました)でMToonのマテリアルとシェーダを書いてもらおうとしましたが、bevyもwgpuも学習データが少なすぎるのかそれっぽいだけで全然違うコードしか出力せず、学習して自分で実装した方が早いという結論に至りました。
シェーダに関しては、bevyはWGSLですがUniVRMのMToonシェーダーも参考にするためHLSLも学習する必要がありました。
HLSLに関してはBoothに販売されていたUnityシェーダプログラミングの教科書を読んで学習してWGSLの方は気合いで書くことにしました。
一応それっぽく描画はできるようにはなりましたが、付け焼き刃の知識では限界があり、恐らく仕様通りではない箇所がいくつかあります。
例えばMToonはディレクショナルライト、リムライト、インダイレクトライト(グローバルイルミネーション)(+emission)のライトを適用させるのですが、グローバルイルミネーションの計算方法がわからず、現状bevyが提供?しているambient_light
という関数で誤魔化しています。
あとアウトラインに関しては、フラグメント関数内で単純にアウトラインの色で塗りつぶしてしまっており本来の仕様と相違があるためやる気が湧いたら修正予定です。
ちなみにアウトラインはベースのレンダーパスの後にアウトライン用のパスを追加する必要があったのですが、これがめちゃくちゃ大変でした。(HLSLだとめっちゃ楽っぽいのに!)
パイプライン、ノード、ドローファンクション、フェイズアイテムなどとにかく必要なものが多く、さらにそれらがどのような関係性なのが把握するのが大変すぎてパスを追加するだけでまる1日は潰れました。
VRMA
VRMAはVRMのアニメーションを再生するためのファイル形式です。
VRMと同じくGLTFがベースになっており、VRMAとVRMのボーンを同期(リターゲット)することでVRMAのアニメーションをVRMに反映させることが出来ます。
bevyにはParallelCommands
という並列にクエリを操作できる機能があり、高速なリターゲットが比較的楽に出来ます。ECSの強みですね。
ちなみにボーンの回転はVRMA側のローカル回転をそのまま適用するようにしているのですが、BlenderのVRMアドオンから出力されたVRMAをリターゲットしようとすると崩れてしまうことに気づきました。
もしいい感じの修正方法があればご教授いただきたいです😭
ボーンのリターゲットを行うシステム
fn bind_bone_rotations(
par_commands: ParallelCommands,
sources: Query<
(
&RetargetBoneTo,
&Transform,
&BoneRestGlobalTransform,
Option<&Hips>,
),
(Changed<Transform>, With<CurrentRetargeting>),
>,
dist_bones: Query<(&Transform, &BoneRestGlobalTransform)>,
) {
sources.par_iter().for_each(
|(retarget_bone_to, src_pose_tf, src_rest_gtf, maybe_hips)| {
let Ok((dist_pose_tf, dist_rest_gtf)) = dist_bones.get(retarget_bone_to.0) else {
return;
};
let transform = Transform {
rotation: src_pose_tf.rotation,
translation: if maybe_hips.is_some() {
calc_hips_position(
src_rest_gtf.0.translation(),
src_pose_tf.translation,
dist_rest_gtf.0.translation(),
)
} else {
dist_pose_tf.translation
},
scale: dist_pose_tf.scale,
};
par_commands.command_scope(|mut commands| {
commands.entity(retarget_bone_to.0).insert(transform);
});
},
);
}
最後に
今回初めてシェーダに触れましたが、学生時代に数学と物理(とあと英語)はしっかり学んでおいた方が良かったなと痛感しました。
Discussion