基於Rust的Tile-Based遊戲開發雜記(02)ggez繪圖實操

w4ngzhen發表於2024-03-17

儘管ggez提供了很多相關特性的demo供執行檢視,但筆者第一次使用的時候還是有很多疑惑不解。經過仔細閱讀demo程式碼並結合自己的實踐,逐步瞭解了ggez在不同場景下的繪圖方式,在此篇文章進行一定的總結,希望能夠幫助到使用ggez的讀者。供執行檢視,但筆者第一次使用的時候還是有很多疑惑不解。經過仔細閱讀demo程式碼並結合自己的實踐,逐步瞭解了ggez在不同場景下的繪圖方式,在此篇文章進行一定的總結,希望能夠幫助到使用ggez的讀者。

基本模式

在ggez官方文件中提到一個核心的功能就是基於wgpu圖形API的硬體加速的2D渲染:

Hardware-accelerated 2D rendering built on the wgpu graphics API

ggez的基礎繪製模式一般分為3步:

  1. 在每一次繪圖事件回撥中,透過圖形上下文構造一個ggez封裝的畫布Canvas例項;
  2. 呼叫畫布的draw方法,傳入想要繪製的圖形(例如一個矩形、一個圓)和相關繪圖引數(位置、大小縮放等變換);
  3. 完成所有影像繪製後,呼叫畫布的finish方法,向底層圖形模組進行一次繪圖提交,進而觸發底層將最終渲染的影像呈現到畫布區域上。

從程式碼的角度來看,大致如下:

struct MyState {}

impl EventHandler for MyState {
    fn update(&mut self, _ctx: &mut Context) -> Result<(), GameError> {
        Ok(())
    }

    ///
    /// 繪圖
    ///
    fn draw(&mut self, ctx: &mut Context) -> Result<(), GameError> {
        // 1. 構造canvas例項
        let mut canvas =
            graphics::Canvas::from_frame(ctx, graphics::Color::from([1.0, 1.0, 1.0, 1.0]));

        // 2. 繪圖
   			// ... ...

        // 3. finish
        canvas.finish(ctx)?;
        Ok(())
    }
}

註釋中步驟1、3的程式碼一般來說都很固定,讀者根據註釋應該很容易理解,這裡不再贅述,接下來我們重點關注具體的圖形繪製程式碼。

簡單繪製一個矩形

當我們希望在視窗上左上角(10, 20)的位置繪製一個40 x 50的紅色矩形時,我們可以透過編寫如下的程式碼來完成:

    fn draw(&mut self, ctx: &mut Context) -> Result<(), GameError> {
        // 1. 構造canvas例項
        let mut canvas =
            graphics::Canvas::from_frame(ctx, graphics::Color::from([1.0, 1.0, 1.0, 1.0]));

        // 2. 繪圖
        let draw_param = DrawParam::new()
      			.color(Color::new(1.0, 0.0, 0.0, 1.0))
            .dest(Point2::from([10., 20.]))
            .scale(Point2::from([40., 50.]));
        canvas.draw(&Quad, draw_param);

        // 3. finish
        canvas.finish(ctx)?;
        Ok(())
    }

本文將在接下來的內容中逐步介紹不同場景下的繪製,主要會更改關於上述程式碼中fn draw中的內容,其餘基本不會改變,所以後續的程式碼片段沒有特殊說明的情況下,均只會貼出fn draw中的內容。

我們首先構造一個DrawParam例項,透過它來描述我們最終期望繪製的圖形的位置和大小。其中,.color()不難理解即配置顏色;dest指繪製的目標位置;最後,我們定義繪製的矩形的尺寸,但這裡值得注意的是,API提供的是scale(中文譯為“縮放”),並不是一個類似於size名稱的API,對於初學者來說,這其實是有點反直覺的,彆著急,我們稍後就來解釋這個地方的概念。

接下來,呼叫draw時,我們第一引數傳給的是一個Quad例項(的引用),第二個引數就是DrawParam資料。這個Quad是什麼?透過檢視原始碼文件,我們瞭解到Quad是ggez內建的一個最基礎的1 x 1的Mesh(圖形學中一般譯為“網格”):

