6-用GPU提升效能(3)-從零開始寫一個武俠冒險遊戲

自由布魯斯發表於2016-06-23

從零開始寫一個武俠冒險遊戲-6-用GPU提升效能(3)

--解決因繪製雷達圖導致的幀速下降問題

概述

現在輪到用 mesh 改寫那個給效能帶來巨大影響的狀態類了, 分析一下不難發現主要是那個實時繪製並且不停旋轉的雷達圖拖累了幀速, 那麼我們就先從雷達圖入手.

開始我感覺這個雷達圖改寫起來會比較複雜, 因為畢竟在狀態類的程式碼中, 雷達圖就佔據了一多半, 而且又有實時繪製, 又要旋轉, 想想都覺得麻煩, 所以就把它放到最後面來實現.

不過現在正式開始考慮時, 才發現, 其實想多了, 而且又犯了個特別容易犯的毛病: 一次性考慮所有的問題, 於是問題自然就變複雜了, 那麼我們繼續遵循最初的原型開發原則, 先提取核心需求, 從簡單入手, 一步一步來, 一次只考慮一個問題, 這樣把整個問題分解開發就發現其實也沒多難.

用 mesh 改寫狀態類

整體思路

改寫工作的核心就是先畫個大六邊形作為雷達圖的背景, 再根據角色的 6 個屬性值畫一個小多邊形(可能會凹進去), 最後讓它旋轉, 其中涉及的實時計算全部放到 shader 中.

還有要做的就是在六邊形頂點處顯示屬性名稱, 最後把狀態列也用 mesh 繪製出來.

改寫雷達圖

具體來說就是兩部分工作:

  • 繪製雷達圖背景:大六邊形
  • 繪製技能線:小多邊形

我們前面也用過 mesh 繪圖, 使用了函式 addRect(), 因為我們當時繪製的是一個方形區域, 現在要繪製六邊形, 可以使用 mesh 的另一種繪圖方式: 為其提供多邊形的頂點, 這些頂點用於組成一個個的三角形, 使用屬性 mesh.vertices 來傳遞頂點, 形如:

mesh.vertices = {vec2(x1,y1), vec2(x2,y2), vec2(x3,y3), ...}

這種繪圖方式最靈活, 不過也比較麻煩, 因為要計算好各個三角形的位置, 這些三角形還要設定好順序, 否則就容易畫錯, 好在 Codea 還提供了一個把多邊形拆分為三角形的函式 triangulate()(實際是封裝了 OpenGL ES 2.0/3.0 的函式), 只要給出多邊形的頂點座標, 就可以返回拼接成多邊形的多個三角形的頂點座標.

先試試再說, 為避免影響已有程式碼, 我們在 Status 類中單獨寫一個新函式 Status:radarGraphMesh(), 在這個函式裡進行我們的改寫工作, 程式碼如下:

