React × TypeScript × SVG × MediaPipe で、軽量・解像度非依存のVTuberをコードだけで作る!
デモ動画
この記事の内容
- Live2Dを使わずSVGのみでVTuberを作る「SVGTuber」の設計と実装手順
- なぜSVGなのか/どういう条件でどんな見た目の効果が出るのかを具体的に解説
- 実コードの抜粋(コメント入り)
- 主要変数・状態を一覧で整理
なぜSVG(SVGTuber)?

- 拡大してもぼやけない(ベクター)。配信画面のズームでも線が綺麗
- 線幅が一定にできる:
vector-effect: non-scaling-stroke - 無料 & 軽量:3D/物理の重い処理やメッシュ編集なし
- フルコード:挙動を全部自分で制御できる(研究・拡張に強い)
- 配信導線が楽:ブラウザで動き、OBSは画面キャプチャでOK
向いている人
- 太い線・カートゥーン調が好き/線幅を常に一定にしたい
- 塗り替えをあとから差し替えたい(線画と塗りを分離)
- Live2Dのメッシュ分割が面倒/重いと感じている
- コードで表情の条件分岐や物理を自由に設計したい
技術スタック(推奨)
- React 18+ / TypeScript / Vite
- MediaPipe Face Mesh(顔ランドマーク検出+虹彩)
- @mediapipe/camera_utils / drawing_utils(Webカメラ入力 & デバッグ描画)
- SVG(キャラは全部ベクター)
- ブラウザAPI:
<video>,<canvas>,requestAnimationFrame, CSStransform
ポイント:
refineLandmarks: trueにすると虹彩(瞳)が安定し、視線・瞬きの品質が上がる。
抜粋:FaceMeshの基本オプション
// 「目の開き/視線」を正しく取るなら refineLandmarks は必須級
faceMesh.setOptions({
maxNumFaces: 1,
refineLandmarks: true, // 虹彩の精度アップ
minDetectionConfidence: 0.5, // 検出の信頼度
minTrackingConfidence: 0.5, // 追跡の信頼度
});
全体像(データフロー)
Webカメラ → FaceMesh(ランドマーク) → 特徴量(瞬き/視線/口/ヨー・ピッチ・ロール) → スムージング/クランプ → React State → <Character /> props → SVG変換(移動/回転/マスク/形状) → OBSでキャプチャ
Step 1. パーツ分けとSVG描画
- 髪(前/サイド/後ろ/装飾):複雑な形状のため、Inkscape等のベクターお絵描きツールで作ってimport
- 顔・眉・白目・黒目・口・体・手足:コードで直書き(
ellipse/line/polygon/path) - マスク(clipPath)
- 顔の中(まつげ・上まぶた以外)は顔楕円でクリップ
- 黒目は白目でクリップ → はみ出し防止
線幅を一定に保つ基本
/* スケール変形しても線幅を変えない */
.hair-wrap :is(path,ellipse,polygon,rect,circle){
vector-effect: non-scaling-stroke;
}
注意:パス形状そのものをスケールすると「線」は維持できても形は潰れます。厳密に線幅を一定にしたいときは、縮尺で絞るのではなく形状バリエーションを切替えるのがベター。
Step 2. 顔の動きに紐づける(条件→効果がわかる設計)
Step 2-1. 瞬き(左右独立・ウィンク対応)

条件:上下まぶたの距離 ÷ 目の横幅(正規化)
効果:t=0(開)→1(閉)で上まぶた線を下げる。瞬き中は視線をホールドして黒目が暴れないように。
抜粋:開き具合の正規化 & ヒステリシス
// 目の縦幅/横幅 → 0..1に正規化(開いているほど値は大きい)
const vL = Math.abs(L_bottom.y - L_top.y) / (Math.abs(L_outer.x - L_inner.x) + 1e-6);
// 「開き」と「閉じ」で閾値を変える(ヒステリシス)→ 目のパカパカ開閉を抑制
const applyCloseSnap = (t: number, snapRef: React.MutableRefObject<boolean>) => {
const CLOSE_SNAP_ON = 0.90, CLOSE_SNAP_OFF = 0.85; /* on/off の違いがミソ */
/* ...略... */
};
Step 2-2. 視線

