0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

自然に見えるアニメーションのための3つのポイント

0
Last updated at Posted at 2026-04-16

「自然な動き」を作ろうとすると、意外な難しさを感じることが多いのではないでしょうか?

ただ動かすだけなら簡単なのに、
生き物っぽくしようとすると一気に出てくる、「なんか違う感」。

とりあえずバウンス系のイージングを試してみるけど、
なんか違うまま終わる、あの感じ。

この記事では、GSAPを使って
「生きているように見える蝶のアニメーション」を実装しながら、

上記3つのポイントを説明していこうと思います。

ということで、まずは 完成品 から

See the Pen butterfly by maehata (@maehata1) on CodePen.

基本構造

まずは、今回作る蝶の基本の構造を作成していきます。

使用画像

今回は以下の2枚の画像を使って実装していきます。
butterfly_left.png
butterfly_right.png

画像はこちらから、ダウンロードできます。

HTML

<!-- gsapの読み込み -->
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/ScrollTrigger.min.js"></script>

<div class="butterfly">
  <div class="butterfly_body" data-butterfly-body>
    <img class="butterfly_wing butterfly_wing__left"  src="./butterfly_left.png" alt="左羽の画像" width="200" height="400">
    <img class="butterfly_wing butterfly_wing__right" src="./butterfly_right.png"  alt="右羽の画像" width="200" height="400">
  </div>
</div>

構造としては、蝶々を入れる箱の中に蝶を配置しています。
羽をrotateで羽ばたかせるため、蝶の画像を真っ二つにしています。
また、[data-butterfly-body]でJSに要素を渡します。
今回は、アニメーションの作成にGSAPを利用するため、こちらも読み込んでおきましょう。

CSS

/* css */
.butterfly {
  display: block;
  position: absolute;
  width: 150px;
  height: 150px;
  top: 50%;
  left: 50%;
  translate: -50% -50%;
  transform-origin: center;
  pointer-events: none;
}

.butterfly_body {
  width: 100%;
  display: grid;
  grid-template-columns: 1fr 1fr;
}

.butterfly_wing {
  width: 100%;
  height: 100%;
}

.butterfly_wing__left {
  /* 左羽の中心を、右中央に設定 */
  transform-origin: right center;
}

.butterfly_wing__right {
  /* 右羽の中心を、左中央に設定 */
  transform-origin: left center;
}

2つの羽のrotateを体の中心にしたいので、各羽ごとにtransform-originを設定しています。

JS

// 蝶の羽ばたきのアニメーション
const animateWingFlap = (target) => {
  const [leftWing, rightWing] = target.querySelectorAll('img');
  if (!leftWing || !rightWing) return;
  
  const flapAngle = 60;
  const flapDuration = 1;

  const loop = () => {

    gsap.timeline({
      onComplete: loop  //アニメーションtimelineの終了でループを発火
    })
    // 羽を閉じるアニメーション
    .to([leftWing, rightWing], {
      rotationY: (i) => (i === 0 ? flapAngle : -flapAngle),
      duration: flapDuration * 0.5,
    })
    // 羽を開くアニメーション
    .to([leftWing, rightWing], {
      rotationY: 0,
      duration: flapDuration * 0.5,
    });
  };

  loop();
};


const butterflyAnimation = () => {
  const butterflyList = document.querySelectorAll('[data-butterfly-body]');
  if (!butterflyList.length) return;

  butterflyList.forEach((el) => {
    animateWingFlap(el);
  });
};

butterflyAnimation();

animateWingFlap()は 以下構成でできています。

  1. 蝶の羽の開閉度をflapAngleで、羽ばたきの時間をflapDurationで定義する
  2. gsapで羽を広げるアニメーションと、羽を閉じるアニメーションを追加
  3. timelineonCompleteでgsapのアニメーション設定をループする

完成品は以下になります。
butterfly_01.gif

現状では、機能はしているけどどこか機械的です。
なので、ここから3つのポイントを活かして、蝶を生き物らしくしていきます。

Point1. easeによる緩急の表現

ここからはeaseをつけて、アニメーションを生き物らしく情緒的にしていきます。
ですが、その前にeaseとは何のためにあるのでしょうか?
まずはeaseの意味から考えていきましょう。

■ easeは「緩急」を作るもの
生き物の動きが自然に見える理由の一つに、緩急/速度の変化があります。
これは、筋肉の動きとある程度対応しています。

  • 力を入れるとき(収縮) → 素早く動く
  • 力が抜けるとき(弛緩) → ゆるやかに動く
    ※実際の筋肉はもっと複雑ですが、ここでは見た目のイメージとして捉えています。

■ 蝶の羽ばたきに当てはめる
この考え方を蝶の羽ばたきに当てはめると、次のようになります。

  • 羽を閉じる → 素早く動き出して減速する
  • 羽を開く → なめらかに動く

■ easeの場合
この「緩急」をeaseで表現するとこうなります。

// 閉じる(急 → 緩)
duration: 0.4,
ease: "power2.out"