A Drawable unit type that maps to a simple 1x1 quad mesh.
一種可繪製的單元型別,對映到簡單的1x1四方網格。

這裡,我們不深究Quad這個1 x 1的mesh網格在計算機圖形學中的意義,先簡單將其理解為一個1 x 1的小方塊。那麼我們再回看之前提到的DrawParam::scale,該API指定的是Quad的縮放比例,也就是說,當我們程式碼中邊寫的是scale([40., 50.])的時候,實際上就是希望將一個原本1 x 1的矩形,使其寬擴大40倍,高擴大50倍。

為什麼要使用縮放而不是直觀的定義尺寸?這涉及到圖形學中的變換,我們暫時不在本文中深究。

複雜圖形

前面的Quad讀者可以理解為只是ggez內建的一個極為簡單的mesh“模板”,透過它我們能在畫布指定位置繪製一個指定大小且純色的矩形塊。但實際上,我們在繪圖的過程中必然不可能只會畫這些簡單的方塊,或多或少都會畫一些不同形狀的幾何,譬如圓、橢圓、三角形等,以及我們可能還需要為這些幾何圖形實現漸變,增加邊框等效果。作為一款支援2D渲染的遊戲框架,這部分的能力當然不會缺失。接下來我們繼續介紹ggez在複雜圖形的繪圖方面的內容。

Mesh

在ggez中,提供了圖形學知識體系中的Mesh資料結構,它是一份包含頂點資料快取、索引資料快取,並可以儲存在GPU上的資料,並且透過文件我們瞭解到它的克隆複製成本很低。

Mesh data stored on the GPU as a vertex and index buffer. Cheap to clone.

關於Mesh的資料結構的含義,如果讀者沒有學習過計算機圖形學,理解起來可能有困難。但在這裡,我們可以暫時將它理解為想要透過GPU幫助我們繪圖時,提供的一份較為底層的,GPU能直接使用的資料。比如,我們想要畫一個矩形,從應用層面的角度,我們可能會定義一個資料結構叫Rect,它包含如下的資訊:

  1. 位置(position)
  2. 寬高(width和height)
  3. 顏色(color)

但是GPU繪圖的時候,我們需要將這些資訊轉換為GPU能夠使用的,更為底層的資料,可能是四個頂點、顏色等資料。

那麼,在ggez庫中應該如何建立一份Mesh資料呢?以建立一個圓為例,透過閱讀文件,我們可以使用Mesh::new_circle方法得到:

let circle_mesh = Mesh::new_circle(
    ctx, // ctx: &mut Context
    Fill(FillOptions::default()), // 填充模式
    [50., 50.], // 圓心
    25., // 半徑
    0.01, // 繪製圓弧曲線時多邊形長度,越小越圓。
    Color::from_rgb(255, 0, 0) // 顏色
)

該方法的入參也非常容易理解,就是一些繪製圓形的基本配置(半徑、顏色等)。透過該方法構造一個Mesh後,我們就可以按照之前的方式,透過呼叫canvas.draw方法來繪製它:

let circle_mesh = Mesh::new_circle(
    ctx,
    Fill(FillOptions::default()),
    [50., 50.],
    25.,
    0.001,
    Color::from_rgb(255, 0, 0)
)?;
let draw_param = DrawParam::default()
    .dest(Point2::from([100., 100.]))
    .scale(Point2::from([1., 1.]))
    .color(Color::new(0.0, 1.0, 0.0, 1.0));
canvas.draw(&circle_mesh, draw_param);
Ok(())

看到這段程式碼,細心的讀者會立刻發現,我們已經定義了圓心的位置[50.0, 50.0],但是在構造DrawParam資料的時候,又定義了一個:.dest(Point2::from([100., 100.])),即我們希望將圖形繪製到(100, 100)這個位置,很明顯這二者是有衝突的。所以實際是什麼結果呢?這裡直接給出結論:圖形的最終位置為圖形的自身位置 “疊加” DrawParam的位置配置。所以,上述程式碼中最終圓所處的位置為(150, 150)座標處。

