2018/08/12

逐个像素的艺术 —— 2018 iWeb 峰会演讲(全文)

大家好。很荣幸能够站在这个舞台上。我今天演讲的主题是《逐个像素的艺术》。

先简单作一下自我介绍吧,我来自淘宝网,大家知道阿里巴巴大部分员工都是有花名的,我在公司内的花名是「叶斋」。

我大概从 2013 年开始接触前端图形技术,包括 HTML5 / CSS3 / canvas / webgl 等等,后来我还翻译了一本书,叫《WebGL 编程指南》,如果有学习过原生 WebGL 的,我想应该听说过或者看过这本书吧。

我毕业后,一直在淘宝的前端团队工作,目前负责手机淘宝 App 内部的前端图形渲染基础能力的建设。同时我还维护着一个开源的 WebGL 引擎 G3D。

我的博客,还有邮箱都在这里。如果这次分享之后,大家还有想和我交流的,欢迎给我发邮件。

我读高中的时候,遇到一位音乐老师。他曾经在北朝鲜待过一段时间,所以曾在课上播放过一些北朝鲜的官方大型文艺演出节目的片段。北朝鲜的大型文艺演出有一个特点,就是规模特别大,演出的人很多,但是动作及为整齐,其中最能给我留下深刻印象的,就是「人工大屏」。

什么是「人工大屏」呢?就是有很多很多,估计有上万人,站在类似体育馆看台的台阶上,每个人举一块小牌子,牌子上的颜色各不相同。这样从远处看,这些人就组成了一块显示屏。这块显示屏内容可以跟着演出的节奏进行切换,有时候还可以播放动画,非常整齐。

当时我就想,这演出的背后得,付出多大的代价去训练。因为这种情况,只要有一个人不协调,那就是非常明显的。而要把上万个人训练得没有一个人出错,难度有多高。我觉得,这不是大力出奇迹就能做到的,背后应该有极为严密的组织和极为合理的方法,才能把上万人训练成这样。这是怎么做到的呢?当时我就百思不得其解。直到后来,我学习了 WebGL 和 OpenGL,我才理解到,其实 WebGL 和 OpenGL 所做的事情和训练这么多人是一样的。

回到这次演讲的题目。我演讲的题目是《逐个像素的艺术》,那什么是像素呢?其实刚刚我说的「人工大屏」,其中每个人其实就是一个像素。单个像素的行为是非常简单的,就是去显示单个颜色。但是,当像素的数量膨胀,达到相当规模的时候,就构成了图像,构成了电影、游戏,呈现了丰富多彩的世界。

而 WebGL / OpenGL 的核心能力,就是「逐个像素地生成颜色」。这里有个误区,就是很多同学一听到 WebGL,第一反应就是,这是用来做 3D,做游戏的。其实呢,WebGL 本身跟 3D 没有太大的关系,它只是提供了「逐像素绘图」的能力,3D 相关的逻辑是更上层的 Shader 层和 JavaScript 层处理的。

我想在场的,大部分是前端程序员,我们可以回想一下,在面向 UI 的编程过程中,我们所操作的最小单元是什么?是一个按钮,一个 input 框,对不对?至于这个按钮,这个 input 框里面的结构是什么样的,这是浏览器本身实现的,我们并没有太多办法去改变。如果我们想要获得像素级别的控制,只能依赖 canvas 标签。

那么熟悉 canvas 的同学可能会说了,canvas 2d 绘图上下文也具有「逐像素绘图」的能力,对不对。我们知道,canvas 2d context 有一个 putImageData 方法。通过这个方法,我们可以去构造一个 UInt16Array,然后向里面填入颜色,每四个值表示一个像素,RGBA。确实,这条路行得通,但是太慢了,一个 100 x 100 的 canvas,这个 canvas 其实尺寸已经很小了。这样一个 canvas 每一帧要循环 100 乘以 100,也就是 10000 次。但是 WebGL 不一样,WebGL 可以利用显卡 GPU 加速的能力,每一个像素的颜色由一个单独的 GPU 核心运算。我们知道 GPU 的核的数量非常多,可以并发进行大量的简单的运算。有个段子,说 GPU 和 CPU 有什么区别,它俩的区别就是 1 个博士生和 1000 个小学生的区别。现在要进行 1000 次简单的四则运算,请问是 1 个博士生算得快,还是 1000 个小学生算得快?那肯定是 1000 个小学生算得快,对吧,因为 1000 个小学生可以进行大量的并发运算。

