Three.js Makes Animation (feat. Lerp)

강정우·2025년 1월 9일
0

three.js

목록 보기
15/24
post-thumbnail

Makes Animation

애니메이션을 만들어보자.
우선 Lerp 를 알아보기 위한 기본적인 코드를 작성해보자.

// Mesh 를 상속받아서 프로젝트에 필요한 속성 값을 추가.
class Pickable extends Mesh {
    hovered = false
    clicked = false
    colorTo: Color
    defaultColor: Color
    geometry: BufferGeometry
    material: MeshStandardMaterial
    v = new Vector3()

    constructor(geometry: BufferGeometry, material: MeshStandardMaterial, colorTo: Color) {
        super()
        this.geometry = geometry
        this.material = material
        this.colorTo = colorTo
        this.defaultColor = material.color.clone()
        this.castShadow = true
    }

    update(delta: number) {
      	// clicked 속성값이 참일 때
        this.clicked 
      		// MathUrils 의 lerp 함수로 아래 설명하겠다.
            ? (this.position.y = MathUtils.lerp(this.position.y, 1, delta * 5)) 
        	: (this.position.y = MathUtils.lerp(this.position.y, 0, delta * 5))
    }
}

const scene = new Scene()

const spotLight = new SpotLight(0xffffff, 500)
spotLight.position.set(5, 5, 5)
spotLight.angle = 0.3
spotLight.penumbra = 0.5
spotLight.castShadow = true
spotLight.shadow.radius = 20
spotLight.shadow.blurSamples = 20
spotLight.shadow.camera.far = 20
scene.add(spotLight)

await new RGBELoader().loadAsync('img/venice_sunset_1k.hdr').then((texture) => {
    texture.mapping = EquirectangularReflectionMapping
    scene.environment = texture
    scene.background = texture
})

const camera = new PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 100)
camera.position.set(0, 2, 4)

const renderer = new WebGLRenderer({ antialias: true })
renderer.setSize(window.innerWidth, window.innerHeight)
renderer.shadowMap.enabled = true
renderer.shadowMap.type = VSMShadowMap
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.maxPolarAngle = Math.PI / 2 + Math.PI / 16 // 100 도 (degrees)

const raycaster = new Raycaster()
const pickables: Pickable[] = []
let intersects
const mouse = new Vector2()

renderer.domElement.addEventListener('pointerdown', (e) => {
    mouse.set((e.clientX / renderer.domElement.clientWidth) * 2 - 1, -(e.clientY / renderer.domElement.clientHeight) * 2 + 1)

    raycaster.setFromCamera(mouse, camera)
    intersects = raycaster.intersectObjects(pickables, false)
  	// true 면 false, false 면 true
    intersects.length && ((intersects[0].object as Pickable).clicked = !(intersects[0].object as Pickable).clicked)
})

// 앞서 작성한 Pickable custom class 를 인스턴스화.
const cylinder = new Pickable(new CylinderGeometry(0.66, 0.66), new MeshStandardMaterial({ color: 0x888888 }), new Color(0x008800))
scene.add(cylinder)
pickables.push(cylinder)

const floor = new Mesh(new PlaneGeometry(20, 20), new MeshStandardMaterial())
floor.rotateX(-Math.PI / 2)
floor.position.y = -1.25
floor.receiveShadow = true
floor.material.envMapIntensity = 0.2;
scene.add(floor)

const stats = new Stats()
document.body.appendChild(stats.dom)

const clock = new Clock()
let delta = 0

function animate() {
    requestAnimationFrame(animate)

    delta = clock.getDelta()

  	// 모든 상호작용 객체 animation update
    pickables.forEach((p) => {
        p.update(delta)
    })

    controls.update()

    renderer.render(scene, camera)

    stats.update()
}

animate()

lerp()

MathUtils.lerp()

Linear interpolation (선형 보간)을 사용하여 애니메이션을 만들 수 있다.

