从零实现C++矩阵变换库:深入理解仿射变换与齐次坐标

1. 项目概述:为什么我们需要亲手实现矩阵变换?

如果你正在学习计算机图形学、机器人学、机器视觉,甚至是游戏开发,那么“矩阵变换”这个词对你来说一定不陌生。它就像一把万能钥匙,能帮你把三维世界里的物体,轻松地摆放到屏幕的二维平面上,或者让一个机器人手臂精准地移动到指定位置。但很多时候,我们只是调用现成的库函数,比如OpenGL里的glTranslatefglRotatef,或者Eigen库里的Transform,对里面那套数学“黑魔法”知其然不知其所以然。一旦遇到需要自定义变换、优化性能,或者排查一些诡异的图形bug时,就会感到束手无策。

这正是我写这个系列文章的初衷。我不打算只给你看一堆枯燥的数学公式,然后告诉你“记住它,用它”。我想做的是,和你一起,用最直接的C++,从零开始,把平移、旋转、缩放这些基础变换的矩阵“造”出来。我们会深入每一个数字的由来,理解它为什么放在矩阵的那个位置,以及多个变换叠加时,矩阵乘法的顺序背后隐藏着什么几何意义。这个过程,就像是亲手搭建乐高,每一块积木(矩阵元素)的位置和功能你都了然于胸。当你完成时,你得到的不仅仅是一段可以运行的代码,更是一种对空间变换的深刻直觉。这种直觉,能让你在未来面对更复杂的四元数、投影矩阵、骨骼动画时,依然能够从容拆解。

2. 核心理论拆解:仿射变换的数学基石

在动手写代码之前,我们必须把地基打牢。计算机图形学中常用的变换,绝大多数都属于“仿射变换”。这是一个非常重要的概念,它保证了变换后,直线还是直线,平行线依然平行,以及线段之间的比例关系不变。简单来说,它不会把正方形变成曲面或者扭成麻花。

2.1 齐次坐标:升维打击的艺术

为什么我们通常使用3x3(二维)或4x4(三维)的矩阵,而不是2x2或3x3?秘密就在于“齐次坐标”。这是一种非常聪明的数学技巧,它通过增加一个维度(通常设为1),将平移这个“加法”操作,统一到了矩阵乘法这个“乘法”框架里。

考虑一个二维点(x, y)。在齐次坐标下,我们把它写成(x, y, 1)。注意这个额外的1,它是关键。对于一个三维点(x, y, z),齐次坐标就是(x, y, z, 1)。向量(比如方向向量)的齐次坐标最后一位则是0,这表示它不受平移影响,这个区别非常重要。

使用齐次坐标后,一个通用的二维仿射变换矩阵是3x3的,三维则是4x4的。它们具有一个标准的分块形式:

对于二维(3x3矩阵):

[ a b tx ] [ c d ty ] [ 0 0 1 ]
  • 左上角2x2子矩阵[a b; c d]:负责线性变换,包括旋转、缩放、错切。
  • 最右侧的列向量[tx; ty]:负责平移变换。
  • 最后一行[0 0 1]:是齐次坐标的“守护者”,保证变换后点的齐次坐标最后一位仍是1。

对于三维(4x4矩阵):

[ a b c tx ] [ d e f ty ] [ g h i tz ] [ 0 0 0 1 ]
  • 左上角3x3子矩阵:负责三维空间中的线性变换(旋转、缩放)。
  • 最右侧的列向量[tx; ty; tz]:负责平移。
  • 最后一行[0 0 0 1]:作用同二维。

这种统一的形式,使得我们可以通过连续的矩阵乘法,将复杂的变换组合成一个单一的矩阵,极大地提升了计算效率和编程的简洁性。

2.2 基础变换矩阵的推导

现在,让我们看看具体的变换矩阵是如何从几何意义推导出来的。我们以二维为例,三维是类似的扩展。

2.2.1 平移变换矩阵

平移是最简单的。将点(x, y)移动到(x+dx, y+dy)。用齐次坐标和矩阵乘法表示:

[ x'] [ 1 0 dx ] [ x ] [ y'] = [ 0 1 dy ] * [ y ] [ 1 ] [ 0 0 1 ] [ 1 ]

计算过程:x' = 1*x + 0*y + dx*1 = x + dxy' = 0*x + 1*y + dy*1 = y + dy。完美匹配。

