Android字體渲染器:使用OpenGL ES進(jìn)行高效文字渲染
任何有多年客戶端開發(fā)經(jīng)驗(yàn)的開發(fā)者都應(yīng)該知道復(fù)雜的文字渲染是怎么工作的。至少在2010年以前,我剛開始寫libhwui的時(shí)候(這是一個(gè)基于 Android2.0的2D繪畫庫(kù)),我就意識(shí)到處理文字有時(shí)會(huì)比其他方面更復(fù)雜,特別是當(dāng)你嘗試用GPU在屏幕上進(jìn)行繪制的時(shí)候。
文字與Android
Android上的文字渲染加速器硬件最初是由Renderscript團(tuán)隊(duì)寫的,然后被很多工程師改進(jìn)和優(yōu)化,包括我和好友Chet Haase。在網(wǎng)絡(luò)上,可以很容易找到很多關(guān)于怎么使用OpenGL ES渲染文字的教程。如果覺得還不夠,可以看看關(guān)于游戲的文章,只看關(guān)于文字渲染部分就行。
本文說不是很新奇的知識(shí),只是對(duì)于很多開發(fā)者來說,通過本文可以從深層次上了解如何實(shí)現(xiàn)一個(gè)基于GPU的文字渲染系統(tǒng),文章***還介紹了一些比較容易實(shí)現(xiàn)的優(yōu)化方法。
用OpenGL渲染文字的常用方法是計(jì)算包含所需字形的所有紋理集。這個(gè)操作通常是使用一些相當(dāng)復(fù)雜的算法進(jìn)行離線操作,這樣可以在構(gòu)造字形的時(shí)候更加高效。在創(chuàng)建這樣一個(gè)紋理集之前,首先需要知道應(yīng)用程序在運(yùn)行時(shí)要使用的字體,包括字體樣式、大小以及其它屬性。
在Android上,提前進(jìn)行字體紋理生成不是一個(gè)實(shí)用的方案。Android上的UI工具并不能知道應(yīng)用系統(tǒng)會(huì)使用什么字體和字形,并且應(yīng)用還可以在運(yùn)行時(shí)載入自定義的字體,這是主要的限制。Android字體渲染還必須遵循以下條例:
- 它必須在運(yùn)行時(shí)建立字體緩存;
- 它必須能夠處理大量的字體;
- 它必須可以處理大量的符號(hào);
- 它必須要盡可能減少字體上的資源消耗;
- 必須運(yùn)行要快速;
- 在低端和高端機(jī)器上也能夠良好運(yùn)行;
- 能***與其它組件結(jié)合(驅(qū)動(dòng)程序或GPU)。
字體渲染器的實(shí)現(xiàn)
在進(jìn)入底層OpenGL字體渲染器工作原理之前,我們先從應(yīng)用層使用的高級(jí)別的API開始。這些API對(duì)于理解libhwui很重要。
文字API
用于布局和繪制文字主要有4個(gè)API:
- android.widget.TextView:一個(gè)可以處理文字布局和渲染的視圖組件。
- android.text.*:一個(gè)可以創(chuàng)建風(fēng)格化文字和布局的類集合。
- android.graphics.Paint:用于測(cè)量文字。
- android.graphics.Canvas:用于渲染文字。
TextView和android.text的都是在Paint和Canvas上的高級(jí)API。Android3.0以后,Paint和Canvas直接被實(shí)現(xiàn)在Skia之上,這是一個(gè)開源的渲染庫(kù)。SKia提供了一個(gè)很好的Freetype抽象實(shí)現(xiàn),這是一個(gè)很熱門的開源字體柵格化程序。

