個人ブログのようなものです。とくにジャンルはありません。
アシュリー、魔法はよいこになってから!(全3巻)
商品ページ
Amazon
※非収益広告
記事の概要
three.js・three-vrm.jsを使用して、VRoidをWebページ上に表示する Part-03
作成日:2025-04-20
最終更新日:2025-04-20
記事の文字数:8734
VRoidWebツール情報技術
本記事のトピック
  • 概要
  • VRoidStudioとポーズが異なってしまう問題
  • vroidposeデータから指ポーズを指定する
  • VRoidを撮影して保存する
  • サンプルコードはこちら
three.js・three-vrm.jsを使用して、VRoidをWebページ上に表示する Part-03
概要

three.js・three-vrm.jsを使用して、VRoidをWebページ上に表示する方法を載せています。
こちらはPart-03となり、Part-01Part-02の内容を前提としたものになっています。

今回は以下のコード例を載せています。

今回紹介するコード例

  • VRoidStudioとポーズが異なってしまう問題
  • vroidposeデータから指ポーズを指定する
  • VRoidを撮影して保存する

また、今回も例によって、実際に動作確認できる(であろう)サンプルコードを下部に載せています。

VRoidStudioとポーズが異なってしまう問題

Part-02でも触れましたが、VRoidStudioでvroidposeデータを読み込んだ時とポーズが異なってしまう問題が起こります。特に背骨のカーブが極端になります。

Boothで上げているこちらの中の「異議あり」ポーズが分かりやすいです。

本処理でvroidposeをVRoidに設定する際、vroidposeデータ内のBoneDefinitionを使っていますが、実はVRoidStudioではこの部分は使っていないようです。
すなわちこのBoneDefinitionというものはVRoidStudio以外で使えるように書き出しているのだと思いますが、その中の「SpineControlPointDeltaPosition」というパラメータはボーンに上手く設定できず、それがVRoidStudioと差が出てしまう原因、なのだろうと思います。

なので、この差分を上手く埋めないといけないのですが、「SpineControlPointDeltaPosition」がどのように使われるか分からないので、これを追うのは難しいです。

以下の処理では、各ボーンに個別に補正を加えてなるべくVRoidStudioに近づけています(やはりポーズによってカーブ具合が変わってしまっていそうなので寸分の狂いもなくとなると、ちゃんと原因調査が必要そうです)。

const BONE_CORRECTION_MAP_X = { Spine: 10, Chest: -18, UpperChest: -9, Neck: 15, Head: 0, LeftUpperLeg: 2, RightUpperLeg: 2, LeftShoulder: 16, RightShoulder: 16, } // ポーズデータを読み込んだら、それをvrmに適用する document.getElementById("poseDataInput").addEventListener('change', (event)=>{ const file = event.target.files[0]; if(file && vrm){ // ファイルを読み込む const reader = new FileReader(); reader.onload = () => { const poseData = JSON.parse(reader.result); const bonePose = poseData.BoneDefinition; // ボーンごとに保持する for (const boneName in bonePose) { const lowerBoneName = boneName.charAt(0).toLowerCase() + boneName.slice(1); // ポーズデータとVRMボーンの名前を同じにするために、ローワーキャメルにする // vrmにポーズを適用 const vrmBone = vrm.humanoid.getNormalizedBoneNode(lowerBoneName); // 補正する if(vrmBone){ let quat = bonePose[boneName]; if(BONE_CORRECTION_MAP_X[boneName]){ // X軸回転だけのクォータニオンを作成 const correctionQuat = new THREE.Quaternion().setFromEuler( new THREE.Euler(THREE.MathUtils.degToRad(BONE_CORRECTION_MAP_X[boneName]), 0, 0, 'XYZ') ); // 合成(回転順:original → xOnlyQuat) const originalQuat = new THREE.Quaternion(quat.x, quat.y, -quat.z, -quat.w); quat = correctionQuat.premultiply(originalQuat); // ポーズに反映 vrmBone.quaternion.set(quat.x, quat.y, quat.z, quat.w); } else { // ポーズに反映 vrmBone.quaternion.set(quat.x, quat.y, -quat.z, -quat.w); } } } }; reader.readAsText(file); } });

