開発ブログ · プロシージャルアニメーション

HTML Canvas で棒人間を動かす:2 ボーン IK によるプロシージャルアニメーション

スプライトシートなしで、私たちの棒人間が歩き、走り、跳び、槍を振るう仕組み。10 行の IK ソルバー、足の滑りを止めるたったひとつの式、そして命を吹き込むいくつかの安上がりな小技。

更新 執筆 ShengyuStudio約 10 分で読めます

棒人間のページの棒人間には、スプライトシートもアニメーションファイルもありません。毎フレーム、数百行の JavaScript が腰・膝・手・頭の位置を計算し、その結果を HTML の <canvas> に描いています。モーションラボでは歩く、走る、素手で投げる、跳ぶ、そして十七式の槍術套路を演じ、どのタイミングで切り替えても目に見える「カクつき」はありません。

このページは今、小さな遊べるゲーム Stickman Spear Run になっています。棒人間が長い槍を携えて走り、両手で前へ突き出して攻撃するランゲームです(スペースか ↑ でジャンプ、F か → で突き)。元のモーションラボは 2 つめのモードとして残っているので、今でも動きを切り替えて——そこにしかない素手の投げも含めて——骨格が動く様子を観察できます。この記事では、その裏にある技術を実際のソースコードの抜粋とともに解説します。余弦定理による 2 ボーン逆運動学(IK)、イージング付きのキーフレームトラック、足の接地、スクワッシュ&ストレッチ、そして二次的な動きのためのちょっとした Verlet 物理です。

濃い紫のステージで歩いている途中のプロシージャルな棒人間
この棒人間は、毎フレーム計算される 12 個ほどの関節の位置から描かれています。

数値でできた骨格

棒人間全体は、canvas 単位で表したいくつかの骨の長さだけでできています。

const SPINE=54, NECK=13, HEADR=12;
const UARM=27, FARM=25, THIGH=37, SHIN=37;
const HIP_H=66;      // standing hip height: knees always slightly bent

HIP_H(66)が THIGH + SHIN(74)より短いことに注目してください。棒人間は膝を伸ばしきって立つことがなく、それが立ち止まっているときでも生きているように見える理由です。これらのサイズは固定なので、代わりにスクリプトが canvas をスケーリングします。画面上の要素の大きさにかかわらず、描画空間の高さが常に約 440 単位になるようにスケールを選ぶのです。スマホでも 4K モニターでも、構図と間隔はまったく同じに保たれます。

毎フレーム、buildPose(dt) という関数がスクリーン座標の点の集合——骨盤、胸、頭、肩、腰、両膝、両足、両肘、両手——を返し、別の描画ステップがそれを描きます。「関節がどこにあるか」と「それをどう描くか」を分けておくことで、動きに手を触れずに絵柄を変えられます。

余弦定理で解く 2 ボーン IK

逆運動学(IK)が答えるのは、腰がここにあって、足をあそこに置きたいなら、膝はどこに来るか?という問いです。骨が 2 本だけの連鎖なら、厳密な閉形式の解があり、しかもほんの数行で書けます。

function ik(root, target, L1, L2, sign){
  let dx=target.x-root.x, dy=target.y-root.y;
  let d=Math.hypot(dx,dy);
  d=clamp(d, Math.abs(L1-L2)+.01, L1+L2-.01);
  const base=Math.atan2(dy,dx);
  let ca=clamp((L1*L1+d*d-L2*L2)/(2*L1*d),-1,1);
  const a=Math.acos(ca);
  const jointAng=base+sign*a;
  return V(root.x+Math.cos(jointAng)*L1, root.y+Math.sin(jointAng)*L1);
}

stickman.js に含まれる 2 ボーンソルバーの全文。中間の関節(膝または肘)を返します。

root(股関節) joint(膝) target(足) L1 L2 d a
股関節・膝・足は、3 辺の長さがすべてわかっている三角形をつくります。

考え方はこうです。太もも(L1)、すね(L2)、そして股関節から足までの直線(d)が三角形をつくります。3 辺がすべてわかっているので、余弦定理でどの角度も求められます。

余弦定理

L2² = L1² + d² − 2·L1·d·cos(a) ⇒ cos(a) = (L1² + d² − L2²) / (2·L1·d)

