2. UI 와 기초 코딩 - 스페이스 그래비티 개발기

phw3071·2022년 5월 3일
2
post-thumbnail

https://space-gravity.hyuns.dev
직접 체험해보기
(계속 업데이트가 되기 때문에 포스트의 내용과 달라질 수 있습니다)![]

짜잔 벌써 이 정도 만들어졌습니다. 뭔가 많이 생략되었지만 최대한 설명해드릴게요. 사실 기초부터 잘못되어서 싹 갈아엎어야 하는 상황이지만, 일단은 남기고 싶어서 포스트합니다...

1. UI/UX

UI는 React로 만들어졌습니다. 원래라면 JQuery로 떼울 수(?)도 있었지만, 이번 프로젝트에서는 확장성도 고려를 하고, 결정적으로 예쁜 UI를 만들고자 하는 욕심이 있어서 이렇게 결정을 했습니다.

UI가 시뮬레이션을 방해하지 않도록 배경은 블러를 적용시켰고, 사이즈를 최소화하고 마우스를 호버시 툴팁을 추가하였고, 결정적으로 이동이 가능하게 만들었습니다(!)

1-1. 커서

용어 설명
스페이스: 시뮬레이션
행성: 원(사물)

행성의 추가는 마우스에 행성의 크기에 해당하는 원을 따라다니게 만들었습니다. 확실히 직관적이어서 괜찮더라고요. 원 아래에 필요한 정보를 추가를 해서 최대한 직관적이게 만들었습니다. 이후에 기능을 추가하면서 지속적으로 개편할 예정입니다.

커서를 구현하는게 은근히 까다로웠습니다. 커서가 작동하는 구조를 보면 다음과 같습니다.

  1. DOM에서 흰색 원을 마우스를 따라가게 함
  2. 클릭이 시작되면(생성 시작) 커서 처리용 Canvas에 원과 선을 그림
  3. 클릭이 끝나면(생성 끝) 다시 DOM에 흰색 원을 추가하고, 행성 데이터를 시뮬레이터로 전송

왜 Canvas에서 모두 처리를 하지 않고, DOM을 사용하셨냐고 할 수 있는데, 확장성을 고려했기에 그렇습니다. 지금은 몇 글자만 있긴 하지만, 이후에는 아예 크게 만들 수도 있고, 여러 변경사항이 생길 수 있을 것 같아서 이와 같이 제작을 하였습니다. 아예 DOM으로도 하고 싶었지만, 그러면 선을 그릴 마땅한 방법이 없더라고요... 어쩔 수 없이 DOM과 Canvas를 혼합하는 형식으로 사용하고 있습니다.

2. 시뮬레이터

굳이 이해하실 필요는 없습니다. 그냥 내려가시면 됩니다.

// public/simulator.js 중 일부

let loopId
let planets = {}
let speed = 1
const SPEED_RADIUS = 200
const SPACE_G = 0.005

const getSquaredDistance = (planet, targetPlanet) => {
    return Math.round((planet.x - targetPlanet.x)**2 + (planet.y - targetPlanet.y)**2)
}

const getGravitationalAcceleration = (planet, targetPlanet) => {
    return {
        ax: - SPACE_G * planet.weight * targetPlanet.weight * ( planet.x - targetPlanet.x ) / Math.abs((planet.x - targetPlanet.x))**3,
        ay: - SPACE_G * planet.weight * targetPlanet.weight * ( planet.y - targetPlanet.y ) / Math.abs((planet.y - targetPlanet.y))**3
    }
}

