멀티 카메라의 렌더링 결과를 하나의 Scene에 표시 (in Three.js)

여러 개의 카메라의 렌더링 결과를 하나의 장면에 표시해야할 필요가 생긴다. 예를들어 스타크래프트 게임을 보면 화면에 2개의 요소로 구분되어 있다. 첫째는 미니맵과 명령 아이콘들이 표시되는 커멘트 요소와 두번째는 실제 게임 플레이 요소이다. 이를 구현하는 방식은 여러가지가 있겠으나 멀티 카메라를 이용하는 방식이 있다. 게임 플레이 요소는 원근감 있게 PerspectiveCamera를 이용하고 커멘드 요소는 OrthographicCamera로 원근감 없이 렌더링하는 것이다. 이렇게 각 카메라로 렌더링되는 요소를 하나의 장면에 중첩해서 표시하면 된다.

마우스에 대한 상호작용은 Camera를 입력값을 개별 요소로 받으니 어떤 객체든 마우스 상호작용을 쉽게 얻을 수 있다. 이미 알고 있겠지만 Raycast를 이용해서 말이다. 여러개의 카메라를 사용할때 생각할 것은 어떤 요소를 어떤 카메라에 할당해 렌더링할 것인지에 대한 지정이다. 물론 하나의 요소를 여러개의 카메라에서 동시에 렌더링하는 것도 가능하다. 이러한 구분은 Layers라는 기능을 이용하면 매우 직관적으로 처리할 수 있다. three.js에서 Layers는 총 32개로 구성된다. 기본적으로 모든 카메라, 광원, 매시 등은 0번째 레이어에 소속된다. 어떤 카메라가 소속된 레이어와 동일한 레이어에 소속된 것들은 모두 카메라를 통해 렌더링된다.

이를 코드로 작성해 보자. 먼저 카메라 2개를 만들자. 쉽게 설명하기 위해 둘다 PerpectiveCamera 객체다.

_setupCamera() {
  ...
  
  const aspect = width / height;

  const camera1 = new THREE.PerspectiveCamera(60, aspect, 0.1, 10);
  camera1.layers.enable(1);
  camera1.position.z = 3;
  this._camera1 = camera1;

  const camera2 = new THREE.PerspectiveCamera(60, aspect, 0.1, 10);
  camera2.layers.enable(2);
  camera2.position.z = 2;
  this._camera2 = camera2;
}

camera1과 camera2는 각각 0,1 레이어와 0,2 레이어에 소속되어 있다. 만약 camera1을 1번 레이어에만 소속시키려면 camera1.layers.set(1) 코드면 된다.

다음은 렌더링할 매시를 다음처럼 구성한다.

const geometryC1 = new THREE.BoxGeometry();
const materialC1 = new THREE.MeshStandardMaterial();
const meshC1 = new THREE.Mesh(geometryC1, materialC1);
meshC1.layers.set(1);
this._scene.add(meshC1);

const geometryC2 = new THREE.BoxGeometry();
const materialC2 = new THREE.MeshStandardMaterial({ wireframe: true });
const meshC2 = new THREE.Mesh(geometryC2, materialC2)
meshC2.layers.set(2);
this._scene.add(meshC2);

meshC1은 1 레이어에만 소속되어 있다. meshC2는 2 레이어에만 소속되어 있다. 만약 layers의 set 대신 enable를 사용하면 기본적으로 소속된 0번 레이어에 대한 소속은 그대로 유지되므로 명확히 1 번 레이어에만 소속되도록 set를 사용했다. 광원에 대한 layers 설정은 하지 않았으므로 0 레이어에 소속되어 있다. camera1과 camera2의 layers를 enable로 해서 설정했으므로 각 카메라는 기본적으로 소속된 0번 레이어에도 소속되어 있고 0 레이어에 소속된 광원의 영향을 2개 카메라 모두 사용하게 된다.

이제 렌더링과 관련된 코드를 작성해야 한다. 그전에 Renderer 객체에 대해 다음 코드가 필요하다.

renderer.autoClearColor = false;

기본적으로 장면이 렌더링 되기 직전에 자동으로 정해진 색상으로 프레임버퍼가 설정된다. 위의 코드는 이처럼 자동으로 이뤄지는 것을 방지하고자 함이다. 자, 이제 렌더링 코드를 보자.

render() {
  this.update();

  this._renderer.clearColor();
  this._renderer.render(this._scene, this._camera2);
  this._renderer.render(this._scene, this._camera1);

  requestAnimationFrame(this.render.bind(this));
}

Renderer의 autoClearColor를 false로 지정했으므로 이제 직접 프레임버퍼를 지우기 위해 Renderer의 clearColor 매서드를 호출해야 한다. 그런 후에 각 카메라를 이용해 장면을 렌더링한다. 끝이다.

굳히 언급하지 않아도 이미 알겠지만 창 크기가 변경되면 카메라의 기저 인자값들을 재설정해줘야 한다. 카메라가 여러개이므로 이에 대한 코드까지 살펴보고 마무리 한다.

resize() {
  ...

  const aspect = width / height;

  this._camera1.aspect = aspect;
  this._camera1.updateProjectionMatrix();

  this._camera2.aspect = aspect;
  this._camera2.updateProjectionMatrix();

  ...
}