2.2.2 缩放变换矩阵

以原点为中心,将点(x, y)缩放(sx, sy)倍。变换后为(x*sx, y*sy)

[ x'] [ sx 0 0 ] [ x ] [ y'] = [ 0 sy 0 ] * [ y ] [ 1 ] [ 0 0 1 ] [ 1 ]

计算过程:x' = sx*x + 0*y + 0*1 = sx*xy' = 0*x + sy*y + 0*1 = sy*y

2.2.3 旋转变换矩阵(绕原点)

这是最需要理解的一个。将点(x, y)绕原点逆时针旋转 θ 角度。我们可以用三角函数推导。 假设原点到点的距离为r,初始角度为φ,则有x = r*cosφ,y = r*sinφ。 旋转后,新角度为φ+θ,新坐标为:x' = r*cos(φ+θ) = r*(cosφ cosθ - sinφ sinθ) = x*cosθ - y*sinθy' = r*sin(φ+θ) = r*(sinφ cosθ + cosφ sinθ) = x*sinθ + y*cosθ

因此,旋转矩阵为:

[ x'] [ cosθ -sinθ 0 ] [ x ] [ y'] = [ sinθ cosθ 0 ] * [ y ] [ 1 ] [ 0 0 1 ] [ 1 ]

注意:这个推导基于标准的数学坐标系(y轴向上)。在屏幕坐标系(y轴向下,如Windows GDI/Direct2D)中,顺时针旋转对应正角度,矩阵形式可能需要进行调整(通常是sinθ的符号取反),这点在后续实战中至关重要。

2.2.4 绕任意点旋转

绕任意点(cx, cy)旋转,可以通过“组合变换”来实现,这是矩阵乘法的核心优势之一。其步骤是:

  1. 平移:将旋转中心(cx, cy)平移到原点。变换矩阵为T(-cx, -cy)
  2. 旋转:绕原点旋转 θ 角度。变换矩阵为R(θ)
  3. 平移:将旋转中心移回原位置。变换矩阵为T(cx, cy)

最终的组合变换矩阵为:M = T(cx, cy) * R(θ) * T(-cx, -cy)这里请注意矩阵乘法的顺序:变换是从右向左依次应用的。先应用T(-cx, -cy),然后是R(θ),最后是T(cx, cy)。这个顺序绝对不能错。

3. C++实现:设计一个轻量级矩阵变换库

理论清晰之后,我们开始用C++将其实现。我们的目标是设计一个简单、清晰、易于理解和扩展的矩阵类,不依赖于大型数学库。

3.1 矩阵类的设计与实现

我们将实现一个通用的Matrix4x4类来处理三维变换(二维变换可以视为其特例)。选择4x4是因为它是三维图形学的标准。

