Communitygithub.com

aelyraAI/threejs-animation

Three.js 애니메이션 - 키프레임 애니메이션, 스켈레탈 애니메이션, 모프 타겟, 애니메이션 믹싱. 객체 애니메이션, GLTF 애니메이션 재생, 절차적 모션 생성 또는 애니메이션 블렌딩 시 사용하세요.

threejs-animation란 무엇인가요?

이 스킬은 Three.js의 포괄적인 애니메이션 시스템을 전문적으로 다루며, 키프레임 애니메이션, 스켈레탈 애니메이션, 모프 타겟 및 애니메이션 블렌딩과 같은 핵심 기능을 다룹니다. 개발자가 AnimationClip, AnimationMixer 및 AnimationAction 객체를 사용하여 복잡한 애니메이션을 생성하고 제어하는 방법을 제공합니다. 이 스킬은 GLTF 로더를 통해 외부 애니메이션 파일을 로드하는 방법, 스켈레톤과 본을 조작하는 방법, 그리고 부드러운 감쇠, 스프링 물리, 진동과 같은 절차적 애니메이션 패턴을 구현하는 방법을 다룹니다. 또한 크로스페이드, 가중치 기반 블렌딩, 가산 블렌딩을 포함한 여러 애니메이션 간 블렌딩 기법을 다루며, 성능 최적화 팁(클립 공유, 최적화, 오프스크린 애니메이션 비활성화)을 제공합니다. 이 스킬은 웹 기반 3D 애플리케이션, 게임, 대화형 경험에서 작업하는 Three.js 개발자에게 이상적입니다. Claude Code, Cursor, Codex와 같은 AI 에이전트 플랫폼에서 사용할 수 있습니다.

지원 대상~Claude Code~Codex CLI~Cursor
npx skills add https://github.com/aelyraAI/aelyra-skills/tree/HEAD/threejs-animation

즐겨 사용하는 AI에게 물어보기

이 에이전트 스킬이 미리 로드된 새 채팅을 엽니다.

문서

Three.js 애니메이션

빠른 시작

import * as THREE from "three";

// 간단한 절차적 애니메이션
const clock = new THREE.Clock();

function animate() {
  const delta = clock.getDelta();
  const elapsed = clock.getElapsedTime();

  mesh.rotation.y += delta;
  mesh.position.y = Math.sin(elapsed) * 0.5;

  requestAnimationFrame(animate);
  renderer.render(scene, camera);
}
animate();

애니메이션 시스템 개요

Three.js 애니메이션 시스템은 세 가지 주요 구성 요소로 이루어져 있습니다:

  1. AnimationClip - 키프레임 데이터 컨테이너
  2. AnimationMixer - 루트 객체에서 애니메이션 재생
  3. AnimationAction - 클립 재생 제어

AnimationClip

키프레임 애니메이션 데이터를 저장합니다.

// 애니메이션 클립 생성
const times = [0, 1, 2]; // 키프레임 시간 (초)
const values = [0, 1, 0]; // 각 키프레임에서의 값

const track = new THREE.NumberKeyframeTrack(
  ".position[y]", // 속성 경로
  times,
  values,
);

const clip = new THREE.AnimationClip("bounce", 2, [track]);

KeyframeTrack 유형

// 숫자 트랙 (단일 값)
new THREE.NumberKeyframeTrack(".opacity", times, [1, 0]);
new THREE.NumberKeyframeTrack(".material.opacity", times, [1, 0]);

// 벡터 트랙 (위치, 크기)
new THREE.VectorKeyframeTrack(".position", times, [
  0,
  0,
  0, // t=0
  1,
  2,
  0, // t=1
  0,
  0,
  0, // t=2
]);

// 쿼터니언 트랙 (회전)
const q1 = new THREE.Quaternion().setFromEuler(new THREE.Euler(0, 0, 0));
const q2 = new THREE.Quaternion().setFromEuler(new THREE.Euler(0, Math.PI, 0));
new THREE.QuaternionKeyframeTrack(
  ".quaternion",
  [0, 1],
  [q1.x, q1.y, q1.z, q1.w, q2.x, q2.y, q2.z, q2.w],
);