再來討論.scale(Point2::from([1., 1.]))程式碼的意義。這裡我們知道是對圖形進行尺寸縮放,在水平和垂直方向上均縮放1.0倍,也就是說不改變圖形原有大小。如果我們希望對這個圖形在水平方向(x軸)上放大2倍,垂直方向不變,就可以透過scale引數來定製:.scale(Point2::from([2., 1.]))

最後是 .color(Color::new(0.0, 1.0, 0.0, 1.0));。透過該API,我們定義了圖形在繪製的時候使用綠色。很顯然,和前面我們構造circle_mesh指定的紅色(Color::from_rgb(255, 0, 0))是不一致的。這裡最終的結果也是一種疊加,但是它們的疊加不是簡單的加減,而是每一單色的值的相乘。也就是說,按照上面的程式碼,最終:Red=255 * 0.0 = 0Green = 0 * 1.0Blue = 0 * 1.0 = 0,執行以後,你會發現顯示出來的是一個黑色圓形!如果你不配置DrawParamcolor,它預設是白色([1.0, 1.0, 1.0, 1.0]),此時,按照相乘的結果,就始終等於你圖形定義的顏色了。

下圖是一個綜合上述講解後的一個圖形:

010-draw-circle

此外,DrawParam還有諸如rotation(旋轉)offset(偏移)等配置,但是透過閱讀底層程式碼,我們會發現DrawParam關於圖形位置、縮放等資料核心其實是透過變換transform這個欄位資料儲存的:

/// DrawParam原始碼資料結構
pub struct DrawParam {
    /// A portion of the drawable to clip, as a fraction of the whole image.
    /// Defaults to the whole image (\[0.0, 0.0\] to \[1.0, 1.0\]) if omitted.
    pub src: Rect,
    /// Default: white.
    pub color: Color,
    /// Where to put the object.
    pub transform: Transform, // <- 變換是核心
    /// The Z coordinate of the draw.
    pub z: ZIndex,
}

至於變換transform,如果學習過圖形學、線代、向量等知識理解起來應該完全沒有難度。

DrawParam的其他引數:pub src: Rectpub z: ZIndex,我們會在後面實踐並解釋。

目前為止,我們大致瞭解了圖形繪製的兩個部分:1、圖形Mesh資料;2、DrawParam繪製定義資料。透過實踐我們也瞭解了它們二者會有定義重疊的部分(例如位置、顏色等)以及疊加的方式。那麼,當我們實際開發的時候,面對重疊的部分,究竟是透過配置Mesh本身還是DrawParam呢?要回答這個問題,我們首先要了解一份Mesh資料建立以後,它能做什麼。透過閱讀文件,我們發現Mesh資料在建立以後,僅僅是提供了一些克隆等API,也就是說,一旦Mesh資料構造完成,就無法對顏色、位置資料進行二次加工設定。而DrawParam資料很容易修改位置、大小、顏色等。也就是說,Mesh資料更偏向於靜態繪圖,而DrawParam主要負責可變化的繪製。如果在你的場景中,存在對一些圖形按照每幀在不同的位置,呈現不同的顏色,那麼筆者更建議建立一份圖形的Mesh資料,然後在每幀繪製階段透過臨時構造DrawParam來制定當前幀的繪製情況。

舉例來說,比如我想在窗體中繪製一個圓形,隨著每幀從左到右移動,並且顏色隨著從左到右從黑色變成紅色:

020-draw-dynamic-circle

為了達到這樣的效果,最直觀的做法是我們可以在每一次fn draw呼叫的時候,構造一份對應時刻的對應顏色的圓形的Mesh例項,並進行繪製。但是效能和資源利用更好的方式則是提前建立一份Mesh資料,並在每一次draw呼叫時,只改變DrawParam的引數即可:

030-draw-dynamic-circle-code

MeshBuilder與MeshData

儘管比起之前的Qaud圖形,我們現在已經能夠繪製圓、三角形、多邊形等更多種類的圖形,但總的來說依然是一些常見的幾何圖形,對於實際的應用場景可能還遠遠不夠。比如說,我們希望繪製一座房子,大概像下圖這樣:

040-house-draft

我們將這個圖形分解為三個部分:頂部使用一個棕色三角形作為房頂,房頂下方使用一個黃色矩形作為房屋體,在房屋體內部使用一個棕色的矩形作為門。按照之前的方式,我們首先構造mesh:

