Library:

Three.js로 도미노 만들기

칠일오. 2024. 9. 20. 15:25

1분 코딩님의 도미노 예제에서 조그마한 기능을 더 추가한 Three.js로 도미노 만드는 방법에 대해 기술해 보도록 하겠다.

 

완성된 모습

Default Setting

Three.js를 브라우저에 그리기 위해서는 기본이자 핵심 구성요소인 renderer, scene, camera, light를 정의해야 한다. 이에 대해 순차적으로 작성해 보겠다.

 

Renderer

브라우저(웹)에 3D 그래픽을 표현하기 위해서는 WebGL이 필요하다. 그러나 까다로운 점이 많은 WebGL을 대신하여, 이를 쉽게 사용하기 위한 프레임워크나 라이브러리가 개발되었고, 그중 Three.js를 꼽을 수 있다. Three.js를 사용할 때 가장 첫 번째 단계가 바로 이 WebGL을 이용하여 canvas라는 도화지를 3D 렌더링 해주는 것이다. Three.js의  내장 메서드인 WebGLRenderer를 이용하면 굉장히 쉽다.

// Renderer
const canvas = document.querySelector('#three-canvas');
const renderer = new THREE.WebGLRenderer({
    canvas,
    antialias: true
});

 

WebGLRenderer의 매개변수 종류가 궁금하다면 아래 링크에서 확인할 수 있다.

👉 [three.js 공식문서] WebGLRenderer

 

브라우저의 크기만큼 canvas가 차지할 수 있도록 정의한다. 참고로 setSize는 WebGLRenderer의 내장 객체이다.

renderer.setSize(window.innerWidth, window.innerHeight);

고해상도 기기를 만날 경우, 픽셀 밀도가 낮으면 흐리게 보이는 것처럼 캔버스의 화질이 떨어지게 된다. 때문에 이에 대비하여 픽셀의 밀도가 1 이상이면, 즉 고해상도일 경우에는 픽셀 비율을 2배로 주어 더 선명한 화면을 보여줄 수 있도록 한다.

renderer.setPixelRatio(window.devicePixelRatio > 1 ? 2 : 1);
Window: devicePixelRatio
값이 1이면 기존 96 DPI (일부 플랫폼에서는 76 DPI) 디스플레이를 의미하며, 값이 2이면 HiDPI/Retina 디스플레이로 예측됩니다.

WebGLRenderer: setPixelRatio ( value : number )
Sets device pixel ratio. This is usually used for HiDPI device to prevent blurring output canvas.

 

sence에 올라간 모든 요소들에 그림자가 생기도록 설정할 수도 있다. 그림자는 선택 사항이지만 좀 더 사실적인 표현이 가능해진다. 이때 기본 값( THREE.PCFShadowMap )보다 그림자의 경계를 더 부드럽게 처리할 수 있는 타입을 지정할 수도 있다.

renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;

renderer를 그려진 그림을 웹에 띄워주는 역할을 해주는 것이라고 한다면, 이제는 도화지에 실제로 그림을 그려보자. 

 

Scene

3D 모델이 올라갈 무대 공간을 만든다.

// Scene
const scene = new THREE.Scene();

 

Camera

3D 모델을 담아낼 카메라도 필요하다. 실컷 3D 모델을 만들어도 무대에 카메라를 잘못 설치한다면, 볼 수 없게 되는 것이다. 현실에서도 카메라의 종류가 다양하듯이, three.js에서도 다양한 카메라들이 존재하지만, 가장 많이 사용되는 PerspectiveCamera를 사용하였다. 사람의 눈과 가장 유사한 카메라이다.

// Camera
const camera = new THREE.PerspectiveCamera(
    100,
    window.innerWidth / window.innerHeight,
    0.1,
    1000
);
camera.position.y = 1.5;
camera.position.z = 4;
scene.add(camera);

매개변수에 대한 설명은 예전 포스트에서 이미 작성하였기 때문에 생략하겠다.

👉 [Three.js] To infinity and beyond! 3D 웹 도전기 #1

 

단순히 카메라만 놓으면 카메라가 바라보는 고정된 시야만 볼 수 있다. 만약 정말 우리의 눈처럼 카메라의 각도나 위치를 자유자재로 조정(유연하게 동작)하고 싶다면 orbitControls를 사용하여 해결할 수 있다. three.js의 내장 메서드는 아니기 때문에 import 해와야 한다.

