Minecraft 1.20 - Modern UI 文本引擎技術規(guī)范
1. 什么是Modern UI?
本文所指的Modern UI是由我——BloCamLimb(Icyllis Milica)創(chuàng)作的桌面應用開發(fā)框架,使用Java編寫,包含使用LWJGL與OpenGL 4.5、Vulkan 1.1接口的獨立圖形渲染引擎,UI組件、窗口系統(tǒng)、事件循環(huán)、動畫狀態(tài)機,以及使用ICU庫(International Component for Unicode)的國際化支持的富文本布局引擎。
項目在LGPL 3.0許可下開源,代碼倉庫:https://github.com/BloCamLimb/ModernUI
Modern UI 本身與 Minecraft 并無關聯(lián),但我為 Minecraft 制作了 Modern UI 擴展組件,使得Modern UI 能運行在 Minecraft 中,并提供了模組開發(fā)API和一些擴展功能;其中最復雜的就是專門為 Minecraft 設計的文本引擎,稱為現(xiàn)代文本引擎(Modern Text Engine)。該擴展 Mod 需 Forge 啟動,本文將介紹我在設計該引擎的一些技術細節(jié)。
該項目同樣在LGPL 3.0許可下開源,但代碼被移動到了新倉庫:https://github.com/BloCamLimb/ModernUI-MC
3. U16字符串到U32字符串
在Java中,字符是無符號16位整數(shù),對應Unicode中的16位碼元(code unit)。而Unicode字符集的字符需要用32位整數(shù)表示,稱為碼點(code point)。如果一個字符在BMP平面(Basic Multilingual Plane)上,那么一個碼元就表示一個碼點;如果一個字符在SMP平面(Supplementary Multilingual Plane)上,那么兩個相鄰的碼元組成一個碼點,前一個碼元稱為高代理,后一個碼元稱為低代理,這兩個碼元合稱代理對(Surrogate Pair)。所以要想得到Unicode字符串,就得先把輸入的U16字符串轉成U32字符串。在這里往往要執(zhí)行一個附加的操作,就是修復無效代理對。如果當前字符是高代理,而下個字符不是低代理,那么就把這個字符替換成U+FFFD(Replacement Character);如果當前字符就是低代理,那么直接將其替換成U+FFFD。早期Modern UI可以單獨設置是否要修復無效代理對,在新版中已經(jīng)默認啟動,無法再設置。如下圖,英文字母只是示意,代指某個字符:

4. 字形列表
文字渲染的單位是字形(Glyph),想要渲染文字,就要把字符串(Characters)轉成字形列表(Glyph List),并且需要知道每個字形在一段文字中的相對位置(x,y)。字形是每個字體文件(或字體家族)定義的,不同字體的字形圖像和字形碼不一樣。
那么一個U32字符就對應一個字形嗎?顯然沒有這么簡單。
對于英文和中文,一個碼元就可以映射一個字形。對于一部分在輔助平面上的字符,一個碼點就可以映射一個字形。然而,如果一個碼點后面緊接著一個變種選擇符(Variation Selector),那么可以同一個碼點可以映射成不同的字形。例如:一個不擁有EMOJI_PRESENTATION屬性的字符,其后面必須連接一個Variation Selector 16字符(U+FE0F)才可以渲染成彩色Emoji樣式,否則應渲染成黑白Emoji樣式。這種碼點到字形的映射表存儲在TrueType字體文件中;映射表有多種格式,其中UVS格式(Unicode Variation Sequences),支持變種選擇功能。

支持變種選擇符還沒結束,有許多語言的文字包含連接字符(Combining)的包圍字符(Enclosing),這意味著兩個或更多個字符分開渲染與合在一起渲染的結果不同。例如上面提到的U32字符串為"AcDfg",單獨渲染[A,c,D,f,g]這五個字符對應的字形是[o,p,q,r,s],合起渲染"Ac"有可能是"mn",合起渲染"AcDfg"有可能是"uvwxy"。你看,原本的ab兩個碼元,可以渲染成o,可以渲染成m,也可以渲染成u(注:也可能超過兩個碼元才對應一個字形,見后文提到的字素簇的概念),所以一個字符加一個變種選擇符也不能唯一地確定一個字形,它和前后的多個字符有關,我們稱其上下文(Context)或語境。如下圖,上下文范圍和布局范圍的單位是碼元而非碼點:


要想正確得渲染文字,就必須正確地將字符串轉為字形列表,這個過程稱為文本整形(Text Shaping),它需要提供字符串、字體Strike、上下文范圍、布局范圍和文字方向。其中上下文范圍必須大于等于布局范圍。例如上述例子中,渲染[0,3)范圍的"abc"的上下文范圍不同,字形也不同。
HarfBuzz是一款開源文本整形引擎,Android,X11,Windows,JavaFX,Chromium等內(nèi)部都使用了HarfBuzz,Modern UI也不例外。HarfBuzz是用C++編寫的,意味著不同平臺要編譯出不同的版本,但Java 11以上自帶HarfBuzz,所以不需要額外添加native庫,這也是為什么Modern UI在Minecraft 1.16.5上也需要Java 11才能運行的原因。
5. 文本運行?邏輯運行?視覺運行?樣式運行?字體運行?
既然HarfBuzz可以正確地將字符串轉為字形列表,那我們能直接把整個字符串喂給HarfBuzz嗎?顯然沒有這么簡單。我們必須把整個字符串分成多個運行(Run),每個運行又分成多個子運行,每個子運行又可以分成它的子運行。

