유튜브에서 C++ 콘솔 애플리케이션을 이용해서 3차원에서 회전하는 큐브를 구현한 영상을 보았다. 콘솔에서 저렇게 표현할 수 있다는게 재미있어 보여 따라 만들어 보았다.
https://github.com/Jeongmin94/SpinningCube
Windows API나 DirectX를 사용하지 않고 오로지 콘솔로만 3차원에 있는 물체를 화면에 표현할 수 있다는 점이 되게 흥미로웠다. 특히 구현의 편의성 때문에 간소화된 부분이 많이 있음에도 불구하고 실제로 그래픽스 라이브러리에서 물체를 회전, 투영하기 위해 사용되는 방법들이 그대로 적용되어 있었다.
먼저 원점을 기준으로 물체를 회전하기 위해 회전 행렬을 이용했다. 추가적인 라이브러리 없이 C++ 콘솔로만 구현을 하다보니 행렬을 사용한 것은 아니고, 행렬 연산의 결과물을 각각의 x, y, z 좌표에 적용했다.
struct vec3
{
float x, y, z;
vec3() : x(0.0f), y(0.0f), z(0.0f) {};
vec3(float _x, float _y, float _z) : x(_x), y(_y), z(_z) {};
vec3(const vec3& v) : x(v.x), y(v.y), z(v.z) {};
vec3& operator=(const vec3& v)
{
this->x = v.x;
this->y = v.y;
this->z = v.z;
return *this;
}
};
vec3 rotationAboutX(const vec3& v, float degree)
{
float rad = toRad(degree);
return vec3(
v.x,
cos(rad) * v.y - sin(rad) * v.z,
sin(rad) * v.y + cos(rad) * v.z);
}
vec3 rotationAboutY(const vec3& v, float degree)
{
float rad = toRad(degree);
return vec3(
cos(rad) * v.x + sin(rad) * v.z,
v.y,
-sin(rad) * v.x + cos(rad) * v.z);
}
vec3 rotationAboutZ(const vec3& v, float degree)
{
float rad = toRad(degree);
return vec3(
cos(rad) * v.x - sin(rad) * v.y,
sin(rad) * v.x + cos(rad) * v.y,
v.z);
}
x, y, z 순서대로 위의 함수들을 호출해주면 원점을 기준으로 회전한 물체의 좌표를 구할 수 있게 된다. 주의해야 되는 부분은 원점을 기준으로 회전을 시키는 것이기 때문에 물체를 이동 하려면 물체의 좌표를 원점으로 옮겼다가 회전 후에 다시 원래 위치로 옮겨주어야 한다는 것이다. 나는 그냥 회전 후 투영 시킨 좌표에서 x, y에 대한 오프셋을 조절하여 이동 하였는데, 정석적으로 이동하려면 원점에서 회전 -> 원래 좌표로 이동이라는 것만 알아두면 된다.
다음으로는 래스터라이즈에 대한 부분이다. 여기에서 직접 래스터라이즈를 구현하지는 않았다. 대신 물체의 정점과 높이, 너비를 기반으로 일정 간격씩 증가하면서 각 픽셀의 색을 결정할 수 있도록 하였다. 다음과 같은 방식이다.
for (float x = -width; x < width; x += incr)
{
for (float y = -height; y < height; y += incr) {
// 큐브의 각 면에 대한 계산
calculateSurface(vec3(x, y, -width), angleX, angleY, angleZ, pixels, option.colors[0], '0');
calculateSurface(vec3(x, y, width), angleX, angleY, angleZ, pixels, option.colors[1], '1');
calculateSurface(vec3(width, x, y), angleX, angleY, angleZ, pixels, option.colors[2], '2');
calculateSurface(vec3(-width, x, y), angleX, angleY, angleZ, pixels, option.colors[3], '3');
calculateSurface(vec3(x, width, y), angleX, angleY, angleZ, pixels, option.colors[4], '4');
calculateSurface(vec3(x, -width, y), angleX, angleY, angleZ, pixels, option.colors[5], '5');
}
}
이렇게 하면 실제 래스터라이즈에서 보간을 통해 계산되는 정점 사이의 픽셀 정보들을 간단하게 흉내낼 수 있다. 당연히 정확하지는 않다.
마지막으로 원근 투영이다. 투영은 3차원 공간에 있는 물체의 좌표를 2차원 스크린 좌표로 매핑하는 것을 의미한다. 대부분의 그래픽스 라이브러리에서는 view frustum을 사용하여 절두체 바깥에 있는 물체를 렌더링하지 않기 위해 물체의 좌표를 우선 view frustum의 near plane 좌표로 매핑 및 정규화 작업을 거치고 나서 원근 투영을 적용한다. 그러나 여기에서는 view frustum을 고려하지 않고 물체의 좌표를 곧바로 투영한다. 그래서 투영 역시 다음과 같이 간단하게 표현할 수 있게 된다.
void Cube::calculateSurface(const vec3& v, float angleX, float angleY, float angleZ, vector<pixel>& pixels, ColorType color, char output) const
{
vec3 tmp(v);
tmp = rotateXYZ(tmp, angleX, angleY, angleZ);
// 투영 평면과 눈 사이의 거리 d = 1 로 고정
float depth = 1.0f / (tmp.z + option.distanceFromEye);
// 콘솔에선 세로가 가로의 두 배
int px = (int)(option.screenWidth / 2 + option.scailFactor * tmp.x * depth * 2) + option.offsetX;
int py = (int)(option.screenHeight / 2 + option.scailFactor * tmp.y * depth) + option.offsetY;
int idx = option.screenWidth * py + px;
if (0 <= idx && idx < option.screenHeight * option.screenWidth) {
if (pixels[idx].depth < depth)
{
pixels[idx].depth = depth;
pixels[idx].color = color;
pixels[idx].c = output;
}
}
}
px, py의 값을 계산할 때, 화면의 정중앙을 기준으로 하여 투영된 좌표를 계산하게 된다. scailFactor는 스케일링을 위한 요소로 회전후 scailFactor를 곱해주면 사이즈를 조절할 수 있게 된다. depth는 시점과 물체 사이의 거리인 z 값으로 임의로 설정한 near plane과 시점 사이의 거리인 1을 나누어준 것이다. 원근 투영을 할 때 삼각형의 닮음 관계를 이용하여 좌표를 계산할 수 있는데, 쉽게 생각하면 물체가 멀리 있을수록 작게 보이게 하기 위해 분모를 z 값으로 설정한 것이라고 보면 된다.
2023.10.06 - [그래픽스] - 원근 투영 행렬 유도하기
2024.05.13 - [게임 프로그래밍/DirectX11] - 회전 행렬 유도
'그래픽스' 카테고리의 다른 글
yaw, pitch, roll (0) | 2024.06.09 |
---|---|
림(rim) 효과 (0) | 2024.06.03 |
퐁(Phong) 반사 (0) | 2024.06.01 |
조명(Lighting) (0) | 2024.06.01 |
원근 투영 행렬 유도하기 (0) | 2023.10.06 |