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

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

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

概述

我們之前所有的繪圖工作都是直接使用基本繪圖函式來繪製的, 這樣寫出來的程式碼容易理解, 不過這些程式碼基本都是由 CPU 來執行的, 沒怎麼發揮出 GPU 的作用, 實際上現在的移動裝置都有著功能不弱的 GPU(一般都支援 OpenGL ES 2.0/3.0), 本章的目標就是把我們遊戲中繪圖相關的大部分工作都轉移到 GPU 上, 這樣既可以解決我們程式碼目前存在的一些小問題, 同時也會帶來很多額外好處:

  • 首先是效能可以得到很大提升, 我們現在的幀速是40左右, 主要是雷達圖的實時繪製拖慢了幀速;
  • 方便在地圖類上實現各種功能, 如大地圖的區域性顯示, 地圖平滑捲動;
  • 保證地圖上的物體狀態更新後重繪地圖時的效率;
  • 幀動畫每次起步時速度忽然加快的問題, 反向移動時角色動作顯示為倒退, 需要映象翻轉;
  • 狀態列可以通過 紋理貼圖 來使用各種中文字型(Codea不支援中文字型);
  • 最大的好處是: 可以通過 shader 來自己編寫各種圖形特效.

Codea 裡使用 GPU 的方法就是用 meshshader 來繪圖, 而 mesh 本身就是一種內建 shader. 還有一個很吸引人的地方就是: 使用 mesh 後續可以很容易地把我們的 2D 遊戲改寫為 3D 遊戲, 這也是我們這個遊戲的一個嘗試: 玩家可以自由地在 2D3D 之間轉換.

基於以上種種理由, 我們後續會把遊戲中大部分圖形繪製工作都放到 GPU 上, CPU 只負責處理耗費資源很少的選單選項等 UI 繪製.

本章先簡單介紹一下 Codea 中的 meshshader, 接著按照從易到難的順序, 依次把 幀動畫類, 地圖類狀態類 改寫為用 GPU 繪製(也就是用 mesh 繪製)

這部分內容稍微深入一些, 需要讀者對 OpenGL ES 2.0 中的座標系統有一點了解, 另外對於著色器語言 shader language 也要有一定了解, 這樣讀起來不會太吃力, 不過沒有這方面背景也不要緊, 多讀幾遍, 上機跑幾遍例程, 再自己胡亂修改修改看看是什麼效果, 這麼折騰一番也差不多會了.

因為一方面本章內容稍微難一些, 另一方面本章的篇幅也比較長, 因此本章將拆分為兩個或者三個子章節.

Codea 中的 mesh + shader 介紹

簡單介紹 mesh

meshCodea 中的一個用來繪圖的類, 用來實現一些高階繪圖, 用法也簡單, 先新建一個 mesh 例項, 接著設定它的各項屬性, 諸如設定頂點 m.vertices, 設定紋理貼圖 m.texture, 設定紋理座標 m.texCoords, 設定著色器 m.shader= shader(...) 等等, 最後就是用它的 draw() 方法來繪製, 如果有觸控事件需要處理, 那就寫一下它的 touched(touch) 函式, 最簡單例程如下:

function setup()
    m = mesh()    
    mi = m:addRect(x, y, WIDTH/10, HEIGHT/10)
    m.texture = readImage("Documents:catRunning")
    m.shader = shader(shaders["sprites"].vs,shaders["sprites"].fs)
    m:setRectTex(mi, s, t, w,h)
end

function draw()
    m:draw()
end

簡單介紹 shader

shaderOpenGL 中的概念, 我們在移動裝置上使用的 OpenGL 版本是 OpenGL ES 2.0/3.0, shader 是其中的著色器, 用於在管線渲染的兩個環節通過使用者的自定義程式設計實行人工干預, 這兩個環節一個是 頂點著色-vertex, 一個是 片段(畫素)著色-fragment, 也就是說實際上它就是針對 vertexfragment 的兩段程式, 它的最簡單例程如下:

shaders = {
sprites = { vs=[[
//--------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()
{
    // 取得畫素點的紋理取樣
    lowp vec4 col = texture2D( texture, vTexCoord ) * vColor;
    gl_FragColor = col;
}
]]}
}

為方便呼叫,我們把它們寫在兩段字串中, 然後放到一個表裡.