MathUtils.lerp(시작점, 끝점, 얼마나 욺직이는 지)) 따라서 마치 애니메이션이 다음과 같은 속도로 진행된다. 하지만 가장 중요한 것은 lerp 의 특징과 delta 의 특징이 만나 지정한 1 혹은 0 에 도달하지 못 하고 1, 0 에 근사한 수치로 계속 lerp 가 수행된다.

delta는 일반적으로 프레임 간의 시간 차이를 나타내며, 이 값이 작을수록 this.position.y의 변화는 느려지고, 클수록 변화는 빨라진다. 따라서 this.position.y는 목표 값인 1이나 0에 점진적으로 접근하게 되지만, delta가 1보다 작으면 완전히 도달하지는 못하고 근사치로 남게 되기 때문이다.

그래서 custom lerp 를 만들어 보자면

custom lerp

function lerp(from: number, to: number, speed: number) {
    const amount = (1 - speed) * from + speed * to
    return Math.abs(from - to) < 0.001 ? to : amount
}

위 코드와 같이 입실론을 설정하여 해당 값 보다 낮다면 그냥 목표치로 설정해버리면 된다.

material.color.lerp

this.hovered
    ? (this.material.color.lerp(this.colorTo, delta * 10),
            (this.material.roughness = lerp(this.material.roughness, 0, delta * 10)),
            (this.material.metalness = lerp(this.material.metalness, 1, delta * 10))
    )
    : (this.material.color.lerp(this.defaultColor, delta),
        	(this.material.roughness = lerp(this.material.roughness, 1, delta)),
        	(this.material.metalness = lerp(this.material.metalness, 0, delta)))

MathUtil 뿐만 아니라 Material 에 color 속성도 lerp 를 사용할 수 있다.
또한 앞서 작성한 custom lerp 를 사용하면 Material 의 다른 속성에도 lerp 를 줄 수 있다.

.scale.lerp()

.scale 속성은 Vector3 를 갖는 값으로 x, y, z 를 수동으로 값을 설정해줘도 되지만

this.clicked
  ? this.scale.set(
  lerp(this.scale.x, 1.5, delta * 5),
  lerp(this.scale.y, 1.5, delta * 5),
  lerp(this.scale.z, 1.5, delta * 5)
)
: this.scale.set(
  lerp(this.scale.x, 1.0, delta),
  lerp(this.scale.y, 1.0, delta),
  lerp(this.scale.z, 1.0, delta)
)

scale 속성에도 자체적으로 lert 함수가 존재하여 이를 이용하면 더 짧고 가시성있게 코드를 작성할 수 있다.

this.clicked ? this.v.set(1.5, 1.5, 1.5) : this.v.set(1.0, 1.0, 1.0)
this.scale.lerp(this.v, delta * 5)

JEasings

JEasings 은 애니메이션 연출을 할 수 있는 JS Easing 엔진이다. (Three.js 에 포함되지 않은 third party library)
이 JEasing 을 사용하면 lerp() 처럼 일정 시간동안 객체 속성의 값을 새로운 값으로 설정할 수 있다.

  1. 시간 설정
  2. 변화할 새 값
  3. (optional) 시작 시간 지연
  4. (optional) 각 animate update 에서 실행할 스크립트
  5. (optional) 완료 후 실행할 스크립트
  6. (optional) transition 이 진행되는 속도를 조절할 수 있는 함수 설정
  7. (optional) 변수에 저장 가능
  8. (optional) JEasing 을 chain 으로 연결하여 순서대로 혹은 연속적으로 실행할 수 있도록 함.
  • 즉, JEasings 은 lerp 를 사용하여 애니메이션을 제어하는 것 보다 더 많은 기능을 제공한다.

JEasing 역시 .update() 함수로 각 프래임 마다 update 를 해줘야 동작한다.

function animate() {
    requestAnimationFrame(animate)
    controls.update()
    JEASINGS.update()
    renderer.render(scene, camera)
    stats.update()
}

JEasing()

