中级OpenGL教程 022:探秘三维世界的血脉传承——物体父子关系与矩阵递归奥义

📅 2026/7/21 6:33:20 👁️ 阅读次数 📝 编程学习
中级OpenGL教程 022:探秘三维世界的血脉传承——物体父子关系与矩阵递归奥义

中级OpenGL教程 022:探秘三维世界的血脉传承——物体父子关系与矩阵递归奥义

  • 📜 序章:三维世界的伦理纲常 🌳
  • Bilibili 同步视频
  • 🎨 第一章:缘起Blender——直观感知父子之缘 🎭
    • 🌱 1.1 初遇:两物独立,各不相干
    • 🔗 1.2 结缘:Ctrl+P,父子定盟
    • ⚡ 1.3 神效:父动子随,子变父安
    • 🌲 1.4 繁衍:层级嵌套,生生不息
  • 🏗️ 第二章:数据筑基——Object类父子结构的匠心设计 🔧
    • 🧩 2.1 设计理念:双向指针,各得其所
    • 📐 2.2 成员变量:两大支柱,撑起层级
    • ⚙️ 2.3 成员函数:三大接口,各司其职
    • 🔬 2.4 addChild详解:三步成礼,慎终如始
      • 🎯 第一步:查重——防重复,避混乱
      • ➕ 第二步:入列——添新丁,入族谱
      • 🔗 第三步:认父——告所生,明归属
    • 📤 2.5 简单函数:获取父子,直截了当
  • ⚡ 第三章:矩阵分野——本地与世界的两重天地 🌌
    • 🧮 3.1 矩阵之要义:变换之载体
    • 🌍 3.2 两种矩阵:本地与世界
      • 🏠 本地矩阵(Local Matrix)
      • 🌐 世界矩阵(World Matrix)
    • 📊 3.3 层级示例:祖孙三代的矩阵推演
  • 🔄 第四章:递归妙法——层级矩阵的链式推演 🌀
    • 🧠 4.1 递归思想:自己调用自己
    • 📝 4.2 递归三要素
    • 🎬 4.3 递归过程推演:祖孙三代的故事
      • 第一幕:Object2登场
      • 第二幕:Object1接力
      • 第三幕:Object0——递归出口
      • 第四幕:Object1收结果
      • 第五幕:Object2终成正果
  • 💻 第五章:代码实战——从理论到工程的落地之路 ⚒️
    • 🛠️ 5.1 改造getModelMatrix函数
    • 🧪 5.2 测试场景搭建
      • 场景设计
    • 👀 5.3 预期效果验证
  • 🎯 第六章:性能考量与进阶思考 🚀
    • ⚡ 6.1 性能优化:缓存机制
    • 🌳 6.2 场景图(Scene Graph)
    • 🔮 6.3 进阶话题预告
  • 📝 结语:道阻且长,行则将至 🌟

📜 序章:三维世界的伦理纲常 🌳

盖闻天地万物,皆有伦常;父子相继,代代相传。🎋 三维图形之世界,亦复如是。夫物体者,非孤立之存在,实有层级之关联。父动而子随,子变而父安,此乃自然之理也。

今有OpenGL图形编程之学,欲通模型变换之玄妙,必先明父子关系之精义。🔑 本篇博文,将自Blender直观之象始,至C++代码实现终,层层递进,步步深入,冀诸君读后,于层级变换之理,豁然开朗焉。


Bilibili 同步视频

中级OpenGL教程 022:探秘三维世界的血脉传承——物体父子关系与矩阵递归奥义


🎨 第一章:缘起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类之中,当添二成员变量:

// 头文件:Object.h#include<vector>// 引入vector容器#include<string>// 引入字符串#include<map>// 引入映射表classObject{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指针,令子知晓:“吾之父,即此对象也。” 如此双向贯通,父子关系方得圆满。✨

// 源文件:Object.cpp#include<algorithm>// 引入std::find#include<iostream>// 引入错误输出/** * @brief 添加子物体 * @param obj 待添加的子物体指针 * * 三步曲: * 1. 查重:避免重复添加同一子物体 * 2. 入列:将子物体加入children数组 * 3. 认父:设置子物体的parent指针(关键!) */voidObject::addChild(Object*obj){// ── 第一步:查重 ──// 使用std::find在vector中查找目标对象autoiter=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二函数,最为简单,直接返回对应成员变量即可。

/** * @brief 获取所有子物体(返回拷贝,防止外部修改内部数据) * @return 子物体数组的副本 */std::vector<Object*>Object::getChildren(){returnm_children;// 返回拷贝,保护内部数据}/** * @brief 获取父物体指针 * @return 父物体指针,若无父则返回nullptr */Object*Object::getParent(){returnm_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世界矩阵。

展开观之:

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函数,加入递归逻辑,其法有三:

/** * @brief 获取模型的世界变换矩阵(递归版本) * @return 4x4世界变换矩阵 * * 核心逻辑: * 1. 获取父物体的世界矩阵(递归调用) * 2. 计算自身的本地变换矩阵 * 3. 本地矩阵 × 父世界矩阵 = 自身世界矩阵 */mat4Object::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;returnworldMatrix;}

