feat: Play-In-Editor (PIE) with in-memory scene snapshot

Editor toolbar gains Play/Stop (Ctrl+P) with UE semantics:

- Play: serialize current scene to an in-memory YAML snapshot via new
  SceneSerializer::SerializeToString, switch display to kDisplayDirect
  (primary camera entity renders), unfreeze time (timeScale 0 -> 1)
- Stop: DeserializeFromString atomically rebuilds the pre-play scene,
  back to kDisplayViewport (editor camera), refreeze time; selection
  cleared and gizmo drag reset since all entity handles dangle
- SceneSerializer refactored: SerializeDoc/ParseRoot shared by file
  and memory variants; file Save/Load behavior unchanged
- Running state disables gizmo + editor hotkeys in the viewport (input
  goes to game scripts), draws yellow PLAYING hint and a no-camera
  warning; File->Save/Load disabled while playing (UE behavior)

Physics bodies/constraints/characters and script instances are
reconciled automatically by the existing per-frame sync on restore.
This commit is contained in:
2026-10-04 02:22:49 +08:00
parent c949e8684b
commit 2577809ee4
6 changed files with 291 additions and 59 deletions
+97
View File
@@ -17,6 +17,7 @@
#include "StatsPanel.h" #include "StatsPanel.h"
#include <Engine/Core/Log.h> #include <Engine/Core/Log.h>
#include <Engine/Core/Time.h>
#include <Engine/Serialization/SceneSerializer.h> #include <Engine/Serialization/SceneSerializer.h>
#include <imgui.h> #include <imgui.h>
@@ -36,6 +37,8 @@ Editor::~Editor() = default;
void Editor::Init(const EditorContext& ctx) void Editor::Init(const EditorContext& ctx)
{ {
m_Ctx = ctx; m_Ctx = ctx;
// PIE 运行态指针:指向本对象成员(ctx 拷贝后补接,面板只读)
m_Ctx.playState = &m_Playing;
m_Hierarchy = std::make_unique<HierarchyPanel>(); m_Hierarchy = std::make_unique<HierarchyPanel>();
m_Inspector = std::make_unique<InspectorPanel>(); m_Inspector = std::make_unique<InspectorPanel>();
@@ -44,6 +47,60 @@ void Editor::Init(const EditorContext& ctx)
m_AssetBrowser = std::make_unique<AssetBrowserPanel>(); m_AssetBrowser = std::make_unique<AssetBrowserPanel>();
} }
// ---- PIE(Play-In-Editor)----
// Play:当前场景 → 内存 YAML 快照 → 切游戏渲染(primary 相机实体)
// → 解冻时间(物理/脚本/动画开始跑)。快照失败不进 Play(现场不动)
void Editor::DoPlay()
{
if (!m_Ctx.scene || !m_Ctx.assets || !m_Ctx.renderer) return;
m_PlaySnapshot = Serialization::SceneSerializer::SerializeToString(
*m_Ctx.scene, *m_Ctx.assets, "PlaySnapshot");
if (m_PlaySnapshot.empty())
{
ENGINE_ERROR("PIE: snapshot failed, play aborted");
return;
}
m_Playing = true;
m_Ctx.renderer->SetDisplayMode(kDisplayDirect);
Time::SetTimeScale(1.0f);
ENGINE_INFO("PIE: playing (scene snapshot {} bytes)",
m_PlaySnapshot.size());
}
// Stop:快照原子换血恢复 → 回编辑器渲染 → 冻结时间。
// 换血后所有 Entity 句柄悬空:清选中(描边同步清)、Gizmo 拖拽态
// 复位。物理 body/约束/角色由对账系统随实体重建/清理,无需干预。
// 恢复失败(快照损坏,理论罕见)也必须回编辑态,现场留给用户
// File→Load 或退出前 Save
void Editor::DoStop()
{
if (!m_Ctx.scene || !m_Ctx.assets || !m_Ctx.renderer) return;
std::string envPath;
if (!Serialization::SceneSerializer::DeserializeFromString(
*m_Ctx.scene, *m_Ctx.assets, m_PlaySnapshot, envPath))
{
ENGINE_ERROR("PIE: snapshot restore failed (scene is now the "
"played-out state)");
}
m_PlaySnapshot.clear();
m_Playing = false;
m_Ctx.renderer->SetDisplayMode(kDisplayViewport);
Time::SetTimeScale(0.0f);
SelectEntity(entt::null);
m_Gizmo.dragging = false;
ENGINE_INFO("PIE: stopped, editor scene restored");
}
void Editor::TogglePlay()
{
m_Playing ? DoStop() : DoPlay();
}
void Editor::Draw() void Editor::Draw()
{ {
// ImGuizmo 每帧首调(官方约定):内部会清 mbOverGizmoHotspot 等 // ImGuizmo 每帧首调(官方约定):内部会清 mbOverGizmoHotspot 等
@@ -133,8 +190,47 @@ void Editor::DrawMenuBar()
return; return;
} }
// ---- PIE 工具栏(UE 主工具栏语义):Play/Stop 切换按钮 +
// Ctrl+P 全局热键。运行态按钮变 Stop(黄底提示在视口内)----
{
const ImGuiIO& io = ImGui::GetIO();
// Ctrl+P:文本输入中(改名/路径框)不触发,防打字误切 PIE
if (!m_Playing && io.KeyCtrl && !io.WantTextInput &&
ImGui::IsKeyPressed(ImGuiKey_P, false))
{
TogglePlay();
}
if (m_Playing)
{
ImGui::PushStyleColor(ImGuiCol_Button,
ImVec4(0.75f, 0.45f, 0.10f, 1.0f));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered,
ImVec4(0.85f, 0.55f, 0.15f, 1.0f));
ImGui::PushStyleColor(ImGuiCol_ButtonActive,
ImVec4(0.90f, 0.62f, 0.20f, 1.0f));
if (ImGui::SmallButton("Stop (Ctrl+P)"))
{
TogglePlay();
}
ImGui::PopStyleColor(3);
ImGui::SameLine();
ImGui::TextColored(ImVec4(1.0f, 0.72f, 0.25f, 1.0f),
"PLAYING - editor input disabled");
}
else if (ImGui::SmallButton("Play (Ctrl+P)"))
{
TogglePlay();
}
ImGui::SameLine();
ImGui::Separator();
}
if (ImGui::BeginMenu("File")) if (ImGui::BeginMenu("File"))
{ {
// 运行态禁存取:Save 会把"玩出来的状态"当编辑结果落盘,
// Load 会撕掉快照恢复链(UE 运行态同样禁)
ImGui::BeginDisabled(m_Playing);
if (ImGui::MenuItem("Save Scene...", nullptr)) if (ImGui::MenuItem("Save Scene...", nullptr))
{ {
m_ShowSaveModal = true; m_ShowSaveModal = true;
@@ -143,6 +239,7 @@ void Editor::DrawMenuBar()
{ {
m_ShowLoadModal = true; m_ShowLoadModal = true;
} }
ImGui::EndDisabled();
ImGui::Separator(); ImGui::Separator();
if (ImGui::MenuItem("Quit", "Alt+F4")) if (ImGui::MenuItem("Quit", "Alt+F4"))
{ {
+15
View File
@@ -55,6 +55,17 @@ public:
// 请求退出(弹确认框;窗口关闭事件走这里) // 请求退出(弹确认框;窗口关闭事件走这里)
void RequestQuit() { m_ShowQuitConfirm = true; } void RequestQuit() { m_ShowQuitConfirm = true; }
// ---- PIE(Play-In-Editor,UE 工具栏语义)----
bool IsPlaying() const { return m_Playing; }
// Play:内存快照当前场景 → 切游戏渲染(primary 相机)→ 解冻时间;
// Stop:快照原子换血恢复 → 回编辑器渲染 → 冻结时间。
// 编辑器态默认 timeScale=0(物理冻结 = UE 语义),Play 解冻
void TogglePlay();
private:
void DoPlay(); // TogglePlay 的 Play 半程(快照 + 切渲染 + 解冻)
void DoStop(); // TogglePlay 的 Stop 半程(恢复 + 回编辑器 + 冻结)
// ---- 选中状态 ---- // ---- 选中状态 ----
// 同步描边:Renderer 落帧后 SelectionMask/Outline 只画选中实体 // 同步描边:Renderer 落帧后 SelectionMask/Outline 只画选中实体
//(bits = 实体位模式,0 = 清选中) //(bits = 实体位模式,0 = 清选中)
@@ -109,6 +120,10 @@ private:
bool m_ShowSaveModal = false; bool m_ShowSaveModal = false;
bool m_ShowLoadModal = false; bool m_ShowLoadModal = false;
char m_ScenePathBuf[256] = "scenes/sandbox.scene"; char m_ScenePathBuf[256] = "scenes/sandbox.scene";
// PIE 状态:Play 时场景 YAML 内存快照,Stop 时原子换血恢复
bool m_Playing = false;
std::string m_PlaySnapshot;
}; };
#endif //HELLOVULKAN_EDITOR_H #endif //HELLOVULKAN_EDITOR_H
+4
View File
@@ -42,6 +42,10 @@ struct EditorContext {
// 无关;面板经此做 F 焦点/Gizmo 视图/对齐跳转) // 无关;面板经此做 F 焦点/Gizmo 视图/对齐跳转)
Engine::EditorCamera *editorCamera = nullptr; Engine::EditorCamera *editorCamera = nullptr;
// PIE 运行态(Play-In-Editor):Editor 持有,只读指针。
// 面板门控用——运行态禁 Gizmo/编辑器热键,渲染源已是 primary 相机
const bool *playState = nullptr;
std::function<int()> getEnvIndex; // 当前环境表下标 std::function<int()> getEnvIndex; // 当前环境表下标
std::function<void(int)> setEnvIndex; std::function<void(int)> setEnvIndex;
}; };
+34 -5
View File
@@ -152,7 +152,10 @@ std::optional<entt::entity> ViewportPanel::Draw(const EditorContext &ctx,
selected != entt::null selected != entt::null
? reg.try_get<ECS::TransformComponent>(selected) ? reg.try_get<ECS::TransformComponent>(selected)
: nullptr; : nullptr;
if (out.focused && !ImGui::GetIO().WantTextInput && // PIE 运行态:输入归游戏(脚本读 GLFW 轮询),Gizmo/编辑器
// 热键全部让位(UE Play 语义:Scene view 编辑操作冻结)
const bool playing = ctx.playState && *ctx.playState;
if (!playing && out.focused && !ImGui::GetIO().WantTextInput &&
!ImGui::IsMouseDown(ImGuiMouseButton_Right)) { !ImGui::IsMouseDown(ImGuiMouseButton_Right)) {
if (ImGui::IsKeyPressed(ImGuiKey_W)) gizmo.op = ImGuizmo::TRANSLATE; if (ImGui::IsKeyPressed(ImGuiKey_W)) gizmo.op = ImGuizmo::TRANSLATE;
if (ImGui::IsKeyPressed(ImGuiKey_E)) gizmo.op = ImGuizmo::ROTATE; if (ImGui::IsKeyPressed(ImGuiKey_E)) gizmo.op = ImGuizmo::ROTATE;
@@ -169,7 +172,8 @@ std::optional<entt::entity> ViewportPanel::Draw(const EditorContext &ctx,
// ---- Gizmo 操纵杆(ImGui drawlist 叠加,无 GPU pass)---- // ---- Gizmo 操纵杆(ImGui drawlist 叠加,无 GPU pass)----
// 选中实体 + 有 Transform 才出杆;矩阵分解直接写回 // 选中实体 + 有 Transform 才出杆;矩阵分解直接写回
// TransformComponent(与 Inspector 同源,下一帧数值自动跟随) // TransformComponent(与 Inspector 同源,下一帧数值自动跟随)
if (tr != nullptr) { // PIE 运行态不出杆(编辑操作冻结)
if (tr != nullptr && !playing) {
// 相机矩阵:跟随渲染源(编辑器形态 = EditorCamera,游戏 // 相机矩阵:跟随渲染源(编辑器形态 = EditorCamera,游戏
// 形态 = primary 相机实体;与 SceneRenderer::BuildFrame // 形态 = primary 相机实体;与 SceneRenderer::BuildFrame
// 同一分派),参数与渲染侧一致(aspect = 渲染分辨率、 // 同一分派),参数与渲染侧一致(aspect = 渲染分辨率、
@@ -277,14 +281,23 @@ std::optional<entt::entity> ViewportPanel::Draw(const EditorContext &ctx,
} }
// Gizmo 状态提示(左上角 drawlist 叠加,原因同下:不占布局行) // Gizmo 状态提示(左上角 drawlist 叠加,原因同下:不占布局行)
// PIE 运行态 = 黄色 PLAYING 提示替代编辑状态
{ {
char tip[64]; char tip[64];
std::snprintf(tip, sizeof(tip), "Space: %s [T] Snap: Ctrl", if (playing) {
gizmo.localSpace ? "Local" : "World"); std::snprintf(tip, sizeof(tip),
"PLAYING - Ctrl+P to stop");
} else {
std::snprintf(tip, sizeof(tip),
"Space: %s [T] Snap: Ctrl",
gizmo.localSpace ? "Local" : "World");
}
const ImVec2 imgMin = ImGui::GetItemRectMin(); const ImVec2 imgMin = ImGui::GetItemRectMin();
ImGui::GetWindowDrawList()->AddText( ImGui::GetWindowDrawList()->AddText(
ImVec2(imgMin.x + 8.0f, imgMin.y + 8.0f), ImVec2(imgMin.x + 8.0f, imgMin.y + 8.0f),
IM_COL32(255, 255, 255, 150), tip); playing ? IM_COL32(255, 184, 46, 220)
: IM_COL32(255, 255, 255, 150),
tip);
} }
// Dynamic 刚体提示:物理每帧把姿态写回 Transform,拖拽即刻 // Dynamic 刚体提示:物理每帧把姿态写回 Transform,拖拽即刻
@@ -292,6 +305,22 @@ std::optional<entt::entity> ViewportPanel::Draw(const EditorContext &ctx,
// 用 drawlist 叠加而非 ImGui::TextDisabled:后者在 Image 下方 // 用 drawlist 叠加而非 ImGui::TextDisabled:后者在 Image 下方
// 占一行布局 → 滚动条出现/消失循环 → avail 抖动 → 渲染分辨率 // 占一行布局 → 滚动条出现/消失循环 → avail 抖动 → 渲染分辨率
// 每帧重报(视口疯狂顶动);且默认字体无中文字形(显示 ?) // 每帧重报(视口疯狂顶动);且默认字体无中文字形(显示 ?)
if (playing) {
// 运行态无 primary 相机实体 = 游戏画面黑屏(BuildFrame
// 跳帧),提示补相机(Hierarchy 右键或 Add Component)
ECS::TransformComponent *camT = nullptr;
if (ViewCamera(ctx, camT) == nullptr) {
const ImVec2 imgMin = ImGui::GetItemRectMin();
ImGui::GetWindowDrawList()->AddText(
ImVec2(imgMin.x + 8.0f,
imgMin.y + size.y * 0.5f),
IM_COL32(255, 120, 80, 230),
"No primary camera entity - add a Camera "
"component and check 'Primary'");
}
}
// Dynamic 刚体提示(drawlist 叠加,理由同上)
if (selected != entt::null) { if (selected != entt::null) {
if (const auto *rb = reg.try_get<ECS::RigidBodyComponent>(selected); if (const auto *rb = reg.try_get<ECS::RigidBodyComponent>(selected);
rb != nullptr && rb != nullptr &&
@@ -55,6 +55,23 @@ namespace Engine::Serialization {
Assets::AssetManager& assets, Assets::AssetManager& assets,
const std::string& inPath, const std::string& inPath,
std::string& outEnvironmentPath); std::string& outEnvironmentPath);
// ---- 内存快照(同一套实体读写,不同载体)----
// 消费者:编辑器 Play-In-Editor 快照恢复、Prefab、运行时动态
// 拼装场景。documentName 仅写入文档头 "Scene" 字段(注释性),
// 快照可传实体名或留空;environmentPath 语义同文件版。
static std::string SerializeToString(
const ECS::Scene& scene,
Assets::AssetManager& assets,
const std::string& documentName = {},
const std::string& environmentPath = {});
// 失败(YAML 异常/版本过高)返回 false 且不动现场。
// 实体悬空语义同文件版 Deserialize。
static bool DeserializeFromString(ECS::Scene& scene,
Assets::AssetManager& assets,
const std::string& yamlText,
std::string& outEnvironmentPath);
}; };
} // namespace Engine::Serialization } // namespace Engine::Serialization
+124 -54
View File
@@ -384,47 +384,65 @@ namespace Engine::Serialization {
// ---- 保存 ---- // ---- 保存 ----
// Emitter 构建段(文件版与内存版共用):documentName 空则不写
// 文档头 "Scene" 字段(快照无文件名,该字段是注释性的)
static std::string SerializeDoc(const ECS::Scene& scene,
Assets::AssetManager& assets,
const std::string& documentName,
const std::string& environmentPath)
{
// float32 无损往返需 9 位有效数字(std::numeric_limits
// <float>::max_digits10)
YAML::Emitter out;
out.SetFloatPrecision(9);
out << YAML::BeginMap;
if (!documentName.empty()) {
out << YAML::Key << "Scene" << YAML::Value << documentName;
}
out << YAML::Key << "Version" << YAML::Value << kSceneVersion;
if (!environmentPath.empty()) {
out << YAML::Key << "Environment" << YAML::Value
<< environmentPath;
}
const auto& s = scene.GetSettings();
out << YAML::Key << "Settings" << YAML::Value << YAML::BeginMap;
out << YAML::Key << "ClearColor" << YAML::Value;
EmitVec4(out, s.ClearColor);
out << YAML::Key << "AmbientColor" << YAML::Value;
EmitVec4(out, s.AmbientColor);
out << YAML::Key << "DrawSkybox" << YAML::Value << s.DrawSkybox;
out << YAML::EndMap;
out << YAML::Key << "Entities" << YAML::Value << YAML::BeginSeq;
const ECS::Registry& reg = scene.GetRegistry();
reg.view<ECS::IdComponent>().each(
[&](entt::entity e, const ECS::IdComponent&) {
EmitEntity(out, assets, reg, e);
});
out << YAML::EndSeq;
out << YAML::EndMap;
if (!out.good()) {
ENGINE_ERROR("SceneSerializer: emit failed: {}", out.GetLastError());
return {};
}
return {out.c_str(), out.size()};
}
bool SceneSerializer::Serialize(const ECS::Scene& scene, bool SceneSerializer::Serialize(const ECS::Scene& scene,
Assets::AssetManager& assets, Assets::AssetManager& assets,
const std::string& outPath, const std::string& outPath,
const std::string& environmentPath) const std::string& environmentPath)
{ {
try { try {
// float32 无损往返需 9 位有效数字(std::numeric_limits const std::string doc = SerializeDoc(
// <float>::max_digits10) scene, assets,
YAML::Emitter out; std::filesystem::path(outPath).stem().string(),
out.SetFloatPrecision(9); environmentPath);
if (doc.empty()) {
out << YAML::BeginMap; return false; // emit 失败,日志已在 SerializeDoc 打过
out << YAML::Key << "Scene" << YAML::Value
<< std::filesystem::path(outPath).stem().string();
out << YAML::Key << "Version" << YAML::Value << kSceneVersion;
if (!environmentPath.empty()) {
out << YAML::Key << "Environment" << YAML::Value
<< environmentPath;
}
const auto& s = scene.GetSettings();
out << YAML::Key << "Settings" << YAML::Value << YAML::BeginMap;
out << YAML::Key << "ClearColor" << YAML::Value;
EmitVec4(out, s.ClearColor);
out << YAML::Key << "AmbientColor" << YAML::Value;
EmitVec4(out, s.AmbientColor);
out << YAML::Key << "DrawSkybox" << YAML::Value << s.DrawSkybox;
out << YAML::EndMap;
out << YAML::Key << "Entities" << YAML::Value << YAML::BeginSeq;
const ECS::Registry& reg = scene.GetRegistry();
reg.view<ECS::IdComponent>().each(
[&](entt::entity e, const ECS::IdComponent&) {
EmitEntity(out, assets, reg, e);
});
out << YAML::EndSeq;
out << YAML::EndMap;
if (!out.good()) {
ENGINE_ERROR("SceneSerializer: emit failed: {}", out.GetLastError());
return false;
} }
std::ofstream file(outPath, std::ios::trunc); std::ofstream file(outPath, std::ios::trunc);
@@ -433,7 +451,7 @@ namespace Engine::Serialization {
outPath); outPath);
return false; return false;
} }
file << out.c_str(); file << doc;
return true; return true;
} }
catch (const YAML::Exception& e) { catch (const YAML::Exception& e) {
@@ -446,35 +464,42 @@ namespace Engine::Serialization {
} }
} }
// ---- 加载 ---- std::string SceneSerializer::SerializeToString(
const ECS::Scene& scene,
bool SceneSerializer::Deserialize(ECS::Scene& scene, Assets::AssetManager& assets,
Assets::AssetManager& assets, const std::string& documentName,
const std::string& inPath, const std::string& environmentPath)
std::string& outEnvironmentPath)
{ {
outEnvironmentPath.clear();
YAML::Node root;
try { try {
root = YAML::LoadFile(inPath); return SerializeDoc(scene, assets, documentName, environmentPath);
} }
catch (const YAML::Exception& e) { catch (const YAML::Exception& e) {
ENGINE_ERROR("SceneSerializer: cannot parse '{}' ({})", inPath, ENGINE_ERROR("SceneSerializer: snapshot failed ({})", e.what());
e.what()); return {};
return false;
} }
catch (const std::exception& e) { catch (const std::exception& e) {
ENGINE_ERROR("SceneSerializer: cannot read '{}' ({})", inPath, ENGINE_ERROR("SceneSerializer: snapshot failed ({})", e.what());
e.what()); return {};
return false;
} }
}
// ---- 加载 ----
// 解析段(文件版与内存快照共用):root → 原子换血重建。
// sourceName 仅用于日志(文件路径或 "snapshot")
static bool ParseRoot(ECS::Scene& scene,
Assets::AssetManager& assets,
const YAML::Node& root,
const std::string& sourceName,
std::string& outEnvironmentPath)
{
outEnvironmentPath.clear();
// Version 闸:高版本拒载(结构未知),缺失按 0 宽容旧档 // Version 闸:高版本拒载(结构未知),缺失按 0 宽容旧档
if (const int version = root["Version"].as<int>(0); if (const int version = root["Version"].as<int>(0);
version > kSceneVersion) { version > kSceneVersion) {
ENGINE_ERROR("SceneSerializer: '{}' version {} > supported {}", ENGINE_ERROR("SceneSerializer: '{}' version {} > supported {}",
inPath, version, kSceneVersion); sourceName, version, kSceneVersion);
return false; return false;
} }
@@ -714,8 +739,53 @@ namespace Engine::Serialization {
scene.UpdateTransforms(); scene.UpdateTransforms();
ENGINE_INFO("SceneSerializer: loaded {} entities from '{}'", ENGINE_INFO("SceneSerializer: loaded {} entities from '{}'",
parsed.size(), inPath); parsed.size(), sourceName);
return true; return true;
} }
bool SceneSerializer::Deserialize(ECS::Scene& scene,
Assets::AssetManager& assets,
const std::string& inPath,
std::string& outEnvironmentPath)
{
YAML::Node root;
try {
root = YAML::LoadFile(inPath);
}
catch (const YAML::Exception& e) {
ENGINE_ERROR("SceneSerializer: cannot parse '{}' ({})", inPath,
e.what());
return false;
}
catch (const std::exception& e) {
ENGINE_ERROR("SceneSerializer: cannot read '{}' ({})", inPath,
e.what());
return false;
}
return ParseRoot(scene, assets, root, inPath, outEnvironmentPath);
}
bool SceneSerializer::DeserializeFromString(
ECS::Scene& scene,
Assets::AssetManager& assets,
const std::string& yamlText,
std::string& outEnvironmentPath)
{
YAML::Node root;
try {
root = YAML::Load(yamlText);
}
catch (const YAML::Exception& e) {
ENGINE_ERROR("SceneSerializer: snapshot parse failed ({})",
e.what());
return false;
}
catch (const std::exception& e) {
ENGINE_ERROR("SceneSerializer: snapshot parse failed ({})",
e.what());
return false;
}
return ParseRoot(scene, assets, root, "snapshot", outEnvironmentPath);
}
} // namespace Engine::Serialization } // namespace Engine::Serialization