那么我这次演讲的题目,《逐个像素的艺术》,主要呢就是分享我个人学习、实践 WebGL 的一些心得、体会,以及 WebGL 技术在手机淘宝内部的应用情况。

我们看一下 WebGL 在 Google 上的搜索热度趋势。WebGL 差不多 09 年开始有一些草案,11 年标准正式发布,一时间可以说是万众瞩目,可以说那个时候,大家对 WebGL 的期待是很高的,都觉得这技术能够彻底颠覆 Web 的形式。但是呢,随着时间的推移,WebGL 热度,大家可以看到,不仅没升,反而稳中有降。从 11 年到现在这么多年,WebGL 似乎并没有什么里程碑式的产品,更没有像当初大家预期的那样,使 Web 发生翻天覆地的变化。

我们知道前端技术的迭代周期是很快的,逆水行舟,不进则退,如果说 React 的搜索热度趋势和上面这张图差不多的话,那我估计 React 离完蛋不远了。那是不是说,WebGL 就要完蛋了呢?当然不是。否则我也不会站在这里,对吧?

WebGL 并没有完蛋!

首先 WebGL 是一种非常底层的技术,注定了是很慢热的。技术深度比较深,从人才到技术的积累速度都比传统前端技术慢很多,但相对来说技术的过时也会慢很多。

随着无线化的全面到来,PC 由消费产品向生产力工具转变的趋势,我认为这也许会给 WebGL 带来新的机遇。我们知道 WebGL 的功能始终只是 OpenGL ES 的子集,OpenGL ES 又是 OpenGL 的子集,所以 WebGL 在与桌面游戏竞争时,画面效果一直是出于下风的,对游戏来说,画面效果是极为重要的。另一方面,Web 化并未给桌面游戏带来什么太大的好处,为了玩到一个 3A 级大作,比如《巫师3》这种,用户完全有耐心去进行桌面游戏的安装和更新操作,在浏览器里玩对用户来说并没有太大的价值。

但是,生产力工具不一样。随着各类 ERP 系统,特定领域的管理工具(比如家居家装设计,医疗影像等等)从桌面迁移到 Web,WebGL 将会成为这些工具在 Web 上进行图形渲染的唯一选择。而且,生产力工具的图形渲染需求,对画面的要求没有游戏那么高,而迁移到 Web 这件事带来的好处,又会比游戏强很多。

一个明证就是,新一代更加专业的 WebGL 引擎 Babylon.js 的出现,在很多 Web 生产力工具中得到应用。

所以我认为,在今天,WebGL 仍然是值得学习的。

但是我在学习 WebGL 过程中发现,对于没有太多基础的前端工程师而言,入门 WebGL 是一件非常困难的事情。

我总结了一下,对一个初学者来说,有三座大山需要去爬。分别是数学基础,渲染管线和状态机,3D 建模知识。下面我简单介绍下:

首先是数学基础。不知道在座的各位,高中立体几何有没有全部还给老师。我这里给大家出了一道题。左边我画了一个坐标系,有 X 轴 Y 轴和 Z 轴,然后空间中有一个点 A,从 A 点向几个面和轴作垂线。然后角 AOB 是 45 度,角 BOP 是 30 度,,求 A 点的坐标是多少。

(互动环节,第一个回答上来的同学送一个淘公仔,以下解释答案)。

A 点的坐标是 (0.866, 1, 0.5),具体是怎么算的呢?AO 是 1.414,也就是 根号2,角 AOB 是 45 度,所以我们知道 AB 和 OB 的场地都是 1,AB 的长度就是坐标 Y 分量的值。然后角 BOP 是 30 度,我们知道 sin(30°) 是 0.5,那么 BP 就是 0.5,OP 是根号3除以2,也就是 0.866。最后的答案是 (0.866, 1, 0.5)。

下面我们来看另一个问题,就是如何描述一个点在空间中的变换,所谓变换就是指平移,旋转和缩放。举个例子,一个点 P 的坐标是 (x, y, z),平移 (a, b, c),也就是沿 X 轴平移 a,沿 Y 轴平移 b,沿 Z 轴平移 c,求平移后的点的坐标。

这个问题其实很简单,对吧,答案是 (x+a, y+b, z+c),方法也很简单,就是简单的矢量加法,各个分量相加就可以。

但是在 WebGL 中,并不是这样计算的,而是像右边这样,使用一个矩阵来计算。首先我们给点的坐标加上一位 1,得到 (x, y, z, 1),然后使用这样一个矩阵来乘列向量。4 乘 4 的矩阵乘以 4 维列向量的方法,得到一个新的 4 维列向量,每一个值是矩阵的一行乘以列向量得到的单个值。比如,x+a 是这样算出来的:1*a+0*b+0*c+1*1

