본문 바로가기

개발자 강화/프론트엔드

matter.js - 물리엔진에서 물리를 빼면 남는 것

Intro

'물체끼리 부딪혔는지' 판단하는 로직을 어떻게 구현하면 좋을까?

직접 좌표를 비교하는 방식으로도 가능하지만,

물체가 많아지고 형태가 다양해지면 금방 한계에 부딪힌다.

 

matter.js를 렌더링 없이 충돌 감지 전용으로 사용한 경험을 공유하고자 한다!

특히 충돌 필터링 최적화와 모니터 주사율 때문에 생긴 예상치 못한 버그들을 다룬다.


matter.js가 뭐죠?

브라우저 기반 2D 물리엔진이다.

보통 물체가 떨어지고 튕기는 시뮬레이션에 쓰인다.

 

하지만 나는 좀 다르게 접근했다.

1. css 애니메이션으로 물체의 움직임을 제어하고
2. matter.js는 순수하게 충돌 감지만 담당한다.

 

왜 물리 엔진으로 움직임까지 안 맡기나? 라는 의문이 들 수 있다.

matter.js에 렌더링까지 맡기면 Canvas 기반이 되어야 한다.

그러면 기존에 DOM으로 만들어둔 UI 시스템과 섞기 어려워진다.

CSS로 움직이면 기존 컴포넌트 구조를 그대로 쓸 수 있어서 개발 비용이 훨씬 작다.

 

CSS로 물체를 움직인다는 건 이런 것이다.

 

element.style.left = '100px'이라고 하면,

이 요소를 왼쪽에서 100px 위치에 놓으라는 뜻이다.

 

매 프레임마다 이 값을 바꿔주면 물체가 움직이는 것처럼 보인다.

100px -> 102px -> 104px ...

이걸 1초에 60번 반복하면 부드러운 이동이 된다.

 

물리 엔진이 필요한 건 '충돌 판정' 하나였으니, 이것만 가져다 쓰기로 했다.

이렇게 하면 렌더링 부담 없이 정밀한 충돌 판정만 가져올 수 있다.

 

핵심 모듈 구조

matter.js 구조는 직관적이다.

 

Engine이 전체 물리 시뮬레이션을 관리하고,

그 안에 World라는 컨테이너가 있다.

World 안에 Body(물체)들을 넣으면 엔진이 매 프레임마다 물리 계산을 돌린다.

Events로 충돌 같은 이벤트를 감지할 수 있다.

 

Engine
└── World
    ├── Body (플레이어)
    ├── Body (아이템 A)
    ├── Body (아이템 B)
    └── ...
Events: collisionStart, collisionEnd, ...

 

기본 사용법

import Matter from 'matter-js'
const { Engine, World, Bodies, Body, Events } = Matter

// 1. 엔진 생성 — 충돌 감지 전용이므로 중력 0
const engine = Engine.create()
engine.gravity.y = 0

// 2. 물체 생성
const player = Bodies.circle(400, 200, 40, {
  label: 'player',
  isStatic: false,
})

const item = Bodies.rectangle(600, 300, 50, 50, {
  label: 'item',
})

// 3. 월드에 추가
World.add(engine.world, [player, item])

// 4. 매 프레임 물리 계산
function gameLoop() {
  Engine.update(engine, 1000 / 60)
  requestAnimationFrame(gameLoop)
}

// 5. 정리
World.remove(engine.world, item)

 

 

핵심 코드 구조를 코드로 풀어보자면 이렇게 쓸 수 있다.

 

그런데, 진짜 이야기는 여기서부터다.

기본 예제는 쉽지만 실무에 적용할 땐 예제엔 나오지 않는 것들을 마주한다.


CSS와 matter.js, 어떻게 연결하지?

앞서 'css로 움직이고, matter.js로 충돌만 감지한다'라고 했는데,

읽다 보면 자연스럽게 이런 의문이 든다.

css가 움직이는 좌표를 matter.js Body는 어떻게 아는걸까?


DOM의 좌표를 믿자

