☰
Games101作业0全解析:Eigen矩阵变换与CMake环境配置避坑指南
2026/10/29 18:07:44 网站建设 项目流程

简介:一份面向 GAMES101《计算机图形学与混合现实研讨会》课程学生的作业 0 说明文档,由闫令琪老师主讲,内容聚焦虚拟机的安装、配置与作业环境搭建。文档从 VirtualBox 下载安装讲起,分别给出 Windows、macOS、Linux 系统的安装指引,接着说明虚拟硬盘文件的获取与挂载、Guest Additions 增强功能的两种安装方式,以及作业框架从主机导入虚拟机并使用 Visual Studio Code 编辑的方法;同时还梳理了作业框架中的 C++ 基础语法、常见编译错误类型和 Eigen 库使用注意事项。对于初次接触虚拟机环境的学生,文档还列出了 Ubuntu 系统密码、拖放功能设置和命令行补救方案,可直接按步骤操作。资源包含 1 个 PDF 文件,大小约 874KB,适合正在学习 GAMES101 或需要快速熟悉课程虚拟机流程的初学者。已有 433 人浏览学习,照文档执行即可获得统一完善的开发环境,并顺利完成作业 0 的提交。

1. Games101 作业0 是什么:一次“把数学变成代码”的最小试验

第一次打开作业0 的说明 PDF,多数人的直觉是“这不过是个 hello world”。但把点 P(2,1) 绕原点旋转 45 度、再平移 (1,2),用 C++ 和 Eigen 输出结果坐标,背后考的是三件事:能不能装好图形学课程的 C++ 工具链、能不能读懂齐次坐标下的矩阵乘法、能不能把线性代数写成 Eigen。作业0 的全部内容就是这几行变换,但它决定了作业1 的投影矩阵、作业2 的光栅化能不能顺利起步。这篇文章会从环境配置、代码实现、验证提交和常见踩坑四个角度把它过透,适合第一次接触 Games101 或者卡在环境配置里的读者。

2. 环境准备:先把 Eigen 和 CMake 在本地跑通

2.1 软件栈选型:为什么是 C++ 11 + CMake + Eigen 而不是 Python

Games101 的讲义代码默认是 C++,从作业0 的顶点变换到作业3 的着色器再到作业7 的光线求交,官方框架都围绕 C++ 展开。作业0 虽然只算一个点,但它的真实目的是让你在写后续作业前,把编译器、构建工具和第三方库这一整套环境调通。用 Python 的 numpy 当然能算出同样的坐标,但后续作业没有 Python 版本的原生框架,提前用 C++ 跑通流程,后面才不会被环境问题打断思路。

为什么选 Eigen 而不自己手写矩阵?Eigen 是 header-only 的模板库,所有实现都在头文件里,编译期展开,不需要链接单独的 .so 或 .a。它还提供了矩阵求逆、特征分解、四元数等作业7 会用到的高级功能。作业0 只需要 Matrix3f 和 Vector3f,但选 Eigen 的意义在于后续每个作业都会复用同一套 API,作业1 的投影矩阵用 Matrix4f,作业4 的贝塞尔曲线用 Vector2f,接口风格完全一致。

构建工具用 CMake 是为了把“编译哪几个文件、去哪里找头文件、生成什么可执行文件”写进一份 CMakeLists.txt。相比每次手敲 g++ 命令,CMake 能在后续作业里保持完全一致的构建流程。我一般会在每个作业目录里放同一份模板,只改文件名和可执行文件名,这样从作业0 到作业8 的编译命令都是 mkdir build、cmake ..、make,不会因为换了目录就忘了参数。

2.2 在 Ubuntu 上用三条命令装齐依赖

常见做法是用 apt 直接安装,GCC、CMake 和 Eigen 三个包一次装齐。执行下面命令前先更新索引,避免 apt 把几十天前的缓存当真:

sudo apt update sudo apt install -y g++ cmake sudo apt install -y libeigen3-dev

