Android字型渲染器——使用OpenGL ES進行高效文字渲染

chris發表於2014-06-16

任何有多年客戶端開發經驗的開發者都應該知道複雜的文字渲染是怎麼工作的。至少在2010年以前,我剛開始寫libhwui的時候(這是一個基於Android2.0的2D繪畫庫),我就意識到處理文字有時會比其他方面更復雜,特別是當你嘗試用GPU在螢幕上進行繪製的時候。

文字與Android

Android上的文字渲染加速器硬體最初是由Renderscript團隊寫的,然後被很多工程師改進和優化,包括我和好友Chet Haase。在網路上,可以很容易找到很多關於怎麼使用OpenGL ES渲染文字的教程。如果覺得還不夠,可以看看關於遊戲的文章,只看關於文字渲染部分就行。

本文說不是很新奇的知識,只是對於很多開發者來說,通過本文可以從深層次上了解如何實現一個基於GPU的文字渲染系統,文章最後還介紹了一些比較容易實現的優化方法。

用OpenGL渲染文字的常用方法是計算包含所需字形的所有紋理集。這個操作通常是使用一些相當複雜的演算法進行離線操作,這樣可以在構造字形的時候更加高效。在建立這樣一個紋理集之前,首先需要知道應用程式在執行時要使用的字型,包括字型樣式、大小以及其它屬性。

在Android上,提前進行字型紋理生成不是一個實用的方案。Android上的UI工具並不能知道應用系統會使用什麼字型和字形,並且應用還可以在執行時載入自定義的字型,這是主要的限制。Android字型渲染還必須遵循以下條例:

  • 它必須在執行時建立字型快取;
  • 它必須能夠處理大量的字型;
  • 它必須可以處理大量的符號;
  • 它必須要儘可能減少字型上的資源消耗;
  • 必須執行要快速;
  • 在低端和高階機器上也能夠良好執行;
  • 能完美與其它元件結合(驅動程式或GPU)。

字型渲染器的實現

在進入底層OpenGL字型渲染器工作原理之前,我們先從應用層使用的高階別的API開始。這些API對於理解libhwui很重要。

文字API

用於佈局和繪製文字主要有4個API:

  • android.widget.TextView:一個可以處理文字佈局和渲染的檢視元件。
  • android.text.*:一個可以建立風格化文字和佈局的類集合。
  • android.graphics.Paint:用於測量文字。
  • android.graphics.Canvas:用於渲染文字。

TextView和android.text的都是在Paint和Canvas上的高階API。Android3.0以後,Paint和Canvas直接被實現在Skia之上,這是一個開源的渲染庫。SKia提供了一個很好的Freetype抽象實現,這是一個很熱門的開源字型柵格化程式。

1-BitH26buboQae4iO-FpSyg

對於Android4.4,情況變得有些複雜。Paint和Canvas都使用了一個內部的JNI API,叫做TextLayoutCache。它可以處理複雜的文字佈局(CTL)。這個API依賴Harfbuzz,一個空間開源的字形引擎。TextLayoutCache的輸入是一個字型和一個Java的UTF-16的字串,輸出是一個帶有x/y座標的字形列表。

TextLayoutCache是支援非拉丁語言的要點,比如阿拉伯語言、希伯來語、泰國語等,本文不會解釋TextLayoutCache和Harfbuzz的工作原理,但本人強烈建議讀者去學習學習CTL。如果在開發應用的時候需要支援非拉丁語言環境,那麼就要學習它了。如果你曾經參與過OpenGL渲染文字的文章中的討論,就會發現這種特殊的問題是很少見的。繪製文字比簡單排布字形更復雜。某些語言中,比如阿拉伯語是從右到左的,還有泰語甚至需要把字形排布在前一個字形的上面或者下面。

1-VvVj04gAzuTsMC_AN9RGRA

也就是說,當直接或間接呼叫Canvas.drawText()函式的時候,OpenGL 渲染器不會收到你傳送的引數,而是收到一串數字、符號標識,還有x/y 座標集合。

點陣化和快取