처음에는 계산된 좌표를 CSS에 Body에 각각 넣어주는 방식을 생각할 수 있다.

하지만 이러면 두 시스템의 좌표가 미묘하게 어긋날 위험이 있다.

브라우저 zoom, CSS transform, 레이아웃 시프트 같은 변수가 있기 때문이다. (ㅠㅠ)

 

그렇다면 이렇게 해보면 어떨까?

CSS 위치를 먼저 적용하고, DOM에서 실제 화면 좌표를 읽어서 Body에 반영한다.

function gameLoop() {
  // 1. 위치 계산 (수학 공식, 등속 이동 등)
  const newX = calculatePosition(deltaTime)

  // 2. CSS에 적용
  element.style.left = `${newX}px`

  // 3. DOM에서 실제 화면 좌표를 읽어옴
  const rect = element.getBoundingClientRect()

  // 4. 물리 Body를 화면 좌표에 동기화
  Body.setPosition(body, {
    x: rect.left + rect.width / 2,
    y: rect.top + rect.height / 2,
  })

  // 5. 엔진 업데이트 → 충돌 판정
  Engine.update(engine, 1000 / 60)

  requestAnimationFrame(gameLoop)
}

 

흐름을 정리하면 이렇다.

위치 계산 → CSS 적용 → getBoundingClientRect() → Body.setPosition() → Engine.update()
                              ↑                                              ↓
                     DOM이 진실의 원천                                충돌 이벤트 발생

 

물체의 위치 정보는 CSS에도 있고 matter.js Body에도 있다.

 

둘 다 각각 위치 값을 가지고 있는데,

이 둘이 달라지면 화면에서는 안 닿았는데 충돌 판정이 나거나,

닿았는데도 안 잡히는 문제가 생긴다.

이런 문제가 발생한다면 voc가 폭발할 것이다. 으악!

 

그래서 '뭘 기준으로 믿을 것이냐'를 정해야 하는데,

여기서는 DOM(CSS가 적용된 실제 화면 요소)를 기준으로 정하고,

matter.js가 매 프레임 거기에 맞추는 구조이다.

 

getBoundingClientRect()는 요소의 실제 화면 픽셀 좌표를 반환한다.

이걸 쓰면 CSS transform이 적용되어 있든, 부모 컨테이너가 어디에 있든 상관없이 정확한 위치를 얻을 수 있다.

물리 Body도 같은 화면 좌표계를 쓰게 되므로, 두 시스템이 항상 일치한다.

 

매 프레임 getBoundingClientRect를 호출하면 느리지 않나?

브라우저가 화면을 그리는 순서를 보자.

JavaScript 실행 → Style 계산 → Layout(reflow) → Paint → Composite

 

Layout 단계에서 브라우저가 '이 요소는 화면 어디에, 몇 px 크기로 그려야하지?'를 계산한다.

이게 reflow다.

 

비용이 꽤 큰 작업이라 브라우저는 이걸 최대한 미루려고 한다.

style.left를 바꿔도, '나중에 화면 그릴 때 한 번에 하자'하고 쌓아둔다.

 

그런데, getBoundingClientRect()를 호출하면 이야기가 달라진다.

'이 요소의 정확한 화면 좌표를 지금 당장 알려줘!'라고 요청하는 것이다.

브라우저가 미뤄왔던 레이아웃 계산을 강제로 실행해야 한다.

 

물체가 수백 개라면 문제가 된다.

 

// 수백 번의 강제 reflow
items.forEach((item) => {
  item.element.style.left = `${item.x}px`          // 스타일 변경
  const rect = item.element.getBoundingClientRect() // 강제 reflow!
  Body.setPosition(item.body, { ... })
})
// 아이템 500개면 → reflow 500번

 

하지만, 우리 게임에서는 물체가 아직 그렇게 많지 않고

어차피 style.left를 바꾼 직후라서

브라우저가 다음 프레임에 똑같이 계산해야할 걸 조금 앞당긴 셈이다.

실질적인 추가 비용은 거의 없다.

 

만약 물체가 수백 개인 상황이라면

