遊戲編程算法與技巧 Game Programming Algorithms and Techniques (Sanjay Madhav 著)

http://gamealgorithms.nethtml

 

第1章 遊戲編程概述 (已看)git

第2章 2D圖形 (已看)程序員

第3章 遊戲中的線性代數 (已看)github

第4章 3D圖形 (已看)web

第5章 遊戲輸入 (已看)面試

第6章 聲音 (已看)正則表達式

第7章 物理 (已看)算法

第8章 攝像機 (已看)spring

第9章 人工智能 (已看)express

第10章 用戶界面 (已看)

第11章 腳本語言和數據格式 (已看)

第12章 網絡遊戲 (已看)

第13章 遊戲示例:橫向滾屏者(iOS)

第14章 遊戲示例:塔防(PC/Mac)

 

第1章 遊戲編程概述

  遊戲編程的發展

世界上第一款商業遊戲Computer Space在1971年推出.是後來Atari的創辦人Nolan Bushnell 和 Ted Dabney 開發的.這款遊戲當時並非運行在傳統計算機上的.它的硬件甚至沒有處理器和內存.它只是一個簡單的狀態機,在多個狀態中轉換.Computer Space的全部邏輯必須在硬件上完成.

隨着"Atari 2600"在1977年推出,開發者們有了一個開發遊戲的平臺.這正是遊戲開發變得更加軟件化的時候,不再用設計複雜的硬件了.從Atari時期一直到如今,仍然有一些遊戲技術保留着

家用機推出的時候,它的硬件就會被鎖定5年多,稱爲一個"世代".家用機的優勢也在於其鎖定了硬件,使得程序員能夠有效利用機能.

    Atari時期(1977-1985年)

這個時期的程序員須要對底層硬件有必定的理解.CPU運行在1.1MHz,內存只有128字節.這些限制使得用C語言開發不太實際.大多數遊戲都是徹底用匯編語言開發的. 更糟糕的是,調試是徹底看我的能力的.沒有任何開發工具和SDK.

    NES和SNES時期(1985-1995年)

然而到了1983年,北美遊戲市場崩潰了.主要緣由在於,市場上充斥着大量質量低下的遊戲.

直到1985年推出的紅白機(NES)才把產業帶回正軌.

到了超級任天堂(SNES)時代,開發團隊進一步擴大.隨着開發團隊的擴大,不可避免地會變得更加專業化.

NES和SNES的遊戲仍然徹底用匯編語言開發,由於內存依然不足.幸運的是任天堂有提供SDK和開發工具,開發者再也不像Atari時期那麼痛苦.

    PS和PS2時期(1995-2005年)

因爲高級語言帶來了生產力提高,開發團隊規模的增加在這個時期剛開始有所減緩.大部分早期遊戲仍然只須要8~10位程序員.即便最複雜的遊戲,好比2001年推出的GTA3,工程師團隊也是那樣的規模

雖然本時期早期開發團隊規模跟NES時期差很少,但是到了後期就變龐大了很多.好比2004年在Xbox推出的Full Spectrum Warrior 總共有15名程序員參與開發,大部分都是專門開發某個功能的.但這個增長比起下個時期能夠說微不足道

    Xbox360,PS3和Wii時期(2005-2013年)

家用機的高畫質表現致使遊戲開發進入了兩種境地.AAA遊戲變得更加龐大,也有着相應龐大的團隊和費用.而獨立遊戲則跟過去的小團隊開發規模相仿

遊戲編程的另外一個大的開發趨勢就是中間件及開源的出現.有的中間件是完整的遊戲引擎,好比Unreal,Unity.有的則是專門作某個系統,好比物理引擎Havok Physics. 這樣就節省了大量的人力,財力.可是缺點就是某個策劃功能可能會受到中間件的限制

    遊戲的將來

儘管遊戲開發多年來有許多變遷,有趣的是,許多早期概念到如今仍然適用,20年都沒變的核心概念的基礎部分: 遊戲循環, 遊戲時間管理和遊戲對象模型

  遊戲循環

整個遊戲程序的核心流程控制稱爲遊戲循環.之因此是一個循環,是由於遊戲老是不斷地執行一系列動做直到玩家退出.每迭代一次遊戲循環稱爲1幀.大部分實時遊戲每秒鐘更新30~60幀.若是一個遊戲跑60FPS(幀/秒),那麼這個遊戲循環每秒要執行60次.

遊戲開發中有着各類各樣的遊戲循環,選擇上須要考慮許多因素,其中以硬件因素爲主

    傳統的遊戲循環

一個傳統的遊戲循環能夠分紅3部分: 處理輸入,更新遊戲世界,生成輸出.一個基本的遊戲循環多是這樣的:

while game is running
    process inputs
    update game world
    generate outputs
loop
View Code

process inputs會檢查各類輸入設備,好比鍵盤,鼠標,手柄.可是這些輸入設備並不僅輸入已經想到的,任何外部的輸入在這一階段都要處理完成

update game world 會執行全部激活而且須要更新的對象.這可能會有成千上萬個遊戲對象.

對於generate outputs, 最耗費計算量的輸出一般就是圖形渲染成2D或3D.另外還有其餘一些輸出,好比音頻,涵蓋了音效,背景音樂,對話,跟圖形輸出一樣重要.

while player.lives > 0
    // 處理輸入
    JoystickData j = grab raw data from joystick

    // 遊戲世界更新
    update player.position based on j
    foreach Ghost g in world
        if player colliders with g
            kill either player or g
        else 
            update AI for g based on player.position
        end
    loop

    // Pac-Man 吃到藥丸
    ...

    // 輸出結果
    draw graphics
    update audio
loop
View Code

    多線程下的遊戲循環

  時間和遊戲

大多數遊戲都有關於時間進展(progression of time)的概念.對於實時遊戲,時間進展一般都很快.好比30FPS(Frame Per Second)的遊戲,每幀大約用時33ms.即便是回合制遊戲也是經過時間進展來完成功能的,只不過它們使用回合來計數

    真實時間和遊戲時間

真實時間,就是從真實世界流逝的時間;遊戲時間,就是從遊戲世界流逝的時間.區分它們很重要.雖然一般都是1:1,但不老是這樣.好比說,遊戲處於暫停狀態.雖然真實時間流逝了,可是遊戲時間沒有發生變化

馬克思 佩恩 運用了"子彈時間"的概念去減慢遊戲時間.這種狀況下,遊戲時間比實際時間要慢得多.與減速相反的例子就是,不少體育類遊戲都提高了遊戲速度.在足球遊戲中,不要求玩家完徹底全地經歷15分鐘,而是一般都會讓時鐘撥快1倍.還有一些遊戲甚至會有時間倒退的情形,好比<<波斯王子:時之沙>>就可讓玩家回到以前的時刻

    經過處理時間增量來表示遊戲邏輯

對於 enemy.position.x += 5   16MHz的處理器比8MHz的處理器要快1倍.

爲了解決這樣的問題,須要引入增量時間的概念: 從上一幀起流逝的時間 enemy.position.x += 150 * deltaTime

while game is running
    realDeltaTime = time since last frame
    gameDeltaTime = realDeltaTime * gameTimeFactor
    
    // 進程輸入
    ...
    update game world with gameDeltaTime
    
    // 渲染輸出
    ...
loop
View Code
targetFrameTime = 33.3f
while game is running
    realDeltaTime = time since last frame
    gameDeltaTime = realDeltaTime * gameTimeFactor

    // 處理輸入
    ...
    
    update game world with gameDeltaTime

    // 渲染輸出
    ...

    while (time spent this frame) < targetFrameTime
        // 作一些事情將多出的時間用完
        ...
    loop
loop
View Code

  遊戲對象

廣義上的遊戲對象是每一幀都須要更新或者繪製的對象.雖然叫做"遊戲對象",但並不意味着就必須用傳統的面向對象.有的遊戲採用傳統的對象,也有的用組合或者其餘複雜的方法.無論如何實現,遊戲都須要跟蹤管理這些對象,在遊戲循環中把它們整合起來.

    遊戲對象的類型

更新和繪製都須要的對象.  任何角色,生物或者能夠移動的物體都須要在遊戲循環中的 update game world 階段更新,還要在 generate outputs 階段渲染.在<<超級馬里奧兄弟>>中,任何敵人及全部會動的方塊都是這種遊戲對象

只繪製不更新的對象,  稱爲靜態對象. 這些對象就是那些玩家能夠看到,可是永遠不須要更新的對象.他能夠是遊戲背景中的建築.一棟建築不會移動也不會攻擊玩家,可是須要繪製

須要更新但不須要繪製的對象.  一個例子就是攝像機.技術上來說,你是看不到攝像機的,可是不少遊戲都會移動攝像機.另外一個例子就是觸發器.觸發器會檢測玩家的位置,而後觸發合適的行爲.因此觸發器j就是一個看出見的檢測與玩家碰撞的盒子.

    遊戲循環中的遊戲對象

class GameObject
    // 成員變量/函數
    ...
end

interface Drawable
    function Draw()
end

interface Updateable
    function Update(float deltaTime)
end

// 只更新的遊戲對象
class UGameObject inherits GameObject, implements Updateable
    // 重載更新函數
    ...
end

// 只渲染的遊戲對象
class DGameObject inherits GameObject, implements Drawable
    // 重載繪製函數
    ...
end

class DUGameObject inherits UGameObject, implements Drawable
    // 重載繪製和更新函數
    ...
end

class GameWorld
    List updateableObjects
    List drawableObjects
end

while game is running
    realDeltaTime = time since last frame
    gameDeltaTime = realDeltaTime * gameTimeFactor
    
    // 處理輸入
    ...
    
    // 更新遊戲世界
    foreach Updateable o in GameWorld.updateableObjects
        o.Update(gameDeltaTime)
    loop

    // 渲染輸出
    foreach Drawable o in GameWorld.drawableObjects
        o.Draw()
    loop

    // 幀數限制代碼
    ...
loop
View Code

  相關資料

    遊戲編程的發展

