Discordの立ち絵で待機モーション&リップシンクを実現するOBSカスタムCSS解説

Discordの立ち絵で待機モーション&リップシンクを実現するOBSカスタムCSS解説

2022年12月24日追記
2022年12月16日ごろにDiscord Streamkit Overlayの出力ソースが変わったらしく、class名等を調節しないと動かなくなりました。新仕様対応コードの解説を書いたのであわせてご覧ください。

OBSにDiscordを表示するときの立ち絵口パクとかアイコンがぴょこぴょこするのってCSSアニメーションだから、待機モーションも作れるんじゃないか?と思ったのでやりました。

Discord Streamkit OverlayのカスタムCSSはCSSなんだからパカパカアニメじゃなくてパーツ動かせるじゃん?って気づいてやってみたらできた pic.twitter.com/FaWECDPX8n

— なつみ (@natsumi_m31) February 3, 2022

2022.3.29追記:その後もっと作り込んだのでよかったらこちらも見てください。

この記事は、すでにDiscord Streamkit OverlayをOBSに設定して表示するところまでできている人向けの記事です。
待機モーションのCSSアニメーションの仕組みについてのみ解説します。

動画も作りましたがコードは記事を見ていただくほうがわかりやすいと思います。

Discordのアニメーションでできることとできないこと

できること

  • Discordアバターの代わりに立ち絵を表示する
  • 立ち絵をゆらゆらさせる待機モーション
  • パーツごとに異なる動き
  • まばたきや髪の毛の揺れなどの表現
  • 発話中は待機モーションと同時に口パク(リップシンク)
  • 入室中メンバーを複数人同時に表示

できないこと

  • 演者のモーションキャプチャ
  • 笑う、叫ぶなどの声の種類に合わせたアニメーションの切り替え
  • HTMLの要素数以上のパーツ分割

個人で配信または動画撮影をするだけであれば、3Dモデルやモーションキャプチャを使用してリッチな表現をできますが、この手法のメリットは「Discordメンバー全員をアバターで表現」できることと、「会話しているアニメーションをリアルタイムで表示」できることです。

ふだん2Dイラストのアバターで活動している人が視聴者にもっと存在感を感じてもらえるような目的で使うのに向いています。
グループでの雑談配信やwebラジオなど、会話中心の動画配信で表現の幅を広げたい場合にもおすすめです。

Discord Streamkit Overlay 待機モーションカスタムCSSコード解説

完成コード

先に完成したコード全体を掲載しておきます。
どのようなことをやっているのか、ひとつずつ説明していきます。


/*-- アバター領域共通設定 --*/

/* デフォルトアバター非表示 */
.avatar {
    display: none;
}

ul.voice-states {
    position: fixed;
    top: 0;
    left: 0;
    display: block;
    width: 1920px;
    height: 1080px;
    padding: 0!important;
}

li.voice-state {
    position: absolute;
    right: 100px;
    bottom: 0;
    width: 457px;
    height: 656px!important;
    overflow: visible;
}

li.voice-state::before,
li.voice-state::after,
li.voice-state .user,
li.voice-state .user::before,
li.voice-state .user::after,
li.voice-state .user .name,
li.voice-state .user .name::before,
li.voice-state .user .name::after {
    content: "";
    position: absolute;
    top: 0;
    left: 0;
    display: block;
    width: 457px;
    height: 656px;
    background: url() 0 0 no-repeat;
}

li.voice-state .user .name {
    color: transparent!important;
    background-color: transparent!important;
}

/*-- アバター個別設定 --*/

/* base */
li.voice-state[data-reactid*="DiscordのユーザーID"] {
    animation: base 4s linear 0s alternate infinite;
}

/* right arm */
li.voice-state[data-reactid*="DiscordのユーザーID"] .user::before {
    background-image: url(画像URL);
    animation: right-arm 3s ease-in-out 0s alternate infinite;
    transform-origin: 150px 430px;
}

/* left arm */
li.voice-state[data-reactid*="DiscordのユーザーID"] .user::after {
    background-image: url(画像URL);
    animation: left-arm 3s ease-in-out 0s alternate infinite;
    transform-origin: 280px 380px;
}

/* body */
li.voice-state[data-reactid*="DiscordのユーザーID"] .user .name{
    background-image: url(画像URL);
}

/* face */
li.voice-state[data-reactid*="DiscordのユーザーID"] .user .name::before{
    background-image: url(画像URL);
    animation: face 3.5s ease-in-out 0s alternate infinite;
    transform-origin: 240px 370px;
}