DOM을 안읽고, 계산한 좌표를 직접 Body에 넣는 방식으로 바꿔야 한다.

대신 CSS transform이나 부모 위치가 바뀌면 좌표가 틀어질 수 있다는 트레이드 오프가 있다.

 

왜 이 방식이 좋은가?

이 패턴의 장점은 움직임 로직과 충돌 로직이 완전히 분리된다는 것이다.

진자 운동이든, 등속 이동이든, css transform이든 style.left든,

어떤 방식으로 움직여도 마지막에 DOM 좌표를 읽어서 Body에 넣기만 하면 충돌 감지가 작동한다.

움직임을 바꾸고 싶을 때 충돌 로직을 건드릴 필요가 없다.


충돌 감지: 누가 누구를 만났는지

matter.js에서 충돌을 감지하려면 Events.on으로 이벤트를 등록한다.

 

Events.on(engine, 'collisionStart', (event) => {
  event.pairs.forEach((pair) => {
    const { bodyA, bodyB } = pair
    console.log(`${bodyA.label}과 ${bodyB.label}이 충돌!`)
  })
})

 

충돌 이벤트에는 세 종류가 있다.

collisionStart — 두 물체가 처음 맞닿는 순간
collisionActive — 맞닿아 있는 동안 매 프레임
collisionEnd — 두 물체가 떨어지는 순간

 

대부분의 게임 로직에서는 collisionStart만으로 충분하다.

잡았다!는 처음 닿는 순간 한 번만 판정하면 된다.


충돌 필터링: 쓸데없는 연산을 줄이자

 

문제

기본적으로 matter.js는 모든 Body 쌍에 대해 충돌 검사를 수행한다.

아이템이 10개라면 아이템끼리만 (10*9)/2 = 45번의 불필요한 충돌 연산이 발생한다.

우리가 알고 싶은 건 플레이어와 아이템 사이 충돌 뿐인데도!!

 

해결: 비트마스크 충돌 필터

matter.js는 collisionFilter라는 속성으로 '누가 누구와 충돌할 수 있는지' 제어한다.

핵심은 비트 연산이다.

// 카테고리 정의 (비트마스크)
const CATEGORY = {
  PLAYER: 0x0001,  // 0001 (2진수)
  ITEM:   0x0002,  // 0010 (2진수)
}

 

각 물체에 category(나는 누구인가)와 mask(나는 누구와 충돌할 것인가)를 지정한다

// 플레이어 — 아이템과만 충돌
const player = Bodies.circle(200, 200, 30, {
  label: 'player',
  collisionFilter: {
    category: CATEGORY.PLAYER,
    mask: CATEGORY.ITEM,
  },
})

// 아이템 — 플레이어와만 충돌
const item = Bodies.circle(400, 300, 25, {
  label: 'item',
  collisionFilter: {
    category: CATEGORY.ITEM,
    mask: CATEGORY.PLAYER,
  },
})

 

matter.js는 내부적으로 이렇게 판단한다

 

두 물체가 충돌하려면:
  (A.mask & B.category) !== 0  AND
  (B.mask & A.category) !== 0

 

구체적으로 따져보면

player.mask & item.category  →  0010 & 0010 = 0010 ≠ 0  충돌 판정!
item.mask & player.category  →  0001 & 0001 = 0001 ≠ 0  충돌 판정!
→ 충돌 검사 O

item.mask & item.category    →  0001 & 0010 = 0000 = 0  무시!
→ 아이템끼리는 충돌 검사 X

 

이것만으로 아이템 간 불필요한 45번의 연산이 사라진다.


더 줄일 수 있다: 상황별 충돌 on/off

카테고리 필터 만으로 많이 줄였지만,

런타임에 mask를 동적으로 바꾸면 더 세밀한 제어가 가능하다.

 

특정 게임 상태에서만 충돌 활성화

예를 들어, 플레이어가 '공격 모드'일 때만 충돌을 감지하고,

'복귀 모드'에서는 감지하지 않으려면 이렇게 하면 된다.