「BONE_CORRECTION_MAP_X」という定数で、各ボーンのX回転角に補正を掛けています。
それをquaternionにして、premultiplyで元のボーン設定と合成しています。それだけです。

なお、前回パートでは「getRawBoneNode」を使っていましたが、今回は「getNormalizedBoneNode」を使っています。

そのため、前回パートで書いていたコードをベースにする場合、以下の部分を削除する必要があります(理由も前回パートに書いてある通りです)。

// 姿勢はvroidposeに従って制御するので、自動制御はOFFにする vrm.humanoid.autoUpdateHumanBones = false;
vroidposeデータから指ポーズを指定する

前項で触れた通り、基本的なボーン設定はvroidposeデータ内の「BoneDefinition」に入っています。

ただ、指(手)の形だけはVRoidStudioで指定したテンプレートが保存されており、ボーンとしては保存されていません
なのでこのテンプレートをボーンの値に変換する必要があります(他のボーンはVRoidStudioでも使えるように書き出してくれているのに指だけは出してくれてないのは何か意図がありそうだけど……)。

vroidposeデータ内の以下が指の形に関係ある部分です。
・LeftHandAnimationName:左手に適用しているテンプレート
・LeftHandAnimationWeight:左手のテンプレートを適用している度合い
・RightHandAnimationName:右手に適用しているテンプレート
・RightHandAnimationWeight:右手のテンプレートを適用している度合い

これを以下のコードのようにボーン変換します。

