three.js로 웹에서 멋진 3D 장면 연출하기

three.js를 이용하여 웹에서.. 몽환적인 장면을 만들어 보는 코드를 작성해 보았습니다. 결과는 다음 동영상과 같습니다.

복잡한 Shader를 사용하지 않았습니다. three.js의 기본적인 API만을 사용했습니다.

또 다른 한가지 예입니다. 피닉스 한마리가 날아오르는 장면을 연출한 것인데요. 처음엔 생기가 없지만 조금씩 높이 날아 오를수록 몸이 빛나기 시작합니다.

조만간 제 Youtube 채널(GIS DEVELOPER)에 위 2가지에 예제를 three.js로 어떻게 만드는지 그 내용을 업로드할 예정입니다.

Webpack, TypeScript를 이용한 웹페이지 개발 설정

기본 설정

# Node.js 설치

npm 명령 실행을 위함

# npm init -y

npm init는 package.json을 만들기 위한 명령이고 -y를 붙임으로써 별도의 입력 없이 기본 값으로 진행 시킴. package.json은 작성하고자 하는 프로젝트에 대한 설정 파일로 볼 수 있으며, 프로젝트 이름과 버전 등과 같은 설명과 프로젝트가 사용하는 라이브러리에 대한 정보 그리고 프로젝트 실행 등을 위한 명령에 대한 정보가 담겨있음. package.json 파일은 npm을 위한 파일임(VS.Code를 위한 것이 아님)

# npm install webpack webpack-cli --save-dev

# npm install typescript ts-loader --save-dev

# tsconfig.json 파일 생성 및 내용 작성

{
    "compilerOptions": {
        "outDir": "./dist/",
        "sourceMap": true,
        "noImplicitAny": true,
        "module": "es6",
        "target": "es5",
        "allowJs": true
    }
}

# webpack.config.js 파일 생성 및 내용 작성

const path = require("path");

module.exports = {
    mode: "development",
    entry: "./src/index.ts",
    devtool: "inline-source-map",
    module: {
        rules: [
            {
                test: /\.tsx?$/,
                use: "ts-loader",
                exclude: /node_modules/,
            },
        ],
    },
    resolve: {
        extensions: [".tsx", ".ts", ".js"],
    },
    output: {
        filename: "bundle.js",
        path: path.resolve(__dirname, "dist"),
    },
}

# src, dist 폴더 및 index.html(dist 폴더), index.ts(src 폴더) 파일 생성

# index.html 코드 입력

...

<script src="bundle.js"></script> 

...

# index.ts 코드 입력

console.log("Hello");

# package.json 파일의 “scripts” 파트에 “bundle”: “webpack” 입력

{
  ..
  "scripts": {
    ..
    "bundle": "webpack"
  },
  ..
}

# npm run bundle 실행

Typescript로 작성된 파일을 Javascript 파일로 트랜스파일링 시킴

자동 실행을 위한 설정

# package.json 파일의 “scripts” 파트에 “watch”: “webpack –watch” 및 “start”: “npm run bundle && npm run watch” 추가

{
  ..
  "scripts": {
    ..
    "bundle": "webpack",
    "watch": "webpack --watch",
    "start": "npm run bundle && npm run watch"
  },
  ..
}

# VS.Code에서 Live Server 확장 기능 설치

# npm start 실행

이제부터 Typescript로 작성된 파일을 수정하면 Javascript 파일로 지동으로 트랜스파일링 시킴

# index.html 열고 GO LIVE 활성화

브레이크 디버깅

# VS.Code의 “실행 및 디버그” 클릭을 통해 launch.json을 생성 (크롬 선택)

# launch.json 파일에서 “url”의 값을 “http://127.0.0.1:5500/dist”으로 수정

{
    ..
    "configurations": [
        {
            ..
            "url": "http://127.0.0.1:5500/dist",
            ..
        }
    ]
}

babylonjs 라이브러리 설치

# npm install @babylonjs/core

# tsconfig.json에 “moduleResolution”: “node” 추가

{
    "compilerOptions": {
       ..
       "moduleResolution": "node"
    }
}

위의 설정을 입력해야 babylonjs 모듈을 import할 때(예: import { Scene } from “@babylonjs/core”) 문제가 없음

최종 설정 파일 내용

# tsfonfig.json

{
    "compilerOptions": {
        "outDir": "./dist/",
        "sourceMap": true,
        "noImplicitAny": true,
        "module": "es6",
        "target": "es5",
        "allowJs": true,

        "moduleResolution": "node"
    }
}

# webpack.config.js

const path = require("path");

