C++游戏引擎编辑器开发:从零实现Hierarchy与Inspector面板

1. 项目概述:为什么从零实现编辑器面板是引擎开发的关键一步

如果你正在尝试构建自己的C++游戏引擎,那么开发一个功能完备的编辑器几乎是一个绕不开的坎。很多开发者,包括几年前的我自己,都曾陷入一个误区:认为引擎的核心是渲染、物理和音频,编辑器不过是“锦上添花”的UI工具,可以先用简单的控制台或配置文件凑合。这个想法会让你在项目规模稍微扩大后立刻陷入泥潭。想象一下,你需要手动编写几十行JSON或XML来摆放场景中的一百个物体,或者通过修改代码中的常量来调整一个光源的颜色——这种开发效率是灾难性的。

因此,Hierarchy(层级)面板Inspector(检视)面板就成为了编辑器最核心、最高频使用的两个组件。Hierarchy是你的场景“地图”,它以树状结构清晰展示了所有游戏对象(GameObject)的父子关系和组织逻辑;Inspector则是你的“手术刀”,可以精确地查看和修改选中对象的每一个属性,从位置、旋转到自定义脚本的公开变量。这两个面板共同构成了场景编辑的“可视化编程”环境,是连接引擎底层数据与开发者创作意图的桥梁。

这个项目,就是带你从零开始,用C++实现这两个核心编辑器面板。我们将不依赖Unity或Unreal那样的庞然大物,而是基于一个简单的渲染窗口(比如用GLFW+OpenGL或SDL2搭建),构建起一套可扩展的编辑器框架。你会深入理解游戏对象组件系统(ECS或传统的GameObject-Component模型)如何在UI层面进行绑定和交互,掌握即时模式(Immediate Mode)GUI库(如Dear ImGui)的高效集成方法,并最终打造出一个可以实际用于编辑场景的原型工具。无论你是想深入学习引擎架构,还是为自己的游戏项目制作定制化工具,这个过程都将让你对游戏数据的组织、序列化和人机交互有前所未有的深刻理解。

2. 核心架构设计:数据驱动与UI的松耦合绑定

在动手写一行UI代码之前,我们必须先把地基打牢。编辑器UI的本质是底层数据模型的可视化映射。因此,一个清晰、健壮且可扩展的底层架构是成功的关键。这里我们采用在中小型引擎中经久不衰的GameObject-Component(游戏对象-组件)模型

2.1 游戏对象与组件系统的核心设计

首先,我们定义最基础的Component(组件)基类。它应该是一个抽象类,主要包含类型信息、所属对象指针和生命周期方法。

