属性同步
在写游戏的属性同步模块,典型的Client/Server架构,两端都是c++实现。
思路
第一步,找出变化的属性。
第二步,服务端序列化变化属性的标识,以及最新的属性值。客户端收到后反序列化,根据标识修改对应的属性为最新值。
朴素
朴素的方法是每次修改时手动设脏标志,服务端同步时写入脏标志和脏数据,客户端按相同顺序解释更新。
enum class ActorProperties : uint32_t {
AP_id = 1 << 0,
AP_name = 1 << 1,
AP_position = 1 << 2,
AP_MAX
};
class Actor {
public:
void set_id(int id) {
id_ = id;
// 手动设置脏标志
set_dirty(dirty_properties_, (uint32_t)ActorProperties::AP_id);
}
// 其他属性的访问接口
// ...
// 服务端下发
void replicate(OutputByteStream &output_stream) {
// 写入脏标志
output_stream.write(dirty_properties_);
// 哪个脏了写哪个
if (is_dirty(dirty_properties_, (uint32_t)ActorProperties::AP_id)) {
output_stream.write(id_);
}
if (is_dirty(dirty_properties_, (uint32_t)ActorProperties::AP_name)) {
output_stream.write(name_);
}
if (is_dirty(dirty_properties_, (uint32_t)ActorProperties::AP_position)) {
position_->replicate(output_stream);
}
dirty_properties_ = 0;
}
// 客户端同步
void on_replicate(InputByteStream &input_stream) {
// 读取脏标志
uint32_t dirty_properties = input_stream.read_uint32();
// 哪个脏了更新哪个,注意要以相同的顺序进行判断
if (is_dirty(dirty_properties, (uint32_t)ActorProperties::AP_id)) {
id_ = input_stream.read<int>();
}
if (is_dirty(dirty_properties, (uint32_t)ActorProperties::AP_name)) {
name_ = input_stream.read_string();
}
if (is_dirty(dirty_properties, (uint32_t)ActorProperties::AP_position)) {
position_.on_replicate(input_stream);
}
}
private:
uint32_t dirty_properties_ = 0;
int id_ = 0;
std::string name_;
// 自定义类型要自己支持序列化与反序列化操作
Vector3 position_;
};
上面的代码为了演示把两端代码写在一起了,我个人比较倾向于双端独立的架构,所以实际上是分开的。当把代码分开两份后要注意两端对ActorProperties
的定义要一致,同时序列化与反序列化时的顺序也要一致。
一个小问题是,标志位不够用怎么办?或许可以分组,或许可以把属性搞成层级结构,类似上面的position。
但大问题还是要写的代码太多了,并且使用上需要小心,假设有地方不使用接口就设置了属性值,那就完蛋。
更详细的讨论可以参考《网络多人游戏架构与编程》第5章。
类型信息
《网络多人游戏架构与编程》中提到可以构建类型信息以减轻代码过多的痛点。
简单的类型信息可以这样定义:
enum class PrimitiveType {
Int,
Float,
Str,
// 其他类型
// ...
UStruct
};
struct MetaClass;
struct Property {
// 类型
PrimitiveType primitive_type;
// 相对于对象首地址的偏移量
size_t offset;
// 复杂类型仅靠primitvie_type和offset并不足以表达,所以加了一个u作为额外的信息
union {
MetaClass *meta_cls;
size_t element_size;
} u;
};
struct MetaClass {
// 每个具备MetaClass的类都要提供一个脏标志属性,dirty_properties_offset表示该属性相对对象首地址的偏移量
size_t dirty_properties_offset;
// 所有属性
std::vector<Property> properties;
};
其中PrimitiveType
表示所有支持同步的类型,基础类型自然包含在内,自定义的类型如果很常用,或者需要特殊处理(例如没有类型信息或者不使用脏标志),也可以单独加在PrimitiveType
中,否则使用UStruct
即可。
然后需要手动为每个类构建类型信息,类似:
MetaClass* Actor::get_meta_cls() {
static MetaClass *cls = nullptr;
if (cls != nullptr)
return cls;
cls = new MetaClass;
cls->dirty_properties_offset = offsetof(Actor, dirty_properties_);
Property prop_id{PrimitiveType::Int, offsetof(Actor, id_)};
Property prop_name{PrimitiveType::Str, offsetof(Actor, name_)};
Property prop_position{PrimitiveType::UStruct, offsetof(Actor, position_), Vector3::get_meta_cls()};
cls->properties.push_back(prop_id);
cls->properties.push_back(prop_name);
cls->properties.push_back(prop_position);
return cls;
}
代码看起来有点多,可以考虑使用模板来减轻一下工作量。或者直接看一下现成的第三方实现能不能满足需求,例如rttr。
当然无论哪种实现,类型构建代码还是得写,嫌麻烦的话,可以试试使用libclang解释源文件,然后程序生成。
有了类型信息之后,就不需要每个类自己实现序列化与反序列化接口了,直接写一个统一的实现即可:
void replicate(OutputByteStream &output_stream, MetaClass *meta_cls, void *obj) {
uint8_t *cls_obj = (uint8_t *)obj;
uint32_t dirty_properties = *(uint32_t*)(cls_obj + meta_cls->dirty_properties_offset);
if (dirty_properties == 0)
return;
// 写入脏标志
output_stream.write(dirty_properties);
for (size_t i = 0; i < meta_cls->properties.size(); i++) {
// 哪个脏了写哪个
if (((1 << i) & dirty_properties) != 0) {
const Property &property = meta_cls->properties[i];
void *property_obj = cls_obj + property.offset;
switch (property.primitive_type) {
case PrimitiveType::Int:
output_stream.write(*(int*)property_obj);
break;
case PrimitiveType::Float:
output_stream.write(*(float*)property_obj);
break;
case PrimitiveType::Str:
output_stream.write(*(std::string*)property_obj);
break;
// ...
// 其他类型
case PrimitiveType::UStruct:
{
replicate(output_stream, property.u.meta_cls, property_obj);
}
break;
default:
break;
}
}
}
*(uint32_t*)(cls_obj + meta_cls->dirty_properties_offset) = 0;
}
void on_replicate(InputByteStream &input_stream, MetaClass *meta_cls, void *obj) {
if (input_stream.is_end())
return;
uint8_t *cls_obj = (uint8_t*)obj;
// 读取脏标志
uint32_t dirty_properties = input_stream.read<uint32_t>();
for (size_t i = 0; i < meta_cls->properties.size(); i++) {
const Property &property = meta_cls->properties[i];
// 哪个脏了更新哪个
if ((dirty_properties & (1 << i)) != 0) {
void *property_obj = cls_obj + property.offset;
switch (property.primitive_type) {
case PrimitiveType::Int:
*(int*)property_obj = input_stream.read<int>();
break;
case PrimitiveType::Float:
*(float*)property_obj = input_stream.read<float>();
break;
case PrimitiveType::Str:
*(std::string*)property_obj = input_stream.read_string();
break;
case PrimitiveType::UStruct:
on_replicate(input_stream, property.u.meta_cls, property_obj);
break;
default:
// assert(0);
break;
}
}
}
}
基于比较计算增量变化
仅管有了类型信息的加持,但脏标志还是得自己手动设。有没有办法把这一步也给省了呢?有,「比较」,就像UE做的那样。
原理上可以为每个需要同步的对象创建一个副本,记为shadow,表示上一次同步后的状态,在同步时对比对象本身与shadow的每一个属性,计算出有变化的部分,然后更新shadow到对象当前的最新状态,等待再下一次同步。
但是这方案本质上有两个弱点,第一,副本消耗内存,第二,同步时逐个属性对比消耗cpu。对象越多,属性越复杂,消耗越大。工程上可用的实现当然会尽可能优化,但无论再怎么优化,本质的消耗还是在,所以需要上层使用者自己去控制整个同步的量,例如精减需要同步的属性,控制同步的频率之类。
以下是一个小示例。
首先是构建shadow。
通常不会直接使用复制构造函数,而是只为需要同步的属性建立副本,降低内存消耗。也就是说,shadow的内存布局与原始对象不一致。
修改上面的代码,在Property中添加一个offset_in_shadow
,表示属性在shadow中的偏移量,在MetaClass中添加一个shadow_size
,表示整个shadow的大小。
在构建shadow内存布局的时候要注意内存对齐:
struct SizeInfo {
size_t size;
size_t alignment;
};
void setup_shadow_layout(MetaClass *meta_cls, const std::vector<SizeInfo> &size_infos) {
assert(meta_cls->properties.size() == size_infos.size());
size_t address = 0;
for (size_t i = 0; i < meta_cls->properties.size(); i++) {
Property &property = meta_cls->properties[i];
const SizeInfo &size_info = size_infos[i];
// 注意要保证内存对齐
size_t align = size_info.alignment;
if (address % align != 0) {
size_t padding = align - (address % align);
address += padding;
}
property.offset_in_shadow = address;
address += size_info.size;
}
meta_cls->shadow_size = address;
}
其中setup_shadow_layout
在get_meta_cls
中调用,size_infos
是属性的大小与对齐信息,使用sizeof
和alignof
即可获取。
有了这两个信息基本上就足够构建shadow了:
void _make_shadow(MetaClass *meta_cls, void *obj, void *shadow) {
uint8_t *cls_obj = (uint8_t *)obj;
uint8_t *shadow_obj = (uint8_t *)shadow;
for (size_t i = 0; i < meta_cls->properties.size(); i++) {
const Property &property = meta_cls->properties[i];
void *property_obj = cls_obj + property.offset;
void *property_shadow = shadow_obj + property.offset_in_shadow;
switch (property.primitive_type) {
case PrimitiveType::Int:
*(int*)property_shadow = *(int*)property_obj;
break;
case PrimitiveType::Float:
*(float*)property_shadow = *(float*)property_obj;
break;
case PrimitiveType::Str:
{
// inplacement new
std::string *p_str = new (property_shadow) std::string;
*p_str = *(std::string*)property_obj;
}
break;
// ...
// 其他类型
case PrimitiveType::UStruct:
_make_shadow(property.u.meta_cls, property_obj, property_shadow);
break;
default:
assert(0);
break;
}
}
}
void* make_shadow(MetaClass *meta_cls, void *obj) {
void *shadow = new uint8_t[meta_cls->shadow_size];
_make_shadow(meta_cls, obj, shadow);
return shadow;
}
之后就可以在同步时自动计算脏数据了:
// c++ 20 concept
template<class T>
concept StreamAcceptAble = std::is_arithmetic_v<T> || std::is_same_v<T, std::string>;
template<StreamAcceptAble T>
void replicate_property(OutputByteStream &output_stream, uint32_t &dirty_properties, size_t i, T &dst, T &src) {
if (dst != src) {
dirty_properties |= (1 << i);
output_stream.write(src);
dst = src;
}
}
void replicate(OutputByteStream &output_stream, MetaClass *meta_cls, void *obj, void *shadow) {
uint8_t *cls_obj = (uint8_t *)obj;
uint8_t *shadow_obj = (uint8_t *)shadow;
// 先占位
size_t old_pos = output_stream.tellp();
uint32_t dirty_properties = 0;
output_stream.write(dirty_properties);
for (size_t i = 0; i < meta_cls->properties.size(); i++) {
const Property &property = meta_cls->properties[i];
void *property_obj = cls_obj + property.offset;
void *property_shadow = shadow_obj + property.offset_in_shadow;
switch (property.primitive_type) {
case PrimitiveType::Int:
replicate_property(output_stream, dirty_properties, i, *(int*)property_shadow, *(int*)property_obj);
break;
case PrimitiveType::Float:
replicate_property(output_stream, dirty_properties, i, *(float*)property_shadow, *(float*)property_obj);
break;
case PrimitiveType::Str:
replicate_property(output_stream, dirty_properties, i, *(std::string*)property_shadow, *(std::string*)property_obj);
break;
case PrimitiveType::UStruct:
{
size_t pos = output_stream.tellp();
replicate(output_stream, property.u.meta_cls, property_obj, property_shadow);
if (output_stream.tellp() != pos)
dirty_properties |= (1 << i);
}
break;
default:
assert(0);
break;
}
}
size_t cur_pos = output_stream.tellp();
// 返回到dirty_properties的位置,写入正确的值
output_stream.seekp(old_pos);
if (dirty_properties != 0) {
output_stream.write(dirty_properties);
output_stream.seekp(cur_pos);
}
}
// on_replicate不用改
TODO
UE的属性同步系统还有一些令人眼馋的功能,例如:
- 支持指定属性同步的范围与时机。
- 支持客户端在收到属性变化时触发一个notify回调。
- 属性同步被做成了非可靠传输。
- 支持指针。
- 当然也没有奇怪的属性数量限制。
另外现在也就写了几个类型支持,连数组都还没加上。
把所有这些都补上的话,上面的代码势必要再改几遍了。