Technology[Front]

3D맵 구현하기 - THREE.js 구조

Thunderland 2022. 2. 17. 11:43

웹환경 하에서 3D 맵을 구현하기 위해 자바스크립트 라이브러리 중 하나인 THREE.js에 대해 알아보겠습니다.

(1) THREE.js

import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.118/build/three.module.js';
import {FBXLoader} from 'https://cdn.jsdelivr.net/npm/three@0.118.1/examples/jsm/loaders/FBXLoader.js';
import {GLTFLoader} from 'https://cdn.jsdelivr.net/npm/three@0.118.1/examples/jsm/loaders/GLTFLoader.js';
import {OrbitControls} from 'https://cdn.jsdelivr.net/npm/three@0.118.1/examples/jsm/controls/OrbitControls.js';

let _APP = null;

window.addEventListener('DOMContentLoaded', () => {
  _APP = new ThirdPersonCameraDemo();
});

class ThirdPersonCameraDemo {
  constructor() {
    this._Initialize();
  }

  _Initialize() {

    this._threejs = new THREE.WebGLRenderer({
      antialias: true,
    });
    this._threejs.outputEncoding = THREE.sRGBEncoding;
    this._threejs.shadowMap.enabled = true;
    this._threejs.shadowMap.type = THREE.PCFSoftShadowMap;
    this._threejs.setPixelRatio(window.devicePixelRatio);
    this._threejs.setSize(window.innerWidth, window.innerHeight);

    document.body.appendChild(this._threejs.domElement);

    window.addEventListener('resize', () => {
      this._OnWindowResize();
    }, false);
    
// 게임창 크기 사이즈  설정
  _OnWindowResize() {
    this._camera.aspect = window.innerWidth / window.innerHeight;
    this._camera.updateProjectionMatrix();
    this._threejs.setSize(window.innerWidth, window.innerHeight);
  }
  
// 카메라 설정
    this._camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
   
// Scene 생성
    this._scene = new THREE.Scene();
    
// OrbitControls
    this._setupControls();
    
    _setupControls() {
    new OrbitControls(this._camera, this._threejs.domElement);
  }
    
//라이트 설정
    let light = new THREE.DirectionalLight(0xFFFFFF, 1.0);
    this._scene.add(light);
    
//Background 설정
    const loader = new THREE.CubeTextureLoader();
    const texture = loader.load([
        './resources/posx.jpg',
        './resources/negx.jpg',
        './resources/posy.jpg',
        './resources/negy.jpg',
        './resources/posz.jpg',
        './resources/negz.jpg',
    ]);
    texture.encoding = THREE.sRGBEncoding;
    this._scene.background = texture;
    
//지면 설정
    const plane = new THREE.Mesh(
        new THREE.PlaneGeometry(100, 100, 10, 10),
        new THREE.MeshStandardMaterial({
            color: 0x808080,
          }));
    this._scene.add(plane);

    this._mixers = [];
    this._previousRAF = null;

// 애니메이션 모델 설정
    this._LoadAnimatedModel();
    
// update(rerendering)
    this._RAF();
  }

  _LoadAnimatedModel() {
    const params = {
      camera: this._camera,
      scene: this._scene,
    }
    this._controls = new BasicCharacterController(params);

    this._thirdPersonCamera = new ThirdPersonCamera({
      camera: this._camera,
      target: this._controls,
    });
  }

  _RAF() {
    requestAnimationFrame((t) => {
      if (this._previousRAF === null) {
        this._previousRAF = t;
      }

      this._RAF();

      this._threejs.render(this._scene, this._camera);
      this._Step(t - this._previousRAF);
      this._previousRAF = t;
    });
  }

  _Step(timeElapsed) {
    const timeElapsedS = timeElapsed * 0.001;
    if (this._mixers) {
      this._mixers.map(m => m.update(timeElapsedS));
    }

    if (this._controls) {
      this._controls.Update(timeElapsedS);
    }

    this._thirdPersonCamera.Update(timeElapsedS);
  }
}

class BasicCharacterController {
  constructor(params) {
    this._Init(params);
  }

  _Init(params) {
    this._params = params;
    this._animations = {};
    
    this._input = new BasicCharacterControllerInput();
    this._stateMachine = new CharacterFSM(
        new BasicCharacterControllerProxy(this._animations));

    this._LoadModels();
  }
  _LoadModels() {

// 애니메이션 캐릭터 설정
    const loader = new FBXLoader();
    
    loader.setPath('./resources/remy/');
    loader.load('remy.fbx', (fbx) => {
      fbx.traverse(c => {
        c.castShadow = true;
      });
      this._target = fbx;
      this._params.scene.add(this._target);

      const loader = new FBXLoader(this._manager);
      loader.setPath('./resources/remy/');
      loader.load('idle.fbx', (a) => { _OnLoad('idle', a); });
    });
    
// 폰트 설정
    const Font_loader = new THREE.FontLoader();

    Font_loader.load("./data/NanumMyeongjo_Regular.json", (font) => {
      const geometry = new THREE.TextGeometry("#1", {
        font : font,
      });
      
    const material = new THREE.MeshStandardMaterial({
      color : "#689F38",
    });

    const mesh = new THREE.Mesh(geometry, material);
    this._params.scene.add(mesh);
    });
  }

  get Position() {
    return this._position;
  }

  get Rotation() {
    if (!this._target) {
      return new THREE.Quaternion();
    }
    return this._target.quaternion;
  }

