02. 顶点:三角形怎么被摆到屏幕上
02. 顶点:三角形怎么被摆到屏幕上
本篇位置:模块二 · 第 2 篇 | 前置:开篇 | 预计阅读:25 分钟
你已经在用它了
合成每个图层之前,这一行把图层自己的变换叠到画布上(libs/renderengine/skia/SkiaRenderEngine.cpp):
// Layers have a local transform that should be applied to them
canvas->concat(getSkM44(layer.geometry.positionTransform).asM33());一行代码,图层就被摆到了屏幕上该在的位置——缩放、平移、90° 旋转全在里面。
三个问题:
- 这行
concat之后,具体是谁、在什么时候、对什么做了乘法? 矩阵总得乘到某个东西上,那个东西是什么? - 你这里是 3×3,游戏里是三个 4×4 连乘(M1 第 4 篇的 MVP)。多出来的是什么? 为什么 2D 合成不需要?
- 完全在屏幕外的图层会被画吗? 是谁在什么时候把它挡掉的?
学完你会什么
- 说清顶点变换是在管线的哪一站、以什么粒度发生的。
- 说清 2D 合成的 3×3 和 3D 渲染的 4×4 差在哪,为什么差这么多。
- 说清顶点着色器除了算位置还输出什么,以及那些数据后来怎么用。
- 说清裁剪和剔除各自在做什么、省掉了什么,以及裁剪为什么反而会让三角形变多。
- 分清你熟的
clipRect和管线里的视锥裁剪不是一回事。
1. 那个乘法,是逐顶点做的
先回答"谁乘谁"。
那行 concat 只是记下了一个矩阵,什么都还没算。真正的乘法发生在 GPU 上,而且发生在管线第一站——顶点着色器里。
上一篇说过,你那个矩形会被拆成 4 个顶点。顶点着色器对每一个顶点各跑一遍,每一遍做的事就是:
4 个顶点,4 次矩阵乘法,各自独立、互不依赖——这正是它能被做成大规模并行硬件的原因。几十万个顶点的场景,GPU 上成百上千个核心同时算,谁也不用等谁。
注意这里的粒度:变换是逐顶点的,不是逐像素的。一个图层不管在屏幕上占多少像素,顶点阶段永远只算 4 次。这是个很重要的性能直觉——顶点阶段的开销取决于几何复杂度,和它占多大屏幕面积无关。(占多大面积影响的是片段阶段,那是第 3 篇往后的事。)
2. 从 3×3 到 4×4:多出来的是什么
你的 positionTransform 是个 3×3 矩阵,处理二维的平移、缩放、旋转(用齐次坐标补的第三维,M1 第 3 篇讲过为什么是 3×3 而不是 2×2)。
游戏里是三个 4×4 连乘:
M1 第 4 篇已经把这三个矩阵各自干什么讲透了,这里只说你多出来的是什么:
| 你的合成 | 3D 渲染 | |
|---|---|---|
| 维度 | 2D + 齐次 = 3×3 | 3D + 齐次 = 4×4 |
| 有没有"相机" | 没有。屏幕就是画布 | 有。V 负责把世界搬到相机眼前 |
| 有没有"远近" | 没有。图层没有深度 | 有。P 把视锥压成标准盒子,近大远小 |
| 变换链长度 | 1 个矩阵 | 3 个矩阵 |
| 之后还有一步除法吗 | 没有 | 有,透视除法(除以 ) |
最本质的差别是那个 。
二维合成里,一个点变换完就是最终位置。三维渲染里,顶点着色器输出的是裁剪空间坐标 ,还要再除以 才得到真正的屏幕位置——这一步叫透视除法,它就是"近大远小"的来源:离得远的点 大,除完之后 被压得更靠近屏幕中心。
所以问题 2 的答案是:多出来的是深度和透视。你的图层没有远近之分,所以整套 和 的机制都用不上,3×3 就够了。
顺带说清一个容易混的顺序:透视除法不是顶点着色器做的,是它之后由固定功能硬件做的。这个顺序很关键——下一节的裁剪必须在除法之前完成,原因见第 5 节。
3. 顶点着色器还输出什么
算位置只是顶点着色器的一半工作。它还负责把要给下游用的数据准备好。
一个典型的顶点着色器输出:
| 输出 | 干什么用 | 谁消费 |
|---|---|---|
| 位置(裁剪空间) | 决定这个顶点在屏幕哪儿 | 固定功能:装配、裁剪、光栅化 |
| UV | 去纹理里取色(第 4 篇) | 片段着色器 |
| 法线 | 算光照(第 7 篇) | 片段着色器 |
| 顶点色 | 直接当颜色或调制用 | 片段着色器 |
除了位置之外的那些,就是上一篇提过的 varying。
关键在于它们的数量对不上。一个三角形只有 3 个顶点,各带一份 UV;而它盖住的可能是几千个像素。光栅化负责把 3 份插成几千份——这是第 3 篇的主题。
这里只需要建立一个意识:顶点着色器写出去的每一个 varying,都会在光栅化阶段被插值一遍,然后逐片段送进片段着色器。varying 越多,光栅化要插的值越多、片段着色器的输入带宽越大。这是移动端优化里一个常被忽略的开销。
4. 图元装配:顶点怎么拼成三角形
顶点算完了,还是一堆散点。图元装配(primitive assembly)负责把它们三个一组拼成三角形。
怎么分组由绘制时指定的图元类型决定,最常见的两种:
- 三角形列表(triangle list):每 3 个顶点一个三角形。6 个顶点 = 2 个三角形。
- 三角形带(triangle strip):每多 1 个顶点就多 1 个三角形。4 个顶点 = 2 个三角形。
一个矩形用 strip 只需要 4 个顶点就能拼出 2 个三角形(共用一条对角边),比 list 的 6 个省 2 个——这就是为什么画 quad 几乎都用 strip。
拼好之后,三个顶点的顺序开始起作用了。
图:同样三个顶点,按 v0→v1→v2 的顺序绕行,可以是逆时针也可以是顺时针。有向面积的符号正好相反:+0.1913 和 −0.1913。
这个顺序叫绕序(winding order)。它本身没有对错,但它给了硬件一个极其便宜的判断依据:算一下有向面积(就是 M1 第 1 篇讲的二维叉积),符号是正是负,就知道这个三角形是正面朝着你还是背面朝着你。
5. 裁剪:为什么反而会让三角形变多
现在回答问题 3。
完全在屏幕外的东西当然不该画。但"不该画"这件事,在管线里分成了两个不同的动作:
整个在外面的——直接丢掉,什么都不用做。
跨在边界上的——不能直接丢(有一部分是可见的),也不能不管(另一部分会浪费光栅化的算力)。得切开。
图左:一个三角形跨过了视锥边界。图中:沿边界切开之后,剩下的是一个 4 个顶点的多边形。图右:GPU 只认三角形,所以这个四边形又被拆回 2 个三角形,才交给光栅化。
这就是裁剪反直觉的地方:它是个"省算力"的操作,却可能让三角形数量变多。一个三角形进去,两个出来。
划算的原因是:光栅化和片段着色的开销和像素数成正比,而裁剪的开销和顶点数成正比。切一刀多出一个三角形(几个顶点的代价),换掉的是屏幕外那一大片区域本来要走一遍光栅化的开销——通常赚得很多。
为什么必须在透视除法之前做?因为除法要除以 ,而相机背后的点 是负的。负数除下去,点会被翻到屏幕的另一侧——一个本该在你背后的顶点,会莫名其妙出现在画面里。必须在除法之前,趁 还带着正确符号的时候,把这些点切掉。
6. 背面剔除:直接省掉一半
裁剪管的是"在不在视野里",还有一个更便宜的判断:这一面朝着我吗?
一个封闭的物体(一个箱子、一个角色),你从任何角度看,都只能看到它朝向你的那一半——背面的三角形全部被前面的挡住了。既然看不见,就没必要走光栅化。
图:一个封闭物体的 4000 个面,按朝向分类。背向观察者的有 2031 个,占 50.8%——差不多正好一半。
怎么判断朝向?就是第 4 节那个绕序。约定"逆时针为正面"之后,硬件对每个三角形算一次有向面积:为正就是正面、保留;为负就是背面、直接丢掉,连光栅化都不进。
一次符号判断,省掉一半的工作量——这是整条管线里性价比最高的优化之一,而且完全免费(有向面积本来就要算,光栅化判定覆盖时还要用,见第 3 篇)。
什么时候要关掉它?当物体不是封闭的时候。一片树叶、一面旗帜、一个平面广告牌——它们只有一层,从背面看应该能看到,剔了就消失了。这类物体要么关掉剔除,要么正反面各画一遍。
顺带一提,合成器里从来不用操心这个:图层永远是正面朝着屏幕的矩形,剔除开不开都一样。
⚠️ 别搞混
你天天写的 canvas->clipRect() 和这一篇讲的裁剪,中文都叫"裁剪",但不是一回事:
canvas->clipRect() / clipRRect() | 视锥裁剪(clipping) | |
|---|---|---|
| 谁做 | Skia,在生成绘制命令时 | GPU 固定功能硬件 |
| 依据 | 你指定的一个矩形区域 | 视锥的 6 个面(3D)或屏幕边界 |
| 粒度 | 限制"往哪片区域画" | 切开跨界的三角形 |
| 目的 | 逻辑上的遮罩 | 避免处理看不见的几何 |
| 会不会改变几何 | 不改,只是限制写入范围 | 会,切出新顶点、新三角形 |
一个是"只许画在这块地方",一个是"把伸出去的部分剪掉"。前者是绘制语义,后者是管线优化。
回到源码
问题 1:concat 之后是谁乘的?
concat 本身只是把矩阵记进画布状态。真正的乘法在 GPU 的顶点着色器里,对这个图层的 4 个顶点各做一次,彼此独立。
粒度很关键:顶点阶段的开销只取决于顶点数量,和图层在屏幕上占多大面积无关。一个铺满全屏的图层和一个小图标,顶点阶段的成本一模一样。
问题 2:3×3 和 4×4 差在哪?
差的是深度和透视。你的图层没有远近,屏幕就是画布,一个矩阵直接把顶点摆到位。3D 渲染要先把世界搬到相机前(V)、再把视锥压成标准盒子(P),输出的是带 的裁剪空间坐标,之后还要除以 才落到屏幕上——那次除法就是"近大远小"。
问题 3:屏幕外的图层谁挡掉的?
分三道关,都在你的代码之外由硬件完成:
- 背面剔除——朝向不对的三角形,按绕序判一次符号就丢(合成场景用不上);
- 裁剪——整个在视锥外的整体丢弃,跨边界的切开,只保留可见部分;
- 光栅化——最后落到屏幕外的片段,本来就不会生成。
这三道关的共同点是:越早丢掉越省。剔除只看一个符号,裁剪只动几个顶点,如果拖到片段阶段才发现"这个像素在屏幕外",前面的插值和着色就全浪费了。这个"尽早丢弃"的思路会在第 5 篇(Early-Z)和第 11 篇(手机 GPU)反复出现。
自测
1. 一个铺满全屏的图层,和一个 48×48 的小图标,顶点阶段的开销差多少?
一样。都是 4 个顶点、4 次矩阵乘法。
顶点阶段的开销只和几何复杂度(顶点数)有关,和覆盖多少像素无关。覆盖面积影响的是光栅化和片段阶段。
这个区分在性能分析时很重要:看到 GPU 忙,先分清是顶点侧还是片段侧的瓶颈——移动端绝大多数情况是后者。
2. 为什么裁剪必须发生在透视除法之前?
因为透视除法要除以 ,而相机背后的点 是负的。
负数除下去,点会被翻到屏幕的另一侧——一个本该在你身后、根本看不见的顶点,会跑到画面里来,画出完全错误的几何。
所以必须趁 还带着正确符号的时候(也就是在裁剪空间里、除法之前)就把这些点切掉。这也是"裁剪空间"这个名字的由来。
3. 裁剪是为了省算力,为什么它反而让三角形变多了?
因为一个跨界的三角形被切开之后,剩下的往往是四边形(图里就是 4 个顶点),而 GPU 只认三角形,所以又被拆成 2 个三角形。
划算的原因是两边的代价不同量级:裁剪的开销和顶点数成正比(多几个顶点),省下的是屏幕外那一大片区域走光栅化和片段着色的开销,和像素数成正比。多一个三角形换掉几万个像素的处理,通常赚很多。
4. 背面剔除靠什么判断?为什么说它几乎免费?
靠绕序——三个顶点在屏幕上的绕行方向。算一次有向面积(二维叉积),符号为正是正面、为负是背面。
说它免费,是因为这个有向面积本来就要算:光栅化判定"像素在不在三角形内"用的边函数就是同一组叉积(第 3 篇)。相当于顺手看一眼符号,就省掉了封闭物体大约一半的三角形。
注意它的前提是物体封闭。树叶、旗帜、广告牌这类单层几何要关掉剔除,否则从背面看就消失了。
5. canvas->clipRect() 和视锥裁剪都叫"裁剪",差别是什么?
一个是"只许画在这块地方",一个是"把伸出去的部分剪掉"。
clipRect 是 Skia 的绘制语义——你指定一块区域,限制后续绘制的写入范围,不改变几何本身。
视锥裁剪是 GPU 固定功能——按视锥的边界真的把三角形切开,产生新顶点、新三角形,目的是不让看不见的几何进入光栅化。
前者是你写的逻辑,后者是硬件的优化。
小结
| 概念 | 一句话 | 要点 |
|---|---|---|
| 顶点变换的粒度 | 逐顶点,各自独立 | 开销只看顶点数,与覆盖面积无关 |
| 3×3 vs 4×4 | 多出来的是深度和透视 | 合成没有远近,用不上 和 |
| 透视除法 | 除以 ,"近大远小"的来源 | 在顶点着色器之后,由硬件做 |
| varying | 顶点着色器给下游准备的数据 | 每个都会被光栅化插值一遍 |
| 图元装配 | 顶点三个一组拼三角形 | strip 比 list 省顶点 |
| 绕序 | 三个顶点的绕行方向 | 有向面积的符号 = 正面还是背面 |
| 裁剪 | 切开跨界的三角形 | 会让三角形变多,但整体划算 |
| 裁剪的时机 | 必须在透视除法之前 | 否则相机背后的点会翻到画面里 |
| 背面剔除 | 按绕序丢掉背向的面 | 封闭物体省掉约一半;几乎免费 |
| 贯穿全书的思路 | 越早丢弃越省 | 第 5 篇 Early-Z、第 11 篇还会遇到 |
延伸阅读
- LearnOpenGL-CN《面剔除》:https://learnopengl-cn.github.io/04 Advanced OpenGL/04 Face culling/
- 《A trip through the Graphics Pipeline》Part 5:Clipping:https://fgiesen.wordpress.com/2011/07/05/a-trip-through-the-graphics-pipeline-2011-part-5/
- GAMES101 Lecture 5:Rasterization 1(含裁剪与剔除):https://games-cn.org/intro-graphics/
- AOSP 源码:
frameworks/native/libs/renderengine/skia/SkiaRenderEngine.cpp