需要预先学习:


1. 网格(Mesh)

在 几何体 中你已经见过网格的定义:一组顶点,加上描述顶点如何连成三角形的索引。缓冲对象与顶点 里又把这些数据装进了 VertexBuffer 和 IndexBuffer,并用 VertexBufferLayout 描述顶点格式。这一篇把三样东西组合成 Mesh 类——一个构造即上传、析构即释放的几何体对象,并顺着它引出材质与模型两个更高层的封装。


1.1 网格类的设计

Mesh 不引入新的 OpenGL 概念,它是一层纯粹的组织:把"绘制一个几何体所需的三样数据"收进同一个类。

成员 存放的内容 对应的概念
VertexBuffer 一串 float 顶点数据(位置、UV、法线……)
IndexBuffer 一串 uint32_t 顶点如何组成三角形
VertexBufferLayout 属性列表 每个顶点的数据怎么切分、解读

三者在绘制时总是同时出现:单有 VertexBuffer,GPU 眼里只是一串没有含义的数字,要靠 layout 才知道怎么切成一个个顶点;索引则决定这些顶点按什么顺序拼成三角形。与其在渲染代码里散装管理三个对象,不如组合成一个 Mesh,生命周期也随之统一——构造时数据上传 GPU,析构时自动释放。

在这个基础上,有几个设计上的决定:

禁止拷贝,允许移动。 VertexBuffer 和 IndexBuffer 各自持有 GPU 资源(内部的 GLuint ID),一份资源只能有一个所有者:如果两个 Mesh 持有同一个 ID,先析构的那个会把资源删掉,另一个手里的 ID 就成了悬空引用。移动语义在这里还有现实用途——工厂方法按值返回 Mesh,返回的过程正是靠移动构造把 GPU 资源从局部对象转移到调用方手里。

用工厂方法生成常用几何体。 三角形、矩形这类几个顶点的几何体,手写顶点数组还能接受;球体有上百个顶点,手写既不现实也没必要——顶点位置有明确的数学规律,用代码生成才靠谱。create_cuboid、create_sphere 这些工厂方法(factory method,以静态成员函数代替构造函数来创建对象)接收尺寸、分段数等参数,内部程序化生成顶点与索引,一次调用得到完整的几何体。

统一的默认顶点布局。 工厂方法都按 default_layout 生成顶点:位置 Float3 + UV Float2 + 法线 Float3,每个顶点恰好 8 个 float。前两个属性在 纹理 里已经用过;法线(normal,垂直于表面的单位向量)记录表面朝向,是光照计算的输入,着色与渲染管线 中有它的用法。

注:UV 和法线在画纯色几何体时用不上,但依然一开始就放进顶点数据——真实模型的顶点本来就带这些属性,按"模型级"的格式生成几何体,后面接材质、加载模型文件时都不必回头改顶点格式。


1.2 具体实现

头文件与实现如下,工厂方法的实现要点放在代码后面讲:

// Mesh.hpp
#pragma once

#include "OpenGL.hpp"

using namespace MY::OpenGL;

class Mesh
{
public:
    Mesh() = delete;
    ~Mesh();

    Mesh(const VertexBuffer& vb, const IndexBuffer& ib, 
        const VertexBufferLayout& layout = VertexBufferLayout::default_layout());

    Mesh(const Mesh&) = delete;
    Mesh& operator=(const Mesh&) = delete;
    Mesh(Mesh&&) noexcept;
    Mesh& operator=(Mesh&&) noexcept;

public:
    const VertexBuffer& get_vertex_buffer() const { return m_vb; }
    const uint32_t get_vertex_count() const { return m_vb.get_vertex_count(); }

    const IndexBuffer& get_index_buffer() const { return m_ib; }
    const uint32_t get_index_count() const { return m_ib.get_index_count(); }

    const VertexBufferLayout& get_layout() const { return m_layout; }

private:
    VertexBuffer            m_vb;
    IndexBuffer             m_ib;
    VertexBufferLayout      m_layout;

public:
    // 工厂方法,创建一些常用的 Mesh 对象,按照 default_layout 创建

    /**
     *  创建一个立方体
     *  @param width 立方体的宽度
     *  @param height 立方体的高度
     *  @param depth 立方体的深度
     */
    static Mesh create_cuboid(float width, float height, float depth);

    /**
     *  创建一个正方体
     *  @param size 正方体的边长
     */
    static Mesh create_cube(float size);

