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

中级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 层级示例:祖孙三代的矩阵推演)
  • [🔄 第四章:递归妙法------层级矩阵的链式推演 🌀](#🔄 第四章:递归妙法——层级矩阵的链式推演 🌀)
  • [💻 第五章:代码实战------从理论到工程的落地之路 ⚒️](#💻 第五章:代码实战——从理论到工程的落地之路 ⚒️)
    • [🛠️ 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 同步视频

中级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类之中,当添二成员变量:

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 世界矩阵(最终)

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

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

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

--- 全文完 ---

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

相关推荐
ch0sen1pm9 小时前
刚学完 CAS 原子操作,我花了一晚上写了三个无锁队列
c++
qeen879 小时前
【C++】vector的模拟实现详解(二)
c++·学习·算法·迭代器·stl
j7~9 小时前
【C++】C++ 继承全解析:从基本语法到菱形继承的底层原理
开发语言·c++·继承·组合·虚继承·#暑假·七月创作之星博客挑战赛
数行拙笔9 小时前
C++客户端---String类型
开发语言·c++·bootstrap
草莓熊Lotso9 小时前
【Linux网络】深入理解Linux IO多路复用:从本质到select服务器实战
linux·运维·服务器·c语言·网络·数据库·c++
selfsongs21 小时前
指针、内存管理、现代 C++ 特性
c++
吃着火锅x唱着歌1 天前
Effective C++ 学习笔记 条款37 绝不重新定义继承而来的缺省参数值
c++·笔记·学习
2401_841495641 天前
【数据结构】B+树
数据结构·数据库·c++·b+树·概念·结构·操作原理