Codea 中的 meshshader 可以到官網檢視手冊, 或者看看 Codea App 的內建手冊(中文版), 還算全面.

大致介紹了 meshshader 之後, 就要開始我們的改寫工作了, 先從 幀動畫類開始.

用 mesh 改寫幀動畫類

思路

仔細分析之後, 發現用 mesh 去實現幀動畫, 簡直是最合適不過了, 只要充分利用好它的紋理貼圖和紋理座標屬性, 就可以很方便地從一張大圖上取得一幅幅小圖, 而且動畫顯示速度控制也很好寫, 我們先用 mesh 建立一個矩形, 把整副幀動畫素材圖作為它的紋理貼圖, 這樣我們就可以通過設定不同的紋理座標來取得不同的子幀, 而且它的紋理座標的引數特別適合描述子幀: 左下角 x, 左下角 y, 寬度, 高度, 注意, 紋理座標的範圍是 [0,1].

結合具體程式碼進行說明

下面看看程式碼:

function setup()
    ...
    -- 新建一個矩形, 儲存它的標識索引 mi
    mi = m:addRect(self.x, self.y,WIDTH/10,HEIGHT/10)
    --    把整副幀動畫素材設定為紋理貼圖
    m.texture = readImage("Documents:catRunning")
    --    計算出各子幀的紋理座標存入表中
    coords = {{0,3/4,1/2,1/4}, {1/2,3/4,1/2,1/4}, {0,2/4,1/2,1/4}, {1/2,2/4,1/2,1/4}, 
            {0,1/4,1/2,1/4}, {1/2,1/4,1/2,1/4}, {0,0,1/2,1/4}, {1/2,0,1/2,1/4}}
    -- 把第一幅子幀設定為它的紋理座標
    m:setRectTex(mi, self.coords[1][1], self.coords[1][2] ,self.coords[1][3], self.coords[1][4])
    ...
end

因為我們這幅素材圖分 2 列, 4 行, 共有 8 副子幀, 第一幅子幀在左上角, 所以第一幅子幀對應的紋理座標就是 {0, 3/4, 1/2, 1/4}, 其餘以此類推, 我們把所有子幀的紋理座標按顯示順序依次存放在一個表中, 後續可以方便地過遞增索引來迴圈顯示.

先在 setup() 中設定好 timespeed 的值, 接著在 draw() 中可以通過這段程式碼來控制每幀的顯示時間:

function draw()
    ...
    -- 如果停留時長超過 speed,則使用下一幀
    if os.clock() - time >= speed then
        i = i + 1
        time = os.clock()
    end
    ...
end

我們一般用幀動畫來表現玩家控制的角色, 需要移動它的顯示位置, 可以在 draw() 中用這條語句實現:

-- 根據 x, y 重新設定顯示位置
m:setRect(mi, x, y, w, h)

目前我們的程式碼需要每副子幀的尺寸一樣大, 如果子幀尺寸不一樣大的話, 就需要做一個轉換, 我們決定讓屬性紋理座標表仍然使用真實座標, 新增一個類方法來把它轉換成範圍為 [0,1] 的表, 如下:

-- 原始輸入為形如的表:
pos = {{0,0,110,120},{110,0,70,120},{180,0,70,120},{250,0,70,120},
       {320,0,105,120},{423,0,80,120},{500,0,70,120},{570,0,70,120}}

-- 把絕對座標值轉換為相對座標值
function convert(coords)
    local w, h = m.texture.width, m.texture.height
    local n = #coords
    for i = 1, n do
        coords[i][1], coords[i][2] = coords[i][1]/w, coords[i][2]/h
        coords[i][3], coords[i][4] = coords[i][3]/w, coords[i][4]/h
    end
end

用 shader 實現映象翻轉

現在還有一個問題, 就是當角色先向右移動, 然後改為向左移動時, 角色的臉仍然朝向右邊, 看起來就像是倒著走一樣, 因為我們的幀動畫素材中橘色就是臉朝右的, 該怎麼辦呢? 有種辦法是做多個方向的幀動畫素材, 比如向左, 向右, 向前, 向後, 這貌似是通用的解決方案, 不過我們這裡有一種辦法可以通過 shader 實現左右映象翻轉, 然後根據移動方向來決定是否呼叫翻轉 shader.

