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 애니메이션 시스템은 세 가지 주요 구성 요소로 이루어져 있습니다:
- AnimationClip - 키프레임 데이터 컨테이너
- AnimationMixer - 루트 객체에서 애니메이션 재생
- 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;
}
성능 팁
- 클립 공유: 동일한 AnimationClip을 여러 믹서에서 사용 가능
- 클립 최적화:
clip.optimize()를 호출하여 중복 키프레임 제거 - 오프스크린 시 비활성화: 보이지 않는 객체에 대한 믹서 업데이트 중지
- 애니메이션에 LOD 사용: 먼 캐릭터에 대해 더 단순한 리그 사용
- 활성 믹서 제한: 각 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- 셰이더의 버텍스 애니메이션