import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls';

const controls = new OrbitControls(camera, renderer.domElement);
console.log(renderer.domElement) // canvas#three-canvas

첫 번째 매개변수는 제어할 카메라 객체를 받고, 두 번째 매개변수는 이벤트가 적용될 객체를 받는다. renderer.domElement를 console.log로 찍어보면 cavas 요소를 가리키는 것을 알 수 있다.

 

Light

불 꺼진 방과 같은 무대에 조명도 설치해 주자.

// Light
const ambientLight = new THREE.AmbientLight('white', 0.5);
scene.add(ambientLight);

const directionalLight = new THREE.DirectionalLight('white', 1);
directionalLight.position.x = 1;
directionalLight.position.z = 2;
directionalLight.castShadow = true;
scene.add(directionalLight);

 

참고로 어둠에서는 그림자가 없는 것처럼 빛이 있어야 renderer에 설정한 그림자들도 제대로 동작할 것이다.

👉 [Three.js] To infinity and beyond! 3D 웹 도전기 #6

 

도미노 만들기

우선, 도미노 mesh를 놓을 평면 mesh부터 만들어보자.

// Mesh
const floorMesh = new THREE.Mesh(
    new THREE.PlaneGeometry(100, 100),
    new THREE.MeshStandardMaterial({
        color: 'slategray'
    })
);
floorMesh.rotation.x = -Math.PI / 2;
floorMesh.receiveShadow = true;
scene.add(floorMesh);

도미노 mesh의 경우, 여러 개를 생성해야 하기 때문에 클래스로 만들어서 찍어내는 방식으로 만들면 훨씬 깔끔하게 구현할 수 있다. 클래스를 정의하기 전에 인스턴스를 먼저 작성하면 어떤 값이 필요한지 훨씬 더 명확해지기 때문에 클래스를 작성하기가 더 쉬워진다.

// 도미노 생성
const dominos = [];
let domino;
for (let i = -3; i < 17; i++) {
    domino = new Domino({
        index: i,
        scene,
        cannonWorld,
        gltfLoader,
        z: -i * 0.8 // -방향으로 도미노가 세워지도록
    });
    dominos.push(domino);
}

반복문을 통해 원하는 개수만큼 인스턴스를 생성하고, 배열에 넣어준다. 이때 i 값을 -3으로 한 이유는 0 값을 주었을 때, floorMesh 기준에서 너무 뒤 쪽에 도미노 mesh들이 위치하여 임의로 -3을 주었다. 

 

Domino.js라는 파일을 만들어서 도미노 mesh의 속성을 담은 클래스를 만들어 주자. 만약 도미노 mesh를 glTF 형식으로 넣고자 한다면, 아래 코드를 추가해야 한다.

// Loader
const gltfLoader = new GLTFLoader();

현재는 주석 처리되어있지만, 나의 경우 블렌더로 만든 3D 객체를 사용하기 전에 Three.js에서 제공하는 기본 메서드를 이용해서 mesh를 만들어서 테스트한 후에 교체하였다.

// import { Mesh, BoxGeometry, MeshStandardMaterial } from 'three';

export class Domino {
    constructor(info) {
        this.scene = info.scene;
        
        this.width = info.width || 0.6;
        this.height = info.height || 1;
        this.depth = info.depth || 0.2;

        this.x = info.x || 0;
        this.y = info.y || 0.5;
        this.z = info.z || 0;

        this.rotationY = info.rotationY || 0;

        this.defaultMaterial = info.defaultMaterial;

        // 방식 1) Blender로 만든 3D 객체 모델
        info.gltfLoader.load(
            '/models/domino.glb',
            glb => {
                this.modelMesh = glb.scene.children[0];
                this.modelMesh.name = `(${info.index}) DOMINO`; // 각 도미노 이름 명시
                this.modelMesh.castShadow = true;
                this.modelMesh.position.set(this.x, this.y, this.z);
                this.scene.add(this.modelMesh);

                this.setCannonBody();
            }
        );

        // 방식 2) Three.js 내장 메서드를 이용하여 만든 Mesh
        // const boxGeometry = new BoxGeometry(0.6, 1, 0.2);
        // const boxMaterial = new MeshStandardMaterial({
        //     color: 'orange'
        // });
        // const boxMesh = new Mesh(boxGeometry, boxMaterial);
        // this.modelMesh = boxMesh;
        // this.modelMesh.name = `(${info.index}) DOMINO`;
        // this.modelMesh.castShadow = true;
        // this.modelMesh.position.set(this.x, this.y, this.z);

        // this.scene.add(this.modelMesh);

        // this.setCannonBody();
    }
}