我们发现,使用这个矩阵乘下来的结果和之前平移加和的结果是一样的,那这个矩阵就称之为平移矩阵,是不是很神奇?那使用矩阵有什么好处呢?其实啊,除了平移,旋转和缩放也可以统一用矩阵来描述,这样就可以将不同的变换统一为一个格式来描述。而且,当多个变换复合的时候,比如先旋转再平移,我们也可以将旋转矩阵和平移矩阵相乘得到的新矩阵,来描述「先旋转再平移」这么一个复合的变换,非常方便。

那使用矩阵还有一个好处,就是矩阵不仅可以变换位置,还可以变换矢量。上面我们给点 P 的坐标多加了一位 1,如果我们多加的一位是 0,会怎样?加一位 0,表示这个 x,y,z 表示的是一个空间中的一个方向,而不是位置,而平移一个方向,方向本身是不会变的,矩阵乘下来,因为这一位是 0 了,所以也不会变。

熟悉 CSS3 的同学,都知道 CSS3 有个 transform 属性,我们可以使用 translate, rotate 等关键字来描述变换,我想大家应该都用得很溜吧。其实仔细看文档的同学,就会发现还可以使用一个叫 matrix 的关键字来描述变换,这里其实就是使用变换矩阵来描述,这种方式更加直接,实际上浏览器内部也是使用矩阵来描述的。

刚刚我举了这俩例子,只是图形学所需要数学知识的冰山一角。当你想要描述旋转的时候,你可能要用到欧拉角,四元数;对模型进行变换的时候,需要考虑是在本地坐标系还是在世界坐标系中进行;相机里也涉及到很多数学知识,包括逆矩阵,不同类型的投影矩阵等等等等。

下面来看第二座大山,渲染管线和 WebGL 状态机。渲染管线这个词不知道大家听说过没有,它是 WebGL 的核心。它提供了一个「友好」的对显卡工作原理的描述,我认为理解了渲染管线,就基本理解了 WebGL 的本质。

这里简单解释一下,举个例子,我要绘制这么一个三角形,那么在 JavaScript 环境中,我们需要构建出一些结构性的数据,来描述这个三角形,其中最重要的就是顶点位置信息,这里三个点 ABC 分别是 1,0,1,3,2,0,0,3,0.5。将这些结构性数据发送给 vertexShader 顶点着色器,顶点着色器是一小段用 glsl 编写的程序,在初始化的时候由 JavaScript 动态编译然后放在渲染管线里面。经过顶点着色器处理,三角形的顶点位置发生了变化,它们会被变换到 CCV 标准立方体中,标准立方体是一个在 X,Y,Z 轴上均在 -1 到 1 的这么一个,边长为 2 的立方体。

然后,数据经过一个名为「光栅化」的过程,三角形就被转化为了屏幕中的一些像素,也就是说这些像素需要被「着色」。

然后再经过片元着色器的处理,片元着色器和顶点着色器一样,也是 JavaScript 初始化的时候放置到渲染管线中的。经过偏远着色器的处理,每一个像素就被「上色」了,最终绘制得到一个红色的三角形。

这里我其实还是讲得比较简单的,还有一些环节(比如深度检测等等)没有涉及。但是呢,如果你能够弄明白渲染管线运行的整个过程,明白 WebGL 的每个 API 究竟是在操作渲染管线的哪个部分,那我觉得啊,你学习 WebGL 已经开始入门了。

除了渲染管线,你还需要去了解 WebGL 状态机。这么说吧,我们可以把 WebGL 看成是一个大机器,这个机器的产出,就是每一帧生成一张图片,那么这个机器运行需要一些原料,机器上面还有很多开关。通常,机器运行的流程是这样的:

在初始化的时候,我们要准备原料:这些原料包括主要包括着色器程序,数据块,纹理等等。原料的准备是比较耗时的;然后,就开始了每一帧的绘制。我们知道一般来说我们 1 秒钟会渲染 24 帧,如果帧数降低的话,就会给人卡的感觉。

在每一帧,我们做的事情包括,第一步,是去把原料和机器连接起来,这个操作的开销是很低的,大家可以理解为把指针指向原料;第二步,是去操作机器上的各种开关,这些开关可能是离散状态的,就像普通的开关一样,也有可能是需要你输入浮点数,可以把他理解为滑块型的开关;第三部,命令机器,开动起来,也就是去调一次 draw call(drawElements 或者 drawArray)。如此三步,就可以绘制场景中的一部分了。一帧有可能会重复多次上述的流程,最后就这一帧的图像就绘制出来了。

