Three.js GLTF Animations

강정우·2025년 1월 10일
0

three.js

목록 보기
16/24
post-thumbnail

GLTF Animations

이제 GLTF 에 저장된 animation 들에 대해 알아보자.

우선 모델을 불러올 건데 원숭이 수잔 처럼 대표 모델을 일단 Mixamo 에서 받아주자.
여기서는 FBX 모델을 받으면 Blender 에서 GLB 파일로 트랜스 파일할 수 있다.

본인이 원하는 캐릭터, 걷는 모습의 animation 을 골라주고 위와 같은 옵션으로 받아주면 된다.

그리고 import 를 한 후 export 를 gltf 로 내보내면 되는데 여기서 naming 규칙을 정하면 된다. markerman$@walk.glb
필자는 이름$스킨@동작 으로 지어 같은 모델이라 하더라고 구분을 지었다.
그래서 three.js 에서 가져온 후 로그를 찍어보면 animation 배열에 뭔가가 들어있다. 바로 우리가 걷기 모션을 설정한 값이다.

Mixer

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가 하는 일

AnimationMixer는 3D 모델에 적용된 애니메이션 클립을 실행, 중지, 업데이트 및 블렌딩(혼합)하는 데 사용된다.

  1. 애니메이션 클립 실행
    mixer.clipAction(...) 을 호출하면 특정 애니메이션 클립을 재생할 수 있다.
    위 코드에서는 gltf.animations[0] (GLTF 파일의 첫 번째 애니메이션 클립)을 재생한다.

  2. 애니메이션 상태 관리
    애니메이션의 속도, 반복 횟수, 블렌딩 등을 관리한다.
    e.g. 애니메이션을 반복하거나 특정 지점에서 멈추도록 설정 가능.

  3. .update()
    mixer.update(deltaTime) 을 호출하면 애니메이션의 진행 상태를 매 프레임 업데이트한다.
    코드에서 clock.getDelta() 로 계산된 경과 시간을 mixer.update() 에 전달함으로써 애니메이션을 프레임 속도와 무관하게 올바른 속도로 재생한다.

  4. 블렌딩(믹싱)
    서로 다른 애니메이션 클립을 부드럽게 전환하거나 동시에 재생할 때 혼합해준다.
    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)
  • 추가로 Scene 에 여러 모델이 있다면 각 모델별로 AnimationMixer 가 별도로 존재해야한다.

key mapping (feat. reset(), fadeIn())

우선 키를 저장할 수 있는 객체를 하나 생성하고 해당 객체에 눌린 키를 바인드 하는 함수도 함께 작성한다.

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
  ...
  

우선 간단하게 누른 시간 대비 속도를 곱하여 앞뒤로만 욺직이는 코드를 구현하였다.

💻 GLTF Animation Code

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()
profile
智(지)! 德(덕)! 體(체)!

0개의 댓글