Three.js – ShaderMaterialで、ブレンドシェイプ(MorphTarget)アニメーション対応
2025.10.15
うぉ。
超久々に記事を書くような。。
気がつけば2ヶ月も空いたのか。
その間、色々やった気がするなぁ。。
Rust × TauriでがっつりGUIツール作ったり、ファミリーデーのために子供でも遊べるゲームのためラズパイ5用いて BLEアプリケーション作ったり、Unityで使用するFBXファイルをglTF/ glbに変換してThree .js用いてレンダリングを行ったりと。。。
諸々、記事にしようと思っていたらもう2ヶ月ですよ。おそロシア。
でも、今回は残したいなと思い、重い腰とブラウザを(そんな時代ではない)立ち上げ記事を書こうと。
なんで書こうと思ったか?ですが、「結構ハマった」からです。
やられました。お陰様でShaderMaterialもGLSL Shaderもすっかり仲良くなれた気がします。
そう、表題でもあるように「ShaderMaterialで、ブレンドシェイプ(MorphTarget)アニメーション対応」しようとしたらやたら滅多とぶつかった訳です。
FBXファイルをglTF/ glbに変換
何はともあれですよ。
FBXモデルデータをウェブで利用できるように、glTF/ glbに変換しようではありませんか。
ということで、以前書いた記事の「汎用 3D mesh/model viewerを求め。と、簡単に、FBXファイルをglTF(glb)に変換ツールを求め。」で、書いた様に「FBX2glTF」を用いてファイルを変換します。
こちらから環境にあった実行ファイルをダウンロードしましょう。
例で、Macだと「FBX2glTF-darwin-x64」となります。
ダウンロード完了したら、実行権限を付与し、パスの通った任意のフォルダ(/usr/local/bin や、~/bin)に配置します。
chmod +x ~/Downloads/FBX2glTF-darwin-x64
その際、使いやすい様にRenameを行います。
sudo mv FBX2glTF-darwin-x64 /usr/local/bin/FBX2glTF
FBX2glTF -i model.fbx -b
- -i … 入力ファイル
- -b … バイナリ形式で出力 (.glb)
- -o … 出力ファイル名やフォルダ指定
FBX2glTF -i model.fbx -b -o output.glb
これで JSON形式の .gltf ではなく、単一バイナリの .glb が出力されます。
- –binary または -b … GLB形式で出力
- –embed … .gltf にバイナリや画像を埋め込む(JSON + base64一体型)
- –verbose … 詳細ログ
- –draco … Draco圧縮
面倒なのでツールで一括変換しよう
ルートディレクトリ指定で、ファイルを探索して変換するようにshell scriptを用意しましょう。
#!/bin/bash # === 使い方 === # ./convert_fbx.sh /path/to/search/root # ================= # ルートパスを引数から取得 ROOT_PATH="$1" # 引数が空ならエラー if [ -z "$ROOT_PATH" ]; then echo "使用方法: $0 <探索ルートパス>" exit 1 fi # 指定されたディレクトリが存在するか確認 if [ ! -d "$ROOT_PATH" ]; then echo "エラー: 指定したディレクトリが存在しません: $ROOT_PATH" exit 1 fi # 再帰的に .fbx ファイルを探索して処理 find "$ROOT_PATH" -type f -name "*.fbx" | while read -r fbx_file; do # 出力ファイルパスを決定(.fbx → .glb に置き換え) output_file="${fbx_file%.fbx}.glb" echo "変換中: $fbx_file" echo "出力先: $output_file" # 同階層に .glb を出力 FBX2glTF -i "$fbx_file" -b -o "$output_file" echo "完了: $(basename "$output_file")" echo "-----------------------------" done echo "全てのFBXファイルの変換が完了しました。"
上記のscript用いて、以下の様に任意の実行ルートパスを指定することで一括変換が行えます。
convert_fbx.sh ./path/to/target_root
さて下ごしらえは終わりで、いよいよ本題です。
ShaderMaterial
そもそもなんでShaderMaterialを使用するかですが、すでにUnityで制作を進めている方でShaderを用いた色替えなどの処理を行っているため、GLSL Shaderを作成しShaderMaterialでマテリアル化を行った訳で、fbxアニメーションなども適応させたShaderMaterial作成はこんな感じで収まっていたのですが、
return new THREE.ShaderMaterial({ uniforms, vertexShader, fragmentShader, transparent: blended, alphaTest: cutout ? (matInfo.CutoutThreshold ?? 0) : 0, depthWrite: !blended } as any)
ブレンドシェイプ(MorphTarget)アニメーションだけまだ適応されていなく、適応するためにChatGPTと相談して進めていたのですが、ShaderMaterialでブレンドシェイプ(MorphTarget)を適応させるには、こんな感じである程度Shader作成が必要になるって言うんですよ。
const mat = new THREE.ShaderMaterial({ uniforms: { ... }, vertexShader, fragmentShader, defines: { USE_MORPHTARGETS: '' }, morphTargets: true, });
その他にも
// definesの継承も勧められたり。。 const material = new THREE.ShaderMaterial({ uniforms: { ... }, vertexShader, fragmentShader, defines: { ...(defines ?? {}), USE_MORPHTARGETS: '', }, morphTargets: true, });
morphTargets: trueが定義されていないのか、IntelliJで定義エラーが出るし。。
シェーダー側はこのような流れ。
■ MorphTarget 属性を受け取る
#ifdef USE_MORPHTARGETS attribute vec3 morphTarget0; attribute vec3 morphTarget1; attribute vec3 morphTarget2; attribute vec3 morphTarget3; uniform float morphTargetInfluences[8]; #endif
■ 頂点位置を加算する
#ifdef USE_MORPHTARGETS transformed += (morphTarget0 - position) * morphTargetInfluences[0]; transformed += (morphTarget1 - position) * morphTargetInfluences[1]; transformed += (morphTarget2 - position) * morphTargetInfluences[2]; transformed += (morphTarget3 - position) * morphTargetInfluences[3]; #endif
ネットで検索してもそんな感じで見つかるし、この辺のソースを適応しても
export default /* glsl */` #ifdef USE_MORPHTARGETS // morphTargetBaseInfluence is set based on BufferGeometry.morphTargetsRelative value: // When morphTargetsRelative is false, this is set to 1 - sum(influences); this results in position = sum((target - base) * influence) // When morphTargetsRelative is true, this is set to 1; as a result, all morph targets are simply added to the base after weighting transformed *= morphTargetBaseInfluence; transformed += morphTarget0 * morphTargetInfluences[ 0 ]; transformed += morphTarget1 * morphTargetInfluences[ 1 ]; transformed += morphTarget2 * morphTargetInfluences[ 2 ]; transformed += morphTarget3 * morphTargetInfluences[ 3 ]; #ifndef USE_MORPHNORMALS transformed += morphTarget4 * morphTargetInfluences[ 4 ]; transformed += morphTarget5 * morphTargetInfluences[ 5 ]; transformed += morphTarget6 * morphTargetInfluences[ 6 ]; transformed += morphTarget7 * morphTargetInfluences[ 7 ]; #endif #endif `;
色々やったが、なんか意図していない形で移動や変形が行われる。
複数メッシュ組で複数マテリアルの組を作っていたりするので、こんな感じで修正が必要っても言って来るし。
const mat = hasMorph || hasSkin ? baseMaterial.clone() : baseMaterial; mat.morphTargets = hasMorph; mat.skinning = hasSkin; mat.defines = { ...(baseMaterial.defines || {}), ...(hasMorph ? { USE_MORPHTARGETS: '' } : {}), ...(hasSkin ? { USE_SKINNING: '' } : {}), }; mat.uniforms = THREE.UniformsUtils.clone(baseMaterial.uniforms); mat.needsUpdate = true;
しかも、AnimatorもTransformとMorphTargetも分けた方が良いなどとChatGPTは言って来るので対応していたらAnimatorも結構カオスな感じに。。。
// こちらでアニメーションを行っていたのですが、 additiveClip.blendMode = THREE.AdditiveAnimationBlendMode // 通常ボーンとマージしてみたり const additiveClip = THREE.AnimationUtils.makeClipAdditive( // ・名前に "Blend" を含む ・morphTrack が存在する trackは別のAnimationClipで作成したり const morphClip = new THREE.AnimationClip(clip.name, clip.duration, morphTracks)
原因は他にあり、GPU に attribute が届いてないのか? Morph を持つMeshは、独自の ShaderMaterial インスタンスを持たせる必要があるのか?などなどChatGPTに質問したら「それが原因だね」と常に原因を決めつけてくれるが、全部試すが全然うまくいかず、色々考えてましたが、
無理やり、StandardMaterialを適応すると意図した動きになっているので、
そもそも、Shaderだけが原因ではないかと探っていく中、babylonjsが提供してGoogle Chrome拡張のSpector.jsを使い、意図した動きとなるStandardMaterial用いたときのドローコールを確認していると、ついに見つけたのです!
これ。
#ifdef USE_MORPHTARGETS attribute vec3 morphTarget0; attribute vec3 morphTarget1; attribute vec3 morphTarget2; attribute vec3 morphTarget3; #ifdef USE_MORPHNORMALS attribute vec3 morphNormal0; attribute vec3 morphNormal1; attribute vec3 morphNormal2; attribute vec3 morphNormal3; #else attribute vec3 morphTarget4; attribute vec3 morphTarget5; attribute vec3 morphTarget6; attribute vec3 morphTarget7; #endif #endif #ifdef USE_MORPHTARGETS #ifndef USE_INSTANCING_MORPH uniform float morphTargetBaseInfluence; uniform float morphTargetInfluences[MORPHTARGETS_COUNT]; #endif uniform sampler2DArray morphTargetsTexture; uniform ivec2 morphTargetsTextureSize; vec4 getMorph(const in int vertexIndex, const in int morphTargetIndex, const in int offset) { int texelIndex = vertexIndex * MORPHTARGETS_TEXTURE_STRIDE + offset; int y = texelIndex / morphTargetsTextureSize.x; int x = texelIndex - y * morphTargetsTextureSize.x; ivec3 morphUV = ivec3(x, y, morphTargetIndex); return texelFetch(morphTargetsTexture, morphUV, 0); } #endif varying vec2 vUv; varying vec3 vNormal; varying vec3 vViewDir; void main() { vUv = uv; vec3 transformed = position; #ifdef USE_MORPHTARGETS transformed *= morphTargetBaseInfluence; for (int i = 0; i < MORPHTARGETS_COUNT; i++) { if ( morphTargetInfluences[i] != 0.0) transformed += getMorph(gl_VertexID, i, 0).xyz * morphTargetInfluences[i]; } #endif ....
これですべてが解決。
USEの定義も必要ないし。。
ずっっっっっとChatGPTは嘘(嘘ではないが)言ってくるし。。
ChatGPTが答えを出せずまま、解決したので勝手にAIに勝った日とする。
AIに頼るのもいいが、大事なのは「今、何が起きていてなんでそうなっているのか」を把握するのが非上に大事だと思った今日このごろでした。
しかし、少々時間を使ってしまった。。
ではではぁ。