可以看到,每一帧的操作其实性能开销是比较低的,初始化时性能开销很高。那么基本上遵循下面这个原则,就可以让 WebGL 应用保持比较在一个比较好的水平了:首先,不要在每一帧中去处理物料本身;其次,尽可能减少每一帧 drawCall 的次数,当然代价就是初始化构建原料时的复杂度可能会增加。

理解了 WebGL 状态机,再去操作 WebGL API,我相信你就会有种成竹在胸,游刃有余的感觉了。

最后一座大山,是 3D 建模知识。我们不可能始终使用 JavaScript 代码来构建渲染场景需要用到的模型。通常,要借助一些三维建模软件,比如 blender,maya,3d max 甚至 sketchup 等等来。我想如果有志于搞 3D 编程的话,至少学会使用一款 3D 建模软件,进而理解 3D 模型的结构。只有理解 3D 模型的结构,才可能进一步选择合适自己的模型格式。

虽然模型格式有多种多样,比如 obj,stl,fbx,gltf,但是最基本的结构是一致的。举个例子:我有一个模型,就是一个正方形,处于 X-Y 轴这个平面上。它的模型里包含了如下这些信息:一是顶点的坐标,毋庸置疑,这里有四个顶点,所以使用长度为 12 的一个数组来表示;二呢,是法线数据,法线是极为重要的,是光照的基础,这里法线是和顶点一一对应的,都是指向 Z 轴正方向,也就是 (0,0,1);三是 UV 数据,因为我们这边贴了一张纹理,UV 数据表示顶点与纹理坐标的对应关系,比如 A 这个点对应在纹理图片中是左上角,所以 A 这个点的 UV 是 (0,0);四是顶点索引数据,因为通常我们是通过绘制三角形来绘制模型,这里的正方形 ABCD 其实是通过绘制两个三角形 ABC 和 ACD 来完成的,索引 [0,1,2,0,2,3] 表示的就是绘制三角形的顺序。当然最后我们还是用到一张图片纹理。以上这些,就是用来表示一个模型最基础的数据结构。

不同的渲染算法,可能会对模型格式提出不同的要求。比如 PBR 渲染(基于物理规则的渲染),就要求模型具有诸如粗糙度,金属度之类的信息。下面是 G3D 渲染的一个经典的 PBR 头盔 demo,这里模型中除了基本的顶点数据,UV 数据,还会使用多张纹理来表示不同的参数,比如基地色;粗糙度/金属度,这里把两个参数合并在了一张纹理的两个通道中;法线,这里法线其实是一个修正量,用来修正跟着顶点的法线,会获得更加细腻的效果;还有发光分量等等。

所以,当我们渲染的物体,材质越来越复杂,算法越来越复杂的时候,实际上模型本身也需要去做出合适的改变。

这样,我们就把三座大山全部过完了。除了这三座大山,在入门 WebGL 的过程中,还有一些比较小的门槛,或者说小山坡吧。不过我相信,如果你连前面三座大山都能克服下来,下面这些问题应该不大会阻碍你了。比如说,我们要去熟悉 WebGL 的古怪风格的 API,做任何事情都要先 bind 一下;我们要去学习 GLSL 的语法,这是编写着色器的语言,不过如果你有一些 C 语言基础的话,这应该不是什么难事;比如,我们需要掌握 WebGL 调试的一些方法,尤其是调试 Shader 的一些方法,在 Shader 里面不能 console.log,通常需要一些特殊的技巧把一些中间结果给输出出来;当然,因为 WebGL 项目所需要管理的规模会越来越大,管理的资源的种类可能也会越来越多,所以对前端工程能力也有一定的要求,至少 Webpack 得用得比较溜,各种资源,还有着色器源码的拼接,内联这些工作,都是可以放在编译时来完成的。

当你翻过了这三座大山,也克服了这些小山,你就算精通 WebGL 了吗?我想再给大家泼一盆冷水,其实这时候,也才算是刚刚入门而已。当你掌握了上面这些知识并能熟练运用,你就算是走进了图形渲染技术这座花园的大门,这座花园里有着数不尽的奇珍异宝,你可以自如地把它们拿过来把玩把玩。你可以去更深入地去阅读书籍和文献,去探寻比如水体该如何实现,宝石该如何实现这些一个一个具体又精妙的问题,然后尝试用你手上的工具,WebGL 来实践。