對(duì)于Android4.4,情況變得有些復(fù)雜。Paint和Canvas都使用了一個(gè)內(nèi)部的JNI API,叫做TextLayoutCache。它可以處理復(fù)雜的文字布局(CTL)。這個(gè)API依賴Harfbuzz,一個(gè)空間開源的字形引擎。TextLayoutCache的輸入是一個(gè)字體和一個(gè)Java的UTF-16的字符串,輸出是一個(gè)帶有x/y坐標(biāo)的字形列表。
TextLayoutCache是支持非拉丁語(yǔ)言的要點(diǎn),比如阿拉伯語(yǔ)言、希伯來語(yǔ)、泰國(guó)語(yǔ)等,本文不會(huì)解釋TextLayoutCache和 Harfbuzz的工作原理,但本人強(qiáng)烈建議讀者去學(xué)習(xí)學(xué)習(xí)CTL。如果在開發(fā)應(yīng)用的時(shí)候需要支持非拉丁語(yǔ)言環(huán)境,那么就要學(xué)習(xí)它了。如果你曾經(jīng)參與過 OpenGL渲染文字的文章中的討論,就會(huì)發(fā)現(xiàn)這種特殊的問題是很少見的。繪制文字比簡(jiǎn)單排布字形更復(fù)雜。某些語(yǔ)言中,比如阿拉伯語(yǔ)是從右到左的,還有泰 語(yǔ)甚至需要把字形排布在前一個(gè)字形的上面或者下面。

也就是說,當(dāng)直接或間接調(diào)用Canvas.drawText()函數(shù)的時(shí)候,OpenGL 渲染器不會(huì)收到你發(fā)送的參數(shù),而是收到一串?dāng)?shù)字、符號(hào)標(biāo)識(shí),還有x/y 坐標(biāo)集合。
點(diǎn)陣化和緩存
字體渲染器的每一個(gè)繪制方法都是和字體相關(guān)的。字體用于緩存?zhèn)€別字形符號(hào),而字形符號(hào)又被存儲(chǔ)在緩存結(jié)構(gòu)中(緩存結(jié)構(gòu)可以包含不同字體的字形符 號(hào))。緩存結(jié)構(gòu)是持有多個(gè)緩沖區(qū)的一個(gè)重要的對(duì)象,有block集合、pixel緩沖區(qū)、OpenGL結(jié)構(gòu)處理器,還有點(diǎn)陣緩沖區(qū)(也就是網(wǎng)格)。

