[Three.js] 활용하기 2

Study·2021년 9월 18일
3

Three.js

목록 보기
7/8
post-thumbnail
post-custom-banner

Three.js 배경과 하늘 상자

Three.js 에서 단순한 배경은 CSS 만큼 쉽다.

먼저 CSS로 canvas에 배경을 추가한다.

<style>
body {
    margin: 0;
}
#c {
    width: 100%;
    height: 100%;
    display: block;
    background: url(resources/images/daikanyama.jpg) no-repeat center center;
    background-size: cover;
}
</style>

그리고 WebGLRendereralpha 옵션을 켜 아무것도 없는 공간은 투명하게 보이도록 설정한다.

function main() {
  const canvas = document.querySelector('#c');
//  const renderer = new THREE.WebGLRenderer({canvas});
  const renderer = new THREE.WebGLRenderer({
    canvas,
    alpha: true,
  });

배경 후처리 효과의 영향을 받게 하려면 Three.js 로 배경을 렌더링해야 한다.

간단히 장면의 배경에 텍스처를 입혀주기만 하면 된다.

const loader = new THREE.TextureLoader();
const bgTexture = loader.load('resources/images/daikanyama.jpg');
scene.background = bgTexture;

배경은 지정되었지만 화면에 맞춰 늘어진다.

이미지의 일부만 보이도록 repeatoffset 속성을 조정해 문제를 해결해본다.

function render(time) {
 
   ...
  /**
   * 배경 텍스처의 repeat과 offset 속성을 조정해 이미지의 비율이 깨지지
   * 않도록 합니다.
   * 이미지를 불러오는 데 시간이 걸릴 수 있으니 감안해야 합니다.
   **/
  const canvasAspect = canvas.clientWidth / canvas.clientHeight;
  const imageAspect = bgTexture.image ? bgTexture.image.width / bgTexture.image.height : 1;
  const aspect = imageAspect / canvasAspect;
 
  bgTexture.offset.x = aspect > 1 ? (1 - 1 / aspect) / 2 : 0;
  bgTexture.repeat.x = aspect > 1 ? 1 / aspect : 1;
 
  bgTexture.offset.y = aspect > 1 ? 0 : (1 - aspect) / 2;
  bgTexture.repeat.y = aspect > 1 ? 1 : aspect;
 
  ...
 
  renderer.render(scene, camera);
 
  requestAnimationFrame(render);
}

이제 Three.js 가 배경을 렌더링한다. 그냥 보기에 CSS와 차이가 없지만, 후처리 효과의 영향을 받는다는 점이 다르다.

물론 3D 장면을 만들 때 단순한 배경을 자주 사용하진 않는다. 대신 주로 하늘 상자를 사용한다. 하늘 상자는 말 그대로 하늘을 그려놓은 상자로써, 상자 안에 카메라를 놓으면 마치 배경에 하늘이 있는 것처럼 보이는 효과를 준다.

일반적으로 육면체에 텍스처를 입히고 안쪽을 렌더링하도록 설정해 하늘 상자를 구현한다. 각 면에 수평선처럼 보이는 이미지를 텍스처로 배치하는 것이다.
다시 말해 육면체나 구체를 만들고, 텍스처를 입힌 뒤, 바깥 면이 아닌 안쪽 면을 렌더링하도록 THREE.BackSide 값을 넣어주면 된다. 그리고 바로 장면에 추가하거나 하늘 상자를 담당할 장면 하나, 다른 요소를 담당할 장면 하나 이렇게 총 2개를 만들 수도 있다. OrthographicCamera 를 쓸 필요는 없으니 PerspectiveCamera 를 그대로 사용하면 된다.

다른 방법 중 하나는 큐브맵이다.
정육면체 한 면 당 하나씩 텍스처를 지정하는 것이다.

아래 사진을 이용한다.

해당 이미지들을 CubeTextureLoader 로 불러와 장면의 배경으로 설정한다.


function render(time) {
 
   ...
 
  /**
   * 배경 텍스처의 repeat과 offset 속성을 조정해 이미지의 비율이 깨지지
   * 않도록 합니다.
   * 이미지를 불러오는 데 시간이 걸릴 수 있으니 감안해야 합니다.
   **/
   /*
  const canvasAspect = canvas.clientWidth / canvas.clientHeight;
  const imageAspect = bgTexture.image ? bgTexture.image.width / bgTexture.image.height : 1;
  const aspect = imageAspect / canvasAspect;
 
  bgTexture.offset.x = aspect > 1 ? (1 - 1 / aspect) / 2 : 0;
  bgTexture.repeat.x = aspect > 1 ? 1 / aspect : 1;
 
  bgTexture.offset.y = aspect > 1 ? 0 : (1 - aspect) / 2;
  bgTexture.repeat.y = aspect > 1 ? 1 : aspect;
 */
  ...
 
  renderer.render(scene, camera);
 
  requestAnimationFrame(render);
}

카메라도 조작이 가능하도록 만든다.

import { OrbitControls } from './resources/threejs/controls/OrbitControls.js';
const fov = 75;
const aspect = 2;  // canvas 기본값
const near = 0.1;
const far = 5;
const far = 100;
const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
camera.position.z = 2;
camera.position.z = 3;
 
const controls = new OrbitControls(camera, canvas);
controls.target.set(0, 0, 0);
controls.update();

드래그하면 큐브맵이 주위를 둘러싼 것을 볼 수 있다.

다른 방법은 등장방형도법(Equirectangular map)을 이용하는 것이다.
이런 사진은 360도 카메라로 촬영한 것이다.

![]

등장방형도법의 사용법도 별반 다르지 않으며 이미지 텍스처로 불러온뒤, 콜백에서 불러온 이미지 텍스처를 WebGLCubeRenderTarget.fromEquirectangularTexture 를 호출할 때 넘겨주면 큐브맵을 만들 수 있다.
WebGLCubeRenderTarget 을 생성할 때 큐브맵의 크기를 지정해주기만 하면 된다. 예제의 경우 등장방형도법 이미지의 높이를 넘겨주면 된다.

{
  /*
  const loader = new THREE.CubeTextureLoader();
  const texture = loader.load([
    'resources/images/cubemaps/computer-history-museum/pos-x.jpg',
    'resources/images/cubemaps/computer-history-museum/neg-x.jpg',
    'resources/images/cubemaps/computer-history-museum/pos-y.jpg',
    'resources/images/cubemaps/computer-history-museum/neg-y.jpg',
    'resources/images/cubemaps/computer-history-museum/pos-z.jpg',
    'resources/images/cubemaps/computer-history-museum/neg-z.jpg',
  ]);
  scene.background = texture;
  */
  const loader = new THREE.TextureLoader();
  const texture = loader.load(
    'resources/images/equirectangularmaps/tears_of_steel_bridge_2k.jpg',
    () => {
      const rt = new THREE.WebGLCubeRenderTarget(texture.image.height);
      rt.fromEquirectangularTexture(renderer, texture);
      scene.background = rt.texture;
    });
}

Three.js 투명도

먼저 예제로 정육면체 8개를 2x2x2 그리드에 맞추어 장면을 만든다.

[불필요한 렌더링 제거] 에서 사용한 예제를 가져와 makeInstance 함수가 x, y, z 값을 받고록 수정한다.

// function makeInstance(geometry, color) {
function makeInstance(geometry, color, x, y, z) {
  const material = new THREE.MeshPhongMaterial({color});
 
  const cube = new THREE.Mesh(geometry, material);
  scene.add(cube);
 
//  cube.position.x = x;
  cube.position.set(x, y, z);
 
  return cube;
}

그리고 정육면체 8개를 만든다.

function hsl(h, s, l) {
  return (new THREE.Color()).setHSL(h, s, l);
}
 
//makeInstance(geometry, 0x44aa88,  0);
//makeInstance(geometry, 0x8844aa, -2);
//makeInstance(geometry, 0xaa8844,  2);
 
{
  const d = 0.8;
  makeInstance(geometry, hsl(0 / 8, 1, .5), -d, -d, -d);
  makeInstance(geometry, hsl(1 / 8, 1, .5),  d, -d, -d);
  makeInstance(geometry, hsl(2 / 8, 1, .5), -d,  d, -d);
  makeInstance(geometry, hsl(3 / 8, 1, .5),  d,  d, -d);
  makeInstance(geometry, hsl(4 / 8, 1, .5), -d, -d,  d);
  makeInstance(geometry, hsl(5 / 8, 1, .5),  d, -d,  d);
  makeInstance(geometry, hsl(6 / 8, 1, .5), -d,  d,  d);
  makeInstance(geometry, hsl(7 / 8, 1, .5),  d,  d,  d);
}

카메라를 조정한다.

const fov = 75;
const aspect = 2;  // canvas 기본값
const near = 0.1;
//const far = 5;
const far = 25;
const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
//camera.position.z = 4;
camera.position.z = 2;

배경색은 하얀색으로 바꿔주고

const scene = new THREE.Scene();
scene.background = new THREE.Color('white');

정육면체 옆면도 빛을 받도록 조명을 하나 더 추가한다.

// {
function addLight(...pos) {
  const color = 0xFFFFFF;
  const intensity = 1;
  const light = new THREE.DirectionalLight(color, intensity);
//  light.position.set(-1, 2, 4);
  light.position.set(...pos);
  scene.add(light);
}
addLight(-1, 2, 4);
addLight( 1, -1, -2);

정육면체를 투명하게 만들려면 transparent 속성을 키고 opacity 속성을 설정해줘야 한다.

function makeInstance(geometry, color, x, y, z) {
//  const material = new THREE.MeshPhongMaterial({color});
  const material = new THREE.MeshPhongMaterial({
    color,
    opacity: 0.5,
    transparent: true,
  });
 
  const cube = new THREE.Mesh(geometry, material);
  scene.add(cube);
 
  cube.position.set(x, y, z);
 
  return cube;
}

8개의 반투명 정육면체를 확인하자.

완벽하다 생각할 수 있지만 투명하다고 했지만, 정육면체 뒷면이 보이지 않는다.

이전 재질에 관해 학습했을 때 side 속성에 대해 배웠다.
이 속성을 THREE.DoubleSide 로 설정해 정육면체의 양면이 모두 보이도록 해본다.

const material = new THREE.MeshPhongMaterial({
  color,
  map: loader.load(url),
  opacity: 0.5,
  transparent: true,
  side: THREE.DoubleSide,
});

예제를 돌려보면 가끔 뒷면 또는 뒷면의 일부가 보이지 않는다.

이는 렌더링 방식에 있는데, WebGL은 각 geometry의 삼각형을 한번에 하나식 렌더링한다. 그리고 삼각형의 픽셀 하나를 렌더링할 때마다 2개의 정보를 기록하는데, 하나는 해당 픽셀의 색이고 다른 하나는 픽셀의 깊이이다.
다음 삼각형을 그릴 때 해당 픽셀이 이미 그려진 픽셀보다 깊이가 깊다면 해당 픽셀을 렌더링하지 않는다.

이는 불투명한 물체에서는 문제가 되지 않지만 투명한 물체에는 문제가 있다.

이 문제를 해결하려면 투명한 물체를 분류해 뒤에 있는 물체를 앞에 있는 물체보다 먼저 렌더링해야 한다. Mesh 같은 경우는 Three.js 가 자동으로 처리해준다. 만약 그러지 않으면 제일 첫 번째 예제에서 뒤에 있는 정육면체를 아예 볼 수 없다.

정육면체에는 한 면에 2개, 총 12개의 삼각형이 있다. 시선에 따라 카메라에서 가까운 삼각형을 먼저 렌더링할 것이다. 그래서 때때로 뒷면이 보이지 않을 수 밖에 없다.

구체나 정육면체 등 블록 물체의 경우, 모든 물체를 한 번씩 더 렌더링해 문제를 해결할 수 있다. 하나는 안쪽면 삼각형만 렌더링하고, 다른 하나는 바깥쪽 삼각형만 렌더링하도록 한다.

function makeInstance(geometry, color, x, y, z) {
  [THREE.BackSide, THREE.FrontSide].forEach((side) => {
    const material = new THREE.MeshPhongMaterial({
      color,
      opacity: 0.5,
      transparent: true,
      side,
    });
 
    const cube = new THREE.Mesh(geometry, material);
    scene.add(cube);
 
    cube.position.set(x, y, z);
  });
}

side: THREE.BackSide mesh 를 먼저 넣고, 그 다음 정확히 같은 위치에 side: THREE.FrontSide mesh 를 넣었으니 Three.js 의 분류 기준은 고정적일 수 있다.

이번엔 평면 2개를 교차로 배치해본다. 각 평면에는 다른 텍스처를 넣을 것이다.

const planeWidth = 1;
const planeHeight = 1;
const geometry = new THREE.PlaneGeometry(planeWidth, planeHeight);
 
const loader = new THREE.TextureLoader();
 
function makeInstance(geometry, color, rotY, url) {
  const texture = loader.load(url, render);
  const material = new THREE.MeshPhongMaterial({
    color,
    map: texture,
    opacity: 0.5,
    transparent: true,
    side: THREE.DoubleSide,
  });
 
  const mesh = new THREE.Mesh(geometry, material);
  scene.add(mesh);
 
  mesh.rotation.y = rotY;
}
 
makeInstance(geometry, 'pink',       0,             'resources/images/happyface.png');
makeInstance(geometry, 'lightblue',  Math.PI * 0.5, 'resources/images/hmmmface.png');

평면은 한 번에 한 면밖에 보지 못하니, side: THREE.DoubleSide 로 설정했다. 또한 텍스처를 전부 불러왔을 때 장면을 다시 렌더링하도록 render 함수를 loader.load 메소드에 넘겨줬다. 이는 필요에 따른 렌더링을 구현하기 위함이다.

아까와 비슷한 문제가 발생했다.

평면을 둘로 쪼개 실제로는 교차하지 않게끔 만들면 문제를 해결할 수 있다.

function makeInstance(geometry, color, rotY, url) {
  const base = new THREE.Object3D();
  scene.add(base);
  base.rotation.y = rotY;
 
  [-1, 1].forEach((x) => {
    const texture = loader.load(url, render);
    texture.offset.x = x < 0 ? 0 : 0.5;
    texture.repeat.x = .5;
    const material = new THREE.MeshPhongMaterial({
      color,
      map: texture,
      opacity: 0.5,
      transparent: true,
      side: THREE.DoubleSide,
    });
 
    const mesh = new THREE.Mesh(geometry, material);
//    scene.add(mesh);
    base.add(mesh);
 
//    mesh.rotation.y = rotY;
    mesh.position.x = x * .25;
  });
}

어떻게 구현할지는 개발자 몫이며 블렌더 같은 3D 에디터를 사용했다면 텍스처 좌표를 직접 수정할 수도 있다. 예제의 경우 PlaneGeometry 를 써서 텍스처를 크기에 맞춰 늘린다.
texture.repeat 속성과 texture.offset 속성을 조정해 각 면에 적절한 텍스처를 입혀줄 수 있다.

위 코드에선 Object3D 를 만들어 두 평면의 부모로 지정했다.
이렇게 하면 복잡한 계산없이 간단히 Object3D 만 돌려 평면을 회전시킬 수 있다.

해당 방법은 교차점이 변하지 않는 간단한 경우에만 가능하다.

텍스처가 들어간 요소는 알파 테스트를 활성화해 이를 해결할 수 있다.

알파 테스트란 Three.js 가 픽셀을 렌더링하지 않는 특정 알파 단계를 의미한다. 만약 아무것도 그리지 않게 설정한다면 위와 같은 문제는 사라진다. 상대적으로 경계가 분명한 텍스처, 나뭇잎, 잔디 등의 경우 잘 작동한다.

이번에도 2개의 면을 만들어 테스트 한다. 이번엔 각 면에 각기 다른 부분적으로 투명한 텍스처를 사용한다.

이전에 평면 2개를 교차한 예제를 가져와 이 텍스처를 alphaTest 속성을 지정한다.

function makeInstance(geometry, color, rotY, url) {
  const texture = loader.load(url, render);
  const material = new THREE.MeshPhongMaterial({
    color,
    map: texture,
//    opacity: 0.5,
    transparent: true,
    alphaTest: 0.5,
    side: THREE.DoubleSide,
  });
 
  const mesh = new THREE.Mesh(geometry, material);
  scene.add(mesh);
 
  mesh.rotation.y = rotY;
}
 
//makeInstance(geometry, 'pink',       0,             'resources/images/happyface.png');
//makeInstance(geometry, 'lightblue',  Math.PI * 0.5, 'resources/images/hmmmface.png');
makeInstance(geometry, 'white', 0,             'resources/images/tree-01.png');
makeInstance(geometry, 'white', Math.PI * 0.5, 'resources/images/tree-02.png');

이대로 실행해도 되지만, 간단한 UI를 만들어 alphaTesttransparent 속성을 갖고 놀 수 있게 해본다.
씬 그래프에 대한 글의 dat.GUI 를 쓴다.

먼저 dat.GUI 에 지정할 헬퍼 클래스를 만든다. 이 헬퍼 클래스는 장면 안의 모든 재질에 해당 값으로 변경하도록 할 것이다.

class AllMaterialPropertyGUIHelper {
  constructor(prop, scene) {
    this.prop = prop;
    this.scene = scene;
  }
  get value() {
    const { scene, prop } = this;
    let v;
    scene.traverse((obj) => {
      if (obj.material && obj.material[prop] !== undefined) {
        v = obj.material[prop];
      }
    });
    return v;
  }
  set value(v) {
    const { scene, prop } = this;
    scene.traverse((obj) => {
      if (obj.material && obj.material[prop] !== undefined) {
        obj.material[prop] = v;
        obj.material.needsUpdate = true;
      }
    });
  }
}

다음으로 GUI를 추가한다.

const gui = new GUI();
gui.add(new AllMaterialPropertyGUIHelper('alphaTest', scene), 'value', 0, 1)
    .name('alphaTest')
    .onChange(requestRenderIfNotRequested);
gui.add(new AllMaterialPropertyGUIHelper('transparent', scene), 'value')
    .name('transparent')
    .onChange(requestRenderIfNotRequested);

물론 dat.GUI 모듈도 불러온다.

import { GUI } from '../3rdparty/dat.gui.module.js';

예제를 확대해보면 평면에 하얀 테두리가 보일 것이다.

이는 앞서 본 예제와 같은 문제다. 하얀 테두리 요소가 먼저 그려져서 그런 것이다. alphaTesttransparent 옵션으로 조정하며 해결책을 찾아야 한다.

profile
Study
post-custom-banner

0개의 댓글