애니메이션을 만들어보자.
우선 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()
Linear interpolation (선형 보간)을 사용하여 애니메이션을 만들 수 있다.
MathUtils.lerp(시작점, 끝점, 얼마나 욺직이는 지))
따라서 마치 애니메이션이 다음과 같은 속도로 진행된다. 하지만 가장 중요한 것은 lerp 의 특징과 delta 의 특징이 만나 지정한 1 혹은 0 에 도달하지 못 하고 1, 0 에 근사한 수치로 계속 lerp 가 수행된다.
delta는 일반적으로 프레임 간의 시간 차이를 나타내며, 이 값이 작을수록 this.position.y의 변화는 느려지고, 클수록 변화는 빨라진다. 따라서 this.position.y는 목표 값인 1이나 0에 점진적으로 접근하게 되지만, delta가 1보다 작으면 완전히 도달하지는 못하고 근사치로 남게 되기 때문이다.
그래서 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
}
위 코드와 같이 입실론을 설정하여 해당 값 보다 낮다면 그냥 목표치로 설정해버리면 된다.
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
속성은 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 은 애니메이션 연출을 할 수 있는 JS Easing 엔진이다. (Three.js 에 포함되지 않은 third party library)
이 JEasing 을 사용하면 lerp()
처럼 일정 시간동안 객체 속성의 값을 새로운 값으로 설정할 수 있다.
JEasing 역시 .update()
함수로 각 프래임 마다 update 를 해줘야 동작한다.
function animate() {
requestAnimationFrame(animate)
controls.update()
JEASINGS.update()
renderer.render(scene, camera)
stats.update()
}
우선 간단하게 카메라를 욺직이는 코드를 작성해보자.
...
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()
...
물론 raycaster
에 intersectObjects()
에 전달하는 파라미터에 따라 plane 이면 해당 좌표로 카메라를 옮길 수도 있고 혹은 Object 라면 해당 물건을 옮길 수도 있다.
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()
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()