Blenderで作成したキャラクターがThree.jsで壊れた — 3Dキャラクター実装の回顧
fundaプロジェクトと個人ポートフォリオにThree.jsでキャラクターを導入する際に直面した問題の記録
fundaプロジェクトと個人ポートフォリオにThree.jsでキャラクターを導入する際に直面した問題の記録
入力したパスワードは秘密コメントの閲覧、修正、削除に使われます。
TL;DR
エンジンの限界を理解する: Blenderでの完璧な構造が、Three.jsにおいても正解であるとは限りません。
結果中心のリギング: リギングは単に「持ってくる」ものではなく、エンジン用の「結果データへ変換する」プロセスです。
設計の核: すべての判断基準は「ウェブのランタイムでいかに効率的に動作するか」に置くべきです。
今回の記事に登場する2つのプロジェクトのポートフォリオは、Funda、Chaen でご覧いただけます。
FUNDAプロジェクトを進める中で、サービスの核心的な価値である「楽しさ」と「ゲーミフィケーション」を視覚的に投影する媒介が必要でした。単に画面を飾る装飾ではなく、ユーザーと情緒的に交流し、学習動機を付与するUX構成要素としてキャラクターを企画することになりました。
キャラクターは3つの基準で選定しました。
最初はサービス名「Funda(パンダ)」と名前が似ているパンダを考えてみました。パンダは可愛さをアピールするには最適でしたが、「知的な」「学習」というカテゴリーよりは「ゆったりとした」イメージが強く、保留にしました。
次にカワウソも考えましたが、やはり人間型に近い外見の方がアニメーションを考慮しやすいと思い、候補に残しました。
最後にキツネは知的なイメージ、開発者、学習のすべてと相性が良かったのですが、オレンジ色がブランドカラーと衝突するため悩み、最終的に同じキツネ科であるホッキョクギツネを選択することになりました。
![]() |
|---|
本格的な3D作業に先立ち、キャラクターの印象と動線を決定づけるために簡単なスケッチと2D実装を先行しました。実際、2Dの成果物だけでもサービス運営自体には大きな問題はないように見えましたが、クオリティの面で「あと一歩」足りないというフィードバックを受けました。
当時の作業時間(リソース)と成果物のクオリティの間で多くの悩みがありましたが、今回のプロジェクトのキーポイントは結局「キャラクター」だと判断しました。プロジェクトの成否を分ける重要な要素であれば、時間をかけてでも確実なクオリティを確保すべきだと考え、3Dキャラクターへの挑戦が始まりました。
人間のキャラクターを2回実装した経験をベースに進めました。デフォルメを何度も修正しながらディテールを詰めました。テクスチャリングの大部分はノイズを使用しましたが、ディテールが必要な部分は Poly Haven からインポートして使用しました。
| 1次ヘッド | 2次デフォルメ | 3次デフォルメ |
|---|---|---|
![]() | ![]() | ![]() |
特に最も力を入れた部分は「目」でした。ホッキョクギツネ特有の神秘的な雰囲気を出すために紫と白を組み合わせ、TransmissionとGlass BSDFを活用してガラスのような屈折効果を実装しました。しかし、このノード構造が後にThree.js環境で大きな技術的課題となりました(後述)。

