こんにちはsuginokoです。
Qiitaで記事を投稿するのはなんと1年半ぶりになります。
suginokoはNoteとQiitaを運用しているのですが、Noteは日々仕事で感じたことや学んだことを、Qiitaは技術的な内容を投稿するようにしています。最近はマネジメント寄りの学びが多いのでNoteの更新が中心でした。
今回、久しぶりに技術記事を投稿します。Webフロントエンドに関する内容で、私が単に「やってみたかったから、遊んでみたかったから」という動機で取り組んだものです。正直なところ、技術記事という意味で二番煎じになるかもしれませんが楽しそうなので挑戦してみました。
やりたいこと
弊社 の安川社長を二次元キャラクター「ヤスカワ君」として登場させ、会話できるようにします。
- ヤスカワ君を描く
- ヤスカワ君を動かす
- ヤスカワ君をWebブラウザに表示する
- 自分の声をテキストとして表示する
- そのテキストをヤスカワ君を通して回答させる。(その際、ヤスカワ君は話しているようなアニメーションをする)
- ヤスカワ君の回答をテキストで表示させるだけでなく、ヤスカワ君のような音声でも出力させる(明るい感じの日本人青年男性声)
これだけで、ヤスカワ君と会話をしているような体験ができれば自分としては目標達成です。
前編では1~3の対応ついて書いていきますのでブラウザにヤスカワ君が出力されたら完了です。
※ローカル環境のみでの検証であり、デプロイは考慮してません。
使用ツール、環境
- Procreate
- Adobe Photoshop
- Spine(ESS) v4.2
- React v19
- PIXI.js(PIXI.js React)
- Google Cloud Speech-To-Text
- Google Cloud Text-To-Speech
- GAS(Google Apps Script)
- Gemini AI(テキスト生成)
今回Google系のサービスを多く採用したのは、Google I/O 2025で様々な発表があっため、せっかくならGoogleのAIを使って何かしたいなということ、また、私自身がAIを使った実装経験が少なかったことも理由の1つです。
アニメーションツールにSpineを選んだのは過去に「骨入れて動かすの楽しそう」という純粋な興味本位です。全て個人のアカウントで試してみました。
ヤスカワ君を描く
最初に断っておきますが、私は時々イラストを描くものの、画力は全くないのでクオリティはご容赦ください。
イラストはiPadアプリのProcreateで描きました。
アニメーションさせることを前提に、パーツを分けて描く必要があり、最終的なアニメーションに違和感がないように意識しました(画力がないため、最終的には人間の構造として違和感があると思いますがご了承ください)。
私が描いた社長のイラストがこちらです。
イラストを描く際はアニメーションを動かすパーツを意識してレイヤーを分けました。
おおよそ上記のようなレイヤー分けを行いました。
「顔」「腕1」「腕2」「太もも1」のような形です。
完成したイラストをPhotoshopに持ちこみ、調整を行って、Spineに出力できる形式で保存しました。Spineの形式での保存の仕方は調べたら沢山出てきますので割愛します。
骨を入れて命を吹き込む
Spineは無料版だと全ての機能が使えますが保存ができないため、個人でESS版を購入しました。「骨を入れて動かせるって楽しそう!」という理由で買ったのですが、本来であればプロフェッショナル版を買うべきでした。高すぎて断念した結果、使いたい機能がESS版にはなかったのでヤスカワ君を動かす上でだいぶ妥協が入ってます。
とはいえ、骨の入れ方やアニメーションの作り方は色々動かしてみるうちにわかってきました。UIが非常にわかりやすいので感覚的に作業することができました。
描いたヤスカワ君をSpineに読み込みます。最初の配置が意図しない位置になりましたが、Spine上で修正できたので問題ありませんでした。
表示順序(レイヤー構造のような並びをしています)が合ってないなら調整し、どのパーツにどう骨を入れるかを決めていき設定します。
今回、細かく動かすパーツはほとんどなかったので、四肢と首、目、口に入れました。
アニメーションを作る
骨を入れ終わったらいよいよアニメーションを作成していきます。私にとってはここが一番楽しい工程です。
会話をさせたいので、会話しているようなポージングを作りました。
Spineの基本的な使い方が把握できればいよかったので、多くの種類は作成していません。
主に以下の3つのアニメーションを作りました。(4種類作ったのですが1つは使いづらそうなので不採用にしました)
- 腕組み立ちポーズ(デフォルト)
- 腕組みおしゃべり
- マイク持って大げさにおしゃべり
これでデータをJson形式でエクスポートします。
エクスポート時は注意が必要です。
必要なデータが3つあるので出力しましょう(Json形式ではなくSkel形式でも出力できたはずなので、そちらでも問題ないはずです。今回はJsonファイルで対応しました)。
エクスポートしたパーツのpngファイルを見てみるとこのような画像が出力されます。
これでブラウザに出力するための準備は完了しました。
パーツ分けも初めてで、骨入れ作業も未経験でしたが、プロの方々は本当にすごいと実際に取り組んでみて感じました。
ブラウザに出力する
Spineで出力したデータをもとに、まずはヤスカワ君をブラウザに表示させます。
出力はPixiJSを使用しますが、Reactを使っているので、厳密には@pixi/reactです。
以前対応したとき(PIXIのv3かv4だった気がする)はPixiJS単体で出力できた気がするのですが、うまく出力できずとでしたので、今回は@pixi/react
を利用してみます。
調べてみると、最近PIXI.jsがv7からv8になったとかで書き方も変わっていたようでノウハウがあまり見つかりませんでした。しかし、公式がSpineをブラウザ上で扱う場合の対応について記載されていたので、こちらを元に開発を進めました。
一部抜粋ですが、以下のようなSpine出力用のコンポーネントを作ります。
const { app } = useApplication()
// アセットのロードとSpineインスタンスの生成
useEffect(() => {
// const app = new Application()
const loadSpineAssets = async () => {
try {
// Assets.load でJSONとアトラスファイルをロード
// @esotericsoftware/spine-pixi-v8 が PIXI.Assets を拡張しているため、Spineデータとして認識される。
Assets.add({ alias: 'spine-json', src: spineJsonUrl });
Assets.add({ alias: 'spine-atlas', src: spineAtlasUrl });
await Assets.load(['spine-json', 'spine-atlas']);
// Spine.from() を使用してSpineインスタンスを作成
const spine = Spine.from({
skeleton: 'spine-json',
atlas: 'spine-atlas',
});
spine.x = x;
spine.y = y;
spine.scale.set(scale);
console.log(`Spine instance created at (${x}, ${y}) with scale ${scale}`);
// アニメーションの設定
if (animationName !== '' || animationName !== null) {
spine.state.setAnimation(0, animationName, initLoop);
} else {
console.warn(`Animation "${animationName}" not found in Spine data.`);
}
app.stage.addChild(spine); // PixiJSのStageに追加
spineRef.current = spine;
setIsLoaded(true);
} catch (error) {
console.error("Failed to load Spine data:", error);
}
};
loadSpineAssets();
return () => {
if (spineRef.current) {
app.stage?.removeChild(spineRef.current);
spineRef.current.destroy({ children: true }); // 子要素も一緒に破棄
spineRef.current = null;
setIsLoaded(false);
}
};
}, [spineJsonUrl, spineAtlasUrl, animationName, x, y, scale, initLoop, app.stage]);
if (!isLoaded) {
return null;
}
return null;
Assets.load
でJsonデータとアセットのロードを行い、Spine.from
でSpineコンテナを作成、これをPIXI.jsのStageに追加します。
最後にreturn null
なのはSpineコンテナがPIXI.jsのStageに追加されているため、特にレンダリングする必要がないからです。
このコンポーネントにpropsとしてJsonデータとアセットデータ、オプションを渡してやれば動くと思います。
setAnimation
を使ってあげれば指定のアニメーションを動かすこともできますし、アニメーションをループさせるかどうかも決められます。
これでヤスカワ君をブラウザに出力するところまで完了しました。
後編では出力されたヤスカワ君と会話するための実装をしていきます。