// Matrix4x4.h #pragma once #include <array> #include <cmath> // 用于sin, cos等函数 class Matrix4x4 { public: // 数据存储:使用一个一维数组,按行主序(row-major)存储。 // 这对于理解内存布局和后续与OpenGL/DirectX等API交互很重要。 // 元素索引: m[index] 对应矩阵的 [row][col],其中 index = row * 4 + col std::array<float, 16> m; // 构造函数 Matrix4x4(); // 构造单位矩阵 Matrix4x4(const std::array<float, 16>& elements); // 从数组初始化 // 静态方法:创建特定变换矩阵(工厂方法) static Matrix4x4 Identity(); static Matrix4x4 Translate(float tx, float ty, float tz); static Matrix4x4 Scale(float sx, float sy, float sz); static Matrix4x4 RotateX(float angle); // 绕X轴旋转 static Matrix4x4 RotateY(float angle); // 绕Y轴旋转 static Matrix4x4 RotateZ(float angle); // 绕Z轴旋转(对应二维绕原点旋转) static Matrix4x4 RotateAxis(float angle, float ax, float ay, float az); // 绕任意轴旋转(进阶) // 运算符重载 Matrix4x4 operator*(const Matrix4x4& other) const; // 矩阵乘法 // 为了方便,也可以重载 () 运算符来获取元素,但这里我们用Get/Set方法 float Get(int row, int col) const; void Set(int row, int col, float value); // 应用变换到点(齐次坐标,w=1)和向量(齐次坐标,w=0) std::array<float, 3> TransformPoint(const std::array<float, 3>& point) const; std::array<float, 3> TransformVector(const std::array<float, 3>& vector) const; // 实用函数 void Print() const; };
// Matrix4x4.cpp #include "Matrix4x4.h" #include <iostream> Matrix4x4::Matrix4x4() { // 初始化为单位矩阵 m = {1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1}; } Matrix4x4::Matrix4x4(const std::array<float, 16>& elements) : m(elements) {} Matrix4x4 Matrix4x4::Identity() { return Matrix4x4(); } Matrix4x4 Matrix4x4::Translate(float tx, float ty, float tz) { Matrix4x4 mat; mat.m[12] = tx; // 对应第4行第1列 (索引 3*4 + 0) mat.m[13] = ty; // 对应第4行第2列 (索引 3*4 + 1) mat.m[14] = tz; // 对应第4行第3列 (索引 3*4 + 2) // 注意:mat.m[15] 保持为1 return mat; } Matrix4x4 Matrix4x4::Scale(float sx, float sy, float sz) { Matrix4x4 mat; mat.m[0] = sx; // (0,0) mat.m[5] = sy; // (1,1) mat.m[10] = sz; // (2,2) // 其余对角线元素为1,非对角线为0,已在构造函数中设置 return mat; } Matrix4x4 Matrix4x4::RotateZ(float angle) { // 二维旋转就是绕Z轴旋转 float rad = angle * M_PI / 180.0f; // 角度转弧度 float cosA = std::cos(rad); float sinA = std::sin(rad); Matrix4x4 mat; mat.m[0] = cosA; mat.m[1] = -sinA; mat.m[4] = sinA; mat.m[5] = cosA; // Z轴和W轴保持不变 // m[10] = 1, m[15] = 1 已在构造函数设置 return mat; } // 绕X轴和Y轴旋转的矩阵,用于三维 Matrix4x4 Matrix4x4::RotateX(float angle) { float rad = angle * M_PI / 180.0f; float cosA = std::cos(rad); float sinA = std::sin(rad); Matrix4x4 mat; mat.m[5] = cosA; mat.m[6] = -sinA; mat.m[9] = sinA; mat.m[10] = cosA; return mat; } Matrix4x4 Matrix4x4::RotateY(float angle) { float rad = angle * M_PI / 180.0f; float cosA = std::cos(rad); float sinA = std::sin(rad); Matrix4x4 mat; mat.m[0] = cosA; mat.m[2] = sinA; mat.m[8] = -sinA; mat.m[10] = cosA; return mat; } // 矩阵乘法:核心操作 Matrix4x4 Matrix4x4::operator*(const Matrix4x4& other) const { Matrix4x4 result; // 清零结果矩阵,虽然构造函数是单位矩阵,但乘法需要计算 // 更高效的做法是直接计算每个元素,这里为了清晰使用循环 for (int i = 0; i < 4; ++i) { // 行 for (int j = 0; j < 4; ++j) { // 列 float sum = 0.0f; for (int k = 0; k < 4; ++k) { // result(i,j) = sum_{k} this(i,k) * other(k,j) sum += this->Get(i, k) * other.Get(k, j); } result.Set(i, j, sum); } } return result; } float Matrix4x4::Get(int row, int col) const { // 边界检查在实际项目中很重要,这里省略以保持简洁 return m[row * 4 + col]; } void Matrix4x4::Set(int row, int col, float value) { m[row * 4 + col] = value; } // 变换点 (x, y, z, 1) std::array<float, 3> Matrix4x4::TransformPoint(const std::array<float, 3>& point) const { std::array<float, 4> homogPoint = {point[0], point[1], point[2], 1.0f}; std::array<float, 4> result = {0, 0, 0, 0}; for (int i = 0; i < 4; ++i) { for (int j = 0; j < 4; ++j) { result[i] += this->Get(i, j) * homogPoint[j]; } } // 齐次除法 (通常由图形API处理,这里我们假设w=1,且变换后w仍为1或接近1) // 对于仿射变换,w分量变换后仍为1,所以直接取前三个分量 // 对于投影变换,需要做除法 result[i]/result[3],这里暂不处理 return {result[0], result[1], result[2]}; } // 变换向量 (x, y, z, 0) - 不受平移影响 std::array<float, 3> Matrix4x4::TransformVector(const std::array<float, 3>& vector) const { // 注意:这里只使用矩阵的左上角3x3线性部分,或者等价地,用齐次坐标w=0 std::array<float, 4> homogVector = {vector[0], vector[1], vector[2], 0.0f}; std::array<float, 4> result = {0, 0, 0, 0}; for (int i = 0; i < 4; ++i) { for (int j = 0; j < 4; ++j) { result[i] += this->Get(i, j) * homogVector[j]; } } // 对于向量,w分量应为0 return {result[0], result[1], result[2]}; } void Matrix4x4::Print() const { for (int i = 0; i < 4; ++i) { for (int j = 0; j < 4; ++j) { std::cout << Get(i, j) << "\t"; } std::cout << std::endl; } }