// 색상 트랙
new THREE.ColorKeyframeTrack(".material.color", times, [
  1,
  0,
  0, // 빨간색
  0,
  1,
  0, // 초록색
  0,
  0,
  1, // 파란색
]);

// 부울 트랙
new THREE.BooleanKeyframeTrack(".visible", [0, 0.5, 1], [true, false, true]);

// 문자열 트랙 (모프 타겟용)
new THREE.StringKeyframeTrack(
  ".morphTargetInfluences[smile]",
  [0, 1],
  ["0", "1"],
);

보간 모드

const track = new THREE.VectorKeyframeTrack(".position", times, values);

// 보간
const track = new THREE.VectorKeyframeTrack(".position", times, values);
track.setInterpolation(THREE.InterpolateLinear); // 기본값
track.setInterpolation(THREE.InterpolateSmooth); // 3차 스플라인
track.setInterpolation(THREE.InterpolateDiscrete); // 계단 함수

AnimationMixer

객체와 그 하위 객체에서 애니메이션을 재생합니다.

const mixer = new THREE.AnimationMixer(model);

// 클립에서 액션 생성
const action = mixer.clipAction(clip);
action.play();

// 애니메이션 루프에서 업데이트
function animate() {
  const delta = clock.getDelta();
  mixer.update(delta); // 필수!

  requestAnimationFrame(animate);
  renderer.render(scene, camera);
}

Mixer 이벤트

mixer.addEventListener("finished", (e) => {
  console.log("애니메이션 완료:", e.action.getClip().name);
});

mixer.addEventListener("loop", (e) => {
  console.log("애니메이션 반복:", e.action.getClip().name);
});

AnimationAction

애니메이션 클립의 재생을 제어합니다.

const action = mixer.clipAction(clip);

// 재생 제어
action.play();
action.stop();
action.reset();
action.halt(fadeOutDuration);

// 재생 상태
action.isRunning();
action.isScheduled();

// 시간 제어
action.time = 0.5; // 현재 시간
action.timeScale = 1; // 재생 속도 (음수 = 역방향)
action.paused = false;

// 가중치 (블렌딩용)
action.weight = 1; // 0-1, 최종 포즈에 대한 기여도
action.setEffectiveWeight(1);

// 반복 모드
action.loop = THREE.LoopRepeat; // 기본값: 무한 반복
action.loop = THREE.LoopOnce; // 한 번 재생하고 정지
action.loop = THREE.LoopPingPong; // 앞/뒤로 번갈아가며 재생
action.repetitions = 3; // 반복 횟수 (기본값 Infinity)

// 클램핑
action.clampWhenFinished = true; // 완료 시 마지막 프레임 유지

// 블렌딩
action.blendMode = THREE.NormalAnimationBlendMode;
action.blendMode = THREE.AdditiveAnimationBlendMode;

페이드 인/아웃

// 페이드 인
action.reset().fadeIn(0.5).play();

// 페이드 아웃
action.fadeOut(0.5);

// 애니메이션 간 크로스페이드
const action1 = mixer.clipAction(clip1);
const action2 = mixer.clipAction(clip2);

action1.play();

// 나중에, action2로 크로스페이드
action1.crossFadeTo(action2, 0.5, true);
action2.play();

GLTF 애니메이션 로드

스켈레탈 애니메이션의 가장 일반적인 소스입니다.

import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";

const loader = new GLTFLoader();
loader.load("model.glb", (gltf) => {
  const model = gltf.scene;
  scene.add(model);

  // 믹서 생성
  const mixer = new THREE.AnimationMixer(model);

  // 모든 클립 가져오기
  const clips = gltf.animations;
  console.log(
    "사용 가능한 애니메이션:",
    clips.map((c) => c.name),
  );

  // 첫 번째 애니메이션 재생
  if (clips.length > 0) {
    const action = mixer.clipAction(clips[0]);
    action.play();
  }

  // 이름으로 특정 애니메이션 재생
  const walkClip = THREE.AnimationClip.findByName(clips, "Walk");
  if (walkClip) {
    mixer.clipAction(walkClip).play();
  }

  // 업데이트 루프를 위해 믹서 저장
  window.mixer = mixer;
});

// 애니메이션 루프
function animate() {
  const delta = clock.getDelta();
  if (window.mixer) window.mixer.update(delta);

  requestAnimationFrame(animate);
  renderer.render(scene, camera);
}

