网格、材质与模型
需要预先学习:
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 具体实现
(具体实现待补充)