// 開く(なめらか)
duration: 0.6,
ease: "sine.in"

gsapのeaseについて

■ ポイント
急な動き → 強めなease-out系
緩やかな動き → 緩めなease (in/out/inOut は場面に合わせて使い分ける)

このように速度カーブを設計することで、自然な動きに見せることができます。

上記をanimateWingFlap()に追加したコードは以下になります。

const animateWingFlap = (target) => {
  const [leftWing, rightWing] = target.querySelectorAll('img');
  if (!leftWing || !rightWing) return;
  
  const flapAngle = 60;
  const flapDuration = 1;

  const loop = () => {

    gsap.timeline({
      onComplete: loop
    })
    .to([leftWing, rightWing], {
      rotationY: (i) => (i === 0 ? flapAngle : -flapAngle),
      
      // durationとeaseの変更を追加
      duration: flapDuration * 0.4,   // 短め
      ease: "power2.out"              // 急を意識したeasing
    })
    .to([leftWing, rightWing], {
      rotationY: 0,

      // durationとeaseの変更を追加
      duration: flapDuration * 0.6,    // 長め
      ease: "sine.in"                  // 緩を意識たeasing
    });
  };

  loop();
};

完成品は以下になります。
butterfly_02.gif

グッと躍動感が上がりました。

Point2. ランダムによる揺らぎの追加

現状のアニメーションは、一定の動きを繰り返しているだけなので単調に見えます。
そこでランダム値を使って、動きに揺らぎを加えます。

ただし、単純に Math.random() を使うだけでは問題があります。

value = Math.random() * 100;

このようにすると、値の制御ができないため

  • ほとんど動かない
  • 動きすぎる
  • 羽が不自然にクロスする

等の意図しない挙動が発生します

そこで、ランダムの範囲を制御します。

const randomBetween = (min, max) => (Math.random() * (max - min)) + min;

これにより

  • 最小値〜最大値の範囲内でランダムになる
  • 動きの破綻を防げる
  • チューニングがしやすくなる

完全なランダムではなく、
意図した範囲の中で揺らすことが重要です。

■ 羽の動きにランダムを適用する
では、先ほど作成したランダム関数を、羽ばたきの「時間」と「角度」に適用してみます。
ループごとにランタム値を適用したいので、flapAngleflapDurationはloop内で定義します。

// 最小値と最大値を入れた配列の追加
const WING_FLAP_DURATION_RANGE = [0.4, 1.5]; // 時間の最小と最大値
const WING_FLAP_ANGLE_RANGE = [50, 80];      // 角度の最小と最大値


// ランダムな値を生成する関数の追加
const randomBetween = (min, max) => (Math.random() * (max - min)) + min;

const animateWingFlap = (target) => {
  const [leftWing, rightWing] = target.querySelectorAll('img');
  if (!leftWing || !rightWing) return;

  const loop = () => {
    // ランダムな角度を取得
    const flapAngle = randomBetween(...WING_FLAP_ANGLE_RANGE);

    // ランダムな時間を取得
    const flapDuration = randomBetween(...WING_FLAP_DURATION_RANGE);

    gsap.timeline({
      onComplete: loop
    })
    
    // ランダム値を適用
    .to([leftWing, rightWing], {
      rotationY: (i) => (i === 0 ? flapAngle : -flapAngle),  
      duration: flapDuration * 0.4,
      ease: 'power2.out',
    })
    .to([leftWing, rightWing], {
      rotationY: 0,                    
      duration: flapDuration * 0.6,
      ease: 'sine.in',
    });
  };

  loop();
};

ランダムを加えることで、羽ばたきのリズムに揺らぎが生まれます。

butterfly_03.gif

■ 蝶全体の動きにもランダムを適用
さらに、蝶の位置・向きにもランダムを加えます。

const WING_FLAP_DURATION_RANGE = [0.4, 1.5];
const WING_FLAP_ANGLE_RANGE = [50, 80];

// 蝶の位置・向き・時間の最大と最小値
const WING_X_RANGE = [-40, 40];
const WING_Y_RANGE = [-40, 40];
const WING_ROTATION_RANGE = [-40, 40];
const WING_DURATION_RANGE = [1.2, 2.8];

const randomBetween = (min, max) => (Math.random() * (max - min)) + min;

const animateWingFlap = (target) => {
  // 省略
}

// 蝶の位置・向きをランダムにアニメーションさせる関数の追加
const animateButterflyTransform = (target) => {
  const loop = () => {

    gsap.to(target, {
      x: randomBetween(...WING_X_RANGE),
      y: randomBetween(...WING_Y_RANGE),
      rotation: randomBetween(...WING_ROTATION_RANGE),
      duration: randomBetween(...WING_DURATION_RANGE),
      ease: 'sine.inOut',
      onComplete: loop,
    });
  };

  loop();
};