050-multi-mesh-a-house

在這段程式碼中,我們首先在DrawHouseState結構體中增加了3個mesh資料欄位:roof(屋頂)、house_body(房屋體)、door(門),在初始化階段我們構造這三部分並儲存起來。

接下來是繪製階段程式碼:

    fn draw(&mut self, ctx: &mut Context) -> Result<(), GameError> {
        // 1. 構造canvas例項
        let mut canvas =
            graphics::Canvas::from_frame(ctx, graphics::Color::from([1.0, 1.0, 1.0, 1.0]));

      	// 2. draw呼叫了3次!
        let draw_param = DrawParam::default()
            .dest(Point2::from([100., 100.]))
        canvas.draw(&self.roof, draw_param.clone());
        canvas.draw(&self.house_body, draw_param.clone());
        canvas.draw(&self.door, draw_param.clone());

        // 3. finish
        canvas.finish(ctx)?;
        Ok(())
    }

在繪製階段,我們定義了一份DrawParam資料,同時分別對roofhouse_body以及door進行繪製。這段程式碼執行後的效果如下:

060-house-result1

上述程式碼並不複雜,相信讀者能夠理解。但是這樣的方式並不優雅,因為隨著圖形結構複雜度愈來越高,我們不可能隨時關注一大堆的mesh例項;此外,這樣的方式還有一個問題:為了繪製一個“房子”,我們呼叫了3次canvas.draw方法,會有效能上的問題(後續會量化)。

為了解決上述問題,ggez為我們提供了MeshBuilder。透過MeshBuilder,我們可以將多個mesh同時組合得到一份整體的mesh資料:

070-single-mesh-a-house

上面的程式碼,就是透過MeshBuilder依次構造了一個三角形、兩個矩形。MeshBuilder最後的build方法會返回一個MeshData,請注意,這的MeshData結構體並不是前面的Mesh資料,而是Mesh結構體建立的來源資料,我們可以將MeshData例項傳遞給Mesh::from_data方法來建立Mesh。於是,此處我們只透過一個mesh就包含了整個房屋的圖形資料。

最後,在渲染的時候,我們只需要呼叫canvas.draw一次:

fn draw(&mut self, ctx: &mut Context) -> Result<(), GameError> {
        // 1. 構造canvas例項
        let mut canvas =
            graphics::Canvas::from_frame(ctx, graphics::Color::from([1.0, 1.0, 1.0, 1.0]));

        // 2. DrawParam和繪製一次
        let draw_param = DrawParam::default()
            .dest(Point2::from([100., 100.]));
        canvas.draw(&self.house, draw_param.clone());

        // 3. finish
        canvas.finish(ctx)?;
        Ok(())
    }

InstanceArray

理論上來講,MeshBuilder提供了將基礎圖形構成複雜圖形以及方便對其進行整體操作的能力。但還有一個場景我們需要進一步討論:如何繪製大量的圖形?有的讀者可能會說,那好辦,在繪圖的時候,一個for迴圈,多次呼叫canvas.draw繪製大量的圖形:

080-draw-house-for400

上述的程式碼,我們透過兩個for迴圈共計400次,依次在(0, 0)(0, 50)等位置繪製了50x50的正方形,將原來的房子繪製到對應區域。其中,縮放程式碼let scale = [SIZE / 100., SIZE / 100.];含義是我們的房子本身的尺寸是寬100,高100的尺寸,為了將其剛好會知道50x50的區域內,就需要按照比例縮放:

090-house-scale

上述的程式碼最終執行的效果如下:

100-draw-house-for400-result

從程式碼邏輯的角度上講使用for迴圈還算過得去,但是從效能層面上卻有很大的問題。在這裡為了視覺化效能,我們使用ggez提供的API獲得整個應用在執行過程中的fps均值,以此粗略地估算應用在每一次重新整理時的效能情況:

impl EventHandler for DrawMultiHouseState {
    fn update(&mut self, _ctx: &mut Context) -> Result<(), GameError> {
        println!("game fps: {:?}", _ctx.time.fps());
        Ok(())
    }

    fn draw(&mut self, ctx: &mut Context) -> Result<(), GameError> {
				// ... ...
    }
}