字型渲染器的每一個繪製方法都是和字型相關的。字型用於快取個別字形符號,而字形符號又被儲存在快取結構中(快取結構可以包含不同字型的字形符號)。快取結構是持有多個緩衝區的一個重要的物件,有block集合、pixel緩衝區、OpenGL結構處理器,還有點陣緩衝區(也就是網格)。

1-qK4rIi_HDsEYPQQxFK5uPg

這個物件儲存的資料結構比較簡單:

  • 在字型渲染器中字型是儲存在一個LRU快取中的;
  • 字形符號分別儲存在對應的map字型集合中(key就是字形檔案的identifier);
  • 快取結構使用一個塊連結串列集合來記錄空間的大小;
  • 畫素緩衝區是一個uint8_t或者uint32_t型別的陣列(作alpha值和RGBA的快取);
  • 網格其實就是一個頂點陣列,帶有兩個屬性:x/y位置和u/v座標;
  • 一個GLuint的處理器。

字型渲染器對不同型別的快取結構提供了幾種快取紋理例項,也就是根據不同的大小區分,這個大小可能會根據不同裝置而有所不同,這裡這裡說的是預設的大小(快取的數量是硬編碼的):

  • 1024*512 alpha快取。
  • 2048*256 alpha快取。
  • 2028*512alpha快取。
  • 1024*512alpha快取。
  • 2048*256alpha快取。

當快取紋理物件建立之後,其對應的緩衝區不會自動分配空間,除了1024*512的alpha快取總是自動分配外,其它的都是根據需要來分配空間。

字形符號以列的形式打包在紋理中,只要字型渲染器遇到沒有快取的符號,它就會向快取紋理請求響應的型別(儲存在以上的有序列表中),然後快取該符號。

這是上述的blocks列表使用到的地方,這個列表包含了當前已分配的列和所有未分配的空間。如果字形符號和已經存在的列匹配,那該字形符號就會被加到該列的底部。

如果所有列都被佔用,從左邊的剩餘空間開闢新列。因為所有字型都是等寬的,渲染器會把每個字形的寬度弄成4畫素的倍數(預設是4畫素)。這是對列的重利用和字形打包的一個折衷,這個打包目前還不是很好,但是實現起來比較快。

所有的字形符號都儲存在一個含有1個畫素邊框的結構中,這樣在雙線過濾取樣的時候可以避免偽跡的產生。

在文字帶有縮放變形操作的渲染中,瞭解文字何時被渲染也是非常重要的。這個變形操作直接到Skia/Freetype來處理,這就意味著字形符號是在快取結構中變形儲存的。這樣可以改善渲染的質量。幸運的是,文字一般很少做縮放動畫效果,就算是使用了,也只是設計很少的字形符號。本人做過很多實驗,也沒有找到一個實際使用的場景。

還有其它關於paint的屬性會影響字形符號的柵格化和儲存的:粗體、斜體、還有X縮放(在Canvas上做矩陣變換)、字型風格以及線條寬度等。

柵格化的可選方案

事實上,還有其它的方式去在GPU上處理文字字形符號。可以直接被渲染程向量,但是這樣做開銷很大。我調查過標記距離欄位的方法,但是簡單實現的時候遇到了精度的問題(建立曲線的時候會不穩定)。

本人建議讀者可以看看Glyphy這個專案。這是一個開源庫,作者是Harfbuzz。專案在標記距離欄位技術上進行延伸,同時也解決了精度的問題。我暫時沒有花太多時間看這個專案。但是上一次在做著色器的時候,發現這種技術在Android上是被禁止使用的。

預快取技術

字形符號快取是一定要做的。如果做預快取的話,效果會更好。因為libhwui是一個延遲的渲染器(和Skia的快速模式正好相反),所有螢幕上出現的字形都是一幀一幀開始的。在一系列的顯示操作(批處理和合並操作)中,字型渲染器需要儘可能多地快取字形符號。