条件:虹彩中心(瞳)から目の中心比を取り、[-1..1]にクランプ
効果:eyeOffsetX/Y で黒目の位置を動かす。瞬きによるYのチラつきを抑えるため、瞬きが深い時は前フレームを保持する
抜粋:瞬き時の視線ホールド
// 瞬き量 tBlink に応じて前フレーム値へ寄せる(特にY)→ 黒目が暴れない
const wHold = smoothstep(0.25, 0.70, tBlink);
eyeLocalY = (1 - wHold) * eyeLocalY + wHold * prevY;
Step 2-3. 口の形(真顔/ V字形 / ▽形 /“お”)

条件(例)
mRatio(口の縦/横)から開き量 tMを算出- 開き量、口角の高さと口幅の狭さを見て、丸い口(“お”)/ V字形 / ▽形 / 直線 を切り替え
効果
- 閉じている かつ 口角が下がっている → 直線(真顔の口)〜口角が上がるほどV字形に近づく
- 中程度開いている かつ 口幅が狭い かつ 笑顔でない時 → 丸い口(“お”)
- それ以外 → 閉じている時はV字形、開いた時は▽形
抜粋:開き量0..1
// 開いている時/閉じている時の自己校正(EMA)で人に合わせる
const mRange = Math.max(1e-5, mouthOpenBase.current! - mouthClosedBase.current!);
let tM = (mRatio - mouthClosedBase.current!) / mRange; // 0=閉, 1=開
Step 2-4. 顔の向き(ヨー/ピッチ/ロール)



条件
- ヨー・ピッチ:鼻先とこめかみ中点の差ベクトル →
atan2- ※ヨーは「左右の首振り角度」、ピッチは「上下のうなずき角度」
- ロール:左右こめかみの線分角度
- ※ロールは「首を傾ける動き」
- 小さなブレはデッドゾーンで無視/クランプで最大角を制限
効果(2D的な表現を重視)
- 顔内部(眉・目・鼻・口・メガネ):平行移動を中心
- 鼻は 1.15×、口は 0.9× 動かす(立体感の擬似表現)
- それ以外はあえてただの平行移動にしている
- サイドヘア:向いた側をやや絞る(※形状切替推奨)
- ※補足:ここで私は、顔の向いた側のサイドヘアを少し絞るためにX方向スケールを掛けています。このとき vector-effect: non-scaling-stroke を使っていても、パスそのものを変形しているため線幅も一緒に変わってしまいます。線幅を完全に一定に保ちたい場合は、スケールではなくパス形状を直接描き分けるか、SVGをコードで生成して動かす方法がおすすめです。
- 後ろ髪:顔と逆方向へ遅れ気味に動かす
- ロール:顔楕円の中心を軸に回転
抜粋:ヨー/ピッチ→顔オフセット
const kx = 0.20, ky = 0.25; // 角度→pxのゲイン。±25°/±20°で±5px程度
setFaceX((p) => smooth(p, yaw * kx));
setFaceY((p) => smooth(p, +pitch * ky));
Step 3. 髪の擬似物理(バネ・減衰)

設計意図
- 頭の回転速度と平行移動速度を入力に、前髪/後ろ髪を顔の動きに少し遅れて追従させる
- DEAD ZONEで微小振動を無視、クランプで振れ幅を制約
抜粋:最小のスムージング道具
// 変化を一定割合で追う(指数平滑の簡易形)
const smooth = (prev: number, next: number, a = 0.25) => prev + (next - prev) * a;
// 範囲外を切り捨て(暴れ止め)
const clamp = (v: number, lo = -1, hi = 1) => Math.max(lo, Math.min(hi, v));
Step 4. 呼吸(上半身の上下+肩の回転)

条件:周期トライアングル波(吸う/吐く)。フェイストラッキングとは関係なく、常に動き続ける
効果:
- 胴体・腕・手を上下
- 吐く側(胴体が下がる)で、肩を中心に、腕が少し開く。最小角+レンジで自然さを担保
抜粋:肩回転の下限を保証
const shoulderMin = 2; // 最小2°
const shoulderRange = shoulderMaxDeg - shoulderMin;
setShoulderDeg(-(shoulderMin + open01 * shoulderRange));
Step 5. 上半身の傾き(“画面上の顔位置”ベース)