const butterflyAnimation = () => {
  const butterflyList = document.querySelectorAll('[data-butterfly-body]');
  if (!butterflyList.length) return;

  butterflyList.forEach((el) => {
    animateWingFlap(el);
    
    //蝶の位置・向きをアニメーションさせる関数の呼び出し
    animateButterflyTransform(el);
  });
};

butterflyAnimation();

ランダムな移動が加わることで、空間内を漂うような動きになります。

butterfly_04.gif

規則性を少しだけ崩すことで、一気に生き物らしさが生まれました。

Point3. scaleとshadowによる奥行きの演出

ここまでで動きは自然になりましたが、まだ少し平面的に見えます。
そこで、奥行き・立体感を追加します。

■ 説明
奥行きは「カメラ・物体・地面」の関係で考えると整理しやすくなります。
これら三つの関係性は、上下の1軸でこのようになります。

カメラ(視点)| 物体(蝶)| 地面

このとき、それぞれの役割は以下の通りです。

  • scale → カメラとの距離(大きさ)
  • shadow → 地面との距離(高さ)

現実ではこの2つは連動しています。

  • 物体が上にいく(カメラに近づく) → 大きく見える + 影が薄い
  • 物体が下にいく(カメラから離れる) → 小さく見える + 影が濃い

■ 実装
上記の説明を元に実装を行っていきます。
まず、scaleとshadowを連動させていきます。

const WING_SCALE_RANGE = [0.9, 1.1];  //スケールのランダム範囲

// スケールを元に影の強さを正規化
const getShadowFactor = (scale) => {
  const [minScale, maxScale] = WING_SCALE_RANGE;
  return (scale - minScale) / (maxScale - minScale);
};

getShadowFactor()では、ランダムで取得したscaleの値を
0〜1の範囲に正規化しています。

これにより

  • scaleの値に依存せず
  • 一貫した基準で影の強さを扱える

→ 他の値(影)と連動させやすくなります

これらを、位置や回転をアニメーションさせていた、animateButterflyTransform()内で呼び出します。

const WING_FLAP_DURATION_RANGE = [0.4, 1.5];
const WING_FLAP_ANGLE_RANGE = [50, 80];

const WING_X_RANGE = [-40, 40];
const WING_Y_RANGE = [-40, 40];
const WING_ROTATION_RANGE = [-40, 40];
const WING_DURATION_RANGE = [1.2, 2.8];

// 蝶のスケールの最小と最大値を追加
const WING_SCALE_RANGE = [0.9, 1.1];  

const randomBetween = (min, max) => (Math.random() * (max - min)) + min;

// スケールを元に影の強さを正規化する関数の追加
const getShadowFactor = (scale) => {
  const [minScale, maxScale] = WING_SCALE_RANGE;
  return (scale - minScale) / (maxScale - minScale);
};

const animateWingFlap = (target) => {
  // 省略
}

const animateButterflyTransform = (target) => {
  const loop = () => {
  
    //スケールをランダムで取得し、影の強さを定義する
    const scale = randomBetween(...WING_SCALE_RANGE);
    const shadowFactor = getShadowFactor(scale);
    
    gsap.to(target, {
      // 省略

      // gsap内でscaleとcss関数のアニメーションを適用
      scale,
      '--butterfly-shadow-factor': shadowFactor,
    });
  };

  loop();
};



const butterflyAnimation = () => {
  // 省略
};

butterflyAnimation();

drop-shadowの値はJSで書くと冗長になるため、
CSS変数を使い管理します。

.butterfly_body {
  /* 影の強さを取得する変数の追加 */
  --butterfly-shadow-factor: 0;
  
  width: 100%;
  display: grid;
  grid-template-columns: 1fr 1fr;

  /* 影の追加 */
  filter: drop-shadow(
    calc(6px + (8px * var(--butterfly-shadow-factor)))
    calc(6px + (8px * var(--butterfly-shadow-factor)))
    calc(6px + (10px * var(--butterfly-shadow-factor)))
    rgba(0, 0, 0, 0.6)
  );
}

最後に--butterfly-shadow-factorをCSSで影として利用できる形に変換します。

calc(6px + (8px * var(--butterfly-shadow-factor)))

このcalcを使った計算は、

  • 最低値 → 6px(ベースの影)
  • 追加分 → 最大で +8px(ぼかし・距離)

→ 0〜1の値を、影の変化量に変換しています

ということで、これらを反映させたものが以下になります。
butterfly_08.gif
自然な奥行き感が出て、クオリティがグッとあがりました!!

まとめ

今回の実装では、蝶のアニメーションをより自然に見せるために、いくつかの工夫を行いました。

どれも特別なことではありませんが、これらを組み合わせることで、単調だった動きに 生き物らしさ を与えることができます。

最後に

ここまで実装での工夫について説明してきましたが、
一番重要なことは、現実の生き物や環境を観察することです。

  • なぜこの動きは自然に見えるのか
  • なぜ違和感が出るのか

その感覚をもとに、
現実の動きを少しずつ分解して、コードに置き換えていく

こういう試行錯誤が、表現の面白さだと思います。

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?