所謂一個運行,就是指一個區(qū)間 [start,end) 附加這個區(qū)間的信息,這個區(qū)間必須是上下文區(qū)間。如上圖所示,一個長度為10的字符串,首先要計算 BiD i運行決定文字方向;然后計算 Style 運行決定字體樣式;然后計算 Font 運行決定使用哪個字體(包含樣式和大小);最后計算 Script 運行決定這段文字的字母系統(tǒng)。我們需要把每個 Script Run 進行 HarfBuzz 整形,最后按照視覺順序連接在一起。每一級運行都有不同的算法。
大部分語言是從左向右讀的,例如英語,而有一些語言是從右向左讀的,例如阿拉伯語。如果一段文字混合著英語和阿拉伯語,那么我們該如何渲染文字呢?相信你已經(jīng)注意到了這里有很多順序:邏輯順序(Logical Order)、視覺順序(Visual Order)、LTR(從左到右順序,Left-to-Right)、RTL(從右到左順序,Right-to-Left),這便是雙向文本分析(Bidirectional Analysis),簡稱BiDi分析。無論文本是單樣式文本還是多樣式文本,必須先進行BiDi分析,且要分析的字符串必須是邏輯順序。
所謂邏輯順序,就是文字閱讀的順序,同樣也是字符串在內(nèi)存和硬盤中存儲的順序。例如 English Arabic Text 便是邏輯順序,阿拉伯語的 A 在英語字母 h 的后面。而渲染在屏幕上時,它應該是 English cibarA Text,讀 English 時還是按照從左向右讀,而 Arabic 是從右向左讀,視覺上阿拉伯語的 A 在阿拉伯語的 r 后面。渲染時要將邏輯順序重排序成視覺順序,并將 BiDi 文字分成多個 BiDi 段落,每個段落要么是 LTR 方向,要么是 RTL 方向,每個段落也稱為 BiDi 運行。我們只需調(diào)用 ICU 庫中的 Bidi 類便可完成 BiDi 分析,但 BiDi 有四種算法,Modern UI 又在這四種算法的基礎上提供了雙向文本啟發(fā)式算法來決定最終的算法。
樣式運行是由邏輯順序上字體樣式的轉折點決定的,每一個連續(xù)的、字體樣式不變的區(qū)間便是一個樣式運行。字體樣式指粗、斜體的組合,它會影響字形渲染和字形位置,盡管它們的字形碼是相同的(屬于同一字體家族或模擬粗、斜體)。
字體運行的計算比較復雜,樣式運行中包含了要使用哪個字體,但這個字體指的是一個字體合集,其中包含了多個字體家族,每個字體家族又包含了多個字體文件。我們要從多個字體家族中找到可為當前連續(xù)區(qū)間渲染字符的、最佳匹配的字體家族,并根據(jù)字體樣式選擇其中的某款最合適的字體,來進行后續(xù)的計算。這套算法是由 Modern UI 實現(xiàn)的,也涉及上下文分析。簡要概括就是根據(jù)連體字、Emoji 組合、字體偏好的語言地區(qū)、是否包含當前字符、是否包含變種選擇符,盡可能讓相鄰文字使用同一個字體,并且保證能渲染該字符。如果所選字體不能渲染該字符,Modern UI 則會從系統(tǒng)中找出能渲染它的字體。
FreeType 內(nèi)容省略。
6. Minecraft 中的多樣式文本組件
Minecraft中的文本有四種形式:String、FormattedText、Component、FormattedCharSequence。其中?FormattedText 和?Component 都可以提取出 String,而 Minecraft 中?String 允許包含格式控制碼,即ChatFormatting。它由章節(jié)符號U+00A7前綴和一個碼元組成。碼元是一個ASCII字符,不區(qū)分大小寫,它決定格式類型,分為16個顏色控制碼,2個字體樣式控制碼(粗、斜體),2個效果控制碼(下劃線、刪除線),1個混淆控制碼和1個重置控制碼。控制碼本身不進行布局和渲染,只更改當前字符的樣式,控制狀態(tài)機。因此在Modern UI布局引擎中,有 strip*?概念,意思是剔除控制碼之后的字符串(或字符數(shù)組)。
Style 是一個包含所有樣式信息的類,如文字顏色、粗、斜體、下劃線、刪除線、混淆字、字體名稱、懸浮事件、點擊事件和建議(用于補全命令)。對于布局和渲染而言,只需知道前幾種。Modern UI可以將字符樣式壓縮成一個32位int,其中低24位表示文字顏色,25到29位分別表示,粗體、斜體、下劃線、刪除線、混淆字;第30位表示快速數(shù)位替換,31位表示位圖替換,32位表示隱式顏色。隱式顏色就是不使用低24位的RGB顏色,而使用參數(shù)提供的基樣式(基顏色)。
FormattedText 是一個接口,可包含多個樣式化文本,通過 visit 函數(shù)遍歷其內(nèi)容,該函數(shù)接受一個 Style 作為基樣式,每個內(nèi)容的樣式會疊加在此樣式上,遍歷可得到所有的 String 和 該條目所使用的的 Style。當然,這里的 String 也可以包含控制碼,所以 Style 并不是最終的 Style。Component 則是 FormattedText 的實現(xiàn),每個 Component 可以按照邏輯順序包含多個子 Component,每個 Component 包含一個 String 和 Style,其中 String 和 Style 可變。
Modern UI 在每次處理 FormattedText 時,都會遍歷其內(nèi)容,構建剔除控制碼之后的字符數(shù)組(單位為碼元),以及一個int數(shù)組存儲字符樣式(壓縮方法見上)和一個Object數(shù)組存儲每個字符所使用的字體名稱。構建會使用預分配的緩沖區(qū)提高性能,這些緩沖區(qū)存放在一個共享的 LookupKey 中,用于從緩存中查找之前的布局結果。如果Cache Miss,便會執(zhí)行文字布局。
FormattedCharSequence 是一個函數(shù)式接口,表示深度處理的字符串,其接收一個 Sink 得到每個碼點和 Style。但是 Minecraft 會通過?FormattedBidiReorder 將 FormattedText 處理成排序后的?FormattedCharSequence。如果 Minecraft 直接用處理好的?FormattedCharSequence 調(diào)用渲染,那 Modern UI 得不到完整的按照邏輯順序的字符數(shù)組,則不能完成 BiDi 分析和其他布局操作,因此 Modern UI 使用了諸多優(yōu)化來盡可能禁止原版或其他 Mod 創(chuàng)建?FormattedCharSequence。
7. 禁止重排
為了得到 FormattedText 而不是?FormattedCharSequence,Modern UI 創(chuàng)建了一個新類?FormattedTextWrapper 實現(xiàn)?FormattedCharSequence,并持有原始?FormattedText 的引用。如下:
這樣我們可以通過 instanceof 來得到原本的?FormattedText。這里的 accept 只是一個備用方案,如果原版直接把多個?FormattedCharSequence composite 在一起,那么內(nèi)部會調(diào)用accept,這里的實現(xiàn)是不進行重排,直接遍歷?FormattedText 按照邏輯順序傳輸碼點,這種情況下 Modern UI 會使用最慢的布局方式,如原版的聊天欄。
原版有幾個類中都有輸入一個?FormattedText 返回一個?FormattedCharSequence 的方法,因此 Modern UI 直接通過 Mixin 重寫該方法,直接返回?FormattedTextWrapper。這里有?FormattedBidiReorder、Language 和?ClientLanguage。此外 Font 類的?bidirectionalShaping 也直接返回原始字符串。
在處理格式控制碼時,需要根據(jù)碼元得到對應的 ChatFormatting,然后應用到 Style 中,每次遍歷都要大量的這種操作。原版的 ChatFormatting#getByCode(char) 是非常蠢的:
不難看出,Mojang 這段代碼性能不佳。toString()和values()會創(chuàng)建新的對象,toLowerCase()不是特別快,還要遍歷長度為22的數(shù)組。因為格式控制碼全是ASCII字符,Modern UI 做出了改良,直接創(chuàng)建一個長度為128的引用數(shù)組,以大小寫的char code作為索引,直接查表一次得到 ChatFormatting,比原版不知道快到哪里去了:
8. 文字度量與 Minecraft 坐標系
Modern UI 之所以能提供高質(zhì)量渲染,是因為 Modern UI 渲染和布局能1比1精確到像素網(wǎng)格,不像原版一樣直接拉伸放大,1x1變成2x2、變成3x3,依然是像素化的。換句話說,Modern UI 使用的單位是屏幕空間(或者叫設備空間)上的像素,因此在不同GUI比例(界面比例)下,Modern UI 會重新創(chuàng)建文本布局,重新計算字形位置,重新渲染新的、在設備空間下的字形圖像。但 Minecraft GUI 中的坐標可不在設備空間下,無論是2D還是3D世界,Minecraft 的文字都使用相同的單位,因此不同 GUI 比例下僅僅是使用了一個縮放系數(shù)為 GUI 比例正交投影矩陣。所以 Modern UI 在?Minecraft 中使用的坐標,必須在原本的基礎上做該變換的逆變換,我通常稱這個坐標系為 Minecraft GUI 縮放坐標系(Minecraft GUI scaled coordinates),它與屏幕空間相差一個縮放變換,我通常稱這個變換過程為歸一化。
未完待續(xù)
9. SDF 文字渲染
未完待續(xù)
10. 快速數(shù)位替換
未完待續(xù)
11. Unicode 換行算法
未完待續(xù)
12. 高覆蓋率紋理圖冊生成
未完待續(xù)
13. 彩色 Emoji 處理
未完待續(xù)