3Dキャラ素体をアプリに統合するまでの泥臭い話
はじめに
「頂点数・モーフ・リグが完全に一致しているドロップイン置換だから、設定ファイルの1行を書き換えるだけで終わるはず」——そう思っていた時期が私にもありました。
本記事は、カスタム3Dキャラクター素体(GLBファイル)をElectron + Three.jsアプリへ統合するセッションで遭遇した問題と、その調査・解決の記録です。Claude Codeを使いながら進めた開発ログをベースに、実際に詰まった箇所と診断の手順を中心にまとめます。
やったこと
フェーズ1:素体GLBのドロップイン置換
設定ファイルの BASE_GLB_URL を1行変えるだけの想定でした。
// src/renderer/config/defaults.ts
CHARACTER: {
// before
BASE_GLB_URL: 'assets/character/base-human.glb',
// after
BASE_GLB_URL: 'assets/character/sora-base-human.glb',
}
変更前に「本当にドロップイン互換か」をコードで検証しました。
# GLB検証スクリプト(抜粋)
assert new_vertex_count == 16571 # 14517+1506+340+208
assert new_morph_count == 138
assert face_indices_bytes == base_face_indices_bytes # バイト一致
assert rig_bone_count == 137
頂点数・モーフ名・面インデックス・リグ、すべて一致。しかし実際にBlenderでヘッドレスレンダリングしてみると——
blender --background --python render_check.py
耳が潰れ、顎下に裂けたような陥没が出ていました。wrap_sora_base.py の最寄り点マッチが耳・顎を顔面へ引き寄せた典型的な症状で、一度コミットを見送り、Cowork側(アセット担当)に差し戻しました。
修正版が届いてから再計測し、問題なしと判断してコミット。アセット検収はBlenderヘッドレスが頼りになりました(pnpm dev がElectron同梱のNode 20とプロジェクト要求のNode 22の不一致でESMローダがクラッシュし、実機確認ができなかったため)。
フェーズ2:テクスチャ合成の定数チューニング
写真投影テクスチャの「お面」感を解消するため、継ぎ目の羽ぼかし幅を広げようとしました。
// defaults.ts(変更前)
SEAM_INSET_PX: 48,
EYE_OPENING_EXCLUDE: { FEATHER_PX: 4 },
// 変更後(案)
SEAM_INSET_PX: 123, // outputSize * 0.06 = 2048 * 0.06
EYE_OPENING_EXCLUDE: { FEATHER_PX: 10 },
しかし実測すると——
実効幅 = min(SEAM_INSET_PX, 島の内接最大 × SEAM_INSET_MAX_RATIO)
face_main: 内接最大86px → SEAM=48で48px / SEAM=123でも52px(+4pxのみ!)
mouth_patch: 内接最大70px → SEAM=48で42px / SEAM=123でも42px(不変)
SEAM_INSET_MAX_RATIO=0.6 の島サイズキャップが支配的で、123に増やしてもface_mainで4pxしか変わらない。「羽ぼかしが狭すぎる」のではなく、顔UV島そのものが小さすぎるという構造問題でした。
この発見のために buildFaceTexture の return 直前にデバッグダンプを仕込みました。
// photo-fit/debug-dump.ts
if (process.env.SORA_DUMP_FACE_TEXTURE) {
const png = encodePng(baked);
const ts = new Date().toISOString().replace(/[:.]/g, '').slice(0, 15);
fs.mkdirSync('.arkit_preview/debug', { recursive: true });
fs.writeFileSync(`.arkit_preview/debug/face_texture_${ts}.png`, png);
}
⚠️
contextIsolation: true/nodeIntegration: falseのElectronレンダラではfsは使えません。vitest(Node環境)専用の手段として実装しています。
フェーズ3:モーフ適用のクランプ漏れ修正
写真フィット後に目が「ぎょろ目」になる問題。原因は2つの経路でキャップが効いていないことでした。
// feature-fit.ts(問題箇所)
// FEATURE_OWNED_SLIDERS(eye系4本)は最小二乗の箱制約から除外されており
// computeFeatureFit が直接値を決めていた
// さらに state.ts の合流が:
const next = { ...s.values, ...scaled, ...featureValues };
// featureValues が最後に勝ち、fitStrength(0.85)でスケールもされない
修正は capBound 関数を一般化して両経路に適用しました。
const EYE_CAP = 0.7;
const SUBDUED_CAP = 0.3; // 口系・鼻系
function capBound(id: string, w: number): number {
if (EYE_ID_KEYS.some(k => id.includes(k))) return clamp(w, -EYE_CAP, EYE_CAP);
if (SUBDUED_ID_KEYS.some(k => id.includes(k))) return clamp(w, -SUBDUED_CAP, SUBDUED_CAP);
return w;
}
口系モーフに SUBDUED_CAP を boundsOf(最小二乗経路)にしか掛けていなかった問題も同時に発見。face_upperlip_vol / face_lowerlip_vol は id に 'mouth' を含まないため SUBDUED_ID_KEYS のマッチに漏れており、'lip' キーを追加して解消しました。
// before
const SUBDUED_ID_KEYS = ['mouth', 'nose', 'nostril'];
// after
const SUBDUED_ID_KEYS = ['mouth', 'nose', 'nostril', 'lip'];
フェーズ4:顎下の「緑穴」とDoubleSide
「スキンマテリアルを THREE.DoubleSide にすれば顎下の穴が塞がる」という診断が来ました。実装して検証すると——
片面描画: 背景貫通 637px
両面描画: 背景貫通 637px(不変!)
両面化は「面の裏側が見えるとき」に効くアプローチです。境界エッジを調べると:
base-human.glb: 境界エッジ 2,266本(口腔・眼窩・首の開口)
sora-base-human.glb: 境界エッジ 2,266本(完全一致)
面が最初から存在しないので、両面化は原理的に無効。小顎化(顎が約15mm後退)で、base由来の既存開口に視線が通るようになったのが真因でした。最終的にアセット側で首シェルのタックで遮蔽し、neutral・jawOpen=1.0ともに0pxを達成しました。
ハマったポイント
1. 実機起動できない環境での検証
pnpm dev が常に落ちる状態での開発はつらかったです。Blenderヘッドレス・vitestのNodeモード・GLBの直接解析で代替しましたが、「自分で目で見て確認できない」制約は思った以上に判断を難しくします。
2. 「実装済みの機能を再実装しかけた」
指示書が古いパイプライン像で書かれており、「羽ぼかしを追加しろ」という指示が実は「既に入っているが、UV島サイズの制約で効いていない」という状況でした。コードを先に読んで現状を把握してから方針を決める順序が重要です。
3. キャップの経路が2つある
boundsOf(最小二乗経路)と feature-fit(featureValues経路)の両方にキャップを入れないと片方が素通しになります。合流が { ...scaled, ...featureValues } で後者が勝つ構造は特にわかりにくい。
4. 検収ノイズ
変位5mm未満の頂点を「動いている」とカウントすると、浮動小数の再量子化ノイズで誤検出します。背景貫通の走査窓を広げすぎると、肩口のシルエット境界を「穴」として拾います。検収基準は事前に定量で決めることが大事でした。
学び
- ドロップイン置換でも必ずヘッドレスレンダリングで目視する。コードの規約チェックと見た目は別問題です
- 定数チューニングの前に、その定数が実際に効いているか実測する。キャップ値を上げても島サイズのキャップが支配的なら意味がありません
- 「面の裏が見える」と「面が無い」は別の問題。DoubleSideは前者にしか効きません
- テストは「二値であること」より「中間帯が存在すること」をアサートすると、意図を正確に回帰防止できます
// 「二値前提」(壊れやすい)
expect(alpha).toBe(0 || 1);
// 「中間帯の存在」(羽ぼかしの退行を防ぐ)
const intermediate = pixels.filter(a => a > 0 && a < 1);
expect(intermediate.length).toBeGreaterThan(0);
小さな定数変更でも「実際に効いているか」を確かめる一手間が、後の手戻りを防ぐ最善の投資でした。(個人の感想)