Three.js 애니메이션
대상 버전은 three.js r186입니다. 예제는 WebGLRenderer를 사용합니다. WebGPU 및 TSL 섹션에서는 WebGPURenderer를 사용합니다.
빠른 시작
import * as THREE from "three";
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 100);
camera.position.set(0, 0.5, 3);
const mesh = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshNormalMaterial());
scene.add(mesh);
const timer = new THREE.Timer();
timer.connect(document); // 또한 탭이 숨겨진 후 델타를 작게 유지합니다
function animate() {
timer.update(); // 매 프레임 가장 먼저: 이 프레임의 getDelta()/getElapsed()를 고정합니다
const delta = timer.getDelta();
const elapsed = timer.getElapsed();
mesh.rotation.y += delta; // 프레임 속도에 독립적인 회전
mesh.position.y = Math.sin(elapsed * 2) * 0.25; // 절대 시간에 따라 상하 운동
renderer.render(scene, camera);
}
renderer.setAnimationLoop(animate);
시스템 구조
- AnimationClip: 키프레임 데이터 — 시간 및 값의 트랙, 재생 상태 없음.
- AnimationMixer: 하나의 루트 객체와 그 모든 하위 객체에서 클립을 재생합니다.
- AnimationAction: 하나의 믹서에서 하나의 클립에 대한 재생 제어.
매 프레임마다 mixer.update(delta)는 활성 클립을 샘플링하고 값을 일반 객체 속성에 기록합니다. 시스템은 CPU에서 실행되며 렌더러를 건드리지 않으므로, 두 렌더러에서 동일한 방식으로 모델을 구동합니다.
AnimationClip 및 키프레임 트랙
// 애니메이션 속성당 하나의 트랙. values.length는 times.length * valueSize여야 합니다.
const times = [0, 1, 2]; // 키프레임 시간(초)
const values = [0, 1, 0]; // Number 트랙의 키프레임당 하나의 스칼라
const track = new THREE.NumberKeyframeTrack(".position[y]", times, values); // 루트의 position.y
const clip = new THREE.AnimationClip("bounce", 2, [track]); // 지속 시간(초)
// -1을 지속 시간으로 전달하면 트랙에서 자동으로 유도합니다.
트랙 이름은 nodeName.objectName[objectIndex].propertyName[propertyIndex] 형식을 따릅니다. 속성을 제외한 모든 부분은 선택 사항입니다. .position은 루트의 position을 바인딩하고, .material.opacity는 재질 속성을 바인딩하며, .position[y]는 하나의 구성 요소를 바인딩하고, .morphTargetInfluences[smile]는 하나의 모프 타겟을 바인딩하며, Hips.position은 이름이 지정된 하위 객체(스킨 모델에서 뼈를 참조하는 방식)를 바인딩합니다. 객체 이름은 material, materials, bones 또는 map이 될 수 있습니다.
// Number 트랙 (값 크기 1); transparent: true로 재질을 만들지 않으면 opacity는 무시됩니다.
new THREE.NumberKeyframeTrack(".material.opacity", [0, 1], [1, 0]);
// Vector 트랙 (값 크기 3: 키프레임당 x, y, z)
new THREE.VectorKeyframeTrack(".position", [0, 1, 2], [0, 0, 0, 1, 2, 0, 0, 0, 0]);
// Rotation 트랙 (값 크기 4): 키프레임당 쿼터니언 x, y, z, w (오일러 각도 아님)
const q2 = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), Math.PI);
new THREE.QuaternionKeyframeTrack(
".quaternion",
[0, 1],
[0, 0, 0, 1, q2.x, q2.y, q2.z, q2.w], // 단위 쿼터니언, 그 다음 q2
);
// Color 트랙 (값 크기 3)
new THREE.ColorKeyframeTrack(".material.color", [0, 1], [1, 0, 0, 0, 1, 0]);
// Boolean 트랙 (항상 이산적)
new THREE.BooleanKeyframeTrack(".visible", [0, 1], [true, false]);
보간
const track = new THREE.VectorKeyframeTrack(
".position",
[0, 1, 2],
[0, 0, 0, 1, 2, 0, 0, 0, 0],
);
track.setInterpolation(THREE.InterpolateLinear); // 기본값
track.setInterpolation(THREE.InterpolateSmooth); // 키프레임을 통한 3차 보간
track.setInterpolation(THREE.InterpolateDiscrete); // 계단식, 중간 값 없음
// 베지어는 키프레임당 구성 요소당 두 개의 절대 (시간, 값) 제어점을 사용합니다.
// 보폭 1의 두 키프레임 트랙은 배열당 4개의 항목이 필요합니다.
const morphTrack = new THREE.NumberKeyframeTrack(
".morphTargetInfluences[smile]",
[0, 1],
[0, 1],
);
morphTrack.setInterpolation(THREE.InterpolateBezier);
morphTrack.settings = { inTangents: [0, 0, 1, 1], outTangents: [0, 1, 1, 0] };
AnimationMixer
const track = new THREE.NumberKeyframeTrack(".rotation[y]", [0, 2], [0, Math.PI * 2]);
const clip = new THREE.AnimationClip("spin", 2, [track]);
// 애니메이션 대상 루트당 하나의 믹서; 루트와 모든 하위 객체에 도달합니다.
const mixer = new THREE.AnimationMixer(mesh);
const action = mixer.clipAction(clip); // 캐시됨: 이 클립과 루트에 대해 다시 호출하면 동일한 action을 반환합니다.
action.play();
const timer = new THREE.Timer();
timer.connect(document);
function animate() {
timer.update();
mixer.update(timer.getDelta()); // 필수: 모든 활성 action을 진행시킵니다.
renderer.render(scene, camera);
}
renderer.setAnimationLoop(animate);
// 이벤트는 믹서가 업데이트될 때 발생합니다. "finished"는 LoopOnce 또는 유한 반복이 필요합니다.
mixer.addEventListener("finished", (e) => console.log("finished:", e.action.getClip().name));
mixer.addEventListener("loop", (e) => console.log("looped:", e.action.getClip().name));
AnimationAction
const mixer = new THREE.AnimationMixer(mesh);
const clip = new THREE.AnimationClip("spin", 2, [
new THREE.NumberKeyframeTrack(".rotation[y]", [0, 2], [0, Math.PI * 2]),
]);
const action = mixer.clipAction(clip);
action.play(); // 예약 및 시작
action.reset(); // 시간 0으로 되감고 페이드 취소
action.stop(); // 예약 해제; 다른 예약된 action이 애니메이션하지 않는 속성은 원래 값으로 돌아갑니다.
action.halt(0.3); // 0.3초 내에 감속하여 정지
action.time = 0.5; // 클립 내에서 탐색
action.timeScale = -1; // 음수는 역방향 재생
action.paused = true; // 현재 위치에서 정지
action.setLoop(THREE.LoopOnce, 1); // 한 번 통과 후 정지(또는 유지)
action.setLoop(THREE.LoopRepeat, Infinity); // 기본값
action.setLoop(THREE.LoopPingPong, 4); // 통과할 때마다 방향 교대
action.clampWhenFinished = true; // 리셋 대신 최종 포즈 유지
페이드 및 크로스페이드
const mixer = new THREE.AnimationMixer(mesh);
const idleClip = new THREE.AnimationClip("idle", 2, [
new THREE.NumberKeyframeTrack(".position[y]", [0, 1, 2], [0, 0.05, 0]),
]);
const walkClip = new THREE.AnimationClip("walk", 1, [
new THREE.NumberKeyframeTrack(".rotation[y]", [0, 1], [0, Math.PI]),
]);
const idle = mixer.clipAction(idleClip).play();
const walk = mixer.clipAction(walkClip).play();
// 0.4초에 걸쳐 idle에서 walk로 전환; 세 번째 인수가 true이면 두 타임 스케일을 왜곡하여
// 페이드 중 사이클 길이를 일치시킵니다.
idle.crossFadeTo(walk, 0.4, false);
// 단측 호출로 동일한 전환 (두 형식을 모두 사용하지 마세요)
walk.reset().fadeIn(0.4).play(); // 가중치를 0에서부터 증가
idle.fadeOut(0.4); // 가중치를 0으로 감소
glTF 애니메이션 재생
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
import * as SkeletonUtils from "three/addons/utils/SkeletonUtils.js";
const gltf = await new GLTFLoader().loadAsync("model.glb");
const model = gltf.scene;
scene.add(model);
// GLTFLoader의 기본 재질(MeshStandardMaterial)은 조명이 필요합니다: 조명 없이 모델은 검게 렌더링됩니다.
const sun = new THREE.DirectionalLight(0xffffff, 2);
sun.position.set(3, 5, 2);
scene.add(sun, new THREE.HemisphereLight(0xffffff, 0x444444, 1.5));
// gltf.animations는 파싱된 AnimationClips 배열입니다.
const clips = gltf.animations;
const mixer = new THREE.AnimationMixer(model);
const walkClip = THREE.AnimationClip.findByName(clips, "Walk");
if (walkClip) mixer.clipAction(walkClip).play();
// 두 번째 캐릭터: 스킨 모델을 복제하고 복사본에 자체 믹서를 제공합니다.
const model2 = SkeletonUtils.clone(model);
model2.position.x = 1.5; // 첫 번째 옆에 배치
scene.add(model2);
const mixer2 = new THREE.AnimationMixer(model2);
if (walkClip) mixer2.clipAction(walkClip).play(); // 동일한 클립, 두 개의 믹서
const timer = new THREE.Timer();
timer.connect(document);
function animate() {
timer.update();
const delta = timer.getDelta();
mixer.update(delta);
mixer2.update(delta);
renderer.render(scene, camera);
}
renderer.setAnimationLoop(animate);
스켈레탈 애니메이션
// mesh는 로드된 모델 루트입니다. 첫 번째 SkinnedMesh를 찾습니다.
const skinnedMesh = mesh.getObjectByProperty("type", "SkinnedMesh");
const skeleton = skinnedMesh.skeleton;
console.log(skeleton.bones.map((b) => b.name));
// 포즈를 지정하거나 연결할 뼈를 가져옵니다.
const handBone = skeleton.bones.find((b) => b.name === "RightHand");
// 리그 시각화
const helper = new THREE.SkeletonHelper(mesh);
scene.add(helper);
절차적 뼈 제어는 mixer.update() 후에 실행되어야 합니다. 그렇지 않으면 동일한 뼈를 애니메이션하는 클립이 덮어씁니다.
const skinnedMesh = mesh.getObjectByProperty("type", "SkinnedMesh");
const headBone = skinnedMesh.skeleton.bones.find((b) => b.name === "Head");
const mixer = new THREE.AnimationMixer(mesh); // 모델의 클립을 재생합니다 (glTF 애니메이션 재생 참조)
const timer = new THREE.Timer();
timer.connect(document);
function animate() {
timer.update();
mixer.update(timer.getDelta()); // 클립이 여기서 뼈 포즈를 기록합니다.
// 이후에 덮어씁니다: update()는 재생 중인 클립이 애니메이션하는 모든 속성을 덮어씁니다.
if (headBone) headBone.rotation.y = Math.sin(timer.getElapsed()) * 0.3;
renderer.render(scene, camera);
}
renderer.setAnimationLoop(animate);
뼈는 Object3D이므로, 연결한다는 것은 부모-자식 관계를 설정하는 것을 의미합니다.
const skinnedMesh = mesh.getObjectByProperty("type", "SkinnedMesh");
const handBone = skinnedMesh.skeleton.bones.find((b) => b.name === "RightHand");
const weapon = new THREE.Mesh(
new THREE.BoxGeometry(0.08, 0.08, 0.5),
new THREE.MeshNormalMaterial(),
);
if (handBone) {
handBone.add(weapon); // weapon이 이제 손 뼈의 변환을 따릅니다.
weapon.position.set(0, 0, 0.25); // 뼈 공간 내에서 오프셋
weapon.rotation.set(0, Math.PI / 2, 0);
}
모프 타겟
// mesh는 모프 타겟을 가지고 있습니다 (로드되었거나 수동으로 추가됨).
const names = Object.keys(mesh.morphTargetDictionary); // 이름 -> 인덱스
console.log(names, mesh.morphTargetInfluences); // 타겟당 하나의 가중치
// 인덱스 또는 이름으로 타겟 블렌드; 0 = 기본 형태, 1 = 완전한 타겟
mesh.morphTargetInfluences[0] = 0.5;
const smileIndex = mesh.morphTargetDictionary["Mouth_Smile"];
if (smileIndex !== undefined) mesh.morphTargetInfluences[smileIndex] = 1;
모프 타겟 수동 추가:
const geometry = mesh.geometry;
// 모프 타겟은 기본 위치(또는 법선) 속성 옆에 있는 전체 크기의 속성입니다.
const raised = geometry.attributes.position.array.slice(); // 동일한 정점 수
for (let i = 1; i < raised.length; i += 3) {
raised[i] += 0.5; // y는 세 번째 구성 요소마다: 모든 정점을 들어올립니다.
}
const target = new THREE.Float32BufferAttribute(raised, 3);
target.name = "raised"; // 이름이 지정된 타겟은 트랙에서 접근 가능합니다.
geometry.morphAttributes.position = [target];
mesh.updateMorphTargets(); // morphTargetInfluences와 사전을 재구성합니다.
모프 타겟 구동:
// 절차적, timer.update() 후에 렌더 루프 내에서:
// mesh.morphTargetInfluences[0] = (Math.sin(timer.getElapsed() * 2) + 1) / 2;
// 키프레임: Number 트랙이 이름으로 하나의 타겟을 구동합니다. String 트랙을 사용하지 마세요.
const track = new THREE.NumberKeyframeTrack(
".morphTargetInfluences[raised]",
[0, 0.5, 1],
[0, 1, 0],
);
const clip = new THREE.AnimationClip("pulse", 1, [track]);
const mixer = new THREE.AnimationMixer(mesh);
mixer.clipAction(clip).play();
가중치로 클립 블렌딩
const mixer = new THREE.AnimationMixer(mesh);
const idleClip = new THREE.AnimationClip("idle", 2, [
new THREE.NumberKeyframeTrack(".position[y]", [0, 1, 2], [0, 0.05, 0]),
]);
const runClip = new THREE.AnimationClip("run", 1, [
new THREE.NumberKeyframeTrack(".position[y]", [0, 0.5, 1], [0, 0.3, 0]),
]);
const idle = mixer.clipAction(idleClip).play();
const run = mixer.clipAction(runClip).play();
// 둘 다 매 프레임 샘플링; 가중치가 포즈를 혼합합니다.
function setBlend(speed) {
const t = Math.min(speed / 5, 1); // 0 = idle, 5 이상 = 완전 run
idle.setEffectiveWeight(1 - t);
run.setEffectiveWeight(t);
}
setBlend(2.5); // 절반 idle, 절반 run
추가 애니메이션
const mixer = new THREE.AnimationMixer(mesh);
const walkClip = new THREE.AnimationClip("walk", 1, [
new THREE.NumberKeyframeTrack(".rotation[y]", [0, 1], [0, Math.PI]),
]);
const breathClip = new THREE.AnimationClip("breath", 2, [
new THREE.NumberKeyframeTrack(".position[y]", [0, 1, 2], [0, 0.02, 0]),
]);
// 모든 트랙을 프레임 0을 기준으로 다시 설정하고 클립을 추가형으로 표시합니다.
THREE.AnimationUtils.makeClipAdditive(breathClip);
mixer.clipAction(walkClip).play(); // 일반 기본 클립
mixer.clipAction(breathClip).play(); // 기본 포즈 위에 오프셋을 추가합니다.
클립 유틸리티
const clip = new THREE.AnimationClip("walk", 2, [
new THREE.NumberKeyframeTrack(".rotation[y]", [0, 1, 2], [0, Math.PI, Math.PI * 2]),
]);
const walk = THREE.AnimationClip.findByName([clip], "walk"); // 이름 조회
// 30fps에서 프레임 [0, 31)의 기존 키프레임을 유지합니다 (시간 0과 1). 리샘플링하지 않습니다.
const cut = THREE.AnimationUtils.subclip(clip, "half", 0, 31, 30);
const copy = clip.clone(); // 독립적인 딥 카피
clip.optimize(); // 중복 시간 키와 부드럽지 않은 트랙에서 양쪽 이웃과 동일한 키를 제거합니다 (베지어 탄젠트는 압축되지 않음).
clip.resetDuration(); // 가장 긴 트랙에서 지속 시간을 다시 계산합니다. 탐색하지 않습니다.
console.log(clip.validate()); // 값 대 시간 비율이 분수이거나, 빈 트랙, 정렬되지 않은 시간 또는 NaN인 경우 false를 반환합니다.
절차적 모션
const timer = new THREE.Timer();
timer.connect(document);
// 지수적 접근; lambda 값이 높을수록 더 빠르게 수렴합니다. 프레임 속도에 독립적입니다.
...
[번역을 위해 잘림]