cyphen156

게임 수학 5장-2 : 행렬(가상 세계의 변환 도구) 본문

수학/게임수학

게임 수학 5장-2 : 행렬(가상 세계의 변환 도구)

cyphen156 2023. 9. 8. 13:58

앞선 글에서는 선형성에 대해서 알아보았습니다. 이런  선형 변환의 계산 과정을 체계화 하여 손쉽게 만든 것을 행렬(Matrix)이라고 합니다.

행렬(Matrix)

행렬은 간단하게 표현하면 표(Table)입니다.

행렬을 그리면 n개의 행과 m개의 열로 이루어진 직사각형의 형태를 띕니다.

1행 1열 1행 2열 1행 3열 1행 4열 1행 5열
2행 1열 2행 2열 2행 3열 2행 4열 2행 5열
3행 1열 3행 2열 3행 3열 3행 4열 3행 5열

(3 X 5형태의 행렬)

열(Row)벡터와 행(Col)백터

그래픽스 기술은 대표적으로 OpenGL(Open Graphic Library)와 DirectX가 존재하는데 OpenGL은 열 벡터를 기준으로 데이터를 처리하고, DirectX는 행 벡터를 기준으로 데이터를 처리한다는 차이점이 존재하지만 두 데이터의 변환은 역행렬의 존재로 인해 간단하게 처리가 가능합니다.

특히 벡터의 기준이 열이되느냐 행이 되느냐는 사소하지만 큰 차이가 있습니다. 바로 상호 교환을 위해 전치(Transpose)시켜야 한다는 점 입니다. 그래서 보통 열 벡터를 사용하는 OpenGL과 C#스크립트를 사용하여 게임엔진을 구성하는 Unity엔진 사용 유저와 DirectX와 C++언어를 사용하여 게임엔진을 구성하는 Unreal엔진 사용 유저간의 대화가 간혹가다가 안맞을 때가 있다는 점을 고려하여 항상 인지하고 있어야 합니다.

열벡터 A와 행벡터 B

정방행렬(Square Matrix)

정사각형 형태의 행과 열의 크기가 같은 행렬을 정방행렬이라 말하고 선형 변환을 표현할 때 사용합니다.

정방행렬 C

행렬의 연산

행렬은 여러가지 수학적 연산이 가능합니다. 행렬의 연산은 정방행렬, 또는 서로 다른 두 행렬의 행과 열의 조건이 연산의 조건과 맞아야지만 가능합니다.

덧셈

행렬의 덧셈은 서로 다른 두 행렬의 크기가 같은 경우에만 성립합니다.

행렬의 덧셈

그런데 "덧셈이 가능하면 뺄셈도 가능하지 않아요?" 라는 의문이 들 수 있습니다. 하지만 이것은 컴퓨터에게는 불가능한 일로 컴퓨터의 뺄셈 연산은 항상 2번째 피 연산자의 2진 보수를 구한 뒤 더하는 것으로 연산을 처리하기 때문에 수학적 행렬연산에서는 가능한 뺄셈 연산을 컴퓨터는 사용하지 않는 것 입니다. 이것은 같은 의미로 스칼라 곱셈, 행렬 곱에도 적용되는 원칙입니다.

스칼라 곱셈

스칼라 곱이라 함은 벡터에 단순한 수치를 곱한 값을 의미한다고 하였습니다. 이것은 행렬에도 적용됩니다.

행렬의 스칼라 곱셈

전치행렬 

전치행렬은 행렬의 행과 열을 서로 바꾸는 작업을 합니다. 가령 3X2행렬의 경우 2X3의 행렬이 됩니다. 표현은 첨자 T를 표시해줍니다.

행렬 곱

서로 다른 두 행렬의 곱셈은 첫 번째 피연산자의 열과 두 번째의 피연산자의 행의 갯수가 같아야만 연산이 가능합니다. 그외의 경우에는 연산을 할 수 없습니다.

가령 2X3행렬 A와 3X4 행렬 B는 A X B의 연산이 가능(2 X 4 행렬)하지만 그 반대인 B X A의 연산은 불가능합니다. 이를 이용하여 행렬의 연산에는 결합 법칙은 성립하지만 교환법칙이 성립하지 않음을 알 수 있습니다. 

A x B의 연산 결과 행렬C

행렬 곱 연산에서 교환 법칙이 성립되지 않음 증명

이를 증명하기 위해 행렬 A (2X3)과 행렬 B(3X2)를 이용하겠습니다.

