# 09 | 如何用仿射变换对几何图形进行坐标变换? 你好,我是月影。 前面两节课,我们学习了用向量表示的顶点,来描述曲线和多边形的方法。但是在实际绘制的时候,我们经常需要在画布上绘制许多轮廓相同的图形,难道这也需要我们重复地去计算每个图形的顶点吗?当然不需要。我们只需要创建一个基本的几何轮廓,然后通过**仿射变换**来改变几何图形的位置、形状、大小和角度。 仿射变换是拓扑学和图形学中一个非常重要的基础概念。利用它,我们才能在可视化应用中快速绘制出形态、位置、大小各异的众多几何图形。所以,这一节课,我们就来说一说仿射变换的数学基础和基本操作,它几乎会被应用到我们后面讲到的所有视觉呈现的案例中,所以你一定要掌握。 ## 什么是仿射变换? 仿射变换简单来说就是“线性变换+平移”。实际上在平常的Web开发中,我们也经常会用到仿射变换,比如,对元素设置CSS的transform属性就是对元素应用仿射变换。 再说回到几何图形,针对它的仿射变换具有以下2个性质: 1. 仿射变换前是直线段的,仿射变换后依然是直线段 2. 对两条直线段a和b应用同样的仿射变换,变换前后线段长度比例保持不变 由于仿射变换具有这两个性质,因此对线性空间中的几何图形进行仿射变换,就相当于对它的每个顶点向量进行仿射变换。 那具体怎么操作呢?下面,我们就来详细说说。 ## 向量的平移、旋转与缩放 常见的仿射变换形式包括**平移、旋转、缩放**以及它们的组合。其中,平移变换是最简单的仿射变换。如果我们想让向量P(x0, y0)沿着向量Q(x1, y1)平移,只要将P和Q相加就可以了。 ![](https://static001.geekbang.org/resource/image/3b/b5/3b1afc9f056d4840cb111252bcc179b5.jpeg "平移后的向量p的坐标") **接着是旋转变换**。实际上,旋转变换我们在第5课接触过,当时我们把向量的旋转定义成了如下的函数: ``` class Vector2D { ... rotate(rad) { const c = Math.cos(rad), s = Math.sin(rad); const [x, y] = this; this.x = x * c + y * -s; this.y = x * s + y * c; return this; } } ``` 但是,我们并没有讨论这个函数是怎么来的,那在这里我们通过三角函数来简单推导一下。 ![](https://static001.geekbang.org/resource/image/91/18/914yy44e969c9f75f5413295eef29718.jpg) 假设向量P的长度为r,角度是⍺,现在我们要将它逆时针旋转⍬角,此时新的向量P’的参数方程为: ![](https://static001.geekbang.org/resource/image/73/1b/7383bf5a2529bc6b1687617769b6da1b.jpeg) 然后,因为rcos⍺、rsin⍺是向量P原始的坐标x0、y0,所以,我们可以把坐标代入到上面的公式中,就会得到如下的公式: ![](https://static001.geekbang.org/resource/image/88/f4/88aea77872789dfb0322db466315f5f4.jpeg) 最后,我们再将它写成矩阵形式,就会得到一个旋转矩阵。至于为什么要写成矩阵形式,我后面会讲,这里你先记住这个旋转矩阵的公式就可以了。 ![](https://static001.geekbang.org/resource/image/e5/a2/e52cae6173e2b4056e9aa752a93076a2.jpeg) **然后是缩放变换**。缩放变换也很简单,我们可以直接让向量与标量(标量只有大小、没有方向)相乘。 ![](https://static001.geekbang.org/resource/image/46/72/46d1bb8b507b1f1c9bc14dd6715a4372.jpeg) 对于得到的这个公式,我们也可以把它写成矩阵形式。结果如下: ![](https://static001.geekbang.org/resource/image/2b/a4/2b15e082213c56756686771526afbda4.jpg) 现在,我们就得到了三个基本的仿射变换公式,其中旋转和缩放都可以写成矩阵与向量相乘的形式。这种能写成矩阵与向量相乘形式的变换,就叫做**线性变换**。线性变换除了可以满足仿射变换的2个性质之外,还有2个额外的性质: 1. 线性变换不改变坐标原点(因为如果x0、y0等于零,那么x、y肯定等于0); 2. 线性变换可以叠加,多个线性变换的叠加结果就是将线性变换的矩阵依次相乘,再与原始向量相乘。 那根据线性变换的第2条性质,我们就能总结出一个通用的线性变换公式,即一个原始向量P0经过M1、M2、…Mn 次的线性变换之后得到最终的坐标P。线性变化的叠加是一个非常重要的性质,它是我们对图形进行变换的基础,所以你一定要牢记线性变化的叠加性质。 ![](https://static001.geekbang.org/resource/image/de/c7/deca8b0bce015f249a48a5c6e7dcdfc7.jpeg) 好了,常见的仿射变换形式我们说完了。总的来说,向量的基本仿射变换分为平移、旋转与缩放,其中旋转与缩放属于线性变换,而平移不属于线性变换。基于此,我们可以得到仿射变换的一般表达式,如下图所示: ![](https://static001.geekbang.org/resource/image/c2/57/c275c765a311e4faa2845435f9d54e57.jpg) ## 仿射变换的公式优化 上面这个公式我们还可以改写成矩阵的形式,在改写的公式里,我们实际上是给线性空间增加了一个维度。换句话说,我们用高维度的线性变换表示了低维度的仿射变换! ![](https://static001.geekbang.org/resource/image/53/27/53e134cae1bfced9e5a1bd60df0aed27.jpeg) 这样,我们就将原本n维的坐标转换为了n+1维的坐标。这种n+1维坐标被称为**齐次坐标**,对应的矩阵就被称为**齐次矩阵**。 齐次坐标和齐次矩阵是可视化中非常常用的数学工具,它能让我们用线性变换来表示仿射变换。这样一来,我们就能利用线性变换的叠加性质,来非常方便地进行各种复杂的仿射变换了。落实到共识上,就是把这些变换的矩阵相乘得到一个新的矩阵,再把它乘以原向量。我们在绘制几何图形的时候会经常用到它,所以你要记住这个公式。 ## 仿射变换的应用:实现粒子动画 好了,现在你已经知道了仿射变换的数学基础。那它该怎么应用呢?一个很常见的应用,就是利用它来实现粒子动画。 你可能还不熟悉粒子动画,我们先来快速认识一下它。它能在一定时间内生成许多随机运动的小图形,这类动画通常是通过给人以视觉上的震撼,来达到获取用户关注的效果。在可视化中,粒子动画可以用来表达数据信息本身(比如数量、大小等等),也可以用来修饰界面、吸引用户的关注,它是我们在可视化中经常会用到的一种视觉效果。 在粒子动画的实现过程中,我们通常需要在界面上快速改变一大批图形的大小、形状和位置,所以用图形的仿射变换来实现是一个很好的方法。 为了方便你理解,我们今天只讲一个简单的粒子动画。这个粒子动画的运行效果,是从一个点开始发射出许多颜色、大小、角度各异的三角形,并且通过不断变化它们的位置,产生一种撒花般的视觉效果。 ### 1\. 创建三角形 因为这个粒子动画中主要用到了三角形,所以我们第一步就要创建三角形。**创建三角形一共可以分为两步,第一步,****我们定义三角形的顶点并将数据送到缓冲区**。这一步,你直接看下面创建WebGLProgram的步骤就能理解。如果你还不是很熟悉,我建议你复习一下第4节课的内容。 ``` const position = new Float32Array([ -1, -1, 0, 1, 1, -1, ]); const bufferId = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, bufferId); gl.bufferData(gl.ARRAY_BUFFER, position, gl.STATIC_DRAW); const vPosition = gl.getAttribLocation(program, 'position'); gl.vertexAttribPointer(vPosition, 2, gl.FLOAT, false, 0, 0); gl.enableVertexAttribArray(vPosition); ``` **第二步,我们实现一个创建随机三角形属性的函数**。具体来说就是,利用randomTriangles随机创建一个三角形的信息,其中的参数包括颜色u\_color、初始旋转角度u\_rotation、初始大小u\_scale、初始时间u\_time、动画持续时间u\_diration、运动方向u\_dir和创建时间startTime。除了startTime之外的数据,我们都需要传给shader去处理。 ``` function randomTriangles() { const u_color = [Math.random(), Math.random(), Math.random(), 1.0]; // 随机颜色 const u_rotation = Math.random() * Math.PI; // 初始旋转角度 const u_scale = Math.random() * 0.05 + 0.03; // 初始大小 const u_time = 0; const u_duration = 3.0; // 持续3秒钟 const rad = Math.random() * Math.PI * 2; const u_dir = [Math.cos(rad), Math.sin(rad)]; // 运动方向 const startTime = performance.now(); return {u_color, u_rotation, u_scale, u_time, u_duration, u_dir, startTime}; } ``` ### 2\. 设置uniform变量 通过前面的代码,我们已经将三角形顶点信息传入缓冲区。我们知道,在WebGL的shader中,顶点相关的变量可以用attribute声明。但是,我们现在要把u\_color、u\_rotation等一系列变量也传到shader中,这些变量与三角形具体顶点无关,它们是一些固定的值。这时候,我们就要用到shader的另一种变量声明,也就是uniform来声明。 那它们有什么区别呢?首先,attribute变量是对应于顶点的。也就是说,几何图形有几个顶点就要提供几份attribute数据。并且,attribute变量只能在顶点着色器中使用,如果要在片元着色器中使用,需要我们通过varying变量将它传给片元着色器才行。这样一来,片元着色器中获取的实际值,就是经过顶点线性插值的。 而uniform声明的变量不同,uniform声明的变量和其他语言中的常量一样,我们赋给unform变量的值在shader执行的过程中不可改变。而且一个变量的值是唯一的,不随顶点变化。**uniform变量既可以在顶点着色器中使用,也可以在片元着色器中使用。** 在WebGL中,我们可以通过 gl.uniformXXX(loc, u\_color); 的方法将数据传给shader的uniform变量。其中,XXX是我们随着数据类型不同取得不同的名字。我在下面列举了一些比较常用的,你可以看看: * gl.uniform1f传入一个浮点数,对应的uniform变量的类型为float * gl.uniform4f传入四个浮点数,对应的uniform变量类型为float\[4\] * gl.uniform3fv传入一个三维向量,对应的uniform变量类型为vec3 * gl.uniformMatrix4fv传入一个4x4的矩阵,对应的uniform变量类型为mat4 今天,关于WebGL的uniform的设置,我们只需要知道这个最常用的方法就可以了,更详细的设置信息,你可以参考[MDN官方文档](https://developer.mozilla.org/en-US/docs/Web/API/WebGLRenderingContext/uniform)。 接下来,我们实现这个函数,将随机三角形信息传给shader里的uniform变量。代码如下: ``` function setUniforms(gl, {u_color, u_rotation, u_scale, u_time, u_duration, u_dir}) { // gl.getUniformLocation 拿到uniform变量的指针 let loc = gl.getUniformLocation(program, 'u_color'); // 将数据传给 unfirom 变量的地址 gl.uniform4fv(loc, u_color); loc = gl.getUniformLocation(program, 'u_rotation'); gl.uniform1f(loc, u_rotation); loc = gl.getUniformLocation(program, 'u_scale'); gl.uniform1f(loc, u_scale); loc = gl.getUniformLocation(program, 'u_time'); gl.uniform1f(loc, u_time); loc = gl.getUniformLocation(program, 'u_duration'); gl.uniform1f(loc, u_duration); loc = gl.getUniformLocation(program, 'u_dir'); gl.uniform2fv(loc, u_dir); } ``` ### 3\. 用requestAnimationFrame实现动画 然后,我们使用requestAnimationFrame实现动画。具体的方法就是,我们在update方法中每次新建数个随机三角形,然后依次修改所有三角形的u\_time属性,通过setUniforms方法将修改的属性更新到shader变量中。这样,我们就可以在shader中读取变量的值进行处理了。代码如下: ``` let triangles = []; function update() { for(let i = 0; i < 5 * Math.random(); i++) { triangles.push(randomTriangles()); } gl.clear(gl.COLOR_BUFFER_BIT); // 对每个三角形重新设置u_time triangles.forEach((triangle) => { triangle.u_time = (performance.now() - triangle.startTime) / 1000; setUniforms(gl, triangle); gl.drawArrays(gl.TRIANGLES, 0, position.length / 2); }); // 移除已经结束动画的三角形 triangles = triangles.filter((triangle) => { return triangle.u_time <= triangle.u_duration; }); requestAnimationFrame(update); } requestAnimationFrame(update); ``` 我们再回过头来看最终要实现的效果。你会发现,所有的三角形,都是由小变大朝着特定的方向旋转。那想要实现这个效果,我们就需要用到前面讲过的仿射变换,在顶点着色器中进行矩阵运算。 在这一步中,顶点着色器中的glsl代码最关键,我们先来看一下这个代码是怎么写的。 ``` attribute vec2 position; uniform float u_rotation; uniform float u_time; uniform float u_duration; uniform float u_scale; uniform vec2 u_dir; varying float vP; void main() { float p = min(1.0, u_time / u_duration); float rad = u_rotation + 3.14 * 10.0 * p; float scale = u_scale * p * (2.0 - p); vec2 offset = 2.0 * u_dir * p * p; mat3 translateMatrix = mat3( 1.0, 0.0, 0.0, 0.0, 1.0, 0.0, offset.x, offset.y, 1.0 ); mat3 rotateMatrix = mat3( cos(rad), sin(rad), 0.0, -sin(rad), cos(rad), 0.0, 0.0, 0.0, 1.0 ); mat3 scaleMatrix = mat3( scale, 0.0, 0.0, 0.0, scale, 0.0, 0.0, 0.0, 1.0 ); gl_PointSize = 1.0; vec3 pos = translateMatrix * rotateMatrix * scaleMatrix * vec3(position, 1.0); gl_Position = vec4(pos, 1.0); vP = p; } ``` 其中有几个关键参数,你可能还比较陌生,我来分别介绍一下。 首先,我们定义的p是当前动画进度,它的值是u\_time / u\_duration,取值区间从0到1。rad是旋转角度,它的值是初始角度u\_rotation加上10π,表示在动画过程中它会绕自身旋转5周。 其次,scale是缩放比例,它的值是初始缩放比例乘以一个系数,这个系数是p \* (2.0 - p),在我们后面讨论动画的时候你会知道,p \* (2.0 - p)是一个缓动函数,在这里我们只需要知道,它的作用是让scale的变化量随着时间推移逐渐减小就可以了。 最后,offset是一个二维向量,它是初始值u\_dir与 2.0 \* p \* p 的乘积,因为u\_dir是个单位向量,这里的2.0表示它的最大移动距离为 2,p \* p也是一个缓动函数,作用是让位移的变化量随着时间增加而增大。 定义完这些参数以后,我们得到三个齐次矩阵:translateMatrix是偏移矩阵,rotateMatrix是旋转矩阵,scaleMatrix是缩放矩阵。我们将pos的值设置为这三个矩阵与position的乘积,这样就完成对顶点的线性变换,呈现出来的效果也就是三角形会向着特定的方向旋转、移动和缩放。 ### 4\. 在片元着色器中着色 最后,我们在片元着色器中对这些三角形着色。我们将p也就是动画进度,从顶点着色器通过变量varying vP传给片元着色器,然后在片元着色器中让alpha值随着vP值变化,这样就能同时实现粒子的淡出效果了。 片元着色器中的代码如下: ``` precision mediump float; uniform vec4 u_color; varying float vP; void main() { gl_FragColor.xyz = u_color.xyz; gl_FragColor.a = (1.0 - vP) * u_color.a; } ``` 到这里,我们就用仿射变换实现了一个有趣的粒子动画。 ## CSS的仿射变换 既然我们讲了仿射变换,这里还是要再提一下CSS中我们常用的属性transform。 ``` div.block { transform: rotate(30deg) translate(100px,50px) scale(1.5); } ``` CSS中的transform是一个很强大的属性,它的作用其实也是对元素进行仿射变换。 它不仅支持translate、rotate、scale等值,还支持matrix。CSS的matrix是一个简写的齐次矩阵,因为它省略了3阶齐次矩阵第三行的0, 0, 1值,所以它 只有6个值。 transform在CSS中变换元素的方法,我们作为前端工程师都比较熟悉了。但你知道怎么优化它来提高性能吗?下面,我就重点来说说这一点。 结合上面介绍的齐次矩阵变换的原理,我们可以对CSS的transform属性进行压缩。举个例子,我们可以这么定义CSS transform,代码如下: ``` div.block { transform: rotate(30deg) translate(100px,50px) scale(1.5); } ``` 也就是我们先旋转30度,然后平移100px、50px,最后再放大1.5倍。实际上相当于我们做了如下变换: ![](https://static001.geekbang.org/resource/image/5e/98/5e18daef0ff059498804419b704c6a98.jpeg) 这里我就不再自己写矩阵乘法的库了,我们用一个向量矩阵运算的数学库math,它几乎包含了所有图形学需要用到的数学方法,我们在后面课程中也会经常用到它,你可以参考[GitHub仓库](https://github.com/akira-cn/graphics/tree/master/common/lib/math)先了解一下它。 我们简单算一下三个矩阵相乘,代码如下: ``` import {multiply} from 'common/lib/math/functions/mat3fun.js'; const rad = Math.PI / 6; const a = [ Math.cos(rad), -Math.sin(rad), 0, Math.sin(rad), Math.cos(rad), 0, 0, 0, 1 ]; const b = [ 1, 0, 100, 0, 1, 50, 0, 0, 1 ]; const c = [ 1.5, 0, 0, 0, 1.5, 0, 0, 0, 1 ]; const res = [a, b, c].reduce((a, b) => { return multiply([], b, a); }); console.log(res); /* [1.299038105676658, -0.7499999999999999, 61.60254037844388, 0.7499999999999999, 1.299038105676658, 93.30127018922192, 0, 0, 1] */ ``` 所以呢,我们最终就可以将上面的transform用一个矩阵表示: ``` div.block { transform: matrix(1.3,0.75,-0.75,1.3,61.6,93.3); } ``` 这样的transform效果和之前rotate、translate和scale分开写的效果是一样的,但是字符数更少,所以能减小CSS文件的大小。 那在我们介绍完仿射变换之后,你是不是对CSS transform的理解也更深了呢?没错,不光是transform,在我们之后的学习中,你也可以多想想,还有哪些内容在CSS中也有相似的作用,是不是也能利用在可视化中学到的知识来优化性能。 ## 要点总结 这一节课我们介绍了用向量和矩阵运算来改变几何图形的形状、大小和位置。其中,向量的平移、旋转和缩放都属于仿射变换,而仿射变换具有2个性质: 1. 变换前是直线段的,变换后依然是直线段 2. 对两条直线段a和b应用同样的仿射变换,变换前后线段长度比例保持不变 那仿射变换中的旋转和缩放又属于线性变换,而线性变换在仿射变换性质的基础上还有2个额外的性质: 1. 线性变换不改变坐标原点(因为如果x0、y0等于零,那么x、y肯定等于0) 2. 线性变换可以叠加,多个线性变换的叠加结果就是将线性变换的矩阵依次相乘,再与向量相乘 通过齐次坐标和齐次矩阵,我们可以将平移这样的非线性仿射变换用更高维度的线性变换来表示。这么做的目的是让我们能够将仿射变换的组合简化为矩阵乘法运算。 到这里,数学基础篇的内容我们就学完了。在这一篇的开头,我们说了要总结出一个通用的基础数学绘图体系,这样才不至于陷入细节里。所以啊,我总结了一个简单的知识脑图,把我们在数学篇里讲过的数学知识汇总到了一起,它肯定不会是一个非常完整的数学绘图体系,但是对我们之后的学习来说,已经足够用了。 ![](https://static001.geekbang.org/resource/image/bf/7c/bfdd8c7f5f15e5b703128cdaf419f07c.jpg) 最后呢,我还想再啰嗦几句。图形学作为可视化的基础,是一门很深的学问。它牵涉的数学内容非常多,包括线性代数、几何、微积分和概率统计等等。那这门课里我们所介绍的数学知识,其实还都只是一些入门知识。 那如果你对图形学本身很感兴趣,想要深入学习它在其他领域,比如游戏、视频、AR/VR等领域的应用,这里我推荐你一些深入学习的资料。 1. [3Blue1Brown的数学和图形学基础课程](https://www.youtube.com/watch?v=fNk_zzaMoSs&list=PLZHQObOWTQDPD3MizzM2xVFitgF8hE_ab) 讲得深入浅出,是非常棒的入门教程。 2. [《Fundamentals of Computer Graphics》](https://book.douban.com/subject/26868819/)这本书是图形学入门的经典教材。 ## 小试牛刀 1. 在实现粒子动画的时候,我们让translateMatrix \* rotateMatrix \* scaleMatrix,这三个矩阵按这样的顺序相乘。那如果我们颠倒它们的相乘次序,把roateMatrix放到translateMatrix前面,或者把scaleMatrix放到translateMatrix前面,会产生什么样的结果呢?为什么呢?你可以思考一下,然后从GitHub上fork代码,动手试一试。 2. 我们知道,CSS的transform除了translate、rotate和scale变换以外,还有skew变换。skew变换是一种沿着轴向的扭曲变换,它也属于一种线性变换,它的变换矩阵是: ![](https://static001.geekbang.org/resource/image/b2/44/b265fbd6719e6785c9d0da9364a91f44.jpeg) 你可以使用这个矩阵,给我们的粒子动画加上随机的扭曲效果吗? 3. 因为齐次坐标和齐次矩阵的概念,可以从二维一直推广到N维,而且CSS的transform还支持3D变换。那你可以用齐次矩阵的原理对CSS属性的3D变换应用matrix3d,实现出有趣的3D变换效果吗?(💡小提示:要支持3维的齐次坐标,需要4维齐次矩阵)? 欢迎在留言区和我讨论,分享你的答案和思考,也欢迎你把这节课分享给你的朋友,我们下节课见! * * * ## 源码 \[1\]粒子动画的[完整代码](https://github.com/akira-cn/graphics/tree/master/webgl_particles) \[2\]矩阵运算数学库的[完整代码](https://github.com/akira-cn/graphics/tree/master/common/lib/math) ## 推荐阅读 \[1\]WebGL的uniform变量设置[官方文档](https://developer.mozilla.org/en-US/docs/Web/API/WebGLRenderingContext/uniform) \[2\][3Blue1Brown的数学和图形学基础课程](https://www.youtube.com/watch?v=fNk_zzaMoSs&list=PLZHQObOWTQDPD3MizzM2xVFitgF8hE_ab) \[3\]图形学入门经典教材[《Fundamentals of Computer Graphics》](https://book.douban.com/subject/26868819/)