스켈레탈 애니메이션

스켈레톤과 본

// 스킨드 메시에서 스켈레톤 접근
const skinnedMesh = model.getObjectByProperty("type", "SkinnedMesh");
const skeleton = skinnedMesh.skeleton;

// 본 접근
skeleton.bones.forEach((bone) => {
  console.log(bone.name, bone.position, bone.rotation);
});

// 이름으로 특정 본 찾기
const headBone = skeleton.bones.find((b) => b.name === "Head");
if (headBone) headBone.rotation.y = Math.PI / 4; // 머리 돌리기

// 스켈레톤 헬퍼
const helper = new THREE.SkeletonHelper(model);
scene.add(helper);

프로그래매틱 본 애니메이션

function animate() {
  const time = clock.getElapsedTime();

  // 본 애니메이션
  const headBone = skeleton.bones.find((b) => b.name === "Head");
  if (headBone) {
    headBone.rotation.y = Math.sin(time) * 0.3;
  }

  // 클립도 재생 중이면 믹서 업데이트
  mixer.update(clock.getDelta());
}

본 부착

// 본에 객체 부착
const weapon = new THREE.Mesh(weaponGeometry, weaponMaterial);
const handBone = skeleton.bones.find((b) => b.name === "RightHand");
if (handBone) handBone.add(weapon);

// 부착 오프셋
weapon.position.set(0, 0, 0.5);
weapon.rotation.set(0, Math.PI / 2, 0);

모프 타겟

서로 다른 메시 형태 간 블렌딩.

// 모프 타겟은 지오메트리에 저장됨
const geometry = mesh.geometry;
console.log("모프 속성:", Object.keys(geometry.morphAttributes));

// 모프 타겟 영향 접근
mesh.morphTargetInfluences; // 가중치 배열
mesh.morphTargetDictionary; // 이름 -> 인덱스 매핑

// 인덱스로 모프 타겟 설정
mesh.morphTargetInfluences[0] = 0.5;

// 이름으로 설정
const smileIndex = mesh.morphTargetDictionary["smile"];
mesh.morphTargetInfluences[smileIndex] = 1;

모프 타겟 애니메이션

// 절차적
function animate() {
  const t = clock.getElapsedTime();
  mesh.morphTargetInfluences[0] = (Math.sin(t) + 1) / 2;
}

// 키프레임 애니메이션 사용
const track = new THREE.NumberKeyframeTrack(
  ".morphTargetInfluences[smile]",
  [0, 0.5, 1],
  [0, 1, 0],
);
const clip = new THREE.AnimationClip("smile", 1, [track]);
mixer.clipAction(clip).play();

애니메이션 블렌딩

여러 애니메이션을 함께 혼합합니다.

// 액션 설정
const idleAction = mixer.clipAction(idleClip);
const walkAction = mixer.clipAction(walkClip);
const runAction = mixer.clipAction(runClip);

// 다른 가중치로 모두 재생
idleAction.play();
walkAction.play();
runAction.play();

// 초기 가중치 설정
idleAction.setEffectiveWeight(1);
walkAction.setEffectiveWeight(0);
runAction.setEffectiveWeight(0);

// 속도에 따라 블렌딩
function updateAnimations(speed) {
  if (speed < 0.1) {
    idleAction.setEffectiveWeight(1);
    walkAction.setEffectiveWeight(0);
    runAction.setEffectiveWeight(0);
  } else if (speed < 5) {
    const t = speed / 5;
    idleAction.setEffectiveWeight(1 - t);
    walkAction.setEffectiveWeight(t);
    runAction.setEffectiveWeight(0);
  } else {
    const t = Math.min((speed - 5) / 5, 1);
    idleAction.setEffectiveWeight(0);
    walkAction.setEffectiveWeight(1 - t);
    runAction.setEffectiveWeight(t);
  }
}

가산 블렌딩

// 기본 포즈
const baseAction = mixer.clipAction(baseClip);
baseAction.play();

// 가산 레이어 (예: 호흡)
const additiveAction = mixer.clipAction(additiveClip);
additiveAction.blendMode = THREE.AdditiveAnimationBlendMode;
additiveAction.play();