a は、股関節の位置で「股関節→足」の線と太ももがなす角です。「股関節→足」の線の向きは base = atan2(dy, dx) なので、太ももは base ± a の方向を向きます。股関節からその方向へ L1 だけ進めば、そこが膝です。sign 引数は、鏡像になる 2 つの解のどちらを取るかを決めます。膝は前に曲がり、両手で槍を握るときは、手前と奥の肘が互いに離れる向きに曲がるよう逆の符号を使います。

2 つのクランプがソルバーを頑丈にしています。

  • d のクランプ。[|L1 − L2|, L1 + L2] のわずかに内側に収めます。目標が届かない位置にあると三角形が存在せず、acos は NaN を返してしまいます。クランプすれば「届く限り目標に向かって伸ばす」という意味になります。
  • 余弦のクランプ。[−1, 1] に収めます。浮動小数点の誤差で 1.0000001 になることがあり、これも NaN の領域です。

脚のコードでは、ik() を呼ぶ前にもうひとつ安全策を入れています。足の目標が股関節から THIGH + SHIN − 1.5 より遠い場合は、同じ線に沿って引き戻すのです。脚はほぼまっすぐにはなっても完全に伸びきることはなく、2 ボーンの連鎖が最大まで伸びたときに起きる目に見える「パチッ」という跳ねを防げます。

脚は IK、腕はおもに FK

何でも IK で解きたくなりますが、私たちはそうしません。足は正確な位置——地面の上——にある必要があるので、脚は IK で解きます。一方、歩くときに振れる腕には目標がなく、あるのはリズムです。そこでは順運動学(FK:角度を決めて位置を計算する)のほうがシンプルで、見た目も自然です。

function armFK(aUp, flex){
  const e=add(shoulder,{x:Math.sin(aUp)*UARM, y:Math.cos(aUp)*UARM});
  const h=add(e,        {x:Math.sin(aUp+flex)*FARM, y:Math.cos(aUp+flex)*FARM});
  return {elbow:e, hand:h};
}

ここでの角度の約束は「0 で真下、正の値で前に振る」。これで歩きのポーズが考えやすくなります。歩行中、各腕は同じ側の脚と逆に振れ、前に振るときは肘がより曲がり、後ろに振るときは伸びます——この小さな工夫で、ロボットのような振り子っぽさが消えます。腕が IK に切り替わるのは、手が決まった場所にある必要があるとき、つまり槍の柄を握るときだけです。

ひとつの位相変数で回す歩行サイクル

歩きと走りは、0 から 1 までループするただひとつの数値 phase で駆動されます。手前の足は phase、奥の足は phase + 0.5 を使うので、2 本の足は常に半サイクルずれています。もうひとつの数値 gait が 0(歩き)から 1(走り)までブレンドし、歩容のパラメーターはすべてこれで補間されます。

パラメーター歩き(gait = 0)走り(gait = 1)
ピッチ cyc(1 秒あたりのサイクル数)1.52.55
歩幅4460
足の上がる高さ1526
接地率(1 サイクルのうち足が地面に着いている割合)0.620.40
腰の上下動の振幅512

重要なのは接地率です。0.5 を超えると、各サイクルの一部で両足が地面に着いています——それが「歩き」の定義です。0.5 を下回ると、どちらの足も着いていない瞬間、つまり空中局面が生まれます。走りが走りらしく見えるのはこのためです。gait を滑らかにブレンドする(毎フレーム目標値へイージングで近づける)ことで、歩きと走りの切り替えでカクつくことはありません。

それぞれの足の軌道は 2 つに分かれます。立脚期(サイクルの最初の duty の部分)では、腰の半歩前から半歩後ろまで、地面にぴったり着いたまま直線的に移動します。遊脚期では、スムーズステップ曲線に沿って再び前へ移動し、半周期のサイン波で持ち上げられます。

if(pph<duty){ const u=pph/duty; fx=lerp(stride/2,-stride/2,u); fy=groundY; }
else{ const u=(pph-duty)/(1-duty); fx=lerp(-stride/2,stride/2,smooth(u));
      fy=groundY - stepH*Math.sin(PI*u); }