    /**
     *  创建一个球体
     *  @param radius 球体的半径
     *  @param longitude_segments 经度分段数,越大球体越圆
     *  @param latitude_segments 纬度分段数,越大球体越圆
     */
    static Mesh create_sphere(float radius, uint32_t longitude_segments = 32, uint32_t latitude_segments = 16);
};
// Mesh.cpp
#include "Mesh.hpp"

using namespace MY;
using namespace MY::OpenGL;

static constexpr float PI = 3.14159265358979323846f;

Mesh::Mesh(VertexBuffer&& vb, IndexBuffer&& ib, const VertexBufferLayout& layout)
    : m_vb(std::move(vb)), m_ib(std::move(ib)), m_layout(layout) { }

Mesh::Mesh(Mesh&& other) noexcept
    : m_vb(std::move(other.m_vb)), m_ib(std::move(other.m_ib)), m_layout(other.m_layout) { }

Mesh& Mesh::operator=(Mesh&& other) noexcept
{
    if(this != &other)
    {
        m_vb = std::move(other.m_vb);
        m_ib = std::move(other.m_ib);
        m_layout = other.m_layout;
    }
    return *this;
}

Mesh Mesh::create_cuboid(float width, float height, float depth)
{
    // 准备数据
    float hw = width  * 0.5f;
    float hh = height * 0.5f;
    float hd = depth  * 0.5f;

    // 24 个顶点(6 个面 × 4 顶点/面),每个顶点 8 个 float:位置(3) + UV(2) + 法线(3)
    // 法线方向:后面(0,0,-1)  前面(0,0,1)  右面(1,0,0)  左面(-1,0,0)  上面(0,1,0)  下面(0,-1,0)
    float vertices[192] = {
        // 后面 (z = -hd), 法线 (0, 0, -1)
        -hw, -hh, -hd,  0.0f, 0.0f,   0.0f,  0.0f, -1.0f,   // 0: 后-左下
        hw, -hh, -hd,  1.0f, 0.0f,   0.0f,  0.0f, -1.0f,   // 1: 后-右下
        hw,  hh, -hd,  1.0f, 1.0f,   0.0f,  0.0f, -1.0f,   // 2: 后-右上
        -hw,  hh, -hd,  0.0f, 1.0f,   0.0f,  0.0f, -1.0f,   // 3: 后-左上

        // 前面 (z = +hd), 法线 (0, 0, 1)
        -hw, -hh,  hd,  0.0f, 0.0f,   0.0f,  0.0f,  1.0f,   // 4: 前-左下
        hw, -hh,  hd,  1.0f, 0.0f,   0.0f,  0.0f,  1.0f,   // 5: 前-右下
        hw,  hh,  hd,  1.0f, 1.0f,   0.0f,  0.0f,  1.0f,   // 6: 前-右上
        -hw,  hh,  hd,  0.0f, 1.0f,   0.0f,  0.0f,  1.0f,   // 7: 前-左上

        // 右面 (x = +hw), 法线 (1, 0, 0)
        hw, -hh, -hd,  0.0f, 0.0f,   1.0f,  0.0f,  0.0f,   // 8:  右-后下
        hw, -hh,  hd,  1.0f, 0.0f,   1.0f,  0.0f,  0.0f,   // 9:  右-前下
        hw,  hh,  hd,  1.0f, 1.0f,   1.0f,  0.0f,  0.0f,   // 10: 右-前上
        hw,  hh, -hd,  0.0f, 1.0f,   1.0f,  0.0f,  0.0f,   // 11: 右-后上

        // 左面 (x = -hw), 法线 (-1, 0, 0)
        -hw, -hh,  hd,  0.0f, 0.0f,  -1.0f,  0.0f,  0.0f,   // 12: 左-前下
        -hw, -hh, -hd,  1.0f, 0.0f,  -1.0f,  0.0f,  0.0f,   // 13: 左-后下
        -hw,  hh, -hd,  1.0f, 1.0f,  -1.0f,  0.0f,  0.0f,   // 14: 左-后上
        -hw,  hh,  hd,  0.0f, 1.0f,  -1.0f,  0.0f,  0.0f,   // 15: 左-前上

        // 上面 (y = +hh), 法线 (0, 1, 0)
        -hw,  hh, -hd,  0.0f, 0.0f,   0.0f,  1.0f,  0.0f,   // 16: 上-后左
        hw,  hh, -hd,  1.0f, 0.0f,   0.0f,  1.0f,  0.0f,   // 17: 上-后右
        hw,  hh,  hd,  1.0f, 1.0f,   0.0f,  1.0f,  0.0f,   // 18: 上-前右
        -hw,  hh,  hd,  0.0f, 1.0f,   0.0f,  1.0f,  0.0f,   // 19: 上-前左

        // 下面 (y = -hh), 法线 (0, -1, 0)
        -hw, -hh,  hd,  0.0f, 0.0f,   0.0f, -1.0f,  0.0f,   // 20: 下-前左
        hw, -hh,  hd,  1.0f, 0.0f,   0.0f, -1.0f,  0.0f,   // 21: 下-前右
        hw, -hh, -hd,  1.0f, 1.0f,   0.0f, -1.0f,  0.0f,   // 22: 下-后右
        -hw, -hh, -hd,  0.0f, 1.0f,   0.0f, -1.0f,  0.0f,   // 23: 下-后左
    };

    // 每面两个三角形,保持逆时针 winding
    uint32_t indices[36] = {
        0, 2, 1,  2, 0, 3,   // 后面
        4, 5, 6,  6, 7, 4,   // 前面
        8,10, 9, 10, 8,11,   // 右面
        12,14,13, 14,12,15,   // 左面
        16,18,17, 18,16,19,   // 上面
        20,23,22, 22,21,20,   // 下面
    };

    VertexBuffer vb(vertices, 24, 8 * sizeof(float));
    IndexBuffer ib(indices, 36);

    Mesh mesh(std::move(vb), std::move(ib));

    return mesh;
}