リギングは、動物型のヘッド + 人間型のボディ構造をベースにRigifyを活用しました。マフラーとしっぽに揺れを出すために「Damped Track」も適用しました。
表情は顔のメッシュ、まつげ、眉毛、舌、歯にそれぞれシェイプキーを付与してコントロールしました。
| ボディ・リギング | 表情(Facial Expression) |
|---|---|
![]() | ![]() |
Blender内では完璧に動作し、あとは書き出すだけの状態でした。しかし、問題はその後に発生しました。
ウェブにアップしてみると、モディファイアが全く適用されていない状態でレンダリングされました。Three.jsは毎フレームのモディファイアをリアルタイムで計算する構造をサポートしていません。Blenderに戻ってモディファイアを「適用(Apply)」しなければなりませんでしたが、シェイプキーがあるメッシュはサブディビジョンサーフェス(Subdivision Modifier)を正常に適用できないというエラーが発生しました。
![]() | ![]() |
|---|
すでにシェイプキーを5回以上作り直した状態だったので、最初からやり直す時間はなく、結局外部プラグイン(SKKeeper)で強制的に適用することにしました。シェイプキーが消えたというエラーが多数発生し、壊れたら作り直す覚悟でThree.jsに移したところ、画面上の問題は見当たらず、モディファイアもうまく適用されていました。しかし、正攻法ではないため不安は残りました。
RigifyベースのIK、コンストレイント構造がThree.jsでは完全に無視されました。意図した動きが一つも実現されませんでした。Deformボーンだけを書き出した時は当然の結果でしたが、他のMCH、ORGボーンをすべて書き出して認識させようとしても、全く動きませんでした。
Blenderではコントローラーを通じてリアルタイムでボーンを制御できましたが、Three.jsではコントローラーそのものが無視されてしまうのです。
Rigifyボーンを持ってきて直接コントロールしようとしましたが、思い通りに動作しませんでした。何度もボーンを書き出し直してみましたが、時間がかかりすぎたため、5つのデバッグ用スクリプトを書いて順番に検証しました。
結果:ボーンはすべて存在し、マッピングも完了し、座標値も動いているのに、画面には反映されませんでした。
結論として、Rigifyは構造が複雑すぎて、Three.jsから直接コントロールするのは不可能だということが分かりました。結局、Rigifyと直接的な連結がなく、ヘッドボーンにペアレントした目玉、眉毛、舌などの要素だけが動かせる状態でした。
キャラクターの生動感を決定づける「目」を実装する際、Blenderでの精巧なノード設計がウェブ(Three.js)では意図通りに動作しないという技術的ボトルネックに直面しました。
しかし、GLBでエクスポートしてThree.jsで確認したところ、意図は完全に崩れました。
ベイクした画像だけではウェブ上で物理的なガラス質感を再現できないと判断し、ランタイムで直接 MeshPhysicalMaterial を注入する方式を選択しました。
MeshPhysicalMaterial は、Three.jsで最も物理的に正確な表現をサポートします。IOR(屈折率)、Transmission、Coatなどのパラメータを直接設定することで、BlenderのGlassマテリアルに近い結果を再現できました。
| Before | After |
|---|---|
![]() | ![]() |
コンストレイントベースのリギングを排除し、すべてのアニメーションを**フレーム単位のキーフレームデータとしてベイク(焼き付け)**しました。
Dope SheetとAction Editorで各動作を整理し、これをNLA (Non-Linear Animation) Editorでプッシュダウン(Push Down)してトラックを管理しました。エクスポート前にアクション名を整理することで、コードから actions['Greeting'].play() のように迷わず制御できるようになりました。
この2つを分離することで、ようやくインタラクションが可能になりました。
useFixSkinnedMesh カスタムフックSubdivisionの強制適用後、Three.jsで新たな問題が発生しました。
これらを解決するためにカスタムフックを作成しました。
プロジェクトを進める中で、視線追跡(Eye Tracking)やクリック時に倒れるアニメーションなどを追加しました。ここで最も悩んだのは、**「どこまでをベイクするか」**でした。
すべての動きをBlenderでベイクして持ってくれば実装は簡単です。しかし、それでは**リアルタイムなインタラクションが不可能な、単なる「再生ボタンを押すだけのGIF」**と変わりません。私が求めていたのは、状況に合わせて様々な表情の組み合わせを柔軟に使い分けることでした。
そこで、次のようなハイブリッド戦略を立てました。
ハイブリッド方式を採用したことで、異なる制御主体(ベイクされたデータ vs マウス座標)が衝突する問題が発生し、2つの例外処理を適用しました。
特定の演出(例:倒れる、挨拶する)が再生されている間も視線がマウスを追ってしまうと、アニメーションデータが定義したボーンの回転値とリアルタイムの lookAt ロジックが衝突し、画面が震えたり不自然な動きになります。これを防ぐためにアニメーション実行中は視線追跡を一時遮断するフラグを設計しました。
表情は重なることがあります。「笑い」の重みが1の状態で「怒り」を1にすると、メッシュが奇妙に歪みます。これを防ぐため、新しい表情グループに移行する際は、以前のグループのインフルエンス(Influence)値を0に正確に初期化した後、新しい値を注入するロジックを構築しました。
多数のページに3Dキャラクターが配置される構造だったため、モバイルのパフォーマンスが重要でした。
メッシュ自体が高ポリゴンだったため、ランタイムの最適化には限界がありました。特に LOD (Level of Detail) を適用して、カメラとの距離に応じてポリゴン数を自動調整する仕組みを導入していれば、よりパフォーマンスを引き出せたはずです。
1番目のキャラクター「Fundy」での経験は大きな教訓となりました。ウェブ環境の3Dは単に「綺麗なモデル」ではなく、「徹底的に計算されたアセット」であるべきだという点です。今回の人物キャラクターでは、設計段階から以下の最適化パイプラインを適用しました。
面を闇雲に増やす代わりに、Weighted Normalと High Poly Normal Map Baking 戦略を使用しました。全体のポリゴン数は1万以下に抑えつつ、ハイポリモデルのディテールをノーマルマップとして焼き付けることで、滑らかな質感を再現しました。
屈折演算(Glassマテリアル)は重すぎるため、今回は目玉のメッシュを一つにまとめ、ディテールをテクスチャとして焼き込みました。屈折の代わりにフォトショップでハイライトを描き込み、視覚的な満足度とパフォーマンスを両立させました。
過去にはパーツごとに数十枚の画像を使用していましたが、今回は類似マテリアルをまとめて 2048pxのアトラス3枚 に統合しました。
さらに、PBRに必要な Occlusion (R), (G), (B) を一つの画像の各チャンネルにパッキングする を作成しました。