loopId = setTimeout(function loop() {
    const newPlanets = {...planets}

    // 주요 로직
    for (let planetId of Object.keys(planets)) {
        const planet = newPlanets[planetId]
        if ( !planet ) continue // 삭제된 행성 계산 무시

        newPlanets[planetId] =  {
            weight: planet.weight,
            radius: planet.radius,
            x: planet.x + planet.vx / SPEED_RADIUS,
            y: planet.y + planet.vy / SPEED_RADIUS,
            vx: planet.vx,
            vy: planet.vy,
            color: planet.color
        }

        for (let [targetPlanetId, targetPlanet] of Object.entries(newPlanets)) { // 충돌 감지 & 중력
            if (targetPlanetId === planetId) continue

            // 충돌 감지
            if (getSquaredDistance(planet, targetPlanet) < (planet.radius + targetPlanet.radius)**2) {
                const weight = newPlanets[planetId].weight + newPlanets[targetPlanetId].weight
                const radius = Math.round(Math.sqrt(newPlanets[planetId].radius**2 + newPlanets[targetPlanetId].radius**2))

                if (planet.weight >= targetPlanet.weight) {
                    newPlanets[planetId].weight = weight
                    newPlanets[planetId].radius = radius
                    delete newPlanets[targetPlanetId]
                } else {
                    newPlanets[targetPlanetId].weight = weight
                    newPlanets[targetPlanetId].radius = radius
                    delete newPlanets[planetId]
                }
                continue
            }

            // 중력 가속도
            const a = getGravitationalAcceleration(planet, targetPlanet)
            newPlanets[planetId].vx = newPlanets[planetId].vx + a.ax
            newPlanets[planetId].vy = newPlanets[planetId].vy + a.ay
        }
    }

    planets = newPlanets
    self.postMessage({kind: 'newPlanets', planets: newPlanets})
    setTimeout(loop, 128/speed)
}, 1024/speed)

... (IO코드)

위 코드는 2022.05.03 12:38 에 있는 코드입니다. (후술할 문제로 지금은 수정되었습니다.)

2-1.시뮬레이터 작동 구조

주의!
현재의 작동구조는 문제가 많습니다. 갈아엎을 예정이며, 반면교사 정도로 알아주세요.

시뮬레이터 코드가 실행되면 setTimeout()에 의해 루프가 진행됩니다.

  1. 기존 행성 정보를 newPlanet라는 변수에 복사합니다.
  2. 각 행성마다 for문을 통해 루프를 돕니다.
    1. 행성의 속도를 통해 좌표를 이동시킵니다.
    2. 다른 각 행성을 for문을 통해 루프를 돕니다.
      1. 행성 간에 충돌이 있는지 확인하고, 있다면 질량이 더 큰 쪽으로 흡수합니다.
      2. 각 행성간의 중력을 계산하여 속도를 변경합니다.
  3. 모든 계산이 끝나면 계산 결과를 Drawer로 전송합니다.

2-1-1. 개선점

2-1-1-1. 중복 For문 O(n^2)

저는 고등학교 3학년이며, 딱 그 정도의 지식 수준을 가지고 있습니다.
이 코드를 만들 당시에는 벡터라는 개념 자체를 배우지를 않았습니다. (당연히 중력계산식도 안배웠었습니다.)
현재는 중간고사를 마치며 기초적인 지식은 알게되었지만, 그래도 부족합니다.
개선해야 될 점조차 개선이 필요합니다. 혹시라도 문제가 있다면 언제든 알려주시면 감사합니다.

작동구조의 2-2에서 이중 for문이 실행되고 있습니다. 이에 따라 시간복잡도가 O(n^2)가 되버리는 문제가 있습니다. 만약 절차를 추가해서 중복 계산을 방지하게 되면 조금 줄일 수 있을 것으로 생각이 됩니다. 제가 시간복잡도를 잘 모르는지라 정확하지는 않지만 O(nlogn)이지 않을까 생각합니다.

다만 스페이스 그래비티의 목표를 랜덤으로 생성된 행성을 통해 태양계를 구현해보는 것이므로, O(n^2)이더라도 목표까지는 가능할 것으로 생각하고 있습니다., 일단은 현재로 진행을 하되 O(n^2)으로 인해 연산량이 심각하게 많아진다면 코드를 추가로 작성을 하겠습니다.


그래프 출처: https://heekim0719.tistory.com/266

2-1-1-2(틀린) 계산과정

현재 버전의 가장 큰 문제점은 위 사진에서도 볼 수 있듯, 행성들이 부자연스럽게 움직인다는 점입니다. 멀리 있을 때는 잘 움직이나 싶더라도 행성이 서로 근접하게 되면 갑작스럽게 발사 되듯 움직이고, 이상한 방향으로 움직입니다.

이러한 문제가 발생한 원인은 x방향 중력과 y방향 중력을 별도로 계산한 것입니다. 이 코드를 작성할 당시의 저는 벡터를 x,y로 분리하는 법을 몰랐습니다. 그렇게에 중력을 하나의 벡터로 계산하는 것이 아닌 x,y 각각 계산을 하였습니다. 그렇기에 x축에서는 충돌할 만큼 충분히 접근했음에도 y축에서는 멀리 있기에 충돌하지 못하고, R이 0으로 수렴하면서 힘이 비정상적으로 가해진 것으로 보고 있습니다. 행성이 멀리 떨어져있음에도 서로가 영향을 미치는 것또한 이것의 연장선으로 보고 있습니다.

