享元模式(Flyweight)
迷雾散去,显露雄伟的古老森林;阳光洒落,尽显各式的繁枝绿叶。
这是我们游戏开发者梦想的超凡场景,这样的场景通常由一个模式支撑着,它的名字低调至极:享元模式。
森林
我可以用几句话描述一片森林,但是在游戏中实现却是另外一回事。当你看到一整片树林填满屏幕时,图像编程人员所知道的是他们必须每1/60秒就要将数百万个面(多边形)发送给GPU。
我们所说的成千上万的树,一颗树就要数千个面(多边形)去描述。即使有足够多的内存存储这片森林,为了渲染它,数据也必须从CPU传送到GPU,这会造成CPU到GPU的带宽总线紧张(受限)。
那么描述一棵树大概需要哪些元素呢?
- 描述树干、树枝和绿叶形状的多边形网格
- 树皮和树叶的纹理。
- 在森林中的位置和朝向
- 使得每棵树有所不同的可调参数:大小/色彩等
如果在代码中描述一棵树,那么大概会是如下所示:
c++
class Tree
{
private:
Mesh mesh_;
Texture bark_;
Texture leaves_;
Vector position_;
double height_;
double thickness_;
Color barkTint_;
Color leafTint_;
};
从上代码可知,一个Tree对象含有很多数据,其中的网格和纹理数据量尤其大。在游戏更新的一帧时间内是不可能把一片森林的树的对象全部发送给GPU的。幸运的是享元模式可以解决这个问题。
关键就是即使森林中有成千的树,这些树是相似的。树使用了相同的网格和纹理。即不同实例化后的Tree对象中mesh_/bark_/leaves_是一样的。因此,我们可以把这些共享的元素剥离出来,形成下述的分离的两个类:
c++
// 共享的类
class TreeModel
{
private:
Mesh mesh_;
Texture bark_;
Texture leaves_;
};
// 细分的类
class Tree
{
private:
TreeModel* model_; // 指向共享的对象
Vector position_;
double height_;
double thickness_;
Color barkTint_;
Color leafTint_;
};
地形
上面提到了森林中的树木,那么游戏中必然涉及到了树木所位于的地形。为了加深对这个模式的理解,我们继续讨论游戏中的地形问题。
游戏中的地形有各式各样:草地、沙地、丘陵、河流等。不同地形在游戏中可能有以下主要的属性:
- 人物角色在该地的移速
- 人物是否可以行走的标识
- 用于渲染的纹理
那么我们自然可以想到如何优雅地表示地形:
c++
class Terrain
{
public:
Terrain(int movementCost,
bool isAccess,
Texture texture)
: movementCost_(movementCost),
isAccess_(isAccess),
texture_(texture)
{}
int getMovementCost() const { return movementCost_; }
bool isAccess() const { return isAccess_; }
const Texture& getTexture() const { return texture_; }
private:
int movementCost_;
bool isAccess_;
Texture texture_;
};
自然地,那么整个游戏世界上的地形该如何表达呢?最直接简单的如下所示:
c++
class World
{
void setTerrain(int x, int y, Terrain dst) {
tiles_[x][y] = dst;
}
private:
Terrain tiles_[WIDTH][HEIGHT];
// Other stuff...
};
那么上述的地形如何使用享元模式呢?请看下述的例子:
c++
World wd;
wd.setTerrain(0, 0, Terrain(1, true, GRASS_TEXTURE)); // 草地
wd.setTerrain(0, 1, Terrain(1, true, GRASS_TEXTURE)); // 草地
wd.setTerrain(1, 0, Terrain(3, true, HILL_TEXTURE)); // 丘陵
wd.setTerrain(1, 1, Terrain(3, true, HILL_TEXTURE)); // 丘陵
wd.setTerrain(2, 0, Terrain(1, false, RIVER_TEXTURE)); // 河流
wd.setTerrain(2, 1, Terrain(1, false, RIVER_TEXTURE)); // 河流
这个例子中,[0,0]和[0,1]位置上的都是使用了相同的草地地形,Terrain实例元素完全一致,跟外部的位置因素没有任何关系,因此这个Terrain元素可以分离出来的元素。World实现变为如下所示:
c++
class World
{
void setTerrain(int x, int y, Terrain* dst) {
tiles_[x][y] = dst;
}
private:
Terrain* tiles_[WIDTH][HEIGHT];
// Other stuff...
};
最终的地形赋值如下所示:
c++
World wd;
Terrain grassTerrain = Terrain(1, true, GRASS_TEXTURE);
Terrain hillTerrain = Terrain(3, true, HILL_TEXTURE);
Terrain riverTerrain = Terrain(1, false, RIVER_TEXTURE);
wd.setTerrain(0, 0, &grassTerrain); // 草地
wd.setTerrain(0, 1, &grassTerrain); // 草地
wd.setTerrain(1, 0, &hillTerrain); // 丘陵
wd.setTerrain(1, 1, &hillTerrain); // 丘陵
wd.setTerrain(2, 0, &riverTerrain); // 河流
wd.setTerrain(2, 1, &riverTerrain); // 河流
享元模式(关键概念)
享元模式(Flyweight Pattern)是一种结构型设计模式,用于通过共享尽可能多的对象,以减少内存占用和提高性能。
它将对象的状态分为内部状态
与外部状态
,从而实现对可共享部分的复用。
概念 | 说明 | 示例 |
---|---|---|
Flyweight(享元对象) | 表示可以共享的对象,封装内部状态。 | 共享的网格、纹理、地形数据 |
Intrinsic State(内部状态) | 对象中可被多个实例共享的部分,不随环境变化。 | 同上 |
Extrinsic State(外部状态) | 每次使用享元时由外部传入的部分,不可共享。 | 树的位置、颜色、朝向、地形位置 |
Flyweight Factory(享元工厂) | 创建并管理享元对象的共享池,确保重复使用已有实例。 | MeshManager、MaterialCache |
Client(客户端) | 使用享元对象的代码部分,负责维护外部状态。 | 场景渲染器、Entity系统 |
享元模型在渲染中的作用(OpenGL实践)
上述的两个例子用了享元模式共享了很多数据,目前我们只看到了能够减少内存的作用,那么再来看下该模式是如何提升渲染过程的性能的?
我们以OpenGL为例,OpenGL天然支持享元模式。很多时候渲染的时候可以用共享的顶点数据,在屏幕上进行不同位置的渲染。如下所示
c++
#include <GL/glew.h>
#include <GLFW/glfw3.h>
#include <iostream>
#include <vector>
#include <cmath>
#include <cstdlib>
// 顶点着色器
const char* vertexShaderSrc = R"(
#version 330 core
layout(location = 0) in vec2 aPos; // 顶点坐标(共享)
layout(location = 1) in vec2 iOffset; // 实例位置
layout(location = 2) in float iScale; // 实例缩放
layout(location = 3) in vec3 iColor; // 实例颜色
out vec3 vColor;
void main()
{
vec2 pos = aPos * iScale + iOffset;
gl_Position = vec4(pos, 0.0, 1.0);
vColor = iColor;
}
)";
// 片段着色器
const char* fragmentShaderSrc = R"(
#version 330 core
in vec3 vColor;
out vec4 FragColor;
void main()
{
FragColor = vec4(vColor, 1.0);
}
)";
// Shader编译工具函数
GLuint createShader(GLenum type, const char* src)
{
GLuint shader = glCreateShader(type);
glShaderSource(shader, 1, &src, nullptr);
glCompileShader(shader);
GLint success;
glGetShaderiv(shader, GL_COMPILE_STATUS, &success);
if (!success)
{
char log[512];
glGetShaderInfoLog(shader, 512, nullptr, log);
std::cerr << "Shader compile error:\n" << log << std::endl;
}
return shader;
}
int main()
{
// 初始化GLFW
if (!glfwInit()) return -1;
GLFWwindow* window = glfwCreateWindow(800, 600, "Two-leaf Grass (Flyweight)", nullptr, nullptr);
if (!window) { glfwTerminate(); return -1; }
glfwMakeContextCurrent(window);
// 初始化GLEW
glewExperimental = GL_TRUE;
if (glewInit() != GLEW_OK)
{
std::cerr << "Failed to init GLEW\n";
return -1;
}
// -------------------------------
// 共享几何(每棵草由两片叶子组成)
// -------------------------------
float vertices[] = {
// 第一片叶子(往左)
-0.02f, 0.0f,
0.00f, 0.1f,
0.02f, 0.0f,
// 第二片叶子(往右)
0.00f, 0.0f,
0.02f, 0.1f,
0.04f, 0.0f
};
GLuint vao, vbo;
glGenVertexArrays(1, &vao);
glGenBuffers(1, &vbo);
glBindVertexArray(vao);
glBindBuffer(GL_ARRAY_BUFFER, vbo);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);
// -------------------------------
// 实例数据(位置 + 缩放 + 颜色)
// -------------------------------
const int NUM = 150;
std::vector<float> instanceData;
for (int i = 0; i < NUM; ++i)
{
float x = ((rand() % 200) / 100.0f - 1.0f); // [-1, 1]
float y = ((rand() % 100) / 100.0f - 1.0f); // [-1, 0]
float scale = 0.4f * ((rand() % 100) / 100.0f + 0.5f);
float r = 0.1f + (rand() % 30) / 255.0f;
float g = 0.6f + (rand() % 80) / 255.0f;
float b = 0.1f + (rand() % 30) / 255.0f;
instanceData.insert(instanceData.end(), { x, y, scale, r, g, b });
}
GLuint instanceVBO;
glGenBuffers(1, &instanceVBO);
glBindBuffer(GL_ARRAY_BUFFER, instanceVBO);
glBufferData(GL_ARRAY_BUFFER, instanceData.size() * sizeof(float), instanceData.data(), GL_STATIC_DRAW);
// 实例属性
glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)0);
glEnableVertexAttribArray(1);
glVertexAttribDivisor(1, 1);
glVertexAttribPointer(2, 1, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)(2 * sizeof(float)));
glEnableVertexAttribArray(2);
glVertexAttribDivisor(2, 1);
glVertexAttribPointer(3, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)(3 * sizeof(float)));
glEnableVertexAttribArray(3);
glVertexAttribDivisor(3, 1);
// 着色器程序
GLuint vs = createShader(GL_VERTEX_SHADER, vertexShaderSrc);
GLuint fs = createShader(GL_FRAGMENT_SHADER, fragmentShaderSrc);
GLuint shader = glCreateProgram();
glAttachShader(shader, vs);
glAttachShader(shader, fs);
glLinkProgram(shader);
glUseProgram(shader);
glClearColor(0.3f, 0.6f, 0.9f, 1.0f);
// -------------------------------
// 渲染循环
// -------------------------------
while (!glfwWindowShouldClose(window))
{
glClear(GL_COLOR_BUFFER_BIT);
glUseProgram(shader);
glBindVertexArray(vao);
glDrawArraysInstanced(GL_TRIANGLES, 0, 6, NUM);
glfwSwapBuffers(window);
glfwPollEvents();
}
// 清理
glDeleteVertexArrays(1, &vao);
glDeleteBuffers(1, &vbo);
glDeleteBuffers(1, &instanceVBO);
glfwTerminate();
return 0;
}
解析代码:
- vao: 使用了一份共同的vertices,代表叶子几何形状(两个三角形)
- instanceVBO: 表示每个实例的数据(位置 + 缩放 + 颜色)
- glDrawArraysInstanced:执行对实例的绘制
