Bevyでデスクトップマスコットを作る#1

Humanoid bonesとExpressionsはリターゲット済み
Spring BonesとLookAt(これはいらないかも)をリターゲットしていきます。
仕様はvrm-specificationを参考する予定です。
また、SpringBoneに関してはDiscordで実際に実装した人からbevy_vrmでやっているとのことだったためそれも参考にします。

SpringBoneの更新タイミングはVRMAの更新と衝突しないようにPostUpdateで行うことにします。
SpringChain内のJointの更新には根本から先端にかけて行う必要があるそうなので単純にpar_iterで更新することは不可能っぽい。
そのためSpringChainのルート(根本)のエンティティにSpringChainというエンティティの配列を保持するコンポーネントをインサートしておくことにします。
各SpringJointが属するChainは2つ以上にならないと記載されているため同一Entityに複数のSpringChainが挿入されないことを前提として実装します。
更新順序
SpringBone系全体の更新は、SpringBoneJoint同士の依存を解決しながら行われます。 具体的には、Jointの親にJointが存在する場合、その親が優先して処理されます。 すなわち、根本から先端にかけて順番に更新処理が行われます。****

今日深夜までずっと作業していてめっちゃ眠いので文章がおかしくなっているかもしれません。
アプリ側で用意しているサンプルモデルにSpringBoneが設定されていなかったため、UniVRMから出力したVRM1.0モデルで動作確認。
めちゃくちゃボーンの位置が崩れていますが、髪の毛がなびいているためSpringBoneは設定できていることが確認できます。
Unityから出力されたVRMのリターゲットも修正しないといけないなぁ

Unity1.0はローカル軸が保持されているらしくUniVRMの処理を見ても回転のリターゲットはローカル空間のものが使われていました。
また、ポジションは尻のみリターゲットされていることにも気づきました。
上記の内容を取り込むとUnityから出力されたVRM1.0+VRoidStudioが配布しているサンプルのVRMAで正常にアニメーションすることを確認できましたが、Blenderから出力したVRMAに関しては引き続きボーンの回転がおかしくなっています。
BlenderのVRMアドオンのIssueで他アプリケーションでボーンが崩れるというチケットが起票されていたためアドオン自体のバグの可能性が高そう。
Blender以外で表情付きのアニメーションの出力に対応しているものがなさそうでどうするか悩み中...
(例えばUniVRMではbvhからVRMAの出力ができますがそもそもbvhに表情の拡張情報が格納されていないはずなので対応されていないと思います)

Humanoidボーンとして指定されたノードに対するアニメーションを、アニメーションデータとして扱います。 Humanoidボーンに対するアニメーションにスケールを含めてはいけません。 また、Hipsボーン以外に対するアニメーションに平行移動を含めてはいけません。
ちゃんと仕様に書いてありました。
仕様書はちゃんと読まんといかんね。

グローバルなマウス位置の取得のためにdevice_query
を使用していましたがMACの場合アクセシビリティ権限が必要になるため依存関係から削除しました。
この際に役立ったのがPickingのPointer<Over>
で、ウィンドウのヒットデータがfalseの場合でも機能しました。

dmgにバンドルしたところ、device_query
を削除したおかげでアクセシビリティ権限の確認ダイアログが出ないようになりましたが、キャラクターをドロップした際に画面の録画の許可ダイアログが出てしまいました。
どうやらCGWindowListCreateImage
が原因っぽいですが....異常なほどにセキュリティが厳しいなぁ

MToonShaderを仕様書を見ながら実装します。
以前VRMの部分だけ別クレートに切り出してくれと言われたため、別リポジトリに分離してます。
ざっと眺めて見ましたが、グラフィックス系素人の自分からしたら絶望的な難易度だと感じました(白目)
流石に知識ほぼゼロの状態で挑むのは無謀だと感じたため、Unityシェーダープログラミングの教科書 というシリーズを3まで読んで辞書代わりにすることにします。

