이 절은 PhysX, Havok, Bullet Physics, Open Dynamics Engine 같은 실시간 강체 시뮬레이터를 개괄합니다. 물리 엔진 자체를 직접 개발하는 게임 프로그래머는 많지 않으며, 처음부터 새로 작성하는 사람은 더 적습니다. 우리 대부분은 그것을 사용하는 방법만 알면 됩니다.
다행히 이 점에서는 앞서 나열한 물리 엔진들의 공통점이 많습니다. 하지만 물리 엔진도 다른 프로그래밍 도구와 같습니다. 직접 만들 생각이 없더라도 내부 작동을 기본적으로 이해하면 더 효과적으로 사용할 수 있습니다.
여러 이유로 아주 자세한 설명까지 할 수 없다는 점이 아쉽습니다. 첫째, ‘물리 엔진이 작동하는 방법’을 설명하려고 하면 원문 집필 당시 이 분야에 여전히 큰 다양성과 빠른 혁신이 있다는 사실 때문에 복잡해집니다. 둘째, 수학이 금방 입문서에 적절한 수준보다 어려워집니다. 솔직히 저자들에게는 최신 기술 전체를 처음부터 끝까지 간결하게 정리할 만큼의 전문성이 없습니다.
하지만 사용자 관점에서 여러 물리 엔진의 공통점 중 입문 수준으로 설명할 수 있는 부분은 많습니다. 따라서 일반적인 물리 엔진 인터페이스의 개요부터 시작합니다. 이 장 끝부분에서는 골라낸 몇 가지 수학도 설명합니다.
12.6.1 물리 엔진의 상태변수
프레드 브룩스의 말로 알려진 컴퓨터 과학의 오래된 격언이 있습니다. “순서도를 보여주고 표를 숨기면 나는 계속 이해하지 못할 것입니다. 표를 보여주면 보통 순서도는 필요하지 않을 것입니다. 뻔히 보일 테니까.” 오래된 용어를 들으면 The Print Shop으로 연속 접지 용지에 인쇄한 현수막이 떠오르지만, 그 말의 핵심은 여전히 유효합니다.
소프트웨어를 작성하거나 이해하려면 먼저 어떤 데이터를 다루는지 설명하는 데서 시작하는 것이 좋습니다. 물리 엔진 사용자 관점에서 강체 시뮬레이터 안에는 세 종류의 주요 데이터 객체가 있습니다. 동역학 바디, 충돌 형상 데이터, 제약입니다. 차례로 살펴보겠습니다.
동역학 바디의 상태. 실시간 강체 시뮬레이터에서 가장 기본적인 데이터 객체는 아마 동역학 바디(dynamics body)일 것입니다. 강체의 ‘영혼’이라고 생각할 수 있습니다. 어디로 갈지 정하지만 겉모습은 없어서 그 효과를 통해서만 간접적으로 볼 수 있습니다. 뒤에서 설명할 충돌 형상은 강체에 ‘육신’, 즉 모양을 부여합니다. 눈으로 보는 강체의 부분은 보통 어떤 그래픽 모델이며, 이는 물리 엔진과 관계없으므로 여기서는 다루지 않습니다.
여기서 동역학 바디와 일상적 의미의 ‘물체’—기술 용어나 프로그래밍 용어로서의 객체를 뜻하는 것은 아닙니다—사이의 관계를 설명할 필요가 있습니다. Old Man Murray가 발견 소식을 즐겨 전하던 게임 속 흔하디흔한 나무상자 같은 간단한 강체는 동역학 바디 하나로 시뮬레이션할 수 있습니다.
자동차나 사람 몸처럼 움직이는 부분이 있는 복잡한 물체는 강체가 아니므로 하나의 동역학 바디로 시뮬레이션할 수 없습니다. 대신 단단한 부분들로 나누고 각 부분에 대응하는 동역학 바디들을 뒤에서 설명할 관절 제약으로 연결해야 합니다. 물론 이런 복합 물체의 그래픽 표현까지 ‘강체’일 필요는 없지만, 물리 시뮬레이션 안에서 각 동역학 바디는 강체입니다.
동역학 바디가 영혼과 비슷한 점은 또 있습니다. 정확하게 정의하려 하기보다 속성을 나열하는 편이 쉽습니다. 따라서 동역학 바디를 이루는 변수들을 열거해 보겠습니다. 변수의 수명 주기, 즉 언제 초기화되고 얼마나 자주 값이 바뀌는지에 따라 분류하겠습니다. 어떤 속성은 대부분 일정하고, 어떤 속성은 물체가 존재하는 동안 계속 변하며, 어떤 속성은 시뮬레이션 시간 단계가 시작될 때 생기거나 초기화되고 끝날 때 버리는 작업용 변수입니다.
첫 번째 종류는 응용 프로그램이 바디를 생성할 때 초기화하며 시뮬레이션 동안 보통 일정한 속성입니다. 반드시 일정해야 하는 것은 아닙니다.
•
질량과 관성 텐서는 강체의 중요한 속성이며, 시뮬레이션 동안 바뀌지 않는 경우가 많습니다. 하지만 시간이 지나면서 응용 프로그램이 바꾸지 못할 본질적인 이유는 없습니다. 예를 들어 자동차가 연료를 태우는 상황을 표현할 수 있습니다. 앞서 말했듯 시뮬레이션이 실제로 자주 필요로 하는 것은 역질량과 역관성 텐서이므로, 필요할 때마다 계산하지 않도록 추가 유도량으로 보관하는 경우가 많습니다. 또한 관성 텐서는 강체의 물체 축을 기준으로 표현된 벡터에 작용할 때만 상수입니다. 월드 축을 기준으로 하면 물체가 회전함에 따라 계속 달라집니다. 원문은 이 대목에서 관성 텐서를 ‘회전’이라고 부르지만, 회전행렬과의 구분은 보충 해설에 적었습니다.
•
때로 물리 엔진은 물체의 질량중심 오프셋을 저장합니다. 관성 텐서처럼 물체 공간에서만 상수입니다. 앞서 설명했듯 수치 안정성과 단순함을 위해 엔진 내부에서는 물체 공간의 원점이 질량중심이라고 가정하고, 사용자 인터페이스 루틴에서 이 오프셋을 처리하기도 합니다.
•
각 바디는 하나 이상의 충돌 형상 객체와 연결됩니다. 아래에서 설명하듯 그 합집합이 강체의 모양을 정의합니다.
두 번째 종류는 시간에 따라 변하며 ‘이력’을 담고 있어서 한 프레임에서 다음 프레임으로 이어가야 하는 상태변수입니다. 시뮬레이션의 전체 상태를 저장했다가 나중에 재개하려면—예를 들어 게임 저장 기능을 만들려면—다른 정보로부터 복원할 수 없는 다음 변수들을 직렬화해야 합니다.
•
위치
•
자세
•
선속도
•
각속도
이 목록은 속도를 주요 상태변수로 선택한 것입니다. 그러면 운동량은 쉽게 계산할 수 있는 유도량이 됩니다. 반대로 운동량을 주요 상태변수로, 속도를 유도량으로 삼을 수도 있습니다. 첫 번째 방법은 관성, 따라서 운동량을 무한대로 다루는 운동학적 제어 물체를 처리할 때 장점이 있습니다. 두 번째 방법은 운동량 보존을 자동으로 지키므로 질량이 시간에 따라 바뀌는 상황을 더 자연스럽게 처리할 수 있습니다.
마지막으로 각 동역학 바디에는 작업용 변수에 저장하는 속성들이 있습니다. 이 양들도 시간에 따라 변하지만 본래 ‘이력’이 적분되어 들어 있는 값은 아닙니다. 보통 시뮬레이션 단계의 어느 시점에서 초기화합니다. 상태를 저장했다가 재개하려는 경우 대개〔주20〕 이 변수들을 상태 설명에 포함할 필요가 없습니다.
•
힘과 토크 누산기
•
선형 충격량과 각충격량 누산기
•
현재 접촉 제약 목록
여기서 ‘누산기’라는 표현은 실제로 자주 하는 작업을 반영합니다. 프레임 중 어느 시점에 이 값들을 0으로 초기화한 뒤 여러 외력 원천을 조회하고 그 알짜 결과를 이 변수들에 저장하는 경우가 많습니다.
코드 12.2는 동역학 바디 상태의 핵심을 정리하고 C++에서 어떻게 구현할 수 있을지 보여주는 구조체입니다. 이 변수명들은 이 장 뒤의 의사코드에서도 사용합니다. 실제 물리 엔진의 대응 클래스와 비교하여 그 엔진이 어떤 데이터를 더 보관하는지, 어떤 다른 선택을 했는지 살펴보면 좋습니다.
struct DynamicsBody {
//
// 기본 물리량
//
// 질량중심의 위치
Vector3 pos;
// 쿼터니언 형식의 자세
Quaternion rotQuat;
// 질량
float mass;
// 물체 공간에서 표현한 관성모멘트
Matrix3x3 jBody;
// 속도
Vector3 linVel;
Vector3 angVel;
//
// 유도 물리량
//
// 행렬 형식의 자세
Matrix3x3 rotMat;
// 역질량과 역관성 텐서
float oneOverMass;
Matrix3x3 invJBody;
//
// 임시 / 작업용 변수
//
// 힘 누산기. 매 시간 단계에서 0으로 초기화한다.
Vector3 force;
Vector3 torque;
Vector3 linImpulse;
Vector3 angImpulse;
//
// 충돌 및 제약 목록
//
vector<UserConstraint*> userConstraints;
vector<CollisionShape*> collisionShapes;
};
C++
복사
코드 12.2. 동역학 바디 상태변수.
충돌 형상. 동역학 바디가 강체의 영혼이라면 충돌 형상은 육체로 드러난 모습입니다. 동역학 바디의 모양뿐 아니라 ‘영혼 없는’ 정적 물체의 모양도 정의합니다. 물리 엔진은 보통 여러 기본 도형을 지원합니다. 복잡도가 낮은 순서로 나열하면 다음과 같습니다.
•
구, 상자, 평면, 원기둥, 원뿔 같은 기본적인 추상 도형
•
볼록 다면체
•
임의의 충돌 메시. ‘삼각형 수프(triangle soup)’라고도 합니다.
간단한 모양은 속도와 안정성 모두에 이점이 있습니다. 오목하거나 복잡한 모양을 여러 기본 도형으로 조립하는 편이 좋은 경우가 많은 이유입니다. 이런 기본 도형들은 서로 겹쳐도 됩니다. 같은 동역학 바디에 연결된 두 형상 객체는 서로 충돌하지 않으므로 합집합만 중요합니다. 어떤 형상 객체 쌍이 충돌하는지 유연하게 정할 수 있어야 한다는 점도 중요합니다.
예를 들어 캐릭터의 허벅지는 연결된 몸통과 정강이에 충돌하지 않지만, 몸의 다른 모든 부분과는 충돌하게 할 수 있습니다. 또는 비디오 게임 물리의 제1법칙에 따라 적은 통과하지만 플레이어는 통과하지 못하는 장벽을 만들 수 있습니다.
충돌 형상 객체는 동역학 바디 하나와 연결되어 그 바디에 대해 고정된 상대 위치·자세를 가지거나, 어떤 동역학 바디와도 연결되지 않은 채 정적인 ‘월드’의 일부가 됩니다.
제약. 강체 시뮬레이터의 세 번째이자 마지막 주요 객체 종류는 제약(constraint) 입니다. 제약은 두 강체 사이 또는 강체와 월드 사이의 관계를 유지합니다. 응용 프로그램은 두 종류의 사용자 제약을 만들 수 있습니다. 관절과 모터입니다. 세 번째 종류는 충돌 반응에 관여하는 접촉 제약입니다.
사용자 제약은 원하는 관계를 유지하려고 응용 프로그램이 지정하는 ‘보통의’ 제약입니다. 더 흔하고 이해하기 쉬운 종류는 두 부분 사이의 공간 관계를 유지하는 관절(joint) 입니다. 대부분의 물리 엔진에 내장된 관절 유형의 예는 다음과 같습니다.
•
볼 소켓 관절(ball-and-socket joint) 은 공유하는 한 점이 두 물체 각각의 물체 축 기준으로 고정된 위치를 유지하도록 제한합니다. 한 물체의 물체 공간에는 고정된 공이 있고 다른 물체에는 고정된 소켓이 있다고 생각해도 됩니다. 제약은 이 두 점이 같은 위치에 오게 합니다.
•
힌지 관절(hinge joint) 은 볼 소켓 관절에 조건 하나를 더합니다. 공에 연결된 축과 소켓에 연결된 축이 같은 직선 위에 있어야 합니다. 따라서 두 물체는 공유 축을 중심으로 경첩처럼 회전할 수 있습니다. 힌지의 회전각에 한계를 둘 수도 있습니다.
•
슬라이더 관절(slider joint) 또는 프리즘 관절(prismatic joint) 은 각 물체의 물체 공간에 고정된 두 축을 같은 직선 위에 놓이도록 제한합니다. 물체들은 그 축을 따라 앞뒤로 미끄러질 수 있고, 경우에 따라 축 주위로 비틀 수도 있습니다. 평행이동 범위에 한계를 적용할 수 있습니다.
•
유니버설 관절(universal joint) 은 볼 소켓 관절과 비슷하지만 회전각의 한계를 지정할 수 있습니다. 이 각 한계는 오일러각—헤딩과 피치를 떠올려 보세요—이어서 운동 범위가 직사각형이 됩니다. 비틀림인 ‘뱅크’에도 한계를 둘 수 있습니다.
•
원뿔 관절(conical joint) 은 유니버설 관절과 비슷하지만 회전 범위의 경계가 직사각형이 아니라 원뿔 모양입니다.
예를 들어 사람 골격의 각 ‘뼈’를 별도 강체로 시뮬레이션하고 제약으로 부모 뼈에 연결할 수 있습니다. 무릎에는 뒤로 꺾이지 않도록 한계를 정한 힌지 관절을 쓸 수 있습니다. 엉덩이와 어깨에는 원뿔 관절이나 유니버설 관절을 쓸 수 있습니다.
관절 제약이 바디들의 위치·자세를 다루는 데 비해 모터(motor) 는 두 바디 사이에 지정한 상대속도를 유지하려는 제약입니다. 적절한 모터를 쓰면 응용 프로그램은 물리 엔진에 이렇게 지시할 수 있습니다. “바디 A는 바디 B의 기준계에 고정된 축 을 기준으로 각속력 를 유지해야 합니다.”
대부분의 물리 엔진에는 다양한 제약과 사용자 정의 제약 유형을 추가하는 장치도 있습니다. 제약이 반드시 절대적이어야 하는 것도 아닙니다. 제약을 지키기 위해 적용할 수 있는 힘에 한계를 두도록 엔진에 지시할 수 있습니다. 대부분의 엔진은 제약을 만족시키려고 실제로 얼마의 힘을 적용했는지 조회하는 방법을 제공합니다.
이는 모터가 힘겨워할 때 소리를 재생하거나, 어떤 문턱값을 넘으면 관절을 파괴하는 등의 작업에 유용합니다. 관절과 그 한계는 ‘부드러울’ 수도 있습니다. 예를 들어 Open Dynamics Engine에서는 오차 감소 매개변수(ERP) 와 제약력 혼합(CFM) 매개변수를 조정하여 관절이 스프링–댐퍼처럼 작동하게 할 수 있습니다.
응용 프로그램 제약은 꼭 ‘기억’을 가지지는 않고 프로그램이 마음대로 생성·파괴할 수 있습니다. 자동차에서 바퀴를 떼거나 몸에서 팔을 분리하는 경우가 예입니다. 하지만 일반적으로 시간 단계를 넘어 유지됩니다. 반대로 접촉 제약은 물리 엔진이 만들고 같은 물리 시간 단계 안에서 없앱니다.
적어도 개념적으로는 그렇습니다. 성능이나 안정성을 위해 내부적으로 남겨 둘 수 있습니다. 접촉 제약은 두 바디의 충돌 형상 사이, 또는 한 바디와 정적 형상 사이의 비침투를 유지합니다. 충돌 검출 시스템의 주요 결과이자 충돌 반응을 수행하는 장치입니다.
접촉 제약을 충돌 검출 중 물리 엔진이 만든다고 해서 응용 프로그램이 참여할 수 없다는 뜻은 아닙니다. 물리 엔진은 접촉점의 생성을 조정하고 동역학 바디에 접촉 제약이 적용될 때 응용 프로그램에 알려주는 여러 훅을 제공합니다. 이 훅은 특정 물체 쌍의 상호작용을 세밀하게 바꾸는 강력한 수단입니다. 충돌 알림은 소리·파티클 효과·체력 감소 같은 피드백을 구현하는 데 필수적입니다.
이 절에서는 물리 엔진의 세 가지 주요 데이터 ‘표’를 소개했습니다. 다음 절에서는 ‘순서도’를 다룹니다. 독자와 프레드 브룩스에게 너무 ‘뻔한’ 내용은 아니기를 바랍니다.
〔주20〕 정확한 세부사항은 수치 적분 방법에 따라 다릅니다. 일부 적분 방법은 더 높은 차수의 도함수를 근사하기 위해 이전 값들을 사용합니다.
원문 표현과 구현 조건
관성 텐서는 대칭행렬이지만 회전행렬 자체가 아닙니다. 질량 분포와 회전 저항을 나타냅니다. 원문 12.6.1의 ‘being a rotation’은 부정확한 표현입니다. 이상적인 단일 질점의 3차원 관성 텐서는 특이행렬이라 역행렬이 없을 수 있다는 점도 주의합니다.
12.6.2 전체 흐름 살펴보기
앞 절의 ‘표’를 조작하도록 물리 엔진이 제공하는 API들은 상당히 비슷합니다. 하지만 내부 작동 방식은 더 다양합니다. 이제부터는 일반적인 설명과 개략적인 논의에 의존할 수밖에 없습니다. 이 절은 물리 엔진이 게임 루프에 어떻게 들어가는지 전체 흐름을 보여주는 의사코드로 보여준 다음, 물리 엔진의 핵심 내부에서 사용하는 몇 가지 일반적인 전략을 간단히 설명합니다.
먼저 게임 루프 자체를 코드 12.3으로 정리합니다.
void gameLoop() {
getReady();
while (!gameOver) {
simPrePhysics();
physicsEngine->update();
simPostPhysics();
render();
}
}
C++
복사
코드 12.3. 아주 간단한 게임 루프.
각 ‘함수’에서 하는 물리 관련 작업을 설명하겠습니다. 먼저 getReady()에서는 보통의 텍스처·모델 로딩 외에도 12.6.1절에서 설명한 세 가지 주요 유형의 객체를 물리 시스템에 만듭니다. 월드에는 아마 많은 충돌 형상이 있을 것입니다. 간단한 시뮬레이션 물체마다 동역학 바디 하나와 충돌 형상 하나 또는 몇 개를 둘 수 있습니다.
관절이 있는 복잡한 모델에는 관절 제약으로 연결한 여러 바디와 각 바디의 충돌 형상이 필요할 수 있습니다. 플레이어 캐릭터에는 매 프레임 원하는 위치로 끌어당기도록 신중하게 조절한 제약을 설정할 수도 있습니다.
게임 루프 안에서는 시뮬레이션을 세 단계로 나누었습니다. 먼저 물리 엔진을 호출하기 전에 하는 여러 작업을 simPrePhysics()로 묶었습니다. 여기서는 물리 처리에 들어갈 입력을 준비합니다. 운동학적으로 제어되는 물체를 처리하고 새 위치·속도를 물리 시스템에 알릴 수 있습니다. 플레이어 입력을 읽고 그 조작이 요구하는 위치를 정한 뒤, 물리 엔진이 캐릭터를 그 위치로 옮기는 데 사용하는 제약·힘·토크를 갱신합니다.
네트워크 게임에서는 로컬 호스트가 권한을 갖지 않는 물체에 관해 네트워크를 조회하고, 엔진에 “이 물체를 여기로 가게 해 보라”고 알려주는 제약을 갱신할 수 있습니다. 모든 물리 객체를 getReady()에서 한 번만 만들고 영원히 유지할 필요도 없습니다. 언제든 물리 세계에 물체를 추가하거나 제거할 수 있습니다.
다음으로 물리 엔진을 호출해 작업하게 합니다. 대부분의 코드는 엔진 내부에 있지만 여러 목적을 위해 게임 코드와 다시 통신합니다. 단순한 알림일 수도 있고 응용 프로그램이 동작을 맞춤 조정할 기회를 주는 것일 수도 있습니다. 이 절 뒤에서 엔진들이 사용하는 몇 가지 접근법을 살펴봅니다.
물리 엔진이 끝나면 simPostPhysics()로 묶은 여러 후속 작업이 필요합니다. 가장 중요한 것은 아마 엔진이 결정한 새 위치와 자세로 게임 객체를 갱신하는 일입니다. 모든 객체를 돌며 물리 엔진에서 새 위치를 조회하거나 콜백으로 알림을 받을 수 있습니다. 갱신된 위치만이 물리 처리의 결과는 아닙니다.
제약을 유지하는 데 필요했던 힘이나 발생한 충돌 목록에도 관심이 있을 수 있습니다. 게임 설계와 카메라 시뮬레이션 방법에 따라, 플레이어의 움직임에 맞추어 움직이도록 물리 처리가 끝난 뒤 카메라를 갱신하는 경우가 많습니다.
마지막으로 render() 함수가 나타내듯 어느 시점에서는 물론 장면을 그려야 합니다.
의사코드의 physicsEngine->update()는 물리 엔진의 핵심을 나타냅니다. 엔진 두 개가 완전히 같은 방식으로 작동하는 것은 아니지만 공통적인 주제는 있습니다. 여기서는 Erleben 등의 더 자세한 조사 [19]를 바탕으로 몇 가지 전략을 간단히 정리합니다.
페널티 방법(penalty methods). 페널티 방법은 스프링 같은 장치로 충돌을 해소합니다. 충돌 검출은 서로 파고든 충돌 형상 쌍의 목록을 제공합니다. 각 쌍에 대해 형상 소유자인 동역학 바디를 찾아 침투 깊이에 비례하는 반발력을 각각 가합니다. 다시 말해 페널티 방법은 충돌을 발견한 시간 단계 안에서 곧바로 해소하려 하지 않습니다. 시간이 지나면서 힘 때문에 물체들이 분리됩니다. 물론 ‘스프링’을 신중하게 조절해야 합니다. 물체를 쌓아 두면 평형 상태에서 스프링 힘이 중력과 균형을 이루므로, 보통 페널티 방법의 목적은 침투를 완전히 없애기보다 허용할 수준으로 제한하는 것입니다. 코드 12.4는 단순화한 구현 예입니다.
void PhysicsEngine::update() {
// 동역학 바디에 작용하는 외력을 모은다.
// 예: 중력, 스프링 등
computeForces();
// 서로 파고든 충돌 형상과 그 소유 바디를 찾는다.
struct Collision {
DynamicsBody *body1, *body2; // 관련된 바디
Vector3 p; // 충돌 위치
Vector3 n; // 접촉 법선
float penetrationDepth;
};
vector<Collision> collisions = collisionDetection();
// 각 충돌을 스프링처럼 다룬다.
for (each collision) {
// 침투 깊이로부터 힘을 계산한다.
Vector3 f = calculateForce(collisions[i]);
// 두 바디에 이 힘을 더한다.
collisions[i].body1->addForceAtPoint(collisions[i].p, f);
collisions[i].body2->addForceAtPoint(collisions[i].p, -f);
}
// 힘(가속도)을 속도에, 속도를 위치에 적분하여
// 시뮬레이션을 앞으로 진행한다.
integrateForces();
}
C++
복사
코드 12.4. 페널티 방법에 기반한 물리 시뮬레이션의 의사코드.
순차 충격량 시뮬레이션(sequential impulse simulations). 이 방법은 Mirtich [46]가 널리 알렸습니다. 정지 접촉과 충돌 접촉을 모두, 필요하다면 아주 높은 빈도로 일어나는 충돌로 모형화합니다. 두 물체 A와 B의 충돌을 발견하면 12.5.4절의 간단한 뉴턴 충돌 모형 같은 충돌 법칙으로 침투를 막을 충격량을 계산합니다. 하지만 이것이 A와 B의 다른 위치에서, 또는 B와 C 사이에서 다른 충돌을 일으킬 수도 있습니다. 따라서 모든 접촉점의 상대속도가 정지하거나 분리하는 상태가 될 때까지 반복해야 합니다. 코드 12.5가 기본 아이디어입니다.
void PhysicsEngine::update() {
// 동역학 바디에 작용하는 외력을 모은다.
// 예: 중력, 스프링 등
computeForces();
// 힘(가속도)을 속도에 적분하되 아직 위치는 갱신하지 않는다.
updateVelocities();
// 충돌하는 바디를 찾는다.
struct Collision {
DynamicsBody *body1, *body2; // 관련된 바디
Vector3 p; // 충돌 위치
Vector3 n; // 접촉 법선
float penetrationDepth;
};
vector<Collision> collisions = collisionDetection();
// 모든 상대 접촉 속도가 해결될 때까지 충격량을 적용한다.
for (;;) {
// 접촉점의 상대 점 속도가 법선을 따라 서로 가까워지는
// 충돌을 찾는다. 정지 또는 분리 접촉이 아니라 충돌 접촉이다.
Collision *c = nextUnresolvedCollision(collisions);
if (c == NULL)
break;
// 충돌 법칙으로 충격량을 계산한다.
Vector3 impulse = calculateCollisionImpulse(c);
// 두 바디에 충격량을 적용한다.
// 선속도와 각속도가 즉시 바뀐다.
c->body1->addImpulseAtPoint(c->p, impulse);
c->body2->addImpulseAtPoint(c->p, -impulse);
// 모든 충돌 속도가 정지 또는 분리 상태가 될 때까지 반복한다.
}
// 이제 속도를 바탕으로 위치를 앞으로 진행한다.
integrateVelocities();
}
C++
복사
코드 12.5. 순차 충격량 물리 시뮬레이션의 의사코드.
첫 번째 어려움은 접촉 처리 순서에 따라 시뮬레이션 결과가 달라질 수 있다는 것입니다. 그 순서는 충돌 검출이 우연히 만들어 낸 순서인 경우가 많습니다. 두 번째는 무한 루프에 빠질 수 있다는 점입니다. 따라서 종료를 보장하도록 주의해야 합니다.
속도 기반 시뮬레이션(velocity-based simulations). 원문은 이 기법들이 집필 당시 실시간 시뮬레이션의 최첨단을 대표한다고 설명합니다. 침투 방지와 충돌 해소를 만족시켜야 할 제약으로 봅니다. 앞에서 말했듯 관절·모터 같은 사용자 제약과 표준화된 방식으로 함께 처리합니다. 각 제약에 대해 선속도·각속도의 변화에 비해 제약 충족 정도의 변화율이 어떻게 변하는지 살펴봅니다. 이런 편미분의 행렬을 야코비 행렬(Jacobian matrix) 이라고 합니다. 시뮬레이터는 이 정보를 사용해 제약을 만족하는 속도를 풉니다. 코드 12.6이 이를 보여줍니다.
void PhysicsEngine::update() {
// 제약에서 오는 힘을 제외하고 동역학 바디에 작용하는 외력을 모은다.
computeForces();
// 힘을 임시 적용하여 제약을 적용하기 전의 위치와 속도를 계산한다.
integrateForcesTentatively();
// 제약 목록을 만든다. 두 출처는 충돌 검출의 접촉 제약과
// 사용자 제약이다.
vector<ConstraintRow> constraintRows;
collisionDetection(constraintRows);
processApplicationConstraints(constraintRows);
// 제약을 만족하는 속도를 푼다.
solveConstraints(constraintRows);
// 계산된, 제약을 만족하는 속도로 이제 실제 위치를 갱신한다.
integrateVelocities();
}
C++
복사
코드 12.6. 속도 기반 물리 시뮬레이션의 의사코드.
결과 행렬 문제는 일반적인 선형 연립방정식이 아니라 연립부등식입니다. 예를 들어 충돌 법칙이 두 물체가 어떤 속도로 튕겨 나가야 한다고 예측하면 그 속도를 최소 속도로 해석합니다. 다른 제약—예를 들어 스프링이 한 물체를 끌어당기는 경우—때문에 예상보다 빠르게 떨어진다고 해서 접촉 제약을 위반한 것은 아닙니다.
이런 계는 선형 상보성 문제(linear complementarity problem, LCP) 라는 표준 형식으로 나타낼 수 있습니다. 원문의 영문 표기는 ‘complimentary’지만 이 용어는 complementarity입니다.
속도 기반 방법의 여러 구현은 페널티 방법과 순차 충격량 시뮬레이션의 아이디어도 빌려 쓰므로, 어느 한 종류로 엄격히 분류하기 어려울 때가 있습니다. 선형 상보성 문제를 푸는 점진적 행렬 솔버는 바디의 속도를 조금씩 조정합니다. 물리적으로는 이런 조정을 일련의 충격량으로 해석할 수 있습니다.
따라서 속도 기반 솔버와 순차 충격량 솔버에는 비슷한 점이 있습니다. 차이는 순차 충격량 솔버가 충돌 법칙을 여러 번 적용한다는 것입니다. 속도 기반 시뮬레이션에서는 충돌 법칙을 한 번 적용하여 이상적인 상대속도를 정한 뒤 그 속도를 목표 또는 제약으로 취급합니다. 특히 Open Dynamics Engine 같은 일부 속도 기반 솔버는 페널티 방법과 비슷한 기법으로 제약을 ‘부드럽게’ 다룰 수 있습니다.
12.6.3 오일러 적분
모든 물리 엔진은 시간상으로 ‘한 걸음 앞으로’ 나아갈 수 있어야 합니다. 어떤 시각 의 위치·속도, 선형 값과 각 값을 모두 알고 있으며 미래의 시각 에서의 값을 구하고 싶다고 하겠습니다. 결과는 물체가 받는 알짜힘과 물론 시각 의 초기속도에 달려 있습니다.
힘 자체도 바디의 위치·속도에 따라 달라질 수 있습니다. 예를 들어 고정점에 스프링으로 연결된 바디는 위치에 따라 다른 힘을 받습니다. 공기 같은 유체를 통과하는 바디는 유체와의 상대속도에 어떤 관계—비선형일 수도 있습니다—를 가지는 항력을 받습니다. 힘이 바디들 사이의 상호작용에서 생길 수도 있습니다.
예를 들어 스프링으로 연결한 네 바디가 사면체를 이루고 한 꼭짓점만 물에 잠긴 경우를 생각할 수 있습니다. 즉 사소한 문제가 아닙니다.
이 작업의 수학 용어는 수치 적분(numerical integration) 입니다. 설명은 크게 둘로 나뉩니다. 이 절에서는 회전을 무시하고 선가속도와 선속도로 적분의 기본 개념을 설명합니다. 12.6.4절에서는 각가속도와 각속도를 적분하는 방법을 살펴봅니다.
적분은 도함수에서 원래 함수를 알아내는 과정이었습니다. 여기서는 시간의 함수 세 개, 즉 위치·속도·가속도를 다룹니다. 앞의 설명을 떠올려 보면 속도는 위치의 도함수이고 가속도는 속도의 도함수임을 기억할 것입니다. 미분방정식을 기호적으로 푸는 것이 아니므로 수치 적분을 다룬다고 합니다.
대신 알고 있는 도함수, 즉 힘에 뉴턴의 제2법칙을 적용해서 얻는 가속도를 유한한 개수의 떨어진 시각에서만 표본화합니다. 어려움이 무엇인지 이해하기 위해 가장 단순한 접근부터 시작하고 어디에서 실패하는지 보겠습니다.
시간 단계의 길이를 초 단위 로 나타내겠습니다. 는 시뮬레이션 주파수입니다. 예를 들어 60 Hz로 실행하면 입니다. 가장 간단한 적분 방법은 한 단계 동안 도함수가 일정하다고 가정하는 것입니다. 현재가 번째 시간 단계라면 다음 단계 의 위치는 다음과 같습니다.
속도를 위치에 오일러 적분하기
이 전략을 오일러 적분(Euler integration) 이라고 합니다. 수치 적분은 보통 미적분 뒤에 배우는 ‘고급’ 주제일 수 있지만, 대부분에게 오일러 적분은 진짜 해석적 적분보다 이해하기 쉽습니다. 오일러 적분으로 해석적 적분을 소개하는 것은 흔한 방식이며, 이름을 말하지는 않았지만 그림 11.10에서 운동을 구할 때 정확히 그렇게 했습니다.
원문은 이 대상을 토끼라고 부릅니다. 그때 드러난 핵심은 간단한 오일러 적분이 시간 단계 안에서 도함수가 달라지는 것을 무시하며, 그것이 오차의 원인이라는 점입니다. 답의 오차를 줄이는 가장 분명한 방법은 단계 길이 를 줄이는 것입니다. 어떤 경우에는 기호 조작을 통해 이를 극한까지 줄여 완벽한 답, 즉 해석적 적분을 얻을 수 있습니다.
하지만 함수가 복잡해서 그 값을 계산해 보는 것, 즉 ‘표본화’하는 것만 가능한 때도 있습니다.
원문 집필 당시 게임에서 관심이 컸던 세 가지 시뮬레이션인 머리카락·천·유체를 생각해 보겠습니다. 각각 시뮬레이션 대상을 작은 조각으로 나누어 문제를 이산화한 다음, 단순화한 힘 법칙으로 각 조각을 시뮬레이션합니다. 천의 기본 전략은 정점 집합으로 표현하고 각 정점을 주변 정점들과 ‘스프링’으로 연결하며 항력의 영향을 받게 하는 것입니다.
어떤 정점이 받는 힘은 그 정점과 이웃들의 위치·속도에 모두 달려 있습니다. 어느 순간에 작용하는 힘을 계산하기는 어렵지 않지만, 위치·속도에 의존하므로 결과를 곧바로 적분할 수는 없습니다. 단순한 적분이 아니라 미분방정식 문제가 되는 이유입니다. 이런 방정식은 대개 해석적 해라는 정면 공격으로 풀리지 않으므로 수치 적분을 써야 합니다.
천 정점 하나의 속도를 시간의 함수로 쓰고 테일러 급수 전개로 다항식 근사를 한다고 하겠습니다. 11.4.6절을 참고하겠습니다. 시각 의 값을 알고 있으며 그 주위의 작은 구간만 관심 있다고 가정합니다. 그러면 다음 형태로 근사할 수 있습니다.
오일러 적분은 이 식의 일차항 까지만 맞추므로 1차 수치 적분법이라고 합니다. 여기서 그 항은 단순히 가속도 입니다. 가 대략 이고 일반적으로 높은 차수의 항이 빠르게 작아지는 것은 사실입니다. 하지만 계수 들의 크기는 모르므로 더 많은 항을 맞출수록 더 높은 정확도를 얻을 수 있습니다.〔주21〕 가속도가 일정하다면 오일러 적분은 속도를 정확하게 구합니다.
하지만 힘이 위치·속도에 의존한다면 오일러 적분이 무시하는 높은 차수의 항들은 0이 아닙니다.
더 좋은 결과를 얻으려면 식 (12.33)의 더 많은 항을 맞추는 고차 수치 적분법이 필요합니다. 흔하고 중요한 방법 하나가 룽게–쿠타(Runge–Kutta)입니다. 한 번 이상 시험 단계를 밟아 서로 다른 위치에서 힘을 표본화하고, 그 표본들을 적절한 방식으로 결합합니다.
수치 적분은 많은 문헌과 충분히 연구된 다양한 기법이 있는 성숙한 분야입니다. 물리 시뮬레이션에만 초점을 맞춘 입문서라면 여러 방법을 설명하는 것이 적절하겠지만, 폭넓은 내용을 다루는 이 입문서의 몇 장에 끼워 넣을 수 있는 범위를 벗어나므로 아쉽습니다. 그래도 수치 적분에 관해 중요한 교훈 두 가지는 강조하고 싶습니다.
첫 번째 메시지는 이미 전했습니다. 오일러 적분의 약점을 이해하고 정확도·안정성·성능 사이에서 다른 절충을 제공하는 방법들이 있음을 알아두세요. 목적에 더 잘 맞는 적분기는 서로 다릅니다. 원문은 당시 실시간으로 해결하려던 천·머리카락·유체·연체 시뮬레이션, 관절이 있는 특히 인간형 캐릭터에서 동적 물리와 사전 제작 애니메이션을 결합하는 문제 등에서는 거의 어떤 적분기도 오일러 적분보다 잘 작동한다고 설명합니다.
이런 발전된 시뮬레이션에 관심이 있다면 적분 방법의 이해가 선행되어야 합니다. 12.7절의 추천 자료가 좋은 출발점입니다.
두 번째 메시지는, 방금 나열한 ‘고급’ 시뮬레이션에는 고차 방법이 더 적합하지만 강체 시뮬레이션에는 단순한 오일러 적분을 여전히 많이 쓴다는 것입니다. 이유를 한 단어로 말하면 제약입니다. 어떤 시뮬레이션에서는 힘이 시간이나 위치의 연속함수이고 테일러 급수가 잘 전개되어 고차 적분법이 잘 맞습니다.
스프링 같은 연결을 사용하는 시뮬레이션이나 충돌을 부드러운 페널티 방식으로 다루는 시뮬레이션이 그렇습니다. 이 경우 고차 적분기를 사용하여 시간 단계마다 ‘안쪽 루프’의 시험 단계·표본 수를 늘리는 편이, 를 줄여 ‘바깥 루프’ 반복 수를 늘리는 것보다 같은 CPU 시간으로 더 많이 오차를 줄입니다.
하지만 강체 시뮬레이션의 제약은 불연속인 경우가 많으며 본질적으로 LCP 기반 접근을 요구합니다. 이런 불연속함수를 훅의 법칙으로 근사하면 매우 큰 스프링 상수가 필요하고 불안정성이 생깁니다. 실제로 그 미분방정식은 강성(stiff) 방정식이라고 하며, 원문은 명시적 적분 대신 암시적 적분이 필요하다고 설명합니다.
안타깝게도 당시 널리 쓰이던 속도 기반 제약 솔버는 제약을 맞추기 위해 속도를 조정하므로, 제안한 속도 변화가 적분 뒤 위치와 제약 충족에 어떤 영향을 주는지 ‘미래를 내다볼’ 수 있어야 합니다. 복잡한 적분 방법은 이런 예측을 더 복잡하게 만듭니다. 강성 방정식에 쓰는 암시적 적분법도 본질적으로 ‘미래를 내다보는’ 방식으로 작동합니다.
원문 집필 당시 게임의 인기 있는 실시간 강체 시뮬레이터 설계들이 선택한 바에 따르면, 접촉·관절 제약을 처리하는 방법 때문에 고차 적분기보다 시간 단계 를 줄이는 편이 CPU 시간을 더 효율적으로 쓸 수 있었습니다. 더 직설적으로 말하면 당시 실시간 시뮬레이션은 정확도보다 안정성을 더 중요하게 여겼습니다.
복잡한 문제들의 자세한 설명은 여기까지로 하고, 이제 위치·속도·가속도의 기본 오일러 적분식을 제시할 준비가 되었습니다.
가속도와 속도의 오일러 적분
핵심 연산들이므로 C++에서 어떻게 구현할지 보겠습니다. 코드 12.7은 수행 순서 덕분에 아래첨자가 필요 없으므로 식보다 읽기 조금 쉽습니다.
struct Particle {
Vector3 pos; // 질량중심의 월드 위치
Vector3 linVel; // 속도
Vector3 force; // 현재 작용하는 힘
float mass; // 물체의 질량
// 시간 간격 dt만큼 간단한 오일러 단계를 진행한다.
void eulerIntegrate(float dt) {
Vector3 acceleration = force / mass;
linVel += acceleration * dt;
pos += linVel * dt;
}
}
C++
복사
코드 12.7. 간단한 오일러 적분. 원문의 구조체 끝 표기를 그대로 보존했습니다.
〔주21〕 보통은 그렇습니다. Press 등 [56]은 차수가 높다고 정확도가 반드시 높아지는 것은 아니라고 강조합니다.
명시적 오일러와 반암시적 오일러
12.6.3의 첫 식은 이전 속도로 위치를 갱신하는 명시적 오일러입니다.
반면 절 끝의 식과 코드 12.7은 갱신한 속도로 위치를 갱신하는 반암시적 오일러입니다.
코드의 두 줄 순서가 의미를 바꿉니다. 둘 다 1차 방법이지만 에너지 오차와 안정성의 성질이 같지는 않습니다. 고차라고 언제나 게임에 더 좋은 것도 아니며, ‘오일러 적분’이라는 이름만으로 구현을 판정해서는 안 됩니다. 실제 예로 Box2D 공식 FAQ는 반암시적 오일러를 사용한다고 명시합니다. 원문이 적분법과 솔버를 분류하는 방식은 집필 당시의 설명이므로 현대 엔진의 고정된 분류표로 받아들이지 않는 것이 좋습니다.
원문 표현과 구현 조건
원문 코드 12.7은 설명용 코드입니다. 실제 C++ 구조체 선언을 끝내려면 마지막 } 뒤에 ;를 붙여야 합니다. mass == 0인 정적 물체에 force / mass를 적용해서도 안 됩니다.
12.6.4 회전의 적분
이제 3차원 회전 데이터를 적분하는 방법을 조금 이야기하겠습니다. 회전에서 에 해당하는 식은 무엇일까요? 먼저 각속도와 자세 값의 도함수 사이에 관한 역학의 잘 알려진 결과 몇 가지를 제시합니다.
질량중심 주위로 순간 각속도 로 회전하는 물체에 고정된 임의의 점 을 생각하겠습니다. 을 나타내는 좌표계의 원점은 물체의 질량중심에 있지만 축들은 물체와 함께 회전하지 않는다고 가정합니다. 이 책에서는 점의 좌표를 ‘직립 공간’으로 표현한다고 합니다. 이 좌표를 ‘질량중심 좌표’라고 부르기도 합니다. 11.8.2절에서 이 벡터의 속도를 로 구했습니다. 같은 뜻으로 다음처럼 쓸 수 있습니다.
11.8절에서 구심가속도 때문에 속도가 계속 변한다는 것은 등속 원운동의 기본 성질이라고 했습니다. 속도가 일정하지 않으므로 방금 보았듯 간단한 오일러 단계는 정확하지 않습니다. 입니다. 하지만 충분히 확대해 보면 원형 경로의 짧은 구간이 직선과 매우 비슷해지고 오일러 단계도 그리 나쁘지 않습니다. 다시 말해 가 충분히 작거나 각속도 가 충분히 느리면 다음 근사를 받아들일 수 있습니다.
지금까지의 설명은 모든 벡터 에 적용되므로 물체 축 자체에 적용해 보겠습니다. 회전행렬 은 물체의 자세를 나타내며 왼쪽 행벡터를 물체 공간에서 직립 공간으로 회전합니다. 의 행들은 직립 공간으로 나타낸 물체 축입니다. 각 축에 식 (12.34)를 적용하고 싶습니다. 즉 와 각 행의 외적을 이 순서대로 취하려는 것입니다. 다행히 외적은 행렬 곱으로 쓸 수 있습니다. 연습문제 4.8을 참고하겠습니다.
이제 회전행렬의 도함수도 행렬 곱으로 표현할 수 있습니다.
각속도 인 물체의 자세 행렬 의 도함수
무슨 뜻일까요? 벡터 와 도함수 때처럼 의 각 성분은 의 대응 성분의 도함수입니다. 실제로 행렬 함수 의 각 성분은 범위 안에서 진동합니다. 하지만 앞에서처럼 와 각속도 가 작다면 굽이치며 진동하는 패턴의 짧은 구간이 직선처럼 보여, 간단한 오일러 단계 를 받아들일 수 있습니다.
다만 회전행렬에는 새로운 문제가 있습니다. 결과 행렬은 정규직교일 가능성이 낮습니다. 성분들 사이의 상호의존성을 무시한 채 각 성분에 독립적으로 오일러 단계를 적용했기 때문입니다. 해결 방법은 단계마다 행렬을 다시 직교화하는 것입니다. 6.3.3절을 참고하겠습니다.
물체의 자세를 회전행렬 대신 쿼터니언으로 표현해도 같은 기본 기법을 쓸 수 있습니다. 자세의 성분별 도함수를 찾고 각 성분에 독립적으로 오일러 단계를 적용한 다음 자세를 보정합니다. 쿼터니언의 도함수는 다음과 같습니다.
각속도 인 물체의 자세 쿼터니언 의 도함수
여기서 3차원 각속도 벡터 는 을 붙여 쿼터니언 공간으로 확장한 것입니다. Eberly [17, 10.6절]가 이 결과를 유도하므로 여기서는 증명하지 않습니다. 가 회전을 나타내는 단위 쿼터니언일 것으로 기대하면 안 됩니다. 오일러 적분 결과 도 길이가 1이라고 기대할 수 없으므로 정규화해야 합니다.
방금 설명한 자세 적분은 표준적인 방법입니다. 이 기법에는 두 가지 오차 원인이 있습니다. 첫 번째는 오일러 적분 자체에서 생깁니다. 각가속도와 더 높은 차수의 도함수를 무시하고 각속도가 일정한 것처럼 진행합니다. 이 오차는 각 데이터뿐 아니라 선형 데이터에도 있었습니다. 다만 선형량을 적분할 때는 유일한 오차 원인이었습니다.
두 번째는 성분별 도함수 때문에 생깁니다. 회전행렬이나 쿼터니언의 성분 사이 상호의존성을 고려하지 않기 때문입니다. 적어도 직교좌표를 쓰면 위치 성분들은 독립이므로, 이 오차는 각 데이터에만 있습니다. 다행히 두 번째 원인은 제거할 수 있습니다.
회전행렬 이나 쿼터니언 가 나타내는 물체의 각속도 가 일정하다고 잠시 가정하겠습니다. 이런 흔한 경우에는 각가속도를 무시해도 정확도를 잃지 않습니다. 하지만 가 커질수록 커지는 오차는 남습니다. 해결 방법은 간단합니다.
이 시간 단계 동안 일어날 유한 회전을 구하고, 해당 각변위를 적용합니다. 도구는 이미 갖고 있습니다. 지수 사상 형태의 회전 를 축–각 형태로 바꿉니다. 8.4절을 참고하겠습니다. 이 각변위를 회전행렬 또는 쿼터니언으로 바꾼 다음 현재 회전과 이어 붙입니다.
각각 5.1.3절과 8.5.2절을 참고하겠습니다. 본질적으로 오일러 적분을 수행할 더 나은 좌표계를 선택한 것입니다.
두 번째 방법은 계산 비용이 조금 더 크므로 그만한 가치가 있는지 물어볼 수 있습니다. 흔한 등각속도 상황에서는 부동소수점 반올림 오차를 제외하면 두 번째 방법이 어떤 시간 단계 에서도 정확합니다. 따라서 각속도가 크거나 시계 바늘처럼 높은 정확도가 필요하면 이 대안으로 바꾸는 것이 유리할 가능성이 큽니다.
하지만 오일러 적분 오차와 성분별 도함수 방법의 오차가 서로 더해지거나 상쇄될 수 있으므로, 한 오차 원인을 줄였다고 모든 경우에 최종 결과의 정확도가 높아진다고 보장할 수는 없습니다. 일부 물리 엔진이 각변위 적분 방법을 선택사항으로 제공하는 이유입니다.
원문 표현과 구현 조건
12.6.4의 쿼터니언 곱 순서는 각속도를 어느 공간에서 표현하는지와 사용하는 회전·쿼터니언 관례에 의존합니다. 다른 엔진으로 옮길 때 곱 순서를 무조건 복사하지 말고, 축 하나의 90도 회전으로 방향을 검증합니다.




