contributed by < sammer1107
>
進階電腦系統理論與實作
這個 class 負責根據一個 Game 物件的狀態來算繪遊戲場景。Renderer 內固定有一個 RayCaster,讓 Renderer 可以使用他來計算場景。
inline static uint32_t GetARGB(uint8_t brightness)
{
return (brightness << 16) + (brightness << 8) + brightness;
}
這個方法從一個亮度值來創造一個 ARGB 像素,ARGB 的值單純就只是複製亮度 brightness
而已。而 A,R,G 和 B 各用 8 bit 來表示,一起放在一個 32 bit 整數內,所以可以用 shift operator <<
來設定。
void TraceFrame(Game *g, uint32_t *frameBuffer);
這個方法根據傳遞的 Game 物件,會利用 RayCaster 計算場景,然後畫到 frameBuffer 上。
這裡 frameBuffer 是一個以一維型式儲存的二維陣列,大小為 SCREEN_WIDTH * SCREEN_HEIGHT
。儲存方式為 row major。
_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));
首先開始算繪場景前,我們先將 RayCaster 初始化在玩家目前位置。這裡將 x,y 座標乘以 256 是為了轉成 fixed point 格式,如果 RayCaster 實作為浮點數版本,則會在 Start 內將格式還原為 floating point。
再來我們將掃過所有的 x ,在每個 x 把整條垂直線畫出來。再這之前,我們先看 Trace 方法回傳的變數們,這些變數提供我們畫出畫面中某條垂直線所需要的資訊:
for (int x = 0; x < SCREEN_WIDTH; x++) {
uint8_t sso; // half of the height of the wall to draw in this vertical stripe
uint8_t tc; // texture x coordinate, 2 bit fix point
uint8_t tn; // texture number of hitted wall
uint16_t tso; // texture y coord to start from, 10 bit fixed point
uint16_t tst; // how many step in texture for each screen pixel, 10 bit fixed point
uint32_t *lb = fb + x;
_rc->Trace(x, &sso, &tn, &tc, &tso, &tst);
...
2*sso
。牆的高度是可能會比畫面還高的,例如圖的右邊的部份。故我們有這一段 code:
int16_t ws = HORIZON_HEIGHT - sso;
if (ws < 0) {
ws = 0;
sso = HORIZON_HEIGHT;
}
牆面的 texture 定義在 raycaster_data.h 中的 g_texture8,為
tso=0
。否則在牆不能容入畫面時,如圖中右邊, tso 必須從中間某個值開始。有了這些資訊,我們就可以畫出一條垂直線了。
// paint background gradient (upper)
for (int y = 0; y < ws; y++) {
*lb = GetARGB(96 + (HORIZON_HEIGHT - y));
lb += SCREEN_WIDTH;
}
for (int y = 0; y < sso * 2; y++) {
// paint texture pixel
auto ty = static_cast<int>(to >> 10);
auto tv = g_texture8[(ty << 6) + tx];
to += ts;
if (tn == 1 && tv > 0) {
// dark wall
tv >>= 1;
}
*lb = GetARGB(tv);
lb += SCREEN_WIDTH;
}
// render background gradient (lower)
for (int y = 0; y < ws; y++) {
*lb = GetARGB(96 + (HORIZON_HEIGHT - (ws - y)));
lb += SCREEN_WIDTH;
}
>> 10
的動作。ty*64 + tx
並且用 <<
取代乘法。在每個 x 上畫完垂直線後,就完成整個畫面的繪製了。
這個函數接收 rayX 與 rayY ,然後到地圖中查找這個位置是不是牆壁。
bool RayCasterFloat::IsWall(float rayX, float rayY, float rayA)
{
float mapX = 0;
float mapY = 0;
float offsetX = modff(rayX, &mapX);
float offsetY = modff(rayY, &mapY);
int tileX = static_cast<int>(mapX);
int tileY = static_cast<int>(mapY);
if (tileX < 0 || tileY < 0 || tileX >= MAP_X - 1 || tileY >= MAP_Y - 1) {
return true;
}
return g_map[(tileX >> 3) + (tileY << (MAP_XS - 3))] &
(1 << (8 - (tileX & 0x7)));
}
首先我們要先了解地圖與座標系統的運作,觀察此函式與 g_map 定義之後,我得出以下推論:
MAP_X
與 MAP_Y
的定義,這個地圖為 tileX & 0x7
為 0,我們會作 g_map[...] & (1 << 8)
,如此並不會抓到我們想要的 MSB。故 8 應改成 7。tileX >= MAP_X - 1
與 fixed point 實作不一致。若 tileX 為 31 ,我們仍應該到地圖查找而不是直接回傳,所以這裡應該改成 tile > MAP_X - 1
。
bool RayCasterFloat::IsWall(float rayX, float rayY, float rayA)
{
int tileX = static_cast<int>(rayX);
int tileY = static_cast<int>(rayY);
if (tileX < 0 || tileY < 0 || tileX > MAP_X - 1 || tileY > MAP_Y - 1) {
return true;
}
return g_map[(tileX >> 3) + (tileY << (MAP_XS - 3))] &
(1 << (7 - (tileX & 0x7)));
}
這個方法根據 playerX,playerY 與 rayA 來發射出光線,並回傳碰撞方向與位置,回傳資訊有兩種可能:
Distance | 碰撞方向(hitDirection) | hitoffset |
---|---|---|
碰撞面垂直 x 軸 (1) | 碰撞點 y 值 | |
碰撞面垂直 y 軸 (0) | 碰撞點 x 值 |
Distance | 特殊情況 |
---|---|
因為浮點數計算誤差 導致撞不到方塊 |
地圖座標系統
在進入 Distance 的細節前,首先我們要了解 x, y, angle 的座標系統。 要注意的是這裡的角度從 y 軸出發,順時針繞一圈。
程式一開始先做了一些前置準備:
int tileStepX = 1;
int tileStepY = 1;
float tileX = 0;
float tileY = 0;
if (rayA > M_PI) {
tileStepX = -1;
}
if (rayA > M_PI_2 && rayA < 3 * M_PI_2) {
tileStepY = -1;
}
float rayX = playerX;
float rayY = playerY;
float offsetX = modff(rayX, &tileX);
float offsetY = modff(rayY, &tileY);
再來則計算此光線發射出去會最先碰到的垂直線(平行 y 軸)與水平線(平行 x 軸)。到達水平面要走的 x 距離為 startDeltaX,到達垂直面要走的 y 則為startDeltaY。
再來我們可以看到每個紫色點之間的距離都是固定的,粉色也一樣。我們把紫色點之間的 x 差叫作 stepX,粉色之間的 y 差叫作 StepY。如下圖:
這部份對應的程式碼如下。interceptX 對應到紫色點的 x 座標,interceptY 對應到粉色點的 y。
float startDeltaX, startDeltaY;
if (rayA <= M_PI_2) {
startDeltaX = (1 - offsetY) * tan(rayA);
startDeltaY = (1 - offsetX) / tan(rayA);
} else if (rayA <= M_PI) {
//...
} else if (rayA < 3 * M_PI_2) {
//...
} else {
//...
}
float interceptX = rayX + startDeltaX;
float interceptY = rayY + startDeltaY;
float stepX = fabs(tan(rayA)) * tileStepX;
float stepY = fabs(1 / tan(rayA)) * tileStepY;
到這裡已經做好了讓光線開始前進的準備。這部份的 code 長這樣。
do {
somethingDone = false;
while (((tileStepY == 1 && (interceptY <= tileY + 1)) ||
(tileStepY == -1 && (interceptY >= tileY)))) {
//...
}
while (!verticalHit && ((tileStepX == 1 && (interceptX <= tileX + 1)) ||
(tileStepX == -1 && (interceptX >= tileX)))) {
//...
}
} while ((!horizontalHit && !verticalHit) && somethingDone);
somethingDone == true
)的話,就繼續做。至於 somethingDone
存在的意義其實是為了處理浮點數運算的誤差,到後文會有更詳細的解說。while (((tileStepY == 1 && (interceptY <= tileY + 1)) ||
(tileStepY == -1 && (interceptY >= tileY)))) {
somethingDone = true;
tileX += tileStepX;
if (IsWall(tileX, interceptY, rayA)) {
verticalHit = true;
rayX = tileX + (tileStepX == -1 ? 1 : 0);
rayY = interceptY;
*hitOffset = interceptY;
*hitDirection = true;
break;
}
interceptY += stepY;
}
verticalHit = true
,準備離開整個迴圈,我們已經找到牆壁了。float deltaX = rayX - playerX;
float deltaY = rayY - playerY;
return sqrt(deltaX * deltaX + deltaY * deltaY);
如此就完成 Distance 了。
如果我們從任何一方格出發,則我們不是先遇到垂直線,就是先遇到水平線,不可能兩個都遇不到。故從邏輯上, 不可能會出現 somethingDone=false
跳出迴圈的情況。但在下面兩種情況,真的有可能會發生這樣的情況。
我一開始觀察到如果將人物生成在 x,y 皆為整數的地方(0~31 都可以被浮點數 exact 表示),則會出現
回到上面 startDelta 的計算:
if (offsetX == 0) {
startDeltaY = (1) / fabs(tan(rayA));
} else {
startDeltaY = (offsetX) / fabs(tan(rayA));
}
我們對 offset=0 也就是人物在整數上面都有做特別的處理,我們會忽略現在身在的邊界,而去尋找下一個邊界當作第一個碰撞點。但這會造成問題。以下圖為例:
現在 interceptX < tileX ,且 interceptY < tileY,就造成了 somethingDone = false
。
解決辦法就是不要偷吃步,從當前邊界做起:
如此 interceptY > tileY,就會進去第一個迴圈而不會 somethingDone = false
了。這在程式碼中只要把原本對 offset == 0 的額外處理拿掉就可以了:
if (rayA <= M_PI_2) {
startDeltaX = (1 - offsetY) * tan(rayA);
startDeltaY = (1 - offsetX) / tan(rayA);
} else if (rayA <= M_PI) {
startDeltaX = (offsetY) *fabs(tan(rayA));
startDeltaY = -(1 - offsetX) / fabs(tan(rayA));
} else if (rayA < 3 * M_PI_2) {
startDeltaX = -(offsetY) *fabs(tan(rayA));
startDeltaY = -(offsetX) / fabs(tan(rayA));
} else {
startDeltaX = -(1 - offsetY) * fabs(tan(rayA));
startDeltaY = (offsetX) / fabs(tan(rayA));
}
somethingDone = false
的情況。這是由於浮點數的誤差造成,當 interceptX 或 interceptY 很靠近整數時,可能會發生明明會在方格內碰撞,卻跳到方格外一點的情況,造成兩邊條件都不成立。while (((tileStepY == 1 && (interceptY <= tileY + 1 + DELTA)) ||
(tileStepY == -1 && (interceptY >= tileY - DELTA)))) {
//...
}
somethingDone = false
,我才在 interceptX 與 interceptY 來加入一個補償值,讓判斷能夠正常。
do {
bool somethingDone = false;
while (((tileStepY == 1 && (interceptY <= tileY + 1)) ||
(tileStepY == -1 && (interceptY >= tileY)))) {
//...
}
while (!verticalHit && ((tileStepX == 1 && (interceptX <= tileX + 1)) ||
(tileStepX == -1 && (interceptX >= tileX)))) {
//...
}
if (!somethingDone) {
interceptX += -tileStepX * DELTA;
interceptY += -tileStepY * DELTA;
}
} while ((!horizontalHit && !verticalHit));
這個方法根據先前 start 得到的 playerX, playerY, playerA (角度),從 screenX 這個位置射出 ray,來得到要畫的 牆壁高度、texture 亮度與 texture 座標。
void RayCasterFloat::Trace(uint16_t screenX,
uint8_t *screenY,
uint8_t *textureNo,
uint8_t *textureX,
uint16_t *textureY,
uint16_t *textureStep)
{
float hitOffset;
int hitDirection;
float deltaAngle = atanf(((int16_t) screenX - SCREEN_WIDTH / 2.0f) /
(SCREEN_WIDTH / 2.0f) * M_PI / 4);
float lineDistance = Distance(_playerX, _playerY, _playerA + deltaAngle,
&hitOffset, &hitDirection);
float distance = lineDistance * cos(deltaAngle);
float dum;
*textureX = (uint8_t)(256.0f * modff(hitOffset, &dum));
*textureNo = hitDirection;
*textureY = 0;
*textureStep = 0;
if (distance > 0) {
*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;
}
}
} else {
*screenY = 0;
}
}
float deltaAngle = atanf(((int16_t) screenX - SCREEN_WIDTH / 2.0f) /
(SCREEN_WIDTH / 2.0f) * tan(FOV / 2));
modff(hitOffset, &dum)
,得到座標的小數部份64 / txs
。而這裡因為要轉成 10 bit fixed point,所以多乘了 256 / txs * 256
。
要從牆壁的高度計算畫面上的高度,我們要先知道垂直方向的
所以我們若令中線距離為
再來假設牆面距離玩家
所以把
所以假如要讓 INV_FACTOR 隨著 FOV 自動調整的話,需要改為這樣:
#define INV_FACTOR (float) (SCREEN_WIDTH / (4.0f * tanf(FOV / 2)))
實測 floating point 不一樣的 FOV:
左邊仍為舊的 fixed point (90 度 FOV)。
原本的程式碼中這一段
*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;
}
}
screenY
為 8bit unsigned,而且當我們很靠近牆時,算出來的高度可能是畫面半高(128)的好幾倍,所以可能會遇到 overflow 的問題。解決辦法是先用 float txs
儲存結果,等判斷完範圍之後,再設定 screenY
:
float txs = 2 * INV_FACTOR / distance;
if (txs != 0) {
*textureStep = (256 / txs) * 256;
if (txs > SCREEN_HEIGHT) {
auto wallHeight = (txs - SCREEN_HEIGHT) / 2;
*textureY = wallHeight * (256 / txs) * 256;
*screenY = SCREEN_HEIGHT >> 1;
} else {
*screenY = txs / 2;
}
}
當走近牆面的時候,會發現定點數與浮點數的差異。
牆面上半部:
牆面下半部:
// render upper wall
uint16_t to = (32 << 10) - (tst >> 1);
lb += (sso - 1) * SCREEN_WIDTH;
for (int y = 0; y < sso; y++) {
auto ty = static_cast<int>(to >> 10);
auto tv = g_texture8[(ty << 6) + tx];
to -= tst;
if (tn == 1 && tv > 0) {
// dark wall
tv >>= 1;
}
*lb = GetARGB(tv);
lb -= SCREEN_WIDTH;
}
// render lower wall
to = (32 << 10) + (tst >> 1);
lb += sso * SCREEN_WIDTH;
for (int y = 0; y < sso; y++) {
auto ty = static_cast<int>(to >> 10);
auto tv = g_texture8[(ty << 6) + tx];
to += tst;
if (tn == 1 && tv > 0) {
// dark wall
tv >>= 1;
}
*lb = GetARGB(tv);
lb += SCREEN_WIDTH;
}
走到特定點的時候,會出現奇怪的東西。我想是因為距離太遠有東西 overflow 的緣故。我去追查定點數實作中跟距離有關的部份,找到查表的函式 LookupHeight:
void RayCasterFixed::LookupHeight(uint16_t distance,
uint8_t *height,
uint16_t *step)
{
if (distance >= 256) {
const uint16_t ds = distance >> 3;
if (ds >= 256) {
*height = LOOKUP8(g_farHeight, 255) - 1;
*step = LOOKUP16(g_farStep, 255);
}
*height = LOOKUP8(g_farHeight, ds);
*step = LOOKUP16(g_farStep, ds);
} else {
*height = LOOKUP8(g_nearHeight, distance);
*step = LOOKUP16(g_nearStep, distance);
}
}
ds >= 256
,進入 if 查表後,出來竟然又查了一次,而且存取到 array 長度之外的位置。所以我們只要把 else 正確的補上即可:
if (ds >= 256) {
*height = LOOKUP8(g_farHeight, 255) - 1;
*step = LOOKUP16(g_farStep, 255);
} else {
*height = LOOKUP8(g_farHeight, ds);
*step = LOOKUP16(g_farStep, ds);
}