우선 간단하게 카메라를 욺직이는 코드를 작성해보자.

...

function render() {
    renderer.render(scene, camera)
}

// 렌더러에 더블클릭 이벤트 생성
renderer.domElement.addEventListener('dblclick', (e) => {
  	// Raycaster 에서 배운 마우스 위치 설정 후 렌더러에게 던짐
    mouse.set((e.clientX / renderer.domElement.clientWidth) * 2 - 1, -(e.clientY / renderer.domElement.clientHeight) * 2 + 1)

  	// raycaster 에 마우스, 카메라 렌더링
    raycaster.setFromCamera(mouse, camera)
  
	// 상호작용 할 바닥 던짐
    const intersects = raycaster.intersectObjects([plane], false)

    if (intersects.length) {
        const p = intersects[0].point
        
        // 해당 좌표로 이동
        controls.target.set(p.x, p.y, p.z)
...

하지만 이 코드는 해당 지점으로 부드러운 transition 없이 바로 transform 을 조져버린다.
그래서 앞서 언급한 JEasing 을 상용하여 GreenSock 에 나온 transition 함수들 처럼 값을 부여하여 부드럽게 욺직일 수 있도록 코드를 수정하였다.

...
// JEasing the controls.target
new JEASINGS.JEasing(controls.target)
    .to(
        {
            x: p.x,
            y: p.y,
            z: p.z
        },
        500
    )
    //.delay (1000) // 딜레이를 주는 코드
    .easing(JEASINGS.Cubic.Out)
    // .onUpdate(() => render()) // 만약 주사율대로 rendering 하는 코드가 animate 에 없다면 onUpdate 에 콜백함수로 넣어줘야함.
    .start()
...
  • JEasing 이 갖고있는 transition 함수들

물론 raycasterintersectObjects() 에 전달하는 파라미터에 따라 plane 이면 해당 좌표로 카메라를 옮길 수도 있고 혹은 Object 라면 해당 물건을 옮길 수도 있다.

💻 lerp code

import './style.css'
import {
    Mesh,Color,MeshStandardMaterial,BufferGeometry,Raycaster,Scene,SpotLight,PerspectiveCamera,WebGLRenderer,
  VSMShadowMap,BoxGeometry,CylinderGeometry,TetrahedronGeometry,PlaneGeometry,Vector2,Clock,
  EquirectangularReflectionMapping,MeshPhongMaterial,Vector3,MathUtils,
} from 'three'
import {OrbitControls} from 'three/addons/controls/OrbitControls.js'
import {RGBELoader} from 'three/addons/loaders/RGBELoader.js'
import Stats from 'three/addons/libs/stats.module.js'

function lerp(from: number, to: number, speed: number) {
    const amount = (1 - speed) * from + speed * to
    return Math.abs(from - to) < 0.001 ? to : amount
}

class Pickable extends Mesh {
    hovered = false
    clicked = false
    colorTo: Color
    defaultColor: Color
    geometry: BufferGeometry
    material: MeshStandardMaterial
    v = new Vector3()

    constructor(geometry: BufferGeometry, material: MeshStandardMaterial, colorTo: Color) {
        super()
        this.geometry = geometry
        this.material = material
        this.colorTo = colorTo
        this.defaultColor = material.color.clone()
        this.castShadow = true
    }

    update(delta: number) {
        this.rotation.x += delta / 2
        this.rotation.y += delta / 2

        this.clicked
            ? (this.position.y = lerp(this.position.y, 1, delta * 5))
            : (this.position.y = lerp(this.position.y, 0, delta * 5))

        this.hovered
            ? (this.material.color.lerp(this.colorTo, delta * 10),
                    (this.material.roughness = lerp(this.material.roughness, 0, delta * 10)),
                    (this.material.metalness = lerp(this.material.metalness, 1, delta * 10))
            )
            : (this.material.color.lerp(this.defaultColor, delta * 10),
                (this.material.roughness = lerp(this.material.roughness, 1, delta)),
                (this.material.metalness = lerp(this.material.metalness, 0, delta)))

        this.clicked ? this.v.set(1.5, 1.5, 1.5) : this.v.set(1.0, 1.0, 1.0)
        this.scale.lerp(this.v, delta * 5)
    }
}

const scene = new Scene()

const spotLight = new SpotLight(0xffffff, 500)
spotLight.position.set(5, 5, 5)
spotLight.angle = 0.3
spotLight.penumbra = 0.5
spotLight.castShadow = true
spotLight.shadow.radius = 20
spotLight.shadow.blurSamples = 20
spotLight.shadow.camera.far = 20
scene.add(spotLight)

await new RGBELoader().loadAsync('img/venice_sunset_1k.hdr').then((texture) => {
    texture.mapping = EquirectangularReflectionMapping
    scene.environment = texture
    scene.background = texture
})

const camera = new PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 100)
camera.position.set(0, 2, 4)

const renderer = new WebGLRenderer({antialias: true})
renderer.setSize(window.innerWidth, window.innerHeight)
renderer.shadowMap.enabled = true
renderer.shadowMap.type = VSMShadowMap
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.maxPolarAngle = Math.PI / 2 + Math.PI / 16 // ~ 100 degrees

const raycaster = new Raycaster()
const pickables: Pickable[] = [] // used in the raycaster intersects methods
let intersects
const mouse = new Vector2()

renderer.domElement.addEventListener('pointerdown', (e) => {
    mouse.set((e.clientX / renderer.domElement.clientWidth) * 2 - 1, -(e.clientY / renderer.domElement.clientHeight) * 2 + 1)

    raycaster.setFromCamera(mouse, camera)

    intersects = raycaster.intersectObjects(pickables, false)

    intersects.length && ((intersects[0].object as Pickable).clicked = !(intersects[0].object as Pickable).clicked)
})

renderer.domElement.addEventListener('mousemove', (e) => {
    mouse.set(
        (e.clientX / renderer.domElement.clientWidth) * 2 - 1,
        -(e.clientY / renderer.domElement.clientHeight) * 2 + 1
    )

    raycaster.setFromCamera(mouse, camera)

    intersects = raycaster.intersectObjects(pickables, false)

    pickables.forEach((p) => (p.hovered = false))

    intersects.length && ((intersects[0].object as Pickable).hovered = true)
})

const cylinder = new Pickable(new CylinderGeometry(0.66, 0.66), new MeshStandardMaterial({color: 0x888888}), new Color(0x008800))
scene.add(cylinder)
pickables.push(cylinder)

const cube = new Pickable(
    new BoxGeometry(),
    new MeshStandardMaterial({color: 0x888888}),
    new Color(0xff2200)
)
cube.position.set(-2, 0, 0)
scene.add(cube)
pickables.push(cube)

const pyramid = new Pickable(
    new TetrahedronGeometry(),
    new MeshStandardMaterial({color: 0x888888}),
    new Color(0x0088ff)
)
pyramid.position.set(2, 0, 0)
scene.add(pyramid)
pickables.push(pyramid)

const floor = new Mesh(new PlaneGeometry(20, 20), new MeshStandardMaterial())
floor.rotateX(-Math.PI / 2)
floor.position.y = -1.25
floor.receiveShadow = true
floor.material.envMapIntensity = 0.2;
scene.add(floor)

const stats = new Stats()
document.body.appendChild(stats.dom)

const clock = new Clock()
let delta = 0

function animate() {
    requestAnimationFrame(animate)

    delta = clock.getDelta()

    pickables.forEach((p) => {
        p.update(delta)
    })

    controls.update()

    renderer.render(scene, camera)

    stats.update()
}

animate()

💻 JEasing code

import './style.css'
import * as THREE from 'three'
import {OrbitControls} from 'three/addons/controls/OrbitControls.js'
import {GLTFLoader} from 'three/addons/loaders/GLTFLoader.js'
import {RGBELoader} from 'three/addons/loaders/RGBELoader.js'
import Stats from 'three/addons/libs/stats.module.js'
import JEASINGS from 'jeasings'

const scene = new THREE.Scene()

const gridHelper = new THREE.GridHelper()
gridHelper.position.y = -1
scene.add(gridHelper)

await new RGBELoader().loadAsync('img/venice_sunset_1k.hdr').then((texture) => {
    texture.mapping = THREE.EquirectangularReflectionMapping
    scene.environment = texture
})

const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 100)
camera.position.set(0, 1, 4)

const renderer = new THREE.WebGLRenderer({antialias: true})
renderer.toneMapping = THREE.ACESFilmicToneMapping
renderer.toneMappingExposure = 0.8
renderer.shadowMap.enabled = 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)
    // render()
})