**🚨 矩阵乘法顺序警告:**矩阵乘法不满足交换律,顺序至关重要!
**正确:**自身本地矩阵 × 父世界矩阵(local × parent)
**错误:**父世界矩阵 × 自身本地矩阵(parent × local)
顺序颠倒,则变换效果完全不同,切记切记!

🧪 5.2 测试场景搭建

代码既成,当测试之。测试之法,当建一场景,观其效果。

场景设计

  • 📦 **立方体(父):**位于原点,绕Z轴旋转,每帧转4度

  • ⚽ **球体(子):**位于X=2处,为立方体之子;自身绕Y轴旋转,每帧转6度

预期效果:球体既随立方体绕Z轴公转,又绕自身Y轴自转,如行星之绕日,同时自转。🌍☀️

// 在场景初始化函数中voidprepareScene(){// ── 1. 创建立方体(父物体)──autocubeGeometry=Geometry::createCube(1.0f);autocubeMesh=newMesh(cubeGeometry);cubeMesh->setPosition(vec3(0.0f,0.0f,0.0f));// 位于原点// ── 2. 创建球体(子物体)──autosphereGeometry=Geometry::createSphere(1.0f);autosphereMesh=newMesh(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);}// 在每帧更新函数中voidupdateScene(floatdeltaTime){// 父物体(立方体)绕Z轴旋转(公转)meshes[0]->rotateAroundZ(4.0f*deltaTime);// 子物体(球体)绕自身Y轴旋转(自转)meshes[1]->rotateAroundY(6.0f*deltaTime);}

👀 5.3 预期效果验证

运行程序,当见何景象?

  • 🔄 立方体绕Z轴缓缓旋转,如日之经天;

  • 🌍 球体随立方体同步旋转,如行星之绕日公转;

  • ⚡ 球体同时绕自身Y轴快速旋转,如地球之自转;

  • 🎯 球体始终保持与立方体的相对距离(X=2),不离不弃。

若效果如此,则父子关系之矩阵计算,大功告成矣!🎉


🎯 第六章:性能考量与进阶思考 🚀

⚡ 6.1 性能优化:缓存机制

递归虽妙,然亦有弊。若每帧皆从头递归计算,层级深时,重复计算甚多,性能堪忧。😰

优化之法,在于缓存(Cache):物体计算过世界矩阵后,存之;若父物体未变,自身亦未变,则直接返回缓存之矩阵,不必重算。

classObject{private:mat4 m_cachedWorldMatrix;// 缓存的世界矩阵boolm_isDirty;// 脏标记:是否需要重新计算// ... 其他成员 ...public:// 当位置/旋转/缩放改变时,标记为脏voidsetPosition(vec3 pos){m_position=pos;markDirty();// 标记自己和所有子物体为脏}// 标记脏:自己变了,孩子们也得重新算voidmarkDirty(){m_isDirty=true;for(autochild:m_children){child->markDirty();// 递归标记子物体}}};

🌳 6.2 场景图(Scene Graph)

父子关系之终极形态,即**场景图(Scene Graph)**是也。🗺️ 整个场景为一树形结构,根节点之下,众子分焉;子又有子,层层嵌套。

渲染之时,自根节点始,深度优先遍历,依次计算各节点之世界矩阵,而后渲染。此即现代游戏引擎之标准做法也。

🔮 6.3 进阶话题预告

本篇所讲,乃父子关系之基础。进阶之境,尚有诸多玄妙:

  • 🔗 **变换空间转换:**本地空间→世界空间→观察空间→裁剪空间,空间转换之链条;

  • 👻 **反向动力学(IK):**子动而父随,如人伸手而臂随之,骨骼动画之核心;

  • 🧬 **骨骼蒙皮动画:**骨骼层级驱动顶点形变,角色动画之基石;

  • 🌐 **四元数与万向锁:**旋转之更深奥义,避免欧拉角之弊端。

此皆后话,容后再表。📚


📝 结语:道阻且长,行则将至 🌟

呜呼!父子关系,看似简单,实则蕴含图形学之至理。🎋 数据结构之设计,矩阵乘法之顺序,递归思想之运用,无一不考验程序员之功底。

然诸君不必畏难。天下难事,必作于易;天下大事,必作于细。🌈 自Blender直观之象入手,而数据结构,而矩阵原理,而递归实现,步步为营,久久为功,则终有豁然贯通之一日。

愿诸君以此篇为阶,登堂入室,于三维图形之广袤天地,纵横驰骋,游刃有余。🚀 路漫漫其修远兮,吾将上下而求索。与诸君共勉!


📌 本篇要点回顾:

  • ✅ 父子关系:父动子随,子变父安,单向传递

  • ✅ 数据结构:children数组 + parent指针,双向记录

  • ✅ addChild三步:查重、入列、认父,一步不可少

  • ✅ 两种矩阵:本地矩阵(自身)vs 世界矩阵(最终)

  • ✅ 递归计算:世界矩阵 = 本地矩阵 × 父世界矩阵

  • ✅ 乘法顺序:矩阵不可交换,顺序至关重要

  • ✅ 性能优化:脏标记 + 缓存,避免重复计算

— 全文完 —

🌟 感谢阅读,如有疑问,欢迎评论区交流 🌟