three.js๋ก ํ์ ํ๋ ์ฑ ๋ง๋ค์ด ๋ณด๊ธฐ
๐ฅ ๋๊ธฐ
ํ์ฌ์์ ๋ ์๋น๋ฅผ ์ง์ํด์ฃผ๋ ๋์ ์ฝ๋ก๋ ํ์ฐธ ์ ๋ถํฐ ๊ฑฐ๋ฆฌ๋๊ธฐ๋ฅผ ํ๋ ์ฑ ์ ์กฐ๊ธ์ฉ ์ฝ๊ธฐ ์์ํ๋ค. ์ต๊ทผ์ ์ฑ ์ ์์๋ณด๋ฌ ์๋ผ๋์ ๋ค์ด๊ฐ๋ณด๋ ์ฑ ์ ๋ง์ฐ์ค๋ฅผ ์ฌ๋ฆฌ๋ฉด ์ฑ ์ ๋๋ ค๋ณผ ์ ์๋๋ก ๋์ด์์๋ค.
์์ฆ three.js์ ๋ํด ํธ๊ธฐ์ฌ์ด ์์๋๋ฐ ์ด ๊ธฐํ์ three.js ๊ฒฝํํด๋ณผ ๊ฒธ ํน์ ๋ค๋ฅธ ์ฌ์ด๋ ํ๋ก์ ํธ์์ ์จ๋จน์ด๋ณผ ์ ์์ง ์์๊น ํ๋ ๋ง์์ผ๋ก ์๋ผ๋์ ์ฑ ์ three.js๋ก ํด๋ก ํด๋ณด๊ฒ ๋์๋ค.(์๋ผ๋์์ ์ฑ ์ ์, ๋ค, ์๋ฉด์ ์ฌ์ฉํ ์ด๋ฏธ์ง ์์ค๋ ๊ตฌํ ์ ์์ด์ ๋๋ฌด ์ข์๋ค.๐)
ํด๋ก ์ ํ๋ฉด์ ์ด๋ฐ์ ๋ฐ ์์ค๋ฅผ ํตํด์ three.js๋ฅผ ์กฐ๊ธ์ ์ดํดํด๋ณผ ์ ์๊ฒ ๋์๋๋ฐ ํน์ three.js๋ฅผ ๋ญ๊ฐ๋ฅผ ๋ง๋ค์ด๋ณด๋ฉด์ ๊ณต๋ถํด๋ณด๊ณ ์ถ์ผ์ ๋ถ๋ค์ ์ํด ๊ณผ์ ํ์์ผ๋ก ๋ด์ฉ์ ์ ๋ฆฌ ํด๋ณด์๋ค.
๐ฎ ์๋ผ๋ ์ฑ ํด๋ก ํด๋ณด๊ธฐ
๐ค ๊ณผ์
์๋ผ๋์ ์ฑ ๋ฏธ๋ฆฌ๋ณด๊ธฐ๋ฅผ three.js๋ฅผ ํตํด ์ต๋ํ ์ ์ฌํ๊ฒ ๊ตฌํํด๋ณธ๋ค.
์ ํ์๊ฐ: 5์๊ฐ(๊ธด์ฅ๊ฐ์ ์ํด)
๐งบ ์ค๋น๋ฌผ
์ฑ ์ ์, ๋ค, ์๋ฉด์์ ์ฌ์ฉํ ์ด๋ฏธ์ง๋ ์ฌ๊ธฐ์์ ๋ฐ์ ์ ์๋ค. ๋ค๋ฅธ ์ฑ ์ ํ์ง๋ฅผ ์ป์ด์ค๊ณ ์ถ๋ค๋ฉด ์๋ผ๋์์ ์ํ๋ ์ฑ ์ ๊ฒ์ํด์ ์๋์ฒ๋ผ ๊ฐ๋ฐ์ ๋๊ตฌ๋ฅผ ํตํด ๊ฐ์ ธ์ฌ ์ ์๋ค.
โ ์๊ตฌ์ฌํญ
- ์ฒ์์๋ ์ฑ ์ ์ ๋ฉด์ด ๋ณด์ฌ์ง๊ฒ ํ๊ธฐ
- ๋ง์ฐ์ค๋ฅผ ์ฑ ์ ์ฌ๋ฆฌ๋ฉด ์ฑ ์ ์๋ฉด์ด ์ด์ง ๋ณด์ด๋๋ก ๋๋ฆฌ๊ธฐ
- ์๋ผ๋ ๋ฏธ๋ฆฌ๋ณด๊ธฐ์ ๋์ผํ ๊ทธ๋ฆผ์ ๊ตฌํํ๊ธฐ
- (์ ํ) ํด๋ฆญ ์ ์ฑ ์ด 180๋ ํ์ ํ๊ฒ ํ๊ธฐ
๐ ๋ ํผ๋ฐ์ค
- https://www.youtube.com/watch?v=cghSq_dlgYU&t=733s
- https://threejs.org/docs/index.html#manual/en/introduction/Creating-a-scene
์๋๋ถํฐ๋ ๋ด๊ฐ ๋ง๋ ๋ฐฉ๋ฒ์ ๋ํ ์ค๋ช ์ด๋ค.(๐ฅ์คํฌ์ฃผ์!)
๐ง ๋ด๊ฐ ๋ง๋ ๋ฐฉ๋ฒ
๋ด๊ฐ ๊ตฌํํญ ๋ฐฉ๋ฒ์ ๋ค์ ๋งํฌ์์ ๋ณผ ์ ์๋ค.
๋ด ๋ธ๋ก๊ทธ๋ ๋ค๋ฅธ ํ๋ก์ ํธ์์ ์ฌ์ฉํ ์ง๋ ๋ชจ๋ฅด๊ธฐ์ React๋ก ๊ฐ๋ฐํด์ ๋ฐฐํฌํด๋์๋ค.
๐ฒ ๊ธฐ๋ณธ ๊ตฌ์กฐ ๋ง๋ค๊ธฐ
๋จผ์ three.js๋ก 3D ์ธ์์ ์ธํธ์ฅ์ ๋ง๋ค์ด๋ณด์.
๐ Scene
Scene
์ ๋ง๊ทธ๋๋ก ์ฅ๋ฉด์ ์๋ฏธํ๋ค. ๋ฌด์ธ๊ฐ๋ฅผ ๋ณด์ฌ์ฃผ๊ธฐ ์ํด์๋ scene์ ๋จผ์ ๋ง๋ค์ด์ผ ํ๋ค.
const scene = new THREE.Scene();
scene.background = new THREE.Color(style.background);
๊ทธ๋ฆฌ๊ณ ๋์ ๋ง๋ scene์์ ๋ณด์ฌ์ค ์ฌ๋ฌ ์์๋ค์ ์ถ๊ฐํ๋ค.
๐ท Camera
Camera
๋ ๋ง๊ทธ๋๋ก ์นด๋ฉ๋ผ๋ก Scene์ ์ด๋์, ์ด๋๋ฅผ, ์ด๋ป๊ฒ ๋ณด๊ณ ์๊ฒ ํ ๊ฒ์ธ๊ฐ๋ฅผ ์ ์ํ ์ ์๋ค. ๋๋ ์ด๋ฒ ํ๋ก์ ํธ์์ ์ฌ๋์ ๋์ ๊ฐ์ฅํ ๋ฐฉ์์ ์นด๋ฉ๋ผ์ธ PerspectiveCamera
๋ฅผ ์ฌ์ฉํ์๋ค.
const camera = new THREE.PerspectiveCamera(70, aspect, 1, 1000);
camera.position.set(0, 0, 6);
๐ฅ WebGLRenderer
Scene
๊ณผ Camera
๊ฐ ์ค๋น๋์๋ค๋ฉด ์ด์ ๋ณด์ฌ์ฃผ๊ธฐ๋ฅผ ํ๊ธฐ ์ํ ๊ธฐ๋ณธ ํ๊ฒฝ์ ์ค๋น๊ฐ ์๋ฃ๋์๋ค. ์ด๋ฅผ ์ค์ ๋ก ๋ณด์ฌ์ฃผ๊ธฐ ์ํด์๋ WebGLRenderer
๋ฅผ ์ฌ์ฉํด์ผ ํ๋ค.
WebGLRenderer
๋ ๋ง ๊ทธ๋๋ก ๋ด๊ฐ ๋ง๋ Scene์ WebGL์ ํ์ฉํด์ ๋ณด์ฌ์ฃผ๋ ์ญํ ์ ํ๋ค. ๋๋ renderer๋ฅผ ๋ง๋ค๋ฉด์ ๊ทธ๋ฆผ์๊ฐ ๋ณด์ฌ์ง ์ ์๋๋ก shadowMap
์ ์ค์ ํด์ฃผ์๋ค.
const renderer = new THREE.WebGLRenderer();
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.setSize(style.width, style.height);
๊ทธ๋ฆฌ๊ณ ๋์ ์ด๋ ๊ฒ ์๋์ ๊ฐ์ด animate ํจ์๋ฅผ ํตํด render
๋ฅผ ์งํํ๋ฉด screen์ ๋ณํ๋๋ ๋ด์ฉ์ ์ง์์ ์ผ๋ก ๊ทธ๋ฆด ์ ์๊ฒ ๋๋ค. ์ฌ๊ธฐ์ setInterval์ด ์๋ requestAnimationFrame
๋ฅผ ์ฌ์ฉํ๋ ์ด์ ๋ ๋ง์ง๋ง ํนํ ๋ธ๋ผ์ฐ์ ์ ๋ค๋ฅธ ํญ์ผ๋ก ์ด๋ํ์ ๋๋ ๋ฐ๋ณต์ด ์ ์ ๋ฉ์ถฐ์ ์ธ๋ฐ์๋ ๋ฐฐํฐ๋ฆฌ ์๋ชจ๊ฐ ์๋ค๊ณ ํ๋ค.
function animate() {
requestAnimationFrame(animate);
renderer.render(scene, camera);
}
animate();
๐ฆ ๋ฌผ์ฒด ์ถ๊ฐํ๊ธฐ
๊ทธ๋ผ ์ด์ ๋ณธ๊ฒฉ์ ์ผ๋ก ๋ฌผ์ฒด๋ค์ ์ถ๊ฐํด๋ณด์. Mesh
๋ Object์ ๊ธฐ๋ณธํ์ด๋ค. Mesh์ ์ฌ๋ฌ Material์ ์ถ๊ฐํ๋ฉด์ ๋ฌผ์ฒด๋ฅผ ๋ง๋ค ์ ์๋ค. ์ด ๊ณผ์ ๋ฅผ ํด๊ฒฐํ๊ธฐ ์ํด์ ์ถ๊ฐํด์ผํ๋ Mesh๋ ๋ญ๊ฐ ์์๊น? ์ธ๋ป ์๊ฐํ๋ฉด ์ฑ
๋ง ์์ผ๋ฉด ๋ ๊ฒ ๊ฐ์ง๋ง ์ฑ
์ ์๋ ๊ทธ๋ฆผ์๊ฐ ๋ณด์ฌ์ง๊ธฐ ์ํด ๊ทธ๋ฆผ์๊ฐ ๋น์ถ์ด์ง ๋ฌผ์ฒด๋ ํ์ํ๋ค.
๐ plane (๊ทธ๋ฆผ์๋ฅผ ์ํ ๋ฐฐ๊ฒฝ)
ํน์ ์ถ๊ฐ ๊ตฌํ์ธ ํ์ ์ ์ํด์ ๊ทธ๋ฆผ์๋ฅผ ๋ณด์ฌ์ค ๋ฌผ์ฒด๋ ํน์ ์๊น์ ๊ฐ์ง๊ณ ์์ง ์๊ณ ๊ทธ๋ฅ ๊ทธ๋ฆผ์๋ง ๋ณด์ฌ์ค ์ ์๋ค๋ฉด ์ต๊ณ ์ผ ๊ฒ์ด๋ค. ์ด๋ฅผ ์ํด์ ๊ทธ๋ฆผ์๋ง์ ๋ฐ๊ณ ๋ค๋ฅธ ๋ถ๋ถ๋ค์ ๋ชจ๋ ํฌ๋ช
ํ ์ํ์ธ ShadowMaterial
์ ํ์ฉํ๋ค.
const planeGeometry = new THREE.PlaneGeometry(500, 500, 32, 32);
const planeMaterial = new THREE.ShadowMaterial();
planeMaterial.opacity = 0.5;
const plane = new THREE.Mesh(planeGeometry, planeMaterial);
plane.receiveShadow = true;
scene.add(plane);
๐ cube (์ฑ )
์ฑ
์ ๋ง๋ค๊ธฐ ์ํด์๋ ์ก๋ฉด์ฒด์ธ BoxGeometry
์ ์ฌ์ด์ฆ๋ฅผ ์ ํด ์ฑ
์ ํํ๋ฅผ ๋ง๋ค๊ณ ์ฑ
์ 6๊ฐ์ ๋ฉด์ ๋ค์ด๊ฐ Material๋ฅผ getBookMaterials
ํจ์๋ฅผ ํตํด ๊ตฌํํ์๋ค.
const geometry = new THREE.BoxGeometry(3.5, 5, 0.5);
const cube = new THREE.Mesh(geometry, getBookMaterials(bookCovers));
cube.castShadow = true;
cube.position.set(0, 0, 0);
scene.add(cube);
์ฑ
์ ์ก๋ฉด์ ๋ค์ด๊ฐ Material์ ์ ์ํ๋ ํจ์๋ ๋ค์๊ณผ ๊ฐ๋ค. 6๊ฐ ๋ฉด์ ๋๋ฉด์ ๊ฐ ๋ฉด์ ํด๋นํ๋ image Url์ด ๋์ด์ค๋ฉด Image๋ฅผ TextureLoader
๋ฅผ ํตํด ์ด๋ฏธ์ง๋ฅผ ๋ฃ์ด์ ๋ฉด์ ํด๋นํ๋ Material์ ๋ฐํํ๊ณ ์๋ค๋ฉด ํฐ์ Material์ ๋ฐํํ๋๋ก ๊ตฌํํ์๋ค.
const getBookMaterials = (urlMap) => {
const materialNames = ['edge', 'spine', 'top', 'bottom', 'front', 'back'];
return materialNames.map((name) => {
if (!urlMap[name]) return new THREE.MeshBasicMaterial(0xffffff);
const texture = new THREE.TextureLoader().load(urlMap[name]);
// to create high quality texture
texture.generateMipmaps = false;
texture.minFilter = THREE.LinearFilter;
texture.needsUpdate = true;
return new THREE.MeshBasicMaterial({ map: texture });
});
};
๐ก Light
๊ทธ๋ผ ์ด์ ์กฐ๋ช
์ ์ค์นํด๋ณด์. ์กฐ๋ช
์ ์์น์ ๊ฐ๋๋ฅผ ์ ์กฐ์ ํด์ค์ผ ์ฑ
๋ค์ ์์ฐ์ค๋ฌ์ด ๊ทธ๋ฆผ์๊ฐ ์๊ฒจ๋ ๊ฒ์ด๋ค. ์ด๋ฅผ ์ํด์ ํน์ ์ง์ ์์ ๋น์ ์๋ DirectionalLight
๋ฅผ ํ์ฉํ์๋ค.
const light = new THREE.DirectionalLight(0xffffff, 1);
light.position.set(-10, 10, 10);
light.castShadow = true;
scene.add(light);
๐คธโโ๏ธ ํ์ ์ํค๊ธฐ
์ด์ ์ฑ ์ ๋ง์ฐ์ค๋ฅผ ํธ๋ฒํ์ ๋ ์ฑ ์ด ์์ง์ด๋๋ก ํด๋ณด์. ๊ทผ๋ฐ ์๋ผ๋์ ์ฑ ์ด ์์ง์ด๋ ๊ฑธ ๋ณด๋ฉด ์ฑ ์ด ์์ง์์ ๋ฐ๋ผ ์ฐ์ธก์ ๋ณด์ด๋ ๊ทธ๋ฆผ์๊ฐ ๊ฐ์๊ธฐ ์ฌ๋ผ์ง๋ ๊ฒ์ ๋ณผ ์ ์๋ค. ์กฐ๋ช ์ด ์ข์๋จ์ ์๋ค๋ฉด ์ฑ ์ ๋๋ฆฐ๋ค๊ณ ๊ทธ๋ฆผ์๊ฐ ์ฌ๋ผ์ง๋ฉด ์๋๋๋ฐ ๋ญ๊ฐ ์ด์ํ๋ค. ๊ฐ์ ๊ตฌ๋๋ก ์ค์ ๋ก ์ฑ ๊ณผ ์กฐ๋ช ์ ์์น์ํค๊ณ ์ฑ ์ ๋๋ฆฌ๋ค๊ฐ ์ด๊ฒ ๋ถ๊ฐ๋ฅํ๋ค๋ ๊ฒ์ ์๊ฒ ๋์๋ค.
๊ทธ๋ฌ๋ค ์ฑ ์ ๋๋ฆฌ๋ ๊ฒ์ด ์๋๋ผ ๋ด ๋์ ์์น๋ฅผ ๋ฐ๊ฟ์ผํ๋ค๋ ๊ฒ์ ์๊ฒ ๋์๋ค. ์ด๋ฐ ๋ถ๋ถ์ 2D ์ธ๊ณ์์ ๊ณ ๋ฏผํด๋ณด์ง ๋ชปํ๋ ๊ฒ์ด๋ผ ์ ๊ธฐํ๋คใ ใ
๋จผ์ ๋ง์ฐ์ค๊ฐ ์ฌ๋ผ์์๋์ง ์ฌ๋ถ๋ฅผ ํ์ธํ๊ธฐ ์ํด eventListener
๋ฅผ ์ถ๊ฐํ๋ค.
let isMouseOver = false;
refCurrent.addEventListener('mouseover', () => {
isMouseOver = true;
});
refCurrent.addEventListener('mouseleave', () => {
isMouseOver = false;
});
๊ทธ๋ผ ๋ง์ฐ์ค๊ฐ ์์ ์์ผ๋ฉด ์ต๋ 135๋๊น์ง ์นด๋ฉ๋ผ๋ฅผ ํ์ ์ํค๊ณ ๋ง์ฐ์ค๊ฐ ๋ ๋๋ฉด ๋ค์ ์๋ ์์น๋ก ์์ํ ๋์์ค๋๋ก ๋ง๋ค์๋ค. ์ฌ๊ธฐ์ ์นด๋ฉ๋ผ์ ์์น๊ฐ ๋ฐ๋์ด๋ ๊ณ์ scene์ ๋ณด๊ณ ์๋๋ก lookAt ํจ์๋ฅผ ํ์ฉํ๋ค.
const animate = () => {
requestAnimationFrame(animate);
isMouseOver ? rotate() : rotateBack();
camera.lookAt(scene.position);
renderer.render(scene, camera);
};
์นด๋ฉ๋ผ์ ๋ฌผ์ฒด์ ๊ฑฐ๋ฆฌ๋ฅผ ์ ์งํ ์ํ๋ก ๊ฐ๋๋ง ๋ฐ๊พธ๊ธฐ ์ํด์ ์ค๋๋ง์ ์ผ๊ฐํจ์๋ฅผ ํ์ฉํ์ฌ x์ z ๊ฐ์ ๊ตฌํ๋ค.
์ฑ
๊ณผ์ ๊ฑฐ๋ฆฌ๋ distanceTo
ํจ์๋ฅผ ์ฌ์ฉํ๋ฉด ๊ฐ๋จํ๊ฒ ๊ตฌํ ์ ์์๋ค.
const distance = camera.position.distanceTo(cube.position);
const rotate = () => {
if (degrees < 135) {
degrees += 2;
const radian = degrees * (Math.PI / 180);
camera.position.x = Math.cos(radian) * distance;
camera.position.z = Math.sin(radian) * distance;
}
};
const rotateBack = () => {
if (degrees > 90) {
degrees -= 2;
const radian = degrees * (Math.PI / 180);
camera.position.x = Math.cos(radian) * distance;
camera.position.z = Math.sin(radian) * distance;
}
};
โก๏ธ์ต์ข ์ฝ๋
์ด๋ ๊ฒ ๊ตฌํํ 3d-book์ ์ต์ข ์ฝ๋๋ ๋ค์๊ณผ ๊ฐ๋ค!
import React, { useEffect, useRef } from 'react';
import * as THREE from 'three';
const Book = (props) => {
const mountRef = useRef(null);
useEffect(() => {
const { bookCovers, style } = props;
const aspect = style.width / style.height;
const getBookMaterials = (urlMap) => {
const materialNames = ['edge', 'spine', 'top', 'bottom', 'front', 'back'];
return materialNames.map((name) => {
if (!urlMap[name]) return new THREE.MeshBasicMaterial(0xffffff);
const texture = new THREE.TextureLoader().load(urlMap[name]);
// to create high quality texture
texture.generateMipmaps = false;
texture.minFilter = THREE.LinearFilter;
texture.needsUpdate = true;
return new THREE.MeshBasicMaterial({ map: texture });
});
};
const refCurrent = mountRef.current;
const scene = new THREE.Scene();
scene.background = new THREE.Color(style.background);
const planeGeometry = new THREE.PlaneGeometry(500, 500, 32, 32);
const planeMaterial = new THREE.ShadowMaterial();
planeMaterial.opacity = 0.5;
const plane = new THREE.Mesh(planeGeometry, planeMaterial);
plane.receiveShadow = true;
scene.add(plane);
const light = new THREE.DirectionalLight(0xffffff, 1);
light.position.set(-10, 10, 10);
light.castShadow = true;
scene.add(light);
const geometry = new THREE.BoxGeometry(3.5, 5, 0.5);
const cube = new THREE.Mesh(geometry, getBookMaterials(bookCovers));
cube.castShadow = true;
cube.position.set(0, 0, 0);
scene.add(cube);
let isMouseOver = false;
refCurrent.addEventListener('mouseover', () => {
isMouseOver = true;
});
refCurrent.addEventListener('mouseleave', () => {
isMouseOver = false;
});
let degrees = 90;
const camera = new THREE.PerspectiveCamera(70, aspect, 1, 1000);
camera.position.set(0, 0, 6);
const renderer = new THREE.WebGLRenderer();
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.setSize(style.width, style.height);
const distance = camera.position.distanceTo(cube.position);
const rotate = () => {
if (degrees < 135) {
degrees += 2;
const radian = degrees * (Math.PI / 180);
camera.position.x = Math.cos(radian) * distance;
camera.position.z = Math.sin(radian) * distance;
}
};
const rotateBack = () => {
if (degrees > 90) {
degrees -= 2;
const radian = degrees * (Math.PI / 180);
camera.position.x = Math.cos(radian) * distance;
camera.position.z = Math.sin(radian) * distance;
}
};
const animate = () => {
requestAnimationFrame(animate);
isMouseOver ? rotate() : rotateBack();
camera.lookAt(scene.position);
renderer.render(scene, camera);
};
refCurrent.appendChild(renderer.domElement);
animate();
return () => {
refCurrent.removeChild(renderer.domElement);
};
}, [props]);
return <div ref={mountRef} style={props.style} />;
};
export default Book;
๐คฉ ๋๋์
๋ญ๊ฐ ์๋ก์ ๋ค. ์ฌํ๊น์ง์ ๊ฐ๋ฐ์ ๋ชจ๋ํฐ๋ผ๋ ๋จ๋ฉด์์ ๋ณด์ฌ์ง๋ ์ธ์์ ๊ทธ๋ฆฌ๋ ๋๋์ด์๋ค๋ฉด ์ด๋ฒ ๊ฐ๋ฐ์ ๋ญ๊ฐ ์ธํธ์ฅ์ ๊พธ๋ฉฐ๋๊ณ ๋ณด์ฌ์ง ์ฅ๋ฉด์ ์ดฌ์ํ๋ ๋๋์ด์๋ค. ์ ์ฐ๋ ์ผ๊ฐํจ์๋ ์ข ์จ๋ณด๊ณ ์ค์ ๋ก ์ฑ ๋ ๋๋ ค๋ณด๋ฉด์ ๋ง๋ค์ด๋ด๋ค๋ณด๋ ๊ฐ๋ฐํ๋ค๋ณด๋ ์ฌ๋ฏธ๋ ์๊ณ ๋ณด๋๋ ์์๋ค.
์ด๊ฒ ์ด๋ป๊ฒ ์ฐ์ผ์ง ๋ชจ๋ฅด๊ฒ ์ง๋ง 3d๋ก ๋ง๋ค์ด ๋ณผ๋ง ํ ๊ฒ๋ค์ ์ข ๊ณ ๋ฏผํด๋ด์ผ๊ฒ ๋คใ ใ