아래 코드를 보면 모델과 텍스쳐가 따로 로드되었다.
// 텍스쳐
const material = new THREE.MeshStandardMaterial()
material.map = new THREE.TextureLoader().load(image)
// 위에서 적용한 image 를 더 선명한 색으로 설정하는 코드
material.map.colorSpace = THREE.SRGBColorSpace
// 3D 모델
new GLTFLoader().load(model, (gltf) => {
gltf.scene.traverse((child) => {
(child as THREE.Mesh).material = material
})
scene.add(gltf.scene)
})
사실 런타임에서 실제로 이미지를 로드하여 모델에 적용하는 경우는 매우 드물다.
보통은 블렌더에서 모델을 만들고 텍스처를 적용한다.
블렌더는 이런 목적에 더 특화되어있다.
const hdr = 'warm_restaurant_night_4k.hdr'
const image = 'https://sbcode.net/img/grid.png'
const model = 'https://sbcode.net/models/suzanne_no_material.glb'
그동안 우리는 어떤 사이트에서 img 를 내려받아서 표출하거나 local에 저장하여 사용했다. 하지만 여기서 CDN 을 활용해보겠다.
왜냐하면 다른 사이트에서 asset을 내려받으려 해도 CORS 문제 때문에 당연히 다운이 안 되기 때문이다.
JSDELIVR 에 들어가면 상단 navi 바에 Tools 에 Github 를 확인할 수 있다. 그리고 github three.js 에 들어가서 gltf 폴더를 찾아서 원하는 모델을 받는다.
const hdr = 'https://cdn.jsdelivr.net/gh/mrdoob/three.js@dev/examples/textures/equirectangular/venice_sunset_1k.hdr'
const image = 'https://cdn.jsdelivr.net/gh/mrdoob/three.js@dev/examples/textures/uv_grid_opengl.jpg'
const model = 'https://cdn.jsdelivr.net/gh/mrdoob/three.js@dev/examples/models/gltf/Xbot.glb'
그리고 위와 같이 작성하여 해당 asset 을 사용하면 된다.
사실 이거는 엄청 돌아가는 방법이다.
일단 모듈 처럼 상단에 asset 들을 import 해주고
import hdr from './img/venice_sunset_1k.hdr'
import image from './img/grid.png'
import model from './models/suzanne_no_material.glb'
index.d.ts
설정 파일을 만들어 .hdr
.png
.glb
도 모듈처럼 import 하라고 일러줄 수 있다.
// index.d.ts 혹은 vite-env.d.ts
declare module '*.png'
declare module '*.hdr'
declare module '*.glb'
하지만 그래도 실행하면 아래와 같은 에러코드를 만나는데 보면 소스를 파싱해오는데 JS 문법과 맞지 않은 컨텐츠를 포함하고 있어 로드할 수 없다고 뜬다. 따라서 vite.config.js
를 프로젝트 root 위치에 생성 후
import { defineConfig } from 'vite'
export default defineConfig({
assetsInclude: ['**/*.hdr', '**/*.glb'],
})
이렇게 .hdr
을 핸들하겠다는 설정을 넣어주고 개발 서버를 재시작 하면 아래와 같이 로그가 뜨면서 다시 정상적으로 동작한다.
항목 | vite-env.d.ts | vite.config.ts |
---|---|---|
역할 | 환경 변수 타입 정의 및 TypeScript 보조 | Vite의 빌드 및 개발 환경 설정 |
내용 | 환경 변수, import.meta.env 타입 정의 | 플러그인, 경로 별칭, 서버 설정 등 |
위치 | 루트 또는 src 디렉토리 | 프로젝트 루트 |
파일 형식 | TypeScript 타입 선언 파일 (.d.ts) | TypeScript 설정 파일 (.ts) |
주요 사용처 | TypeScript 컴파일러 | 개발자 및 빌드 시스템 |
vite-env.d.ts: Vite 환경 변수 타입을 정의해 TypeScript에서 올바르게 사용하도록 돕는 선언 파일.
vite.config.ts: Vite의 전반적인 개발 및 빌드 설정을 관리하는 핵심 설정 파일.
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'
const scene = new THREE.Scene()
const hdr = 'warm_restaurant_night_4k.hdr'
const image = 'img/grid.png'
const model = 'models/suzanne_no_material.glb'
// const model = 'https://cdn.jsdelivr.net/gh/mrdoob/three.js@dev/examples/models/gltf/Flower/Flower.glb'
new RGBELoader().load(hdr, (texture) => {
texture.mapping = THREE.EquirectangularReflectionMapping
scene.environment = texture
scene.background = texture
})
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 100)
camera.position.set(-2, 0.5, 2)
const renderer = new THREE.WebGLRenderer({ antialias: true })
renderer.toneMapping = THREE.ACESFilmicToneMapping
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
const material = new THREE.MeshStandardMaterial()
material.map = new THREE.TextureLoader().load(image)
material.map.colorSpace = THREE.SRGBColorSpace
const plane = new THREE.Mesh(new THREE.PlaneGeometry(10, 10), material)
plane.rotation.x = -Math.PI / 2
plane.position.y = -1
scene.add(plane)
new GLTFLoader().load(model, (gltf) => {
gltf.scene.traverse((child) => {
(child as THREE.Mesh).material = material
})
scene.add(gltf.scene)
})
const stats = new Stats()
document.body.appendChild(stats.dom)
function animate() {
requestAnimationFrame(animate)
controls.update()
renderer.render(scene, camera)
stats.update()
}
animate()
모든 Three.js 의 loader 는 THREE.Loader class 를 상속받아서 만들어 졌다.
THREE.Loader 베이스 클래스에는 로드된 개체와 보류 중인 개체 및 데이터를 추적하기 위한 기본 ‘THREE.LoadingManager’ 객체가 내부에 있다.
LoadingManager
는 리소스가 로딩을 시작한 시점, 로딩이 완료된 시점, 다운로드 진행 상황(대부분의 브라우저에서), 리소스 다운로드 시도 중 오류가 발생했는지 여부를 파악한다.
new GLTFLoader().load(
'model.glb',
// onLoad
(gltf) => {
scene.add(gltf.scene)
},
// onProgress (Optional)
(xhr) => {
console.log((xhr.loaded / xhr.total) * 100 + '% loaded')
},
// onError (Optional)
(err) => {
console.error('Error : ' + err)
}
)
GLTFLoader, OBJLoader, TextureLoader 등 로더를 인스턴스화할 때 리소스가 로드 중(onLoad), 다운로드 중(onProgress), 다운로드 중 오류(onError)가 발생했을 때 사용할 콜백 함수를 전달할 수 있다.
다시 로더 얘기로 돌아와서 GLTF 와 GLB 는 나중에 얘기하고 일단 3D 모델링을 로드하는 로더를 작성하면 다음과 같이 작성할 수 있다.
그리고 가장 중요한 것은 async 즉, 비동기 함수이다. 따라서 아래와 같이 작업하면
const loader = new GLTFLoader()
let car: THREE.Object3D;
loader.load('models/suv_body.glb', (gltf) => {
console.log("body load Done");
scene.add(gltf.scene)
})
loader.load('models/suv_wheel.glb', (gltf) => {
gltf.scene.position.set(-0.65, 0.2, -0.77)
console.log("front wheel 1 load Done");
car.add(gltf.scene)
})
loader.load('models/suv_wheel.glb', (gltf) => {
gltf.scene.position.set(0.65, 0.2, -0.77)
gltf.scene.rotateY(Math.PI)
console.log("front wheel 2 load Done");
car.add(gltf.scene)
})
loader.load('models/suv_wheel.glb', (gltf) => {
gltf.scene.position.set(-0.65, 0.2, 0.57)
console.log("back wheel 1 load Done");
car.add(gltf.scene)
})
loader.load('models/suv_wheel.glb', (gltf) => {
gltf.scene.position.set(0.65, 0.2, 0.57)
gltf.scene.rotateY(Math.PI)
console.log("back wheel 2 load Done");
car.add(gltf.scene)
})
보면 loader 객체를 하나를 선언하고 .load()
함수로 계속하여 gltf 모델을 추가하고 있다.
그런데 이 동작은 비동기로 진행된다. 즉, 용량이 큰 body 의 경우는 나중에 로드된다는 뜻이다.
그리고 어떤 경우는 car 에 종속성을 이용하여 car 에 바퀴를 붙이면 car 가 정의 되어있지 않다고 crash 를 낼 때도 있다.
이를 방지하기위해 앞서 언급한 onLoad 라이프 사이클을 사용하면 된다.
const loader = new GLTFLoader()
let car: THREE.Object3D;
loader.load('models/suv_body.glb', (gltf) => {
car = gltf.scene;
loader.load('models/suv_wheel.glb', (gltf) => {
gltf.scene.position.set(-0.65, 0.2, -0.77)
car.add(gltf.scene)
})
loader.load('models/suv_wheel.glb', (gltf) => {
gltf.scene.position.set(0.65, 0.2, -0.77)
gltf.scene.rotateY(Math.PI)
car.add(gltf.scene)
})
loader.load('models/suv_wheel.glb', (gltf) => {
gltf.scene.position.set(-0.65, 0.2, 0.57)
car.add(gltf.scene)
})
loader.load('models/suv_wheel.glb', (gltf) => {
gltf.scene.position.set(0.65, 0.2, 0.57)
gltf.scene.rotateY(Math.PI)
car.add(gltf.scene)
})
scene.add(gltf.scene)
})
그리고 개발자는 같은 코드가 반복 되는 것을 병적으로 싫어하는 데 위 코드는 같은 코드가 네번이나 반복 되었다.
그리고 onLoad 의 콜백을 사용하지 않고 깔끔하게 코드를 나누며 비동기로 처리하고 싶다면,
await
키워드와 load() => loadAsync()
함수를 사용하면 된다.
const loader = new GLTFLoader()
let suvBody: THREE.Object3D
await loader.loadAsync('models/suv_body.glb').then((gltf) => {
suvBody = gltf.scene
})
loader.load('models/suv_wheel.glb', function (gltf) {
const wheels = [gltf.scene, gltf.scene.clone(), gltf.scene.clone(), gltf.scene.clone()]
wheels[0].position.set(-0.65, 0.2, -0.77)
wheels[1].position.set(0.65, 0.2, -0.77)
wheels[1].rotateY(Math.PI)
wheels[2].position.set(-0.65, 0.2, 0.57)
wheels[3].position.set(0.65, 0.2, 0.57)
wheels[3].rotateY(Math.PI)
suvBody.add(...wheels)
scene.add(suvBody)
})
그리고 코드를 더 깔끔하게 작성하고 자동차를 생성하는 함수를 만들어 여러 번 사용하겠다고 한다면 다음과 같이 작성하면 된다.
async function loadCar(x: number = 0, y: number = 0, z: number = 0) {
const loader = new GLTFLoader()
const [...model] = await Promise.all([loader.loadAsync('models/suv_body.glb'), loader.loadAsync('models/suv_wheel.glb')])
const suvBody = model[0].scene
const wheels = [model[1].scene, model[1].scene.clone(), model[1].scene.clone(), model[1].scene.clone()]
wheels[0].position.set(-0.65, 0.2, -0.77)
wheels[1].position.set(0.65, 0.2, -0.77)
wheels[1].rotateY(Math.PI)
wheels[2].position.set(-0.65, 0.2, 0.57)
wheels[3].position.set(0.65, 0.2, 0.57)
wheels[3].rotateY(Math.PI)
suvBody.add(...wheels)
suvBody.position.set(x, y, z);
scene.add(suvBody)
}
await loadCar()
await loadCar(-2, 0, -2)
await loadCar(2, 0, 2)
await loadCar(-2, 0, 2)
await loadCar(2, 0, -2)
new RGBELoader().load('img/warm_restaurant_night_4k.hdr', (texture) => {
texture.mapping = THREE.EquirectangularReflectionMapping
scene.environment = texture
scene.background = texture
scene.backgroundBlurriness = 1.0
})
// to
await new RGBELoader().loadAsync('img/warm_restaurant_night_4k.hdr').then((texture) => {
texture.mapping = THREE.EquirectangularReflectionMapping
scene.environment = texture
scene.background = texture
scene.backgroundBlurriness = 1.0
})
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'
const scene = new THREE.Scene()
new RGBELoader().load('img/warm_restaurant_night_4k.hdr', (texture) => {
texture.mapping = THREE.EquirectangularReflectionMapping
scene.environment = texture
scene.background = texture
scene.backgroundBlurriness = 1.0
})
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 100)
camera.position.set(2, 1, -2)
const renderer = new THREE.WebGLRenderer({antialias: true})
renderer.toneMapping = THREE.ACESFilmicToneMapping
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.target.y = 0.75
controls.enableDamping = true
async function loadCar(x: number = 0, y: number = 0, z: number = 0) {
const loader = new GLTFLoader()
const [...model] = await Promise.all([loader.loadAsync('models/suv_body.glb'), loader.loadAsync('models/suv_wheel.glb')])
const suvBody = model[0].scene
const wheels = [model[1].scene, model[1].scene.clone(), model[1].scene.clone(), model[1].scene.clone()]
wheels[0].position.set(-0.65, 0.2, -0.77)
wheels[1].position.set(0.65, 0.2, -0.77)
wheels[1].rotateY(Math.PI)
wheels[2].position.set(-0.65, 0.2, 0.57)
wheels[3].position.set(0.65, 0.2, 0.57)
wheels[3].rotateY(Math.PI)
suvBody.add(...wheels)
suvBody.position.set(x, y, z);
scene.add(suvBody)
}
await loadCar()
const stats = new Stats()
document.body.appendChild(stats.dom)
function animate() {
requestAnimationFrame(animate)
controls.update()
renderer.render(scene, camera)
stats.update()
}
animate()
무료 모델링은 Kenney 에서 받을 수 있다.