Ver5.0 간단한 물리엔진 구현 소스코드: 공 하나 튀기는 데서 시작하는 나만의 물리 세계 만들기

간단한 물리엔진 구현 소스코드: 공 하나 튀기는 데서 시작하는 나만의 물리 세계 만들기
🎱 게임에서 상자가 쓰러지고 공이 통통 튀는 걸 보면서 "저거 어떻게 만든 거지?" 싶었던 적 있지?
정답은 생각보다 소박해. 위치·속도·힘 세 가지를 매 프레임 조금씩 갱신하고, 부딪히면 밀어내는 것. 이게 물리엔진의 뼈대야.
오늘은 친구랑 코딩 카페에 앉아서 같이 짜는 느낌으로, JavaScript + Canvas만으로 돌아가는 약 150줄짜리 2D 물리엔진을 처음부터 끝까지 만들어 볼 거야.
1. 물리엔진, 대체 정체가 뭐야? 🤔
물리엔진은 "시간이 아주 조금 흘렀을 때, 물체들은 어디에 있을까?"라는 질문에 반복해서 답하는 프로그램이야.
현실의 시간은 연속적이지만 컴퓨터는 그걸 못 다루니까, 보통 1/60초 같은 작은 조각(타임스텝, dt)으로 잘라서 계산해. 이걸 수치 적분(numerical integration)이라고 불러.
상용·오픈소스 엔진들, 예컨대 Erin Catto의 Box2D, 웹에서 많이 쓰는 Matter.js, 3D 쪽의 Bullet, NVIDIA PhysX도 내부적으로는 결국 같은 루프를 돌아. 물론 그 위에 엄청난 최적화와 안정화 기법이 얹혀 있지만, 뼈대는 우리가 오늘 만들 것과 똑같아.
이 네 단계가 한 바퀴 = 한 스텝이야. 이걸 1초에 60번 돌리면 우리가 보는 "그럴듯한 물리"가 완성돼. 생각보다 단순하지? 😎
2. 벡터부터 챙기자: 물리엔진의 공용어
위치, 속도, 힘은 전부 크기와 방향을 가진 벡터야. 2D니까 x, y 두 성분이면 충분해. 엔진 코드의 절반은 벡터 연산이라 이걸 먼저 깔끔하게 만들어 두면 이후가 편해.
class Vec2 {
constructor(x = 0, y = 0) { this.x = x; this.y = y; }
add(v) { return new Vec2(this.x + v.x, this.y + v.y); }
sub(v) { return new Vec2(this.x - v.x, this.y - v.y); }
scale(s) { return new Vec2(this.x * s, this.y * s); }
dot(v) { return this.x * v.x + this.y * v.y; }
length() { return Math.hypot(this.x, this.y); }
normalize() {
const len = this.length();
return len > 0 ? this.scale(1 / len) : new Vec2(0, 0);
}
}
💡 내적(dot)은 진짜 핵심이야. 두 벡터가 같은 방향이면 양수, 반대면 음수, 수직이면 0이 나와.
나중에 "두 물체가 서로 다가오는 중인가, 멀어지는 중인가?"를 판단할 때 이 부호 하나로 끝내버려.
참고로 매 연산마다 new Vec2를 만들면 가비지가 많이 생겨. 물체가 수천 개쯤 되면 성능에 영향이 있으니 실전에서는 addInPlace 같은 제자리 연산을 따로 두기도 해. 오늘은 읽기 쉬운 쪽을 택할게.
3. 물체(Body) 정의하기
우리 엔진의 주인공은 원(circle)이야. 원은 충돌 계산이 가장 쉬워서 입문용으로 최고거든. 물체가 가져야 할 정보는 이 정도면 돼.
class Body {
constructor(x, y, radius, mass, restitution = 0.8) {
this.pos = new Vec2(x, y);
this.vel = new Vec2(0, 0);
this.force = new Vec2(0, 0);
this.radius = radius;
this.mass = mass;
// 질량 0 = 움직이지 않는 정적 물체(바닥, 벽 등)
this.invMass = mass > 0 ? 1 / mass : 0;
this.restitution = restitution; // 반발계수 e (0~1)
}
applyForce(f) { this.force = this.force.add(f); }
}
여기서 눈여겨볼 건 invMass(역질량)이야. 왜 굳이 1/m을 저장하냐면, 공식에서 나눗셈 대신 곱셈을 쓰게 되고 무엇보다 질량이 무한대인 물체를 invMass = 0으로 표현할 수 있거든. 그러면 바닥에 아무리 세게 부딪혀도 바닥은 꿈쩍 안 해. Box2D 같은 실전 엔진도 이 방식을 써.
4. 적분: 오일러 vs 세미-임플리싯 오일러 ⚖️
이제 시간을 흘려보낼 차례야. 가장 직관적인 방법은 명시적 오일러(Explicit Euler)야.
근데 이 녀석은 함정이 있어. 위치를 옛날 속도로 업데이트하기 때문에 스프링이나 궤도 운동처럼 진동하는 시스템에서 에너지가 조금씩 늘어나. 공이 튈 때마다 점점 더 높이 튀어오르는 기괴한 현상이 생길 수 있지.
그래서 게임 물리에서는 순서만 살짝 바꾼 세미-임플리싯 오일러(Semi-implicit / Symplectic Euler)를 거의 표준처럼 써.
코드 두 줄 순서를 바꿨을 뿐인데, 이 방법은 심플렉틱(symplectic) 성질 덕분에 장기적으로 에너지가 폭주하지 않고 안정적으로 유지돼. 가성비 최강의 꿀팁이야. 🍯
function integrate(body, gravity, dt) {
if (body.invMass === 0) return; // 정적 물체는 패스
const acc = gravity.add(body.force.scale(body.invMass));
body.vel = body.vel.add(acc.scale(dt)); // 1) 속도 먼저
body.pos = body.pos.add(body.vel.scale(dt)); // 2) 새 속도로 위치
body.force = new Vec2(0, 0); // 힘은 매 스텝 초기화
}
더 욕심이 난다면 베를레 적분(Verlet)도 있어. 속도를 따로 저장하지 않고 x_new = 2x − x_old + a·dt²로 이전 위치만 기억하는 방식이야. 천 시뮬레이션, 로프, 래그돌처럼 "점들을 막대로 연결한 구조"에 특히 잘 어울려서 Thomas Jakobsen이 히트맨(Hitman: Codename 47)의 캐릭터 물리에 활용한 사례로 유명해.
| 적분법 | 구현 난이도 | 안정성 | 주 사용처 |
|---|---|---|---|
| 명시적 오일러 | 매우 쉬움 | 낮음 (에너지 증가) | 학습용, 단순 이동 |
| 세미-임플리싯 오일러 | 매우 쉬움 | 좋음 | 대부분의 게임 엔진 |
| 베를레 | 쉬움 | 좋음 | 천, 로프, 파티클 |
| RK4 | 보통 | 정확도 높음 | 과학 시뮬레이션 |
5. 고정 타임스텝: 컴퓨터 사양 따라 물리가 바뀌면 곤란해 ⏱️
requestAnimationFrame의 프레임 간격은 모니터 주사율이나 기기 성능에 따라 들쭉날쭉해. 60Hz에서는 약 16.7ms, 144Hz 모니터에서는 약 6.9ms지. 이 값을 그대로 dt로 넣으면 같은 공이 기기마다 다르게 튀는 사태가 벌어져.
그래서 Glenn Fiedler의 유명한 글 "Fix Your Timestep!"에서 소개된 누산기(accumulator) 패턴을 써. 흐른 실제 시간을 통에 모아두고, 고정된 dt만큼씩 덜어내며 물리를 돌리는 거야.
const FIXED_DT = 1 / 60;
let accumulator = 0;
let lastTime = performance.now();
function loop(now) {
let frameTime = (now - lastTime) / 1000;
lastTime = now;
if (frameTime > 0.25) frameTime = 0.25; // 탭 전환 후 폭주 방지
accumulator += frameTime;
while (accumulator >= FIXED_DT) {
world.step(FIXED_DT);
accumulator -= FIXED_DT;
}
render();
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
⚠️ frameTime 상한을 꼭 둬! 브라우저 탭을 잠깐 내렸다 돌아오면 수 초가 한 번에 쌓이는데, 그걸 다 처리하려고 while문이 수백 번 돌면 프레임이 더 느려지고 또 더 많이 쌓이는 악순환, 이른바 "죽음의 나선(spiral of death)"에 빠져.
6. 충돌 감지: 두 원이 겹쳤는지 알아내기 🔍
원끼리의 충돌은 정말 우아해. 중심 사이 거리가 반지름 합보다 작으면 겹친 거야. 끝!
다만 제곱근(sqrt)은 상대적으로 비싸니까, 먼저 거리의 제곱으로 비교하고 실제 겹쳤을 때만 sqrt를 계산하는 게 국룰이야.
function circleVsCircle(a, b) {
const delta = b.pos.sub(a.pos);
const rSum = a.radius + b.radius;
const distSq = delta.dot(delta);
if (distSq >= rSum * rSum) return null; // 안 닿음
const dist = Math.sqrt(distSq);
const normal = dist > 0 ? delta.scale(1 / dist) : new Vec2(1, 0);
return { a, b, normal, penetration: rSum - dist };
}
반환하는 정보를 매니폴드(manifold)라고 불러. "누가, 어느 방향(법선 n)으로, 얼마나 깊이(penetration) 겹쳤나"를 담은 충돌 보고서라고 보면 돼. 법선은 A에서 B를 향하도록 통일하는 게 중요해. 방향이 꼬이면 물체가 서로 빨려 들어가는 공포 영화가 펼쳐지거든. 👻
박스가 필요하면 AABB(축 정렬 경계 상자)가 다음 단계야. 두 상자의 x 구간과 y 구간이 둘 다 겹치면 충돌이고, 겹친 양이 더 작은 축이 법선 방향이 돼. 회전하는 다각형까지 가면 SAT(분리축 정리)나 GJK 알고리즘이 등장하는데, 오늘은 원으로 기본기를 다지자.
7. 충돌 해결: 충격량으로 튕겨내기 💥
이제 엔진의 하이라이트야. 겹친 걸 찾았으면 속도를 바꿔서 튕겨내야지. 이때 쓰는 게 충격량(impulse) j야. 뉴턴의 반발 법칙과 운동량 보존에서 유도된 공식은 이래.
여기서 vrel = vB − vA(상대 속도), e는 반발계수야. e = 1이면 에너지 손실 없는 완전 탄성 충돌(슈퍼볼 느낌), e = 0이면 찰흙처럼 딱 붙어버리는 완전 비탄성 충돌이지. 두 물체의 e가 다르면 보통 둘 중 작은 값(min)을 쓰는 경우가 많아.
function resolveCollision(m) {
const { a, b, normal } = m;
const relVel = b.vel.sub(a.vel);
const velAlongNormal = relVel.dot(normal);
// 이미 서로 멀어지는 중이면 건드리지 않음
if (velAlongNormal > 0) return;
const invMassSum = a.invMass + b.invMass;
if (invMassSum === 0) return; // 둘 다 정적 물체
const e = Math.min(a.restitution, b.restitution);
const j = -(1 + e) * velAlongNormal / invMassSum;
const impulse = normal.scale(j);
a.vel = a.vel.sub(impulse.scale(a.invMass));
b.vel = b.vel.add(impulse.scale(b.invMass));
}
✨ if (velAlongNormal > 0) return; 이 한 줄이 아까 말한 내적의 활약 포인트야.
이미 떨어지고 있는 물체에 또 충격량을 주면 서로 들러붙어서 부르르 떠는 버그가 생겨. 이 조건이 그걸 막아줘.
충격량은 각 물체에 역질량에 비례해서 나눠 적용돼. 그래서 볼링공(무거움)과 탁구공(가벼움)이 부딪히면 탁구공만 휙 날아가는 거야. 그리고 바닥처럼 invMass가 0인 물체는 속도 변화가 0이니 절대 안 움직이지. 앞에서 깔아둔 복선이 여기서 회수돼! 🎯
8. 위치 보정: 가라앉는 공을 구출하라 🛟
충격량만으로는 속도만 고칠 뿐, 이미 파고든 위치는 그대로야. 게다가 부동소수점 오차가 쌓이면 바닥 위의 공이 조금씩 땅속으로 가라앉는(sinking) 현상이 생겨. 그래서 겹친 깊이의 일부만큼 서로 밀어내는 선형 투영(linear projection) 보정을 해줘.
function positionalCorrection(m) {
const { a, b, normal, penetration } = m;
const PERCENT = 0.4; // 보정 비율 (보통 0.2~0.8)
const SLOP = 0.01; // 이 정도 겹침은 허용 (떨림 방지)
const invMassSum = a.invMass + b.invMass;
if (invMassSum === 0) return;
const amount = Math.max(penetration - SLOP, 0) / invMassSum * PERCENT;
const correction = normal.scale(amount);
a.pos = a.pos.sub(correction.scale(a.invMass));
b.pos = b.pos.add(correction.scale(b.invMass));
}
왜 100% 다 안 밀어내냐고? 한 번에 확 밀면 다른 물체와 새로 겹치면서 덜덜 떨리는(jitter) 현상이 생기거든. 조금씩 여러 프레임에 걸쳐 밀어내는 게 훨씬 부드러워. SLOP은 "이 정도는 봐줄게" 하는 허용 오차라서, 쌓인 물체들이 미세하게 진동하는 걸 막아줘. 이 PERCENT·SLOP 조합은 Randy Gaul이 쓴 입문 튜토리얼 시리즈에서도 소개된 대표적인 기법이야.
9. 전부 합체! World 클래스 🧩
재료가 다 모였어. 이제 하나의 월드로 묶어보자.
class World {
constructor(width, height) {
this.bodies = [];
this.gravity = new Vec2(0, 980); // 픽셀 단위 중력(아래가 +y)
this.width = width;
this.height = height;
this.iterations = 4; // 충돌 해결 반복 횟수
}
add(body) { this.bodies.push(body); return body; }
step(dt) {
for (const b of this.bodies) integrate(b, this.gravity, dt);
for (let k = 0; k < this.iterations; k++) {
const contacts = [];
for (let i = 0; i < this.bodies.length; i++) {
for (let j = i + 1; j < this.bodies.length; j++) {
const m = circleVsCircle(this.bodies[i], this.bodies[j]);
if (m) contacts.push(m);
}
}
for (const m of contacts) resolveCollision(m);
for (const m of contacts) positionalCorrection(m);
}
this.handleWalls();
}
handleWalls() {
for (const b of this.bodies) {
if (b.invMass === 0) continue;
if (b.pos.y + b.radius > this.height) {
b.pos.y = this.height - b.radius;
b.vel.y = -b.vel.y * b.restitution;
}
if (b.pos.x - b.radius < 0) {
b.pos.x = b.radius; b.vel.x = -b.vel.x * b.restitution;
}
if (b.pos.x + b.radius > this.width) {
b.pos.x = this.width - b.radius; b.vel.x = -b.vel.x * b.restitution;
}
}
}
}
그리고 화면에 그리는 부분과 클릭하면 공이 생기는 이벤트까지 붙이면 완성이야.
const canvas = document.querySelector('canvas');
const ctx = canvas.getContext('2d');
const world = new World(canvas.width, canvas.height);
canvas.addEventListener('click', (e) => {
const r = 10 + Math.random() * 20;
const ball = world.add(new Body(e.offsetX, e.offsetY, r, r * r * 0.01, 0.7));
ball.vel = new Vec2((Math.random() - 0.5) * 300, 0);
});
function render() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (const b of world.bodies) {
ctx.beginPath();
ctx.arc(b.pos.x, b.pos.y, b.radius, 0, Math.PI * 2);
ctx.fillStyle = '#4f7cff';
ctx.fill();
}
}
HTML에 <canvas width="800" height="500"> 하나 두고 위 코드들을 순서대로 붙이면, 클릭할 때마다 공이 떨어져서 서로 부딪히고 쌓이는 걸 볼 수 있어. 질량을 r²에 비례하게 준 건 원의 넓이에 비례하도록 해서 큰 공이 더 묵직하게 느껴지게 하려는 거야. 🎉
🔁 iterations를 왜 여러 번 돌릴까?공 A가 B를 밀어냈더니 B가 C와 새로 겹칠 수 있잖아. 한 번만 처리하면 피라미드처럼 쌓인 공들이 출렁거려. 여러 번 반복하면 해가 점점 수렴해서 안정적으로 쌓여. Box2D 같은 엔진이 쓰는 순차 충격량(Sequential Impulses) 방식도 반복 계산으로 수렴시킨다는 점에서 같은 발상이야.
10. 다음 레벨로 가는 업그레이드 로드맵 🚀
여기까지 왔으면 이미 "물리엔진 만들어봤다"고 자랑해도 돼. 근데 공을 1,000개쯤 넣으면 슬슬 버벅이기 시작할 거야. 지금 충돌 검사가 모든 쌍을 비교하는 O(n²) 방식이라서 그래. 1,000개면 약 50만 쌍을 매 반복마다 검사하는 셈이지.
특히 터널링은 꼭 알아둬. 반지름 10px 공이 초당 3,000px로 날아가면 1/60초 동안 50px을 이동해. 두께가 그보다 얇은 벽이면 이전 프레임엔 벽 앞, 다음 프레임엔 벽 뒤에 있어서 충돌을 아예 못 잡아. 간단한 해결책은 dt를 쪼개 여러 번 도는 서브스텝, 정석은 이동 경로 전체를 스윕해서 검사하는 CCD야.
11. 디버깅 꿀팁 모음 🐞
| 증상 | 흔한 원인 | 처방 |
|---|---|---|
| 공이 점점 더 높이 튐 | 명시적 오일러 사용, e > 1 | 세미-임플리싯으로 순서 변경, e ≤ 1 확인 |
| 바닥으로 서서히 가라앉음 | 위치 보정 없음 | positionalCorrection 추가 |
| 쌓인 공이 덜덜 떨림 | 보정 비율 과다, slop 없음 | PERCENT 낮추고 SLOP 부여, iterations 증가 |
| 서로 빨려 들어감 | 법선 방향 뒤집힘 | A→B 방향 통일 확인 |
| 기기마다 움직임이 다름 | 가변 dt 사용 | 고정 타임스텝 + 누산기 |
| NaN 폭발 | 거리 0으로 나눔 | dist > 0 검사, 기본 법선 지정 |
디버깅할 땐 법선 벡터와 속도 벡터를 화살표로 그려보는 것이 진짜 최고야. 숫자 로그 백 줄 보는 것보다 화살표 하나가 훨씬 빨리 원인을 알려줘. 그리고 전체 운동에너지(½mv²의 합)를 화면 구석에 띄워두면 적분이 이상한지 바로 눈에 보여.
12. 직접 만들어보는 게 왜 의미 있을까? 🌱
솔직히 실제 프로젝트에서는 Box2D, Matter.js, Rapier 같은 검증된 엔진을 쓰는 게 맞아. 수년간 다듬어진 안정성을 혼자 따라잡기는 어렵거든. 그런데도 한 번 직접 만들어보면 엔진 파라미터의 의미가 확 와닿아. "restitution이 왜 1을 넘으면 안 되지?", "velocityIterations를 늘리면 왜 쌓인 상자가 안정되지?" 같은 질문에 스스로 답할 수 있게 되지. 이게 진짜 실력 차이를 만들어.
이렇게 만든 미니 엔진은 포트폴리오로도 꽤 매력적이야. 인터랙티브 웹 배너, 간단한 캐주얼 게임, 교육용 시뮬레이터처럼 활용처가 넓어서, 재능넷 같은 재능 거래 플랫폼에서 웹 인터랙션이나 게임 프로토타입 작업을 의뢰받을 때도 든든한 무기가 돼. 물리 감각이 있는 개발자는 생각보다 귀하거든.
📌 오늘의 핵심 정리
① 위치·속도·힘은 벡터, 정적 물체는 invMass = 0으로 표현
② 적분은 속도 먼저, 위치 나중(세미-임플리싯 오일러)
③ 시간은 고정 타임스텝 + 누산기로, 프레임 시간 상한은 필수
④ 충돌은 감지 → 충격량 → 위치 보정 3종 세트
⑤ 반복(iterations)으로 쌓인 물체를 안정화하고, 성능은 광역 단계로 해결
이 다섯 줄만 기억하면 어떤 물리엔진 문서를 펼쳐도 길을 잃지 않을 거야. 이제 에디터 열고 첫 공을 떨어뜨려 봐! 🏀
재능넷의 지식인의 숲에서 다음엔 회전과 마찰까지 붙인 2편으로 또 만나자고.
댓글 작성
이 글에 대한 여러분의 생각을 들려주세요
로그인이 필요합니다
댓글을 작성하려면 먼저 로그인해주세요.
댓글 0
지식인의 숲 - 지적 재산권 보호 고지
지적 재산권 보호 고지
- 저작권 및 소유권: 본 컨텐츠는 재능넷의 독점 AI 기술로 생성되었으며, 대한민국 저작권법 및 국제 저작권 협약에 의해 보호됩니다.
- AI 생성 컨텐츠의 법적 지위: 본 AI 생성 컨텐츠는 재능넷의 지적 창작물로 인정되며, 관련 법규에 따라 저작권 보호를 받습니다.
- 사용 제한: 재능넷의 명시적 서면 동의 없이 본 컨텐츠를 복제, 수정, 배포, 또는 상업적으로 활용하는 행위는 엄격히 금지됩니다.
- 데이터 수집 금지: 본 컨텐츠에 대한 무단 스크래핑, 크롤링, 및 자동화된 데이터 수집은 법적 제재의 대상이 됩니다.
- AI 학습 제한: 재능넷의 AI 생성 컨텐츠를 타 AI 모델 학습에 무단 사용하는 행위는 금지되며, 이는 지적 재산권 침해로 간주됩니다.
아직 댓글이 없습니다.