+) mesh에서 blender로 만든 glb 파일을 연결해 보니 모델이 자꾸 측면 방향으로 놓였다. blender에서 mesh의 중심축과 scale을 재정의해주었더니 정상적으로 출력이 되었다. 정확한 원인 파악은 실패하였지만, blender에서 작업할 때 항상 방향이나 크기를 신경 쓰면서 제작해야겠다는 교훈을 얻었다...ㅎ

 

도미노 쓰러뜨리기 (물리엔진 적용)

이제 mesh가 만들어졌으니 물리엔진을 적용해 보자.

main.js 파일로 돌아와서 Cannon 라이브러리를 사용하여 아래와 같이 작성하였다.

// Cannon(물리 엔진)
const cannonWorld = new CANNON.World();
cannonWorld.gravity.set(0, -9.82, 0);

// 성능을 위한 세팅
cannonWorld.broadphase = new CANNON.SAPBroadphase(cannonWorld);

// Contact Material
const defaultMaterial = new CANNON.Material('default');

const defaultContactMaterial = new CANNON.ContactMaterial(
    defaultMaterial,
    defaultMaterial,
    {
        friction: 0.01,
        restitution: 0.9
    }
);
cannonWorld.defaultContactMaterial = defaultContactMaterial;

어떤 재질 느낌으로 표현할지, 마찰의 정도, 반발의 정도를 지정하여 표현하고 싶은 힘의 정도를 정의하였다. 마찰력 커질수록 물체가 받는 힘이 강해진다. 즉 무거워지기 때문에 느려진다. 반대로 적을수록 가벼워지기 때문에 빨라진다. 반발력은 물체끼리 부딪힐 때의 액션의 크기, 반응의 정도로 풀어 설명할 수 있을 것 같다. 도미노의 경우 빠르게 연속적으로 쓰러져야 하기 때문에 마찰력은 낮추고 반발력은 높여서 어느 정도 물체끼리 닿으면 쉽게 쓰러질 수 있도록 만들어준다.

 

앞선 포스트에서 언급했듯이, 보이지는 않지만 mesh가 물리엔진을 따라감으로써 시각적으로 표현이 되는 것이기 때문에 물리엔진의 body에도 mesh 형태를 가지고 있어야 한다.

const floorShape = new CANNON.Plane();
const floorBody = new CANNON.Body({
    mass: 0,
    position: new CANNON.Vec3(0, 0, 0),
    shape: floorShape,
    material: defaultMaterial
});
floorBody.quaternion.setFromAxisAngle(
    new CANNON.Vec3(-1, 0, 0),
    Math.PI / 2
);
cannonWorld.addBody(floorBody);

바닥 역할을 할 floorBody의 경우, 물리 작용을 일으킬 요소가 아닌 단순히 도미노 mesh들을 놓을 공간이기 때문에 정적으로 만들어주어야 하기 때문에 mass에 0 값을 주었다.

 

Domino.js 파일로 이동하여 도미노 mesh 형태도 cannon에 추가해 준다.

setCannonBody() {
    const shape = new Box(new Vec3(this.width / 2, this.height / 2, this.depth / 2));

    this.cannonBody = new Body({
        mass: 1,
        position: new Vec3(this.x, this.y, this.z),
        shape
    });

    this.cannonBody.quaternion.setFromAxisAngle(
        new Vec3(0, 1, 0), // y축
        this.rotationY
    );

    // 힘 전달
    this.modelMesh.cannonBody = this.cannonBody;

    this.cannonWorld.addBody(this.cannonBody);
}

 

실시간 업데이트

실시간으로 업데이트가 되면서 그려지도록 draw 함수를 작성하였다. 매 프레임마다 업데이트가 발생하여 보다 부드러운 애니메이션이 구현된다. 출력할 scene과 camera를 WebGL에 그려주어야 화면에 나타난다!

해당 함수 내부에 도미노 mesh가 cannon body를 따라가도록 연결해 준다.

// 그리기
const clock = new THREE.Clock();