使用預快取技術的主要優勢在於,可以完全或者最小化紋理載入的時間。紋理載入操作是消耗非常大的,它會推延CPU或者GPU。甚至在幀渲染過程中,改變紋理還會在GPU體系結構帶來更多記憶體的壓力。

ImaginationTech的PowerVRml SGX GPUs使用了延遲疊加技術架構,可以提供很多有趣的特性。但如果在渲染幀時需要修改紋理,會強制要求驅動程式對紋理進行復制。因為字型結構相當大,如果不好好處理紋理載入的話,很容易就記憶體耗盡了。

這樣的場景確實發生在Google Play的一個應用中。這個APP是一個簡單的計算器,僅使用一些數學符號和數字進行簡單的繪製按鈕。字型渲染器在某的時候甚至渲染不出第一幀。因為按鈕是連續進行繪製的,每一個按鈕都會觸發一個紋理載入,然後複製整個字型快取。系統根本沒有這麼多記憶體去儲存這麼多快取的備份。

清空快取

因為用作字形快取的紋理是非常大的,它們有時會被系統回收再利用,以便為其它程式更多的RAM。

當使用者隱藏當前的應用時,系統給應用傳送一條訊息要求釋放盡可能多的記憶體。很明顯,這就需要銷燬最大的字形快取結構。在Android中,這個大快取結構就是所有字形的快取。除了預設第一個建立的以外(1024*512的預設快取)。

紋理結構在沒有儲存空間的時會被清空。字型渲染器使用LRU演算法對素有字型進行記錄,僅僅是記錄而已。如果需要,就會根據最近最少使用的紋理來清除記憶體。目前沒有提供這個操作,但是它確實是一個不錯的優化策略。

批處理和合並操作

Android4.3引入的繪製批處理和合並操作是一項重要的優化,徹底減少了大量往OpenGL驅動傳送指令的問題。

為了進行合併操作,字型渲染器在進行多種繪製呼叫的時候會快取文字,每個快取紋理都會擁有一個客戶端的2048 quads的陣列(1 quad = 1 glyph)。當呼叫lilbhwui中的一個文字繪製API時,字型渲染器獲取合適的網格為每個字形符號進行位置和u/v座標的繪製。網格在批處理的末端被髮送到GPU上(由延遲顯示系統決定)。或者當一個quad的緩衝區滿了的時候,可能會出現多網格渲染同一個字串的情況——一個字元快取佔用一個網格。

這個優化過程很容易實現,對顯示效果幫助也很大。因為字型渲染器使用多快取結構,所以在一個字串的渲染過程彙總,可能字形符號會來自不同的紋理。如果沒有批處理好合並操作的話,每個繪製呼叫都要傳遞給GPU。字型渲染器就需要不斷切換不同的快取結構,這樣會帶來很大的消耗。

在測試字型渲染器的時候,我已經在一個測試App中發現了這個問題。這個App只是簡單地用不同的樣式和大小渲染一句“hello world”。其中字母“o”被儲存在不同的紋理中,和其它的字元不一樣。這種情況導致字型渲染器開始時只繪製了“hell”,然後渲染“o”,然後再渲染“w”,然後在渲染“o”,接著才是“rld”。這5個繪製呼叫和5個紋理進行繫結連線後,只有其中兩個是實際需要的,現在渲染器先繪製“hell w rld”,然後在一起繪製兩個“o”,這就是批處理和合並操作的好處了。

優化紋理載入

之前提到過字型渲染在更新快取紋理的時候(記錄每個紋理中的髒資料塊)會盡可能載入少一點資料。但是很不幸,這個方法還是有兩個限制。

首先,OpenGL ES2.0不允許隨意上傳一個矩形區域。glTextSubImage2D 會讓你指定矩形的x/y座標和寬高來更新矩形裡面的紋理。並且它會把矩形的寬當做記憶體裡的資料幅度,這個可以通過建立一個合適大小的CPU緩衝區來解決,但是也需要事先知道這個矩形的到底有多大。

有一個很好的折衷,就是載入包含髒資料塊(矩形)的最小畫素帶。因為這個畫素帶和紋理一樣寬,這樣就可以節省空間。比每次都要更新整個紋理效果好得多。

第二個問題是紋理載入屬於非同步呼叫,這樣可能造成相當長的CPU延遲(甚至可能會達到1毫秒,依賴紋理的大小、驅動程式還有GPU)。像之前說的那樣,如果使用預快取應該是沒有問題的。但是如果使用的是“重字型”的場景,或者是區域化語言的場景的話(較多的使用字形符號比如中文),那麼問題就還是會出現的。

令人欣慰的是,OpenGL3.0為這兩個問題提供瞭解決方案,這樣就可以直接使用一個畫素儲存的屬性來載入資料矩形了。GL_UNPACK_ROW_LENGTH這個屬性指定了記憶體源資料的寬度。需要注意的是,這個屬性會影響到當前OpenGL上下文的全域性狀態。

載入紋理時,CPU延遲可以通過使用畫素緩衝物件(PBOs)來避免。就像所有OpenGL裡的緩衝區物件一樣PBO會駐留在GPU中,但也可以對映到記憶體中。PBOs有很多有趣的屬性,但是我們關心的是一個在主存中取消對映關係後還可以進行非同步載入紋理的屬性,此時操作佇列變成:

glMapBufferRange → write glyphs to buffer → glUnmapBuffer → glPixelStorei(GL_UNPACK_ROW_LENGTH) → glTexSubImage2D

呼叫glTexSubImage2D可以立即返回,而不用阻塞渲染器,字型渲染器可以在記憶體中對映整個緩衝區,而且似乎不會出現問題。這對於快取紋理的更新操作是一個不錯的方案。

這兩種OpenGL ES3.0的優化方法會出現在Android4.4中。

陰影效果

一般文字在渲染的時候都會帶有陰影效果,這是一個相當耗費資源的操作。在臨近的字形符號可以進行相互模糊操作之後,字型渲染器不再進行獨立的預模糊操作。有很多中方法可以實現模糊化,但是為了在同一幀中把這些調配操作和紋理取樣操作最小化,陰影效果會被簡單儲存為紋理,在多幀切換的時候可以儲存。

因為應用程式可以輕易地拖垮GPU,所以我們還是得依靠CPU來對文字進行模糊化。最簡單和高效的方式就是使用Renderscript的C++ API,只需要簡單幾行程式碼就可以實現核心功能。最簡單的方法是在初始化Renderscript的時候指定RS_INIT_LOW_LATENCY標記來強制執行在CPU上。

未來的優化操作

有一個優化方法我希望可以在我離開Android團隊之前實現。文字預快取、非同步和部分紋理更新都是一些重要的優化操作。但是柵格化文字元號一直都是一個很耗費資源的操作,在systrace可以很容易看到(啟用gfs標識然後看precacheText事件)。

對預快取的一個簡單的優化方式就是,把這個操作放到另一個工作執行緒去執行,把柵格化操作放到後臺。這個技術已經被用到一些複雜的路徑柵格化操作中,但是沒有新增到OpenGL架構之中。

改進批處理和合並操作也是一個可能的優化方式,用於繪製文字的顏色一般是被髮送到一個fragment陰影統一操作。這樣可以減少傳送到GPU的頂點資料,但副作用會產生很多不需要的批處理指令:一個批處理操作只能包含一種文字顏色。如果文字顏色也儲存為頂點屬性,那麼就可以網GPU傳遞更少的資料。

原始碼

如果想詳細地看看字型渲染器的實現,可以瀏覽libhwui的GitHub,可以從FontRender.cpp開始,因為很多驚喜都在這裡發生,它的支援類可以在font或者sub目錄找到。對了,PixelBuffer.cpp這個檔案也不錯,可以看看。這就是一個畫素緩衝區的抽象實現,可以用於CPU(uint8_t型別的陣列)或者GPU緩衝區(PBO)。

最後的話

本文只是對Android的字型渲染器進行簡單介紹,還有很多實現的細節沒有考慮到,或者很多問題以後會說明,所以有什麼問題可以儘管向我提問。

相關文章