第一行的 update 会拉取软件源的最新索引;第二行装的是编译器和构建工具,g++ 负责把 C++ 源码编译成可执行文件,cmake 负责读 CMakeLists.txt 生成 Makefile;第三行装的 libeigen3-dev 是 Eigen 的开发包,包含全部头文件和 CMake 配置文件。Ubuntu 20.04 上这个包对应 Eigen 3.3.7,22.04 上对应 3.4.x,两个版本的 API 都足够覆盖作业0 用到的功能,不必特意指定版本。

装完后先确认头文件确实就位:

ls /usr/include/eigen3/Eigen/Dense

看到这个文件存在,说明 Eigen 的头文件已经装好。注意到路径是 /usr/include/eigen3/Eigen/Dense:外层是 eigen3,内层才是 Eigen 的命名目录。这个层级关系是后面第 5 章头文件报错的根源,建议现在就先记下来。

Windows 上稍有差别。最省事的做法是下载 Eigen 官方 zip 包,解压到工程的 third_party/eigen3 目录,然后在 CMakeLists 里用 target_include_directories 把这个路径传给编译器。后面 2.3 会给出具体写法。不要试图把 Eigen 塞进系统目录,那样每次换电脑都要重装一次,工程目录里自带第三方库反而更可移植。

2.3 最小 CMake 工程:把 include 路径和 target 链接一次说清

建立一个作业目录 assignment0,里面放两个文件:main.cpp 和 CMakeLists.txt。main.cpp 先写一个能编译的最小程序,比如只输出一个向量:

#include <Eigen/Dense> #include <iostream> int main() { Eigen::Vector3f p(1.0f, 2.0f, 3.0f); std::cout << p.transpose() << std::endl; return 0; }

这里用 #include <Eigen/Dense> 引入整个 Eigen 的稠密矩阵模块,Vector3f 是三个 float 组成的向量,transpose() 把它打印成一行。暂时不需要更多逻辑,先确认编译链路是通的。

同目录下的 CMakeLists.txt:

cmake_minimum_required(VERSION 3.10) project(Games101_Assignment0) set(CMAKE_CXX_STANDARD 11) set(CMAKE_CXX_STANDARD_REQUIRED ON) find_package(Eigen3 REQUIRED) add_executable(assignment0 main.cpp) target_link_libraries(assignment0 Eigen3::Eigen)

这段配置的逻辑是:find_package(Eigen3 REQUIRED) 会寻找 libeigen3-dev 自带的 Eigen3Config.cmake,找到后定义一个名为 Eigen3::Eigen 的导入目标;target_link_libraries 把这个目标接到 assignment0 上,CMake 会自动把 -I/usr/include/eigen3 加进编译参数。这个“链接”不产生任何二进制依赖,只是把头文件搜索路径传过去,所以它本质上是接口传递,而不是传统意义的链接库。

接着在命令行执行:

mkdir build cd build cmake .. make ./assignment0

mkdir 和 cd 是为了在独立的 build 目录里做构建,保证源码目录干净;cmake .. 读取上一级目录的 CMakeLists.txt 并生成 Makefile;make 按 Makefile 编译;./assignment0 运行程序。如果前两步没有报错,环境配置就算完成了。Windows 上如果用的是 Visual Studio 的 CMake 集成,直接把源码目录打开也能生成工程,但命令行流程保持一致更省心。

Windows 下不使用包管理器时,把下载的 Eigen 头文件解压到 third_party/eigen3,然后在 CMakeLists.txt 里删掉 find_package 一行,改用:

cmake_minimum_required(VERSION 3.10) project(Games101_Assignment0) set(CMAKE_CXX_STANDARD 11) set(CMAKE_CXX_STANDARD_REQUIRED ON) add_executable(assignment0 main.cpp) target_include_directories(assignment0 PRIVATE "${CMAKE_SOURCE_DIR}/third_party/")

这样编译器会在 third_party 下找到 eigen3/Eigen/Dense。PRIVATE 表示这个 include 路径只在编译本目标时生效,不会泄漏给其他目标。两种接法的效果等价,区别只是路径来源:一个来自系统安装包,一个来自工程目录自带的头文件。

3. 作业0 的数学和代码:旋转 45 度再平移 (1,2) 的 Eigen 实现

3.1 先弄懂变换顺序:先旋转后平移为什么写 T·R·P