  Update(timeInSeconds) {
    if (!this._stateMachine._currentState) {
      return;
    }
    this._stateMachine.Update(timeInSeconds, this._input);
    if (this._mixer) {
      this._mixer.update(timeInSeconds);
    }
  }
};

class BasicCharacterControllerInput {
  constructor() {
    this._Init();    
  }

  _Init() {
    this._keys = {
      forward: false,
      backward: false,
      left: false,
      right: false,
      space: false,
      shift: false,
    };
    document.addEventListener('keydown', (e) => this._onKeyDown(e), false);
    document.addEventListener('keyup', (e) => this._onKeyUp(e), false);
  }

  _onKeyDown(event) {
    switch (event.keyCode) {
      case 87: // w
        this._keys.forward = true;
        break;
    }
  }

  _onKeyUp(event) {
    switch(event.keyCode) {
      case 87: // w
        this._keys.forward = false;
        break;
    }
  }
};

class BasicCharacterControllerProxy {
  constructor(animations) {
    this._animations = animations;
  }

  get animations() {
    return this._animations;
  }
};

class CharacterFSM extends FiniteStateMachine {
  constructor(proxy) {
    super();
    this._proxy = proxy;
    this._Init();
  }

  _Init() {
    this._AddState('idle', IdleState);
  }
};


class FiniteStateMachine {
  constructor() {
    this._states = {};
    this._currentState = null;
  }

  _AddState(name, type) {
    this._states[name] = type;
  }

  SetState(name) {
    const prevState = this._currentState;
  }

  Update(timeElapsed, input) {
    if (this._currentState) {
      this._currentState.Update(timeElapsed, input);
    }
  }
};

class State {
  constructor(parent) {
    this._parent = parent;
  }

  Enter() {}
  Exit() {}
  Update() {}
};

class IdleState extends State {
  constructor(parent) {
    super(parent);
  }

  get Name() {
    return 'idle';
  }

  Enter(prevState) {
    const idleAction = this._parent._proxy._animations['idle'].action;
    if (prevState) {
      const prevAction = this._parent._proxy._animations[prevState.Name].action;
      idleAction.play();
    } else {
      idleAction.play();
    }
  }

  Exit() {
  }

  Update(_, input) {
    if (input._keys.forward || input._keys.backward) {
      this._parent.SetState('walk');
    } else if (input._keys.space) {
      this._parent.SetState('dance');
    }
  }
};

class ThirdPersonCamera {
  constructor(params) {
    this._params = params;
    this._camera = params.camera;
  }

  _CalculateIdealOffset() {
    const idealOffset = new THREE.Vector3(-10, 10, -20); //3인칭 시점
    // const idealOffset = new THREE.Vector3(0, 10, 0); //1인칭 시점
    return idealOffset;
  }

  _CalculateIdealLookat() {
    const idealLookat = new THREE.Vector3(0, 10, 50);
    return idealLookat;
  }

  Update(timeElapsed) {
    const idealOffset = this._CalculateIdealOffset();
    const idealLookat = this._CalculateIdealLookat();
    this._camera.lookAt(this._currentLookat);
  }
}

위의 코드는 실제 프로젝트에서 일부 발췌한 것으로 실제 사용시 수정해서 사용하시면 됩니다. 각 기능에 맞게 class와 메서드(함수)를 분리하면서 THREE.js의 구조가 복잡해져서 아래 그림으로 도식화했습니다.

도식화

window.DOMContentLoaded가 완료되면 ThirdPersonCameraDemo가 실행되고 ThirdPersonCameraDemo 클래스에서는 전반적인 Renderer, 그림자, 사이즈조절, 화면DOM 요소 추가, 카메라, 장면, OrbitControls, 광원, 백그라운드, 도형, 애니메이션 모델, Rerendering 에 대한 코드를 포함하고 있습니다. 해당 클래스의 애니메이션 모델을 설정하는 부분은 별도의 class BasicCharacterController로 분리하였는데요. 해당 클래스에서는 애니메이션 모델의 조작, 실행될 애니메이션 설정, 모델 Load, 위치, 회전, 업데이트에 대한 코드를 포함하고 있습니다. 애니메이션 모델의 조작은 별도의 BasicCharacterControllerInput 클래스에서 keydown과 keyup에 대한 코드를 구현하고 있습니다. 실행될 애니메이션 설정은 별도의 CharacterFSM 클래스에서 각 상황을 정의하고 있고 이는 FiniteStateMachine을 상속해서 해당 클래스에서 각 상황을 추가하고 세팅하고 업데이트하는 코드를 구현하고 있습니다. 각 상황은 별도의 State라는 클래스를 상속하고 해당 클래스에서는 각 애니메이션 상황에 대한 입장, 퇴장, 업데이트 코드를 구현하고 있습니다. 만약 BasicCharacterController에서 Update가 실행되면 FiniteStateMachine의 UpdateState구문을 실행시키고 이는 State의 Update구문을 실행시켜 최종적으로 애니메이션을 업데이트합니다. 모델 Load의 경우 FBXLoader나 FontLoader와 같은 로더의 기능을 사용해서 모델을 로드하고 있습니다. 마지막으로 애니메이션 모델을 설정하는 부분은 ThirdPersonCamera 클래스 또한 객체화하고 있습니다. 해당 클래스에서는 애니메이션 모델이 움직이면 카메라가 이를 인식해서 카메라의 시점변경 등을 코드로 구현하고 이를 Update하는 코드를 포함하고 있습니다.

 

이상으로 이번 게시글에서는 THREE.js의 구조에 대해 알아보았습니다.