上述的程式碼,我們在每一次update中,向控制檯列印當前應用的fps值。可以看到在筆者的機器上,未經過編譯最佳化的程式碼,將這400個小房子繪製到螢幕上,平均的fps在12左右:

110-low-fps

對於遊戲來說,這麼簡單的繪製400個圖形fps就這麼低顯然是不應該的。那麼這裡的最佳實踐是什麼呢?答案是使用ggez提供的InstanceArray。該InstanceArray可以用來一次性儲存大量的DrawParam資料。當我們要繪製400個房子的時候,實際上只需要構造400個DrawParam,將它們存放到InstanceArray中,這400個DrawParam,每一個的dest引數都不同,用來表示400個房子的不同位置。當我們需要進行繪製的時候,只需要呼叫一次canvas.draw_instanced_mesh方法,將InstanceArray作為第二個引數傳入,即可在螢幕上呈現這400個房子,而不是迴圈400次,每次draw一次:

120-draw-house-by-instance-arr-code

核心本質是每呼叫一次draw,就是資料從記憶體到GPU的一次資料傳輸。

透過使用InstanceArray,在同樣的編譯條件下,在本人60hz重新整理率的機器上,繪製這400個圖形的fps均值直接拉滿60幀:

130-full-fps

圖片與文字繪製

實際上,圖片與文字繪製的模式大體上和前面的圖形繪製是保持一致的,都是首先建立一個被繪製的例項:

  • 圖片:ggez::graphics::Image
  • 文字:ggez::graphics::Text

然後構造DrawParam例項或是存放DrawParamInstanceArray例項;最後呼叫canvas.drawcanvas.draw_instanced_mesh完成單個或批次繪製。接下來我們分別介紹一下ggez繪製圖片資料和文字的具體實踐。

圖片繪製

如果是對矮人要塞或是CDDA大災變等Tile-Based遊戲深入瞭解過,就會發現,這些遊戲的圖形通常不是一張又一張的小圖片存放起來,而是使用一張NxN規格的圖片,把所有的圖塊統一鋪在上面的:

140-tile-img-in-picture

例如,上圖是矮人要塞的Spacefox圖塊集。你會發現遊戲中所有的圖形元素都按照16x16的大小統一集中到了這張圖片上。那麼在實際執行中是如何渲染的呢?遊戲只需要將這一張圖片載入到記憶體中,當想要渲染一個“包裹”(上圖的第一行倒數第五個就是“包裹”)圖形的時候,只需要提供區域偏移資訊即可只繪製。

當然,我們先介紹基礎圖片繪製的方式,將上述一整張圖片繪製到窗體上。首先,我們需要載入圖片:

pub struct DrawImageState {
    image: graphics::Image,
}

impl DrawImageState {
    pub fn new(ctx: &mut Context) -> GameResult<Self> {
        /// 使用該路徑前,請手動將"spacefox_16x16.png"複製到
        /// 編譯的生成的target/debug/resources目錄下(沒有請手動建立)
        let image = graphics::Image::from_path(ctx, "/spacefox_16x16.png")?;
        Ok(DrawImageState { image })
    }
}

上述程式碼在State結構體中定義了一個image欄位,用於存放ggez::graphics::Image例項;在初始化程式碼中,我們透過呼叫graphics::Image::from_path來讀取圖片spacefox_16x16.png預設情況下,圖片的搜尋目錄會從可執行程式所在目錄下的resources目錄中查詢。所以為了後續正常執行,我們先暫時手動將圖片複製至對應目錄:

150-copy-image

關於ggez中的檔案系統,後續會有文章詳細講解。

圖片的載入和儲存準備好以後,我們在繪圖階段編寫如下程式碼:

   fn draw(&mut self, ctx: &mut Context) -> Result<(), GameError> {
        // 1. 構造canvas例項
        let mut canvas =
            graphics::Canvas::from_frame(ctx, graphics::Color::from([1.0, 1.0, 1.0, 1.0]));

        // 2. 繪製圖片到指定位置
        let dest_point = Vec2::new(0.0, 0.0);
        canvas.draw(&self.image, DrawParam::new().dest(dest_point));

        // 3. finish
        canvas.finish(ctx)?;
        Ok(())
    }