作业0 的说明 PDF 里通常只有一句话需求:把点 P = (2,1) 绕原点旋转 45 度,再平移 (1,2),输出最终坐标。这句话里的“再”字决定了矩阵乘法的书写顺序。用齐次坐标表示点,二维点 (x,y) 写作 (x,y,1),第三维取 1 表示这是一个点而不是方向向量;旋转矩阵 R 和平移矩阵 T 都是 3x3,最后的结果向量是 v' = T * R * v。

矩阵乘法从右往左作用:最右边的 v 先被紧挨着它的 R 处理,也就是先旋转;旋转后的中间结果再被左边的 T 平移。如果写成 R * T * v,就变成了先平移后旋转,两个顺序的结果完全不同。作业0 要求的是先旋转再平移,所以乘法顺序必须是从右到左依次读成“旋转→平移”。

对应的数学形式是绕 z 轴的旋转矩阵,z 轴垂直屏幕向外,45 度角按右手系逆时针为正。代码里矩阵元素 cos、sin 的位置和正负号都来自这个约定。手算验证会在第 4 章展开,现在先记住:矩阵列向量在右,变换从右往左执行,先发生的变换写在内侧。

3.2 用 Matrix3f 与 Vector3f 写出最小可运行代码

把 2.3 的 main.cpp 替换成下面这份完整实现:

#include <Eigen/Dense> #include <cmath> #include <iostream> int main() { // 1. 给定点 P(2,1),齐次坐标第三维取 1 表示这是一个点 Eigen::Vector3f point(2.0f, 1.0f, 1.0f); // 2. 角度转弧度:sin/cos 只接受弧度 const float angle_deg = 45.0f; const float angle_rad = angle_deg * 3.14159265f / 180.0f; // 3. 绕 z 轴旋转 45 度的旋转矩阵 Eigen::Matrix3f rotation; rotation << std::cos(angle_rad), -std::sin(angle_rad), 0.0f, std::sin(angle_rad), std::cos(angle_rad), 0.0f, 0.0f, 0.0f, 1.0f; // 4. 平移向量 (1,2) 对应的齐次平移矩阵 Eigen::Matrix3f translation; translation << 1.0f, 0.0f, 1.0f, 0.0f, 1.0f, 2.0f, 0.0f, 0.0f, 1.0f; // 5. 先旋转后平移,输出结果 Eigen::Vector3f result = translation * rotation * point; std::cout << "point: " << point.transpose() << "\n"; std::cout << "result: " << result.transpose() << std::endl; return 0; }

代码第 1 步定义点向量;第 2 步把 45 度换算成弧度,因为 std::sin 和 std::cos 的参数单位是弧度。第 3 步的 rotation 矩阵按 3x3 逐行填写,左上角的 2x2 是标准的二维旋转矩阵,第三行第三列为 1 保持齐次坐标的 w 分量不变。第 4 步的 translation 把 (1,2) 放在第三列的前两行,等价于对坐标做向量加法 (x+1, y+2)。第 5 步的表达式 translation * rotation * point 是这段代码里最关键的一行,两个 3x3 矩阵依次作用在 3 维列向量上,乘法顺序决定变换顺序。

编译这段代码不需要额外的链接参数,因为 Eigen 全部由头文件实现。如果手动用 g++ 编译,需要在命令里加 -I/usr/include/eigen3 指到头文件目录;如果用的是 2.3 那份包含 Eigen3::Eigen 的 CMakeLists,直接 make 即可。

3.3 用 print 检查中间结果:把旋转和平移拆开验证

实际调试时,别急着把两步变换合在一起输出,先拆开分别打印旋转后坐标和平移后坐标,能更快定位是旋转错了还是平移错了:

Eigen::Vector3f after_rotation = rotation * point; std::cout << "after rotation: " << after_rotation.transpose() << "\n"; Eigen::Vector3f after_translation = translation * after_rotation; std::cout << "after translation: " << after_translation.transpose() << "\n";

transpose() 把列向量转成行向量再打印,输出形如 0.707107 2.12132 1,最后一位 1 是齐次坐标的 w 分量。看到 w 仍是 1,说明这个结果还是“点”而不是方向;如果 w 变成 0,说明变换矩阵写错了。

