🙌🏻 해당 글은 Three.js Journey의 강의 노트입니다.
Texture는 쉽게 말해, geometries의 표면을 감싸는 이미지이다. 그러나 ThreeJS에서의 Texture는 단순히 색상이나 무늬를 의미하는 것은 아니다. ThreeJS는 다양한 타입의 Texture를 제공한다. 우리는 Texture를 이용하기 위해 João Paulo
의 Door Texture를 활용해볼 것이다.
이러한 질감(특히 금속성 및 거칠기)은 우리가 PBR 원칙이라고 부르는 것을 따릅니다. PBR은 물리적 기반 렌더링을 의미합니다. 실제 지침을 따르는 경향이 있는 많은 기술을 재그룹화하여 현실적인 결과를 얻습니다.
다른 많은 기술이 있지만 PBR은 사실적인 렌더링의 표준이 되고 있으며 많은 소프트웨어, 엔진 및 라이브러리에서 이를 사용하고 있습니다.
지금은 텍스처를 로드하는 방법, 텍스처를 사용하는 방법, 적용할 수 있는 변환 및 최적화 방법에 중점을 둘 것입니다. PBR에 대한 자세한 내용은 이후 강의에서 다루겠지만, 궁금한 점이 있으면 여기에서 자세히 알아볼 수 있습니다.
-https://marmoset.co/posts/basic-theory-of-physically-based-rendering/
/src/
폴더 활용하기 (JS 모듈처럼 임포트해서 사용)import imageSource from './image.png'
console.log(imageSource)
/static/
폴더 활용하기const imageSource = '/image.png'
console.log(imageSource)
2번의 경우 현재 Webpack을 사용하고 있기 때문에 가능한 문법이기 때문에, 다른 방식으로 ThreeJS를 활용하고 있다면 1번의 경우처럼 이미지를 임포트하는 것을 추천한다.
이제 가져온 이미지를 로드할 차례이다.
이미지를 로드하는 것 역시 여러 방법을 활용할 수 있다.
먼저 이미지를 로드한다. 하지만 이미지를 바로 활용할 수는 없다. 우리는 먼저 Texture를 만들고 Texture를 이용해 이미지를 적용해주어야 한다. 왜냐하면 WebGL은 GPU가 접근할 수 있는 아주 구체적인 포맷을 요구하기 때문이다.
그리고 나서 해당 텍스쳐를 머테리얼에서 사용해준다. 그러나 아쉽게도 우리는 이벤트리스너에 넘겨준 콜백함 수 안에서 텍스쳐를 선언했으므로 외부에서 해당 텍스쳐에 접근할 수 없다. 하여, 텍스쳐를 밖에서 선언해주고, 이미지가 로드됨에 따라 텍스쳐의 needsUpdate
속성을 변경해준다. 여기까지만 해서는 실제로 텍스쳐가 적용된 것을 확인할 수 없다. 우리는 material의 MeshBasicMaterial의 인자로 { map: texture }
를 넘겨주어야 한다.
const image = new Image()
const texture = new THREE.Texture(image)
image.addEventListener('load', () =>
{
console.log('image loaded!')
texture.needsUpdate = true
})
image.src = '/textures/door/color.jpg'
const geometry = new THREE.BoxGeometry(1, 1, 1)
const material = new THREE.MeshBasicMaterial({ map: texture })
const mesh = new THREE.Mesh(geometry, material)
scene.add(mesh)
native JS도 엄청 복잡한 것은 아니지만, 좀 더 직관적인 방법이 존재한다. TextureLoader를 활용하는 것이다. 게다가 한 번 TextureLoader를 인스턴스화하면 몇 개의 텍스쳐든 로드할 수 있다. textureLoader의 load()
메소드에는 첫번째 인자로 로드할 이미지 파일의 경로, 그리고 연달아 총 세 개의 콜백함수를 넘길 수 있는데, 각각은 아래와 같은 역할을 한다. 에러를 추적하기에 좋다.
load
: when the image loaded successfullyprogress
: when the loading is progressingerror
: if something went wrongconst textureLoader = new THREE.TextureLoader()
const texture = textureLoader.load(
'/textures/door/color.jpg',
() =>
{
console.log('loading finished')
},
() =>
{
console.log('loading progressing')
},
() =>
{
console.log('loading error')
}
)
여러 장의 이미지를 로드하고 관리해야 할 때는, loadingManager를 활용하면 좋다. loadingManager에는 onStart, onLoad, onProgress, onError 각각에 콜백함수를 미리 넘겨줄 수 있다. 아래에서 콘솔을 확인해보면, 첫 이미지 로딩이 시작될 때, 모든 이미지의 로딩이 마무리되었을 때 각각을 감지해낼 수 있음을 알 수 있다.
const loadingManager = new THREE.LoadingManager()
loadingManager.onStart = () =>
{
console.log('loading started')
}
loadingManager.onLoad = () =>
{
console.log('loading finished')
}
loadingManager.onProgress = () =>
{
console.log('loading progressing')
}
loadingManager.onError = () =>
{
console.log('loading error')
}
const textureLoader = new THREE.TextureLoader(loadingManager)
const colorTexture = textureLoader.load('/textures/door/color.jpg')
const alphaTexture = textureLoader.load('/textures/door/alpha.jpg')
const heightTexture = textureLoader.load('/textures/door/height.jpg')
const normalTexture = textureLoader.load('/textures/door/normal.jpg')
const ambientOcclusionTexture = textureLoader.load('/textures/door/ambientOcclusion.jpg')
const metalnessTexture = textureLoader.load('/textures/door/metalness.jpg')
const roughnessTexture = textureLoader.load('/textures/door/roughness.jpg')
이제 이런 생각이 들 수 있다. 큐브가 아닌 다른 geometry에 텍스쳐를 씌운다면 어떻게 될까?
다음과 같이 시도해보자!
const geometry = new THREE.SphereGeometry(1, 32, 32)
const geometry = new THREE.ConeGeometry(1, 1, 32)
const geometry = new THREE.TorusGeometry(1, 0.35, 32, 100)
각각의 geometry에 따라 서로 다른 모습으로 텍스쳐가 쪼그라들거나 늘려진 모습을 살펴볼 수 있다. 여기서 UV unwrapping이라는 개념이 등장한다. 이는 마치 종이접기를 통해 완성된 입체 모형을 평면으로 펼치는 것과 같다.
geometry.attributes.uv
속성을 활용하면 우리는 UV의 2D 좌표를 살펴볼 수 있다. 보통 ThreeJS에 내장된 Geometry의 경우에는 UV 좌표를 제공해주지만, 직접 Geometry를 만든 경우 이 UV 좌표를 지정해주어야 한다. 3D 소프트웨어를 통해 Geometry를 만드는 경우에도 마찬가지이다. (큰 걱정을 할 필요는 없는데, 사실 대부분의 3D 소프트웨어들은 UV unwrapping을 자동으로 수행해준다.)
일단 다시 큐브 geometry로 돌아와서 이번에는 texture를 어떻게 변형할 수 있는지 살펴보자!
텍스쳐를 반복하기 위해서는 repeat
프로퍼티를 활용하게 되는데 기본적으로 해당 프로퍼티의 값은 Vector2
를 상속받고 있다. 즉, x, y 값을 갖는다는 것!
const colorTexture = textureLoader.load('/textures/door/color.jpg')
colorTexture.repeat.x = 2
colorTexture.repeat.y = 3
// (1)
// colorTexture.wrapS = THREE.RepeatWrapping
// colorTexture.wrapT = THREE.RepeatWrapping
// (2)
// colorTexture.wrapS = THREE.MirroredRepeatWrapping
// colorTexture.wrapT = THREE.MirroredRepeatWrapping
위 이미지들은 차례로, 위 코드에서 (1)과 (2)가 활성화되지 않은 경우, (1)만 활성화한 경우, (2)를 활성화한 경우에 해당한다. 기본적으로 texture는 반복되도록 기본설정이 되어있지 않다! 때문에 우리는 (1)과 (2)와 같이 반복 속성을 수동으로 설정해주어야 함을 알 수 있고, 이 반복의 방향 역시 따로 설정해줄 수 있음을 알 수 있다.
offset은 한국말로 번역하기가 쉽지 않은데, 아래 영영 사전 정의를 참고하자!
to create an equal balance between two things
offset이란 말보다는 실제 어떻게 텍스쳐가 렌더링되는지를 살펴보면 더 이해가 쉬울 것이다.
offset
이란 속성을 활용하고 이 역시 Vector2이다.
colorTexture.offset.x = 0.5
colorTexture.offset.y = 0.5
Geometry의 모서리와 꼭짓점을 살펴보면 Texture가 (비교적 자연스럽게) 연결되어 렌더링된 모습을 살펴볼 수 있다.
colorTexture.rotation = Math.PI * 0.25
// 회전 축 설정하기
// colorTexture.center.x = 0.5
// colorTexture.center.y = 0.5
repeat 속성과 offset 속성에 주석처리를 하면, 아래 왼쪽 이미지 처럼 텍스쳐가 렌더되는 것을 확인할 수 있는데, repeat 속성을 활용할 때는 회전의 축 역시도 설정해줄 수 있다. 설정해주고나면 오른쪽 이미지와 같이 텍스쳐를 회전시킬 수도 있다.
위 gif를 살펴보자. 큐브의 윗면이 비스듬히 보일 때, 텍스쳐가 블러처리되어 보이는 것을 알 수 있을 것이다. 이게 바로 필터링과 밉맵핑 때문이다!
밉맵핑은 다시 말해 1x1 텍스쳐를 얻을 때까지, 계속해서 더 작은 버전의 텍스쳐를 생성하는 기술이다. 이때 생성되는 모든 텍스쳐의 변형이 GPU로 전송되고, GPU는 가장 적합한 버전의 텍스쳐를 선택하게 된다. 사실 밉맵핑은 따로 거창한 코드를 적어주지 않아도 ThreeJS와 GPU가 알아서 처리하고 있으므로 크게 신정쓰지 않아도 되는데, 우리는 다만 사용할 필터 알고리즘을 직접 선택할 수 있다. 다양한 옵션이 있는 것은 아니고, 축소와 확대의 옵션이 있다.
minification filter는 텍스쳐의 픽셀이 렌더할 픽셀보다 작을 때 사용한다. 다른말로 텍스쳐가 geometry의 표면에 비해서 너무 클 때! minFilter 속성을 사용하면, 텍스쳐의 축소필터를 변경할 수 있다. 다음 여섯 가지의 값이 존재한다.
이 중 THREE.NearestFilter
를 시험해보자!
colorTexture.minFilter = THREE.NearestFilter
아래 쪽이 THREE.NearestFilter
가 적용된 모습인데 아주 다른 결과를 볼 수 있다.
물론 pixel ratio가 1보다 큰 경우에는 차이를 명확히 알기 힘들 수 있다.
더 명확한 차이를 살펴보기 위해 조금 다른 texture로 실험해보자!
이 역시 아래쪽이 THREE.NearestFilter
가 적용된 모습.
Magnification filter는 Minification filter와 반대로, 텍스쳐의 픽셀이 렌더할 픽셀보다 클 때 사용한다. 그러니까 Geometry의 표면에 배해 텍스쳐가 너무 작을 때 말이다. 이 이미지를 활용해볼 것이다!
magFilter
에는 두 가지 옵션이 있는데, 아래 두 필터가 그 두 옵션이다.
우선 아래 왼쪽 이미지와 같이 전면이 블러처리된 것을 볼 수 있다.
아래 이미지가 각각의 필터를 적용한 모습이다. 블러처리된 왼쪽 이미지가 쓸모없다고 생각할 수 있겠지만, 사실 마인크래프트와 같은 픽셀 중심의 텍스쳐가 아니라면, 왼쪽
(이 부분 이해가 좀 힘듬)
마지막으로 알아둘 것은 THREE.NearestFilter
는 다른 필터보다 cheap하기 때문에, 더 나은 퍼포먼스를 기대할 수 있다는 점이다. 또, minFilter
프로퍼티로만 밉맵핑을 활용하는 것이 좋다. 기본적으로, THREE.NearestFilter
를 사용하면 밉맵핑이 필요없다. 대신 colorTexture.generateMipmaps = false
를 활용하면 밉맵핑을 deactivate할 수 있다. GPU의 부하를 줄일 수 있는 방법이다.
colorTexture.generateMipmaps = false
colorTexture.minFilter = THREE.NearestFilter
텍스쳐를 준비할 때 세 가지를 염두에 두어야 한다!
당연한 이야기이지만, 당신의 웹사이트에 방문한 유저들은 텍스쳐들을 다운로드해야 한다. acceptable한 유형의 이미지 파일을 적용하되, 가능한 가볍게 파일들을 유지하는 것이 중요하다. TinyPNG와 같은 압축 웹사이트나 소프트웨어를 활용하는 것도 방법이다. jpg나 png 모두 활용하가능하다. (webp나 avif는?)
기본적으로 텍스쳐의 모든 픽셀은 GPU에 저장되어야 한다. 하드드라이브와 마찬가지로 GPU에도 스토리지 제한이 있다. 자동생성된 밉맵핑이 저장해야 하는 픽셀수를 증가시키기떄문에 가급적 이미지의 크기를 줄이는 것이 좋다. ThreeJS는 밉맵핑을 시도하며 1x1 텍스쳐를 얻을 때까지 반복적으로 절반의 작은 버전의 텍스처를 생성한다. 때문에 텍스처 너비와 높이는 2의 거듭제곱이어야 한다. 2의 거듭제곱이 아닌 경우 ThreeJS는 가장 근접한 2의 거듭제곱 수로 이미지를 늘리려고 시도할 것이므로 좋지 않은 결과를 얻을 수 있다.
위에서 다루지 않았지만, 텍스쳐는 투명도를 지원한다. jpg 파일에는 알파채널이 없기 때문에 png를 활용하는 것이 좋다.
이렇게 방대한 내용을 다루었지만 사실 가장 어려운 것은 완벽한 텍스처를 찾는 일이다. 다양한 웹사이트가 있다.
저작권에 주의해서 사용해야 한다. 물론 포토샵이나 2D 소프트웨어, Substance Designer 등을 사용해 자신만의 커스텀 텍스쳐를 생성할 수도 있다.