CVars
随着引擎的发展,我们发现引擎中越来越多的配置选项。 如果要在 UI 中调整它们,每次添加变量时都需要添加一些编辑代码。 这很快就会变得很烦人,而且这些配置变量也不是全局可访问的。
如果您看看像虚幻引擎或 IDTech 引擎这样的引擎是如何做的,它们有一个控制台变量系统。 用户可以声明任何他们想要的控制台变量 (CVAR),然后通过代码访问它。 将配置和调试选项都放在同一个中心位置提供了一些非常好的特性,例如拥有 1 个中心化的编辑方式,并且能够轻松地将它们保存到磁盘。
对于 vulkan 引擎,我们将实现一个 CVAR 系统,它与虚幻引擎中的系统非常相似,但高度简化。 这也是任何 Cpp 项目都可以做的事情,它不需要引擎其余部分的任何东西。 这里解释的 CVar 系统与为 Novus Core 项目创建的系统相同,但进行了一些小的调整。
用法
要声明 CVAR,就这样做
//checkbox CVAR
AutoCVar_Int CVAR_TestCheckbox("test.checkbox", "just a checkbox", 0, CVarFlags::EditCheckbox);
//int CVAR
AutoCVar_Int CVAR_TestInt("test.int", "just a configurable int", 42);
//float CVAR
AutoCVar_Int CVAR_TestFloat("test.float", "just a configurable float", 13.37);
//string CVAR
AutoCVar_String CVAR_TestString("test.string", "just a configurable string", "just a configurable string");
我们在任何 cpp 文件中将其声明为全局变量。 这样做会利用 Cpp 静态初始化来确保它在 CVarSystem(单例)中注册自己。 它可以添加到任何地方,包括函数内部。
创建 cvar 后,您可以直接使用它。
// get value
int var = CVAR_TestInt.Get();
//set value
CVAR_TestInt.Set(7);
使用 cvar 属性是访问 CVAR 最快和最有效的方式,因为它正确地缓存了数据。 但这不是唯一的方法。 拥有像这样的 CVars 的部分原因是它们可以在任何地方访问。
//returns a pointer because it will be nullptr if the cvar doesn't exist
int* value = CVarSystem::Get()->GetIntCvar("test.int");
if(value)
{
int var = *value;
}
//for the setter it can be done directly.
//if the cvar doesn't exist, this does nothing
CVarSystem::Get()->SetIntCvar("test.int",3);
这使得 CVars 成为一种全局数据库。 不要那样使用它。 CVars 旨在用于配置变量和其他类似的全局变量,因此最好避免将其用作全局变量存储。
最后,可以在运行时创建 cvars。 如果您从文件加载 cvars 或作为模组系统或脚本的一部分,这将非常有用。 上面的 AutoCvar 就是使用这个
CVarParameter* cvar = CVarSystem::Get()->CreateIntCVar("test.int2", "another int cvar", 3/*default value */, 3/* current value*/);
无法删除 cvars。 这是设计使然,因为删除这些全局变量没有多大意义。
所有 cvars 都将在 Imgui 菜单栏上的 cvar 编辑器菜单中获得一个位置。 这使得查看 cvar 值非常容易,并允许从引擎内部集中编辑它们。
现在让我们谈谈代码是如何工作的
架构
我们将开始编写主要的 CVarSystem 类。 这将是一个单例,以便可以根据需要在任何地方轻松访问它。
一个非常重要的细节是,我们将采用基于继承的 pImpl 模式。 我们将创建 2 个类,一个将是接口类(在头文件中),而另一个将是实际的实现。 CVarSystem 中的每个函数都将是纯虚函数,我们将在 cpp 文件中实现。
我们在私有实现方面非常激进,因为 cvar 系统是很快就会包含在整个代码库中的东西。 我们在实现中使用了 Imgui 以及一些 STL,所以我们真的不希望这些泄漏到公共接口中。
class CVarSystem
{
public:
//just a Get() for now
static CVarSystem* Get();
}
在 cpp 文件中,我们将添加实现类
class CVarSystemImpl : public CVarSystem
{
}
//static initialized singleton pattern
CVarSystem* CVarSystem::Get()
{
static CVarSystemImpl cvarSys{};
return &cvarSys;
}
Impl 类现在是空的,但我们实现了 CVarSystem::Get()
函数。 在那里,我们声明一个静态 CVarSystemImpl 对象,并返回其地址。 这被称为静态初始化的单例,它是 Cpp11 及更高版本中实现单例的现代方法。 它有一个非常有趣的特性,即由于函数内部静态变量的规则,Get() 是完全线程安全的。
为了存储 cvars,我们将实现一种将 cvars 存储到系统中的方法,为此,我们首先实现一个非常简单的 cvar 结构
cvars.h
enum class CVarFlags : uint32_t
{
None = 0,
Noedit = 1 << 1,
EditReadOnly = 1 << 2,
Advanced = 1 << 3,
EditCheckbox = 1 << 8,
EditFloatDrag = 1 << 9,
};
cvars.cpp
enum class CVarType : char
{
INT,
FLOAT,
STRING,
};
class CVarParameter
{
public:
friend class CVarSystemImpl;
int32_t arrayIndex;
CVarType type;
CVarFlags flags;
std::string name;
std::string description;
};
此 CVarParameter 将存储名称、描述和类型。 它还存储标志和 arrayIndex。 这些标志在头文件中声明,因为它们将是公共接口的一部分,但 CVarParameter 本身将是私有细节。
我们在参数内部存储 arrayIndex,因为我们将把 cvar 数据(实际的 int/float)存储到数组中,所以我们需要访问它们。 这样,我们就可以对所有类型的数据使用相同的结构。
让我们实现该数据数组。
cvars.cpp
template<typename T>
struct CVarStorage
{
T initial;
T current;
CVarParameter* parameter;
};
template<typename T>
struct CVarArray
{
CVarStorage<T>* cvars;
int32_t lastCVar{ 0 };
CVarArray(size_t size)
{
cvars = new CVarStorage<T>[size]();
}
~CVarArray()
{
delete cvars;
}
T GetCurrent(int32_t index)
{
return cvars[index].current;
};
void SetCurrent(const T& val, int32_t index)
{
cvars[index].current = val;
}
int Add(const T& value, CVarParameter* param)
{
int index = lastCVar;
cvars[index].current = value;
cvars[index].initial = value;
cvars[index].parameter = param;
param->arrayIndex = index;
lastCVar++;
return index;
}
//functions elided to keep it short. look at source code for the extras.
};
我们将为此使用模板,以确保实现多种类型的 cvars 更加简单。
从 CVarStorage<T>
类型开始,我们在其中存储给定 CVar 的当前值和初始值,以及指向 CVarParamter
的指针,此 Storage 保存该值。
CVarArray<T>
是这些 CVarStorage 对象的数组,其中包含一些处理该数组的函数。 它将是一个非常典型的堆分配数组,如向量,但我们不支持调整大小。
在数组中,我们保存一个整数以了解它的填充程度,我们使用索引来获取数据。 此索引将与 CVarStorage 中存储的索引相关联。
我们现在可以将这些数组添加到 CVarSystemImpl 类中
cvar.cpp
class CVarSystemImpl : public CVarSystem
{
public:
constexpr static int MAX_INT_CVARS = 1000;
CVarArray<int32_t> intCVars2{ MAX_INT_CVARS };
constexpr static int MAX_FLOAT_CVARS = 1000;
CVarArray<double> floatCVars{ MAX_FLOAT_CVARS };
constexpr static int MAX_STRING_CVARS = 200;
CVarArray<std::string> stringCVars{ MAX_STRING_CVARS };
//using templates with specializations to get the cvar arrays for each type.
//if you try to use a type that doesn't have specialization, it will trigger a linked error
template<typename T>
CVarArray<T>* GetCVarArray();
template<>
CVarArray<int32_t>* GetCVarArray()
{
return &intCVars2;
}
template<>
CVarArray<double>* GetCVarArray()
{
return &floatCVars;
}
template<>
CVarArray<std::string>* GetCVarArray()
{
return &stringCVars;
}
}
我们将为所有 cvars 硬编码数组大小。 这样做是为了简化系统。 通常,您并没有那么多 cvars,所以实际上并没有浪费太多空间。 1000 个 float 和 int cvars 比虚幻引擎使用的还要多,而 Quake 3 只有几十个。
这里需要注意的一点是 GetCVarArray
模板技巧。 通过这样做,我们可以在处理不同的数据数组时允许非常显着的代码重复。 我们首先声明 GetCVarArray 模板函数,其实现为空,如果未实现,则会出错。 然后,我们继续进行 GetCVarArray 模板的 3 个单独的特化。 最终结果是我们可以这样做。
auto array = GetCVarArray<int32_t>() //gets array for int32 cvars
我们继续编写创建和注册 cvar 的实际代码。
class CVarSystem
{
public:
virtual CVarParameter* GetCVar(StringUtils::StringHash hash) = 0;
virtual CVarParameter* CreateFloatCVar(const char* name, const char* description, double defaultValue, double currentValue) = 0;
//other getters
}
class CVarSystemImpl : public CVarSystem
{
public:
CVarParameter* GetCVar(StringUtils::StringHash hash) override final;
CVarParameter* CreateFloatCVar(const char* name, const char* description, double defaultValue, double currentValue) override final;
//more versions of Create for ints/strings
private:
CVarParameter* InitCVar(const char* name, const char* description);
std::unordered_map<uint32_t, CVarParameter> savedCVars;
}
在这里我们介绍了 StringUtils::StringHash
对象。 这是字符串的哈希值。 在 cvars 的内部,我们经常通过哈希值来引用它们。 StringHash 对象使我们可以在编译时进行哈希处理,这是一件非常好的事情。 这意味着
GetCVar("cvar.int")
将在编译时计算字符串的哈希值。 您可以在 string_utils.h
中查看结构的实现。
我们还在公共接口类和私有实现中添加了 CreateFloatCVar(和其他)。
在私有部分,我们还有 InitCVar 和 CVarParameters 的哈希映射,我们将在其中存储 cvars。 在大多数实现中,在 unordered_map 中使用 uint32_t 作为键不会对其进行哈希处理。
让我们实现创建函数。
CVarParameter* CVarSystemImpl::InitCVar(const char* name, const char* description)
{
if (GetCVar(name)) return nullptr; //return null if the cvar already exists
uint32_t namehash = StringUtils::StringHash{ name };
savedCVars[namehash] = CVarParameter{};
CVarParameter& newParam = savedCVars[namehash];
newParam.name = name;
newParam.description = description;
return &newParam;
}
对于 InitCVar 函数,我们没有做太多事情,我们哈希 cvar 的名称,设置其名称和描述,并将其插入到 savedCVars 哈希映射中。
初始化的第二部分是类型化部分。 我们将展示 float,但它对所有类型都有效。
CVarParameter* CVarSystemImpl::CreateFloatCVar(const char* name, const char* description, double defaultValue, double currentValue)
{
CVarParameter* param = InitCVar(name, description);
if (!param) return nullptr;
param->type = CVarType::FLOAT;
GetCVarArray<double>()->Add(defaultValue, currentValue, param);
return param;
}
在类型化的 CreateFloatCVar 函数中,我们使用上面的函数初始化 cvar,然后我们使用 GetCVarArray<double>()
来抓取正确的数据数组以将 cvar 插入其中。 我们还确保将 cvar 的 param->type 设置为 Float 类型。
对于 int 和 string,只需替换类型即可,其他都相同。
现在我们可以创建 cvars,让我们继续讨论 GetCVar 函数
CVarParameter* CVarSystemImpl::GetCVar(StringUtils::StringHash hash)
{
auto it = savedCVars.find(hash);
if (it != savedCVars.end())
{
return &(*it).second;
}
return nullptr;
}
它只是从哈希映射中获取 cvar。
现在我们可以从系统中创建和获取不同类型的 CVArs,但我们仍然没有实现存储和检索数据的功能。
让我们向公共接口添加更多函数。
class CVarSystem
{
public:
virtual double* GetFloatCVar(StringUtils::StringHash hash) = 0;
virtual void SetFloatCVar(StringUtils::StringHash hash, double value) = 0;
}
我们将有一个 GetFloatCVar 函数,它获取一个 stringhash 并返回指向数据的指针。 我们还有等效的 Setter。
继续到实现类
class CVarSystemImpl : public CVarSystem
{
public:
double* GetFloatCVar(StringUtils::StringHash hash) override final;
void SetFloatCVar(StringUtils::StringHash hash, double value) override final;
//templated get-set cvar versions for syntax sugar
template<typename T>
T* GetCVarCurrent(uint32_t namehash) {
CVarParameter* par = GetCVar(namehash);
if (!par) {
return nullptr;
}
else {
return GetCVarArray<T>()->GetCurrentPtr(par->arrayIndex);
}
}
template<typename T>
void SetCVarCurrent(uint32_t namehash, const T& value)
{
CVarParameter* cvar = GetCVar(namehash);
if (cvar)
{
GetCVarArray<T>()->SetCurrent(value, cvar->arrayIndex);
}
}
}
我们将 Get 和 Set 添加为实现的重写函数,但我们也创建了 Get 和 Set 的模板版本,它们将为不同类型共享。
对于 GetCVarCurrent,我们通过哈希值抓取 cvar,然后通过 cvar 中的数组索引抓取数据。 setter 的工作方式类似。
现在让我们实现 get/set 的实际接口函数
double* CVarSystemImpl::GetFloatCVar(StringUtils::StringHash hash)
{
return GetCVarCurrent<double>(hash);
}
void CVarSystemImpl::SetFloatCVar(StringUtils::StringHash hash, double value)
{
SetCVarCurrent<double>(hash, value);
}
在这些函数中,我们只调用模板版本。 这样,我们可以避免为每种类型实现此功能而产生大量重复代码。
CVarSystem 的核心 API 现在已实现。 这就是它的全部内容。 AutoCVar 对象只是这些函数的语法糖。
让我们实现 AutoCVar_Float 以展示它们的工作原理。
template<typename T>
struct AutoCVar
{
protected:
int index;
using CVarType = T;
};
struct AutoCVar_Float : AutoCVar<double>
{
AutoCVar_Float(const char* name, const char* description, double defaultValue, CVarFlags flags = CVarFlags::None);
double Get();
void Set(double val);
};
所有类型化的 AutoCVar 对象都将继承自具有索引的 AutoCVar。 此索引将直接索引到给定类型的数据数组中。
回到 cpp 文件,我们现在填充 AutoCVar 函数。
//get the cvar data purely by type and array index
template<typename T>
T GetCVarCurrentByIndex(int32_t index) {
return CVarSystemImpl::Get()->GetCVarArray<T>()->GetCurrent(index);
}
//set the cvar data purely by type and index
template<typename T>
void SetCVarCurrentByIndex(int32_t index,const T& data) {
CVarSystemImpl::Get()->GetCVarArray<T>()->SetCurrent(data, index);
}
//cvar float constructor
AutoCVar_Float::AutoCVar_Float(const char* name, const char* description, double defaultValue, CVarFlags flags)
{
CVarParameter* cvar = CVarSystem::Get()->CreateFloatCVar(name, description, defaultValue, defaultValue);
cvar->flags = flags;
index = cvar->arrayIndex;
}
double AutoCVar_Float::Get()
{
return GetCVarCurrentByIndex<CVarType>(index);
}
void AutoCVar_Float::Set(double f)
{
SetCVarCurrentByIndex<CVarType>(f,index);
}
我们又回到了更多的模板技巧。 为了使所有 autocvars 具有一些抽象的语法,我们创建了 2 个 Get 和 Set cvar 函数,它们是全局的(不在类内部),并直接通过模板类型本身设置和获取值。
然后我们可以使用 CVarType typedef 从它们实现 AutoCVar_Float Get/Set,CVarType typedef 通过继承自 AutoCVar<double>
而存在。 构造函数注册 cvar 并存储索引。
同样,这对于所有其他类型的工作方式完全相同。
如果您想向 cvar 系统添加更多类型,您只需要在公共 API 中创建函数,实现它们,并在实现中添加一个存储数组。 一些常见的要添加的类型是向量属性,甚至可能是其他对象。
对于 imgui 编辑器本身。 它只是从数据数组中使用的普通 imgui 编辑函数。