中级OpenGL教程 022:探秘三维世界的血脉传承------物体父子关系与矩阵递归奥义
- [📜 序章:三维世界的伦理纲常 🌳](#📜 序章:三维世界的伦理纲常 🌳)
- [Bilibili 同步视频](#Bilibili 同步视频)
- [🎨 第一章:缘起Blender------直观感知父子之缘 🎭](#🎨 第一章:缘起Blender——直观感知父子之缘 🎭)
-
- [🌱 1.1 初遇:两物独立,各不相干](#🌱 1.1 初遇:两物独立,各不相干)
- [🔗 1.2 结缘:Ctrl+P,父子定盟](#🔗 1.2 结缘:Ctrl+P,父子定盟)
- [⚡ 1.3 神效:父动子随,子变父安](#⚡ 1.3 神效:父动子随,子变父安)
- [🌲 1.4 繁衍:层级嵌套,生生不息](#🌲 1.4 繁衍:层级嵌套,生生不息)
- [🏗️ 第二章:数据筑基------Object类父子结构的匠心设计 🔧](#🏗️ 第二章:数据筑基——Object类父子结构的匠心设计 🔧)
-
- [🧩 2.1 设计理念:双向指针,各得其所](#🧩 2.1 设计理念:双向指针,各得其所)
- [📐 2.2 成员变量:两大支柱,撑起层级](#📐 2.2 成员变量:两大支柱,撑起层级)
- [⚙️ 2.3 成员函数:三大接口,各司其职](#⚙️ 2.3 成员函数:三大接口,各司其职)
- [🔬 2.4 addChild详解:三步成礼,慎终如始](#🔬 2.4 addChild详解:三步成礼,慎终如始)
-
- [🎯 第一步:查重------防重复,避混乱](#🎯 第一步:查重——防重复,避混乱)
- [➕ 第二步:入列------添新丁,入族谱](#➕ 第二步:入列——添新丁,入族谱)
- [🔗 第三步:认父------告所生,明归属](#🔗 第三步:认父——告所生,明归属)
- [📤 2.5 简单函数:获取父子,直截了当](#📤 2.5 简单函数:获取父子,直截了当)
- [⚡ 第三章:矩阵分野------本地与世界的两重天地 🌌](#⚡ 第三章:矩阵分野——本地与世界的两重天地 🌌)
-
- [🧮 3.1 矩阵之要义:变换之载体](#🧮 3.1 矩阵之要义:变换之载体)
- [🌍 3.2 两种矩阵:本地与世界](#🌍 3.2 两种矩阵:本地与世界)
-
- [🏠 本地矩阵(Local Matrix)](#🏠 本地矩阵(Local Matrix))
- [🌐 世界矩阵(World Matrix)](#🌐 世界矩阵(World Matrix))
- [📊 3.3 层级示例:祖孙三代的矩阵推演](#📊 3.3 层级示例:祖孙三代的矩阵推演)
- [🔄 第四章:递归妙法------层级矩阵的链式推演 🌀](#🔄 第四章:递归妙法——层级矩阵的链式推演 🌀)
-
- [🧠 4.1 递归思想:自己调用自己](#🧠 4.1 递归思想:自己调用自己)
- [📝 4.2 递归三要素](#📝 4.2 递归三要素)
- [🎬 4.3 递归过程推演:祖孙三代的故事](#🎬 4.3 递归过程推演:祖孙三代的故事)
- [💻 第五章:代码实战------从理论到工程的落地之路 ⚒️](#💻 第五章:代码实战——从理论到工程的落地之路 ⚒️)
-
- [🛠️ 5.1 改造getModelMatrix函数](#🛠️ 5.1 改造getModelMatrix函数)
- [🧪 5.2 测试场景搭建](#🧪 5.2 测试场景搭建)
- [👀 5.3 预期效果验证](#👀 5.3 预期效果验证)
- [🎯 第六章:性能考量与进阶思考 🚀](#🎯 第六章:性能考量与进阶思考 🚀)
-
- [⚡ 6.1 性能优化:缓存机制](#⚡ 6.1 性能优化:缓存机制)
- [🌳 6.2 场景图(Scene Graph)](#🌳 6.2 场景图(Scene Graph))
- [🔮 6.3 进阶话题预告](#🔮 6.3 进阶话题预告)
- [📝 结语:道阻且长,行则将至 🌟](#📝 结语:道阻且长,行则将至 🌟)
📜 序章:三维世界的伦理纲常 🌳
盖闻天地万物,皆有伦常;父子相继,代代相传。🎋 三维图形之世界,亦复如是。夫物体者,非孤立之存在,实有层级之关联。父动而子随,子变而父安,此乃自然之理也。
今有OpenGL图形编程之学,欲通模型变换之玄妙,必先明父子关系之精义。🔑 本篇博文,将自Blender直观之象始,至C++代码实现终,层层递进,步步深入,冀诸君读后,于层级变换之理,豁然开朗焉。
Bilibili 同步视频
🎨 第一章:缘起Blender------直观感知父子之缘 🎭
🌱 1.1 初遇:两物独立,各不相干
昔者Blender之中,有立方体(Cube)与球体(Sphere)二物,并存于场景之内。🏛️ 方其未结父子之缘也,立方体移则立方体自移,球体能转则球体自转,二者泾渭分明,毫无干涉。
譬之如世间陌路之人,擦肩而过,互不相识。你走你的阳关道,我过我的独木桥。🚶♂️🚶♀️ 平移也,旋转也,缩放也,皆各行其是,不相为谋。
**📌 核心观察:**未建立父子关系时,物体之间相互独立,变换互不影响。
🔗 1.2 结缘:Ctrl+P,父子定盟
然则如何使二物结为父子,血脉相连?其法至简:先选球体(子),再选立方体(父),按下Ctrl+P,择"保持变换",则父子之盟定矣。🤝
结盟之后,但见绿线一条,牵于二物之间,此Blender示父子关系之特有表达也。🌿 再观场景集合之面板,立方体之下,球体在焉,俨然父在上而子在下,秩序井然。
⚡ 1.3 神效:父动子随,子变父安
父子关系既成,则奇妙之象生焉:
-
🌟 **父移子随:**移动立方体,则球体随之而动,如影随形;
-
🔄 **父转子随:**旋转立方体,则球体绕父而转,不离不弃;
-
📏 **父缩子随:**缩放立方体,则球体同步伸缩,比例不失;
-
🎯 **子变父安:**球体可单独平移、旋转、缩放,而立方体岿然不动。
嗟夫!父之变换,无不加于子;子之变换,不及于父。此乃父子关系之金科玉律也。⚖️
🌲 1.4 繁衍:层级嵌套,生生不息
父子之关系,非止于一级也。子又有子,孙又有孙,子子孙孙,无穷匮也。🌳 一层之上更有一层,一层之下复有一层,三层四层五层,皆可随意构建。
譬如大树之有枝干,干生枝,枝生叶,层层相属,蔚为大观。此即三维场景之**层级树(Hierarchy Tree)**是也。
🏗️ 第二章:数据筑基------Object类父子结构的匠心设计 🔧
🧩 2.1 设计理念:双向指针,各得其所
既明父子关系之理,则当思如何以代码实现之。🎯 吾辈程序员,遇问题必先思数据结构。父子关系者,树形结构也,其实现之法,在于双向记录:
-
👨👦 **父知其子:**每个父物体,须有一子物体列表,知晓膝下诸子;
-
👦 **子知其父:**每个子物体,须有一父物体指针,知晓所从何来。
**📌 重要原则:**一父可有众子,一子唯有一父。若一子而多父,则听谁之命?逻辑大乱矣。故常规设计之下,单父为正理。
📐 2.2 成员变量:两大支柱,撑起层级
于Object类之中,当添二成员变量:
cpp
// 头文件:Object.h
#include <vector> // 引入vector容器
#include <string> // 引入字符串
#include <map> // 引入映射表
class Object {
private:
// ────────────── 父子关系核心数据 ──────────────
std::vector<Object*> m_children; // 子物体数组:存储所有子对象指针
Object* m_parent; // 父物体指针:指向唯一的父对象
// ... 其他成员:位置、旋转、缩放等 ...
public:
// ... 其他函数声明 ...
};
m_children者,vector容器也,其中存放子物体之指针,数量不限,多子多福。👨👧👦
m_parent者,指针也,指向其父物体。初始化为nullptr,盖因顶层物体无父,如孙悟空之石破天惊,无父无母。🐒
⚙️ 2.3 成员函数:三大接口,各司其职
数据既备,函数随之。当有三函数,以操作父子关系:
| 函数名 | 功用 |
|---|---|
addChild() |
添加子物体:将新子纳入膝下,并告知其谁为父 |
getChildren() |
获取子物体列表:返回所有子物体的拷贝 |
getParent() |
获取父物体:返回父物体指针 |
🔬 2.4 addChild详解:三步成礼,慎终如始
addChild函数,看似简单,实则暗藏玄机。其执行有三步,一步不可缺:
🎯 第一步:查重------防重复,避混乱
加入之前,必先检查:此子是否已在膝下?若已在而又加,则一而二,二而三,数组之中重复狼藉,后患无穷。🚫
查重之法,用STL之find函数,遍历vector,寻觅目标。若得之,则打印错误,提前返回,不复执行后续。
➕ 第二步:入列------添新丁,入族谱
若查重通过,无重复之虞,则将子物体指针,push_back入m_children数组之中。此一步,最为简单,然亦最为基础。📥
🔗 第三步:认父------告所生,明归属
**此步最为关键,万万不可忘却!**⚠️ 子物体既入父之数组,父知有子矣;然子尚不知其父为谁,岂不悲哉?
故必设子物体之m_parent为this指针,令子知晓:"吾之父,即此对象也。" 如此双向贯通,父子关系方得圆满。✨
cpp
// 源文件:Object.cpp
#include <algorithm> // 引入std::find
#include <iostream> // 引入错误输出
/**
* @brief 添加子物体
* @param obj 待添加的子物体指针
*
* 三步曲:
* 1. 查重:避免重复添加同一子物体
* 2. 入列:将子物体加入children数组
* 3. 认父:设置子物体的parent指针(关键!)
*/
void Object::addChild(Object* obj) {
// ── 第一步:查重 ──
// 使用std::find在vector中查找目标对象
auto iter = std::find(
m_children.begin(), // 起始迭代器
m_children.end(), // 结束迭代器
obj // 要查找的目标
);
// 若迭代器不等于end,说明找到了,即已存在
if (iter != m_children.end()) {
std::cerr << "❌ 错误:重复添加子物体!" << std::endl;
return; // 提前返回,不执行后续
}
// ── 第二步:入列 ──
m_children.push_back(obj); // 将子物体加入数组
// ── 第三步:认父(重中之重!)──
obj->m_parent = this; // 告诉子物体:我是你爸爸
}
**💡 迭代器小知识:**std::find返回迭代器(iterator),其性类似指针。若找到目标,迭代器指向该元素;若未找到,则指向容器末尾(end())。end()并非最后一个元素,而是最后一个元素之后的空位置。
📤 2.5 简单函数:获取父子,直截了当
getChildren与getParent二函数,最为简单,直接返回对应成员变量即可。
cpp
/**
* @brief 获取所有子物体(返回拷贝,防止外部修改内部数据)
* @return 子物体数组的副本
*/
std::vector<Object*> Object::getChildren() {
return m_children; // 返回拷贝,保护内部数据
}
/**
* @brief 获取父物体指针
* @return 父物体指针,若无父则返回nullptr
*/
Object* Object::getParent() {
return m_parent; // 直接返回父指针
}
**设计考量:**getChildren返回拷贝而非引用,何也?盖因若返回引用,则外部代码可随意修改内部数组,破坏封装性。返回拷贝,虽有少许性能开销,然安全性大增,权衡之下,此为上策。🛡️
⚡ 第三章:矩阵分野------本地与世界的两重天地 🌌
🧮 3.1 矩阵之要义:变换之载体
夫三维图形者,矩阵之天下也。📐 平移、旋转、缩放,无一不可以矩阵表之。每一物体,皆有其Model Matrix(模型矩阵),决定其在空间中之位置、朝向与大小。
昔者未涉父子关系之时,getModelMatrix函数,但凭自身之position、rotation、scale三参数,计算变换矩阵。此矩阵,仅关乎自身,不及其他。
🌍 3.2 两种矩阵:本地与世界
父子关系既入,则矩阵之分,不可不察。矩阵有二:
🏠 本地矩阵(Local Matrix)
本地矩阵者,物体相对于其父之变换也。🎋 仅用自身之位置、旋转、缩放计算而得,不考虑父物体之影响。
譬之如人在家中,相对于客厅沙发之位置,此即本地坐标也。你在沙发左三尺,右二尺,皆相对于沙发而言,与房屋在城市中之位置无涉。
🌐 世界矩阵(World Matrix)
世界矩阵者,物体在整个场景中之最终变换也。🌍 须将自身本地矩阵,与父之世界矩阵,祖父之世界矩阵,层层相乘,方得最终结果。
譬之如人在地球上之经纬度,须将"家中位置"、"城市位置"、"国家位置"层层叠加,方得最终之全球坐标。
📌 核心公式:
世界矩阵 = 本地矩阵 × 父物体世界矩阵
(注意:矩阵乘法顺序至关重要,不可颠倒!)
📊 3.3 层级示例:祖孙三代的矩阵推演
试举一例,以明其理。假设有三物体,祖孙三代:Object0(祖父)→ Object1(父)→ Object2(子)。🧓👨👦
各物体之世界矩阵,当如何计算?
-
🧓 **Object0(顶层):**无父无母,石头里蹦出来的。其世界矩阵 = 本地矩阵。盖因无人影响之,自身即世界也。
-
👨 **Object1(中层):**有父Object0。其世界矩阵 = Object1本地矩阵 × Object0世界矩阵。
-
👦 **Object2(底层):**有父Object1。其世界矩阵 = Object2本地矩阵 × Object1世界矩阵。
展开观之:
plain
Object2世界矩阵 = T2 × T1 × T0
= 自身本地 × 父世界 × 祖世界
= 由近及远,层层相乘
其中T0、T1、T2分别为Object0、Object1、Object2之本地变换矩阵。
🔄 第四章:递归妙法------层级矩阵的链式推演 🌀
🧠 4.1 递归思想:自己调用自己
然则如何实现此层层相乘之逻辑?答曰:递归。🔄
递归者何?函数自己调用自己之谓也。欲算我之世界矩阵,必先算父之世界矩阵;欲算父之世界矩阵,必先算祖之世界矩阵......直至顶层,无父而止。
此过程,如剥洋葱,层层深入,直至核心;又如登塔,自下而上,逐级攀升。🗼
📝 4.2 递归三要素
| 要素 | 说明 |
|---|---|
| 递归出口 | 父物体为nullptr时,停止递归,返回单位矩阵(或自身本地矩阵) |
| 递归调用 | 有父物体时,调用父物体的getModelMatrix()获取父之世界矩阵 |
| 合并结果 | 将自身本地矩阵与父之世界矩阵相乘,得到自身世界矩阵并返回 |
🎬 4.3 递归过程推演:祖孙三代的故事
设吾辈调用Object2之getModelMatrix(),则递归过程如下,诸君且细品之:🍵
第一幕:Object2登场
Object2曰:"欲算我之世界矩阵,须知我父之世界矩阵。" 遂调用Object1->getModelMatrix()。自己则暂停等待,如子待父命。⏸️
第二幕:Object1接力
Object1曰:"欲算我之世界矩阵,须知我父之世界矩阵。" 遂调用Object0->getModelMatrix()。自己亦暂停等待,如父待祖命。⏸️
第三幕:Object0------递归出口
Object0曰:"我无父也!我的本地矩阵即我的世界矩阵。" 遂计算自身本地矩阵,直接返回。🚪 此即递归之出口,触底反弹之时也。
第四幕:Object1收结果
Object1得Object0返回之矩阵,大喜。遂以自身本地矩阵乘之,得自身世界矩阵,返回给Object2。📤
第五幕:Object2终成正果
Object2得Object1返回之矩阵,亦大喜。遂以自身本地矩阵乘之,得自身世界矩阵。🎉 至此,递归完成,结果得矣。
**💡 递归之妙:**如投石入水,波纹层层扩散,直至岸边(出口),而后折返,层层归位。每一层只关心自己与上一层的关系,不必知晓全局之全貌。此即递归之"分而治之"思想也。
💻 第五章:代码实战------从理论到工程的落地之路 ⚒️
🛠️ 5.1 改造getModelMatrix函数
理论既明,代码随之。改造getModelMatrix函数,加入递归逻辑,其法有三:
cpp
/**
* @brief 获取模型的世界变换矩阵(递归版本)
* @return 4x4世界变换矩阵
*
* 核心逻辑:
* 1. 获取父物体的世界矩阵(递归调用)
* 2. 计算自身的本地变换矩阵
* 3. 本地矩阵 × 父世界矩阵 = 自身世界矩阵
*/
mat4 Object::getModelMatrix() {
// ────────── 第一步:获取父物体的世界矩阵 ──────────
mat4 parentMatrix = mat4(1.0f); // 初始化为单位矩阵
// 若有父物体,则递归获取父之世界矩阵
if (m_parent != nullptr) {
parentMatrix = m_parent->getModelMatrix(); // 递归调用!
}
// 若无父物体,parentMatrix保持单位矩阵,乘之不影响结果
// ────────── 第二步:计算自身本地变换矩阵 ──────────
mat4 scaleMatrix = ...; // 缩放矩阵(根据m_scale计算)
mat4 rotateMatrix = ...; // 旋转矩阵(根据m_rotation计算)
mat4 translateMatrix = ...; // 平移矩阵(根据m_position计算)
// 组合本地变换:平移 × 旋转 × 缩放
mat4 localMatrix = translateMatrix * rotateMatrix * scaleMatrix;
// ────────── 第三步:本地矩阵 × 父世界矩阵 ──────────
// 注意乘法顺序:父矩阵在右,自身在左
mat4 worldMatrix = localMatrix * parentMatrix;
return worldMatrix;
}
**🚨 矩阵乘法顺序警告:**矩阵乘法不满足交换律,顺序至关重要!
**正确:**自身本地矩阵 × 父世界矩阵(local × parent)
**错误:**父世界矩阵 × 自身本地矩阵(parent × local)
顺序颠倒,则变换效果完全不同,切记切记!
🧪 5.2 测试场景搭建
代码既成,当测试之。测试之法,当建一场景,观其效果。
场景设计
-
📦 **立方体(父):**位于原点,绕Z轴旋转,每帧转4度
-
⚽ **球体(子):**位于X=2处,为立方体之子;自身绕Y轴旋转,每帧转6度
预期效果:球体既随立方体绕Z轴公转,又绕自身Y轴自转,如行星之绕日,同时自转。🌍☀️
cpp
// 在场景初始化函数中
void prepareScene() {
// ── 1. 创建立方体(父物体)──
auto cubeGeometry = Geometry::createCube(1.0f);
auto cubeMesh = new Mesh(cubeGeometry);
cubeMesh->setPosition(vec3(0.0f, 0.0f, 0.0f)); // 位于原点
// ── 2. 创建球体(子物体)──
auto sphereGeometry = Geometry::createSphere(1.0f);
auto sphereMesh = new Mesh(sphereGeometry);
sphereMesh->setPosition(vec3(2.0f, 0.0f, 0.0f)); // X=2处
// ── 3. 建立父子关系(关键一步!)──
cubeMesh->addChild(sphereMesh); // 球体成为立方体的子物体
// ── 4. 加入场景渲染列表──
meshes.push_back(cubeMesh);
meshes.push_back(sphereMesh);
}
// 在每帧更新函数中
void updateScene(float deltaTime) {
// 父物体(立方体)绕Z轴旋转(公转)
meshes[0]->rotateAroundZ(4.0f * deltaTime);
// 子物体(球体)绕自身Y轴旋转(自转)
meshes[1]->rotateAroundY(6.0f * deltaTime);
}
👀 5.3 预期效果验证
运行程序,当见何景象?
-
🔄 立方体绕Z轴缓缓旋转,如日之经天;
-
🌍 球体随立方体同步旋转,如行星之绕日公转;
-
⚡ 球体同时绕自身Y轴快速旋转,如地球之自转;
-
🎯 球体始终保持与立方体的相对距离(X=2),不离不弃。
若效果如此,则父子关系之矩阵计算,大功告成矣!🎉
🎯 第六章:性能考量与进阶思考 🚀
⚡ 6.1 性能优化:缓存机制
递归虽妙,然亦有弊。若每帧皆从头递归计算,层级深时,重复计算甚多,性能堪忧。😰
优化之法,在于缓存(Cache):物体计算过世界矩阵后,存之;若父物体未变,自身亦未变,则直接返回缓存之矩阵,不必重算。
cpp
class Object {
private:
mat4 m_cachedWorldMatrix; // 缓存的世界矩阵
bool m_isDirty; // 脏标记:是否需要重新计算
// ... 其他成员 ...
public:
// 当位置/旋转/缩放改变时,标记为脏
void setPosition(vec3 pos) {
m_position = pos;
markDirty(); // 标记自己和所有子物体为脏
}
// 标记脏:自己变了,孩子们也得重新算
void markDirty() {
m_isDirty = true;
for (auto child : m_children) {
child->markDirty(); // 递归标记子物体
}
}
};
🌳 6.2 场景图(Scene Graph)
父子关系之终极形态,即**场景图(Scene Graph)**是也。🗺️ 整个场景为一树形结构,根节点之下,众子分焉;子又有子,层层嵌套。
渲染之时,自根节点始,深度优先遍历,依次计算各节点之世界矩阵,而后渲染。此即现代游戏引擎之标准做法也。
🔮 6.3 进阶话题预告
本篇所讲,乃父子关系之基础。进阶之境,尚有诸多玄妙:
-
🔗 **变换空间转换:**本地空间→世界空间→观察空间→裁剪空间,空间转换之链条;
-
👻 **反向动力学(IK):**子动而父随,如人伸手而臂随之,骨骼动画之核心;
-
🧬 **骨骼蒙皮动画:**骨骼层级驱动顶点形变,角色动画之基石;
-
🌐 **四元数与万向锁:**旋转之更深奥义,避免欧拉角之弊端。
此皆后话,容后再表。📚
📝 结语:道阻且长,行则将至 🌟
呜呼!父子关系,看似简单,实则蕴含图形学之至理。🎋 数据结构之设计,矩阵乘法之顺序,递归思想之运用,无一不考验程序员之功底。
然诸君不必畏难。天下难事,必作于易;天下大事,必作于细。🌈 自Blender直观之象入手,而数据结构,而矩阵原理,而递归实现,步步为营,久久为功,则终有豁然贯通之一日。
愿诸君以此篇为阶,登堂入室,于三维图形之广袤天地,纵横驰骋,游刃有余。🚀 路漫漫其修远兮,吾将上下而求索。与诸君共勉!

📌 本篇要点回顾:
-
✅ 父子关系:父动子随,子变父安,单向传递
-
✅ 数据结构:children数组 + parent指针,双向记录
-
✅ addChild三步:查重、入列、认父,一步不可少
-
✅ 两种矩阵:本地矩阵(自身)vs 世界矩阵(最终)
-
✅ 递归计算:世界矩阵 = 本地矩阵 × 父世界矩阵
-
✅ 乘法顺序:矩阵不可交换,顺序至关重要
-
✅ 性能优化:脏标记 + 缓存,避免重复计算
--- 全文完 ---
🌟 感谢阅读,如有疑问,欢迎评论区交流 🌟