WGSLから起動時(Startup)からの経過時間を取得したい場合、bevy_pbr::mesh_view_bindings::globalsのglobals.time
を使うと楽

UVアニメーションの実装はなんかできたっぽい

Rimライト実装してみたけど血色が悪くなった。
なんか間違ってるっぽい

テクスチャのバインドを追加したらResource limit exceeded: StageInfo
というエラーが発生。
ChatGPTの回答は以下の通り
原因 ― Apple Metal の “1 シェーダーステージ当たり上限” を超えた
M4 Pro(Metal バックエンド)のフラグメントステージ上限は 「テクスチャ 16 個 & サンプラ 16 個」。
ログを見ると textures: 16, samplers: 17 —— サンプラが 1 つ多くて リソース制限エラーになっています。
(buffers: 17 はまだ許容範囲ですが、これも増えると同じく引っ掛かります)
ExtendMaterialを使っているためスタンダードマテリアルからバインドされているテクスチャやサンプラとの合計で上限を超えたっぽい。

リポジトリ名をbevy_vrm1に変更しました。

バインドレスモードを試してみたけど自分の環境(MacBook)だと機能しないようなので断念。
結局拡張マテリアルをやめる方針に変更。
pbr_input_from_standard_material
が使えなくなるため一部の値は自前で計算する必要あり

鏡面反射に関係する値はトゥーンシェーダには関係ない(はず)なので無視
拡散色はdiffuse_transmissionとdiffuse_occlusionをStandardMaterialからバインドすれば問題なさそう。
そういえばアンビエントライト放置したままだった...

帰宅したので作業再開
上ではアンビエントライトといっていますが、正しくは大域照明(以下GI)でした。

球面調和関数イミワカナンさすぎるのでOutlineを先にやります。
アウトラインの表示には通常の描画パスの後にアウトライン用のパスを追加する必要があり、Unityならめちゃ簡単っぽいですがBevyだとめちゃ難しいっぽいです。
BevyのExampleにcustom_render_phaseというものがあり、これは(おそらく)マルチパスを実現するやり方を示していますが、700行近くあるため並の精神では戦意喪失すると思います。ていうかしてます。

上記ExampleがなにをやっているのかをChatGPTに聞いてみると以下の回答が出力されました。
口調がおかしいのは、質問文に小学生にもわかるように教えてと聞いているためです。(※自分はおっさんです)
プライドを捨てる必要がありますが噛み砕いて説明されるのでおすすめです。
回答
やっていることを “図工のじっけん” にたとえてみよう
1. ふつうの絵をかく
Bevy というお絵かきソフトで
• まるい白い土台(白い円)
• 青っぽい箱(キューブ)
をライト(電気スタンド)で照らしてかきます。
ここまではいつもの「3D 絵の時間」です。
2. もう一回、特別なペンで箱だけなぞる
ソフトに「特別ステップ(カスタムフェーズ)」を作ります。
• 対象は 赤ペンマーク(DrawStencil)が付いたものだけ
• 今回は箱にだけ赤ペンマークを付けた
• 土台には印がないので無視される
3. 特別ステップのしごと
• 赤いマーカー(特別シェーダー)で
青い箱を“ぬり絵みたいにベタっと赤”で上からなぞる
• 光も影も計算しないので 影が付かない真っ赤なシルエットになる
4. できあがり
画面には
• 下に白い丸い土台(光の当たり方そのまま)
• その上に 赤い輪郭だけの箱 が浮かんでいる
という⼀風変わった絵が出ます。
⸻
何をデモしているの?
• Bevy で「もう 1 回だけ別の描き方を挟む方法」
「最初にふつうの絵 → そのあと自分だけの描き方」という
2 段重ねを作るやり方を教えている。
• 箱だけ選んで赤く塗る例
─ 土台は塗らないのは「印がある物だけ選ぶ」しくみの見本。
• 本当はステンシルと呼ばれる “型紙” 的なテクニックを応用すると
くり抜き・影絵・アウトラインなどが作れるよ、というサンプル。
つまり 「好きな物だけ、好きな順番で、好きな色や効果を重ね描きするしくみ」 を
小さな例で見せているんだよ。

