본문 바로가기

Project

[Project] 3D Renderer

728x90

1. 개요

나를 포함한 대부분의 사람들은 우리가 사는 3차원 세상을 2차원 평면인 화면에 표시하는 형태의 컴퓨터 프로그램을 많이 접한다. 특히 마인크래프트, GTA, 오버워치 등의 3D 게임이 전부 그러하다. 이러한 프로그램들을 보면서 이것들은 어떻게 구현한 것일까에 대한 궁금증을 가지게 되었고 그에 대한 원리를 공부하고 간단한 예제 프로그램을 제작했었는데, 시간이 많이 지나 해당 개념이 가물가물할 현시점에 복습도 할겸 프로그램은 더 발전된 형태로 업그레이드 시키면서, 기록으로도 남겨놓는 것이 좋을 것이라 판단하여 해당 포스팅을 작성한다.

2. 기본 아이디어

  • 우리의 눈은 $3$ 차원 도형을 관찰할 때, 나와 반대편에 위치한 면에 대한 정보는 알 수 없다.
  • $n$ 차원 세계의 관찰자는 매 관찰하는 순간마다 $n - 1$ 차원의 도형만을 인식한다.
  • 따라서 우리 눈은 $2$ 차원 평면에 대한 정보만을 획득할 수 있다.
  • 표현하려는 입체도형의 위치 정보를 $2$ 차원 화면상에 사영시킴으로 목적을 달성할 수 있다.

3. 원리

위의 사진을 보면 정육면체에 대한 3차원 정보가 2차원 화면에 정사영되고 있다. 이는 곧 3차원 공간좌표를 2차원 평면좌표로 변경해야 함을 의미한다.


사실 엄밀히 말하면 위 그림도 올바르지 않다. 우리는 어떠한 대상을 관찰할 때 볼 수 있는 시야가 한정되어 있다. 즉, 시야각이 존재한다.


다시 아래 그림을 살펴보자.

위 이미지에서는 시점을 추가하였다. 우리의 눈의 시야각은 화면의 크기를 결정짓게 되고, 임의의 물체의 각 점과 시점을 이은 직선과 화면의 교점의 집합이 물체의 상 즉, 화면에 표시될 정보가 된다.

4. 구현

위 그림은 구현해야 할 상황을 한 눈에 표현한 것이다.


먼저 벡터, 그 이외의 값들을 정의하자.

4.1. 정적변수

4.1.1. 원점 $O$

원점은 $x$, $y$, $z$ 의 좌표가 모두 $0$ 인 점을 의미한다. 모든 위치벡터의 시점이라 할 수 있다.

$$
O = \vec{0} = \begin{bmatrix} 0 \\ 0 \\ 0 \end{bmatrix}
$$


코드에서는 o 으로 표기하며 불변값이므로 정적변수로서 선언한다.

o = np.array([ .0,  .0,  .0])

4.1.2. 3차원 공간단위벡터 $\hat{i}$, $\hat{j}$, $\hat{k}$

3차원 공간의 $x$, $y$, $z$ 축과 평행한 단위벡터를 각각 $\hat{i}$, $\hat{j}$, $\hat{k}$ 이라고 한다.

$$
\hat{i} = \begin{bmatrix} 1 \\ 0 \\ 0 \end{bmatrix},
\hat{j} = \begin{bmatrix} 0 \\ 1 \\ 0 \end{bmatrix},
\hat{k} = \begin{bmatrix} 0 \\ 0 \\ 1 \end{bmatrix}
$$


코드에서는 i, j, k 으로 표기하며 불변값이므로 정적변수로서 선언한다.

i = np.array([1.0,  .0,  .0])
j = np.array([ .0, 1.0,  .0])
k = np.array([ .0,  .0, 1.0])

4.1.3. 화면 크기 $w$, $h$

화면의 너비를 $w$, 높이를 $h$ 라고 한다.


코드에서는 w, h 으로 표기하며 불변값이므로 정적변수로서 선언한다.

w, h = Camera.size

4.1.4. 좌표-픽셀 간 비율 $r$

실제 계산된 좌표를 화면에 표시할 때 어색함이 없도록 하는 조정값이다.

$$
r = 1,000
$$


코드에서는 ratio 로 표기하며 불변값이므로 정적변수로서 선언한다.

ratio = 1000

4.1.5. 임의의 점 $A$, $\vec{a}$

위치벡터 $\vec{a}$ 라고도 표현하는 임의의 점 $A$ 은 다른 보조적 역할의 점 및 벡터들과 달리 최종적으로 화면에 표시할 점의 실제 공간좌표를 의미한다. 이는 표시하려는 객체에 따라 그 개수가 정해진다. 헤딩 좌표는 사용자에 의해 설정되며 변하지 않는다.


코드에서는 a 로 표기한다.

4.2. 변수

4.2.1. 시점 $V$, $\vec{v}$

시점 $V$ 은 관찰자가 위치한 점을 의미한다. 위치벡터 $\vec{v}$ 라고도 표현한다.

$$
V = \vec{v} = \begin{bmatrix} v_x \\ v_y \\ v_z \end{bmatrix}
$$