Mesh Mesh::create_cube(float size)
{
    return create_cuboid(size, size, size);
}

Mesh Mesh::create_sphere(float radius, uint32_t longitude_segments, uint32_t latitude_segments)
{
    std::vector<float> vertices;
    std::vector<uint32_t> indices;

    const float dlon = 2.0f * PI / static_cast<float>(longitude_segments);
    const float dlat = PI / static_cast<float>(latitude_segments);

    // 生成顶点:纬度从北极到南极,经度绕 Y 轴
    for(uint32_t lat = 0; lat <= latitude_segments; ++lat)
    {
        float theta = lat * dlat;
        float sin_theta = std::sin(theta);
        float cos_theta = std::cos(theta);

        for(uint32_t lon = 0; lon <= longitude_segments; ++lon)
        {
            float phi = lon * dlon;
            float sin_phi = std::sin(phi);
            float cos_phi = std::cos(phi);

            float x = radius * sin_theta * cos_phi;
            float y = radius * cos_theta;
            float z = radius * sin_theta * sin_phi;

            float u = static_cast<float>(lon) / static_cast<float>(longitude_segments);
            float v = 1.0f - static_cast<float>(lat) / static_cast<float>(latitude_segments);

            vertices.insert(vertices.end(), {
                x, y, z,
                u, v,
                x / radius, y / radius, z / radius
            });
        }
    }

    for(uint32_t lat = 0; lat < latitude_segments; ++lat)
    {
        for(uint32_t lon = 0; lon < longitude_segments; ++lon)
        {
            uint32_t a = lat * (longitude_segments + 1) + lon;
            uint32_t b = a + 1;
            uint32_t c = (lat + 1) * (longitude_segments + 1) + lon;
            uint32_t d = c + 1;

            indices.insert(indices.end(), { a, b, d,  d, c, a });
        }
    }

    VertexBuffer vb(vertices.data(), static_cast<uint32_t>(vertices.size() / 8), 8 * sizeof(float));
    IndexBuffer ib(indices.data(), static_cast<uint32_t>(indices.size()));

    Mesh mesh(std::move(vb), std::move(ib));

    return mesh;
}

几个点值得注意:

  • 立方体是 24 个顶点,不是 8 个。立方体只有 8 个角点,但同一个角点分属相邻的三个面,法线与 UV 各不相同——位置属于几何,法线属于面,UV 属于贴图方式,三者的组合无法跨面共享。于是每个面独立使用 4 个顶点,6 面 × 4 = 24,索引也随之变成每面各画两个三角形,不再跨面复用顶点
  • 球体在接缝处多存一列顶点。经度 lon = 0 与 lon = longitude_segments 两列顶点的位置完全重合,却各存一份,因为它们的 u 坐标一个是 0、一个是 1:如果共享顶点,接缝处的 u 只能取一个值,那一圈三角形的贴图会被横向压缩成一细条;分成两列存放,两侧的 UV 插值才能各自正确
  • 球体的法线就是单位化的位置。球心在原点时,球面上任意一点相对球心的方向恰好就是该点的法线方向,直接取 x / radius, y / radius, z / radius,不需要额外计算
  • v 坐标做了一次翻转。顶点按纬度从北极(theta = 0)生成到南极,而纹理 v = 1 在上方,因此 v = 1 - lat / latitude_segments,让北极对应贴图顶部、南极对应底部
  • 移动构造与移动赋值复用成员的移动操作。vb、ib 的移动负责转移 GLuint ID 并把源对象置 0,layout 是纯数据直接拷贝;赋值运算符里的自赋值检查不能省——成员的移动赋值会先释放旧资源,自赋值时等于先删掉资源、再转移一个已删除的 ID