因為我們只是左右翻轉, 可以這樣想象: 在影像中心垂直畫一條中線, 把中線左邊的點翻到中線右邊, 把中線右邊的點翻到中線左邊, 也就是每個點只改變它的 x 值, 假設一個點原來的座標為 (x, y), 翻轉後它的座標就變成了 (1.0-x, y), 注意, 此處因為是紋理座標, 所以該點座標範圍仍然是 [0,1], 這次變化只涉及頂點, 所以我們只需要修改 vertex shader, 程式碼如下:

void main()
{
    vColor = color;
    // vTexCoord = texCoord;
    vTexCoord = vec2(1.0-texCoord.x, texCoord.y);
    gl_Position = modelViewProjection * position;
}

不過這樣處理在每個子幀的尺寸有差異時會出現顯示上的問題, 因為我們的紋理座標是手工計算出來的, 它所確定的子幀不是嚴格對稱的, 解決辦法就是給出一個精確左右對稱的紋理座標, 這樣弄起來也挺麻煩, 其實最簡單的解決辦法是把素材處理一下, 讓每副子幀的尺寸相同就好了.

用 shader 去掉素材白色背景

在使用 runner 素材時, 因為它的背景是白色, 需要處理成透明, 之前我們專門寫了一個函式 Sprites:deal() 預先對影像做了處理, 現在我們換一種方式, 直接在 shader 裡處理, 也很簡單, 就是在用取樣函式得到當前畫素的顏色時, 看看它是不是白色,若是則使用 shader 內建函式 discard 將其丟棄, 注意, 這裡的顏色值必須寫成帶小數點的形式, 因為它是一個浮點型別, 對應的 fragment shader 程式碼如下:

// 定義一個用於比較的最小 alpha 值, 由使用者自行控制
uniform vec4 maxWhite;

void main()
{
    // 取得畫素點的紋理取樣
    lowp vec4 col = texture2D( texture, vTexCoord ) * vColor;

    if ( col.r > maxWhite.x &&  col.g > maxWhite.y && col.b > maxWhite.z) 
        discard;
    else        
        gl_FragColor = col;
}

試著執行一下, 發現效果還不錯.

發現還有個小問題, 就是修改了 self.wself.h 後, 顯示的區域出現了錯誤, 需要找找原因.

完整程式碼

寫成類的完整程式碼如下:

-- c06.lua

--# Shaders
-- 用 mesh/shader 實現幀動畫,把運算量轉移到 GPU 上,可用 shader 實現各種特殊效果
Sprites = class()

function Sprites:init()
    self.m = mesh()
    self.m.texture  = readImage("Documents:catRunning")
    self.m.shader = shader(shaders["sprites"].vs,shaders["sprites"].fs)
    self.coords = {{0,3/4,1/2,1/4}, {1/2,3/4,1/2,1/4}, {0,2/4,1/2,1/4}, {1/2,2/4,1/2,1/4}, 
                    {0,1/4,1/2,1/4}, {1/2,1/4,1/2,1/4}, {0,0,1/2,1/4}, {1/2,0,1/2,1/4}}
    self.i = 1

    local w,h = self.m.texture.width, self.m.texture.height
    local ws,hs = WIDTH/w, HEIGHT/h
    self.x, self.y = w/2, h/2
    self.w, self.h = WIDTH/10, HEIGHT/10
    self.mi = self.m:addRect(self.x, self.y, self.w, self.h)
    self.speed = 1/30
    self.time = os.clock()
end

function Sprites:convert()
    local w, h = self.m.texture.width, self.m.texture.height
    local n = #self.coords
    for i = 1, n do
        self.coords[i][1], self.coords[i][2] = self.coords[i][1]/w, self.coords[i][2]/h
        self.coords[i][3], self.coords[i][4] = self.coords[i][3]/w, self.coords[i][4]/h
    end
end

function Sprites:draw()
    -- 依次改變貼圖座標,取得不同的子幀
    self.m:setRectTex(self.mi, 
                      self.coords[(self.i-1)%8+1][1], self.coords[(self.i-1)%8+1][2], 
                      self.coords[(self.i-1)%8+1][3], self.coords[(self.i-1)%8+1][4])
    -- 根據 self.x, self.y 重新設定顯示位置
    self.m:setRect(self.mi, self.x, self.y, self.w, self.h)
    -- 如果停留時長超過 self.speed,則使用下一幀
    if os.clock() - self.time >= self.speed then
        self.i = self.i + 1
        self.time = os.clock()
    end

    self.m:draw()