코드에서는 v 로 표기하며 사용자에 의해 조정될 수 있는 가변값으로 동작한다.


시점의 초깃값은 다음과 같이 설정한다.

$$
V_\text{init} = \vec{v}_\text{init} = \begin{bmatrix} 3 \\ 3 \\ 3 \end{bmatrix}
$$

self.v = np.array([3.0, 3.0, 3.0])

4.2.2. 시점 위치벡터의 크기 $| \vec{v} |$

시점 위치벡터 $\vec{v}$ 의 크기 $| \vec{v} |$ 는 다음과 같다.

$$
| \vec{v} | = \sqrt{ v_x^2 + v_y^2 + v_z^2 }
$$


코드에서는 v_norm 으로 표기하며 numpy 의 내장 메소드 linalg.norm() 를 활용하여 구한다.

self.v_norm = np.linalg.norm(self.v)

4.2.3. 시선벡터의 크기 $| \vec{p} |$

시선벡터 $\vec{p}$ 의 크기 초깃값은 다음과 같이 설정한다.

$$
| \vec{p} |_\text{init} = .5
$$


코드에서는 p_norm 으로 표기하며 사용자에 의해 조정될 수 있는 가변값으로 동작한다.

self.p_norm = .5

4.2.4. 시선벡터 $\vec{p}$

시선벡터 $\vec{p}$ 는 시점(視点) $V$ 을 시점(始点) 으로 하고 화면의 정중앙점 $C$ 을 종점으로 하는 벡터이다. 항상 시선벡터의 끝은 화면의 정중앙점이 존재한다.

시선벡터의 초깃값은 다음과 같이 설정한다.

$$
\vec{p}_\text{init} = - \frac{| \vec{p} |}{| \vec{v} |} \vec{v}
$$

코드에서는 p 으로 표기하며 사용자에 의해 조정될 수 있는 가변값으로 동작한다.

self.p = -self.v / self.v_norm * self.p_norm

4.2.5. 화면의 단위법선벡터 $\vec{n}$

화면의 단위법선벡터 $\vec{n}$ 는 화면에 수직한 단위벡터이다. 이때 시선벡터 $\vec{p}$ 가 평면에 수직하므로 법선벡터라고 할 수 있고, 따라서 $\vec{n}$ 와 $\vec{p}$ 는 평행함을 알 수 있다. 또한 $\vec{n}$ 의 크기는 $1$ 이므로 단위법선벡터 $\vec{n}$ 는 다음과 같이 나타낼 수 있다.

$$
\vec{n} = \frac{1}{| \vec{p} |} \vec{p}
$$


코드에서는 n 으로 표기하며 사용자에 의해 조정될 수 있는 가변값으로 동작한다.

self.n = self.p / self.p_norm

4.2.6. 화면의 정중앙점 $C$, $\vec{c}$

위치벡터 $\vec{c}$ 라고도 표현하는 화면의 정중앙점 $C$ 은 시점(視点) 에서 가장 가까운 화면 상의 점 즉, 시선벡터 $\vec{p}$ 에 대하여 시점(始点) 을 시점(視点) $V$ 으로 했을 때의 종점을 나타낸다.


이를 수식으로 나타내면 다음과 같다.

$$
\vec{c} = \vec{v} + \vec{p}
$$


코드에서는 c 로 표기하며 사용자에 의해 조정될 수 있는 가변값으로 동작한다.

self.c = self.v + self.p

4.3. 좌표변환

우리는 3치원 공간상 좌표를 2차원 평면(화면)상 좌표로 치환해야 한다.

해당 방법은 다음의 절차를 따른다.

좌표변환 절차

  1. 화면 단위벡터 $\hat{i}'$, $\hat{j}'$ 의 공간좌표를 설정한다.
  2. 점 $V$ 와 $A$ 를 지나는 직선 $\overline{VA}$ 의 방정식를 구한다.
  3. 화면 평면의 방정식을 구한다.
  4. 직선 $\overline{VA}$ 와 화면의 교점 $A'$ 을 구한다.
  5. 교점 $A'$ 가 선분 $\overline{VA}$ 위에 있는지 여부를 판단한다.
  6. 벡터 $\vec{a}'$ ($\vec{CA'}$) 를 계산한다.
  7. 벡터 $\vec{a}'$ 를 $\hat{i}'$, $\hat{j}'$ 의 두 성분으로 나눈다.
  8. 화면상 좌표를 구한다.

4.3.1. 화면 단위벡터 $\hat{i}'$, $\hat{j}'$ 의 공간좌표를 설정한다.

화면의 $x$, $y$ 축 방향을 정의하기 위해 각각의 단위벡터를 구해야 한다. 방향을 정의한다는 것은 해당 단위벡터가 가변적이지만 계산상 편리하고 이해하기 쉽도록 고정함을 의미한다. 각각 $\hat{i}'$, $\hat{j}'$ 으로 정의한다.

$$
\hat{i}' = \begin{bmatrix} i_x \\ i_y \\ i_z \end{bmatrix},
\hat{j}' = \begin{bmatrix} j_x \\ j_y \\ j_z \end{bmatrix}
$$