module.exports = {
    mode: "development",
    entry: "./src/index.ts",
    devtool: "inline-source-map",
    module: {
        rules: [
            {
                test: /\.tsx?$/,
                use: "ts-loader",
                exclude: /node_modules/
            },
        ],
    },
    resolve: {
        extensions: [".tsx", ".ts", ".js"],
    },
    output: {
        filename: "bundle.js",
        path: path.resolve(__dirname, "dist"),
    },
}

# package.json

{
  "name": "tstbabylonjs_ts",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "bundle": "webpack",
    "watch": "webpack --watch",
    "start": "npm run bundle && npm run watch"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "ts-loader": "^9.4.1",
    "typescript": "^4.8.4",
    "webpack": "^5.74.0",
    "webpack-cli": "^4.10.0"
  },
  "dependencies": {
    "@babylonjs/core": "^5.26.1"
  }
}

# launch.json

{
    // IntelliSense를 사용하여 가능한 특성에 대해 알아보세요.
    // 기존 특성에 대한 설명을 보려면 가리킵니다.
    // 자세한 내용을 보려면 https://go.microsoft.com/fwlink/?linkid=830387을(를) 방문하세요.
    "version": "0.2.0",
    "configurations": [
        {
            "type": "chrome",
            "request": "launch",
            "name": "Launch Chrome against localhost",
            "url": "http://127.0.0.1:5500/dist",
            "webRoot": "${workspaceFolder}"
        }
    ]
}

Java의 Reflection을 이용한 API

이미 완성되어 컴파일까지 되어 배포된 프로그램에 대한 기능 확장을 위해서 Reflection API를 사용할 수 있습니다. 이 글은 이 목적을 위해 일단 필요한 코드를 정리한 글 입니다.

Java에서 어떤 타입에 대한 이름을 문자열로만 알고 있을 때, 그 타입의 인스턴스를 생성하기 위한 코드는 다음과 같습니다.

try {
	Class<?> clazz = Class.forName("tstConstructor.service.FirstService");
	//Class<?> clazz = tstConstructor.service.FirstService.class;
	
	Constructor<?> constructor = clazz.getConstructor(String.class);
	Serviceable instance = (Serviceable)constructor.newInstance("Jack");
	instance.run();
} catch (Exception e) {
	e.printStackTrace();
}	

위의 코드에서 FirstService 클래스의 코드는 다음과 같습니다.

package tstConstructor.service;

public class FirstService implements Serviceable {
	private String _name;
	
	public FirstService(String name) {
		this._name = name;
	}
	
	@Override
	public void run() {
		System.out.println("Hello, my name is " + this._name);
	}
}

또한 Serviceable 인터페이스의 코드는 다음과 같습니다.

package tstConstructor.service;

public interface Serviceable {
	void run();
}

또한 Java에서 어떤 타입에 대한 이름을 문자열로만 알고 있을 때, 그 타입의 정적 필드값을 얻기 위한 코드는 다음과 같습니다.

try {
	Class<?> clazz = Class.forName("tstConstructor.service.Gizmo");
	//Field field = Gizmo.class.getField("NAME");
	Field field = clazz.getField("NAME");
	Class<?> fieldType = field.getType();
	if(fieldType == String.class){
		System.out.println(field.get(null).toString());
	} else if(fieldType == double.class){
	    System.out.println(field.getDouble(null)); 
	}
} catch (Exception e) {
	e.printStackTrace();
}

Gizmo 클래스의 코드는 다음과 같습니다.

package tstConstructor.service;

public class Gizmo {
	public static String NAME = "GIZMO";
}

three.js에서 HDR 데이터를 이용한 배경 및 광원으로 사용하기

HDR은 High Dynamic Range의 약자로 밝은 부분과 어두운 부분의 차이를 효과적으로 보정하여 전반적으로 균형있게 출력할 수 있는 기술이라고 합니다. 다르게 설명하면 보다 더 사실적인 명암(밝기와 어두움)를 표현하기 위해 사용되는 기술이라고도 합니다. 아래의 연속된 3개의 이미지 중 가장 오른쪽이 HDR 기술이 적용된 이미지입니다. 왼쪽 이미지는땅 부분이 소실되었고 가운데 이미지는 하늘 부분이 소실되었습니다. 오른쪽 이미지는 HDR 기술을 적용해 만든 것으로 전반적으로 군형있게 표현되고 있습니다.

원래 일반적인 이미지 데이터는 가장 어두운 색의 값을 0, 가장 밝은 색의 값을 1이라는 범위로 정하고 모든 색상을 0~1사이로 맞춥니다. 하지만 HDR이 적용되어 만들어진 이미지 데이터는 0~1 사이의 한정된 범위가 아닌 훨씬 더 넓은 범위를 갖습니다. 즉 사용하는 데이터 크기가 더 큽니다. HDR 데이터의 크기만 봐도 수십에서 수백 MB가 되는 것을 보면 쉽게 이해할 수 있습니다.

