问题描述
我对使用 SDL、OpenGL 和 C++ 的游戏开发非常深入,并且正在寻找方法来优化游戏在 GLSL 着色器之间为许多不同类型的不同对象切换的方式。这更像是一个 C++ 问题而不是一个 OpenGL 问题。但是,我仍然想提供尽可能多的上下文,因为我觉得需要一些理由来说明为什么我需要的建议的 Shader 类需要按原样创建/删除。
前四个部分是我在此之前的理由、旅程和尝试,但是我的问题很可能仅通过最后一部分就可以回答,而且我有意将其写成一个 tldr。
Shader 类的必要性:
在游戏过程中创建游戏对象时,我在网上看到许多 OpenGL 着色器的实现都是在同一个函数中创建、编译和删除的。事实证明,这在我游戏的特定部分效率低下且速度太慢。因此,我需要一个系统来在加载时创建和编译着色器,然后在游戏期间在它们之间间歇性地使用/交换,然后再被删除。
这导致了管理 OpenGL 着色器的类 (Shader
) 的创建。类的每个实例都应该管理一个唯一的 OpenGL 着色器,并包含一些围绕着色器类型的复杂行为,从哪里加载它,从哪里使用它,它采用的统一变量等。
话虽如此,这个类最重要的作用是存储从gluint
返回的id
变量glCreateShader()
,并管理所有与OpenGL着色器相关的OpenGL调用有了这个id
。我知道鉴于 OpenGL 的全局性质,这实际上是徒劳的(因为程序中的任何地方都可以在技术上调用 glDeleteShader()
并使用匹配的 id
并破坏类),但是为了有意封装所有 OpenGL在整个代码库中调用非常特定的区域,该系统将显着降低代码复杂性。
问题从哪里开始...
管理此 gluint id
的最“自动”方式是在对象的构造上调用 glCreateShader()
,在对象的销毁上调用 glDeleteShader()
。这保证(在 OpenGL 限制内)OpenGL 着色器将在 C++ 着色器对象的整个生命周期中存在,并且无需调用某些 void createShader()
和 deleteShader()
函数。
这一切都很好,但是在考虑复制此对象会发生什么时很快就会出现问题。如果这个对象的副本被破坏了怎么办?这意味着将调用 glDeleteShader()
并有效地破坏着色器对象的所有副本。
诸如在着色器向量中意外调用 std::vector::push_back()
之类的简单错误呢?各种 std::vector
方法可以调用其类型的构造函数/复制构造函数/析构函数,这可能会导致与上述相同的问题。
好吧...我们创建一些 void createShader()
和 deleteShader()
方法怎么样,即使它很乱?不幸的是,这只是推迟了上述问题,因为再次任何修改OpenGL着色器的调用将不同步/彻底破坏具有相同id
的着色器类的所有副本。在此示例中,我将 OpenGL 调用限制为 glCreateShader()
和 glDeleteShader()
以保持简单,但是我应该注意到类中还有许多其他 OpenGL 调用可以创建各种实例/静态变量跟踪实例副本太复杂而无法证明这样做的合理性。
在进入下面的类设计之前,我想说的最后一点是,对于像原始 C++、OpenGL 和 SDL 游戏这样大的项目,我更希望我犯的任何潜在 OpenGL 错误生成编译器错误而不是图形更难追查的问题。这可以体现在下面的类设计中。
Shader
类的第一个版本:
正是基于上述原因,我选择:
- 使构造函数
private
。 - 提供一个公共
static create
函数,该函数返回一个指向新 Shader 对象的指针,而不是构造函数。 - 使复制构造函数
private
。 - 使
operator=
private
(虽然这可能不是必需的)。 - 将析构函数设为私有。
- 在构造函数中调用
glCreateShader()
,在析构函数中调用glDeleteShader()
,以使 OpenGL 着色器在此对象的整个生命周期内都存在。 - 当
create
函数调用new
关键字(并返回指向它的指针)时,外部调用Shader::create()
的地方必须手动调用delete
(稍后会详细介绍)。
据我所知,前两个要点利用了工厂模式,如果尝试创建类的非指针类型,则会产生编译器错误。然后,第三、第四和第五个要点会阻止对象被复制。然后,第七个要点确保 OpenGL 着色器将在 C++ 着色器对象的相同生命周期内存在。
智能指针和主要问题:
在上述内容中,我唯一不喜欢的就是 new
/delete
调用。考虑到类试图实现的封装,它们还会使对象析构函数中的 glDeleteShader()
调用感觉不合适。鉴于此,我选择:
- 更改
create
函数以返回std::unique_ptr
类型的Shader
而不是Shader
指针。
create
函数看起来像这样:
std::unique_ptr<Shader> Shader::create() {
return std::make_unique<Shader>();
}
但随后出现了一个新问题……不幸的是,std::make_unique
要求 构造函数 是 public
,这会干扰上一节中描述的必要性。幸运的是,我通过将其更改为以下内容找到了解决方案:
std::unique_ptr<Shader> Shader::create() {
return std::unique_ptr<Shader>(new Shader());
}
但是……现在 std::unique_ptr
要求 析构函数 是公开的!这是...更好但不幸的是,这意味着可以在类外部手动调用析构函数,这反过来意味着可以从类外部调用 glDeleteShader()
函数。
Shader* p = Shader::create();
p->~Shader(); // Even though it would be hard to do this intentionally,I don't want to be able to do this.
delete p;
最后一课:
为了简单起见,我删除了大部分实例变量、函数/构造函数参数和其他属性,但这是最终提议的类(大部分)的样子:
class GLSLShader {
public:
~GLSLShader() { // OpenGL delete calls for id }; // want to make this private.
static std::unique_ptr<GLSLShader> create() { return std::unique_ptr<GLSLShader>(new GLSLShader()); };
private:
GLSLShader() { // OpenGL create calls for id };
GLSLShader(const GLSLShader& glslShader);
GLSLShader& operator=(const GLSLShader&);
gluint id;
};
除了析构函数是公开的这一事实之外,我对这个类中的一切都很满意。我已经对这个设计进行了测试,性能提升非常明显。尽管我无法想象我会不小心在 Shader
对象上手动调用析构函数,但我不喜欢它公开暴露。我也觉得我可能会不小心漏掉一些东西,比如第二部分中的 std::vector::push_back
考虑。
我找到了两个可能的解决方案来解决这个问题。我想就这些或其他解决方案提供一些建议。
-
使
std::unique_ptr
或std::make_unique
成为friend
类的Shader
。我一直在阅读诸如 this one 之类的线程,但是这是为了使构造函数可访问,而不是析构函数。我也不太明白将std::unique_ptr
或std::make_unique
设为friend
(该主题的最佳答案 + 评论)需要考虑的缺点/额外注意事项? -
根本不使用智能指针。有没有办法让我的
static create()
函数返回一个原始指针(使用new
关键字),当Shader
超出范围并且析构函数被调用?
非常感谢您抽出宝贵时间。
解决方法
这是一个上下文挑战。
你解决了错误的问题。
GLuint id
,将在对象的构造上调用 glCreateShader()
和 glDeleteShader()
在这里解决问题。
零规则是让资源包装器管理生命周期,而不是在业务逻辑类型中执行此操作。我们可以围绕 GLuint
编写一个包装器,该包装器知道如何清理自身并且只能移动,通过劫持 std::unique_ptr
来存储整数而不是指针来防止双重破坏。
我们开始:
// "pointers" in unique ptrs must be comparable to nullptr.
// So,let us make an integer qualify:
template<class Int>
struct nullable{
Int val=0;
nullable()=default;
nullable(Int v):val(v){}
friend bool operator==(std::nullptr_t,nullable const& self){return !static_cast<bool>(self);}
friend bool operator!=(std::nullptr_t,nullable const& self){return static_cast<bool>(self);}
friend bool operator==(nullable const& self,std::nullptr_t){return !static_cast<bool>(self);}
friend bool operator!=(nullable const& self,std::nullptr_t){return static_cast<bool>(self);}
operator Int()const{return val;}
};
// This both statelessly stores the deleter,and
// tells the unique ptr to use a nullable<Int> instead of an Int*:
template<class Int,void(*deleter)(Int)>
struct IntDeleter{
using pointer=nullable<Int>;
void operator()(pointer p)const{
deleter(p);
}
};
// Unique ptr's core functionality is cleanup on destruction
// You can change what it uses for a pointer.
template<class Int,void(*deleter)(Int)>
using IntResource=std::unique_ptr<Int,IntDeleter<Int,deleter>>;
// Here we statelessly remember how to destroy this particular
// kind of GLuint,and make it an RAII type with move support:
using GLShaderResource=IntResource<GLuint,glDeleteShader>;
现在该类型知道它是一个着色器并清除它自己非空。
GLShaderResource id(glCreateShader());
SomeGLFunction(id.get());
对于任何错别字深表歉意。
你的类中的东西,复制 ctors 被阻止,移动 ctors 做正确的事情,dtors 自动清理等等。
struct GLSLShader {
// public!
~GLSLShader() = default;
GLSLShader() { // OpenGL create calls for id };
private: // does this really need to be private?
GLShaderResource id;
};
简单多了。
std::vector<GLSLShader> v;
这很管用。我们的 GLShaderResource
是半规则的(只移动规则类型,不支持排序),vector
对这些很满意。 0 规则意味着拥有它的 GLSLShader
也是半规则的并且支持 RAII -- 资源分配是初始化 -- 这反过来意味着它在存储在 std
容器中时会正确地自行清理.
“Regular”类型意味着它“表现得像一个 int
”——就像原型值类型一样。当您使用常规或半常规类型时,C++ 的标准库和大部分 C++ 都喜欢它。
请注意,这基本上是零开销; sizeof(GLShaderResource)
与 GLuint
相同,堆上没有任何内容。我们有一堆编译时类型的机器包装了一个简单的 32 位整数;编译时类型机器生成代码,但不会使数据比 32 位更复杂。
开销包括:
-
某些调用约定使得只传递一个
struct
包装的int
与传递一个int
不同。 -
在销毁时,我们检查其中的每一个以查看是否由
0
决定我们是否要调用glDeleteShader
;编译器有时可以证明某些东西保证为零并跳过该检查。但它不会告诉你它是否确实成功了。 (OTOH,众所周知,人类在证明他们跟踪所有资源方面很糟糕,所以一些运行时检查并不是最糟糕的事情)。 -
如果您正在执行完全未优化的构建,则在调用 OpenGL 函数时会有一些额外的指令。但是在编译器进行任何非零级别的
inline
ing 后,它们将消失。 -
该类型在某些方面(可复制、可销毁、可构造)不是“微不足道的”(C++ 标准中的一个术语),这使得在 C++ 标准下执行
memset
之类的操作是非法的;你不能用一些低级的方式把它当作原始内存。
有问题!
许多 OpenGL 实现都有指向 glDeleteShader
/glCreateShader
等的指针,上面依赖于它们是实际函数,而不是指针或宏或其他任何东西。
有两种简单的解决方法。第一个是在上面的 &
参数中添加一个 deleter
(两个点)。这有一个问题,它只在它们现在实际上是指针时才有效,而当它们是实际函数时就无效了。
制作在这两种情况下都有效的代码有点棘手,但我认为几乎每个 GL 实现都使用函数指针,所以除非您想制作“库质量”的实现,否则您应该很好。在这种情况下,您可以编写一些辅助类型来创建按名称调用(或不调用)函数指针的 constexpr 函数指针。
最后,显然有些析构函数需要额外的参数。这是草图。
using GLuint=std::uint32_t;
GLuint glCreateShaderImpl() { return 7; }
auto glCreateShader = glCreateShaderImpl;
void glDeleteShaderImpl(GLuint x) { std::cout << x << " deleted\n"; }
auto glDeleteShader = glDeleteShaderImpl;
std::pair<GLuint,GLuint> glCreateTextureWrapper() { return {7,1024}; }
void glDeleteTextureImpl(GLuint x,GLuint size) { std::cout << x << " deleted size [" << size << "]\n"; }
auto glDeleteTexture = glDeleteTextureImpl;
template<class Int>
struct nullable{
Int val=0;
nullable()=default;
nullable(Int v):val(v){}
nullable(std::nullptr_t){}
friend bool operator==(std::nullptr_t,std::nullptr_t){return static_cast<bool>(self);}
operator Int()const{return val;}
};
template<class Int,auto& deleter>
struct IntDeleter;
template<class Int,class...Args,void(*&deleter)(Int,Args...)>
struct IntDeleter<Int,deleter>:
std::tuple<std::decay_t<Args>...>
{
using base = std::tuple<std::decay_t<Args>...>;
using base::base;
using pointer=nullable<Int>;
void operator()(pointer p)const{
std::apply([&p](std::decay_t<Args> const&...args)->void{
deleter(p,args...);
},static_cast<base const&>(*this));
}
};
template<class Int,void(*&deleter)(Int)>
using IntResource=std::unique_ptr<Int,deleter>>;
using GLShaderResource=IntResource<GLuint,glDeleteShader>;
using GLTextureResource=std::unique_ptr<GLuint,IntDeleter<GLuint,glDeleteTexture>>;
int main() {
auto res = GLShaderResource(glCreateShader());
std::cout << res.get() << "\n";
auto tex = std::make_from_tuple<GLTextureResource>(glCreateTextureWrapper());
std::cout << tex.get() << "\n";
}
,
自己实现一个deleter,并让deleter 成为您班级的朋友。 然后像这样编辑您的声明:
static std::unique_ptr<GLSLShader,your_deleter> create();