class Component { public: virtual ~Component() = default; GameObject* GetOwner() const { return m_Owner; } void SetOwner(GameObject* owner) { m_Owner = owner; } // 序列化/反序列化,用于保存和加载场景 virtual void Serialize(YAML::Emitter& out) const = 0; virtual void Deserialize(const YAML::Node& node) = 0; // 在Inspector面板中绘制UI virtual void OnInspectorGUI() = 0; protected: GameObject* m_Owner = nullptr; };

接下来是GameObject(游戏对象)类。它是组件的容器,并维护一个变换层级结构。

class GameObject { public: std::string name; uint64_t id; // 全局唯一ID,用于序列化后保持引用关系 // 变换组件(每个GameObject必有) TransformComponent transform; // 组件管理 template<typename T> T* AddComponent(); template<typename T> T* GetComponent(); template<typename T> bool RemoveComponent(); // 层级关系 void SetParent(GameObject* parent); GameObject* GetParent() const; const std::vector<GameObject*>& GetChildren() const; private: std::vector<std::unique_ptr<Component>> m_Components; GameObject* m_Parent = nullptr; std::vector<GameObject*> m_Children; // ... 其他管理逻辑 };

注意:这里将TransformComponent作为GameObject的内置成员,而非通过AddComponent添加,是一个常见且实用的设计。因为变换是每个实体都必需的,这样做可以减少动态内存分配,访问也更直接高效(obj.transformvsobj.GetComponent<TransformComponent>())。

最后,我们需要一个Scene(场景)类来管理所有的GameObject。它负责对象的创建、销毁、按ID查找,以及整个场景的序列化。

class Scene { public: GameObject* CreateGameObject(const std::string& name = "GameObject"); void DestroyGameObject(GameObject* obj); GameObject* FindGameObjectByID(uint64_t id); GameObject* FindGameObjectByName(const std::string& name); // 场景根节点(虚拟的,不渲染) GameObject* GetRoot() { return &m_RootObject; } // 序列化整个场景到文件 void Serialize(const std::filesystem::path& filepath); // 从文件反序列化整个场景 bool Deserialize(const std::filesystem::path& filepath); private: std::unordered_map<uint64_t, std::unique_ptr<GameObject>> m_GameObjectRegistry; GameObject m_RootObject; // 所有对象的根父级 };

这个三层结构(Scene -> GameObject -> Component)构成了我们编辑器数据的骨架。Hierarchy面板将展示以m_RootObject为根的树,Inspector面板则根据当前选中的GameObject,动态调用其所有组件的OnInspectorGUI方法。

2.2 编辑器状态管理与UI框架选型

编辑器需要维护一些全局状态,其中最重要的是当前选中的对象。我们创建一个EditorContext单例或一个全局可访问的结构来管理这些状态。

struct EditorContext { // 当前激活的场景 Scene* activeScene = nullptr; // 当前在Hierarchy中选中的GameObject GameObject* selectedGameObject = nullptr; // 视图相关状态(如摄像机位置、网格显示等) // ... static EditorContext& Get() { static EditorContext instance; return instance; } };

对于UI框架,Dear ImGui是C++游戏引擎编辑器开发的事实标准。它是一个即时模式GUI库,意味着你每一帧都需要描述整个UI,库内部会处理状态、输入和绘制。这与传统的保留模式GUI(如Qt)完全不同。它的优势在于与游戏渲染循环无缝集成、轻量、高性能,且风格统一。

集成ImGui非常简单:

