A. 前言
参考Piccolo引擎运行时架构文档,确定新功能应该属于引擎的哪一层。
比如要优化json数据加载太慢的问题,则需要工具层加入资产序列化工具,将原始资产转换为二进制资产;资源层加入对应的从二进制资产加载以及反序列化构建对应数据对象功能。
功能层以外的新功能与通常的软件开发类似,开发流程不再赘述。
接下来将详细按流程说明如何为Piccolo引擎加入新的组件。
B. 组件资产数据
1. 定义资产数据结构
资产数据结构是一个复合结构,代码中直接对应C++中的类/结构体。Piccolo引擎中使用元数据系统 meta
的序列化/反序列化功能 serializer
实现数据的保存/读取。目前,序列化后的数据为JSON格式,元数据系统支持C++语言以下数据类型:
bool
char
int
unsigned int
float
double
std::string
std::vector
struct
class
T*
定义资产数据结构声明的头文件都需要包含元数据系统头文件 runtime/core/meta/reflection/reflection.h
。资产数据结构定义直接编写class,可参考 resource/res_type
下其他数据结构定义。建议组件运行时数据结构命名为XXXXComponent,对应组件资产数据结构命名为XXXXComponentRes。
资产数据结构使用元数据系统相关宏以提供对应的序列化、反序列化、反射功能。包括:
REFLECTION_TYPE
CLASS
REFLECTION_BODY
META
这些宏具体实现原理以后会在元数据系统相关文档中解释。
下面以物理碰撞组件的 rigid_body.h
为例说明这些宏的用法。
REFLECTION_TYPE
和 REFLECTION_BODY
宏参数使用类名添加到对应位置即可。
CLASS
宏代替原本的 class
关键字,逗号后面的参数表示哪些属性需要反射,其中:
Fileds
表示该类需要反射除黑名单的所有属性。WhiteListFields
表示该类需要反射白名单指定的属性。
META
宏加在属性上方,括号中的参数表示该属性在反射时如何处理,其中:
Enable
表示将该属性加入白名单。Disable
表示将该属性加入黑名单。
文件:engine/source/runtime/resource/res_type/components/rigid_body.h
#pragma once
#include "runtime/resource/res_type/data/basic_shape.h"
#include "runtime/core/math/axis_aligned.h"
#include "runtime/core/math/transform.h"
#include "runtime/core/meta/reflection/reflection.h"
namespace Piccolo
{
enum class RigidBodyShapeType : unsigned char
{
box,
sphere,
capsule,
invalid
};
REFLECTION_TYPE(RigidBodyShape)
CLASS(RigidBodyShape, WhiteListFields) // 仅反射白名单中的属性
{
REFLECTION_BODY(RigidBodyShape);
public:
Transform m_global_transform;
AxisAlignedBox m_bounding_box;
// 不支持枚举,不反射
RigidBodyShapeType m_type {RigidBodyShapeType::invalid};
META(Enable) // 属性加入反射白名单
Transform m_local_transform;
META(Enable) // 属性加入反射白名单
Reflection::ReflectionPtr<Geometry> m_geometry; // 反射指针
RigidBodyShape() = default;
RigidBodyShape(const RigidBodyShape& res);
~RigidBodyShape();
};
REFLECTION_TYPE(RigidBodyComponentRes)
CLASS(RigidBodyComponentRes, Fields) // 反射所有属性
{
REFLECTION_BODY(RigidBodyComponentRes);
public:
std::vector<RigidBodyShape> m_shapes;
float m_inverse_mass;
int m_actor_type;
};
} // namespace Piccolo
文件:engine/source/runtime/resource/res_type/data/basic_shape.h
#pragma once
#include "runtime/core/math/vector3.h"
#include "runtime/core/meta/reflection/reflection.h"
namespace Piccolo
{
REFLECTION_TYPE(Geometry)
CLASS(Geometry, Fields)
{
REFLECTION_BODY(Geometry);
public:
virtual ~Geometry() {}
};
REFLECTION_TYPE(Box)
CLASS(Box : public Geometry, Fields) // 子类定义
{
REFLECTION_BODY(Box);
public:
~Box() override {}
Vector3 m_half_extents {0.5f, 0.5f, 0.5f};
};
REFLECTION_TYPE(Sphere)
CLASS(Sphere : public Geometry, Fields) // 子类定义
{
REFLECTION_BODY(Sphere);
public:
~Sphere() override {}
float m_radius {0.5f};
};
REFLECTION_TYPE(Capsule)
CLASS(Capsule : public Geometry, Fields) // 子类定义
{
REFLECTION_BODY(Capsule);
public:
~Capsule() override {}
float m_radius {0.3f};
float m_half_height {0.7f};
};
} // namespace Piccolo
RigidBodyComponentRes
是 RigidBodyComponent
对应的核心资产数据结构。使用了 Fields
参数表示反射该类的所有属性。其中 m_shapes
是 RigidBodyShape
类型的数组。
RigidBodyShape
类型既是资产数据结构,同时也是运行时数据结构。因此,RigidBodyShape
除了作为资产数据的局部变换 m_local_transform
和碰撞几何形状 m_geometry
两个属性之外,还有作为运行时数据的全局变换 m_global_transform
、轴对齐包围盒 m_bounding_box
和形状类型的枚举类型 m_type
。因此使用了 WhiteListFields
参数表示仅反射该类白名单中的属性。META(Enable)
将 m_local_transform
和 m_geometry
加入白名单。理论上形状类型的枚举类型 m_type
也应属于资产数据,但是因为目前元数据系统不支持枚举类型,只能在运行时中读取数据时设置。
碰撞几何形状 m_geometry
使用了反射指针 Reflection::ReflectionPtr
。从 rigid_body.h
包含的 basic_shape.h
中可以看出碰撞几何形状支持方盒、球体、胶囊体等多种类型,这里需要 Geometry
的基类指针实现数据上的多态。Box
、Sphere
、Capsule
类展示了如何使用元数据宏标注继承的子类。
注意:反射指针Reflection::ReflectionPtr
在 new
/delete
时使用专用的宏PICCOLO_REFLECTION_NEW
/PICCOLO_REFLECTION_DELETE
。目前在serilizer读取数据时会自动调用PICCOLO_REFLECTION_NEW
,使用反射指针的资产数据结构需要在析构函数中自行调用PICCOLO_REFLECTION_DELETE
避免内存泄漏。
2. “生产”资产数据
a. 资产数据的组织
资产数据在 engine/asset
目录中,新加的资产可以参考现有资产的目录组织添加。当前目录结构如下:
asset
资产根目录
如Piccolo引擎运行时架构文档所述,Piccolo引擎运行时功能层核心框架采用世界 world
-- 关卡 level
-- GO object
-- 组件 component
的层级架构, 因此资产数据组织也是这样的层级关系。
world
目录下存放世界资产文件,世界资产文件引用下属关卡资产文件。
level
目录下存放关卡资产文件,关卡资产文件引用下属GO定义资产文件以及关卡中该GO实例覆盖的组件资产(即允许关卡中的GO实例拥有和GO定义中不同的组件)。
objects
目录下存放GO定义资产文件,GO定义资产文件中包括各组件的定义数据。组件引用的数据文件在该GO分类文件夹下的 components
文件夹中,并按照组件类型分文件夹组织。GO上的组件定义是多态的反射指针实现的,为了区分各组件类型,$typeName
JSON数据值 为组件运行时数据结构标识符,$context
JSON数据值中保存具体组件运行时数据。建议的范式是,运行时数据结构中有定义数据结构成员,将它标记为需要反射。
如对于碰撞体组件,首先碰撞体组件运行时数据结构中需要有组件定义数据结构成员。场景中墙体的GO定义资产文件 asset/objects/environment/wall/wall.object.json
中的物理碰撞组件。$typeName
JSON数据值 为组件运行时数据结构标识符,如 物理碰撞组件 对应的组件运行时数据结构为 RigidBodyComponent
。$context
JSON数据值 为具体组件运行时数据的序列化数据。
文件:engine/source/runtime/function/framework/component/rigidbody/rigidbody_component.h
REFLECTION_TYPE(RigidBodyComponent) // 碰撞体组件运行时数据结构
CLASS(RigidBodyComponent : public Component, WhiteListFields)
{
REFLECTION_BODY(RigidBodyComponent)
public:
RigidBodyComponent() = default;
~RigidBodyComponent() override;
void postLoadResource(std::weak_ptr<GObject> parent_object) override;
void tick(float delta_time) override {}
void updateGlobalTransform(const Transform& transform);
protected:
META(Enable)
RigidBodyComponentRes m_rigidbody_res; // 标记需要反射的碰撞体组件定义数据结构
PhysicsActor* m_physics_actor {nullptr};
};
文件:engine/asset/objects/environment/wall/wall.object.json
{
"components": [
...
{
"$typeName": "RigidBodyComponent",
"$context": {
"rigidbody_res": {
"actor_type": 1,
...
}
}
}
]
}
注意:资产中引用其他资产文件时,使用从 asset
目录开始的相对路径。
如场景中墙体GO定义中的模型网格组件中引用的网格obj文件和材质资产。
文件:engine/asset/objects/environment/wall/wall.object.json
{
"components": [
...
{
"$typeName": "MeshComponent",
"$context": {
"mesh_res": {
"sub_meshes": [
{
"material": "asset/objects/environment/_material/gold.material.json",
"obj_file_ref": "asset/objects/environment/wall/components/mesh/wall.obj",
...
}
]
}
}
}
]
}
b. 填充资产数据
目前,Piccolo还没有资源调节管线,因此需要手动填充资产数据。数据格式为标准JSON格式。
下面继续以上文中物理碰撞组件为例,讲解组件资产中的数据组织。
注意与上文 rigid_body.h
头文件中的对应。
文件:engine/asset/objects/environment/wall/wall.object.json
{
"components": [
...
{
"$typeName": "RigidBodyComponent",
"$context": {
"rigidbody_res": {
"actor_type": 1,
"inverse_mass": 0,
// 碰撞几何形状数组 std::vector<RigidBodyShape> m_shapes;
// 数组数据类型
"shapes": [
{
// 碰撞几何形状(多态) Reflection::ReflectionPtr<Geometry> m_geometry;
// 指针数据类型
"geometry": {
"$context": {
"half_extents": {
"x": 0.15,
"y": 5,
"z": 3.5
}
},
"$typeName": "Box"
},
// 变换 Transform m_local_transform;
// 复合数据类型
"local_transform": {
"position": {
// 三维向量分量 float x;
// 基本数据类型
"x": 0,
"y": 0,
"z": 3.5
},
"rotate": {},
"scale": {
"x": 1,
"y": 1,
"z": 1
}
}
}
]
}
}
}
]
}
JSON中每一项数据均为 名称: 值
对。JSON数据名称对应头文件中属性成员变量的标识符,JSON数据名称可以去掉属性成员变量标识符中的 m_
前缀。
基本数据类型 bool
、char
、int
、unsigned int
、float
、double
、std::string
,JSON数据值直接写字面量即可。
数组数据类型 std::vector
,JSON数据值使用JSON数组 [... , ...]
表示。
复合数据类型 struct
和 class
,JSON数据值使用复合JSON数据值 {... , ...}
表示。
指针数据类型原生指针 T*
和反射指针 Reflection::ReflectionPtr<T>
,JSON数据值使用复合数据值,并用固定格式说明指针的数据类型。如上面例子中碰撞几何形状 "geometry"
,该复合数据值有两条数据,名称为 "$context"
的数据值为实际数据值,名称为 "$typeName"
的数据值为子类指针数据类型标识符。
3. 使用资产数据
a. 加载资产数据
资产数据要在引擎中访问,首先需要加载资产文件。通过配置文件或者资产引用关系获取到资产相对路径之后,使用以下代码即可加载:
std::string resource_url;
Resource resource;
g_runtime_global_context.m_asset_manager->loadAsset(resource_url, resource);
GO定义中的数据已经统一处理,不需要再额外处理加载逻辑。
目前资产加载仅支持同步加载,加载完成后引擎才会继续执行。
b. 访问资产数据
资产数据加载后,即可按照C++中访问结构体的方式访问数据。
注意使用反射指针时,需要首先使用 getTypeName()
检查子类类型,然后静态转换为子类指针访问。建议维护一个运行时的枚举类型,避免每次使用字符串比较检查子类类型。另外,引擎中也不建议使用C++编译器提供的运行时类型推断(RTTI),性能比较低下。
目前Piccolo引擎资产数据在加载关卡时统一加载,元数据系统反序列化时会为反射指针自动分配空间;对应地,资产定义数据结构析构函数中需要注意对应释放内存。如果加载的数据需要一些后处理,如根据数据初始化enum属性,处理特定逻辑等,需要实现组件运行时数据结构的
postLoadResource
函数。
例子见 engine/source/runtime/resource/res_type/components/motor.cpp
中 MotorComponentRes
的析构函数 和 engine/source/runtime/function/framework/component/motor/motor_component.cpp
中 MotorComponent
的 postLoadResource
函数。
文件:engine/source/runtime/function/framework/component/motor/motor_component.cpp
void MotorComponent::postLoadResource(std::weak_ptr<GObject> parent_object)
{
m_parent_object = parent_object;
if (m_motor_res.m_controller_config.getTypeName() == "PhysicsControllerConfig")
{
// Motor使用PhysicsController,设置 m_controller_type 的 enum 为 ControllerType::physics
m_controller_type = ControllerType::physics;
PhysicsControllerConfig* controller_config =
static_cast<PhysicsControllerConfig*>(m_motor_res.m_controller_config);
m_controller = new CharacterController(controller_config->m_capsule_shape);
}
else if (m_motor_res.m_controller_config != nullptr)
{
m_controller_type = ControllerType::invalid;
LOG_ERROR("invalid controller type, not able to move");
}
...
}
文件:engine/source/runtime/resource/res_type/components/motor.cpp
MotorComponentRes::~MotorComponentRes()
{
PICCOLO_REFLECTION_DELETE(m_controller_config);
}
C. 组件在引擎中注册
编辑器模式下执行 tick
逻辑的组件需要在 PiccoloEditor::PiccoloEditor()
中注册,如下段代码实例。
PiccoloEditor::PiccoloEditor()
{
registerEdtorTickComponent("TransformComponent");
registerEdtorTickComponent("MeshComponent");
}
如例子中的网格组件即使在编辑器模式下也需要每帧向 SceneManager
提交最新的网格描述信息,否则在编辑器中无法变换物体的姿态。但是动画组件需要禁用编辑器下的 tick
计算,否则编辑器中的动画改变会影响拾取操作。
D. 组件实现
1. 接口实现
所有的组件运行时数据类型都应继承自 Component
类,定义如下段代码所示。
文件:engine/source/runtime/function/framework/component/component.h
#pragma once
#include "runtime/core/meta/reflection/reflection.h"
namespace Piccolo
{
class GObject;
// Component
REFLECTION_TYPE(Component)
CLASS(Component, WhiteListFields)
{
REFLECTION_BODY(Component)
protected:
std::weak_ptr<GObject> m_parent_object;
bool m_is_dirty {false};
public:
Component() = default;
virtual ~Component() {}
// Instantiating the component after definition loaded
virtual void postLoadResource(std::weak_ptr<GObject> parent_object) { m_parent_object = parent_object;}
virtual void tick(float delta_time) {};
bool isDirty() const { return m_is_dirty; }
void setDirtyFlag(bool is_dirty) { m_is_dirty = is_dirty; }
bool m_tick_in_editor_mode {false};
};
} // namespace Piccolo
如Piccolo引擎运行时架构文档所述,Piccolo引擎运行时功能层核心框架采用世界 world
-- 关卡 level
-- GO object
-- 组件 component
的层级架构。因此引擎运行时主循环会 tick
当前世界,当前世界会 tick
该世界当前关卡,当前关卡会 tick
关卡中每个GO,每个GO会 tick
每个组件。组件基类 Component
中的 tick
函数即为GO tick
组件的入口。
同理,世界卸载时,会卸载所有关卡,销毁所有GO,销毁所有组件。组件基类 Component
的析构函数是虚函数,保证了析构时能够调用正确的子类析构函数。需要正确实现子类析构函数防止内存泄漏。
2. 访问其他组件接口
每个组件从基类都继承了父物体指针,通过父物体指针可以获取同物体其他组件的指针。Piccolo引擎提供了两个宏函数 tryGetComponent
和 tryGetComponentConst
可分别获取组件的非常量和常量指针。示例代码片段如下所示:
// 获取AnimationComponent的常量指针
if (!m_parent_object.lock()) return;
const AnimationComponent* animation_component = m_parent_object.lock()->tryGetComponentConst(AnimationComponent);
// 获取TransformComponent的非常量指针
if (!m_parent_object.lock()) return;
TransformComponent* transform_component = m_parent_object.lock()->tryGetComponent(TransformComponent);
建议仅需读取数据时使用常量指针,需要改动数据时使用非常量指针,方便清晰看出组件之间的数据依赖关系。
3. 编辑器逻辑
如果组件在编辑器模式下有特殊逻辑,可以通过包含 runtime/engine.h
引擎运行时全局头文件,使用其中的全局变量 g_is_editor_mode
判断当前帧是否处于编辑器模式下。
4. 帧缓冲区
因为Piccolo引擎是单线程架构,GO以及组件tick存在先后关系,会出现组件 tick
时依赖其他组件获取的数据不一致的情况。跨GO访问组件依赖问题比较复杂,比较好的解决方案是改用其他架构。GO内的组件依赖,如A、B、C、D 四个组件依次 tick
,B、D两个组件都会读取A组件中的状态,C组件 tick
时会修改A组件的状态(如Motor组件更新了GO的变换)。不做任何处理的情况下会出现B组件使用的A组件的状态是这一帧更新之前的,D组件使用的A组件状态是这一帧更新之后的,会引发逻辑bug。
Piccolo引擎中解决这个问题的方案是帧缓冲区,给A组件的状态提供两个缓冲区,每一帧计算时一个缓冲区作为当帧输入缓冲区,另一个缓冲区作为当帧输出缓冲区,以保证所有依赖D组件的其他组件在这一帧获取的输入数据都是一样的。A组件tick时交换两个缓冲区,并将上一帧的输入缓冲区复制进下一帧的输出缓冲区,以保证A组件在某一帧停止更新时,不会在下一帧跳回至上一帧的状态。另一方面将被依赖的组件安排在了前面,保证不会在一帧当中交换缓冲区。
具体帧缓冲区的实现可以参考 TransformComponent
中的实现。