Link

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 编辑函数。