function draw() {
    const delta = clock.getDelta();

    cannonWorld.step(1/60, delta, 3);

    // 물리현상 업데이트
    dominos.forEach(item => {
        if (item.cannonBody) {
            item.modelMesh.position.copy(item.cannonBody.position);
            item.modelMesh.quaternion.copy(item.cannonBody.quaternion);
        }
    });

    renderer.render(scene, camera);
    renderer.setAnimationLoop(draw);
}

 

광선에 따라 mesh 선택하기 (+쓰러지는 방향 정하기)

각 도미노 mesh에 물리작용은 일어나겠지만, 임의의 힘을 가했을 때(도미노를 미는 행위)의 힘에 대해서는 정의하지 않았다. 광선을 우리의 손이라 생각하고, 클릭하면 광선을 쏘아 해당 mesh부터 쓰러지도록 만들어보자.

 

카메라에서 광선을 쏘아서 이를 맞은 도미노는 쓰러지도록 작성된 코드이다.

// Raycaster
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();

function checkIntersects() {
    raycaster.setFromCamera(mouse, camera);
    ...
}

마우스의 좌표를 기준으로 카메라가 바라보는 방향으로 광선을 쏜다.

 

예제를 진행하다 보니 광선을 맞은 면에 따라 힘이 작용되었으면 좋겠다는 생각을 해서, 추가한 코드이다. 카메라의 방향, 위치에 따라 광선을 쏘는 원리를 이용해서 특정 범위로 구분하여 해당 조건에 따라 반응할 액션을 정의해 보았다.

function checkIntersects() {
    const direction = new THREE.Vector3();
    camera.getWorldDirection(direction);

    const intersects = raycaster.intersectObjects(scene.children);
    console.log(intersects[0].object.name);

    // 힘 속성 정의
    for (const item of intersects) {
        if (item.object.cannonBody) {
            if(direction.x < 0.1 && direction.x > -0.1) {
                item.object.cannonBody.applyForce(
                    new CANNON.Vec3(0, 0, -100),
                    new CANNON.Vec3(0, 0, 0)
                );
                break;
            } else if(direction.x > 0) {
                item.object.cannonBody.applyForce(
                    new CANNON.Vec3(100, 0, 0),
                    new CANNON.Vec3(0, 0, 0)
                );
                break;
            } else if(direction.x < 0) {
                item.object.cannonBody.applyForce(
                    new CANNON.Vec3(-100, 0, 0),
                    new CANNON.Vec3(0, 0, 0)
                );
                break;
            } 
        }
    }
}

scene에 있는 모든 자식 요소들 중에서 광선을 맞은 객체들은 intersects 배열에 할당된다. 이 배열을 이용하여 순회하면서 왼쪽에서 힘을 가하면(왼쪽에서 광선을 쏘면) +x 축 방향으로 쓰러지고, 오른쪽에서 힘을 가하면 -x 축 방향으로 쓰러지고, 중심에서 밀면 기존의 도미노처럼 동작하도록 만들었다.

 

첫 번째 요소가 광선을 맞으면 반복문이 종료되어야 하기 때문에 break문을 작성해 주어야 한다.

 

이벤트 실행

canvas.addEventListener('click', e => {
    if (preventDragClick.mouseMoved) return;

    mouse.x = e.clientX / canvas.clientWidth * 2 - 1;
    mouse.y = -(e.clientY / canvas.clientHeight * 2 - 1);

    checkIntersects();
});

const preventDragClick = new PreventDragClick(canvas);

preventDragClick은 마우스의 동작이 드래그인지, 클릭인지 구분하여 이벤트 실행 여부를 제어한다. 자세한 설명은 아래 글에서 확인할 수 있다.

👉 [Three.js] To infinity and beyond! 3D 웹 도전기 #7

 

클릭한 지점의 좌표를 -1 ~ 1까지의 범위로 재정의해서 각 mouse.x, mouse.y에 재할당해 주었다. (raycaster에서 사용)

 

마지막으로 draw 함수를 호출해 주면 완성이다!

draw();

 

아래는 좌우로 밀어 보기도 하고, 정면으로 밀어보았을 때의 모습이다! 

사실 1분 코딩님 따라한 게 전부라서 스스로 해냈다고 보기는 어렵지만... 그래도 원하는 결과물이 나와서 뿌듯하다!