코드에서는 각각 i_prime, j_prime 으로 표기한다.

4.3.1.1. $\hat{i}'$ 정의하기

기본적으로 $\hat{i}'$ 는 화면상의 벡터이므로 화면의 법선벡터인 시선벡터 $\vec{p}$ 와 수직이다.

$$
\hat{i} \perp \vec{p}
$$

$$
\hat{i}' \cdot \vec{p} = \begin{bmatrix} i_x & i_y & i_z \end{bmatrix} \begin{bmatrix} p_x \\ p_y \\ p_z \end{bmatrix} = 0
$$

$$
\therefore p_x i_x + p_y i_y + p_z i_z = 0
$$


또한 자연스러운 연출이 가능하도록 $\hat{i'} \perp \hat{j}$ 으로 설정한다.

$$
\hat{i'} \perp \hat{j}
$$

$$
\hat{i}' \cdot \vec{j} = \begin{bmatrix} i_x & i_y & i_z \end{bmatrix} \begin{bmatrix} 0 \\ 1 \\ 0 \end{bmatrix} = 0
$$

$$
\therefore i_y = 0
$$


그리고 기본적으로 이 역시 단위벡터이므로 길이가 $1$이다.

$$
| \hat{i}' | = \sqrt{i_x^2 + i_y^2 + i_z^2} = 1
$$



따라서 다음의 관계식을 구할 수 있다.

$$
\begin{cases}
i_y = 0 \\
p_x i_x + p_y i_y + p_z i_z = 0 \\
i_x^2 + i_y^2 + i_z^2 = 1
\end{cases}
$$


3개의 미지수 $i_x$, $i_y$, $i_z$ 에 대한 관계식 3개가 있으므로 연립방정식의 해를 구할 수 있다.

$$
\hat{i}'
= \begin{bmatrix} i_x \\ i_y \\ i_z \end{bmatrix}
= \frac{1}{\sqrt{p_x^2 + p_z^2}}
\begin{bmatrix} p_z \\ 0 \\ -p_x \end{bmatrix}
$$


코드에서는 다음과 같이 구현한다.

px, py, pz = self.p

ix = pz / math.sqrt(px ** 2 + pz ** 2)
iy = 0
iz = -px / math.sqrt(px ** 2 + pz ** 2)

self.i_prime = np.array([ix, iy, iz])

4.3.1.2. $\hat{j}'$ 정의하기

다음으로 화면의 $y$ 축 방향 즉, $\hat{j}'$ 을 정의해보자.

$$
\hat{j}' = \begin{bmatrix} j_x \\ j_y \\ j_z \end{bmatrix}
$$


$\hat{j}'$ 역시 화면 내의 벡터이므로 화면의 법선벡터인 $\vec{p}$ 와 수직임을 알 수 있다.

$$
\hat{j}' \perp \vec{p}
$$

$$
\hat{j}' \cdot \vec{v} = \begin{bmatrix} j_x & j_y & j_z \end{bmatrix} \begin{bmatrix} p_x \\ p_y \\ p_z \end{bmatrix} = 0
$$


또한, 당연하게도 $\hat{j}'$ 는 $\hat{i}'$ 와 수직이다.

$$
\hat{j}' \perp \hat{i}'
$$

$$
\hat{j}' \cdot \hat{i}' =
\begin{bmatrix} j_x & j_y & j_z \end{bmatrix} \begin{bmatrix} i_x \\ i_y \\ i_z \end{bmatrix} = 0
$$


그리고 역시 단위벡터로 길이가 $1$이다.

$$
| \hat{j}' | = \sqrt{j_x^2 + j_y^2 + j_z^2} = 1
$$



따라서 다음의 관계식을 구할 수 있다.

$$
\begin{cases}
p_x j_x + p_y j_y + p_z j_z = 0 \\
i_x j_x + i_z j_z = 0 \\
j_x^2 + j_y^2 + j_z^2 = 1
\end{cases}
$$


3개의 미지수 $j_x$, $j_y$, $j_z$ 에 대한 관계식 3개가 있으므로 연립방정식의 해를 구할 수 있다.

$$
\alpha = -\frac{p_x^2 + p_z^2}{p_x p_y}, \ \ \beta = \frac{p_z}{p_x}
$$

라고 할 때 연립방정식의 해는 다음과 같다.

$$
\hat{j}' =
\begin{bmatrix}
j_x \\ j_y \\ j_z
\end{bmatrix} = \frac{1}{\sqrt{1 + \alpha^2 + \beta^2}} \begin{bmatrix}
1 \\ \alpha \\ \beta
\end{bmatrix}
$$

$$
p_x \ne 0, p_y \ne 0
$$

하지만, 위 식은 $p_x = 0$ 또는 $p_y = 0$ 일 때 정의되지 않으므로 해당 경우에 대해 예외처리를 해주어야 한다.


$p_x = 0$ 또는 $p_y = 0$ 인 경우는 다음의 다섯가지 경우에 해당한다.

  1. $p_x = 0$, $p_y \ne 0$, $p_z \ne 0$
  2. $p_x \ne 0$, $p_y = 0$, $p_z \ne 0$
  3. $p_x = 0$, $p_y = 0$, $p_z \ne 0$
  4. $p_x = 0$, $p_y \ne 0$, $p_z = 0$
  5. $p_x \ne 0$, $p_y = 0$, $p_z = 0$

이때 $ | \vec{p} | > 0$ 으로써 $\vec{p} = \vec{0} $ 인 경우는 제외한다.


다음 그림을 보자.

해당 그림은 $\vec{p}$ 의 좌표값에 $0$ 이 두 개인 벡터가 형성하는 평면과 그 때의 $\hat{i}'$, $\hat{j}'$ 를 표시한 것이다.

$$
\vec{p} =
\begin{bmatrix} p_x \\ 0 \\ 0 \end{bmatrix} \text{or}
\begin{bmatrix} 0 \\ p_y \\ 0 \end{bmatrix} \text{or}
\begin{bmatrix} 0 \\ 0 \\ p_z \end{bmatrix}
$$


  • $p_x \ne 0$, $p_y = 0$, $p_z = 0$ 일 경우, [5.]

$$
\hat{i}' = \begin{bmatrix} 0 \\ 0 \\ - \text{sign}(p_x) \end{bmatrix}, \
\hat{j}' = \begin{bmatrix} 0 \\ \text{sign}(p_x) \\ 0 \end{bmatrix}
$$


  • $p_x = 0$, $p_y \ne 0$, $p_z = 0$ 일 경우, [4.]

$\sqrt{p_x^2 + p_z^2} = 0$ 이므로 연립방정식의 해는 무수히 많다.


따라서 임의로 값을 다음과 같이 설정한다.

$$
\hat{i}' = \frac{\text{sign}(p_y)}{\sqrt{2}} \begin{bmatrix} -1 \\ 0 \\ 1 \end{bmatrix}, \
\hat{j}' = -\frac{\text{sign}(p_y)}{\sqrt{2}} \begin{bmatrix} 1 \\ 0 \\ 1 \end{bmatrix}
$$


  • $p_x = 0$, $p_y = 0$, ($p_z \ne 0$) 일 경우, [3.]

$$
\hat{i}' = \begin{bmatrix} \text{sign}(p_z) \\ 0 \\ 0 \end{bmatrix}, \
\hat{j}' = \begin{bmatrix} 0 \\ \text{sign}(p_z) \\ 0 \end{bmatrix}
$$

또한 위 그림에서는 $\vec{p}$ 의 좌표값에 $0$ 이 하나인 벡터가 형성하는 평면과 $\hat{i}'$, $\hat{j}'$ 가 표시되어 있다.

$$
\vec{p} =
\begin{bmatrix} p_x \\ p_y \\ 0 \end{bmatrix} \text{or}
\begin{bmatrix} p_x \\ 0 \\ p_z \end{bmatrix} \text{or}
\begin{bmatrix} 0 \\ p_y \\ p_z \end{bmatrix}
$$


  • $p_x = 0$, $p_y \ne 0$, $p_z \ne 0$ 일 경우, [1.]

$$
\hat{i}' =
\begin{bmatrix} \text{sign}(p_y) \\ 0 \\ 0 \end{bmatrix},
\hat{j}' = \frac{1}{\sqrt{p_y^2 + p_z^2}} \begin{bmatrix}
0 \\ - | p_z | \\ \text{sign}(p_z) p_y \end{bmatrix}
$$


  • $p_x \ne 0$, $p_y = 0$, $p_z \ne 0$ 일 경우, [2.]

$$
\hat{i}' = \frac{1}{\sqrt{p_x^2 + p_z^2} }
\begin{bmatrix}
\text{sign}(p_x) | p_z | \\ 0 \\ - \text{sign}(p_z)| p_x |
\end{bmatrix},
\hat{j}' = \begin{bmatrix} 0 \\ -1 \\ 0 \end{bmatrix}
$$

4.3.1.3. 정리

정리하면 다음과 같다.

$$
\hat{i}' = \begin{cases}
\frac{1}{\sqrt{p_x^2 + p_z^2}}
\begin{bmatrix} p_z \\ 0 \\ -p_x \end{bmatrix} & p_x \ne 0, p_y \ne 0 \\
\begin{bmatrix} \text{sign}(p_x) \\ 0 \\ 0 \end{bmatrix} & p_x = 0, p_y = 0 \\
\begin{bmatrix} 0 \\ 0 \\ - \text{sign}(p_x) \end{bmatrix} & p_x \ne 0, p_y = 0, p_z = 0 \\
\begin{bmatrix}
\text{sign}(p_x) | p_z | \\ 0 \\ - \text{sign}(p_z)| p_x |
\end{bmatrix} & p_x \ne 0, p_y = 0, p_z \ne 0 \\
\frac{\text{sign}(p_y)}{\sqrt{2}} \begin{bmatrix} -1 \\ 0 \\ 1 \end{bmatrix} & p_x = 0, p_y \ne 0, p_z = 0 \\
\begin{bmatrix} \text{sign}(p_y) \\ 0 \\ 0 \end{bmatrix} & p_x = 0, p_y \ne 0, p_z \ne 0 \\
\end{cases}, \ \hat{j}' = \begin{cases}
\frac{1}{\sqrt{1 + \alpha^2 + \beta^2}} \begin{bmatrix}
1 \\ \alpha \\ \beta \end{bmatrix} & p_x \ne 0, p_y \ne 0 \\
\begin{bmatrix} 0 \\ \text{sign}(p_z) \\ 0 \end{bmatrix} & p_x = 0, p_y = 0 \\
\begin{bmatrix} 0 \\ \text{sign}(p_x) \\ 0 \end{bmatrix} & p_x \ne 0, p_y = 0, p_z = 0 \\
\begin{bmatrix} 0 \\ -1 \\ 0 \end{bmatrix} & p_x \ne 0, p_y = 0, p_z \ne 0 \\
-\frac{\text{sign}(p_y)}{\sqrt{2}} \begin{bmatrix} 1 \\ 0 \\ 1 \end{bmatrix} & p_x = 0, p_y \ne 0, p_z = 0 \\
\frac{1}{\sqrt{p_y^2 + p_z^2}} \begin{bmatrix}
0 \\ - | p_z | \\ \text{sign}(p_z) p_y \end{bmatrix} & p_x = 0, p_y \ne 0, p_z \ne 0
\end{cases} \\
$$

$$
\alpha = -\frac{p_x^2 + p_z^2}{p_x p_y}, \ \ \beta = \frac{p_z}{p_x}
$$

4.3.2. 점 $V$ 와 $A$ 를 지나는 직선 $\overline{VA}$ 의 방정식를 구한다.

$$
V = \vec{v} = \begin{bmatrix} v_x \\ v_y \\ v_z \end{bmatrix},
A = \vec{a} = \begin{bmatrix} a_x \\ a_y \\ a_z \end{bmatrix}
$$

에 대하여 직선의 방정식은 다음과 같다.

$$
\vec{r}(t) = \vec{v} + t(\vec{a} − \vec{v})
$$

직선 $\overline{VA}$ 의 방정식
$$
\frac{x - a_x}{a_x - v_x} = \frac{y - a_y}{a_y - v_y} = \frac{z - a_z}{a_z - v_z}
$$

4.3.3. 화면 평면의 방정식을 구한다.

벡터 $\vec{p}$ 는 화면 평면의 법선벡터이다.

따라서 화면은 벡터 $\vec{p}$ 에 수직하고 점 $C$ 를 지난다.

$$
\vec{p} = \begin{bmatrix} p_x \\ p_y \\ p_z \end{bmatrix},
C = \vec{c} = \begin{bmatrix} c_x \\ c_y \\ c_z \end{bmatrix}
$$

에 대하여 화면 평면의 방정식은 다음과 같다.

화면 평면의 방정식
$$
p_x (x - c_x) + p_y (y - c_y) + p_z (z - c_z) = 0
$$

4.3.4. 직선 $\overline{VA}$ 와 화면의 교점 $A'$ 을 구한다.

위에서 구한 두 방정식 즉, 직선 및 평면의 교점을 구해야 한다.

직선 $\overline{VA}$ 의 방정식
$$
\frac{x - a_x}{a_x - v_x} = \frac{y - a_y}{a_y - v_y} = \frac{z - a_z}{a_z - v_z}
$$

화면 평면의 방정식
$$
p_x (x - c_x) + p_y (y - c_y) + p_z (z - c_z) = 0
$$

임의의 실수 $t$ 에 대하여 다음과 같다고 하자.

$$
\frac{x - v_x}{a_x - v_x} = \frac{y - v_y}{a_y - v_y} = \frac{z - v_z}{a_z - v_z} = t
$$


직선의 방정식을 변형해서 $x(t)$, $y(t)$, $z(t)$ 를 각각 $t$ 에 대한 식으로 나타낸다.

$$
\begin{cases}
x(t) = v_x + (a_x − v_x) t \\
y(t) = v_y + (a_y − v_y) t \\
z(t) = v_z + (a_z − v_z) t
\end{cases}
$$


각각의 식을 평면의 방정식에 대입하여 $t$ 값을 구하면 다음과 같다.

$$
t = \frac{p_x (c_x - a_x) + p_y (c_y - a_y) + p_z (c_z - a_z)}{p_x (a_x - v_x) + p_y (a_y - v_y) + p_z (a_z - v_z)}
$$


구한 $t$ 값을 바탕으로 $A'$ 를 나타내면 다음과 같다.

$$
A' = \begin{bmatrix} a'_x \\ a'_y \\ a'_z \end{bmatrix}
= \begin{bmatrix} a_x + (a_x − v_x) t \\ a_y + (a_y − v_y) t \\ a_z + (a_z − v_z) t \end{bmatrix}
$$


코드로는 다음과 같이 구현하였다.

px, py, pz = self.p
ax, ay, az = a

av = a - self.v
avx, avy, avz = av

denominator = px * (cx - ax) + py * (cy - ay) + pz * (cz - az)
numerator = px * avx + py * avy + pz * avz

if not numerator: return None
t = denominator / numerator

x, y, z = ax + avx * t, ay + avy * t, az + avz * t
A_prime = np.array([x, y, z])

4.3.5. 교점 $A'$ 가 선분 $\overline{VA}$ 위에 있는지 여부를 판단한다.

위 그림을 보면 교점 $A'$ 가 선분 $\overline{VA}$ 위에 있는 경우에 한해 화면에 표시됨을 알 수 있다. 만약 해당 경우를 예외로 처리하지 않으면 어색한 장면이 연출될 것이다.

$$
\vec{VA'} = k \vec{VA} , \ \ \ 0 \lt k \le 1
$$

코드로는 다음과 같이 구현하였다.

div = np.array([.0, .0, .0])

for i in range(3):
  if av[i]: div[i] = A_prime_v[i] / av[i]
  else: div[i] = .0

if not (0 < sum(div) <= 3):
  return None

4.3.6. 벡터 $\vec{a}'$ ($\vec{CA'}$) 를 계산한다.

화면의 정중앙점 $C$ 을 시점, 화면과 직선의 교점 $A'$ 을 종점으로 하는 벡터를 구한다.

$$
\vec{a}' = \vec{CA'} = \vec{c} - \vec{OA'}
$$

코드로는 다음과 같이 구현하였다.

a_prime = A_prime - self.c

4.3.7. 벡터 $\vec{a}'$ 를 $\hat{i}'$, $\hat{j}'$ 의 두 성분으로 나눈다.

벡터 $\vec{a}'$ 를 처음에 구한 $\hat{i}'$, $\hat{j}'$ 의 두 성분으로 나눈 뒤 그것의 길이를 취하면 2차원 화면 좌표를 구할 수 있다.

$$
\vec{a}'_{\text{scr}} = \begin{bmatrix} x \\ y \end{bmatrix}
$$

$$
x = \vec{a}' \cdot \hat{i}', \
y = \vec{a}' \cdot \hat{j}'
$$

따라서 구하려는 2차원 좌표는 다음과 같다.

$$
\vec{a}'_{\text{scr}} = \begin{bmatrix}
\vec{a}' \cdot \hat{i}' \\ \vec{a}' \cdot \hat{j}'
\end{bmatrix}
$$

코드로는 다음과 같이 구현하였다.

a_prime_x_norm = np.dot(a_prime, self.i_prime) * Camera.ratio
a_prime_y_norm = np.dot(a_prime, self.j_prime) * Camera.ratio

a_prime_screen = np.array([a_prime_x_norm, a_prime_y_norm])

4.4. 화면 전환 동작

프로그램을 여기서 마무리 한다면, 위와 같은 화면만 볼 수 있게 되며 이로써는 화면 내의 3차원을 느끼기에 충분치 않을 것이다. 따라서 여러 가지 입력 동작 (Input Event) 을 통해 실제 관찰자가 물체를 두고 자유롭게 이동하며 관찰할 수 있도록 구현하고자 한다.

기본적으로 마인크래프트(Minecraft) 게임의 동작 방식을 일부 차용하였다.

4.4.1. 마우스 위치 변경

마우스의 위치가 변경될 때, 시점 $V$ 의 위치는 그대로 두고 시선벡터 $\vec{p}$ 가 움직이도록 구현한다.

$$
\vec{v_{\text{aft}}} = \vec{v_{\text{bef}}} + \vec{\Delta}
$$

def __rotate(self, delta):
  delta_x, delta_y = np.array(delta)
  delta_x /= Camera.w / (self.rotate_velocity * 50)
  delta_y /= Camera.h / (self.rotate_velocity * 50)

  dx = delta_x * self.i_prime
  dy = delta_y * self.j_prime

  ndx = self.n + dx
  ndy = self.n + dy
  n = ndx + ndy

  self.n = n / np.linalg.norm(n)
  self.p = self.n * self.p_norm

  self.update()

4.4.2. 마우스 휠 조정

마우스의 휠이 조정될 때, 시선벡터 $\vec{p}$ 의 방향은 그대로 두고 크기만 알맞게 조정되도록 구현한다.

즉, 확대/축소 기능을 구현한다.

$$
\vec{p_{\text{aft}}} = k \vec{p_{\text{bef}}}
$$

def zoom_in(self):
  self.p_norm = min(self.p_norm + .01, 3)
  self.p = self.n * self.p_norm
  self.update()

def zoom_out(self):
  self.p_norm = max(self.p_norm - .01, .1)
  self.p = self.n * self.p_norm
  self.update()

4.4.3. 키보드 입력 (시점 이동)

키보드 입력에 따라 시점 $V$ 의 위치가 변경되도록 한다.

  • W 입력: 앞으로 이동
  • S 입력: 뒤로 이동
  • A 입력: 좌로 이동
  • D 입력: 우로 이동
  • SPACE 입력: 위로 이동
  • SHIFT 입력: 아래로 이동

def __move(self, delta):
  self.v += delta
  self.update()

def move(self, direction):
  self.camera_moving = CameraMoving(direction, self.moving_velocity)
  lr = self.i_prime
  ud = -Camera.j
  fb = self.n
  self.camera_moving.sync(self.v, lr, ud, fb)
  delta = self.camera_moving.get_delta()
  self.__move(delta)

4.4.3.1. W, S 입력

앞뒤 방향은 벡터 $\vec{n}$ 의 방향으로 적당한 상수를 곱한 뒤 시점벡터 $\vec{v}$ 에 더하여 입력 후 벡터를 구한다.

$$
\vec{v_{\text{aft}}} = \vec{v_{\text{bef}}} + k \vec{n}
$$

4.4.3.2. A, D 입력

좌우 방향은 벡터 $\hat{i}'$ 의 방향으로 적당한 상수를 곱한 뒤 시점벡터 $\vec{v}$ 에 더하여 입력 후 벡터를 구한다.

$$
\vec{v_{\text{aft}}} = \vec{v_{\text{bef}}} + k \hat{i}'
$$

4.4.3.3. SPACE, SHIFT 입력

상하 방향은 벡터 $\hat{j}$ 의 방향으로 적당한 상수를 곱한 뒤 시점벡터 $\vec{v}$ 에 더하여 입력 후 벡터를 구한다.

$$
\vec{v_{\text{aft}}} = \vec{v_{\text{bef}}} + k \hat{j}
$$

4.4.4. 키보드 입력 (기타)

  • CTRL 입력: 가속
  • F 입력: 뒤 돌기

4.4.4.1. CTRL 입력

4.4.3.1., 4.4.3.2., 4.4.3.3. 에서 언급한 적당한 상수 를 조절함으로써 가속할 수 있다.

4.4.4.2. F 입력

뒤 돌기는 시선벡터의 방향을 반전시키면 된다.

$$
\vec{p_{\text{aft}}} = - \vec{p_{\text{bef}}}
$$

def turn_back(self):
  self.p = -self.p
  self.update()

4.5. 대상

위치시킬 대상 객체를 구현한다.

4.5.1. 대상 Object

Object 클래스는 여러 대상 클래스의 추상 클래스이다.

class Object(ABC):
  @abstractmethod
  def get_coords(self): pass
  @abstractmethod
  def get_render_data(self): pass

4.5.2. 점 Point

Point 클래스는 3차원 좌표에 점을 찍는다.

class Point(Object):
  def __init__(self, *args):
    self.color = Color.WHITE

    if len(args) == 1:
      self.x, self.y, self.z = args[0]

    elif len(args) == 2:
      vec, color = args
      self.x, self.y, self.z = vec

    elif len(args) == 3:
      self.x, self.y, self.z = args

    elif len(args) == 4:
      self.x, self.y, self.z, color = args

  def get_coords(self): 
    return np.array([self.x, self.y, self.z])

  def get_render_data(self):
    return 0, self.color
  • 용례
## 매개변수 1개
# 하얀 점 [0, 0, 0]
p1 = Point([.0, .0, .0]) 

## 매개변수 2개
# 빨간 점 [0, 1, 0]
p2 = Point([.0, 1.0, .0], Color.RED)

## 매개변수 3개
# 하얀 점 [0, 0, 1]
p3 = Point(.0, .0, 1.0)

## 매개변수 4개
# 빨간 점 [0, 1, 1]
p4 = Point(.0, 1.0, 1.0, Color.GREEN)

c.add_object(p1)
c.add_object(p2)
c.add_object(p3)
c.add_object(p4)
  • 동작화면

4.5.2.1. 무작위 점 RandomPoint

class RandomPoint(Point):
  def __init__(self, *args):
    self.color = Color.WHITE

    self.x_range = []
    self.y_range = []
    self.z_range = []

    if len(args) in [1, 2]:
      self.x_range = args[0][:]
      self.y_range = args[0][:]
      self.z_range = args[0][:]

    elif len(args) in [3, 4]:
      self.x_range = args[0][:]
      self.y_range = args[1][:]
      self.z_range = args[2][:]

    if not len(args) % 2:
      self.color = args[-1]

    self.x = random.uniform(self.x_range[0], self.x_range[1])
    self.y = random.uniform(self.y_range[0], self.y_range[1])
    self.z = random.uniform(self.z_range[0], self.z_range[1])
  • 용례
# 무작위점 500개 생성
random_stars = [RandomPoint([-100.0, 100.0]) for _ in range(500)]

c.add_objects(random_stars)
  • 동작화면

4.5.3. 선 Line

class Line(Object):
  def __init__(self, start, end, color=Color.WHITE):
    self.start = start
    self.end = end
    self.color = color

  def get_coords(self):
    return [self.start, self.end]

  def get_render_data(self):
    return 1, self.color
  • 용례
# 점 [0, 0, 0] 과 점 [1, 2, 3] 을 잇는 하얀 선분
line = Line(Point(.0, .0, .0), Point(1.0, 2.0, 3.0))

c.add_objects(line)
  • 동작화면

4.5.3.1. 축 Axis

class Axis(Line):
  def __init__(self, length=2, color=Color.WHITE):
    self.length = length
    self.start = np.array([0, 0, 0])
    self.end = None
    self.color = color

  def get_coords(self):
    return [self.start, self.end]

  def get_render_data(self):
    return 1, self.color

4.5.3.2. $x$ 축 XAxis

class XAxis(Axis):
  def __init__(self):
    super().__init__()
    self.end = np.array([self.length, 0, 0])
    self.color = Color.RED

4.5.3.3. $y$ 축 YAxis

class YAxis(Axis):
  def __init__(self):
    super().__init__()
    self.end = np.array([0, self.length, 0])
    self.color = Color.GREEN

4.5.3.4. $z$ 축 ZAxis

class ZAxis(Axis):
  def __init__(self):
    super().__init__()
    self.end = np.array([0, 0, self.length])
    self.color = Color.BLUE

4.5.4. 다각형 Polygon

class Polygon(Object):
  def __init__(self, points, color=Color.WHITE):
    self.points = points
    self.color = color

  def get_coords(self):
    return self.points

  def get_render_data(self):
    return 2, self.color
  • 용례
# 세 점 [0, 0, 0], [1, 0, 0], [0, 1, 0] 을 이은 하얀 삼각형
p1 = Polygon([
  Point( .0,  .0,  .0), 
  Point(1.0,  .0,  .0), 
  Point( .0, 1.0,  .0)
])

# 세 점 [0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0] 을 이는 하얀 사각형
p2 = Polygon([
  Point( .0,  .0,  .0), 
  Point(1.0,  .0,  .0), 
  Point(1.0, 1.0,  .0),
  Point( .0, 1.0,  .0)
])

c.add_object(p1)
c.add_object(p2)
  • 동작화면

4.5.5. 정육면체 Cube

class Cube:
  def __init__(self, center, radius):
    self.center = center.get_coords()

    self.points = [
      Point(self.center - np.array([  radius,  radius,  radius])),
      Point(self.center - np.array([ -radius,  radius,  radius])),
      Point(self.center + np.array([  radius, -radius,  radius])),
      Point(self.center - np.array([  radius,  radius, -radius])),

      Point(self.center + np.array([  radius,  radius,  radius])),
      Point(self.center + np.array([ -radius,  radius,  radius])),
      Point(self.center - np.array([  radius, -radius,  radius])),
      Point(self.center + np.array([  radius,  radius, -radius]))
    ]

    self.polygons = [
      Polygon([self.points[0], self.points[1], self.points[2], self.points[3]]),
      Polygon([self.points[0], self.points[1], self.points[7], self.points[6]]),
      Polygon([self.points[0], self.points[3], self.points[5], self.points[6]]),
      Polygon([self.points[4], self.points[2], self.points[1], self.points[7]]),
      Polygon([self.points[4], self.points[7], self.points[6], self.points[5]]),
      Polygon([self.points[4], self.points[2], self.points[3], self.points[5]])
    ]

  def get_coords(self):
    return self.polygons

  def get_render_data(self):
    return 3, Color.WHITE
  • 용례
# 정중앙점이 [0.5, 0.5, 0.5], 내접하는 구의 반지름이 0.5 인 하얀 정육면체
cube = Cube(Point(.5, .5, .5), .5)

c.add_object(cube)
  • 동작화면

4.5.6. 구 Sphere

class Sphere:
  circle_segments = 50

  def __init__(self, center, radius, segments=20):
    self.center = center.get_coords()
    self.radius = radius
    self.segments = segments

    self.latitudes = [
      self.get_latitude(i - self.segments / 2)
      for i in range(self.segments)
    ]

    self.longitudes = [
      self.get_longitude(i)
      for i in range(self.segments)
    ]

    self.polygons = self.latitudes + self.longitudes

  def get_coords(self):
    return self.polygons

  def get_latitude(self, n):
    theta = math.pi / self.segments
    h = self.radius * math.sin(theta * n)
    r = self.radius * math.cos(theta * n)
    c = self.center + np.array([.0, h, .0])

    print(n, math.degrees(theta * n), r)

    dt = 2 * math.pi / Sphere.circle_segments
    points = [
      Point(c + r * np.array([math.cos(dt * i), 0, math.sin(dt * i)]))
      for i in range(Sphere.circle_segments)
    ]

    return Polygon(points)

  def get_longitude(self, n):
    theta = 2 * math.pi / Sphere.circle_segments
    dt = 2 * math.pi / self.segments

    points = [
      Point(self.center + self.radius * np.array([
        math.cos(theta * i) * math.cos(dt * n),
        math.sin(theta * i),
        math.cos(theta * i) * math.sin(dt * n)
      ]))
      for i in range(Sphere.circle_segments)
    ]

    return Polygon(points)

  def get_render_data(self):
    return 3, Color.WHITE
  • 용례
# 중심이 [0, 0, 0], 반지름이 1 인 하얀 구
sphere = Sphere(Point(.0, .0, .0), 1.0)

c.add_object(sphere)
  • 동작화면

5. 전체코드

728x90

'Project' 카테고리의 다른 글

[Project] Tico & Tico Simulator  (0) 2024.06.06