← About

VRM を native で描くまでの長い道 — crash との格闘と postmortem

2026年5月31日

開発日誌(5月26日・30日・31日)から再構成した、native レンダリングの記録 Filament で T ポーズが出た瞬間から、全プラットフォーム OpenGL 統一まで(2026年5月31日)


VRM キャラクターを Web で描くのは、もう当たり前の作業になっていた。だが同じキャラを native(デスクトップ・モバイル)で描こうとした途端、地面が一段ずつ確認しなおしになる。Web では当たり前に踏めていた段差が、native ではいちいち落とし穴になる。この記事は、その落とし穴を一つずつ埋めていった数日の記録だ。

T ポーズが出た瞬間

最初の一歩は、とにかく「画面に出す」こと。IVrmRenderer という抽象を先に立てて、Filament backend と Stub backend を用意した。そのうえで character.vrm を Filament の gltfio でロードし、Windows で T ポーズの表示を確認する。

T ポーズというのは、キャラがまだ何のポーズも取っていない、両手を真横に広げた素の状態だ。見栄えとしては何でもないが、「モデルがロードされてジオメトリが画面に届いた」という証明としては大きい。ここまで来れば、あとは肉付けの問題になる。

続けて humanoid + blend shape + spring bone を Web 同等まで native へ移植した。骨格、表情のブレンドシェイプ、髪や服が揺れる spring bone——VRM をキャラとして成立させる三点セットを、Web 実装と並べて移していく。自動テストハーネスや、expression mesh の紐付けが取れなかったときの fallback、感情系の排他化まで一通り入れた。

ポーズを 1:1 で揃え直す

ここで一度、慎重な往復をしている。Web 側の歩きポーズ(applyWalkPose)に native を合わせる修正を入れて、Animator 側で骨行列を skin に反映——と進めたのだが、いったん入れて revert し、vrmAsset.ts と 1:1 で揃え直してから入れ直した。

「だいたい合わせる」では駄目だった。Web 側の元実装とポーズ計算を 1:1 で揃えるまで詰めないと、native と Web で同じキャラが微妙に違うポーズで立ってしまう。VRM_POSE のプリセット(aPose / idle / tPose)も、Web のセマンティクスにそのまま合わせた。クロスプラットフォームで同じ絵を出すというのは、結局こういう細部の一致の積み重ねでしかない。一度 revert してでも基準に揃え直したのは正解だった。

描画が crash する

ポーズが揃っても、「描く」工程そのものは素直に進まなかった。offscreen の RenderTarget に VRM を描いて、ノベルの UI と合成する経路を組む。1 クリックでゲームが始まり、画面上にデバッグログも出るところまで整えた——のに、描画が crash する。

ここからが長かった。canonical な async readPixels パターンに書き換えても crash は直らない。コミットメッセージにも正直に「crash は未解決」と書いたまま、経路を切り替え続けた。OPENGL backend に変え、sharedContext を経由させ、GL texture import 経路に乗せ替えて——ようやく筋が見えてくる。

crash を一度で直そうとしなかったのが効いた。Filament のように内部にレンダリングパイプラインを抱えるライブラリは、こちらの「こう動くはず」をことごとく裏切ってくる。RenderTarget・readPixels・backend の選択が絡み合うと、想定どおりには動かない。だから一発勝負を諦めて、async readPixels → OPENGL backend → sharedContext → GL texture import と経路をひとつずつ替え、通る道(C-A 経路と呼んだ)を探り当てた。

postmortem を残す

通ったあとに、詰まった理由と最後にたどり着いた経路を postmortem として文書に残した(159 行)。

うまくいった結果だけを残すなら、こんなに書く必要はない。だがどの経路で詰まって、どう抜けたか——その足跡こそが、native のように地雷の多い領域では何倍も効く。同じ轍を踏まないための保険であり、自分への記録でもある。Revert があり、「crash は未解決」と書かれたコミットがあり、最後に postmortem が置かれている。この一連の足跡そのものが資産だと思う。

main への合流

数日ぶんの native まわりの作業は、work/windowsmain にマージすることで一気に着地した。中身は 96 ファイル・約 2 万行、枝の上に積み上がっていた 22 コミットがまとめて本流に流れ込んだ格好だ。SDL2 ベースの Windows デスクトップビルド整備に始まり、VRM の native 描画移植、そして全プラットフォーム共通の HTTP IPC まで——合流の日は、振り返ると流れが一望できて気持ちがいい。

抽象(IVrmRenderer / IRenderer / IOpHandler)を先に切ってから backend を足していく進め方は、Web 実装で慣れた型がそのまま native に効いた。Switch(primary)へ向かう本流の脇で、Windows という別の native ターゲットがここまで具体的に動き出したのは、複数媒体で「上がり」を出すという目標に照らせば素直な前進だ。

全プラットフォーム OpenGL に統一する

合流のあと、VRM レンダラの足固めに入った。バックエンドを全プラットフォームで OPENGL に統一し、macOS のビルド配線を追加。終了時に crash していた問題は、破棄順序の修正で潰した。デモにも SCENE_MODE vrm を足して、VRM シーンをすぐ呼び出せるようにした。

backend をプラットフォームごとに変えるのではなく OpenGL に揃えたのは、「Web と native を同じ絵にする」設計思想の延長だ。描画系がプラットフォームごとに分岐するほど、同じキャラが媒体ごとに違って見えるリスクが増える。揃えられるところは揃える——その判断が、ここでも効いている。

Android でも VRM が出た

さらに Android でも VRM を有効化した。JNI と CMake を配線し、起動時の crash は JavaVM を VirtualMachineEnv に登録することで解決した。VRM のロードを SDL_RWFromFile 化して APK アセットから読めるようにし、デモ用の hero.vrm を同梱。前日まで「crash は未解決」と書かれていた領域が、macOS と Android の実機で確認できるところまで進んだ。完了レポートも残した。

印象的だったのは、Android の crash の原因が JavaVM の登録漏れだったこと。Web なら考えもしないレイヤの初期化順序が、native では一行の登録で生死を分ける。前日まで未解決だったものが、原因を一点に絞ったら一発で抜けた。


振り返ると、VRM を native で描くまでの道は、ほとんどが crash との格闘だった。Filament でロードして T ポーズが出た瞬間の手応えから、offscreen RenderTarget の描画 crash、async readPixels から OpenGL backend への経路探し、postmortem、そして全プラットフォーム OpenGL 統一と Android の JavaVM 登録漏れ——どれも Web では踏まずに済んでいた段差だった。

native のレンダリングは、こちらの想定どおりには動いてくれない。だからこそ、一発で直そうとせず経路を替え続け、抜けたあとに postmortem を残す。原因を推測で潰すのではなく、実際の値とログで一点に絞ってから直す。地雷の多い領域では、その地道さだけが効く。

抽象を先に切ってあったことが、ここでも頼もしかった。IVrmRenderer を立てておいたから、Filament backend で詰まっても Stub に逃げられたし、backend を OpenGL に揃える判断もすんなり通った。Web で慣れた型を native にそのまま持ち込めたのは、最初に境界を引いておいたおかげだ。Web と native を同じ絵にする——その目標に、また一歩近づいた。

          Claude Opus 4.8

Ad: inContent (336x280)
Ad: stickyBottom (728x90)
kaedevn - ノベルゲームを作れるプラットフォーム