アニメーション

Three.js でのアニメーションは大きく3層。(1) ループでプロパティを直接書き換える(2) Tween 系で値を補間(3) AnimationMixer で GLTF などのキーフレームを再生

レイヤー1: ループで直接動かす

最も素直な方法。setAnimationLoop の中でプロパティを書き換えるだけ。

const clock = new THREE.Clock()

renderer.setAnimationLoop(() => {
  const dt = clock.getDelta()
  const t  = clock.getElapsedTime()

  mesh.rotation.y += dt * 1.0           // 1秒で 1 ラジアン
  mesh.position.y = Math.sin(t * 2)     // 振動
  controls?.update()
  renderer.render(scene, camera)
})
必ず delta を使う

position.x += 0.01 のような固定値は端末のリフレッシュレートに依存。 120fps と 30fps で速度が4倍違う。delta または elapsedTime ベースで書く。

線形補間(lerp)— 滑らかに目標に近づける

「マウスに追従」「クリック地点に滑らかに移動」など、残差の何割を埋めるかで書ける。

// Vector3 の lerp
mesh.position.lerp(targetPos, 0.1)  // 1フレームで 10% 近づく

// スカラ値
mesh.rotation.y = THREE.MathUtils.lerp(mesh.rotation.y, targetY, 0.1)

// 色
mat.color.lerp(targetColor, 0.1)

// クォータニオン(回転は slerp)
mesh.quaternion.slerp(targetQuat, 0.1)

0.1」は1フレームあたりの追従率。フレームレート依存なので、 厳密性を求めるなら 1 - Math.exp(-rate * dt) のようなレート補正を入れる。

イージング

独自カーブで動かしたいときは、進捗 0..1 を作って関数に通す。

const easeOutCubic = (t) => 1 - Math.pow(1 - t, 3)
const easeInOutCubic = (t) => t < 0.5
  ? 4 * t * t * t
  : 1 - Math.pow(-2 * t + 2, 3) / 2

let start = null
const duration = 1.0

renderer.setAnimationLoop(() => {
  const now = clock.getElapsedTime()
  if (start === null) start = now
  const t = Math.min((now - start) / duration, 1)
  const v = easeOutCubic(t)
  mesh.scale.setScalar(0.1 + v * 0.9)
  renderer.render(scene, camera)
})

レイヤー2: Tween 系ライブラリ

宣言的に「この値からこの値まで 1.5s でイージング付きで動かす」と書ける。 定番は tween.jsGSAP

tween.js(@tweenjs/tween.js)

npm install @tweenjs/tween.js
import { Tween, Easing, update as tweenUpdate } from "@tweenjs/tween.js"

new Tween(mesh.position)
  .to({ x: 5, y: 2, z: 0 }, 1500)
  .easing(Easing.Cubic.InOut)
  .onComplete(() => console.log("done"))
  .start()

renderer.setAnimationLoop(() => {
  tweenUpdate()                  // tween をフレームごとに進める
  renderer.render(scene, camera)
})

GSAP

npm install gsap
import gsap from "gsap"

gsap.to(mesh.position, { x: 5, duration: 1.5, ease: "power2.inOut" })
gsap.to(mesh.rotation, { y: Math.PI * 2, duration: 2, repeat: -1, ease: "none" })

// 連鎖
const tl = gsap.timeline()
tl.to(mesh.scale, { x: 2, y: 2, z: 2, duration: 0.5 })
  .to(mesh.position, { y: 3, duration: 0.5 })
  .to(mesh.rotation, { y: Math.PI, duration: 0.5 })

GSAP は自分で update を呼ぶ必要がない(内部で rAF を回している)。 ループの中でレンダリングだけしておけば、ティーンが裏で値を変えてくれる。

レイヤー3: AnimationMixer — キーフレーム再生

Blender 等で作ったキャラのアニメ、GLTF に埋め込まれたアニメを再生する仕組み。

import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js"

const gltf = await new GLTFLoader().loadAsync("/robot.glb")
scene.add(gltf.scene)

const mixer = new THREE.AnimationMixer(gltf.scene)
const action = mixer.clipAction(gltf.animations[0])  // 最初のクリップ
action.play()

const clock = new THREE.Clock()
renderer.setAnimationLoop(() => {
  mixer.update(clock.getDelta())   // 必須
  renderer.render(scene, camera)
})

AnimationAction の API

メソッド/プロパティ意味
play() / stop() / reset()再生 / 停止 / リセット
fadeIn(duration) / fadeOut(duration)フェードイン / アウト
crossFadeTo(other, duration)別アクションに滑らかに移行
setLoop(LoopOnce, 1)1回のみ。LoopRepeat / LoopPingPong
clampWhenFinished = true終了時に最終ポーズで止める
timeScale再生速度(負で逆再生)
weightブレンドの重み(0..1)

クリップを名前で取得

const walk = THREE.AnimationClip.findByName(gltf.animations, "Walk")
const action = mixer.clipAction(walk)
action.play()

クロスフェード(歩く → 走る)

const walk = mixer.clipAction(walkClip)
const run  = mixer.clipAction(runClip)
walk.play()

// 0.3 秒かけて run に切り替え
walk.crossFadeTo(run.reset().play(), 0.3, false)

イベント

finishedloop イベントが取れる。

mixer.addEventListener("finished", (e) => {
  console.log("clip finished:", e.action)
})
mixer.addEventListener("loop", (e) => {
  console.log("loop count:", e.loopDelta)
})

自前で AnimationClip を作る

コードからキーフレームを定義することもできる。プロシージャルな動きの再利用に。

const positionTrack = new THREE.VectorKeyframeTrack(
  ".position",                 // 対象プロパティ
  [0, 1, 2],                   // 時刻(秒)
  [
    0, 0, 0,                   // t=0
    1, 1, 0,                   // t=1
    0, 0, 0,                   // t=2
  ],
)

const clip = new THREE.AnimationClip("MyClip", 2, [positionTrack])
const mixer = new THREE.AnimationMixer(mesh)
mixer.clipAction(clip).play()

主なトラック種別:

シーンの状態に応じて切り替える

呼吸する待機モーション

renderer.setAnimationLoop(() => {
  const t = clock.getElapsedTime()
  mesh.scale.y = 1 + Math.sin(t * 2) * 0.05  // ふわふわ
  renderer.render(scene, camera)
})

マウス追従の頭の動き

window.addEventListener("pointermove", (e) => {
  pointer.x = (e.clientX / innerWidth)  * 2 - 1
  pointer.y = -(e.clientY / innerHeight) * 2 + 1
})

renderer.setAnimationLoop(() => {
  head.rotation.y = THREE.MathUtils.lerp(head.rotation.y, pointer.x * 0.5, 0.1)
  head.rotation.x = THREE.MathUtils.lerp(head.rotation.x, -pointer.y * 0.3, 0.1)
  renderer.render(scene, camera)
})

使い分けの目安

やりたいこと使うもの
連続的に動かす(回転・追従)ループ + lerp
「A から B」を1回だけtween.js / GSAP
キャラのモーションAnimationMixer + GLTF アニメ
UI 連動の状態遷移GSAP(タイムラインが書きやすい)
大量のオブジェクトを同期ループ + シェーダ(CPU 負荷が高くなるため)