matter.js를 이용한 강체 시뮬레이션

matter.js 라이브러리를 이용하여 구현하고자 하는 결과는 아래와 같습니다.

먼저 HTML 코드입니다.

<!DOCTYPE html>
<html>
<head>
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link rel="stylesheet" href="style_matter.js.css">
    <script src="decomp.js"></script>
    <script src="pathseg.js"></script>
    <script src="matter.0.16.1.js"></script>
    <script src="app_matter.js.js" defer></script>
</head>
<body>
    <div></div>
</body>
</html> 

지형은 SVG 데이터를 통해 좌표를 뽑아오는데 이를 위해 matter.js 이외에도 decomp.js와 pathseg.js 라이브러리가 필요합니다. div 요소에 강체 시뮬레이션의 결과가 표시됩니다. 다음은 CSS입니다.

* {
    outline: none;
    padding: 0;
    margin: 0;
}
    
body {
    display: flex;
    justify-content: center;
    align-items: center;
    width: 100%;
    height: 100vh;
}

div 요소를 화면 중심에 놓기 위한 것이 전부입니다. 이제 js 코드를 살펴보겠습니다.

먼저 아래의 코드로 기본 코드를 작성합니다.

const engine = Matter.Engine.create();
const world = engine.world;

const render = Matter.Render.create({
    element: document.querySelector("div"),
    engine: engine,
    options: {
        width: 800,
        height: 600,
        wireframes: false,
        background: 'black'
    }
});

Matter.Render.run(render);

const runner = Matter.Runner.create();
Matter.Runner.run(runner, engine);

다음으로 지형과 지형 속에 사각형 물체를 구성하는 코드입니다.

fetch("./terrain.svg")
    .then((response) => { return response.text(); })
    .then((raw) => { return (new window.DOMParser()).parseFromString(raw, "image/svg+xml"); })
    .then(function(root) {
        const paths = Array.prototype.slice.call(root.querySelectorAll('path'));
        const vertices = paths.map((path) => { return Matter.Svg.pathToVertices(path, 30); });
        const terrain = Matter.Bodies.fromVertices(400, 350, vertices, {
            isStatic: true,
            render: {
                fillStyle: '#2c3e50',
                strokeStyle: '#2c3e50',
                lineWidth: 1,
            }
        }, true);

        Matter.World.add(world, terrain);

        const bodyOptions = {
            frictionAir: 0.1, 
            friction: 0.5,
            restitution: 0.1
        };
        
        Matter.World.add(world, Matter.Composites.stack(100, 200, 40, 10, 15, 15, (x, y) => {
            if (Matter.Query.point([terrain], { x: x, y: y }).length === 0) {
                return Matter.Bodies.polygon(x, y, 4, 10, bodyOptions);
            }
        }));
    }
);

끝으로 마우스를 통해 사각형 객체를 드레그하여 옮길 수 있도록 합니다.

const mouse = Matter.Mouse.create(render.canvas),
mouseConstraint = Matter.MouseConstraint.create(engine, {
    mouse: mouse,
    constraint: {
        stiffness: 0.2,
        render: {
            visible: false
        }
    }
});

Matter.World.add(world, mouseConstraint);

matter.js는 매우 정교한 강체 시뮬레이션에는 적합하지 않으나 시각적인 면에서 다양한 물리 효과를 2차원에서 폭넓게 응용할 수 있다는 점이 매우 큰 장점입니다.

아래는 필요한 코드와 데이터 전체를 다운로드 받을 수 있는 링크입니다.

Javascript 객체(object)의 키(key)와 값(value)을 배열로 얻기

만약 다음과 같은 자바스크립트 객체가 있다고 할 때..

const obj = {
  a: 'Dip2K',
  b: 30,
  c: true,
  d: {}
};

obj 객체를 구성하는 전체 키를 배열로 얻는 코드는 다음과 같습니다.

console.log(Object.keys(obj)); 
// Array ["a", "b", "c", "d"]

다시 obj 객체를 구성하는 전체 값을 배열로 얻는 코드는 다음과 같구요.

console.log(Object.values(obj)); 
// Array ["Dip2K", 30, true, Object {  }]

배열에 대해서 각 구성 항목을 참조하는 코드는 다음과 같습니다.

for(i of Object.values(obj)) {
    console.log(i);
}

/* 
"Dip2K"
30
true
Object {  }
*/

three.js에서 기본 정육면체(BoxGeometry)에 텍스쳐 맵핑하기

three.js에서 제공하는 기본 정육면체에 대해 텍스쳐 맵핑을 하는 코드는 다음과 같습니다.

const geometry = new THREE.BoxGeometry(1, 1, 1);
const loader = new THREE.TextureLoader();
const material = new THREE.MeshBasicMaterial({
    map: loader.load("flower-5.jpg", undefined, undefined, function(err) {
        alert('Error');
    }),
});
const cube = new THREE.Mesh(geometry, material);
this.scene.add(cube);