three.js에서도 HDR 데이터를 사용할 수 있는데, 그 용도는 크게 두 가지입니다. 첫번째는 3차원 배경으로써의 사용과 두번째는 HDR 데이터 자체를 광원으로써 사용하는 것입니다. HDR 데이터는 인터넷에서 검색을 통해 쉽게 다운로드 받을 수 있습니다. 저 같은 경우 polyhaven이라는 사이트에서 데이터를 받았습니다.

three.js에서 HDR 데이터를 사용하는 주요 코드만을 정리하면 다음과 같습니다. 먼저 필요한 모듈과 HDR 데이터를 로딩하는 함수는 다음과 같습니다.

import { RGBELoader } from "../examples/jsm/loaders/RGBELoader.js"

class App {
    ...

    _setupBackground() {
        new RGBELoader()
            .load("./data/brown_photostudio_03_8k.hdr", (texture) => {
                texture.mapping = THREE.EquirectangularReflectionMapping;
                this._scene.background = texture; // 3차원 배경으로 사용
                this._scene.environment = texture; // 광원으로 사용

                //texture.dispose();
            }
        );
    }

    _setupModel() {
        const geometry = new THREE.TorusKnotGeometry(1, 0.3, 256, 64, 2, 3);
        // HDR을 광원으로 사용하기 위해서는 재질을 MeshStandardMaterial 또는 파생 객체를 사용해야 가능함
        const material = new THREE.MeshStandardMaterial({color: 0xffffff}); 

        const cube = new THREE.Mesh(geometry, material);
        this._scene.add(cube);
    }

    ...
}

위 코드에 대한 결과는 다음과 같습니다.

위의 이미지는 three.js에 어떠한 광원(Light) 객체도 장면에 추가하지 않은 결과입니다. 보시면 TorusKnot 모델이 너무 밝게 표현되어 백색으로 뭉게져 보입니다. 이는 톤맵핑 설정이 바르지 않기 때문입니다. 톤맵핑(Tone Mapping)은 HDR 영역의 색상 표현을 모니터의 색상 표현(SDR)으로 맵핑시켜준다는 개념입니다. 이를 위해 다음 코드가 필요합니다.

renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1;

결과는 다음과 같습니다.

toneMappingExposure 값을 적절하게 조정하면 밝기값이 조정됩니다.

HDR은 3차원 배경으로써 사용될 수도 있지만 광원에 대한 매우 효과적인 해결책입니다. 3차원 배경으로는 사용하지 않고 광원으로써 HDR를 사용하고자 한다면 this._scene.background = texture 코드를 제거하기만 하면 됩니다.

Node.js에서 독립적인 프로세스를 통한 연산 및 그 결과 얻기

독립적인 프로세스를 통해 얻는 장점은 여러 개의 CPU를 활용하여 보다 더 빠른 결과를 얻을 수 있다는 점인데, 단점은 CPU 간의 데이터 공유가 참조(Reference)가 아닌 복사(Copy)이므로 메모리 사용량이 2배가 된다는 점이다. 하지만 이러한 단점은 특정한 상황에서는 아무런 문제가 되지 않는 경우가 있으므로 Node.js에서 독립적인 프로세스를 이용한 연산은 매우 유용한 경우가 많다.

먼저 독립적인 프로세스에서 수행할 코드는 worker.js 파일에 다음처럼 작성한다.

const process = require("process");

function getSum(arr) {
    let sum = 0;
    arr.forEach(i => {  sum += i; });
    return sum;
}

process.on("message", (message) => {
    if(message.cmd === "sum") {
        process.send({ result: getSum(message.data) });
    }
});

getSum이라는 함수를 처리하며 다른 프로세스로부터 전달 받은 데이터(배열 형태) 안의 숫자 값들의 합계를 전달해 준다.

이 worker.js를 사용하는 소스는 다음과 같다.

const cp = require("child_process");
const worker = cp.fork("./worker.js");

function task(arr) {
    return new Promise((resolve, reject) => {
        function onMessage(message) {
            resolve(message.result);
            worker.removeListener("message", onMessage);
        }

        worker.addListener("message", onMessage);

        worker.send({ 
            cmd: "sum",
            data: arr
        });
    })
}

task([1,2,3,4,5,6,7,8,9,10])
    .then((result) => {
        console.log(result);
        worker.kill();
    })
    .catch(err => {
        console.log(err);
    });

4번 코드에서 독립적인 프로세스에서 수행된 결과를 얻기 위해 Promise API를 사용했다는 점. 20번 코드에서 Prmoise의 일반적인 처리를 위해 then, catch를 사용하여 정상적인 경우와 예외처리를 하고 있다는 점이 코드의 완결성을 느끼게 해준다.