万字长文:我是如何把 Skia 的体积缩小到 1/8 的?
随着移动互联网的一路高歌,越来越多的 APP 不满足系统原生的 UI 体系。开启了各种花式的玩法。
早几年 ReactNative,Weex 等,企图尝试让系统组件可以像浏览器一样动态加载,从而提高发版本的效率。更早几年还有一众通过在系统 Webview 基础上面搭建起来的动态化方案,包括当下诸多的小程序平台等。
Flutter 的发布仿佛给业界带来一丝新的生机,通过 Skia 渲染器完美的保证了在诸多平台渲染的一致性。但也带来专属于 Flutter 本身的一些问题。不过多的讨论关于 Flutter 本身,这里只谈关于 Skia 和矢量渲染技术中属于我的理解。
首先要承认我是彻彻底底的标题党。目前为止我通过官方的编译选项来对 Skia 进行编译裁剪,二进制体积依旧很大。而我的目标就是把 Css 和排版还有渲染器整体做到 1.5MB 以内,如果选用合理小巧的 JS 引擎整体控制在 2MB 到 2.5MB 左右。
所以如何把 Skia 裁剪到 1/8? 答案是重写一个(认真严肃)!!!
目前渲染器已经基本完成,关键节点的性能测试和 Skia 处于同一水平(甚至还要好一些)。但是体积只有 Skia 体积(疯狂裁剪后)的 1/8。大概是多大?580KB(x86-64 下构建的产物,Android Armv7a 下还要小许多)。在这基础上又添加了对复杂文本的排版功能,这部分依赖 Freetype(解析字体文件的开源库)和 Harfbuzz(对字模整形的开源库)还有文本的排版引擎,带上这部分功能体积会大一些(目前为止 Skia 还不具备复杂文本排版能力)。
本文希望可以通过简单通俗的语言和大家探讨渲染器背后的核心技术,如果你也有类似的需求希望能给到足够的启发。
关于矢量渲染器
矢量渲染器作为现代 UI 的核心支撑模块,常常被作为内嵌在操作系统内的图形子系统的一部分提供给上层开发者。比如 Windows 下的 GDI/GDI+/Direct2D,Android 下的 Skia/HWUI (HWUI 对一些复杂多边形的处理依旧依赖 Skia 的软绘制,所以不能算完备的矢量渲染器),MacosX/iOS 内置的 CoreGraphics,Linux 下的 Cario 渲染器。
同样其他的跨平台的库,比如 QT 就自己实现了矢量渲染器,这样可以在不同平台下拥有统一的渲染效果。Flutter,Chrome 和 Android 采用同样的 Skia 渲染器来完成跨平台的能力。所以要想在不同平台拥有比较好的渲染一致性,剥离对系统提供渲染器的依赖是很重要的一步。
同样行业出现了一些类似于包括 NanoVG 在内的一些渲染器,此类渲染器都采用了模板掩码的一种特殊技法(Opengl 红宝书中提到的)来解决复杂多边形的绘制问题,巧妙的规避了复杂的几何运算。但是天下没有免费的午餐,它同样也会带来相对应的性能问题。而且天花板很低,后续优化几乎无从下手。对于游戏这类的场景偶尔需要显示一些面板来说无可厚非,但是对于传统的界面程序还是显得捉襟见肘。
在探讨之前我觉得有必要定义一下“渲染”这个词。这个词在目前互联网技术上面有诸多含义,带有一定的迷惑性。下文所有提及的“渲染”都和计算机图形学中“渲染”拥有同样的含义,指的是把特定的像素填充对应的颜色,以及围绕这一目的的相关算法。
鸟瞰渲染器全貌
时至今日 Google 甚至微软的诸多产品都采用 Skia 作为核心渲染组件。包括但是不限于 Android、Chrome、Flutter、Xamarin 等等。不得不说这是一个伟大的技术产品。
渲染器本身是一个极其复杂的程序,就拿 Skia 来说核心侧有超过 80w 行的代码。如果算上第三方库甚至达到了惊人的 150w 到 200w 行之巨。即使构造的这个轻量的渲染器项目也有超过 25w 行的代码(剔除第三方库,比如图片编解码、字体解析、XML 加载库等等。仍然还有超过 13w 行的核心代码)。
这么复杂的项目我打算从以下几个方向来依次阐述它的核心技术:
渲染抽象层的设计
目前消费电子设备基本都配备了硬件显卡,但是很不凑巧主流设备中的显卡驱动存在较大的差异。因此想要构建完善的硬件加速渲染器,对不同厂商的 GPU 驱动做一层抽象是非常有必要。
其中 API 的差异可以通过对驱动接口的包装来抹平(有点繁琐),编程语言就相对来说非常麻烦了。Skia 内部内置了自己的一套显卡编程语言叫 SKSL,可惜文档比较少。为了达到缩减包体积的效果,设计了一套自己的编程语言。我管它叫 RSL。
设计一套新的 Shader 编程语言
为什么要设计一套新的编程语言和语法?为什么不直接使用 glsl 的语法?
这里有 2 方面的思考(主要为了方便我实现编译器或者叫转化器):
-
在这之前我尝试让 OpenglES 运行在 iOS 的 Metal 之上(小游戏引擎的内核项目),手写过 glsl 的编译器。用来转化到 Metal 的 MSL 语法之上。由于 glsl 的 Spec 文档有点多而且复杂,为了测试编译器的稳定性,抓取了 ShaderToy(一个交流 webgl shader 的网站)1w5 千个左右的 shader 进行测试。语法分析通过率只有 95%多点,总有一些我没有考虑到语法。所以说还是不太稳定,工作量有点大。
-
glsl 规范比较老,缺乏语义的支持。
-
应该还有其他的理由,比如我自己设计的语法。但凡有不太容易实现的部分,我可以选择剔除掉。
我有想过把 RSL 的实现换成微软的 HLSL 实现,这样我就可以不用维护 RSL 的编译器。同时还能享受微软 HLSL 编译器强大的优化能力。实际上我也确实这么做了,但是这样会明显增加包体积(会增加十几 MB,我实在没有办法忍受把这么一个巨无霸塞进去)。所以目前也是只是对内置的 Shader 在离线编译的时候会使用这个编译方案。如果需要动态下发还是保留 RSL 的方式,互相补充。这也是目前能找到的最好最稳定的办法,重点是不增加二进制体积。
几何
从这一节开始涉及渲染器最为核心的灵魂,数学是一切魔法的开始。
三角形和三角剖分
在图形学中三角形的重要性已经没有必要去描述了。它的质性简单,可以让显卡的插值器更加简单高效的工作。试想一下如果显卡支持的不是三角形而是四边形,那么由四个顶点很有可能不共面,这就会出现很复杂的情况了,而三角形则不会出现这个问题。
如果只能渲染三角形那就太单调啦,实际情况中通常需要把多边形剖分成一组三角形的网格,我们管这个网格叫 Mesh。只有得到了 Mesh 后才能提交给 GPU 并行计算。我们管这个过程叫三角剖分,可见三角剖分是联系复杂多边形和三角形之间的桥梁。
复杂的多边形
如何定义多边形?在计算几何里面也是一个比较麻烦的问题,常见的多边形可以是下图这样的。
如上图所示,看起来杂乱无章实际上也是一个合法的多边形。这样的多边形也应该被算法正确的处理,比如三角化,甚至做一些布尔运算。
多边形规范
在图形学中会使用一些关键点序列来描述一个多边形。通常认为沿着关键点序列的顺序行走,左手边代表多边形的内部,相反右手边代表多边形的外部。
这里有一个问题,类似于圆这样的“多边形”应该如何处理?对于曲线需要先进行离散化,一般在处理的过程中会传递一个忍受值,当离散相邻的两个点之间的距离小于忍受值就不在进行细分了。所以曲线可以看成由许许多多的“短”的线段围成的多边形。
时至今日三角剖分算法已经是计算机图形学中一个成熟的话题了。常见的三角剖分算法比如 “Monotone”、“EarCut” 等等。其中 Mapbox(一家专注以地图渲染的公司)就开源了一个袖珍精巧的基于“Earcut”的剖分算法。还有一些剖分算法对生成的三角形的形状具有有一定的约束,比如“符合德劳内的三角剖分算法”。在工业领域当然不希望剖分出来的三角形又长又细。因为这样用做零件加工、存储和运输都十分不方便。
画一条直线
有了前文的理论支持,现在开始面对一些实际的问题吧,比如从画一条直线开始。
利用给定的线宽并沿着直线的法线方向(一条直线有两个法线方向,互为相反向量)进行偏移。就可以得到一个矩形,对这个矩形进行剖分就可以得到由 2 个三角形组成的三角网格。GPU 可以高效绘制这个网格,用以表示这条有宽度的线。
画一条折线
稍微复杂一些,但是原理和绘制一条直线基本类似。
难度体现在如何得到图形的轮廓,也就是如何构建或者优雅的描述这样一个复杂的多边形。就像美术从业人员会用 Photoshop 这类产品做产品的原型设计,大多会用到一个叫“钢笔工具”的绘图功能。它通过使用分段 3 阶贝塞尔曲线来拟合几乎任何图形的外围轮廓。Skia 的 SkPath 类的功能就和“钢笔工具”类似。
具体内部原理并不复杂,实现的难度并不大,这里就不过度对其实现原理加以概述。
多边形减法
不仅仅在多边形定义的过程中会出现多边形区域重叠。回想一下绘制折线的过程需要对折线中的子线段进行法线平移,相当于扩大了线段描述的区域。那么扩大了区域的同时难免会出现多边形区域重叠。而渲染器在执行渲染前需要对多边形进行堆叠的剔除。
布尔运算
在详细描述如果解决多边形堆叠问题前,先来了解一下多边形布尔运算。Skia 中存在对 SkPath 的 OP 操作就是对这个算法的实现。剔除多边形堆叠就可以简化成对多边形“自己”和“自己”求并集。
这是一个古老的数学问题。不仅在图形学中存在,在材料科学等领域都有广泛的使用场景。数学家们为了找到这个问题的完美解,历时长达 50 年,直到 1990 年 Vatti bala 发表的博士毕业论文《A Generic Solution to Polygon Clipping》才标志着这一问题被解决。值得一提的是中国前计算几何协会的会长、浙江大学前数学系的主任、中国计算几何的泰斗梁友栋教授早在 Vatti 这篇论文发表的 10 年之前给出了一个存在约束条件的解,也就是著名的 Liang-Barskey 算法。直到 2009 年时隔 20 年后 Francisco Martı´nez 发表了《A new algorithm for computing Boolean operations on polygons》貌似给出了一个更快的解决方案。此外从行业的经验来看,Boost 库中的多边形运算的子库被认为是错误的实现。
由于《A Generic Solution to Polygon Clipping》这篇文章,后续这类算法和衍生算法被简称为 GPC。
GPC 通用多边形裁剪
得到 GPC 的过程非常坎坷,但是算法本身却十分容易描述。如下简要的描述下算法的整个过程。为了简单,采用下图 2 个凸多边形的并集运算作为样例。
如上图所示,多边形 A(A0,A1,A2,A3,A4)和多边形 B(B0,B1,B2,B3)。首先计算出所有的边的交点,并计算出交点相对多边形的进出性。然后随机选取一个交点沿多边形一边进行“行进”直到遇到下一个交点。交点代表着分叉口,通过“进出性”来选取对应的路线。递归整个过程,直到全部的交点都被处理掉。
最后要解决的是如何快速求解多边形边的交点?尤其当多边形异常复杂的情况下。这个可以通过线扫描配合优先队列的方式来完成。此类算法在诸多论文中都有详细的描述,不做详细研究。
上图只是描述了一个最简单的情况,真实的情况下一般是下面这样:
上图前三个步骤和前文的描述没有任何区别。在最后一步对轮廓进行了一次扩展,上图所描述的多边形简单,如果对任意复杂度的多边形执行这个过程就非常复杂了。这个过程叫“Polygon Offsetting” 具体实现可以参考 《Polygon Offsetting by Computing Winding Numbers》Paper no. DETC2005-85513 pp. 565-575 这篇论文中描述。
在正确进行了外轮廓的拓展后,多边形原本的区域被称为“实部”,扩展出来的部分被称为“虚部”。“实部”依旧按照正常的渲染方式进行,此外从“实部”径向渐变过渡到“虚部”的边缘就可以模拟出抗锯齿的效果。
总结
如前文所述,从分段贝塞尔曲线到二维构形,从多边形堆叠到通用多边形并交差。已经具备了完善的二维建模的能力,也配备了操作二维图形的手术刀。配合三角剖分算法可以完成和 GPU 的对接。
硬件加速的必要性
在计算机显卡还没有普及的年代,UI 依赖的矢量渲染器都是通过 CPU 来实现,CPU 通过线扫描为主的一系列算法来完成像素染色。此后 GPU 得到了广为普及,由于 GPU 的设计天然不适合来进行矢量渲染。故在早期尝试使用 GPU 来加速矢量渲染的尝试中大多得到的都是负优化。
这是由于为了适应现代 GPU 的运算模式,不得不在提交 GPU 之前做很多预处理。包括但不限于 “三角化” “特殊的边缘抗锯齿算法” 等等,但是在软渲染的流程中则简单的多。显卡尽管可以比 CPU 更快速的处理像素,但是像素的成本处理在整个过程中占比不高。随着显卡速度越来越快、屏幕分辨率越来越高、显卡的驱动标准进一步提升,这些问题得到了反转。目前硬件加速矢量渲染已经作为重要的优化手段来使软件界面更加流畅。
裁剪
此裁剪和几何部分的多边形裁剪并不一样。特定场景下渲染器需要对渲染的结果做一些限定,比如上层的渲染逻辑只希望部分绘制的结果被用户看到。就像 Android 中父 View 限定子 View 的绘制不能超过父亲指定的区域一样。
硬件提供的裁剪
几乎所有的显卡都提供了 scissor 的能力。我们在渲染前给显卡前设置一个矩形区域,如果有像素超过这个窗口就会被显卡丢弃掉。
但是显卡自带的裁剪能力要求裁剪的区域必须是一个矩形,并且这个矩形还不能够旋转。如果要裁剪一个奇异形状就无能无力了,这极大限制它的使用场景。但是由硬件直接提供的能力性能非常好,对渲染无侵入。
ClipPath
Skia 中提供了一个裁剪画布的接口 ClipPath,它可以把一个贝塞尔曲线围成的区域作为裁剪的区域。它的功能很强大,几乎可以涵盖全部的裁剪需求,如果不是性能太差就没有必要提及其他的方式了。
如果需要通过 ClipPath 来实现对画布的裁剪,需要先构建一个和画布一样尺寸的掩码图。然后把区域绘制到掩码图上,在后续的绘制过程中要逐像素采样掩码图来判断要不要剔除。当然这个过程非常的繁重,体现在三个方面:
-
需要对区域做预处理,甚至需要做堆叠剔除
-
需要对贝塞尔曲线包围的区域做三角化
-
需要消耗一次额外的绘制操作
正如前文描述的那样,复杂曲线围成的区域处理起来都非常复杂而且慢。
更快的数据结构
为了解决或者说部分解决 贝塞尔曲线的复杂度带来的性能损耗。可以使用多个矩形来表示一个复杂区域,但是要求矩形之间不能存在堆叠。下图描述了如何剔除矩形之间的堆叠,只需要执行一次线扫描算法即可。
比如上图中像素 P 和像素 Q,如果需要保留多边形 (A,B,C,D,E)区域。那么就要找到一个办法来区分像素 P 和 Q 谁落在多边形内,谁落在多边形外。这不是一个很麻烦的事。
上文描述了几个典型的裁剪方式。理论上需要上层业务进行合理的选择,用以达到最佳的性能,而不是无脑的 ClipPath。
和 Skia 的差异
SaveLayers
暂时不觉得需要提供 SaveLayers 这类接口。
CPU Backend 渲染器
只支持硬件加速渲染,尽可能的多支持不同的硬件。不考虑 CPU Backend,对普众的消费电子设备显卡应该和 CPU 一样是标配。不存在 GPU 的设备不在考虑范围内。
为什么体积会小这么多
主要由一下几个因素:
-
我们实现的渲染器不支持 CPU 软渲染策略,一些都是为了硬件加速设计的,更加简洁。
-
作为 Shader 处理的逻辑,核心的编译器相关模块都是离线实现。同样的 Skia 的 SKSL 编译器需要内置到 Skia 的核心逻辑中难以剥离。
-
由于 Skia 的历史非常悠久,存在相当多的 legacy 性质的代码和模块。
总结
至此整个矢量渲染器的核心技术就已经描述完毕。每个部分单独实现难度并不大,但是集合起来构建一个完备的项目还是太“顶”了。如果你也想实现一个类似的渲染器,那么祝你好运。
关于渲染器的未来使用场景
跨平台
这个方向毋庸置疑,未来类似 Flutter 这样的跨多端的会慢慢变成主流(多年前笔者从事 Windows 开发,就是先用系统的渲染器绘制一套 UI 体系,然后在上面做各种业务。Flutter 在移动端算一个新东西,但是业界早有类似的解决方案),那么构建适合自己业务的渲染引擎非常有必要,也是技术实力的体现。
Mini 浏览器
随着前端的敏捷的开发方式慢慢在整个行业得到接受,国内有众多尝试在 系统原生组件或者 Flutter 上构建类似浏览器的逻辑(远不如浏览器那么复杂)。我把这类项目称为 Mini 浏览器项目,那么渲染器可以最大化减少包体积,提高渲染性能。
天下没有免费的午餐,没有哪一个硬件渲染器能够保证,随意使用其 API 就能得到好的性能。Flutter 本身也因为过多使用 Skia 的 ClipPath 和 SaveLayers 导致性能低下。对渲染本身足够理解、对硬件的足够理解,知己知彼才能做到最好。
混合渲染
纵观全文,我都致力于把二维渲染实时转化成由三角形构成的 Mesh。那么 3D 游戏为什么可以在渲染复杂的场景下提供好的性能?原因在于 3D 游戏中使用的 3D 模型大多都是通过 “3DMax” “Maya” “Blender”这里建模工具离线构建的。
从三角形的 Mesh 角度来说,2D 和 3D 没有本质区别,所以可以混合到一起渲染。这会带来一些新的原来不具备的特性。移动设备时至今日运算能力已经很强了,但是交互方式却没有大的变化,随着混合模式下的渲染会带来更加新颖的体验的交互模式。
作者介绍
陈国栋, 主要从事多端跨平台、计算机图形学、编译器、高并发、分布式共识和一致性的研究和实践。曾在腾讯、百度、蘑菇街、爱奇艺等公司任职。
目前就职于字节跳动 Client Infra 团队。我所在的团队对外负责的技术产品有 Lynx(移动端动态化跨平台引擎)、JavaScript 虚拟机、浏览器内核、自渲染框架内核等。欢迎有意向在以上几个方向参与研究和研发的同学加入我们。联系邮箱:chenguodong@bytedance.com,希望从事图形渲染方向或者技术性探讨也可以直接联系我个人微信:breakerror 。
活动推荐
11 月 19-20 日,GMTC 全球大前端技术大会(深圳站)2021 将落地深圳大中华喜来登酒店。会议聚焦于前端、移动、AI 领域,面向各行业前端、移动开发、AI 技术感兴趣的中高端技术人员。
目前会议已邀请到腾讯在线教育部技术负责人 & IMWeb 前端团队负责人王辉、快手平台 Web 开发中心负责人方超(FlashSoft)担任联席主席;TC39 成员 Hax(贺师俊)担任特邀技术顾问;字节跳动 Flutter 基础架构团队负责人袁辉辉、阿里巴巴资深无线技术专家杨青(所为)担任专题出品人。更多精彩内容将持续上线官网,欢迎大家点击阅读原文关注。
门票限时 7 折特惠仅剩 4 天,购票请咨询票务小姐姐小倩:18514549229~
左耳朵耗子直播推荐
8 月 14 日周六,我会连麦左耳朵耗子,和他聊聊创业,以及他的一些技术思考。如果你感兴趣,欢迎预约。
本文文字及图片出自 InfoQ
你也许感兴趣的:
- 【外评】电脑从哪里获取时间?
- 【外评】为什么 Stack Overflow 正在消失?
- Android 全力押注 Rust,Linux 却在原地踏步?谷歌:用 Rust 重写固件太简单了!
- 【外评】哪些开源项目被广泛使用,但仅由少数人维护?
- 【外评】好的重构与不好的重构
- C 语言老将从中作梗,Rust for Linux 项目内讧升级!核心维护者愤然离职:不受尊重、热情被消耗光
- 【外评】代码审查反模式
- 我受够了维护 AI 生成的代码
- 【外评】Linux 桌面市场份额升至 4.45
- 【外评】作为全栈开发人员如何跟上 AI/ML 的发展?
你对本文的反应是: