Korok字體系統設計

Korok字體系統設計

來自專欄 korok引擎開發

目前我們總共設計了三個版本的字體系統,第一個版本僅僅是為了驗證功能所以實現的很糟糕。第二個版本就是現在的版本,略微好一點,但是還有很大的提升空間。第三版應該會在下個迭代周期實現。

此處講講我們在實現字體系統時的探討和計劃。

  1. 字體系統是如何工作的
  2. 關於 TrueType/FreeType
  3. 如何處理中文問題
  4. 當前的設計和實現

字體系統是如何工作的?

很久之前我一直有個誤解,我以為 OpenGL 是可以直接渲染字體的(好像是因為看了某本紅色封面的書的緣故),後來我才知道 OpenGL 是底層的圖形庫,渲染文本會考慮到字體/字形等因素,不同國家和地區的字體也不一樣,對於OpenGL來說這是上層應用層的工作。

現在比較流行的字體渲染方案:

  1. BitmapFont 比較古老的方案(現在依然用的很多)
  2. FreeType/TrueType 現在比較主流的方案

其實字體渲染和普通的精靈渲染並沒有什麼不同,比如 BitmapFont 就是把字形寫到一張紋理圖集上,記著每個字元的紋理坐標,當渲染字元串的時候,按照每個字元取出它的紋理坐標位置挨個渲染就可以了。

for char in "quick brown fox" { cursor.advance() uv := get(char) render(uv, cursor)}

Cursor 是當前游標的位置,處理字體的過程就是渲染一個個字體圖元的過程,這和我們平時渲染精靈圖集並沒有什麼不同。只是需要注意一些控制字元比如
,這類字元是決定當前游標的位置的,而不是用來顯示的(
=換行)。

這是一張 BitmapFont 的圖片,可以感受一下:

BitmapFont 解釋了字體系統的工作原理,沒有什麼神奇的魔法就是一個一個小圖片繪製而已,如果要繪製字元 A 只要在紋理中找到 A 的坐標,繪製出這個紋理即可。

但是 BitmapFont 存在一些問題,比如無法很好處理字體縮放(這是很明顯的,因為圖片是固定大小的,如果縮放那麼就會導致失真);字型檔更新麻煩,圖片上的字元有限,每次更新要重新設計整個 BitmapFont 圖片。

另一種方案就比較好用了 - FreeType/TrueType,它可以可以很好的解決這些問題。

FreeType/TrueType

TrueType 是一套用方程描述的字體,類似於矢量圖片可以隨意的拉伸。這套協議是1980年蘋果和微軟開發的一套字體(文件後綴 .ttf)。像微軟雅黑這樣的字型檔是TrueType技術的一種具體應用。

另外Adobe的Type 1和微軟的OpenType用的也很多。

使用 TrueType 需要先把字形繪製到紋理上,然後就可以像使用 BitmapFont 一樣來使用了,至於之前的問題:

  1. 字體縮放怎辦? 簡單的可以分別用 FreeType 生成繪製兩張紋理圖集,一張原定大小的紋理,一張字體放大後的紋理
  2. 如何更新字體?字型檔文件文件沒有紋理大小的限制,所以通常可以存放一類字元的所有字形,使用的時候渲染出來就可以直接使用了。

FreeType 是一個可以把 TrueType 字體渲染到 Bitmap 的開源庫,通常使 FreeType/TrueType 的工作流程是:

  1. 找到一個合適的字型檔文件(ttf)
  2. 使用 FreeType 把所需字元按照一定大小渲染到 Bitmap
  3. 把 Bitmap 上傳到 GPU,用它來渲染文字

有興趣的同學可以看看 The FreeType Project.

如何處理中文問題

如果是在英文環境中,字體渲染的事情就此可以結束了,通常英文字元不超過256個,我們可以設計幾套不同大小的字元,然後分別渲染到一個足夠大的紋理上。但是遇到中文字元的時候,這件事變得異常麻煩 -- 因為中文實在太多了!!

我們不能在一張紋理上渲染出所有的中文字元。漢字的常用字有 2500 個左右,次常用字1000 個左右(日語常用字在 1000 個左右)。如果再考慮不同的字體大小,那麼每種字體都會使紋理使用翻倍。

在處理中文問題之前,先看下可能的渲染方案:

  1. 可以把字型檔渲染到一個紋理上,渲染字元串的時候查詢紋理坐標進行渲染
  2. 把整個字元串生成一個圖片,直接進行渲染

第一種方案建立一個按字元索引的紋理。這樣每次渲染的時候從這個紋理裡面取得字形紋理坐標,構建頂點數組填充VBO。第二種方案,看似比較詭異但是事實上在文字不多的且不太變化的遊戲中也是非常可行的方式,簡單&快速。Cocos2DX 中早前的 CCLabelTTF 字體就是使用這種方案,很多開源引擎也內置這種方式。

但是此處我們討論的是更通用的情況,所以接下來要看的是第一種渲染方案。那麼問題就集中在如何存儲大量的中文字元?如果我們可以申請任意多紋理來存儲這些字元,那麼這個問題就不是問題了。但是過多的紋理會破壞我們的Batch系統,降低程序的性能。所以我們假設只有一個 1024*1024 的紋理可用,那麼需要:

  • 盡量的緊湊的把字元打包在一起
  • 設計適當的字元緩存/更新方案

因為在遊戲中並不是每時每刻都需要所有的字形,所以可以設計一個緩存方案,把此時需要的字形寫入到紋理中,如果紋理滿了再把已經不再使用的字形剔除。其實對於第二點我們有豐富的處理經驗(畢竟寫了7/8年的代碼,緩存使用家常便飯),LRU/LFU/FIFO/LIFO... 總有一個可以解決問題。

更多參見 Cache replacement policies

對於字形打包,比如一個 , 號佔用的空間顯然小於 鵠 的佔用空間。我們需要一種合適的排版演算法能讓這些字元到緊湊的排列在一起,同時如果刪除了一個字元我們應該也能夠記錄這個位置等下次更新字元的時候可以找到最優的更新位置。

這個問題其實早就被前輩們研究了很多遍了,這就是書架演算法,書架演算法來自於最簡單的字元排版演算法,如果把字元從左到右挨個排列,排滿了就換行繼續排列,那麼這就像在一個書架上放書一樣。當然,也沒這麼簡單,書架演算法的變種有幾十種。

有興趣的同學可以看看這篇論文 - clb.demon.fi/files/Rect

目前比較常用的是 GuillotinePacker 演算法,雖然不是最優但是在性能和填充率上可以取得一個不錯的平衡。這樣的話,我們已經解決了棘手的兩個問題,接下來只要在 korok 中實現這套演算法即可。但是這種演算法也並不完美,動態的增刪緩存肯定會帶來性能問題。如果我們從應用層來重新看待這個問題並且能確定使用的字元數量在一個不大的規模,那麼最優的方案是提前把這些所有需要的字元提前渲染在一個 Bitmap 上使用(這相當於 BitmapFont,只是這是我們是在引擎初始化的時候完成,而不是使用一些字體工具製作的)。

我們把可以運行時添加/刪除字元的方案稱為動態方案,提前算好字元範圍的方案稱為靜態方案(那麼 BitmapFont 也屬於靜態方案),這樣比較理想的方式是同時提供兩種方案,由用戶根據遊戲類型來選擇,字元使用少的遊戲使用靜態方案,大量文本的遊戲使用動態方案,這是我們期望的字體系統實現。很可惜的是我們現在離又一次延期已經很近了,所以只好暫時放棄動態方案只提供目前易於實現的靜態方案,在下一個迭代周期中再實現吧。

目前字體系統現狀

在當前 Korok 的實現中,字體系統是一個完全獨立的系統,它對外只提供一個介面:

// Font provides all the information needed to render a Rune.//// Tex2D returns the low-level bk-texture.// Glyph returns the matrix of rune.// Bounds returns the largest width and height for any of the glyphs// in the fontAtlas.type Font interface { Tex2D() (uint16, *bk.Texture2D) Glyph(rune rune) (g Glyph, ok bool) Bounds() (gw, gh float32) Frame(rune rune) (x1, y1, x2, y2 float32)}

這個介面提供了渲染字體需要的必要知識。如此可以很好的屏蔽了 BitmapFont 和 TrueType 字體的區別(或者其它更多字體實現),對於使用者來說永遠是一個 Font 介面,這樣一切優化我們都能在字體模塊單獨處理。

一段使用字體的代碼:

// 分別載入 BitmapFont 和 ttfasset.Font.LoadBitmap("font1", "font.png", "font.json")asset.Font.LoadTrueType("font2", "honokamin.ttf")// 使用字體font, _ := asset.Font.Get("font2")gui.SetFont(font)// 內部實現,使用字體介面渲染字元g, _ := font.Glyph(r)x1, y1 := dx, dy - (g.Height + g.YOffset) x2, y2 := x1 + g.Width, y1 + g.Heightu1,v1, u2, v2 := font.Frame(r)

對於 gui 系統來說,可以方便的切換使用 BitmapFont 或是 ttf .

事實上當前 Korok 對 TrueType 字體的支持是非常笨拙的,但是日後我們可以提供新的更好的實現而不必影響到現在的GUI系統等依賴字體的系統的運行。當前的 TrueType 演算法使一個字體系統實現只對應一個固定大小的紋理,紋理初始化的時候需要把所有需要的字元全部渲染到紋理上(運行時不能修改)。

目前對於英文系統來說必然是沒有問題的,但是對於中文系統可能會出現問題。但是也想好了應對策略,對於整個遊戲來說在不同的階段需要的字符集是不一樣的,那麼就可以把不同階段所需要的字元渲染到不同的固定紋理上,那麼在切換場景的時候也跟著切換字體就可以了。這樣既可以滿足大量字元的支持,也能做到使用最少的紋理,代價是需要在應用層考慮這個問題。其實早期的 unity 也是這種方案(有貼 為證)。

根據我們的調研,目前各個流行的引擎的字體演算法大概如下:

1. Cocos2D 動態方案,在默認情況下會使用一個 512 * 512 的紋理,然後把字形寫入到這個紋理,如果寫滿了會再創建一個 512 * 512 的紋理繼續寫。關於 Cocos2D 是否有使用一些讓字體打包更緊湊的演算法,並沒有細讀。

2. Dear Imgui 純靜態方案, 這是當期那炙手可熱的一個 Imgui 庫,它的演算法是啟動系統前載入所有需要的字形。

3. Xenko 提供了兩套方案,一套是靜態方案,一套是動態更新方案採用一個 1024 * 1024 的紋理來緩存字元(這是一個C#實現的ECS-Like引擎,以後會分析這個引擎)。

4. Unity3D 支持動態方案和靜態方案,沒見過unity的源碼不太了解具體實現。

其實還有些奇技淫巧可以用來解決字體的相關問題,比如業界經常會使用一些工具從完整的ttf文件中剔除不會使用的字元來減少ttf文件的大小,也有引擎會使用 SDF 演算法來提高縮放的精度這樣可以避免創建多套不同大小的字體紋理。在Korok的下一個迭代周期中,字體支持會考慮SDF,同時通過動態字體提供強大的靈活性,比如動態的更新不同大小/不同TTF文件來的到統一的紋理上,減少紋理數量的使用。

關於我們的遊戲引擎項目,你可以關註:KorokEngine/Korok ,也歡迎關注我們專欄.

以上(題圖是我們用當前的GUI系統渲染的一首很美的歌詞)。

Oceans apart day after day

And I slowly go insane

I hear your voice on the line

But it doesnt stop the pain

If I see you next to never

How can we say forever?

Wherever you go, whatever you do

I will be right here waiting for you

Whatever it takes, or how my heart breaks

I will be right here waiting for you

Bryan Adams - Right Here Waiting For You Lyrics | MetroLyrics


推薦閱讀:

TAG:字體 | 遊戲引擎 | Go語言 |