/** * VRoidStudioでの手のテンプレートから手のボーンに反映するための値 * https://ringo.ciao.jp/cont/view/20240305/ の値を一部改変して利用 */ const HAND_TEMPLATE = { Natural: { Thumb: { Metacarpal: [0, -0.11, 0.3], Proximal: [0, -0.42, 0], Distal: [-0.11, 0.16, -0.09], }, Index: { Proximal: [0, -0.05, 0.14], Intermediate: [0, 0, 0.18], Distal: [0, 0, 0.18], }, Middle: { Proximal: [0, 0, 0.38], Intermediate: [0, 0, 0.18], Distal: [0, 0, 0.36], }, Ring: { Proximal: [0, 0.05, 0.59], Intermediate: [0, 0, 0.31], Distal: [0, 0, 0.33], }, Little: { Proximal: [0, 0.06, 0.72], Intermediate: [0, 0, 0.41], Distal: [0, 0, 0.76], }, }, Grip: { Thumb: { Metacarpal: [0, -0.3, 0.5], Proximal: [0, -0.8, 0], Distal: [0, -1.5, 0], }, Index: { Proximal: [0, 0, 1.2], Intermediate: [0, 0, 1.5], Distal: [0.5, 0, 1.5], }, Middle: { Proximal: [0, 0, 1.2], Intermediate: [0, 0, 1.5], Distal: [0, 0, 1.5], }, Ring: { Proximal: [0, 0, 1.2], Intermediate: [0, 0, 1.5], Distal: [0, 0, 1.5], }, Little: { Proximal: [0, 0, 1.2], Intermediate: [0, 0, 1.5], Distal: [0, 0, 1.5], }, }, Open: { Thumb: { Metacarpal: [0, 0.05, -0.05], Proximal: [0, 0.1, 0], Distal: [0, 0.15, 0], }, Index: { Proximal: [0, 0.2, 0], Intermediate: [0, 0, 0], Distal: [0, 0, 0], }, Middle: { Proximal: [0, 0, 0], Intermediate: [0, 0, 0], Distal: [0, 0, 0], }, Ring: { Proximal: [0, -0.2, 0], Intermediate: [0, 0, 0], Distal: [0, 0, 0], }, Little: { Proximal: [0, -0.4, 0], Intermediate: [0, 0, 0], Distal: [0, 0, 0], }, }, Good: { Thumb: { Metacarpal: [0, 0.1, -0.1], Proximal: [0, 0.2, 0], Distal: [0, 0.3, 0], }, Index: { Proximal: [0, 0, 1.2], Intermediate: [0, 0, 1.5], Distal: [0.5, 0, 1.5], }, Middle: { Proximal: [0, 0, 1.2], Intermediate: [0, 0, 1.5], Distal: [0, 0, 1.5], }, Ring: { Proximal: [0, 0, 1.2], Intermediate: [0, 0, 1.5], Distal: [0, 0, 1.5], }, Little: { Proximal: [0, 0, 1.2], Intermediate: [0, 0, 1.5], Distal: [0, 0, 1.5], }, }, V: { Thumb: { Metacarpal: [0.53, -0.3, 0.5], Proximal: [0, -0.8, 0], Distal: [0, -1.2, 0], }, Index: { Proximal: [0, 0.5, 0], Intermediate: [0, 0, 0], Distal: [0, 0, 0], }, Middle: { Proximal: [0, 0, 0], Intermediate: [0, 0, 0], Distal: [0, 0, 0], }, Ring: { Proximal: [0, 0, 1.2], Intermediate: [0, 0, 1.5], Distal: [0, 0, 1.5], }, Little: { Proximal: [0, 0, 1.2], Intermediate: [0, 0, 1.5], Distal: [0, 0, 1.5], }, }, Gao: { Thumb: { Metacarpal: [0, 0, 0.5], Proximal: [0, 0, 0], Distal: [0, -1.5, 0], }, Index: { Proximal: [0, 0.2, -0.4], Intermediate: [0, 0, 0.8], Distal: [0, 0, 1], }, Middle: { Proximal: [0, 0, -0.4], Intermediate: [0, 0, 0.8], Distal: [0, 0, 1], }, Ring: { Proximal: [0, -0.2, -0.4], Intermediate: [0, 0, 0.8], Distal: [0, 0, 1], }, Little: { Proximal: [0, -0.4, -0.4], Intermediate: [0, 0, 0.8], Distal: [0, 0, 1], }, }, Index: { Thumb: { Metacarpal: [0.5, -0.3, 0.5], Proximal: [0, -0.8, 0], Distal: [0, -1.2, 0], }, Index: { Proximal: [0, 0, 0], Intermediate: [0, 0, 0], Distal: [0, 0, 0], }, Middle: { Proximal: [0, 0, 1.2], Intermediate: [0, 0, 1.5], Distal: [0, 0, 1.5], }, Ring: { Proximal: [0, 0, 1.2], Intermediate: [0, 0, 1.5], Distal: [0, 0, 1.5], }, Little: { Proximal: [0, 0, 1.2], Intermediate: [0, 0, 1.5], Distal: [0, 0, 1.5], }, }, }; // ポーズデータを読み込んだら、それをvrmに適用する document.getElementById("poseDataInput").addEventListener('change', (event)=>{ const file = event.target.files[0]; if(file && vrm){ // ファイルを読み込む const reader = new FileReader(); reader.onload = () => { /* ~通常(指以外)のボーン読み込み処理は省略~ */ // 指の形を取り込む setHandPose(poseData.LeftHandAnimationName, poseData.LeftHandAnimationWeight); setHandPose(poseData.RightHandAnimationName, poseData.RightHandAnimationWeight); }; reader.readAsText(file); } }); // 指の形を取り込む function setHandPose(handAnimationName, weight){ if(!handAnimationName) return; const handSide = handAnimationName.includes('L_') ? 'left' : 'right'; const handName = handAnimationName.replace(/^[LR]_/, ""); const handConfig = HAND_TEMPLATE[handName]; for(const finger in handConfig){ for(const joint in handConfig[finger]) { const boneName = `${handSide}${finger}${joint}`; const [rx, ry, rz] = handConfig[finger][joint]; const euler = (handSide === 'left') ? new THREE.Euler(rx * weight, ry * weight, rz * weight) : new THREE.Euler(rx * weight, -ry * weight, -rz * weight); const quat = new THREE.Quaternion().setFromEuler(euler); // vrmにポーズを適用 const vrmBone = vrm.humanoid.getNormalizedBoneNode(boneName); if(vrmBone){ vrmBone.quaternion.set(quat.x, quat.y, -quat.z, -quat.w); } } } }