3.2 实战演练:组合变换与顺序的重要性

让我们用一个具体的例子来验证我们的库,并深刻理解矩阵乘法的顺序。假设我们有一个点P(1, 0, 0),我们想先把它平移(2, 3, 0),再绕Z轴旋转90度。

// main.cpp #include "Matrix4x4.h" #include <iostream> int main() { // 定义点P std::array<float, 3> point = {1.0f, 0.0f, 0.0f}; std::cout << "原始点 P: (" << point[0] << ", " << point[1] << ", " << point[2] << ")\n"; // 创建平移矩阵 T(2,3,0) Matrix4x4 T = Matrix4x4::Translate(2.0f, 3.0f, 0.0f); std::cout << "\n平移矩阵 T:" << std::endl; T.Print(); // 创建旋转矩阵 Rz(90) Matrix4x4 R = Matrix4x4::RotateZ(90.0f); std::cout << "\n旋转矩阵 Rz(90):" << std::endl; R.Print(); // **顺序1: 先平移,后旋转 (T * R)** // 注意:矩阵乘法从右向左应用,所以 M1 = R * T 表示先T后R。 // 但我们构造组合矩阵时,是 M_combined = R * T。 // 应用时:P' = M_combined * P = (R * T) * P = R * (T * P) // 所以,R * T 这个矩阵代表了“先T后R”的变换。 Matrix4x4 M1 = R * T; // 代表先应用T,再应用R std::cout << "\n组合矩阵 M1 = R * T (代表先平移T,后旋转R):" << std::endl; M1.Print(); auto point1 = M1.TransformPoint(point); std::cout << "变换后点 P1 = M1 * P: (" << point1[0] << ", " << point1[1] << ", " << point1[2] << ")\n"; // **顺序2: 先旋转,后平移 (R * T) 的逆序** // 矩阵 M2 = T * R 代表先R后T Matrix4x4 M2 = T * R; // 代表先应用R,再应用T std::cout << "\n组合矩阵 M2 = T * R (代表先旋转R,后平移T):" << std::endl; M2.Print(); auto point2 = M2.TransformPoint(point); std::cout << "变换后点 P2 = M2 * P: (" << point2[0] << ", " << point2[1] << ", " << point2[2] << ")\n"; // 手动分步验证顺序1 std::cout << "\n--- 手动验证顺序1: 先平移后旋转 ---" << std::endl; auto step1 = T.TransformPoint(point); // 平移后 std::cout << "经过平移 T 后: (" << step1[0] << ", " << step1[1] << ", " << step1[2] << ")\n"; auto step2 = R.TransformPoint(step1); // 再旋转 std::cout << "再经过旋转 R 后: (" << step2[0] << ", " << step2[1] << ", " << step2[2] << ")\n"; std::cout << "这与 P1 一致吗? " << ((std::abs(step2[0]-point1[0])<1e-5 && std::abs(step2[1]-point1[1])<1e-5) ? "是" : "否") << std::endl; // 手动分步验证顺序2 std::cout << "\n--- 手动验证顺序2: 先旋转后平移 ---" << std::endl; auto step1b = R.TransformPoint(point); // 旋转后 std::cout << "经过旋转 R 后: (" << step1b[0] << ", " << step1b[1] << ", " << step1b[2] << ")\n"; auto step2b = T.TransformPoint(step1b); // 再平移 std::cout << "再经过平移 T 后: (" << step2b[0] << ", " << step2b[1] << ", " << step2b[2] << ")\n"; std::cout << "这与 P2 一致吗? " << ((std::abs(step2b[0]-point2[0])<1e-5 && std::abs(step2b[1]-point2[1])<1e-5) ? "是" : "否") << std::endl; return 0; }