end

function Sprites:touched(touch)
    self.x, self.y = touch.x, touch.y
end


-- 遊戲主程式框架
function setup()
    displayMode(OVERLAY)

    -- 幀動畫素材1
    img1 = readImage("Documents:runner")
    pos1 = {{0,0,110,120},{110,0,70,120},{180,0,70,120},{250,0,70,120},
            {320,0,105,120},{423,0,80,120},{500,0,70,120},{570,0,70,120}}

    -- 幀動畫素材2       
    img2 = readImage("Documents:catRunning")
    local w,h = 1024,1024
    pos2 = {{0,h*3/4,w/2,h/4},{w/2,h*3/4,w/2,h/4},{0,h*2/4,w/2,h/4},{0,h*2/4,w/2,h/4},
            {0,h*1/4,w/2,h/4},{w/2,h*1/4,w/2,h/4},{0,h*0/4,w/2,h/4},{0,h*0/4,w/2,h/4}}

    -- 開始初始化幀動畫類            
    myS = Sprites()
    myS.m.texture = img1
    myS.coords = pos1
    -- 若紋理座標為絕對數值, 而非相對數值(即範圍在[0,1]之間), 則需將其顯式轉換為相對數值
    myS:convert()

    -- 使用自定義 shader
    myS.m.shader = shader(shaders["sprites1"].vs,shaders["sprites1"].fs)
    -- 設定 maxWhite
    myS.m.shader.maxWhite = 0.8

    -- 設定速度
    myS.speed = 1/20
    myS.x = 500
end

function draw()
    background(39, 31, 31, 255)
    -- 繪製 mesh
    myS:draw()
    sysInfo()
end

function touched(touch)
    myS:touched(touch)
end

-- 系統資訊
function sysInfo()
    -- 顯示FPS和記憶體使用情況
    pushStyle()
    --fill(0,0,0,105)
    -- rect(650,740,220,30)
    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
shaders = {

sprites = { vs=[[
// 左右翻轉著色器
//--------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;
    vTexCoord = vec2(1.0-texCoord.x, texCoord.y);
    gl_Position = modelViewProjection * position;
}
]],
fs=[[
//---------Fragment shader------------
//Default precision qualifier
precision highp float;

varying vec2 vTexCoord;
varying vec4 vColor;

// 紋理貼圖
uniform sampler2D texture;

void main()
{
    // 取得畫素點的紋理取樣
    lowp vec4 col = texture2D( texture, vTexCoord ) * vColor;
    gl_FragColor = col;
}
]]},


sprites1 = { vs=[[
// 把白色背景轉換為透明著色器
//--------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;

// 定義一個用於比較的最小 alpha 值, 由使用者自行控制
uniform float maxWhite;

void main()
{
    // 取得畫素點的紋理取樣
    lowp vec4 col = texture2D( texture, vTexCoord ) * vColor;

    if ( col.r > maxWhite.x &&  col.g > maxWhite.y && col.b > maxWhite.z)  
        discard;
    else        
        gl_FragColor = col;
}
]]}
}

回頭來看, 就發現用 mesh 改寫後的幀動畫類既簡單又高效. 而且有了 shader 這個大殺器, 我們可以非常方便地為角色新增各種特效, 上面用過的 映象去素材白色背景 就是兩種比較簡單的特效, 我們在下面介紹幾種其他特效.

在幀動畫角色上用 shader 增加特效

角色灰化

一些遊戲, 比如 魔獸世界, 在玩家控制的角色死亡時, 會進入靈魂狀態, 這時所有的畫面全部變為灰色, 我們也可以在這裡寫一段 shader 來實現這個效果, 不過我們打算稍作修改, 只把玩家角色變為灰色, 螢幕上的其餘部分都保持原色.

先寫一個從彩色到灰度的轉換函式, 這個函式要在 fragment shader 中使用:

float intensity(vec4 col) {
    // 計算畫素點的灰度值
    return 0.3*col.x + 0.59*col.y + 0.11*col.z;
}

然後修改片段著色程式碼:

