4주차는 다뤄야할 내용이 많았다. 재질과 텍스쳐, OrbitControls를 사용한 카메라 이동 및 회전 구현, 마우스와 키보드 입력처리, 불필요한 렌더링 방지였는 데, 공식 문서의 설명과 예제 코드를 다 보다보니 카메라 파트에 다른 파트의 코드들을 얹어보면 될 것 같아, 그렇게 정리해보았다. 마우스와 키보드 입력처리는 녹여내기엔 애매하기도 하고, 다른 주차나 프로젝트에서 한번쯤 다루게 될 것 같아 제외했다.
OrbitControls
따로 모듈을 불러와야한다. 특정 좌표(controls.target)를 중심으로 공전하는 것처럼 카메라를 이동해준다. 마우스를 드래그하면 공전하는 움직임을 확인할 수 있다. 휠을 올리거나 내리면 좌표로부터의 거리가 가까워지거나 멀어진다. enableDamping 을 true 로 설정하면 카메라의 움직임에 따라 부드럽게 렌더링된다.
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
// 특정 좌표를 중심으로 카메라를 자전 또는 공전(orbit)
const controls = new OrbitControls(camera, canvas);
controls.enableDamping = true; // 부드러운 동작
controls.target.set(0, 5, 0); // 아래 큐브와 구의 중심지점을 기준으로 카메라가 움직임.
controls.update();
카메라 설정 GUI 로 조정하기
GUI도 따로 모듈을 불러와야한다. GUI 로 조정할 카메라 설정 값들을 라벨과 함께 넘겨주고, onChange 메소드에 GUI 상에서 변경될 시 호출할 메소드를 넘긴다. 메소드를 넘겨주지 않으면, 값을 GUI에서 조정해도 화면 상에 아무런 변화가 발생하지 않는다. 따로 함수를 정의할 필요가 없다면 화살표 함수로 넘겨주면 된다.
import { GUI } from 'three/addons/libs/lil-gui.module.min.js';
// MinMaxGUIHelper 생략
function updateByUI() {
camera.updateProjectionMatrix()
render()
}
const gui = new GUI();
const minMaxGUIHelper = new MinMaxGUIHelper(camera, 'near', 'far', 0.1);
gui.add(camera, 'fov', 1, 180).onChange(updateByUI);
gui.add(minMaxGUIHelper, 'min', 0.1, 50, 0.1).name('near').onChange(updateByUI);
gui.add(minMaxGUIHelper, 'max', 0.1, 100, 0.1).name('far').onChange(updateByUI);
// gui.add(minMaxGUIHelper, 'max', 0.1, 50, 0.1).name('far').onChange(() => {
// camera.updateProjectionMatrix()
//});
카메라의 updateProjectionMatrix 메서드가 궁금해서 지피티에게 열심히 물어봤다. 요약하자면 원근감을 표현하는 PerspectiveCamera에서 투시에 사용하는 설정 값(fov, far, near, aspect)을 매트릭스로 가지고 있는 데, 이 설정 값 중 하나라도 변경하게되면 매트릭스에 업데이트를 해줘야, 매트릭스에 반영되어서 투시된 화면이 제대로 렌더링 될 수 있다는 것이다.
바닥(plane)을 재질과 텍스쳐로 표현하기
체크무늬 바닥을 표현하기 위해 이미지 파일을 불러와 텍스쳐를 설정해준다. 이때 colorSpace는 텍스처에 적용되는 색상 공간(텍스처의 색상을 어떻게 해석하고 렌더링할지 결정)을 정의하는 속성으로, THREE.SRGBColorSpace 또는 THREE.LinearSRGBColorSpace 로 설정해줄 수 있다. 전자는 이미지 텍스처를 텍스처로 로드할 때 주로 사용하며, 조명 등에 영향받지 않고 정확하게 렌더링할 수 있다. 후자는 3D 렌더링과 조명 계산에서 사용하는 색상 공간이다. 조명 계산이 올바르게 수행되기 위해선, 텍스처가 선형 공간에 있어야하는 데 LinearSRGBColorSpace는 선형적이고, SRGBColorSpace는 비선형적이라고 한다. 예제에서는 조명에 바닥이 영향 받는 게 더 자연스러워 SRGBColorSpace를 코멘트 처리해둔 것 같았다.
Primitive 전시회에서 나를 힘들게 했던 DoubleSide 설정이 코드에 있길래 한번 짚고 넘어갔다. *^^* 후. 예제대로 하면 바닥 평면이 위 아래면 모두 렌더링이 되어있지만, 빛이 윗면에만 비추어 아랫면을 바라보도록 카메라를 움직이면 깜깜하다. 만약 DoubleSide 가 아니라면, 구와 정육면체의 밑바닥이 보이게된다. 아랫면이 제대로 보이는 지 보고 싶다면 AmbientLight 를 추가해보면 된다. 한편 예시코드에선 바닥면을 x 축을 기준으로 -90도 회전했다. - 는 축을 기준으로 시계반대방향이다. -90도로 회전해야 앞면이 위를 바라보는 데, 사실 DoubleSide 설정을 해두었고, 바닥 평면에 영향받을 자식 객체들이 없기 때문에 + 90 도로 회전해도 별 상관 없다. 만약 DoubleSide 설정을 없애고 +90도로 회전하면 바닥(plane)이 갑자기 사라지게 된다.
const planeSize = 40;
const loader = new THREE.TextureLoader();
const texture = loader.load('https://threejs.org/manual/examples/resources/images/checker.png');
texture.wrapS = THREE.RepeatWrapping; // 수평래핑 : 텍스쳐 반복
texture.wrapT = THREE.RepeatWrapping; // 수직래핑 : 텍스쳐 반복
texture.magFilter = THREE.NearestFilter;
texture.colorSpace = THREE.LinearSRGBColorSpace
const repeats = planeSize / 2;
texture.repeat.set(repeats, repeats); // 텍스처에 사용되는 이미지에서 칸하나를 1*1로 잡기위해.
const planeGeo = new THREE.PlaneGeometry(planeSize, planeSize);
const planeMat = new THREE.MeshPhongMaterial({
map: texture,
side: THREE.DoubleSide, // 카메라를 뒤집어보면 아무것도 안보이는데 조명이 없어서 그렇게 보임.
});
// scene.add(new THREE.AmbientLight()); // 평면 반대편 확인용
const mesh = new THREE.Mesh(planeGeo, planeMat);
mesh.rotation.x = Math.PI * - .5;
scene.add(mesh);
Materials 과 Textures
아래 사진에서 첫번째 열에 나열된 객체들의 재질들의 순서는 코드에 나열된 재질들의 순서와 같다. 개인적으로 Lambert 와 Phong 의 차이가 그리 크게 느껴지지 않아 웬만하면 Lambert 를 사용하는게 효율적으로 보인다. Toon 이나 MeshNormal 은 정말 특수한 목적으로 사용될 것 같다. Standard 와 Physical 은 개인적으로 느끼기에 가장 차이점을 느낄 만한 설정 값으로 렌더링 해보았다.
...
new THREE.MeshLambertMaterial({ color: color, flatShading: true }), // 정점에서만 광원계산
new THREE.MeshPhongMaterial({ color: color, flatShading: true, shininess: 150 }), // 픽셀하나하나 광원 계산, shininess 반사점의 밝기 조절
new THREE.MeshToonMaterial({ color: color }),
new THREE.MeshStandardMaterial({ color: color, roughness: 0.2, metalness: 0.2 }),
new THREE.MeshStandardMaterial({ color: color, roughness: 1, metalness: 1 }),
new THREE.MeshStandardMaterial({ color: color, roughness: 0.5, metalness: 0.5 }),
new THREE.MeshPhysicalMaterial({ color: color, roughness: 0.5, metalness: 0.5, clearcoat: 1, clearcoatRoughness: 0.1 }),
new THREE.MeshNormalMaterial(),
...
한편 이미지를 로드해 텍스쳐로 만든 경우에도, Material 의 설정을 따라감을 알 수 있었다. 그러니까 꽃그림이라도 Phong 으로 설정해 조명의 영향을 받게 할 수 있다. 직육면체의 Material을 배열로 넘겨줄 경우 6개를 모두 넘겨줘야한다. 그렇지 않으면 없는 만큼 면이 투명해진다 ㅎ
const loader = new THREE.TextureLoader();
const cubeSize = 4;
const cubeGeo = new THREE.BoxGeometry(cubeSize, cubeSize, cubeSize);
const materials = [
new THREE.MeshPhongMaterial({ map: loadColorTexture(require('../assets/flower-1.jpg')) }),
new THREE.MeshPhongMaterial({ map: loadColorTexture(require('../assets/flower-2.jpg')) }),
new THREE.MeshPhongMaterial({ map: loadColorTexture(require('../assets/flower-3.jpg')) }),
new THREE.MeshBasicMaterial({ map: loadColorTexture(require('../assets/flower-4.jpg')) }),
new THREE.MeshBasicMaterial({ map: loadColorTexture(require('../assets/flower-5.jpg')) }),
new THREE.MeshBasicMaterial({ map: loadColorTexture(require('../assets/flower-6.jpg')) }),
];
const cube = new THREE.Mesh(cubeGeo, materials);
cube.position.set(0,cubeSize,-10)
scene.add(cube)
function loadColorTexture(path) {
const texture = loader.load(path);
texture.colorSpace = THREE.SRGBColorSpace;
return texture;
}
}
+ 이미지가 로딩된 이후 텍스처에 매핑해주기 때문에, 최초의 렌더링에서 꽃 큐브가 까맣게 보인다. 처음에는 그림자진 것 인가..? 했는 데 조명이 있거나 없거나 똑같았다. 공식문서와 지피티에게 물어보니 material 을 업데이트한 후에는 needsUpdate 설정을 해줘야 반영된다고 하는 데 어쩌면 그래서 그런가 싶다.
++ 실험해보니 needsUpdate 설정보다는, 텍스처 로드가 완료되었을 때, 메쉬를 씬에 추가하고 렌더함수를 다시 호출해 그리면 해결 할 수 있다. 씬에 추가된 시점에서 텍스처가 로드되지않다면 까맣게 보이는 데, 변화가 발생할 때만 렌더링 하도록 해두었기 때문에 첫 화면이 까만 상자처럼 보이게 되었다. 화면이 깜박이는 것 처럼 보이는 데, 이게 싫다면 로딩 프로그레스 화면을 띄운 후에 렌더링이 되도록 하면 될 것 같다.
// 까맣게 보이는 이슈 해결. 텍스쳐가 모두 로딩 된 후 렌더함수재호출
loadManager.onLoad = () => {
const cube = new THREE.Mesh(cubeGeo, materials);
cube.position.set(0, cubeSize, 0)
scene.add(cube);
render()
};
+++ 유리 같은 재질도 한번 구현해보고 싶은데, Texture와 Material 만으로는 표현하기 어려운 것 같았다. HDR 환경맵을 설정해줘야하는 것 같은 데, 다음에 재도전해보는 걸로..!
여러 시점으로 화면 보여주기 feat.가위
canvas 크기만큼 겹치는 div 와 그 div 를 반반으로 나누는 div 두 개를 생성하고, canvas 대신 그 두개의 div 에 두개의 카메라가 각각 같은 씬을 렌더링 해준다.
const renderer = new THREE.WebGLRenderer({ antialias: true, canvas });
const aspect = window.clientWidth / 2 / window.clientHeight; // the canvas default
const camera = new THREE.PerspectiveCamera(45, aspect, 0.1, 100);
camera.position.set(0, 10, 20);
const camera2 = new THREE.PerspectiveCamera(60, aspect, 0.1, 500)
camera2.position.set(40, 10, 30)
camera2.lookAt(0, 5, 0)
const controls = new OrbitControls(camera, viewEls[0]);
controls.enableDamping = true; // 부드러운 동작
controls.target.set(0, 5, 0); // 아래 큐브와 구의 중심지점을 기준으로 카메라가 움직임.
controls.update();
const controls2 = new OrbitControls(camera2, viewEls[1]);
controls.enableDamping = true; // 부드러운 동작
controls2.target.set(0, 5, 0);
controls2.update();
function render() {
resizeRendererToDisplaySize(renderer)
renderer.setScissorTest(true);
{
const aspect = setScissorForElement(viewEls[0]);
// 비율에 따라 카메라 조정
camera.aspect = aspect;
camera.updateProjectionMatrix();
cameraHelper.update();
// 기존 화면에서 가이드라인(CameraHelper)이 노출되지 않도록 설정
cameraHelper.visible = false;
scene.background.set(0x000000);
// 렌더링
renderer.render(scene, camera);
}
// 두 번째 카메라 렌더링
{
const aspect = setScissorForElement(viewEls[1]);
// 비율에 따라 카메라 조정
camera2.aspect = aspect;
camera2.updateProjectionMatrix();
// 가이드라인 활성화
cameraHelper.visible = true;
scene.background.set(0x000040);
renderer.render(scene, camera2);
}
}
function setScissorForElement(elem) {
const canvasRect = canvas.getBoundingClientRect();
const elemRect = elem.getBoundingClientRect();
// canvas에 대응하는 사각형을 구하기
const right = Math.min(elemRect.right, canvasRect.right) - canvasRect.left;
const left = Math.max(0, elemRect.left - canvasRect.left);
const bottom = Math.min(elemRect.bottom, canvasRect.bottom) - canvasRect.top;
const top = Math.max(0, elemRect.top - canvasRect.top);
const width = Math.min(canvasRect.width, right - left);
const height = Math.min(canvasRect.height, bottom - top);
// canvas의 일부분만 렌더링하도록 scissor 적용
const positiveYUpBottom = canvasRect.height - bottom;
renderer.setScissor(left, positiveYUpBottom, width, height);
renderer.setViewport(left, positiveYUpBottom, width, height);
// 비율 반환
return width / height;
}
+setViewport 와 setScissors 을 항상 함께 쓰는 것 같은 데, 함수의 명확한 차이를 잘 모르겠다... gpt 에 따르면 이렇다는 데 출처가 표기가 안되어서 믿음이 안간다. 한번 테스트 해봐야겠다.
- setViewport: 장면이 어디에 그려질지와 크기를 설정합니다.
- setScissor: 설정한 영역 외에는 렌더링하지 않도록 잘라내는 기능을 제공합니다.
++ setScissor vs setViewport
gpt 설명이 맞았다 *^^*
불필요한 렌더링 방지하기
애니메이션을 사용하지 않은 다면, 프레임마다 렌더링할 필요가 없다. 기존 렌더 함수를 프레임마다 호출하는 대신, 카메라 컨트롤이 변하거나, 윈도우의 창이 조정되거나, GUI 에서 조정될 때마다 업데이트 되면 된다.
function updateByUI() {
camera.updateProjectionMatrix()
render()
}
const gui = new GUI();
const minMaxGUIHelper = new MinMaxGUIHelper(camera, 'near', 'far', 0.1);
gui.add(camera, 'fov', 1, 180).onChange(updateByUI);
gui.add(minMaxGUIHelper, 'min', 0.1, 50, 0.1).name('near').onChange(updateByUI);
gui.add(minMaxGUIHelper, 'max', 0.1, 100, 0.1).name('far').onChange(updateByUI);
...
controls.addEventListener('change', render);
controls2.addEventListener('change', render)
window.addEventListener('resize', render);
render();
참고
threejsstudies/src/week4.js at main · munjimon822/threejsstudies
Contribute to munjimon822/threejsstudies development by creating an account on GitHub.
github.com
'개발 > js' 카테고리의 다른 글
threejs 스터디 5주차) 두근두근 메탈릭 하트 (0) | 2024.11.29 |
---|---|
네이버 앱에서 css가 깨질 땐..! (1) | 2024.11.20 |
three.js 스터디 3주차) 씬 그래프 탐구 feat.태양계 (0) | 2024.11.14 |
three.js 스터디 2주차) Primitives (2) | 2024.11.01 |
three.js 스터디 1주차) Hello Cube! (0) | 2024.10.29 |