左手と右手でほぼ同じ処理なので、「setHandPose」というメソッドでまとめています。

まずHAND_TEMPLATEというところで、「手の形のテンプレート」と「指のボーン」の対応表を作成しておきます。この値はコード中にも書いていますが、こちらの方が出した値をベースにしています。

この対応表に従い、「手の形のテンプレート」を「指のボーン」に変換してあとはそのボーンをVRoidに適用すればOKです。

ボーンに変換する前に「LeftHandAnimationWeight / RightHandAnimationWeight」のWeight値を掛けるのを忘れないようにしましょう。上のコードにある通り、Weight値はそのまま掛けてしまって良さそうです。

VRoidを撮影して保存する

Canvasに表示されたVRoidを画像として保存する方法についてです。

単にCanvasに表示された内容を、よくあるやり方で画像として保存すれば良い……と思いきや一工夫必要だったので一応書いておきます。

const canvasElm = renderer.domElement; // renderer.domElementにcanvas要素が入ってます // a タグを作成して、ダウンロードをトリガー const link = document.createElement("a"); renderer.render(scene, camera); // バッファ解放されている可能性が高いため、一度明示的に描画する! link.href = canvasElm.toDataURL("image/png"); link.download = `${Date.now()}.png`; document.body.appendChild(link); link.click(); document.body.removeChild(link);

上の処理の殆どは、Canvasに表示している内容を画像として保存するコードとしてよくあるモノだと思いますが、「renderer.render(scene, camera);」という部分を追加しています。

どうやらrenderer(THREE.WebGLRenderer)というものは、Canvasに書き込んだ後バッファを自動的に解放するようになっており、解放された状態で「renderer.domElement.toDataURL("image/png");」とやっても何もない空っぽの画像が書き出されてしまいます(タイミングによっては稀に上手く書き出せると思いますが)。

一応バッファの自動解放を防ぐことも簡単にできるようですが、そうするとバッファが圧迫されてパフォーマンスに影響があると思われるため、なるべく取りたくない手段ではあります。

なので、「renderer.domElement.toDataURL("image/png");」する前に、「renderer.render(scene, camera);」でもう一度明示的にレンダリングしておくことで空っぽの画像が出力されるのを防いでいます。

サンプルコードはこちら

サンプルコードはPart-01と同じ場所(GitHub)にあります。

コメントログ
※コメントは最新50件が表示されます
コメント投稿