腰は 1 サイクルに 2 回(1 歩ごとに 1 回)、-|sin(phase·2π)| を使って上下します。走りではこの曲線の位相をずらし、最高点が空中局面と重なるようにしています。また、体の前傾に比例して足を少し前に出し、重心の真下に足が来るようにしています。

足の接地:ムーンウォークを止める

棒人間は画面上の決まった位置にとどまり、地面のほうが流れていきます。スクロール速度を間違えると、接地しているはずの足が地面の上を滑ってしまう——おなじみのムーンウォークバグです。直し方は 1 行です。

const scroll = stride*cyc/duty;   // ground speed = stride × cadence ÷ duty factor

なぜこれでうまくいくのか。立脚期のあいだに、足は腰に対してちょうど 1 stride(歩幅)分移動します。立脚期は 1 サイクルの duty 分で、1 サイクルは 1/cyc 秒なので、立脚期は duty/cyc 秒続きます。したがって足は体に対して次の速さで後ろへ動きます。

stride ÷ (duty ÷ cyc) = stride × cyc ÷ duty

地面がちょうどその速さで後ろへ流れれば、足と地面は一緒に動き、足は地面に貼りつきます。表の数値を使うと、歩きのスクロールは 44 × 1.5 ÷ 0.62 ≈ 毎秒 106 単位、走りは 60 × 2.55 ÷ 0.40 ≈ 383。歩幅は 3 分の 1 長いだけなのに、走りは 3 倍以上速いことに注目してください。大部分の仕事をしているのは、短くなった接地率です。よくある間違いは stride × cadence を使うことで、これだと接地率が無視され、立脚中の足がずるずると流れてしまいます。

シーンの地面は柔らかな点の列で、毎フレーム scroll × dt だけ動きます。棒人間が移動をやめて投げる・跳ぶ・槍術を演じるときは、スクロールが 0 になり、点も止まります。

イージング付きキーフレームトラック

投げやジャンプはサイクルではなく、予備動作・本動作・戻りのある単発の動きです。これには小さなキーフレームトラックを使います。[時刻, 値] のペアの配列を、0 から 1 に正規化した時刻 p でサンプリングするものです。

function track(t, keys, ease){
  ease=ease||smooth;
  if(t<=keys[0][0]) return keys[0][1];
  for(let i=0;i<keys.length-1;i++){
    const [t0,v0]=keys[i],[t1,v1]=keys[i+1];
    if(t<=t1){ let u=(t-t0)/((t1-t0)||1); return lerp(v0,v1,ease(u)); }
  }
  return keys[keys.length-1][1];
}

アニメーションさせるプロパティごとに専用のトラックを持たせます。ジャンプでの体の上下オフセットはこうです。

rootY = track(p,[[0,0],[0.2,10],[0.32,-6],[0.5,-160],[0.68,-70],[0.82,4],[0.9,-2],[1,0]],
              (p<0.5?easeOutCubic:easeInCubic));

物語として読んでみてください。しゃがみ込み(+10。canvas の y は下向きであることに注意)、踏み切り、頂点で 160 単位まで上昇、落下、着地の衝撃を地面よりわずかに下で吸収し、落ち着く。イージングは途中で切り替わります。上りは easeOutCubic(勢いよく飛び出し、頂点に向かって減速)、下りは easeInCubic(ゆっくり始まり、加速していく)。重力の放物線を、安上がりながら説得力のある形でまねています。

槍術套路は同じ考え方をさらに推し進めたものです。18 個のウェイポイント(十七式のポーズに、最初のポーズと一致する締めのキーを加えたもので、套路が継ぎ目なくループします)があり、それぞれが全身——槍の角度、握りの位置、前傾、重心移動、しゃがみ、スタンスの幅、持ち上げ、つぶれ——を記述し、区間ごとに任意のイージングを指定できます。見栄えを良くする小技が 2 つあります。同じウェイポイントを 2 つ続けると静止が生まれ、武術の型の「決め」のポーズになります。そして突きには easeOutCubic を使うので、槍は勢いよく飛び出してから減速してポーズに収まり、キレと力強さが伝わります。

スクワッシュ&ストレッチ

ジャンプでは縦のスケール sy もアニメーションさせています。しゃがみで 0.84 につぶれ、踏み切りで 1.14 に伸び、着地で再び 0.82 につぶれる。横のスケールはそこから導き出し、体積が保たれているように見せます。

