---
# System prepended metadata

title: '台科電腦圖學導論Project 2: Maze Visibility and Rendering'

---

# 台科電腦圖學導論Project 2: Maze Visibility and Rendering 
Last Update : 2023-02-19
:::warning
閱前注意事項 : **請勿抄襲本專案的程式碼**，賴教授跟我反應過有很多人抄襲我的code，因此希望大家是透過我的文字理解原理架構，再自己重寫一遍，實作上有任何問題都可以Email(frakwu@gmail.com)，千萬不要直接抄，如果看完還是不懂，可以約時間用Discord討論一下。
:::

### GITHUB連結:https://github.com/frakw/NTUST_COMPUTER_GRAPHICS

![](https://i.imgur.com/bx0llgn.gif)

這裡記錄著我遇到的一些坑，大部分是講一些程式架構跟coding注意事項，如果想要更了解背後的理論或數學可以去參考另一位學長的[文章](https://hackmd.io/@fqiObQWCSUWMlLKBbLIqXg/SyTCC1qYS)。

## 整體程式架構與流程
一開始首先要做的就是用opengl內建的function去實作，可參考助教的[文章](https://medium.com/maochinn/fltk-project-maze-338c2109989d)基本上畫地板跟call幾個function就可以完成整個project，但難就難在要自己刻出gluPerspective、gluLookAt、glVertex3f，跟深度測試。大致流程如下:
* 畫天空與地板(glvertex2f)，用2D的方式畫，可以達到無限延伸的效果，如果是用3D畫，就需要設很大的範圍才能類似無限 ![](https://i.imgur.com/Ucoqkoi.png)
* 根據助教的文章實作出整個迷宮，但這時候可能會遇到一些怪異的bug，例如天空地板亂跑，這是因為重置為單位矩陣的那幾行程式碼放錯地方了，這幾行必須要放在畫天空地板程式碼之前。
```cpp=
glMatrixMode(GL_PROJECTION);
glLoadIdentity();
glMatrixMode(GL_MODELVIEW);
glLoadIdentity();
```
* 成功看到迷宮後就可以開始實作2個最重要的矩陣:`projection matrix`與`model view matrix`，其實它們分別對應到`gluPerspective`與`gluLookAt`
* 推薦一個網頁讓你了解整個流程:http://www.opengl-tutorial.org/cn/beginners-tutorials/tutorial-3-matrices/
* `model matrix`在這個project好像不會用到，概念上它就是以自我中心為0,0定義出自己的身體、手、腳...的位置
* `view matrix`這個矩陣是由2個矩陣所構成的(rotate與translate)，rotate顧名思義就是旋轉，而translate就是移動位置，這個矩陣的效果有點像是把相機轉到對的角度跟位置，才可以看到想要的vertex，但實際上我們做不是移動或旋轉相機，而是對整個世界做移動旋轉，比如:相機往右移30cm，實際上是將整個世界左移30cm，至於為甚麼要這樣，講白了就是:**到底是你在看世界還是世界在看你?**
* `projection matrix`這個矩陣就是用來產生投影的效果，但是它為為模擬人類的視錐，產生遠小近大的感覺，直接看圖更清楚
這是還沒乘上projection的樣子
![](https://i.imgur.com/L9mgdxB.png)
乘projection後的
![](https://i.imgur.com/13UXEl0.png)
projection matrix也分2種，一種是我們使用的Perspective型，另一種就是沒有遠小近大的Orthographic型
![](https://i.imgur.com/u2c0EPB.jpg)
* 但重點是，這2個matrix到底要怎麼產生，上面的都屁話，畢竟 **talk is cheap show me code**
其實google一下就找到惹~
[gluLookAt](https://www.khronos.org/opengl/wiki/GluLookAt_code)
```cpp=
#define square(x) ((x)*(x))
void NormalizeVector(float x[3]) {
	float Norm = sqrt(square(x[0]) + square(x[1]) + square(x[2]));
	x[0] /= Norm;
	x[1] /= Norm;
	x[2] /= Norm;
}

void ComputeNormalOfPlane(float result[3], float A[3], float B[3]) {
	result[0] = A[1] * B[2] - A[2] * B[1];
	result[1] = A[2] * B[0] - A[0] * B[2];
	result[2] = A[0] * B[1] - A[1] * B[0];
}

void LookAt(float eyePosition3DX, float eyePosition3DY, float eyePosition3DZ,
	float center3DX, float center3DY, float center3DZ,
	float upVector3DX, float upVector3DY, float upVector3DZ)
{
	float forward[3], side[3], up[3];
	float matrix[16];

	forward[0] = center3DX - eyePosition3DX;
	forward[1] = center3DY - eyePosition3DY;
	forward[2] = center3DZ - eyePosition3DZ;
	NormalizeVector(forward);
	// 向量外積
	float tmp[3] = { upVector3DX ,upVector3DY, upVector3DZ };
	ComputeNormalOfPlane(side, forward, tmp);
	NormalizeVector(side);
	//  向量外積
	ComputeNormalOfPlane(up, side, forward);
	matrix[0] = side[0];
	matrix[4] = side[1];
	matrix[8] = side[2];
	matrix[12] = 0.0;
	matrix[1] = up[0];
	matrix[5] = up[1];
	matrix[9] = up[2];
	matrix[13] = 0.0;
	matrix[2] = -forward[0];
	matrix[6] = -forward[1];
	matrix[10] = -forward[2];
	matrix[14] = 0.0;
	matrix[3] = matrix[7] = matrix[11] = 0.0;
	matrix[15] = 1.0;
	global::model_view = glm::make_mat4(matrix);
	global::model_view = glm::translate(global::model_view, glm::vec3(-eyePosition3DX, -eyePosition3DY, -eyePosition3DZ));
}

```
[gluPerspective](https://www.khronos.org/opengl/wiki/GluPerspective_code)
```cpp=
void Frustum(float* matrix, float left, float right, float bottom, float top,float znear, float zfar)
{
	float temp, temp2, temp3, temp4;
	temp = 2.0 * znear;
	temp2 = right - left;
	temp3 = top - bottom;
	temp4 = zfar - znear;
	matrix[0] = temp / temp2;
	matrix[1] = 0.0;
	matrix[2] = 0.0;
	matrix[3] = 0.0;
	matrix[4] = 0.0;
	matrix[5] = temp / temp3;
	matrix[6] = 0.0;
	matrix[7] = 0.0;
	matrix[8] = (right + left) / temp2;
	matrix[9] = (top + bottom) / temp3;
	matrix[10] = (-zfar - znear) / temp4;
	matrix[11] = -1.0;
	matrix[12] = 0.0;
	matrix[13] = 0.0;
	matrix[14] = (-temp * zfar) / temp4;
	matrix[15] = 0.0;
}
void Perspective(float fovyInDegrees, float aspectRatio,float znear, float zfar)
{
	float matrix[16];
	float ymax, xmax;
	float temp, temp2, temp3, temp4;
	ymax = znear * tanf(fovyInDegrees * M_PI / 360.0);
	xmax = ymax * aspectRatio;
	Frustum(matrix, -xmax, xmax, -ymax, ymax, znear, zfar);
	global::projection = glm::make_mat4(matrix);
}
```
* 產生這些matrix之後就可以用`glLoadMatrix`(替代最上層矩陣)或`glMultMatrix`(乘上最上層矩陣)，來測試矩陣是否正確。
* 我使用的是glm內建的資料結構mat4x4，這是為了之後的矩陣乘向量，因為用glm就不用自己刻出來了，不過自己刻出來好像也不難就是了，至於如何安裝glm，請自己google吧，位置放對基本上就完成了。
* 然後還有一點就是建議將矩陣都設為全域變數，要用的時候就不用傳來傳去，搞得很混亂。
* 如果你有試著把矩陣印出來看就會發現其實projection matrix一直都是相同的，這是正常現象，所以其實可以把`Perspective`計算projection的程式碼放到Maze物件constructor最後面的地方，就可以減少重複的運算，但如果要這麼做的話，建議再多設一個全域變數aspect，然後在Mazewindow的constructor時算好aspect = w() / h();。`Perspective(viewer_fov, global::aspect, my_near, my_far);`，更多細節可以去參考我的程式碼。
* 做完這些就會感到很迷茫，不知道接下來要幹嘛，因為不知道要怎麼改成glvertex2f，會這樣是因為後2個功能(clipping與迷宮遞迴)其實是寫在一起的，但我們當然不可能一次完成2個，所以先解決clipping的問題，clipping的問題只要解決了，1x1的迷宮就應該要能正常顯示了。![](https://i.imgur.com/C87HQ57.png)
* 在測試clipping時，可以先把所有edge clip然後畫出來，就不要管哪些牆不畫，全畫就對了
```cpp=
for(int i=0;i<num_edges;i++){
    //clipping edges[i] 這道牆
    //用glvertex2f畫
}
```
* 這裡補充一下，框架裡總共有2種可以表示牆的資料結構，一種是`edge`，另一種是`LineSeg`，這2個最大差別在於一個是用指標指到vertex，另一個是自己擁有2個座標的記憶體空間，如果要傳到function運作，會建議使用`LineSeg`，要不然不小心改到某個值，造成整個程式一堆bug就完了，但框架一開始初始化就是把牆的資訊存在`edge`裡，所以這2個一定要搞清楚，怎麼互換也要先寫好，copy consrtuctor的問題也不要忘了。
* 那，問題來了，怎麼切RRRRRR???? 這個問題搞了我好幾天好幾夜。首先，在這裡你有3條路可選
    1. 在還沒乘以任何矩陣前就切好所有vertex
    2. vertex乘以modelview matrix後再切
    3. 乘完projection matrix後除以Homogeneous的w
PPT是這樣寫:
```cpp=
List outputList = subjectPolygon;   
  for (Edge clipEdge in clipPolygon) do
     List inputList = outputList;
     outputList.clear();
     Point S = inputList.last;
     for (Point E in inputList) do
        if (E inside clipEdge) then
           if (S not inside clipEdge) then
             outputList.add(ComputeIntersection(S,E,clipEdge));
           end if
           outputList.add(E);
        else if (S inside clipEdge) then
           outputList.add(ComputeIntersection(S,E,clipEdge));
        end if
        S = E;
     done
  done

```
第一次看就懂才有鬼，你會看不懂是因為這code是拿來切多邊形的，outputList跟inputList都不需要，我們要做的更簡單，更新一個牆(edge)的頭(start)尾(end)即可，但在切之前，我們先搞清楚到底要拿甚麼來切牆壁，答案就是前面提到的視錐(frustum)，普通的視錐是像前面放的圖一樣，立體的梯形，有12個邊，也就是說一面牆在畫之前，要先經過這12個邊切切切(clipping)，然後才可以畫出來，不過這個專案比較特別，因為相機不需要上下看，所以只要用梯形平面(4個邊)順時針去切就可以了，甚至你可以像我一樣懶，只切左右2邊，也就是像小地圖那樣的2條射線，但為了避免出現bug，梯形短邊的那條可能還是需要切一下。
![](https://i.imgur.com/0SX31Zd.png)
切的方向
![](https://i.imgur.com/SLs6GUg.png)

clipping的pseudo code:
```cpp=
for(int i=0;i<num_edges;i++){   
    拿edges[i]的2個座標 開始點start 結束點end
    if(clip(左視錐射線,start,end) && clip(右視錐射線,start,end)){
        //用glvertex2f畫牆
    }
}

///////////////////////////////////////////////////
bool clip(視錐射線,start,end) {
    
	if (開始點在視錐射線的右邊) {
		if (結束點在視錐射線的左邊) {
			//狀況1
			//結束點在視錐外
			//所以要更新結束點到牆與視錐射線的交叉點
		}
		else {
			//狀況2
			//啥都不用更新，這面牆完全在視錐內
		}
	}
	else if (結束點在視錐射線的右邊) {
		//狀況3
		//開始點在視錐外
		//所以要更新開始點到牆與視錐射線的交叉點
	}
	else {
		//狀況4
		//不用畫，直接跑下個牆(edge)
		return false;
	}
	return true;

}
```
這段pseudo code只是為了理解在幹嘛，實際怎麼寫還是看你，至於要如何判斷左右，其實早就寫在Edge的`Point_Side`function裡了，，為了保持一致姓，所以把`Point_Side`移植到LineSeg，傳入xy座標就會回傳左右的標示`(Edge::RIGHT\LEFT)`

同樣的，要怎麼算出視錐與牆的交叉點，也已經寫在`Cross_Param`function了，這個function回傳的是從起始點到交叉點間所占該牆(edge)的比例，有了比例就可以線性內插出交叉點座標。

狀況1
![](https://i.imgur.com/FtgmphO.png)
狀況2，對2條射線來說，開始與結束點都在右手邊，此牆不用切，完整保留
![](https://i.imgur.com/qZwwETF.png)
狀況3
![](https://i.imgur.com/wc4uaP2.png)
狀況4
![](https://i.imgur.com/hTnJGVy.png)
寫成C++就是下面這樣，要注意的是，我把迷宮的xy平面對應到3維座標的xz，所以在存取y值時要拿z的那格資料，也就是[2]。
```cpp=
bool clip(LineSeg frustum_side,glm::vec4& start,glm::vec4& end) {
	char s_side = frustum_side.Point_Side(start[0], start[2]);
	char e_side = frustum_side.Point_Side(end[0], end[2]);
	if (s_side == Edge::RIGHT){
		if (e_side == Edge::LEFT) {
			float percent = frustum_side.Cross_Param(LineSeg(start, end, 2));
			end[0] = frustum_side.start[0] + (frustum_side.end[0] - frustum_side.start[0]) * percent;
			end[2] = frustum_side.start[1] + (frustum_side.end[1] - frustum_side.start[1]) * percent;
		}
	}
	else if (e_side == Edge::RIGHT){
		float percent = frustum_side.Cross_Param(LineSeg(start, end, 2));
		start[0] = frustum_side.start[0] + (frustum_side.end[0] - frustum_side.start[0]) * percent;
		start[2] = frustum_side.start[1] + (frustum_side.end[1] - frustum_side.start[1]) * percent;
	}
	else {
		return false;
	}
	return true;
}
```
以我自己的設計來說，是將clip寫成一個function，然後回傳bool來判斷要不要畫，你也可以外面包一層迴圈來跑過所有視錐射線。

clip完成後，1x1的迷宮就應該要能正常顯示，至於畫牆的部分，在之前測試projection與modelview矩陣時都是使用`glVertex3f`，要如何改寫成`glVertex2f`呢?
1. 因為矩陣都是使用4x4，所以vertex也要是4個值
2. 填法 `glm::vec4 vertex(迷宮平面的y值(LineSeg的[1]), 1.0f, 迷宮平面的x值(LineSeg的[0]), 1.0f)` 至於為甚麼這樣填，我也不懂
3. NDC座標 = projection * modelview * vertex
4. 壓縮NDC座標到平面(screen space) 螢幕座標=NDC座標/NDC座標的w值 (screen_coord = NDC/NDC[3])
5. `glBegin(GL_POLYGON);` `glVertex2f(screen_coord[0],screen_coord[1])` `glEnd();`

寫成C++(此段已整合到迷宮遞迴，我的程式碼裡看不到):
```cpp=
//畫一面牆的函式
void Maze::draw_wall(LineSeg wall,LineSeg L, LineSeg R, LineSeg front){ //L R是左右視錐射線 front是梯形短邊的射線
    glm::vec4 start(wall.start[1], 1.0f,wall.start[0], 1.0f);
    glm::vec4 end(wall.end[1], 1.0f, wall.end[0], 1.0f);
    
    glm::vec4 NDC_start = global::model_view * start;
    glm::vec4 NDC_end  = global::model_view * end;
    if (!clip(L, NDC_start, NDC_end) || !clip(R, NDC_start, NDC_end) || !clip(front, NDC_start, NDC_end)) return; //有任何一條射線判斷這面牆不用畫即可直接終止掉。
    NDC_start = global::projection * NDC_start;
    NDC_end = global::projection * NDC_end;
    if (NDC_start[3] < my_near && NDC_end[3] < my_near) return;//my_near為最近可視距離，通常為0.01
    glm::vec4 screen_start = NDC_start / NDC_start[3];
    glm::vec4 screen_end   = NDC_end / NDC_end[3];
    glBegin(GL_POLYGON);
    glVertex2f(screen_start[0], screen_start[1]);
    glVertex2f(screen_end[0], screen_end[1]);
    glVertex2f(screen_end[0], -screen_end[1]);
    glVertex2f(screen_start[0], -screen_start[1]);
    glEnd();
}
```
接下來是畫迷宮Cell的部分，因為我們現在只要測試1x1的迷宮能不能跑，所以只針對view_cell(玩家現在所在的Cell指標)去畫(draw_cell)，然後再拿這個Cell的四面牆去畫(draw_wall)
```cpp=

//畫一個Cell(四面牆)的函式
void Maze::draw_cell(Cell* now_cell, LineSeg L, LineSeg R) {
	now_cell->foot_print = true;
	LineSeg front(R.end[0],R.end[1],L.start[0],L.start[1]); //前面提到的梯形短邊，可以用左右視錐射線拼出
	for (int i = 0;i < 4;i++) {
		LineSeg edge_line(now_cell->edges[i]);
		glm::vec4 start(edge_line.start[1], 1.0f, edge_line.start[0], 1.0f),end(edge_line.end[1], 1.0f, edge_line.end[0], 1.0f);
		glColor3fv(now_cell->edges[i]->color);
		draw_wall(edge_line,L,R,front);
	}
}

//畫整個迷宮的函式
void Maze::Draw_View(const float focal_dist)
{
	frame_num++;
	global::model_view = glm::mat4(1);//填1才會是identity matrix
	float viewer_pos[3] = { viewer_posn[Maze::Y], 0.0f, viewer_posn[Maze::X] };
	LookAt(viewer_pos[Maze::X], viewer_pos[Maze::Y], viewer_pos[Maze::Z],
		viewer_pos[Maze::X] + sin(Maze::To_Radians(viewer_dir)),
		viewer_pos[Maze::Y],
		viewer_pos[Maze::Z] + cos(Maze::To_Radians(viewer_dir)),
		0.0, 1.0, 0.0);
	for (int i = 0;i < num_cells;i++)cells[i]->foot_print = false;
	draw_cell(view_cell,
		LineSeg(my_near * tan(To_Radians(viewer_fov * 0.5f)), -my_near, my_far * tan(To_Radians(viewer_fov * 0.5f)), -my_far),
		LineSeg(-my_far * tan(To_Radians(viewer_fov * 0.5f)), -my_far, -my_near * tan(To_Radians(viewer_fov * 0.5f)), -my_near));
}

```
上面所說的東西都完成後，應該會變下面這樣，1x1的迷宮正常跑，但10x10的就只能看到單個房間 :