F1=F2=Gm1m2R2{\displaystyle F_{1}=F_{2}=G{{m_{1}m_{2}} \over {R^{2}}}}

이를 해결하는 방법은 간단합니다. 두 행성간의 벡터를 구한다음 삼각함수를 통해 x,y축을 분리하면 됩니다. 다만 제가 코드를 얼마나 효율적으로 쓸 수 있을지는 모르겠습니다. 삼각함수를 이용한 식 자체가 꽤나 큰 성능을 먹기도 하고, 결정적으로 힘이 연적방향으로 작용하지 않을 때의 물체의 운동을 계산해본적이 없습니다. (기껏해야 포물선 운동이 끝...) 아마 여러 시행착오가 기다리겠지만, 그래도 해봐야죠 ㅎㅎ

2-2 Draw와 시뮬레이션의 분리

space-gravity는 시뮬레이터이며, 굉장히 많은 연산량을 필요로 할 것으로 추정하고 있습니다. 이렇게 연산량이 많은 코드를 Draw하는 코드와 함께 돌린다면 당연하게도 UI 파트가 매끄럽지 않을 가능성이 큽니다. 그렇기에 Worker Thread를 이용하여 시뮬레이터와 Drawer를 나누기도 하였습니다.

// src/screen/main.tsx 중 일부

useEffect(() => {
  _worker.current = new Worker('./simulator.js')

  // 메세지 수신
  _worker.current.onmessage = (msg:any) => {
    switch (msg.data.kind) {
      case 'newPlanets':
        setPlanets(msg.data.planets)
        break;

      default:

        break;
    }
  }
}, [])

// 새로운 행성 추가
useEffect(() => {
  if (!_worker.current) return
  if (!newPlanet) return
  _worker.current.postMessage({kind: 'planetAdd', newPlanet: { id: uuidv4(), data: newPlanet }})
  setNewPlanet(undefined)
}, [newPlanet])
// public/simulator.js 중 일부
self.addEventListener('message', event => {
    switch (event.data.kind) {
        case 'planetAdd':
            // self.postMessage(event.data.planets)
            console.log(event.data.newPlanet.data)
            planets[event.data.newPlanet.id] = event.data.newPlanet.data
            break

        case 'speedUpdate':
            speed = event.data.speed;
            break

        case 'stopSimulate':
            clearInterval(loopId)
            break

        default:
            console.error('Wrong Command')
    }
})

new Worker() 을 통해 워커를 생성하고 프로세스간 통신을 통해 정보를 주고 받습니다. 이를 통해서 Drawer는 항상 최대 프레임을 유지할 수 있으며 Simulator가 사용자의 FPS와 별도로 계산을 할 수 있으며, 연산량이 많이 계산이 지연되더라도 화면이 끊기지는 않습니다. (물론 행성은 끊기지만, 커서가 느려지거나 버튼 동장이 느려지는 건은 막을 수 있을겁니다.)

초당 업데이트 횟수를 부르는 말로 TPS(tick for second, 마인크래프트), UTS(update for second, 팩토리오) 로 사용하던데, 스페이스 그래비티에서는 UTS로 칭하겠습니다. 절때로 제가 시험 끝나고 3일 내내 팩토리오만 해서 그런건 아닙니다.

3. 마치며

이번 포스트는 스페이스 그래비티를 갈아엎기전 어떤 구조를 가지고, 어떤 문제점이 있었는지를 확실히 하기 위해 작성하였습니다. 얼핏 봐서는 꾀나 다 만든 것 처럼 보이지만, 제 물리 지식의 부재, 그리고 시간이 쫓겨 완성만 바라보고 코드 퀄리티는 보지를 않았기에 여러모로 부족한 부분이 많습니다.

이 포소트를 마치고 나면 스페이스 그래비티를 뜯어고칠 예정입니다. 학교 대회에 출품할 예정이기 때문에 시간 제한이 있기는 하지만, 다른 날과 달리 시간 자체는 넉넉하기 때문에(금요일빼고 학교 안갑니다 ㅋㅋㅋ) 코드 퀄리티를 조금 신경쓰면서 작성할 예정입니다.

끝!

profile
새로운 상상을 하고, 상상을 현실로 만드는 학생 개발자

2개의 댓글

comment-user-thumbnail
2022년 5월 3일


구독과 좋아요 알림설정 누르고 감

1개의 답글