// 공격 모드 → 충돌 활성화
playerBody.collisionFilter.mask = CATEGORY.ITEM

// 복귀 모드 → 충돌 비활성화
playerBody.collisionFilter.mask = 0

 

'mask'를 0으로 설정하면 아무 카테고리와도 매칭되지 않으므로,

충돌 검사 자체가 스킵된다.

 

화면 안에 있을 때만 충돌 활성화

화면 밖에 있는 아이템까지 충돌 검사를 할 필요는 없다.

const isVisible = itemX > 0 && itemX < viewportWidth

itemBody.collisionFilter.mask = isVisible
  ? CATEGORY.PLAYER
  : 0

 

이런 식으로 실제로 의미있는 충돌만 남기면,

물체가 수십 개여도 엔진 부하를 최소한으로 유지할 수 있다.


예상 못한 버그: 모니터마다 속도가 다르다고...?

증상

개발 중 이상한 현상을 발견했다.

디자이너를 화면 앞에 데려와 같이 애니메이션 논의를 하려고,

개발 화면을 맥북 모니터에서 외부 모니터로 옮겼다.

 

그런데, 갑자기 애니메이션 눈에 띄게 느려지는 것이었다.

 

엥? 갑자기요?

 

원인: requestAnimationFrame은 모니터 주사율을 따른다

requestAnimationFrame은 모니터의 화면 갱신 타이밍에 맞춰 콜백을 호출한다

 

모니터 주사율 초당 콜백 호출
맥북(ProMotion) 120Hz 120회
일반 외장 모니터 60Hz 60회

 

만약 매 프레임마다 고정된 값을 더해서 시간을 계산하고 있었다면:

// 프레임 의존적 — 주사율에 따라 속도가 달라짐
function animate() {
  time += 0.016  // 매 프레임 고정값
  updatePosition(time)
  requestAnimationFrame(animate)
}

 

120Hz 모니터에서는 1초에 0.016*120=1.92만큼 증가하고,

60Hz에서는 0.016*60=0.96만큼만 증가한다.

 

같은 1초인데 2배 차이가 나는 셈이다.

 

해결: deltaTime

실제 경과 시간을 측정해서 사용하면 주사율과 무관하게 일정한 속도를 보장할 수 있다.

 

// 프레임 독립적 — 어떤 주사율이든 동일한 속도
let lastTimestamp = 0

function animate(timestamp: number) {
  const deltaTime = (timestamp - lastTimestamp) / 1000  // 초 단위
  lastTimestamp = timestamp

  time += deltaTime  // 실제 경과 시간만큼만 증가
  updatePosition(time)
  requestAnimationFrame(animate)
}

requestAnimationFrame(animate)

 

모니터 주사율 프레임 당 delta 1초 누적
맥북 120Hz ~0.0083초 -0.0083초*120 1.0
외장 모니터 60Hz ~0.0167초 -0.0167초*60 ≈ 1.0

 

어떤 모니터에서든 1초에 정확히 1.0만큼 증가한다.

문제 해결!

 

놓쳤다면 디바이스마다 애니메이션 속도가 달라지는 참사가 일어났을수도...

 


마치며

 

정리하면 이렇다.

 

- matter.js는 렌더링 없이 충돌 감지 전용으로 충분히 쓸 수 있다.

- 비트마스크 collisionFilter로 필요한 충돌만 골라서 연산량을 줄일 수 있다.

- mask를 런타임에 바꿔서 상황별로 충돌을 on/off 할 수 있다

- requestAnimationFrame 기반 애니메이션에서는 반드시 deltaTime을 써야 기기 간 일관성을 보장할 수 있다.

 

물리엔진이라고 하면 좀 거창하고 거대해보이는데,

충돌 감지 면에서는 도입 비용 대비 얻는 계산 효율이 크다!

 

비슷한 고민이 있다면 한 번 시도해보는 것을 추천한다.

 


참고

- matter.js 공식 문서: https://brm.io/matter-js/docs/

- matter.js github: https://github.com/liabru/matter-js

 

728x90