three.js에서 경로(Path)를 따라 객체 이동시키기

구현하고자 하는 결과는 아래의 그림처럼 노란색 경로가 있고 빨간색 직육면체가 이 경로를 따라 자연스럽게 이동하는 것입니다.

먼저 제가 사용하는 three.js의 구성 중 거의 변경되지 않는 HTML과 CSS를 살펴보겠습니다. HTML은 다음과 같습니다.

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="style.css">
<script type="module" src="app.js" defer>
</head>
<body>
</body>
</html>
<!DOCTYPE html> <html> <head> <meta name="viewport" content="width=device-width, initial-scale=1"> <link rel="stylesheet" href="style.css"> <script type="module" src="app.js" defer> </head> <body> </body> </html>
<!DOCTYPE html>
<html>
    <head>
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <link rel="stylesheet" href="style.css">
        <script type="module" src="app.js" defer>
    </head>
    <body>
    </body>
</html> 

CSS는 다음과 같구요.

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
* {
outline: none;
padding: 0;
margin: 0;
}
* { outline: none; padding: 0; margin: 0; }
* {
    outline: none;
    padding: 0;
    margin: 0;
}

그리고 이제 app.js에 대한 코드를 살펴보겠습니다. 먼저 기초 코드입니다.

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
import * as THREE from 'https://cdnjs.cloudflare.com/ajax/libs/three.js/r126/three.module.min.js'
class App {
constructor() {
this._initialize();
}
_initialize() {
this.domWebGL = document.createElement('div');
document.body.appendChild(this.domWebGL);
let scene = new THREE.Scene();
let renderer = new THREE.WebGLRenderer();
renderer.setClearColor(0x000000, 1.0);
this.domWebGL.appendChild(renderer.domElement);
window.onresize = this.resize.bind(this);
this.renderer = renderer;
this.scene = scene;
this._setupModel();
this._setupLights()
this._setupCamera();
this.resize();
}
_setupModel() {
// 경로 및 직사각형 모델 구성
}
update(time) {
// 직사각형 모델을 경로에 따라 이동시킴
}
_setupLights() {
const light = new THREE.DirectionalLight(0xffffff, 1);
light.position.set(30, 50, 20);
this.scene.add(light);
}
_setupCamera() {
const fov = 60;
const aspect = 1;
const zNear = 0.1;
const zFar = 1000;
let camera = new THREE.PerspectiveCamera(fov, aspect, zNear, zFar);
camera.position.set(40, 40, 40).multiplyScalar(0.3);
camera.lookAt(0,-2,0);
this.scene.add(camera);
this.camera = camera;
}
render(time) {
requestAnimationFrame(this.render.bind(this));
this.update(time);
this.renderer.render(this.scene, this.camera);
}
resize() {
let camera = this.camera;
let renderer = this.renderer;
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
}
}
window.onload = function() {
(new App()).render(0);
}
import * as THREE from 'https://cdnjs.cloudflare.com/ajax/libs/three.js/r126/three.module.min.js' class App { constructor() { this._initialize(); } _initialize() { this.domWebGL = document.createElement('div'); document.body.appendChild(this.domWebGL); let scene = new THREE.Scene(); let renderer = new THREE.WebGLRenderer(); renderer.setClearColor(0x000000, 1.0); this.domWebGL.appendChild(renderer.domElement); window.onresize = this.resize.bind(this); this.renderer = renderer; this.scene = scene; this._setupModel(); this._setupLights() this._setupCamera(); this.resize(); } _setupModel() { // 경로 및 직사각형 모델 구성 } update(time) { // 직사각형 모델을 경로에 따라 이동시킴 } _setupLights() { const light = new THREE.DirectionalLight(0xffffff, 1); light.position.set(30, 50, 20); this.scene.add(light); } _setupCamera() { const fov = 60; const aspect = 1; const zNear = 0.1; const zFar = 1000; let camera = new THREE.PerspectiveCamera(fov, aspect, zNear, zFar); camera.position.set(40, 40, 40).multiplyScalar(0.3); camera.lookAt(0,-2,0); this.scene.add(camera); this.camera = camera; } render(time) { requestAnimationFrame(this.render.bind(this)); this.update(time); this.renderer.render(this.scene, this.camera); } resize() { let camera = this.camera; let renderer = this.renderer; renderer.setPixelRatio(window.devicePixelRatio); renderer.setSize(window.innerWidth, window.innerHeight); camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); } } window.onload = function() { (new App()).render(0); }
import * as THREE from 'https://cdnjs.cloudflare.com/ajax/libs/three.js/r126/three.module.min.js'

class App {
    constructor() {
        this._initialize();
    }

    _initialize() {
        this.domWebGL = document.createElement('div');
        document.body.appendChild(this.domWebGL);

        let scene = new THREE.Scene();
        let renderer = new THREE.WebGLRenderer();

        renderer.setClearColor(0x000000, 1.0);
        this.domWebGL.appendChild(renderer.domElement);  
        window.onresize = this.resize.bind(this); 
        
        this.renderer = renderer;
        this.scene = scene;

        this._setupModel();
        this._setupLights()
        this._setupCamera();

        this.resize();
    }

    _setupModel() {
        // 경로 및 직사각형 모델 구성
    }

    update(time) {
        // 직사각형 모델을 경로에 따라 이동시킴
    }

    _setupLights() {
        const light = new THREE.DirectionalLight(0xffffff, 1);
        light.position.set(30, 50, 20);
        this.scene.add(light);
    }

    _setupCamera() {
        const fov = 60;
        const aspect = 1;
        const zNear = 0.1;
        const zFar = 1000;
        
        let camera = new THREE.PerspectiveCamera(fov, aspect, zNear, zFar);

        camera.position.set(40, 40, 40).multiplyScalar(0.3);
        camera.lookAt(0,-2,0);
        
        this.scene.add(camera);
        this.camera = camera;
    }

    render(time) {
        requestAnimationFrame(this.render.bind(this));

        this.update(time);
        this.renderer.render(this.scene, this.camera);
    }

    resize() {
        let camera = this.camera;
        let renderer = this.renderer;
        
        renderer.setPixelRatio(window.devicePixelRatio);
        renderer.setSize(window.innerWidth, window.innerHeight);

        camera.aspect = window.innerWidth / window.innerHeight;
        camera.updateProjectionMatrix();
    }
}

window.onload = function() {
    (new App()).render(0);
}

위의 코드에서 경로와 정육면체 매쉬를 구성하는 _setupModel과 매쉬 모델을 움직이도록 속성값을 업데이트하는 update 함수는 아직 비어 있습니다.

모델을 구성하는 _setupModel 함수의 코드는 다음과 같습니다.

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
_setupModel() {
const path = new THREE.SplineCurve( [
new THREE.Vector2( 10, 5 ),
new THREE.Vector2( 5, 5 ),
new THREE.Vector2( 5, 10 ),
new THREE.Vector2( -5, 10 ),
new THREE.Vector2( -5, 5 ),
new THREE.Vector2( -10, 5 ),
new THREE.Vector2( -10, -5 ),
new THREE.Vector2( -5, -5 ),
new THREE.Vector2( -5, -10 ),
new THREE.Vector2( 5, -10 ),
new THREE.Vector2( 5, -5 ),
new THREE.Vector2( 10, -5 ),
new THREE.Vector2( 10, 5 ),
] );
this.path = path;
const points = path.getPoints( 100 );
const geometry = new THREE.BufferGeometry().setFromPoints( points );
const material = new THREE.LineBasicMaterial( { color : 0xffff00 } );
const pathLine = new THREE.Line( geometry, material );
pathLine.rotation.x = Math.PI * .5;
this.scene.add(pathLine);
const boxGeometry = new THREE.BoxGeometry(1, 1, 3);
const boxMaterial = new THREE.MeshPhongMaterial({color: 0xff0000});
const boxMesh = new THREE.Mesh(boxGeometry, boxMaterial);
this.scene.add(boxMesh);
this.boxMesh = boxMesh;
}
_setupModel() { const path = new THREE.SplineCurve( [ new THREE.Vector2( 10, 5 ), new THREE.Vector2( 5, 5 ), new THREE.Vector2( 5, 10 ), new THREE.Vector2( -5, 10 ), new THREE.Vector2( -5, 5 ), new THREE.Vector2( -10, 5 ), new THREE.Vector2( -10, -5 ), new THREE.Vector2( -5, -5 ), new THREE.Vector2( -5, -10 ), new THREE.Vector2( 5, -10 ), new THREE.Vector2( 5, -5 ), new THREE.Vector2( 10, -5 ), new THREE.Vector2( 10, 5 ), ] ); this.path = path; const points = path.getPoints( 100 ); const geometry = new THREE.BufferGeometry().setFromPoints( points ); const material = new THREE.LineBasicMaterial( { color : 0xffff00 } ); const pathLine = new THREE.Line( geometry, material ); pathLine.rotation.x = Math.PI * .5; this.scene.add(pathLine); const boxGeometry = new THREE.BoxGeometry(1, 1, 3); const boxMaterial = new THREE.MeshPhongMaterial({color: 0xff0000}); const boxMesh = new THREE.Mesh(boxGeometry, boxMaterial); this.scene.add(boxMesh); this.boxMesh = boxMesh; }
_setupModel() {
    const path = new THREE.SplineCurve( [
        new THREE.Vector2( 10, 5 ),
        new THREE.Vector2( 5, 5 ),
        new THREE.Vector2( 5, 10 ),
        new THREE.Vector2( -5, 10 ),
        new THREE.Vector2( -5, 5 ),
        new THREE.Vector2( -10, 5 ),
        new THREE.Vector2( -10, -5 ),
        new THREE.Vector2( -5, -5 ),
        new THREE.Vector2( -5, -10 ),
        new THREE.Vector2( 5, -10 ),
        new THREE.Vector2( 5, -5 ),
        new THREE.Vector2( 10, -5 ),
        new THREE.Vector2( 10, 5 ),
    ] );

    this.path = path;

    const points = path.getPoints( 100 );
    const geometry = new THREE.BufferGeometry().setFromPoints( points );
    const material = new THREE.LineBasicMaterial( { color : 0xffff00 } );
    const pathLine = new THREE.Line( geometry, material );
    pathLine.rotation.x = Math.PI * .5;
    this.scene.add(pathLine);

    const boxGeometry = new THREE.BoxGeometry(1, 1, 3);
    const boxMaterial = new THREE.MeshPhongMaterial({color: 0xff0000});
    const boxMesh = new THREE.Mesh(boxGeometry, boxMaterial);
    this.scene.add(boxMesh);

    this.boxMesh = boxMesh;
}

그리고 update 함수는 다음과 같습니다.

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
update(time) {
const boxTime = time * .0001;
const boxPosition = new THREE.Vector3();
const boxNextPosition = new THREE.Vector2();
this.path.getPointAt(boxTime % 1, boxPosition);
this.path.getPointAt((boxTime + 0.01) % 1, boxNextPosition);
this.boxMesh.position.set(boxPosition.x, 0, boxPosition.y);
this.boxMesh.lookAt(boxNextPosition.x, 0, boxNextPosition.y);
}
update(time) { const boxTime = time * .0001; const boxPosition = new THREE.Vector3(); const boxNextPosition = new THREE.Vector2(); this.path.getPointAt(boxTime % 1, boxPosition); this.path.getPointAt((boxTime + 0.01) % 1, boxNextPosition); this.boxMesh.position.set(boxPosition.x, 0, boxPosition.y); this.boxMesh.lookAt(boxNextPosition.x, 0, boxNextPosition.y); }
update(time) {
    const boxTime = time * .0001;

    const boxPosition = new THREE.Vector3();
    const boxNextPosition = new THREE.Vector2();
    
    this.path.getPointAt(boxTime % 1, boxPosition);
    this.path.getPointAt((boxTime + 0.01) % 1, boxNextPosition);
    
    this.boxMesh.position.set(boxPosition.x, 0, boxPosition.y);
    this.boxMesh.lookAt(boxNextPosition.x, 0, boxNextPosition.y);
}

위의 코드 중 7과 8번 라인의 getPointAt은 경로를 구성하는 좌표를 얻을 수 있는데, 이 함수의 첫번째 인자는 0에서 1사이의 값을 가질 수 있고 0일때 경로의 시작점 1일때 경로의 끝점을 얻을 수 있습니다.

three.js에서 한글 텍스트 렌더링하기

three.js에서 한글을 출력하기 위해서는 2가지 방식이 존재하는데, 첫째는 한글을 표현하는 도형에 대한 구성 좌표를 이용한 모델로 렌더링하는 방식과 둘째는 일단 표현하고자 하는 한글을 Canvas에 그린 뒤 이미지화하여 이 이미지를 사각형 모델에 텍스쳐 맵핑하는 방식이 있습니다.

이 글은 첫번째 방법에 대한 내용에 대한 코드를 설명합니다. 먼저 한글에 대한 도형을 구성하는 좌표가 필요한데 한글 폰트 파일에서 좌표를 추출하여 JSON으로 생성해 이 JSON 파일을 사용합니다. 이를 위해 TypeFace.js 사이트를 통해 원하는 결과를 얻을 수 있습니다.

이렇게 얻은 폰트의 JSON 파일을 이용해 모델을 생성하는 코드는 다음과 같습니다.

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
let fontLoader = new THREE.FontLoader();
fontLoader.load("Do Hyeon_Regular.json", (font) => {
let geometry = new THREE.TextGeometry(
"GIS Devloper, 김형준",
{
font: font,
size: 1,
height: 0,
curveSegments: 12
}
);
geometry.computeBoundingBox();
let xMid = -0.5 * ( geometry.boundingBox.max.x - geometry.boundingBox.min.x );
geometry.translate( xMid, 0, 0 );
let material = new THREE.MeshBasicMaterial({
color: 0xffffff,
wireframe: true
});
let mesh = new THREE.Mesh(geometry, material);
this.scene.add(mesh);
this.mesh = mesh;
this.render();
});
let fontLoader = new THREE.FontLoader(); fontLoader.load("Do Hyeon_Regular.json", (font) => { let geometry = new THREE.TextGeometry( "GIS Devloper, 김형준", { font: font, size: 1, height: 0, curveSegments: 12 } ); geometry.computeBoundingBox(); let xMid = -0.5 * ( geometry.boundingBox.max.x - geometry.boundingBox.min.x ); geometry.translate( xMid, 0, 0 ); let material = new THREE.MeshBasicMaterial({ color: 0xffffff, wireframe: true }); let mesh = new THREE.Mesh(geometry, material); this.scene.add(mesh); this.mesh = mesh; this.render(); });
let fontLoader = new THREE.FontLoader();
fontLoader.load("Do Hyeon_Regular.json", (font) => {
    let geometry = new THREE.TextGeometry(
        "GIS Devloper, 김형준",
        { 
            font: font,
            size: 1,
            height: 0,
            curveSegments: 12
        }
    );

    geometry.computeBoundingBox();
    let xMid = -0.5 * ( geometry.boundingBox.max.x - geometry.boundingBox.min.x );
    geometry.translate( xMid, 0, 0 );

    let material = new THREE.MeshBasicMaterial({
        color: 0xffffff, 
        wireframe: true
    });

    let mesh = new THREE.Mesh(geometry, material);
    
    this.scene.add(mesh);

    this.mesh = mesh;

    this.render();
});

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

위의 실행 결과를 얻기 위한 폰트 데이터 및 전체 코드를 다운로드 받을 수 있습니다.