  1. 在你的渲染循环中,在每一帧开始处调用ImGui_ImplXXX_NewFrame()(XXX代表你的后端,如OpenGL3、SDL2)。
  2. 在游戏渲染之前,调用你的编辑器UI绘制函数。
  3. 在游戏渲染之后,调用ImGui::Render(),然后调用后端渲染函数将ImGui绘制数据提交到GPU。
// 主循环伪代码 while (!windowShouldClose) { // 处理输入等 ImGui_ImplOpenGL3_NewFrame(); ImGui_ImplSDL2_NewFrame(); ImGui::NewFrame(); // 1. 绘制你的3D场景视图 RenderSceneViewport(); // 2. 绘制编辑器UI DrawEditorMainMenu(); DrawHierarchyPanel(); DrawInspectorPanel(); DrawConsolePanel(); // ... 其他面板 // 渲染 ImGui::Render(); ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData()); SwapBuffers(); }

这种“先渲染3D场景,再在其上叠加UI”的方式,是游戏编辑器的典型做法。DrawHierarchyPanelDrawInspectorPanel就是我们接下来要实现的核心函数。

3. Hierarchy面板实现:场景树的递归渲染与交互

Hierarchy面板的目标是将场景中所有GameObject的父子关系,以可折叠的树形结构显示出来,并支持选择、拖拽排序、创建/删除对象等交互。

3.1 递归绘制游戏对象树

核心是一个递归函数,它遍历一个GameObject的所有子节点,并为每个节点绘制一个ImGui的TreeNode

void DrawGameObjectNode(GameObject* obj) { if (!obj) return; // 设置树节点的标志 ImGuiTreeNodeFlags node_flags = ImGuiTreeNodeFlags_OpenOnArrow | ImGuiTreeNodeFlags_OpenOnDoubleClick | ImGuiTreeNodeFlags_SpanAvailWidth; // 如果当前对象被选中,添加选中标志 if (EditorContext::Get().selectedGameObject == obj) { node_flags |= ImGuiTreeNodeFlags_Selected; } // 判断该节点是否有子节点,如果没有则设置为叶节点(不可展开) bool hasChildren = !obj->GetChildren().empty(); if (!hasChildren) { node_flags |= ImGuiTreeNodeFlags_Leaf | ImGuiTreeNodeFlags_NoTreePushOnOpen; } // 绘制树节点。ImGui::TreeNodeEx返回true表示节点被展开。 bool node_open = ImGui::TreeNodeEx((void*)(uintptr_t)obj->id, node_flags, "%s", obj->name.c_str()); // 处理节点点击(选择) if (ImGui::IsItemClicked() && !ImGui::IsItemToggledOpen()) { EditorContext::Get().selectedGameObject = obj; } // 处理拖拽源(开始拖拽这个对象) if (ImGui::BeginDragDropSource()) { // 设置拖拽 payload,这里我们传递对象的指针或ID ImGui::SetDragDropPayload("DND_GAMEOBJECT", &obj, sizeof(GameObject*)); ImGui::Text("移动 %s", obj->name.c_str()); ImGui::EndDragDropSource(); } // 处理拖拽目标(将对象拖拽到此作为子对象) if (ImGui::BeginDragDropTarget()) { if (const ImGuiPayload* payload = ImGui::AcceptDragDropPayload("DND_GAMEOBJECT")) { IM_ASSERT(payload->DataSize == sizeof(GameObject*)); GameObject* payload_obj = *(GameObject**)payload->Data; if (payload_obj && payload_obj != obj) { // 重新设置父级 payload_obj->SetParent(obj); } } ImGui::EndDragDropTarget(); } // 如果节点是打开的且有子节点,则递归绘制子节点 if (node_open && hasChildren) { for (auto& child : obj->GetChildren()) { DrawGameObjectNode(child); } ImGui::TreePop(); // 关闭该节点 } }

DrawHierarchyPanel函数则在一个ImGui窗口内,从场景根节点开始调用这个递归函数。

void DrawHierarchyPanel() { ImGui::Begin("Hierarchy"); Scene* scene = EditorContext::Get().activeScene; if (scene) { // 从场景的根节点开始绘制 DrawGameObjectNode(scene->GetRoot()); // 处理在Hierarchy空白处的点击(取消选择) if (ImGui::IsWindowHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { EditorContext::Get().selectedGameObject = nullptr; } // 在空白处右键弹出创建菜单 if (ImGui::BeginPopupContextWindow("HierarchyContextMenu")) { if (ImGui::MenuItem("创建空对象")) { GameObject* newObj = scene->CreateGameObject("New GameObject"); EditorContext::Get().selectedGameObject = newObj; } ImGui::EndPopup(); } } else { ImGui::Text("未加载场景"); } ImGui::End(); }

3.2 关键交互逻辑的实现细节

  1. 对象选择:通过ImGui::IsItemClicked()检测节点点击,并更新EditorContext中的选中状态。同时,在窗口空白处点击可以取消选择。
  2. 拖拽父子关系:这是Hierarchy面板最复杂也最实用的功能。我们利用ImGui的拖拽API。
    • 作为源:在节点上调用BeginDragDropSource,设置一个自定义的Payload(例如DND_GAMEOBJECT),里面存放对象的指针或ID。
    • 作为目标:在每个节点(以及窗口空白处)调用BeginDragDropTarget,检查是否接收到对应类型的Payload。如果接收到,就调用GameObject::SetParent来修改层级关系。这里必须做有效性检查,比如防止将一个对象设置为自己的子节点(会导致循环引用)。
  3. 右键菜单:通过BeginPopupContextWindow可以在窗口空白处弹出菜单,用于快速创建对象。你也可以在节点上实现BeginPopupContextItem,为单个对象提供“重命名”、“复制”、“删除”等选项。

实操心得:对象唯一ID的重要性。在序列化/反序列化场景时,对象的指针是无效的。我们必须为每个GameObject分配一个运行时唯一的ID(如递增的uint64)。在拖拽Payload和序列化中,使用这个ID来标识对象,反序列化后再通过ID在场景中查找恢复对象引用。这是保持场景数据完整性的关键。

4. Inspector面板实现:基于反射的通用属性绘制

Inspector面板是动态的,它需要根据当前选中的GameObject,自动生成其所有组件的属性编辑UI。硬编码每种组件的UI是不可维护的。我们需要一种反射(Reflection)或属性系统来动态查询和修改对象的属性。

4.1 构建轻量级运行时类型信息(RTTI)与属性系统

C++没有原生的运行时反射,我们需要自己构建一个简易的系统。核心是定义一个Field(字段)类,它描述一个属性的名字、类型、以及在内存中的位置(偏移量或getter/setter)。

enum class FieldType { Float, Int, Bool, String, Vector2, Vector3, Color, // ... 其他类型 }; class Field { public: std::string name; FieldType type; size_t offset; // 该字段在所属类实例中的内存偏移量 // 通过基类指针和偏移量,获取字段数据的指针 void* GetDataPtr(void* componentInstance) const { return (char*)componentInstance + offset; } }; // 类型描述符 class TypeDescriptor { public: std::string name; std::vector<Field> fields; void AddField(const std::string& name, FieldType type, size_t offset) { fields.push_back({name, type, offset}); } }; // 一个全局的类型注册表 class TypeRegistry { public: static TypeRegistry& Get() { static TypeRegistry instance; return instance; } void RegisterType(const std::string& typeName, const TypeDescriptor& desc) { m_TypeMap[typeName] = desc; } const TypeDescriptor* GetTypeDescriptor(const std::string& typeName) const { auto it = m_TypeMap.find(typeName); return it != m_TypeMap.end() ? &it->second : nullptr; } private: std::unordered_map<std::string, TypeDescriptor> m_TypeMap; };

然后,我们需要为每个组件类定义其TypeDescriptor。这可以通过宏来简化,在类定义的末尾进行注册。

#define REGISTER_COMPONENT_TYPE(typeName, ...) \ class typeName##DescriptorRegistrar { \ public: \ typeName##DescriptorRegistrar() { \ TypeDescriptor desc; \ desc.name = #typeName; \ __VA_ARGS__ \ TypeRegistry::Get().RegisterType(#typeName, desc); \ } \ }; \ static typeName##DescriptorRegistrar typeName##_registrar; // 在TransformComponent的定义文件中 class TransformComponent : public Component { public: glm::vec3 position = {0.0f, 0.0f, 0.0f}; glm::vec3 rotation = {0.0f, 0.0f, 0.0f}; // 欧拉角 glm::vec3 scale = {1.0f, 1.0f, 1.0f}; void OnInspectorGUI() override; }; // 注册TransformComponent的字段 REGISTER_COMPONENT_TYPE(TransformComponent, desc.AddField("position", FieldType::Vector3, offsetof(TransformComponent, position)); desc.AddField("rotation", FieldType::Vector3, offsetof(TransformComponent, rotation)); desc.AddField("scale", FieldType::Vector3, offsetof(TransformComponent, scale)); )

4.2 利用类型描述符动态生成UI

有了类型描述符,OnInspectorGUI的实现就可以通用了。我们可以先实现一个通用的DrawFieldGUI函数,根据FieldType调用不同的ImGui控件。

void DrawFieldGUI(const Field& field, void* dataPtr) { switch (field.type) { case FieldType::Float: { float* value = static_cast<float*>(dataPtr); ImGui::DragFloat(field.name.c_str(), value, 0.1f); break; } case FieldType::Int: { int* value = static_cast<int*>(dataPtr); ImGui::DragInt(field.name.c_str(), value, 1); break; } case FieldType::Bool: { bool* value = static_cast<bool*>(dataPtr); ImGui::Checkbox(field.name.c_str(), value); break; } case FieldType::Vector3: { glm::vec3* value = static_cast<glm::vec3*>(dataPtr); ImGui::DragFloat3(field.name.c_str(), glm::value_ptr(*value), 0.1f); break; } case FieldType::Color: { glm::vec4* value = static_cast<glm::vec4*>(dataPtr); ImGui::ColorEdit4(field.name.c_str(), glm::value_ptr(*value)); break; } // ... 处理其他类型 } }

然后,在组件的OnInspectorGUI中,我们查找自己的类型描述符,并遍历所有字段进行绘制。

void TransformComponent::OnInspectorGUI() { const TypeDescriptor* desc = TypeRegistry::Get().GetTypeDescriptor("TransformComponent"); if (desc) { // 可以在这里先绘制一个折叠栏头 if (ImGui::CollapsingHeader("Transform", ImGuiTreeNodeFlags_DefaultOpen)) { for (const auto& field : desc->fields) { void* dataPtr = field.GetDataPtr(this); // this 是当前组件实例 DrawFieldGUI(field, dataPtr); } } } else { // 后备方案:硬编码绘制(用于调试或未注册的类型) ImGui::DragFloat3("Position", glm::value_ptr(position), 0.1f); ImGui::DragFloat3("Rotation", glm::value_ptr(rotation), 0.5f); ImGui::DragFloat3("Scale", glm::value_ptr(scale), 0.1f); } }

最后,DrawInspectorPanel函数就非常简单了:检查当前选中的对象,然后遍历其所有组件,调用它们的OnInspectorGUI

void DrawInspectorPanel() { ImGui::Begin("Inspector"); GameObject* selected = EditorContext::Get().selectedGameObject; if (selected) { // 显示对象名称,并允许编辑 char nameBuffer[256]; strcpy(nameBuffer, selected->name.c_str()); if (ImGui::InputText("##Name", nameBuffer, sizeof(nameBuffer))) { selected->name = std::string(nameBuffer); } ImGui::Separator(); // 绘制内置的Transform组件(因为我们没有把它放在m_Components里) selected->transform.OnInspectorGUI(); // 绘制所有其他组件 for (auto& component : selected->GetAllComponents()) { component->OnInspectorGUI(); } ImGui::Separator(); // 添加组件按钮 if (ImGui::Button("添加组件")) { ImGui::OpenPopup("AddComponentPopup"); } if (ImGui::BeginPopup("AddComponentPopup")) { if (ImGui::MenuItem("Mesh Renderer")) { selected->AddComponent<MeshRendererComponent>(); } if (ImGui::MenuItem("Point Light")) { selected->AddComponent<PointLightComponent>(); } // ... 列出所有可添加的组件类型 ImGui::EndPopup(); } } else { ImGui::Text("未选择任何对象"); } ImGui::End(); }

注意事项:属性撤销/重做(Undo/Redo)。这是专业编辑器的必备功能。每当Inspector中的值被修改,你需要记录一个“命令”(Command)。一个简单的实现是,在DrawFieldGUI中,当ImGui控件值改变时(ImGui::IsItemDeactivatedAfterEdit()是一个很好的判断时机),将修改前的值和修改后的值,连同对象ID和字段信息,压入一个命令栈。实现一个ICommand接口和CommandHistory类来管理这个栈。虽然初期可以跳过,但它对用户体验提升巨大。

5. 面板协同与场景序列化

Hierarchy和Inspector不是孤立的,它们必须紧密协同工作,并与场景数据同步。

5.1 实现面板间的联动反馈

联动主要靠共享的EditorContext状态。例如:

  • 在Hierarchy中选中对象EditorContext::selectedGameObject被更新,Inspector面板在下一次绘制时会检测到这个变化,并开始绘制新对象的属性。
  • 在Inspector中修改对象名称GameObjectname字段被修改,Hierarchy面板在下一次绘制时,对应树节点的标签应该更新。由于我们每一帧都重新绘制整个UI,这个更新是自动的。
  • 在Hierarchy中拖拽改变父子关系:底层GameObject的父子指针被更新。这不仅影响Hierarchy的显示,也可能影响该对象的变换(子对象的世界变换是相对于父对象的)。我们需要确保TransformComponent能正确响应这种变化,通常是在变换计算时考虑父矩阵。

5.2 场景的保存与加载(序列化)

没有序列化功能的编辑器是没有灵魂的。我们需要将整个Scene,包括所有GameObject及其Component的层级关系和属性,保存到磁盘文件(如YAML、JSON或二进制格式),并能重新加载。

我们之前已经在Component基类中预留了SerializeDeserialize纯虚函数。以YAML格式(使用yaml-cpp库)为例,实现Scene的序列化:

void Scene::Serialize(const std::filesystem::path& filepath) { YAML::Emitter out; out << YAML::BeginMap; out << YAML::Key << "Scene" << YAML::Value << "Untitled"; out << YAML::Key << "GameObjects" << YAML::Value << YAML::BeginSeq; // 一个辅助函数,递归序列化对象及其子对象 std::function<void(YAML::Emitter&, GameObject*)> serializeGameObject; serializeGameObject = [&](YAML::Emitter& out, GameObject* obj) { if (!obj || obj == &m_RootObject) return; // 不序列化根对象 out << YAML::BeginMap; out << YAML::Key << "ID" << YAML::Value << obj->id; out << YAML::Key << "Name" << YAML::Value << obj->name; out << YAML::Key << "ParentID" << YAML::Value << (obj->GetParent() ? obj->GetParent()->id : 0); // 序列化Transform(特殊处理,因为它是内置的) out << YAML::Key << "Transform" << YAML::Value; obj->transform.Serialize(out); // 序列化其他所有组件 out << YAML::Key << "Components" << YAML::Value << YAML::BeginSeq; for (auto& comp : obj->GetAllComponents()) { out << YAML::BeginMap; out << YAML::Key << "Type" << YAML::Value << typeid(*comp).name(); // 需要更友好的类型名 comp->Serialize(out); out << YAML::EndMap; } out << YAML::EndSeq; out << YAML::EndMap; // 递归序列化子对象 for (auto child : obj->GetChildren()) { serializeGameObject(out, child); } }; // 从根对象的子对象开始序列化 for (auto child : m_RootObject.GetChildren()) { serializeGameObject(out, child); } out << YAML::EndSeq; out << YAML::EndMap; std::ofstream fout(filepath); fout << out.c_str(); }

反序列化则是一个相反的过程:先创建所有GameObject并建立ID映射,然后再根据ParentID建立层级关系,最后为每个对象反序列化其组件。

bool Scene::Deserialize(const std::filesystem::path& filepath) { std::ifstream fin(filepath); std::stringstream buffer; buffer << fin.rdbuf(); YAML::Node data = YAML::Load(buffer.str()); if (!data["GameObjects"]) return false; Clear(); // 清空当前场景 std::unordered_map<uint64_t, GameObject*> idToObjectMap; std::vector<std::pair<uint64_t, uint64_t>> parentChildPairs; // 存储<子ID, 父ID> // 第一遍:创建所有对象,并记录父子关系 for (auto objNode : data["GameObjects"]) { uint64_t id = objNode["ID"].as<uint64_t>(); std::string name = objNode["Name"].as<std::string>(); GameObject* newObj = CreateGameObject(name); newObj->id = id; // 使用文件中保存的ID idToObjectMap[id] = newObj; uint64_t parentId = objNode["ParentID"].as<uint64_t>(0); if (parentId != 0) { parentChildPairs.emplace_back(id, parentId); } // 反序列化Transform if (objNode["Transform"]) { newObj->transform.Deserialize(objNode["Transform"]); } // 反序列化其他组件 if (objNode["Components"]) { for (auto compNode : objNode["Components"]) { std::string typeName = compNode["Type"].as<std::string>(); // 根据typeName创建对应的组件实例 Component* comp = CreateComponentByTypeName(typeName); if (comp) { comp->Deserialize(compNode); newObj->AddComponent(std::unique_ptr<Component>(comp)); } } } } // 第二遍:建立层级关系 for (auto& [childId, parentId] : parentChildPairs) { auto childIt = idToObjectMap.find(childId); auto parentIt = idToObjectMap.find(parentId); if (childIt != idToObjectMap.end() && parentIt != idToObjectMap.end()) { childIt->second->SetParent(parentIt->second); } } return true; }

实操心得:序列化版本控制。你的组件数据结构可能会随着开发而改变(增加、删除或重命名字段)。为了能加载旧版本的场景文件,你需要在序列化数据中加入一个“版本号”字段。在反序列化时,根据版本号执行不同的解析逻辑,或者进行数据迁移。忽略这一点,未来版本迭代时会非常痛苦。

6. 性能优化与高级功能展望

当你的场景中有成千上万个对象时,简单的递归遍历绘制整个Hierarchy树会变得卡顿。同样,Inspector面板如果每帧都无条件绘制所有组件的所有字段,也会造成浪费。

6.1 面板渲染的性能优化策略

  1. Hierarchy的虚拟化与裁剪:对于大型场景,不要一次性绘制所有节点。可以结合ImGui的ClipperAPI,或者实现一个简单的“虚拟列表”,只绘制视口内可见的节点。计算每个节点的深度和展开状态,动态决定需要绘制的范围。
  2. Inspector的局部更新:ImGui控件本身会检查值是否改变,但调用OnInspectorGUI本身有开销。可以为组件增加一个bool m_IsExpanded状态,只有展开的组件才进行详细绘制。更进一步的优化是使用脏标记(Dirty Flag),只有组件的属性被修改时,才标记需要重新序列化或通知相关系统(如渲染器)。
  3. 对象选择的高效查找EditorContext::selectedGameObject是一个指针,查找很快。但要实现“按名称搜索”功能,就需要更高效的数据结构。可以为Scene维护一个std::unordered_map<std::string, GameObject*>的姓名索引(注意处理重名),或者使用诸如ImGui::InputText配合ImGuiListClipper来实现一个带过滤的搜索框。

6.2 可扩展性设计与高级功能入口

我们当前的架构已经具备了良好的扩展性:

  • 添加新组件:只需继承Component类,实现SerializeDeserializeOnInspectorGUI方法,并使用宏注册其字段即可。新组件会自动出现在Inspector和场景序列化中。
  • 自定义属性绘制:如果通用DrawFieldGUI不能满足需求(例如,你想为一个Texture*字段绘制一个缩略图并支持拖拽赋值),你可以在该组件的OnInspectorGUI中完全覆盖该字段的绘制逻辑,而不依赖自动生成。
  • 多选与批量编辑:目前只支持单选。你可以将EditorContext::selectedGameObject改为一个std::vector<GameObject*>。在Hierarchy中通过Ctrl+Click实现多选。在Inspector中,当多选时,只显示所有选中对象共有的组件,并且编辑一个值会同时应用到所有选中对象。这需要更复杂的属性比较和设置逻辑。

实现一个基础的Hierarchy和Inspector面板,是理解游戏引擎编辑器如何连接数据与界面的绝佳实践。从简单的树形显示和文本框开始,逐步加入拖拽、撤销、序列化、反射等高级功能,你会亲身体会到工具链对开发效率的指数级提升。这个过程中遇到的每一个问题,比如循环引用、撤销栈管理、编辑器与运行时数据分离,都是引擎架构中的经典课题。当你亲手解决了它们,你对软件设计的理解会更深一层。我建议你在实现基本功能后,尝试为你的引擎添加一个简单的“播放”按钮,在编辑态和运行态之间切换,你会立刻明白一个稳定、解耦的编辑器架构是多么重要。