# 2020q3 render
###### tags: `sysprog`
contributed by < `sciyen` >
[repository](https://github.com/sciyen/raycaster) on github
[Toc]
# 遊戲方法
- 按 :arrow_up:/:arrow_down: 前進/後退
- 按 :arrow_left:/:arrow_right: 向左轉/右轉
- 按 `e` 舉槍
- 按 `g` 開啟 God Mode 使出透視眼
# 特殊函數筆記
1. `float modff( float x, float * intptr );` in `math.h` ([Microsoft docs](https://docs.microsoft.com/zh-tw/cpp/c-runtime-library/reference/modf-modff-modfl?view=msvc-160))
Splits a floating-point value into **fractional** and **integer** parts.
- This function **returns the signed fractional portion of x**. There's *no error return*.
- Example:
```
x = -14.87654321; /* Divide x into its fractional */
y = modf( x, &n ); /* and integer parts */
result: y = -0.876543, n = -14
```
# 程式原理分析
## Overview
### Map
從 `IsWall()` 中可以推敲出與地圖 `g_map` 有關的資訊:
- 地圖 `g_map` 定義在 `raycaster_data.h` 中,為 `uint8_t` 1d array with 32*4 elements.
- A bit as a pixel.
- 大小為正方形 (32x32) pixel, 四個 `uint8_t` element 為一個 X axis ,共分成 32 rows, that is, Y axis.
如果按照二維方向排列應該長這樣:
```
0b00000000, 0b00000000, 0b00000000, 0b00000000,
0b00000000, 0b00000000, 0b00000000, 0b00000000,
...
0b00000000, 0b00000000, 0b00000000, 0b00000000,
```
橫向 32 bit,縱向 32 rows.
#### 圖片大小的線索:
`raycaster_float.cpp` line: 15
```c=15
if (tileX < 0 || tileY < 0 || tileX >= MAP_X - 1 || tileY >= MAP_Y - 1) {
return true;
}
```
- 可以知道當 `tileX` 小於 `0` 或超過 `MAP_X-1` 時,即算是出界,因此 X 軸邊界最大值為 `MAP_X` ,其值為 32 , Y axis 同理。
`raycaster_float.cpp` line: 18
```c=18
return g_map[(tileX >> 3) + (tileY << (MAP_XS - 3))] &
(1 << (8 - (tileX & 0x7)));
```
- 此段程式碼用於判斷座標 (`tileX`, `tileY`) 是否為障礙物,由於 `g_map` 中,每個 element 為 `uint8_t` ,因此每個 element 可以代表 8 個 pixel ,$8=2^3$ ,因此 `tileX` 需除 8 ,也就是 `>> 3` 。
- `g_map` 是 1d array ,因此要計算 Y 方向應除掉 X 方向的長度, X 方向共包含 32 個 bit ($32=2^5$) ,但是一個 element 可以代表 8 個 bit ,因此,應除 $32/8=4$ 或 `>> 4`。
- `MAP_XS` 的意義為地圖大小的 2 指數,即 `2^5=32` ,其中 32 為 `MAP_X` ,`MAP_XS` 為指數部分。
- 第 18 行用來取 X axis 的 bit ,需注意的是,地圖中 LSB 為 X 正向, MSB 為 X 反向,與數值大小方向相反。取 7 的原因為,一個 element 可以代表 8 個 bit,這 8 個 bit 恰好是 X 座標的 3 個 LSBs 因此取 3 個 LSB 全為 1 的情況,即為 `0x7`。
:::warning
這邊的 `8-(tileX & 0x7)` 應該錯了,考慮 `tileX` 最大即最小情況:
- 最小 `tileX` =0 時,`8-0=8`:1<<8,會超過 uint8_t 的範圍
- 最大 `tileX` =7 時,`8-7=1`:1<<1=2,發現永遠沒有 1 的情況
因此應該將 8 修正為 7。
:::
修正後就會發現消失的牆壁回來了!
![](https://i.imgur.com/EcDoHm0.png)
:::success
#### 更有彈性的寫法:
若地圖改以其他變數型態表示時,可以這樣設定:假設用 `uint32_t` 由於 32 為 $2^5$ 因此只要設定 `MAP_ELEMENT_SIZE` 為 5 就可以不用修改 `IsWall()` 內部的程式碼。
```c=
#define MAP_ELEMENT_SIZE 5 // 2^5 = 32
#define MAP_X (uint8_t)(1 << MAP_XS)
#define MAP_ELEMENT_MASK ((1 << MAP_ELEMENT_SIZE) - 1)
...
const uint32_t g_map[] = {
0b00000000000000000000000000000000,
...
}
...
bool IsWall(...){
...
return g_map[(tileX >> MAP_ELEMENT_SIZE) + (tileY << (MAP_XS - MAP_ELEMENT_SIZE))] &
(1 << (MAP_ELEMENT_MASK - (tileX & MAP_ELEMENT_MASK)));
}
```
:::
### Texture
在程式碼中, texture 以 `uint8_t` 的陣列儲存,而大小是 `64x64` 的方塊,因此 $(2^6)^2 = 2^{12}=4096$。
線索:
`raycaster_data.h`中, `g_texture8` 的定義:
```c=32
const uint8_t LOOKUP_TBL g_texture8[4096];
```
`renderer.cpp`中,對每個 texture 元素的存取:
```c=38
auto tv = g_texture8[(ty << 6) + tx];
```
### Coordinate System
與一般數學上熟悉的角度定義不同,在遊戲中定義視角方向為 Y 方向,向視野右邊角度為正,向視野左邊角度為負。
<center><img src="https://i.imgur.com/lYkLG6w.png" style="width: 300px"></center>
<center>Fig. Gaming coordinate system</center>
一般來說,求 X 方向應該使用 $cos$ ,Y 方向使用 $sin$ 但在這邊會相反,可以在 `game.cpp` 中找到線索:
`game.cpp` line: 8
```c=10
playerX += 0.5f * m * sin(playerA) * seconds * 5.0f;
playerY += 0.5f * m * cos(playerA) * seconds * 5.0f;
```
此外,在 `Distance()` 中也有類似的實作,將 `tileStep` 定義為如下的 vector:
<center><img src="https://i.imgur.com/5CM2HML.png" style="width: 300px"></center>
<center>Fig. Gaming coordinate system</center>
可以發現這邊重新定義的 unit vector 是以 $\theta$ 與 Y 軸夾角的情況下,投影在原本 X-Y 軸座標系下的單位向量。
`game.cpp` line: 42
```c=42
int tileStepX = 1;
if (rayA > M_PI) {
tileStepX = -1;
}
int tileStepY = 1;
if (rayA > M_PI_2 && rayA < 3 * M_PI_2) {
tileStepY = -1;
}
```
# Raycasting Procedures
#### Assumption:
1. 視角僅可能為畫面中間,如此一來計算就只需要計算 `SCREEN_WIDTH` 次 `Distance()` (只要在畫面水平中線掃一次)
#### Procedures
- Trace Frame(`renderer.cpp`)
計算出畫面 (frame) 上每個 pixel 的顏色 (`GetARGB`) ,實際上,由於畫面上下對稱,因此只計算畫面水平中線,再往上下方向用 `Trace()` 計算畫面中的障礙物高度。
- Trace(`raycaster_float.cpp`, `raycaster_fixed.cpp`)
計算撞到的 Wall 在目前視角下的障礙物資訊,包含 texture、該視角障礙物高度等資訊。
- Distance
### TraceFrame()
對整個畫面 render ,沿著 X 方向執行 `trace()` :
<center><img src="https://i.imgur.com/r1F0Y3z.png" style="width: 300px"></center>
<center>Fig. Frame tracing direction</center>
障礙物在畫面中分成兩種情況:
- 障礙物高度低於畫面高度
<center><img src="https://i.imgur.com/CmzUBFX.png" style="width: 400px"></center>
<center>Fig. Field Of View</center>
- 障礙物高度超過畫面高度
<center><img src="https://i.imgur.com/MiRruGZ.png" style="width: 400px"></center>
<center>Fig. Field Of View</center>
因此要畫出畫面中特定 X 座標的障礙物我們需要以下資訊:
- screenY: 障礙物在畫面中最高位置的 y 像素
- textureY0: 障礙物開始 render 的 texture 起始位置(再來只要一直往正 Y 方向 render 就好)
- textureStep: 每在畫面中移動一個像素時, texture 的 Y 座標的變動值。
### Trace()
#### ==Function Objective==
計算給定的螢幕 X 的位置,撞到的 Wall 在目前視角下的畫面 ( Y 座標、材質等資訊)
- input:
- screenX: 往此畫面 X 座標看過去,此函式將回傳該點 Y 軸成像。
- output:
- screenY: 障礙物在視野中的 Y 軸高度(最高點)。
- textureNo: 障礙物的 ID。
- textureX: 看到的障礙物 texture X axis index(最高點)。
- textureY: 看到的障礙物 texture Y axis index(最高點)。
- textureStep: 隨著畫面 Y 軸往下移動一個像素,在 texture Y index 中移動的距離。
<center><img src="https://i.imgur.com/iduRPAm.png" style="width: 400px"></center>
<center>Fig. View window</center>
其中,特定 `screenX` 相對於畫面中心的角度差 $\Delta\theta$ 可以表示為:
$$
\Delta\theta = acrtan(\frac{ViewOffsetX}{View Window Distance}) = arctan(\frac{screenX-\frac{screenWidth}{2}}{\frac{screenWidth}{2}\frac{\tau_{max}}{2}})
$$
- 其中,$\tau_{max}$ 可以限制可視範圍,$screenX$ 為 0 時為最左側;$screenX$ 為 $\frac{screenWidth}{2}$ 時為最右側。
$$
\Delta\theta_{max}=acrtan(\frac{\pm2}{\tau_{max}}) \approx \pm51.8^{\circ}
$$
- $D_v$: View Window Distance 為視野畫面 (ViewWindow) 與 player (Origin) 的距離
:::success
#### 設定 Field Of View (FOV)
如果要讓視角的定義更直覺,應該設定 FOV 視角:
```c=
#define FOV_X (M_PI / 2)
```
`raycaster_float.cpp`
```c=145
float deltaAngle = atanf(((int16_t) screenX - SCREEN_WIDTH / 2.0f) /
(SCREEN_WIDTH / (2.0f * tanf(FOV_X / 2.0f))));
```
如此一來
$$
\Delta\theta_{max}=acrtan(tan(\pm\frac{FOV_X}{2})) = \pm\frac{FOV_X}{2}
$$
$$
D_v = \frac{screenWidth}{2tan(\frac{FOV_X}{2})}
$$
重新定義的 FOV 如下圖:
- 綠線為水平 (X) 方向視角極限
- 藍線為垂直 (Y) 方向視角極限
<center><img src="https://i.imgur.com/IJhs2Qf.png" style="width: 300px"></center>
<center>Fig. Field Of View</center>
45 度:
![](https://i.imgur.com/gnlWQaK.png)
90 度:
![](https://i.imgur.com/W8iMpNK.png)
120 度:
![](https://i.imgur.com/n3SAv9I.png)
:::
#### ==計算畫面中障礙物的高度==
<center><img src="https://i.imgur.com/ytddUtx.png" style="width: 300px"></center>
<center>Fig. Gaming coordinate system</center>
利用相似三角形,可以做以下推論
$$
\frac{\frac{screenY}{2}}{D_v}=\frac{\frac{WallHeight}{2}}{distance},\\ i.e. \ screenY=\frac{WallHeight \times D_v}{distance}=\frac{WallHeight \times screenWidth}{4 \times distance \times tan(\frac{FOV_X}{2})}
$$
:::success
為配合前面 FOV 的定義,可以做如下改寫:
將原本程式碼中的 `INV_FACTOR` 重新定義為
```c=
#define WALL_HEIGHT 1.0f
#define INV_FACTOR \
(float) (WALL_HEIGHT * SCREEN_WIDTH / (4.0f * tanf(FOV_X / 2)))
```
:::
#### ==texture rendering==
由於這個地圖中的障礙物都是 1x1 的方塊,要把 texture 貼到方塊上只要知道方塊上任意點的 x-y 座標,就可以計算出相應的 texture 。
求出 Wall 在視野內的高度 (`screenY`) 後,就得到視野內的方塊 x-y 資訊,這邊使用定點數表示 x-y 座標:
- Tecture X 方向:
在這個遊戲中因為視野不會傾斜,因此 y 方向皆為垂直線條, texture 的 X 座標隨螢幕的 X 座標成比例關係:
$$
x = TextureSize \times HitOffsetRatio
$$
`raycaster_float.cpp`
```c=152
*textureX = (uint8_t)(256.0f * modff(hitOffset, &dum));
```
**Note**: `textureSize` 為 $2^6=32$ , 2 bits fixed point,因此 $2^{6+2}=256$
`renderer.cpp`
```c=21
const auto tx = static_cast<int>(tc >> 2);
```
先把界於 `0~1` 的 `hitOffset` 對應到 `uint8_t` 能夠表示的最大範圍後 (`textureX`) , 在 `renderer.cpp` 再轉換到 texture 相對應的 size 。因為 texture size 為 $2^6=64$,`uint8_t` size 為 $2^8$ ,因此需要右移 $(8-2)=2$ ,相當於使用 2 bits 定點數。
:::info
這邊 X 方向使用 2 bits 定點數即可,是因為視野不會傾斜,不會對成像造成明顯影響。
:::
- Texture Y 方向,使用 10 bits fixed point:
目標為找出畫面上特定 Y 座標點對應的 texture 座標,因此可以看作兩個座標空間的轉換,這邊記作 ${Screen Coordinate}\to{Texture Coordinate}$:
將 texture 看作一線性變換:
$$
textureY^*=textureY_0+y\frac{TextureSize-2\times textureY_0}{WallHeight}
$$
$textureY_0$: texture 的起始座標。
$TextureSize$: texture 的圖片大小。
$WallHeight$: 視野中牆壁的高度,也就是 $2\times screenY$。若牆高超過視野高度,則 $WallHeight=ScreenHeight$
$$
textureY_0=TextureSize\frac{(ProjectHeight-ScreenHeight)/2}{ProjectHeight}
$$
$ProjectHeight$: 投影在成像平面上的牆壁高度。
這邊有兩種狀況:
- 牆壁高度沒有超過視野高度: $WallHeight=2 \times screenY \leq ScreenHeight$
由於牆壁會完全畫出來,因此 texture 從 0 開始畫,即 $textureY_0=0$。
$$
textureY^* = 0 + y \frac{TextureSize}{WallHeight}
$$
$y$ 為畫面取樣時的 y 座標,其範圍應為 $0\leq y\lt WallHeight$。
- 牆壁高度超過視野高度: $2 \times screenY \geq ScreenHeight = WallHeight$
從視野中只能看到部分牆壁,因此最高處從 $textureY_0$ 開始。
:::success
#### 原程式碼中的運算缺失
##### screenY overflow
可以發現,出現異常的地方總是在牆壁高度過高的時候(相較於左側視野範圍內,較低的牆壁高度總是沒有異常)
![](https://i.imgur.com/HvwrmJr.png)
在原本程式碼中,並未判斷 `screenY` 是否超過 `screenHeight` 也未對超過的情況做處理。此外,在 $screenY$ 的計算中,原本的程式碼是以 `uint8_t` 的整數去接收 `INV_FACTOR / distance` 因此若牆壁高度的計算結果超過 255 ,便會導致 $screenY$ overflow。
`raycaster_float.cpp` 原程式碼:
```c=151
*screenY = INV_FACTOR / distance;
auto txs = (*screenY * 2.0f);
if (txs != 0) {
*textureStep = (256 / txs) * 256;
if (txs > SCREEN_HEIGHT) {
auto wallHeight = (txs - SCREEN_HEIGHT) / 2;
*textureY = wallHeight * (256 / txs) * 256;
}
}
```
##### 鋸齒畫面
此外,由於原本的做法是用 `textureStep(ts)` 做累加,在 Y 方向累加的同時,浮點誤差也會跟著變大導致畫面下半部型成鋸齒狀:
`renderer.cpp` 原程式碼:
```c=37
auto ty = static_cast<int>(to >> 10);
auto tv = g_texture8[(ty << 6) + tx];
to += ts;
```
<center><img src="https://i.imgur.com/48cvuy1.png" style="width: 500px"></center>
<center>Fig. Fixed point 產生的鋸齒畫面</center>
#### 修正
利用前述的方式計算 `textureY` ,先在 `raycaster_float.cpp` 中判斷 $WallHeight$ ,若成像的高度大於畫面範圍 $ScreenHeight$,則再計算對應的 $textureY$
`raycaster_float.cpp` 改為:
```c=157
auto txs = 2.0f * INV_FACTOR / distance;
if (txs > SCREEN_HEIGHT){
*screenY = SCREEN_HEIGHT / 2;
*textureY = static_cast<uint16_t>(TEXTURE_SIZE * (txs - SCREEN_HEIGHT)
/ 2 / txs * (1 << 10));
}
else
*screenY = txs / 2;
```
`renderer.cpp` 改為:
```c=37
uint16_t ty = (tso + (((TEXTURE_SIZE / 2) << 10) - tso)
* y / sso) >> 10;
```
由於程式碼中使用 10 bits 定點數,因此 `textureY` 在運算開始前先乘 `(1 << 10)` ,並且在計算 $textureY^*$ 最後再右移 10。
<center><img src="https://i.imgur.com/CpyREFv.png" style="width: 500px"></center>
<center>Fig. Fixed point 修正後的畫面</center>
:::
#### ==消除魚眼==
當距離太近的時候,會產生如下的魚眼效果:
![](https://i.imgur.com/R0QVCdk.png)
<center>Fig. Fisheye effect</center>
未校正前的影像是投影在「成像平面」上(見下圖),產生 $M$ 點,可以發現,相較於投影在「投影球面」上的 $P$ 點
- 投影在成像平面上有**更寬闊的視野範圍**,但是會造成**失真**,因為成像位置與觀測位置每點的距離都不太一樣,越靠近左右成像距離越長。
要修正魚眼效果要修正成像距離,使其成像在「投影球面」上,因此:
$$
Distance=LineDistance\times tan(\Delta \theta)
$$
<center><img src="https://i.imgur.com/gF04T1T.png" style="width: 400px"></center>
<center>Fig. Fisheye effect compensation</center>
:::info
在程式碼中, $LineDistance$ 指的是 **物體** 至 **玩家** 的距離,而在這邊修正魚眼指的是 **成像平面投影點** 至 **觀測點** 的距離,但是由於 $screenY$ 的 [**計算**](https://hackmd.io/SgFjqgnWSpOWUrJRK1-16w?both#%E8%A8%88%E7%AE%97%E7%95%AB%E9%9D%A2%E4%B8%AD%E9%9A%9C%E7%A4%99%E7%89%A9%E7%9A%84%E9%AB%98%E5%BA%A6) 是線性的,因此先乘和後乘 $tan()$ 是等效的;$Distance$ 以此類推。
:::
:::success
#### 魚眼修正的加速
由魚眼校正的計算可以發現,每個 $\Delta \theta$ 、 $tan(\Delta\theta)$ 對於 $X$ 皆是 1 對 1 的對應關係,因此可以建立對應的 table 做查表,以節省計算。
:::
## raycaster_float.cpp
### Distance()
#### Function Objective
Find the distance to obstacles from current player's position and facing direction (ray).
- hitOffset: 用於計算 texture 的 X offset,為撞到 wall 的該點與 player 的距離,其方向依據撞到的是 X 方向或 Y 方向有關。
- hitDirection: 表示撞到的是 X 方向或 Y 方向。
為了判斷從 player 射出的 ray 最先碰撞到的障礙物,這邊先計算 X、Y 方向的 `startDelta` ,其意義為分別從 X 及 Y 方向出發,最先遇到的網格 (grid) 距離,接下來因為 grid 皆為單位大小,因此只要計算經過幾個 grid 就好。
整理在各個象限時的情況:
1. $0 \leq \theta < \frac{\pi}{2}$,$tan(\theta)>0$
<center><img src="https://i.imgur.com/S2qILq2.png" style="width: 300px"></center>
$$
\left\{
\begin{array}{lr}
startDeltaX=(1-offsetY)tan(\theta) \\
startDeltaY=\frac{1-offsetX}{tan(\theta)}
\end{array}
\right.
$$
2. $\frac{\pi}{2} \leq \theta < \pi$,$tan(\theta)<0$
<center><img src="https://i.imgur.com/lV9PFKf.png" style="width: 300px"></center>
$$
\left\{
\begin{array}{lr}
startDeltaX=offsetY \times (-tan(\theta)) \\
startDeltaY=\frac{1-offsetX}{tan(\theta)}
\end{array}
\right.
$$
3. $\pi \leq \theta < \frac{3\pi}{2}$,$tan(\theta)>0$
<center><img src="https://i.imgur.com/R2ckrO3.png" style="width: 300px"></center>
$$
\left\{
\begin{array}{lr}
startDeltaX=offsetY \times (-tan(\theta)) \\
startDeltaY=\frac{offsetX}{-tan(\theta)}
\end{array}
\right.
$$
4. $\frac{3\pi}{2} \leq \theta < 2\pi$,$tan(\theta)<0$
<center><img src="https://i.imgur.com/BhS8RH4.png" style="width: 300px"></center>
$$
\left\{
\begin{array}{lr}
startDeltaX=(1-offsetY)(-tan(\theta)) \\
startDeltaY=\frac{offsetX}{-tan(\theta)}
\end{array}
\right.
$$
:::success
#### Simplifying the calculation of startDelta
這邊發現一個很有趣的現象,如果將 $tan(\theta)$ 前的正負號提出來,恰好為 X、Y 象限的單位向量,因此可以將原本的計算改為
$$
\left\{
\begin{array}{lr}
startDeltaX = vecX \times tan(\theta) \times correctY \\
startDeltaY = vecY \times \frac{1}{tan(\theta)} \times correctX
\end{array}
\right.
$$
修正項與 X、Y 象限單位向量的關係為 $(correctY, correctX)=(unitX, unitY)$ ,這邊 X、Y 恰好相反。
<center><img src="https://i.imgur.com/5CM2HML.png" style="width: 300px"></center>
<center>Fig. Unit vector</center>
而 $(vecX, vecY)$ 則與象限有關,可以整理成下表
||$0 \leq \theta < \pi$|$\pi \leq \theta < 2\pi$|
|-|-|-|
|$vecY$|$1-offsetX$|$offsetX$|
|$unitX$|$1$|$-1$|
||$\frac{\pi}{2} \leq \theta < \frac{3\pi}{2}$|$0 \leq \theta < \frac{\pi}{2}$&&$\frac{3\pi}{2} \leq \theta < 2\pi$|
|-|-|-|
|$vecX$|$offsetY$|$1-offsetY$|
|$unitY$|$-1$|$1$|
如此一來可以大幅簡化原本 `startDelta` 的計算,也減少許多 if-else 的判斷次數
修改過的 `raycaster_float.cpp`:
```c=46
float vecX = 1 - offsetY; // The case that 3pi/2 ~ pi/2
float vecY = 1 - offsetX; // The case that 0 ~ pi
int tileStepX = 1; // The case that 0 ~ pi
int tileStepY = 1; // The case that 3pi/2 ~ pi/2
// Generate directional unit vector according to player angle
if (rayA > M_PI) {
tileStepX = -1;
vecY = (offsetX == 0) ? 1 : offsetX;
}
if (rayA > M_PI_2 && rayA < 3 * M_PI_2) {
tileStepY = -1;
vecX = (offsetY == 0) ? 1 : offsetY;
}
// Calculate the starting delta
float startDeltaX = vecX * tan(rayA) * tileStepY;
float startDeltaY = vecY / tan(rayA) * tileStepX;
```
再來測量修改後的加速情形,以下資料取樣自進入遊戲後不動的情況下, 14.8 秒間共 74 筆數據 (每 0.2 秒一筆)的平均結果,時間計算方法請參考 [Performance Estimation](https://hackmd.io/SgFjqgnWSpOWUrJRK1-16w?both#Performance-Estimation) 實驗 [詳細資料](https://docs.google.com/spreadsheets/d/1ADTBi5f8Ap-Z2bWFIqjCS75Kltma4PXpWCXGmZTwI3s/edit#gid=0&range=A1:M81)
|實驗|fixed point(ms)|float point(ms)|diff(ms)|FPS(frame/sec)|
|:-:|:-:|:-:|:-:|:-:|
|Old|0.308|0.344|0.036|323.410|
|Old|0.293|0.330|0.037|407.674|
|New|0.263|0.289|0.026|286.609|
|New|0.285|0.325|0.040|401.003|
可以發現使用簡化後的 float point 平均來說比原本方法快一點,但是沒有很明顯。
:::
接下來要找與障礙物的碰撞點,這邊用 $0 \leq \theta \leq \frac{\pi}{2}$ 舉例:
<center><img src="https://i.imgur.com/phXx11b.png" style="width: 400px"></center>
以 $intercept$ 為起點
$$
\left\{
\begin{array}{lr}
interceptX&=rayX + startDeltaX \\
interceptY&=rayY + startDeltaY
\end{array}
\right.
$$
發射出的向量會以相同的方向,但以不同的大小前進,因此分成以下兩種可能:
- 撞到橫軸,即為綠色點,其 Y 座標為整數
$$
hitPos=(interceptX, tileY) \\
moveVec=(|tan(\theta)|sgn(unitX), 1\cdot sgn(unitY))
$$
- 撞到縱軸,即為紅色點,其 X 座標為整數
$$
hitPos=(tileX, interceptY) \\
moveVec=(1\cdot sgn(unitY), \frac{1}{|tan(\theta)|}sgn(unitX))
$$
因此,所有的碰撞點可以表示成如下:
$$
hitPos^*=hitPos + n \times moveVec, \quad n \in \mathbb{Z}
$$
<center><img src="https://i.imgur.com/Ict6Ezp.png" style="width: 500px"></center>
<center>Fig. Collision points</center>
如此一來只要重複比對是哪一個碰撞點最先撞到即可。
- 紅色: $tileY+1<interceptY$ 只要這個條件還滿足,表示下一個紅色仍然在綠色的範圍內,因此,下一個點仍然是紅色。
- 如果撞到牆壁一定是 Y 方向的面。
```c=75
while (((tileStepY == 1 && (interceptY <= tileY + 1)) ||
(tileStepY == -1 && (interceptY >= tileY)))) {
somethingDone = true;
tileX += tileStepX;
if (IsWall(tileX, interceptY)) {
verticalHit = true;
rayX = tileX + (tileStepX == -1 ? 1 : 0);
rayY = interceptY;
*hitOffset = interceptY;
*hitDirection = true;
break;
}
interceptY += stepY;
}
```
- 綠色: $tileX+1<interceptX$ 只要這個條件還滿足,表示下一個綠色仍然在紅色的範圍內,因此,下一個點仍然是綠色。
- 如果撞到牆壁一定是 X 方向的面。
```c=89
while (!verticalHit && ((tileStepX == 1 && (interceptX <= tileX + 1)) ||
(tileStepX == -1 && (interceptX >= tileX)))) {
somethingDone = true;
tileY += tileStepY;
if (IsWall(interceptX, tileY)) {
horizontalHit = true;
rayX = interceptX;
*hitOffset = interceptX;
*hitDirection = 0;
rayY = tileY + (tileStepY == -1 ? 1 : 0);
break;
}
interceptX += stepX;
}
```
藉由重複以上兩個判斷,除非撞到牆壁,否則交替會一直延續。在這個地圖中一定會撞到某個點,因為在 `IsWall()` 中有設定邊界,撞到範圍外一定會停止。
為了防止形成無窮迴圈,這邊用一個變數 `somethingDone` 判斷射線是否仍在前進,否則就回傳 0 跳出迴圈。
# Fixed point implementation
## 定點數運算
### 數字系統
#### angle
在 fixed point 實作中,是以 8 bits 表示一個 $\frac{\pi}{2}$,可以在 `precalculator.cpp` 中找到線索:
`precalculator.cpp` line: 45
```c=45
int16_t da = static_cast<int16_t>(deltaAngle / M_PI_2 * 256.0f);
```
也就是說,新變數 `da`
$$
da=deltaAngle \times \frac{256}{\frac{\pi}{2}}
$$
因此,若要判斷 `angle` 所在象限 (有多少個 $\frac{\pi}{2}$ ),只要取 `angle >> 8` 即可;而 `angle % 256` 則為佔 $\frac{\pi}{2}$ 的比例。例如:
`raycaster_fixed.cpp` line:101
```c=101
const uint8_t quarter = rayA >> 8;
const uint8_t angle = rayA % 256;
```
注意: 和一般數學慣例不一樣的是,這邊的 `quarter` 是從第 0 象限開始。
#### 角度表示範圍與誤差
數學中 $tan(\theta)$ 與 $cot(\theta)$ 存在極端值 (當 $tan(\pi/2)$ 時為無窮大),也就是會出現 `uint16_t` 無法表示的範圍。
##### 極值
$tan(\theta)$ 的極值出現在最靠近 $\frac{\pi}{2} + n \pi$ 的地方,其中 $n$ 為整數。
##### 誤差
由於電腦數值系統中,僅能以有限數量的位元儲存數字,因此對於像是 $tan()$ 這種無理數,僅能近似其數值,而近似結果與實際數字的差距就定義為誤差。
$$
Error(\theta_a) = tan_a(\theta_a) - tan(\theta)
$$
- $\theta_a$ 為在該數值表示方法下近似的 $\theta$ 為離散值。
- $\tan_a()$ 為選用的 $tan()$ 近似算法 為離散的 one to one mapping
- $\theta$ 及 $tan()$ 為連續值
<center><img src="https://i.imgur.com/cQRBi3K.png" style="width:500px"></center>
<center>Fig: Error of tan</center>
由於 $tan(\theta)$ 的微分為 $\frac{1}{sec^2(\theta)}$ ,其在 $0\leq\theta<\frac{\pi}{2}$ 的範圍內為嚴格遞增函數,可以得到 $abs(E_L(\theta)) < E_H(\theta) < \Omega(\theta)$ ,並且, $\theta$ 越接近 $\frac{\pi}{2}$, $\Omega(\theta)$ 越大。
<center><img src="https://i.imgur.com/CGLBhAT.png" style="width:400px"></center>
<center>Fig: 1/sec^2()</center>
##### 討論與比較
- 假設 $\theta$ 為 8 bits 定點數
- 極值: 在 $\theta$ 最接近 $\frac{\pi}{2}$ 時,也就是 $\theta=\frac{\pi}{2} \frac{255}{256}$ ,`tan` 有最大值 $tan(\frac{\pi}{2} \frac{255}{256}) = 162.937$。
- 最大誤差: $\tan(\frac{\pi}{2} \frac{255}{256}) - \tan(\frac{\pi}{2} \frac{254}{256}) = 81.489$。
- 假設 $\theta$ 為 10 bits 定點數
- 極值: 在 $\theta$ 最接近 $\frac{\pi}{2}$ 時,也就是 $\theta=\frac{\pi}{2} \frac{1023}{1024}$ ,`tan` 有最大值 $tan(\frac{\pi}{2} \frac{1023}{1024}) = 651.335$。
- 最大誤差: $\tan(\frac{\pi}{2} \frac{1023}{1024}) - \tan(\frac{\pi}{2} \frac{1022}{1024}) = 325.950$。
- 但是如果在 $\frac{\pi}{2} \frac{255}{256}$ 的誤差則為 $\tan(\frac{\pi}{2} \frac{1020}{1024}) - \tan(\frac{\pi}{2} \frac{1019}{1024}) = 32.595$
可以發現,使用 10 bits fixed point 的約比 8 bits fixed point 少 $60.0\%$ 的誤差。
### 無號乘法 `MulU()`
這是一個將 8 bits 整數 `v` 乘以 8 bits **無號**定點數 `f` 的運算,回傳的是四捨五入過後的 16 bits 無號整數。
```c=
uint16_t RayCasterFixed::MulU(uint8_t v, uint16_t f)
{
const uint8_t f_h = f >> 8;
const uint8_t f_l = f % 256;
const uint16_t hm = v * f_h;
const uint16_t lm = v * f_l;
return hm + (lm >> 8);
}
```
Example:
```
v: 0000 0011 |
f: 0000 0001 | 0000 1000
fh: 0000 0001 -> higher bits
fl: 0000 1000 -> lower bits
hm: 0000 0011
lm: 0001 1000
re: 0000 0011
```
若 `lm` 小於 256 表示 `v * f` 的小數部分不超過 1 無法進位,因此會在 `lm >> 8` 的時候捨去。
### 有號乘法 `MulS()`
這是一個將 8 bits 整數 `v` 乘以 8 bits **有號**定點數 `f` 的運算,回傳的是四捨五入過後的 16 bits 有號整數。
```c=
int16_t RayCasterFixed::MulS(uint8_t v, int16_t f)
{
const uint16_t uf = MulU(v, static_cast<uint16_t>(ABS(f)));
if (f < 0) {
return ~uf;
}
return uf;
}
```
這邊如果 f 小於 0 ,最後會做一個 not 運算變成 `1 補數`。
:::warning
Q: 為什麼不是用 `2 補數`?
:::
### `AbsTan()`
利用 $tan()$ 的對稱關係,可以用映射的方式得到不同象限的函數值。
<center><img src="https://upload.wikimedia.org/wikipedia/commons/thumb/c/c1/Tan_proportional.svg/1200px-Tan_proportional.svg.png" style="width: 300px"></center>
<center>Fig. function of tan (wikimedia)</center>
如此一來,可以做個歸納: (這邊的象限定義使用前述的 [數字系統](#%E6%95%B8%E5%AD%97%E7%B3%BB%E7%B5%B1),以第 0 象限作為開始)
- 第 1 象限: 垂直水平映射
- 第 2 象限: 不變
- 第 3 象限: 垂直水平映射
但是,`AbsTan()` 只需要做垂直映射,因此:
```c=
inline int16_t RayCasterFixed::AbsTan(uint8_t quarter,
uint8_t angle,
const uint16_t *lookupTable)
{
if (quarter & 1) {
return LOOKUP16(lookupTable, INVERT(angle));
}
return LOOKUP16(lookupTable, angle);
}
```
:::info
#### invert
這邊垂直映射是利用
```c=
#define INVERT(x) (uint8_t)((x ^ 255) + 1)
```
也就是說,利用 $a + b = 256$ 的關係,找出對稱的 index
```
000 -> 000
001 -> 255
254 -> 002
255 -> 001
```
:::
### 乘 `MulTan()`
這個 function 用查表的方式快速計算 $value \times tan(angle)$ 或是 $value \times cot(angle)$
由 $tan()$ 與 $cot()$ 的關係:
$$
tan(-\theta+\frac{\pi}{2})=\frac{sin(-\theta+\frac{\pi}{2})}{cos(-\theta+\frac{\pi}{2})}=\frac{-cos(-\theta)}{sin(-\theta)}=cot(\theta)
$$
可以發現 $tan()$ 和 $cot()$ 的關係其實是將 $tan()$ 取垂直映射後,右(左)移 $\frac{\pi}{2}$:
<center><img src="https://mathbitsnotebook.com/Algebra2/TrigGraphs/otherg97.gif" style="width: 300px"></center>
<center>Fig. function of cot (https://mathbitsnotebook.com/Algebra2/TrigGraphs/TGtanSecCscCot.html)</center>
而實際上,可以藉由對 $\frac{\pi}{4}$ 映射得到。
如此一來,可以做個歸納: (這邊的象限 `quarter` 定義使用前述的 [數字系統](#%E6%95%B8%E5%AD%97%E7%B3%BB%E7%B5%B1),以第 0 象限作為開始)
- 需要左右映射的:
- $tan()$: 1、3 象限
- $cot()$: 0、2 象限
- 需要上下映射的 (負號):
- $tan()$: 1、3 象限
- $cot()$: 1、3 象限
也就是
```c=
uint8_t signedValue = value;
if (inverse) {
if (value == 0) {
if (quarter % 2 == 1) {
return -AbsTan(quarter, angle, lookupTable); // cot 上下映射
}
return AbsTan(quarter, angle, lookupTable);
}
signedValue = INVERT(value);
}
if (signedValue == 0) {
return 0;
}
if (quarter % 2 == 1) {
return -MulU(signedValue, LOOKUP16(lookupTable, INVERT(angle))); // cot 上下映射
}
return MulU(signedValue, LOOKUP16(lookupTable, angle));
```
### `CalculateDistance()`
這邊的 `CalculateDistance()` 與 `raycaster_float.cpp` 中的 `Distance()` 很像,不一樣的是
1. `CalculateDistance()` 中的 `tan()` 計算改以查表的方式進行。
2. 對於 `angle` 等於 0 的特例做了最佳化。
3. 使用 `goto` 的技巧簡化在 `angle == 0` 與 `angle != 0` 的情況中,計算 texture 的重複性程式碼。
# New Features
## Performance Estimation
利用 SDL2.0 的 API ,可以計算 High resolution 的 performance.
- `SDL_GetPerformanceCounter()`
Returns the current counter value.
- `SDL_GetPerformanceFrequency()`
Returns a platform-specific count per second.
為了分別計算 fixed point performance、float point performance、FPS 這三種指標,利用 counter 為其計時:
`main.cpp`:
```c=125
const auto renderFloatTickStart = SDL_GetPerformanceCounter();
floatRenderer.TraceFrame(&game, floatBuffer);
const auto renderFloatTickEnd = SDL_GetPerformanceCounter();
const auto floatRenderSeconds = (renderFloatTickEnd - renderFloatTickStart) /
static_cast<float>(tickFrequency);
```
#### Exponential Filter
由於計算速度很快,容易產生許多高頻雜訊,所以引入 [Exponential Filter](https://gregstanleyandassociates.com/whitepapers/FaultDiagnosis/Filtering/Exponential-Filter/exponential-filter.htm)
$$
y(k) = a*y(k-1) + (1-a)*x(k)
$$
where
- $x(k)$ is the raw input at time step $k$
- $y(k)$ is the filtered output at time step $k$
- $a$ is called the `smoothing constant`, normally between 0.8 ~ 0.99
這邊 $T$ 我們用估計的 frame update rate ,大約是 0.00357 (s), `smoothing factor` $\tau$ 選擇 0.95
$$
a = e^{\frac{-T}{\tau}} \quad or \quad \tau = \frac{-T}{log(a)}
$$
$\tau$ is the resulting `filter time constant` 大約為 0.16 (s) ,表示假設為 step input ,大概耗費 0.16 秒,資料會成長 $1/e \approx 36.8\%$
`main.cpp`:
```c=67
void printPerformanceInfo(float floatRenderSec,
float fixedRenderSec,
float overallSec)
{
const float smoothFactor = 0.95f;
static auto lastPrintTick = SDL_GetPerformanceCounter();
static float floatRC = floatRenderSec;
static float fixedRC = fixedRenderSec;
static float overRC = overallSec;
auto currentTick = SDL_GetPerformanceCounter();
const static auto tickFrequency = SDL_GetPerformanceFrequency();
floatRC = smoothFactor * floatRC + (1 - smoothFactor) * floatRenderSec;
fixedRC = smoothFactor * fixedRC + (1 - smoothFactor) * fixedRenderSec;
overRC = smoothFactor * overRC + (1 - smoothFactor) * overallSec;
if ((currentTick - lastPrintTick) / static_cast<float>(tickFrequency) >
0.2f) {
printf("fixed: %.6f(s), float: %.6f(s), FPS: %.6f(s)\n", fixedRC,
floatRC, 1 / overRC);
lastPrintTick = currentTick;
}
}
```
## Multi obstacles
### 修改地圖儲存方式 `g_map32`
原本的地圖一個 position 只有兩種情況,因此只需要一個 bit 即可表示:
- It is a wall: 以 `1` 表示
- It is not a wall: 以 `0` 表示
若要儲存不同的 wall ,表示一個 position 有多種可能,需要在 `g_map` 中拓展一個 position 的儲存空間,由於原本的地圖是以 `uint8_t` 若將型態改為 `uint32_t` 即可以將一個 position 以 4 bits 表示,共可以表示 16 種牆壁,而不改變原本地圖 X-Y 座標的定義。
以下為將 8 bits 地圖轉換為 32 bits 地圖的轉換工具:
```c=
uint32_t out_map[sizeof(g_map)] = {0};
for (int i = 0; i < sizeof(g_map); i++) {
uint8_t in = g_map[i];
for (int j = 0; j < 8; j++) {
out_map[i] |= (in & (1 << j)) << (4 * j - j);
}
}
```
該程式在每個 g_map 後面插入三個 0:
```
0b 0 1 1 1 1 0 1 0 -> 表示 8 個牆壁,每個牆壁 1 bit
0b00000001000100010001000000010000 -> 表示 8 個牆壁,每個牆壁 4 bit
```
### 新地圖的牆壁判斷 `IsWall()`
接下來,關係會變得稍微複雜,首先定義以下常數
- `MAP_XS`: 地圖大小的指數 (假設地圖寬度為 32 ,則 `MAP_XS` 為 5 ,因為 $32=2^5$)
- `MAP_ELEMENT_WS`: Element 的意義為,儲存地圖的單位型態,例如:
- `uint8_t` 為 8 bits 因此 `MAP_ELEMENT_WS` 為 3 ($8=2^3$);
- `uint32_t` 為 32 bits 因此 `MAP_ELEMENT_WS` 為 5 ($32=2^5$)。
- `OBSTACLE_SIZE`: 只用來儲存一個 obstacle (wall) 使用的 bits 數量,例如使用 4 bits 則會有 $2^4=16$ 種 wall 的可能。
接下來的常數是由以上使用者定義的常數衍生出來的,
- `MAP_X`: 地圖大小,為 $2^{MAP\_WS}$
- `MAP_ELEMENT_SIZE`: 儲存地圖的單位變數 bits 數量,為 $2^{MAP\_ELEMENT\_WS}$
- `OBSTACLES_PER_ELEMENT`: 每個單位變數能夠儲存的 obstacle 數量,為 $\frac{MAP\_ELEMENT\_SIZE}{OBSTACLE\_SIZE}$
- `ELEMENTS_PER_ROW`: 儲存每個橫軸 (X軸) 所需要的變數數量,為 $\frac{MAP\_X}{OBSTACLES\_PER\_ELEMENT}$
- `OBSTACLE_MASK`: 單一個 obstacle 的 bit mask ,用來取得單一 obstacle 的 filter,為 $2^{OBSTACLE\_SIZE}-1$
假設
```
MAP_XS := 5 // map is 32 x 32 wide
MAP_ELEMENT_WS := 5 // using uint32_t
OBSTACLE_SIZE := 4 // storing 1 wall with 4 bits
- then we can get:
MAP_X = 32
MAP_ELEMENT_SIZE = 32
OBSTACLES_PER_ELEMENT = 8
ELEMENTS_PER_ROW = 4
OBSTACLE_MASK = 0b1111
```
將 `IsWall()` 中做判斷的程式碼改為如下
```c=18
return (g_map32[(tileX / OBSTACLES_PER_ELEMENT) +
(tileY * ELEMENTS_PER_ROW)] >>
((OBSTACLES_PER_ELEMENT - tileX % OBSTACLES_PER_ELEMENT) *
OBSTACLE_SIZE)) &
OBSTACLE_MASK;
```
首先要取得地圖中紀錄給定座標的變數,因此計算 index:
$$
index=tileY \times ELEMENTS\_PER\_ROW + \frac{tileX}{OBSTACLES\_PER\_ELEMENT}
$$
接下來要從每個變數中取得對應於目前 tileX 中相對應的 bits ,需注意的是,由於 LSB 對應於 tileX+ 方向,因此
```
0 1 2 3 4 5 6 7 // tileX % OBSTACLES_PER_ELEMENT = 4
0b00000001000100010001000000010000 // g_map32
假設 tileX 為 4 因此我們需要右移 3 格 // 7 - 4
0b00000000000000000001000100010001
最後加上 obstacle bits mask: 0b1111
0b00000000000000000000000000000001
即可得到
```
### 不同碰撞情況的表示方式
原本的程式碼利用 `hitDirection` 表示 ray 撞擊的方向,假如是在 vertical 方向則標示為 1 ;反之為 0 。
在擴增地圖版本中,為了同時傳達不同的 obstacles 與 `hitDirection` ,將 `hitDirection` 重新定義:
- `hitDirection` / 2 表示 texture id
- `hitDirection` 如為奇數,則為 vertical hit
如此一來就可以保留原本的實作,並且與舊版相容。
實際的改寫包含:
`raycaster_float.cpp`: line 85,將 hitDirection 指定為 `IsWall()` 的回傳值,其意義為撞到的 obstacle ID,並且由於是 vertical hit ,根據以上規則,將 `hitDirection` * 2 + 1
```c=
if (*hitDirection = IsWall(tileX, interceptY)) {
verticalHit = true;
rayX = tileX + (tileStepX == -1 ? 1 : 0);
rayY = interceptY;
*hitOffset = interceptY;
// Use odd number to indicate different hit direction
*hitDirection = (*hitDirection - 1) * 2 + 1;
break;
}
```
`raycaster_float.cpp`: line 100,將 hitDirection 指定為 `IsWall()` 的回傳值,其意義為撞到的 obstacle ID,並且由於是 horizontal hit ,根據以上規則,將 `hitDirection` * 2
```c=
if (*hitDirection = IsWall(interceptX, tileY)) {
horizontalHit = true;
rayX = interceptX;
*hitOffset = interceptX;
rayY = tileY + (tileStepY == -1 ? 1 : 0);
*hitDirection = (*hitDirection - 1) * 2;
break;
}
```
`renderer.cpp`: line 43,將 **dark wall** 的定義改為判斷奇數:
```c=
if (tn % 2 == 1 && tv > 0) {
// dark wall
tv >>= 1;
}
```
:::success
這邊為了保留原本的 texture 相容性,設定 `textureNo` 為 0 時為 gray scale 模式,因此
:::
### 多種 texture
#### 32 bits color encode to 16 bits color
為了在有限的記憶體空間中表示彩色,我將一個 `uint16_t` 分成幾個 part:
- A (Apparent 透明度): 1 bits
- R (Red): 5 bits
- G (Green): 5 bits
- B (Blue): 5 bits
```c=
#define CHANNEL_BITS 5
#define CHANNEL_MASK (((1 << CHANNEL_BITS) - 1) << (8 - CHANNEL_BITS))
#define R_OFFSET (CHANNEL_BITS * 2 - (8 - CHANNEL_BITS))
#define G_OFFSET (CHANNEL_BITS * 1 - (8 - CHANNEL_BITS))
#define B_OFFSET (8 - CHANNEL_BITS)
```
- `CHANNEL_BITS`: 用來儲存單一顏色的 bits 數量
- `CHANNEL_MASK`: 由於要從原本的 8 bits 顏色轉換為 5 bits ,因此要犧牲一些精度,這邊只取 MSB 的 5 個 bits。
- `R_OFFSET`、`G_OFFSET`、`B_OFFSET`: 分別計算不同顏色 channel 所需的位移量
因此新的 16 bits 顏色就可以這樣取得:
```c=
uint16_t out = ((r & CHANNEL_MASK) << R_OFFSET) +
((g & CHANNEL_MASK) << G_OFFSET) +
((b & CHANNEL_MASK) >> B_OFFSET);
```
```
R: 0b11111000
G: 0b11111000
B: 0b11111000
0b0111111111111111
```
這邊的色彩轉換是用我之前寫的一個 bmp 圖片讀取工具做的
```c=
if (openBMP(&bmp, filename)) {
int width = getWidth(&bmp);
int height = getHeight(&bmp);
unsigned char r, g, b;
for (int y = height - 1; y >= 0; y--) {
for (int x = 0; x < width; x++) {
// Obtain the rgb value of a pixel at (x, y)
getPixelData(&bmp, x, y, &r, &g, &b);
uint16_t out = ((r & CHANNEL_MASK) << R_OFFSET) +
((g & CHANNEL_MASK) << G_OFFSET) +
((b & CHANNEL_MASK) >> B_OFFSET);
printf("%d, ", out);
}
}
// Release bmp memory
freeBmp(&bmp);
} else {
printf("Fail to open bmp file");
return 0;
}
```
#### 16 bits color decode to 32 bits color
我在 `raycaster.h` 中新增以下定義用於計算 decode 時所需的各個 mask 及 offset :
```c=29
// Number of bits to representing a single color channel
#define TEXTURE_BITS_COLOR 5
#define TEXTURE_COLOR_MASK ((1 << TEXTURE_BITS_COLOR) - 1)
#define TEXTURE_R_MASK (TEXTURE_COLOR_MASK << (TEXTURE_BITS_COLOR * 2))
#define TEXTURE_G_MASK (TEXTURE_COLOR_MASK << (TEXTURE_BITS_COLOR * 1))
#define TEXTURE_B_MASK (TEXTURE_COLOR_MASK << (TEXTURE_BITS_COLOR * 0))
#define TEXTURE_R_OFFSET \
(8 * 2 + (8 - TEXTURE_BITS_COLOR) - TEXTURE_BITS_COLOR * 2)
#define TEXTURE_G_OFFSET \
(8 * 1 + (8 - TEXTURE_BITS_COLOR) - TEXTURE_BITS_COLOR * 1)
#define TEXTURE_B_OFFSET \
(8 * 0 + (8 - TEXTURE_BITS_COLOR) - TEXTURE_BITS_COLOR * 0)
```
將 `uint16_t` 的顏色轉換為 `uint32_t` ,`renderer.h`: line 16
```c=16
inline static uint32_t GetARGB_color(uint16_t color)
{
return ((color & TEXTURE_R_MASK) << TEXTURE_R_OFFSET) +
((color & TEXTURE_G_MASK) << TEXTURE_G_OFFSET) +
((color & TEXTURE_B_MASK) << TEXTURE_B_OFFSET);
}
```
#### texture mapping
為了讓原本的 gray scale texture 可以相容,因此區分兩種 texture rendering method:
- Gray Scale: 使用 `GetARGB()` decode
- Colored: 使用 `GetARGB_color()` decode
記錄不同 texture 的 texture list:
`renderer.h`
```c=23
const uint8_t *g_texture_gray[NUMBER_OF_GRAY_TEXTURE] = {g_texture8};
const uint16_t *g_texture_color[2] = {g_texture8_computer, g_texture8_cat};
```
根據 [前述](#%E4%B8%8D%E5%90%8C%E7%A2%B0%E6%92%9E%E6%83%85%E6%B3%81%E7%9A%84%E8%A1%A8%E7%A4%BA%E6%96%B9%E5%BC%8F) `textureNo` 的定義,依據 ray 撞擊的 obstacle 種類 (`tn / 2`) 可以判斷出要以 gray scale 或是 colored 方式選擇 texture 及 render:
`renderer.cpp`:
```c=40
uint16_t tv;
if (tn / 2 < NUMBER_OF_GRAY_TEXTURE) {
// gray scale texture
tv = (g_texture_gray[tn / 2])[(ty << 6) + tx];
*lb = GetARGB(tv);
} else {
// colored texture
tv = (g_texture_color[tn / 2 - 1])[(ty << 6) + tx];
*lb = GetARGB_color(tv);
}
```
原本程式碼的設計是當遇到 vertical hit 的 obstacles 的時候,會將原本的 texture 以亮度變暗的方式做 render ,但如果是 colored 的 textu部則不能直接右移,因為不同的 color channel 會互相影響,因此在平移之前加一個 `0xFEFEFE` 的 mask ,確保 LSB 都是 0 再做平移。
`renderer.cpp`:
```c=51
// View with different direction
if (tn % 2 == 1 && *lb > 0) {
// dark wall
*lb = (*lb & 0xFEFEFE) >> 1;
}
```
#### 效果比較
- 8 bits texture 效果 (Ax2, Rx2, Gx2, Bx2),色彩深度: 64
<table>
<tr><th>原圖 (64x64 bmp)</th><th>8 bits texture</th><tr>
<tㄉˇ
<td><img src="https://i.imgur.com/2QGQoq3.png" style="width: 300px"></td>
<td><img src="https://i.imgur.com/UMSdYGy.png" style="width: 400px"></td></tr>
</table>
- 16 bits texture 效果 (Ax2, Rx2, Gx2, Bx2),色彩深度: 32768
<table>
<tr><th>原圖 (64x64 bmp)</th><th>16 bits texture</th><tr>
<tr>
<td><img src="https://i.imgur.com/2QGQoq3.png" style="width: 300px"></td>
<td><img src="https://i.imgur.com/HzSe3KS.png" style="width: 400px"></td></tr>
</table>
## 遊戲體驗加強
再來我加入一些遊戲的元素,例如持槍、準心等等。
我在 `Renderer` 中新增了一個 function `RenderGame()` 負責所有非 raycasting 的 render.
### 顯示槍枝
- Gun Side View: 在 player 未舉槍之前,槍枝以 side view 方式呈現,並且隨著 player 行動,槍枝會跟著步伐晃動。
- 準心晃動: 因為槍枝晃動所以準心也會跟著變大變小。
- Gun Center View: 舉槍後,視野縮小,準心也縮小。
#### Gun Side view
我將 color 的 MSB 作為圖片透明控制,因此在 render 的時候檢查 color 的 MSB 是 0 才需要 override 該像素的 color buffer.
```c=79
// rendering hand and gun
uint32_t *lb =
fb +
(SCREEN_HEIGHT - TEXTURE_GUN_SIDE_HEIGHT + (uint8_t) offset) *
SCREEN_WIDTH +
(SCREEN_WIDTH - TEXTURE_GUN_SIDE_WIDTH);
for (int j = 0; j < TEXTURE_GUN_SIDE_HEIGHT - (uint8_t) offset; j++) {
for (int i = 0; i < TEXTURE_GUN_SIDE_WIDTH; i++) {
auto tv = g_texture_gunside[j * TEXTURE_GUN_SIDE_WIDTH + i];
if ((tv & 0x8000) == 0)
*lb = GetARGB_color(tv);
lb++;
}
lb += SCREEN_WIDTH - TEXTURE_GUN_SIDE_WIDTH;
}
```
#### Arming point
準心大小可以從 `game.h` 設定
```c=5
#define ARM_POINT_LEN 10
#define ARM_POINT_RAD 10
#define ARM_POINT_COLOR 0x00FFFFFF
```
`renderer.cpp` 把準心畫出來
```c=96
// rendering aiming point
lb = fb + (SCREEN_HEIGHT / 2) * SCREEN_WIDTH + (SCREEN_WIDTH / 2);
for (int l = 0; l < ARM_POINT_LEN; l++) {
uint8_t len = offset + l + ARM_POINT_RAD;
*(lb - len * SCREEN_WIDTH) = ARM_POINT_COLOR;
*(lb + len * SCREEN_WIDTH) = ARM_POINT_COLOR;
*(lb - len) = ARM_POINT_COLOR;
*(lb + len) = ARM_POINT_COLOR;
}
```
![](https://i.imgur.com/1h4PRzn.png)
#### Moving
為了呈現人物在走動時晃動的效果,讓 `Game` 多紀錄一個參數 `moving` ,表示該 time step 時人物的移動距離,然後再用一個週期函數 (這邊用 sin ) 讓 offset 持續震盪,最後,為了讓 offset 在人物沒有移動時逐漸回到原點,將 offset 乘上一個小於 0 的值。
```c=71
static float time = 0, offset = 0;
if (g->moving > 0) {
time += 0.02f;
offset = (10 * (sin(time) + 1));
} else {
offset *= 0.99f;
}
```
#### Gun Center view
增加舉槍動作,當按下 `e` 時,可以切換為舉槍姿勢,
我把 player 的姿態記錄在 `Game` 裡,定義 enum 為:
```c=9
enum PlayerPose {POSE_STAND, POSE_SQUAT};
```
並且在 `Game` 中加入
```c=14
PlayerPose pose;
```
取得鍵盤事件,我將 `main.cpp` 中的 `ProcessEvent()` 增加了一個 `e` 的判斷:
```c=34
static bool ProcessEvent(const SDL_Event &event,
int *moveDirection,
int *rotateDirection, Game *game)
{
...
case SDLK_e:
game->pose = p ? POSE_SQUAT : POSE_STAND;
...
}
```
修改槍枝的 render ,若為蹲下的 pose (`POSE_SQUAT`) ,則畫出另一個槍枝的 texture:
```c=79
// rendering hand and gun
if (g->pose == POSE_SQUAT) {
const int sx = SCREEN_WIDTH / 2 - TEXTURE_GUN_CENTER_WIDTH / 2 + 1;
const int sy = SCREEN_HEIGHT - TEXTURE_GUN_CENTER_HEIGHT;
uint32_t *lb = fb + sy * SCREEN_WIDTH + sx;
for (int j = 0; j < TEXTURE_GUN_CENTER_HEIGHT; j++) {
for (int i = 0; i < TEXTURE_GUN_CENTER_WIDTH; i++) {
auto tv = g_texture_gun_center[j * TEXTURE_GUN_CENTER_WIDTH + i];
if ((tv & 0x8000) == 0)
*lb = GetARGB_color(tv);
lb++;
}
lb += SCREEN_WIDTH - TEXTURE_GUN_SIDE_WIDTH;
}
} else {
uint32_t *lb =
fb +
(SCREEN_HEIGHT - TEXTURE_GUN_SIDE_HEIGHT + (uint8_t) offset) *
SCREEN_WIDTH +
(SCREEN_WIDTH - TEXTURE_GUN_SIDE_WIDTH);
for (int j = 0; j < TEXTURE_GUN_SIDE_HEIGHT - (uint8_t) offset; j++) {
for (int i = 0; i < TEXTURE_GUN_SIDE_WIDTH; i++) {
auto tv = g_texture_gun_side[j * TEXTURE_GUN_SIDE_WIDTH + i];
if ((tv & 0x8000) == 0)
*lb = GetARGB_color(tv);
lb++;
}
lb += SCREEN_WIDTH - TEXTURE_GUN_SIDE_WIDTH;
}
}
```
效果展示:
![](https://i.imgur.com/WMaZHxW.png)
### 碰撞
在更新 player 位置之前,先檢查會不會撞到牆,如果會撞到的話讓 player 往反方向反彈。
`game.cpp`:
```c=13
float try_move_x = 0.5f * m * sin(playerA) * seconds * 5.0f;
float try_move_y = 0.5f * m * cos(playerA) * seconds * 5.0f;
int x = playerX + try_move_x;
int y = playerY + try_move_y;
if (((g_map32[(x / OBSTACLES_PER_ELEMENT) + (y * ELEMENTS_PER_ROW)] >>
((OBSTACLES_PER_ELEMENT - x % OBSTACLES_PER_ELEMENT) *
OBSTACLE_SIZE)) &
OBSTACLE_MASK) == 0) {
playerX += try_move_x;
playerY += try_move_y;
} else {
playerX -= try_move_x;
playerY -= try_move_y;
}
```
## 透明牆壁
### Allow the ray keep going
能夠看到障礙物後面的物體,表示光線能夠穿透障礙物,在這個專題的意思就是,在 raycasting 的時候, ray 不能夠被牆壁阻擋。於是乎我把腦筋動到 `Distance()` 上,如何重複呼叫 `Distance()` 是關鍵的問題。
一開始我多加一個參數叫做 `keepGoing` 來判斷是否是上次的 ray: `raycaster_float.cpp`
```c=26
float RayCasterFloat::Distance(float playerX, // In, Player location X
float playerY, // In, Player location Y
float rayA, // In, Player location Angle
float *hitOffset, // Out,
int *hitDirection, // Out,
bool keepGoint) // In
{
...
}
```
但是這樣我發現必須從 `Trace()` 就傳入 `keepGoing` 的參數,因此連 `Trace()` 都要修改函數原型,導致 `raycaster.h` 中 `Raycaster` 的 virtual function `Trace()` 要連帶修改,最後連毫無關聯的 `raycaster_fixed.cpp` 也要修改,因此這樣不是一個好的辦法。
`raycaster.h`
```c=73
virtual void Trace(uint16_t screenX,
uint8_t *screenY,
uint8_t *textureNo,
uint8_t *textureX,
uint16_t *textureY,
uint16_t *textureStep) = 0;
```
但後來我發現,如果在同一個地方發出 ray ,則 x 座標應該是一樣的,在 `Distance()` 內部判斷就可以避免修改 `Raycaster` 的原型。
`raycaster_float.cpp`
```c=135
void RayCasterFloat::Trace(uint16_t screenX,
uint8_t *screenY,
uint8_t *textureNo,
uint8_t *textureX,
uint16_t *textureY,
uint16_t *textureStep)
{
static uint16_t previousX = UINT16_MAX;
bool keepGoing = false;
if (screenX == previousX)
keepGoing = true;
previousX = screenX;
...
float lineDistance = Distance(_playerX, _playerY, _playerA + deltaAngle,
&hitOffset, &hitDirection, keepGoing);
...
}
```
我用了一個 `static` 的變數去紀錄上一次 X 座標的值,當這次與前次不一樣表示 ray 應該 keepGoing ,並且把這個資訊傳給 `RaycasterFloat` 的 private function `Distance()`
在 `Distance()` 中,我發現若要沿用上一次的結果,大部分參數都不需要重新計算,唯一要注意的是 $(tileX, inceptionY)$ 與 $(inceptionX, tileY)$ 的資料更新,因此,我將大部分參數設定為 `static` 並用 `keepGoing` 決定要不要計算初始值 $(startDeltaX, startDeltaY)$
設定為 `static` 的參數:
```c=44
static float rayX = playerX;
static float rayY = playerY;
static float tileX = 0, tileY = 0, offsetX = 0, offsetY = 0,
startDeltaX = 0, startDeltaY = 0, interceptX = 0,
interceptY = 0;
static int tileStepX = 0, tileStepY = 0;
```
將初始 `startDelta` 、 `inception` 、 `tile` 的計算放進 `keepGoing` 檢查:
```c=50
if (!keepGoing) {
rayX = playerX;
rayY = playerY;
offsetX = modff(rayX, &tileX);
offsetY = modff(rayY, &tileY);
float vecX = 1 - offsetY; // The case that 3pi/2 ~ pi/2
float vecY = 1 - offsetX; // The case that 0 ~ pi
tileStepX = 1; // The case that 0 ~ pi
tileStepY = 1; // The case that 3pi/2 ~ pi/2
// Generate directional unit vector according to player angle
if (rayA > M_PI) {
tileStepX = -1;
vecY = (offsetX == 0) ? 1 : offsetX;
}
if (rayA > M_PI_2 && rayA < 3 * M_PI_2) {
tileStepY = -1;
vecX = (offsetY == 0) ? 1 : offsetY;
}
// Calculate the starting delta
startDeltaX = vecX * tan(rayA) * tileStepY;
startDeltaY = vecY / tan(rayA) * tileStepX;
interceptX = rayX + startDeltaX;
interceptY = rayY + startDeltaY;
}
```
最後我發現 inception 需要一個補償,但我不確定原因為何,若沒有補償會出現下面的現象,穿透牆壁後, texture 的位置錯開了。
![](https://i.imgur.com/dRjBJrw.png)
我在 `Distance()` 的最後加入下面的補償,以 vertical hit 的 case 看得話,我覺得原因應該是在判斷碰撞的 while 迴圈中,可以發現只有 tileX 被往前推進,而在判斷牆壁之後就直接 break 了,因此 inceptionY 會少一次推進,而 horizontal hit 也是如此。
```c=123
if (verticalHit)
interceptY += stepY;
else
interceptX += stepX;
```
vertical hit 的計算:
```c=86
while (((tileStepY == 1 && (interceptY <= tileY + 1)) ||
(tileStepY == -1 && (interceptY >= tileY)))) {
somethingDone = true;
tileX += tileStepX; // 這邊 X 軸被推進
if (*hitDirection = IsWall(tileX, interceptY)) {
verticalHit = true;
rayX = tileX + (tileStepX == -1 ? 1 : 0);
rayY = interceptY;
*hitOffset = interceptY;
// Use odd number to indicate different hit direction
*hitDirection = (*hitDirection - 1) * 2 + 1;
break; // 遇到 break 之後
}
interceptY += stepY; // 這個不會執行到
}
```
### Rendering apparent walls
現在我們已經可以偵測牆壁後面的牆壁了,只要在同一個 X 軸下重複呼叫 `RaycasterFloat` 的 `Trace()` 即可,他會回傳牆壁之後的牆壁的計算結果。
接下來為了簡化程式碼,我在 `renderer.cpp` 中重新設計了一個 general 版本的 `TraceFrame()` Helper,可以簡化多次做 Render 一個 X 座標的程式碼,並且使之後增加功能更易於維護。
我發現對於牆壁後牆壁的 Render 可以設計為重複的工作,只是範圍不太一樣而已。
<center><img src="https://i.imgur.com/SqJr16G.png" style="width: 500px"></center>
`raycaster_float.cpp`
```c=5
uint16_t Renderer::RecursiveTraceFrame(
uint32_t *fb, // In, Frame buffer
int x, // In, screen X
uint16_t up, // In, upper screen position
uint16_t down, // In, lower screen position
uint8_t offset) // In, downscale
{
uint8_t sso; // top point of wall
uint8_t tc; // x axis of texture (256 -> 64)
uint8_t tn; // texture number
uint16_t tso; // y axis of texture
uint16_t tst; // texture step (currentlt not used)
uint32_t *lb = fb + x + up * SCREEN_WIDTH; // frame buffer
_rc->Trace(x, &sso, &tn, &tc, &tso, &tst);
auto tx = static_cast<int>(tc >> 2);
if (sso >= HORIZON_HEIGHT)
sso = HORIZON_HEIGHT;
// render top sky
for (int y = up; y < HORIZON_HEIGHT - sso; y++) {
if (offset > 0)
*lb = DOWN_SCALE(*lb) +
DOWN_SCALE(GetARGB(96 + (HORIZON_HEIGHT - y)));
else
*lb = GetARGB(96 + (HORIZON_HEIGHT - y));
lb += SCREEN_WIDTH;
}
// render obstacle
for (int y = 0; y < sso * 2; y++) {
// paint texture pixel
uint16_t ty =
(tso + (((TEXTURE_SIZE / 2) << 10) - tso) * y / sso) >> 10;
uint16_t tv;
uint32_t color;
if (tn / 2 < NUMBER_OF_GRAY_TEXTURE) {
// gray scale texture
tv = (g_texture_gray[tn / 2])[(ty << 6) + tx];
color = GetARGB(tv);
} else {
// colored texture
tv = (g_texture_color[tn / 2 - 1])[(ty << 6) + tx];
color = GetARGB_color(tv);
}
if (offset > 0)
*lb = DOWN_SCALE(*lb) + DOWN_SCALE(color);
else
*lb = color;
lb += SCREEN_WIDTH;
}
// render bottom sky
for (int y = HORIZON_HEIGHT + sso; y < down; y++) {
if (offset > 0)
*lb =
DOWN_SCALE(*lb) + DOWN_SCALE(GetARGB(96 + y - HORIZON_HEIGHT));
else
*lb = GetARGB(96 + y - HORIZON_HEIGHT);
lb += SCREEN_WIDTH;
}
return sso;
}
```
透明的方法就是保留原本一半的顏色,混入被遮蓋物體一半的顏色:
$$
\left\{
\begin{array}{lr}
B_{k+1} = \frac{1}{2}B_k + \frac{1}{2}C_k \\
B_0 = 0
\end{array}
\right.
$$
- $B_k$ 是第 k 次的 frame buffer
- $C_k$ 是第 k 次的 trace 的顏色
為了簡化顏色 down scale 的計算,用這個 macro 將顏色變為原本的一半。
`renderer.h`
```c=7
#define DOWN_SCALE(color) (((color) & 0xFEFEFE) >> 1)
```
剩下要注意的是:
- first trace() 的時候,`frame buffer` 會殘留有過去的影像,因此直接覆值。
- second trace() (或是還有更多次的話),要更新為過去的一半 加上 現在的一半。
這樣一來,原本的 `TraceFrame()` 就可以寫得相當簡單:
```c=73
void Renderer::TraceFrame(Game *g, uint32_t *fb)
{
_rc->Start(static_cast<uint16_t>(g->playerX * 256.0f),
static_cast<uint16_t>(g->playerY * 256.0f),
static_cast<int16_t>(g->playerA / (2.0f * M_PI) * 1024.0f));
for (int x = 0; x < SCREEN_WIDTH; x++) {
uint16_t sso = RecursiveTraceFrame(fb, x, 0, SCREEN_HEIGHT, 0);
sso = RecursiveTraceFrame(fb, x, HORIZON_HEIGHT - sso,
HORIZON_HEIGHT + sso, 1);
}
}
```
由於視角始終都在地平線上的關係,可以保證下一次 trace 後的 sso 絕對比前一次的值要小 (距離遠的物體看起來比較矮)
||first trace|second trace|
|:-:|:-:|:-:|
|up|0|HORIZON_HEIGHT - sso|
|down|SCREEN_HEIGHT|HORIZON_HEIGHT + sso|
### God mode
最後我新增一個 god mode 的模式,當按下 `g` 鍵後就會開啟透視外掛
`renderer.cpp`
```c=81
if (g->godMode > 0)
sso = RecursiveTraceFrame(fb, x, HORIZON_HEIGHT - sso,
HORIZON_HEIGHT + sso, 1);
```
`game.h`
```c=11
class Game
{
public:
...
int godMode;
...
};
```
效果:
<center><img src="https://i.imgur.com/PexcrJA.png" style="width:500px"></center>
#### 破壞場景
## 增強遊戲體驗
#### 關於 Z 軸的可能性
好像,沒有這個可能
- ray 會穿透牆壁背後,也就是 `traceFrame()` 裡面的 `sso` 範圍會有好幾個,也很難判斷什麼時候要停。
- 頂部的 texture 要包含 X 方向 offset
- 從最近開始 (螢幕下方) 直到撞到邊屆為止