Three.js Loading Assets

강정우·2025년 1월 7일
0

three.js

목록 보기
11/24
post-thumbnail

Loading Assets

아래 코드를 보면 모델과 텍스쳐가 따로 로드되었다.

// 텍스쳐
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)
})

사실 런타임에서 실제로 이미지를 로드하여 모델에 적용하는 경우는 매우 드물다.

보통은 블렌더에서 모델을 만들고 텍스처를 적용한다.
블렌더는 이런 목적에 더 특화되어있다.

image 로드 (feat. .hdr, .glb)

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

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 을 사용하면 된다.

ES6 import, export 사용하기

사실 이거는 엄청 돌아가는 방법이다.
일단 모듈 처럼 상단에 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 VS vite.config.ts

항목vite-env.d.tsvite.config.ts
역할환경 변수 타입 정의 및 TypeScript 보조Vite의 빌드 및 개발 환경 설정
내용환경 변수, import.meta.env 타입 정의플러그인, 경로 별칭, 서버 설정 등
위치루트 또는 src 디렉토리프로젝트 루트
파일 형식TypeScript 타입 선언 파일 (.d.ts)TypeScript 설정 파일 (.ts)
주요 사용처TypeScript 컴파일러개발자 및 빌드 시스템

vite-env.d.ts: Vite 환경 변수 타입을 정의해 TypeScript에서 올바르게 사용하도록 돕는 선언 파일.
vite.config.ts: Vite의 전반적인 개발 및 빌드 설정을 관리하는 핵심 설정 파일.

💻 Asset load e.g. 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'

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()

Loader

모든 Three.js 의 loader 는 THREE.Loader class 를 상속받아서 만들어 졌다.

THREE.Loader 베이스 클래스에는 로드된 개체와 보류 중인 개체 및 데이터를 추적하기 위한 기본 ‘THREE.LoadingManager’ 객체가 내부에 있다.

LoadingManager (onLoad, onProgress, onError)

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)가 발생했을 때 사용할 콜백 함수를 전달할 수 있다.

load()

다시 로더 얘기로 돌아와서 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)
})

LoadAsync

그리고 개발자는 같은 코드가 반복 되는 것을 병적으로 싫어하는 데 위 코드는 같은 코드가 네번이나 반복 되었다.
그리고 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/Await, Promise.All() & LoadAsync

그리고 코드를 더 깔끔하게 작성하고 자동차를 생성하는 함수를 만들어 여러 번 사용하겠다고 한다면 다음과 같이 작성하면 된다.

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)
  • 추가적으로 용량이 큰 배경들도 async 로 비동기적으로 로드하면 좋다.
    비동기 적인 장점들을 모두 가져갈 수 있다. 병렬로드, 메인 스레드가 로드 작업으로 차단되지 않으므로 UX을 개선등...
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
})

💻 Loader e.g. 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'

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 에서 받을 수 있다.

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

0개의 댓글