/* mouth */
li.voice-state[data-reactid*="DiscordのユーザーID"] .user .name::after{
    background-image: url(画像URL);
    visibility: hidden;
}

li.voice-state[data-reactid*="DiscordのユーザーID"] .speaking + .user .name::after{
    animation: mouth 0.3s steps(2) 0s alternate infinite;
}

@keyframes base {
    0% {
        transform: rotate(-2deg);
    }
    5% {
        transform: rotate(-2deg);       
    }
    50% {
        transform: rotate(0);
    }
    95% {
        transform: rotate(2deg);
    }
    100% {
        transform: rotate(2deg);
    }
}

@keyframes right-arm {
    0% {
        transform: rotate(0);
    }
    90% {
        transform: rotate(-12deg);
    }
    100% {
        transform: rotate(-12deg);
    }
}

@keyframes face {
    0% {
        transform: rotate(-4deg);
    }
    90% {
        transform: rotate(6deg);
    }
    100% {
        transform: rotate(6deg);
    }
}

@keyframes left-arm {
    0% {
        transform: rotate(0);
    }
    90% {
        transform: rotate(4deg);
    }
    100% {
        transform: rotate(4deg);
    }
}

@keyframes mouth {
    0% {
        visibility: hidden;
    }
    100% {
        visibility: visible;
    }
}

立ち絵イラストを表示するための事前準備

まずは、既存のDiscordのアバターを非表示にします。


/* デフォルトアバター非表示 */
.avatar {
    display: none;
}

これでclass=”avatar”がついたimg要素が表示されなくなります。

次にユーザーのリストになっているul要素を調整します。


ul.voice-states {
    position: fixed;
    top: 0;
    left: 0;
    display: block;
    width: 1920px;
    height: 1080px;
    padding: 0!important;
}

このulは全員分のリストが入った箱です。
画面いっぱいに表示領域を広げてあげたいので、OBSで録画・配信している解像度を設定してください。
ここでは幅1920px、高さ1080pxとしています。

また、もともとDiscord Streamkit Overlay側で用意されているスタイルでpaddingが設定されているので、0にしておきます。
強制的に上書きしたいので!importantを使用しています。

paddingをそのままにしておくと、高さにプラスされてしまい、イラストを表示する際に位置を調整する必要があって面倒くさいので先に0にしておきます。
CSSに詳しい方であればbox-sizingなどを使って調整しても構いません。

続いてulの中に入っているli要素です。


li.voice-state {
    position: absolute;
    right: 100px;
    bottom: 0;
    width: 457px;
    height: 656px!important;
    overflow: visible;
}

これがメンバー1人ずつの領域です。

指定は、画面にどのように表示したいかによって適宜変更してください。
たとえば上記サンプルは右下寄せにするためright,bottomを指定していますが、liのサイズや中身の画像の形、アニメーションの可動域によっては、left指定のほうがいいなどもあるかもしれません。

position: absolute;と、right,bottomを使うことにより、直接ulの右下にフィットするように指定しています。
そして幅と高さはイラスト全体のサイズを指定します。

heightは先程のulと同様に、強制的に上書きする必要があるため!importantをつけています。

そしてoverflow: visible;は、中身のパーツがアニメーションで動いたときに、指定したliのサイズをはみ出した部分も表示されるようにしています。

もし複数人を同時に表示したい場合は、以下のようにユーザー別にスタイルを変更してあげます。


/* 共通設定 */
li.voice-state {
    position: absolute;
    overflow: visible;
}
/* 個別設定 */
li.voice-state[data-reactid*="DiscordのユーザーID"] {
        bottom: 0;
        left: 100px;
        width: 300px;
        height: 500px!important;
}
li.voice-state[data-reactid*="DiscordのユーザーID"] {
        bottom: 0;
        left: 500px;
        width: 200px;
        height: 700px!important;
}
li.voice-state[data-reactid*="DiscordのユーザーID"] {
        bottom: 0;
        left: 800px;
        width: 400px;
        height: 400px!important;
}

サイズは配置を個別に変更できるので、この画像のように配信者自身をメインにし、トーク相手をアイコンにするというレイアウトも可能です。

デフォルトの状態でユーザー名が表示されている箇所が見えないようにしたら準備完了です。


li.voice-state .user .name {
    color: transparent!important;
    background-color: transparent!important;
}