条件:画面上の顔X位置(ユーザーが画面の端に寄る)+ヨー角(少し影響)
効果:最大±8°で上半身を傾け、体重移動の自然さを演出
抜粋:位置→角度
// だいたい dx≈±0.10 で ±8° になるゲイン
const POS_GAIN_DEG = 80;
const tiltFromPos = clampDeg(dxScreen * POS_GAIN_DEG, -8, 8);
Step 6. 動きの安定化方法(clamp / EMA / dead zone / ヒステリシス)
- clamp:物理値の上限下限を決めて飛びを抑える
- EMA/smooth/lerp:なめらかな追従
- dead zone:ごく小さい振れを無視
- ヒステリシス:開→閉/閉→開で閾値を変え、パカパカを抑止
抜粋:デッドゾーンの典型
const DEAD_ZONE = 0.3;
const wFollow = clamp((mag - DEAD_ZONE) / (5 - DEAD_ZONE), 0, 1); // 小さな動きは0
Step7. UIとOBS



開発時のおすすめ設定
- Webカメラ映像を常に表示
自分の顔の動きとキャラの反応をリアルタイムで確認でき、調整がスムーズ。 - グリッド表示
位置やバランスを数値で指定する際に便利。座標の目安になる。 - グリーンバック切替
OBSのクロマキー合成用に、背景をワンクリックで緑にできると配信準備が楽。
ブラウザUIにあると便利な機能
- スライダー:キャラの倍率を調整(OBSでの構図合わせが簡単になる)
- トグルボタン:カメラ表示/グリッド表示/グリーンバックをそれぞれON/OFF
抜粋:UIトグル(グリーンバック切り替え)
<input type="checkbox" checked={showGreenBg}
onChange={e => setShowGreenBg(e.target.checked)} />
配信への組み込み
- ブラウザでアバターを動かし、OBSの画面キャプチャなどで取り込み
- グリーンバックON → OBSでクロマキー適用 → 背景透過して配信画面へ配置
先駆者様:Pose Animator の紹介と、SVGTuber との違い・使い分け
- 先行事例として Pose Animator というオープンソースツールがあります。
- これは TensorFlow.js と MediaPipe FaceMesh(+PoseNet)を使い、SVGキャラを顔や全身の動きに合わせて動かす高機能ツールです。ボーン構造を設定して正確な動きを再現するのが特徴です。
- 一方、私の SVGTuber はシンプルな絵柄と2D的表現に特化し、SVGをほぼ直書き。ボーン設定は不要で、Live2D的な瞬き・口パク・髪の遅れなど、VTuber配信に必要な要素に絞って実装している点が特徴です。
以上、SVGのみでVTuberを作る方法の紹介でした!