한눈에 봐도 같지 않음이 느껴지죠? 행렬의 크기 자체가 달라져버립니다.

그런데 이런 성질을 역이용 할 수 있는 방법이 있습니다. 바로 2차원 벡터를 열벡터 또는 행벡터로 사용하여 행렬 곱을 연산하는 것 입니다. 이러한 성질의 역이용이 가능한 이유는 서로 다른 두 행렬 곱의 전치는 피연산자 행렬 두 개를 전치한 후 역순으로 곱한것과 같기 때문입니다. 

즉, 합성함수의 역함수를 구하는 것과 같은 법칙이 성립한다는 것 입니다.

행렬 곱의 전치의 증명

짜잔! 보시는 대로 두 연산의 결과가 같습니다.

이제 이것을 이용하여 정방행렬 A와 이차원 벡터를 변환한 열벡터 (x, y)를 곱한 결과를 보면

라는 2차원 벡터 공간의 선형 변환과 동일한 결과를 나타낸 다는 것을 알 수 있습니다. 또한 벡터의 선형 변환을 적용하기 위한 연산 순서가 오른쪽에서 왼쪽 방향으로 이루어짐을 알 수 있습니다.

그래서 결합법칙을 이용하면 컴퓨터의 벡터생성의 연산을 크게 줄여준다는 것을 알 수 있습니다.

행렬을 통한 벡터의 변환

행렬을 통해 벡터를 변환할 때는 사용되는 프로그래밍언어, 그래픽스 라이브러리에 따라 기준이 바뀐다고 했습니다. 여기에서는 OpenGL과 동일한 열벡터를 기준으로 설명하겠습니다.

행렬의 변환은 차원수에 해당하는 정방행렬의 행렬에 벡터를 곱하여 표준기저벡터를 변환할 수 있습니다.

크기 변환 행렬

물체의 크기를 변경하는 행렬

크기 변환 행렬 S와 변환된 벡터(x, y)

회전 변환 행렬

주어진 각θ를 통해 벡터 공간을 회전시키는 행렬

전단 변환 행렬

전단 변환 행렬은 한 점을 원점으로 고정하고, 어느 한 변을 임의의 값 만큼 위치를 이동시키는 행렬입니다.

단위 행렬을 기본 베이스로 크기 변환 행렬에서의 0에 해당되는 위치의 값을 좌표이동시킬 값, a || b만큼 변화시킵니다.

이 식의 증명은 S에 벡터 A(x, y)를 곱하면 x+ay, bx+y라는 형태를 띄게 되는데 

이러한 전단 변환의 결과로 x축으로는 a만큼, y축으로는 b만큼 밀린 형태의 사다리꼴 모양이 나타나게 됩니다.

아래의 예시에서는 x축으로는 a만큼, y축으로는 0만큼 밀기변환 한 결과입니다.

삼각함수의 덧셈 정리

  • cos(A+B) = cosAcosB - sinAsinB
  • sin(A+B) = sinAcosB + cosAsinB

삼각함수의 덧셈 정리는 회전변환의 결합법칙을 통해서 증명할 수 있다. 가령 +45만큼 회전한 뒤 -30만큼 회전 변환을 수행한 단위 정사각형이 있다고 가정하면, 두 단계의 회전변환을 수행한 뒤의 최종 결과는 맨 처음 회전변환을 수행하지 않은 정사각형이 +15만큼 회전변환을 수행했다고 볼 수 있다.

수식으로 표현하면

역행렬

역행렬은 출력에서 입력값을 도출해 내는 행렬입니다. 

즉, 원본 변환행렬을 단위 행렬로 만드는 행렬을 구하면 그것이 바로 역행렬 입니다.

실습하기

다음의 코드는 본문 내용중 CH5 행렬 : 가상 세계의 변환 도구 내의 예제 코드 1~2까지를 모두 합친 코드입니다.

  1. Home/End버튼을 통해 하트그래프와 사각형 그래프를 "회전"시킬 수 있습니다
  2. 화살표 키를 입력하여 중앙에 있는 하트그래프를 평행이동 시킬 수 있습니다.
  3. 중앙 하트그래프의 이동 거리에 따라 왼쪽, 오른쪽 하트가 각각 전단 변환 (밀기 변환) 합니다. 그 결과로 하트가 찌그러집니다.
#include "Precompiled.h"
#include "SoftRenderer.h"
#include <random>
using namespace CK::DD;