ユーザー名のテキストと、背景色を透明にしました。

ここでdisplay: none;を使わない理由は次の項目で説明します。

待機モーションで別々に動かすパーツ画像の用意

いよいよアバター本体を作っていきます。

今回は、画像はすべて同じサイズの枠の中に配置しました。
パーツに合わせて切り出した画像でも同じことはできますが、CSSで配置の指定などを個別に調整しないといけないのが面倒だったのでサイズを揃えてあります。

頭、発話中の開いた口、胴体、右腕、左腕としました。

このほかに目を切り分けてまばたきやウインクをさせたり、ポニーテールを揺らしたり、衣装のリボンを揺らしたり、おっぱいを揺らしたりすることができるでしょう。(まあ定期的にウインクするのはちょっと不自然なので片目ずつパーツを分けることもないかと思います。)

パーツを割り当てられるHTML要素は次のとおりです。

  • li.voice-state::before
  • li.voice-state::after
  • li.voice-state div.user
  • li.voice-state div.user::before
  • li.voice-state div.user::after
  • li.voice-state div.user span.name
  • li.voice-state div.user span.name::before
  • li.voice-state div.user span.name::after
追記
当初「li.voice-state」を画像をあてられる要素として上記リストに含めていましたが、カスタマイズ時にその行が必要かどうかを判断したり、ベースの枠としての設定と画像表示を切り分けて調整したりするのは、CSSがわからない人には難しいためサンプルコードから省くことにしました。また、「li.voice-state」が「ul.voice-states」と近しい大きさのときに意図した表示にならないケースがあったため、誰でも共通で使える形に調整しています。CSSできる人は「li.voice-state」も利用して複雑な設定が可能です。(そういう人にはそもそも解説がいらないと思いますが…)

Discord Streamkit Overlayに書かれているHTMLには.voice-state.user.nameがあります。

さらにそれぞれの要素は、before擬似要素とafter擬似要素を内包することができるため、それらすべてを別々のパーツに割り当てることが可能です。

さきほど.nameをdisplay:none;にしなかったのはこのためです。
display:none;にしてしまうと.name自身が描画されなくなってしまうため、before擬似要素もafter擬似要素も表示することができなくなり、3つ分のパーツが使えなくなってしまいます。
パーツが不足しないのであれば、display:none;にしてしまっても問題ありません。

なおimg要素にはbefore擬似要素やafter擬似要素という概念はありません。
img要素自身のcontentを書き換えて再利用すればもうひとつパーツを増やせますが、今回とくに必要ないので割愛します。

最大10個のパーツを割り当てることができるので、かなり細かい表現もできそうですね。

重なり順を意識してどのパーツを割り当てるか決めておきましょう。
z-indexを使って調整しても構いません。

実際に重なり順の挙動を試したのがこちらのコードです。

See the Pen
Discord Streamkit Overlay HTML elements layer
by natsumi (@mayo31)
on CodePen.0

どうしてこうなるのかわからんって人はスタックコンテキストについて調べてみてください。本記事はCSSの仕様を解説する記事ではないのでそのあたりは割愛します。

パーツ別にゆらゆらするアニメーションの設定

アニメーションはそれぞれ名前をつけて管理します。

ここではイラスト全体をまとめて動かすためのbase、顔を動かすためのface、右腕を動かすためのright-armなどのように命名しました。
baseを例に説明します。


@keyframes base {
    0% {
        transform: rotate(-2deg);
    }
    5% {
        transform: rotate(-2deg);       
    }
    50% {
        transform: rotate(0);
    }
    95% {
        transform: rotate(2deg);
    }
    100% {
        transform: rotate(2deg);
    }
}

CSSアニメーションは、1回分(1ターン分)の動きを0%〜100%の時間で設定します。

上記の例では、0%(アニメーションスタート)は左に2°傾けて、100%(アニメーション終了)の時点では右に2°傾いています。
ほかのパーツも同様に、どんな動きをさせたいかによって指定していきます。

しかし、これだけだと、左から右に傾いて終わっちゃうじゃないか?と思うかもしれません。

実は、このアニメーションの設定はただ「特定の動きに名前をつけた」だけです。
baseという動きを作ったら、それを適用したい要素のほうで指定して呼び出します。


/* base */
li.voice-state[data-reactid*="DiscordのユーザーID"] {
    animation: base 4s linear 0s alternate infinite;
}

