Community코딩 & 개발github.com

SHARDROOM/threejs-animation

키프레임, 스켈레탈 및 모프 타겟 애니메이션, 절차적 모션과 클립 블렌딩을 WebGL 또는 WebGPU 및 TSL에서 지원합니다. 객체 애니메이션, glTF 애니메이션 재생, 뼈 포즈 지정, 모프 타겟 제어, 또는 클립 믹싱 및 크로스페이딩에 사용하세요.

threejs-animation란 무엇인가요?

이 스킬은 three.js r186 버전을 대상으로 하는 포괄적인 애니메이션 가이드입니다. 개발자는 AnimationClip, AnimationMixer, AnimationAction 시스템을 통해 CPU 기반의 키프레임 애니메이션을 구현할 수 있으며, 객체 속성에 직접 값을 기록합니다. WebGLRenderer와 WebGPURenderer를 모두 지원하며, glTF 파일에서 로드된 애니메이션 클립을 재생하고, 스켈레탈 메시의 뼈대를 절차적으로 제어하거나, 모프 타겟을 블렌딩하는 방법을 상세히 설명합니다. 또한 가중치 기반 클립 블렌딩, 추가 애니메이션(additive animation), 페이드 인/아웃 및 크로스페이드 기법과 같은 고급 기능을 다루어, 자연스러운 애니메이션 전환과 복잡한 캐릭터 움직임을 구현할 수 있도록 돕습니다.

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

Installed? Explore more 코딩 & 개발 skills: steipete/bluebubbles, steipete/eightctl, steipete/blucli · View all 6 →

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

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

문서

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 값이 높을수록 더 빠르게 수렴합니다. 프레임 속도에 독립적입니다.

...

[번역을 위해 잘림]

관련 스킬