sysprog
contributed by < sciyen
>
repository on github
e
舉槍g
開啟 God Mode 使出透視眼float modff( float x, float * intptr );
in math.h
(Microsoft docs)從 IsWall()
中可以推敲出與地圖 g_map
有關的資訊:
g_map
定義在 raycaster_data.h
中,為 uint8_t
1d array with 32*4 elements.uint8_t
element 為一個 X axis ,共分成 32 rows, that is, Y axis.如果按照二維方向排列應該長這樣:
橫向 32 bit,縱向 32 rows.
raycaster_float.cpp
line: 15
tileX
小於 0
或超過 MAP_X-1
時,即算是出界,因此 X 軸邊界最大值為 MAP_X
,其值為 32 , Y axis 同理。raycaster_float.cpp
line: 18
tileX
, tileY
) 是否為障礙物,由於 g_map
中,每個 element 為 uint8_t
,因此每個 element 可以代表 8 個 pixel , ,因此 tileX
需除 8 ,也就是 >> 3
。g_map
是 1d array ,因此要計算 Y 方向應除掉 X 方向的長度, X 方向共包含 32 個 bit () ,但是一個 element 可以代表 8 個 bit ,因此,應除 或 >> 4
。MAP_XS
的意義為地圖大小的 2 指數,即 2^5=32
,其中 32 為 MAP_X
,MAP_XS
為指數部分。0x7
。
這邊的 8-(tileX & 0x7)
應該錯了,考慮 tileX
最大即最小情況:
tileX
=0 時,8-0=8
:1<<8,會超過 uint8_t 的範圍tileX
=7 時,8-7=1
:1<<1=2,發現永遠沒有 1 的情況因此應該將 8 修正為 7。
修正後就會發現消失的牆壁回來了!
若地圖改以其他變數型態表示時,可以這樣設定:假設用 uint32_t
由於 32 為 因此只要設定 MAP_ELEMENT_SIZE
為 5 就可以不用修改 IsWall()
內部的程式碼。
在程式碼中, texture 以 uint8_t
的陣列儲存,而大小是 64x64
的方塊,因此 。
線索:
raycaster_data.h
中, g_texture8
的定義:
renderer.cpp
中,對每個 texture 元素的存取:
與一般數學上熟悉的角度定義不同,在遊戲中定義視角方向為 Y 方向,向視野右邊角度為正,向視野左邊角度為負。
一般來說,求 X 方向應該使用 ,Y 方向使用 但在這邊會相反,可以在 game.cpp
中找到線索:
game.cpp
line: 8
此外,在 Distance()
中也有類似的實作,將 tileStep
定義為如下的 vector:
可以發現這邊重新定義的 unit vector 是以 與 Y 軸夾角的情況下,投影在原本 X-Y 軸座標系下的單位向量。
game.cpp
line: 42
SCREEN_WIDTH
次 Distance()
(只要在畫面水平中線掃一次)renderer.cpp
)GetARGB
) ,實際上,由於畫面上下對稱,因此只計算畫面水平中線,再往上下方向用 Trace()
計算畫面中的障礙物高度。
raycaster_float.cpp
, raycaster_fixed.cpp
)對整個畫面 render ,沿著 X 方向執行 trace()
:
障礙物在畫面中分成兩種情況:
因此要畫出畫面中特定 X 座標的障礙物我們需要以下資訊:
計算給定的螢幕 X 的位置,撞到的 Wall 在目前視角下的畫面 ( Y 座標、材質等資訊)
其中,特定 screenX
相對於畫面中心的角度差 可以表示為:
如果要讓視角的定義更直覺,應該設定 FOV 視角:
raycaster_float.cpp
如此一來
重新定義的 FOV 如下圖:
45 度:
90 度:
120 度:
利用相似三角形,可以做以下推論
為配合前面 FOV 的定義,可以做如下改寫:
將原本程式碼中的 INV_FACTOR
重新定義為
由於這個地圖中的障礙物都是 1x1 的方塊,要把 texture 貼到方塊上只要知道方塊上任意點的 x-y 座標,就可以計算出相應的 texture 。
求出 Wall 在視野內的高度 (screenY
) 後,就得到視野內的方塊 x-y 資訊,這邊使用定點數表示 x-y 座標:
Tecture X 方向:
在這個遊戲中因為視野不會傾斜,因此 y 方向皆為垂直線條, texture 的 X 座標隨螢幕的 X 座標成比例關係:
raycaster_float.cpp
Note: textureSize
為 , 2 bits fixed point,因此
renderer.cpp
先把界於 0~1
的 hitOffset
對應到 uint8_t
能夠表示的最大範圍後 (textureX
) , 在 renderer.cpp
再轉換到 texture 相對應的 size 。因為 texture size 為 ,uint8_t
size 為 ,因此需要右移 ,相當於使用 2 bits 定點數。
這邊 X 方向使用 2 bits 定點數即可,是因為視野不會傾斜,不會對成像造成明顯影響。
Texture Y 方向,使用 10 bits fixed point:
目標為找出畫面上特定 Y 座標點對應的 texture 座標,因此可以看作兩個座標空間的轉換,這邊記作 :
將 texture 看作一線性變換:
: texture 的起始座標。
: texture 的圖片大小。
: 視野中牆壁的高度,也就是 。若牆高超過視野高度,則
: 投影在成像平面上的牆壁高度。
這邊有兩種狀況:
可以發現,出現異常的地方總是在牆壁高度過高的時候(相較於左側視野範圍內,較低的牆壁高度總是沒有異常)
在原本程式碼中,並未判斷 screenY
是否超過 screenHeight
也未對超過的情況做處理。此外,在 的計算中,原本的程式碼是以 uint8_t
的整數去接收 INV_FACTOR / distance
因此若牆壁高度的計算結果超過 255 ,便會導致 overflow。
raycaster_float.cpp
原程式碼:
此外,由於原本的做法是用 textureStep(ts)
做累加,在 Y 方向累加的同時,浮點誤差也會跟著變大導致畫面下半部型成鋸齒狀:
renderer.cpp
原程式碼:
利用前述的方式計算 textureY
,先在 raycaster_float.cpp
中判斷 ,若成像的高度大於畫面範圍 ,則再計算對應的
raycaster_float.cpp
改為:
renderer.cpp
改為:
由於程式碼中使用 10 bits 定點數,因此 textureY
在運算開始前先乘 (1 << 10)
,並且在計算 最後再右移 10。
當距離太近的時候,會產生如下的魚眼效果:
未校正前的影像是投影在「成像平面」上(見下圖),產生 點,可以發現,相較於投影在「投影球面」上的 點
要修正魚眼效果要修正成像距離,使其成像在「投影球面」上,因此:
在程式碼中, 指的是 物體 至 玩家 的距離,而在這邊修正魚眼指的是 成像平面投影點 至 觀測點 的距離,但是由於 的 計算 是線性的,因此先乘和後乘 是等效的; 以此類推。
由魚眼校正的計算可以發現,每個 、 對於 皆是 1 對 1 的對應關係,因此可以建立對應的 table 做查表,以節省計算。
Find the distance to obstacles from current player's position and facing direction (ray).
為了判斷從 player 射出的 ray 最先碰撞到的障礙物,這邊先計算 X、Y 方向的 startDelta
,其意義為分別從 X 及 Y 方向出發,最先遇到的網格 (grid) 距離,接下來因為 grid 皆為單位大小,因此只要計算經過幾個 grid 就好。
整理在各個象限時的情況:
這邊發現一個很有趣的現象,如果將 前的正負號提出來,恰好為 X、Y 象限的單位向量,因此可以將原本的計算改為
修正項與 X、Y 象限單位向量的關係為 ,這邊 X、Y 恰好相反。
而 則與象限有關,可以整理成下表
&& | ||
---|---|---|
如此一來可以大幅簡化原本 startDelta
的計算,也減少許多 if-else 的判斷次數
修改過的 raycaster_float.cpp
:
再來測量修改後的加速情形,以下資料取樣自進入遊戲後不動的情況下, 14.8 秒間共 74 筆數據 (每 0.2 秒一筆)的平均結果,時間計算方法請參考 Performance Estimation 實驗 詳細資料
實驗 | 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 平均來說比原本方法快一點,但是沒有很明顯。
接下來要找與障礙物的碰撞點,這邊用 舉例:
以 為起點
發射出的向量會以相同的方向,但以不同的大小前進,因此分成以下兩種可能:
因此,所有的碰撞點可以表示成如下:
如此一來只要重複比對是哪一個碰撞點最先撞到即可。
藉由重複以上兩個判斷,除非撞到牆壁,否則交替會一直延續。在這個地圖中一定會撞到某個點,因為在 IsWall()
中有設定邊界,撞到範圍外一定會停止。
為了防止形成無窮迴圈,這邊用一個變數 somethingDone
判斷射線是否仍在前進,否則就回傳 0 跳出迴圈。
在 fixed point 實作中,是以 8 bits 表示一個 ,可以在 precalculator.cpp
中找到線索:
precalculator.cpp
line: 45
也就是說,新變數 da
因此,若要判斷 angle
所在象限 (有多少個 ),只要取 angle >> 8
即可;而 angle % 256
則為佔 的比例。例如:
raycaster_fixed.cpp
line:101
注意: 和一般數學慣例不一樣的是,這邊的 quarter
是從第 0 象限開始。
數學中 與 存在極端值 (當 時為無窮大),也就是會出現 uint16_t
無法表示的範圍。
的極值出現在最靠近 的地方,其中 為整數。
由於電腦數值系統中,僅能以有限數量的位元儲存數字,因此對於像是 這種無理數,僅能近似其數值,而近似結果與實際數字的差距就定義為誤差。
由於 的微分為 ,其在 的範圍內為嚴格遞增函數,可以得到 ,並且, 越接近 , 越大。
tan
有最大值 。tan
有最大值 。可以發現,使用 10 bits fixed point 的約比 8 bits fixed point 少 的誤差。
MulU()
這是一個將 8 bits 整數 v
乘以 8 bits 無號定點數 f
的運算,回傳的是四捨五入過後的 16 bits 無號整數。
Example:
若 lm
小於 256 表示 v * f
的小數部分不超過 1 無法進位,因此會在 lm >> 8
的時候捨去。
MulS()
這是一個將 8 bits 整數 v
乘以 8 bits 有號定點數 f
的運算,回傳的是四捨五入過後的 16 bits 有號整數。
這邊如果 f 小於 0 ,最後會做一個 not 運算變成 1 補數
。
Q: 為什麼不是用 2 補數
?
AbsTan()
利用 的對稱關係,可以用映射的方式得到不同象限的函數值。
如此一來,可以做個歸納: (這邊的象限定義使用前述的 數字系統,以第 0 象限作為開始)
但是,AbsTan()
只需要做垂直映射,因此:
這邊垂直映射是利用
也就是說,利用 的關係,找出對稱的 index
MulTan()
這個 function 用查表的方式快速計算 或是
由 與 的關係:
可以發現 和 的關係其實是將 取垂直映射後,右(左)移 :
而實際上,可以藉由對 映射得到。
如此一來,可以做個歸納: (這邊的象限 quarter
定義使用前述的 數字系統,以第 0 象限作為開始)
也就是
CalculateDistance()
這邊的 CalculateDistance()
與 raycaster_float.cpp
中的 Distance()
很像,不一樣的是
CalculateDistance()
中的 tan()
計算改以查表的方式進行。angle
等於 0 的特例做了最佳化。goto
的技巧簡化在 angle == 0
與 angle != 0
的情況中,計算 texture 的重複性程式碼。利用 SDL2.0 的 API ,可以計算 High resolution 的 performance.
SDL_GetPerformanceCounter()
SDL_GetPerformanceFrequency()
為了分別計算 fixed point performance、float point performance、FPS 這三種指標,利用 counter 為其計時:
main.cpp
:
由於計算速度很快,容易產生許多高頻雜訊,所以引入 Exponential Filter
where
smoothing constant
, normally between 0.8 ~ 0.99這邊 我們用估計的 frame update rate ,大約是 0.00357 (s), smoothing factor
選擇 0.95
is the resulting filter time constant
大約為 0.16 (s) ,表示假設為 step input ,大概耗費 0.16 秒,資料會成長
main.cpp
:
g_map32
原本的地圖一個 position 只有兩種情況,因此只需要一個 bit 即可表示:
1
表示0
表示若要儲存不同的 wall ,表示一個 position 有多種可能,需要在 g_map
中拓展一個 position 的儲存空間,由於原本的地圖是以 uint8_t
若將型態改為 uint32_t
即可以將一個 position 以 4 bits 表示,共可以表示 16 種牆壁,而不改變原本地圖 X-Y 座標的定義。
以下為將 8 bits 地圖轉換為 32 bits 地圖的轉換工具:
該程式在每個 g_map 後面插入三個 0:
IsWall()
接下來,關係會變得稍微複雜,首先定義以下常數
MAP_XS
: 地圖大小的指數 (假設地圖寬度為 32 ,則 MAP_XS
為 5 ,因為 )MAP_ELEMENT_WS
: Element 的意義為,儲存地圖的單位型態,例如:
uint8_t
為 8 bits 因此 MAP_ELEMENT_WS
為 3 ();uint32_t
為 32 bits 因此 MAP_ELEMENT_WS
為 5 ()。OBSTACLE_SIZE
: 只用來儲存一個 obstacle (wall) 使用的 bits 數量,例如使用 4 bits 則會有 種 wall 的可能。接下來的常數是由以上使用者定義的常數衍生出來的,
MAP_X
: 地圖大小,為 MAP_ELEMENT_SIZE
: 儲存地圖的單位變數 bits 數量,為 OBSTACLES_PER_ELEMENT
: 每個單位變數能夠儲存的 obstacle 數量,為 ELEMENTS_PER_ROW
: 儲存每個橫軸 (X軸) 所需要的變數數量,為 OBSTACLE_MASK
: 單一個 obstacle 的 bit mask ,用來取得單一 obstacle 的 filter,為 假設
將 IsWall()
中做判斷的程式碼改為如下
首先要取得地圖中紀錄給定座標的變數,因此計算 index:
接下來要從每個變數中取得對應於目前 tileX 中相對應的 bits ,需注意的是,由於 LSB 對應於 tileX+ 方向,因此
原本的程式碼利用 hitDirection
表示 ray 撞擊的方向,假如是在 vertical 方向則標示為 1 ;反之為 0 。
在擴增地圖版本中,為了同時傳達不同的 obstacles 與 hitDirection
,將 hitDirection
重新定義:
hitDirection
/ 2 表示 texture idhitDirection
如為奇數,則為 vertical hit如此一來就可以保留原本的實作,並且與舊版相容。
實際的改寫包含:
raycaster_float.cpp
: line 85,將 hitDirection 指定為 IsWall()
的回傳值,其意義為撞到的 obstacle ID,並且由於是 vertical hit ,根據以上規則,將 hitDirection
* 2 + 1
raycaster_float.cpp
: line 100,將 hitDirection 指定為 IsWall()
的回傳值,其意義為撞到的 obstacle ID,並且由於是 horizontal hit ,根據以上規則,將 hitDirection
* 2
renderer.cpp
: line 43,將 dark wall 的定義改為判斷奇數:
這邊為了保留原本的 texture 相容性,設定 textureNo
為 0 時為 gray scale 模式,因此
為了在有限的記憶體空間中表示彩色,我將一個 uint16_t
分成幾個 part:
CHANNEL_BITS
: 用來儲存單一顏色的 bits 數量CHANNEL_MASK
: 由於要從原本的 8 bits 顏色轉換為 5 bits ,因此要犧牲一些精度,這邊只取 MSB 的 5 個 bits。R_OFFSET
、G_OFFSET
、B_OFFSET
: 分別計算不同顏色 channel 所需的位移量因此新的 16 bits 顏色就可以這樣取得:
這邊的色彩轉換是用我之前寫的一個 bmp 圖片讀取工具做的
我在 raycaster.h
中新增以下定義用於計算 decode 時所需的各個 mask 及 offset :
將 uint16_t
的顏色轉換為 uint32_t
,renderer.h
: line 16
為了讓原本的 gray scale texture 可以相容,因此區分兩種 texture rendering method:
GetARGB()
decodeGetARGB_color()
decode記錄不同 texture 的 texture list:
renderer.h
根據 前述 textureNo
的定義,依據 ray 撞擊的 obstacle 種類 (tn / 2
) 可以判斷出要以 gray scale 或是 colored 方式選擇 texture 及 render:
renderer.cpp
:
原本程式碼的設計是當遇到 vertical hit 的 obstacles 的時候,會將原本的 texture 以亮度變暗的方式做 render ,但如果是 colored 的 textu部則不能直接右移,因為不同的 color channel 會互相影響,因此在平移之前加一個 0xFEFEFE
的 mask ,確保 LSB 都是 0 再做平移。
renderer.cpp
:
原圖 (64x64 bmp) | 8 bits texture |
---|---|
原圖 (64x64 bmp) | 16 bits texture |
---|---|
再來我加入一些遊戲的元素,例如持槍、準心等等。
我在 Renderer
中新增了一個 function RenderGame()
負責所有非 raycasting 的 render.
我將 color 的 MSB 作為圖片透明控制,因此在 render 的時候檢查 color 的 MSB 是 0 才需要 override 該像素的 color buffer.
準心大小可以從 game.h
設定
renderer.cpp
把準心畫出來
為了呈現人物在走動時晃動的效果,讓 Game
多紀錄一個參數 moving
,表示該 time step 時人物的移動距離,然後再用一個週期函數 (這邊用 sin ) 讓 offset 持續震盪,最後,為了讓 offset 在人物沒有移動時逐漸回到原點,將 offset 乘上一個小於 0 的值。
增加舉槍動作,當按下 e
時,可以切換為舉槍姿勢,
我把 player 的姿態記錄在 Game
裡,定義 enum 為:
並且在 Game
中加入
取得鍵盤事件,我將 main.cpp
中的 ProcessEvent()
增加了一個 e
的判斷:
修改槍枝的 render ,若為蹲下的 pose (POSE_SQUAT
) ,則畫出另一個槍枝的 texture:
效果展示:
在更新 player 位置之前,先檢查會不會撞到牆,如果會撞到的話讓 player 往反方向反彈。
game.cpp
:
能夠看到障礙物後面的物體,表示光線能夠穿透障礙物,在這個專題的意思就是,在 raycasting 的時候, ray 不能夠被牆壁阻擋。於是乎我把腦筋動到 Distance()
上,如何重複呼叫 Distance()
是關鍵的問題。
一開始我多加一個參數叫做 keepGoing
來判斷是否是上次的 ray: raycaster_float.cpp
但是這樣我發現必須從 Trace()
就傳入 keepGoing
的參數,因此連 Trace()
都要修改函數原型,導致 raycaster.h
中 Raycaster
的 virtual function Trace()
要連帶修改,最後連毫無關聯的 raycaster_fixed.cpp
也要修改,因此這樣不是一個好的辦法。
raycaster.h
但後來我發現,如果在同一個地方發出 ray ,則 x 座標應該是一樣的,在 Distance()
內部判斷就可以避免修改 Raycaster
的原型。
raycaster_float.cpp
我用了一個 static
的變數去紀錄上一次 X 座標的值,當這次與前次不一樣表示 ray 應該 keepGoing ,並且把這個資訊傳給 RaycasterFloat
的 private function Distance()
在 Distance()
中,我發現若要沿用上一次的結果,大部分參數都不需要重新計算,唯一要注意的是 與 的資料更新,因此,我將大部分參數設定為 static
並用 keepGoing
決定要不要計算初始值
設定為 static
的參數:
將初始 startDelta
、 inception
、 tile
的計算放進 keepGoing
檢查:
最後我發現 inception 需要一個補償,但我不確定原因為何,若沒有補償會出現下面的現象,穿透牆壁後, texture 的位置錯開了。
我在 Distance()
的最後加入下面的補償,以 vertical hit 的 case 看得話,我覺得原因應該是在判斷碰撞的 while 迴圈中,可以發現只有 tileX 被往前推進,而在判斷牆壁之後就直接 break 了,因此 inceptionY 會少一次推進,而 horizontal hit 也是如此。
vertical hit 的計算:
現在我們已經可以偵測牆壁後面的牆壁了,只要在同一個 X 軸下重複呼叫 RaycasterFloat
的 Trace()
即可,他會回傳牆壁之後的牆壁的計算結果。
接下來為了簡化程式碼,我在 renderer.cpp
中重新設計了一個 general 版本的 TraceFrame()
Helper,可以簡化多次做 Render 一個 X 座標的程式碼,並且使之後增加功能更易於維護。
我發現對於牆壁後牆壁的 Render 可以設計為重複的工作,只是範圍不太一樣而已。
raycaster_float.cpp
透明的方法就是保留原本一半的顏色,混入被遮蓋物體一半的顏色:
為了簡化顏色 down scale 的計算,用這個 macro 將顏色變為原本的一半。
renderer.h
剩下要注意的是:
frame buffer
會殘留有過去的影像,因此直接覆值。這樣一來,原本的 TraceFrame()
就可以寫得相當簡單:
由於視角始終都在地平線上的關係,可以保證下一次 trace 後的 sso 絕對比前一次的值要小 (距離遠的物體看起來比較矮)
first trace | second trace | |
---|---|---|
up | 0 | HORIZON_HEIGHT - sso |
down | SCREEN_HEIGHT | HORIZON_HEIGHT + sso |
最後我新增一個 god mode 的模式,當按下 g
鍵後就會開啟透視外掛
renderer.cpp
game.h
效果:
好像,沒有這個可能
traceFrame()
裡面的 sso
範圍會有好幾個,也很難判斷什麼時候要停。