const controls = new OrbitControls(camera, renderer.domElement)
controls.enableDamping = true
// controls.addEventListener('change', render)

let suzanne: THREE.Mesh, plane: THREE.Mesh

new GLTFLoader().load('models/suzanne_the_monkey.gltf', (gltf) => {
    suzanne = gltf.scene.getObjectByName('수잔') as THREE.Mesh
    suzanne.castShadow = true
    ;((suzanne.material as THREE.MeshStandardMaterial).map as THREE.Texture).colorSpace = THREE.LinearSRGBColorSpace

    plane = gltf.scene.getObjectByName('평면') as THREE.Mesh
    plane.scale.set(50, 1, 50)
    ;(plane.material as THREE.MeshStandardMaterial).envMap = scene.environment // since three@163, we need to set `envMap` before changing `envMapIntensity` has any effect.
    ;(plane.material as THREE.MeshStandardMaterial).envMapIntensity = 0.05
    plane.receiveShadow = true

    const spotLight = gltf.scene.getObjectByName('스폿') as THREE.SpotLight
    spotLight.intensity /= 100
    spotLight.castShadow = true
    spotLight.target = suzanne

    scene.add(gltf.scene)

    // render()
})

const raycaster = new THREE.Raycaster()
const mouse = new THREE.Vector2()