這個(gè)對(duì)象存儲(chǔ)的數(shù)據(jù)結(jié)構(gòu)比較簡(jiǎn)單:
- 在字體渲染器中字體是存儲(chǔ)在一個(gè)LRU緩存中的;
- 字形符號(hào)分別存儲(chǔ)在對(duì)應(yīng)的map字體集合中(key就是字形文件的identifier);
- 緩存結(jié)構(gòu)使用一個(gè)塊鏈表集合來記錄空間的大??;
- 像素緩沖區(qū)是一個(gè)uint8_t或者uint32_t類型的數(shù)組(作alpha值和RGBA的緩存);
- 網(wǎng)格其實(shí)就是一個(gè)頂點(diǎn)數(shù)組,帶有兩個(gè)屬性:x/y位置和u/v坐標(biāo);
- 一個(gè)GLuint的處理器。
字體渲染器對(duì)不同類型的緩存結(jié)構(gòu)提供了幾種緩存紋理實(shí)例,也就是根據(jù)不同的大小區(qū)分,這個(gè)大小可能會(huì)根據(jù)不同設(shè)備而有所不同,這里這里說的是默認(rèn)的大?。ň彺娴臄?shù)量是硬編碼的):
- 1024*512 alpha緩存。
- 2048*256 alpha緩存。
- 2028*512alpha緩存。
- 1024*512alpha緩存。
- 2048*256alpha緩存。
當(dāng)緩存紋理對(duì)象創(chuàng)建之后,其對(duì)應(yīng)的緩沖區(qū)不會(huì)自動(dòng)分配空間,除了1024*512的alpha緩存總是自動(dòng)分配外,其它的都是根據(jù)需要來分配空間。
字形符號(hào)以列的形式打包在紋理中,只要字體渲染器遇到?jīng)]有緩存的符號(hào),它就會(huì)向緩存紋理請(qǐng)求響應(yīng)的類型(存儲(chǔ)在以上的有序列表中),然后緩存該符號(hào)。
這是上述的blocks列表使用到的地方,這個(gè)列表包含了當(dāng)前已分配的列和所有未分配的空間。如果字形符號(hào)和已經(jīng)存在的列匹配,那該字形符號(hào)就會(huì)被加到該列的底部。
如果所有列都被占用,從左邊的剩余空間開辟新列。因?yàn)樗凶煮w都是等寬的,渲染器會(huì)把每個(gè)字形的寬度弄成4像素的倍數(shù)(默認(rèn)是4像素)。這是對(duì)列的重利用和字形打包的一個(gè)折衷,這個(gè)打包目前還不是很好,但是實(shí)現(xiàn)起來比較快。
所有的字形符號(hào)都存儲(chǔ)在一個(gè)含有1個(gè)像素邊框的結(jié)構(gòu)中,這樣在雙線過濾采樣的時(shí)候可以避免偽跡的產(chǎn)生。
在文字帶有縮放變形操作的渲染中,了解文字何時(shí)被渲染也是非常重要的。這個(gè)變形操作直接到Skia/Freetype來處理,這就意味著字形符號(hào)是 在緩存結(jié)構(gòu)中變形存儲(chǔ)的。這樣可以改善渲染的質(zhì)量。幸運(yùn)的是,文字一般很少做縮放動(dòng)畫效果,就算是使用了,也只是設(shè)計(jì)很少的字形符號(hào)。本人做過很多實(shí)驗(yàn), 也沒有找到一個(gè)實(shí)際使用的場(chǎng)景。
還有其它關(guān)于paint的屬性會(huì)影響字形符號(hào)的柵格化和存儲(chǔ)的:粗體、斜體、還有X縮放(在Canvas上做矩陣變換)、字體風(fēng)格以及線條寬度等。
柵格化的可選方案
事實(shí)上,還有其它的方式去在GPU上處理文字字形符號(hào)??梢灾苯颖讳秩境滔蛄?,但是這樣做開銷很大。我調(diào)查過標(biāo)記距離字段的方法,但是簡(jiǎn)單實(shí)現(xiàn)的時(shí)候遇到了精度的問題(創(chuàng)建曲線的時(shí)候會(huì)不穩(wěn)定)。
本人建議讀者可以看看Glyphy這個(gè)項(xiàng)目。這是一個(gè)開源庫(kù),作者是Harfbuzz。項(xiàng)目在標(biāo)記距離字段技術(shù)上進(jìn)行延伸,同時(shí)也解決了精度的問題。我暫時(shí)沒有花太多時(shí)間看這個(gè)項(xiàng)目。但是上一次在做著色器的時(shí)候,發(fā)現(xiàn)這種技術(shù)在Android上是被禁止使用的。
預(yù)緩存技術(shù)
字形符號(hào)緩存是一定要做的。如果做預(yù)緩存的話,效果會(huì)更好。因?yàn)閘ibhwui是一個(gè)延遲的渲染器(和Skia的快速模式正好相反),所有屏幕上出現(xiàn)的字形都是一幀一幀開始的。在一系列的顯示操作(批處理和合并操作)中,字體渲染器需要盡可能多地緩存字形符號(hào)。
使用預(yù)緩存技術(shù)的主要優(yōu)勢(shì)在于,可以完全或者最小化紋理加載的時(shí)間。紋理加載操作是消耗非常大的,它會(huì)推延CPU或者GPU。甚至在幀渲染過程中,改變紋理還會(huì)在GPU體系結(jié)構(gòu)帶來更多內(nèi)存的壓力。
ImaginationTech的PowerVRml SGX GPUs使用了延遲疊加技術(shù)架構(gòu),可以提供很多有趣的特性。但如果在渲染幀時(shí)需要修改紋理,會(huì)強(qiáng)制要求驅(qū)動(dòng)程序?qū)y理進(jìn)行復(fù)制。因?yàn)樽煮w結(jié)構(gòu)相當(dāng)大,如果不好好處理紋理加載的話,很容易就內(nèi)存耗盡了。
這樣的場(chǎng)景確實(shí)發(fā)生在Google Play的一個(gè)應(yīng)用中。這個(gè)APP是一個(gè)簡(jiǎn)單的計(jì)算器,僅使用一些數(shù)學(xué)符號(hào)和數(shù)字進(jìn)行簡(jiǎn)單的繪制按鈕。字體渲染器在某的時(shí)候甚至渲染不出***幀。因?yàn)榘粹o 是連續(xù)進(jìn)行繪制的,每一個(gè)按鈕都會(huì)觸發(fā)一個(gè)紋理加載,然后復(fù)制整個(gè)字體緩存。系統(tǒng)根本沒有這么多內(nèi)存去存儲(chǔ)這么多緩存的備份。
清空緩存
因?yàn)橛米髯中尉彺娴募y理是非常大的,它們有時(shí)會(huì)被系統(tǒng)回收再利用,以便為其它程序更多的RAM。
當(dāng)用戶隱藏當(dāng)前的應(yīng)用時(shí),系統(tǒng)給應(yīng)用發(fā)送一條消息要求釋放盡可能多的內(nèi)存。很明顯,這就需要銷毀***的字形緩存結(jié)構(gòu)。在Android中,這個(gè)大緩存結(jié)構(gòu)就是所有字形的緩存。除了默認(rèn)***個(gè)創(chuàng)建的以外(1024*512的默認(rèn)緩存)。
紋理結(jié)構(gòu)在沒有存儲(chǔ)空間的時(shí)會(huì)被清空。字體渲染器使用LRU算法對(duì)素有字體進(jìn)行記錄,僅僅是記錄而已。如果需要,就會(huì)根據(jù)最近最少使用的紋理來清除內(nèi)存。目前沒有提供這個(gè)操作,但是它確實(shí)是一個(gè)不錯(cuò)的優(yōu)化策略。
批處理和合并操作
Android4.3引入的繪制批處理和合并操作是一項(xiàng)重要的優(yōu)化,徹底減少了大量往OpenGL驅(qū)動(dòng)發(fā)送指令的問題。
為了進(jìn)行合并操作,字體渲染器在進(jìn)行多種繪制調(diào)用的時(shí)候會(huì)緩存文字,每個(gè)緩存紋理都會(huì)擁有一個(gè)客戶端的2048 quads的數(shù)組(1 quad = 1 glyph)。當(dāng)調(diào)用lilbhwui中的一個(gè)文字繪制API時(shí),字體渲染器獲取合適的網(wǎng)格為每個(gè)字形符號(hào)進(jìn)行位置和u/v坐標(biāo)的繪制。網(wǎng)格在批處理的末 端被發(fā)送到GPU上(由延遲顯示系統(tǒng)決定)?;蛘弋?dāng)一個(gè)quad的緩沖區(qū)滿了的時(shí)候,可能會(huì)出現(xiàn)多網(wǎng)格渲染同一個(gè)字符串的情況——一個(gè)字符緩存占用一個(gè)網(wǎng) 格。
這個(gè)優(yōu)化過程很容易實(shí)現(xiàn),對(duì)顯示效果幫助也很大。因?yàn)樽煮w渲染器使用多緩存結(jié)構(gòu),所以在一個(gè)字符串的渲染過程匯總,可能字形符號(hào)會(huì)來自不同的紋理。 如果沒有批處理好合并操作的話,每個(gè)繪制調(diào)用都要傳遞給GPU。字體渲染器就需要不斷切換不同的緩存結(jié)構(gòu),這樣會(huì)帶來很大的消耗。
在測(cè)試字體渲染器的時(shí)候,我已經(jīng)在一個(gè)測(cè)試App中發(fā)現(xiàn)了這個(gè)問題。這個(gè)App只是簡(jiǎn)單地用不同的樣式和大小渲染一句“hello world”。其中字母“o”被存儲(chǔ)在不同的紋理中,和其它的字符不一樣。這種情況導(dǎo)致字體渲染器開始時(shí)只繪制了“hell”,然后渲染“o”,然后再渲 染“w”,然后在渲染“o”,接著才是“rld”。這5個(gè)繪制調(diào)用和5個(gè)紋理進(jìn)行綁定連接后,只有其中兩個(gè)是實(shí)際需要的,現(xiàn)在渲染器先繪制“hell w rld”,然后在一起繪制兩個(gè)“o”,這就是批處理和合并操作的好處了。
優(yōu)化紋理加載
之前提到過字體渲染在更新緩存紋理的時(shí)候(記錄每個(gè)紋理中的臟數(shù)據(jù)塊)會(huì)盡可能加載少一點(diǎn)數(shù)據(jù)。但是很不幸,這個(gè)方法還是有兩個(gè)限制。
首先,OpenGL ES2.0不允許隨意上傳一個(gè)矩形區(qū)域。glTextSubImage2D 會(huì)讓你指定矩形的x/y坐標(biāo)和寬高來更新矩形里面的紋理。并且它會(huì)把矩形的寬當(dāng)做內(nèi)存里的數(shù)據(jù)幅度,這個(gè)可以通過創(chuàng)建一個(gè)合適大小的CPU緩沖區(qū)來解決, 但是也需要事先知道這個(gè)矩形的到底有多大。
有一個(gè)很好的折衷,就是加載包含臟數(shù)據(jù)塊(矩形)的最小像素帶。因?yàn)檫@個(gè)像素帶和紋理一樣寬,這樣就可以節(jié)省空間。比每次都要更新整個(gè)紋理效果好得多。
第二個(gè)問題是紋理加載屬于異步調(diào)用,這樣可能造成相當(dāng)長(zhǎng)的CPU延遲(甚至可能會(huì)達(dá)到1毫秒,依賴紋理的大小、驅(qū)動(dòng)程序還有GPU)。像之前說的那 樣,如果使用預(yù)緩存應(yīng)該是沒有問題的。但是如果使用的是“重字體”的場(chǎng)景,或者是區(qū)域化語(yǔ)言的場(chǎng)景的話(較多的使用字形符號(hào)比如中文),那么問題就還是會(huì) 出現(xiàn)的。
令人欣慰的是,OpenGL3.0為這兩個(gè)問題提供了解決方案,這樣就可以直接使用一個(gè)像素存儲(chǔ)的屬性來加載數(shù)據(jù)矩形了。GL_UNPACK_ROW_LENGTH這個(gè)屬性指定了內(nèi)存源數(shù)據(jù)的寬度。需要注意的是,這個(gè)屬性會(huì)影響到當(dāng)前OpenGL上下文的全局狀態(tài)。
加載紋理時(shí),CPU延遲可以通過使用像素緩沖對(duì)象(PBOs)來避免。就像所有OpenGL里的緩沖區(qū)對(duì)象一樣PBO會(huì)駐留在GPU中,但也可以映 射到內(nèi)存中。PBOs有很多有趣的屬性,但是我們關(guān)心的是一個(gè)在主存中取消映射關(guān)系后還可以進(jìn)行異步加載紋理的屬性,此時(shí)操作隊(duì)列變成:
glMapBufferRange → write glyphs to buffer → glUnmapBuffer → glPixelStorei(GL_UNPACK_ROW_LENGTH) → glTexSubImage2D
調(diào)用glTexSubImage2D可以立即返回,而不用阻塞渲染器,字體渲染器可以在內(nèi)存中映射整個(gè)緩沖區(qū),而且似乎不會(huì)出現(xiàn)問題。這對(duì)于緩存紋理的更新操作是一個(gè)不錯(cuò)的方案。
這兩種OpenGL ES3.0的優(yōu)化方法會(huì)出現(xiàn)在Android4.4中。
陰影效果
一般文字在渲染的時(shí)候都會(huì)帶有陰影效果,這是一個(gè)相當(dāng)耗費(fèi)資源的操作。在臨近的字形符號(hào)可以進(jìn)行相互模糊操作之后,字體渲染器不再進(jìn)行獨(dú)立的預(yù)模糊 操作。有很多中方法可以實(shí)現(xiàn)模糊化,但是為了在同一幀中把這些調(diào)配操作和紋理采樣操作最小化,陰影效果會(huì)被簡(jiǎn)單存儲(chǔ)為紋理,在多幀切換的時(shí)候可以保存。
因?yàn)閼?yīng)用程序可以輕易地拖垮GPU,所以我們還是得依靠CPU來對(duì)文字進(jìn)行模糊化。最簡(jiǎn)單和高效的方式就是使用Renderscript的C++ API,只需要簡(jiǎn)單幾行代碼就可以實(shí)現(xiàn)核心功能。最簡(jiǎn)單的方法是在初始化Renderscript的時(shí)候指定RS_INIT_LOW_LATENCY標(biāo)記 來強(qiáng)制運(yùn)行在CPU上。
未來的優(yōu)化操作
有一個(gè)優(yōu)化方法我希望可以在我離開Android團(tuán)隊(duì)之前實(shí)現(xiàn)。文字預(yù)緩存、異步和部分紋理更新都是一些重要的優(yōu)化操作。但是柵格化文字符號(hào)一直都是一個(gè)很耗費(fèi)資源的操作,在systrace可以很容易看到(啟用gfs標(biāo)識(shí)然后看precacheText事件)。
對(duì)預(yù)緩存的一個(gè)簡(jiǎn)單的優(yōu)化方式就是,把這個(gè)操作放到另一個(gè)工作線程去執(zhí)行,把柵格化操作放到后臺(tái)。這個(gè)技術(shù)已經(jīng)被用到一些復(fù)雜的路徑柵格化操作中,但是沒有添加到OpenGL架構(gòu)之中。
改進(jìn)批處理和合并操作也是一個(gè)可能的優(yōu)化方式,用于繪制文字的顏色一般是被發(fā)送到一個(gè)fragment陰影統(tǒng)一操作。這樣可以減少發(fā)送到GPU的頂 點(diǎn)數(shù)據(jù),但副作用會(huì)產(chǎn)生很多不需要的批處理指令:一個(gè)批處理操作只能包含一種文字顏色。如果文字顏色也存儲(chǔ)為頂點(diǎn)屬性,那么就可以網(wǎng)GPU傳遞更少的數(shù) 據(jù)。
源代碼
如果想詳細(xì)地看看字體渲染器的實(shí)現(xiàn),可以瀏覽libhwui的GitHub,可以從FontRender.cpp開始,因?yàn)楹芏囿@喜都在這里發(fā)生,它的支持類可以在font或者sub目錄找到。對(duì)了,PixelBuffer.cpp這個(gè)文件也不錯(cuò),可以看看。這就是一個(gè)像素緩沖區(qū)的抽象實(shí)現(xiàn),可以用于CPU(uint8_t類型的數(shù)組)或者GPU緩沖區(qū)(PBO)。
***的話
本文只是對(duì)Android的字體渲染器進(jìn)行簡(jiǎn)單介紹,還有很多實(shí)現(xiàn)的細(xì)節(jié)沒有考慮到,或者很多問題以后會(huì)說明,所以有什么問題可以盡管向我提問。
原文鏈接: medium 翻譯: chris