这两步打印能帮你把错误一分为二:旋转后坐标不对,问题在角度、正负号或矩阵元素;旋转后对但最终不对,问题在平移矩阵或乘法顺序。第 4 章会给出精确的期望值,可以直接对照。

4. 验证与提交:对照参考输出和两种自查方法

4.1 参考输出:手工计算一遍得到 (0.707107, 2.121320) 和 (1.707107, 4.121320)

45 度的正弦和余弦都等于 √2/2,把 P(2,1) 代入旋转公式:

x' = 2·cos45 − 1·sin45 = (2−1)·√2/2 ≈ 0.707107
y' = 2·sin45 + 1·cos45 = (2+1)·√2/2 ≈ 2.121320

再把 (1,2) 加上去,得到最终坐标: x'' = 0.707107 + 1 = 1.707107,y'' = 2.121320 + 2 = 4.121320。

中间结果和最终结果汇总如下:

变换阶段xy
原始点 P2.0000001.000000
旋转 45 度后0.7071072.121320
再平移 (1,2) 后1.7071074.121320

对照时注意两点:小数点后第三位出现 ±1e-6 的抖动是浮点数二进制定位近似造成的,属于正常现象,不必追求每一位完全一致;但如果第一位小数就对不上,说明角度单位或矩阵顺序出了问题。把这张表保存下来,后面做作业1 的投影矩阵时还能复用同样的验算思路。

4.2 用 Python + NumPy 交叉验证,避免被 C++ 的坑带偏

C++ 编译报错或输出异常时,最快的定位方法是先用 Python 把同一道题算一遍:

import numpy as np theta = np.deg2rad(45) R = np.array([ [np.cos(theta), -np.sin(theta), 0], [np.sin(theta), np.cos(theta), 0], [0, 0, 1], ]) T = np.array([ [1, 0, 1], [0, 1, 2], [0, 0, 1], ]) p = np.array([2.0, 1.0, 1.0]) print("after rotation:", R @ p) print("after translation:", T @ (R @ p))

np.deg2rad 完成角度到弧度的换算,对应 C++ 里手动乘 3.14159265f / 180.0f 的操作;@ 运算符是矩阵乘法,与 C++ 里 translation * rotation 的语义一致。注意这里仍然先算 R @ p,再算 T @ (R @ p),保持“先旋转后平移”。如果顺手写成 R @ (T @ p),输出会变成 (0, 4.243) 一类完全不同的值,这正好印证了第 5 章的顺序坑。

Python 脚本只需要 numpy,不需要任何图形库。运行后如果输出和 4.1 的表格一致,说明数学推导没问题,C++ 那边的报错就只剩环境或语法层面,可以逐行对比两份代码找差异。

4.3 提交前做好三件事:打印精度、注释、CMake 一键构建

第一件事是把浮点打印精度调高。std::cout 默认只保留 6 位有效数字,万一结果本身偏差很小,默认精度可能把差异抹掉。在包含文件后加一行:

#include <iomanip> std::cout << std::fixed << std::setprecision(6);

std::fixed 让输出固定使用小数格式而不是科学计数法,setprecision(6) 指定小数部分保留六位,这样 0.707107 会完整显示,误差一目了然。

第二件事是注释。作业0 只有几行代码,有人觉得注释多余,但作业1 会在这个文件基础上加入投影矩阵,作业2 再加入视口变换,三个月后回头翻自己的代码,注释里写清“角度必须先转弧度”“先旋转后平移,所以 T 写在 R 左边”,比重新推导一遍矩阵快得多。不需要长篇注释,每一段变换一行说明就够。

第三件事是确认 CMake 一键构建。在干净目录里重新执行 mkdir build、cmake ..、make,确保没有依赖本地 IDE 的隐藏配置。可以把这三条命令写进作业目录里的 README,这样换电脑、换系统后也能照着一行行恢复环境。作业0 的交付内容不应该只有一行输出,而是一份能随时重新构建的最小工程。

提示:这里说的“手算 + Python 交叉验证”流程可以延续到后面每个作业。作业1 的投影矩阵、作业2 的重心坐标插值都能用同一套方法快速定位数学错误,比只看最终渲染图片判断问题来源要省时间。