renderer.domElement.addEventListener('dblclick', (e) => {
    mouse.set((e.clientX / renderer.domElement.clientWidth) * 2 - 1, -(e.clientY / renderer.domElement.clientHeight) * 2 + 1)

    raycaster.setFromCamera(mouse, camera)

    const intersects = raycaster.intersectObjects([plane], false)

    if (intersects.length) {
        const p = intersects[0].point

        // 기존
        // controls.target.set(p.x, p.y, p.z)

        // JEasing 을 사용한 카메라 컨트롤
        // new JEASINGS.JEasing(controls.target)
        //     .to(
        //         {
        //             x: p.x,
        //             y: p.y,
        //             z: p.z
        //         },
        //         500
        //     )
        //     .delay (1000)
        //     .easing(JEASINGS.Cubic.Out)
        //     // .onUpdate(() => render())
        //     .start()

        // slding x,z
        new JEASINGS.JEasing(suzanne.position)
            .to(
                {
                    x: p.x,
                    z: p.z
                },
                500
            )
            .start()

        // going up
        new JEASINGS.JEasing(suzanne.position)
            .to(
                {
                    y: p.y + 3
                },
                250
            )
            .easing(JEASINGS.Cubic.Out)
            .start()
            .onComplete(() => {
                // going down
                new JEASINGS.JEasing(suzanne.position)
                    .to(
                        {
                            y: p.y + 1
                        },
                        250
                    )
                    .easing(JEASINGS.Cubic.In)
                    .start()
            })
    }
})

const stats = new Stats()
document.body.appendChild(stats.dom)

function animate() {
    requestAnimationFrame(animate)

    controls.update()

    JEASINGS.update()

    render()

    stats.update()
}

function render() {
    renderer.render(scene, camera)
}

animate()
profile
智(지)! 德(덕)! 體(체)!

0개의 댓글