실행하면 다음과 같은 결과를 얻을 수 있습니다.

그런데 이 THREE.BoxGeometry는 각 면에 대해 다른 텍스쳐 맵핑을 지정할 수 있습니다. 아래처럼요.

const geometry = new THREE.BoxGeometry(1, 1, 1);
const loader = new THREE.TextureLoader();
const materials = [
    new THREE.MeshBasicMaterial({ map: loader.load("flower-1.jpg") }),
    new THREE.MeshBasicMaterial({ map: loader.load("flower-2.jpg") }),
    new THREE.MeshBasicMaterial({ map: loader.load("flower-3.jpg") }),
    new THREE.MeshBasicMaterial({ map: loader.load("flower-4.jpg") }),
    new THREE.MeshBasicMaterial({ map: loader.load("flower-5.jpg") }),
    new THREE.MeshBasicMaterial({ map: loader.load("flower-6.jpg") }),
];

const cube = new THREE.Mesh(geometry, materials);
this.scene.add(cube);

결과는 다음과 같습니다.

이 글은 three.js의 전체 코드가 아닌 정육면체에 텍스쳐 맵핑에 대한 코드만을 언급하고 있습니다. 전체 코드에 대한 뼈대는 아래 글을 참고 하시기 바랍니다. 위의 코드들은 모두 _setupModel 함수의 코드입니다.

three.js start project 코드

연기처럼 사라지는 텍스트 효과

먼저 이 글의 내용은 Online Tutorials라는 유튜브 채널에 올라온 내용을 보고 작성한 글입니다. 완전이 같지는 않으나 거의 대부분의 기술적인 내용은 이 채널의 내용을 토대로 합니다. 매우 좋은 채널이므로 웹 개발자라면 참고해 보시면 좋을 것 같습니다.

만들어진 최종 결과는 아래 영상과 같습니다.

위의 결과에 대한 코드를 살펴보면.. 먼저 아래처럼 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>
        <div>
            <p class=text>산모퉁이를 돌아 논가 외딴 우물을 홀로 찾아가선 가만히 들여다봅니다.우물 속에는 달이 밝고 구름이 흐르고 하늘이 펼치고 파아란 바람이 불고 가을이 있습니다. 그리고 한 사나이가 있습니다. 어쩐지 그 사나이가 미워져 돌아갑니다. 돌아가다 생각하니 그 사나이가 가엾어집니다. 도로 가 들여다보니 사나이는 그대로 있습니다. 다시 그 사나이가 미워져 돌아갑니다. 돌아가다 생각하니 그 사나이가 그리워집니다. 우물 속에는 달이 밝고 구름이 흐르고 하늘이 펼치고 파아란 바람이 불고 가을이 있고 추억처럼 사나이가 있습니다.| 윤동주의 자화상</p>
        </div>
    </body>
</html> 

다음은 CSS의 일부입니다.

* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

div {
    position: relative;
    display: flex;
    justify-content: center;
    align-items: center;
    min-height: 100vh;
    background: #000;
}

div .text {
    color: #fff;
    margin: 100px;
    user-select: none;
    font-size: 1.5em;
}

이제 자바스크립트 코드를 작성하는데요. 아래와 같습니다.

const text = document.querySelector("div .text");
text.innerHTML = text.textContent.replace(/\S/g, "$&");

document.querySelectorAll("div .text span").forEach((letter) => {
    letter.addEventListener("mouseover", () => {
        letter.classList.add("active");
    });
});

코드의 컨셉은.. 먼저 글자 하나하나를 span 요소로 만들고, 마우스의 커서가 span 요소에 올라가면 해당 요소에 active 클래스를 지정하는 것이 전부입니다. 이제 span 요소와 active 클래스에 대한 CSS를 살펴보면 다음과 같습니다.

div .text span {
    padding: 2px;
    display: inline-block;
}

div .text span.active {
    animation: smoke 5s linear forwards;
    transform-origin: bottom;
}

@keyframes smoke {
    0% {
        pointer-events: none;
        opacity: 1;
        z-index: -1;
    }
    50% {
        opacity: 1;
    }
    100% {
        opacity: 0;
        visibility: hidden;
        filter: blur(20px);
        transform: translateX(100px) translateY(-300px) rotate(720deg) scale(6);
    }
}

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

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

먼저 제가 사용하는 three.js의 구성 중 거의 변경되지 않는 HTML과 CSS를 살펴보겠습니다. 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는 다음과 같구요.

* {
    outline: none;
    padding: 0;
    margin: 0;
}

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

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 함수의 코드는 다음과 같습니다.

_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 함수는 다음과 같습니다.

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일때 경로의 끝점을 얻을 수 있습니다.