[JavaScript로 중력 구현하기] 2. 코딩하기

    [JavaScript로 중력 구현하기] 2. 코딩하기


    이번 포스팅에서는 저번 포스팅에 이어 중력을 직접 JS로 구현해보려고 한다. 개발환경은 JavaScript ES7, babel, Webpack, Three.js을 사용하였다.

    필요한 상수 값들 선언

    먼저 프로그램에서 사용할 상수 값들 부터 선언하겠다.

    export const initOptions = {
      framerate: 60,
      G: 250, // 내 맘대로 중력상수
      START_SPEED: 30, // 초기화 시 물체들의 속도
      OBJECT_COUNT: 40, // 초기화 후 렌더 될 물체 개수
      TRACE_LENGTH: 100, // 물체의 이동 궤적 길이
      MIN_MASS: 400, // 물체들의 최소 질량
      MAX_MASS: 3000, // 물체들의 최대 질량
      DENSITY: 0.15 // 물체들이 렌더되는 밀도
    };
    export const SPHERE_SIDES = 20;
    export const MASS_FACTOR = 0.01;

    원래라면 6.6742e-11의 값을 가지는 중력 상수는 250이나 준 이유는 이 물체들의 질량이 너무 작기 때문이다. 실제 중력상수 값을 이용해서 계산을 하고 그 중력의 영향이 눈에 보일 정도가 되려면 질량도 행성급으로 커야한다.

    MASS_FACTOR는 나중에 렌더할 때 물체의 질량에 비례하도록 구의 크기를 설정하려고 하는데 질량 값들이 400~3000이다보니까 구의 부피가 너무 커질 것 같아서 그걸 보정하기 위해 선언한 상수이다. 일종의 압축률 비슷한 느낌으로 나중에 구의 scale값을 정의할 때 물체의 질량에 MASS_FACTOR를 곱해줘서 일정한 비율로 크기를 줄일 예정이다.

    이 포스팅은 ThreeJS를 설명하기 위한 포스팅이 아니므로 SceneRenderer의 선언 및 초기화 등에 대해서는 생략하고 지나가겠다.

    Mover 클래스 선언

    자 그럼 이제 실제 움직일 놈들을 구현하자. 이름을 Object로 하고 싶었지만 알다시피 JS에서 Object라는 이름은 Build-in Object가 이미 가지고 있다. 그래서 다른 이름을 고심하다가 그냥 Mover라고 했다. 만들던 중에 다른 사람들이 구현한 것도 좀 찾아보고 그러다보니 알게된 건데 다들 Mover라고 하더라…

    import { SPHERE_SIDES, MASS_FACTOR } from 'src/constants';
    import {
     Vector3, SphereGeometry, Line,
     MeshPhongMaterial, PointLight, Mesh, Geometry
    } from 'three';
    
    export class Mover {
     constructor(mass, velocity, location, id, scene) {
       this.uid = `mover-${id}`;
       this.location = location;
       this.velocity = velocity;
       this.acceleration = new Vector3(0.0, 0.0, 0.0);
       this.mass = mass;
       this.alive = true;
    
       this.geometry = new SphereGeometry(100, SPHERE_SIDES, SPHERE_SIDES);
       this.vertices = [];
    
       this.line = new Line();
       this.color = this.line.material.color;
    
       this.basicMaterial = new MeshPhongMaterial({
         color: this.color,
         specular: this.color,
         shininess: 10
       });
    
       this.mesh = new Mesh(this.geometry, this.basicMaterial);
       this.mesh.castShadow = false;
       this.mesh.receiveShadow = true;
    
       this.position = this.location;
       this.parentScene = scene;
     }
    }

    Mover클래스로 생성된 객체는 constructor 실행 시 넘겨받은 랜덤한 질량값과 속도, 위치 값을 가지고 있다. 그리고 acceleration값을 초기화 해준다. 가속도는 어떠한 방향으로 움직이는 속도이므로 3개의 원소를 가진 벡터로 선언해 주었다.

    그리고 alive 멤버 변수는 Mover끼리 충돌판정이 나면 Mover 두개를 하나로 합쳐서 더 큰 질량을 가진 Mover로 만들 예정이기때문에 얘가 죽었나 살았나를 판별하기 위한 값이다.

    Mover 객체들 렌더하기

    이후 Scene에는 초기화 시 Mover들을 그려주는 로직을 써주었다.

    export default {
      reset() {
        const movers = this.movers;
    
        if(movers) { // movers리스트 초기화
          movers.forEach(v => {
            this.scene.remove(v.mesh);
            this.scene.remove(v.selectionLight);
            this.scene.remove(v.line);
          });
        }
    
        movers = [];
        for (let i = 0; i < parseInt(this.options.MOVER_COUNT); i++) {
          const mass = this.getRandomize(this.options.MIN_MASS, this.options.MAX_MASS);
          const maxDistance = parseFloat(1000 / this.options.DENSITY);
          const maxSpeed = parseFloat(this.options.START_SPEED);
    
          const velocity = new Vector3(
            this.getRandomize(-maxSpeed, maxSpeed),
            this.getRandomize(-maxSpeed, maxSpeed),
            this.getRandomize(-maxSpeed, maxSpeed)
          );
          const location = new Vector3(
            this.getRandomize(-maxDistance, maxDistance),
            this.getRandomize(-maxDistance, maxDistance),
            this.getRandomize(-maxDistance, maxDistance)
          );
    
          // 랜덤한 속도, 위치, 질량을 가진 Mover를 생성
          movers.push(new Mover(mass, velocity, location, i, this.scene));
        }
    
        // Mover가 초기화될 때 만들어진 Mesh, Line, Light 객체를 Scene에 넣는다
        movers.forEach(v => v.addMover());
    
        this.movers = movers;
      },
      getRandomize(min, max) {
          return Math.random() * (max - min) + min;
      }
    }

    이제 Scene은 여러 개의 Mover를 담은 리스트인 movers를 가지게 되었다. 이제 렌더되는 동안 계산만 하면 끝! 인데… 전 포스팅에서 말했듯이 여러 개의 물체에 대한 중력을 구하는 다체문제는 해를 구할 수가 없다. 그래서 우리는 movers를 순회하며 한번 순회할 때마다 다른 Mover들과의 중력을 이체문제로 모두 계산하는 로직을 만들어야한다.

    Mover 객체들 운동 시키기

    let movers = this.movers;
    
    movers.forEach((o1, i) => {
      if(!o1.alive) return false;
      movers.forEach((o2, j) => {
        if(o1.alive && o2.alive && i !== j) {
          // o1 -> o2의 거리
          const distance = o1.location.distanceTo(o2.location);
    
          // o1, o2의 반지름 r1, r2
          const r1 = (o1.mass / MASS_FACTOR / MASS_FACTOR / 4 * Math.PI) ** (1/3);
          const r2 = (o2.mass / MASS_FACTOR / MASS_FACTOR / 4 * Math.PI) ** (1/3);
    
          if(distance <= r1 + r2) {
            // 둘의 거리가 둘의 반지름의 합 이하면 충돌한 것으로 판정, 두 물체를 합친다
            o2.eat(o1);
          }
          else {
            // 충돌이 아닐 경우 그냥 운동만 시킨다
            o2.attract(o1, this.options);
          }
        }
      });
    });

    우선 이런 식으로 전체 Mover들을 순회하면서 충돌 판정을 내주었다. Mover클래스의 eat메소드는 충돌 판정이 난 두개의 Mover들을 하나로 합치는 로직을 가지고 있으며, attract메소드에는 현재 두 물체의 거리에서의 중력을 측정하고 Mover의 가속도에 더해주는 역할을 하고 있다.

    이후 모든 계산이 끝나면 Mover의 위치와 크기, 속도, 방향 등을 업데이트 해주었다. Gravity클래스의 calcGravity메소드는 저번 포스팅에서 적었던 로직 그대로이다.

    Mover의 운동에 관련된 메소드들은 다음과 같다.

    export class Mover {
      constructor(mass, velocity, location, id, scene) {
        // ...
      }
    
      eat(otherMover) {
        const newMass = this.mass + otherMover.mass;
        const newLocation = new Vector3(
          (this.location.x * this.mass + otherMover.location.x * otherMover.mass) / newMass,
          (this.location.y * this.mass + otherMover.location.y * otherMover.mass) / newMass,
          (this.location.z * this.mass + otherMover.location.z * otherMover.mass) / newMass
        );
    
        const newVelocity = new Vector3(
          (this.velocity.x * this.mass + otherMover.velocity.x * otherMover.mass) / newMass,
          (this.velocity.y * this.mass + otherMover.velocity.y * otherMover.mass) / newMass,
          (this.velocity.z * this.mass + otherMover.velocity.z * otherMover.mass) / newMass
        );
    
        this.location = newLocation;
        this.velocity = newVelocity;
        this.mass = newMass;
    
        otherMover.kill();
      }
    
      attract(otherMover, options) {
        const force = Gravity.calcGravity(this, otherMover, options.G);
        this.applyForce(force);
      }
    
      applyForce(force) {
        if(!this.mass) this.mass = 1.0;
        const f = force.divideScalar(this.mass);
    
        // mover의 가속도에 힘을 적용
        this.acceleration.add(f);
      }
    
      update() {
        this.velocity.add(this.acceleration); // 속도에 가속도 더함
        this.location.add(this.velocity); // 위치에 속도 더해서 이동시킴
        this.acceleration.multiplyScalar(0); // 가속도 초기화
        this.mesh.position.copy(this.location); // mover의 mesh객체에 위치 적용
      }
    }

    정리하자면 매 프레임마다 movers리스트를 순회하면서 각 Mover들간의 중력을 계산하고 가속도를 적용한 후 실제로 Mover를 이동시키는 것이다. 전체 소스는 중력 테스트 프로젝트 깃허브 레파지토리에서 확인해볼 수 있다.

    이상으로 JavaScript로 중력 구현하기 포스팅을 마친다.

    Evan Moon

    🐢 거북이처럼 살자

    개발을 잘하기 위해서가 아닌 개발을 즐기기 위해 노력하는 개발자입니다. 사소한 생각 정리부터 튜토리얼, 삽질기 정도를 주로 끄적이고 있습니다.