01. 坐标系与向量
01. 坐标系与向量
本篇位置:模块一 · 第 1 篇 | 前置:无(系列开篇,符号不认识就翻数学速查表) | 预计阅读:35 分钟
你已经在用它了
先看两段你天天打交道的代码。第一段是 Rect(frameworks/native/libs/ui/include/ui/Rect.h,本系列源码摘自 Android 17 分支,不同版本可能略有出入):
inline int32_t getWidth() const {
return right - left;
}
inline int32_t getHeight() const {
return bottom - top;
}
inline bool isEmpty() const {
return (getWidth() <= 0) || (getHeight() <= 0);
}第二段是合成状态里并排放着的四个"空间"(CompositionEngine/include/compositionengine/impl/OutputCompositionState.h,注释是原文):
// The common space for all layers in the layer stack.
ProjectionSpace layerStackSpace;
// Oriented physical display space.
ProjectionSpace orientedDisplaySpace;
// The space of the framebuffer.
ProjectionSpace framebufferSpace;
// The space of the physical display.
ProjectionSpace displaySpace;
// Transformation from layerStackSpace to displaySpace
ui::Transform transform;三个问题:
getHeight()** 为什么是bottom - top而不是top - bottom?** 这暗示了top和bottom谁大谁小,为什么是这样?- 同一个屏幕上的像素,为什么需要四套坐标空间来描述? 一个像素不就在一个地方吗?
Rect** 的left和一句"往右挪 100 像素"里的 100,都是整数,为什么不能混着算?**
这三个问题看起来是 Android 的实现细节,其实是同一件数学事情的三个面:坐标是相对的,而"位置"和"位移"是两种不同的东西。
学完你会什么
- 说清"一个点的坐标"为什么不是这个点的固有属性,以及为什么代码里必须写明坐标属于哪个空间。
- 分清点、向量、位移三者,知道哪些运算合法、哪些运算根本没有意义。
- 用点积做投影、判垂直、判同向背向——不用算角度。
- 用二维叉积判断一个点在线段的哪一侧、一个三角形的绕序、以及它的面积。
- 说清 向下这一个约定,会连带影响哪些结论。
1. 坐标不是点的属性
一个像素在屏幕上就待在那儿,它自己不知道自己的坐标是多少。
坐标是一个答案,而问题是:"从某个原点出发,沿着某几根轴,各走多远?" 换一个原点或换一组轴,同一个像素的答案就变了。
拿第 2 篇会详细讲的 ROT_180 举例:一部 1080×1920 的手机,屏幕转了半圈之后,图层自己坐标系里的点 ,在屏幕坐标系里变成了 。
图:同一个像素,两套数字。左边是图层自己的坐标系,右边是屏幕转 180° 之后的坐标系。像素没动,动的是"从哪儿开始数"。
于是有了本篇第一条结论:
一个坐标值单独拿出来是没有意义的,它必须带着"我属于哪个空间"这条信息。
这正是问题 2 的答案。layerStackSpace、orientedDisplaySpace、framebufferSpace、displaySpace 不是四份冗余数据,它们是四把不同的尺子:
| 空间 | 从哪儿量 |
|---|---|
layerStackSpace | 所有图层共用的逻辑坐标系,方向永远是ROTATION_0 |
orientedDisplaySpace | 物理屏的尺寸,但摆成和上面同一个朝向 |
framebufferSpace | 帧缓冲这块内存自己的坐标 |
displaySpace | 物理屏真正的坐标,内容可能是转过的 |
而那行 // Transformation from layerStackSpace to displaySpace 底下的 ui::Transform transform,就是两把尺子之间的换算规则。第 2 篇整篇都在讲这个换算规则长什么样。
2. Android 的 Y 轴是向下的
数学课本画坐标系,原点在左下角, 朝上。Android 屏幕坐标反过来:原点在左上角, 朝下。
图:同一组数字 ,在两套约定里落在画面的不同位置。
这个约定的来源很朴素——扫描线是从屏幕顶端往下扫的,从左上角开始数最自然。它带来的直接后果就是问题 1 的答案:
屏幕上"上面"的那条边 值更小,"下面"那条边 值更大,所以 top < bottom,高度自然是 bottom - top。同理 left < right,宽度是 right - left。isEmpty() 检查两者是否 ,就是在检查这个矩形有没有被写反或写空。
本系列的坐标约定:Part 1(第 1~3 篇)全部使用屏幕坐标(原点左上、 向下),因为这是 SurfaceFlinger 的世界。到 Part 2 讲 3D 渲染时会切换到 向上的约定——那是另一个世界的规矩,切换的时候会明说。
轴朝向会翻转好几个结论的符号,本篇第 6 节和第 2 篇第 3 节各有一处,遇到时会点明。排查"公式明明对了但方向反了"这类问题,第一个该怀疑的就是它。
3. 点、向量、位移:长得一样,不是一回事
可能是两种完全不同的东西:
- 点(point):一个位置。"这个像素在 、 处。"它依赖原点——挪动原点,数字就变。
- 向量(vector):一个方向加长度。"往右 100、往下 200。"它不依赖原点——把它平移到画面任何地方,它还是同一个向量。
图: 和 是两个点(有确定位置)。橙色箭头 是一个向量,它描述"从 A 到 B 要怎么走"。把这个箭头原样搬到画面左下角(蓝色),它仍然是同一个向量——因为向量只关心方向和长度。
由此得到一组必须记住的规则:
| 运算 | 结果 | 有没有意义 |
|---|---|---|
| 点 − 点 | 向量 | ✅ 从后者指向前者的位移 |
| 点 + 向量 | 点 | ✅ 从这个点出发挪一段 |
| 向量 + 向量 | 向量 | ✅ 两段位移接起来 |
| 向量 × 数 | 向量 | ✅ 方向不变,长度缩放 |
| 点 + 点 | — | ❌没有意义 |
最后一条是问题 3 的答案。Rect 的 right - left 之所以合法,是因为它是位置减位置得到长度;而把两个 left 加起来得到的东西不对应任何几何量——它会随着原点的挪动而变化,是个没有物理含义的数。
这个区分在代码里通常靠命名和类型来维持(
Rectvsui::Size,positionvsoffset)。到第 3 篇你会看到,图形学干脆把它编进了数字本身:点的第四个分量是 1,向量是 0,这样加减法自动就合法或非法了。
4. 向量的基本运算
加减是分量各自加减,几何上是"首尾相接":
数乘只改长度不改方向(乘负数会掉头):
长度(也叫模,写作 )就是勾股定理:
归一化(normalize)是除以自己的长度,得到一个方向不变、长度正好为 1 的向量,记作 :
验算一下: ✓
归一化在图形代码里几乎无处不在——只有长度为 1 的方向向量,才能直接拿去当"纯方向"用(下一节的点积、以及第 4 篇的光照都要求这一点)。
归一化有一个必须防的坑:零向量除以零。
长度为 0 的向量归一化会得到
0/0,结果是NaN。而NaN有个恶劣性质——它参与的任何运算结果还是NaN,会顺着计算链一路污染下去,最后你在很远的地方看到一片黑或者一片乱码,回溯起来极其费劲。零向量是怎么来的?两个重合的点相减、退化成一条线的三角形、被算成 0 的缩放系数。归一化之前先判长度是不是接近 0,是少数几个"极便宜但极容易漏"的防御点。
5. 点积:一个乘法,三种用法
点积(dot product)把两个向量变成一个数。按分量算:
它还有一个更有用的等价形式:
其中 是两个向量的夹角。这两个式子说的是同一件事,但第二个式子解释了它为什么有用。
用法一:求投影长度
当 是单位向量时(),上面的公式退化成 ——这正是 投在 方向上的影子长度。
图:,(屏幕坐标里 表示朝上)。先把 归一化, 就是米色那一段的长度。灰色虚线是从 的端点向 作的垂线。
"把一个向量拆成沿某方向的分量 + 垂直于该方向的分量"是图形学里的高频动作,靠的就是这一条。
用法二:判断朝向(只看符号,不用算角度)
因为 和 恒为正,点积的符号完全由 决定:
| 点积 | 含义 | |
|---|---|---|
| 小于 90° | 大致同向 | |
| 正好 90° | 互相垂直 | |
| 大于 90° | 大致反向 |
这个判断不需要开方、不需要反三角函数,只要几次乘加。"这个面朝不朝着摄像机""这两个方向是不是差不多""这两条边垂不垂直",代码里全用它。
算例:,
负数,所以两者夹角大于 90°,大致反向。
用法三:光照强度
这个用途要等到第 4 篇讲法线才能完整展开,这里先混个眼熟:几乎每个 fragment shader 里都有一行形如 max(dot(N, L), 0.0) 的代码, 是表面法线、 是指向光源的方向,两者都归一化过。它算的是"这个面正对着光的程度"——正对时点积接近 1(最亮),侧着时接近 0(最暗),背着光时为负数、被 max 截断成 0。
看 shader 代码时,dot ** 是出现频率最高的函数之一** ,认出它在算什么,能省掉一大半阅读时间。
6. 二维叉积:判左右、判绕序、算面积
三维的叉积留到第 4 篇(那时候才需要求法线)。二维平面上有一个更简单的版本,它的结果是一个数而不是向量:
它同样有两层含义。
符号:b 在 a 的哪一边
沿着 的方向走, 是偏向你的左手边还是右手边,看这个数的符号就知道。
由此可以判断一个点落在一条有向线段的哪一侧:
图左:线段从 指向 。对任意一点 ,算 ,负号表示 在 前进方向的左手边,正号表示右手边。图右:沿着 走一圈,叉积的符号说明这个三角形的绕序。
具体算一个。,,:
负号,所以 在左手边。
这里又是 轴朝向的坑。 在数学课本的画法( 向上)里,正的叉积对应"逆时针";在屏幕坐标( 向下)里,同一个公式的正号对应画面上的顺时针。
公式本身没变,变的是你看画面的方式。所以任何一份关于"顺时针还是逆时针"的约定,都必须同时说清它用的是哪套坐标系,否则那句话就是没有信息量的。
绝对值:平行四边形的面积
等于以 、 为两条邻边的平行四边形面积;除以 2 就是三角形的面积。
上图右边的三角形:,,
面积 。
这两条含义合起来,就是光栅化的地基:判断一个像素在不在三角形内部,做法正是对三条边各算一次叉积,看三个符号是否一致;而这三个叉积值再除以总面积,就是第 6 篇要讲的重心坐标——顶点的颜色、纹理坐标怎么填满三角形内部,全靠它。背面剔除(判断一个三角形是正面还是反面朝向观察者)同样是看这个符号。
回到源码
问题 1:getHeight()** 为什么是 bottom - top?**
因为 Android 屏幕坐标 向下,画面上方的边 值反而更小,所以 top < bottom。这不是笔误,是坐标系约定的直接后果。同理 isEmpty() 判 <= 0,实际是在检查这个矩形有没有被写反。
问题 2:为什么需要四套坐标空间?
因为坐标是相对的。同一个像素在图层的逻辑坐标系、在摆正后的显示空间、在帧缓冲内存里、在物理屏上,是四组不同的数字。把它们分成四个具名的 ProjectionSpace,等于强迫每个坐标值都带上"我用的是哪把尺子"。ui::Transform transform 就是尺子之间的换算规则。
问题 3:left** 和"挪 100 像素"里的 100,为什么不能混着算?**
前者是位置(点),后者是位移(向量)。位置减位置得到位移(right - left 是宽度,合法);位置加位移得到新位置(合法);但位置加位置不对应任何几何量,随着原点挪动会变,是个没有含义的数。
以后再看到一个坐标值,你应该先问的不是"它是多少",而是"它属于哪个空间、它是位置还是位移"。 图形代码里绝大多数"位置偏了一点点"或者"整个歪掉"的 bug,追到底都是这两个问题里的一个答错了。
自测
1. 一个 Rect 是 {left=100, top=200, right=100, bottom=500},isEmpty() 返回什么?为什么?
返回 true。
getWidth() = right - left = 100 - 100 = 0,满足 getWidth() <= 0,所以是空矩形。它高 300 但宽 0,是一条竖线,盖不住任何像素。
2. 向量 (−3, 4) 的长度是多少?归一化之后是什么?
归一化:。验算 ✓
注意长度永远非负,负分量平方之后就没有负号了。
3. 已知 a·b = 0 且两者都不是零向量,能得出什么结论?
两者互相垂直。
因为 ,两个长度都不为 0,那只能是 ,即 。
补充一句:如果允许零向量,点积为 0 也可能是因为其中一个是零向量——所以判垂直之前要先排除这种情况,这和第 4 节那个归一化的坑是同一类问题。
4. 三角形三个顶点 (0,0)、(4,0)、(0,3),用叉积算它的面积。
,
面积 。
对照直角三角形公式 ✓
5. 同事说"我们约定三角形按逆时针绕序算正面"。这句话缺了什么信息?
缺了用哪套坐标系看。
"逆时针"是一个关于画面的描述,而画面朝向取决于 轴指哪边。同一组顶点数据,在 向上的坐标系里是逆时针,在 向下的屏幕坐标里看就是顺时针。
所以完整的说法必须是"在某某坐标系下按逆时针"。GLES 和 Vulkan 的默认正面绕序、以及 Vulkan 翻转 viewport 之后绕序要不要跟着改,踩的都是这个坑。
小结
| 概念 | 一句话 | 在源码里的样子 |
|---|---|---|
| 坐标是相对的 | 同一个像素在不同空间里数字不同 | layerStackSpace / displaySpace 等四个空间 |
| Android 的 Y 轴 | 原点左上, 向下 | Rect 的 top < bottom |
| 点 vs 向量 | 点是位置,向量是方向+长度 | Rect 的 left vs 一句"挪 100 像素" |
| 合法运算 | 点−点=向量,点+向量=点;点+点没意义 | getWidth() = right - left |
| 长度与归一化 | ;除以长度得单位向量 | 归一化前必须防零向量,否则NaN 扩散 |
| 点积 | ;投影、判垂直、判同向 | shader 里的dot(N, L) |
| 点积符号 | 正=同向,零=垂直,负=反向;不用算角度 | 朝向判断的标准写法 |
| 二维叉积 | ;符号管左右绕序,绝对值管面积 | 光栅化判点在三角形内、背面剔除 |
| 绕序的陷阱 | "顺/逆时针"必须说清是哪套坐标系 | 轴一翻,结论就反 |
延伸阅读
- 3Blue1Brown《线性代数的本质》第 1、7、8 集(向量、点积、叉积):https://www.bilibili.com/video/BV1ys411472E
- GAMES101 Lecture 2:Review of Linear Algebra:https://games-cn.org/intro-graphics/
- Scratchapixel: Points, Vectors and Normals:https://www.scratchapixel.com/lessons/mathematics-physics-for-computer-graphics/geometry/points-vectors-and-normals.html
- AOSP 源码:
frameworks/native/libs/ui/include/ui/Rect.h、CompositionEngine/include/compositionengine/ProjectionSpace.h