-- 用 mesh 繪製雷達圖
function Status:raderGraphMesh()
    -- 雷達圖底部大六邊形背景
    self.m = mesh()
    -- 雷達圖中心座標,半徑,角度
    local x0,y0,r,a,s = 250,330,500,360/6,4
    -- 計算右上方斜線的座標
    local x,y = r* math.cos(math.rad(30)), r* math.sin(math.rad(30))
    -- 六邊形 6 個頂點座標,從正上方開始,逆時針方向
    local points = triangulate({vec2(0,r/s),vec2(-x/s,y/s),vec2(-x/s,-y/s),
                                vec2(0,-r/s),vec2(x/s,-y/s),vec2(x/s,y/s)})
    print(#points, points[1], points[2],points[3])
    self.m.vertices = points
    local c1 = color(0, 255, 121, 123)
    self.m:setColors(c1)    
end


-- main 主程式框架
function setup()
    displayMode(OVERLAY)
    myStatus = Status()
    myStatus:raderGraphMesh()
end

function draw()
    background(32, 29, 29, 255)

    translate(650,300)
    myStatus.m:draw()

    sysInfo()
end

-- 系統資訊: 顯示FPS和記憶體使用情況
function sysInfo()
    pushStyle()
    fill(255, 255, 255, 255)
    -- 根據 DeltaTime 計算 fps, 根據 collectgarbage("count") 計算記憶體佔用
    local fps = math.floor(1/DeltaTime)
    local mem = math.floor(collectgarbage("count"))
    text("FPS: "..fps.."    Mem:"..mem.." KB",650,740)
    popStyle()
end

截圖如下:

雷達圖背景大六邊形

看起來不錯, 再在這個大六邊形上面畫一個小六邊形, 小六邊形的頂點需要根據屬性值(體力,內力,精力,智力,,)來計算, 還好我們前面寫過一個計算函式 linesDynamic(t,n,j,z,q,x), 把它改個名字, 再稍作改動, 讓它返回計算出來的頂點, 然後再新建一個名為 m1mesh, 用來繪製代表屬性值的小六邊形, 程式碼如下:

-- 用 mesh 繪製雷達圖
function Status:raderGraphMesh()
    ...
    -- 實時繪製頂點位置,根據各狀態屬性值,實時計算頂點位置
    local function axisDynamic()
        local t,n,j,z,q,x = self.tili, self.neili, self.jingli,self.zhili, self.qi, self.xue
        local c,s = math.cos(math.rad(30)), math.sin(math.rad(30))
        local points = triangulate({vec2(0,t),vec2(-n*c,n*s),vec2(-j*c,-j*s),
                                    vec2(0,-z),vec2(q*c,-q*s),vec2(x*c,x*s)})
        return points
    end

    -- 繪製代表屬性值的小六邊形
    self.m1 = mesh()
    self.m1.vertices = axisDynamic()
    local c = color(0, 255, 121, 123)
    self.m1:setColors(c)
    ...
end

在主程式的 draw() 中增加一句 myStatus.m1:draw() 就可以了, 看看執行截圖:

增加了屬性值小六邊形

很好, 非常符合我們的要求, 不過有一點就是作為背景的大六邊形的對角的線沒有畫出來, 現在需要處理一下, 實際上用 shader 最適合畫的圖形就是三角形, 直線有點麻煩(雖然 OpenGL ES 2.0 支援 三角形, 直線 三種基本圖形繪製, 不過我在 Codea 中沒找到直線的函式), 當然, 我們也可以用兩個狹長的三角形拼成一個細長的矩形來模擬直線, 不過這樣比較麻煩, 所以我們打算改用另外一種方法來實現: 把組成六邊形的三角形的頂點設定不同的顏色, 這樣相鄰兩個三角形之間那條公共邊就被突出了.

mesh 中, 可以用這個函式來設定頂點的顏色 mesh:color(i, color), 第一個引數 i 是頂點在頂點陣列中的索引值, 從 1 開始, 貌似我們的六邊形總共生成了 12 個頂點(感覺好像有些不對), 每 3 個頂點組成一個三角形, 先隨便改改看看是什麼效果, 就修改其中 1,5,9 號頂點好了, 馬上試驗:

...
local c1,c2 = color(0, 255, 121, 123),color(255, 57, 0, 123)
self.m:setColors(c2)
self.m:color(1,c1)
self.m:color(5,c1)
self.m:color(9,c1)
...

看看截圖:

第一次修改頂點顏色

果然, 完全不是我們想象中的六個小三角形, 原來出於優化的原因, 函式 triangulate() 會生成儘量少的三角形, 我們的六邊形只需要 4 個三角形就可以了, 所以它返回 12 個頂點, 看來想達到我們的效果, 還得手動設定頂點, 好在我們的圖形比較規則, 只需要再加一箇中心的座標就夠了, 而我們中心點的座標很有先見之明地被設定為了 vec2(0,0), 程式碼如下:

...
-- 手動定義組成六邊形的6個三角形的頂點
local points = {vec2(0,r/s), vec2(-x/s,y/s), vec2(0,0),
                vec2(-x/s,y/s), vec2(-x/s,-y/s), vec2(0,0),
                vec2(-x/s,-y/s), vec2(0,-r/s), vec2(0,0),
                vec2(0,-r/s), vec2(x/s,-y/s), vec2(0,0),
                vec2(x/s,-y/s), vec2(x/s,y/s), vec2(0,0),
                vec2(x/s,y/s), vec2(0,r/s), vec2(0,0)} 
self.m.vertices = points

local c1,c2 = color(186, 255, 0, 123),color(25, 235, 178, 123)
self.m:setColors(c2)
self.m:color(1,c1)
self.m:color(4,c1)
self.m:color(7,c1)
self.m:color(10,c1)
self.m:color(13,c1)
self.m:color(16,c1)

...

六個三角形顏色區分的截圖

再看看效果, 還可以, 好, 就按這個方式寫了.

現在需要處理的是這個用於實時計算屬性值頂點的函式 axisDynamic(), 認真分析一下, 就會發現, 其實我們不需要實時計算, 因為屬性值並不是實時更新的, 它應該是隨著角色的活動而變化, 角色有活動它才會變, 當然這也取決於我們的設定, 如果我們設定說角色只要有動作就會耗費體力, 哪怕角色坐著不動, 只要時間流逝它也會變的話, 那麼它就需要實時繪製了, 我們先按實時繪製來實現. 既然是實時計算, 那我們希望把這部分計算處理也放到 GPU 中處理, 也就是說需要在 shader 中實現這個函式.

另外就是目前只用一個函式 radarGraphMesh() 來實現雷達圖的繪製, 有些結構不合理, 一些初始化的工作在每次繪製時都要做, 所以打算把它拆分成三個個函式, 函式 radarGraphInit() 用來負責初始化一些頂點資料, 函式 radarGraphVertex() 用來根據屬性值實時計算頂點座標, 函式 radarGraphDraw() 用來執行繪圖操作, 如下:

function Status:radarGraphInit()
    -- 雷達圖底部六邊形背景
    self.m = mesh()
    p = {"體力","內力","精力","智力","氣","血"}
    -- 中心座標,半徑,角度,縮放比例
    local x0,y0,r,a,s = 150,230,50,360/6,1
    -- 計算右上方斜線的座標
    local x,y = r* math.cos(math.rad(30)), r* math.sin(math.rad(30))
    -- 六邊形 6 個頂點座標,從正上方開始,逆時針方向
    local points = triangulate({vec2(0,r/s),vec2(-x/s,y/s),vec2(-x/s,-y/s),
                                vec2(0,-r/s),vec2(x/s,-y/s),vec2(x/s,y/s)})
    print(#points, points[1], points[2],points[3])
    -- 手動定義組成六邊形的6個三角形的頂點
    local points = {vec2(0,r/s), vec2(-x/s,y/s), vec2(0,0),
                    vec2(-x/s,y/s), vec2(-x/s,-y/s), vec2(0,0),
                    vec2(-x/s,-y/s), vec2(0,-r/s), vec2(0,0),
                    vec2(0,-r/s), vec2(x/s,-y/s), vec2(0,0),
                    vec2(x/s,-y/s), vec2(x/s,y/s), vec2(0,0),
                    vec2(x/s,y/s), vec2(0,r/s), vec2(0,0)} 
    self.m.vertices = points

    local c1,c2 = color(186, 255, 0, 123),color(25, 235, 178, 123)
    self.m:setColors(c2)
    self.m:color(1,c1)
    self.m:color(4,c1)
    self.m:color(7,c1)
    self.m:color(10,c1)
    self.m:color(13,c1)
    self.m:color(16,c1)


    -- 繪製代表屬性值的小六邊形
    self.m1 = mesh()
    self.m1.vertices = self:radarGraphVertex()
    local c = color(221, 105, 55, 123)
    self.m1:setColors(c)

end

-- 實時繪製頂點位置,根據各狀態屬性值,實時計算頂點位置
function Status:radarGraphVertex()
    local l = 4
    local t,n,j,z,q,x = self.tili/l, self.neili/l, self.jingli/l,self.zhili/l, self.qi/l, self.xue/l
    local c,s = math.cos(math.rad(30)), math.sin(math.rad(30))
    local points = triangulate({vec2(0,t),vec2(-n*c,n*s),vec2(-j*c,-j*s),
                                    vec2(0,-z),vec2(q*c,-q*s),vec2(x*c,x*s)})
    return points
end

function Status:radarGraphDraw()
    setContext(self.img)
    pushMatrix()
    pushStyle()
    -- 平移到中心 (x0,y0), 方便以此為中心旋轉
    translate(x0,y0)
    -- 圍繞中心點勻速旋轉
    rotate(30+ElapsedTime*10)

    self.m:draw()
    self.m1:draw()


    strokeWidth(2)    
    -- noSmooth()
    stroke(21, 42, 227, 255)
    fill(79, 229, 28, 255)
    -- 繪製雷達圖相對頂點之間的連線
    for i=1,6 do
        -- print(i)
        text(p[i],0,45)
        -- line(0,0,0,r)
        rotate(a)
    end

    popStyle()
    popMatrix()
    setContext()    
end

再把 Status:radarGraphInit() 放到 Status:init() 中, 把 Status:radarGraphDraw() 放到 Status:drawUI() 中, 如下:

function Status:init() 
    ...

    -- 初始化雷達圖
    self:radarGraphInit()
end

function Status:drawUI()
    ...

    self:radarGraphDraw()
    sprite(self.img, 400,300)
end

執行發現幀速大幅提升, 基本在 60 左右, 看來之前拖累效能的原因是不合理的程式結構(把所有工作都放到一個函式 Status:radarGraph() 中去繪製雷達圖), 真是歪打正著, 這麼看來, 這裡僅僅做完這兩點:

  • 把繪圖方式改寫為 mesh;
  • 修改不合理的程式結構.

就已經把效能大幅度提升了, 也就沒必要再用 shader 來改寫了.

剩下的就是一些收尾工作, 比如把一些除錯時使用的全域性變數改寫為類屬性什麼的, 完成後的完整狀態類如下:

-- 用 mesh 繪製,先繪製背景六邊形,再繪製技能六邊形,再繪製動態技能,最後再考慮旋轉
-- 角色狀態類
Status = class()

function Status:init() 
    -- 體力,內力,精力,智力,氣,血
    self.tili = 100
    self.neili = 30
    self.jingli = 70
    self.zhili = 100
    self.qi = 100
    self.xue = 100
    self.gongfa = {t={},n={},j={},z={}}
    self.img = image(200, 300)
    -- 初始化雷達圖
    self:radarGraphInit()
end

function Status:update()
    -- 更新狀態:自我修煉,日常休息,戰鬥
    self.neili = self.neili + 1
    self:xiulian()
end

function Status:drawUI()
    setContext(self.img)
    background(119, 121, 72, 255)
    pushStyle()
    fill(35, 112, 111, 114)
    rect(5,5,200-10,300-10)
    fill(70, 255, 0, 255)
    textAlign(RIGHT)
    local w,h = textSize("體力: ")
    text("體力: ",30,280) 
    text(math.floor(self.tili), 30 + w, 280)
    text("內力: ",30,260) 
    text(math.floor(self.neili),  30 + w, 260)
    text("精力: ",30,240) 
    text(math.floor(self.jingli), 30 + w, 240)
    text("智力: ",30,220) 
    text(math.floor(self.zhili), 30 + w, 220)
    text("氣    : ",30,200) 
    text(math.floor(self.qi), 30 + w, 200)
    text("血    : ",30,180) 
    text(math.floor(self.xue), 30 + w, 180)
    -- 繪製狀態列繪製的角色
    sprite("Documents:B1", 100,90)
    popStyle()
    setContext()

    self:radarGraphDraw()
    sprite(self.img, 400,300)
end

function Status:xiulian()
    -- 修煉基本內功先判斷是否滿足修煉條件: 體力,精力大於50,修煉一次要消耗一些
    if self.tili >= 50 and self.jingli >= 50 then
        self.neili = self.neili * (1+.005)
        self.tili = self.tili * (1-.001)
        self.jingli = self.jingli * (1-.001)
    end
end

-- 用 mesh 繪製, 改寫為3個函式
function Status:radarGraphInit()
    -- 雷達圖底部六邊形背景
    self.m = mesh()
    p = {"體力","內力","精力","智力","氣","血"}
    -- 中心座標,半徑,角度,縮放比例
    self.x0, self.y0, self.rr, self.ra, self.rs = 150,230,40,360/6,1
    local x0,y0,r,a,s = self.x0, self.y0, self.rr, self.ra, self.rs
    -- 計算右上方斜線的座標
    local x,y = r* math.cos(math.rad(30)), r* math.sin(math.rad(30))
    -- 六邊形 6 個頂點座標,從正上方開始,逆時針方向
    local points = triangulate({vec2(0,r/s),vec2(-x/s,y/s),vec2(-x/s,-y/s),
                                vec2(0,-r/s),vec2(x/s,-y/s),vec2(x/s,y/s)})
    print(#points, points[1], points[2],points[3])
    -- 手動定義組成六邊形的6個三角形的頂點
    local points = {vec2(0,r/s), vec2(-x/s,y/s), vec2(0,0),
                    vec2(-x/s,y/s), vec2(-x/s,-y/s), vec2(0,0),
                    vec2(-x/s,-y/s), vec2(0,-r/s), vec2(0,0),
                    vec2(0,-r/s), vec2(x/s,-y/s), vec2(0,0),
                    vec2(x/s,-y/s), vec2(x/s,y/s), vec2(0,0),
                    vec2(x/s,y/s), vec2(0,r/s), vec2(0,0)} 
    self.m.vertices = points

    local c1,c2 = color(186, 255, 0, 123),color(25, 235, 178, 123)
    self.m:setColors(c2)
    self.m:color(1,c1)
    self.m:color(4,c1)
    self.m:color(7,c1)
    self.m:color(10,c1)
    self.m:color(13,c1)
    self.m:color(16,c1)


    -- 繪製代表屬性值的小六邊形
    self.m1 = mesh()
    self.m1.vertices = self:radarGraphVertex()
    local c = color(221, 105, 55, 123)
    self.m1:setColors(c)

end

-- 實時繪製頂點位置,根據各狀態屬性值,實時計算頂點位置
function Status:radarGraphVertex()
    local l = 4
    -- 中心座標,半徑,角度,縮放比例
    local x0,y0,r,a,s = self.x0, self.y0, self.rr, self.ra, self.rs
    local t,n,j,z,q,x = self.tili/l, self.neili/l, self.jingli/l,self.zhili/l, self.qi/l, self.xue/l
    local c,s = math.cos(math.rad(30)), math.sin(math.rad(30))
    local points = triangulate({vec2(0,t),vec2(-n*c,n*s),vec2(-j*c,-j*s),
                                    vec2(0,-z),vec2(q*c,-q*s),vec2(x*c,x*s)})
    return points
end

function Status:radarGraphDraw()
    setContext(self.img)
    pushMatrix()
    pushStyle()

    -- 中心座標,半徑,角度
    local x0,y0,r,a,s = self.x0, self.y0, self.rr, self.ra, self.rs
    -- 平移到中心 (x0,y0), 方便以此為中心旋轉
    translate(x0,y0)
    -- 圍繞中心點勻速旋轉
    rotate(30+ElapsedTime*10)

    self.m:draw()

    strokeWidth(2)    
    -- Smooth()
    stroke(21, 42, 227, 255)
    fill(79, 229, 128, 255)
    -- 繪製雷達圖相對頂點之間的連線
    for i=1,6 do
        text(p[i],0,r+15)
        -- line(0,0,0,49)
        rotate(a)
    end
    self.m1.vertices = self:radarGraphVertex()
    self.m1:draw()

    popStyle()
    popMatrix()
    setContext()    
end


-- main 主程式框架
function setup()
    displayMode(OVERLAY)
    myStatus = Status()
end

function draw()
    background(32, 29, 29, 255)    
    myStatus:drawUI()
    sysInfo()
end

-- 系統資訊: 顯示FPS和記憶體使用情況
function sysInfo()
    pushStyle()
    fill(255, 255, 255, 255)
    -- 根據 DeltaTime 計算 fps, 根據 collectgarbage("count") 計算記憶體佔用
    local fps = math.floor(1/DeltaTime)
    local mem = math.floor(collectgarbage("count"))
    text("FPS: "..fps.."    Mem:"..mem.." KB",650,740)
    popStyle()
end


-- Shader
shadersStatus = {
status = { vs=[[
// 雷達圖著色器: 用 shader 繪製雷達圖
//--------vertex shader---------
attribute vec4 position;
attribute vec4 color;
attribute vec2 texCoord;

varying vec2 vTexCoord;
varying vec4 vColor;

uniform mat4 modelViewProjection;

void main()
{
    vColor = color;
    vTexCoord = texCoord;
    gl_Position = modelViewProjection * position;
}
]],
fs=[[
//---------Fragment shader------------
//Default precision qualifier
precision highp float;

varying vec2 vTexCoord;
varying vec4 vColor;

// 紋理貼圖
uniform sampler2D texture;

void main()
{
    // vec4 col = texture2D(texture,vec2(mod(vTexCoord.x,1.0), mod(vTexCoord.y,1.0)));
    vec4 col = texture2D(texture,vTexCoord);
    gl_FragColor = vColor * col;
}
]]}
}
```

發現改寫為 mesh, 再把原來的一個函式拆分成三個後, 不僅效能提升了, 而且程式碼也沒那麼多了, 更重要的是讀起來很清晰.

本章小結

現在, 我們已經用 mesh 完成 幀動畫, 地圖類狀態類 的改寫, 而且效果還不錯, 幀速也提升到了 60 左右, 既然達到了起初的目標, 那麼剩下的就是再次把這幾個改寫後的模組整合到一起, 整合後的程式碼在這裡: c06.lua.

所有章節連結

Github專案地址

Github專案地址, 原始碼放在 src/ 目錄下, 圖片素材放在 assets/ 目錄下, 整個專案檔案結構如下:

Air:Write-A-Adventure-Game-From-Zero admin$ tree
.
├── README.md
├── Vim 列編輯功能詳細講解.md
├── assets
│   ├── IMG_0097.PNG
│   ├── IMG_0099.JPG
│   ├── IMG_0100.PNG
│   ├── c04.mp4
│   ├── cat.JPG
│   └── runner.png
├── src
│   ├── c01.lua
│   ├── c02.lua
│   ├── c03.lua
│   ├── c04.lua
│   ├── c05.lua
│   ├── c06-01.lua
│   ├── c06-02.lua
│   ├── c06-03.lua
│   └── c06.lua
├── 從零開始寫一個武俠冒險遊戲-0-開發框架Codea簡介.md
├── 從零開始寫一個武俠冒險遊戲-1-狀態原型.md
├── 從零開始寫一個武俠冒險遊戲-2-幀動畫.md
├── 從零開始寫一個武俠冒險遊戲-3-地圖生成.md
├── 從零開始寫一個武俠冒險遊戲-4-第一次整合.md
├── 從零開始寫一個武俠冒險遊戲-5-使用協程.md
├── 從零開始寫一個武俠冒險遊戲-6-用GPU提升效能(1).md
├── 從零開始寫一個武俠冒險遊戲-6-用GPU提升效能(2).md
└── 從零開始寫一個武俠冒險遊戲-6-用GPU提升效能(3).md

2 directories, 26 files
Air:Write-A-Adventure-Game-From-Zero admin$ 

開源中國專案文件連結

從零開始寫一個武俠冒險遊戲-0-開發框架Codea簡介
從零開始寫一個武俠冒險遊戲-1-狀態原型
從零開始寫一個武俠冒險遊戲-2-幀動畫
從零開始寫一個武俠冒險遊戲-3-地圖生成
從零開始寫一個武俠冒險遊戲-4-第一次整合
從零開始寫一個武俠冒險遊戲-5-使用協程
從零開始寫一個武俠冒險遊戲-6-用GPU提升效能(1)
從零開始寫一個武俠冒險遊戲-6-用GPU提升效能(2)
從零開始寫一個武俠冒險遊戲-6-用GPU提升效能(3)

相關文章