左から順に、

  1. 適用するアニメーションの名前
  2. 1回のアニメーションの再生秒数
  3. アニメーションの加速度
  4. アニメーション開始までの遅延秒数
  5. 順再生、逆再生、往復再生
  6. 再生回数

となっています。

加速度はここではlinearになっていますが、これは一定のスピードで動きます。
ほかにも徐々に加速したり、徐々に減速したり、または加速したあとに減速する、といった指定が可能です。@keyframesのタイムラインと加速度を組み合わせることでリアルな動きやカートゥーン的な動きなど、いろいろなモーションを作ることができますので、研究してみてください。

alternateは@keyframesの内容を往復します。
そのため片道分の動きを設定するだけで、あとは自動で往復の動きを繰り返してくれるわけです。

そしてinfiniteは無限ループ再生です。

関節から動かすための設定

腕や首の動きを自然にするために、関節の位置を中心として回転させたいですね。
腕ならば肩から回すようにしたいし、首を傾げる動きをさせたいのに頭がその場でぐるっと回ったら気持ち悪いですからね。

というわけでその起点を設定できるのが、transform-originです。


/* right arm */
li.voice-state[data-reactid*="DiscordのユーザーID"] .user::before {
    background-image: url(画像URL);
    animation: right-arm 3s ease-in-out 0s alternate infinite;
    transform-origin: 150px 430px;
}

要素の一番左上を0地点として、そこから横方向、縦方向の順で、位置を指定します。
上記の例だと左から150px、上から430pxの位置を基準に変形します。

今回のサンプルではrotate()で回転させていますが、そのほかにも拡大・縮小ができるscale()や、斜めに歪ませることができるskew()といった値を設定できますので、これらも覚えると自由度が高まります。ぜひ調べてみてください。

口パク部分のCSSアニメーション設定

最後に口パク部分のアニメーションです。


@keyframes mouth {
    0% {
        visibility: hidden;
    }
    100% {
        visibility: visible;
    }
}

とてもシンプルですね。
スタート時は開いた口の画像は見えなくなっていて、アニメーションが始まったら表示されます。
それを繰り返すことで口がぱくぱく動くように見えます。

display: none;opacity: 0;なんかを使っても同じことが実現できます。

通常時は常にhiddenになるように指定しておいて、発話中のステータスであるclass=”speaking”が付与されている間はアニメーションするようにします。
もちろん、口パク以外にも、喋っている間だけ別のポーズをとらせるということもできます。


/* mouth */
li.voice-state[data-reactid*="DiscordのユーザーID"] .user .name::after{
    background-image: url(画像URL);
    visibility: hidden;
}

li.voice-state[data-reactid*="DiscordのユーザーID"] .speaking + .user .name::after{
    animation: mouth 0.3s steps(2) 0s alternate infinite;
}

.speakingがつくのはimg要素なので、「.speakingの次にある.user」を辿って指定してあげます。

ここで気になるのはsteps()という指定方法ですね。

lineareaseといった加速度を使っている場合、状態が徐々に移り変わります。
もし変化させるプロパティがopacityだったら、0〜1の間に0.3や0.7といった途中の段階があって、透明から半透明を経て不透明になります。
ところがsteps()を使うと、アニメーションを指定した数字で分割してコマ撮りのような表現になります。

パッパッパッと明滅するようなアニメーションにしたいときに使える技です。

たとえばまばたきのモーションをさせるとき、アニメーションの中割りを作って滑らかにすることが可能です。
開いている目、閉じかけの目、閉じた目の複数の画像を用意しておいて、@keyframeのなかでbackground-imageを差し替えていきます。
そうやってコマごとの画像を切り替えて、それぞれの秒数を調整するとクオリティの高いアニメーションに仕上がります。

steps(n)をつかったローディングスピナーの記事

このころはbox-shadowにハマっていたんだな…なつかしい…

まとめ

今回は思いつきを手っ取り早く試すためにいらすとやさんのイラストをお借りしましたが、SVG画像なんかを使えば、むぎゅーっと潰れるなどの変形をしても画像が粗くならずなめらかにできます。
2Dイラストでも変形の角度をうまく調整すれば奥行を感じさせることもできると思います。
魅力的なモーションを楽しんでください!

コードのご利用に関して

紹介しているコードは、個人でお楽しみの場合は自由にご利用いただいてかまいません。
法人のご利用および、動画配信プラットフォームその他の収益化しているコンテンツでこの内容を転載される場合には事前にご一報ください。

Discord Streamkit Overlay カスタムCSSの関連記事