Appendix(コードのダイジェスト)
App.tsx
Webカメラから取得した顔の動きを解析し、特徴量をReactの状態に変換してCharacter.tsxへ渡す中枢コンポーネントです。
// App.tsx(抜粋・コメント入り)
export default function App() {
// ▼ WebカメラとデバッグCanvas(ミラー表示用)の参照
const videoRef = useRef<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
// ▼ まばたき用ヒステリシスのフラグ(開側/閉側で別閾値にする)
const snapOpenLRef = useRef(false); // 左目:開側
const snapOpenRRef = useRef(false); // 右目:開側
const snapCloseLRef = useRef(false); // 左目:閉側
const snapCloseRRef = useRef(false); // 右目:閉側
// ▼ 表示系トグル(デバッグとOBS用グリーンバック)
const [showCamera, setShowCamera] = useState(false);
const [showGrid, setShowGrid] = useState(false);
const [showGreenBg, setShowGreenBg] = useState(true);
// ▼ キャラのスケール(OBSで構図調整しやすい)
const [charScale, setCharScale] = useState(1.25);
// ▼ 黒目(eyeX/Y)と顔パーツ共通(faceX/Y)の平行移動量(px)
const [eyeX, setEyeX] = useState(0);
const [eyeY, setEyeY] = useState(0);
const [faceX, setFaceX] = useState(0);
const [faceY, setFaceY] = useState(0);
// ▼ まばたき量(0=開,1=閉)左右別
const [blinkLeft, setBlinkLeft] = useState(0);
const [blinkRight, setBlinkRight] = useState(0);
// ▼ 笑顔強度(0..1)— 下まぶた持ち上げなどに利用
const [smile01, setSmile01] = useState(0);
// ▼ 前フレームのヨー/ピッチ(差分から“頭の速さ”を出す)
const prevYawRawRef = useRef(0);
const prevPitchRawRef = useRef(0);
// ▼ 口関連(開き/狭さ/への字)
const [mouthOpen, setMouthOpen] = useState(0); // 0..1
const [mouthNarrow01, setMouthNarrow01] = useState(0); // 口幅の狭さ
const [mouthFrown01, setMouthFrown01] = useState(0); // への字度
// ▼ 顔の回転(ロール:左負/右正)
const [faceRotDeg, setFaceRotDeg] = useState(0);
const prevFaceRotRef = useRef(0); // ブリンク中の“ホールド”用
// ▼ 上半身の傾き(±8°を想定)— 画面上の顔位置+ヨーから算出
const [torsoTiltDeg, setTorsoTiltDeg] = useState(0);
const headZeroXRef = useRef<number | null>(null); // 画面Xのゼロ点(自己校正)
// ▼ デバッグ表示用のヨー/ピッチ
const [faceYawDeg, setFaceYawDeg] = useState(0);
const [facePitchDeg, setFacePitchDeg] = useState(0);
// ===== ユーティリティ(平滑化・制限)=====
const smooth = (p: number, n: number, a = 0.25) => p + (n - p) * a; // なめらか追従
const clamp = (v: number, lo = -1, hi = 1) => Math.max(lo, Math.min(hi, v)); // 範囲制限
const clampDeg = (v: number, lo = -15, hi = 15) => Math.max(lo, Math.min(hi, v));
const avg = (pts: {x:number;y:number;z?:number}[]) => ({
x: pts.reduce((s,p)=>s+p.x,0)/pts.length,
y: pts.reduce((s,p)=>s+p.y,0)/pts.length,
z: pts.reduce((s,p)=>s+(p.z??0),0)/pts.length,
});
const smoothstep = (e0:number, e1:number, x:number) => {
const t = Math.max(0, Math.min(1, (x - e0)/(e1 - e0))); return t*t*(3-2*t);
};
// ===== 自己校正(開/閉の“基準値”を人に合わせて更新)=====
const openBaseL = useRef<number | null>(null);
const closedBaseL = useRef<number | null>(null);
const openBaseR = useRef<number | null>(null);
const closedBaseR = useRef<number | null>(null);
const mouthOpenBase = useRef<number | null>(null);
const mouthClosedBase = useRef<number | null>(null);
// ===== キー操作(Z/X/C/Vでゼロ点/基準をリセット)=====
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const k = e.key.toLowerCase();
if (k === "z") /* ロールのゼロ点を現在値に */ ;
if (k === "x") /* ヨーのゼロ点を再取得 */ ;
if (k === "c") /* ピッチのゼロ点を再取得 */ ;
if (k === "v") headZeroXRef.current = null; // 画面位置のゼロを再セット
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
// ===== FaceMeshの結果を受け取り → 特徴量 → React State へ反映 =====
useEffect(() => {
const faceMesh = new FaceMesh({ /* CDNからアセットを取る */ });
faceMesh.setOptions({ maxNumFaces: 1, refineLandmarks: true, minDetectionConfidence: 0.5, minTrackingConfidence: 0.5 });
faceMesh.onResults((results) => {
// 1) デバッグCanvasへミラー描画(向きの直感と合わせる)
// 2) ランドマークから:瞬き/視線/口/ヨー/ピッチ/ロール を算出
// 3) デッドゾーン, clamp, smooth, ヒステリシス で安定化
// 4) setState() で <Character /> に渡す props を更新
/* ...長いので本文に詳細... */
});
// カメラ開始
let camera: Camera | null = null;
if (videoRef.current) {
camera = new Camera(videoRef.current, {
onFrame: async () => { await faceMesh.send({ image: videoRef.current! }); },
width: 640, height: 480,
});
camera.start();
}
return () => { try{(camera as any)?.stop?.()}catch{} try{(faceMesh as any)?.close?.()}catch{} };
}, []);
}
components/Character.tsx
App.tsxから渡された状態値をもとにSVGパーツを動かしてキャラクターの見た目を描画するコンポーネントです。「propsに何を渡すとどこが動くか」が要点です。
// Character.tsx(抜粋・コメント入り)
type CharacterProps = {
// 黒目のローカル移動量(視線)。±4px想定
eyeOffsetX?: number;
eyeOffsetY?: number;
// 顔パーツ共通の平行移動(ヨー/ピッチ由来)。±5px想定
faceOffsetX?: number;
faceOffsetY?: number;
// まばたき量(0=開,1=閉)左右別
blinkLeft?: number;
blinkRight?: number;
// 口の開き(0..1)
mouthOpen?: number;
// 笑い目(下まぶた持ち上げ)。0..1
eyeSmile?: number;
// メガネの見た目(線幅は non-scaling-stroke 推奨)
showGlasses?: boolean;
glassesStrokeWidth?: number;
glassesLensOpacity?: number;
// 髪の擬似物理パラメータ(バネk/減衰c、遅れ量など)
hairPhysics?: boolean;
hairStiffness?: number;
hairDamping?: number;
// 顔の回転(ロール)
faceRotateDeg?: number;
// 上半身の傾き(±8°)
torsoTiltDeg?: number;
};
// 線幅一定のコア:ベクタ効果(スケールしても線は太らない)
<style>{`
.hair-wrap :is(path,ellipse,polygon,rect,circle){
fill: currentColor !important;
}
.hair-wrap [fill="none"]{
fill:none !important; stroke: currentColor; stroke-width:1.2px;
vector-effect: non-scaling-stroke;
}
`}</style>
// 目のクリップ(白目の中だけ黒目が見える)
<clipPath id={clipIdLeft}>
<path d={`M ... Z`} />
</clipPath>
<ellipse
cx={X_EYE_LEFT + dx + px} cy={Y_EYE_CENTER + dy + py}
rx={EYE_WIDTH/2} ry={EYE_HEIGHT/2}
fill={COLOR_EYE} stroke="black" strokeWidth={1}
clipPath={`url(#${clipIdLeft})`}
/>
// メガネ:線幅は変えたくないので vectorEffect を指定
<path d={lensUPath(Lx, Ly)}
fill="none" stroke={glassesStroke}
strokeWidth={glassesStrokeWidth}
vectorEffect="non-scaling-stroke"
/>
// 上半身の傾き(回転中心のYは胴下部:自然に傾く)
const torsoDeg = clamp(torsoTiltDeg, -8, 8);
const torsoPivot = (torsoPivotY ?? Y_BODY_BOTTOM);
const upperMotion = `rotate(${torsoDeg}, ${X_FACE_CENTER}, ${torsoPivot})`;
<g transform={upperMotion}>{/* 胴・首・腕・髪など */}</g>
変数・状態の一覧
App.tsx(入力→特徴量→状態)
videoRef/canvasRef… Webカメラ&デバッグCanvas- UI:
showCamera/showGrid/showGreenBg/charScale - 視線・顔:
eyeX/eyeY,faceX/faceY,faceRotDeg - まばたき:
blinkLeft/blinkRight(0..1, 左右別) - 口:
mouthOpen(0..1),mouthNarrow01,mouthFrown01 - 笑顔:
smile01(0..1, 下まぶた上がる度) - デバッグ角:
faceYawDeg/facePitchDeg - 上半身:
torsoTiltDeg(±8°) - 自己校正:
openBase*/closedBase*(目),mouth*Base(口)
→ 人ごと/距離ごとの基準値更新
Character.tsx(見た目に効くprops)
- 視線:
eyeOffsetX/Y - 顔パーツ:
faceOffsetX/Y,faceRotateDeg - 瞬き:
blinkLeft/Right - 口:
mouthOpen+mouthNarrow01/ mouthFrown01(形の条件分岐に使用) - 笑い目:
eyeSmile(下まぶた上げ) - 髪物理:
hairPhysics,hairStiffness,hairDamping, ほか追従量 - 上半身:
torsoTiltDeg - 装飾:
showGlasses,glasses*(vector-effectで線幅一定)