Crane,David. "GDC 2011 Classic Postmortem on Pitfall!" (https://www.youtube.com/watch?v=tfAnxaWiSeE). Pitfall! 的做者 David Crane 的1小時演講,關於在Atari上開發的心得.

    遊戲循環

Gregory,Jason. Game Engine Architecture. Boca Raton: A K Peters, 2009. 這本書用了幾節篇幅講了多種多線程下的遊戲循環,包括在PS3的非對稱CPU架構上使用的狀況.

West,Mick "Programming Responsiveness" 和 "Measuring Responsiveness"(http://tinyurl.com/594f6r和http://tinyurl.com/5qv5zt).這些是Mick West在Gamasutra上些的文章,討論了那些致使輸入延遲的因素,同時也對遊戲中的輸入延遲進行了測量

    遊戲對象

Dickheiser, Michael,Ed. Game Programming Gems 6. Boston: Charles River Media, 2006. 這卷書中的一篇文章 "Game Object Component System" 講了一種與傳統面向對象遊戲對象模型所不一樣的方法.雖然實現上有點複雜,可是愈來愈多的商業遊戲中的遊戲對象經過組合的形式來實現.

第2章 2D圖形

   2D渲染基礎

    CRT顯示器基礎

在多年前,陰極射線管(Cathode Ray Tube)顯示器是顯示器的主流.CRT裏圖像的元素就是像素.對於彩色顯示器,每一個顏色由紅,綠,藍組成.顯示器的分辨率決定了像素的數量.好比一個300x200的顯示器由200行像素,叫做掃描線.每一個掃描線能夠有300個元素,因此總共有60000個像素之多.位於(0, 0)的像素一般在左上角,但不是全部顯示器都這樣.

CRT內部,繪製圖像是經過電子槍發射電子流完成的.這把槍從左上角開始沿第一條掃描線進行繪製.當它完成以後就繼續下一條掃描線,而後不斷地重複,直到全部掃描都畫完

當電子槍剛剛完成一幀的繪製的時候,它的槍頭在右下角.噴槍從右下角移動到左上角所花費的時間,咱們稱爲場消隱期(VBLANK).這個間隔以ms計,間隔不是由CRT,計算機或者電視機決定的,而是由用途決定的

    像素緩衝區和垂直同步

新的硬件使得由足夠的內存將全部顏色保存在像素緩衝區中.但這不是說遊戲循環就能夠徹底無視CRT噴槍.假設噴槍在屏幕上繪製到一半時,恰好遊戲循環到了"generate outputs"階段.它開始爲新的一幀往像素緩衝區寫像素時,CRT還在上一幀的繪製過程當中.這就致使了屏幕撕裂,具體表現就是屏幕上同時顯示了不一樣的兩幀的各自一半畫面.更糟糕的是,新一幀的數據提交時,上一幀還沒開始.這就不只是一幀中有兩半不一樣的畫面了,而是連畫面都沒有

一個解決方案就是同步遊戲循環,等到場消隱期再開始渲染.這樣會消除分裂圖像的問題,可是它限制了遊戲循環的間隔,只有場消隱期期間才能進行渲染,對於如今的遊戲來講是不行的

另外一個解決方案叫做雙緩衝技術.雙緩衝技術裏,有兩塊像素緩衝區.遊戲交替地繪製在這兩塊緩衝區裏.在1幀內,遊戲循環可能將顏色寫入緩衝區A,而CRT正在顯示緩衝區B.到了下一幀,CRT顯示緩衝區A,而遊戲循環寫入緩衝區B.因爲CRT和遊戲循環都在使用不一樣的緩衝區,因此沒有CRT繪製不完整的風險

爲了徹底消滅屏幕撕裂,緩衝區交換必須在場消隱期進行.這就是遊戲中常見的垂直同步設置.技術上來說這是不恰當的,由於垂直同步是顯示器在場消隱期剛結束時才告訴你的信號.無論怎樣,緩衝區交換是y一個相對快速的操做,遊戲渲染一幀花費的時間則長的多(儘管理想中要比CRT繪製一幀快).因此在場消隱期交換緩衝區徹底消除了屏幕撕裂風險

有些遊戲確實容許緩衝區交換在繪製完成前儘快進行,這就會致使屏幕撕裂的可能.這種狀況一般是由於玩家想要得到遠比屏幕刷新速度快的頻率.若是一款顯示器有60Hz的刷新率,同步緩衝區交換到場消隱期最多隻有60Hz.可是玩家爲了減小輸入延遲(或者有很快的機器相應速度),可能會消除同步以達到更高的幀率

function RenderWorld() {
    // 繪製遊戲世界中全部對象
    ...
    wait for VBLANK
    swap color buffers
end
View Code

雖然CRT顯示器今天幾乎再也不使用,可是雙緩衝技術在正確的時間交換仍是能在LCD上消除屏幕撕裂的問題.一些遊戲甚至使用了三緩衝技術,使用3個緩衝區而不是兩個.三緩衝區能使幀率在一些特殊狀況下更加平滑,但也增長了輸入延遲

  精靈

精靈是使用圖片中的一個方塊繪製而成的2D圖像.一般精靈用來表示角色和其餘動態對象.對於簡單的遊戲來說,精靈也可能用於背景,雖然有更加有效的方式來完成,特別是靜態的背景.大多數2D遊戲運用大量的精靈,對於移動端來講,精靈一般就是遊戲體積(磁盤空間佔用)的主要部分.因此,高效利用精靈是很是重要的

stb_image.c

    繪製精靈

最簡單的繪製場景的方式是,先畫背景後畫角色.這就像畫家在畫布上畫畫同樣,也由於這樣,這個算法叫做畫家算法

class Sprite
    ImageFile image
    int drawOrder
    int x, y
    function Draw()
        // 把圖片在正確的(x, y)上繪製出來
        ...
    end
end

SortedList spriteList


// 建立新的精靈...
Sprite newSprite = specify image and desired x/y
newSprite.drawOrder = set desired draw order value
// 根據渲染順序添加到排序列表
spriteList.Add(newSprite.drawOrder, newSprite)
// 準備繪製
foreach Sprite s in spriteList
    s.Draw()
loop
View Code

畫家算法也能夠運用在3D環境下,但它有不少缺陷.而在2D場景中,畫家算法工做得很號

    動畫精靈

struct AnimFrameData
    // 第1幀動畫的索引
    int startFrame
    // 動畫的全部幀數
    int numFrames
end

struct AnimData
    // 全部動畫用到的圖片
    ImageFile images[]
    // 全部動畫用到的幀
    AnimFrameData frameInfo[]
end

class AnimatedSprite inherits Sprite
    // 全部動畫數據(包括ImageFiles和FrameData)
    AnimData animData
    // 當前運行中的動畫
    int animNum
    // 當前運行中的動畫的幀數
    int frameNum
    // 當前幀播放了多長時間
    float frameTime
    // 動畫的FPS(默認24FPS)
    float animFPS = 24.0f

    function Initialize(AnimData myData, int startingAnimNum)
    function UpdateAnim(float deltaTime)
    function ChangeAnim(int num)
end

function AnimatedSprite.Initialize(AnimData myData, int startingAnimNum)
    animData = myData
    ChangeAnim(startingAnimNum)
end

function AnimatedSprite.ChangeAnim(int num)
    animNum = num
    // 當前動畫爲第0幀的0.0f時間
    frameNum = 0
    animTime = 0.0f
    // 設置當前圖像, 設置爲startFrame
    int imageNum = animData.frameInfo[animNum].startFrame
    image = animData.images[imageNum]
end

function AnimatedSprite.UpdateAnim(float deltaTime)
    // 更新當前幀播放時間
    frameTime += deltaTime
    
    // 根據frameTime判斷是否播放下一幀
    if frameTime > (1 / animFPS)
        // 更新當前播放到第幾幀
        // frameTime / (1 / animFPS)就至關於frameTime * animFPS
        frameNum += frameTime * animFPS
    
        // 檢查是否跳過最後一幀
        if frameNum >= animData.frameInfo[animNum].numFrames
            // 取模能保證幀數循環正確
            // (好比, If numFrames == 10 and frameNum == 11
            // frameNum會獲得11 % 10 = 1)
            frameNum = frameNum % animData.frameInfo[animNum].numFrames
        end
        
        // 更新當前顯示圖片
        // (startFrame是相對於全部圖片來決定的,而frameNum是相對於某個動畫來決定的)
        int imageNum = animData.frameInfo[animNum].startFrame + frameNum
        image  = animData.images[imageNum]

        // 咱們用fmod(浮點數運算),至關於取模運算
        frameTime = fmod(frameTime, 1 / animFPS)
    end
end
View Code

    精靈表單(SpriteSheet)

TexturePacker

使用單張圖片區存儲全部精靈,稱之爲精靈表單.

在精靈表單中,可讓精靈打包儘量地靠近,從而減小浪費的無用空間

精靈表單的另外一個優點就是不少GPU要紋理加載後才能繪製.若是繪製過程當中頻繁地切換紋理,會形成至關多的性能損耗,特別是對於大一點的精靈.可是若是全部精靈到在一張紋理中,是能夠消除切換產生的損耗的

取決與於遊戲中精靈的數量,把全部的精靈都放入一張紋理是不現實的.大多數硬件都有紋理最大尺寸限制

  滾屏

    單軸滾屏

單軸滾屏遊戲中,遊戲只沿x軸或者y軸滾動.

最簡單的方法j就是把關卡背景按屏幕大小進行切割

// 一臺iPhone 4/4S 屏幕大小爲960 x 640
const int screenWidth = 960;
// 全部屏幕大小的背景圖
string backgrounds[] = { "bg1.png", "bg2.png", /* ... */ }
// 全部水平擺放的屏幕大小的背景圖數量
int hCount = 0;
foreach string s in backgrounds
    Sprite bgSprite
    bgSprite.image.Load(s)
    // 第1個屏幕在 x = 0處,第2個在x = 960處, 第3個在 x = 1920處...
    bgSprite.x = hCount * screenWidth
    bgSprite.y = 0
    bgSpriteList.Add(bgSprite)
    hCount++
loop
View Code
// camera.x就是player.x在區間中通過clamp的值
camera.x = clamp(player.x, screenWidth / 2, hCount * screenWidth - screenWidth / 2)

Iterator i = bgSpriteList.begin()
while i != bgSpriteList.end()
    Sprite s = i.value()
    // 找到第1張圖片來繪製
    if (camera.x - s.x) < screenWidth
        // 第1張圖: s.x = 0, camera.x = 480, screenWidth / 2 = 480
        // 0 - 480 + 480 = 0
        draw s at (s.x - camera.x + screenWidth / 2, 0)
        i++
        s = i.value()
        draw s at (s.x - camera.x + screenWidth / 2, 0)
        break
    end
    i++
loop
View Code

    無限滾屏

無限滾屏就是當玩家失敗才中止滾屏的遊戲.固然,這裏不可能有無限多個背景來滾屏.所以遊戲中的背景會重複出現.固然,大部分無限滾屏遊戲都擁有大量的背景圖片和很好的隨機性來產生豐富的背景.一般遊戲都會用連續的四五張圖片組成序列,有了不一樣系列的圖片可選後再打亂重組

    平行滾屏

平行滾屏中,背景拆分紅幾個不一樣深度的層級.每一層都用不一樣的速度來滾動以製造不一樣深度的假象

    四向滾屏

四向滾屏中,遊戲世界會在水平和垂直方向上滾動

// 這裏假設用二維數據array[row][column]記錄全部片斷
for int i = 0, i < vCount, i++
    // 這是正確的行嗎
    if (camera.y - segments[i][0].y) < screenHeight
        for int j = 0, j < hCount, j++
            // 這是正確的列嗎
            if (camera.x - segments[i][j].x) < screenWidth
                // 這是左上角的可見片斷
            end
        loop
    end
loop
View Code

  磚塊地圖(TileMap)

磚塊地圖經過把遊戲世界分割成等分的方塊(或者其餘多邊形)來解決這個問題.每一個方塊表明的精靈佔據着某一塊網格位置.這些引用的精靈能夠放在多張或者一張磚塊集合裏.因此若是樹木在磚塊集合中的索引號爲0,每一個表示樹木的方塊均可以用0表示.雖然正方形是磚塊地圖的最多見形式,但這不是必須的.一些遊戲採用六邊形,有的則採用平行四邊形.這主要取決於你但願的視角

無論什麼狀況,磚塊地圖是一種很好的節省內存的方式,這讓策劃和美工更容易工做.那些動態建立內容的2D遊戲,好比Spelunky,沒有磚塊地圖將很難實現

    簡單的磚塊地圖

Tiled Map Editor

// 基本關卡文件格式 5 x 5
5, 5
0, 0, 1, 0, 0
0, 1, 1, 1, 0
1, 1, 2, 1, 1
0, 1, 1, 1, 0
0, 0, 1, 0, 0

class Level
    const int tileSize = 32
    int width, height
    int tiles[][]
    function Draw()
end

function Draw()
    for int row = 0, row < height, row++
        for int col = 0, col < width, col++
            // 在 (col * tileSize, row * tileSize)繪製tiles[row][col]
        loop
    loop
end
View Code

雖然這種基於文本的磚塊地圖在簡單關卡中運行順利,可是在商業遊戲中,會採用更加健壯的格式

    斜視等視角磚塊地圖

斜視等視角中,視角經過旋轉,讓場景更具深度感

  相關資料

    Cocos2D

iOS遊戲開發最流行的2D代碼庫,在http://cocos2d.org/能夠獲取

Itterheim, Stephen. Learn Cocos2d 2: Game Development for iOS. New York: Apress, 2012. 雖然有不少本書講Cocos2D,但這本書是相對強的

    SDL

Simple DirectMedia Layer(SDL) 是另外一種可供選擇的代碼庫.這是用C語言開發的流行的跨平臺2D遊戲代碼庫,同時也可移植到其餘語言.SDL能夠在www.libsdl.org獲取.

第3章 遊戲中的線性代數

   向量

向量表示了n維空間下的長度和方向,每一個維度都用一個實數去表示

當解決向量問題的時候,你會發如今不一樣位置均可以繪製向量是很是有幫助的.由於改變向量繪製的位置並不會改變向量自己,這個經常使用的技巧要銘記在心

    加法

    減法

    長度,單位向量和正規化

向量長度等於1的時候就是單位向量.單位向量(unit vector)的符號就是上方加一頂帽子,就像這樣

把非單位向量轉換成單位向量,這個轉換叫做正規化(normalize).

一個經驗法則就是,那些只關心方向的向量,你能夠將它們正規化.若是你關心方向和長度,那麼向量不該該正規化

    標量乘積

    點乘

    問題舉例:向量反射

    叉乘

兩個向量叉乘會獲得第3個向量.給定兩個向量,能夠肯定一個平面.叉乘獲得的向量就會垂直於這個平面,稱之爲平面的法線

由於它能獲得平面的法線,因此叉乘只能在3D向量中使用.這就意味着,爲了在2D向量中使用叉乘,要先經過將z份量設爲0的方式轉換成3D向量

值得注意的是,技術上來說,平面的垂直向量有兩個:與c反方向的向量.因此你怎麼知道叉乘結果向量的朝向?結果取決於座標系的手系

0

你可能會注意到,若是你講將食指對準b,中指對準a,拇指的朝向就會是反方向.這是由於叉乘不知足交換律,實際上它知足反交換律:

跟點乘同樣,叉乘也有要注意的特殊狀況.若是叉乘返回的3個份量都爲0,意味着兩個向量共線,也就是在一條直線上.兩個共線的向量不能肯定一個平面,這就是爲何沒法返回該平面的法線.

    問題舉例:旋轉一個2D角色

    線性插值

線性插值可以計算兩個值中間的數值.舉個例子,若是a = 0並且b = 10, 從a到b線性插值20%就是2.線性插值不只做用在實數上,它可以做用在任意維度的值上.能夠對點,向量,矩陣,四元數等數值進行插值.無論值的維度是什麼,都能用一個公式表達:

Lerp(a, b, f) = (1 - f) * a + f * b 在公式中,a 和 b都是即將插值的變量,而f則是介於[0, 1]的因子

在遊戲中,插值的常見應用就是將兩個頂點插值.假設有一個角色在a點,他須要平滑地移動到b點.Lerp經過f值從0增長到1,既可作到將a平滑過渡到b點

    座標系

有時候x軸,y軸和z軸用軸向量.軸向量用於定義座標系

  矩陣

    加法/減法

    標量乘法

    乘法

    逆矩陣

    轉置

    用矩陣變換3D向量

  相關資料

Lengyel, Eric. Mathematics for 3D Game Programming and Computer Graphics (Third Edition). Boston: Course Technology, 2012. 這本書討論了本章不少概念的細節,有完整的計算和證實.它也涵蓋了超出本書範疇的更加複雜的數學,可是對於一些遊戲程序員仍是頗有用的(特別是專一於圖形學領域的程序員).

第4章 3D圖形

   基礎

第一款3D遊戲中的渲染是徹底以軟件方式實現的(即沒有硬件支持).這意味着即便是畫線這種基礎功能都要圖形程序員去完成.這套3D模型正確渲染到2D顏色緩衝的算法稱爲軟件光柵化,大部分計算機圖形學會花費大量時間在這些算法上.可是現代的計算機已經有了稱之爲圖形處理單元(GPU)的圖形硬件,這些硬件實現了繪製點,線,三角形等功能

由此,現代遊戲再也不須要開發實現軟件光柵化了.而焦點則轉變爲將須要渲染的3D場景數據以正確的方式傳遞給顯卡,通常都經過像OpenGL和DirectX這樣的庫完成.若是須要進一步自定義這個需求,能夠編寫一段運行在顯卡上稱之爲着色器的小程序來應用傳入的數據.在這些數據都傳遞完成以後,顯卡就會將這些數據繪製在屏幕上.編寫Bresenham畫線算法的日子一去不復返

在3D圖形中須要注意的是,常常須要計算近似值.這只是由於計算機沒有足夠的時間去計算真實的光照.遊戲不像CG電影那樣,能夠花上幾個小時就爲了渲染一幀的畫面.一個遊戲一般須要每秒畫30幀或者60幀,因此精確度須要在性能上作出折中.因爲近似模擬而產生的顯示錯誤稱之爲圖形失真,沒有遊戲能夠徹底避免失真

    多邊形

3D對象在計算機程序中有多種顯示方法,在遊戲中最普遍應用的就是經過多邊形顯示,更具體一點來講是三角形

爲何是三角形?首先,它們是最簡單的多邊形,它們能夠僅用3個頂點表示.第二點就是三角形老是在一個平面上,而多個頂點的多邊形則有可能在多個平面上.最後,任何3D對象都k能夠簡單地用細分三角面表示,且不會l留下漏洞或者進行變形

單個模型,咱們稱爲網格,是由多個三角片組成

  座標系

一個座標系空間有不一樣的參考系.好比說,在笛卡爾座標系中,原點在世界的中間,全部座標都相對於中心點.與之相似,還有不少座標系有不一樣的原點.在3D渲染管線中,渲染3D模型到2D顯示器,必須經歷4個主要的座標系空間

  模型座標系/局部座標系

  世界座標系

  視角座標系/攝像機座標系

  投影座標系

    模型座標系

當咱們在建模的時候,好比像在Maya這樣的軟件裏面,全部模型頂點的表示都是相對於模型原點的.模型座標系就是那個相對於模型自身的座標系.在模型座標系中,原點一般就在模型中心,角色模型的原點在角色兩腳之間.這是由於對象的中心點會更好處理

如今假設遊戲場景中有100個不一樣的對象.若是遊戲只是加載它們而後以模型座標系繪製會發生什麼?因爲全部對象都在模型空間建立,全部對象,包括玩家,都會在原點.相信這樣的關卡會很無趣.爲了讓這個關卡加載正確,須要另外一個座標系

    世界座標系

有一個新得座標系稱爲世界座標系.在世界座標系中,全部對象都相對於世界的原點偏移

就像以前說過的,常常會有3D遊戲使用4D向量.當4D座標系應用在3D空間中時,它們被稱爲齊次座標,而第4個份量被稱爲w份量

在大多數狀況下,w份量要麼是0,要麼是1.若是w=0,表示這個齊次座標是3D向量.而w=1,則表示齊次座標是3D的點.但很容易讓人疑惑的是,Vector4類同時用於表示向量和頂點.所以,經過命名規範來保持語義是很重要的

Vector4 playerPosition  // 這是個點
Vector4 playerFacing  // 這是個向量

用於變換的矩陣一般是4 x 4矩陣.爲了與4 x 4矩陣相乘,同時也須要4D向量.

矩陣變換就是矩陣用某種方法來影響向量或者頂點.矩陣變換使得咱們能夠將模型座標系變換爲世界座標系

WorldTransform = Scale x Rotation x Translation

    視角/攝像機座標系

在全部對象放置到世界座標系上正確的位置以後,下一件要考慮的事情就是攝像機的位置.一個場景或者關卡能夠徹底靜止,可是若是攝像機的位置改變,就徹底改變了屏幕上的顯示.這個稱爲視角/攝像機座標系

因此還須要另一個矩陣告訴顯卡如何將世界座標系的模型變換到相對於攝像機的位置上.最多見的矩陣是觀察矩陣.在觀察矩陣當中,攝像機的位置經過3個軸的額外份量來表示

function CreateLookAt(Vector3 eye, Vector3 target, Vector3 Up) {
    Vector3 F = Normalize(target - eye)
    Vector3 L = Normalize(CrossProduct(Up, F))
    Vector3 U = CrossProduct(F, L)

    Vector3 T
    T.x = -DotProduct(L, eye)
    T.y = -DotProduct(U, eye)
    T.z = -DotProduct(F, eye)

    // 經過F, L, U和T建立並返回觀察矩陣
end
View Code

    投影座標系

投影座標系有時候也叫做屏幕座標系,是一種將3D場景平鋪到2D平面上獲得的座標系.一個3D場景能夠經過多種方式平鋪在2D平面上,兩種最多見的方法分別是正交投影和透視投影

正交投影中,整個世界擠在2D圖像中,徹底沒有深度的感受.就是說離攝像機遠的對象與離攝像機近的對象在視覺上是同樣的.任何純2D的遊戲均可以看做使用了正交投影

另外一種常見的投影則是透視投影.在這種投影中,對象在攝像機中會顯得近大遠小.大部分3D遊戲都採用這種投影

兩種投影都有近平面和遠平面.近平面是靠近攝像機的平面,而介於攝像機和近平面之間的物體不參與繪製.遊戲中若是某我的物太過於靠近攝像機會忽然消失,就是被近平面剔除的緣由.與之相似,遠平面就是遠離攝像機的平面,任何物體超過這個平面就不參與繪製了.

正交投影矩陣由4個參數構成,視口的寬和高,還有遠平面和近平面到眼睛的距離

透視投影則多了一個參數視場(FOV).就是攝像機的可見角度.視場決定了你能看到多少內容,加了視場以後就能夠計算出透視矩陣.這個透視矩陣還有同樣要考慮的事情.那就是當頂點與矩陣相乘以後,w份量再也不是1.透視分割須要讓每個變換後的頂點份量除以w份量,使得w份量再一次爲1.這過程真正使得透視變換具備了深度感

  光照與着色

    顏色

    頂點屬性

爲了讓模型有顏色,須要在頂點上存儲額外的信息.這些信息被稱爲頂點屬性,在大多數現代遊戲中每一個頂點都會帶多個屬性.固然,額外的內存負擔會影響模型的頂點數

有不少參數能夠作頂點屬性.在紋理映射中,2D的圖片能夠映射到3D的三角形中.每一個頂點上都有一個紋理座標指定紋理的特定部分與之對應.最多見的紋理座標系是UV座標系,紋理上的x座標稱爲u,而y座標稱爲v

僅經過紋理,就可讓場景看起來充滿色彩.可是會看起來不夠真實,由於場景中沒有真實的光照.大部分光照模型依賴於另外一種頂點屬性:頂點法線

要記住,一個三角形從技術角度來說有兩個法線,取決於叉乘的順序.對於三角形來講,這個順序取決於頂點序,能夠是順時針,也能夠是逆時針.假設一個三角形的頂點A,B,C有順時針頂點序,就是說從A到B到C的順序是順時針方向,而這些頂點的叉乘結果在右手座標系中朝書頁方向.若是A到B到C的順序是逆時針方向,那麼法線就朝書頁外

頂點序的另外一個做用就是用於渲染優化,稱爲背面剔除,就是說沒有朝向攝像機的三角片不進行渲染

    光照

一個沒有光照的遊戲看起來會容易單調枯燥,因此大多數3D遊戲必須實現某種光照.3D遊戲中使用的光照有好幾種類型.一些光照會全局做用於整個場景,而一些光照只做用於光照範圍內的區域

環境光就是一種添加到場景中每個物體上的固定光照.由於環境光提供了一些光照,環境光對每一個物體來講做用都同樣.因此能夠將環境光想象成多雲天氣的時候提供最基本的光照

方向光是一種沒有位置的光,只指定光照的方向.跟環境光同樣,方向光做用於整個場景.可是,因爲方向光是帶方向的,因此它們會照亮物體的正面,而背面則沒有光照

點光源就是某個點向四面八方射出的光照.因爲它們從某個點射出,點光源也只會照亮物體的正面.在大多數狀況下,不但願點光源無限遠.在小範圍內有光,可是超了範圍光照就立刻衰減.點光源不會簡單地一直向前.爲了模擬這種情形,能夠增長衰減半徑來控制隨着距離的增長光照衰減的方式

聚光燈跟點光源很像,除了點光源向全部方向發射,聚光燈只在椎體內有光.爲了計算椎體範圍,須要一個角度做爲聚光燈的參數.跟點光源同樣,聚光燈只會照亮物體的正面.一個聚光燈的經典例子就是聚光燈,另外一個例子就是手電筒

    Phong光照模型

Phong光照模型是一種局部光照模型,由於它不考慮光的二次反射.換句話說,每一個物體都認爲在場景中只有本身被渲染.在物理世界中,若是一個紅光打到白色的牆上,紅光會有少許反射到房屋裏其餘地方.可是在局部光照模型中是不會發生的

在Phong光照模型中,光被分爲幾種份量:環境光,漫反射和高光.這3種份量都會影響物體的顏色

環境光份量用於總體照亮場景.因爲它均勻地做用域整個場景,因此環境光份量與光源的位置和攝像機的位置無關

漫反射份量是光源做用於物體表面的主要反射.它會被全部方向光,點光源和聚光燈影響.爲了計算漫反射份量,你同時須要物體表面的法線和物體表面到光源方向的向量.可是跟環境光份量同樣,漫反射一樣也不被攝像機的位置影響

高光份量,表示物體表面上閃亮的高光.一些有強高光的物體,好比光滑的金屬,會比塗上暗色塗料的物體光亮不少.相似漫反射份量,高光份量也同時取決於光源的位置和物體表面的法線.但它還取決於攝像機的位置,由於高光會隨着視角方向變換而變換

// Vector3 N = 物體表面的法線
// Vector3 eye = 攝像機的位置
// Vector3 pos = 物體表面的位置
// float a = 高光量

Vector3 V = Normalize(eye - pos)    // 從物體表面到光源
Vector3 Phong = AmbientColor
foreach Light light in scene
    if light affects surface
        Vector3 L = Normalize(light.pos - pos)    // 從物體表面到光源
        Phong += DiffuseColor * DotProduct(N, L)
        Vector3 R = Normalize(Reflect(-L, N))    // 計算-L關於N的反射
        Phong += SpecularColor * pow(DotProduct(R, V), a)
    end
end
View Code

    着色

着色就是計算表面的三角片如何填充.最基礎的着色類型是平面着色,就是整個三角片只有一種顏色.使用平面着色,只需每一個三角片進行一次光照計算(一般在三角片的中心),而後把經過計算獲得的顏色賦予整個三角片.這樣作基本能實現着色,但是很差看

有一種稍微複雜一點的着色方法,咱們稱之爲Gouraud着色.在這種着色方法中,光照模型的計算須要逐個頂點進行一次.這就使得每一個頂點有不一樣的顏色.而三角片的剩餘部分則經過頂點顏色插值填充.舉個例子,若是一個頂點爲紅色,而另外一個頂點爲藍色,兩點之間的顏色是從紅到藍慢慢混合過渡的.

雖然Gouraud着色比較近似天然色了,但仍是有很多問題.首先,着色的質量取決於模型的多邊形數量.在低多邊形模型上,着色結果會有很多有棱角的邊,雖然Gouraud着色在高多邊形模型上能達到不錯的效果,可是會佔用很多內存

另外一個問題是高光用在低多邊形模型上效果極差.而在低多邊形模型上高光有可能會徹底消失,由於只依據頂點來計算光照.雖然Gouraud着色流行了好幾年,可是隨着GPU性能的提高,就再也沒人使用了

Phong着色是計算三角片上每一個像素的光照模型.爲了完成這種效果,頂點的法線須要在三角片表面進行插值,而後利用插值獲得的法線進行光照計算

正如你們想象的那樣,Phong着色的計算量比Gouraud着色昂貴得多,特別是在場景中有不少燈光的時候.可是,大多數現代硬件均可以輕鬆處理這部分額外的計算.Phong着色能夠認爲是逐像素光照,由於光照結果是針對每一個像素進行單獨計算的

有趣的是無論選用哪一種着色方法,輪廓都是同樣的.因此哪怕使用了Phong着色,低多邊形對象的外輪廓仍是很明顯

  可見性

在你瞭解了網格,矩陣,燈光,光照模型,着色方法以後,3D渲染裏最重要的最後一點就是可見性判斷.哪一個對象可見,哪一個不可見?在這個問題上,3D遊戲要比2D遊戲複雜得多

    再探畫家算法

在第2章中討論了畫家算法(將場景從後往前繪製),它在2D遊戲中很好用.這是由於2D精靈之間的順序關係清晰,並且大多數2D引擎都內在支持層級的概念.對於3D遊戲而言,這個順序不多是靜止的,由於攝像機的透視能夠改變

這意味着在3D場景中使用畫家算法的話,全部場景中的三角片都必須排序,可能每一幀都須要排序,攝像機在場景中移動一次就得排一次序.若是場景中有10000個對象,那麼依據場景中的深度進行排序的計算會很是昂貴

這樣看上去畫家算法很是低效,可是還有更糟的.考慮同屏顯示多個視角的分屏遊戲.若是玩家A和玩家B面對面,兩個玩家從後往前的順序是徹底不一樣的.爲了解決這個問題,不只要每幀排序兩次,並且內存中還要有兩個排序隊列,兩個都不是好的解決方案

另外一個畫家算法的問題就是會引發大量的重繪,有的像素每幀會繪製屢次.若是你想作第2章的太空場景,一些像素每幀都會繪製不少次:一個是星星區域,一個是太陽,一個是小行星,一個是太空船

在現代3D遊戲中,計算最終光照和貼圖的過程是渲染管線最昂貴的部分.像素重繪意味着前一次繪製的花費被浪費了.所以,大多數遊戲都盡力避免重繪的發生.而在畫家算法中這是不可能完成的任務

並且,三角片重疊的時候也有問題.圖中的3個三角片.哪一個在前,哪一個在後

答案就是沒有一個三角片在最後.在這種狀況下,畫家算法惟一的方法就是將這個三角片按照正確的方式切割成更小的三角片

因爲以上各類問題,畫家算法在3D遊戲中很是少用

    深度緩衝區

在深度緩衝區中,會有一塊額外的緩衝區僅在渲染的過程做用中使用.這塊額外的緩衝區稱爲深度緩衝區,爲場景中的每一個像素存儲數據,就像顏色緩衝同樣.可是跟顏色緩衝區存儲顏色不一樣,深度緩衝區存儲的是像素到攝像機的距離(或深度).更準確地講,每幀用到的緩衝區(包括顏色緩衝區,深度緩衝區,模板緩衝區等)統稱爲幀緩衝

在使用深度緩衝區的每一幀渲染開始以前,深度緩衝區都會清空,確保當前深度緩衝區中的全部像素都無限遠.而後,在渲染的過程當中,像素的深度會在繪製以前先計算出來.若是該像素的深度比當前深度緩衝區存儲的深度小,那麼這個像素就進行繪製,而後新的深度值會寫入深度緩衝區.因此每幀第一個繪製的對象老是可以將它的全部顏色和深度分別寫入顏色緩衝區和深度緩衝區.可是當繪製第二個對象的時候,那些比已有像素更遠的像素都不會進行繪製.

// zBuffer[x][y]存儲像素深度
foreach Object o in scene
    foreach Pixel p in o
        float depth = calculate depth at p
        if zBuffer[p.x][p.y] > depth
            draw p
            zBuffer[p.x][p.y] = depth
        end
    end
end
View Code

經過深度緩衝,場景能夠以任意順序繪製,若是場景中沒有透明的物體,那麼繪製結果必定正確.這也不是說順序不相關,好比說,若是場景從後往前繪製,你每幀會繪製不少無用的像素.只是深度緩衝方法背後的思想是無序對場景進行排序,這樣會顯著提高性能.並且因爲深度緩衝針對像素工做,而不是每一個對象,所以哪怕遇到三角片重疊的狀況也沒問題

但也不是說深度緩衝能夠解決全部可見性問題.好比說,透明對象在深度緩衝中就不太使用.假設有一灘半透明的水,水底有石頭.若是使用純深度緩衝的方法先畫水體,那麼水體會寫入深度緩衝,這樣就會影響石頭的繪製.爲了解決這個問題,應用深度緩衝先畫全部不透明的物體.而後能夠關掉深度緩衝寫入功能,渲染全部透明物體.但爲了確保在不透明物體的背後的對象不進行渲染仍需進行深度檢查

像顏色同樣,深度緩衝的表示方法也有固定的幾種.最小的深度緩衝區爲16位,但是在節省內存的同時也帶來了一些反作用.在深度值衝突的時候,兩個來自不一樣對象的像素捱得很是近,並且離攝像機很遠,會產生每幀交替出現先後像素切換得狀況.16位浮點數j精度不夠高致使在第1幀時像素A比像素B得深度要低,而在第2幀比像素B深度要大.爲了不這種狀況,大多數現代遊戲都會採用24位或者32位的深度緩衝區

還有很重要的一點是,僅靠深度緩衝並不能解決像素重繪的問題.若是你先畫了一個像素,而後才發現該像素不應畫,那麼前一個像素的渲染就純屬浪費.一個解決方案就是採用先進行深度的pass(一幀畫面能夠屢次渲染,每次爲一個pass,若是某個pass什麼顏色都沒有輸出,只輸出深度,那麼它就是這裏說的先進行深度的pass).將深度計算與着色分開,在最終的光照計算pass以前完成深度計算.

深度緩衝的檢查是基於像素的,這一點要銘記於心.舉個例子,若是有一棵樹徹底被建築遮擋,深度緩衝仍是會對這棵樹的每一個像素進行測試.爲了解決這類問題,商業遊戲常用複雜的剔除或者遮擋算法去消除那些在某些幀徹底看不到的對象.相似的算法有二叉樹分區算法(BSP),入口算法和遮擋體積

  再探世界變換

將旋轉存儲成歐拉角會有個大問題,主要是由於它們不夠靈活.假設有一艘太空飛船在模型座標系中頭部朝向z軸,想將它旋轉到朝任意點P.爲了在歐拉角中完成這個旋轉,你須要判斷這個旋轉的角度.但是這個旋轉不僅影響一個軸,須要多個軸配合旋轉才能完成.這就致使計算比較困難

另外一個問題就是,若是歐拉角關於某個軸旋轉90,同時也會影響到原來的軸的朝向.舉個例子,若是一個對象關於z軸旋轉90,那麼x軸,y軸會重疊,原有角度關係就會丟失.這就稱爲萬向鎖

最後一個是關於將兩個朝向進行插值的問題.假設遊戲有指向下一個目標的箭頭,一旦這個目標達成,這個箭頭就應該轉向下一個節點.因爲視覺緣由,箭頭不該該忽然就指向新節點,它應該個一兩秒的過渡,雖然經過歐拉角能夠完成,但很難使得整個插值好看

因爲種種限制,用歐拉角表示世界空間的旋轉不是一個好方法.能夠採用其餘的數學表示方式來作這件事

    四元數

對於遊戲程序員而言,只要認爲四元數表示關於任意軸旋轉就能夠了.所以有了四元數,不再是受限於關於某個軸旋轉,而是關於任意你想要的軸旋轉

四元數的另一個優勢就是很容易在兩個四元數之間進行插值.有兩種插值方式:線性插值球形插值.球形插值比起線性插值更加準確,同時計算上也可能更加昂貴,取決於系統.

四元數只須要用4個浮點數來存儲信息,這樣更加節省內存.因此就像位置和統一縮放能夠分別存儲爲3D向量和浮點標量同樣,物體的旋轉也能夠存儲爲四元數

無論哪一種用途,遊戲中的四元數所有都是標準四元數,就像單位向量同樣,四元數的長度爲1.一個四元數有着一個向量和一個標量,常常都寫爲q = [qv, qs].向量和標量的計算由旋轉的軸a和旋轉的角度θ決定:

要注意的是旋轉的軸是必須正規化的.若是沒有這樣作,你可能會發現物體以奇怪的方式縮放.若是你剛開始使用四元數,物體以奇怪的方式拉扯,極可能就是由於四元數沒有以正規化的軸來建立

還能夠一個接一個地使用四元數運算.爲了這麼作,四元數應該以相反的順序乘在一塊兒.因此若是一個物體先旋轉q而後旋轉p,那麼乘積j就是pq.兩個四元數相乘採用Grassmann積:

與矩陣同樣,四元數也存在逆四元數.幸運的是,計算標準四元數的逆只須要將份量取逆便可.將向量部分取反也稱爲共軛四元數.

因爲由逆四元數,那麼也有單位四元數.定義以下:

    3D遊戲對象的表示

class 3DGameObject
    Quaternion rotation
    Vector3 position
    float scale
    
    function GetWorldTransform()
        // 先縮放, 後旋轉, 最後平移, 順序很重要
        Matrix temp = CreateScale(scale) * CreateFromQuaternion(rotation) * CreateTranslation(position)
        return temp
    end
end
View Code

  相關資料

Akenine-Moller, Tomas, et. al. Real-Time Rendering (3rd Edition). Wellesley: AK Peters, 2008. 這是一本圖形渲染的資料大全.雖然最新的版本也是幾年前出版的,可是它始終是迄今爲止對圖形程序員而言最佳的渲染讀物

Luna,Frank. Introduction to 3D Game Programming with DirectX 11. Dulles: Mercury Learning & Information, 2012. 講渲染的書常常都會關注到特定的API,因此有不少DirectX和OpenGL的書.Luna從2003年起就開始寫DirectX相關的書,在我剛開始稱爲遊戲程序員的時候就從它的早期做品中學到了不少內容.因此若是你打算使用DirectX來開發遊戲(只爲PC平臺),我會推薦這本書

第5章 遊戲輸入

   輸入設備

絕大多數的輸入形式能夠分紅兩種: 數字與模擬. 數字形式的輸入就是那些只有兩種狀態的"按下"和"沒有按".舉個例子,鍵盤上的按鍵是數字的,空格鍵要麼按下要麼沒按下.絕大多數的鍵盤不存在兩種狀態的中間狀態.模擬形式的輸入就是設備能夠返回某個數字的範圍.一個常見的模擬設備就是搖桿,它會老是返回二維空間的範圍值

輸入系統在其餘方面要考慮的是遊戲中對同時按鍵序列按鍵的支持.這種需求在格鬥遊戲中很流行,玩家會常常同時使用按鍵和序列按鍵來搓招.處理同時按鍵和序列按鍵超出了本章的主題,可是能夠經過狀態機的方式來識別玩家的各類輸入

    數字輸入

enum KeyState
    StillReleased,
    JustPressed,
    JustReleased,
    StillPressed
end

function UpdateKeyboard()
    // lastState和currentState是在其餘地方定義好的數組
    // 記錄了完整的鍵盤信息
    lastState = currentState
    currentState = get keyboard state
end

function GetKeyState(int keyCode)
    if lastState[keyCode] == true
        if currentState[keyCode] == true
            return StillPressed
        else
            return JustReleased
        end
    else
        if currentState[keyCode] == true
            return JustPressed
        else 
            return StillReleased
    end
end
View Code

    模擬輸入

由於模擬設備有一組範圍值,有輸入誤差是很常見的.假設一個搖桿有x值和y值用16位有符號整形表示.這意味着x和y均可以表示-32768到+32768範圍的值.若是一個搖桿放在平面上,玩家不去碰它,理論上因爲搖桿沒有被控制,這個x和y應該都爲0.可是,實際上該值會在0的附近

因爲這個緣由,不多會讓模擬輸入直接應用到角色的移動上,這會讓角色永遠停不下來.這意味着即便你放下手柄,角色還會在屏幕上處處亂走.爲了解決這個問題,大多數遊戲都會採用某種模擬輸入過濾的方式,用於消除輸入的誤差值

一個簡單的無效區域實現就是在x和y值接近0的時候直接設置爲0.若是值的範圍在+/-32K,也就是無效區域範圍大約10%的區間

int deadZone = 3000
Vector2 joy = get joystick input

if joy.x >= -deadZone && joy.x <= deadZone
    joy.x = 0
end

if joy.y >= -deadZone && joy.y <= deadZone
    joy.y = 0
end
View Code

可是這個方法有兩個問題.首先無效區域是一個正方形而不是圓形.也就是說,若是搖桿的x和y都稍微比無效區域小一點,角色仍是不會移動,哪怕實際上已經超過了10%的閾值

另外一個問題就是沒有徹底利用全部有效範圍值.從10%到100%都有速度,可是小於10%就什麼都沒有了.咱們採用了一種解決方案,使得0%到100%都有速度.換句話說,咱們但願有效輸入映射到0%的速度,而不是映射到10%.這樣會將原來的10%到100%的中值55%映射到50%

爲了解決這個問題,咱們不該該將x和y看做獨立的份量,而是將搖桿的輸入視爲一個2D向量.而後就能夠經過向量運算來完成無效區域的過濾

float deadZone = 3000
float maxValue = 32677
Vector2 joy = get joystick input
float length = joy.length()

// 若是長度小於無效區域, 那麼認爲沒有輸入
if length < deadzone
    joy.x = 0
    joy.y = 0
else
    // 計算無效區域到最大值之間的百分比
    float pct = (length - deadZone) / (maxValue - deadZone)
    
    // 正規化向量,而後相乘獲得最終正確結果
    joy = joy / length
    joy = joy * maxValue * pct
end
View Code

  基於事件的輸入系統

想象你開車帶小孩去兒童樂園.小朋友很是興奮,因此每分鐘都問一次"咱們到了嗎?".她不斷地問直到你最終到達目的地,這個問題的答案才爲"是".如今想象不止一個小孩,你載着一車小孩去兒童樂園,因此每一個小孩都來不斷地問這個問題.這不只影響駕駛,還很浪費精力

這個場景本質上是個輪詢系統,每一個小孩都來輪詢結果.對於輸入系統來講,不少代碼都想輪詢輸入,好比主動去檢查空格鍵是否"剛剛按下".每一幀的多個地方都不斷用GetKeyState查詢K_SPACE.這不只致使須要多寫代碼,並且還會容易產生更多的Bug

咱們回到兒童樂園的例子,想象你再次面對一車的孩子.比起讓孩子不斷地重複提問,不如僱傭一個事件機制或者推送系統.在基於事件的系統中,小孩須要註冊它們關心的事件(這個例子中,關心的事件就是到達兒童樂園),而後會在事件發生的時候通知到他們.如今就能夠安靜地開車到兒童公園了,只要你到達以後通知全部小孩已經達到就能夠了

輸入系統一樣也能夠設計爲基於事件的.能夠設計一個接受特定輸入事件的系統.當事件發生的時候,系統就通知全部已經註冊的代碼.因此全部須要知道空格狀態的系統均可以註冊關於空格鍵"剛剛按下"的事件,它們會在事件發生後立刻收到通知

要注意的是,基於事件的輸入系統仍是要輪詢輸入的.就好像你做爲一個司機,必須在行車的過程當中時刻了解是否達到目的地,而後通知孩子們同樣.輸入系統要不斷地輪詢空格鍵,這樣就能夠正確地發出通知.與以前不一樣的地方就是,如今咱們只有一個地方進行輪詢

    基礎事件系統

class MouseManager
    List functions

    // 接受那些將傳遞的參數(int, int)做爲信號的函數
    function RegisterToMouseClick(function handler(int, int))
        functions.Add(handler)
    end
    
    function Update(float deltaTime)
        bool mouseClicked = false
        int mouseX = 0, mouseY = 0

        // 輪詢鼠標點擊
        ...
        
        if mouseClicked
            foreach function f in functions
                f(mosueX, mouseY)
            end
        end
    end
end

// 爲鼠標點擊註冊myFunction
MouseManager.RegisterToMouseClick(myFunction)
View Code

    一個更復雜的系統

// KeyState枚舉 (JustPressed, JustReleased等)
...
// GetKeyState
...

// BindInfo就是字典中的值
struct BindInfo
    int keyCode
    KeyState stateType
end

class InputManager
    // 存儲了全部綁定
    Map keyBindings
    // 只存儲那些激活的綁定
    Map activeBindings
    
    // 使按鍵綁定更加方便
    function AddBinding(string name, int code, KeyState type)
        keyBindings.Add(name, BindInfo(code, type))
    end

    // 初始化全部綁定
    function InitializeBindings()
        // 能夠從文件解析
        // 而後調用AddBinding進行綁定

        // 好比說, "Fire"能夠映射到剛剛釋放的回車鍵
        // AddBinding("Fire", K_ENTER, JustReleased)
        ...
    end

    function Update(float deltaTime)
        // 清除全部上一幀中的激活綁定
        activeBindings.Clear()

        // KeyValuePair有key和value成員,分別是來自字典鍵和值
        foreach KeyValuePair k in keyBindings
            // 使用前面定義的GetKeyState獲得某個按鍵的狀態
            if GetKeyState(k.value.keyCode) == k.value.stateType
                // 若是與綁定一致,則認爲綁定是被激活的
                activeBindings.Add(k.key, k.value)
            end
        end

        // 若是有任何的激活綁定, 那麼優先發送給UI系統
        if activeBindings.Count() != 0
            // 發送激活綁定給UI
            ...
            // 發送激活綁定給遊戲的剩餘部分
            ...
        end
    end
end
View Code 

  移動設備輸入

    觸屏和手勢

大多數移動設備都支持多點觸摸,就是說容許用戶同時多個手指進行操做

一些遊戲經過多點觸摸實現虛擬手柄,這會有一個虛擬搖桿和虛擬按鍵讓玩家交互.這個作法在家用機版本的遊戲上很流行,但有時候會讓玩家很困惑,由於沒有真實手柄的反饋

有一些操做只有觸摸屏才能實現,稱之爲手勢操做,就是一系列的觸摸引起的行動.一個很流行的手勢操做就是iOS設備上的"兩指縮放",用戶能夠用食指和拇指同時靠近來進行縮小,同時遠離進行放大

檢測手勢有不少種方法,其中一種流行的算法就是Rubine算法,它在Dean Rubine在1991年的論文"Specifying gestures by example."中提出.雖然Rubine算法是由筆畫識別發展出來的,可是無論是單個手指的手勢識別仍是多個手指的手勢識別,它均可以實現

    加速器和陀螺儀

    其餘移動設備輸入

  相關資料

Abrash, Michael. Ramblings in Valve Time. http://blogs.valvesoftware.com/abrash/. 這個博客的博主是遊戲產業的傳奇人物Michael Abrash, 講了不少有洞見的虛擬實現和現實加強的文章

Rubine, Dean. "Specifying gestrues by example." In SIGGRAPH'91: Proceedings of the 18th annual conference on computer graphics and interactive techniques. (1991): 329-337. 這是Rubine手勢識別算法的原版論文.

第6章 聲音

   基本聲音

    原始數據

原始數據是指音效設計師使用相似Audacity(http://audacity.sourceforge.net)這樣的工具來建立的最原始的音頻文件.http://kcat.strangesoft.net/openal.html

    聲音事件

聲音事件映射了一個或者多個原始數據文件.聲音事件事實上是由代碼觸發的.因此比起直接播放fs1.wav文件,可能調用一個"footstep"的聲音事件更好.這個想法就是聲音能夠包含多個聲音文件還能有元數據,將這些聲音文件做爲總體

舉個例子,假設有一個爆炸聲音事件.這個事件應該隨機選擇5個WAV文件中的一個來播放.另外地,因爲爆炸是能夠在遠處就聽見的,因此應該有能聽見的距離的元數據.並且爆炸聲音事件應該具備高優先級,這樣即便全部頻道都用完了,爆炸仍是能播放

遊戲中的元數據有多種實現方式.一個方法是使用JSON文件格式,爆炸聲音事件大概會像下面這樣

{
    "name": 「explosion",
    "falloff": 150,
    "priority": 10,

    "sources": [
        "explosion1.wav",
        "explosion2.wav",
        "explosion3.wav"
    ]
}
View Code

在任何狀況下,在解析數據的時候,都可以直接將數據映射到聲音事件類:

class SoundCue
    string name
    int falloff
    int priority
    // 全部源文件的字符串鏈表
    List sources;

    function Play()
        // 隨機選擇一個源文件來播放
        ..
    end
end
View Code

以前提到的系統可能不少遊戲已經夠用了,可是對於腳步聲的例子來講就可能不夠.若是角色能夠在不一樣的路面行走----石頭,沙地,草地等,聲音須要根據當前的地面來斷定.在這種狀況下,系統須要一些方法知道當前地面所屬聲音的分類,而後從中隨機選擇來播放.換句話來講,系統須要一些方法根據當前地面而切換不一樣的音頻集合

{
    "name": "footstep",
    "falloff": 25,
    "priority": "low",
    "switch_name": "foot_surface",

    "sources": 
    [
        { 
            "switch": "sand",
            "sources":
            [
                "fs_sand1.wav",
                "fs_sand2.wav",
                "fs_sand3.wav"
            ]
        },
        {
            "switch": "grass",
            "sources":
            [
                "fs_grass1.wav",
                "fs_grass2.wav",
                "fs_grass3.wav"
            ]
        }
    ]
}
View Code

咱們能夠在原有的SoundCue類上增長功能.一個更好的方案是提取出ISoundCue接口,而後實現SoundCue類和新的SwitchalbeSoundCue類:

interface ISoundCue
    function Play()
end

class SwitchableSoundCue implements ISoundCue
    string name
    int falloff
    int priority
    string switch_name

    // 存儲(string, List)鍵值對的哈希表
    // 好比說("sand", ["fs_sand1.wav", "fs_sand2.wav", "fs_sand3.wav"])
    HasMap sources
    
    function Play()
        // 獲得當前值賦值到switch_name
        // 而後在哈希表中查找鏈表, 而後隨機播放一個
        ...
    end
end
View Code

爲了讓這個實現順利工做,咱們須要以全局的方式獲取和設置切換.經過這樣的方法,角色跑動的代碼就能夠判斷地面而後切換到相應的腳步聲.而後腳步聲的聲音事件Play函數就能夠知道當前所切換的值,而後播放正確的腳步聲

最終,實現聲音事件系統的關鍵就是有足夠的配置信息去判斷播放什麼和怎麼播放.有足夠多的變量數量可以讓音頻設計師獲得更多的靈活性從而更好地設計真實的交互

  3D聲音

雖然不是絕對,可是大多數2D音效都是位置無關的.這意味着對於大多數2D遊戲,聲音的輸出在左右兩個喇叭都是同樣的.有些遊戲也會考慮音源的位置,好比音量隨着距離衰減,但仍是少數2D遊戲才這麼作

對於3D音效和3D遊戲來講,音源的位置就特別重要.大多數音效都有本身獨特的隨着監聽者距離增大衰減的方式,一個例子就是遊戲中的虛擬麥克風能聽到附近的音效

但也不是說3D遊戲不使用2D音效.一些要素好比用戶界面,解說,背景音等還在使用2D音效.可是出如今遊戲世界中的音效一般都是3D音效

    監聽者和發射者

無論監聽者怎麼監聽遊戲世界中的音效,發射者就是發射特定音效的物體.好比說,若是有一堆柴火發出噼裏啪啦的聲音,就會有一個聲音發射器放在那個位置而後放出噼裏啪啦的聲音.而後基於監聽者和火柴聲的發射者之間的距離就能夠算出音量的大小.發射者相對於監聽者的朝向決定了哪一個喇叭有聲音

    衰減

衰減描述了音效的音量隨着遠離監聽者會如何減少.能夠用任何可能的函數去表達衰減.可是,因爲音量的單位分貝是一個對數刻度,線性衰減會產生對數變換關係.這種線性分貝衰減函數一般都是默認方法,可是顯然不是惟一的方法

就像點光源同樣,能夠增長更多的參數.咱們能夠設定在內半徑以內衰減函數不起組用,而外半徑以外就徹底聽不到聲音.這樣的聲音系統容許音頻設計師建立多種衰減函數來展示出隨着不一樣距離有不一樣的音效

    環繞聲

很多平臺都不支持環繞聲的概念----大多數移動設備最多隻支持立體聲.可是,PC和家用機遊戲是能夠有兩個以上喇叭的.在5.1環繞系統中,會有總共5個正式的喇叭和1個用於表現低頻效果的低音炮

傳統的5.1配置是放置3個喇叭在前面,兩個在後面.前面的喇叭放置在左,中,右.然後面的喇叭只有左,右.低音炮的位置不過重要,不過放在室內角落會讓低頻效果更好

  數字信號處理

廣義上講,數字信號處理(DSP)是計算機中表示的信號.在音頻領域中,數字信號處理說的是加載音頻文件而後在修改以後獲得不一樣的效果.舉個數字信號處理相對簡單的例子,加載音頻文件而後增長或者減少它的音高

看起來不必在運行時進行處理,先離線處理好這些效果,而後在遊戲中播放也許會更好.可是運行時使用數字信號處理理由就是它可以節省大量內存.假設有一款劍擊遊戲,有20種不一樣的音效在武器相互碰撞的時候發出.這些音效聽起來就像在野外開闊的場地發出.如今想象一下,若是遊戲有多種場地會發出聲音,除了野外以外,還有洞穴,大教堂,以及大量的其餘地方

問題在於,在洞穴中發出的聲音和在野外發出的聲音聽起來徹底不同.特別是當刀劍在洞穴中發生碰撞時,會有很大的回聲.不用數字信號處理效果,惟一的方法就是爲二十多種刀劍聲再針對不一樣的場地配置.若是有5種不一樣的場景,就意味着有5倍的增加,從而有一百多種的刀劍音效.如今若是須要全部戰鬥音效,而不只僅是刀劍音效,遊戲內存很快就被用盡.可是若是有了數字信號處理效果,一樣的20個刀劍音效會用在不一樣的地方,它們只要根據場所調整成相應的音效便可

    常見數字信號處理效果

遊戲中常見的數字信號處理效果就是以前提到的回聲.任何想在狹窄空間建立回聲效果的遊戲都會琢磨如何實現.一個很是流行的回聲效果庫叫做Freeverb3, http://freeverb3.sourceforge.net有提供.Freeverb3是一個衝量驅動系統,就是說爲了對任何音效實現回聲效果,須要一個音頻文件表達在特殊場景播放的數據

另外一個大量使用的數字信號處理效果就是音高偏移,特別是多普勒偏移.音高偏移會經過調整頻率增長或者減少音效的音高.雖然多普勒偏移會很經常使用.但賽車遊戲中引擎的音高會隨着速度的變化而變化

遊戲中大多數的數字信號處理效果一般都會修改頻率的範圍或者輸出分貝的級別.舉個例子,一個壓縮機縮小了音量的範圍,致使很小的聲音獲得了加強,同時很大的聲音獲得了減少.一般用於統一多個音頻文件,讓它們保持類似的範圍

另外一個例子是低通濾波器,經過刪減頻率的方式減少音量.在遊戲中很常見的就是當玩家附近發生爆炸時的嗡鳴聲.爲了實現效果,時間會拉長,而後應用低通濾波器,接着播放嗡鳴聲

遊戲中還有不少其餘效果,但以上4種是遊戲中最多見的

    區域標記

有一些效果不多會在整個關卡一直使用,好比說迴響效果.一般只有關卡的一些區域才須要使用迴響效果.好比說,若是一關裏有野外區域和洞穴,迴響效果可能只有在洞穴纔會有.有不少種方法能夠添加區域,最簡單的方式就是在地面上標記凸多邊形

  其餘聲音話題

    多普勒效應

若是你站在街上,同時一輛警車打開警報器向你靠近,音高會隨着警車的靠近而提升.相對地,在警車遠離以後,聲音的音高也會下降.

多普勒效應(或者稱之爲多普勒偏移)的出現是因爲聲波在空氣中傳播須要時間.在警車靠近的時候,意味着連續的聲波都比前一個要早到.這就致使了頻率的增長,就會有更高的音高.警車就在你身邊的時候,你能夠聽到聲音的真實音高.最後,在警車遠離你的時候,聲波會須要愈來愈長的時間到達你的位置,致使了低音高的出現.

有趣的是多普勒效應不只在音波中才會出現,而是全部與波相關的狀況都會出現.最有名的就數光波,若是物體靠近會出現紅光,若是物體遠離會出現藍光.可是爲了產生可以注意獲得的偏移,對象要在很是大的空間裏走的很是快.這在地球上的普通速度上不會出現,因此這效果在天文學上才能觀測到

在遊戲中,動態多普勒效應只會在告訴移動的對象身上應用,好比汽車.技術上來說,也能夠在子彈上應用,可是因爲它們太快,咱們一般只播子彈飛走的聲音.因爲多普勒效應會讓音高增長或者下降,只有在支持數字信號處理效果處理音高的時候才能用

    聲音遮擋

  相關資料

Boulanger, Richard and Victor Lazzarini, Eds. The Audio Programming Book. Boston: MIT Press, 2010. 這本書是數字信號處理領域的著做, 涵蓋了大量的示例代碼

Lane,John. DSP Filter Cookbook. Stamford: Cengage Learning, 2000. 這本書比上一本高級,它實現了不少特殊效果,包括壓縮機和低通道濾波

第7章 物理

  平面,射線和線段

    平面

平面是平的,在二維上無限延伸,就如同線能夠在一維空間無限延伸同樣.在遊戲中,咱們一般用平面做爲地面和牆體的抽象,雖然可能還有其餘用法.一個平面能夠有多種表示方法,可是一般遊戲程序員會傾向於用如下表示

P是平面上任意一點,n是平面法線,d是平面到原點的最小距離

回憶一下,三角形是能夠確保在一個平面上.上面的平面表達式會採用的一個緣由就是,給定一個三角形,很容易根據三角形構造出該平面,.在咱們計算出n和d以後,能夠測試若是任意點P,也一樣知足等式,那麼P也在平面上

假設咱們有ABC以順時針頂點序排列.爲了獲得三角形所在平面的表達式,咱們須要先計算三角形的法線

不要忘記了,叉乘的順序是很關鍵的,根據頂點序進行叉乘很重要.因爲ABC頂點序以順時針排列.咱們但願構造向量從A到B和從B到C.在咱們有了兩個向量以後,咱們對向量進行叉乘,而後對結果進行正規化獲得n.

在獲得n以後,咱們須要使用平面上的頂點解出d,因爲.

幸運的是,咱們已經知道三角形上的3個點都在平面上,爲A,B和C.咱們能夠將這些點與n點乘,就會獲得d的值

平面上的全部頂點都會獲得相同的d值,那是由於這是一個投影: n是正規化過的,P是未正規化過的,因此咱們會獲得平面到原點在n方向上的最小距離.這個最小距離無論你採用哪一個頂點都同樣,由於它們都在一個平面上

在獲得n和d以後,咱們能夠將這些值存儲在咱們的Plane數據結構體內:

struct Plane
    Vecotr3 normal
    float d
end
View Code

    射線和線段

射線就是從某個點開始出發,朝某個方向無限延伸.在遊戲中,一般用參數方程表示射線.回憶一下,參數方程是藉助其餘參數來表達的,通常稱之爲t.對於射線來講,參數方程以下:.R0就是起點,而v就是射線穿越的方向.因爲射線是從某點開始,而後朝着某個方向無限延伸,爲了讓射線表達式順利工做,t必須大於等於0.就是說當t爲0時,這個參數方程在起點R0就停了

線段與射線相似,除了既有起點又有終點以外.咱們能夠使用徹底相同的參數方程來表示線段.惟一不一樣的地方就是如今t有了上限,由於線段必須有一個終點

技術上來說,光線投射就是射出一條射線,而後檢查是否打到某個對象.可是,大多數物理引擎都會因爲實際上使用的是線段作檢測的方法讓人迷惑,包括Havok和Box2D.這麼作的緣由是遊戲世界一般會有必定的約束,因此使用線段更加合理.我認可這個術語會讓人困惑.可是,這是遊戲產業的慣用術語,我想保持這個術語的一致性.因此請記得本書中的光線投射實際上使用的都是線段

struct RayCast
    Vector3 startPoint
    Vector3 endPoint
end
View Code

將startPoint和endPoint轉換成線段參數方程的形式至關簡單.R0就是startPoint,v就是endPoint - startPoint.當以這種方式轉換以後,t的值從0到1會對應到光線投射.也就是說,t爲0就在startPoint,而t爲1則在endPoint

  碰撞幾何體

值得一提的是,遊戲對象擁有多個不一樣級別的碰撞幾何體也是很常見的.這樣,簡單的碰撞體能夠先進行第一輪碰撞檢測.在簡單的碰撞體發生了碰撞以後,再選擇更精細的碰撞體進一步檢測碰撞

    包圍球

最簡單的碰撞體就是包圍球(在2D遊戲中則是包圍圈).一個球體能夠經過兩個變量定義----向量表示球體的中心點,標量表示球體的半徑

class BoundingSphere
    Vector3 center
    float radius
end
View Code

    軸對齊包圍盒

對於2D遊戲來講,一個軸對齊包圍盒(AABB, axis-aligned bounding box)就是一個每條邊都平行於x軸或者y軸的矩形.相似地,在3D遊戲中,AABB就是長方體,並且每條棱都與對應的軸平行.無論2D仍是3D,AABB均可以用兩個點表示:最大點和最小點.在2D中,最小點就是左下角的點,而最大點則是右上角的點

class AABB2D
    Vector2 min
    Vector2 max
end
View Code

因爲AABB必須與對應的軸平行,若是一個對象旋轉,那麼AABB就須要拉長.可是對於3D遊戲來講,人形角色一般只繞向上的軸旋轉,這種旋轉並不會讓AABB有太多的變化.所以,使用AABB做爲人形角色的包圍體是很常見的,特別是AABB和球體之間的碰撞計算量很小

    朝向包圍盒

一個朝向包圍盒(或者OBB)相似於軸對齊包圍盒,只是再也不要求與軸平行.就是說,這是一個長方形(2D)或者長方體(3D),並且每條軸再也不須要與對應的座標軸平行.OBB的優勢就是能夠隨着遊戲對象旋轉,所以無論遊戲對象的朝向如何,OBB的精準度都很高.可是這個精準度的提高是有代價的,與AABB相比,OBB的計算花費高太多了.

    膠囊體

在2D遊戲中,膠囊體能夠看做是一個AABB加上兩端各一個半圓.之因此叫膠囊體是由於看上去就跟藥物同樣.若是咱們把膠囊體擴展到3D,就會變成一個圓柱加上兩端各一個半球.膠囊體在人形角色的碰撞體表示中是很流行的,由於他們比AABB精準一些.膠囊體還以看做帶半徑的線段,在遊戲引擎中就是這麼表示的

struct Capsule2D
    Vector2 startPoint
    Vector2 endPoint
    float radius
end
View Code

    凸多邊形

另外一個碰撞幾何體表示的選擇就是使用凸多邊形(在3D領域稱之爲凸包).與你所想的差很少,凸多邊形比其餘方式效率都要低,可是比它們都精準

    組合碰撞集合體

最後一個增長精準度的選擇就是使用組合碰撞幾何體進行碰撞檢測.在人形的例子中,咱們以在頭部使用球形,身幹用AABB,凸多邊形用於手腳等.經過不一樣的碰撞幾何體組合,咱們幾乎能夠消滅漏報

雖然檢測碰撞幾何體組合比檢測模型的三角片要快,但仍是慢得讓你不想用.在人形的例子中,應該先用AABB或者膠囊體進行第一輪碰撞檢測.而後經過以後再進行更精確的測試,好比組合碰撞幾何體.這種方法取決於你是否須要將精準度分級別.在檢測子彈是否打中角色的時候會用到,可是阻擋玩家走進牆裏就不必了

  碰撞檢測

    球與球的交叉

若是兩個球得半徑之和小於兩個球之間的距離,那麼就發生了交叉,可是,計算距離會用到平方根,爲了不平方根的比較,一般都會使用距離的平方與半徑之和的平方進行比較

function SphereIntersection(BoundingSphere a, BoundingSphere b) {
    // 構造兩個中心點的向量,而後求長度的平方
    Vector3 centerVector = b.center - a.center;
    // 回憶一下, v的長度平方等於v點乘v
    float distSquared = DotProduct(centerVector, centerVector);
    
    // distSquared是否小於等於半徑和的平方?
    if distSquared < ((a.radius + b.radius) * (a.radius + b.radius))
        return true
    else
        return false
    end
end
View Code

    AABB與AABB交叉

如同球體交叉同樣,AABB的交叉計算即便在3D遊戲中也是很廉價的

function AABBIntersection(AABB2D a, AABB2D b) {
    bool test = (a.max.x < b.min.x) || (b.max.x < a.min.x) || (a.max.y < b.min.y) || (b.max.y < a.min.y)
    return !test
end
View Code

    線段與平面交叉

首先,咱們有線段和平面的兩個等式:咱們想判斷是否存在一個值t,使得點落在平面上.話句話說,咱們想判斷是否存在t值,使得R(t)知足平面等式中P的值.因此能夠將R(t)帶入P:接下來的問題就是解出t的值:.

回憶一下線段,起點則對應於t = 0,而終點則對應於t = 1.因此當咱們解出t時,若是t的值在這個範圍外,那麼能夠忽略它.特別的是,負值表示線段朝向遠離平面的方向.

一樣值得注意的是,若是v與n點乘結果爲0,會產生除0異常.回想一下,若是兩個向量點乘結果爲0,意味着兩個向量垂直.在這種狀況下,表示v與平面平行,所以不會有交叉,惟一交叉的狀況就是線段就在平面上.在任何狀況下,除0異常是必需要考慮的.還有一點就是,若是線段與平面真的相交,咱們能夠將t替換爲交點的值

// 返回值就是這個結構體
struct LSPlaneReturn
    bool intersects
    Vector3 point
end

// 記住光線投射實際上就是線段
function LSPlaneIntersection(RayCast r, Plane p) {
    LSPlaneReturn retVal
    retVal.intersects = false

    // 計算線段方程的v
    Vector3 v = r.endPoint - r.startPoint

    // 檢查線段是否與平面平行
    float vDotn = DotProduct(v, p.normal)
    if vDotn is not approximately 0
        t = -1 * (DotProduct(r.startPoint, p.normal) + p.d)
        t /= vDotn
    
        // t應該介於起點與終點(0到1)之間
        if t >= 0 && t <= 1
            retVal.intersects = true
            
            // 結算交點
            retVal.point = r.startPoint + v * t
        end
    else
        // 測試起點是否在平面上
        ...
    end
    
    return retVal
end
View Code

    線段與三角片交叉

假設你須要算出用線段表示的子彈與某個三角片之間是否發生碰撞.第一步就是算出三角片所在的平面.在有了這個平面以後,你能夠看看這個平面是否與線段相交.若是它們相交,你就會獲得與三角形所在平面相交的交點.最後,因爲平面是無限大的,咱們要檢測該點是否在三角片以內

若是三角形ABC以順時針頂點序表達,第一步就是構造一個從A到B的向量.而後構造一個向量從A到P,P就是交點的位置.若是旋轉向量AB到AP是順時針,P就在三角形一側.這個檢測對每一條邊(BC和CA)進行計算,若是每一個都是順時針,也就是每條邊算出P都在三角形一側,就能夠得出結論說P就在三角形內部

可是咱們怎麼判斷是順時針仍是逆時針?若是咱們在右手座標系中計算AB x AP,獲得的向量會朝向書頁內.回想一下右手座標系中順時針序三角形的法線也是朝向書頁內部的.這種狀況下,叉乘向量的方向就與三角形法線方向一致.若是兩個正規化過的向量點乘值爲正值,它們朝向大體一致.因此若是你將AB x AP的結果正規化後再與法線點乘結果爲正數,那麼P就在AB所在的三角形一側

這個算法能夠用於三角形的其餘邊.它看起來不只可以做用於三角形,對於任意在同一平面上的多邊形也一樣適合

// 這個函數只能在頂點爲順時針頂點序及共面下正常工做
function PointInPolygon(Vector3[] verts, int numSides, Vector3 point) {
    // 計算多邊形的法線
    Vector3 normal = CrossProduct(Vector3(verts[1] - verts[0]), Vector3(verts[2] - verts[1]))
    normal.Normalize();
    
    // 臨時變量
    Vector3 side, to, cross;

    for int i = 1, i < numSides, i++
        // 從上一個頂點到當前頂點
        side = verts[i] - verts[i - 1];
        // 從上一個頂點到point
        to = point - verts[i - 1]
    
        cross = CrossProduct(side, to);
        cross.Normalize();

        // 表示在多邊形外部
        if DotProduct(cross, normal) < 0
            return false
        end
    loop

    // 必須檢測最後一條邊,就是最後一個頂點到第一個頂點
    side = verts[0] - verts[numSize - 1]
    to = point - verts[numSize - 1]
    cross = CrossProduct(side, to)
    cross.Normalize()

    if DotProduct(cross, normal) < 0
        return false
    end
    
    // 在全部邊內部
    return true
end
View Code

    球與平面交叉

在遊戲中球能夠與牆發生碰撞,爲了對這個碰撞準確建模,能夠使用球與平面的交叉.給定平面的n和d,碰撞檢測最簡單的方法就是創建一個新得平面,對齊球心而且與原有平面平行.若是兩個平面距離比球的半徑要小,那麼就發生了交叉.就像球與球的交叉同樣,球與平面的交叉也不復雜

function SpherePlaneIntersection(BoundingSphere s, Plane p)
    // 經過平面的法線p.normal及平面的點s.center計算平面的d
    float dSphere = -DotProduct(p.normal, s.center)
    // 檢查是否在範圍以內
    return (abs(d - dSphere) < s.radius)
end
View Code

    球形掃掠體檢測

到目前爲止,咱們講了即時碰撞檢測算法.就是說那些算法只能檢查當前幀中發生的碰撞.雖然不少狀況下都有效,可是也有不少不適用的時候

若是子彈朝着紙張發射,不存在子彈與紙張交錯在一塊兒的準確的一幀.這是由於子彈速度很快,而紙張很薄.這個問題一般被稱爲子彈穿過紙張問題.爲了解決這個問題,可以進行連續碰撞檢查(CCD)的能力是必要的

球形掃掠體檢測中,有兩個移動中的球體.而輸入則是兩個球在上一幀的位置(t = 0)和這一幀的位置(t = 1).給定這些數據,咱們能夠判斷兩幀之間兩個球是否發生了碰撞

因此不像即時碰撞檢測的球與球交叉那樣,它是不會由於不一樣幀而丟失交叉

你可能注意到,球形掃掠體看上去和膠囊體差很少.那是由於球形掃掠體確實就是膠囊體.球形掃掠體有起點,終點及半徑,徹底就是一個膠囊體.因此膠囊體與膠囊體的碰撞徹底能夠在這裏使用

如同線段與平面交叉問題同樣,先解等式會對咱們有幫助.並且解決膠囊體的碰撞自己也是一個很是流行的問題,因此常常會在面試的時候被問到.因爲它涉及不少遊戲程序員須要掌握的線性代數概念

給定球體的上一幀和這一幀的位置,就能夠將球的位置轉換爲參數方程.整個轉換獲得的函數能夠用於光線投射.因此給定球P和球Q,咱們能夠用兩個參數方程表示:

咱們想要求的就是t,t就是兩個球距離等於半徑之和的時候,由於那就是發生交叉的時候.數學表示以下:

這個等式的問題就是咱們須要一些方法來避開長度運算.訣竅j就在於向量v的長度平方就等於自身進行點乘:

所以,若是對公式兩邊取平方,咱們能夠獲得以下等式:

如今咱們有了這個等式,就能夠對t求解.這個過程有點複雜.首先,將P(t)h和Q(t)替換進來

而後,咱們能夠提取因子整理公式:

爲了更進一步簡化,能夠繼續替換:

因爲點乘被加法隔開了,咱們能夠對(A + Bt)項使用了FOIL(first, outside, inside, last)法則:

若是咱們將(rp + rq)2 移到等式左邊,而後再作一下替換,會的到更加熟悉的等式:

你可能會想起這種二次方程能夠用好久沒用過的方法來解:平方根下的值,咱們稱之爲判別式,是很是重要的.有3種狀況: 小於0, 大於0, 等於0,以及大於0

若是判別式小於0,t就沒有實根,就是說沒有交叉發生.若是判別式等於0,意味着兩個球相切.若是判別式大於0,意味着兩個球徹底交叉在一塊兒,兩個根比較小的就是最先發生交叉的時候

在咱們解出t的值之後,咱們能夠看一下若是值介於0和1之間,編碼的時候就要注意了.記住t值若是大於1則是這一幀以後,若是小於0則是這一幀以前.所以,t值超出範圍的狀況不是這個函數接受的範圍

// p0 / q0 是上一幀的球體
// p1 / q1 是這一幀的球體
function SweptSphere(BoundingSphere p0, BoundingSphere q0, BoundingSphere p1, BoundingSphere q1) {
    // 首先計算v用於參數方程
    Vector3 vp = p1.center - p0.center
    Vector3 vq = q1.center - q0.center

    // 計算A和B
    // A = P0 - Q0
    Vector3 A = p0.center - q0.center
    // B = vp - vq
    Vector3 B = vp - vq

    // 計算a, b和c
    // a = B dot B
    float a = DotProduct(B, B)
    // b = 2(A dot B)
    float b = 2 * (DotProduct(A, B))
    // c = (A dot A) - (rp + rq) * (rp + rq)
    float c = DotProduct(A, A) - ((q0.radius + p0.radius) * (q0.radius + p0.radius))
    // 如今計算判別式(b * b - 4ac)
    float disc = b * b - 4 * a * c
    if disc >= 0
        // 若是咱們須要t的值,咱們能夠用如下的方式解出
        // t = (-b - sqrt(disc)) / (2a)
        // 可是, 這個函數只要回答真就能夠了
        return true
    else
        // 沒有實數解,因此沒有交叉發生
        return false
    end
end
View Code

    響應碰撞

咱們能夠使用前面提到的各類算法來檢測碰撞.可是在檢測結果出來以前,遊戲應該如何處理呢?這就是響應碰撞的問題.一些狀況下,響應會很簡單:一個或多個對象可能會死亡而後從遊戲世界中移除.稍微複雜一點的響應就是一些好比火簡這樣的物體會減小敵人的生命值

可是若是兩個對象須要相互彈開呢?好比兩個小行星碰撞.一個簡單的解決方法就是根據碰撞的方向讓速度反向.但這麼作會有不少問題.一個問題就是行星會被卡住.假設兩個行星在某一幀發生碰撞,那麼就會引發速度取反.可是若是它們速度很慢,致使下一幀還繼續碰撞呢?那麼速度就會無限地循環變化下去,而後就會卡住

爲了解決第一個問題,咱們須要找到兩個行星發生碰撞的準確位置,儘管它們每幀都會發生.因爲行星使用包圍球,咱們能夠使用球與球之間的交叉來找出碰撞發生的時間.在咱們算出時間以後,咱們要將回滾到那個時間點的位置.而後咱們就能夠根據狀況添加速度了(咱們初步方案是取反).而後用新的速度在剩餘的時間力更新對象的行爲.

可是,這裏的碰撞響應仍是有一個大問題:將速度取反不是一個正確的行爲.咱們能夠看一下爲何,假設你有兩個行星朝着同一個方向運動,一個在另外一個前面.前面的行星比後面的行星速度要慢一些,因此最終後面的行星會跟上前面的行星.就是說當兩個行星碰撞的時候,它們會徒然朝另外一個方向運動,這確定是不對的

因此比起將速度取反,咱們其實是想根據發生碰撞的平面的法線將速度進行反射.若是行星與牆面碰撞,計算碰撞的牆面是很簡單的,牆面就是咱們要的平面.可是兩個球在某個點碰撞的例子,咱們所要的平面就是碰撞點的切線平面

爲了構造切線平面,咱們首先要獲得發生碰撞的點.這個能夠用線性插值算出.若是有兩個球體在某個點發生碰撞,這個點確定就在兩個球心所連成的線段上.它所在的位置就取決於兩個球的半徑.若是咱們有兩個BoundingSphere的實例A和B在某個點發生碰撞,這個點能夠經過線性插值計算出來 Vector3 pointOfIntersection = Lerp(A.position, B.position, A.radius / (A.radius + B.radius))

而找出切線平面很簡單,就是一個球心指向另外一個球心的向量,而後正規化.有了平面上的點和平面的法線,咱們就能夠建立在這個碰撞點上的切線平面了.雖然碰撞響應須要對速度進行反射,可是咱們只要有平面的法線就能夠了

有了這個反射以後的速度,行星碰撞看上去好多了,雖然看上去仍是很奇怪,由於行星的反射先後都會保持恆定速度.在現實中,兩個對象碰撞的時候,有一個恢復係數,衡量兩個物體在碰撞後的反彈程度: CR = 碰撞後的相對速度 / 碰撞前的相對速度

在彈性碰撞(CR > 1)的狀況下,碰撞後的相對速度大於碰撞前的相對速度.在另外一方面,在無彈性碰撞(CR < 1)就會致使碰撞後相對速度更低.在行星的例子中,咱們更傾向於無彈性碰撞,除非它們是魔法行星

    優化碰撞

咱們討論的全部碰撞檢測算法都只能檢測一對物體間的碰撞.一個可能會遇到的問題是,若是有大量的物體須要進行碰撞檢測呢?假設咱們有10000個對象在遊戲世界中,而後想檢測咱們的角色與任意一個物體是否發生碰撞.原始的方法須要進行10000次碰撞檢測:將角色和每個物體進行檢測.這樣就很是沒有效率,特別是在檢測距離很遠的對象的時候

因此必須對遊戲世界進行分區,這樣主角只要跟所在區域的對象進行碰撞檢測就能夠了.2D遊戲中的一種分區方法就是四叉樹,遊戲世界會遞歸切割成矩形,直到每個葉子節點只引用一個對象

在進行碰撞檢測的時候,程序會優先檢測最外層的四叉樹矩形中的玩家所在象限的對象是否與玩家發生了碰撞,這樣就馬上剔除了3/4的對象.而後這個遞歸算法會不斷進行下去,直到找到全部潛在與玩家發生碰撞的對象.在只剩下少數的對象以後,就能夠對每一個對象進行碰撞體檢測了.

四叉樹不是惟一的分區方法.還有不少方法,好比二進制空間分割(BSP)及八叉樹(3D版的四叉樹).大多數算法都是基於空間的,還有一些是啓發式分組的.

  基於物理的移動

若是一個遊戲對象在遊戲世界中移動,就有一些物理會用於模擬這種運動.牛頓物理(也叫做經典物理)在17世紀被牛頓用公式表示出來.遊戲中會大量使用牛頓物理,這是一個很好的模型,由於遊戲對象不會以光速運動.牛頓物理由多個不一樣部分組成,可是本節聚焦最基礎的部分:線性力學.就是沒有旋轉的運動

    線性力學概覽

線性力學的兩個基石是力與質量.是一種相互做用,能夠致使物體運動.力有着方向和大小,所以能夠用向量表示.質量表示物體所含物質的量.對於力學來講,主要的關係是質量越大,物體就越難運動

若是一個足夠大的力做用到物體上,理論上它會開始加速.這個想法就是牛頓第二定律: F = m * a

這裏, F是力, m是質量, a是加速度.因爲力等於質量點成加速度,因此加速度能夠經過力除以質量獲得.給定一個力,這個等式就能夠計算出加速度

一般而言,咱們想表示加速度爲一個接受時間的函數, a(t). 如今加速度有了與速度和位置的關係.這個關係就是位置函數(r(t))的導數就是速度函數(v(t)),而速度函數的導數就是加速度函數.這裏的符號表達式: v(t) = dr / dt, a(t) = dv / dt

可是這個公式在遊戲中求值不是很方便.在遊戲中,咱們但願給對象一個做用力,而後這個力就持續產生加速度.在咱們有了加速度以後,咱們就能夠獲得這段時間內的速度.最終,獲得了速度,咱們就能夠判斷物體的位置.全部這些都要在這段時間的每一幀都進行計算.換句話說,咱們要的是對導數反向操做,你可能會想到積分.但不是全部積分咱們都感興趣.關於積分你可能會想到咱們最熟悉的不定積分:.可是在遊戲中,不定積分沒有什麼用,首先由於它不能直接使用.還有就是,咱們但願每一幀都經過加速度算出速度和位置.意味着使用數值積分,一種能夠每幀都使用其計算積分近似值的方法.若是你要求曲線下的面積,能夠經過梯形法則,算出數值積分

    可變時間步長帶來的問題

在咱們討論數值積分以前,須要先解決基於物理的移動問題.在使用數值積分以後,你就或多或少地不能使用可變的時間幀.這是由於數值積分的準確性取決於時間步長.步長越短就越精確

這意味着若是每幀的時間不長都改變,近似值也會每幀變更.若是準確性改變,行爲也會有顯著的變化.想象你正在玩一款角色能夠跳躍的遊戲,就像<<超級馬里奧兄弟>>.平時玩遊戲的時候,角色的起跳速度都同樣.可是忽然間,幀率下降,而後你看到馬里奧跳得更高了.這種狀況是因爲數值積分的百分比偏差在低幀率的時候放大了,因此跳得更高了

因爲這個緣由,任何遊戲使用物理計算位置得時候,都不要使用可變的時間步長.物理計算用可變步長固然是能夠的,可是這樣就會很複雜

    力的計算

數值積分讓咱們能夠由加速度計算出速度,而後由速度算出位置.可是爲了算出加速度,咱們須要力和質量.這裏有多種多樣的力須要考慮.有些力,好比重力,一直做用在物體身上.而有些力能夠用衝量代替,就是那些只在一幀起做用的力

舉個例子,跳躍可能最早受到衝量的做用而起跳.可是跳躍開始以後,重力的做用下,角色就會回到地面.因爲多個力能夠同時做用在物體上,在遊戲中最多見的作法就是算出全部力的協力,而後除以質量算出加速度: 加速度 = 協力 / 質量

    歐拉和半隱式歐拉積分

最簡單的數值積分就是歐拉積分,以瑞士著名數學家命名.在歐拉積分中,新的位置是由舊的位置加上速度乘以時間步長獲得.而後速度以相似的方式經過加速計算出來

class PhysicsObject
    // 物體上全部做用力
    List forces
    Vector3 acceleration, velocity, position
    float mass
    
    function Update(float deltaTime)
        Vector3 sumOfForces = sum of forces in forces
        acceleration = sumOfForces / mass

        // 歐拉積分
        position += velocity * deltaTime
        velocity += acceleration * deltaTime
    end
end
View Code

雖然歐拉積分很簡單,它並無真正表現得很是準確.一個大問題就是位置是用舊得速度算出來的,而不是時間步長以後的新速度.這樣會隨着時間的推移讓偏差不斷地積累

一個簡單的改法就是將歐拉積分的位置和速度更新順序調換.就是說如今位置是使用新的速度來計算.這就是半隱式歐拉積分,它會更加合理,更加穩定,著名的遊戲物理引擎Box2D就用了這種方法.可是,若是咱們想要更加精確,咱們就要使用更加複雜的數值積分方法

    Verlet積分法

Verlet積分法中,首先算出本次時間步長中點的速度值.而後將它看做平均速度計算整個步長的位置.而後,加速度根據力和質量計算出來,最終利用新的加速度在步長結束的時候計算出速度.

function Update(float deltaTime)
    Vector3 sumOfForces = sum of forces in forces

    // Verlet積分法
    Vector3 avgVelocity = velocity + acceleration * deltaTime / 2.0f
    // 位置用平均速度算出來
    position += avgVelocity * deltaTime
    // 計算新的加速度和位置
    acceleration = sumOfForces / mass
    velocity = avgVelocity + acceleration * deltaTime / 2.0f
end
View Code

本質上Verlet積分法使用平均速度計算位置.這比起兩種歐拉積分都要準確得多.同時計算也更加昂貴,雖然顯然比歐拉方法要好,但還不夠

    其餘積分方法

還有很多其餘積分方法可能會在遊戲中用到,可是它們有點複雜.它們當中最受歡迎的方法是四階Runge-Kutta方法.它本質上是使用泰勒近似求解的結果表示運動的微分方程的近似解.這個方法無可爭議地比歐拉和Verlet方法都要準確,但也更慢.對於那些須要高準確度的遊戲(好比汽車模擬)來講是有意義的,可是對於多數遊戲而言都太重

    角力學

角力學是關於旋轉的力學研究.好比說,你可能須要這種物理效果,就是物體圍繞另外一個物體旋轉.就像線性力學有質量,做用力,加速度,速度,位置同樣,角力學有轉動慣量,力矩,角加速度,角速度和角度.角力學的機制比線性力學還要複雜一些,但也不會複雜不少.就像線性力學同樣,角力學也會在旋轉的時候用到積分.注重效果的遊戲大多會用到角力學,可是因爲大多數遊戲只用線性力學,因此這裏選擇只講線性力學

  相關資料

Ericson, Christer. Real-time Collision Detection. San Francisco: Morgan Kaufmann, 2005. 這本書是碰撞檢測大全.書中有各類幾何體的碰撞及排列組合.要注意的是,本書設計大量數學知識,因此在看以前最好先適應數學表達的方法

Fielder, Glenn. Gaffer on Games - Game Physics. http://gafferongames.com/game-physics/. 這個博客講了不少話題,可是物理學的話題更加吸引人注目.涵蓋了四階Runge-Kutta 積分,角力學,彈簧等話題

第8章 攝像機

  攝像機的類型

    固定攝像機

嚴格來說,固定攝像機就是那種永遠在同一個位置的攝像機.這種固定的攝像機一般只用於很是簡單的3D遊戲.術語"固定攝像機"也能夠擴展爲根據玩家的位置而擺放在預先定義好的位置.

    第一人稱攝像機

第一人稱攝像機是以玩家的視角來體驗遊戲世界的.因爲攝像機是角色視角,第一人稱攝像機是最讓人身臨其境的攝像機類型.第一人稱攝像機在第一人稱涉及遊戲中很是流行,可是在其餘遊戲好比<<上古卷軸: 天際>>也會用到

第一人稱遊戲最多見的作法就是在眼睛附近放攝像機,這樣其餘角色和物體纔會有相應的高度.可是,問題是不少第一人稱遊戲都但願可以顯示角色手部.若是攝像機在眼睛位置,當角色向前看的時候是看不到手的.還有一個問題就是,若是玩家的角色模型繪製出來,你會從接近眼睛位置的攝像機看到很奇怪的效果

爲了解決以上問題,大多數第一人稱遊戲都不會使用普通模型.取而代之的是,使用一個特殊的只有手臂(可能還有腿)的對解剖來說不正確的位置.這樣,就算向前看,玩家總能看到手上有什麼.若是使用了這個方法,一些特殊情形,好比看到本身的倒影這種狀況就須要考慮了.不然,玩家看到空氣中懸掛的手臂,就會被嚇到

    跟隨攝像機

跟隨攝像機會在一個或者多個方向上跟在目標後面.

有的跟隨攝像機與目標始終保持固定距離,而有的則與目標保持彈性距離.有的在跟隨角色的過程當中會旋轉,而有的不會.有的甚至容許玩家忽然轉身看背後有什麼.有大量的這種類型的攝像機的各類各樣的實現

    場景切換攝像機

愈來愈多的遊戲會用到場景切換,就是在播放遊戲劇情的時候,從玩家的攝像機切過去的一種手法.在3D遊戲中實現場景切換,要預先在場景中放置動畫中用到的固定攝像機.不少場景切換會使用電影設備,好比移動鏡頭.爲了達到效果,會用到樣條系統

  透視投影

    視場

觀看世界視野的廣度及角度, 稱爲視場(FOV).對人類來講,咱們的眼睛提供了180°的視野,可是並非每一個角度都有等量的清晰度.雙目並視的時候,兩隻眼均可以同時看到大約120°的視場.而剩餘的市場在邊緣處,可以快速地發現運動,可是不夠清晰

推薦的觀看高清電視的距離很大程度取決於向你推薦的人,THX推薦的觀看距離爲取對角線長度乘以1.2.因此50英寸的電視應該從60英寸的距離觀看.這個距離,電視機會有大約40°的視角度,就是說電視機會佔了觀看者40°的視場.在這種條件下,只要給遊戲留下多於40°的視場,幾乎全部的角色都能看得一清二楚.這就是爲何家用機遊戲須要大約65°的視場

可是若是家用機遊戲替換成PC遊戲會怎樣?在PC的條件下,顯示器會佔用玩家更多的視場.這種條件下一般有90°以上的視場,這個視場及視角度的差別會讓一些玩家感到不舒服.這就是爲何遊戲世界須要把視場收窄,讓大腦回到90°的視場感知.因爲這個問題,讓玩家選擇本身習慣的視場是一個不錯的選項

若是視場變得太大,就會有魚眼效果,屏幕的邊緣變得彎曲.就相似於攝影中使用了廣角鏡頭同樣.大多數遊戲不容許玩家選擇過高的視場

    寬高比

寬高比就是觀看世界視口的寬度和高度的比率

標準的高清分辨率720p(就是1280 x 720)就是16:9寬高比的例子

  攝像機的實現

    基礎的跟隨攝像機

在基礎的跟隨攝像機中,攝像機老是直接跟隨在某個對象後面,並且保持固定的距離

回憶一下爲攝像機建立觀察矩陣,須要3個參數:眼睛的位置(攝像機的位置),攝像機觀察的目標,以及攝像機的上方向量.在基礎跟隨攝像機中,眼的位置能夠設置爲目標的水平和垂直偏移.在計算出位置以後,就能夠計算其餘參數,而後傳遞給CreateLookAt函數:

// tPos, tUp, tForward = 位置,上方和前方向量
// hDist = 水平跟隨距離
// vDist = 垂直跟隨距離
function BasicFollowCamera(Vector3 tPos, Vecotr3 tUp, Vector3 tForward, float hDist, float vDist) {
    // 眼睛就是目標位置的偏移量
    Vector3 eye = tPos - tForward * hDist + tUp * vDist
    // 攝像機向前的方向是從眼睛到目標
    Vector3 cameraForward = tPos - eye
    cameraForward.Normalize()

    // 叉乘計算出攝像機的左邊及上方向量
    Vector3 cameraLeft = CrossProduct(tUp, cameraForward)
    cameraLeft.Normalize()
    Vector3 cameraUp = CrossProduct(cameraForward, cameraLeft)
    cameraUp.Normalize()
    
    // CreateLookAt的參數爲eye, target, 以及up
    return CreateLookAt(eye, tPos, cameraUp)
end
View Code

雖然基礎跟隨攝像機會跟着目標在遊戲中移動,看起來很是僵硬.攝像機老是保持固定距離,沒有彈性.當旋轉的時候,這個基礎跟隨行爲會讓人不知道是世界在轉仍是人在轉.並且,基礎跟隨攝像機沒有一個合理的速度,它的速度就是目標的速度.因爲以上種種緣由,基礎跟隨攝像機不多在遊戲中使用.雖然它提供了簡單的解決方案,可是顯得很不優雅

一個簡單的改善方法就是讓攝像機有一個跟隨目標速度調整跟隨距離的函數.好比說平時跟隨的時候速度爲100,可是當目標全速移動的時候,這個距離爲200.這個簡單改變可以提高基礎跟隨攝像機的速度感,可是還有不少問題沒有辦法解決

    彈性跟隨攝像機

有了彈性跟隨攝像機,就不會因爲目標的朝向或者位置改變而忽然變化,而是攝像機會在幾幀的過程當中逐漸變化.實現方式是同時設定好理想位置與現實位置.理想位置每幀馬上變化,就跟基礎跟隨攝像機同樣(可能會有跟隨距離調整函數).而後真正的攝像機位置在後續幾幀慢慢跟隨到理想位置上,這樣就可以創造平滑的鏡頭效果

這種實現方式是經過虛擬彈簧將理想攝像機和真實攝像機鏈接到一塊兒實現的.每當理想攝像機位置變化時,彈簧都被拉伸.若是理想攝像機位置不變,隨着時間的推移,真實位置總會被調整到理想位置.

彈簧的效果能夠由彈性常量來控制.這個常量越大,彈簧就越僵硬,就是說攝像機歸爲得越快.實現彈性跟隨攝像機,須要肯定每幀的攝像機速度和真實攝像機位置.所以最簡單的實現就是使用一個類來實現.這個算法大概的工做方式是首先基於這個彈性常量計算出加速度.而後將加速度經過數值積分計算出攝像機的速度,而後再進一步計算位置

class SpringCamera
    // 水平和垂直跟隨距離
    float hDist, fDist
    // 彈性常量: 越高表示越僵硬
    // 一個好的初始值很大程度取決於你想要的效果
    float springConstant
    // 阻尼常量由上面的值決定
    float dampConstant

    // 速度和攝像機真實位置向量
    Vector3 velocity, actualPosition
    
    // 攝像機跟隨的目標
    // (有目標的位置, 向前的向量, 向上向量)
    GameObject target

    // 最終的攝像機矩陣
    Matrix cameraMatrix

    // 這個幫助函數從真實位置及目標計算出攝像機矩陣
    function ComputeMatrix()
        // 攝像機的前向是從真實位置到目標位置
        Vector3 cameraForward = target.position - actualPosition
        cameraForward.Normalize()

        // 叉乘計算出攝像機左邊,而後計算出上方
        Vector3 cameraLeft = CrossProduct(target.up, cameraForward)
        cameraLeft.Normalize()
        Vector3 cameraUp = CrossProduct(cameraForward, cameraLeft)
        cameraUp.Normalize()

        // CreateLookAt參數爲 eye, target 及 up
        cameraMatrix = CreateLookAt(actualPosition, target.position, cameraUp)
    end

    // 初始化常量及攝像機,而後初始化朝向
    function Initialize(GameObject myTarget, float mySpringConstant, float myHDist, float myVDist)
        target = myTarget
        springConstant = mySpringConstant
        hDist = myHDist
        vDist = myVDist
        
        // 阻尼常量來自於彈性常量
        dampConstant = 2.0f * sqrt(springConstant)
        
        // 起初,設置位置爲理想位置
        // 就跟基礎跟隨攝像機的眼睛位置同樣
        actualPosition = target.position - target.forward * hDist + target.up * vDist
        
        // 初始化攝像機速度爲0
        velocity = Vector3.Zero
        
        // 設置攝像機矩陣
        ComputeMatrix()
    end

    function Update(float deltaTime)
        // 首先計算理想位置
        Vector3 idealPosition = target.position - target.forward * hDist + target.up * vDist
        
        // 計算從理想位置到真實位置的向量
        Vector3 displacement = actualPosition - idealPosition
        // 根據彈簧計算加速度, 而後積分
        Vector3 springAccel = (-springConstant * displacement) - (dampConstant * velocity)
        velocity += springAccel * deltaTime
        actualPosition += velocity * deltaTime

        // 更新攝像機系統
        ComputeMatrix()
    end
end
View Code

    旋轉攝像機

旋轉攝像機會在目標附近旋轉.旋轉攝像機最簡單的實現方法就是存儲攝像機的位置及與目標的偏移,而不是直接記錄攝像機的世界座標系位置.這是由於旋轉老是關於原點旋轉的.因此若是攝像機位置做爲偏移記錄下來,旋轉j就能夠以目標對象爲原點進行旋轉,這樣就獲得了咱們想要的旋轉效果

class OrbitCamera
    // 攝像機向上的向量
    Vector3 up
    // 目標偏移
    Vector3 offset
    // 目標對象
    GameObject target
    // 最終的攝像機矩陣
    Matrix cameraMatrix

    // 初始化攝像機狀態
    function Initialize(GameObject myTarget, Vector3 myOffset)
        // 在y軸朝上的世界裏,up向量就是Y軸
        up = Vector3(0, 1, 0)
        
        offset = myOffset
        target = myTarget

        // CreateLookAt參數爲eye, target和up
        cameraMatrix = CreateLookAt(target.position + offset, target.position, up)

    end

    // 根據這一幀的yaw/pitch 增長角度進行更新
    function Update(float yaw, float pitch)
        // 建立一個關於世界向上的四元數
        Quaternion quatYaw = CreateFromAxisAngle(Vector3(0, 1, 0), yaw)
        // 經過這個四元數變換攝像機偏移
        offset = Transform(offset, quatYaw)
        up = Transform(up, quatYaw)
        
        // 向前就是target.position - (target.position + offset)
        // 恰好就是-offset
        Vector3 forward = -offset
        forward.Normalize()
        Vector3 left = CrossProduct(up, forward)
        left.Normalize()
    
        // 建立關於攝像機左邊旋轉的四元數值
        Quaternion quatPitch = CreateFromAxisAngle(left, pitch)
        // 經過這個四元數變換攝像機偏移
        offset = Transform(offset, quatPitch)
        up = Transform(up, quatPitch)

        // 如今計算矩陣
        cameraMatrix = CreateLookAt(target.position + offset, target.position, up)

    end
end
View Code

    第一人稱攝像機

使用第一人稱攝像機,攝像機的位置老是放在角色的相對位置上.因此當角色在世界中移動的時候,攝像機依然是玩家的位置加上偏移.雖然攝像機偏移老是不變,目標位置能夠不斷變化.這是由於大多數第一人稱遊戲都支持處處看可是不改變位置的功能

class FirstPersonCamera
    // 以角色位置爲原點的攝像機偏移
    // 對於y軸向上的世界,向上就是(0, value, 0)
    Vector3 verticalOffset
    // 以攝像機爲原點的目標位置偏移
    // 對於z軸向前的世界,向前就是(0, 0, value)
    Vector3 targetOffset
    // 總的偏航和俯仰角度
    float totalYaw, totalPitch
    // 攝像機所在的玩家
    GameObject player
    // 最終的攝像機矩陣
    Matrix cameraMatrix

    // 初始化全部攝像機參數
    function Initialize(GameObject myPlayer, Vector3 myVerticalOffset, Vector3 myTargetOffset)
        player = myPlayer
        verticalOffset = myVerticalOffset
        targetOffset = myTargetOffset

        // 最開始,沒有任何偏航和俯仰
        totalYaw = 0
        totalPitch = 0

        // 計算攝像機矩陣
        Vector3 eye = player.position + verticalOffset
        Vector3 target = eye + targetOffset
        // 在y軸向上的世界裏
        Vector3 up = Vector3(0, 1, 0)
        cameraMatrix = CreateLookAt(eye, target, up)
    end
    
    // 根據這一幀的增量偏航和俯仰進行更新
    function Update(float yaw, float pitch)
        totalYaw += yaw
        totalPitch += pitch

        // 對俯仰進行Clamp
        // 在這種狀況下,範圍爲角度40°(弧度約爲0.78)
        totalPitch = Clamp(totalPtich, -0.78, 0.78)

        // 目標在旋轉以前偏移
        // 真實偏移則是在旋轉以後
        Vector3 actualOffset = targetOffset
        
        // 關於y軸進行偏航旋轉,旋轉真實偏移
        Quaternion quatYaw = CreateFromAxisAngle(Vector3(0, 1, 0), totalYaw)
        actualOffset = Transform(actualOffset, quatYaw)

        // 爲了俯仰計算左邊向量
        // 前向就是偏航以後的真實偏移(通過正規化)
        Vector3 forward = actualOffset
        forward.Normalize()
        Vector3 left = CrossProduct(Vector3(0, 1, 0), forward)
        left.Normalize()
        
        // 關於左邊進行俯仰,旋轉真實偏移
        Quaternion quatPitch = CreateFromAxisAngle(left, totalPitch)
        actualOffset = Transform(actualOffset, quatPitch)

        // 如今構造攝像機矩陣
        Vector3 eye = player.position + verticalOffset
        Vector3 target = eye + actualPosition

        // 在這種狀況下, 咱們能夠傳遞向上向量,由於咱們永遠向上
        cameraMatrix = CreateLookAt(eye, target, Vector3(0, 1, 0))
    end
end
View Code

    樣條攝像機

講解數學定義就會有點過多了,簡單來說,樣條能夠看做爲曲線,用線上的點定義的.樣條在遊戲中很常見,由於使用它進行插值可以在整條曲線上獲得平滑的效果

有不少種不一樣的樣條,最簡單的一種是Catmull-Rom樣條.這種樣條容許鄰近的點插值,這些點裏面有一個控制點在前,兩個激活點在後.P1和P2就是激活點(分別在t = 0和t = 1處),而P0和P3就是在前面和後面的控制點.儘管圖中只有4個點,但其實是沒有限制的.只要在先後加上控制點,曲線就能夠無限延長下去

給定4個控制點,這樣就能夠計算t值介於0和1的全部樣條

class CRSpline
    // Vector3s的數組(動態數組)
    Vector controlPoints

    // 第一個參數爲t=0對應的控制點
    // 第二個參數爲t值
    function Compute(int start, float t)
        // 檢查start - 1, start, start + 1以及start + 2都要存在
        ...

        Vector3 P0 = controlPoints[starts -1]
        Vector3 P1 = controlPoints[start]
        Vector3 P2 = controlPoints[start + 1]
        Vector3 P3 = controlPoints[start + 2]

        // 使用Catmull-Rom公式計算位置
        Vector3 position = 0.5 * ((2 * P1) + (-P0 + P2) * t + (2 * P0 - 5 * P1 + 4 * P2 - P3) * t * t + (-P0 + 3 * P1 - 3 * P2 + P3) * t * t *t)

        return position
    end
end
View Code

這個公式也能夠用於計算t介於0到1之間的切線.首先,計算任意你想要的t的位置.而後,計算t加很小的增量Δt的位置.在有了兩個位置以後,就能夠構造一個P(t)到P(t + Δt)的向量而且正規化,這樣就可以近似地獲得切線.這種方式能夠看做是數值微分

class SplineCamera
    // 攝像機跟隨的樣條路徑
    CRSpline path
    // 當前控制點索引及t值
    int index
    float t
    // speed是t每秒變化率
    float speed
    // 攝像機矩陣
    Matrix cameraMatrix
    
    // 給定當期索引和t,計算攝像機矩陣
    function ComputeMatrix()
        // eye就是樣條所在的t及index對應的位置
        Vector3 eye = path.Compute(index, t)
        // 給出一個稍微前一點的點
        Vector3 target = path.Compute(index, t + 0.05f)
        // 假定y軸朝上
        Vector3 up = Vector3(0, 1, 0)

        cameraMatrix = CreateLookAt(eye, target, up)
    end

    function Initialize(float mySpeed)
        // 初始index應該爲1(由於0是最初的P0)
        index = 1
        t = 0.0f
        speed = mySpeed
        
        ComputeMatrix()
    end

    function Update(float deltaTime)
        t += speed * deltaTime
        
        // 若是t >= 1.0f, 咱們能夠移動到下一個控制點
        // 這裏代碼假設速度不會太快,以致於一幀就超過兩個控制點
        if t >= 1.0f
            index++
            t = t - 1.0f
        end
        
        // 應該檢查index + 1和index + 2是否爲有效點
        // 若是不是,這條樣條就完成了

        ...
        ComputeMatrix()
    end
end
View Code

  攝像機支持算法

    攝像機碰撞

攝像機碰撞致力於解決不少類型攝像機都有的問題,那是在攝像機與目標之間有一個不透明的物體的時候.最簡單的方法(但不是最佳的)就是從目標位置向攝像機位置進行光線投射.若是光線碰撞到任何物體,可讓攝像機移動到阻擋攝像機的物體前面.

另外一個要考慮的問題是在攝像機太過靠近目標的時候,回憶一下,近平面就在攝像機前面一點點,意味着太近的攝像機會讓對象消失一部分.一個流行的解決方案是讓對象在攝像機太過靠近的時候徹底消失或者淡出

淡出方案有時候也經常使用於當攝像機與目標之間有阻擋的時候.不少第三人稱動做遊戲都使用這種方法

    揀選

揀選就是經過點擊的方式選擇3D世界中物體的能力.揀選在RTS遊戲中很常見.雖然揀選不是攝像機算法,可是它與攝像機和投影緊密相關

回憶一下,將一個點從世界空間變換到投影空間,它必須乘以一個攝像機矩陣,再乘以一個投影矩陣.可是,鼠標的位置是屏幕空間上的一個2D點.咱們要作是將這個2D點從屏幕空間變換回到世界空間去,稱之爲反投影.爲了實現反投影,咱們須要一個矩陣能夠作逆向矩陣變換操做.d對於以行爲主的表示,咱們須要攝像機矩陣乘以投影矩陣的逆矩陣:

可是2D點不能乘以4x4矩陣,因此在這個點乘以矩陣以前,咱們必須將其轉換到齊次座標系.這就須要將z和w份量添加到2D點上.z份量一般設爲0或1,取決於該點是放置在近平面仍是遠平面.而做爲一個頂點,w份量總爲1

function Update(Vector4 screenPoint, Matrix camera, Matrix projection)
    // 計算 camera * projection的逆矩陣
    Matrix unprojection = camera * projection
    unprojection.Invert()

    return Transform(screenPoint, unprojection)
end
View Code

Unproject能夠用來計算兩個點: 鼠標位置反投影到近平面(z = 0)和鼠標位置反投影到遠平面(z = 1).這兩個點能夠做爲光線投射的起點和終點.因爲光線投射有可能與多個物體交叉,遊戲應該選擇最近的那個

  相關資料

Haigh-Hutchinson, Mark. Real-Time Cameras. Burlington: Morgan Kaufmann,2009. 這本書普遍地講了各類不一樣的遊戲攝像機,做者在<<銀河戰士>>中實現了各類優秀的攝像機系統

第9章 人工智能

   "真"AI與遊戲AI

在傳統計算機科學中,許多人工智能的研究都趨向於複雜形式的AI,包括遺傳算法和神經算法.可是這些複雜的算法在計算機和遊戲中應用還存在限制.這存在兩個主要緣由,第一個緣由是複雜的算法須要大量的計算時間.大多數遊戲只能分出它們每幀的小部分時間在AI上,這意味着高效比複雜重要.另一個主要緣由就是,遊戲AI一般都有良好的行爲定義,一般都是在策劃的控制之下,而傳統的AI專一於解決更加模糊而普遍的問題

在不少遊戲中,AI行爲只是一種隨機變化的狀態機制組合,但仍是有幾個主要的例外.AI對於複雜的棋牌遊戲,好比象棋和圍棋,須要決策樹支持,這是傳統遊戲理論的基石.可是棋牌遊戲在某一時刻的行動選擇相比起其餘遊戲來說都不會這麼奢侈.也有一些遊戲實時作出很複雜的算法,使人印象深入,但那是特例.通常來說,遊戲中的AI就是智能感知.若是玩家以爲敵人的AI或者隊友的AI行爲很聰明,這個AI系統就已經成功了

但也不是每一個遊戲都須要AI算法.一些簡單的遊戲,好比單人跳棋和俄羅斯方塊就沒有這樣的算法.哪怕是一些複雜的遊戲也可能沒有AI,好比<<搖滾樂隊>>.對於那些100%多人對戰沒有NPC的遊戲來講也同樣.可是對於任意一款設計指定NPC與玩家交互的遊戲來講,AI算法是必須的

  尋路

    探索空間的表示

最簡單的尋路算法設計就是將做爲數據結構.一個圖包含了多個節點,鏈接任意鄰近的點組成.在內存中表示圖有不少種方法,可是最簡單的是鄰接表.在這種表示中,每一個節點包含了一系列指向任意鄰近節點的指針.圖中的完整節點集合能夠存儲在標準的數據結構容器裏

這意味着在遊戲中實現尋路的第一步是如何將遊戲世界用圖來表示.這裏有多種方法.一種最簡單的方法就是將世界分爲一個個正方形的格子(或者六邊形).在這種狀況下,鄰近節點就是格子中鄰近的正方形.這個方法在回合制策略遊戲中很流行,好比<<文明>>或者XCOM

可是,對於實時動做遊戲,NPC一般不是在網格上一個正方形一個正方形地走.由此,在主流遊戲中要麼使用路點要麼使用導航網格.上面兩種方法,均可以手工在場景編輯器中構造數據

可是手工輸入數據不只繁瑣並且容易出錯,因此大多數引擎都會讓這個過程自動化

尋路節點最先在第一人稱射擊遊戲(FPS)中使用,由id Software在20世紀90年代早期推出.經過這種表示方法,關卡設計師能夠在遊戲世界中擺放那些AI能夠達到的位置.這些路點直接被解釋爲圖中的節點.而邊則能夠自動生成.好比讓設計師手動將節點組合在一塊兒,能夠自動處理判斷兩個點之間是否由障礙.若是沒有障礙,那麼邊就會在兩點之間生成

路點的主要缺點是AI只能在節點和邊緣的位置移動.這是由於即便路點組成三角形,也不能保證三角形內部就是能夠行走的.一般會有不少不能走的區域,因此尋路算法須要認爲不在節點和邊緣上的區域都是不可走的

實際上,當部署路點以後,遊戲世界中就會要麼有不少不可到達的區域要麼有不少路點.前者是不但願出現的狀況,由於這樣會讓AI的行爲顯得不可信並且不天然.然後者缺少效率.越多的節點就會有越多的邊緣,尋路算法花費的時間就會越長.經過路點,在性能和精確度上須要折中

一個可選的解決方案就是使用導航網格.在這種方法中,圖上的節點實際上就是凸多邊形.鄰近節點就是簡單的任意鄰近的凸多邊形.這意味着整個遊戲世界區域能夠經過不多數量的凸多邊形表示,結果就是圖上的節點特別少

經過導航網格,在凸多邊形內部的任意位置都認爲是可走的.這意味着AI有了大量的空間能夠行走,所以尋路可返回更天然的路徑

導航網格還有其餘一些優勢.假設遊戲中有牛和小雞在農場行走.因爲小雞比牛小不少,所以有一些區域只有小雞可到達,而牛卻不行.若是這個遊戲使用路點,它一般須要兩份圖:每份圖對應一種生物.這樣,牛隻能在本身相應的路點行走.與之相反,因爲導航網格中每一個節點都是凸多邊形,計算牛可否進入不會花太多時間.所以,咱們能夠只用一份導航網格,而且計算哪些地方牛能夠到達

還有一點就是導航網格徹底能夠自動生成,這也是今天爲何使用路點的遊戲愈來愈少的緣由.好比說,多年來虛幻引擎使用路點做爲尋路空間的表示.其中一款使用路點的虛幻引擎的遊戲就是<<戰爭機器>>.並且,最近幾年的虛幻引擎已經使用導航網格代替路點.再後來的<<戰爭機器>>系列,好比<<戰爭機器3>>就使用的是導航網格,這個轉變引發工業上大量轉用導航網格

話雖這麼說,可是尋路空間的表示並不徹底會影響尋路算法的實現.在本節中的後續例子中,咱們會使用正方形格子來簡化問題.可是尋路算法仍不關心數據是表示爲正方形格子,路點,或是導航網格

    可接受的啓發式算法

全部尋路算法都須要一種方法以數學的方式估算某個節點是否應該被選擇.大多數遊戲都會使用啓發式,以h(x)表示,就是估算從某個位置到目標位置的開銷.理想狀況下,啓發式結果越接近真實越好.若是它的估算老是保證小於等於真實開銷,那麼這個啓發式是可接受的.若是啓發式高估了實際的開銷,這個尋路算法就會有必定機率沒法發現最佳路徑

對於正方形格子,有兩種方式計算啓發式.曼哈頓距離是一種在大都市估算城市距離的方法.某個建築能夠有5個街區遠,但沒必要真的有一條路長度恰好爲5個街區.曼哈頓距離認爲不能沿對角線方向移動,所以也只有這種狀況下才能使用啓發式.若是對角線移動是被容許的,則曼哈頓距離會常常高估真實開銷.在2D格子中,曼哈頓距離的計算以下:  

 

第二種計算啓發式的方法就是歐幾里得距離.這種啓發式的計算使用標準距離公式而後估算直線路徑.不像曼哈頓j距離,歐幾里得距離能夠用在其餘尋路表示中計算啓發式,好比路點或者導航網格.在咱們的2D格子中,歐幾里得距離爲

    貪婪最佳優先算法

在有了啓發式以後,能夠開始實現一個相對簡單的算法:貪婪最佳優先算法.一個算法若是沒有作任何長期計劃並且只是立刻選擇最佳答案的話,則能夠被認爲是貪婪算法.在貪婪最佳優先算法的每一步,算法會先看全部鄰近節點,而後選擇最低開銷的啓發式

雖然這樣看起來理由充足,可是最佳優先算法一般獲得的都是次優的路徑.

路徑上存在沒必要要的向右移動,這是由於這在當時就是最佳的訪問節點.一個理想的路徑應該是一開始往下走,可是這要求必定程度的計劃,這是貪婪算法所不具有的.大多數遊戲都須要比貪婪最佳優先算法所能提供的更好的尋路.可是本章後續的尋路算法都基於貪婪最佳優先算法,因此先理解貪婪算法才能往下繼續,先看看如何實現這個貪婪算法

struct Node

  Node parent

  float h

end

那個parent成員變量用於跟蹤哪一個節點是當前訪問的.parent成員的價值在於構造鏈表,可以從終點回到起點.當算法完成的時候,parent鏈表就能夠經過遍歷獲得最終路徑

浮點數h存儲了某個節點的h(x)的值,這個值致使在選擇節點的時候會偏向於h值最小的節點

算法的下一個組件就是用於臨時存儲節點的容器:開放集合和封閉集合.開放集合存儲了全部目前須要考慮的節點.因爲找到最低h(x)值開銷節點的操做是很常見的,因此對於開放集合能夠採用某種相似於二叉堆或者優先級隊列的容器

封閉集合則包含了全部已經被算法估值的節點.一旦節點在封閉集合中,算法再也不對其進行考慮.因爲常常會檢查一個節點是否存在於封閉集合裏,故會使用搜索的時間複雜度優於O(n)的數據結構,好比二叉搜索樹

假設有開始節點和結束節點,並且咱們須要計算兩點之間的路徑.算法的主要部分在循環中處理,可是,在進入循環以前,咱們須要先初始化一些數據

currentNode = startNode

add currentNode to closedSet

當前節點只是跟蹤哪一個鄰居節點是下一個估值的節點.在算法開始的時候,咱們除了開始節點沒有任何節點,因此須要先對開始節點的鄰居進行估值

在主循環裏,咱們首先要作的事情就是查看全部與當前節點相鄰的節點,並且把一部分加到開放集合裏:

do

  foreach Node n adjacent to currentNode

    if closeSet contains n

      continue

    else

      n.parent = currentNode

      if openSet does not contains n

        compute n.h

        add n to openSet

      end

    end

  loop

注意任意已經在封閉集合裏的節點都會被忽略.在封閉集合裏的節點都在以前進行了估值,因此不須要再進一步估值了.對於其餘相鄰節點,這個算法會把parent設置爲當前節點.而後,若是節點不在開放集合中,咱們計算h(x)的值而且把節點加入開放集合

在鄰近節點處理完以後,咱們再看看開放集合.若是開放集合中再也沒有節點存在.意味着咱們把全部節點都估算過了,這就會致使尋路失敗.實際上也不能保證總有路徑可走,因此算法必須考慮這種狀況

if openSet is empty

  break;  // 退出主循環

end

可是,若是開放集合中還有節點,咱們就能夠繼續.接下來要作的事情就是在開放集合中找到最低h(x)值開銷節點,而後移到封閉集合中.在新一輪迭代中,咱們依舊將其設爲當前節點

currentNode = Node with lowest h in openSet

remove currentNode from openSet

add currentNode to closedSet

最後,咱們要有循環退出的狀況.在找到有效路徑以後,當前節點等於終點,這樣就可以退出循環了

until currentNode == endNode  // end main do...until loop

圖9.7顯示了貪婪最佳優先算法做用在示例數據集的開始兩次迭代.在圖9.7(a)中,起點加入封閉集合,而鄰接節點則加入開放集合.每一個鄰接節點(藍色)都有用曼哈頓距離算出來的本身達到終點的h(x)開銷.箭頭表示子節點指向父節點. 這個算法的下一步就是選擇最低h(x)值節點,在這裏選擇h=3的節點.而後這個節點就會做爲當前節點,放到封閉集合裏.圖9.7(b)顯示了下一步的迭代,將當前節點(黃色)的鄰接節點放入開放集合中

currentNode = startNode
add currentNode to closedSet

do
    // 把鄰接節點加入開放集合
    foreach Node n adjacent to currentNode
        if closedSet contains n
            continue
        else
            n.parent = currentNode
            if openSet does not contain n
                compute n.h
                add n to openSet
            end
        end
    loop

    // 全部可能性都嘗試過了
    if openSet is empty
        break;
    end
    
    // 選擇新的當前節點
    currentNode = Node with lowest h in openSet
    remove currentNode from openSet
    add currentNode to closedSet

    until currentNode ==  endNode

    // 若是路徑解出,經過棧從新構造路徑
    if currentNode == endNode
        Stack path
        Node n = endNode
        while n is not null
            push n onto path
            n = n.parent
        loop
    else
        // 尋路失敗
        end
View Code

    A*尋路

在講了貪婪最佳優先算法以後,咱們就能夠考慮怎麼提高路徑的質量.比起單一地依賴於h(x)做爲尋路的估價,A*算法增長了路徑開銷份量.路徑開銷就是從起點到當前節點的實際開銷,經過g(x)計算

A*中訪問一個節點的開銷等式爲:

爲了可以使用A*算法,Node結構體須要增長f(x)和g(x)的值,以下:

struct Node

  Node parent

  float f

  float g

  float h

end

當一個節點加入開放集合以後,咱們須要計算全部的3個份量,而不只僅是啓發式.並且,開放集合會根據f(x)的值來排序,由於在A*中每次迭代都會選擇f(x)值最低的節點

對於A*算法只有一個主要變化,那就是節點選用的概念.在最佳優先算法中,老是把鄰接節點做爲父節點.可是在A*算法中,已經放在開放集合中的鄰接節點須要估值以後才能決定哪一個當前節點是父節點

在圖9.8(a)中,咱們能夠看到當前節點正在檢查鄰近節點.這個節點到左邊節點的g=2,若是那個點以當前節點爲父節點,g=4,結果會更糟.因此在這種狀況下,當前節點的路徑應該拒絕選用.

currentNode = startNode
add currentNode to closedSet

do
    foreach Node n adjacent to currentNode
        if closedSet contains n
            continue
        else if openSet contains n    // 選用檢查
            compute new_g    // n節點以當前節點爲父節點的g(x)值
            if new_g < n.g
                n.parent = currentNode
                n.g = new_g
                n.f = n.g + n.h    // 該節點的n.h是不變的
            end
        else
            n.parent = currentNode
            compute n.h
            compute n.g
             n.f = n.g + n.h
            add n to openSet
        end
    loop
    
    if openSet is empty
        break
    end
    
    currentNode = Node with lowest f in openSet
    remove currentNode from openSet
    add currentNode to closeSet

    until currentNode == endNode
    // 清單9.1 的路徑構造
    ...
View Code

    Dijkstra算法

最後一個尋路算法能夠經過稍微修改A*算法獲得.在Dijkstra算法中,沒有啓發式的估計----或者換個角度

這意味着Dijkstra算法能夠使用與A*算法同樣的代碼,除了啓發式爲0以外.若是將Dijkstra算法用於咱們的例子,可以獲得與A*算法同樣的路徑.若是隻有A*才使用啓發式,Dijkstra總能獲得與A*算法一樣的路徑.可是,Dijkstra算法一般會訪問更多的節點,因此Dijkstra效率更低

惟一使用Dijkstra代替A*的場景就是場景中同時存在多個有效目標節點的時候,可是你不會知道哪一個更近.但在那種場景下,大多數遊戲都不會使用Dijkstra.這個算法被討論基本上都是處於歷史緣由,由於Dijkstra比A*早10年提出.A*的創新在於結合了貪婪最佳優先和Dijkstra算法.因此雖然本書經過A*討論Dijkstra,但這不是它被開發出來的緣由

  基於狀態的行爲

大多數基礎的AI行爲無非就是不一樣的狀態.以<<乒乓>>的AI舉例,它只須要跟蹤球的位置.這個行爲在整個遊戲的過程當中都沒有改變,因此這樣的AI能夠被認爲是無狀態的.可是當遊戲有點複雜度的時候,AI就須要在不一樣的時候有不一樣的行爲.大多數現代遊戲的NPC在不一樣的位置d都有不一樣的行爲.

    AI的狀態機

有限狀態機能夠完美地表達基於狀態的AI.它有着一組可能的狀態,由必定的條件控制狀態轉換,而在狀態切入切出的時候能夠執行動做

    基礎的狀態機實現

狀態機有多種實現方式.最簡單的需求就是當AI更新的時候,正確的更新行爲必須根據當前狀態來完成.理想狀態下,咱們還想讓狀態機有進入和退出行爲.

若是AI只有兩種狀態,咱們能夠在AI的Update函數中用一個布爾值來判斷.可是這個方案不夠健壯.一個稍微靈活的方式是經過枚舉值來表示不一樣的狀態,這常常在簡單的遊戲中能夠看到

圖9.9中的狀態機就能夠像下面這樣定義枚舉

enum AIState

  Patrol,

  Death,

  Attack

end

而後能夠用AIController類以AIState類型做爲成員變量.在咱們的AIController的Update函數中,能夠根據當前狀態來執行不一樣的行爲:

function AIController.Update(float deltaTime)

  if state == Patrol

    // 執行巡邏行爲

  else if state == Death

    // 執行死亡行爲

  else if state == Attack

    // 執行攻擊行爲

  end

end

狀態的變化和進入/退出行爲能夠在第二個函數中實現

function AIController.SetState(AIState newState)

  // 退出行爲

  if state == Patrol

    // 退出巡邏行爲

  else if state == Death

    // 退出死亡行爲

  else if state == Attack

    // 退出攻擊行爲

  end

  state = newState

  // 進入行爲

  if state == Patrol

    // 進入巡邏行爲

  else if state == Death

    // 進入死亡行爲

  else if state == Attack

    // 進入攻擊行爲

  end

end

這個實現有幾個問題.首先很明顯的一點就是,首先很明顯的一點就是,隨着狀態機的增長,Update和SetState的可讀性會減弱.若是咱們的例子中有20個狀態而不是3個,代碼看上去就像意大利麪條.第二個主要問題是缺少靈活性.加入咱們有兩個AI,它們有不一樣的狀態機.這樣咱們就須要爲不一樣的AI實現不一樣的枚舉和控制器.如今,假設兩個AI之間會公用一些狀態,好比說巡邏狀態.以咱們目前的基礎代碼結構是沒法在AI之間共享狀態的.

一個方法是將巡邏的代碼複製到兩個類中,可是有着兩份一樣的重複代碼是很是很差的實踐.另外一個方法就是寫一個共同的基類,而後把公有的行爲"冒泡"上去.可是這樣,仍是有不少缺點:意味着任何須要巡邏行爲的AI都要從這裏繼承

因此雖然這個基礎的實現能工做,可是除非AI狀態機很是簡單,不然徹底不推薦

    狀態機設計模式

狀態機模式, 容許"一個對象經過改變內在狀態來切換行爲"

這能夠經過類組合的方式完成.因此AIController"有一個"AIState做爲成員變量.每一個特定的狀態都是AIState的子類

基類AIState的定義以下:

class AIState

  AIController parent

  function Update(float deltaTime)

  function Enter()

  function Exit()

end

父引用使得任何AIState的實例均可以讓AIController擁有它.這是必要的,若是咱們想要切換到新的狀態,須要有一些方法通知AIController這些事情.每一個AIState都有本身的Update,Enter,Exit函數,能夠爲某個特定有需求的狀態所實現

AIController類會保留一個當前AIState的引用,並且須要Update和SetState函數

class AIController

  AIState state

  function Update(float deltaTime)

  function SetState(AIState newState)

end

這樣,AIController的Update函數只是簡單地調用AIState的Update函數便可

function AIController.Update(float deltaTime)

  state.Update(deltaTime)

end

經過設計模式,SetState函數也變得清晰多了:

function AIController.SetState(AIState newState)

  state.Exit()

  state = newState

  state.Enter()

end

經過狀態設計模式,全部狀態相關的行爲都移到AIState的子類當中去了.這使得AIController的代碼比以前清晰多了.狀態機模式也使得系統更加模塊化.

  策略和計劃

不少遊戲都須要比基於狀態的敵人更復雜的AI.好比即時戰略遊戲,AI指望看上去與人類玩家相差無幾.這個AI須要有一個大局觀,知道本身要作什麼,而後盡力去作.這就是策略和計劃的工做方式

    策略

策略就是從AI的視角來完成遊戲.好比說,它要思考的是應該更具侵略性仍是防守性.微觀策略由單位行爲組成.這一般能夠用狀態機來完成,因此就沒必要深刻討論了.相對而言,宏觀策略複雜得多.它是AI的大局觀,並且會決定如何完成遊戲.當爲像<<星際爭霸>>那樣的遊戲開發AI的時候,開發者一般根據頂級玩家的思惟進行建模.一個宏觀策略在RTS遊戲中可能會是"突擊"(嘗試儘快攻擊玩家)

策略有時候看上去就像很模糊的使命描述,並且模糊的策略是很難開發的.爲了讓問題更加形象,策略一般被認爲是一系列的特定目標.好比說,若是策略是"科技"(增長裝備科技),一個特定目標可能就是"擴張"(創建第二個基地)

一個策略一般都不止一個目標,也就是說,咱們須要有一個優先級系統來讓AI選擇哪一個目標更加劇要.全部其餘目標若是優先級不是最高,那麼會先擱在後面無論.其餘目標會在最重要的目標完成時從新參與選擇.一個實現目標系統的方式就是像這樣寫一個AIGoal類:

class AIGoal

  function CalculatePriority()

  function ConstructPlan()

end

每一個特定目標都會做爲AIGoal的子類實現.因此當策略進行目標選擇以後,全部策略的目標會放到一個根據優先級排序的容器裏.注意,真正高級的策略系統應該支持同時選用多個目標的功能.由於若是兩個目標不是互斥的,那麼是沒有理由不一樣時選擇兩個目標的

計算優先級的啓發式函數是CalculatePriority,可能會至關複雜,並且根據遊戲規則不一樣而變化.好比說,一個目標是"創建空中單位",可能會在發現敵人正在建造可以消滅空軍的單位時下降優先級.另外一方面,若是AI發現敵人沒有可以傷害空軍的單位,那麼就會增長這一目標的優先級

AIGoal中的ConstructPlan函數就是用於構造計劃的: 一系列爲了達到目標而計劃出來的步驟

    計劃

每一個目標都須要一個相應的計劃.好比說,若是目標是擴張,那麼計劃可能以下:

  1. 爲擴張偵察合適的地點

  2. 創建足夠多的單位來保護擴張

  3. 派遣工人和戰鬥單位去擴張點

  4. 開始建造擴張點

特定目標的計劃能夠用狀態機來實現.計劃中的每一步均可以是狀態機中的一個狀態,而狀態機持續爲該步驟行動直到達到條件.可是,實踐中的計劃不多是線性的.根據計劃某個步驟的成功或者失敗,AI可能會調整步驟的順序

一個須要考慮的事情是計劃須要按期查看目標的可行性.若是擴張計劃中發現沒有適合擴張的位置,那麼目標就是不可行的.一旦目標被標記爲不可行,大局觀須要從新估算.最終,必需要有一個"指揮官"來決定策略的改變

  總結

AI遊戲程序員的目標就是讓系統看上去比較聰明.這可能不是研究人員眼中的"真"AI,可是遊戲一般都不須要這麼複雜的AI.與咱們討論的同樣,遊戲AI領域的一個大問題就是找到從點A到點B的路徑.遊戲中最經常使用的尋路算法就是A*算法,並且它能夠用於任何搜索空間表達爲圖(好比格子,路點,導航網格)的問題.大多數遊戲都有某種方式實現的經過狀態機控制的行爲,而實現狀態機的最佳方式就是狀態機設計模式.最後,策略和計劃可能會在RTS遊戲中創造更加可信真實的行爲

  相關資料

    通用AI

Game/AI(http://ai-blog.net/):這個博客中有不少業界經驗豐富的程序員談論了關於AI編程相關的話題

Millington,Ian and John Funge. Artificial Intelligence for Games (2nd Edition). Burlington: Morgan Kaufmann, 2009: 這本書主要以算法的方式講了不少常見的遊戲AI問題

Game Programming Gems (Series): 這個系列的每一卷都有很多AI編程相關的文章.老版本已經絕版了,但新的還在

AI Programming Wisdom (Series): 與Game Programming Gems 系列相似, 可是徹底專一於遊戲AI,其中一些已經絕版了

    尋路

Recast and Detour (http://code.google.com/p/recastnavigation/): 這是一個優秀的開源尋路庫,由Mikko Mononen 開發,他開發過多款遊戲,好比<<孤島危機>>. Recast 可以生成導航網格,而Detour實現了在這些網格上的尋路

    狀態

Gamma, Eric et. al. Design Patterns: Elements of Reusable Object-Oriented Software. Boston: Addison-Wesley, 1995. 這本書講設計模式的同時描述了狀態機設計模式,對於全部程序員都頗有用

Buckland, Mat. Programming Game AI By Example. Plano: Wordware Publishing, 2005. 這是一本通用的遊戲AI書籍,他有一個很是好的基於狀態的行爲實現

第10章 用戶界面

   菜單系統

    菜單棧

典型家用機遊戲的菜單系統都以"點擊開始"做爲開始界面.在用戶按鍵以後,就會進入主菜單界面.也許你還能夠經過點擊選項進入選項界面,也能夠點擊開發組或者新手教程.一般來說,玩家都可以退出當前菜單而後返回以前的界面.

一個確保菜單總能回退到基本界面的方法就是使用棧這種數據結構.棧最上層的元素就是當前活躍的菜單,而打開新菜單就是往棧中壓入新的菜單.回退到以前的菜單就是將當前的菜單彈出棧.這個機制還能夠改進爲支持多個菜單同時可見,好比說,若是須要接受/拒絕某個請求,一個彈出框能夠在某個菜單以前.爲了達到目標,菜單系統須要對棧的底部到頂部所有引用

    按鈕

    打字

function KeyCodeToChar(int keyCode)
    // 確保這是字母鍵
    if keyCode >= K_A && keyCode <= K_Z
        // 如今,假設大寫的狀況
        // 這個映射取決於語言
        return ('A' + (char)(keyCode - K_A))
    else if keyCode == K_SPACE
        return ' '
    else
        return ''
    end
end
View Code

  HUD元素

最基礎的HUB(平視顯示器)元素就是玩家得分和剩餘生命值.這種HUB實現起來相對瑣碎----在主要遊戲場景渲染以後,咱們只要在頂層繪製文字或者圖標以展現相應的信息便可.可是不少遊戲都使用了更加複雜的元素,包括路點箭頭,雷達,指南和準心

    路點箭頭

class WaypointArrow
    // 記錄箭頭的朝向
    Vector3 facing
    // 箭頭在屏幕上的2D位置
    Vector2 screenPosition
    // 箭頭指向的當前路點
    Vector3 waypoint
    // 用於渲染箭頭的世界變換矩陣
    Matrix worldTransform
    
    // 經過給定位置/旋轉計算世界變換矩陣
    function ComputeMatrix(Vector3 worldPosition, Quaternion rotation)
        // 縮放,旋轉,平移(可是此次咱們沒有縮放)
        worldTransform = CreateFromQuaternion(rotation) * CreateTranslation(worldPosition)
    end

    // 根據屏幕位置計算3D箭頭的世界座標
    function ComputeWorldPosition()
        // 爲了計算反投影,咱們須要一個3D向量
        // z份量是一個在近平面和遠平面之間的百分比
        // 在這種狀況下,我選擇兩者之間10%的一個點(z = 0.1)
        Vector3 unprojectPos = Vector3(screenPosition.x, screenPosition.y, 0.1)
        
        // 獲得攝像機和投影矩陣
        ...
        
        // 調整第8章的反投影函數
        return Unproject(unprojectPos, cameraMatrix, projectionMatrix)
    end

    function Initialize(Vector2 myScreenPos, Vector3 myWaypoint)
        screenPosition = myScreenPos
        // 對於Y軸向上的左手座標系
        facing = Vector3(0, 0, 1)
        SetNewWaypoint(myWaypoint)

        // 初始化世界變換座標系
        ComputeMatrix(ComputeWorldPosition(), Quaternion.Identity)
    end

    function SetNewWaypoint(Vector3 myWaypoint)
        waypoint = myWaypoint
    end

    function Update(float deltaTime)
        // 獲得箭頭的當前世界座標
        Vector3 worldPos = ComputeWorldPosition()

        // 獲得玩家位置
        ...
        // 箭頭的新朝向是一個正規化向量
        // 從玩家的位置指向路點
        facing = waypoint - playPosition
        facing.Normalize()

        // 使用點乘獲得原始朝向(0, 0, 1)和新朝向之間的夾角
        float angle = acos(DotProduct(Vector3(0, 0, 1), facing))
        // 使用叉乘獲得軸的旋轉軸
        Vector3 axis = CrossProduct(Vector3(0, 0, 1), facing)
        Quaternion quat
        // 若是長度爲0,意味着平行
        // 意味着不須要旋轉
        if axis.Length() < 0.01f
            quat = Quaternion.Identity
        else
            // 計算用來表示旋轉的四元數
            axis.Normalize()
            quat = CreateFromAxisAngle(axis, angle)
        end

        // 如今設置箭頭最後的世界變換
        ComputeMatrix(worldPos, quat)
    end
end
View Code 

    準心

就像鼠標光標同樣,準心是一個在屏幕上的座標.咱們拿到這個2D座標,而後執行兩個反投影:一個在近平面,一個在遠平原.獲得這兩個點以後,能夠在這兩點之間執行光線投射.

    雷達

有的遊戲會有雷達系統用來顯示雷達範圍附近的敵方(或者友方).還有幾種雷達變種,一種是任何人都會在相應的雷達中顯示,另一種是隻顯示最近開槍的敵人.無論是哪種,實現原理幾乎都同樣

爲了讓雷達順利工做,須要完成兩件事情.首先,咱們有一種方式能夠遍歷可以在雷達上顯示的全部對象.而後,全部在雷達範圍內的對象都須要根據UI中心作出的相應的偏移.計算距離和轉換爲2D偏移,咱們都但願忽視高度,這意味着咱們必須投影雷達對象到雷達面板上

在咱們計算以前,應該先定義雷達光點結構體,就是那些在雷達上顯示的點.這樣,就能夠根據實際狀況讓這些點有不一樣的大小和顏色

struct RadarBlip

  // 雷達光點的顏色

  Color color = Color.Red

  // 雷達光點的位置

  Vector2 position

  // 雷達光點的縮放

  float scale = 1.0f

end

對於Radar類來講,須要有兩個參數: 遊戲世界中的對象可以被探測出來的最大距離,以及屏幕上顯示的雷達半徑.經過這兩個參數,在咱們獲得光點位置以後,就能夠轉換到屏幕上正確的位置

class Radar
    // 雷達在遊戲世界單位中的範圍
    float range
    // 雷達在屏幕中的位置(x, y)
    Vector2 position
    // 雷達在屏幕中的半徑
    float radius
    // 雷達的背景圖片
    ImageFile radarImage
    // 全部活躍的雷達光點
    List blips

    // 初始化函數設置range, center, radius及image
    ...

    function Update(float deltaTime)
        // 清除上一幀的光點
        blips.Clear()
        // 獲取玩家位置
        ...
        // 將playerPosition轉換爲2D座標
        // 如下假設y軸向上
        Vector2 playerPos2D = Vector2(playerPosition.x, playerPosition.z)

        // 計算須要添加到blip上的旋轉
        // 獲得正規化後的玩家朝向向量
        ...
        // 將playerFacing轉換爲2D
        Vector2 playerFacing2D = Vector2(playerFacing.x, playerFacing.z)
        // 計算雷達前向玩家與玩家朝向之間的夾角
        float angle = acos(DotProduct(playerFacing2D, Vector2(0, 1)))
        // 爲了使用叉乘, 須要轉換爲3D向量
        Vector3 playerFacing3D = Vector3(playerFacing2D.x, playerFacing2D.y, 0)
        // 使用叉乘斷定旋轉的方向
        Vector3 crossResult = CrossProduct(playerFacing3D, Vector2D(0, 1, 0))
        // 順時針爲-z,意味着角度應該取負
        if crossResult.z < 0
            angle *= -1
        end
        
        // 斷定哪些敵人在範圍以內
        foreach Enemy e in gameWorld
            // 將敵人的位置轉換爲2D座標
            Vector2D enemyPos2D = Vector2D(e.position.x, e.position.z)
            // 構造從玩家到敵人的向量
            Vector2 playerToEnemy = enemyPos2D - playerPos2D
            // 檢查長度, 看看是否在距離以內
            if playerToEnemy.Length() <= range
                // 旋轉playerToEnemy, 使得它相對於玩家朝向旋轉(使用2D旋轉矩陣)
                playerToEnemy = Rotate2D(angle)
                // 爲敵人建立雷達光點
                RadarBlip blip
                // 取playerToEnemy向量,轉換爲相對於屏幕上雷達中心點的偏移
                blip.position = playerToEnemy
                blip.position /= range
                blip.position *= radius
                // 將blip添加到blips中
                blips.Add(blip)
            end
        loop
    end
    
    function Draw(float deltaTime)
        // 繪製雷達圖片
        ...

        foreach RadarBlip r in blips
            // 在position + blip.position的位置繪製r
            // 由於blip中存放的是偏移量
            ...
        loop
    end
end
View Code

  其餘須要考慮的UI問題

    支持多套分辨率

一個解決多套分辨率問題的辦法就是避免使用像素座標,也稱爲絕對座標.一個絕對座標的例子是讓UI繪製在(1900, 1000)像素點.使用這種座標的問題就是若是顯示器只有一種1680 x 1050像素的分辨率,在(1900, 1000)位置的UI就會徹底在屏幕以外

這種問題的另外一個解決方法是使用相對座標,就是座標是一個相對值.好比說,若是你想讓某些東西在屏幕的右下角顯示,可能會放置元素在相對於右下角位置的(-100, 100).就是說在1920 x 1080像素分辨率下,它的座標會是(1820, 980),而在1680 x 1050像素分辨率下,座標會是(1580, 950)

一個細微的改良就是根據分辨率進行縮放.緣由是若是在很是高分辨率的狀況下,UI元素可能由於過小而致使不可用.因此分辨率越高,UI就應該越是放大,這樣玩家才能看清楚.一些MMORPG遊戲甚至容許玩家控制UI控件的縮放.若是要支持伸縮,使用相對位置就尤其重要

    本地化

雖然一款遊戲只支持一種語言(一般是英語)也是可行的,可是大多數商業遊戲都須要支持多語言.本地化就是支持更多語言的過程.因爲許多菜單和HUD都有文本顯示,在設計UI系統的時候就須要重視.哪怕一款遊戲不須要本地化,將文本硬編碼到代碼裏面自己就很是很差,這樣不利於非程序員修改.可是若是遊戲須要本地化,將硬編碼移除就特別重要

最簡單的文本本地化方法就是將遊戲中這些文本存儲到外部文件中.這個外部文件能夠使用XML,JSON或者相似的格式.這樣j緊接着就能夠經過字典映射鍵來訪問特定的字符串.所以無論代碼是否須要在屏幕上顯示文本,咱們都須要用對應的鍵來使用字典.這意味着比起直接在按鈕上顯示文本"Cancel",更應該使用"ui_cancel"鍵來從字典取得字符串.在這種狀況下,支持新語言只須要建立新的字典文件

大多數遊戲都經過Unicode字符集來支持不一樣的編碼系統.有多種方式來對Unicode字符進行編碼,最流行的方法就是UTF-8,這是使用最廣的方法.標準的ASCII字符集在UTF-8中就是一個字節,而Unicode字符能夠有6字節.因爲寬度變化,UTF-8字符串不能只根據內存長度而判定字符個數,每種語言都不同

可是改變文本字符串和字符編碼不是惟一要考慮的事情.還有一個問題是一個單詞在不一樣語言中的長度不同

    UI中間件

中間件就是一些外部代碼庫用與簡化開發工做

    用戶體驗

 

  相關資料

Komppa, Jari. "Sol on Immediate Mode GUIs." http://iki.fi/sol/imgui/:這是一個經過C和SDL實現遊戲UI不一樣元素的深刻教程

Quintans,Desi. "Game UI By Example: A Crash Course in the Good and the Bad." http://tinyurl.com/d6wy2yg:這是一篇相對簡短的文章,經過遊戲例子講解了什麼是好UI什麼是壞UI,還討論了在設計遊戲UI的時候要考慮的事情

Spolsky, Joel. User Interface Design for Programmers. Berkeley: Apress,2001. 這個程序員設計UI的方法不是針對遊戲,可是他在關於創造高效UI方面提供了有趣的視角

第11章 腳本語言和數據格式

   腳本語言

多年前,遊戲所有使用匯編語言開發.這是由於早期的機器須要彙編級別的優化才能運行.可是隨着計算能力的提高,而遊戲又變得很複雜,使用匯編開發就變得愈來愈沒有意義了.直到某一天,使用匯編語言開發遊戲帶來的優勢被徹底抵消了,這就是爲何如今全部遊戲引擎都使用像C++那樣的高級語言開發

一樣,隨着計算機性能的提高,愈來愈多的遊戲邏輯開始從C++或者相似的語言轉移.如今許多遊戲邏輯使用腳本語言開發,經常使用的腳本語言有Lua,Python,UnrealScript等

因爲腳本代碼更加容易編寫,因此策劃寫腳本是徹底可行的,這就讓他們獲得了開發原型的能力,而不用鑽進引擎裏面.雖然AAA遊戲的至關部分好比物理引擎或者渲染系統依然使用引擎語言開發,可是其餘系統好比攝像機,AI行爲可能會使用腳本開發

    折中

腳本語言並非萬靈藥,在使用以前必須考慮不少折中.第一個要考慮的就是腳本語言的性能遠不如編譯型語言,好比C++.儘管比起JIT或者基於VM的語言,好比Java,C#,那些腳本語言,好比Lua,Python,在性能上都不具有可比性.這是由於解釋性語言按需加載文本代碼,而不是提早編譯好.多數腳本語言都提供了編譯爲中間格式的選項.雖然始終達不到編譯型語言的速度,但仍是會比解釋型語言要快

因爲這個性能差別的存在,性能敏感的代碼不該該使用腳本語言開發.以AI系統爲例,尋路算法(好比A*)應該是高效的,所以不該該用腳本開發.可是由狀態機驅動的AI行爲應該徹底用腳本開發,由於那不須要複雜的計算.

使用腳本語言的巨大優點就是使得開發時迭代更加快速.假設某個遊戲的AI狀態機必須以C++開發,在玩遊戲的過程當中,AI程序員發現某個敵人的行爲不正確.若是狀態機使用C++開發,程序員須要不少工具去定位問題,並且在玩遊戲的過程當中一般無法解決.雖然Visual Studio的C++確實有"編輯和繼續"功能,但實際上它只有在某些狀況下才能使用.這意味着一般程序員必須暫停遊戲,修改代碼,從新生成可執行文件,從新開始遊戲,最後才能看到問題是否解決

可是一樣的場景若是出如今AI狀態機是用腳本語言開發的時候,就能夠動態從新加載腳本,而後在遊戲仍在運行的時候就把問題解決了.運行中動態加載腳本的能力能夠很大程度地提高生產力

回到C++版本的AI行爲例子中,假設在警衛AI中有BUG,是由訪問野指針引發的,那麼一般都會引起崩潰.若是bug老是出現,遊戲就會常常崩潰.可是若是狀態機是使用腳本語言開發的,那可能只會讓某個特定AI的角色行動不正常,而遊戲的其餘部分都是正常的.第二種狀況要比第一種友好得多

進一步來說,因爲腳本與可執行文件是分開的文件,使得提交工做更加簡單.在大型項目中,生成可執行文件須要好幾分鐘,並且最終文件可能會有100MB.這意味着若是有新版本,須要的人要下載整個文件.可是,若是是用了腳本語言,用戶只要下載幾KB的文件就能夠了,這樣會快不少.這不只對發售後更新補丁很是有幫助,在開發中也一樣有用

因爲生產力的優點,一個最好的經驗法則就是,只要系統不是性能敏感的,都能從腳本語言中受益.固然,爲遊戲增長腳本系統自己也要成本,可是若是多個團隊所以受益,那麼很輕鬆就能回收成本

    腳本語言的類型

    Lua

Lua是一門通用腳本語言,大概是如今遊戲領域最流行的腳本語言.使用Lua的遊戲的例子包括:<<魔獸世界>>,<<英雄連>>,<<冥界狂想曲>>等.Lua這麼流行的一個緣由是它的解釋器很是輕量----純C實現大概佔用內存150KB.另一個緣由就是它很是容易作綁定,也就是在Lua中調用C/C++代碼的能力.它同時支持多任務,因此它可讓許多Lua函數同時運行

語法上,這門語言有點像C族語言,但同時也有一些不一樣點.表達式結尾的分號是可選的,再也不使用大括號控制程序流程.Lua迷人的一個方面j就是它的複雜數據結構只有一種,那就是表格,它能夠以不少不一樣的方式使用,包括數組,鏈表,集合等.

-- 這樣注意 --
-- 這是一個數組
-- 數組從1索引開始
t = { 1, 2, 3, 4, 5 }
-- 輸出4
print( t[4] )

-- 這是一個字典
t = { M = "Monday", T = "Tuesday", W = "Wednesday" }

-- 輸出Tuesday
print( t[T] )
View Code

雖然Lua不是面嚮對象語言,可是經過表格徹底能夠作到面向對象.這種技術常常會用到,由於面向對象在遊戲中很是重要

    UnrealScript

UnrealScript是Epic爲Unreal引擎專門設計的嚴格的面嚮對象語言.不像不少腳本語言,UnrealScript是編譯型的.因爲是編譯型的,它有着比腳本語言更好的性能.但也意味着不支持的運行時從新加載.用Unreal開發的大部分遊戲邏輯都用UnrealScript完成.對於使用完整引擎的遊戲來講(不是免費版的UDK),UnrealScript的綁定容許使用C++實現

在語法上,UnrealScript看上去很是像C++或者Java.由於它是嚴格面向對象的,每一個類都繼承自Object或者Object的子類,而幾乎每一個類都表示場景中派生自Actor的一個角色.UnrealScript很是特別的功能是內建對狀態的支持.能夠根據狀態有不一樣的函數重載,這樣對於AI行爲會更加容易設置.如下的代碼片斷會根據該類當前狀態調用不一樣的Tick函數(Unreal對象的更新函數)

// Auto表示進入的默認狀態
auto state Idle {
    function Tick(float DeltaTime) {
        // 更新Idel狀態
        ...
        
        // 若是發現敵人,那麼進入Alert狀態
        GotoState("Alert")
    }

Begin:
    `log("Entering Idle State")
}

state Alert {
    function Tick(float DeltaTime)
        // 更新Alert狀態
        ...
    }

Begin:
    `log("Entering Alert State")
}
View Code

    可視化腳本系統

  實現一門腳本語言

實現自定義腳本語言與建立一個通用編譯器相似.學習編譯器如何工做是很重要的,哪怕你不須要實現它,由於它能使編程變得高級.沒有編譯原理,每一個人仍會用匯編語言寫代碼,這樣就會致使有能力編程的人大大減小

    標記化

咱們要作的第一步就是將代碼文本加載進來,而後分紅一塊塊的標記,好比標識符,關鍵詞,操做符h和符號.這個過程被稱爲標記化,更正式一點叫做詞法分析.

    正則表達式

    語法分析

語法分析的任務就是遍歷全部標記,而後確保它們符合語法規則.好比說,if表達式須要有適當數目和位置的括號,大括號,測試表達式和表達式來執行.在檢測腳本語言的過程當中,會生成抽象語法樹(AST),它是基於樹的數據結構,定義了整個程序的佈局

注意,圖11.3中的樹是之後序遍歷(左孩子, 右孩子, 父節點)的方式遍歷的,結果會是 5 6 10 * +, 這個結果就是中序表達式之後序表達式的方式表示的結果.這不是隨意決定的,後序遍歷在棧上計算很是方便.最後,全部AST(無論是不是數學表達式)在語法分析以後都會被之後序的方式遍歷

在遍歷AST以前,咱們必須先生成一份AST.生成AST的第一步就是定義一份語法.計算機語言定義語法的經典方式就經過巴科斯範式,通常縮寫爲BNF.BNF的設計是相對簡潔的.可以作整型加法和減法的運算子語法能夠像下面這樣定義:

這個::==操做符表示"定義爲",|操做符表示"或者",<>操做符用於表示語法規則的名字.因此上面的BNF語法的意思,expression要麼是expression加另外一個expression,要麼是expression減另外一個expression.要麼是一個integer.這樣5+6是有效的,由於5和6都是integer,因此它們都是expression,因此它們能夠相加

就像標記化同樣,語法分析也有能夠使用的工具.其中之一就是bison,它能夠在語法規則匹配的時候執行C/C++動做.動做的通常用法就是讓它讀取AST的時候建立合適的節點.好比說,若是加法表達式匹配上了,就會爲加法節點建立兩個孩子:左右操做數各一個

最好能有一個類能對應一種類型的節點.因此加/減語法會須要4種不一樣的類:一個抽象的表達式類,一個整型節點,一個加法節點和一個減法節點.

    代碼的執行和生成

abstract class Expression
    function Execute()
end

class Integer inherits Expression
    // 存儲整數
    int value

    // 構造函數
    ...

    function Execute()
        // 將結果壓到運算符的棧頂
        ...
    end
end

class Addition inherits Expression
    // 左右操做數
    Expression lhs, rhs
    
    // 構造函數
    ...

    function Execute()
        // 後續表示先訪問左孩子, 而後右孩子, 最後到本身
        lhs.Execute()
        rhs.Execute()

        // 將棧頂的兩個值相加,再將結果壓到棧裏
        ...
    end
end

// 減法節點和加法同樣,除了取反以外
...
View Code

  數據格式

另一個遊戲開發中要作的決定就是如何經過數據描述像關卡,遊戲屬性等遊戲元素.對於很是簡單的遊戲來講,你可能不會管那麼多,直接將數據硬編碼,但這不是一個理想的解決方案.經過將數據存儲在外部文件,就可讓非程序員來編輯.同時還使得建立編輯工具(好比關卡編輯器)來處理數據變得可能

當你肯定數據格式的時候,第一個要決定的就是數據是二進制格式仍是文本格式.一個二進制文件一般都不具有可讀性.若是你用文本編輯器打開二進制文件,能夠看到一大串亂碼.一個二進制格式的例子就是PNG格式或者其餘的圖片文件格式.一個文本文件,一般會用ASCII碼錶示,所以具有可讀性.就像判斷是否使用腳本語言同樣,兩種方法之間有一種折中.最後,有的狀況用文本格式合理,而有的狀況用二進制格式合理

    折中

使用二進制文件的第一個優勢是體積更小,加載更快.比起花費時間解析文本並轉換到內存格式,一般能夠直接把整塊加載到內存,而不須要任何轉換.由於對於遊戲來講,效率是很是重要的,因此對於攜帶大量信息的文件來講,一般都會採用二進制格式

可是,效率的提高不是沒有代價的.二進制文件的一個大缺點就是不支持版本控制系統.緣由是很難分辨出了兩個二進制文件到底有什麼不一樣

對於文本格式而言,查看兩個版本的不一樣就很是容易了.

還有最後一個方案,對於數據文本和二進制表達式都適用.在開發的時候,檢查變更是很重要的,因此全部關卡和遊戲數據均可以存成文本.而後,在發佈的時候,咱們能夠加入一個烘焙步驟,將全部文本格式轉換爲二進制格式.這些二進制文件不會進入版本控制系統,並且開發者只修改文本文件.這樣,咱們在開發的時候就可以獲得文本格式帶來的便利,同時又能在發佈的時候得到二進制格式的優勢.因爲兩種優勢都達到了,所以這種方法是很是流行的.惟一要關心的就是測試----開發組須要確保文件的變更不會致使二進制出問題.

    二進制格式

對於存儲遊戲數據來講,二進制格式一般都沒有自定義格式.這是由於有不少方式存儲數據,這些很大程度上取決於語言和框架.若是是C++遊戲,有時候最簡單的方法就是將類的數據直接輸出到外部文件.這個過程被稱爲序列化.可是有一些問題須要考慮,好比說,任何類種的動態數據都以指針形式存在.

    INI

最簡單的文本格式就是INI,常常在用戶須要改配置的時候使用.INI文件是分爲幾節的,而每一節有一系列的鍵和值.好比說,INI文件的圖形設置可能會是這樣的:

[Graphics]

Width=1680

Height=1050

FullScreen=true

Vsync=false

雖然對於簡單數據來說,INI能工做得很好,可是對於複雜得數據而言就顯得有些笨重.這對於關卡這種有佈局的數據結構而言不太適合,好比說,INI不支持嵌套的參數和節

因爲INI簡單並且使用普遍,全部有着大量容易使用的庫可供選擇.minIni

    XML

XML,全稱Extensible Markup Language,是一種HTML概念擴展出來的文件格式.

《巫師2》使用XML存儲全部可以在遊戲種找到的物品的配置.好比說,下面是某一項,其存儲了某個劍的狀態

<ability name="Forgotten Sword of Vrans _Stats">

  <damage_min mult="false" always_random="false" min="50" max="50" />

  <damage_max mult="false" always_random="false" min="55" max="55" />

  <endurance mult="false" always_random="false" min="1" max="1" />

  <crt_freeze display_perc="true" mult="false" always_random="false" min="0.2" max="0.2" type="critical"/>

  <instant_kill_chance display_perc="true" mult="false" always_random="false" min="0.02" max="0.02" type="bonus" />

  <vitality_regen mult="false" always_random="false" min="2" max="2" />

</ability>

關於XML的一個批評就是須要不少額外的字符來表示數據,有不少<和>符號,並且老是須要用名字和引號等修飾每一個參數,老是須要確保有配對的標籤,全部的組合致使文件比較大

可是XML的一大優點就是能夠使用模式,就是強制要求哪些字段是必需要有的.這就是說,很容易驗證XML文件和確保它聲明瞭必要的參數

像其餘常見文件格式同樣,有不少解析器都支持XML.用於C/C++最流行的解析器毫無疑問就是TinyXML(C++會附帶ticpp).一些語言會有內建的XML解析,好比,C#有System.Xml命名空間用於處理XML文件

    JSON

JSON,全稱JavaScript Object Notation,比起INI和XML這種新型的文件格式,JSON在近幾年很是流行.雖然JSON在互聯網交換數據中應用比較多,但在遊戲中用於輕量級數據格式也是能夠的.有大量的第三方庫能夠解析JSON,包括C++的libjson和C#的JSON.NET

根據存儲數據的類型,JSON可能與XML相比速度更快,體積更小.但也不老是這樣

"ability": {

  "name": "Forgotten Sword of Vrans _Stats",

  "damage_min": { "mult": false, "always_random": false, "min": 50, "max": 50 },

  "damage_max": { "mult": false, "always_random": false, "min": 55, "max": 55 },

  "endurance": { "mult": false, "always_random": false, "min": 1, "max": 1 },

  "crt_freeze": { "display_perc": true, "mult": false, "always_random": false, "min": 0.2, "max": 0.2, "type": "critical" },

  "instant_kill_change": { "display_perc": true, "mult": false, "always_random": false, "min": 0.2, "max": 0.2, "type": "bonus" },

  "vitality_regen": { "mult": false, "always_random": false, "min": 2, "max": 2 }

}

  案例學習:<<魔獸世界>>中的UI Mod

《魔獸世界》中的兩個主要空間是佈局和行爲.佈局就是界面中圖片,按鈕,控件的放置,存儲爲XML.而UI的行爲則使用了Lua腳本語言.

    佈局和事件

界面的佈局徹底是XML驅動的,用於設置基本的控件,好比框架,按鈕,滑動條,複選框等,UI開發者均可以使用.一樣在XML文件裏,插件指出哪裏有事件能夠註冊

    行爲

每一個插件的行爲都經過Lua實現,這樣能夠快速實現原型,並且能夠在遊戲運行中從新加載UI.因爲使用了基於表格的繼承系統,能夠修改父類的函數實現重載.大多數的插件代碼專一於處理事件和完成表現.每一個註冊了的事件都須要相應的Lua代碼來處理

    問題:玩家自動操做

    問題:UI兼容性

    結論

  相關資料

Aho, Alfred, et. al. Compilers: Principles, Techniques, and Tools (2nd Edition). Boston: Addison-Wesley,2006. 這本是經典書籍"龍書"的改進版,深刻講解了不少編譯器背後的概念.其中的不少知識均可以用於實現自定義腳本語言

"The SCUMM Diary: Stories behind one of the greatest game engines ever made." Gamasutra. http://tinyurl.com/pypbhp8:這是一篇關於SCUMM引擎和腳本語言的很是有趣的文章,這個引擎幾乎開發了全部LucasArts經典冒險遊戲

第12章 網絡遊戲

  協議

想象經過快遞服務寄出真實的郵件.咱們至少須要一個信封,上面寫着這封郵件的寄信地址和收信地址.一般,還會須要貼上郵票.在信封裏面放着的纔是你真正須要傳遞的信息----郵件自己.數據包能夠想象成是在網絡上傳輸的電子信封.在它的數據頭中有地址和其餘相關信息,而後才發出真正的數據幀

對於信封來講,地址有較標準的格式.寄信地址在左上角,目的地址在右邊中間,而郵票在右上角.這是大多數國家最多見的格式.可是對於網絡數據傳輸,有着不少不一樣的協議或規則來定義數據包以什麼格式以及爲了發送必須作什麼.網絡遊戲如今一般會讓遊戲邏輯使用兩種協議之一:TCP, UDP. 有的遊戲會使用第三種協議,ICMP,經常使用於一些非遊戲邏輯的功能.

    IP

IP,全稱Internet Protocol(網際網絡協議),要經過網路發送數據,這是須要遵照的最基本的協議.本章提到的每個協議,無論是ICMP,TCP仍是UDP,都必須附加IP協議纔可以傳輸數據.哪怕數據只是在本地網絡上傳輸.這就造就了一個事實,那就是如今全部在本地網絡中上的機器都須要一個特定的本地地址,只有這樣才能經過IP標識某個特定的地址

有兩個普遍使用的IP協議版本:IPv4和IPv6.IPv4地址是32位的,IPv6是128位的

    ICMP

ICMP,全稱Internet Control Message Protocol(網際網絡控制消息協議),並不用於在網絡上傳輸大量數據.所以,它不能用於發送遊戲數據.這就是說,在編寫多人遊戲時ICMP有一個方面是相關的:發送回聲包的能力.經過ICMP,能夠發送數據包給特定地址,而後直接讓數據包返回到發送者,這是經過ICMP的回聲請求和回聲響應機制完成的,這個回聲功能能夠用於測量兩臺計算機之間發包所須要的時間

行程往返的時間,就是延遲,在玩家有多臺服務器能夠選擇鏈接的時候特別有用.在遊戲測量全部能夠鏈接的服務器的延遲以後,就能夠選擇鏈接延遲最低的服務器.這個測量某個地址的延遲的過程稱爲ping

    TCP

傳輸控制協議(TCP)是遊戲在網絡上用來傳輸數據最經常使用的兩個協議之一.TCP是一個基於鏈接的,可靠的,保證順序的協議.可靠傳遞聽起來很好,但隨後咱們會進一步討論,TCP協議在遊戲上的應用一般沒有UDP流行

TCP是基於鏈接的,就是說兩臺計算機在任何數據傳輸以前,必須先創建好彼此的鏈接.鏈接完成的方法是經過握手.請求鏈接的計算機發送一個鏈接請求到目標計算機,告訴它本身想要如何鏈接,而後接收者確認這個請求.這個確認在被最初的請求者再次確認以後,三次握手的過程就完成了.

一旦在TCP鏈接創建以後,就能夠在兩臺計算機之間傳輸數據.以前提到,全部經過TCP發送的數據包都是可靠的.可靠的工做原理就是在每當數據包經過TCP發送之後,接收者都會告訴發送者我收到數據包.若是發送者在必定時間以內(超時)沒有收到應答,數據包再發送一次.發送者會不斷地發送數據包,直到收到應答爲止.

結果就是TCP不只保證全部數據包的接收是可靠的,還會保證它們的順序.舉個例子,假設如今有3個數據包A,B,C按順序發送.若是A和C到達,而B沒有到達,接收者不能處理C,除非B到達以後才能往下走.因此須要等待B重傳,在收到以後,才能夠繼續.因爲數據包丟失,或者數據包傳不過去的百分比,會大大減慢數據的傳輸

對於遊戲來講,保證順序很容易成爲沒必要要的瓶頸.若是以前例子中的A,B,C包含了某個玩家的位置信息:就是最開始玩家在A位置,而後在B位置,最後在C位置.在位置C收到以後,遊戲就不用關心位置B,由於玩家不在那個地方了.可是使用TCP,遊戲在收到位置B之前是沒法使用位置C的,這對於TCP顯然不是理想的場景

還有一個TCP須要考慮的有用的方面.全部網絡都有MTC,或者maximum transmission unit(最大傳輸單元),它會決定數據包的大小限制.若是你嘗試發送大於MTU的數據包,它會無法經過.幸運的是,TCP在設計上會由操做系統自動將大的數據塊分紅合適大小的數據包.因此若是你須要從網站上下載1MB的文件,若是使用了TCP,那麼分解爲合適大小的數據包以及保證接收順序的事情,程序員就不用操心了

在大多數場景中,一般不須要在遊戲中傳輸那麼大量的數據.可是仍是會有用到的狀況,好比說,若是遊戲支持自定義地圖,就會有一個問題,那就是新玩家試圖加入遊戲會話的時候是沒有這張自定義地圖的.經過TCP,就能夠輕鬆地將自定義地圖發送給試圖進入遊戲的新玩家,並且不用管地圖的大小

也就是說,對於真正的遊戲邏輯來講,只有小部分遊戲類型須要用到TCP.對於回合制遊戲,使用TCP是有意義的,由於經過網絡發送的全部信息都是相關的,它定義了某個回合中玩家執行的動做.另外一個經常使用TCP的遊戲類型是MMO,特別是《魔獸世界》,它的全部數據都用TCP傳送.因爲《魔獸世界》中的全部數據都是要保證順序的,因此使用內部就能保證順序的協議是很合理的.可是對於那些有更加實時動做的遊戲來講,好比FPS或者動做遊戲,一般都不會使用TCP/IP.這是由於對全部數據包的保證會影響遊戲性能

    UDP

數據包協議(UDP)是一種無鏈接,不可靠的協議.就是說你能夠直接發UDP數據包,就是說你能夠直接發UDP數據包,而不須要與指定目標創建鏈接.因爲它是一個不可靠協議,因此不會有保證數據包到達的手段,也不會保證數據包到達的順序,也沒有接收者應答的功能.因爲UDP是一種更加簡單的協議,數據頭比TCP要小得多

像TCP同樣,UDP也支持大約65000個端口.UDP端口和TCP端口是獨立的,因此若是TCP和UDP使用同一個端口是不會衝突的.因爲UDP是不可靠的,UDP的傳輸比TCP要高效不少.可是因爲不知道數據是否到達,也會形成一些問題.雖然有些數據不過重要(好比對手的位置信息),但仍是會有一些重要的保證遊戲狀態一致的數據.若是玩多人FPS遊戲,你發射了子彈,這個信息就很重要,要保證它被服務器或者其餘玩家所接收

一個嘗試的解決方案就是將TCP用於關鍵數據,使用UDP傳輸不過重要的數據.可是INET 97 proceeding paper指出(在相關資料中有說起),在TCP保證系統運行的同時,使用UDP會致使UDP丟包增長.一個更嚴重的問題是:雖然移動數據不是那麼重要,用UDP傳送很合理,但咱們仍是須要數據包的順序.沒有順序的信息,若是位置B在位置C以後收到,玩家就會被不正確地移動到舊位置

大多數遊戲處理這個問題都是使用UDP,而後在所需的數據包裏增長一些自定義的可靠層來完成.這個額外的層在UDP數據段的開始位置添加----能夠認爲是自定義的協議數據頭.最基本的可靠性數據是順序號,能夠跟蹤哪一個數據包號是哪一個,而後經過設置位域來應答.經過使用位域,某個數據包能夠同時應答多個數據包,而不須要每一個數據包都應答一次.這個系統同時還有靈活性,就是若是某個系統不須要可靠性和順序信息,那麼能夠不添加數據頭直接發送.

就像以前所提到的,UDP在實時遊戲領域中是占主導地位的協議.幾乎全部FPS,動做類,RTS以及其餘網絡遊戲中對時間敏感的信息都會使用UDP.這也是爲何幾乎全部爲遊戲設計的網絡中間件(好比RakNet)只支持UDP

  網絡拓撲

拓撲決定了不一樣的計算機在網絡遊戲會話中是如何相互鏈接的.雖然配置上有不少種不一樣的方式,但大多數遊戲都支持一種或兩種模型:服務器/客戶端或是點對點的.對於不少狀況,兩種方法各有優劣

    服務器/客戶端

服務器/客戶端模型中,有一箇中心計算機(也就是服務器),全部的其餘計算機(也就是客戶端)都會與之通訊.由於服務器與每一臺客戶端通訊,因此在這個模型中,會須要一臺有着比客戶端更高帶寬和處理能力的機器.好比說,若是客戶端發送10Kbps數據,在8人遊戲中,意味着服務器須要接收80Kbps的數據,這類模型一般也叫做中心型結構,由於服務器是全部客戶端的中心節點

服務器/客戶端模型是今天最流行的網絡遊戲拓撲結構.大多數FPS,動做遊戲,MMO,策略遊戲,回合制遊戲等都使用服務器/客戶端模型.固然當中會有一些例外,可是確實不少網絡遊戲都使用服務器/客戶端模型

在常見的服務器/客戶端模型的實現中,服務器會被認爲是權威的,就是說它須要驗證大多數客戶端行爲.假設網絡遊戲容許玩家向另外一名玩家投擲閃避球,在另外一名玩家被閃避球打中以後,投擲的玩家會得分.在權威服務器中,當玩家想投擲閃避球的時候,會先向服務器發起請求,服務器會檢查這是不是一個合法動做.而後服務器會模擬閃避球的彈道,在每一幀檢查這個球是否與某個客戶端發生碰撞.若是客戶端被擊中,服務器會通知客戶端被戰勝

服務器驗證的理由有兩個.第一個理由就是服務器會擁有全部客戶端的最新位置信息.一個客戶端投出閃避球,可能會認爲本身投中了,但這多是由於當時位置不是最新的.而若是客戶端可以用閃避球淘汰其餘玩家而無須通過服務器驗證的話,就很容易有外掛程序做弊淘汰其餘玩家.

由於服務器須要認證,服務器/客戶端模型的遊戲邏輯實現起來就比單人遊戲更加複雜.在單人遊戲中,若是用空格鍵發射導彈,相同的代碼會檢測空格鍵能夠建立和發射導彈.可是在服務器/客戶端遊戲中,空格鍵代碼必須建立發射請求數據包到服務器,而後服務器通知全部其餘玩家導彈的存在.

由於這是兩種徹底不一樣的方法,對於想要實現兩種模式(多人與單人)的遊戲來講,最好的方法就是將單人模式看成特殊的多人模式.這在許多遊戲引擎中是默認的實現方式,包括id Softwave引擎.就是說單人模式其實是將服務器和客戶端都在一臺機器上運行.將單人模式看做多人模式的一種特例的優勢在於,只須要一套遊戲邏輯代碼.不然,網絡程序員要在權威服務器的遊戲邏輯開發上花不少時間

若是咱們回到閃避球遊戲的例子,想象一下若是玩家能夠選擇目標.就是說,它們須要用一些方法以知道對手玩家的運動方向纔可以預判出成功的投擲.在最好的狀況下,客戶端能夠以四分之一秒一次地收到服務器下發的對手玩家的位置更新數據.如今想象若是客戶端只在收到服務器數據的時候才更新對手位置,就是說每隔四分之一秒,對手玩家都會上傳新的位置而對手位置老是閃來閃去.若是在這種狀況下試着去擊中一個閃避球----固然聽上去遊戲彷佛不太好玩

爲了解決這個問題,大多數遊戲都會實現某種客戶端預測,也就是客戶端會在兩次服務器下發數據之間猜想中間的過渡狀況.在移動的例子中,若是服務器在下發對手玩家的速度的同時一塊兒下發位置,那麼客戶端預測就能夠工做.而後,在服務器更新之間的幾幀裏,客戶端能夠根據最後收到的速度和位置,推算對手玩家的位置

只要更新足夠頻繁,客戶端就能讓對手玩家在全部時刻都有足夠準確的表現.可是,若是因爲鏈接問題致使更新不夠頻繁,客戶端預測就會變得不許確.因爲服務器是最權威的存在,客戶端必須修復預測位置與真實位置之間的差別.可是若是預測工做得足夠好,就能看起來很是順暢

這個概念還能夠延伸到本地終端上執行的動做.若是咱們想要一個遊戲邏輯很順暢,就要在玩家按下空格鍵投擲閃避球的時候,屏幕上馬上有投擲動畫.若是客戶端要等到服務器確認閃避球投擲以後纔開始,遊戲會很是卡頓.背後的解決方案就是在服務器確認投擲有效的同時,本地客戶端就能夠開始播放投擲閃避球動畫.若是結果是投擲是非法的,客戶端會修復這個問題.只要玩家正確同步,投擲閃避球的時候,遊戲的感受就會很是順暢

儘管服務器/客戶端模型是一種很是流行的方法,但仍是有一些問題須要考慮.首先,有些遊戲容許一臺計算機同時運行服務器和客戶端,但仍是有一些問題須要考慮.首先,有些遊戲容許一臺計算機同時運行服務器和客戶端.在這種狀況下,就會有主機優點,就是說服務器,客戶端同時跑的玩家,會獲得最快速的服務器響應

另外一個服務器/客戶端模型的問題就是,若是服務器崩潰,遊戲馬上就結束,而全部客戶端都失去與服務器的通訊.而鏈接到新的服務器是很困難的(由於全部客戶端的信息都不完整),因此不可能修復這個問題.就是說若是玩家是主機,並且立刻要失敗了,玩家只要退出遊戲就能夠重啓全部玩家,這樣就讓遊戲變得沒意思了.可是從服務器/客戶端模型來看,若是一個客戶端延遲很是多,對其餘玩家的體驗影響不是特別大

在任何事件中,爲了防止主機帶來的問題,許多服務器/客戶端遊戲只支持專用服務器.在大多數例子下,這意味着服務器是安裝在一個特別的位置的(一般在數據中心),而全部玩家都須要鏈接到這些服務器(沒有玩家能夠作主機).雖然網速快的玩家仍是比網速慢的玩家有優點,可是經過將服務器放在第三方的方法將這種絕對優點大大減弱了.但是,運行專用服務器的缺點就是部署得越多,費用就越高

    點對點

點對點模型中,每一個客戶端都鏈接到其餘客戶端.這意味着對於全部客戶端都要求一樣的性能和帶寬.因爲點對點模型中沒有中心的權威服務器,會有不少種可能:每一個客戶端只認證本身的動做,或者每一個客戶端都認證其餘客戶端,又或者每一個客戶端都模擬整個世界

RTS類型種常常會用到點對點模型.正式一點的名稱爲幀同步模型,就是網絡更新被分爲每次150ms到200ms的回合更新.每當執行一次輸入動做,這個命令都會保存到隊列裏,在每輪結束的時候執行.這就是爲何在你玩多人遊戲《星際爭霸2》的時候,控制單位的命令沒有馬上執行----在單位迴應命令以前有明顯的延遲,由於它們都在等待幀同步回合的結束

由於輸入命令經過網絡傳遞,RTS遊戲中的每一個客戶端其實是在模擬全部單位,它們像本地玩家同樣處理輸入.這也使得記錄下全部對手的指令並在遊戲結束後查看即時回放成爲可能

客戶端的幀同步方法會使全部客戶端都緊密地同步,沒有任何玩家可以比其餘玩家先走.固然,這種同步方式的缺點就是----若是一個客戶端開始延遲,其餘客戶端都要等待,一直到這個玩家遇上來.可是幀同步方法在RTS遊戲裏面很是流行,由於它經過網絡傳輸的數據相對來說會更少.比起發送全部單位的信息,遊戲只在每分鐘發送相對小數量的動做,即便是最好的RTS玩家每分鐘最多也就發送300-400次指令

由於在點對點配置中,每一個點都要模擬遊戲世界的一部分,遊戲狀態必須保證100%的肯定,這使得基於變化的遊戲邏輯不太好作.若是《星際爭霸2》的狂熱者能夠根據擲骰來決定攻擊傷害,這可能會致使這一點的模擬與另外一點的模擬不一致.但這也不是說在點對點模型中徹底不能有隨機要素(好比《英雄連》),可是要付出更大的努力保證各點同步一致

雖然RTS是將點對點模型用得最多的遊戲類型,但還有其餘遊戲也這麼作.一個著名的例子就是《光暈》系列,在多人對戰中使用點對點模型.可是能夠說,點對點沒有服務器/客戶端模型使用那麼普遍

  做弊

任何網絡遊戲主要考慮的一點就是要爲玩家打造公平的遊戲環境.但不幸的是,有的玩家爲了勝利會不擇手段,哪怕打破遊戲的規則.在網絡多人遊戲中,有不少規則都有可能會被打破,因此無論是否可能,網絡遊戲須要防止玩家做弊

    信息做弊

信息做弊是一種讓玩家得到本不應擁有的額外信息的手段.

    遊戲狀態做弊

信息做弊可以讓玩家得到不對稱的優點,而遊戲狀態做弊則能夠徹底破壞遊戲

    中間人攻擊

影響最壞的一種做弊就是在兩臺通訊機器之間設立一臺機器,用於攔截全部數據包.這種稱爲中間人攻擊,在經過網絡發送明文消息的時候特別容易被人篡改.這種攻擊是爲何咱們鏈接到金融機構網站的時候應該使用HTTPS而不是HTTP的主要緣由

使用HTTP,全部數據都以明文的方式經過網絡發送.就是說,若是在網站上你用HTTP提交你的用戶名和密碼,容易遇到中間人竊取信息.而經過HTTPS,全部數據都是加密的,這使得中間人幾乎不可能訪問到這些信息.雖然HTTPS協議暴露出幾個脆弱的地方,可是對於大多數用戶而言這都不是大問題

可是,在遊戲的狀況下,中間人攻擊最大的優勢在於它容許做弊發生在不須要玩遊戲的機器.這就是說幾乎全部的外掛檢測程序都不起做用----它們根本就不知道數據包被攔截並且被篡改了.還有一個問題是,通常經過訪問數據包信息可能容許黑客更加深刻地發掘其餘可以滲透遊戲的漏洞

一種防止這類攻擊的方法就是對全部數據包進行加密,這樣只有服務器才能解密並理解數據包.但這對於大多數遊戲消息來講都太重了,對全部信息加密實際上增長了負荷.但最少,咱們能夠在玩家登陸(好比在MMO)的時候進行一次加密,經過某種加密算法來有效避免玩家帳號被盜

  相關資料

Frohnmayer, Mark and Tim Gift. "The TRIBES Engine Networking Model." 這份經典的論文列出瞭如何實現爲UDP增長可靠性和數據流功能,並且應用到了Tribes上

Sawashima, Hidenari et al. "Characteristics of UDP Packet Loss: Effect of TCP Traffic." Proceedings of INET 97. 這篇文章從技術的角度討論了爲何TCP應答會讓UDP丟包

Steed, Anthony and Manuel Oliveria. Networked Graphics. Burlington: Morgan Kaufmann, 2009. 這本書深刻地講解了遊戲網絡編程的方方面面

第13章 遊戲示例:橫向滾屏者(iOS)

  概覽

    Objective-C

 

    Cocos2D

 

  代碼分析

 

    AppDelegate

 

    MainMenuLayer

 

    GameplayScene

 

    ScrollingLayer

 

    Ship

 

    Projectile

 

    Enemy

 

    ObjectLayer

 

第14章 遊戲示例:塔防(PC/Mac)

  概覽

 

    C#

 

    XNA

 

    MonoGame

 

  代碼分析

 

    設置

 

    單件

 

    遊戲類

 

    遊戲狀態

 

    遊戲對象

 

    關卡

 

    計時器

 

    尋路

 

    攝像機和投影

 

    輸入

 

    物理

 

    本地化

 

    圖形

 

    聲音

 

    用戶界面

相關文章
相關標籤/搜索