이제 GLTF 에 저장된 animation 들에 대해 알아보자.
우선 모델을 불러올 건데 원숭이 수잔 처럼 대표 모델을 일단 Mixamo 에서 받아주자.
여기서는 FBX 모델을 받으면 Blender 에서 GLB 파일로 트랜스 파일할 수 있다.
본인이 원하는 캐릭터, 걷는 모습의 animation 을 골라주고 위와 같은 옵션으로 받아주면 된다.
그리고 import 를 한 후 export 를 gltf 로 내보내면 되는데 여기서 naming 규칙을 정하면 된다. markerman$@walk.glb
필자는 이름$스킨@동작
으로 지어 같은 모델이라 하더라고 구분을 지었다.
그래서 three.js 에서 가져온 후 로그를 찍어보면 animation 배열에 뭔가가 들어있다. 바로 우리가 걷기 모션을 설정한 값이다.
let mixer: THREE.AnimationMixer
new GLTFLoader().load('models/markerman$@walk.glb', (gltf) => {
mixer = new THREE.AnimationMixer(gltf.scene)
mixer.clipAction(gltf.animations[0]).play()
scene.add(gltf.scene)
})
const clock = new THREE.Clock()
let delta = 0
function animate() {
requestAnimationFrame(animate)
delta = clock.getDelta()
controls.update()
mixer && mixer.update(delta)
renderer.render(scene, camera)
stats.update()
}
animate()
위 코드는 우리가 로드한 욺직이는 markerman 객체를 import 하여 욺직임을 보도록 한 코드이다.
Mixer 는 다양한 애니메이션 클립(action)을 "믹싱"하거나 "관리"하는 역할을 한다.
AnimationMixer는 3D 모델에 적용된 애니메이션 클립을 실행, 중지, 업데이트 및 블렌딩(혼합)하는 데 사용된다.
애니메이션 클립 실행
mixer.clipAction(...)
을 호출하면 특정 애니메이션 클립을 재생할 수 있다.
위 코드에서는 gltf.animations[0] (GLTF 파일의 첫 번째 애니메이션 클립)을 재생한다.
애니메이션 상태 관리
애니메이션의 속도, 반복 횟수, 블렌딩 등을 관리한다.
e.g. 애니메이션을 반복하거나 특정 지점에서 멈추도록 설정 가능.
.update()
mixer.update(deltaTime)
을 호출하면 애니메이션의 진행 상태를 매 프레임 업데이트한다.
코드에서 clock.getDelta()
로 계산된 경과 시간을 mixer.update()
에 전달함으로써 애니메이션을 프레임 속도와 무관하게 올바른 속도로 재생한다.
블렌딩(믹싱)
서로 다른 애니메이션 클립을 부드럽게 전환하거나 동시에 재생할 때 혼합해준다.
e.g. 걷기(walk)에서 뛰기(run)로 전환할 때, 자연스럽게 두 애니메이션을 섞어줌.
const walkAction = mixer.clipAction(walkClip);
const runAction = mixer.clipAction(runClip);
walkAction.play();
runAction.fadeIn(1); // 1초 동안 자연스럽게 전환
runAction.crossFadeFrom(walkAction, 1, true)
우선 키를 저장할 수 있는 객체를 하나 생성하고 해당 객체에 눌린 키를 바인드 하는 함수도 함께 작성한다.
const keyMap: { [key: string]: boolean } = {}
const onDocumentKey = (e: KeyboardEvent) => {
keyMap[e.code] = e.type === 'keydown'
}
그리고 animate()
에 현재 눌린 키에 따라 이전에 행동을 activeAction
변수에 담아주고
.fadeIn().play()
함수로 .crossFadeFrom()
를 구현한다.
또한 어떤 애니메이션은 마지막에 T
가 기본으로 붙어있는데 이는 reset()
함수를 사용하면 정상화 된다.
...
if (keyMap['KeyW']) {
if (keyMap['ShiftLeft']) {
if (activeAction != animationActions['run']) {
activeAction.fadeOut(0.5)
animationActions['run'].reset().fadeIn(0.25).play()
activeAction = animationActions['run']
}
} else {
if (activeAction != animationActions['walk']) {
activeAction.fadeOut(0.5)
animationActions['walk'].reset().fadeIn(0.25).play()
activeAction = animationActions['walk']
}
}
} else {
if (activeAction != animationActions['idle']) {
activeAction.fadeOut(0.5)
animationActions['idle'].reset().fadeIn(0.25).play()
activeAction = animationActions['idle']
}
}
...
지금까진 모델이 가만히 서서 욺직였는데 이제 배경도 함께 욺직여서 캐릭터가 이동하는 효과를 주자.
앞서 포스팅 한 커스텀 lerp 를 작성하고 달리기, 걷기에 따른 속도 값도 설정한다.
let speed = 0,
toSpeed = 0;
function lerp(from: number, to: number, speed: number) {
const amount = (1 - speed) * from + speed * to
return Math.abs(from - to) < 0.001 ? to : amount
}
// animation 함수 내부
...
speed = lerp(speed, toSpeed, delta * 10)
gridHelper.position.z -= speed * delta
gridHelper.position.z = gridHelper.position.z % 10
...
우선 간단하게 누른 시간 대비 속도를 곱하여 앞뒤로만 욺직이는 코드를 구현하였다.
import './style.css'
import * as THREE from 'three'
import {OrbitControls} from 'three/addons/controls/OrbitControls.js'
import {RGBELoader} from 'three/addons/loaders/RGBELoader.js'
import {GLTFLoader} from 'three/addons/loaders/GLTFLoader.js'
import Stats from 'three/addons/libs/stats.module.js'
const scene = new THREE.Scene()
const gridHelper = new THREE.GridHelper(100, 100)
scene.add(gridHelper)
new RGBELoader().load('img/venice_sunset_1k.hdr', (texture) => {
texture.mapping = THREE.EquirectangularReflectionMapping
scene.environment = texture
scene.background = texture
scene.backgroundBlurriness = 1
})
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 100)
camera.position.set(0.1, 1, 1)
const renderer = new THREE.WebGLRenderer({antialias: true})
renderer.setSize(window.innerWidth, window.innerHeight)
document.body.appendChild(renderer.domElement)
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight
camera.updateProjectionMatrix()
renderer.setSize(window.innerWidth, window.innerHeight)
})
const controls = new OrbitControls(camera, renderer.domElement)
controls.enableDamping = true
controls.target.set(0, 0.75, 0)
const stats = new Stats()
document.body.appendChild(stats.dom)
function lerp(from: number, to: number, speed: number) {
const amount = (1 - speed) * from + speed * to
return Math.abs(from - to) < 0.001 ? to : amount
}
let mixer: THREE.AnimationMixer
let animationActions: { [key: string]: THREE.AnimationAction } = {}
let activeAction: THREE.AnimationAction
let speed = 0,
toSpeed = 0;
async function loadEve() {
const loader = new GLTFLoader()
const [walk, eve, run] = await Promise.all([
loader.loadAsync('models/eve@walk.glb'),
loader.loadAsync('models/eve$@idle.glb'),
loader.loadAsync('models/eve@run.glb')
])
mixer = new THREE.AnimationMixer(eve.scene)
animationActions['walk'] = mixer.clipAction(walk.animations[0])
animationActions['idle'] = mixer.clipAction(eve.animations[0])
animationActions['run'] = mixer.clipAction(run.animations[0])
animationActions['idle'].play()
activeAction = animationActions['idle']
scene.add(eve.scene)
}
await loadEve()
const keyMap: { [key: string]: boolean } = {}
const onDocumentKey = (e: KeyboardEvent) => {
keyMap[e.code] = e.type === 'keydown'
}
document.addEventListener('keydown', onDocumentKey, false)
document.addEventListener('keyup', onDocumentKey, false)
const clock = new THREE.Clock()
let delta = 0
function animate() {
requestAnimationFrame(animate);
delta = clock.getDelta();
controls.update();
mixer.update(delta);
if (keyMap['KeyW']) {
if (keyMap['ShiftLeft']) {
if (activeAction != animationActions['run']) {
activeAction.fadeOut(0.5)
animationActions['run'].reset().fadeIn(0.25).play()
activeAction = animationActions['run']
toSpeed = 4
}
} else {
if (activeAction != animationActions['walk']) {
activeAction.fadeOut(0.5)
animationActions['walk'].reset().fadeIn(0.25).play()
activeAction = animationActions['walk']
toSpeed = 1
}
}
} else {
if (activeAction != animationActions['idle']) {
activeAction.fadeOut(0.5)
animationActions['idle'].reset().fadeIn(0.25).play()
activeAction = animationActions['idle']
toSpeed = 0
}
}
speed = lerp(speed, toSpeed, delta * 10)
gridHelper.position.z -= speed * delta
gridHelper.position.z = gridHelper.position.z % 10
renderer.render(scene, camera);
stats.update();
}
animate()