// 클립을 가산으로 변환
THREE.AnimationUtils.makeClipAdditive(additiveClip);

애니메이션 유틸리티

import * as THREE from "three";

// 이름으로 클립 찾기
const clip = THREE.AnimationClip.findByName(clips, "Walk");

// 하위 클립 생성
const subclip = THREE.AnimationUtils.subclip(clip, "subclip", 0, 30, 30);

// 가산으로 변환
THREE.AnimationUtils.makeClipAdditive(clip);
THREE.AnimationUtils.makeClipAdditive(clip, 0, referenceClip);

// 클립 복제
const clone = clip.clone();

// 클립 기간 가져오기
clip.duration;

// 클립 최적화 (중복 키프레임 제거)
clip.optimize();

// 클립을 첫 번째 프레임으로 리셋
clip.resetDuration();

절차적 애니메이션 패턴

부드러운 감쇠

// 부드러운 추종/lerp
const target = new THREE.Vector3();
const current = new THREE.Vector3();
const velocity = new THREE.Vector3();

function smoothDamp(current, target, velocity, smoothTime, deltaTime) {
  const omega = 2 / smoothTime;
  const x = omega * deltaTime;
  const exp = 1 / (1 + x + 0.48 * x * x + 0.235 * x * x * x);
  const change = current.clone().sub(target);
  const temp = velocity
    .clone()
    .add(change.clone().multiplyScalar(omega))
    .multiplyScalar(deltaTime);
  velocity.sub(temp.clone().multiplyScalar(omega)).multiplyScalar(exp);
  return target.clone().add(change.add(temp).multiplyScalar(exp));
}

function animate() {
  current.copy(smoothDamp(current, target, velocity, 0.3, delta));
  mesh.position.copy(current);
}

스프링 물리

class Spring {
  constructor(stiffness = 100, damping = 10) {
    this.stiffness = stiffness;
    this.damping = damping;
    this.position = 0;
    this.velocity = 0;
    this.target = 0;
  }

  update(dt) {
    const force = -this.stiffness * (this.position - this.target);
    const dampingForce = -this.damping * this.velocity;
    this.velocity += (force + dampingForce) * dt;
    this.position += this.velocity * dt;
    return this.position;
  }
}

const spring = new Spring(100, 10);
spring.target = 1;

function animate() {
  mesh.position.y = spring.update(delta);
}

진동

function animate() {
  const t = clock.getElapsedTime();

  // 사인파
  mesh.position.y = Math.sin(t * 2) * 0.5;

  // 바운싱
  mesh.position.y = Math.abs(Math.sin(t * 3)) * 2;

  // 원형 운동
  mesh.position.x = Math.cos(t) * 2;
  mesh.position.z = Math.sin(t) * 2;

  // 8자 운동
  mesh.position.x = Math.sin(t) * 2;
  mesh.position.z = Math.sin(t * 2) * 1;
}

성능 팁

  1. 클립 공유: 동일한 AnimationClip을 여러 믹서에서 사용 가능
  2. 클립 최적화: clip.optimize()를 호출하여 중복 키프레임 제거
  3. 오프스크린 시 비활성화: 보이지 않는 객체에 대한 믹서 업데이트 중지
  4. 애니메이션에 LOD 사용: 먼 캐릭터에 대해 더 단순한 리그 사용
  5. 활성 믹서 제한: 각 mixer.update()는 비용이 발생함
// 보이지 않을 때 애니메이션 일시 중지
mesh.onBeforeRender = () => {
  action.paused = false;
};

mesh.onAfterRender = () => {
  // 다음 프레임에 보일지 확인
  if (!isInFrustum(mesh)) {
    action.paused = true;
  }
};

// 클립 캐싱
const clipCache = new Map();
function getClip(name) {
  if (!clipCache.has(name)) {
    clipCache.set(name, loadClip(name));
  }
  return clipCache.get(name);
}

참고

  • threejs-loaders - 애니메이션 GLTF 모델 로드
  • threejs-fundamentals - 시계 및 애니메이션 루프
  • threejs-shaders - 셰이더의 버텍스 애니메이션

Individual skills in this repo

This repo contains 1 individual skill — each has its own dedicated page.

관련 스킬