5. 作业0 避坑指南:角度制、矩阵顺序与头文件路径的五个坑

5.1 输出结果对不上:三角函数直接吃 45 度

现象:旋转后的坐标算出来大约是 (1.48, -1.55),再平移 (1,2) 得到 (2.48, 0.45),和期望值 (0.707, 2.121) 差了十万八千里。

原因:std::sin 和 std::cos 的参数是弧度而不是角度,直接传 45 等于计算 sin(45 弧度) 而不是 sin(45 度),得到的值根本不对。这种错误在数学上不明显,因为运算结果仍然是合法数值,程序不会报错。

解决:使用前先转弧度,就是第 3 章代码里的 angle_deg * 3.14159265f / 180.0f。更稳的做法是在文件顶部定义常量:

constexpr float PI = 3.14159265358979f;

然后角度一律用 degree * PI / 180.0f 换算。作业0 只需要一处转换,后续作业里投影矩阵的视场角也要用到同样换算,提前养成写常量的习惯更省事。

5.2 矩阵乘法顺序写反:R·T·P 变成了先平移后旋转

现象:输出大约是 (0, 4.243),期望是 (1.707, 4.121),单独看每个矩阵都写得对,合起来结果就是不对。

原因:表达式写成了 rotation * translation * point,等价于先平移后旋转。先平移后得到的点是 (3,3),这个点再绕原点转 45 度,算出来的结果就是约 (0, 4.243)。如果输出变成 (4.243, 0) 之类,则多半是旋转矩阵的对角线符号同时写反,那是 5.1 角度坑的派生问题。

解决:把旋转矩阵写在最右边,表达式固定为 translation * rotation * point。这里有个口诀:从右往左读,离变量最近的是最先执行的变换。想要“先旋转再平移”,就让旋转矩阵在最右侧。

5.3 Debug 断言、Release 脏数据:Eigen 矩阵忘了初始化

现象:Debug 构建时程序在矩阵乘法附近直接断言退出,报错信息指向 Eigen 内部的一个检查;Release 构建时程序能跑,但输出偶尔正确、偶尔是一堆随机数。

原因:Eigen 的 Matrix 默认不初始化,内存里原来是什么就直接当矩阵元素用。Debug 模式下 Eigen 开启边界和初始化检查,读未初始化条目会触发运行时断言;Release 模式把这些检查优化掉,垃圾值直接参与计算,结果就变得飘忽。

解决:每个矩阵都用 << 按行初始化,或者先调用 setIdentity() 再覆盖非对角线元素。Vector 创建时直接传参数是最安全的写法:

Eigen::Vector3f point(2.0f, 1.0f, 1.0f); Eigen::Matrix3f rotation; rotation.setIdentity(); // 先确保所有元素有确定值

注意:Release 下不报错不等于正确,这种“时而对时而错”的 bug 定位成本远高于编译错误。作业阶段建议统一使用 Debug 或 RelWithDebInfo 构建,让问题尽早暴露。

5.4 找不到 Eigen/Dense:include 路径指到了 eigen3 里面那一层

现象:编译报 fatal error: Eigen/Dense: No such file or directory,但明明已经装了 libeigen3-dev。

原因:apt 安装的头文件路径是 /usr/include/eigen3/Eigen/Dense。源码里写 #include <Eigen/Dense> 时,编译器必须在搜索路径里看到一个名为 Eigen 的目录,然后在它下面找 Dense。如果 CMake 里把 include_directories 写成了 /usr/include/eigen3/Eigen,那就多套了一层,编译器仍然找不到 Eigen/Dense。另一种常见混用是源码写 #include <eigen3/Eigen/Dense>、include 路径指向 /usr/include,虽然能编过,但代码风格会变得混乱。

解决:统一成一套。使用 CMake 的 Eigen3::Eigen 目标时,路径自动配置,不需要手动操心;手动编译时用下面的命令:

g++ -I/usr/include/eigen3 main.cpp -o assignment0

如果换到没有系统安装 Eigen 的机器,就把第 2 章的 third_party/eigen3 方案拿出来,target_include_directories 指向该目录的父目录,而不是 Eigen 本身。