sx = 1+(1-sy)*0.7;     // squash down → get wider; stretch up → get thinner
const pivot=V(anchorX, groundY);
const S=p=>V(pivot.x+(p.x-pivot.x)*sx, pivot.y+(p.y-pivot.y)*sy);

buildPose の最後で、すべての関節を S() に通します。体の中心ではなく、棒人間の真下の地面の点を軸にスケーリングすることで、体がつぶれても足は床に着いたまま——アニメーターが手で描くのとまったく同じです。頭の半径は sx と sy の平均でスケーリングします。

二次的な動き:バネと Verlet チェーン

一次的な動きは、キャラクターが「すること」。二次的な動きは、それについてくるもの——髪、布、体より一瞬遅れてついてくる頭です。追加するのは安上がりなのに、効果は絶大です。この棒人間には 2 種類あります。

頭の減衰バネ

頭は首の上に固定されているわけではありません。バネ(剛性 260、減衰 26)で理想の位置へ引っぱられているので、体が急に動くと頭は遅れてついてきて、わずかに行き過ぎてから落ち着きます。減衰はあえて低めにして、ちょっとしたカートゥーン的な揺れを残しています。

Verlet の髪

髪の房は、9 単位間隔に並んだ 4 つの点のチェーンです。Verlet 積分では各点の現在位置と前回の位置を保存し、速度は単にその差になります。そのため更新処理はとても短くなります。

const s=scarf[i], vx=(s.x-s.px)*0.86, vy=(s.y-s.py)*0.86;
s.px=s.x; s.py=s.y;
s.x+=vx+windX*dt*dt*60; s.y+=vy+g*dt*dt*60;

係数 0.86 は空気抵抗です。windX は走る速さに応じて大きくなる後ろ向きの力(ジャンプ中はさらに一押し)、g は重力です。点を動かしたあと、距離拘束を 6 回反復して各ペアをちょうど 9 単位の間隔に引き戻し、先頭の点は後頭部に固定します。固定と拘束、必要なのはそれだけです。髪は走れば後ろへなびき、歩けば垂れ、跳べば跳ねる——どれも手でアニメーションさせたものではありません。

柔らかい手足を描く

直線で描いた棒人間は硬く見えます。私たちの棒人間は、各手足の 3 点(たとえば腰・膝・足)を Catmull-Rom スプラインに通して滑らかな曲線に細分化し、そのまわりに先細りのリボン——腰側は太く、足側は細く——を丸い端で塗りつぶしています。結果はラバーホース風のカートゥーンの手足のように、関節で折れるのではなくしなやかに曲がります。奥側の手足は先に、より細く、より暗く、42% の不透明度で描くので、3D を一切使わずに奥行きが感じられます。

最後に、ループは行儀よく作ってあります。各フレームの dt は 33 ms で頭打ちにしているので、一瞬引っかかっても髪が画面の向こうまで吹き飛ぶことはありません。ステージが画面外にスクロールしたときやタブが非表示のときはアニメーションを止め、訪問者のシステムが「視差効果を減らす」を求めている場合は、1 枚の静止フレームで停止した状態から始まります。

盗んでほしいポイント

  • 2 ボーン IK は 10 行。距離と余弦をクランプすれば、NaN は絶対に出ません。
  • 目標があるところは IK、リズムがあるところは FK。
  • 地面の速さ = 歩幅 × ピッチ ÷ 接地率。これさえ正しければ、どんな歩容でも足は床に貼りつきます。
  • 区間ごとにイージングを指定できるキーフレームトラックがあれば、表情豊かな単発の動きには十分。キーを複製すれば静止が作れます。
  • つぶすときは中心ではなく接地点を軸に。幅は高さから導き出して体積を保ちましょう。
  • バネと 4 点の Verlet チェーンで、「生きている感じ」の大半がほとんどコードなしで手に入ります。

ここで紹介したすべては、棒人間のページで実際に動いているところを見られます。今は『Stickman Spear Run』として、走って、跳んで、槍で突くゲームになり、モーションラボは 2 つめのモードとして残っています。棒人間が好きなら、私たちが開発中の物理パズルゲーム Ink Left(インクレフト)にも棒人間が登場します——描きかけのスケッチブックの中に。