编译并运行这段代码(需要链接数学库,例如在g++中使用-lm),你会看到完全不同的结果。P1P2的坐标清晰地展示了变换顺序如何影响最终结果。围绕局部坐标系和世界坐标系的变换差异,其根源就在于此。

3.3 性能优化与工程化考量

我们上面的实现侧重于清晰易懂,但在实际项目中,性能至关重要。这里有几个关键的优化点和工程化建议:

  1. 避免动态内存分配:我们使用了std::array,它在栈上分配,速度远快于std::vectornew
  2. 循环展开:在operator*中,手动展开4x4矩阵乘法的循环可以消除循环开销。编译器优化有时能做到,但手动展开更可靠。
    // 优化后的4x4矩阵乘法示例(部分展开) Matrix4x4 result; const float* a = this->m.data(); const float* b = other.m.data(); float* r = result.m.data(); r[0] = a[0]*b[0] + a[1]*b[4] + a[2]*b[8] + a[3]*b[12]; r[1] = a[0]*b[1] + a[1]*b[5] + a[2]*b[9] + a[3]*b[13]; r[2] = a[0]*b[2] + a[1]*b[6] + a[2]*b[10] + a[3]*b[14]; r[3] = a[0]*b[3] + a[1]*b[7] + a[2]*b[11] + a[3]*b[15]; // ... 以此类推计算所有16个元素
  3. 使用SIMD指令:对于现代CPU(支持SSE, AVX),可以使用 intrinsics 指令一次性对4个浮点数进行操作,性能提升显著。这是专业数学库(如Eigen、GLM)的核心优化手段。
  4. 存储顺序(行主序 vs 列主序):我们采用了行主序。OpenGL默认期望列主序的数据。如果你要为OpenGL提供矩阵,要么在填充时转置,要么使用glUniformMatrix4fv时设置转置标志为GL_TRUE。DirectX数学库(XMMATRIX)也使用行主序。明确你的矩阵存储顺序并与你的图形API对齐,是避免无数坑的关键。
  5. 提供常量引用:对于TransformPoint这类函数,传入参数使用const std::array<float, 3>&避免拷贝。

4. 常见问题与深度避坑指南

在实际使用自己实现的或第三方矩阵库时,会遇到一些典型问题。这里我总结了一份“避坑清单”。

4.1 矩阵乘法顺序混淆

这是新手最常掉进的坑。记住这个核心法则:

  • 数学公式(列向量)v' = M * v。变换矩阵M在左边,列向量v在右边。多个变换连续应用时:v' = M3 * M2 * M1 * v。这意味着先应用M1,然后是M2,最后是M3。即从右向左应用。
  • 代码中的组合:当你写Matrix M = M3 * M2 * M1;时,这个M就代表了“先M1,再M2,最后M3”的复合变换。v' = M * v的结果与依次应用M1, M2, M3相同。
  • 错误检查:当你发现物体旋转和平移的方向不对时,第一反应就应该是检查矩阵乘法的顺序。

4.2 万向节死锁与旋转表示

我们的RotateX/Y/Z函数返回的是绕世界坐标系固定轴旋转的矩阵。当你连续执行RotateY(30) * RotateX(60)时,表示先绕世界X轴转60度,再绕世界Y轴转30度。

然而,在物体自身坐标系(局部坐标系)下进行旋转(比如第一人称相机:偏航Yaw、俯仰Pitch、滚转Roll)时,如果简单地用固定轴欧拉角(如Roll * Pitch * Yaw)组合,就会遇到著名的万向节死锁问题。当Pitch为±90度时,Yaw和Roll的旋转轴重合,丢失一个自由度。

解决方案

  1. 使用四元数(Quaternion):这是解决万向节死锁和平滑插值的最佳方案。四元数用4个数表示旋转,没有奇点。在最终渲染前,再将四元数转换为旋转矩阵。
  2. 使用轴角(Axis-Angle)表示:直接指定一个旋转轴和绕该轴的角度。我们上面提供的RotateAxis静态方法就是基于此。它也没有万向节死锁。
  3. 如果坚持用欧拉角:明确定义旋转顺序(如Yaw-Pitch-Roll),并理解其局限性。避免Pitch接近±90度。