Exampleのパイプラインを移植するだけでめちゃ時間かかった...
バインドグループ1のレイアウト指定がうまくいっていなかったのが原因で、specializeでパイプラインディスクリプたを作成する際にsetup_morph_and_skinning_defs
を使う必要がありました。
この関数のoffsetという引数の意味がよくわかっていません...
また、メッシュをキューに追加する際にMeshPipelineKeyのモーフターゲットの存在チェックをいれたら動きました。
queue_custom_meshes
fn queue_custom_meshes(
custom_draw_functions: Res<DrawFunctions<Stencil3d>>,
mut pipelines: ResMut<SpecializedMeshPipelines<OutlinePipeline>>,
pipeline_cache: Res<PipelineCache>,
custom_draw_pipeline: Res<OutlinePipeline>,
render_meshes: Res<RenderAssets<RenderMesh>>,
render_mesh_instances: Res<RenderMeshInstances>,
mut custom_render_phases: ResMut<ViewSortedRenderPhases<Stencil3d>>,
mut views: Query<(&ExtractedView, &RenderVisibleEntities, &Msaa)>,
has_marker: Query<(), With<DrawStencil>>,
) {
for (view, visible_entities, msaa) in &mut views {
let Some(custom_phase) = custom_render_phases.get_mut(&view.retained_view_entity) else {
continue;
};
let draw_custom = custom_draw_functions.read().id::<DrawMesh3dStencil>();
// Create the key based on the view.
// In this case we only care about MSAA and HDR
let view_key = MeshPipelineKey::from_msaa_samples(msaa.samples()) |
MeshPipelineKey::from_hdr(view.hdr);
let rangefinder = view.rangefinder3d();
// Since our phase can work on any 3d mesh we can reuse the default mesh 3d filter
for (render_entity, visible_entity) in visible_entities.iter::<Mesh3d>() {
// We only want meshes with the marker component to be queued to our phase.
if *render_entity == Entity::PLACEHOLDER || has_marker.get(*render_entity).is_err() {
continue;
}
let Some(mesh_instance) = render_mesh_instances.render_mesh_queue_data(*visible_entity)
else {
continue;
};
let Some(mesh) = render_meshes.get(mesh_instance.mesh_asset_id) else {
continue;
};
// Specialize the key for the current mesh entity
// For this example we only specialize based on the mesh topology
// but you could have more complex keys and that's where you'd need to create those keys
let mut mesh_key = view_key;
mesh_key |= MeshPipelineKey::from_primitive_topology(mesh.primitive_topology());
if mesh.morph_targets.is_some(){
mesh_key |= MeshPipelineKey::MORPH_TARGETS;
}
let pipeline_id = pipelines.specialize(
&pipeline_cache,
&custom_draw_pipeline,
mesh_key,
&mesh.layout,
);
let pipeline_id = match pipeline_id {
Ok(id) => id,
Err(err) => {
error!("{}", err);
continue;
}
};
let distance = rangefinder.distance_translation(&mesh_instance.translation);
// At this point we have all the data we need to create a phase item and add it to our
// phase
custom_phase.add(Stencil3d {
// Sort the data based on the distance to the view
sort_key: FloatOrd(distance),
entity: (*render_entity, *visible_entity),
pipeline: pipeline_id,
draw_function: draw_custom,
// Sorted phase items aren't batched
batch_range: 0..1,
extra_index: PhaseItemExtraIndex::None,
indexed: mesh.indexed(),
});
}
}
}
まだサンプルから弄っていないので赤一色で塗りつぶされていますがアウトライン用のパスが追加できました。
パス追加だけでここまで苦労するとは思わなかった。疲れた。