下面讲一讲图形渲染技术在手机淘宝内部的使用,也就是我们团队所做的一些工作。我们团队,是淘宝技术部的终端架构团队,leader 是大家熟悉的 winter 老师。我们团队现在主要是做两个体系,一个是 UI 体系,一个是图形体系。UI 体系主要就是包含 Weex 相关的事情,图形体系主要是 GCanvas 和 G3D。那我们今天分享的主题是 WebGL,所以重点呢是 G3D,但是说到 G3D,不得不提到 GCanvas,而说到 GCanvas 又不得不提到 Weex,那我们就从 Weex 开始说起。

在过去的一年多里,手机淘宝内发生了一个全面 Weex 化的过程。Weex 是淘系应用,包括手机淘宝,手机天猫,里面的一个基础技术框架,Weex 有点类似于 React Native,通过摒弃 WebView 来提高界面渲染的性能和功能。现在大家在手机淘宝里看到的绝大多数页面都已经是 Weex 的了。

但是全面 Weex 化之后,发现了一个问题,就是 Weex 下没有 Canvas 标签。可是使用 Canvas 的需求仍然存在,尤其是遇到营销活动,比如双十一双十二,这时候需求会很多。这时候,我们团队就发展了一个叫做 GCanvas 的产品,目标呢,就是提供一个符合 W3C 标准的 API 的,Weex 环境下的 Canvas。GCanvas 既支持 2d 绘图,也支持 webgl 绘图。

有了 GCanvas 之后呢,我们就像作一些尝试,在 GCanvas 上来渲染一些 3d 场景,一开始我们试着把 babylon.js 等一些已有的 3D 引擎接进来,后来发现行不通,因为这些引擎都依赖了大量的浏览器 API,比如说它会调用 document.createElement 来创建离屏的 canvas 来进行一些预处理,比如它会发起 ajax 请求去加载资源和图片,等等,它的设计和架构就是为浏览器量身定制的。而我们需要的是一个纯粹的,除了 canvas 以外,不依赖其他浏览器 API 的一个渲染引擎。于是我们就写了重新写了一个 3D 引擎,叫 G3D。

这里我简单地做了一个对比,最左边的是 Web 应用,一般会依赖一个 3D 渲染引擎比如 three.js,babylon.js,由引擎去调用 canvas 的 webgl 绘图上下文,通过浏览器调用系统的图形 API 也就是 OpenGL;而纯 native 的应用,比如手机游戏,一般会用一个大而全的框架,比如 unity,这个框架做的不仅仅是渲染这一层了,还包含很多其他,比如游戏逻辑等等。对于手机淘宝这样的混合型 App,可以依赖一个对标 three.js 的框架,也就是我们的 G3D,然后去调用一个对标 canvas 的 GCanvas,最终还是调用 OpenGL ES。

看一下 G3D 提供的功能,主要分为四块:底层功能是不以 API 的形式开放给开发者的,包括物料管理,状态机管理,场景树,节点变换等等;基础功能包括相机,元几何体,像立方体,球体,圆柱圆锥等等,不同的光照,不同的材质;交互动画,点选拖拽;最上面是插件功能,主要包含对各种模型的解析,包括解析 OBJ 格式的模型,STL 格式的,字体,还有 GLTF 格式的。

这是 G3D 的一些 demo 的演示,分别是透视和正射相机、网格、平行光与点光、元几何体、原始材质 RawMaterial、点选、拖拽、顶点形变动画、PBR 材质、阴影、三种不同的格式模型;最后两个是 PBR 渲染的模型,上面一个是戒指,下面是一个头盔。这个头盔 demo 也是比较经典的用来验证 PBR 渲染的一个案例。

下面是 G3D 的系统架构,这个大家看一看就可以了。值得注意的是,G3D 虽然号称完全不依赖浏览器 API,但是实现过程有一些确实是没办法绕过的,比如 Image 和 Video 这样的对象。这里 G3D 是通过依赖注入的方式来完成解耦,就是初始化 G3D 的时候把这些对象注入进 G3D。

这样我的演讲就结束了,谢谢大家!最后,按照惯例要放这个的,就是说:我们招人。

我们是淘宝技术部,终端架构团队,由 winter 老师亲自带领的队伍,负责维护 Weex / Rax / Binding X / GCanvas / G3D 等多个淘系应用中的基础框架,现在跪求:一个是资深前端工程师/专家,一个是资深无线工程师/专家,如果有图形渲染,图像处理等背景,就更好了!

好的,谢谢大家!

(完)