// 격자를 그리는 함수
void SoftRenderer::DrawGizmo2D()
{
	auto& r = GetRenderer();
	const auto& g = Get2DGameEngine();

	// 그리드 색상
	LinearColor gridColor(LinearColor(0.8f, 0.8f, 0.8f, 0.3f));

	// 뷰의 영역 계산
	Vector2 viewPos = g.GetMainCamera().GetTransform().GetPosition();
	Vector2 extent = Vector2(_ScreenSize.X * 0.5f, _ScreenSize.Y * 0.5f);

	// 좌측 하단에서부터 격자 그리기
	int xGridCount = _ScreenSize.X / _Grid2DUnit;
	int yGridCount = _ScreenSize.Y / _Grid2DUnit;

	// 그리드가 시작되는 좌하단 좌표 값 계산
	Vector2 minPos = viewPos - extent;
	Vector2 minGridPos = Vector2(ceilf(minPos.X / (float)_Grid2DUnit), ceilf(minPos.Y / (float)_Grid2DUnit)) * (float)_Grid2DUnit;
	ScreenPoint gridBottomLeft = ScreenPoint::ToScreenCoordinate(_ScreenSize, minGridPos - viewPos);

	for (int ix = 0; ix < xGridCount; ++ix)
	{
		r.DrawFullVerticalLine(gridBottomLeft.X + ix * _Grid2DUnit, gridColor);
	}

	for (int iy = 0; iy < yGridCount; ++iy)
	{
		r.DrawFullHorizontalLine(gridBottomLeft.Y - iy * _Grid2DUnit, gridColor);
	}

	ScreenPoint worldOrigin = ScreenPoint::ToScreenCoordinate(_ScreenSize, -viewPos);
	r.DrawFullHorizontalLine(worldOrigin.Y, LinearColor::Red);
	r.DrawFullVerticalLine(worldOrigin.X, LinearColor::Green);
}

// 게임 오브젝트 목록


// 최초 씬 로딩을 담당하는 함수
void SoftRenderer::LoadScene2D()
{
	// 최초 씬 로딩에서 사용하는 모듈 내 주요 레퍼런스
	auto& g = Get2DGameEngine();

}

// 게임 로직과 렌더링 로직이 공유하는 변수
Vector2 currentPosition;
float currentShear = 0.f;
float currentScale = 10.f;
float currentDegree = 0.f;

// 게임 로직을 담당하는 함수
void SoftRenderer::Update2D(float InDeltaSeconds)
{
	// 게임 로직에서 사용하는 모듈 내 주요 레퍼런스
	auto& g = Get2DGameEngine();
	const InputManager& input = g.GetInputManager();

	// 게임 로직의 로컬 변수
	static float shearSpeed = 2.f;
	static float moveSpeed = 100.f;
	static float scaleMin = 5.f;
	static float scaleMax = 20.f;
	static float scaleSpeed = 20.f;
	static float rotateSpeed = 180.f;

	Vector2 inputVector = Vector2(input.GetAxis(InputAxis::XAxis), input.GetAxis(InputAxis::YAxis)).GetNormalize();
	Vector2 deltaPosition = inputVector * moveSpeed * InDeltaSeconds;
	float deltaShear = input.GetAxis(InputAxis::XAxis) * shearSpeed * InDeltaSeconds;
	float deltaScale = input.GetAxis(InputAxis::ZAxis) * scaleSpeed * InDeltaSeconds;
	float deltaDegree = input.GetAxis(InputAxis::WAxis) * rotateSpeed * InDeltaSeconds;

	// 물체의 최종 상태 설정
	currentShear += deltaShear;
	currentPosition += deltaPosition;
	currentScale = Math::Clamp(currentScale + deltaScale, scaleMin, scaleMax);
	currentDegree += deltaDegree;
}

// 렌더링 로직을 담당하는 함수
void SoftRenderer::Render2D()
{
	// 렌더링 로직에서 사용하는 모듈 내 주요 레퍼런스
	auto& r = GetRenderer();
	const auto& g = Get2DGameEngine();

	// 배경에 격자 그리기
	DrawGizmo2D();

	// 렌더링 로직의 로컬 변수
	float rad = 0.f;
	static float increment = 0.001f;
	static std::vector<Vector2> hearts;
	HSVColor hsv(0.f, 1.f, 0.85f);

	static Vector2 pivot(200.f, 0.f);

	// 하트를 구성하는 점 생성
	if (hearts.empty())
	{
		for (rad = 0.f; rad < Math::TwoPI; rad += increment)
		{
			float sin = sinf(rad);
			float cos = cosf(rad);
			float cos2 = cosf(2 * rad);
			float cos3 = cosf(3 * rad);
			float cos4 = cosf(4 * rad);
			float x = 16.f * sin * sin * sin;
			float y = 13 * cos - 5 * cos2 - 2 * cos3 - cos4;
			hearts.push_back(Vector2(x, y));
		}
	}
	// 행렬 곱을 사용해 실습 하트의 이동과 회전 동시에 구현하기
	// 각도에 해당하는 사인과 코사인 함수 얻기
	float sin = 0.f, cos = 0.f;
	Math::GetSinCos(sin, cos, currentDegree);

	// 회전 변환 행렬의 기저 벡터와 행렬
	Vector2 rBasis1(cos, sin);
	Vector2 rBasis2(-sin, cos);
	Matrix2x2 rMatrix(rBasis1, rBasis2);

	// 크기 변환 행렬의 기저 벡터와 행렬
	Vector2 sBasis1 = Vector2::UnitX * currentScale;
	Vector2 sBasis2 = Vector2::UnitY * currentScale;
	Matrix2x2 sMatrix(sBasis1, sBasis2);

	// 크기, 회전의 순서로 진행하는 합성 변환 행렬의 계산
	Matrix2x2 finalMatrix = rMatrix * sMatrix;

	// 전단 변환 행렬
	Vector2 shBasis1 = Vector2::UnitX;
	Vector2 shBasis2(currentShear, 1.f);
	Matrix2x2 shMatrix(shBasis1, shBasis2);

	// 합성 행렬
	Matrix2x2 cMatrix = shMatrix * rMatrix * sMatrix;

	// 크기 변환 행렬의 역행렬
	float invScale = 1.f / currentScale;
	Vector2 isBasis1(invScale, 1.f);
	Vector2 isBasis2(1.f, invScale);
	Matrix2x2 isMatrix(isBasis1, isBasis2);

	// 회전 변환행렬의 역행렬
	Matrix2x2 irMatrix = rMatrix.Transpose();

	// 전단 변환 행렬의 역행렬
	Vector2 ishBasis1 = Vector2::UnitX;
	Vector2 ishBasis2(-currentShear, 1.f);
	Matrix2x2 ishMatrix(ishBasis1, ishBasis2);

	// 역행렬의 합성행렬(역순으로 결합하기)
	Matrix2x2 icMatrix = isMatrix * irMatrix * ishMatrix;

	// 각 값을 초기화한 후 색상을 증가시키면서 점에 대응
	rad = 0.f;
	for (auto const& v : hearts)
	{
		// 1. 점에 행렬을 적용한다.
		Vector2 transformedV = finalMatrix * v;

		// 2. 변환된 점을 이동한다.
		Vector2 translatedV = transformedV + currentPosition;

		// 3. 왼쪽, 오른쪽 하트 그리기
		Vector2 left = cMatrix * v;
		r.DrawPoint(left - pivot, hsv.ToLinearColor());

		Vector2 right = icMatrix * v;
		r.DrawPoint(right + pivot, hsv.ToLinearColor());

		hsv.H = rad / Math::TwoPI;
		r.DrawPoint(translatedV, hsv.ToLinearColor());
		rad += increment;
	}


	// 현재 위치, 크기, 각도를 화면에 출력
	r.PushStatisticText(std::string("Position : ") + currentPosition.ToString());
	r.PushStatisticText(std::string("Scale : ") + std::to_string(currentScale));
	r.PushStatisticText(std::string("Degree : ") + std::to_string(currentDegree));
}

// 메시를 그리는 함수
void SoftRenderer::DrawMesh2D(const class DD::Mesh& InMesh, const Matrix3x3& InMatrix, const LinearColor& InColor)
{
}

// 삼각형을 그리는 함수
void SoftRenderer::DrawTriangle2D(std::vector<DD::Vertex2D>& InVertices, const LinearColor& InColor, FillMode InFillMode)
{
}

모든 예제 코드의 소스파일은 

GitHub - onlybooks/gamemath: <이득우의 게임 수학> 공식 깃허브 페이지

 

GitHub - onlybooks/gamemath: <이득우의 게임 수학> 공식 깃허브 페이지

<이득우의 게임 수학> 공식 깃허브 페이지. Contribute to onlybooks/gamemath development by creating an account on GitHub.

github.com

또한 제 개인 깃허브 레포지토리 에 있습니다.

Workspace/C++/GameMath at main · cyphen156/Workspace · GitHub

※ 이득우 교수님의 인프런 게임수학강의를 참고하여 작성되었습니다.