sharpライブラリを使用して、複数のテクスチャを一括処理するスクリプトを作成しました。
色変更が頻繁な髪やコートは白黒でベイクし、Three.jsのランタイムで material.color.set() により色を注入。メモリ節約とカスタマイズの柔軟性を確保しました。
BlenderからDraco圧縮で書き出し、モデル容量を80%以上削減。ネットワークのロード時間を劇的に短縮しました。
今回の作業で得た結論は、3Dキャラクター制作はモデリングの問題ではなく、実行環境に合わせた構造設計の問題であるということです。
Blender内で完璧な構造がThree.jsでは無意味なこともあります。逆にBlenderで少し不便でも、エンジン側で制御しやすい設計にすれば、結果は格段に良くなります。最初の試行錯誤があったからこそ、2番目のキャラクターは最初から正しく作ることができました。寄り道をした経験が、最終的に正しい設計基準を作ってくれたのです。

![]() |
|---|
=== 🔍 DEBUG 1: ボーン存在確認 ===
debug1.tsx:45 ✅ hand_ik.L: {type: 'Bone', isBone: true, rotation: 'x:1.64 y:-0.03 z:-1.66'}
debug1.tsx:45 ✅ hand_ik.R: {type: 'Bone', isBone: true, rotation: 'x:1.64 y:0.03 z:1.66'}
...
debug1.tsx:147 👀 視線追跡: {headCommon_x: '-1.571', headCommon_y: '0.000', pointer: 'x:-0.42 y:0.35'}
debug1.tsx:160 🔄 自動回転: {head_y: '-0.054'}
...
debug1.tsx:121 🦊 しっぽ振り: {wag: '1.00', tail_y: '0.30', tail1_y: '0.25'}
debug1.tsx:105 💃 腰振り: {sway: '-0.100', hips_rotation_z: '-0.100', hips_position_x: '-0.005'}// 眼球メッシュのマテリアルをランタイムで差し替え(概念)
// GLBロード後、眼球に該当するメッシュを探して
// MeshPhysicalMaterialに直接交換
// - transmission: 1 (光が透過する屈折効果)
// - ior: 1.45 (ガラスの屈折率)
// - thickness: 0.5 (マテリアルの厚み)
// - roughness: 0 (滑らかな表面)
const eyeTextures = useTexture(
enhancedEyes
? [
'/character/textures/eyes_color.png',
'/character/textures/eyes_roughness.png',
'/character/textures/eyes_transmission.png',
]
: [],
);
const eyeMaterial = useFundyEyeMaterial(eyeTextures);
useApplyEyeMaterials({ enhancedEyes, eyeMaterial, nodes, materials });
/**
* 眼球/虹彩のマテリアルをモデルに適用するフック
*/
export function useApplyEyeMaterials(params: {
enhancedEyes: boolean;
eyeMaterial?: THREE.Material;
nodes: GLTFResult['nodes'];
materials: GLTFResult['materials'];
}) {
const { enhancedEyes, eyeMaterial, nodes, materials } = params;
useEffect(() => {
const eyeMat = enhancedEyes && eyeMaterial ? eyeMaterial : materials.eye;
const irisMat = materials.iris;
const eyeMeshes = [nodes.Sphere001, nodes.Sphere003].filter(
(mesh): mesh is THREE.Mesh => !!mesh && (mesh as THREE.Mesh).isMesh,
);
// ...各メッシュにマテリアルを適用し needsUpdate = true を設定
}, [enhancedEyes, eyeMaterial, materials.eye, materials.iris, nodes]);
}// useFixSkinnedMesh.ts
export function useFixSkinnedMesh(scene: THREE.Group | THREE.Object3D) {
useEffect(() => {
if (!scene) return;
scene.traverse(child => {
if (child instanceof THREE.SkinnedMesh) {
// frustumCulled = false : 画面外でもレンダリングに含める
child.frustumCulled = false;
// Geometryの境界ボックスを手動補正
const geo = child.geometry;
if (geo?.attributes?.position) {
if (!geo.boundingBox) geo.computeBoundingBox();
if (!geo.boundingSphere) geo.computeBoundingSphere();
}
// MorphTargets(表情)の初期化
if (child.morphTargetInfluences && child.morphTargetDictionary) {
const morphCount = Object.keys(child.morphTargetDictionary).length;
child.morphTargetInfluences = new Array(morphCount).fill(0);
}
}
});
}, [scene]);
}requestAnimationFrame を活用し、バックグラウンドでの不要な演算を削減。renderer.setPixelRatio(Math.min(devicePixelRatio, 2)) で過度な負荷を防止。antialias: false に設定。モバイルはDPRが高いため、オフにしても体感的なクオリティ差は小さく、負荷を大幅に軽減できます。[Action Stash] などの不要なデータを削除してエクスポート。SkeletonUtils.clone を使用。単純な clone() ではボーン構造が壊れるため、ジオメトリを共有しつつスケルトンのみ独立して複製する構造を実装しました。Metallic// ORM合成スクリプトの核心
const orm = Buffer.alloc(SIZE * SIZE * 3);
for (let i = 0; i < SIZE * SIZE; i++) {
orm[i * 3 + 0] = ao[i]; // R: AO
orm[i * 3 + 1] = rough[i]; // G: Roughness
orm[i * 3 + 2] = metal[i]; // B: Metallic
}
await sharp(orm, { raw: { width: SIZE, height: SIZE, channels: 3 } })
.png({ colours: 256 }) // 容量削減
.toFile(`${name}_ORM.png`);オンボーディングの改善からインフラ監視、リアルタイム通信、그리고 3Dキャラクターの最適化まで、7週間にわたるグループプロジェクトの完走記録
OSCCA チャレンジ期間に Githru プロジェクトへ参加し、Issue 提案や初めての Pull Request を経験した記録。