画面下部の「コンタクト」からも連絡可能です。
タイム・リープ<上> あしたはきのう (電撃文庫)
商品ページ
Amazon
※収益広告
管理人作品宣伝
AIの考えた怖い話-Part01
動画 / 最終更新:2024-11-28
怪談系の怖い話を載せています。特に設定部分は人の手が入ってますが、なるべく生成AI(C…怪談系の怖い話を載せています。特に設定部分は人の手が入ってますが、なるべく生成AI(ChatGPT)を利用して書いています。

YouTubeで閲覧するニコニコ動画で閲覧する
利用素材等の詳細情報
VRoidポーズ集-Part03
3Dモデル / 最終更新:2024-12-03
VRoidのポーズデータ(vroidpose)集です。 写真とかによくありそうなポーズ…VRoidのポーズデータ(vroidpose)集です。 写真とかによくありそうなポーズが中心に入っています。

Boothで閲覧する
利用素材等の詳細情報
作品一覧はこちら
関連ページ
three.js・three-vrm.jsを使用して、VRoidをWebページ上に表示する Part-03
概要 three.js・three-vrm.jsを使用して、VRoidをWebページ上に表示する方…
three.js・three-vrm.jsを使用して、VRoidをWebページ上に表示する Part-02
概要 three.js・three-vrm.jsを使用して、VRoidをWebページ上に表示する方…
three.js・three-vrm.jsを使用して、VRoidをWebページ上に表示する Part-01
概要 three.js・three-vrm.jsを使用して、VRoidをWebページ上に表示する方…
GIF / APNG(アニメーション付きPNG)ファイル解析ページ
ファイル読込・操作 以下に調べたいファイルを読み込ませてください。 ファイル情報 カラーパレットを…
gifler.js仕様メモ
本ページの趣旨 「gifler.js」という、gifアニメーションをcanvasに簡単に表示できる…
普通の文章をホラーっぽく変換
テキスト:ホラー変換 変換する度に結果が変わります 変換回数: 変換する 変換結果 変換する…
【プログラミング】実例で分かるかもしれない再帰処理
本ページは以下動画の台本を書き起こしたものです 解説の趣旨・方向性 皆さん、こんばんは今回はプログ…
管理人について
「ふじみ むい」と言います ひょんなことから肉体を得たのでその肉体を使って活動をしています。 とい…
SNSツイート一元化対応(Twitter・Misskey・Mastodon・Bluesky)-公開
概要 SNSツイートを一元化するためのツールを作成しています(古い記事ですが、こちらのページで紹介…
VRoidを使う前に絶対に表情はいじった方が良いと思うという話
デフォルトのVRoidの表情はすごいVRoidっぽい VRoidStudioでは「楽しい」「悲しい…
管理人ツイート
本サイトのタグ一覧
NovelAIR18VRoidWebサイト作成Webツールととモノ。アークナイツアークナイツ-ステージ攻略日記アズールレーンアズールレーン-日記ウマ娘ギャラリーゲームデビラビローグプログラミングホラーポケットタウン怪談気ままな日記情報技術情報技術-WebAPI知的財産権統合戦略白夜極光本サイトについて魔王スライム様がんばる!漫画
人気記事
メイド・オブ・ザ・デッド-攻略お助け情報
ネタバレ注意! 本ページは『メイド・オブ・ザ・デッド』の情報を記録しているものです。 攻略の参考に…
683.8341 pt
ポケットタウン_パズル一覧
グレーのピースの数 (Number of gray pieces):検索グレーピースの数を入力して、…
633.1395 pt
剣と魔法と学園モノ。2G - パーティ編成確認ツール
ツール概要 ととモノ。2Gのパーティ編成を考える際に使うツールです。 あくまでストーリークリアまで…
157.3448 pt
アークナイツ-昇進2率ランキング
アークナイツのTier表を作る際の備忘録です こちらのページで、昇進2率を基にTier表を作ろうと…
86.4865 pt
アークナイツ-常設商品-理性換算
概要 "常設商品でお得な商品はどれか"というのを理性に換算して一覧化したものとなります。 絶対的に…
84.5000 pt
ロックマンエグゼ3-バグのかけら必要数まとめ-
バグのかけら必要数 必要数 これぐらいあれば足りるはず。 コレクト要素に関わる部分だけなら、ギガチ…
75.4464 pt
アークナイツ:統合戦略#5「サルカズの炉辺奇談」-「心打つ鍵鞭」攻略お助け情報
概要 統合戦略#5「サルカズの炉辺奇談」の公式サイトからできる「心打つ鍵鞭」についての、攻略お助け…
53.0816 pt
本サイトについて
本サイトの概要 概要 個人ブログのようなものです。とくにジャンルはありません。 本サイト内の情報に…
50.0000 pt
最新記事
アークナイツ-2025大感謝祭・春商品-理性換算
概要 "「2025大感謝祭・春商品」でお得な商品はどれか"というのを理性に換算して一覧化したものと…
本サイトについて
本サイトの概要 概要 個人ブログのようなものです。とくにジャンルはありません。 本サイト内の情報に…
ヒカルの碁で、なぜ佐為は消えたのか
概要 ヒカルの碁で佐為が消えた理由について、「ヒカルの才能を目覚めさせるという役割を終えたから」と…
剣と魔法と学園モノ。3 - 各ステータス最高・最低となる「種族」「メイン学科」「サブ学科」の組合せ
概要 「ととモノ。3」で各ステータス最高・最低となる「種族」「メイン学科」「サブ学科」の組合せを一…
世界征服彼女 -Switch
商品ページ
Amazon
※収益広告