5.5 换个机器就编不过:find_package、Eigen 版本与 CMake 版本的三方拉扯

现象:宿舍电脑上 make 一次通过,自己的电脑报 Could NOT find Eigen3 或 fatal error: Eigen/Dense: No such file or directory;更隐蔽的情况是 find_package 找到了,却在 build 阶段报出无法打开头文件。

原因:第一台机器 apt 装了 libeigen3-dev,第二台没有;或者第二台用的是从官网解压的源码目录,目录里没有生成 CMake 配置文件,find_package 自然找不到。这类问题常被归为“玄学”,其实一句话就能概括:Eigen 是 header-only,编译失败基本集中在头文件搜索路径没有正确传到编译器。

解决:先确认机器上有没有 /usr/share/eigen3/cmake 这个目录,存在说明 Eigen3Config.cmake 在;然后确认 CMake 版本足够新,老版本对 Eigen3::Eigen 这个导入目标支持不完整。更省心的做法是永远在工程里自带一份 Eigen 头文件,工程完全不依赖系统预装库,任何机器 clone 下来都能编译:

cmake_minimum_required(VERSION 3.10) project(Games101_Assignment0) set(CMAKE_CXX_STANDARD 11) add_executable(assignment0 main.cpp) target_include_directories(assignment0 PRIVATE "${CMAKE_SOURCE_DIR}/third_party/")

代价是多占一点磁盘空间,换来的是一份构建行为完全确定的环境。我一般会把第三方头文件放进工程目录,而不是依赖每台机器都执行一遍 apt install。

6. 作业0 之后:把点换成三角形,提前预习作业1

6.1 用一个矩阵同时变换三个顶点

作业0 只处理一个点,但真实场景里有成百上千个顶点。作业1 的光栅化会处理三角形的三个顶点,每个顶点都要乘同一个变换矩阵。用 Eigen 的列向量矩阵可以一次性完成:

Eigen::Matrix<float, 3, Eigen::Dynamic> verts(3, 3); verts.col(0) = Eigen::Vector3f(-1.0f, -1.0f, 1.0f); verts.col(1) = Eigen::Vector3f( 1.0f, -1.0f, 1.0f); verts.col(2) = Eigen::Vector3f( 0.0f, 1.0f, 1.0f); Eigen::Matrix<float, 3, Eigen::Dynamic> transformed = transform * verts;

verts 是 3 行 N 列的矩阵,每列是一个顶点的齐次坐标;矩阵乘法会把 transform 依次作用到每一列,transformed 的每一列就是变换后的顶点。Eigen::Dynamic 表示列数在运行时确定,以后三角形要加顶点也不用改代码。作业0 里练熟这个写法,作业1 的模型变换部分可以直接照搬。

6.2 验证方法升级:把变换后的三角形输出成像素

把三个变换后的顶点连起来画到屏幕上,就迈进了作业1 的地界。最简单的输出格式是 PPM 纯文本图,不需要任何图像库:把三个顶点的屏幕坐标填进一个小的二维数组,按“在三角形内填白色、否则填黑色”逐像素判断,就能在终端里看到一个旋转过的三角形轮廓。手工填充像素的过程能直观体会“顶点变换”和“片段着色”之间的空档,这正是作业1 要补上的部分。

做完这个扩展,顺手把作业0 的代码整理成完整工程:main.cpp 里保留旋转和平移矩阵、把变换逻辑封装成函数、加一个 README 说明构建步骤。我用这个方法预习作业1 时,最大的收益是提前理解了“变换矩阵作用在坐标系上,而不是作用在某个具体点上”。作业0 里的点只是坐标系变换的一个采样样本。

我自己的教训是:做作业0 时因为急着看结果,跳过了第 4 章的交叉验证,结果作业1 的投影矩阵一直错得莫名其妙,调了一晚上才发现是矩阵顺序从源头就反了。从此每个作业都先手算一个简单场景,再用 Python 对一遍,最后才写 C++。这个习惯帮我省下的调试时间,比写那几行验证脚本多得多。希望这篇作业0 的完整拆解能帮你把第一步走稳,后面作业1 到作业8 的每一步都会轻松不少。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询