2. 材质(Material)

Mesh 回答了"画什么形状",还没回答"长什么样"。同一个立方体,木头和金属是两种外观——决定外观的不是顶点数据,而是着色时用到的那一组资源。把"一个物体怎么被着色"所需的资源打包在一起,就是材质(Material)。


2.1 材质类的设计

先盘点决定外观的资源,它们你都已经见过:

  • 着色器程序(着色器):决定光照模型——顶点如何变换、颜色如何计算
  • 纹理(纹理):决定表面图案;一个材质可以有多个纹理槽位,如漫反射贴图、镜面反射贴图
  • 数值参数:贴图表达不了的属性,如反光度 shininess、整体色调、自发光强度,最终都通过 uniform 传给着色器

Material 类就是这三样的容器,外加一个核心方法 bind()——把材质设置到渲染管线上。它做的事与 纹理 中"应用纹理"一节完全对应:激活纹理单元、绑定纹理、用 glUniform1i 告诉采样器去哪个单元取纹理,最后把数值参数逐个上传。绘制时先 material.bind(),紧接着绘制对应的网格,这个网格就呈现出材质描述的外观。

一个关键设计是材质不拥有着色器程序。着色器与材质是一对多的关系:场景里十几种材质可能共用同一个光照着色器,区别只在贴图与参数。如果每个材质各自持有一份 ShaderProgram:

  • 同一段着色器被重复创建与链接,浪费 GPU 资源和初始化时间
  • 渲染器无法按着色器给绘制排序——glUseProgram 的切换有开销,把同着色器的材质排在一起连续绘制是常见的优化

因此 Material 持有的是外部 ShaderProgram 的引用或指针,着色器的生命周期由上层管理。这与 Mesh 形成对比:Mesh 拥有 vb 和 ib,因为顶点数据是几何体私有的;Material 引用 shader,因为着色器是跨材质共享的。所有权跟着"是否共享"走,这是资源封装的普遍规律。

注:GLSL 端同样有"材质"的概念——着色器中装着贴图采样器与反光度等参数的 uniform 结构体。C++ 端的 Material 类正是它的宿主侧对应物:持有这些资源,bind 时负责把它们的值填进 uniform。


2.2 具体实现

(具体实现待补充)


3. 模型(Model)

有了 Mesh(几何)与 Material(外观),模型(Model)把两者组装成一个可直接绘制的完整对象——它是前两层封装之上的总装层。


3.1 模型类的设计

最简单的模型就是"一个 Mesh + 一个 Material"。但真实模型很少只有一个部分:一辆车由车身、轮胎、车窗组成,每个部分是独立的网格,各有各的材质。所以 Model 内部持有的不是单个网格,而是一组"网格 + 材质"的组合:

Model
 ├── Mesh 1 + Material 1    // 车身:金属漆
 ├── Mesh 2 + Material 2    // 轮胎:橡胶
 └── Mesh 3 + Material 3    // 车窗:玻璃

绘制模型,就是遍历所有条目:绑定材质,绘制网格。

Model 的另一项职责是从模型文件加载。工厂方法能生成球体和立方体,但人物、建筑这类模型来自建模软件,以 obj、gltf 等格式导出。解析这些格式是繁琐的文本处理,通常交给现成的库(如 Assimp):读取文件 → 取出每个子网格的顶点与索引构造 Mesh → 取出材质的贴图路径,加载 Texture、组装 Material。Model 的加载过程,本身就是前两层封装的消费者。

最后是所有权链条。Model 拥有它的 Mesh 与 Material,Mesh 拥有 vb 和 ib,Texture 拥有纹理 ID——一条完整的 RAII 链:Model 析构时成员逐层自动析构,所有 GPU 资源一并释放。这也是前面每一层封装都坚持"禁止拷贝、允许移动"的原因:类与类层层嵌套,任何一层允许拷贝,独占所有权就会在组合时被打穿。


3.2 具体实现

(具体实现待补充)