Claude Codeと二人三脚でVRMアバター生成パイプラインを作った話
はじめに
VRMアバターを「写真1枚から」自動生成するElectron製デスクトップアプリのCharacter Studioを、Claude Codeと共同実装しました。タスク番号T-036〜T-044の約10タスク分の作業記録です。
やったことを一言でまとめると、体型スライダー+写真フィットでキャラを造形し、PerfectSync対応のVRM 1.0として書き出すパイプラインの構築です。
アーキテクチャの中心は以下のモジュール群です。
-
morph-engine— スライダー値→モーフInfluenceの合成(Three.js非依存・純粋ロジック) -
photo-fit— MediaPipe顔検出→Umeyama正規化→最小二乗フィット→TPS投影テクスチャ -
character-io— VRM 1.0書き出し(GLTFExporter + VRMC_vrm拡張手書き注入)
やったこと
morph-engineの設計(T-036)
スライダー値から各モーフのInfluenceを計算するロジックをcompose.tsとして切り出しました。ポイントは±規約です。
// dir==='pos' なら max(0, s)、'neg' なら max(0, -s)
// _plus と _minus は独立モーフ。正値は _plus、負値は _minus を駆動
function composeMorphInfluences(sliders: SliderDef[], values: SliderValues) {
const influences: Record<string, number> = {};
for (const slider of sliders) {
const s = clamp(values[slider.id] ?? 0, -1, 1);
for (const t of slider.targets) {
influences[t.morph] =
t.dir === 'pos' ? clamp01(Math.max(0, s)) : clamp01(Math.max(0, -s));
}
}
return influences;
}
Three.js非依存にすることで、Vitestのヘッドレス環境でユニットテストが書けます。実際に8スライダー×NaN混入/範囲外クランプを含む15ケースを固定しました。
VRM 1.0書き出しの罠(T-038)
three-vrmにはexporter APIがないため、GLTFExporterの出力を後処理してVRMC_vrm拡張を手書き注入する方針を取りました。
GLBはJSONチャンク+BINチャンクの単純な構造なので、分解→JSON編集→再パックは比較的素直に実装できます。
// GLBをJSON/BINチャンクに分解
function unpackGlb(buffer: ArrayBuffer) {
const view = new DataView(buffer);
const jsonLen = view.getUint32(12, true);
const jsonBytes = new Uint8Array(buffer, 20, jsonLen);
const json = JSON.parse(new TextDecoder().decode(jsonBytes));
const binOffset = 20 + jsonLen + 8;
const bin = buffer.slice(binOffset);
return { json, bin };
}
ただしlicenseUrlで大きくハマりました。
【ハマり1】licenseUrlでVRMが読み込めない
仕様書にはlicenseUrlにCC0のURLを入れるよう書いてありましたが、実際に書き出してvrm-runtime.load()で読み込むと失敗します。
原因はthree-vrmのVRMMetaLoaderPluginがデフォルトで受け入れるacceptLicenseUrlsホワイトリストにCC0のURLが含まれていないことでした。
// ✗ NG: CC0のURL
licenseUrl: 'https://creativecommons.org/publicdomain/zero/1.0/'
// ✓ OK: VRM規格のライセンスURL
licenseUrl: 'https://vrm.dev/licenses/1.0/'
実際の利用条件はavatarPermission/commercialUsage側で表現するよう変更しました。
Umeyama正規化の実装(T-040)
SVDの3×3自作は数値的に不安定なので、Horn(1987)の四元数法で代替しました。4×4対称行列のべき乗法(Gershgorin円移動シフト付き)で最大固有値を求める実装です。Three.js非依存の純粋関数として実装でき、ヘッドレステストが可能です。
【ハマり2】射影勾配法が一歩も動かない
T-041の最小二乗フィットで、初期値w=0から最適化が全く動かないバグを発見しました。
原因は劣微分の選択でした。仕様書の数式を文字どおり実装するとw_k=0のとき∂p_i/∂w_k = 0になり、勾配が常にゼロになってしまいます。
// ✗ NG: w=0のとき常にゼロ → 一歩も動かない
if (wk > 0) { g = gPos; }
else if (wk < 0) { g = gNeg; }
else { g = 0; } // ← これが問題
// ✓ OK: 劣微分の標準的な選択規則
} else {
// 両方向を試して損失を下げられる方向を選ぶ
if (gPos < 0) { g = gPos; }
else if (gNeg > 0) { g = gNeg; }
else { g = 0; }
}
【ハマり3】jawOpenでメッシュが裂ける
T-039(cowork実装分)を検証したところ、jawOpen=1にすると下顎が開くのではなく人中付近に穴が開くような破綻が発生しました。
Blenderスクリプトの下顎マスクの上端アンカーが1.516(鼻先の高さ)を誤って参照しており、本来は1.493(口の合わせ目)であるべきでした。約20°の回転変換が上唇・人中まで巻き込むため、境界でメッシュが裂けていました。
jawLeftやjawForwardは同じマスクでも平行移動のみなので破綻せず、回転を伴うjaw_open()だけの不具合でした。アンカーを修正しBlenderでアセットを再生成して解決しました。
学び
純粋ロジックの分離はAIとの協働でも威力を発揮する
Three.jsやMediaPipeへの依存を持たない純粋関数(compose.ts、umeyama.ts、fit.ts、tps.tsなど)にロジックを切り出すことで、Vitestによるヘッドレステストが書けます。Claude Codeがコードを生成した直後にテストを回せるため、バグの発見が早い点が個人的に一番の恩恵でした。
仕様書の数式を「文字どおり」実装すると落とし穴がある
劣微分の問題がその典型例です。数学的に正しい式でも実装上の端点処理が抜けることがあります。今回はClaude Codeが「初期値から一歩も動かない」ことを実際に動かして確認し、原因を特定してくれたので助かりました(個人の感想:AIが実際にテストを走らせて挙動を確認できる環境は思ったより強力です)。
実アセットでテストを回すことで見つかるバグがある
合成データのユニットテストだけでは発見できなかったバグが、実際のbase-human.glbやface_correspondence.jsonを使ったピン留めテストで複数発見されました。特にjawOpenの破綻やlicenseUrlの拒否は「実アセットで通してみて初めてわかった」ケースです。アセット整合性の回帰テストは地味ですが重要です。