アウトラインの描画自体は成功しました。
仕組みとしては、まずモデルの頂点座標を法線方向に少しだけずらし、アウトラインの色で描画。
そしてdepath_stencilでベースのモデルより外側に膨らんでいる頂点だけ描画するようにします。

現状の動作
一点問題があり、髪の毛の位置がSpringBoneによって更新されますが、アウトラインの描画が初期位置から更新されていません。

昨日は仕事終わってから寝てしまって対応できなかったので今日修正しました。
原因はスキニングを考慮していなかったためで、bevy_pbr::skinning::skin_model
を使うと簡単に修正できました。
#ifdef SKINNED
let world_from_local = skin_model(
vertex.joint_indices,
vertex.joint_weights,
vertex.instance_index
);
#else // SKINNED
let world_from_local = get_world_from_local(vertex.instance_index);
#endif // SKINNED

今更だけどスプリングボーンの挙動がおかしい気がするので明日調査予定
後、LookAtも対応したい
後、アウトラインとかGlobal Illuminationがまだ完全には対応できていないので対応したい
後、FirstPersonもまだ
後、一部のVRMAのリターゲットが失敗しているので修正したい

とりあえずClippyとかのエラーを解決してmainブランチにマージまでしたので満足

Spring Boneのの挙動はある程度できましたが、一点気づいたことが。
Unityだとリボンが動くけど、自分のやつだと動かない...。
SpringChainは設定されているのに...なぜ。リボンはJoint数が2つしかないことが関係していそうなき吐きます。
unity | bevy |
---|---|
![]() |
![]() |

あと、裏側のアウトラインがおかしい

スプリングボーンの修正はできました。
コライダーの衝突判定周りがおかしかったようです。
次はLookAtを実装したい。

LookAtはVRMC_vrm-1.0/lookAt.md
を参考にしながら実装中です。
設計方針として、ユーザー側はVRMのルートエンティティに対してLookAtというコンポーネントをインサートするだけで実現できるようにしています。
LookAtはenum型になっており、マウスカーソルの動きを追うLookAt::Cursor
,特定のエンティティの動きを追うLookAt::Target
があります。
Cursorはカメラのエンティティを渡し、そのカメラのレンダーターゲットに指定されているウィンドウのカーソルの動きを捉えるようにしています。
若干つまづいたのがマウスカーソルのビューポートの座標からワールド座標に変換する方法で、Unityだとスクリーン座標から直接ワールド座標に変換できますが、Bevyの場合Ray3dが返るため、そこからワールド座標に変換する工程が必要になります。
Ray3dにはRayの原点と方向が保持されているため距離を知ることができれば期待する位置を取得できます。
そこで、以下のページに載っている方法を参考にし、VRMの頭の位置を原点、VRMの法線をUPに設定した平面を作成し、Ray3d::intersect_plane
を使って距離を計算しました。
LookAt::Cursor
LookAt::Target

LookAtにはBoneとExpressionの2つの種類があり、とりあえずBoneは実装できました。
ExpressionはMorphTargetsを利用する必要があるようです。

てっきりモーフターゲットを使うものだと思っていましたが、UniVRMだとTextureTransformBindingsというものを使っているっぽいです。
実装すること自体はできますが若干手間なのと、恐らくほとんどのケースでボーンで問題ないと思うので一旦放置

ついにbevy_vrm1をリリースしました!正直需要ないですが...
あとDesktopMateみたいにキャラクタに影をつけてみた。

BlenderからエクスポートしたVRMAがリターゲットできなかったのはT-ポーズの姿勢がVRMのものと異なっていたからみたいです。
T-ポーズの姿勢に依存しないようにリターゲットを修正したら解決しました。
また、表情とボーン両方のアニメーションを定義したVRMAを読み込むとどちらか一方しか再生されない状態になっていたため修正しました。
余談ですが、UE5のVRMライブラリとかUniVRMとかはモーションだけ取り込んで表情はエンジン側で行なっているっぽい?

長くなってきたのでここで区切ることにします。