4.3 浮点数精度误差

经过成千上万次矩阵运算后,累积的浮点误差可能导致矩阵不再是正交的(旋转矩阵本应是正交矩阵),或者行列式不为1。这会引起物体缩放、扭曲等诡异现象。

应对策略

  1. 定期正交化:对于纯旋转矩阵,可以定期使用施密特正交化过程或提取四元数再重新归一化的方式,来修正矩阵。
  2. 使用双精度:在关键的计算(如相机视图矩阵构建)中使用double,只在传递给GPU时转换为float
  3. 容忍误差:在比较矩阵是否相等、判断向量是否为零时,使用一个很小的阈值(如1e-6),而不是直接==

4.4 变换的逆与转置

  • 逆矩阵:变换的逆操作。例如,平移(dx, dy, dz)的逆是平移(-dx, -dy, -dz)。旋转矩阵的逆就是它的转置(因为旋转矩阵是正交阵)。M * M_inv = I。求逆运算开销较大,对于仿射变换矩阵(旋转+平移),有快速的解析解,不要直接用通用的高斯消元法。
  • 转置矩阵:行列互换。主要用于将行主序矩阵转换为列主序,或者用于法线变换。

一个重要陷阱:法线变换。如果你用一个包含非均匀缩放的矩阵M去变换一个模型的法线向量,直接使用M是错误的。正确的做法是使用逆转置矩阵(M^{-1})^T。因为法线需要保持与切向垂直,而缩放会破坏这种垂直关系。在只有旋转和均匀缩放的情况下,可以直接用原矩阵变换法线。

4.5 调试与可视化

矩阵计算抽象,出错时难以调试。我常用的方法有:

  1. 打印矩阵:像我们上面实现的Print()函数,在关键步骤后打印出矩阵的值,检查是否为预期的单位阵、平移阵等。
  2. 分步验证:如同上面的示例,将复合变换拆分成单步变换,手动计算或打印中间结果,与复合矩阵的结果对比。
  3. 使用图形调试器:如RenderDoc,可以捕获一帧的渲染状态,查看最终传入GPU的矩阵数据,这是定位渲染问题的终极武器。
  4. 编写单元测试:为每一个变换函数(Translate,RotateZ等)和组合变换编写测试用例,用已知的输入输出验证正确性。这是保证代码长期稳定的基石。

5. 从理论到实战的桥梁:一个简单的软件渲染器示例

为了将所有这些知识串联起来,我强烈建议你尝试一个终极挑战:用我们的矩阵库,在不依赖OpenGL/DirectX的情况下,实现一个极简的“软件渲染器”,将三维空间中的点投影到二维屏幕。

这个挑战会涉及:

  1. 模型变换:用我们的矩阵库摆放一个三维模型(比如一个立方体的8个顶点)。
  2. 视图变换:构造一个“相机”矩阵,将世界坐标转换到相机坐标系。这通常是一个LookAt矩阵,可以通过叉积计算得到。
  3. 投影变换:构造一个透视投影矩阵,将相机视锥体中的3D坐标映射到标准化设备坐标(NDC)。这是另一个4x4矩阵,涉及视场角、宽高比、近远裁剪面等参数。
  4. 视口变换:将NDC的[-1, 1]范围映射到屏幕的[0, width][0, height]

当你成功地将一个三维立方体的顶点,通过投影矩阵 * 视图矩阵 * 模型矩阵这一连串变换,最终画到控制台的字符网格或者一个位图上时,你对整个图形渲染管线的理解将会达到一个全新的层次。你会真正明白,GPU的顶点着色器在幕后为你做了多么核心的工作。

这个软件渲染器不必支持光照、纹理,甚至不需要画线,只要能把顶点变换后的2D坐标算出来并打印,就是巨大的成功。它将是验证你矩阵变换知识掌握程度的试金石。

矩阵变换是连接数学理论与工程实践的桥梁。理解它,不能只停留在调用API。通过亲手实现,你收获的不仅是代码,更是对三维空间的一种直觉。在后续的文章中,我们将探讨更高级的主题,如四元数、矩阵的线性代数本质(特征值、奇异值分解)以及在物理模拟、深度学习中的应用。但无论如何,今天打下的这个坚实基础,将是你未来应对一切复杂空间变换问题的起点。