6週間のグループスプリントを通じて学んだ設計の試行錯誤、協業のノウハウ、そしてシニアからのフィードバックによる成長の記録

UI アーキテクチャパターン(MVC · MVP · MVVM · Flux)の登場経緯と長所短所、比較分析を記録
fundaプロジェクトと個人ポートフォリオにThree.jsでキャラクターを導入する際に直面した問題の記録
ベーシックからグループプロジェクトまで、てCS知識を体得し、AIエンジニアリングと設計の本質を悟りながら成長した7ヶ月間の軌跡
オンボーディングの改善からインフラ監視、リアルタイム通信、그리고 3Dキャラクターの最適化まで、7週間にわたるグループプロジェクトの完走記録
アイデア選定からシニアフィードバックを経てMVP実装まで、プロジェクトの土台を築く過程で経験した試行錯誤と技術的挑戦の記録
ベクトルの長さを1にする「正規化」の本質を、ゲームの移動ロジックやBlenderの「Apply Scale」の事例から紐解きます。
6週間のグループスプリントを通じて学んだ設計の試行錯誤、協業のノウハウ、そしてシニアからのフィードバックによる成長の記録
Boostcampメンバーシップで経験した10週間の学習スプリントを振り返り、技術的な学び、設計の悩み、燃え尽き、そしてAI活用についてまとめた記録。
Boostcamp Challenge 期間の振り返り。毎日のミッション、CS学習、ピアフィードバック、チーム活動、そして AI と共に成長する学習方法についてまとめました。
関数型プログラミングとオブジェクト指向プログラミングの長所と短所、およびリアクトで関数型を選択した理由についての探求の記録