[Electron] #03 Live2Dデスクトップマスコット — VRM対応、VRMAモーション、複数キャラの同時表示まで
はじめに
前回の記事の最後に「しばらく普段使いして、反応の頻度やセリフを調整します」と書きました。普段使いする前に、やりたいことが先に増えました。今回はその続きです。
[Electron] #02 Live2Dデスクトップマスコット
VS Code拡張とWebSocketでつなぎ、日本語ZIPとLinux配布まで
lain-lab.comできたものは次のとおりです。
- Electron を 30 から 44 に上げた(v1.2.1)
- GPU の無い環境(リモートデスクトップ・VM)でも描画できるようにした(v1.2.1)
- VRM(0.x / 1.0)のモデルを表示できるようにした(v1.3.0)
- VRMA のモーションで待機としぐさを動かせるようにした。自作の待機モーションを2本同梱(v1.3.0)
- 複数のキャラを同時に表示できるようにした。Live2D と VRM を混ぜても動く(v1.4.0)
前回 v1.2.0 を出した翌日、11時に Electron の更新から始めて、14時半に v1.3.0、19時に v1.4.0 を公開しました。1日でバージョンが3つ進んでいます。
スクリーンショット
動画
リポジトリ・ダウンロード
fixtan/live2d-desktop-mascot
VS Code の枠内に常駐する Live2D / VRM デスクトップマスコット(Windows / macOS / Linux)
github.comRelease v1.4.0 · fixtan/live2d-desktop-mascot
Windows・macOS・Linux 版と VS Code 拡張(.vsix)
github.com1. Electron 30 → 44:変わっていたところ
最初は Electron 30 で作っていました。14 バージョン分の差があるので、破壊的な変更のうち、このアプリが使っているところだけを拾って直しました。
| 変わったこと | いつから | 対応 |
|---|---|---|
| Linux の Wayland セッションで、ネイティブ Wayland で動くのが既定になった | 38 | XWayland(X11)に固定 |
| Linux でもウィンドウの角が既定で丸くなった | 43 | roundedCorners: false |
console-message の引数がイベントオブジェクト1つにまとまった | 35 | 古い形式の分岐を削除 |
1つ目は、このアプリには致命的です。Wayland では、アプリが自分のウィンドウの位置を取ったり動かしたりできません。画面全体のカーソル位置も取れず、setShape も効きません。ドラッグ移動、視線追従、Linux でのクリック透過が全部止まります。
// src/main/main.js(抜粋)
// Wayland ではウィンドウ位置の取得・移動、画面全体のカーソル位置、setShape が使えないため XWayland に固定する
if (process.platform === 'linux') app.commandLine.appendSwitch('ozone-platform', 'x11');
2つ目は見た目の問題です。透明なウィンドウの角が丸く切り抜かれると、キャラクターの足元や吹き出しの角が欠けます。
ついでに、DevTools を開くための起動フラグを --debug から --devtools に変えました。--debug は Node 自身のフラグとして弾かれて、アプリまで届いていなかったからです。
2. GPU の無い環境で何も描けなくなった
Electron 44 にしたビルドを Debian 12(xrdp でリモートデスクトップ接続、GPU 無し)で起動すると、ウィンドウは出るのにキャラクターが何も描かれませんでした。
原因は Chromium の変更です。Chromium は GPU が使えないとき、WebGL を SwiftShader(CPU で描くソフトウェア実装)で動かしていましたが、この自動フォールバックをやめました。GPU の無い環境では WebGL のコンテキストが取れず、PixiJS も three.js も何も描けません。
明示的に許可すれば、これまでどおり SwiftShader で動きます。
// src/main/main.js(抜粋)
// GPU が使えない環境(リモートデスクトップ・VM)では WebGL が拒否されて何も描けない。
// GPU がある環境では使われない。読み込むのはローカルのファイルだけなので "unsafe" の懸念(外部ページの JIT)は当たらない
app.commandLine.appendSwitch('enable-unsafe-swiftshader');
名前に unsafe と付いているのは、SwiftShader が JIT でコードを生成するので、悪意のあるページに WebGL 経由で攻撃の足場を与えかねないからです。このアプリはローカルのファイルしか読み込まないので、その心配は当たらないと判断しました。
CPU で描くので、GPU 無しの環境では重いです。そこは利用者の環境の問題として割り切り、README に書くだけにしました。
3. adm-zip の脆弱性修正
ZIP の展開に使っている adm-zip を 0.5.16 から 0.6.1 に上げました。展開後のサイズを偽った ZIP で巨大なメモリを確保させられる、いわゆる ZIP 爆弾への対策などが入っています。
モデルの ZIP は人からもらうものなので、ここは上げておく必要があります。前回入れた「ファイル名を UTF-8 として厳密に読めなければ Shift_JIS とみなす」decoder は、0.6.1 でもそのまま使えることを確認しました。
ここまでを v1.2.1 として出しました。
4. VRM の前に:形式を1か所にまとめる
VRM に対応する前に、コードの中の .model3.json の決め打ちを片付けました。同梱モデルの列挙、ドロップ、ライブラリへの取り込み、ファイル選択ダイアログ、設定画面の表示と、あちこちに「Live2D のモデルは .model3.json」という前提が散らばっていました。
対応している形式の一覧を src/shared/formats.js に置き、全部そこを見るようにしました。main プロセスとレンダラーの両方から使います。
// src/shared/formats.js(抜粋)
const FORMATS = [
{
id: 'live2d',
name: 'Live2D (Cubism 3/4)',
file: /\.model3\.json$/i, // モデルの入口になるファイル
singleFile: false, // 同じフォルダの他のファイルも要る
dialogExtensions: ['json'],
adapter: 'Live2DAdapter'
},
{
id: 'vrm',
name: 'VRM (0.x / 1.0)',
file: /\.vrm$/i,
singleFile: true, // ファイル1個で完結する
usesMotionFiles: true, // motions/ の VRMA を使う
dialogExtensions: ['vrm'],
adapter: 'VRMAdapter',
module: './adapters/vrm.js' // 必要になった時に import() する
}
];
形式を増やすときは、ここに1件足して、adapters/ にアダプタを1つ置くだけです。
アダプタは形式が変わったら canvas ごと作り直す
描画は前回からアダプタ(adapters/live2d.js)に任せていて、app.js はアダプタのメソッドしか使いません。VRM 用のアダプタも同じメソッドをそろえれば、app.js から見れば区別が付きません。
1つだけ注意がありました。PixiJS と three.js は、同じ canvas の WebGL コンテキストを使い回せません。Live2D のモデルから VRM のモデルに切り替えるときは、前のアダプタを捨てて、canvas ごと新しく作ります。
// src/renderer/app.js(抜粋)
async function useAdapter(format) {
if (mascot.formatId === format.id) return mascot;
if (!window[format.adapter] && format.module) await import(format.module);
const Adapter = window[format.adapter];
if (!Adapter) throw new Error('アダプタがありません: ' + format.adapter);
mascot.dispose();
mascot = NO_MODEL;
const canvas = document.createElement('canvas');
canvas.id = 'stage';
canvas.style.opacity = settings.opacity;
$('stage').replaceWith(canvas);
mascot = new Adapter(canvas);
mascot.formatId = format.id;
return mascot;
}
VRM のアダプタは ES モジュールにして、VRM のモデルを初めて読むときに import() します。Live2D しか使わない人は、three.js を一度も読み込みません。
モデルが無い間は NO_MODEL という空のアダプタが代わりに応えます。起動直後から main プロセスがカーソル位置を送ってくるので、その空振り先です。メソッドを全部並べてあるので、アダプタが何を実装すればいいかの一覧も兼ねています。
形式ごとのエラー文(Cubism 5.3 形式は未対応、など)も、アダプタが err.userMessage に入れて投げるようにしました。app.js は中身を知らずに吹き出しに出すだけです。
この段階では、Live2D だけの状態で見た目も動作も変わらないことを確かめてから次に進みました。
5. VRM アダプタ:モデルは拡大しない
VRM は three.js と @pixiv/three-vrm で読み込みます。Live2D のアダプタとの一番の違いは、大きさの決め方です。
VRM のモデルはメートル単位で作られています。身長 1.6m のモデルを 480px で表示したいとき、モデルを 300 倍に拡大したくなりますが、これをやると髪やスカートの揺れもの(スプリングボーン)とコライダーの物理がおかしくなります。揺れものの計算はメートル単位の長さや半径を前提にしているからです。
そこで、モデルは動かさず、正射影カメラの範囲で大きさを決めることにしました。「1m あたり何 px か」(ppm)を持っておき、カメラに写す範囲をウィンドウの大きさ ÷ ppm にします。
// src/renderer/adapters/vrm.js(抜粋)
setHeight(px) {
this.ppm = px / (this.box.max.y - this.box.min.y); // 身長が px になる
this._resize();
}
_resize() {
const w = window.innerWidth, h = window.innerHeight;
this.renderer.setSize(w, h, false);
Object.assign(this.camera, { left: 0, right: w / this.ppm, top: 0, bottom: -h / this.ppm });
this.camera.updateProjectionMatrix();
}
身長(box)は読み込んだときに、スキンの変形込みで全メッシュの範囲を測ります。前回 Live2D で「キャンバス全体ではなく実際に描かれている範囲で測る」ようにしたのと同じ考え方です。
クリック透過の判定は、カーソルの下の1ピクセルを readPixels で読んで、不透明なら当たりにしています。描画のあとにバッファを読めるように、レンダラーは preserveDrawingBuffer: true で作っています。
6. VRM 0.x で腕が万歳になる
VRM のモデルは初期状態が T ポーズ(腕を水平に広げた姿勢)なので、読み込んだら腕を下ろした立ち姿にします。上腕を z 軸まわりに約 1.3 ラジアン回すだけです。
VRM 1.0 のモデルではこれで腕が下りましたが、VRM 0.x のモデルでは腕が上がって万歳になりました。
VRM 0.x と 1.0 では、モデルの向きが逆です(0.x は後ろ向き)。VRMUtils.rotateVRM0() を呼ぶと向きはそろいますが、これはモデル全体を y 軸まわりに 180 度回すだけです。正規化ボーンの座標系は回っていないので、x 軸と z 軸まわりの回転の符号が 1.0 と逆になります。
回転は VRM 1.0 の向きで書いておき、0.x のときだけボーンに入れる直前で x と z の符号を反転するようにしました。
// src/renderer/adapters/vrm.js(抜粋)
// 手を下ろした立ち姿(正規化ボーン、VRM 1.0 の向きで書く)
const REST_POSE = {
leftUpperArm: [0, 0, -1.3],
rightUpperArm: [0, 0, 1.3],
leftLowerArm: [0, -0.15, 0],
rightLowerArm: [0, 0.15, 0]
};
// load() の中
this.flip = vrm.meta?.metaVersion === '0' ? -1 : 1;
// 正規化ボーンの回転(VRM 1.0 の向きで指定)
_rot(name, x, y, z) {
this.vrm.humanoid.getNormalizedBoneNode(name)?.rotation.set(x * this.flip, y, z * this.flip);
}
ボーンを回すところは必ず _rot を通すことにしたので、呼吸やしぐさ、視線追従も、0.x と 1.0 を気にせずに書けます。
.vrm はファイル1個で取り込む
Live2D のモデルはフォルダ(または ZIP)単位で配られますが、VRM は .vrm のファイル1個で配られることがほとんどです。formats.js の singleFile が true の形式は、ファイル単体でライブラリに取り込めるようにしました。.vrm をドロップするとライブラリにコピーされます。.model3.json のドロップは、これまでどおり取り込まずに元の場所を参照します。
7. 視線追従:目だけでなく頭と首も向ける
Live2D のモデルは、パラメータで目と顔の向きがカーソルを追います。VRM にも lookAt という目の向きを決める仕組みがあるので、カーソルの少し手前(2m)に置いた目標を見させました。
目だけが動いて顔が正面のままだと、横目でにらんでいるように見えます。そこで頭もカーソルの方を向けることにしました。
- 頭は、カーソルへの角度の半分だけ追う。左右は ±0.45、上下は ±0.3 ラジアンまで
- その角度を、首に 3、頭に 7 の割合で分ける
- 目標の角度になめらかに近づける(急に振り向かない)
// src/renderer/adapters/vrm.js(抜粋)
_updateHeadTurn(dt) {
let yaw = 0, pitch = 0;
if (this.focusOn) {
const p = this.vrm.humanoid.getNormalizedBoneNode('head').getWorldPosition(new THREE.Vector3());
const d = this.lookTarget.position.clone().sub(p);
const clamp = (v, m) => Math.max(-m, Math.min(m, v));
yaw = clamp(Math.atan2(d.x, d.z) * 0.5, 0.45);
pitch = clamp(Math.atan2(d.y, d.z) * 0.5, 0.3);
}
const k = Math.min(1, dt * 5);
this.headYaw += (yaw - this.headYaw) * k;
this.headPitch += (pitch - this.headPitch) * k;
}
視線追従を切ると、頭も同じ速さで正面に戻ります。
8. VRMA モーション
VRM には、VRMA(VRM Animation)という人型モーションのファイル形式があります。モデルが違っても同じモーションを使えるので、全モデル共通のフォルダ(userData/motions/)に置くことにしました。
役割はファイル名で決める
モーションには「待機」と「しぐさ」の2つの役割があります。どのファイルを何に使うかは、ファイル名で決めることにしました。
- 名前が
idleで始まるもの(idle、idle2、idle_sit…)は待機。1本流し終わるたびにランダムに次の待機へ切り替わる - それ以外はしぐさ。クリックやランダムイベントで流れる。ボイスパックの
motionとも部分一致で結びつく
// src/shared/motions.js(抜粋)
const isIdle = (name) => /^idle/i.test(name);
function motionRoles(files) {
const idle = [], gestures = [];
for (const f of files) (isIdle(f.name) ? idle : gestures).push(f);
return { idle, gestures };
}
役割の決め方はこの関数1つにしてあります。あとで「役割ごとに個別に選ぶ」画面を作りたくなったら、ここを差し替えればアダプタは変えずに済みます。
取り込みは、.vrma をキャラクターにドロップする(複数まとめて可)か、.vrma だけが入った ZIP をドロップします。モデルが入っていない ZIP は、モーションパックとして扱います。
待機の VRMA が1本も無いときは、これまでどおり手続きで作った待機(腕を下ろして呼吸し、少し揺れる)としぐさを使います。しぐさの VRMA だけがあっても使いません。しぐさが終わったあとに戻る姿勢が無いからです。
視線追従はモーションの上に足す
VRMA にも目の向き(lookAt)のトラックが入っていることがありますが、これは使いません。カーソル追従と取り合うからです。視線と頭の向きは、モーションで決まった姿勢の上に回転を掛け合わせて足します。
ここで1つつまずきました。モーションに含まれないボーンは、AnimationMixer が何もしないので前のフレームの姿勢のまま残ります。そこに毎フレーム回転を掛け合わせると、足し算が積み重なって首がどんどん回っていきます。
毎フレーム、先に立ち姿に戻してから mixer.update し、そのあとで視線追従を足すようにしました。
// src/renderer/adapters/vrm.js(抜粋)
if (this.mixer) {
// 先に立ち姿へ戻す(モーションに含まれないボーンはそのまま残るので、足し算が毎フレーム積み重ならないように)
this._applyRestPose();
for (const n of ['spine', 'chest', 'upperChest', 'neck', 'head']) this._rot(n, 0, 0, 0);
this.mixer.update(dt);
this._addRot('neck', -this.headPitch * 0.3, this.headYaw * 0.3, 0);
this._addRot('head', -this.headPitch * 0.7 + g.x, this.headYaw * 0.7, g.z);
}
// 今の回転に足す(VRMA の姿勢の上に視線追従などを重ねる)
_addRot(name, x, y, z) {
const node = this.vrm.humanoid.getNormalizedBoneNode(name);
_euler.set(x * this.flip, y, z * this.flip);
node.quaternion.multiply(_quat.setFromEuler(_euler));
}
モーションは実時間、揺れものは頭打ち
GPU 無しの環境では描画が遅く、1フレームの間隔が長くなります。フレームの間隔に上限を付けると、モーションがスローになります。かといって上限を付けないと、揺れものの物理が大きな刻みで計算されて暴れます。
そこで、モーションと表情は実時間で進め、揺れもの(vrm.update)に渡す時間だけ 0.1 秒で頭打ちにしました。ウィンドウが隠れていたあとなどの1秒を超える飛びは捨てています。
const raw = this.timer.getDelta();
const dt = raw > 1 ? 0 : raw;
// ……モーション・表情は dt で進める
vrm.update(Math.min(dt, 0.1));
9. モーションで窓の外に出て切れる
VRMA を流してみると、2つの切れ方がありました。
1つ目は、歩いたり踊ったりするモーションです。腰の位置が前後左右に動く(ルートモーション)ので、キャラクターがウィンドウの外に歩いて行って切れます。デスクトップマスコットはその場で動けば十分なので、腰の前後左右の移動は捨て、上下だけ残しました。しゃがむ・跳ねる動きは残ります。
// src/renderer/adapters/vrm.js(抜粋)
function createClip(anim, vrm, name) {
const h = createVRMAnimationHumanoidTracks(anim, vrm.humanoid, vrm.meta.metaVersion);
const hips = h.translation.get('hips');
if (hips) {
const [rx, , rz] = vrm.humanoid.normalizedRestPose.hips?.position ?? [0, 0, 0];
for (let i = 0; i < hips.values.length; i += 3) { hips.values[i] = rx; hips.values[i + 2] = rz; }
}
const tracks = [...h.translation.values(), ...h.rotation.values()];
// lookAt のトラックは入れない
// ……表情のトラックを足して AnimationClip にする
}
2つ目は、腕を広げるモーションです。ウィンドウはキャラクターの体の幅ぴったりに縮めてあるので、腕が横に出ると切れます。
VRM のときは、ウィンドウの幅を身長ぶん取ることにしました。余白は透明で、クリックは下のウィンドウに抜けます。VS Code の枠内に収める判定と吹き出しの位置には、体の範囲(getBounds())を使うので、ウィンドウを広げても影響しません。前回、ウィンドウの大きさの元(getSize())と体の範囲(getBounds())を分けておいたのが、ここで効きました。
Linux だけは、クリック透過をウィンドウの形の切り抜き(setShape)で実現しています。切り抜いた外側は描画も切れるので、横は窓の幅いっぱいにしました。
10. 自作の待機モーションを同梱する
VRMA を1本も持っていない人は、手続きの待機しか見られません。そこで、Unity で作った待機モーションを2本(Idle.vrma、Idle1.vrma)アプリに同梱しました。
ユーザーのモーションと同梱のモーションは、こう合わせます。
- 待機:ユーザーの
motions/に待機が1本でもあればユーザーの分だけ。無ければ同梱の分。自分の待機を入れたら、同梱の待機は使われなくなる - しぐさ:両方使う。同じ名前ならユーザーの分を優先
// src/shared/motions.js(抜粋)
function mergeMotions(user, bundled) {
const userHasIdle = user.some((f) => isIdle(f.name));
const names = new Set(user.map((f) => f.name.toLowerCase()));
const extra = bundled.filter((f) => !names.has(f.name.toLowerCase()) && (!isIdle(f.name) || !userHasIdle));
return [...user, ...extra];
}
Unity から書き出した VRMA を読むと、コンソールに次の警告が出ます。
VRMAnimationLoaderPlugin: specVersion of the VRMA is not defined. Consider updating the animation file. Assuming the spec version is 1.0.
ファイルに仕様のバージョンが書かれていないという警告で、1.0 として問題なく読めています。
11. ビルド版だけ VRM が読めない
npm start で動かしている間は、VRM も VRMA も問題なく動いていました。ところが、npm run dist:win で作ったビルド版では、VRM のモデルを読むと失敗しました。
調べると、VRM のアダプタが読み込む three/examples/jsm/loaders/GLTFLoader.js が、ビルド版に入っていませんでした。electron-builder は、node_modules/<パッケージ>/examples を必ず除外します。サンプルコード置き場なので普通は要らないからですが、three.js の GLTFLoader などのアドオンは、まさにこの examples/jsm に置かれています。package.json の files に書いても、この除外は上書きできませんでした。
npm install のあとに、必要な3ファイルを src/renderer/vendor/three-addons/ にコピーするスクリプトを postinstall で走らせることにしました。
// scripts/copy-three-addons.js
const FILES = [
'loaders/GLTFLoader.js',
'utils/BufferGeometryUtils.js',
'utils/SkeletonUtils.js'
];
const src = path.join(root, 'node_modules/three/examples/jsm');
const dest = path.join(root, 'src/renderer/vendor/three-addons');
for (const f of FILES) {
fs.mkdirSync(path.dirname(path.join(dest, f)), { recursive: true });
fs.copyFileSync(path.join(src, f), path.join(dest, f));
}
index.html の importmap で、three/addons/ をこのフォルダに向けます。アダプタの import 文はそのままです。
<script type="importmap">
{
"imports": {
"three": "../../node_modules/three/build/three.module.js",
"three/addons/": "./vendor/three-addons/",
"@pixiv/three-vrm": "../../node_modules/@pixiv/three-vrm/lib/three-vrm.module.js"
}
}
</script>
コピー先は .gitignore に入れています。three.js を上げたときに、古いコピーが残らないようにするためです。
この件から、「確認はビルド版で」をルールにしました。npm start で動いても、ビルド版で動くとは限りません。
ここまでを v1.3.0 として出しました。
12. 複数のキャラを同時に表示する
VRM が動くようになると、Live2D のキャラと VRM のキャラを並べたくなります。
設計:1キャラ=透明ウィンドウ1枚、親は main プロセス
最初に決めたのは次の2つです。
- 1キャラにつき、透明なウィンドウを1枚作る。canvas もレンダラーのプロセスも別なので、Live2D と VRM が混ざっても干渉しない
- 全体をまとめる親は main プロセスにする。最初に起動したキャラを親にはしない
2つ目は、今のコードを見れば自然に決まりました。VS Code 拡張とつなぐ WebSocket サーバー、タスクトレイ、設定ファイルは、もともと main プロセスが持っています。キャラのウィンドウはどれも対等で、どれを閉じても他のキャラには影響しません。
mainWindow をやめて、送り主から引く
main プロセスのコードは、ウィンドウが1枚である前提で書いてありました。mainWindow という変数が1つあり、ドラッグ移動も、クリック透過の切り替えも、リサイズも、VS Code の枠内への押し戻しも、全部 mainWindow に対して行っていました。
これを、キャラの ID ごとの状態を持つ Map に置き換えました。レンダラーから届く IPC は、event.sender からどのキャラのウィンドウかを引きます。
// src/main/main.js(抜粋)
// mascots: id → { id, win, inset } inset はウィンドウ内でのキャラ描画範囲
const mascots = new Map();
function mascotOf(sender) {
for (const m of mascots.values()) if (!m.win.isDestroyed() && m.win.webContents === sender) return m;
return null;
}
// 送り主のキャラに対して処理する IPC
const onMascot = (channel, fn) => ipcMain.on(channel, (event, arg) => {
const m = mascotOf(event.sender);
if (m) fn(m, arg);
});
onMascot('set-ignore-mouse', (m, ignore) => m.win.setIgnoreMouseEvents(ignore, { forward: true }));
onMascot('model-bounds', (m, inset) => { m.inset = inset; clampToVSCode(m); });
mainWindow.xxx が m.win.xxx になるだけなので、置き換えはほぼ機械的でした。レンダラー側(app.js)は、自分が何体目かを知らなくても今までどおり動きます。
設定の保存先を分ける
これまで設定はレンダラーの localStorage に保存していました。ところが、キャラのウィンドウはどれも同じ index.html を file:// で開くので、オリジンが同じで localStorage も共有されます。2体目を出すと、1体目と同じ設定を読み書きしてしまいます。
キャラの一覧と、キャラごとの設定と位置を、main プロセスが userData/characters.json に持つことにしました。
{
"version": 1,
"characters": [
{ "id": "c1", "settings": { "model": "haru_greeter", "height": 480, "...": "..." }, "anchor": { "x": 1380, "bottom": 1000 } },
{ "id": "c2", "settings": { "model": "lib:Ireina", "height": 680, "...": "..." }, "anchor": { "x": 1100, "bottom": 1000 } }
]
}
ウィンドウは index.html?id=c1 のように ID をクエリに付けて開きます。レンダラーは起動時に、自分の設定を同期 IPC で受け取ります。保存は、設定ウィンドウに状態を送るための既存の settings-state がそのまま兼ねます。main プロセスは届いた状態を中継しつつ、characters.json に書きます。スライダーを動かすと何十回も届くので、書き込みは 0.5 秒まとめています。
位置は、ウィンドウの左上ではなく「足元の中心」(anchor)で覚えます。ウィンドウの大きさはモデルの身長で変わり、リサイズは足元の中心を固定して行っているからです。左上で覚えると、次に起動したときに、最初の仮の大きさから本来の大きさに変わる瞬間にずれます。
以前の版からの引き継ぎも入れました。characters.json がまだ無い最初の起動だけ、1体目が localStorage の古い設定を読みます。
// src/renderer/app.js(抜粋)
const init = ipcRenderer.sendSync('character-init') || { settings: null, migrate: false, primary: true };
const settings = (() => {
let saved = init.settings;
if (!saved && init.migrate) {
try { saved = JSON.parse(localStorage.getItem('mascot-settings-v3') || 'null'); } catch {}
}
return { ...DEFAULTS, ...(saved || {}) };
})();
全員でしゃべらない:代表と、話す順番
3体いると、VS Code でエラーが出たときに3体が一斉に「エラーが3件あるよ」と言います。うるさいので、次のように決めました。
- VS Code のイベントと時報には、代表(一覧の先頭のキャラ)だけが反応する。代表を消すと、次のキャラが繰り上がる
- ランダムイベントは、キャラごとに今までどおり回す。ただし同時にはしゃべらない
同時にしゃべらないために、しゃべる前に main プロセスに申し出るようにしました。
- 自動の発言(ランダムイベント・時報・VS Code への反応・起動時のあいさつ)は、誰かが話し中なら見送る
- 手動の発言(クリック・話しかける・試聴)は、話し中のキャラを止めて割り込む。ユーザーが触ったキャラが黙っていたら、壊れたと思われるからです
// src/main/main.js(抜粋)
const SPEECH_MAX = 60000;
let speech = { id: null, until: 0 };
ipcMain.on('speech-claim', (event, manual) => {
const m = mascotOf(event.sender);
if (!m) { event.returnValue = false; return; }
const now = Date.now();
const other = speech.id && speech.id !== m.id && now < speech.until ? mascots.get(speech.id) : null;
if (other && !other.win.isDestroyed()) {
if (!manual) { event.returnValue = false; return; } // 自動:見送り
other.win.webContents.send('speech-stop'); // 手動:相手を止める
}
speech = { id: m.id, until: now + SPEECH_MAX };
event.returnValue = true;
});
レンダラーは、吹き出しを出す say() と声を流す playVoice() の入口で申し出ます。同期 IPC にしたのは、「しゃべれるか」の答えを待ってから吹き出しを出すかどうかを決めたいからです。main プロセスは Map を見るだけなので、待ち時間はほぼありません。
// src/renderer/app.js(抜粋)
function claimSpeech(manual) {
if (!ipcRenderer.sendSync('speech-claim', !!manual)) return false;
speaking = true;
return true;
}
// 吹き出しが消えて、声も終わったら順番を返す
function maybeEndSpeech() {
if (!speaking) return;
if (bubble.classList.contains('show') || currentSource) return;
if ('speechSynthesis' in window && speechSynthesis.speaking) return;
speaking = false;
ipcRenderer.send('speech-end');
}
順番を返すのは、吹き出しが消えて、声も鳴り終わったときです。声のファイルが読めなかったなどで speech-end が来なくても、60秒で自動的に空きます。
起動時のあいさつを「自動」にしたのには理由があります。3体が同時に起動して、それぞれ「こんにちは!」と言おうとします。手動扱いだと、後から来たキャラが前のキャラを次々に止めて、最後の1体だけがあいさつします。自動扱いにすると、最初に申し出た1体だけがあいさつし、残りは見送ります。
使ってみると、1体目が話し終わってしばらくすると、別のキャラが話す、という自然な順番になりました。今はどのキャラも同じボイスパックを使っているので、声は同じです。
設定ウィンドウでキャラを選ぶ
設定ウィンドウの一番上に「キャラ」欄を足し、どのキャラの設定を表示するかを選べるようにしました。キャラの追加・削除もここでします。+を押すと、今選んでいるキャラの左どなりに新しいキャラが出ます。
設定ウィンドウが値を持たないという前回の設計は、そのまま使えました。main プロセスが「今どのキャラを選んでいるか」だけを覚えておき、操作はそのキャラに転送し、そのキャラの状態だけを設定ウィンドウに返します。
VS Code 固定モード、モデルのライブラリ、モーションのフォルダは全キャラ共通です。あるキャラでモデルを取り込んだり消したりしたら、他のキャラにも知らせて、設定ウィンドウの一覧をそろえます。使っていたモデルを別のキャラの画面から消されたキャラは、デフォルトのモデルに戻ります。
仮想ディスプレイで、パッチを渡す前に叩く
この変更は、AI に git format-patch のパッチで作ってもらい、手元で git am して取り込んでいます。今回は AI の作業環境で Electron を仮想ディスプレイ(Xvfb)の上で動かし、次のことを自動で確かめてからパッチを受け取りました。
- 2体で起動し、それぞれ別の設定を読むこと
- キャラを追加・削除できること。代表を消すと、次のキャラが代表になること
- 話す順番:Aが手動で話し中なら、Bの自動は拒否され、Bの手動は通ること。終わったら空くこと
localStorageの古い設定が1体目に引き継がれること
Cubism Core とサンプルモデルはリポジトリに入れていないので、その環境ではキャラクターは描かれません。描画・ドラッグ・Live2D と VRM の混在は、手元の Windows で確かめました。一発で3体並んで動きました。
重さ
1体ごとに WebGL のコンテキストが1つ増えます。GPU のある環境では3体でも問題ありませんが、GPU 無しの Debian では重くて実用になりませんでした。GPU 無しの環境では、1体(Live2D か、軽い VRM)を推奨することにして、コードで体数を制限することはしていません。
13. v1.4.0 のリリースでつまずいたこと
バージョンを上げないとファイル名がぶつかる
ビルドの成果物の名前は package.json の version から付きます。1.3.0 のままビルドすると、live2d-desktop-mascot-1.3.0-mac-arm64.dmg がもう一度でき、既存のリリースと区別が付きません。アプリのクレジット画面に出るバージョンもこの値です。機能を足したので 1.4.0 にしました。
npm version 1.4.0 --no-git-tag-version
--no-git-tag-version を付けると、package.json と package-lock.json の書き換えだけをして、タグは付けません。タグは GitHub の Release の画面で作りました。
Mac で git pull しても 1.3.0 のまま
Windows で push したあと、Mac で git pull しても package.json が 1.3.0 のままでした。
最初は package-lock.json を疑いました。以前、npm install で package-lock.json が書き換わり、git pull が止まったことがあったからです。ところが git status を見ると、原因は別でした。
On branch vrm-prototype
Your branch is up to date with 'origin/vrm-prototype'.
Mac は、VRM の試作をしたときのブランチ(vrm-prototype)にいたままでした。git pull はそのブランチを更新していただけです。v1.3.0 の Mac 版はこのブランチからビルドしていましたが、main と同じコミットだったので中身は同じでした。git checkout main してから git pull すると、13 コミット分が早送りで入りました。
Mac と Debian は npm ci にする
package-lock.json が書き換わる問題も、ついでに片付けました。依存関係を変えるのは Windows だけにして、Mac と Debian では npm install の代わりに npm ci を使います。
npm install:package.jsonを見て入れる。必要ならバージョンを解決し直して、package-lock.jsonを書き換えることがあるnpm ci:package-lock.jsonのとおりに入れる。ロックファイルには触らない。入れる前にnode_modulesを丸ごと消す
npm ci なら3台で入るバージョンが完全にそろい、次の git pull が止まることもありません。
git pull
npm ci
npm run dist:mac
3台でビルドした6つのファイルと、変更の無い VS Code 拡張(.vsix)を添付して、v1.4.0 を公開しました。
まとめ
- Electron 38 以降の Linux は Wayland ネイティブが既定。ウィンドウの位置・カーソル位置・
setShapeを使うアプリはozone-platform=x11に固定する。 - Chromium は WebGL の SwiftShader への自動フォールバックをやめた。GPU の無い環境で描くには
enable-unsafe-swiftshaderを明示する。ローカルのファイルしか読まないアプリなら、unsafeの懸念は当たらない。 - 形式の一覧を1か所にまとめ、描画はアダプタに閉じ込める。PixiJS と three.js は同じ canvas を使い回せないので、形式が変わったら canvas ごと作り直す。
- VRM はメートル単位のまま拡大しない。正射影カメラの範囲で大きさを決めれば、揺れものの物理が狂わない。
- VRM 0.x は
rotateVRM0()のあとも、正規化ボーンの x・z 回転の符号が 1.0 と逆。回転は 1.0 の向きで書き、0.x のときだけ反転する。 - VRMA の上に視線追従を足すときは、毎フレーム立ち姿に戻してから
mixer.updateする。モーションに無いボーンで足し算が積み重なる。 - モーションは実時間、揺れものだけ時間の刻みを頭打ちにする。ルートモーションの前後左右は捨てる。
- electron-builder は
node_modules/<パッケージ>/examplesを必ず除外する。three.js のアドオンはpostinstallでコピーする。確認はビルド版で。 - 複数ウィンドウにするときは、親を main プロセスにし、IPC は
event.senderからウィンドウを引く。同じfile://のページはlocalStorageを共有するので、ウィンドウごとの設定は main プロセスで持つ。 - 同時にしゃべらせないなら、main プロセスで順番を管理する。自動の発言は見送り、ユーザーが触ったときは割り込む。
- 複数台でビルドするなら、依存を変える機械を1台に決め、残りは
npm ci。
次は、しばらく普段使いして、反応の頻度やセリフを調整します。今度こそ。