zod ?

JavaScript는 타입이 널널한 언어이고 이런 타입 널널한 JavaScript를 보완하고자 태어난 언어가 TypeScript이다. TypeScript는 다른 다양한 언어 중에서도 상당히 복잡한 문법을 갖는 언어이다. 많은 웹 개발자들이 TypeScript를 꺼리는 이유가 바로 이런 복잡함에 있다. 하지만 JavaScript를 사용하면서 JavaScript의 널널한 타입만큼은 좀 어떻게 보완을 할 수 없냐는 갈증을 풀어줄 라이브러리가 zod다.

npm i zod로 딱 설치하고 다음 코드를 고고씽 하면 된다.

import { z } from "zod"

const SexEnum = z.enum(["Man", "Female"])

const User = z.object({
  name: z.string(),
  age: z.number().optional(),
  birthday: z.date(),
  sex: SexEnum.nullable(),
  live: z.boolean(),
  items: z.array(z.string()),
})

const dip2k = User.parse({
  name: "dip2k",
  // age: 18,
  live: true,
  // sex: "Man",
  sex: null,
  birthday: new Date("1999-12-18"),
  items: [ "Hammer", "Key" ],
});

console.log(dip2k);

뭔 말이 필요할까. 코드 자체에 모든 설명이 담겨 있다. Hey~ zod?

three.js 웹을 Electron을 이용해 어플리케이션으로 만들기 (1/3)

웹은 웹브라우저를 통해 URL만 알면 어디서든 사용할 수 있는 접근성 ‘갑’ 기술이다. 그러나 이 웹은 치명적인 단점이 있는데 바로 인터넷이 되는 환경이여야 하고 또 기본적으로 로컬 리소스에 자유롭게 접근할 수 없다는 점이다. (물론 웹에서도 사용자의 인터렉션을 통해 로컬 리소스을 접근할 수 있기는 하다.)

아래의 동영을 보자. three.js를 이용해 간단한 3D 뷰 페이지를 만들었고 내 PC에 저장된 모델 파일을 열어 표시한다. 분명 웹으로 만들었지만 이 프로그램의 타이틀을 보면 크롬등과 같은 프로그램이 아닌 독자적인 프로그램이라는 것을 알 수 있다. 이 프로그램은 원래 웹이였던 아이를 Electron이라는 기술을 이용해 인터넷 없이도 실행할 수 있는 단독 실행 파일로 만들어 낸 결과이다.

위의 결과를 만들기 위한 방법을 유튜브 영상으로 만들어 올릴 예정인데, 그전에 블로그에 정리하기 위한 목적으로 이 글을 작성한다.

먼저 간단한 웹페이지를 만들어야 한다. 나는 다음 git 명령을 통해 이미 작성된 프로젝트를 가져왔다.

git clone https://github.com/GISDEVCODE/threejs-with-javascript-starter.git .

패키지를 설치하고 실행까지해보면 다음과 같은 결과를 볼 수 있다.

이 실행 결과를 배포 버전으로 만들어야 한다. Electron은 웹 페이지에 대해 배포 버전을 대상으로 하기 때문이다. 이 프로젝트의 경우 배포 명령은 다음과 같다.

npm run build

위의 명령을 실행하기에 앞서 아래 글을 참고해서 손이 덜 가도록 만드는 것을 추천한다.

Vite로 개발된 웹앱 배포 시 주의점

배포 버전을 만들면 dist라는 폴더에 그 결과가 생성되고 이 dist 폴더의 결과를 Electron으로 단독 실행이 가능한 프로그램으로 만들 수 있다. 이를 위해 다음 명령으로 필요한 패키지를 설치한다.

npm i electron --save-dev

Electron에만 관련된 소스코드를 따로 관리하기 위해 프로젝트에서 electron 폴더를 만들고 electron-run.cjs와 preload.js 파일을 만든다. 확장자가 각각 cjs와 js라는 점에 유의하자. electron-run.cjs의 코드를 다음처럼 입력한다.

const { app, BrowserWindow, ipcMain } = require('electron');
const path = require('path');

let win = null;
const createWindow = () => {
  win = new BrowserWindow({
    width: 1024,
    height: 768,
    webPreferences: {
      nodeIntegration: true,
      preload: path.resolve("electron/preload.js")
    }
  });

  win.loadFile(`${path.join(__dirname, '../dist/index.html')}`);
};

app.whenReady().then(() => {
  createWindow();

  app.on('activate', () => {
    if (BrowserWindow.getAllWindows().length === 0) {
      createWindow();
    }
  });
});

app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') {
    app.quit();
  }
});

package.json에 위의 electron-run.cjs를 참조를 다음처럼 해준다.

{
  "main": "./electron/electron-run.cjs",
  ...

  "scripts": {
    ...
    "electron": "electron ."
  },

  ...
}

이제 기본적인 것은 모두 끝났다. 다음 명령을 통해 우리의 웹이 크롬과 같은 브라우저 없이도 단독 실행 가능한 어플리케이션으로 탄생시키기 위해 다음 명령을 실행한다.

npm run electron