在實際執行以後,我們能夠看到如下效果:

160-draw-full-image

接下來,我們該如何將圖片區域性繪製到介面上?答案就是使用DrawParam的src引數來進行配置。首先,為了繪製上圖第一行倒數第5個“包裹”圖形,我們首先要確定它處於整張圖片的哪個位置。已知圖片尺寸為256x256畫素,每一個圖塊尺寸為16x16,“包裹”圖塊處於水平第12個(基於0索引就是11),垂直第1個(基於0索引就是0)。所以,我們知道“包裹”所在的矩形區域為x = 11 * 16, y = 0 * 16, w = 16, h = 16

170-tile-rect

於是,我們建立對應區域資料,並作為引數傳遞給DrawParam:

    fn draw(&mut self, ctx: &mut Context) -> Result<(), GameError> {
        /// ... ...
      
        // 2. 繪製圖片到指定位置
        const TILE_SIZE: f32 = 16.;
        let src_rect = Rect::new(11. * TILE_SIZE, 0. * TILE_SIZE, TILE_SIZE, TILE_SIZE);
        canvas.draw(&self.image, DrawParam::new().src(src_rect).dest(Vec2::new(0.0, 0.0)));
      
 				/// ... ...
    }

初看這段程式碼,應該很好理解,但在實際執行後筆者會發現顯示的很有問題。其實,核心原因是ggez中關於DrawParam::src所需要的矩形資料是一個相對的資料,它的註釋如下:

#[derive(Debug, Copy, Clone, PartialEq)]
pub struct DrawParam {
    /// A portion of the drawable to clip, as a fraction of the whole image.
    /// Defaults to the whole image (\[0.0, 0.0\] to \[1.0, 1.0\]) if omitted.
    pub src: Rect,
    /// ... ...
}

這段註釋指的是:傳入的Rect矩形的x、y、w、h都是相對於整張圖片的相對值,其值範圍是0.0到1.0之間的。回到我們的例子,“包裹”圖塊的對於整張圖片的實際位置和尺寸資料是:x = 11 * 16, y = 0 * 16, w = 16, h = 16,那麼x相對於整張圖片是:(11 * 16) / 水平寬度256,y相對於圖片水平是:(0 * 16) / 水平高度256,寬度w相對於整張圖是16 / 256,高度h相對於整張圖是16 / 256。所以我們需要做如下的轉換處理才能正確繪製:

180-tile-ratio-rect

修正程式碼以後,我們能看到實際的執行效果:

190-draw-part-image

文字繪製

使用ggez繪製文字,離不開兩個重要的結構體:ggez::graphics::Textggez::graphics::TextFragment。其中,Text是被繪製的資料,而TextFragment主要用於定義一段文字中的區域性結構,可以作為Text的引數:

200-draw-text

上述的程式碼,我們首先使用Text::new("hello, world.")在畫布上繪製文字:"hello, world.";然後,我們使用TextFragment構建了個兩個片段:

  1. TextFragment::new("RED").color(Color::RED)
  2. TextFragment::new("BLUE").color(Color::BLUE)

然後透過它們構造了一個新的Text例項。這部分的含義是希望繪製的一段文字,"RED"使用紅色繪製,"BLUE"使用藍色繪製。

上述程式碼的最終效果如下:

210-draw-text-display

寫在最後

本文主要介紹了使用ggez的圖形部分API進行一些基礎圖形、圖片以及文字繪製。儘管ggez在官方提到圖形渲染部分是基於wgpu的硬體加速的2D圖形渲染:

  • Hardware-accelerated 2D rendering built on the wgpu graphics API

但由於ggez底層使用了wgpu,同時也透過一定方式暴露了wgpu的相關API,所以實際上我們依然可以進行利用wgpu進行3D圖形繪製,不過這部分內容需要讀者有相關3D圖形渲染理論知識以及相關圖形庫API的使用經驗,就不在本文中描述了,筆者可以透過官方樣例程式碼一探究竟:

220-3d-cube

本章程式碼倉庫地址:w4ngzhen/rs-game-dev (github.com)

cargo run --package chapter_02

相關文章