void main()
{
    // 取得畫素點的紋理取樣
    lowp vec4 col = texture2D( texture, vTexCoord ) * vColor;
    col.rgb = vec3(intensity(col));
    gl_FragColor = col;
}

如果我們希望在灰化的同時實現虛化, 也就是讓角色變淡, 可以連 alpha 一起修改, 這種淡化特效可以用於角色使用了隱匿技能後的顯示, 程式碼如下:

void main()
{
    // 取得畫素點的紋理取樣
    lowp vec4 col = texture2D( texture, vTexCoord ) * vColor;
    col.rgba = vec4(intensity(col));
    gl_FragColor = col;
}

效果很不錯, 完全達到了我們的預定目標.

中毒狀態

很多遊戲中, 角色如果中毒了, 會在兩個地方顯示出來, 一個是狀態列, 一個是角色本身, 比如 仙劍奇俠傳 中會給角色渲染一層深綠色, 我們用 shader 實現的話, 只需要把取樣得到的畫素點顏色乘以一個指定的顏色值(綠色或其他), 該指定顏色可隨時間變化而變深, 也可以因為吃了解毒藥而逐漸變淺(在我們的設定裡不存在一吃藥就變好的情況, 只能慢慢好), 這部分處理可以充分利用 mesh 的一個方法 setRectColor() 來實現, 程式碼如下:

function setup()
    ...
    myS.m:setRectColor(myS.mi, 0, 255,0,255)
    ...
end

shader 中只需要把取樣點的顏色跟該顏色vColor相乘即可, 我們的模板程式碼就是這樣的:

void main()
{
    // 取得畫素點的紋理取樣
    lowp vec4 col = texture2D( texture, vTexCoord ) * vColor;
    gl_FragColor = col;
}

所以我們只需要在 setup() 中設定一下, 然後呼叫名為 spritesshader 即可.

效果完美, 後面可以根據遊戲需要再加一個根據時間流逝綠色變淡或者變深的處理.

角色的其他狀態, 例如受傷出血也可以通過類似的方法實現(把 vColor 改為紅色即可), 可自行試驗.

角色光粒子化

實際上, 我們上面實現的幾種特效都是比較簡單的, 最後我們來一個複雜點的, 角色昇華, 變成光粒子消散在空中, 當然這種特效也可以放在 NPC 身上, 程式碼如下:

---後續補充

本章用到的 shader 程式碼

下面列出我們在這裡用於實行各種特性的 shader 程式碼:

-- Shader
shaders = {

sprites = { vs=[[
// 左右翻轉著色器
//--------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;
    vTexCoord = vec2(1.0-texCoord.x, texCoord.y);
    gl_Position = modelViewProjection * position;
}
]],
fs=[[
//---------Fragment shader------------
//Default precision qualifier
precision highp float;

varying vec2 vTexCoord;
varying vec4 vColor;

// 紋理貼圖
uniform sampler2D texture;

void main()
{
    // 取得畫素點的紋理取樣
    lowp vec4 col = texture2D( texture, vTexCoord ) * vColor;
    gl_FragColor = col;
}
]]},


sprites1 = { vs=[[
// 把白色背景轉換為透明著色器
//--------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;

// 定義一個用於比較的最小 alpha 值, 由使用者自行控制
uniform vec4 maxWhite;

void main()
{
    // 取得畫素點的紋理取樣
    lowp vec4 col = texture2D( texture, vTexCoord ) * vColor;

    if ( col.r > maxWhite.x &&  col.g > maxWhite.y && col.b > maxWhite.z) 
        discard;
    else        
        gl_FragColor = col;
}
]]},


sprites2 = { vs=[[
// 區域性變灰
//--------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;

float intensity(vec4 col) {
    // 計算畫素點的灰度值
    return 0.3*col.x + 0.59*col.y + 0.11*col.z;
}

void main()
{
    // 取得畫素點的紋理取樣
    lowp vec4 col = texture2D( texture, vTexCoord ) * vColor;
    col.rgba = vec4(intensity(col));
    gl_FragColor = col;
}
]]}
}

本章小結

使用 mesh繪圖時, 可以選擇不載入 shader, 如果需要自定義修改影像中的某些顯示效果, 就要選擇載入 shader 了.

關於幀動畫類的 GPU 改造暫時就寫這麼多, 下一節準備說說如何用 mesh 來改寫地圖類.

所有章節連結

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

相關文章