阶段性合并:动画重定向、碎裂共享库、第三人称玩法、编辑器面板与 vendored ufbx

- 动画:HumanoidRig 三层架构(standard 标准槽 / dialects 方言映射 / mapper 配对),
  指/趾统一走世界增量两遍式烘焙;AnimationAttach 拆为纯算法 + facade;修正 HumanoidBasis
  行走基朝向与跨骨架去重、根运动通道
- 物理:CharacterController 角色互碰撞 / shapeOffset / 接触事件;PhysicsWorld 接触合成与
  字符冲量判定
- 渲染与材质:Model 多材质按材质拆子 mesh 与贴图路径解析、DDS 直传、GLB 内嵌纹理落盘、
  不透明通道 alpha 固定 1.0、glTF FlipUVs 对齐;SceneSerializer 支持 meshIndex 与材质覆盖
- 脚本:FractureLib 抽为共享库(HostView 桥接)、WallFracture 参数直绑、新增
  ThirdPersonCamera / Follower / WASDMove 玩法;移除 GameDirector / PlayerController / Target
- 编辑器:新增 GamepadPanel、LogPanel(LogRing),改进 Inspector / 材质 / 统计 / 视口面板
- 依赖:vendored ufbx(FBX 5.x/6.x/7.x 全版本解析,替代 assimp 对 FBX 6.1 的拒读)
This commit is contained in:
2026-10-11 22:22:19 +08:00
parent 334819bf97
commit beba491bcb
57 changed files with 43200 additions and 1378 deletions
+19
View File
@@ -19,6 +19,8 @@
#include "panels/ControllerEditorPanel.h" #include "panels/ControllerEditorPanel.h"
#include "panels/ViewportPanel.h" #include "panels/ViewportPanel.h"
#include "panels/StatsPanel.h" #include "panels/StatsPanel.h"
#include "panels/LogPanel.h"
#include "panels/GamepadPanel.h"
#include <Engine/Core/Log.h> #include <Engine/Core/Log.h>
#include <Engine/Serialization/SceneSerializer.h> #include <Engine/Serialization/SceneSerializer.h>
@@ -60,6 +62,8 @@ void Editor::Init(const EditorContext& ctx)
m_Inspector = std::make_unique<InspectorPanel>(); m_Inspector = std::make_unique<InspectorPanel>();
m_Viewport = std::make_unique<ViewportPanel>(); m_Viewport = std::make_unique<ViewportPanel>();
m_Stats = std::make_unique<StatsPanel>(); m_Stats = std::make_unique<StatsPanel>();
m_Log = std::make_unique<LogPanel>();
m_Gamepad = std::make_unique<GamepadPanel>();
m_AssetBrowser = std::make_unique<AssetBrowserPanel>(); m_AssetBrowser = std::make_unique<AssetBrowserPanel>();
m_MaterialEditor = std::make_unique<MaterialEditorPanel>(); m_MaterialEditor = std::make_unique<MaterialEditorPanel>();
m_Ctx.materialEditor = m_MaterialEditor.get(); m_Ctx.materialEditor = m_MaterialEditor.get();
@@ -219,6 +223,8 @@ void Editor::Draw()
} }
} }
if (m_ShowStats) m_Stats->Draw(m_Ctx); if (m_ShowStats) m_Stats->Draw(m_Ctx);
if (m_ShowLog) m_Log->Draw(m_Ctx);
m_Gamepad->Draw(m_Ctx); // Tools 弹窗(内部 m_Open 门控)
if (m_ShowAssetBrowser) m_AssetBrowser->Draw(m_Ctx); if (m_ShowAssetBrowser) m_AssetBrowser->Draw(m_Ctx);
if (m_ShowMaterialEditor) m_MaterialEditor->Draw(m_Ctx); if (m_ShowMaterialEditor) m_MaterialEditor->Draw(m_Ctx);
m_ControllerEditor->Draw(m_Ctx); // 面板内部 m_Open 门控 m_ControllerEditor->Draw(m_Ctx); // 面板内部 m_Open 门控
@@ -236,6 +242,11 @@ bool Editor::BlockingKeyboard() const
const ImGuiIO& io = ImGui::GetIO(); const ImGuiIO& io = ImGui::GetIO();
if (io.WantTextInput) return true; // 打字中(改名等)不让位快捷键 if (io.WantTextInput) return true; // 打字中(改名等)不让位快捷键
// PIE 运行态:键盘全给游戏——编辑热键/Gizmo 在运行态本就让位
//(ViewportPanel !playing 分支),不再因视口失焦拦截。否则点
// Play 后焦点停在工具栏,WASD 整局全死(玩家输入无响应的根因)
if (IsPlaying()) return false;
// 游戏形态(Fullscreen):WASD 全窗口生效,键盘只让位文本输入 // 游戏形态(Fullscreen):WASD 全窗口生效,键盘只让位文本输入
if (m_Ctx.renderer && if (m_Ctx.renderer &&
m_Ctx.renderer->GetDisplayMode() == kDisplayDirect) m_Ctx.renderer->GetDisplayMode() == kDisplayDirect)
@@ -298,11 +309,18 @@ void Editor::DrawMenuBar()
ImGui::MenuItem("Details", nullptr, &m_ShowInspector); ImGui::MenuItem("Details", nullptr, &m_ShowInspector);
ImGui::MenuItem("Viewport", nullptr, &m_ShowViewport); ImGui::MenuItem("Viewport", nullptr, &m_ShowViewport);
ImGui::MenuItem("Stats", nullptr, &m_ShowStats); ImGui::MenuItem("Stats", nullptr, &m_ShowStats);
ImGui::MenuItem("Log", nullptr, &m_ShowLog);
ImGui::MenuItem("Content Browser", nullptr, &m_ShowAssetBrowser); ImGui::MenuItem("Content Browser", nullptr, &m_ShowAssetBrowser);
ImGui::MenuItem("Material Editor", nullptr, &m_ShowMaterialEditor); ImGui::MenuItem("Material Editor", nullptr, &m_ShowMaterialEditor);
ImGui::EndMenu(); ImGui::EndMenu();
} }
if (ImGui::BeginMenu("Tools"))
{
ImGui::MenuItem("Gamepad Test", nullptr, &m_Gamepad->m_Open);
ImGui::EndMenu();
}
ImGui::EndMenuBar(); ImGui::EndMenuBar();
} }
@@ -428,6 +446,7 @@ void Editor::DrawDockSpace()
ImGui::DockBuilderDockWindow("Outliner", dockLeft); ImGui::DockBuilderDockWindow("Outliner", dockLeft);
ImGui::DockBuilderDockWindow("Viewport", dockCenter); ImGui::DockBuilderDockWindow("Viewport", dockCenter);
ImGui::DockBuilderDockWindow("Stats", dockCenter); // Viewport 旁 tab ImGui::DockBuilderDockWindow("Stats", dockCenter); // Viewport 旁 tab
ImGui::DockBuilderDockWindow("Log", dockBottom); // Content Browser 旁 tab
ImGui::DockBuilderDockWindow("Content Browser", dockBottom); ImGui::DockBuilderDockWindow("Content Browser", dockBottom);
ImGui::DockBuilderDockWindow("Details", dockRight); ImGui::DockBuilderDockWindow("Details", dockRight);
ImGui::DockBuilderDockWindow("Material Editor", dockRight); // Details 旁 tab ImGui::DockBuilderDockWindow("Material Editor", dockRight); // Details 旁 tab
+5
View File
@@ -29,6 +29,8 @@ class HierarchyPanel;
class InspectorPanel; class InspectorPanel;
class ViewportPanel; class ViewportPanel;
class StatsPanel; class StatsPanel;
class LogPanel;
class GamepadPanel;
class AssetBrowserPanel; class AssetBrowserPanel;
class MaterialEditorPanel; class MaterialEditorPanel;
class ControllerEditorPanel; class ControllerEditorPanel;
@@ -126,6 +128,8 @@ private:
std::unique_ptr<InspectorPanel> m_Inspector; std::unique_ptr<InspectorPanel> m_Inspector;
std::unique_ptr<ViewportPanel> m_Viewport; std::unique_ptr<ViewportPanel> m_Viewport;
std::unique_ptr<StatsPanel> m_Stats; std::unique_ptr<StatsPanel> m_Stats;
std::unique_ptr<LogPanel> m_Log;
std::unique_ptr<GamepadPanel> m_Gamepad;
std::unique_ptr<AssetBrowserPanel> m_AssetBrowser; std::unique_ptr<AssetBrowserPanel> m_AssetBrowser;
std::unique_ptr<MaterialEditorPanel> m_MaterialEditor; std::unique_ptr<MaterialEditorPanel> m_MaterialEditor;
std::unique_ptr<ControllerEditorPanel> m_ControllerEditor; std::unique_ptr<ControllerEditorPanel> m_ControllerEditor;
@@ -137,6 +141,7 @@ private:
bool m_ShowInspector = true; bool m_ShowInspector = true;
bool m_ShowViewport = true; bool m_ShowViewport = true;
bool m_ShowStats = true; bool m_ShowStats = true;
bool m_ShowLog = true;
bool m_ShowAssetBrowser = true; bool m_ShowAssetBrowser = true;
bool m_ShowMaterialEditor = true; bool m_ShowMaterialEditor = true;
+199
View File
@@ -0,0 +1,199 @@
//
// GamepadPanel.cpp:手柄输入可视化回显(极简手柄视图)。
//
// 一块画布 = 手柄俯视图:顶部扳机条 + 肩键,左上左摇杆、左下十字键、
// 右上 ABXY(Xbox 语义色)、右下右摇杆,中央 Back/Guide/Start 小键。
// 按下即亮蓝(面键亮语义色),无数值无卡片——位置即实体,一眼对上
// 手里的手柄。数据源 = Window::PollGamepad(GLFW 原始轴值/按键)。
//
#include "GamepadPanel.h"
#include <Engine/Core/Window.h>
#include <imgui.h>
#include <cmath>
namespace {
// GLFW 标准布局下标
enum PadBtn {
kA = 0, kB = 1, kX = 2, kY = 3,
kLB = 4, kRB = 5, kBack = 6, kStart = 7, kGuide = 8,
kLS = 9, kRS = 10, kUp = 11, kRight = 12, kDown = 13, kLeft = 14,
};
constexpr ImU32 kLit = IM_COL32(90, 170, 250, 255); // 按下亮蓝
constexpr ImU32 kFill = IM_COL32(52, 52, 60, 255); // 暗态填充
constexpr ImU32 kEdge = IM_COL32(104, 104, 114, 255); // 暗态描边
constexpr ImU32 kTxt = IM_COL32(215, 215, 220, 255); // 标签文字
// 块状键(肩键 / Back / Start)
void Block(ImDrawList* dl, const ImVec2& c, float w, float h,
const char* label, bool pressed)
{
const ImVec2 mn(c.x - w * 0.5f, c.y - h * 0.5f);
const ImVec2 mx(c.x + w * 0.5f, c.y + h * 0.5f);
dl->AddRectFilled(mn, mx, pressed ? kLit : kFill, 5.0f);
dl->AddRect(mn, mx, pressed ? IM_COL32(160, 210, 255, 255) : kEdge,
5.0f);
const ImVec2 ts = ImGui::CalcTextSize(label);
dl->AddText(ImVec2(c.x - ts.x * 0.5f, c.y - ts.y * 0.5f), kTxt, label);
}
// 圆键(Guide)
void Circle(ImDrawList* dl, const ImVec2& c, float r, const char* label,
bool pressed)
{
dl->AddCircleFilled(c, r, pressed ? kLit : kFill);
dl->AddCircle(c, r, pressed ? IM_COL32(160, 210, 255, 255) : kEdge, 0,
1.5f);
const ImVec2 ts = ImGui::CalcTextSize(label);
dl->AddText(ImVec2(c.x - ts.x * 0.5f, c.y - ts.y * 0.5f), kTxt, label);
}
// Xbox 语义色面键
void Face(ImDrawList* dl, const ImVec2& c, float r, char label,
bool pressed)
{
ImU32 col;
switch (label)
{
case 'A': col = IM_COL32(32, 150, 40, 255); break;
case 'B': col = IM_COL32(200, 45, 45, 255); break;
case 'X': col = IM_COL32(50, 110, 220, 255); break;
default: col = IM_COL32(225, 185, 40, 255); break;
}
dl->AddCircleFilled(c, r, pressed ? col : IM_COL32(col & 0xFF,
(col >> 8) & 0xFF,
(col >> 16) & 0xFF,
60));
dl->AddCircle(c, r, pressed ? col : kEdge, 0, 2.0f);
const ImVec2 ts = ImGui::CalcTextSize(&label, &label + 1);
dl->AddText(ImVec2(c.x - ts.x * 0.5f, c.y - ts.y * 0.5f),
pressed ? IM_COL32(255, 255, 255, 255) : kTxt, &label);
}
// 摇杆画布(底座 + 死区圈 + 轴点)
void Stick(ImDrawList* dl, const ImVec2& c, float r, float ax, float ay,
float deadzone)
{
dl->AddCircleFilled(c, r, IM_COL32(32, 32, 37, 255));
dl->AddCircle(c, r, kEdge, 0, 1.5f);
dl->AddCircle(c, r * deadzone, IM_COL32(90, 140, 210, 110), 0, 1.2f);
dl->AddLine(ImVec2(c.x - r * 0.93f, c.y), ImVec2(c.x + r * 0.93f, c.y),
IM_COL32(50, 50, 58, 255), 1.0f);
dl->AddLine(ImVec2(c.x, c.y - r * 0.93f), ImVec2(c.x, c.y + r * 0.93f),
IM_COL32(50, 50, 58, 255), 1.0f);
const float mag = std::sqrt(ax * ax + ay * ay);
const float cl = (mag > 1.0f) ? 1.0f / mag : 1.0f;
const ImVec2 pt(c.x + ax * cl * (r - 8.0f),
c.y + ay * cl * (r - 8.0f));
if (mag > deadzone)
{
dl->AddCircleFilled(pt, 10.0f, IM_COL32(90, 170, 250, 70));
}
dl->AddCircleFilled(pt, 7.0f,
mag > deadzone ? kLit
: IM_COL32(140, 140, 148, 255));
}
// 十字键(真十字形,按下臂亮)
void Dpad(ImDrawList* dl, const ImVec2& c, float arm, float w,
const bool* b)
{
const ImVec2 l0(c.x - arm, c.y - w * 0.5f);
const ImVec2 l1(c.x, c.y + w * 0.5f);
const ImVec2 r0(c.x, c.y - w * 0.5f);
const ImVec2 r1(c.x + arm, c.y + w * 0.5f);
const ImVec2 u0(c.x - w * 0.5f, c.y - arm);
const ImVec2 u1(c.x + w * 0.5f, c.y);
const ImVec2 d0(c.x - w * 0.5f, c.y);
const ImVec2 d1(c.x + w * 0.5f, c.y + arm);
dl->AddRectFilled(l0, l1, b[kLeft] ? kLit : kFill, 4.0f);
dl->AddRectFilled(r0, r1, b[kRight] ? kLit : kFill, 4.0f);
dl->AddRectFilled(u0, u1, b[kUp] ? kLit : kFill, 4.0f);
dl->AddRectFilled(d0, d1, b[kDown] ? kLit : kFill, 4.0f);
dl->AddRect(l0, l1, kEdge, 4.0f);
dl->AddRect(r0, r1, kEdge, 4.0f);
dl->AddRect(u0, u1, kEdge, 4.0f);
dl->AddRect(d0, d1, kEdge, 4.0f);
}
// 线性扳机条(GLFW -1..1 → 0..1 填充)
void Trigger(ImDrawList* dl, const ImVec2& mn, const ImVec2& mx,
float raw)
{
dl->AddRectFilled(mn, mx, IM_COL32(32, 32, 37, 255), 4.0f);
const float t = (raw + 1.0f) * 0.5f;
if (t > 0.02f)
{
dl->AddRectFilled(mn, ImVec2(mn.x + (mx.x - mn.x) * t, mx.y),
IM_COL32(235, 120, 55, 255), 4.0f);
}
dl->AddRect(mn, mx, kEdge, 4.0f);
}
} // namespace
void GamepadPanel::Draw(const EditorContext& ctx)
{
if (!m_Open)
{
return;
}
if (!ImGui::Begin("Gamepad", &m_Open))
{
ImGui::End();
return;
}
Engine::GamepadState gp;
const bool connected =
ctx.app && ctx.app->GetWindow().PollGamepad(gp);
if (!connected)
{
ImGui::TextColored(ImVec4(0.60f, 0.60f, 0.60f, 1.0f),
"○ Not connected (JOYSTICK_1)");
ImGui::End();
return;
}
ImGui::TextColored(ImVec4(0.40f, 0.85f, 0.40f, 1.0f), "● Connected");
ImGui::Spacing();
// ---- 手柄画布(固定尺寸,元素 = 手柄真实位置;无背景板)----
constexpr float kW = 440.0f, kH = 262.0f;
constexpr float kDead = 0.15f; // = InputMap kRadialDeadZone
ImGui::BeginChild("pad", ImVec2(kW, kH), false);
ImDrawList* dl = ImGui::GetWindowDrawList();
const ImVec2 o = ImGui::GetWindowPos();
auto C = [&](float x, float y) { return ImVec2(o.x + x, o.y + y); };
// 顶部:线性扳机条 + 肩键
Trigger(dl, C(52, 8), C(122, 20), gp.axes[4]); // LT
Trigger(dl, C(kW - 122, 8), C(kW - 52, 20), gp.axes[5]); // RT
Block(dl, C(78, 40), 76.0f, 20.0f, "LB", gp.buttons[kLB]);
Block(dl, C(kW - 78, 40), 76.0f, 20.0f, "RB", gp.buttons[kRB]);
// 左上:左摇杆|左下:十字键(向右错开——真实手柄摇杆与十字不对齐)
Stick(dl, C(76, 118), 46.0f, gp.axes[0], gp.axes[1], kDead);
Dpad(dl, C(128, 210), 36.0f, 24.0f, gp.buttons);
// 中央:Back / Guide / Start
Block(dl, C(184, 74), 40.0f, 16.0f, "Back", gp.buttons[kBack]);
Circle(dl, C(220, 58), 13.0f, "G", gp.buttons[kGuide]);
Block(dl, C(256, 74), 40.0f, 16.0f, "Start", gp.buttons[kStart]);
// 右上:ABXY 菱形|右下:右摇杆(对称错开,向中心收)
const ImVec2 fc = C(360, 118);
Face(dl, ImVec2(fc.x, fc.y - 24), 17.0f, 'Y', gp.buttons[kY]);
Face(dl, ImVec2(fc.x - 24, fc.y), 17.0f, 'X', gp.buttons[kX]);
Face(dl, ImVec2(fc.x + 24, fc.y), 17.0f, 'B', gp.buttons[kB]);
Face(dl, ImVec2(fc.x, fc.y + 24), 17.0f, 'A', gp.buttons[kA]);
Stick(dl, C(312, 210), 46.0f, gp.axes[2], gp.axes[3], kDead);
ImGui::EndChild();
ImGui::End();
}
+21
View File
@@ -0,0 +1,21 @@
//
// GamepadPanel:手柄输入可视化回显(Tools 菜单弹出窗,非 dock 常驻)。
//
// 三块:摇杆画布(原始轴值 + 引擎死区参考圈)/ 按键网格(按下高亮)/
// 动作层回显(引擎死区+钳制后脚本真正拿到的 Move/Look/Jump)。
// 测试用途:不接手柄时显示未连接状态。
//
#ifndef HELLOVULKAN_GAMEPADPANEL_H
#define HELLOVULKAN_GAMEPADPANEL_H
#include "EditorContext.h"
class GamepadPanel {
public:
void Draw(const EditorContext& ctx);
bool m_Open = false; // Tools 菜单开关(窗口 X 也能关)
};
#endif //HELLOVULKAN_GAMEPADPANEL_H
+39 -10
View File
@@ -198,6 +198,12 @@ namespace
for (u32 i = 0; i < meshes.size(); ++i) for (u32 i = 0; i < meshes.size(); ++i)
{ {
// 「mesh」下拉的过滤:选了具体子 mesh 就只列该槽位(与渲染/
// 物理的子引用语义一致);All meshes(-1)= 全列
if (mr.meshIndex >= 0 && static_cast<int>(i) != mr.meshIndex)
{
continue;
}
ImGui::PushID(static_cast<int>(i)); ImGui::PushID(static_cast<int>(i));
const std::string meshName = const std::string meshName =
@@ -689,13 +695,15 @@ void InspectorPanel::Draw(const EditorContext& ctx, entt::entity selected)
ImGui::RadioButton("Plane", &shape, 1); ImGui::RadioButton("Plane", &shape, 1);
ImGui::SameLine(); ImGui::SameLine();
ImGui::RadioButton("Trans.Plane", &shape, 2); ImGui::RadioButton("Trans.Plane", &shape, 2);
ImGui::SetNextItemWidth(-1.0f); // 固定宽度而非 -1:弹窗是 AlwaysAutoResize,-1(撑满可用
// 宽度)会拿上一帧宽度当基准 → 逐帧膨胀直到撑满屏幕
ImGui::SetNextItemWidth(180.0f);
ImGui::SliderFloat("half size", &half, 0.01f, 10.0f, ImGui::SliderFloat("half size", &half, 0.01f, 10.0f,
"%.2f"); "%.2f");
ImGui::ColorEdit3("color", col); ImGui::ColorEdit3("color", col);
if (shape == 2) if (shape == 2)
{ {
ImGui::SetNextItemWidth(-1.0f); ImGui::SetNextItemWidth(180.0f);
ImGui::SliderFloat("alpha", &alpha, 0.0f, 1.0f, ImGui::SliderFloat("alpha", &alpha, 0.0f, 1.0f,
"%.2f"); "%.2f");
} }
@@ -1271,6 +1279,8 @@ void InspectorPanel::Draw(const EditorContext& ctx, entt::entity selected)
ImGui::DragFloat("Radius", &cc->radius, 0.02f, 0.05f, 5.0f); ImGui::DragFloat("Radius", &cc->radius, 0.02f, 0.05f, 5.0f);
ImGui::DragFloat("Half Height", &cc->capsuleHalfHeight, 0.02f, 0.0f, 5.0f); ImGui::DragFloat("Half Height", &cc->capsuleHalfHeight, 0.02f, 0.0f, 5.0f);
ImGui::DragFloat("Mass", &cc->mass, 1.0f, 1.0f, 500.0f, "%.0f"); ImGui::DragFloat("Mass", &cc->mass, 1.0f, 1.0f, 500.0f, "%.0f");
// 形状附加偏移:叠加在脚底约定之上(模型 pivot 对齐用)
ImGui::DragFloat3("Shape Offset", &cc->shapeOffset.x, 0.02f, -5.0f, 5.0f);
// ---- Movement:扫掠运动学参数 ---- // ---- Movement:扫掠运动学参数 ----
ImGui::SeparatorText("Movement"); ImGui::SeparatorText("Movement");
ImGui::DragFloat("Max Slope (deg)", &cc->maxSlopeAngle, 1.0f, 1.0f, 89.0f); ImGui::DragFloat("Max Slope (deg)", &cc->maxSlopeAngle, 1.0f, 1.0f, 89.0f);
@@ -1901,11 +1911,31 @@ void InspectorPanel::Draw(const EditorContext& ctx, entt::entity selected)
// 警告跳过)---- // 警告跳过)----
ImGui::SeparatorText("Clips"); ImGui::SeparatorText("Clips");
const auto& files = AssetModelFiles(); const auto& files = AssetModelFiles();
std::vector<const char*> opts; // 显示真名(AssetDisplayName:文件 stem,导入资产库的
opts.push_back("<attach animation file...>"); // 哈希 "asset" 读同目录 pathname 取原始名);重名者补
// 父目录区分——不再把哈希路径直接摆给用户(= 显示成 id)
std::vector<std::string> labels;
labels.reserve(files.size());
for (const auto& f : files) for (const auto& f : files)
{ {
opts.push_back(f.c_str()); std::string label = Engine::Model::AssetDisplayName(f);
if (std::find(labels.begin(), labels.end(), label) !=
labels.end())
{
label += " (" +
std::filesystem::path(f)
.parent_path()
.filename()
.string() +
")";
}
labels.push_back(std::move(label));
}
std::vector<const char*> opts;
opts.push_back("<attach animation file...>");
for (const auto& l : labels)
{
opts.push_back(l.c_str());
} }
int sel = 0; int sel = 0;
ImGui::SetNextItemWidth(-1.0f); ImGui::SetNextItemWidth(-1.0f);
@@ -1925,7 +1955,7 @@ void InspectorPanel::Draw(const EditorContext& ctx, entt::entity selected)
} }
// attach 遥测(骨骼匹配结果,面板下方显示) // attach 遥测(骨骼匹配结果,面板下方显示)
s_LastAttachFile = s_LastAttachFile =
std::filesystem::path(path).filename().string(); Engine::Model::AssetDisplayName(path);
s_LastAttachMatched = s_LastAttachMatched =
res.channelsMatched + res.channelsRetargeted; res.channelsMatched + res.channelsRetargeted;
s_LastAttachRetargeted = res.channelsRetargeted; s_LastAttachRetargeted = res.channelsRetargeted;
@@ -1938,10 +1968,9 @@ void InspectorPanel::Draw(const EditorContext& ctx, entt::entity selected)
for (size_t i = 0; i < anim->attachedAnimPaths.size(); ++i) for (size_t i = 0; i < anim->attachedAnimPaths.size(); ++i)
{ {
const auto& p = anim->attachedAnimPaths[i]; const auto& p = anim->attachedAnimPaths[i];
ImGui::BulletText("%s", std::filesystem::path(p) ImGui::BulletText("%s",
.filename() Engine::Model::AssetDisplayName(p)
.string() .c_str());
.c_str());
ImGui::SameLine(); ImGui::SameLine();
ImGui::PushID(static_cast<int>(i)); ImGui::PushID(static_cast<int>(i));
const bool doDetach = ImGui::SmallButton("x"); const bool doDetach = ImGui::SmallButton("x");
+152
View File
@@ -0,0 +1,152 @@
//
// LogPanel.cpp:运行时日志面板实现。
//
// 数据源 = Engine::Debug::LogRing(spdlog ring sink 落的整行)。
// 级别着色(trace 灰 / debug 青 / info 浅白 / warn 黄 / error 红 /
// fatal 亮红带背景条)+ 级别过滤 + 文本过滤 + Auto-scroll(滚动条
// 贴底才自动拉,上翻看历史不被拽回)。
//
#include "LogPanel.h"
#include <Engine/Core/LogRing.h>
#include <imgui.h>
#include <cctype>
#include <cstring>
#include <string>
void LogPanel::Draw(const EditorContext& ctx)
{
(void)ctx;
if (!ImGui::Begin("Log"))
{
ImGui::End();
return;
}
// ---- 工具栏:清空 / 自动滚动 / 文本过滤 / 级别过滤 ----
if (ImGui::Button("Clear"))
{
Engine::Debug::LogRing().Clear();
}
ImGui::SameLine();
ImGui::Checkbox("Auto-scroll", &m_AutoScroll);
ImGui::SameLine();
ImGui::SetNextItemWidth(160.0f);
ImGui::InputTextWithHint("##filter", "filter...", m_Filter, sizeof(m_Filter));
ImGui::SameLine();
ImGui::Checkbox("Trace", &m_ShowTrace);
ImGui::SameLine();
ImGui::Checkbox("Info", &m_ShowInfo);
ImGui::SameLine();
ImGui::Checkbox("Warn", &m_ShowWarn);
ImGui::SameLine();
ImGui::Checkbox("Error", &m_ShowError);
ImGui::Separator();
// ---- 版本感知:有新日志且滚动条贴底 → 拉底(上翻历史不拽回)----
const Engine::u64 ver = Engine::Debug::LogRing().Version();
bool scrollToBottom = false;
if (ver != m_LastVersion)
{
m_LastVersion = ver;
if (m_AutoScroll)
{
scrollToBottom = ImGui::GetScrollY() >=
ImGui::GetScrollMaxY() - 24.0f;
}
}
if (ImGui::BeginChild("loglist", {0.0f, 0.0f}, false,
ImGuiWindowFlags_HorizontalScrollbar))
{
const auto entries = Engine::Debug::LogRing().Snapshot();
for (const auto& e : entries)
{
using lv = spdlog::level::level_enum;
bool show = false;
switch (e.level)
{
case lv::trace:
case lv::debug: show = m_ShowTrace; break;
case lv::info: show = m_ShowInfo; break;
case lv::warn: show = m_ShowWarn; break;
case lv::err:
case lv::critical: show = m_ShowError; break;
default: show = m_ShowInfo; break;
}
if (!show) continue;
if (m_Filter[0] != '\0')
{
// 简单子串过滤(大小写不敏感)
std::string lower;
lower.reserve(e.msg.size());
for (char c : e.msg)
{
lower.push_back(static_cast<char>(
std::tolower(static_cast<unsigned char>(c))));
}
std::string needle;
needle.reserve(strlen(m_Filter));
for (const char* p = m_Filter; *p; ++p)
{
needle.push_back(static_cast<char>(
std::tolower(static_cast<unsigned char>(*p))));
}
if (lower.find(needle) == std::string::npos)
{
continue;
}
}
// ---- 渲染:级别徽标(恒彩色,固定列)+ 消息本体 ----
// 消息色只有 warn/error/fatal 染色(info 白是惯例),颜色
// 辨识主要靠徽标列——UE Output Log 的 Verbosity 列同位
ImVec4 tagCol, msgCol;
const char* tag = "INFO";
bool bg = false;
switch (e.level)
{
case lv::trace:
tag = "TRACE"; tagCol = {0.55f, 0.55f, 0.55f, 1.0f};
msgCol = {0.55f, 0.55f, 0.55f, 1.0f}; break;
case lv::debug:
tag = "DEBUG"; tagCol = {0.40f, 0.80f, 0.90f, 1.0f};
msgCol = {0.70f, 0.70f, 0.70f, 1.0f}; break;
case lv::info:
tag = "INFO"; tagCol = {0.45f, 0.72f, 0.95f, 1.0f};
msgCol = {0.90f, 0.90f, 0.90f, 1.0f}; break;
case lv::warn:
tag = "WARN"; tagCol = {1.00f, 0.78f, 0.20f, 1.0f};
msgCol = {1.00f, 0.78f, 0.20f, 1.0f}; break;
case lv::err:
tag = "ERROR"; tagCol = {1.00f, 0.35f, 0.35f, 1.0f};
msgCol = {1.00f, 0.35f, 0.35f, 1.0f}; break;
default:
tag = "FATAL"; tagCol = {1.00f, 0.15f, 0.15f, 1.0f};
msgCol = {1.00f, 0.15f, 0.15f, 1.0f};
bg = true; break;
}
if (bg)
{
// fatal:整行暗红背景条提级(先画背景再画文本)
const ImVec2 p0 = ImGui::GetCursorScreenPos();
const ImVec2 p1(p0.x + ImGui::GetContentRegionAvail().x,
p0.y + ImGui::GetTextLineHeight());
ImGui::GetWindowDrawList()->AddRectFilled(
p0, p1, IM_COL32(90, 10, 10, 160));
}
ImGui::TextColored(tagCol, "%s", tag);
ImGui::SameLine(40.0f); // 徽标固定列宽,消息对齐成两列
ImGui::TextColored(msgCol, "%s", e.msg.c_str());
}
if (scrollToBottom)
{
ImGui::SetScrollHereY(1.0f);
}
}
ImGui::EndChild();
ImGui::End();
}
+26
View File
@@ -0,0 +1,26 @@
//
// LogPanel:运行时日志面板(环形缓冲渲染,级别着色 + 过滤)。
//
#ifndef HELLOVULKAN_LOGPANEL_H
#define HELLOVULKAN_LOGPANEL_H
#include <Engine/Core/Types.h>
#include "EditorContext.h"
class LogPanel {
public:
void Draw(const EditorContext& ctx);
private:
bool m_ShowTrace = false;
bool m_ShowInfo = true;
bool m_ShowWarn = true;
bool m_ShowError = true;
bool m_AutoScroll = true;
char m_Filter[128] = "";
Engine::u64 m_LastVersion = 0;
};
#endif //HELLOVULKAN_LOGPANEL_H
+28
View File
@@ -173,6 +173,9 @@ void MaterialEditorPanel::DrawAssetMode(const EditorContext& ctx)
EditParam( EditParam(
ImGui::DragFloat("Roughness", &p.factors.y, 0.01f, 0.0f, 1.0f)); ImGui::DragFloat("Roughness", &p.factors.y, 0.01f, 0.0f, 1.0f));
EditParam(ImGui::ColorEdit4("Base Color", &p.baseColor.x)); EditParam(ImGui::ColorEdit4("Base Color", &p.baseColor.x));
// albedo 贴图 alpha 作细节叠加遮罩(脸部腮红/卧蚕等):rgb 叠加色,
// a 强度(0 = 关闭);需 albedo 贴图自带 alpha 遮罩才有局部效果
EditParam(ImGui::ColorEdit4("Alpha Overlay", &p.alphaOverlay.x));
EditParam(ImGui::DragFloat("IOR", &p.surface.x, 0.01f, 1.0f, 3.0f)); EditParam(ImGui::DragFloat("IOR", &p.surface.x, 0.01f, 1.0f, 3.0f));
// 发光 = emissiveFactor × emissive 贴图 × Strength(Emissive 槽 // 发光 = emissiveFactor × emissive 贴图 × Strength(Emissive 槽
// 兜底贴图恒黑,无贴图时颜色因子是唯一入口) // 兜底贴图恒黑,无贴图时颜色因子是唯一入口)
@@ -192,6 +195,23 @@ void MaterialEditorPanel::DrawAssetMode(const EditorContext& ctx)
ImGui::TextDisabled("Transmission/Thickness need Transparent"); ImGui::TextDisabled("Transmission/Thickness need Transparent");
} }
// UV 镜像(材质级开关,作用于全部纹理槽):源资产 UV 朝向与引擎
// 上左原点约定相反时贴图整体翻转,这里一键纠正。写入 misc.y/z,
// 旧 .material 无该分量 → 缺省 0 = 关闭
{
bool flipU = p.misc.y > 0.5f;
const bool c = ImGui::Checkbox("Flip U (left-right)", &flipU);
if (c) { p.misc.y = flipU ? 1.0f : 0.0f; }
EditParam(c);
}
ImGui::SameLine();
{
bool flipV = p.misc.z > 0.5f;
const bool c = ImGui::Checkbox("Flip V (up-down)", &flipV);
if (c) { p.misc.z = flipV ? 1.0f : 0.0f; }
EditParam(c);
}
// ---- 纹理槽(变化 → 落盘 + waitIdle 重建)---- // ---- 纹理槽(变化 → 落盘 + waitIdle 重建)----
ImGui::SeparatorText("Textures"); ImGui::SeparatorText("Textures");
static constexpr const char* kSlotNames[Engine::Material::kSlotCount] = { static constexpr const char* kSlotNames[Engine::Material::kSlotCount] = {
@@ -216,6 +236,13 @@ void MaterialEditorPanel::DrawAssetMode(const EditorContext& ctx)
} }
if (ImGui::BeginPopup("tex_pick")) if (ImGui::BeginPopup("tex_pick"))
{ {
// 清除入口(Unity/UE 语义):该槽置空 → 回退兜底贴图
if (ImGui::Selectable("<none>"))
{
pendingSlot = static_cast<int>(s);
pendingPath.clear();
}
ImGui::Separator();
for (const auto& f : EditorMaterialUtil::AssetTextureFiles()) for (const auto& f : EditorMaterialUtil::AssetTextureFiles())
{ {
if (ImGui::Selectable(f.c_str())) if (ImGui::Selectable(f.c_str()))
@@ -313,6 +340,7 @@ void MaterialEditorPanel::DrawBuiltinMode(const EditorContext& ctx)
ImGui::DragFloat("Metallic", &p.factors.x, 0.01f, 0.0f, 1.0f); ImGui::DragFloat("Metallic", &p.factors.x, 0.01f, 0.0f, 1.0f);
ImGui::DragFloat("Roughness", &p.factors.y, 0.01f, 0.0f, 1.0f); ImGui::DragFloat("Roughness", &p.factors.y, 0.01f, 0.0f, 1.0f);
ImGui::ColorEdit4("Base Color", &p.baseColor.x); ImGui::ColorEdit4("Base Color", &p.baseColor.x);
ImGui::ColorEdit4("Alpha Overlay", &p.alphaOverlay.x);
ImGui::DragFloat("IOR", &p.surface.x, 0.01f, 1.0f, 3.0f); ImGui::DragFloat("IOR", &p.surface.x, 0.01f, 1.0f, 3.0f);
ImGui::ColorEdit3("Emissive Color", &p.emissiveFactor.x); ImGui::ColorEdit3("Emissive Color", &p.emissiveFactor.x);
ImGui::DragFloat("Emissive Strength", &p.surface.z, 0.05f, 0.0f, 100.0f); ImGui::DragFloat("Emissive Strength", &p.surface.z, 0.05f, 0.0f, 100.0f);
+21
View File
@@ -9,6 +9,7 @@
#include "Engine/ECS/Components.h" #include "Engine/ECS/Components.h"
#include "Engine/Physics/PhysicsWorld.h" #include "Engine/Physics/PhysicsWorld.h"
#include "Engine/Core/Time.h" #include "Engine/Core/Time.h"
#include "Engine/Core/Stats.h"
#include <imgui.h> #include <imgui.h>
#include <glm/gtc/type_ptr.hpp> #include <glm/gtc/type_ptr.hpp>
@@ -208,6 +209,26 @@ void StatsPanel::Draw(const EditorContext& ctx)
{ {
ImGui::Text(" %-12s %.3f ms", names[i].c_str(), times[i]); ImGui::Text(" %-12s %.3f ms", names[i].c_str(), times[i]);
} }
// ---- GPU 资源台账(Stats 记账中枢的唯一消费者)----
const auto st = Engine::Stats::Get().TakeSnapshot();
auto MB = [](u64 bytes)
{ return static_cast<double>(bytes) / (1024.0 * 1024.0); };
ImGui::Separator();
ImGui::Text("textures: %llu (%.2f MB)",
static_cast<unsigned long long>(st.textureCount),
MB(st.textureBytes));
ImGui::Text("meshes: %llu (%.2f MB)",
static_cast<unsigned long long>(st.meshCount),
MB(st.meshBytes));
ImGui::Text("buffers: %llu (%.2f MB)",
static_cast<unsigned long long>(st.bufferCount),
MB(st.bufferBytes));
ImGui::Text("allocator (VMA 1:1): %.2f MB",
MB(st.memoryUsedBytes));
ImGui::Text("churn: %.2f MB / %.2f MB (total %.2f / %.2f)",
MB(st.frameAllocation), MB(st.frameDeallocation),
MB(st.totalAllocation), MB(st.totalDeallocation));
} }
ImGui::End(); ImGui::End();
+8
View File
@@ -234,6 +234,14 @@ std::optional<entt::entity> ViewportPanel::Draw(const EditorContext &ctx,
// PIE 运行态:输入归游戏(脚本读 GLFW 轮询),Gizmo/编辑器 // PIE 运行态:输入归游戏(脚本读 GLFW 轮询),Gizmo/编辑器
// 热键全部让位(UE Play 语义:Scene view 编辑操作冻结) // 热键全部让位(UE Play 语义:Scene view 编辑操作冻结)
const bool playing = ctx.playState && *ctx.playState; const bool playing = ctx.playState && *ctx.playState;
// PIE 启动瞬间:视口抢焦点(UE Play 语义——游戏视角接管)。
// 不抢的话焦点停在工具栏 Play 按钮上,编辑器相机飞行等
// 持焦操作下一局全失灵
static bool s_wasPlaying = false;
if (playing && !s_wasPlaying) {
ImGui::SetWindowFocus();
}
s_wasPlaying = playing;
if (!playing && out.focused && !ImGui::GetIO().WantTextInput && if (!playing && out.focused && !ImGui::GetIO().WantTextInput &&
!ImGui::IsMouseDown(ImGuiMouseButton_Right)) { !ImGui::IsMouseDown(ImGuiMouseButton_Right)) {
// Q = 纯选择模式(UE Q 工具语义):op=0 无任何轴位, // Q = 纯选择模式(UE Q 工具语义):op=0 无任何轴位,
-5
View File
@@ -20,7 +20,6 @@
#include <Engine/Core/Time.h> #include <Engine/Core/Time.h>
#include <Engine/Core/Window.h> // SetInputCapture(Application.h 只前置声明) #include <Engine/Core/Window.h> // SetInputCapture(Application.h 只前置声明)
#include <Engine/Core/Log.h> #include <Engine/Core/Log.h>
#include <Engine/Core/Stats.h>
#include <Engine/Renderer/Renderer.h> #include <Engine/Renderer/Renderer.h>
#include <Engine/Renderer/EditorCamera.h> #include <Engine/Renderer/EditorCamera.h>
#include <Engine/Serialization/SceneSerializer.h> #include <Engine/Serialization/SceneSerializer.h>
@@ -146,10 +145,6 @@ namespace
SetImGuiEnabled(!GetImGuiEnabled()); SetImGuiEnabled(!GetImGuiEnabled());
} }
#endif #endif
if (event.key == static_cast<int>(Input::Key::F1))
{
STATS.PrintComparison();
}
} }
+203
View File
@@ -0,0 +1,203 @@
//
// Follower.cpp:跟随者行为(NPC 自动跟随主角,UE AIController 的简化位)。
//
// 挂在每个 NPC 实体上(各自 CharacterController + Animation)。主角由
// TargetName 指定(默认 "player",按 NameComponent 名称匹配——引擎无实体
// 引用参数类型,名称匹配是标准做法)。逻辑分四层,与 WASDMove 同一套手感:
// - 距离闸门(迟滞):拉开到 StopDistance+ResumeGap 才起步,靠近到
// StopDistance 即停——"到附近就停止跟随",边界不抖动
// - 速度分档(迟滞):距离超过 RunDistance 改跑,回落到 0.85× 才退回
// 走——"距离越大走/跑越快"
// - 动量整形:真实速度逐帧逼近期望速度(起步渐快、到点刹停),帧率无关
// - 动画按真实速度分档(idle/walk/run),迟滞防抖
// 移动驱动走 PhysicsWorld 的 CharacterController 门面(水平喂意图速度,
// Y 归物理重力);无 CC 时回退直接平移。多个跟随者用 Offset 错开站位。
//
#include <Engine/Scripting/Script.h>
#include <Engine/Core/Application.h>
#include <Engine/Physics/PhysicsWorld.h>
#include "ScriptLog.h"
#include <entt/entt.hpp>
#include <glm/glm.hpp>
#include <glm/gtc/quaternion.hpp>
#include <cmath>
using namespace Engine;
namespace {
class Follower final : public Engine::Scripting::ScriptBehavior {
public:
const char* Name() const override { return "Follower"; }
void DeclareParams(Engine::Scripting::ParamBuilder& pb) override {
pb.Field("TargetName", &m_TargetName, std::string("player"));
// 相对目标的站位偏移(世界系;多个跟随者错开,防叠一起)
pb.Field("Offset", &m_Offset, glm::vec3{0.0f});
pb.Field("WalkSpeed", &m_WalkSpeed, 3.0f); // 走路 m/s
pb.Field("RunSpeed", &m_RunSpeed, 6.0f); // 跑步 m/s
pb.Field("StopDistance", &m_StopDistance, 2.5f); // 到点停(米)
pb.Field("ResumeGap", &m_ResumeGap, 1.5f); // 再起步的距离余量
pb.Field("RunDistance", &m_RunDistance, 10.0f); // 超过即改跑(米)
pb.Field("TurnResponse", &m_TurnResponse, 10.0f);// 转向阻尼
pb.Field("AccelResponse", &m_AccelResponse, 6.0f); // 加速响应
pb.Field("StopResponse", &m_StopResponse, 12.0f); // 制动响应
// clip 按名引用(Unity anim.Play("Walk") 语义)
pb.Field("IdleClip", &m_IdleClip, std::string("idle"));
pb.Field("WalkClip", &m_WalkClip, std::string("walk"));
pb.Field("RunClip", &m_RunClip, std::string("run"));
}
void OnStart() override {
if (ResolveTarget()) {
SLOG_INFO("[Follower] 跟随目标 '{}' 就绪", m_TargetName);
} else {
SLOG_WARN("[Follower] 未找到目标 '{}'(按 NameComponent 匹配)",
m_TargetName);
}
}
void OnUpdate(float dt) override {
auto& reg = App().GetScene().GetRegistry();
// 目标失效(被销毁/未就绪)→ 重解析;仍无则静止待命
if (m_Target == entt::null || !reg.valid(m_Target)) {
m_Target = entt::null;
if (!ResolveTarget()) {
return;
}
}
const glm::vec3 tp =
reg.get<Engine::ECS::TransformComponent>(m_Target)
.GetPosition();
const glm::vec3 self = Transform().GetPosition();
glm::vec3 delta = (tp + m_Offset) - self;
delta.y = 0.0f; // 只取水平面(角色不俯仰)
const float dist = glm::length(delta);
// ---- 距离闸门(迟滞):到点停、拉开距离再起步 ----
if (!m_Moving) {
if (dist > m_StopDistance + m_ResumeGap) {
m_Moving = true;
}
} else if (dist <= m_StopDistance) {
m_Moving = false;
}
// ---- 速度分档(迟滞):远则跑、近则走 ----
glm::vec3 desired{0.0f};
if (m_Moving && dist > 1e-4f) {
if (!m_Sprinting && dist >= m_RunDistance) {
m_Sprinting = true;
} else if (m_Sprinting && dist <= m_RunDistance * 0.85f) {
m_Sprinting = false;
}
const float speed = m_Sprinting ? m_RunSpeed : m_WalkSpeed;
desired = (delta / dist) * speed;
}
// ---- 动量整形:真实速度逐帧逼近期望(起步渐快/到点刹停)----
const float response =
glm::length(desired) > 0.01f ? m_AccelResponse : m_StopResponse;
m_PlanarVel +=
(desired - m_PlanarVel) * (1.0f - std::exp(-response * dt));
if (glm::length(desired - m_PlanarVel) < 0.02f) {
m_PlanarVel = desired; // 贴齐,防低速蠕动
}
// ---- 驱动:角色控制器喂水平速度(Y 归物理);无则直接平移 ----
auto& phy = App().GetPhysics();
const bool hasCC =
TryGetComponent<Engine::ECS::CharacterControllerComponent>() !=
nullptr;
if (hasCC) {
const glm::vec3 cur = phy.GetCharacterVelocity(EntityBits());
phy.SetCharacterVelocity(
EntityBits(), {m_PlanarVel.x, cur.y, m_PlanarVel.z});
} else {
Transform().SetPosition(self + m_PlanarVel * dt);
}
// ---- 朝向:面向真实速度方向(模型默认 +Z),低速锁定 ----
if (glm::length(m_PlanarVel) > 0.3f) {
const float targetYaw =
std::atan2(m_PlanarVel.x, m_PlanarVel.z);
const glm::vec3 modelFwd =
Transform().GetRotationQuat() * glm::vec3(0.0f, 0.0f, 1.0f);
const float curYaw = std::atan2(modelFwd.x, modelFwd.z);
const float dyaw = std::atan2(std::sin(targetYaw - curYaw),
std::cos(targetYaw - curYaw));
const float k = 1.0f - std::exp(-m_TurnResponse * dt);
Transform().SetRotationQuat(
glm::angleAxis(curYaw + dyaw * k, glm::vec3(0, 1, 0)));
}
// ---- 动画:按真实速度分档(迟滞防临界抖)----
const float planarSpeed = glm::length(m_PlanarVel);
std::string target;
if (planarSpeed < 0.15f) {
target = m_IdleClip;
} else if (m_CurClip == m_RunClip) {
target = (planarSpeed >= m_RunSpeed * 0.7f) ? m_RunClip
: m_WalkClip;
} else {
target = (planarSpeed >= m_RunSpeed * 0.9f) ? m_RunClip
: m_WalkClip;
}
if (target != m_CurClip) {
if (auto* anim =
TryGetComponent<Engine::ECS::AnimationComponent>();
anim != nullptr && anim->Play(target, 0.35f)) {
m_CurClip = target; // 播放成功才记账(模型未就绪下帧重试)
}
}
}
private:
// 按 NameComponent 名称解析目标(跳过自身);失败返回 false
bool ResolveTarget() {
auto& reg = App().GetScene().GetRegistry();
auto view = reg.view<Engine::ECS::NameComponent>();
for (auto e : view) {
if (static_cast<u32>(entt::to_integral(e)) == EntityBits()) {
continue;
}
if (view.get<Engine::ECS::NameComponent>(e).name ==
m_TargetName) {
m_Target = e;
return true;
}
}
return false;
}
std::string m_TargetName = "player";
glm::vec3 m_Offset{0.0f};
float m_WalkSpeed = 3.0f;
float m_RunSpeed = 6.0f;
float m_StopDistance = 2.5f;
float m_ResumeGap = 1.5f;
float m_RunDistance = 10.0f;
float m_TurnResponse = 10.0f;
float m_AccelResponse = 6.0f;
float m_StopResponse = 12.0f;
std::string m_IdleClip = "idle";
std::string m_WalkClip = "walk";
std::string m_RunClip = "run";
entt::entity m_Target = entt::null; // 运行时状态(非参数)
bool m_Moving = false; // 距离闸门状态(迟滞)
bool m_Sprinting = false; // 速度档状态(迟滞)
glm::vec3 m_PlanarVel{0.0f}; // 平滑后的水平真实速度
std::string m_CurClip = "idle"; // 当前 clip(边沿检测基准)
};
ENGINE_REGISTER_SCRIPT(Follower)
} // namespace
+165 -23
View File
@@ -17,8 +17,10 @@
// 墙脚"的观感。 // 墙脚"的观感。
// //
// 用法(宿主 = 挂在可碎实体上的脚本): // 用法(宿主 = 挂在可碎实体上的脚本):
// Fracture::Params p_; // DeclareParams 直绑成员(pb.Field)
// Fracture::Burst burst_; // 碎片簿记 + 生命周期 // Fracture::Burst burst_; // 碎片簿记 + 生命周期
// DeclareParams 直绑 burst_.p 各字段(pb.Field("Count",
// &burst_.p.count, ...))——参数必须与碎裂逻辑读的是同一对象,
// 绑到独立副本会让 Count/Lifetime 等全部失效
// HostView HV() { // 桥接(脚本自身成员函数内可访问 // HostView HV() { // 桥接(脚本自身成员函数内可访问
// return {App(), EntityBits(),// protected 基类设施,库内只走公共 API) // return {App(), EntityBits(),// protected 基类设施,库内只走公共 API)
// [this](auto& n) { return Spawn(n); }, // [this](auto& n) { return Spawn(n); },
@@ -27,8 +29,9 @@
// OnCollisionEnter → burst_.TryFire(HV(), other, point, normal) // OnCollisionEnter → burst_.TryFire(HV(), other, point, normal)
// OnUpdate → burst_.Tick(HV(), dt) // OnUpdate → burst_.Tick(HV(), dt)
// OnDestroy → burst_.Shutdown(HV()) // OnDestroy → burst_.Shutdown(HV())
// 外观:Fire 前改 burst_.look(默认砖色不透明;玻璃靶 = 半透明 // 外观:默认继承宿主(墙)的生效材质(底色/贴图/透明),碎块与墙面
// + 自发光) // 一致;要独立外观(玻璃靶 = 半透明 + 自发光)Fire 前置
// burst_.look.inheritFromHost = false 再填 baseColor/emissive
// HostView 每次调用现填(不存指针:组件存储重排/实例重建均安全) // HostView 每次调用现填(不存指针:组件存储重排/实例重建均安全)
// //
// 占位局限(真碎片资产接入后本路径退役):碎片网格顶点按宿主世界 // 占位局限(真碎片资产接入后本路径退役):碎片网格顶点按宿主世界
@@ -148,23 +151,32 @@ namespace Engine::Fracture {
std::vector<glm::vec3> cpuPositions; std::vector<glm::vec3> cpuPositions;
}; };
inline PrismMesh CellToPrism(const std::vector<glm::vec2>& cell, f32 hz) { // hw/hh = 宿主半尺寸:顶/底面 UV 由 cell 的墙局部坐标归一化到 [0,1]
//(墙面矩形 UV 约定,与立方体面 UV 对齐)——继承墙材质贴图时,碎片
// 贴图与墙面连续,不会各碎片自成一格
inline PrismMesh CellToPrism(const std::vector<glm::vec2>& cell, f32 hz,
f32 hw, f32 hh) {
PrismMesh m; PrismMesh m;
const size_t n = cell.size(); const size_t n = cell.size();
const size_t topBase = 0; const size_t topBase = 0;
const size_t botBase = n; const size_t botBase = n;
m.vertices.reserve(n * 2); m.vertices.reserve(n * 2);
const f32 invW = hw > 1e-6f ? 0.5f / hw : 0.0f;
const f32 invH = hh > 1e-6f ? 0.5f / hh : 0.0f;
const auto uvOf = [&](const glm::vec2& p) {
return glm::vec2{p.x * invW + 0.5f, p.y * invH + 0.5f};
};
for (const glm::vec2& p : cell) { for (const glm::vec2& p : cell) {
const Engine::Vertex v{ const Engine::Vertex v{
{p.x, p.y, hz}, {0.0f, 0.0f, 1.0f}, {p.x, p.y, hz}, {0.0f, 0.0f, 1.0f},
{p.x, p.y}, {1.0f, 0.0f, 0.0f}}; uvOf(p), {1.0f, 0.0f, 0.0f}};
m.vertices.push_back(v); m.vertices.push_back(v);
} }
for (const glm::vec2& p : cell) { for (const glm::vec2& p : cell) {
const Engine::Vertex v{ const Engine::Vertex v{
{p.x, p.y, -hz}, {0.0f, 0.0f, -1.0f}, {p.x, p.y, -hz}, {0.0f, 0.0f, -1.0f},
{p.x, p.y}, {1.0f, 0.0f, 0.0f}}; uvOf(p), {1.0f, 0.0f, 0.0f}};
m.vertices.push_back(v); m.vertices.push_back(v);
} }
@@ -216,6 +228,47 @@ namespace Engine::Fracture {
return m; return m;
} }
// ---- 程序化纯色还原(引擎零改动:颜色只存在于伪路径里)----
// 程序化 Cube/Plane 把颜色烘进 1x1 sRGB 纹理,MaterialParams 留白、
// 内建材质快照也没有颜色字段,故从伪路径尾段 "r,g,b[,a]" 还原线性
// 底色——与 GPU 采样 sRGB 纹理的硬件线性化同式,直乘 baseColor 才等价
inline f32 SrgbByteToLinear(u8 c) {
const f32 s = static_cast<f32>(c) / 255.0f;
return s <= 0.04045f ? s / 12.92f
: std::pow((s + 0.055f) / 1.055f, 2.4f);
}
// "procedural://cube/0.5/140,92,72" → 取最后一个 '/' 之后的纯数字
// 逗号段;段数 < 3 或含非数字(如 halfSize 段)判失败
inline bool ParseProceduralColor(const std::string& path, glm::vec4& out) {
if (path.rfind("procedural://", 0) != 0) return false;
const size_t slash = path.find_last_of('/');
if (slash == std::string::npos) return false;
const std::string tail = path.substr(slash + 1);
int c[3] = {0, 0, 0};
int n = 0;
size_t pos = 0;
while (n < 3 && pos < tail.size()) {
const size_t comma = tail.find(',', pos);
const size_t end =
(comma == std::string::npos) ? tail.size() : comma;
if (end == pos) return false;
int v = 0;
for (size_t i = pos; i < end; ++i) {
const char ch = tail[i];
if (ch < '0' || ch > '9') return false;
v = v * 10 + (ch - '0');
}
c[n++] = v;
if (comma == std::string::npos) break;
pos = comma + 1;
}
if (n < 3) return false;
out = glm::vec4{SrgbByteToLinear(static_cast<u8>(c[0])),
SrgbByteToLinear(static_cast<u8>(c[1])),
SrgbByteToLinear(static_cast<u8>(c[2])), 1.0f};
return true;
}
// ---- 参数(原 WallFracture 全量参数,字段类型随 Inspector 控件)---- // ---- 参数(原 WallFracture 全量参数,字段类型随 Inspector 控件)----
struct Params { struct Params {
i32 count = 30; // Voronoi 种子数 = 碎片数 i32 count = 30; // Voronoi 种子数 = 碎片数
@@ -230,9 +283,15 @@ namespace Engine::Fracture {
f32 minImpactSpeed = 2.0f; // 触发阈值(m/s) f32 minImpactSpeed = 2.0f; // 触发阈值(m/s)
}; };
// ---- 碎片外观(Fire 时套用到全部碎片网格;默认砖色不透明 = // ---- 碎片外观 ----
// 原 WallFracture 观感)---- // 默认 inheritFromHost = true:碎块继承宿主(墙)的"生效材质"——
// 底色/贴图/透明/自发光全套沿用,碎掉后与墙面目视一致。宿主材质
// 取实体级覆盖(.material 资产)优先、模型内建材质兜底,与
// SceneRenderer 收集期的 resolve 同序
// 需要独立外观(玻璃靶 = 半透明 + 自发光等)时置 false,改用下面
// 的显式字段(Fire 前设置)
struct Appearance { struct Appearance {
bool inheritFromHost = true; // true = 材质沿用宿主,忽略下列字段
glm::vec4 baseColor{140.0f / 255.0f, 92.0f / 255.0f, glm::vec4 baseColor{140.0f / 255.0f, 92.0f / 255.0f,
72.0f / 255.0f, 1.0f}; 72.0f / 255.0f, 1.0f};
bool transparent = false; // 透明绘制队列(玻璃碎片) bool transparent = false; // 透明绘制队列(玻璃碎片)
@@ -254,20 +313,36 @@ namespace Engine::Fracture {
const glm::vec3& normal) { const glm::vec3& normal) {
if (m_Fired) return false; if (m_Fired) return false;
// 对方须是活的 Dynamic 刚体且速度够快(过滤静止贴靠/ // 对方须"够快"且是可动的撞击来源,二选一:
// 角色轻碰——子弹/抛体必中) // - 动态刚体(子弹/抛体):用实时线速度
// - 角色(CharacterVirtual,非刚体、无 RigidBodyComponent):
// 用"扫掠前"速度——扫掠中朝墙面的分量会被 Jolt 抵消,
// 实时速度往往已归零,会漏判(见
// PhysicsWorld::GetCharacterImpactVelocity)
// 速度低于阈值(静止贴靠/轻碰)则忽略
auto& registry = host.app.GetScene().GetRegistry(); auto& registry = host.app.GetScene().GetRegistry();
const ECS::Entity otherE = EntityFromBits(other); const ECS::Entity otherE = EntityFromBits(other);
if (!registry.valid(otherE)) return false; if (!registry.valid(otherE)) return false;
const auto* otherRb = f32 speed = 0.0f;
registry.try_get<Engine::ECS::RigidBodyComponent>(otherE); if (const auto* otherRb =
if (!otherRb || registry.try_get<Engine::ECS::RigidBodyComponent>(
otherRb->type != otherE)) {
if (otherRb->type !=
Engine::ECS::RigidBodyComponent::BodyType::Dynamic) { Engine::ECS::RigidBodyComponent::BodyType::Dynamic) {
return false; return false; // 静态/运动学体不算撞击
}
speed = glm::length(
host.app.GetPhysics().GetLinearVelocity(other));
} else if (registry.all_of<
Engine::ECS::CharacterControllerComponent>(
otherE)) {
// 玩家/角色撞击:角色碰撞事件由 PhysicsWorld 合成
// (CharacterVirtual 不进 ContactListener)
speed = glm::length(
host.app.GetPhysics().GetCharacterImpactVelocity(other));
} else {
return false; // 无物理来源(纯 Transform 实体)
} }
const f32 speed = glm::length(
host.app.GetPhysics().GetLinearVelocity(other));
if (speed < p.minImpactSpeed) return false; if (speed < p.minImpactSpeed) return false;
m_Fired = true; m_Fired = true;
@@ -335,6 +410,52 @@ namespace Engine::Fracture {
const f32 hh = 0.5f * wallSize.y; const f32 hh = 0.5f * wallSize.y;
const f32 hz = 0.5f * wallSize.z; const f32 hz = 0.5f * wallSize.z;
// ---- 碎片外观:解析宿主(墙)的生效材质 ----
// 与 SceneRenderer 收集期 resolve 同序:实体级覆盖
//(.material 资产,按 mesh 下标查表)优先,模型内建材质兜底
//(MeshMaterialInfo 为权威只读描述)。不继承则碎片只剩默认
// 砖色,与墙面目视不符
Engine::MaterialAssetData effMat;
glm::vec4 effSolid{0.0f}; // 程序化纯色 albedo(a>0 = 有效)
std::string hostOverridePath; // 宿主 .material 覆盖(空 = 内建材质)
bool hasEffMat = false;
if (look.inheritFromHost) {
const auto* mr = registry.try_get<
Engine::ECS::MeshRendererComponent>(self);
const int slot =
(mr && mr->meshIndex >= 0) ? mr->meshIndex : 0;
if (mr) {
const auto oIt =
mr->materialOverrides.find(static_cast<u32>(slot));
if (oIt != mr->materialOverrides.end()) {
if (auto* asset =
host.app.GetAssets().FindMaterialAsset(
oIt->second)) {
effMat = asset->GetData();
hostOverridePath = oIt->second;
hasEffMat = true;
}
}
}
if (!hasEffMat && mr && mr->model.Valid()) {
if (auto* model =
host.app.GetAssets().GetModel(mr->model)) {
const auto& infos = model->GetMeshMaterialInfos();
if (slot < static_cast<int>(infos.size())) {
effMat.texPaths = infos[slot].texPaths;
effMat.params = infos[slot].params;
effMat.transparent = infos[slot].transparent;
hasEffMat = true;
}
}
// 程序化纯色墙:颜色在生成的 1x1 纹理里(快照无颜色
// 字段),改由脚本从模型伪路径还原线性底色
ParseProceduralColor(
host.app.GetAssets().Models().Path(mr->model),
effSolid);
}
}
// 命中点 → 宿主局部(逆旋转 + 平移),XY 即裂纹平面坐标 // 命中点 → 宿主局部(逆旋转 + 平移),XY 即裂纹平面坐标
const glm::quat invRot = glm::conjugate( const glm::quat invRot = glm::conjugate(
wall.GetWorldRotationQuat()); wall.GetWorldRotationQuat());
@@ -391,7 +512,7 @@ namespace Engine::Fracture {
paths.emplace_back(); paths.emplace_back();
continue; continue;
} }
PrismMesh pm = CellToPrism(cell, hz); PrismMesh pm = CellToPrism(cell, hz, hw, hh);
glm::vec2 c2{0.0f}; glm::vec2 c2{0.0f};
for (const glm::vec2& q : cell) c2 += q; for (const glm::vec2& q : cell) c2 += q;
c2 /= static_cast<f32>(cell.size()); c2 /= static_cast<f32>(cell.size());
@@ -407,11 +528,27 @@ namespace Engine::Fracture {
mesh.lods.push_back(std::move(pm.indices)); mesh.lods.push_back(std::move(pm.indices));
mesh.cpuPositions = std::move(pm.cpuPositions); mesh.cpuPositions = std::move(pm.cpuPositions);
mesh.name = "frag" + std::to_string(s); mesh.name = "frag" + std::to_string(s);
mesh.params.baseColor = look.baseColor; if (hasEffMat) {
mesh.transparent = look.transparent; // 继承墙的生效材质:底色/贴图/UV 变换/透明全套沿用,
mesh.params.emissiveFactor = // 碎块与墙面目视一致(UV 已在 CellToPrism 归一到墙面)
glm::vec4{look.emissive, 1.0f}; mesh.params = effMat.params;
mesh.params.surface.z = look.emissiveStrength; mesh.texPaths = effMat.texPaths;
mesh.transparent = effMat.transparent;
// 程序化纯色墙(无贴图路径):用脚本解析出的线性底色当
// baseColor——碎片 albedo 走默认白纹理,直乘即该色;
// 不走这步会退回 MaterialParams 的白色默认值
if (effSolid.a > 0.0f) {
mesh.params.baseColor = effSolid;
}
} else {
// 无宿主材质可继承(或 look.inheritFromHost=false):
// 用显式外观(玻璃靶 = 半透明 + 自发光)
mesh.params.baseColor = look.baseColor;
mesh.transparent = look.transparent;
mesh.params.emissiveFactor =
glm::vec4{look.emissive, 1.0f};
mesh.params.surface.z = look.emissiveStrength;
}
const Engine::ModelRef ref = const Engine::ModelRef ref =
assets.LoadModel(parsed.path, std::move(parsed)); assets.LoadModel(parsed.path, std::move(parsed));
@@ -436,6 +573,11 @@ namespace Engine::Fracture {
frag.AddComponent< frag.AddComponent<
Engine::ECS::MeshRendererComponent>(); Engine::ECS::MeshRendererComponent>();
fmr.model = refs[s]; fmr.model = refs[s];
// 宿主墙用 .material 覆盖时,碎块直接复用同一份资产:外观
//(含透明通道)与墙完全一致,且不需要引擎改判定
if (!hostOverridePath.empty()) {
fmr.materialOverrides[0] = hostOverridePath;
}
// Dynamic MeshCollider → ConvexHull(数据源 = 刚上传 // Dynamic MeshCollider → ConvexHull(数据源 = 刚上传
// 的 cpuPositions;Box 字段不参与 Mesh 形状) // 的 cpuPositions;Box 字段不参与 Mesh 形状)
-391
View File
@@ -1,391 +0,0 @@
//
// GameDirector.cpp:60 秒限时打靶场导演(玩法原型验证侧线)。
//
// 完整玩法循环(全现有能力,零引擎改动):
// 开局 → 程序化搭玻璃靶阵(行对齐网格,居中逐行收缩)→ 60 秒
// 倒计时 → 射击碎靶计分(Target 报分,跨脚本参数通道)→ 时间到
// 收靶结算 → R 重开(任意时刻可重置)
//
// 计分协议:Score/AcceptHits = 导演的声明参数——Target 经
// ScriptRuntime::GetParam/SetParam 跨实体读写(参数层即按名消息,
// 与序列化同一条路)。Score 写穿 Item.params,PIE Inspector 实时
// 反映;AcceptHits=false 后 Target 停止报分(双保险,收靶为主)。
//
// HUD:引擎暂无文字渲染(缺失能力,已记录),倒计时/得分用
// Debug 图元在世界空间凑——顶部倒计时条(绿→黄→红随剩余时间)
// + 条下一排金色得分点(1 分 = 1 点)。挂在靶阵上方,depthTest
// 关闭保证穿墙可见。
//
// 靶子模型:程序化玻璃立方(ParsedModel 手工装配,半透明 + 自发
// 光),缓存键 = 颜色/尺寸参数量化——同配置共享一份 GPU 模型,
// 跨回合复用;改参数重开局才重建。碎片几何由 FractureLib 每靶
// 现场生成(唯一代键,自管回收)。
//
// 【演示步骤】任意实体挂 GameDirector;场景 primary 相机实体
// (CameraComponent.primary=true,PIE 渲染源)挂 PlayerController +
// TestShoot,摆到面向靶阵(如原点朝 -Z 正对 Center 默认位)→ Play:
// 光标自动捕捉(Esc 释放、点视口重捕),WASD/Space/Ctrl 移动 +
// 鼠标转向,左键射击:靶子玻璃式碎裂 + 得分点亮起,倒计时条流逝,
// 时间到结算(控制台输出得分),R 重开。
//
// 【缺失能力记录(玩法侧线的下一批依据)】
// - UI/HUD 文字(倒计时数字/得分数字只得用图元条凑)
// - 音效(碎裂/射击/倒计时提示全程无声)
// - 准星(靠弹道调试十字凑合)
// - 游戏状态机/关卡流(重开 = 脚本内手搓,无引擎级 GameMode)
//
#include <Engine/Scripting/Script.h>
#include <Engine/Core/Application.h>
#include <Engine/Core/Input.h>
#include <Engine/Core/Log.h>
#include <Engine/Core/Types.h>
#include <Engine/Debug/DebugDraw.h>
#include <Engine/Assets/AssetManager.h>
#include <Engine/Renderer/Model.h>
#include "FractureLib.h" // BitsOf/EntityFromBits(碎片簿记同款换算)
#include <glm/glm.hpp>
#include <algorithm>
#include <cstdio>
#include <string>
#include <vector>
using namespace Engine; // Input/Debug 门面 + 基础类型
namespace {
// ---- 玻璃立方 ParsedModel 装配(半透明 + 自发光;6 面 quad
// 手工展开,外向 CCW:u × v = n 定切向基)----
Engine::ParsedModel BuildGlassCube(const glm::vec3& he,
const glm::vec3& color,
const std::string& path) {
Engine::ParsedModel parsed;
parsed.path = path;
auto& mesh = parsed.meshes.emplace_back();
// 面:法线 n + 切向 u/v(u×v=n 保证外向 CCW 索引序)
static constexpr glm::vec3 kN[6] = {
{1, 0, 0}, {-1, 0, 0}, {0, 1, 0},
{0, -1, 0}, {0, 0, 1}, {0, 0, -1}};
static constexpr glm::vec3 kU[6] = {
{0, 0, -1}, {0, 0, 1}, {1, 0, 0},
{1, 0, 0}, {1, 0, 0}, {1, 0, 0}};
static constexpr glm::vec3 kV[6] = {
{0, 1, 0}, {0, 1, 0}, {0, 0, -1},
{0, 0, 1}, {0, 1, 0}, {0, 1, 0}};
// 轴向半长:轴 a 上的偏移 = a ∘ he(分量积;单位轴即取该轴
// 分量),面心 = n ∘ he
auto along = [&](const glm::vec3& a) {
return glm::vec3{a.x * he.x, a.y * he.y, a.z * he.z};
};
mesh.vertices.reserve(24);
for (int f = 0; f < 6; ++f) {
const u32 base = static_cast<u32>(mesh.vertices.size());
const glm::vec3 center = along(kN[f]);
// quad 角序:(−u,−v) (+u,−v) (+u,+v) (−u,+v)——CCW
for (int c = 0; c < 4; ++c) {
const f32 su = (c == 1 || c == 2) ? 1.0f : -1.0f;
const f32 sv = (c >= 2) ? 1.0f : -1.0f;
const glm::vec3 pos = center + su * along(kU[f]) +
sv * along(kV[f]);
const Engine::Vertex v{
pos, kN[f],
{(su + 1.0f) * 0.5f, (sv + 1.0f) * 0.5f}, kU[f]};
mesh.vertices.push_back(v);
}
mesh.lods.emplace_back();
auto& idx = mesh.lods.back();
idx.insert(idx.end(),
{base, base + 1, base + 2,
base, base + 2, base + 3});
}
// 玻璃材质:低金属度高光洁 + alpha 混合 + 靶色自发光
// (alpha 0.85、自发光 3——曾用 alpha 0.42 近乎全透,亮背景
// 下靶子隐形;拉高保靶子在浅色环境里醒目)
mesh.name = "target";
mesh.params.factors = {0.05f, 0.15f, 0.0f, 0.0f};
mesh.params.baseColor = glm::vec4{color, 0.85f};
mesh.params.surface = {1.5f, 0.0f, 3.0f, 0.0f};
mesh.params.emissiveFactor = glm::vec4{color * 0.85f, 1.0f};
mesh.transparent = true;
return parsed;
}
// ---- 打靶场导演 ----
class GameDirector final : public Engine::Scripting::ScriptBehavior {
public:
const char* Name() const override { return "GameDirector"; }
void DeclareParams(Engine::Scripting::ParamBuilder& pb) override {
// ---- 回合规则 ----
pb.Field("RoundTime", &m_RoundTime, 60.0f); // 局时长(秒)
pb.Field("TargetCount", &m_TargetCount, 5); // 靶数
pb.Field("Score", &m_Score, 0); // 得分(Target 报分写这里)
pb.Field("AcceptHits", &m_AcceptHits, true); // 计分门(Target 读)
// ---- 靶阵布局(Center = 底行中心)----
pb.Field("Center", &m_Center,
glm::vec3{0.0f, 1.5f, -6.0f});
pb.Field("PerRow", &m_PerRow, 5); // 每行靶数
pb.Field("Spacing", &m_Spacing, 1.2f); // 行/列间距 m
// ---- 靶子规格 ----
pb.Field("TargetSize", &m_TargetSize,
glm::vec3{0.7f, 0.7f, 0.2f}); // 单靶尺寸 m(厚 ≥0.2 防高速弹隧穿)
pb.Field("TargetColor", &m_TargetColor,
glm::vec3{1.0f, 0.3f, 0.25f}); // 玻璃色
pb.Field("FragCount", &m_FragCount, 22); // 每靶碎片数(写进 Target)
// ---- HUD ----
pb.Field("HudHeight", &m_HudHeight, 1.6f); // HUD 高于靶阵中心 m
}
void OnStart() override { StartRound(); }
void OnUpdate(f32 dt) override {
// R 上升沿重开(整局任意时刻可重置)
const bool rDown = Input::IsKeyDown(Input::Key::R);
const bool restart = rDown && !m_RWasDown;
m_RWasDown = rDown;
if (restart) {
StartRound();
return;
}
if (m_AcceptHits) {
m_TimeLeft -= dt;
if (m_TimeLeft <= 0.0f) {
m_TimeLeft = 0.0f;
EndRound();
}
}
DrawHud();
}
// PIE stop:玻璃靶模型是本脚本独占资产(参数键缓存),批量
// 停飞卸载(同 WallFracture 碎片模型回收先例)
void OnDestroy() override {
if (!m_ModelKey.empty()) {
std::vector<std::string> paths{m_ModelKey};
App().GetAssets().UnloadModelsByPaths(paths);
m_ModelKey.clear();
m_TargetModel = {};
}
}
private:
// ---- 开局:清场 → 建模型 → spawn 靶阵 → 计时/计分归零 ----
void StartRound() {
DestroyRemaining();
EnsureTargetModel();
SpawnTargets();
m_Score = 0;
SetParam("Score", Engine::Scripting::ParamValue{0});
m_TimeLeft = m_RoundTime;
m_AcceptHits = true;
SetParam("AcceptHits", Engine::Scripting::ParamValue{true});
ENGINE_INFO("[GameDirector] 回合开始:{} 个靶 / {} 秒",
m_TargetCount, m_RoundTime);
}
// ---- 结算:停计分 → 收掉没打碎的靶 → 报分 ----
void EndRound() {
m_AcceptHits = false;
SetParam("AcceptHits", Engine::Scripting::ParamValue{false});
DestroyRemaining();
ENGINE_INFO("[GameDirector] 时间到!得分 {}/{}(R 重开)",
m_Score, m_TargetCount);
}
// ---- 靶阵:行对齐网格,每行水平居中,行向上堆 ----
void SpawnTargets() {
const int count = std::max(1, m_TargetCount);
const int perRow = std::max(1, m_PerRow);
for (int i = 0; i < count; ++i) {
const int row = i / perRow;
const int col = i % perRow;
const int inRow = std::min(count - row * perRow, perRow);
glm::vec3 pos = m_Center;
pos.y += static_cast<f32>(row) * m_Spacing;
pos.x += static_cast<f32>(col) * m_Spacing -
static_cast<f32>(inRow - 1) * m_Spacing * 0.5f;
SpawnTarget(pos);
}
}
void SpawnTarget(const glm::vec3& pos) {
auto t = Spawn("target");
auto& tr = t.GetComponent<Engine::ECS::TransformComponent>();
tr.SetPosition(pos); // scale 1:尺寸烘焙进模型与碰撞体
auto& mr =
t.AddComponent<Engine::ECS::MeshRendererComponent>();
mr.model = m_TargetModel;
// Static Box:厚 ≥0.2m 时对 25m/s 无 CCD 弹保证检出
//(检测带 2*(弹半径0.12+靶半厚0.1)=0.44m > 每步位移0.42m)
auto& rb = t.AddComponent<Engine::ECS::RigidBodyComponent>();
rb.type = Engine::ECS::RigidBodyComponent::BodyType::Static;
rb.collider = Engine::ECS::RigidBodyComponent::ColliderType::Box;
rb.boxHalfExtents = m_TargetSize * 0.5f;
rb.friction = 0.5f;
rb.restitution = 0.1f;
// Target 脚本项:计分去向 + 靶色/碎片档经参数层传入
auto& sc = t.AddComponent<Engine::ECS::ScriptComponent>();
Engine::ECS::ScriptComponent::Item item;
item.name = "Target";
item.params.push_back(
{"Director",
Engine::Scripting::ParamValue{
static_cast<i32>(EntityBits())}});
item.params.push_back(
{"Color", Engine::Scripting::ParamValue{m_TargetColor}});
item.params.push_back(
{"Count", Engine::Scripting::ParamValue{m_FragCount}});
sc.items.push_back(std::move(item));
m_TargetBits.push_back(Engine::Fracture::BitsOf(t));
}
void DestroyRemaining() {
auto& registry = App().GetScene().GetRegistry();
for (const u32 bits : m_TargetBits) {
const ECS::Entity e =
Engine::Fracture::EntityFromBits(bits);
if (registry.valid(e)) {
Destroy(e); // 延迟队列,帧尾统一执行
}
}
m_TargetBits.clear();
}
// ---- 玻璃靶模型:参数量化缓存键(同配置共享一份 GPU 模型,
// 跨回合复用;改色/改尺寸后下次开局重建。注:键按"一个导演
// 实例独占"设计,双导演同参数会互踩——原型的合理取舍)----
void EnsureTargetModel() {
const auto q = [](f32 c) {
const int b = static_cast<int>(
std::clamp(c, 0.0f, 1.0f) * 255.0f + 0.5f);
return std::clamp(b, 0, 255);
};
// 尺寸量化:分米精度(0.1m 步进;同尺寸档共享模型)
const auto qs = [](f32 v) {
return std::clamp(
static_cast<int>(std::max(v, 0.0f) * 10.0f + 0.5f),
0, 999);
};
char hex[96];
std::snprintf(hex, sizeof(hex),
"procedural://rangetarget/%02X%02X%02X_"
"%d_%d_%d",
q(m_TargetColor.r), q(m_TargetColor.g),
q(m_TargetColor.b),
qs(m_TargetSize.x), qs(m_TargetSize.y),
qs(m_TargetSize.z));
const std::string key = hex;
if (key == m_ModelKey && m_TargetModel.Valid()) return;
// 配置变了:旧键独占资产先回收
if (m_TargetModel.Valid()) {
std::vector<std::string> old{m_ModelKey};
App().GetAssets().UnloadModelsByPaths(old);
m_TargetModel = {};
}
auto parsed = BuildGlassCube(m_TargetSize * 0.5f,
m_TargetColor, key);
m_TargetModel = App().GetAssets().LoadModel(
key, std::move(parsed));
m_ModelKey = key;
}
// ---- HUD(图元凑,无文字是已知缺失能力):倒计时条
//(绿→黄→红)+ 得分点(1 分 = 1 金点);结算态整条红 ----
void DrawHud() {
const glm::vec3 base =
m_Center + glm::vec3{0.0f, m_HudHeight, 0.0f};
constexpr f32 kBarW = 3.0f;
const f32 left = base.x - kBarW * 0.5f;
const glm::vec3 z{0.0f, 0.0f, base.z};
const f32 frac = m_AcceptHits
? std::clamp(m_TimeLeft / std::max(m_RoundTime, 0.01f),
0.0f, 1.0f)
: 0.0f;
// 颜色:>50% 绿→黄,≤50% 黄→红
const glm::vec4 green{0.24f, 0.86f, 0.52f, 1.0f};
const glm::vec4 yellow{1.0f, 0.84f, 0.31f, 1.0f};
const glm::vec4 red{1.0f, 0.32f, 0.32f, 1.0f};
const glm::vec4 col = frac > 0.5f
? glm::mix(yellow, green, (frac - 0.5f) * 2.0f)
: glm::mix(red, yellow, frac * 2.0f);
const glm::vec4 dim{0.25f, 0.25f, 0.25f, 1.0f};
// 外框 + 端点刻度
Debug::Line({left, base.y, base.z}, {left + kBarW, base.y, base.z},
dim, 2.0f, 0.0f, /*depthTest*/ false);
Debug::Line({left, base.y - 0.08f, base.z},
{left, base.y + 0.08f, base.z},
dim, 2.0f, 0.0f, false);
Debug::Line({left + kBarW, base.y - 0.08f, base.z},
{left + kBarW, base.y + 0.08f, base.z},
dim, 2.0f, 0.0f, false);
if (m_AcceptHits) {
if (frac > 0.003f) {
Debug::Line({left, base.y, base.z},
{left + kBarW * frac, base.y, base.z},
col, 8.0f, 0.0f, false);
}
} else {
// 结算态:整条红 + 上方短刻线(与运行态一眼区分)
Debug::Line({left, base.y, base.z},
{left + kBarW, base.y, base.z},
red, 4.0f, 0.0f, false);
Debug::Line({left, base.y + 0.14f, base.z},
{left + kBarW, base.y + 0.14f, base.z},
red, 2.0f, 0.0f, false);
}
// 得分点:1 分 = 1 金点(封顶 24 防溢出条外)
const glm::vec4 gold{1.0f, 0.78f, 0.2f, 1.0f};
const int pips = std::min<i32>(m_Score, 24);
for (i32 i = 0; i < pips; ++i) {
const f32 px = left + 0.18f +
static_cast<f32>(i) * 0.24f;
Debug::Cross({px, base.y - 0.3f, base.z}, 0.05f,
gold, 2.0f, 0.0f, false);
}
}
// ---- 回合状态 ----
f32 m_RoundTime = 60.0f;
i32 m_TargetCount = 5;
i32 m_Score = 0;
bool m_AcceptHits = true; // 计分门(参数成员,Target 跨脚本读写)
f32 m_TimeLeft = 60.0f;
bool m_RWasDown = false; // R 上升沿基准
// ---- 布局/规格 ----
glm::vec3 m_Center{0.0f, 1.5f, -6.0f};
i32 m_PerRow = 5;
f32 m_Spacing = 1.2f;
glm::vec3 m_TargetSize{0.7f, 0.7f, 0.2f};
glm::vec3 m_TargetColor{1.0f, 0.3f, 0.25f};
i32 m_FragCount = 22;
f32 m_HudHeight = 1.6f;
// ---- 靶子簿记 ----
std::vector<u32> m_TargetBits; // 本局靶实体(重开收靶用)
Engine::ModelRef m_TargetModel; // 玻璃立方(参数键缓存共享)
std::string m_ModelKey;
};
} // namespace
ENGINE_REGISTER_SCRIPT(GameDirector)
-151
View File
@@ -1,151 +0,0 @@
//
// PlayerController.cpp:FPS 玩家控制脚本——挂在场景 primary 相机实体
// (CameraComponent.primary=true)上,直接驱动该实体的 Transform。
//
// 为什么驱动实体而不是"编辑器相机":PIE 内嵌播放/游戏形态的渲染源
// 是 primary 相机实体(SceneRenderer::BuildFrame 分派,main.cpp 亦
// 注明"PIE 内嵌播放:渲染源已切 primary 相机,编辑器相机停驱")。
// 实体位姿一动,视口所见即玩家所见——这才是 Unity/UE 的正道;
// EditorCamera 是视口 UI 状态,不是场景实体,脚本够不着也不该够。
//
// 能力(全部走 Input 门面,零引擎改动):
// - 鼠标捕捉:OnStart 捕捉(FPS 语义:光标隐入、无限转向);
// Esc 释放(交还编辑器交互);未捕捉时点视口重新捕捉
// - 视角:GetMouseDelta 增量 → yaw 绕世界 up 左乘 + pitch 绕本地
// X 右乘(±89° 钳制,与编辑器相机 Camera::Update 同语义)
// - 移动:WASD 水平 + Space 升 / Ctrl 降(飞行式,无重力——
// 打靶场微调机位用);Shift 加速
//
// 注意:捕捉态下编辑器 UI 收不到鼠标——这是游戏体验的代价;
// Esc 是唯一退路(释放后可点 Inspector/改参数)。
//
// 配套:TestShoot(挂同一实体)射击,同样只在捕捉态开火。
//
#include <Engine/Scripting/Script.h>
#include <Engine/Core/Input.h>
#include <glm/glm.hpp>
#include <glm/gtc/quaternion.hpp>
using namespace Engine; // Input 门面
using Engine::Scripting::ParamBuilder;
namespace {
class PlayerController final : public Engine::Scripting::ScriptBehavior {
public:
const char* Name() const override { return "PlayerController"; }
void DeclareParams(ParamBuilder& pb) override {
pb.Field("LookSensitivity", &m_Sensitivity, 0.12f); // 度/像素
pb.Field("MoveSpeed", &m_MoveSpeed, 4.0f); // 平移速度 m/s
pb.Field("RunSpeed", &m_RunSpeed, 8.0f); // Shift 加速 m/s
pb.Field("CaptureOnStart", &m_CaptureOnStart, true);
}
void OnStart() override {
if (m_CaptureOnStart) {
Input::SetCursorCaptured(true);
}
}
void OnUpdate(f32 dt) override {
// ---- 捕捉管理:Esc 释放 / 点视口重捕 ----
bool captured = Input::IsCursorCaptured();
if (captured && Input::IsKeyDown(Input::Key::Escape)) {
Input::SetCursorCaptured(false);
captured = false;
}
const bool leftDown =
Input::IsMouseButtonDown(Input::MouseButton::Left);
if (!captured) {
// 未捕捉时点击(点在视口内才收得到——ImGui 捕获的
// 区域静默)→ 重捕进入操作态
if (leftDown && !m_LeftWasDown) {
Input::SetCursorCaptured(true);
}
m_LeftWasDown = leftDown;
return; // 释放态:不转向不移动(编辑器交互让路)
}
m_LeftWasDown = leftDown;
Look();
Move(dt);
}
private:
void Look() {
f32 dx = 0.0f, dy = 0.0f;
Input::GetMouseDelta(dx, dy);
if (dx == 0.0f && dy == 0.0f) return;
auto& tr = Transform();
const glm::quat cur = tr.GetRotationQuat();
// yaw:绕世界 up 左乘(不随俯仰改变旋转轴)
const glm::quat yaw =
glm::angleAxis(glm::radians(-dx * m_Sensitivity),
glm::vec3(0.0f, 1.0f, 0.0f));
// pitch:绕本地 X 右乘,钳制 ±89°(防过顶翻转)
const glm::vec3 fwd = cur * glm::vec3(0.0f, 0.0f, -1.0f);
const f32 pitch =
glm::asin(glm::clamp(fwd.y, -1.0f, 1.0f));
const f32 kPitchLimit = glm::radians(89.0f);
const f32 pitchTarget =
glm::clamp(pitch + glm::radians(-dy * m_Sensitivity),
-kPitchLimit, kPitchLimit);
const glm::quat pitchStep =
glm::angleAxis(pitchTarget - pitch,
glm::vec3(1.0f, 0.0f, 0.0f));
tr.SetRotationQuat(yaw * (cur * pitchStep));
}
void Move(f32 dt) {
glm::vec3 axis{0.0f};
if (Input::IsKeyDown(Input::Key::W))
axis.z += 1.0f;
if (Input::IsKeyDown(Input::Key::S))
axis.z -= 1.0f;
if (Input::IsKeyDown(Input::Key::D))
axis.x += 1.0f;
if (Input::IsKeyDown(Input::Key::A))
axis.x -= 1.0f;
if (Input::IsKeyDown(Input::Key::Space))
axis.y += 1.0f;
if (Input::IsKeyDown(Input::Key::LeftControl))
axis.y -= 1.0f;
if (glm::dot(axis, axis) < 1e-6f) return;
const glm::quat r = Transform().GetRotationQuat();
const glm::vec3 fwd = r * glm::vec3(0.0f, 0.0f, -1.0f);
const glm::vec3 right = r * glm::vec3(1.0f, 0.0f, 0.0f);
// 水平走视线投影(含俯仰 = 飞行感),升降用世界 Y
glm::vec3 move =
fwd * axis.z + right * axis.x
+ glm::vec3(0.0f, axis.y, 0.0f);
const f32 len = glm::length(move);
if (len < 1e-6f) return;
move /= len;
const f32 speed = Input::IsKeyDown(Input::Key::LeftShift)
? m_RunSpeed
: m_MoveSpeed;
auto& tr = Transform();
tr.SetPosition(tr.GetWorldPosition() + move * speed * dt);
}
f32 m_Sensitivity = 0.12f;
f32 m_MoveSpeed = 4.0f;
f32 m_RunSpeed = 8.0f;
bool m_CaptureOnStart = true;
bool m_LeftWasDown = false; // 重捕用的左键上升沿基准
};
ENGINE_REGISTER_SCRIPT(PlayerController)
} // namespace
+17
View File
@@ -0,0 +1,17 @@
//
// ScriptLog.h:脚本日志门面(ScriptDbg 的文字版同位物)。
//
// 脚本打日志统一走这里——直接转发引擎 spdlog 默认 logger(终端
// + HelloVulkan.log + 编辑器 Log Panel 三处同收)。约定:消息开头
// 写 [脚本名] 前缀(面板过滤好定位):
// ScriptLog::Info("[WASDMove] jump");
//
#pragma once
#include <Engine/Core/Log.h>
#define SLOG_TRACE(...) ::spdlog::trace(__VA_ARGS__)
#define SLOG_DEBUG(...) ::spdlog::debug(__VA_ARGS__)
#define SLOG_INFO(...) ::spdlog::info(__VA_ARGS__)
#define SLOG_WARN(...) ::spdlog::warn(__VA_ARGS__)
#define SLOG_ERROR(...) ::spdlog::error(__VA_ARGS__)
-120
View File
@@ -1,120 +0,0 @@
//
// Target.cpp:打靶场玻璃靶子脚本(侧线"玩法原型验证")。
//
// 职责(挂靶子实体:MeshRenderer 玻璃立方 + RigidBody Static Box +
// 本脚本;通常由 GameDirector 运行时成批 spawn,也可编辑器手动摆):
// OnCollisionEnter → 玻璃外观套用(半透明 + 靶色自发光)→
// Fracture::Burst::TryFire(一次性保护 + Dynamic 速度校验 +
// 藏靶 + Voronoi 切分 + spawn 玻璃碎片群,共享库内完成)
// ReportScore:向 Director 实体的 GameDirector 脚本报分(Score +1,
// 写穿 Item.params,PIE Inspector 实时反映);Director=0 表示
// 独立靶不计分;导演 AcceptHits=false(回合已结束)不报
// OnUpdate/OnDestroy → Burst::Tick/Shutdown(碎片施速/寿命/回收)
//
// 覆盖的引擎设施:跨脚本参数访问(ScriptRuntime::GetParam/SetParam,
// GameDirector ↔ Target 的计分通道)、共享碎裂库(FractureLib.h,
// 与 WallFracture 同一条碎片路径)、玻璃材质(透明队列 + 自发光)。
//
// 演示步骤:任意实体挂 GameDirector → Play → 左键射击靶阵:
// 靶子玻璃式碎裂 + 计分,60 秒倒计时,时间到结算,R 重开。
//
#include <Engine/Scripting/Script.h>
#include <Engine/Scripting/ScriptRuntime.h> // GetScripts() 跨脚本参数访问
#include <Engine/Core/Types.h>
#include "FractureLib.h"
using namespace Engine; // i32/f32/u32 基础类型(同 WallFracture)
namespace {
// ---- 玻璃靶子 ----
class Target final : public Engine::Scripting::ScriptBehavior {
public:
const char* Name() const override { return "Target"; }
void DeclareParams(Engine::Scripting::ParamBuilder& pb) override {
// 击碎报分去向:Director 实体 bits(0 = 独立靶不计分);
// GameDirector spawn 时写进脚本项参数
pb.Field("Director", &m_Director, 0);
// 靶色(玻璃染色 + 自发光基色;碎片同色系)
pb.Field("Color", &m_Color,
glm::vec3{1.0f, 0.3f, 0.25f});
// 碎裂参数(同 WallFracture 语义;靶面小 → 种子更密)
pb.Field("Count", &m_Burst.p.count, 22);
pb.Field("SeedRadius", &m_Burst.p.seedRadius, 0.35f);
pb.Field("MinSeedDist", &m_Burst.p.minSeedDist, 0.07f);
pb.Field("BurstSpeed", &m_Burst.p.burstSpeed, 1.6f);
pb.Field("Falloff", &m_Burst.p.falloff, 2.0f);
pb.Field("NormalPop", &m_Burst.p.normalPop, 0.6f);
pb.Field("UpBias", &m_Burst.p.upBias, 0.4f);
pb.Field("Lifetime", &m_Burst.p.lifetime, 6.0f);
pb.Field("MaxFragments", &m_Burst.p.maxFragments, 96);
pb.Field("MinImpactSpeed", &m_Burst.p.minImpactSpeed, 2.0f);
}
void OnCollisionEnter(u32 other, const glm::vec3& point,
const glm::vec3& normal) override {
// 玻璃外观:半透明 + 靶色自发光(Fire 前套用,碎片沿用;
// 每次覆写代价可忽略,且吃 Inspector 实时改色)。
// alpha 0.85(与靶体 BuildGlassCube 同步:0.45 时代碎片
// 在亮背景里几乎隐形)
m_Burst.look.baseColor = glm::vec4{m_Color, 0.85f};
m_Burst.look.transparent = true;
m_Burst.look.emissive = m_Color * 0.7f;
m_Burst.look.emissiveStrength = 3.0f;
if (m_Burst.TryFire(HV(), other, point, normal)) {
ReportScore();
}
}
void OnUpdate(f32 dt) override { m_Burst.Tick(HV(), dt); }
// 靶销毁(PIE stop / 重开 / 手动删除):碎片模型批量回收
void OnDestroy() override { m_Burst.Shutdown(HV()); }
private:
// 库桥接:本成员函数内可访问 protected 基类设施(App/Spawn/
// Destroy),库内只走公共 API——见 FractureLib.h HostView 注
Engine::Fracture::HostView HV() {
return {App(), EntityBits(),
[this](const std::string& n) { return Spawn(n); },
[this](Engine::ECS::Entity e) { Destroy(e); }};
}
// 击碎报分:读导演 AcceptHits 门(回合结束不报)→ Score +1。
// 参数层 = 跨脚本通信协议(与序列化同一条路,C# 反射缺失下
// 的按名消息);导演实例未建(罕见:同帧开局碰撞)时忽略
void ReportScore() {
if (m_Director == 0) return;
auto& scripts = App().GetScripts();
const u32 director = static_cast<u32>(m_Director);
// 参数层按名访问(ScriptRuntime 只认 ParamValue 变体,
// 取值自行 std::get_if 校型)
using Engine::Scripting::ParamValue;
ParamValue v{true};
if (scripts.GetParam(director, "GameDirector",
"AcceptHits", v)) {
if (const auto* b = std::get_if<bool>(&v.v); b && !*b) {
return; // 回合已结束(Director 收靶前的漏网命中)
}
}
i32 score = 0;
if (scripts.GetParam(director, "GameDirector", "Score", v)) {
if (const auto* s = std::get_if<i32>(&v.v)) {
score = *s;
}
}
scripts.SetParam(director, "GameDirector", "Score",
ParamValue{static_cast<i32>(score + 1)});
}
i32 m_Director = 0; // 导演实体 bits(0 = 独立靶)
glm::vec3 m_Color{1.0f, 0.3f, 0.25f};
Engine::Fracture::Burst m_Burst; // 碎片簿记(look 每次命中前套)
};
} // namespace
ENGINE_REGISTER_SCRIPT(Target)
+20 -35
View File
@@ -1,16 +1,11 @@
// //
// TestShoot.cpp:射击脚本(发射源 = 绑定实体位姿,挂谁从谁发)。 // TestShoot.cpp:阶段1 验收脚本——脚本侧 spawn/destroy 底座 + 碰撞事件
// // 转发的端到端链路。
// 正确用法:挂到场景 primary 相机实体(CameraComponent.primary=true)
// 上——PIE/游戏形态渲染源就是它(SceneRenderer::BuildFrame 分派),
// 实体朝向即准星,实体位置 + MuzzleOffset 即枪口。配 PlayerController
// (FPS 转向/移动/鼠标捕捉)即完整第一人称射击。
// //
// 三个脚本协作(放一个文件方便对照): // 三个脚本协作(放一个文件方便对照):
// TestShoot(挂相机实体): // TestShoot(挂任意实体,如相机实体):
// 光标捕捉态下鼠标左键上升沿 → 从绑定实体沿视线 spawn 子弹实体 // 鼠标左键上升沿 → 从编辑器相机沿视线 spawn 子弹实体
// (Dynamic 球刚体 + Bullet 脚本项,枪口初速经参数传入); // (Dynamic 球刚体 + Bullet 脚本项,枪口初速经参数传入)
// 未捕捉(编辑器交互中)不射——防点 UI 误发
// Bullet(子弹实体,由 TestShoot 运行时装配): // Bullet(子弹实体,由 TestShoot 运行时装配):
// OnCreate 施加枪口初速(body 已由当帧 Step 对账建好); // OnCreate 施加枪口初速(body 已由当帧 Step 对账建好);
// OnCollisionEnter → 命中点 spawn ImpactFx + 自毁; // OnCollisionEnter → 命中点 spawn ImpactFx + 自毁;
@@ -18,11 +13,6 @@
// ImpactFx(特效实体,由 Bullet 运行时装配,空实体 + 调试点标): // ImpactFx(特效实体,由 Bullet 运行时装配,空实体 + 调试点标):
// 扩张十字 + 白→红渐隐,寿命尽自毁——占位特效 // 扩张十字 + 白→红渐隐,寿命尽自毁——占位特效
// //
// 历史注:早期版本从编辑器相机(EditorCamera)发射——那是视口 UI
// 状态,不是场景实体,PIE 渲染根本不用它(渲染源 = primary 相机
// 实体),子弹会从视口外飞出。已改为绑定实体位姿(Unity 组件语义:
// 挂谁从谁发)。
//
// 覆盖的引擎设施: // 覆盖的引擎设施:
// - Spawn()/Destroy():ScriptBehavior 实体底座(Scene::CreateEntity/ // - Spawn()/Destroy():ScriptBehavior 实体底座(Scene::CreateEntity/
// DestroyEntity 延迟队列) // DestroyEntity 延迟队列)
@@ -31,9 +21,8 @@
// - OnCollisionEnter/Exit:PhysicsWorld::GetEvents → ScriptRuntime // - OnCollisionEnter/Exit:PhysicsWorld::GetEvents → ScriptRuntime
// 帧首派发 // 帧首派发
// //
// 验收步骤:primary 相机实体挂 PlayerController + 本脚本 → Play → // 验收步骤:编辑器给任意实体挂 TestShoot → Play → RMB 飞行视角瞄准
// 鼠标转视角 → 左键射击:弹道点飞出 → 撞击瞬间原地绽放十字特效、 // 墙体 → 左键射击:弹道点飞出 → 撞墙瞬间原地绽放十字特效、子弹消失。
// 子弹消失。
// //
#include <Engine/Scripting/Script.h> #include <Engine/Scripting/Script.h>
@@ -41,6 +30,8 @@
#include <Engine/Core/Input.h> #include <Engine/Core/Input.h>
#include <Engine/Physics/PhysicsWorld.h> #include <Engine/Physics/PhysicsWorld.h>
#include <Engine/Debug/DebugDraw.h> #include <Engine/Debug/DebugDraw.h>
#include <Engine/Renderer/Renderer.h>
#include <Engine/Renderer/EditorCamera.h>
#include <glm/glm.hpp> #include <glm/glm.hpp>
@@ -148,7 +139,7 @@ namespace {
f32 m_Age = 0.0f; f32 m_Age = 0.0f;
}; };
// ---- 枪口脚本(挂谁从谁发:位姿取绑定实体,正挂 primary 相机)---- // ---- 枪口脚本(验收主体:挂到任意实体,位置/朝向取编辑器相机)----
class TestShoot final : public Engine::Scripting::ScriptBehavior { class TestShoot final : public Engine::Scripting::ScriptBehavior {
public: public:
const char* Name() const override { return "TestShoot"; } const char* Name() const override { return "TestShoot"; }
@@ -167,18 +158,10 @@ namespace {
void OnUpdate(f32 dt) override { void OnUpdate(f32 dt) override {
m_Cooldown -= dt; m_Cooldown -= dt;
const bool down =
Input::IsMouseButtonDown(Input::MouseButton::Left);
// 捕捉态门控:光标未捕捉 = 用户在跟编辑器 UI 交互,不射
// (m_FireWasDown 照常对账,重捕按下的那一下不误发)
if (!Input::IsCursorCaptured()) {
m_FireWasDown = down;
return;
}
// 左键上升沿触发(电平查询 + 上帧状态对比,Unity // 左键上升沿触发(电平查询 + 上帧状态对比,Unity
// GetMouseButtonDown 边沿语义的手工等价物) // GetMouseButtonDown 边沿语义的手工等价物)
const bool down =
Input::IsMouseButtonDown(Input::MouseButton::Left);
const bool fire = down && !m_FireWasDown && const bool fire = down && !m_FireWasDown &&
m_Cooldown <= 0.0f; m_Cooldown <= 0.0f;
m_FireWasDown = down; m_FireWasDown = down;
@@ -190,18 +173,20 @@ namespace {
private: private:
void SpawnBullet() { void SpawnBullet() {
// 枪口 = 绑定实体视线前方(挂 primary 相机实体 = 第一人称 // 枪口 = 编辑器相机视线前方(编辑形态 RMB 飞行瞄准;
// 射击;PIE 渲染源就是这台相机,所见即所射) // 与 WASDMove 的朝向来源一致)
const glm::quat rot = Transform().GetWorldRotationQuat(); const auto& cam =
const glm::vec3 fwd = rot * glm::vec3(0.0f, 0.0f, -1.0f); App().GetRenderer().GetEditorCamera().GetTransform();
const glm::vec3 fwd =
cam.GetRotationQuat() * glm::vec3(0.0f, 0.0f, -1.0f);
const glm::vec3 muzzle = const glm::vec3 muzzle =
Transform().GetWorldPosition() + fwd * m_MuzzleOffset; cam.GetWorldPosition() + fwd * m_MuzzleOffset;
// ---- 子弹实体装配(spawn 底座 + 组件运行时装配演示)---- // ---- 子弹实体装配(spawn 底座 + 组件运行时装配演示)----
auto bullet = Spawn("bullet"); auto bullet = Spawn("bullet");
auto& tr = bullet.GetComponent<Engine::ECS::TransformComponent>(); auto& tr = bullet.GetComponent<Engine::ECS::TransformComponent>();
tr.SetPosition(muzzle); tr.SetPosition(muzzle);
tr.SetRotationQuat(rot); tr.SetRotationQuat(cam.GetRotationQuat());
// Dynamic 球刚体:参数给默认档(轻质量/低弹性,命中即停) // Dynamic 球刚体:参数给默认档(轻质量/低弹性,命中即停)
auto& rb = bullet.AddComponent<Engine::ECS::RigidBodyComponent>(); auto& rb = bullet.AddComponent<Engine::ECS::RigidBodyComponent>();
+188
View File
@@ -0,0 +1,188 @@
//
// ThirdPersonCamera.cpp:第三人称环绕相机(UE SpringArm 同位)。
//
// 挂 primary 相机实体。鼠标增量 → yaw/pitch,相机位置 = 目标实体
// (按名解析,默认 "player")+ 球面偏移,朝向锁定目标。WASDMove 的
// 移动参考 = 本相机朝向——WASD 即"相机水平朝向的前后左右"(UE
// gameplay 语义)。枢轴水平硬跟、竖直带阻尼(FollowResponse):XZ
// 与角色零滞后(跑动不错位),Y 滤掉脚底每物理步的微弹跳(踩碎块/
// 撞墙)避免画面抖动。
//
// OnStart 从编辑器摆的机位反推 yaw/pitch/dist:编辑器摆哪,游戏就
// 从哪个角度开场。Esc 释放光标(调参),左键重捕继续玩。
//
#include <Engine/Scripting/Script.h>
#include <Engine/Core/Application.h>
#include <Engine/Core/Input.h>
#include <Engine/Core/Log.h>
#include <Engine/ECS/Components.h>
#include <Engine/ECS/Scene.h>
#include <entt/entt.hpp>
#include <glm/glm.hpp>
#include <cmath>
using namespace Engine;
namespace {
class ThirdPersonCamera final : public Engine::Scripting::ScriptBehavior {
public:
const char* Name() const override { return "ThirdPersonCamera"; }
void DeclareParams(Engine::Scripting::ParamBuilder& pb) override {
pb.Field("Sensitivity", &m_Sensitivity, 1.0f);
pb.Field("Distance", &m_Distance, 0.0f); // 0 = 吸编辑器机位
pb.Field("HeightOffset", &m_HeightOffset, 1.2f);
pb.Field("PitchMinDeg", &m_PitchMin, -35.0f);
pb.Field("PitchMaxDeg", &m_PitchMax, 75.0f);
pb.Field("GamepadTurnSpeed", &m_PadTurnSpeed, 180.0f);
// 跟随目标按名锁定(默认 "player"):场景有多个角色控制器
// 实体(如跟随 NPC)时,按名解析保证相机锁主角而非随机命中
pb.Field("TargetName", &m_TargetName, std::string("player"));
// 竖直跟随阻尼(1/s,指数逼近;0 = 硬跟=旧行为)。水平 XZ
// 恒硬跟(保证跑动时相机/角色不错位、无拖影——全轴阻尼会
// 让角色在高速移动时被"甩"出画面并抖动);仅 Y 阻尼吸收
// 角色脚底每物理步的微弹跳(踩碎块/撞墙),帧率无关
pb.Field("FollowResponse", &m_FollowResponse, 12.0f);
// 目标 Y 瞬移超过该距离(PIE 传送/回合复位)→ 直接吸附
pb.Field("SnapDistance", &m_SnapDistance, 3.0f);
}
void OnStart() override {
auto& reg = App().GetScene().GetRegistry();
// ---- 找目标:优先按名(TargetName)命中主角 ----
auto byName = reg.view<Engine::ECS::NameComponent>();
for (auto e : byName) {
if (byName.get<Engine::ECS::NameComponent>(e).name ==
m_TargetName) {
m_Target = e;
break;
}
}
// 名称未命中(未命名/改名)→ 回退第一个角色控制器实体
if (m_Target == entt::null) {
auto view = reg.view<Engine::ECS::CharacterControllerComponent>();
if (view.empty()) {
ENGINE_WARN(
"[ThirdPersonCamera] 场景无 CharacterController 实体,相机静止");
return;
}
m_Target = *view.begin();
}
// ---- 从编辑器机位反推 yaw/pitch/dist(摆哪开场在哪)----
const auto& camT = Transform();
const auto& tgtT =
App().GetScene().GetRegistry().get<
Engine::ECS::TransformComponent>(m_Target);
const glm::vec3 offset =
camT.GetPosition() -
(tgtT.GetPosition() +
glm::vec3(0.0f, m_HeightOffset, 0.0f));
const float dist = glm::length(offset);
if (dist > 1e-4f && m_Distance <= 0.0f) {
m_Distance = dist;
m_Pitch =
glm::degrees(std::asin(std::clamp(offset.y / dist,
-1.0f, 1.0f)));
m_Yaw = glm::degrees(std::atan2(offset.x, offset.z));
}
ENGINE_INFO(
"[ThirdPersonCamera] 目标已锁定,dist={:.2f} yaw={:.1f} pitch={:.1f}",
m_Distance, m_Yaw, m_Pitch);
Input::SetCursorCaptured(true);
}
void OnUpdate(float dt) override {
if (m_Target == entt::null) {
return;
}
// 光标管理:Esc 释放(Inspector 调参),左键重捕。
// 手柄玩家不碰鼠标——光标保持捕获态不影响摇杆输入
if (Input::IsKeyDown(Input::Key::Escape)) {
Input::SetCursorCaptured(false);
}
if (Input::IsMouseButtonDown(Input::MouseButton::Left)) {
Input::SetCursorCaptured(true);
}
// ---- 鼠标增量 + 右摇杆 → 球面角 ----
// 鼠标 = 相对增量(GetMouseDelta);右摇杆 = 持续量
//(Look 动作,满推 m_PadTurnSpeed 度/秒),两者叠加
float dx = 0.0f, dy = 0.0f;
Input::GetMouseDelta(dx, dy);
const glm::vec2 padLook = Input::ActionValue2("Look");
const float k = 0.12f * m_Sensitivity;
m_Yaw -= dx * k + padLook.x * m_PadTurnSpeed * dt;
m_Pitch += dy * k + padLook.y * m_PadTurnSpeed * dt;
m_Pitch = std::clamp(m_Pitch, m_PitchMin, m_PitchMax);
m_Yaw = std::fmod(m_Yaw, 360.0f);
// ---- 位置 = 目标枢轴 + 球面偏移 ----
// 水平 XZ 硬跟(与角色零滞后——跑动时相机/角色不错位、无
// 拖影),仅 Y 经阻尼逼近(FollowResponse):吸收目标脚底
// 每物理步的微弹跳(踩碎块/撞墙),相机竖直方向不再 1:1
// 抖动。首帧、瞬移(超 SnapDistance)或阻尼为 0 时直接吸附
// = 旧硬跟语义
const auto& tgtT =
App().GetScene().GetRegistry().get<
Engine::ECS::TransformComponent>(m_Target);
const glm::vec3 rawPivot =
tgtT.GetPosition() +
glm::vec3(0.0f, m_HeightOffset, 0.0f);
if (!m_FollowInit || m_FollowResponse <= 0.0f ||
std::abs(rawPivot.y - m_FollowPivotY) > m_SnapDistance) {
m_FollowPivotY = rawPivot.y;
m_FollowInit = true;
} else {
m_FollowPivotY +=
(rawPivot.y - m_FollowPivotY) *
(1.0f - std::exp(-m_FollowResponse * dt));
}
glm::vec3 pivot = rawPivot; // XZ 硬跟
pivot.y = m_FollowPivotY; // Y 阻尼
const float yawR = glm::radians(m_Yaw);
const float pitchR = glm::radians(m_Pitch);
const glm::vec3 dir( // 枢轴 → 相机(yaw 绕 Y,pitch 抬升)
std::sin(yawR) * std::cos(pitchR),
std::sin(pitchR),
std::cos(yawR) * std::cos(pitchR));
auto& camT = Transform();
camT.SetPosition(pivot + dir * m_Distance);
// ---- 朝向锁定目标 ----
// 引擎 YXZ 序(R = Ry·Rx)视向 = (-cosP sinY, sinP, -cosP cosY)
// 反解:pitch = asin(vd.y),yaw = atan2(-vd.x, -vd.z)
const glm::vec3 vd = glm::normalize(pivot - camT.GetPosition());
const float p = glm::degrees(std::asin(std::clamp(vd.y, -1.0f, 1.0f)));
const float y = glm::degrees(std::atan2(-vd.x, -vd.z));
camT.SetRotation(glm::vec3(p, y, 0.0f));
}
private:
float m_Sensitivity = 1.0f;
float m_Distance = 0.0f; // 0 = OnStart 吸编辑器机位
float m_HeightOffset = 1.2f; // 枢轴高度(瞄胸口)
float m_PitchMin = -35.0f;
float m_PitchMax = 75.0f;
float m_PadTurnSpeed = 180.0f; // 右摇杆满推角速度(度/秒)
std::string m_TargetName = "player"; // 目标实体名(NameComponent)
float m_FollowResponse = 12.0f; // 竖直跟随阻尼 1/s(0 = 硬跟)
float m_SnapDistance = 3.0f; // Y 瞬移吸附阈值 m
float m_FollowPivotY = 0.0f; // 阻尼后的枢轴高度(XZ 硬跟)
bool m_FollowInit = false; // 首帧吸附基准
float m_Yaw = 0.0f;
float m_Pitch = 20.0f;
entt::entity m_Target = entt::null;
};
ENGINE_REGISTER_SCRIPT(ThirdPersonCamera)
} // namespace
+79 -31
View File
@@ -1,5 +1,9 @@
// //
// WASDMove.cpp:WASD 控制实体移动(相机水平朝向参考,UE gameplay 语义)。 // WASDMove.cpp:第三人称移动(WASD/手柄摇杆,相机朝向参考,UE gameplay 语义)。
//
// 输入走引擎具名动作层(Move/Jump/Run 默认绑定):WASD + 方向键 +
// 手柄左摇杆移动(引擎径向死区),Space/手柄 A 跳;跑 = Run 动作
//(Shift / 手柄 L3 按下左摇杆)。摇杆模拟量直接驱动动量整形(轻推慢走)。
// //
// 移动语义分派(按实体配置自动选择,优先级从上到下): // 移动语义分派(按实体配置自动选择,优先级从上到下):
// - CharacterControllerComponent → 角色控制器路径(扫掠运动学: // - CharacterControllerComponent → 角色控制器路径(扫掠运动学:
@@ -19,6 +23,7 @@
#include <Engine/Core/Input.h> #include <Engine/Core/Input.h>
#include <Engine/Debug/DebugDraw.h> #include <Engine/Debug/DebugDraw.h>
#include "ScriptDebug.h" #include "ScriptDebug.h"
#include "ScriptLog.h"
#include <Engine/Renderer/Renderer.h> #include <Engine/Renderer/Renderer.h>
#include <Engine/Renderer/EditorCamera.h> #include <Engine/Renderer/EditorCamera.h>
@@ -49,37 +54,68 @@ namespace {
pb.Field("RunClip", &m_RunClip, std::string("run")); pb.Field("RunClip", &m_RunClip, std::string("run"));
} }
void OnStart() override {
SLOG_TRACE("hello");
SLOG_INFO("hello");
SLOG_DEBUG("hello");
SLOG_WARN("hello");
SLOG_ERROR("hello");
SLOG_INFO("[WASDMove] 就绪:走 {:.1f} m/s / 跑 {:.1f} m/s / 跳 {:.1f} m/s",
m_Speed, m_RunSpeed, m_JumpSpeed);
}
void OnUpdate(float dt) override { void OnUpdate(float dt) override {
// 位移方向 = 视线水平投影(W 前进 / A 左 / D 右),UE Scene // 位移方向 = 视线水平投影(W 前进 / A 左 / D 右),UE Scene
// view 移动惯例;Y 锁 0(升降/跳跃归物理或将来扩展)。 // view 移动惯例;Y 锁 0(升降/跳跃归物理或将来扩展)。
// 输入直读按键(Unity Input.GetKey(KeyCode.W) 同形);UI 捕获 // 输入 = 具名动作层(Unity6 Input System / UE5 Enhanced Input
// 键盘时门面静默。要手柄/运行时改键再换动作层 // 同位):Move 默认混绑 WASD/方向键/左摇杆(引擎做径向死区 +
//(Input::ActionValue2("Move")——Unity6 新 Input System / // 单位圆钳制,斜向不超速),Jump 混绑 Space/手柄 A——键盘
// UE5 Enhanced Input 对应物),本示例保持最简 // 手柄一套代码,将来改键/加绑定不进脚本
const glm::quat cq = App().GetRenderer() // 移动参考 = 场景 primary 相机(第三人称相机脚本每帧写它
.GetEditorCamera() // 的朝向——玩家看到的视角就是移动参考);无相机实体时
.GetTransform() //(纯独立形态)回退编辑器相机
.GetRotationQuat(); glm::quat cq;
bool refCam = false;
auto camView = App().GetScene()
.GetRegistry()
.view<Engine::ECS::CameraComponent>();
for (auto e : camView) {
const auto& cam =
camView.get<Engine::ECS::CameraComponent>(e);
if (cam.primary && cam.active) {
cq = App().GetScene()
.GetRegistry()
.get<Engine::ECS::TransformComponent>(e)
.GetRotationQuat();
refCam = true;
break;
}
}
if (!refCam) {
cq = App().GetRenderer()
.GetEditorCamera()
.GetTransform()
.GetRotationQuat();
}
const glm::vec3 fwd = cq * glm::vec3(0.0f, 0.0f, -1.0f); const glm::vec3 fwd = cq * glm::vec3(0.0f, 0.0f, -1.0f);
const glm::vec3 right = cq * glm::vec3(1.0f, 0.0f, 0.0f); const glm::vec3 right = cq * glm::vec3(1.0f, 0.0f, 0.0f);
// 约定:X+ = 右(D),Y- = 前(W);方向合成 = fwd*(-Y) + right*(+X) // 约定:X+ = 右(D),Y- = 前(W);方向合成 = fwd*(-Y) + right*(+X)
glm::vec2 mv(0.0f); const glm::vec2 mvRaw = Input::ActionValue2("Move");
if (Input::IsKeyDown(Input::Key::W)) mv.y -= 1.0f; glm::vec2 mv = mvRaw;
if (Input::IsKeyDown(Input::Key::S)) mv.y += 1.0f;
if (Input::IsKeyDown(Input::Key::A)) mv.x -= 1.0f;
if (Input::IsKeyDown(Input::Key::D)) mv.x += 1.0f;
if (glm::length(mv) > 1.0f) mv = glm::normalize(mv); if (glm::length(mv) > 1.0f) mv = glm::normalize(mv);
glm::vec3 move = fwd * (-mv.y) + right * mv.x; glm::vec3 move = fwd * (-mv.y) + right * mv.x;
move.y = 0.0f; move.y = 0.0f;
if (glm::length(move) > 1e-4f) { // 只钳超速,不放大——摇杆模拟量(幅度 <1)保留进速度:
// 轻推慢走、推满全速(归一化会把幅度吃掉,一动就满速)
if (glm::length(move) > 1.0f) {
move = glm::normalize(move); move = glm::normalize(move);
} }
// 走/跑 // 走/跑:Run 动作(默认绑 Shift + 手柄 L3,按下左摇杆冲刺
const bool moving = glm::length(mv) > 0.1f; // 的 Xbox 惯例)。不判"摇杆推满"——键盘 WASD 幅度恒 1.0,
const bool running = // 那样判会把走路误判成跑
moving && Input::IsKeyDown(Input::Key::LeftShift); const bool running = Input::ActionDown("Run");
const float speed = running ? m_RunSpeed : m_Speed; const float speed = running ? m_RunSpeed : m_Speed;
const glm::vec3 desired = move * speed; // 期望速度(输入意图) const glm::vec3 desired = move * speed; // 期望速度(输入意图)
@@ -95,6 +131,7 @@ namespace {
// 松手滑行刹车。加速/转向用 AccelResponse,松手用更硬的 // 松手滑行刹车。加速/转向用 AccelResponse,松手用更硬的
// StopResponse(角色要"想停就停");空中操纵性降到 1/4 // StopResponse(角色要"想停就停");空中操纵性降到 1/4
//(起跳后不宜急转弯)。指数逼近,帧率无关 //(起跳后不宜急转弯)。指数逼近,帧率无关
const bool moving = glm::length(mvRaw) > 0.1f;
float response = moving ? m_AccelResponse : m_StopResponse; float response = moving ? m_AccelResponse : m_StopResponse;
if (!grounded) { if (!grounded) {
response *= 0.25f; response *= 0.25f;
@@ -113,15 +150,14 @@ namespace {
phy.SetCharacterVelocity( phy.SetCharacterVelocity(
EntityBits(), {m_PlanarVel.x, cur.y, m_PlanarVel.z}); EntityBits(), {m_PlanarVel.x, cur.y, m_PlanarVel.z});
// 跳跃:着地 + 上升沿触发。IsKeyDown 是电平量(按住 // 跳跃:动作层边沿(Pressed = prev 0 → cur 非 0,键盘
// 每帧都真),不设边沿会变成贴地火箭;IsGrounded 防 // Space / 手柄 A 都产生一次上升沿)。IsGrounded 防空中连跳
// 空中连跳 if (Input::ActionPressed("Jump") &&
const bool jumpDown = Input::IsKeyDown(Input::Key::Space);
if (jumpDown && !m_JumpWasDown &&
phy.IsCharacterGrounded(EntityBits())) { phy.IsCharacterGrounded(EntityBits())) {
phy.Jump(EntityBits(), m_JumpSpeed); phy.Jump(EntityBits(), m_JumpSpeed);
SLOG_INFO("[WASDMove] jump(着地起跳 {:.1f} m/s)",
m_JumpSpeed);
} }
m_JumpWasDown = jumpDown;
} else if (auto* rb = } else if (auto* rb =
TryGetComponent<Engine::ECS::RigidBodyComponent>(); TryGetComponent<Engine::ECS::RigidBodyComponent>();
rb != nullptr && rb->bodyValid && rb != nullptr && rb->bodyValid &&
@@ -155,11 +191,24 @@ namespace {
glm::angleAxis(curYaw + dyaw * k, glm::vec3(0, 1, 0))); glm::angleAxis(curYaw + dyaw * k, glm::vec3(0, 1, 0)));
} }
// ---- 动画:idle/walk/run 三态切换(Direct 模式 crossfade)---- // ---- 动画:按真实速度分档(迟滞防临界抖)----
// 目标 clip 是"电平量":Shift 中途按下/松开都要能换,所以 // 判据 = 动量整形后的真实水平速度,不是输入原始值:起步
// 每帧重算目标,只在目标与上帧不同才 Play(边沿)。每帧无 // 滑行期已是 walk,松手刹车期保持 walk 直到真停——动画与
// 条件 Play 会不停重置播放头,动画永远播不完 // 位移观感一致,按键瞬通瞬断不再瞬间换挡。run 双阈值迟滞
const std::string& target = !moving ? m_IdleClip : (running ? m_RunClip : m_WalkClip); //(进 0.9×RunSpeed / 退 0.7×RunSpeed):摇杆在阈值附近晃
// 不抖档。引擎 Play(名) 自带相位对齐 + crossfade,切档
// 突兀感只剩判据本身的问题
const float planarSpeed = glm::length(m_PlanarVel);
std::string target;
if (planarSpeed < 0.15f) {
target = m_IdleClip;
} else if (m_CurClip == m_RunClip) {
target = (planarSpeed >= m_RunSpeed * 0.7f) ? m_RunClip
: m_WalkClip;
} else {
target = (planarSpeed >= m_RunSpeed * 0.9f) ? m_RunClip
: m_WalkClip;
}
if (target != m_CurClip) { if (target != m_CurClip) {
if (auto* anim = TryGetComponent<Engine::ECS::AnimationComponent>(); if (auto* anim = TryGetComponent<Engine::ECS::AnimationComponent>();
anim != nullptr && anim->Play(target, 0.35f)) { anim != nullptr && anim->Play(target, 0.35f)) {
@@ -221,7 +270,6 @@ namespace {
std::string m_WalkClip = "walk"; std::string m_WalkClip = "walk";
std::string m_RunClip = "run"; std::string m_RunClip = "run";
std::string m_CurClip = "idle"; // 当前目标 clip(边沿检测基准) std::string m_CurClip = "idle"; // 当前目标 clip(边沿检测基准)
bool m_JumpWasDown = false; // 上帧跳跃键(上升沿检测)
glm::vec3 m_PrevVel{0.0f}; // 上帧测量速度(加速度差分基准) glm::vec3 m_PrevVel{0.0f}; // 上帧测量速度(加速度差分基准)
glm::vec3 m_AccelS{0.0f}; // 平滑测量加速度(EMA 0.35,调试图) glm::vec3 m_AccelS{0.0f}; // 平滑测量加速度(EMA 0.35,调试图)
}; };
+25 -19
View File
@@ -1,15 +1,19 @@
// //
// WallFracture.cpp:可碎墙脚本——碎裂逻辑已提取至 FractureLib.h // WallFracture.cpp:可碎墙脚本——碎裂逻辑已提取至 FractureLib.h
//(打靶场 Target 复用同一条路径),本文件只剩宿主职责: //(打靶场 Target 复用同一条路径),本文件只剩宿主职责:
// DeclareParams 直绑 Fracture::Params(参数名/默认值与历史档位 // DeclareParams 直绑 Burst::p 各字段(参数名/默认值与历史档位
// 逐一同名同值,旧场景不受影响) // 逐一同名同值,旧场景不受影响)
// OnCollisionEnter → Burst::TryFire(一次性保护 + 对方 Dynamic // OnCollisionEnter → Burst::TryFire(一次性保护 + 对方撞击来源
// 速度校验 + 藏墙 + Voronoi 切分 + spawn 碎片群,库内完成) // 与速度校验 + 藏墙 + Voronoi 切分 + spawn 碎片群,库内完成)。
// 撞击来源 = 动态刚体(子弹/抛体)或角色(玩家撞墙)——角色是
// CharacterVirtual,其碰撞事件由 PhysicsWorld 从角色接触合成
// (Jolt 的 ContactListener 只回调刚体,不覆盖角色)
// OnUpdate → Burst::Tick(施速待办 / 寿命清理 / 模型回收) // OnUpdate → Burst::Tick(施速待办 / 寿命清理 / 模型回收)
// OnDestroy → Burst::Shutdown(独占碎片模型批量停飞卸载) // OnDestroy → Burst::Shutdown(独占碎片模型批量停飞卸载)
// //
// 外观默认 = 砖色不透明(原观感);玻璃/发光碎片换 Target.cpp 的 // 外观默认继承墙的生效材质(底色/贴图/透明),碎块与墙面观感一致;
// 用法(Fire 前改 Burst::look)。 // 要独立外观(玻璃/发光碎片)置 Burst::look.inheritFromHost = false
// 再用 baseColor/emissive 显式指定。
// //
// 验收步骤:编辑器摆一堵墙(MeshRenderer = procedural://cube + // 验收步骤:编辑器摆一堵墙(MeshRenderer = procedural://cube +
// RigidBody Static Box + 挂 WallFracture,缩放即墙尺寸)→ 场景里 // RigidBody Static Box + 挂 WallFracture,缩放即墙尺寸)→ 场景里
@@ -35,25 +39,27 @@ namespace {
void DeclareParams(Engine::Scripting::ParamBuilder& pb) override { void DeclareParams(Engine::Scripting::ParamBuilder& pb) override {
// Voronoi 种子数 = 碎片数(40% 在命中点半径内加密 → 孔洞 // Voronoi 种子数 = 碎片数(40% 在命中点半径内加密 → 孔洞
// 周边碎块小、边缘大块的玻璃碎分布;最小间距防碎片退化) // 周边碎块小、边缘大块的玻璃碎分布;最小间距防碎片退化)
pb.Field("Count", &m_P.count, 30); // 直绑 Burst::p:碎裂逻辑只读 Burst::p,绑到别处(曾经的
pb.Field("SeedRadius", &m_P.seedRadius, 0.9f); // 加密半径 m // 独立 m_P)会让计数器/寿命等全部失效
pb.Field("MinSeedDist", &m_P.minSeedDist, 0.12f); // 种子最小间距 m pb.Field("Count", &m_Burst.p.count, 30);
pb.Field("SeedRadius", &m_Burst.p.seedRadius, 0.9f); // 加密半径 m
pb.Field("MinSeedDist", &m_Burst.p.minSeedDist, 0.12f); // 种子最小间距 m
// 玻璃/陶瓷式碎裂(裂纹分离 + 重力塌落),非爆炸飞散: // 玻璃/陶瓷式碎裂(裂纹分离 + 重力塌落),非爆炸飞散:
// 初速 = 命中点径向 × 距离指数衰减(近动远不动)+ 法线 // 初速 = 命中点径向 × 距离指数衰减(近动远不动)+ 法线
// 轻微外推(剥落)+ 微弱上抛;重力是塌落的主导 // 轻微外推(剥落)+ 微弱上抛;重力是塌落的主导
pb.Field("BurstSpeed", &m_P.burstSpeed, 1.2f); // 近点径向初速 m/s pb.Field("BurstSpeed", &m_Burst.p.burstSpeed, 1.2f); // 近点径向初速 m/s
pb.Field("Falloff", &m_P.falloff, 1.0f); // 距离衰减率 1/m pb.Field("Falloff", &m_Burst.p.falloff, 1.0f); // 距离衰减率 1/m
pb.Field("NormalPop", &m_P.normalPop, 0.4f); // 法线外推 m/s pb.Field("NormalPop", &m_Burst.p.normalPop, 0.4f); // 法线外推 m/s
pb.Field("UpBias", &m_P.upBias, 0.3f); // 上抛偏置 m/s pb.Field("UpBias", &m_Burst.p.upBias, 0.3f); // 上抛偏置 m/s
pb.Field("Lifetime", &m_P.lifetime, 6.0f); // 碎片寿命(秒) pb.Field("Lifetime", &m_Burst.p.lifetime, 6.0f); // 碎片寿命(秒)
pb.Field("MaxFragments", &m_P.maxFragments, 128); // 活碎片上限 pb.Field("MaxFragments", &m_Burst.p.maxFragments, 128); // 活碎片上限
pb.Field("MinImpactSpeed", &m_P.minImpactSpeed, 2.0f); // 触发阈值 pb.Field("MinImpactSpeed", &m_Burst.p.minImpactSpeed, 2.0f); // 触发阈值
} }
void OnCollisionEnter(u32 other, const glm::vec3& point, void OnCollisionEnter(u32 other, const glm::vec3& point,
const glm::vec3& normal) override { const glm::vec3& normal) override {
// 触发条件在库内:对方须是活的 Dynamic 刚体且速度够快 // 触发条件在库内:对方须是够快的撞击来源——动态刚体
//(过滤静止贴靠/角色轻碰——子弹/抛体必中) //(子弹/抛体)或角色(玩家撞墙);速度阈值过滤静止贴靠
m_Burst.TryFire(HV(), other, point, normal); m_Burst.TryFire(HV(), other, point, normal);
} }
@@ -72,8 +78,8 @@ namespace {
[this](Engine::ECS::Entity e) { Destroy(e); }}; [this](Engine::ECS::Entity e) { Destroy(e); }};
} }
Engine::Fracture::Params m_P; // 参数(DeclareParams 直绑) // 碎片簿记 + 生命周期;参数即 m_Burst.p(DeclareParams 直绑其字段)
Engine::Fracture::Burst m_Burst; // 碎片簿记 + 生命周期(look 默认砖色) Engine::Fracture::Burst m_Burst;
}; };
} // namespace } // namespace
+1 -1
View File
@@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.20) cmake_minimum_required(VERSION 3.20)
project(HelloVulkan CXX) project(HelloVulkan C CXX)
set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_STANDARD_REQUIRED ON)
+8
View File
@@ -27,6 +27,13 @@ target_include_directories(ImGuizmo PUBLIC ${IMGUIZMO_DIR})
target_compile_definitions(ImGuizmo PUBLIC IMGUI_DEFINE_MATH_OPERATORS) target_compile_definitions(ImGuizmo PUBLIC IMGUI_DEFINE_MATH_OPERATORS)
target_link_libraries(ImGuizmo PUBLIC imgui) target_link_libraries(ImGuizmo PUBLIC imgui)
# ufbx(FBX 全版本导入,vendored 单文件 C 库):assimp 的 FBX importer
# 只认 FBX 2011+,Mixamo 及大量网上资产是 FBX 6.1 binary(二进制布局
# 与 7.x 在对象 ID 层不兼容,assimp DOM 直接拒读)。ufbx 官方支持
# FBX 5.x/6.x/7.x 全版本,引擎的 .fbx 解析统一走它(glTF/OBJ 仍 assimp)
add_library(ufbx STATIC ${CMAKE_SOURCE_DIR}/Lib/ufbx/ufbx.c)
target_include_directories(ufbx PUBLIC ${CMAKE_SOURCE_DIR}/Lib/ufbx)
# Tracy(CPU/GPU 帧分析) # Tracy(CPU/GPU 帧分析)
add_library(tracy STATIC ${CMAKE_SOURCE_DIR}/Lib/tracy/public/TracyClient.cpp) add_library(tracy STATIC ${CMAKE_SOURCE_DIR}/Lib/tracy/public/TracyClient.cpp)
target_include_directories(tracy PUBLIC ${CMAKE_SOURCE_DIR}/Lib/tracy/public) target_include_directories(tracy PUBLIC ${CMAKE_SOURCE_DIR}/Lib/tracy/public)
@@ -80,6 +87,7 @@ target_link_libraries(Engine PUBLIC
spdlog::spdlog spdlog::spdlog
stb stb
assimp::assimp assimp::assimp
ufbx
meshoptimizer meshoptimizer
Jolt::Jolt Jolt::Jolt
yaml-cpp::yaml-cpp yaml-cpp::yaml-cpp
@@ -16,6 +16,7 @@
#define HELLOVULKAN_ANIMATIONATTACH_H #define HELLOVULKAN_ANIMATIONATTACH_H
#include <Engine/Core/Types.h> #include <Engine/Core/Types.h>
#include <Engine/Renderer/Model.h>
#include <string> #include <string>
#include <vector> #include <vector>
@@ -51,6 +52,16 @@ namespace Engine {
AttachResult AttachClipsToModel(Model& target, ParsedModel&& src, AttachResult AttachClipsToModel(Model& target, ParsedModel&& src,
const std::string& sourcePath); const std::string& sourcePath);
// 纯算法核心(headless 探针可直测):轨道重映射进 dstNodes
// 坐标系,产出的 clips 追加到 outExtra。幂等/命名冲突检查
// 基于 existingClips。AttachClipsToModel 只是
// "取 nodes/clips + 回填" 的薄封装
AttachResult AttachClipsToNodes(
const std::vector<ModelNode>& dstNodes,
const std::vector<AnimationClip>& existingClips,
ParsedModel&& src, const std::string& sourcePath,
std::vector<AnimationClip>& outExtra);
} // namespace Animation } // namespace Animation
@@ -64,15 +64,19 @@ namespace Engine::Animation {
// outGlobals 尺寸 = model.GetNodes().size(),父序 DFS 累乘。 // outGlobals 尺寸 = model.GetNodes().size(),父序 DFS 累乘。
// (crossfade 双层 = EvaluateLayers 的 count==2 特例,同一套 // (crossfade 双层 = EvaluateLayers 的 count==2 特例,同一套
// 加权混合数学,消掉历史双份代码) // 加权混合数学,消掉历史双份代码)
// inPlace = 丢弃动画子树根节点的位移(Unity in-place 语义:
// 根运动开关关闭,原地播放,旋转保留)
void Evaluate(const Model& model, const AnimationClip& cur, void Evaluate(const Model& model, const AnimationClip& cur,
f32 curTime, const CrossfadeState& xf, f32 curTime, const CrossfadeState& xf,
std::vector<glm::mat4>& outGlobals); std::vector<glm::mat4>& outGlobals,
bool inPlace = false);
// N 层加权混合(BlendTree / 状态机过渡的通用入口): // N 层加权混合(BlendTree / 状态机过渡的通用入口):
// 逐层采样局部 TRS → 权重归一化混合(pos/scl lerp、rot 渐进 // 逐层采样局部 TRS → 权重归一化混合(pos/scl lerp、rot 渐进
// slerp)→ global 累乘。全空/全零权重 = rest 姿态 // slerp)→ global 累乘。全空/全零权重 = rest 姿态
void EvaluateLayers(const Model& model, const PoseLayer* layers, void EvaluateLayers(const Model& model, const PoseLayer* layers,
u32 count, std::vector<glm::mat4>& outGlobals); u32 count, std::vector<glm::mat4>& outGlobals,
bool inPlace = false);
// 绑定姿势:全节点 untouched = localTransform 原样累乘。 // 绑定姿势:全节点 untouched = localTransform 原样累乘。
// 与渲染 identity palette 的表现一致(无动画/组件禁用实体的 // 与渲染 identity palette 的表现一致(无动画/组件禁用实体的
+112 -15
View File
@@ -1,13 +1,24 @@
// //
// HumanoidRig:Unity Avatar 语义的跨骨架重定向映射。 // HumanoidRig:Unity Avatar 语义的跨骨架重定向映射。
// //
// 资产组装现实:角色模型与动画 clip 是两套独立资产,骨骼命名完全 // 架构分层(对齐 Unity Mecanim / UE IK Retargeter 的做法):
// 不同源(英文/Mixamo/UE/Blender/MMD 日文)。Unity Humanoid 的解法
// 是把任意命名骨架映射到标准人形槽位(Avatar bone mapping),动画
// 经槽位转换后即可驱动任意人形角色。
// //
// 这里实现其中枢:名字归一化 → 槽位识别(关键词表,en/Mixamo/MMD) // ┌─ standard ─ 标准人形拓扑:HumanoidSlot 52 槽(主干 6 + 臂链
// → 源/目标骨架间生成 node 下标映射对。同名直映是映射的特例。 // │ 4×2 + 指骨 15×2 + 腿链 3×2 + 趾 2),带侧别/镜像/
// │ 指骨结构化访问。重定向的"标准空间"。
// ├─ dialects ─ 命名方言数据表:各来源(Mixamo/Rigify/UE/MMD)的
// │ "槽位 → 归一化骨名"候选模式,纯数据无特判。命名
// │ 习惯差异全部收敛在这层(Unity 把 token 规则编成
// │ 数据表、UE 用链名映射,同思想:识别=查表)。
// └─ mapper ── 归一化(NormalizeLower → side 抽取 → 分隔符剥离)
// + 查表识别(MatchHumanoidSlot)+ 跨骨架配对
// (AutoMapHumanoid:同名直映优先,余下槽位一对一)。
//
// 动画重定向本身在 AnimationAttach:src 动画经映射对烘焙到 dst
// 骨架(两遍式 T-pose 参考搬运),本模块只负责"名字 → 槽位 → 配对"。
//
// 资产组装现实:角色模型与动画 clip 是两套独立资产,骨骼命名完全
// 不同源。同名直映是映射的特例(同源拆分资产零损耗)。
// //
#ifndef HELLOVULKAN_HUMANOIDRIG_H #ifndef HELLOVULKAN_HUMANOIDRIG_H
@@ -16,13 +27,24 @@
#include <Engine/Core/Types.h> #include <Engine/Core/Types.h>
#include <Engine/Renderer/Model.h> #include <Engine/Renderer/Model.h>
#include <glm/glm.hpp>
#include <string_view> #include <string_view>
#include <utility> #include <utility>
#include <vector> #include <vector>
namespace Engine::Animation { namespace Engine::Animation {
// 标准人形槽位(最小主干集,不含手指/表情——主干驱动即可行走) // ---- standard:标准人形槽位(Unity Avatar 52 骨拓扑)----
//
// 布局保证算术可推导(镜像/指骨反查都走位运算友好的分段):
// [0..5] 主干:Hips→Spine→Chest→UpperChest→Neck→Head
// [6..9] 左臂链:Shoulder→UpperArm→LowerArm→Hand
// [10..24] 左指骨:5 指 × 3 段(Thumb/Index/Middle/Ring/Pinky,
// 段 1=近节 → 3=末节,与 Mixamo HandThumb1-3、Rigify
// .01-.03、UE Proximal/Distal 对齐)
// [25..28] 左腿链:UpperLeg→LowerLeg→Foot→Toes
// [29..51] 右侧镜像(+23 偏移,镜像布局与左完全同构)
enum class HumanoidSlot : u32 { enum class HumanoidSlot : u32 {
Hips = 0, Hips = 0,
Spine, Spine,
@@ -34,38 +56,113 @@ namespace Engine::Animation {
LeftUpperArm, LeftUpperArm,
LeftLowerArm, LeftLowerArm,
LeftHand, LeftHand,
LeftThumb1, // 指骨区起点(FingerSlot 算术依赖连续布局)
LeftThumb2,
LeftThumb3,
LeftIndex1,
LeftIndex2,
LeftIndex3,
LeftMiddle1,
LeftMiddle2,
LeftMiddle3,
LeftRing1,
LeftRing2,
LeftRing3,
LeftPinky1,
LeftPinky2,
LeftPinky3,
LeftUpperLeg,
LeftLowerLeg,
LeftFoot,
LeftToes,
RightShoulder, RightShoulder,
RightUpperArm, RightUpperArm,
RightLowerArm, RightLowerArm,
RightHand, RightHand,
LeftUpperLeg, RightThumb1,
LeftLowerLeg, RightThumb2,
LeftFoot, RightThumb3,
RightIndex1,
RightIndex2,
RightIndex3,
RightMiddle1,
RightMiddle2,
RightMiddle3,
RightRing1,
RightRing2,
RightRing3,
RightPinky1,
RightPinky2,
RightPinky3,
RightUpperLeg, RightUpperLeg,
RightLowerLeg, RightLowerLeg,
RightFoot, RightFoot,
RightToes,
Count Count
}; };
// 槽位侧别(左右对称骨骼的识别结果) // 槽位侧别(左右对称骨骼的识别结果)
enum class Side : u8 { None, Left, Right }; enum class Side : u8 { None, Left, Right };
// 槽位显示名("Hips"/"LeftUpperArm"...;调试映射表用) // 指别(0=拇指 … 4=小指;方言表里 Mixamo pinky / UE little 同槽)
enum class FingerKind : u32 { Thumb = 0, Index, Middle, Ring, Pinky };
// ---- standard:槽位结构化访问 ----
// 槽位侧别(主干槽返回 None;镜像侧与枚举名一致)
Side SideOfSlot(HumanoidSlot slot);
// 左右镜像(主干槽原样返回)
HumanoidSlot MirrorSlot(HumanoidSlot slot);
// 指骨槽位合成:(side, 指, 段 1..3) → 槽位
HumanoidSlot FingerSlot(Side side, FingerKind kind, u32 seg);
// 指骨槽位反解(非指骨槽位返回 false)
bool SlotFinger(HumanoidSlot slot, Side& side, FingerKind& kind,
u32& seg);
// 槽位显示名(Unity 语义:"Hips"/"LeftUpperArm"/"LeftHandThumb2"...;
// 调试映射表/Inspector 明细行用)
const char* HumanoidSlotName(HumanoidSlot slot); const char* HumanoidSlotName(HumanoidSlot slot);
// 名字归一化 + 槽位识别。 // ---- mapper:名字归一化 + 槽位识别 ----
// 名字归一化 + 查方言表识别槽位。
// "mixamorig:LeftForeArm" → {LowerArm, Left};"左ひじ" → {LowerArm, Left}; // "mixamorig:LeftForeArm" → {LowerArm, Left};"左ひじ" → {LowerArm, Left};
// "Upper Arm_L" → {UpperArm, Left}。识别失败 ok=false。 // "Upper Arm_L" → {UpperArm, Left};"DEF-thumb.01.L" → {Thumb1, Left};
// "Thumb Proximal_R" → {Thumb1, Right}。识别失败 ok=false。
HumanoidSlot MatchHumanoidSlot(std::string_view name, Side& side, HumanoidSlot MatchHumanoidSlot(std::string_view name, Side& side,
bool& ok); bool& ok);
// 跨骨架自动映射:返回 (srcNodeIdx, dstNodeIdx) 对列表。 // 跨骨架自动映射:返回 (srcNodeIdx, dstNodeIdx) 对列表。
// 同名 node 直映优先(同源拆分资产零损耗),余下按槽位匹配; // 同名 node 直映优先(同源拆分资产零损耗),余下按槽位一对一配对;
// 一槽多候选时取语义关键词优先级表中的顺序(先高后低)。 // 同槽多候选时取方言表内的优先序(先高后低)。
std::vector<std::pair<u32, u32>> AutoMapHumanoid( std::vector<std::pair<u32, u32>> AutoMapHumanoid(
const std::vector<ModelNode>& srcNodes, const std::vector<ModelNode>& srcNodes,
const std::vector<ModelNode>& dstNodes); const std::vector<ModelNode>& dstNodes);
// 估计源/目标骨架的根对齐旋转(rest 姿态 basis 对齐,src 世界 →
// dst 世界)。跨骨架 retarget 时两套骨架常不同朝向(kwy DummyDoll
// Z-up vs X Bot Y-up),增量变换须经该旋转共轭才能跨空间传递。
// up = 头 - 双脚中点;right = 右臂 - 左臂(去 up 分量);
// fwd = up × right,脚趾朝向消歧。槽位不全(缺头/双脚/双臂)
// 或 rest 退化时返回 identity。
glm::quat EstimateHumanoidRootAlignment(
const std::vector<ModelNode>& srcNodes,
const std::vector<ModelNode>& dstNodes);
// 估计骨架的 T-pose 参考姿势(每节点**世界**旋转)。
// 人形动画资产的摆动量以 T-pose 为零位;rest 是 A-pose(臂下垂)
// 时,旋转增量直接搬运会过摆(臂越过中线)。此处把臂链
// (upper_arm 子树)绕肩刚体摆到水平侧向作为参考,其余骨取 rest
// (手指/趾走 rest 零位局部增量通道,见 AnimationAttach——指根
// 扇形编码在 rest 平移里,不参与世界系零位搬运);rest 本为
// T-pose 的资产摆动量≈0 自动退化。行走基不可用时全 rest。
std::vector<glm::quat> EstimateHumanoidTPose(
const std::vector<ModelNode>& nodes,
const std::vector<glm::mat4>& restGlobals);
} // namespace Engine::Animation } // namespace Engine::Animation
#endif // HELLOVULKAN_HUMANOIDRIG_H #endif // HELLOVULKAN_HUMANOIDRIG_H
+113
View File
@@ -0,0 +1,113 @@
//
// LogRing.h:进程级日志环形缓冲(编辑器 Log Panel 的数据源)。
//
// RingSink 注册进默认 logger 的 sinks 后,所有日志(引擎 + 脚本经
// ScriptLog/ENGINE_* 打印的)自动落缓冲;面板按 version 增量感知、
// Snapshot 整体拉取。头文件设施(sink_it_ 内联),无 cpp。
//
#ifndef HELLOVULKAN_LOGRING_H
#define HELLOVULKAN_LOGRING_H
#include <Engine/Core/Types.h>
#include <spdlog/sinks/base_sink.h>
#include <spdlog/details/log_msg.h>
#include <atomic>
#include <mutex>
#include <string>
#include <vector>
namespace Engine::Debug {
struct LogEntry {
spdlog::level::level_enum level;
std::string msg; // 整行(已按 ring pattern 格式化,无色码)
};
class LogRingBuffer {
public:
static constexpr size_t kCap = 2048;
// 门控:启动期系统初始化日志(Vulkan/UBO/IBL...)不进面板,
// 与 Unity Console 语义一致(只收运行期游戏侧日志)。
// Application::Run 进入主循环时开闸
bool IsEnabled() const {
return m_Enabled.load(std::memory_order_relaxed);
}
void SetEnabled(bool v) {
m_Enabled.store(v, std::memory_order_relaxed);
}
void Push(spdlog::level::level_enum lvl, std::string msg) {
std::lock_guard<std::mutex> lk(m_Mutex);
if (m_Entries.size() < kCap) {
m_Entries.push_back({lvl, std::move(msg)});
} else {
m_Entries[m_Head] = {lvl, std::move(msg)};
m_Head = (m_Head + 1) % kCap; // 满后覆写最旧
}
++m_Version;
}
// 快照(满环时按时间序重排)
std::vector<LogEntry> Snapshot() const {
std::lock_guard<std::mutex> lk(m_Mutex);
if (m_Entries.size() < kCap) {
return m_Entries;
}
std::vector<LogEntry> out;
out.reserve(kCap);
out.insert(out.end(), m_Entries.begin() + static_cast<long>(m_Head),
m_Entries.end());
out.insert(out.end(), m_Entries.begin(),
m_Entries.begin() + static_cast<long>(m_Head));
return out;
}
void Clear() {
std::lock_guard<std::mutex> lk(m_Mutex);
m_Entries.clear();
m_Head = 0;
++m_Version;
}
u64 Version() const {
return m_Version.load(std::memory_order_relaxed);
}
private:
mutable std::mutex m_Mutex;
std::vector<LogEntry> m_Entries;
size_t m_Head = 0;
std::atomic<u64> m_Version{0};
std::atomic<bool> m_Enabled{false};
};
inline LogRingBuffer& LogRing() {
static LogRingBuffer inst;
return inst;
}
// spdlog sink 适配:log() 整行入环。pattern 由装配端设置(无色码)
class RingSink : public spdlog::sinks::base_sink<std::mutex> {
protected:
void sink_it_(const spdlog::details::log_msg& msg) override {
if (!LogRing().IsEnabled()) {
return; // 启动期:只落终端/文件,不进面板
}
spdlog::memory_buf_t formatted;
base_sink<std::mutex>::formatter_->format(msg, formatted);
std::string line(formatted.data(), formatted.size());
while (!line.empty() &&
(line.back() == '\n' || line.back() == '\r')) {
line.pop_back();
}
LogRing().Push(msg.level, std::move(line));
}
void flush_() override {}
};
} // namespace Engine::Debug
#endif //HELLOVULKAN_LOGRING_H
+17 -22
View File
@@ -37,29 +37,27 @@ namespace Engine
void AddAllocation(u64 bytes); void AddAllocation(u64 bytes);
void AddDeallocation(u64 bytes); void AddDeallocation(u64 bytes);
// ---- 采样进程真实内存 ---- // ---- 只读快照(面板/遥测消费)----
void SampleProcessMemory(); struct Snapshot
{
// ---- 打印 ---- u64 textureCount = 0;
void Print() const; u64 textureBytes = 0;
void PrintDetailed() const; u64 meshCount = 0;
void PrintComparison() const; u64 meshBytes = 0;
u64 bufferCount = 0;
u64 bufferBytes = 0;
u64 memoryBlockBytes = 0; // MemoryAllocator 已分配块
u64 memoryUsedBytes = 0; // 块内实际占用
u64 frameAllocation = 0; // 本帧新增(BeginFrame 清零)
u64 frameDeallocation = 0; // 本帧释放
u64 totalAllocation = 0;
u64 totalDeallocation = 0;
};
Snapshot TakeSnapshot() const;
private: private:
Stats() = default; Stats() = default;
struct ProcessMemory
{
u64 VmSize = 0; // /proc/self/status VmSize
u64 Rss = 0; // /proc/self/status VmRSS
u64 Pss = 0; // smaps_rollup
u64 SharedClean = 0;
u64 PrivateDirty = 0;
u64 PrivateClean = 0;
};
ProcessMemory ReadProcessMemory() const;
mutable std::mutex m_Mutex; mutable std::mutex m_Mutex;
// 资源计数 // 资源计数
@@ -81,9 +79,6 @@ namespace Engine
u64 m_FrameDeallocation = 0; u64 m_FrameDeallocation = 0;
u64 m_TotalAllocation = 0; u64 m_TotalAllocation = 0;
u64 m_TotalDeallocation = 0; u64 m_TotalDeallocation = 0;
// 进程快照
ProcessMemory m_ProcessMem{};
}; };
#define STATS Engine::Stats::Get() #define STATS Engine::Stats::Get()
+3
View File
@@ -480,6 +480,9 @@ namespace Engine::ECS {
// 重力倍率(UE CharacterMovement.GravityScale 同位):真实 9.81 // 重力倍率(UE CharacterMovement.GravityScale 同位):真实 9.81
// 对人形角色偏"棉花糖"(滞空 1.2s+),游戏手感普遍 2~3 倍 // 对人形角色偏"棉花糖"(滞空 1.2s+),游戏手感普遍 2~3 倍
f32 gravityScale = 2.0f; f32 gravityScale = 2.0f;
// 形状附加偏移:叠加在脚底约定(胶囊底端=原点)之上,模型
// pivot 不在脚底时对齐视觉网格用(Y+ 胶囊上抬)
glm::vec3 shapeOffset{0.0f};
bool operator==(const CharacterControllerComponent&) const = default; bool operator==(const CharacterControllerComponent&) const = default;
// 实体原点 = 脚底(胶囊底端),符合角色 pivot 习惯 // 实体原点 = 脚底(胶囊底端),符合角色 pivot 习惯
@@ -6,6 +6,11 @@
// "看不到"它(无碰撞解算,仅查询)。由 PhysicsWorld 对账创建/销毁并 // "看不到"它(无碰撞解算,仅查询)。由 PhysicsWorld 对账创建/销毁并
// 在 PhysicsSystem::Update 之后驱动(Jolt 官方建议序)。 // 在 PhysicsSystem::Update 之后驱动(Jolt 官方建议序)。
// //
// 角色间碰撞(Jolt 设计行为):CharacterVirtual 彼此默认穿透,需经
// CharacterVsCharacterCollision 显式互查。PhysicsWorld 持有一份
// CharacterVsCharacterCollisionSimple 并注入每个角色(SetCharacterCollision),
// 使角色扫掠时也撞其它角色——进/出列表由本类构造/析构自动维护。
//
// 速度语义(游戏逻辑职责分工): // 速度语义(游戏逻辑职责分工):
// - 水平移动:游戏逻辑每帧 SetCharacterVelocity 喂意图速度 // - 水平移动:游戏逻辑每帧 SetCharacterVelocity 喂意图速度
// - 垂直分量:本类自动积分重力(着地清零向下分量);Jump 置垂直速度 // - 垂直分量:本类自动积分重力(着地清零向下分量);Jump 置垂直速度
@@ -36,13 +41,21 @@ namespace Engine {
f32 mass = 70.0f; f32 mass = 70.0f;
f32 maxSlopeAngle = 45.0f; // 度 f32 maxSlopeAngle = 45.0f; // 度
f32 stepHeight = 0.4f; // 台阶攀爬高 f32 stepHeight = 0.4f; // 台阶攀爬高
// 形状附加偏移(相对"胶囊底端钉实体原点"的脚底约定):
// 模型 pivot 不在脚底时对齐用(Y+ 上抬)
glm::vec3 shapeOffset{0.0f};
bool operator==(const Params&) const = default; bool operator==(const Params&) const = default;
}; };
CharacterController(JPH::PhysicsSystem& system, const Params& params, CharacterController(JPH::PhysicsSystem& system, const Params& params,
const glm::vec3& position); const glm::vec3& position);
~CharacterController() = default; // Ref 引用计数自动释放 // 析构:先从角色间碰撞列表摘除(防悬垂指针),Ref 随即释放
~CharacterController();
// 注入角色间碰撞列表(PhysicsWorld 单份共享):登记本角色并打开
// 互查。构造后由 PhysicsWorld 创建时调用一次;列表须比本对象活得久
void SetCharacterCollision(JPH::CharacterVsCharacterCollisionSimple& list);
// 每物理步调用:重力积分 + ExtendedUpdate(扫掠滑动 + 贴地 + // 每物理步调用:重力积分 + ExtendedUpdate(扫掠滑动 + 贴地 +
// 上台阶)。allocator 与 PhysicsSystem::Update 串行复用同一池 // 上台阶)。allocator 与 PhysicsSystem::Update 串行复用同一池
@@ -59,6 +72,16 @@ namespace Engine {
const Params& GetParams() const { return m_Params; } const Params& GetParams() const { return m_Params; }
// ---- 碰撞事件支持(PhysicsWorld 合成 Enter/Exit 用)----
// 本步活跃接触列表(CharacterVirtual 直通;含对方体 ID/命中点/
// 法线/距离)。CharacterVirtual 非刚体,Jolt 的 ContactListener
// 不会为它回调——由 PhysicsWorld 读本列表对比出事件边沿
const JPH::CharacterVirtual::ContactList& GetActiveContacts() const;
// 上一次 Update 扫掠"之前"的速度(撞击强度判定)。Jolt 在扫掠
// 中会抵消朝向墙面/陡面的速度分量(CancelVelocityTowardsSteepSlopes),
// 扫掠后再读 GetLinearVelocity 往往已归零——撞墙判定必须用这里
glm::vec3 GetSweepVelocity() const;
// 调试渲染:非 body,PhysicsSystem::DrawBodies 画不到,须手动 // 调试渲染:非 body,PhysicsSystem::DrawBodies 画不到,须手动
// (脚本在物理位姿/脚底原点处画胶囊线框) // (脚本在物理位姿/脚底原点处画胶囊线框)
void DebugDraw(JPH::DebugRenderer& renderer) const; void DebugDraw(JPH::DebugRenderer& renderer) const;
@@ -67,6 +90,8 @@ namespace Engine {
Params m_Params; Params m_Params;
JPH::Ref<JPH::CharacterVirtual> m_Character; JPH::Ref<JPH::CharacterVirtual> m_Character;
JPH::CharacterVirtual::ExtendedUpdateSettings m_Ext; JPH::CharacterVirtual::ExtendedUpdateSettings m_Ext;
JPH::CharacterVsCharacterCollisionSimple* m_VsChar = nullptr; // 非拥有
JPH::Vec3 m_SweepVelocity{0.0f, 0.0f, 0.0f}; // 扫掠前速度快照
}; };
} // namespace Engine } // namespace Engine
+12 -1
View File
@@ -102,6 +102,9 @@ namespace Engine {
// 角色脚底位置(物理权威世界坐标):调试图元/特效锚点用它, // 角色脚底位置(物理权威世界坐标):调试图元/特效锚点用它,
// 而不是 Transform().GetPosition()——后者在有父级时是本地坐标 // 而不是 Transform().GetPosition()——后者在有父级时是本地坐标
glm::vec3 GetCharacterPosition(u32 entityBits) const; glm::vec3 GetCharacterPosition(u32 entityBits) const;
// 角色"撞击强度"速度(上一次扫掠前的速度):撞墙/碎墙等判定用它,
// 而非 GetCharacterVelocity——后者已被 Jolt 抵消掉朝向墙面的分量
glm::vec3 GetCharacterImpactVelocity(u32 entityBits) const;
private: private:
// 创建时缓存的参数(变更检测 → 重建 body) // 创建时缓存的参数(变更检测 → 重建 body)
@@ -138,7 +141,8 @@ namespace Engine {
// 角色非刚体(不进 PhysicsSystem),无 body 清理负担 // 角色非刚体(不进 PhysicsSystem),无 body 清理负担
void SyncCharacters(); void SyncCharacters();
// 每物理步(PhysicsSystem::Update 之后,Jolt 官方建议序): // 每物理步(PhysicsSystem::Update 之后,Jolt 官方建议序):
// 重力积分 + 扫掠 → 写回 TransformComponent(标脏) // 重力积分 + 扫掠 → 写回 TransformComponent(标脏)+ 合成角色
// 碰撞事件(CharacterVirtual 不进 ContactListener,见函数注释)
void UpdateCharacters(f32 dt); void UpdateCharacters(f32 dt);
// Mesh 碰撞体:实体视觉网格 → Jolt 形状(Static=MeshShape / // Mesh 碰撞体:实体视觉网格 → Jolt 形状(Static=MeshShape /
// Dynamic=ConvexHull)。无 MeshRenderer 或构建失败返回 null(回退 Box) // Dynamic=ConvexHull)。无 MeshRenderer 或构建失败返回 null(回退 Box)
@@ -165,9 +169,16 @@ namespace Engine {
std::unordered_map<u32, ConstraintRecord> m_Constraints; std::unordered_map<u32, ConstraintRecord> m_Constraints;
// ---- 角色(entityBits → CharacterVirtual 封装)---- // ---- 角色(entityBits → CharacterVirtual 封装)----
// 角色间碰撞互查表:Jolt CharacterVirtual 默认彼此穿透,须显式注入
// CharacterVsCharacterCollisionSimple 才互撞。声明在 m_Characters
// 之前(析构更晚)——角色析构时会回表 Remove,列表须后于角色释放
Scope<JPH::CharacterVsCharacterCollisionSimple> m_VsCharacterCollision;
struct CharacterEntry { struct CharacterEntry {
std::unique_ptr<CharacterController> cc; std::unique_ptr<CharacterController> cc;
glm::vec3 lastPos{0.0f}; // 编辑器拖动检测 glm::vec3 lastPos{0.0f}; // 编辑器拖动检测
// 上一步活跃接触的"对方实体"集合:本步对比出角色碰撞
// Enter/Exit(CharacterVirtual 非刚体,ContactListener 不收)
std::unordered_set<u32> contacts;
}; };
std::unordered_map<u32, CharacterEntry> m_Characters; std::unordered_map<u32, CharacterEntry> m_Characters;
+24 -4
View File
@@ -47,7 +47,13 @@ namespace Engine {
// 无填充歧义),加字段必须双端同步(simple_shader*.frag) // 无填充歧义),加字段必须双端同步(simple_shader*.frag)
struct MaterialParams { struct MaterialParams {
// x = metallic 因子,y = roughness 因子,z = 独立粗糙度图开关,w = MASK 镂空开关 // x = metallic 因子,y = roughness 因子,z = 独立粗糙度图开关,w = MASK 镂空开关
glm::vec4 factors{0.0f, 0.5f, 0.0f, 0.0f}; // 默认 = 电介质表面推荐值(Unity/UE 同款):
// metallic = 0 皮肤/头发/布料等非金属的正确值
// roughness = 1.0 因子语义是"乘贴图",1.0 = 贴图值直用(同 glTF
// roughnessFactor 缺省);配合兜底 MR 纹理 G=128
// 得 effective roughness ≈ 0.5,不再是 ×0.5 后的
// 0.25 假高光(ufbx 路径不写 factors,全靠这里兜底)
glm::vec4 factors{0.0f, 1.0f, 0.0f, 0.0f};
// glTF baseColorFactor:rgb 乘进 albedo,a 作 BLEND 透明度 // glTF baseColorFactor:rgb 乘进 albedo,a 作 BLEND 透明度
glm::vec4 baseColor{1.0f, 1.0f, 1.0f, 1.0f}; glm::vec4 baseColor{1.0f, 1.0f, 1.0f, 1.0f};
// glTF 表面扩展(KHR_materials_ior / transmission / volume / // glTF 表面扩展(KHR_materials_ior / transmission / volume /
@@ -55,7 +61,14 @@ namespace Engine {
// y = 透射率(0 = 普通表面),z = 自发光强度,w = 体积厚度 // y = 透射率(0 = 普通表面),z = 自发光强度,w = 体积厚度
//(折射屏幕偏移的世界距离,0 = 无弯曲直透) //(折射屏幕偏移的世界距离,0 = 无弯曲直透)
glm::vec4 surface{1.5f, 0.0f, 1.0f, 0.0f}; glm::vec4 surface{1.5f, 0.0f, 1.0f, 0.0f};
// 杂项语义:x = unlit(KHR_materials_unlit,shader 早退直出 albedo) // 杂项语义:
// x = unlit(KHR_materials_unlit,shader 早退直出 albedo)
// y = UV 左右镜像(u → 1-u,1 = 开启)
// z = UV 上下镜像(v → 1-v,1 = 开启)
// w = 保留
// 镜像作用于材质全部纹理槽,叠加在每槽 UV 变换(uvA/uvT)之后:
// 用于纠正源资产 UV 朝向(部分角色 rip 的 UV 与 glTF 上左原点
// 约定相反,贴图整体翻转)。材质级语义,非单槽
glm::vec4 misc{0.0f, 0.0f, 0.0f, 0.0f}; glm::vec4 misc{0.0f, 0.0f, 0.0f, 0.0f};
// glTF emissiveFactor:自发光颜色(rgb;黑 = 不发光)。emissive // glTF emissiveFactor:自发光颜色(rgb;黑 = 不发光)。emissive
// 槽默认兜底纹理是纯黑,无贴图时 Emissive Strength 乘出来恒黑 // 槽默认兜底纹理是纯黑,无贴图时 Emissive Strength 乘出来恒黑
@@ -63,8 +76,10 @@ namespace Engine {
glm::vec4 emissiveFactor{0.0f, 0.0f, 0.0f, 0.0f}; glm::vec4 emissiveFactor{0.0f, 0.0f, 0.0f, 0.0f};
// 每槽 KHR_texture_transform(glTF2 每个 textureInfo 独立声明 // 每槽 KHR_texture_transform(glTF2 每个 textureInfo 独立声明
// offset/scale/rotation——烘焙单份顶点 UV 无法表达"一材质多槽 // offset/scale/rotation——烘焙单份顶点 UV 无法表达"一材质多槽
// 各异变换",收敛为 shader 侧线性式 uv' = A·uv + t,作用在本引擎 // 各异变换",收敛为 shader 侧线性式 uv' = A·uv + t,作用在 glTF
// v-up 顶点 UV 空间,换算推导见 Model.cpp glTF 分支注释)。 // 规范 UV 空间(v=0 = 图像顶);顶点 UV 经 aiProcess_FlipUVs 已
// 归一至该空间,A/t 即规范式 off + R(rot)·S(scale)·uv 的摊平,
// 换算推导见 Model.cpp glTF 分支注释)。
// uvA[slot] = (a11, a12, a21, a22)(行主 2×2),uvT[slot] = (tx, ty, 0, 0)。 // uvA[slot] = (a11, a12, a21, a22)(行主 2×2),uvT[slot] = (tx, ty, 0, 0)。
// 长度必须 = Material::kSlotCount;std140 vec4 数组步长 16B 与 // 长度必须 = Material::kSlotCount;std140 vec4 数组步长 16B 与
// CPU 布局天然对齐 // CPU 布局天然对齐
@@ -72,6 +87,11 @@ namespace Engine {
{1.0f, 0.0f, 0.0f, 1.0f}, {1.0f, 0.0f, 0.0f, 1.0f}, {1.0f, 0.0f, 0.0f, 1.0f}, {1.0f, 0.0f, 0.0f, 1.0f},
{1.0f, 0.0f, 0.0f, 1.0f}, {1.0f, 0.0f, 0.0f, 1.0f}}; {1.0f, 0.0f, 0.0f, 1.0f}, {1.0f, 0.0f, 0.0f, 1.0f}};
glm::vec4 uvT[6] = {}; glm::vec4 uvT[6] = {};
// albedo 贴图 alpha 作细节叠加遮罩(脸部腮红/卧蚕等局部效果):
// rgb = 叠加色(与 baseColor 同款直乘/直用,不做空间换算),
// a = 强度(0 = 关闭,mix 恒等无副作用)。alpha 遮罩来自贴图本身,
// 无 alpha 或 alpha 恒 1 的贴图开启后即等效整体染色
glm::vec4 alphaOverlay{0.0f, 0.0f, 0.0f, 0.0f};
}; };
class Material { class Material {
@@ -47,8 +47,9 @@ namespace Engine {
// 材质资产数据段(磁盘格式 ↔ 内存值,无 GPU 状态)。 // 材质资产数据段(磁盘格式 ↔ 内存值,无 GPU 状态)。
// texPaths = 文件系统相对 CWD 路径(与模型解析产物同源,glTF/FBX // texPaths = 文件系统相对 CWD 路径(与模型解析产物同源,glTF/FBX
// 纹理可直接快照);空串 = 该槽用共享默认纹理兜底。 // 纹理可直接快照);空串 = 该槽用共享默认纹理兜底。
// glb 内嵌纹理无独立文件路径 → 对 glb 模型 Create Override 时 // glb 内嵌纹理在模型导入期已抽到 <模型目录>/textures/ 落盘并回写为
// 纹理槽为空(回退默认纹理),参数/透明照常可编辑。 // 真实路径(Model::ParseFromDisk),故此处与文件纹理同语义——覆盖
// 材质可正常继承
struct MaterialAssetData { struct MaterialAssetData {
std::array<std::string, Material::kSlotCount> texPaths; std::array<std::string, Material::kSlotCount> texPaths;
MaterialParams params; MaterialParams params;
+6
View File
@@ -137,6 +137,12 @@ namespace Engine
// 纯 CPU:读盘 + assimp 解析 → 几何 + 材质引用。无线程状态。 // 纯 CPU:读盘 + assimp 解析 → 几何 + 材质引用。无线程状态。
static ParsedModel ParseFromDisk(const std::string& path); static ParsedModel ParseFromDisk(const std::string& path);
// 资产显示名(编辑器 UI / clip 命名的唯一来源):取文件 stem;
// stem 无信息量(空 / 导入资产库的 "asset")时读同目录 pathname
// 文件取原始资产 basename,再回退父目录名。避免 UI 直接显示
// 哈希路径(用户看到的是 id)
static std::string AssetDisplayName(const std::string& path);
// GPU 构建(必须主线程):纹理上传 / mesh 缓冲 / 材质描述符。 // GPU 构建(必须主线程):纹理上传 / mesh 缓冲 / 材质描述符。
// 解析失败返回 nullptr。 // 解析失败返回 nullptr。
static Scope<Model> FromParsed(Device& device, MemoryAllocator& allocator, static Scope<Model> FromParsed(Device& device, MemoryAllocator& allocator,
@@ -31,6 +31,10 @@ namespace Engine {
class Device; class Device;
// 显存估算(按格式字节宽与 mip 链逐级累加;BC 压缩按 4x4 块计)——
// 分配器台账与 Texture 资源台账共用同一算法,两边永不漂移
u64 EstimateImageBytes(vk::Format fmt, u32 w, u32 h, u32 depth, u32 mips);
class MemoryAllocator { class MemoryAllocator {
public: public:
explicit MemoryAllocator(Device& device); explicit MemoryAllocator(Device& device);
+542 -127
View File
@@ -1,17 +1,42 @@
// //
// AnimationAttach 实现:外部动画资产 → 目标模型并入。 // AnimationAttach 实现:外部动画资产 → 目标模型并入。
// //
// 轨道映射两级: // 五段管线(映射表一次构建后**冻结**,逐 clip 只读消费,杜绝循环内
// ① 同名直映——同源拆分资产(角色/动画同一骨架导出)零损耗; // 惰性登记/反向表重建):
// ② Humanoid 槽位重定向——名字不同源的组装资产(角色 FBX +
// 动画 FBX 各自制作),经 Unity Avatar 语义的标准人形槽位配对,
// 逐 key 烘焙"rest→anim 的增量变换"到目标骨架局部空间:
// 平移 out.pos = dstRestPos + (srcPos - srcRestPos)
// 旋转 Δ = srcRot * inv(srcRestRot);out.rot = Δ * dstRestRot
// (Δ 父空间左乘 = 骨骼自身摆动在两套骨架间等价传递)
// 烘焙后 clip 即目标骨架空间数据,评估器零改动。
// //
// AssetManager 是 facade:取模型 + 解析磁盘,纯算法在这里。 // ① 资产导入(Model.cpp) clip 命名与重定向解耦(文件 stem 真名)
// ② 映射表(HumanoidRig) 同名直映 / 52 槽配对 → 冻结 Plan
// ③ 根运动通道 骨架上方"承载节点"(未识别的映射骨祖先)
// 取最深者 → dst 根,世界增量 Δ·RdstRest
// ④ 关节烘焙 两遍式 T-pose 参考搬运(见下数学);手指/
// 趾与主干同通道(Unity/UE 语义:指骨即普通
// 骨骼,无独立局部增量通道——局部系约定免疫)
//
// 关键不变量(本次重写的核心,此前版本违反即整体崩坏):
// * Plan 构建后不可变;不存在 clip 循环内的 emplace 登记
// * 每个 dst 节点至多一条轨道(owner 去重)——杜绝同节点重复轨道
// * "承载节点"= 未识别的骨骼祖先(骨架上方),恒不参与同名直搬;
// 判定基于**槽位识别**而非"所有映射骨祖先"(后者会把 hips/spine
// 等内部骨全部误判成承载节点 → 大半骨架走烘焙而叶走直搬 → 局部
// 化基准错位、全身崩坏,即用户反馈"全部是崩溃的"的根因)
// * 仅"跨骨架对"(两侧骨名不同)走烘焙;同名对零损耗原样直搬
// * 未动画的 src 骨不发旋转键(目标保持 rest,不被 T-pose 零位拉走)
//
// 数学(旋转,两遍式):
// 零位 = src 侧 T-pose 参考 RsT(EstimateHumanoidTPose,两侧对称)
// 世界增量 dW = A·(Rsrc(t)·RsT⁻¹)·A⁻¹
// 目标世界 RwI = dW·RdT(dst)
// 键 = inv(Rw[父])·RwI(父用 anim 后世界旋转)
// rest 即 T-pose 的资产自动退化(dW 恒等 → 与旧直搬等价);A 为
// src 世界 → dst 世界的行走基对齐(EstimateHumanoidRootAlignment)
//
// 数学(平移):
// Root : dstRest + A·(srcPos - srcRest)·unitScale(整体尺度)
// Joint : dstRest + M·(srcPos - srcRest)·ratio(per-骨长比)
// M = inv(dstParW)·A·srcParW(局部增量系转换),退化取 unitScale
//
// 纯算法在 AttachClipsToNodes(headless 可直测);
// AttachClipsToModel 是 facade:取模型 nodes/clips + 回填。
// //
#include <Engine/Animation/AnimationAttach.h> #include <Engine/Animation/AnimationAttach.h>
@@ -28,11 +53,15 @@
#include <filesystem> #include <filesystem>
#include <unordered_map> #include <unordered_map>
#include <utility> #include <utility>
#include <vector>
namespace Engine::Animation { namespace Engine::Animation {
namespace { namespace {
constexpr u32 kNoNode = ~0u;
const glm::quat kIdentity(1.0f, 0.0f, 0.0f, 0.0f);
// mat4 → TRS 分解(rest 局部变换;引擎 node 无 shear) // mat4 → TRS 分解(rest 局部变换;引擎 node 无 shear)
struct TRS { struct TRS {
glm::vec3 pos{}; glm::vec3 pos{};
@@ -47,8 +76,7 @@ namespace Engine::Animation {
const glm::vec3 colX(m[0]); const glm::vec3 colX(m[0]);
const glm::vec3 colY(m[1]); const glm::vec3 colY(m[1]);
const glm::vec3 colZ(m[2]); const glm::vec3 colZ(m[2]);
t.scl = {glm::length(colX), glm::length(colY), t.scl = {glm::length(colX), glm::length(colY), glm::length(colZ)};
glm::length(colZ)};
if (t.scl.x > 1e-8f && t.scl.y > 1e-8f && t.scl.z > 1e-8f) { if (t.scl.x > 1e-8f && t.scl.y > 1e-8f && t.scl.z > 1e-8f) {
t.rot = glm::quat_cast(glm::mat3( t.rot = glm::quat_cast(glm::mat3(
colX / t.scl.x, colY / t.scl.y, colZ / t.scl.z)); colX / t.scl.x, colY / t.scl.y, colZ / t.scl.z));
@@ -56,14 +84,336 @@ namespace Engine::Animation {
return t; return t;
} }
// 世界矩阵 → 旋转(列归一化抗缩放;退化列取 identity)
glm::quat RotOf(const glm::mat4& m)
{
const glm::vec3 cx(m[0]);
const glm::vec3 cy(m[1]);
const glm::vec3 cz(m[2]);
if (glm::length(cx) < 1e-8f || glm::length(cy) < 1e-8f ||
glm::length(cz) < 1e-8f) {
return kIdentity;
}
return glm::quat_cast(glm::mat3(
glm::normalize(cx), glm::normalize(cy), glm::normalize(cz)));
}
// rest 全骨架世界矩阵(node 表父先子后,直接链乘)
std::vector<glm::mat4> RestGlobals(const std::vector<ModelNode>& nodes)
{
std::vector<glm::mat4> g(nodes.size());
for (size_t i = 0; i < nodes.size(); ++i) {
const i32 p = nodes[i].parent;
g[i] = (p >= 0 ? g[static_cast<size_t>(p)]
: glm::mat4(1.0f)) *
nodes[i].localTransform;
}
return g;
}
// ---- 键采样(烘焙用;线性/slerp,rest 兜底)----
glm::vec3 SampleV(const std::vector<PosKey>& ks, float t,
const glm::vec3& def)
{
if (ks.empty()) return def;
if (t <= ks.front().time) return ks.front().value;
for (size_t i = 1; i < ks.size(); ++i) {
if (t <= ks[i].time) {
const f32 a = (t - ks[i - 1].time) /
(ks[i].time - ks[i - 1].time);
return glm::mix(ks[i - 1].value, ks[i].value, a);
}
}
return ks.back().value;
}
glm::vec3 SampleV(const std::vector<SclKey>& ks, float t,
const glm::vec3& def)
{
if (ks.empty()) return def;
if (t <= ks.front().time) return ks.front().value;
for (size_t i = 1; i < ks.size(); ++i) {
if (t <= ks[i].time) {
const f32 a = (t - ks[i - 1].time) /
(ks[i].time - ks[i - 1].time);
return glm::mix(ks[i - 1].value, ks[i].value, a);
}
}
return ks.back().value;
}
glm::quat SampleQ(const std::vector<RotKey>& ks, float t,
const glm::quat& def)
{
if (ks.empty()) return def;
if (t <= ks.front().time) return ks.front().value;
for (size_t i = 1; i < ks.size(); ++i) {
if (t <= ks[i].time) {
const f32 a = (t - ks[i - 1].time) /
(ks[i].time - ks[i - 1].time);
return glm::slerp(ks[i - 1].value, ks[i].value, a);
}
}
return ks.back().value;
}
// 源 clip 于时刻 t 的全骨架世界矩阵(层级 T·R·S 链乘)
std::vector<glm::mat4> SrcWorldAt(
const std::vector<ModelNode>& nodes,
const std::vector<const AnimChannel*>& byNode, float t)
{
std::vector<glm::mat4> world(nodes.size());
for (size_t i = 0; i < nodes.size(); ++i) {
const TRS rest = Decompose(nodes[i].localTransform);
const AnimChannel* ch =
i < byNode.size() ? byNode[i] : nullptr;
const glm::vec3 p =
ch ? SampleV(ch->positions, t, rest.pos) : rest.pos;
const glm::quat r =
ch ? SampleQ(ch->rotations, t, rest.rot) : rest.rot;
const glm::vec3 s =
ch ? SampleV(ch->scales, t, rest.scl) : rest.scl;
const glm::mat4 local = glm::translate(glm::mat4(1.0f), p) *
glm::mat4_cast(r) *
glm::scale(glm::mat4(1.0f), s);
const i32 pi = nodes[i].parent;
world[i] = (pi >= 0 ? world[static_cast<size_t>(pi)]
: glm::mat4(1.0f)) * local;
}
return world;
}
bool ChannelEmpty(const AnimChannel& ch)
{
return ch.positions.empty() && ch.rotations.empty() &&
ch.scales.empty();
}
// 槽位识别(未命中 → Count)
HumanoidSlot SlotAt(const std::vector<ModelNode>& nodes, u32 i)
{
Side side = Side::None;
bool ok = false;
const HumanoidSlot s = MatchHumanoidSlot(nodes[i].name, side, ok);
return ok ? s : HumanoidSlot::Count;
}
// ---- 轨道归类 ----
enum class Kind : u8 {
Skip, // 无对应(道具/辅助骨)
Direct, // 同名直搬(同源拆分资产,原样回放)
Joint, // 跨骨架关节烘焙(含手指/趾:统一世界增量通道)
Root, // 根运动(骨架上方承载节点 → dst 根)
};
struct SrcSlot {
Kind kind = Kind::Skip;
u32 dst = kNoNode;
// 平移烘焙元数据
glm::quat M{kIdentity}; // inv(dstParW)·A·srcParW
glm::vec3 dstRestPos{};
f32 posScale = 1.0f; // 骨长比(Root 用 unitScale)
};
// 冻结的重定向计划(构建后不可变)
struct Plan {
std::vector<SrcSlot> bySrc; // index = src node
std::vector<u32> carriers; // 承载节点(骨架上方,浅→深)
std::vector<glm::quat> srcTPose;
std::vector<glm::quat> dstTPose;
glm::quat align{kIdentity}; // src 世界 → dst 世界
f32 unitScale = 1.0f; // src→dst 整体尺度
u32 dstRoot = kNoNode;
bool needsBake = false;
};
// 骨长尺度(hips→head 世界距离;槽位不全 → 0)
f32 SkeletonSpan(const std::vector<ModelNode>& nodes,
const std::vector<glm::mat4>& g)
{
i32 hips = -1;
i32 head = -1;
for (u32 i = 0; i < nodes.size(); ++i) {
const HumanoidSlot slot = SlotAt(nodes, i);
if (slot == HumanoidSlot::Hips && hips < 0) {
hips = static_cast<i32>(i);
} else if (slot == HumanoidSlot::Head && head < 0) {
head = static_cast<i32>(i);
}
}
if (hips < 0 || head < 0) return 0.0f;
return glm::length(glm::vec3(g[static_cast<size_t>(head)][3]) -
glm::vec3(g[static_cast<size_t>(hips)][3]));
}
// 构建冻结计划:归类每个 src 轨道 + 预计算几何补偿元数据
Plan BuildPlan(const std::vector<ModelNode>& srcNodes,
const std::vector<ModelNode>& dstNodes)
{
Plan pl;
pl.bySrc.assign(srcNodes.size(), SrcSlot{});
// dst 根(node 0 即 DFS 首节点)
for (u32 i = 0; i < dstNodes.size(); ++i) {
if (dstNodes[i].parent < 0) {
pl.dstRoot = i;
break;
}
}
// 跨骨架配对(AutoMapHumanoid:同名直映 + 槽位一对一)
const auto pairs = AutoMapHumanoid(srcNodes, dstNodes);
std::vector<u8> slotBone(srcNodes.size(), 0);
for (const auto& [si, di] : pairs) {
(void)di;
if (SlotAt(srcNodes, si) != HumanoidSlot::Count) {
slotBone[si] = 1;
}
}
// 承载节点 = 已识别槽位骨的祖先里**未识别**者(骨架上方根
// 运动承载链)。判定必须基于槽位识别而非"所有映射骨祖先"
// ——后者把 hips/spine/… 全部内部骨误判进来,导致大半骨架
// 走烘焙、叶走直搬,局部化基准错位(整体崩坏根因)
std::vector<u8> isCarrier(srcNodes.size(), 0);
for (u32 si = 0; si < srcNodes.size(); ++si) {
if (!slotBone[si]) continue;
for (i32 p = srcNodes[si].parent; p >= 0;
p = srcNodes[static_cast<size_t>(p)].parent) {
const u32 pu = static_cast<u32>(p);
if (!slotBone[pu]) isCarrier[pu] = 1;
}
}
for (u32 i = 0; i < srcNodes.size(); ++i) {
if (isCarrier[i]) pl.carriers.push_back(i);
}
// owner:每个 dst 至多一条轨道(跨骨架对优先占用)
std::vector<u32> owner(dstNodes.size(), kNoNode);
// ① 跨骨架对分类(含手指/趾,统一 → Joint 世界增量烘焙)
std::vector<std::pair<u32, u32>> directPairs; // (si,di) 同名
for (const auto& [si, di] : pairs) {
if (isCarrier[si]) continue; // 承载节点另归
if (srcNodes[si].name == dstNodes[di].name) {
directPairs.emplace_back(si, di); // 待 ② 定夺
continue;
}
if (owner[di] != kNoNode) continue; // 已被占
SrcSlot s;
s.dst = di;
// 手指/趾与主干同走世界增量烘焙——Unity/UE 语义:指骨即
// 普通骨骼,无独立通道。世界增量对骨骼局部轴向约定免疫:
// 此前的"指根局部增量"以手骨局部系为基准做共轭,而
// Mixamo/Rigify/UE 的手骨轴向约定互异(沿 +X vs 沿 +Y),
// 共轭后弯曲轴被拧转 → X Bot 指弯向手背、kwy 指呈爪状
//(用户实测)。世界增量只用两侧世界旋转 + 行走基对齐 A
s.kind = Kind::Joint;
owner[di] = si;
pl.bySrc[si] = s;
}
// ② 同名对 → 直搬(仅当目标未被跨骨架对占用;承载节点已排除)
for (const auto& [si, di] : directPairs) {
if (owner[di] != kNoNode) continue;
SrcSlot s;
s.kind = Kind::Direct;
s.dst = di;
pl.bySrc[si] = s;
owner[di] = si;
}
// ③ 承载节点 → 根运动(恒不直搬)。仅当 dst 根未被映射骨占用
// 时才需要独立通道:dst 根若本身是映射骨(Mixamo 的
// mixamorig:Hips 即根),其绝对世界增量已含承载链运动,
// 独立通道反而与之重复
if (pl.dstRoot != kNoNode && owner[pl.dstRoot] == kNoNode) {
for (const u32 c : pl.carriers) {
SrcSlot s;
s.kind = Kind::Root;
s.dst = pl.dstRoot;
pl.bySrc[c] = s;
}
}
// 是否需要几何烘焙(纯同名资产走快速直搬路径,零开销)
for (const auto& s : pl.bySrc) {
if (s.kind == Kind::Joint || s.kind == Kind::Root) {
pl.needsBake = true;
break;
}
}
if (!pl.needsBake) return pl;
// ---- 几何补偿元数据(A / T-pose / 平移尺度)----
const auto srcG = RestGlobals(srcNodes);
const auto dstG = RestGlobals(dstNodes);
pl.align = EstimateHumanoidRootAlignment(srcNodes, dstNodes);
pl.srcTPose = EstimateHumanoidTPose(srcNodes, srcG);
pl.dstTPose = EstimateHumanoidTPose(dstNodes, dstG);
const f32 srcSpan = SkeletonSpan(srcNodes, srcG);
const f32 dstSpan = SkeletonSpan(dstNodes, dstG);
pl.unitScale = srcSpan > 1e-6f ? dstSpan / srcSpan : 1.0f;
for (u32 si = 0; si < srcNodes.size(); ++si) {
SrcSlot& s = pl.bySrc[si];
if (s.kind == Kind::Skip) continue;
const u32 di = s.dst;
// 平移元数据
const i32 sp = srcNodes[si].parent;
const i32 dp = dstNodes[di].parent;
const glm::quat srcParW =
sp >= 0 ? RotOf(srcG[static_cast<size_t>(sp)]) : kIdentity;
const glm::quat dstParW =
dp >= 0 ? RotOf(dstG[static_cast<size_t>(dp)]) : kIdentity;
s.M = glm::inverse(dstParW) * pl.align * srcParW;
s.dstRestPos = Decompose(dstNodes[di].localTransform).pos;
if (s.kind == Kind::Root) {
s.posScale = pl.unitScale;
} else {
const f32 srcLen =
glm::length(Decompose(srcNodes[si].localTransform).pos);
const f32 dstLen =
glm::length(s.dstRestPos);
s.posScale = srcLen > 1e-6f ? dstLen / srcLen
: pl.unitScale;
}
}
return pl;
}
// 唯一 clip 名(同名冲突逐轮加源文件名前缀)
std::string UniqueName(const std::string& base,
const std::string& stem,
const std::vector<AnimationClip>& existing,
const std::vector<AnimationClip>& pending)
{
const auto collides = [&](const std::string& n) {
for (const auto& c : existing) {
if (c.name == n) return true;
}
for (const auto& c : pending) {
if (c.name == n) return true;
}
return false;
};
std::string name = base;
while (collides(name)) name = stem + "_" + name;
return name;
}
} // namespace } // namespace
AttachResult AttachClipsToModel(Model& target, ParsedModel&& src, AttachResult AttachClipsToNodes(
const std::string& sourcePath) const std::vector<ModelNode>& dstNodes,
const std::vector<AnimationClip>& existingClips,
ParsedModel&& src, const std::string& sourcePath,
std::vector<AnimationClip>& outExtra)
{ {
AttachResult res; AttachResult res;
// 幂等:该源文件已并入过(自愈重放/重复 attach 直接返回) // 幂等:该源文件已并入过(自愈重放/重复 attach 直接返回)
for (const auto& c : target.GetClips()) { for (const auto& c : existingClips) {
if (c.sourcePath == sourcePath) { if (c.sourcePath == sourcePath) {
return res; return res;
} }
@@ -73,150 +423,215 @@ namespace Engine::Animation {
return res; return res;
} }
const std::vector<ModelNode>& dstNodes = target.GetNodes();
const std::vector<ModelNode>& srcNodes = src.nodes; const std::vector<ModelNode>& srcNodes = src.nodes;
const Plan pl = BuildPlan(srcNodes, dstNodes);
// ① 同名直映表
std::unordered_map<std::string, u32> nameToIdx;
nameToIdx.reserve(dstNodes.size());
for (u32 i = 0; i < dstNodes.size(); ++i) {
nameToIdx.emplace(dstNodes[i].name, i);
}
// ② Humanoid 槽位映射表(src nodeIdx → dst nodeIdx)
std::unordered_map<u32, u32> retargetMap;
std::unordered_map<u32, HumanoidSlot> retargetSlot;
{
const auto pairs = AutoMapHumanoid(srcNodes, dstNodes);
for (const auto& [si, di] : pairs) {
retargetMap.emplace(si, di);
// 槽位回查(明细行显示用):源骨骼名识别即可
Side side;
bool ok = false;
retargetSlot.emplace(si, MatchHumanoidSlot(
srcNodes[si].name, side, ok));
}
}
// 重定向骨架的 rest(逐 nodeIdx 缓存,避免每 key 重分解)
std::unordered_map<u32, TRS> dstRestCache;
dstRestCache.reserve(retargetMap.size());
for (const auto& [si, di] : retargetMap) {
dstRestCache.emplace(si, Decompose(dstNodes[di].localTransform));
}
const std::string stem = const std::string stem =
std::filesystem::path(sourcePath).stem().string(); std::filesystem::path(sourcePath).stem().string();
std::vector<AnimationClip> extra;
extra.reserve(src.clips.size());
for (auto& clip : src.clips) { for (auto& clip : src.clips) {
AnimationClip out; AnimationClip out;
out.name = clip.name; out.name = UniqueName(clip.name, stem, existingClips, outExtra);
out.duration = clip.duration; out.duration = clip.duration;
out.tps = clip.tps; out.tps = clip.tps;
out.sourcePath = sourcePath; out.sourcePath = sourcePath;
// 同名冲突(内嵌 clip 或其它源):源文件名循环加前缀直到
// 唯一。单轮前缀在"前缀名也撞"(如两次 attach 同名资产)时 // 每 clip 的 src 轨道索引表(按 nodeIdx)
// 仍会产出重复名——Controller/脚本按 clip 名引用,重名即歧义 std::vector<const AnimChannel*> srcByNode(srcNodes.size(),
{ nullptr);
const auto collides = [&](const std::string& n) { for (const auto& ch : clip.channels) {
for (const auto& c : target.GetClips()) { if (ch.nodeIdx < srcByNode.size() && !ChannelEmpty(ch)) {
if (c.name == n) return true; srcByNode[ch.nodeIdx] = &ch;
}
for (const auto& c : extra) {
if (c.name == n) return true;
}
return false;
};
while (collides(out.name)) {
out.name = stem + "_" + out.name;
} }
} }
for (auto& ch : clip.channels) {
if (ch.positions.empty() && ch.rotations.empty() && // ---- 本 clip 的根运动源:承载节点里最深且有轨道者 ----
ch.scales.empty()) { // (更深者世界变换已含其全部祖先运动,取一个即全包括)
continue; // 空轨道无用 u32 rootSrc = kNoNode;
for (const u32 c : pl.carriers) { // carriers 浅→深
if (srcByNode[c] != nullptr) rootSrc = c;
}
// ---- 目标节点 → 源轨道(可控节点,供烘焙局部化)----
std::unordered_map<u32, u32> jointBakeDst; // dst → src(Joint/Root)
std::unordered_map<u32, u32> directDst; // dst → src(Direct)
for (u32 si = 0; si < srcNodes.size(); ++si) {
const SrcSlot& s = pl.bySrc[si];
if (srcByNode[si] == nullptr) continue;
switch (s.kind) {
case Kind::Direct:
directDst.emplace(s.dst, si);
break;
case Kind::Joint:
jointBakeDst.emplace(s.dst, si);
break;
case Kind::Root:
if (si == rootSrc) jointBakeDst.emplace(s.dst, si);
break;
default:
break;
} }
}
// ---- 旋转键:两遍式烘焙 ----
// 采样时刻 = 全部需烘焙通道(Joint/Root)的旋转键时刻并集;
// 逐 t 跑源全骨架 FK → 逐 dst 递推世界旋转 → 局部化写键
std::unordered_map<u32, std::vector<RotKey>> baked;
{
std::vector<f32> times;
for (u32 si = 0; si < srcNodes.size(); ++si) {
const SrcSlot& s = pl.bySrc[si];
const AnimChannel* ch = srcByNode[si];
if (ch == nullptr) continue;
const bool bakeThis =
s.kind == Kind::Joint ||
(s.kind == Kind::Root && si == rootSrc);
if (!bakeThis || ch->rotations.empty()) continue;
for (const auto& k : ch->rotations) times.push_back(k.time);
}
std::sort(times.begin(), times.end());
times.erase(std::unique(times.begin(), times.end()),
times.end());
if (!times.empty()) {
std::vector<glm::quat> Rw(dstNodes.size(), kIdentity);
for (const f32 t : times) {
const auto Wsrc = SrcWorldAt(srcNodes, srcByNode, t);
for (u32 i = 0; i < dstNodes.size(); ++i) {
const i32 pi = dstNodes[i].parent;
const glm::quat Rpar =
pi >= 0 ? Rw[static_cast<size_t>(pi)]
: kIdentity;
glm::quat RwI;
const auto jb = jointBakeDst.find(i);
if (jb != jointBakeDst.end()) {
// 关节/根/手指/趾:世界增量搬运(对两侧
// 骨骼的局部轴向约定免疫)
const u32 si = jb->second;
const glm::quat dW =
pl.align *
(RotOf(Wsrc[si]) *
glm::inverse(pl.srcTPose
[static_cast<size_t>(si)])) *
glm::inverse(pl.align);
RwI = dW * pl.dstTPose[static_cast<size_t>(i)];
} else if (auto db = directDst.find(i);
db != directDst.end()) {
// 直搬骨:anim 后世界 = 父 × 源局部(供其子
// 关节局部化用;自身轨道另走原样直搬)
const u32 si = db->second;
const glm::quat loc = SampleQ(
srcByNode[si]->rotations, t,
Decompose(srcNodes[si].localTransform).rot);
RwI = Rpar * loc;
} else {
RwI = Rpar * Decompose(
dstNodes[i].localTransform).rot;
}
Rw[static_cast<size_t>(i)] = RwI;
if (jb != jointBakeDst.end()) {
baked[i].push_back(
{t, glm::inverse(Rpar) * RwI});
}
}
}
}
}
// ---- 组装轨道(每 dst 至多一条;owner 在 Plan 阶段已保证)----
// 非 const 引用:同名直搬路径整段 move 源轨道(此后 src 不再
// 被读——烘焙已在上面完成)
for (auto& ch : clip.channels) {
if (ChannelEmpty(ch)) continue;
if (ch.nodeIdx >= srcNodes.size()) { if (ch.nodeIdx >= srcNodes.size()) {
++res.channelsSkipped; ++res.channelsSkipped;
continue; continue;
} }
const SrcSlot& s = pl.bySrc[ch.nodeIdx];
const std::string& boneName = srcNodes[ch.nodeIdx].name; const std::string& boneName = srcNodes[ch.nodeIdx].name;
AnimChannel remapped; if (s.kind == Kind::Skip) {
if (auto it = nameToIdx.find(boneName); ++res.channelsSkipped;
it != nameToIdx.end()) { continue;
// ① 同名直搬 }
remapped.nodeIdx = it->second; if (s.kind == Kind::Root && ch.nodeIdx != rootSrc) {
remapped.positions = std::move(ch.positions); continue; // 更深的承载节点已承载根运动,其余并入
remapped.rotations = std::move(ch.rotations); }
remapped.scales = std::move(ch.scales);
out.channels.push_back(std::move(remapped)); AnimChannel outCh;
outCh.nodeIdx = s.dst;
if (s.kind == Kind::Direct) {
// 同名直搬:原样(同资产体系局部空间同源)
outCh.positions = std::move(ch.positions);
outCh.rotations = std::move(ch.rotations);
outCh.scales = std::move(ch.scales);
out.channels.push_back(std::move(outCh));
++res.channelsMatched; ++res.channelsMatched;
continue; } else {
} // 平移:局部增量 × 尺度
const TRS srcRest =
Decompose(srcNodes[ch.nodeIdx].localTransform);
outCh.positions.reserve(ch.positions.size());
for (const auto& k : ch.positions) {
outCh.positions.push_back(
{k.time, s.dstRestPos +
(s.M * (k.value - srcRest.pos)) *
s.posScale});
}
// 旋转:烘焙结果(多源汇一 dst 时共享同份)
if (auto bk = baked.find(s.dst); bk != baked.end()) {
outCh.rotations = bk->second;
}
// 缩放不搬运:跨骨架源的缩放轨道常承载单位换算
// (kwy 根节点的 cm 补偿等),原样拷到目标会整体放大
// 骨骼;目标保持自身 rest 缩放(人体动画一般无缩放)
out.channels.push_back(std::move(outCh));
++res.channelsRetargeted;
// ② 槽位重定向:增量烘焙到目标骨架局部空间。 const bool newRow = std::none_of(
// 平移增量按骨长比缩放(|dstRest| / |srcRest|)—— res.retargetRows.begin(), res.retargetRows.end(),
// 两资产单位/比例不同源(FBX 常为 cm、glTF 为 m)时 [&](const RetargetRow& r)
// 不缩放会把模型拽飞;rest≈0 的根骨骼比例取 1 { return r.srcName == boneName; });
const auto mapIt = retargetMap.find(ch.nodeIdx); if (newRow) {
if (mapIt == retargetMap.end()) { const HumanoidSlot slot = SlotAt(srcNodes, ch.nodeIdx);
++res.channelsSkipped; // 两级都落不上(手指/道具等) res.retargetRows.push_back(
continue; {s.kind == Kind::Root
} ? "RootMotion"
const TRS srcRest = Decompose( : HumanoidSlotName(slot),
srcNodes[ch.nodeIdx].localTransform); boneName, dstNodes[s.dst].name});
const TRS& dstRest = dstRestCache[ch.nodeIdx]; }
remapped.nodeIdx = mapIt->second;
const f32 srcLen = glm::length(srcRest.pos);
const f32 ratio =
(srcLen > 1e-6f) ? (glm::length(dstRest.pos) / srcLen)
: 1.0f;
remapped.positions.reserve(ch.positions.size());
for (const auto& k : ch.positions) {
remapped.positions.push_back(
{k.time, dstRest.pos +
(k.value - srcRest.pos) * ratio});
}
remapped.rotations.reserve(ch.rotations.size());
for (const auto& k : ch.rotations) {
const glm::quat delta =
k.value * glm::inverse(srcRest.rot);
remapped.rotations.push_back(
{k.time, delta * dstRest.rot});
}
remapped.scales = std::move(ch.scales);
out.channels.push_back(std::move(remapped));
++res.channelsRetargeted;
// 明细行(同骨骼多 clip 去重)
const bool newRow = std::none_of(
res.retargetRows.begin(), res.retargetRows.end(),
[&](const RetargetRow& r)
{ return r.srcName == boneName; });
if (newRow) {
res.retargetRows.push_back(
{HumanoidSlotName(retargetSlot[ch.nodeIdx]),
boneName, dstNodes[mapIt->second].name});
} }
} }
// 全部轨道落不上 = 无可驱动骨骼,clip 不入表
if (out.channels.empty()) { if (out.channels.empty()) {
ENGINE_WARN("AttachClipsToModel: clip '{}' has no matching " ENGINE_WARN("AttachClipsToModel: clip '{}' has no matching "
"bones in target (skipped)", "bones in target (skipped)",
clip.name); clip.name);
continue; continue;
} }
extra.push_back(std::move(out)); outExtra.push_back(std::move(out));
++res.clipsAdded; ++res.clipsAdded;
} }
if (!outExtra.empty()) {
ENGINE_INFO("AttachClipsToModel: '{}' -> {} clips "
"({} named, {} retargeted, {} skipped)",
sourcePath, res.clipsAdded, res.channelsMatched,
res.channelsRetargeted, res.channelsSkipped);
}
return res;
}
AttachResult AttachClipsToModel(Model& target, ParsedModel&& src,
const std::string& sourcePath)
{
std::vector<AnimationClip> extra;
AttachResult res = AttachClipsToNodes(
target.GetNodes(), target.GetClips(), std::move(src),
sourcePath, extra);
if (!extra.empty()) { if (!extra.empty()) {
target.AppendClips(std::move(extra)); target.AppendClips(std::move(extra));
} }
ENGINE_INFO("AttachClipsToModel: '{}' -> {} clips "
"({} named, {} retargeted, {} skipped)",
sourcePath, res.clipsAdded, res.channelsMatched,
res.channelsRetargeted, res.channelsSkipped);
return res; return res;
} }
+46 -9
View File
@@ -44,25 +44,62 @@ namespace Engine::Animation {
} }
} }
if (r <= 1e-4f || ch.empty()) return; if (r <= 1e-4f || ch.empty()) return;
// 覆盖 v 角度的相邻扇区(角度归一 [0,2π),wrap 补全;方向子 // 覆盖 v 角度的相邻扇区(角度归一 [0,2π);方向子 ≤16 个,
// ≤16 个,栈数组零堆分配) // 栈数组零堆分配)
constexpr f32 kTwoPi = 6.2831853f; constexpr f32 kTwoPi = 6.2831853f;
const f32 theta = glm::mod(glm::atan(z, x), kTwoPi); constexpr u32 kMaxDir = 16;
u32 idx[16]; u32 idx[kMaxDir];
f32 ang[16]; f32 ang[kMaxDir];
u32 nd = 0; u32 nd = 0;
for (u32 i = 0; i < ch.size() && nd < 16; ++i) { for (u32 i = 0; i < ch.size() && nd < kMaxDir; ++i) {
if (glm::length(ch[i].pos) < 1e-4f) continue; if (glm::length(ch[i].pos) < 1e-4f) continue;
idx[nd] = i; idx[nd] = i;
ang[nd] = glm::mod(glm::atan(ch[i].pos.y, ch[i].pos.x), kTwoPi); ang[nd] = glm::mod(glm::atan(ch[i].pos.y, ch[i].pos.x), kTwoPi);
++nd; ++nd;
} }
if (nd == 0) return;
// 环序自排序(插入排序,nd≤16 就地零堆分配):扇区数学假设
// "数组相邻 = 角度环相邻",输入顺序不可信——编辑器实时改树
// 不重跑 Load 期排序,乱序时右侧拖拽会配到错误的扇区邻居
for (u32 i = 1; i < nd; ++i) {
const u32 ki = idx[i];
const f32 ai = ang[i];
u32 j = i;
while (j > 0 && ang[j - 1] > ai) {
idx[j] = idx[j - 1];
ang[j] = ang[j - 1];
--j;
}
idx[j] = ki;
ang[j] = ai;
}
const f32 theta = glm::mod(glm::atan(z, x), kTwoPi);
if (nd == 1) {
// 单方向子:无扇区可插值,全权重归它(否则角度不等于
// 该方向时落空退化成纯 idle)
out.push_back({idx[0], r});
return;
}
for (u32 i = 0; i < nd; ++i) { for (u32 i = 0; i < nd; ++i) {
const u32 j = (i + 1) % nd; const u32 j = (i + 1) % nd;
const f32 ai = ang[i]; const f32 ai = ang[i];
const f32 aj = (j == 0) ? ang[0] + kTwoPi : ang[j]; if (j == 0) {
if (theta >= ai && theta <= aj) { // wrap 扇区 [ai,2π)∪[0,ang0]:用 || 连接,&& 会让
const f32 span = aj - ai; // theta ∈ [0,ang0) 落空(首方向不在 0° 时拖右侧无匹配)
if (theta >= ai || theta <= ang[0]) {
const f32 span = ang[0] + kTwoPi - ai;
const f32 tt =
theta >= ai ? theta - ai : theta + kTwoPi - ai;
const f32 t = span > 1e-6f ? tt / span : 0.0f;
const f32 wi = (1.0f - t) * r;
const f32 wj = t * r;
if (wi >= kMinBlendWeight) out.push_back({idx[i], wi});
if (wj >= kMinBlendWeight)
out.push_back({idx[0], wj});
break;
}
} else if (theta >= ai && theta <= ang[j]) {
const f32 span = ang[j] - ai;
const f32 t = span > 1e-6f ? (theta - ai) / span : 0.0f; const f32 t = span > 1e-6f ? (theta - ai) / span : 0.0f;
const f32 wi = (1.0f - t) * r; const f32 wi = (1.0f - t) * r;
const f32 wj = t * r; const f32 wj = t * r;
+23 -3
View File
@@ -128,7 +128,8 @@ namespace Engine::Animation {
void AnimationEvaluator::Evaluate(const Model& model, void AnimationEvaluator::Evaluate(const Model& model,
const AnimationClip& cur, f32 curTime, const AnimationClip& cur, f32 curTime,
const CrossfadeState& xf, const CrossfadeState& xf,
std::vector<glm::mat4>& outGlobals) std::vector<glm::mat4>& outGlobals,
bool inPlace)
{ {
// crossfade 双层 = N 层混合的特例(Direct 模式与 Controller // crossfade 双层 = N 层混合的特例(Direct 模式与 Controller
// 过渡共用同一套加权数学)。两权重和恒为 1,渐进归一化后精确 // 过渡共用同一套加权数学)。两权重和恒为 1,渐进归一化后精确
@@ -141,13 +142,14 @@ namespace Engine::Animation {
layers[1] = {xf.src, xf.srcTime, 1.0f - xf.wCur}; layers[1] = {xf.src, xf.srcTime, 1.0f - xf.wCur};
count = 2; count = 2;
} }
EvaluateLayers(model, layers, count, outGlobals); EvaluateLayers(model, layers, count, outGlobals, inPlace);
} }
void AnimationEvaluator::EvaluateLayers(const Model& model, void AnimationEvaluator::EvaluateLayers(const Model& model,
const PoseLayer* layers, const PoseLayer* layers,
u32 count, u32 count,
std::vector<glm::mat4>& outGlobals) std::vector<glm::mat4>& outGlobals,
bool inPlace)
{ {
// ---- 逐层采样(容量跨帧复用;nullptr/零权重层跳过)---- // ---- 逐层采样(容量跨帧复用;nullptr/零权重层跳过)----
if (m_Layers.size() < count) { if (m_Layers.size() < count) {
@@ -208,6 +210,24 @@ namespace Engine::Animation {
acc += m_Sampled[l].weight; acc += m_Sampled[l].weight;
} }
} }
// ---- 根运动裁剪(inPlace,Unity "Apply Root Motion" 关闭同
// 语义):动画子树根节点(touched 且父未被触碰)位移回
// 静止值,旋转/缩放保留——跑动 clip 不再漂出实体锚点,
// 位移归脚本/物理驱动。混合后统一裁剪,crossfade 双层
// 与 BlendTree N 层一个入口全覆盖
if (inPlace) {
const auto& nodes = model.GetNodes();
for (size_t k = 0; k < nodes.size(); ++k) {
if (!m_A.touched[k]) continue;
const i32 parent = nodes[k].parent;
if (parent < 0 ||
!m_A.touched[static_cast<size_t>(parent)]) {
// FBX PreRotation 折在 localTransform 里,静止位移
// 直接取平移列(不拆 TRS 重建,避免丢 PreRotation)
m_A.pos[k] = glm::vec3(nodes[k].localTransform[3]);
}
}
}
ComposeGlobals(model, m_A, outGlobals); ComposeGlobals(model, m_A, outGlobals);
} }
+4 -2
View File
@@ -160,7 +160,8 @@ namespace Engine::Animation {
} }
// ---- 姿态求值(双层混合在求值器内部)---- // ---- 姿态求值(双层混合在求值器内部)----
m_Evaluator.Evaluate(*model, cur, anim.time, xf, m_Globals); m_Evaluator.Evaluate(*model, cur, anim.time, xf, m_Globals,
!anim.applyRootMotion);
PushPalette(entity, model); PushPalette(entity, model);
}); });
@@ -493,7 +494,8 @@ namespace Engine::Animation {
m_Evaluator.EvaluateLayers(*model, m_LayerScratch.data(), m_Evaluator.EvaluateLayers(*model, m_LayerScratch.data(),
static_cast<u32>( static_cast<u32>(
m_LayerScratch.size()), m_LayerScratch.size()),
m_Globals); m_Globals,
!anim.applyRootMotion);
PushPalette(entity, model); PushPalette(entity, model);
}); });
+619 -117
View File
@@ -1,52 +1,359 @@
// //
// HumanoidRig 实现:关键词表 + 归一化 + 自动映射。 // HumanoidRig 实现:standard / dialects / mapper 三层 + 行走基估计。
//
// 分层职责(Unity Avatar 语义,见头文件架构说明):
// standard — 槽位结构化访问(侧别/镜像/指骨算术/显示名)
// dialects — 命名方言数据表(主干统一表 + 指骨三约定展开表)
// mapper — 归一化 + 查表识别 + 跨骨架配对
// 行走基/T-pose 估计 — rest 姿态几何推导,与命名无关
// //
#include <Engine/Animation/HumanoidRig.h> #include <Engine/Animation/HumanoidRig.h>
#include <glm/glm.hpp>
#include <glm/gtc/quaternion.hpp>
#include <array> #include <array>
#include <cctype> #include <cctype>
#include <string> #include <string>
#include <unordered_map> #include <unordered_map>
#include <vector>
namespace Engine::Animation { namespace Engine::Animation {
namespace { // ==================================================================
// standard:槽位结构化访问
// (概念分层:公共 API 在 Engine::Animation 作用域,与头文件声明
// 一致;真正的 C++ 内层 namespace 只用于 dialects/mapper 内部表)
// ==================================================================
// ---- 名字归一化:拆 (body, side) ---- Side SideOfSlot(HumanoidSlot slot)
// {
// 规则: const u32 s = static_cast<u32>(slot);
// 1) 去 "mixamorig:" 等前缀 if (s < 6) return Side::None; // 主干
// 2) 小写;分隔符(空格/_/.-:)一律删除 return s < 29 ? Side::Left : Side::Right;
// 3) left/right(Mixamo "LeftArm")与 左/右(MMD "左腕")统一 }
// 抽成 side,body 里剔除;.L/.R、_L/_R 后缀同上
//
// body 用**精确相等**匹配关键词(非子串)——"lowerarm"
// 不会误命中 "arm"
Side ExtractSide(std::string& body) HumanoidSlot MirrorSlot(HumanoidSlot slot)
{
const u32 s = static_cast<u32>(slot);
// 布局:主干 [0..5] 无镜像;左区 [6..28] 与右区 [29..51]
// 完全同构(4 臂 + 15 指 + 4 腿趾),偏移 23
if (s < 6 || s >= static_cast<u32>(HumanoidSlot::Count)) {
return slot;
}
return static_cast<HumanoidSlot>(s < 29 ? s + 23 : s - 23);
}
HumanoidSlot FingerSlot(Side side, FingerKind kind, u32 seg)
{
// 布局:LeftThumb1=10 起,5 指 × 3 段连续;右侧同构 +23
const u32 base =
side == Side::Left ? 10 : 10 + 23;
return static_cast<HumanoidSlot>(
base + static_cast<u32>(kind) * 3 + (seg - 1));
}
bool SlotFinger(HumanoidSlot slot, Side& side, FingerKind& kind,
u32& seg)
{
const u32 s = static_cast<u32>(slot);
const bool inL = s >= 10 && s <= 24;
const bool inR = s >= 33 && s <= 47;
if (!inL && !inR) return false;
side = inL ? Side::Left : Side::Right;
const u32 rel = s - (inL ? 10u : 33u);
kind = static_cast<FingerKind>(rel / 3);
seg = rel % 3 + 1;
return true;
}
const char* HumanoidSlotName(HumanoidSlot slot)
{
static constexpr std::array<const char*, 52> kNames{
"Hips", "Spine", "Chest", "UpperChest", "Neck", "Head",
"LeftShoulder", "LeftUpperArm", "LeftLowerArm", "LeftHand",
"LeftThumb1", "LeftThumb2", "LeftThumb3",
"LeftIndex1", "LeftIndex2", "LeftIndex3",
"LeftMiddle1", "LeftMiddle2", "LeftMiddle3",
"LeftRing1", "LeftRing2", "LeftRing3",
"LeftPinky1", "LeftPinky2", "LeftPinky3",
"LeftUpperLeg", "LeftLowerLeg", "LeftFoot", "LeftToes",
"RightShoulder", "RightUpperArm", "RightLowerArm",
"RightHand",
"RightThumb1", "RightThumb2", "RightThumb3",
"RightIndex1", "RightIndex2", "RightIndex3",
"RightMiddle1", "RightMiddle2", "RightMiddle3",
"RightRing1", "RightRing2", "RightRing3",
"RightPinky1", "RightPinky2", "RightPinky3",
"RightUpperLeg", "RightLowerLeg", "RightFoot",
"RightToes",
};
const u32 s = static_cast<u32>(slot);
return s < kNames.size() ? kNames[s] : "?";
}
// ==================================================================
// dialects:命名方言数据表(按"命名规定 / convention"分区)
//
// 识别 = 归一化 body 精确查表(Unity 的 token 规则表同思想)。
// 覆盖的命名规定:
// 通用英文 Unity / Blender / 多数 DCC 导出的英文名
// Mixamo Adobe Mixamo(导入前已剥 "mixamorig:" 前缀)
// MMD / PMX MikuMikuDance 標準ボーン(日文名 + 全角数字)
// Rigify Blender Rigify 变形骨(DEF- 前缀)
// UE Unreal Mannequin / IK Rig(段词 Proximal…)
// 3ds Max 3ds Max Biped(bip01)
// 主干(trunk)与指/趾(finger)各一张表:两表键空间不重叠,
// 查表主干优先、指骨兜底。各分区键名互不重叠,查表取首个命中,
// 分区顺序仅为归类、无功能含义。
// ==================================================================
namespace dialects {
// ---- 主干表(body = side 抽取 + 分隔符剥离后的左槽名)----
struct TrunkEntry {
std::string_view body;
HumanoidSlot slot;
};
constexpr std::array<TrunkEntry, 61> kTrunk{{
// ==== 通用英文(Unity / Blender / DCC)====
// 躯干:spine…/chest/spine1 与 upperchest/spine2 同义梯队
{"hips", HumanoidSlot::Hips},
{"pelvis", HumanoidSlot::Hips},
{"spine", HumanoidSlot::Spine},
{"chest", HumanoidSlot::Chest},
{"spine1", HumanoidSlot::Chest},
{"upperchest", HumanoidSlot::UpperChest},
{"spine2", HumanoidSlot::UpperChest},
{"neck", HumanoidSlot::Neck},
{"head", HumanoidSlot::Head},
// 臂:arm/upperarm/bicep 同义;lowerarm/forearm/elbow 同义
{"shoulder", HumanoidSlot::LeftShoulder},
{"clavicle", HumanoidSlot::LeftShoulder},
{"arm", HumanoidSlot::LeftUpperArm},
{"upperarm", HumanoidSlot::LeftUpperArm},
{"bicep", HumanoidSlot::LeftUpperArm},
{"lowerarm", HumanoidSlot::LeftLowerArm},
{"forearm", HumanoidSlot::LeftLowerArm},
{"elbow", HumanoidSlot::LeftLowerArm},
{"hand", HumanoidSlot::LeftHand},
// 腿:upperleg/upleg/thigh 同义;lowerleg/leg/knee/calf 同义
{"upperleg", HumanoidSlot::LeftUpperLeg},
{"upleg", HumanoidSlot::LeftUpperLeg},
{"thigh", HumanoidSlot::LeftUpperLeg},
{"lowerleg", HumanoidSlot::LeftLowerLeg},
{"leg", HumanoidSlot::LeftLowerLeg}, // Mixamo LeftLeg = 小腿
{"knee", HumanoidSlot::LeftLowerLeg},
{"calf", HumanoidSlot::LeftLowerLeg},
{"foot", HumanoidSlot::LeftFoot},
{"ankle", HumanoidSlot::LeftFoot},
{"toebase", HumanoidSlot::LeftToes},
{"toes", HumanoidSlot::LeftToes},
{"toe", HumanoidSlot::LeftToes},
// ==== MMD / PMX 標準ボーン ====
// 下半身=骨盆、上半身链=spine→chest→upperchest、腕=上臂、
// ひじ/肘=肘、手首=腕关、足=大腿、ひざ=膝、足首=踝、つま先=趾。
// センター(根运动控制骨)与下半身同为 Hips 候选,实际取
// DFS 首个命中者(下半身),センター 落为承载节点走根运动
{"下半身", HumanoidSlot::Hips},
{"センター", HumanoidSlot::Hips},
{"上半身", HumanoidSlot::Spine},
{"上半身1", HumanoidSlot::Chest},
{"上半身2", HumanoidSlot::UpperChest},
{"首", HumanoidSlot::Neck},
{"頭", HumanoidSlot::Head},
{"肩", HumanoidSlot::LeftShoulder},
{"腕", HumanoidSlot::LeftUpperArm},
{"ひじ", HumanoidSlot::LeftLowerArm},
{"肘", HumanoidSlot::LeftLowerArm},
{"手首", HumanoidSlot::LeftHand},
{"足", HumanoidSlot::LeftUpperLeg},
{"ひざ", HumanoidSlot::LeftLowerLeg},
{"足首", HumanoidSlot::LeftFoot},
{"つま先", HumanoidSlot::LeftToes},
// ==== Rigify(Blender)DEF- 变形骨 ====
// 前缀保留参与精确匹配——裸 "spine" 在 Mixamo(脊柱段)与
// Rigify(DEF-spine=骨盆变形骨)间语义冲突,独立键名避免
// 误伤既有约定。段号实测自 Lumine(.003 挂肩=胸上段、.005
// 颈、.006 头——眼/牙/发均挂其下)
{"defspine", HumanoidSlot::Hips},
{"defspine001", HumanoidSlot::Spine},
{"defspine002", HumanoidSlot::Chest},
{"defspine003", HumanoidSlot::UpperChest},
{"defspine005", HumanoidSlot::Neck},
{"defspine006", HumanoidSlot::Head},
{"defshoulder", HumanoidSlot::LeftShoulder},
{"defupperarm", HumanoidSlot::LeftUpperArm},
{"defforearm", HumanoidSlot::LeftLowerArm},
{"defhand", HumanoidSlot::LeftHand},
{"defthigh", HumanoidSlot::LeftUpperLeg},
{"defshin", HumanoidSlot::LeftLowerLeg},
{"deffoot", HumanoidSlot::LeftFoot},
{"deftoe", HumanoidSlot::LeftToes},
// ==== 3ds Max Biped ====
{"bip01", HumanoidSlot::Hips},
}};
// ---- 指骨展开表(按命名规定分区)----
// 四种段号约定(实测来源):
// Mixamo LeftHandThumb1..3 → hand + 指名 + 裸数字
// Rigify DEF-thumb.01..03 → (def)? + 指名 + 零填充两位;
// 四指带 f_ 前缀(findex...),
// 拇指没有;master 裸名同构
// UE/kwy Thumb Proximal_L 等 → 指名 + 段词
// (Proximal/Intermediate/Distal = 近/中/末节,
// 小指叫 little 非 pinky);趾为复数 Toes
// MMD/PMX 親指0..2 / 人指1..3 / 中指1..3 / 薬指1..3 / 小指1..3
// → 指名 + 数字(拇指 0 起、其余 1 起;全角数字已由
// mapper::FoldFullWidthDigits 折半)
// 表键为左侧 body(side 由归一化抽取),右槽经 MirrorSlot。
// 各约定的「…先 / 段 4」指尖均不展开——标准拓扑无对应槽位,
// 源轨道识别失败由 attach 侧 skip(与旧行为一致)。
struct BodySlot {
std::string body;
HumanoidSlot slot;
};
const std::vector<BodySlot>& FingerPatterns()
{ {
Side s = Side::None; static const std::vector<BodySlot> kTable = [] {
auto cut = [&](std::string_view token, Side which) std::vector<BodySlot> t;
{ t.reserve(80);
size_t pos = 0;
while ((pos = body.find(token, pos)) != std::string::npos) { // Mixamo:handthumb1 ... handpinky3
body.erase(pos, token.size()); constexpr std::array<std::string_view, 5> kMixamo{
if (s == Side::None) s = which; "thumb", "index", "middle", "ring", "pinky"};
for (const auto kindU : {0u, 1u, 2u, 3u, 4u}) {
const auto slot = static_cast<u32>(
FingerSlot(Side::Left,
static_cast<FingerKind>(kindU),
1));
for (u32 seg = 1; seg <= 3; ++seg) {
t.push_back({std::string("hand") +
std::string(kMixamo[kindU]) +
std::to_string(seg),
static_cast<HumanoidSlot>(
slot + seg - 1)});
}
} }
};
cut("left", Side::Left); // Rigify:defthumb01 ... deffpinky03(+ 无前缀 master 变体)
cut("right", Side::Right); constexpr std::array<std::string_view, 5> kRigify{
cut("左", Side::Left); // MMD 前缀 "thumb", "findex", "fmiddle", "fring", "fpinky"};
cut("右", Side::Right); for (const auto kindU : {0u, 1u, 2u, 3u, 4u}) {
cut(".l", Side::Left); // blender 惯例后缀 const auto slot = static_cast<u32>(
cut(".r", Side::Right); FingerSlot(Side::Left,
cut("_l", Side::Left); static_cast<FingerKind>(kindU),
cut("_r", Side::Right); 1));
return s; for (u32 seg = 1; seg <= 3; ++seg) {
const std::string z =
std::to_string(100 + seg).substr(1); // 01..03
const std::string core =
std::string(kRigify[kindU]) + z;
t.push_back({"def" + core,
static_cast<HumanoidSlot>(
slot + seg - 1)});
t.push_back({core,
static_cast<HumanoidSlot>(
slot + seg - 1)});
}
}
// UE:thumbproximal ... littledistal
constexpr std::array<std::string_view, 5> kUe{
"thumb", "index", "middle", "ring", "little"};
constexpr std::array<std::string_view, 3> kUeSeg{
"proximal", "intermediate", "distal"};
for (const auto kindU : {0u, 1u, 2u, 3u, 4u}) {
const auto slot = static_cast<u32>(
FingerSlot(Side::Left,
static_cast<FingerKind>(kindU),
1));
for (u32 seg = 1; seg <= 3; ++seg) {
t.push_back({std::string(kUe[kindU]) +
std::string(kUeSeg[seg - 1]),
static_cast<HumanoidSlot>(
slot + seg - 1)});
}
}
// MMD/PMX:親指0..2 / 人指1..3 / 中指1..3 / 薬指1..3 /
// 小指1..3(拇指 0 起、其余 1 起;数字已折半角)
{
constexpr std::array<std::string_view, 5> kMmd{
"親指", "人指", "中指", "薬指", "小指"};
for (u32 kindU = 0; kindU < 5; ++kindU) {
const u32 numBase = kindU == 0 ? 0u : 1u;
for (u32 j = 0; j < 3; ++j) {
const auto slot = static_cast<u32>(
FingerSlot(Side::Left,
static_cast<FingerKind>(kindU),
j + 1));
t.push_back({std::string(kMmd[kindU]) +
std::to_string(numBase + j),
static_cast<HumanoidSlot>(slot)});
}
}
}
return t;
}();
return kTable;
} }
// ① 前缀剥离 + 小写(分隔符**保留**——后缀式 side 标记 // 查表:主干优先,指骨兜底(两表键空间无交集)
bool Lookup(const std::string& body, HumanoidSlot& out)
{
for (const auto& e : kTrunk) {
if (body == e.body) {
out = e.slot;
return true;
}
}
for (const auto& e : FingerPatterns()) {
if (body == e.body) {
out = e.slot;
return true;
}
}
return false;
}
} // namespace dialects
// ==================================================================
// mapper:归一化 + 识别 + 配对
// ==================================================================
namespace mapper {
// ⓪ 全角数字折半角:U+FF10..U+FF19 → '0'..'9'(UTF-8 EF BC 90..99)。
// MMD 規範骨常写全角数字(親指0/中指1…),折半后各命名规定可在
// 表里统一用半角书写。仅折数字,全角字母(如「つま先IK」)不动
// ——避免 IK 骨误命中「つま先」
void FoldFullWidthDigits(std::string& s)
{
std::string out;
out.reserve(s.size());
for (size_t i = 0; i < s.size();) {
if (i + 3 <= s.size() &&
static_cast<unsigned char>(s[i]) == 0xEF &&
static_cast<unsigned char>(s[i + 1]) == 0xBC) {
const auto b2 = static_cast<unsigned char>(s[i + 2]);
if (b2 >= 0x90 && b2 <= 0x99) {
out.push_back(
static_cast<char>('0' + (b2 - 0x90)));
i += 3;
continue;
}
}
out.push_back(s[i++]);
}
s.swap(out);
}
// ① 前缀剥离 + 小写(分隔符**保留**——末尾式 side 标记
// ".l"/"_l" 依赖分隔符存在,必须先于分隔符删除识别) // ".l"/"_l" 依赖分隔符存在,必须先于分隔符删除识别)
std::string NormalizeLower(std::string_view name) std::string NormalizeLower(std::string_view name)
{ {
@@ -63,10 +370,11 @@ namespace Engine::Animation {
c = static_cast<char>(std::tolower( c = static_cast<char>(std::tolower(
static_cast<unsigned char>(c))); static_cast<unsigned char>(c)));
} }
FoldFullWidthDigits(s);
return s; return s;
} }
// ② 分隔符删除(在 ExtractSide 之后调用;CJK 多字节原样过) // ② 分隔符删除(side 抽取之后调用;CJK 多字节原样过)
std::string StripSeparators(const std::string& s) std::string StripSeparators(const std::string& s)
{ {
std::string out; std::string out;
@@ -80,105 +388,99 @@ namespace Engine::Animation {
return out; return out;
} }
// ---- body → 左侧槽位 关键词表(右侧 = 左槽 + 段偏移)---- // 窄 side 抽取:side 只出现在 Mixamo 前缀(LeftHand...)、
// 精确相等匹配;顺序即优先级(先命中先用) // MMD 前缀(左手首)与 Blender 末尾后缀(..._L/.L)。
struct BodyRule { // 不用任意位置 cut——Rigify "f_ring" 中间的 "_r" 会被误当
std::string_view body; // 右侧后缀删掉(body 变 "fing" → Ring 指骨全军覆没的历史
HumanoidSlot left; // 陷阱),末尾匹配天然规避
}; Side ExtractSideNarrow(std::string& body)
constexpr std::array<BodyRule, 43> kBodyRules{{
// 主干(MMD 语义:下半身=hips、上半身链=spine→chest)
{"hips", HumanoidSlot::Hips},
{"pelvis", HumanoidSlot::Hips},
{"bip01", HumanoidSlot::Hips},
{"下半身", HumanoidSlot::Hips},
{"センター", HumanoidSlot::Hips},
{"spine", HumanoidSlot::Spine},
{"上半身", HumanoidSlot::Spine},
{"chest", HumanoidSlot::Chest},
{"spine1", HumanoidSlot::Chest},
{"上半身1", HumanoidSlot::Chest},
{"upperchest", HumanoidSlot::UpperChest},
{"spine2", HumanoidSlot::UpperChest},
{"上半身2", HumanoidSlot::UpperChest},
{"neck", HumanoidSlot::Neck},
{"首", HumanoidSlot::Neck},
{"head", HumanoidSlot::Head},
{"頭", HumanoidSlot::Head},
// 臂段(MMD:腕=upper arm、ひじ/肘=elbow、手首=wrist)
{"shoulder", HumanoidSlot::LeftShoulder},
{"clavicle", HumanoidSlot::LeftShoulder},
{"肩", HumanoidSlot::LeftShoulder},
{"arm", HumanoidSlot::LeftUpperArm},
{"upperarm", HumanoidSlot::LeftUpperArm},
{"bicep", HumanoidSlot::LeftUpperArm},
{"腕", HumanoidSlot::LeftUpperArm},
{"lowerarm", HumanoidSlot::LeftLowerArm},
{"forearm", HumanoidSlot::LeftLowerArm},
{"elbow", HumanoidSlot::LeftLowerArm},
{"ひじ", HumanoidSlot::LeftLowerArm},
{"肘", HumanoidSlot::LeftLowerArm},
{"hand", HumanoidSlot::LeftHand},
{"手首", HumanoidSlot::LeftHand},
// 腿段(MMD:足=thigh、ひざ=knee、足首=ankle)
{"upperleg", HumanoidSlot::LeftUpperLeg},
{"upleg", HumanoidSlot::LeftUpperLeg},
{"thigh", HumanoidSlot::LeftUpperLeg},
{"足", HumanoidSlot::LeftUpperLeg},
{"lowerleg", HumanoidSlot::LeftLowerLeg},
{"leg", HumanoidSlot::LeftLowerLeg}, // Mixamo:LeftLeg=小腿(大腿是 UpLeg)
{"knee", HumanoidSlot::LeftLowerLeg},
{"calf", HumanoidSlot::LeftLowerLeg},
{"ひざ", HumanoidSlot::LeftLowerLeg},
{"foot", HumanoidSlot::LeftFoot},
{"ankle", HumanoidSlot::LeftFoot},
{"足首", HumanoidSlot::LeftFoot},
}};
// 左→右槽位偏移:臂/肩段(Shoulder..Hand)+4,腿段(UpperLeg..Foot)+3
HumanoidSlot ToSide(HumanoidSlot leftSlot, Side side)
{ {
if (side != Side::Right) return leftSlot; Side s = Side::None;
const u32 s = static_cast<u32>(leftSlot); if (body.rfind("left", 0) == 0) {
const u32 legStart = static_cast<u32>(HumanoidSlot::LeftUpperLeg); body.erase(0, 4);
return static_cast<HumanoidSlot>(s + (s >= legStart ? 3 : 4)); s = Side::Left;
} else if (body.rfind("right", 0) == 0) {
body.erase(0, 5);
s = Side::Right;
} else if (body.rfind("左", 0) == 0) {
body.erase(0, strlen("左")); // UTF-8 3 字节
s = Side::Left;
} else if (body.rfind("右", 0) == 0) {
body.erase(0, strlen("右"));
s = Side::Right;
}
const auto cutSuffix = [&](std::string_view token,
Side which) {
if (body.size() >= token.size() &&
body.compare(body.size() - token.size(),
token.size(), token) == 0) {
body.erase(body.size() - token.size(), token.size());
if (s == Side::None) s = which;
}
};
cutSuffix(".l", Side::Left);
cutSuffix(".r", Side::Right);
cutSuffix("_l", Side::Left);
cutSuffix("_r", Side::Right);
return s;
} }
} // namespace // 宽 side 抽取(fallback):任意位置删 token,兜底命名习惯
// 不在首尾的资产。窄规则先行,本函数只在查表失败后启用
Side ExtractSideWide(std::string& body)
{
Side s = Side::None;
auto cut = [&](std::string_view token, Side which)
{
size_t pos = 0;
while ((pos = body.find(token, pos)) != std::string::npos) {
body.erase(pos, token.size());
if (s == Side::None) s = which;
}
};
cut("left", Side::Left);
cut("right", Side::Right);
cut("左", Side::Left);
cut("右", Side::Right);
cut(".l", Side::Left);
cut(".r", Side::Right);
cut("_l", Side::Left);
cut("_r", Side::Right);
return s;
}
const char* HumanoidSlotName(HumanoidSlot slot) } // namespace mapper
{
static constexpr std::array<const char*, 20> kNames{
"Hips", "Spine", "Chest", "UpperChest", "Neck", "Head",
"LeftShoulder", "LeftUpperArm", "LeftLowerArm", "LeftHand",
"RightShoulder", "RightUpperArm", "RightLowerArm", "RightHand",
"LeftUpperLeg", "LeftLowerLeg", "LeftFoot",
"RightUpperLeg", "RightLowerLeg", "RightFoot",
};
const u32 s = static_cast<u32>(slot);
return s < kNames.size() ? kNames[s] : "?";
}
// 名字归一化 + 方言查表识别。两轮:窄 side 抽取优先(避开
// f_ring 类中间子串陷阱),查表失败再用宽抽取兜底
HumanoidSlot MatchHumanoidSlot(std::string_view name, Side& side, HumanoidSlot MatchHumanoidSlot(std::string_view name, Side& side,
bool& ok) bool& ok)
{ {
side = Side::None; side = Side::None;
ok = false; ok = false;
std::string body = NormalizeLower(name); const std::string lower = mapper::NormalizeLower(name);
side = ExtractSide(body); for (int pass = 0; pass < 2; ++pass) {
body = StripSeparators(body); std::string body = lower;
if (body.empty()) return HumanoidSlot::Hips; const Side s = pass == 0
? mapper::ExtractSideNarrow(body)
for (const BodyRule& r : kBodyRules) { : mapper::ExtractSideWide(body);
if (body == r.body) { body = mapper::StripSeparators(body);
if (body.empty()) {
return HumanoidSlot::Hips;
}
HumanoidSlot slot;
if (dialects::Lookup(body, slot)) {
side = s;
ok = true; ok = true;
return ToSide(r.left, side); // 表存左槽名:右 side 经镜像换槽(主干槽镜像原样)
return s == Side::Right ? MirrorSlot(slot) : slot;
} }
} }
return HumanoidSlot::Hips; return HumanoidSlot::Hips;
} }
// 跨骨架自动映射:同名直映 + 52 槽一对一配对(指/趾骨同为主干
// 槽位走统一通道,无独立特判)
std::vector<std::pair<u32, u32>> AutoMapHumanoid( std::vector<std::pair<u32, u32>> AutoMapHumanoid(
const std::vector<ModelNode>& srcNodes, const std::vector<ModelNode>& srcNodes,
const std::vector<ModelNode>& dstNodes) const std::vector<ModelNode>& dstNodes)
@@ -202,7 +504,7 @@ namespace Engine::Animation {
} }
} }
// 2) 槽位匹配:两侧各自识别 (slot → nodeIdx),槽位一对一配对 // 2) 槽位配对:两侧各自识别 (slot → nodeIdx),槽位一对一
constexpr u32 kSlotCount = static_cast<u32>(HumanoidSlot::Count); constexpr u32 kSlotCount = static_cast<u32>(HumanoidSlot::Count);
auto slotOf = [](const std::vector<ModelNode>& nodes, auto slotOf = [](const std::vector<ModelNode>& nodes,
std::array<i32, kSlotCount>& out) std::array<i32, kSlotCount>& out)
@@ -218,7 +520,7 @@ namespace Engine::Animation {
} }
const u32 s = static_cast<u32>(slot); const u32 s = static_cast<u32>(slot);
if (out[s] < 0) { if (out[s] < 0) {
out[s] = static_cast<i32>(i); // 关键词先命中优先 out[s] = static_cast<i32>(i); // 方言表先命中优先
} }
} }
}; };
@@ -243,4 +545,204 @@ namespace Engine::Animation {
return pairs; return pairs;
} }
// ==================================================================
// 行走基 / T-pose 估计(rest 姿态几何推导,与命名方言无关)
// ==================================================================
namespace {
// 槽位 → 节点下标(第一个命中;无则 -1)
i32 FindSlotNode(const std::vector<ModelNode>& nodes,
HumanoidSlot slot)
{
for (u32 i = 0; i < nodes.size(); ++i) {
Side side;
bool ok = false;
if (MatchHumanoidSlot(nodes[i].name, side, ok) == slot &&
ok) {
return static_cast<i32>(i);
}
}
return -1;
}
// 脚趾节点(按 Toes 槽位 + 侧别匹配;无则 -1)。走槽位而非
// 名字含 "toe"——否则 MMD「つま先」等 CJK 趾骨检测不到
i32 FindToeNode(const std::vector<ModelNode>& nodes, Side want)
{
for (u32 i = 0; i < nodes.size(); ++i) {
Side side;
bool ok = false;
const HumanoidSlot slot =
MatchHumanoidSlot(nodes[i].name, side, ok);
if (!ok || SideOfSlot(slot) != want) {
continue;
}
if (slot == HumanoidSlot::LeftToes ||
slot == HumanoidSlot::RightToes) {
return static_cast<i32>(i);
}
}
return -1;
}
// rest 姿态的行走基(right/up/fwd,右手系)。up = 头 - 双脚
// 中点;right = 臂差(armR - armL)去 up 分量;fwd = up × right。
// 脚趾朝向消歧:fwd 与脚尖反向时 right/fwd 绕 up 同翻 180°
// (镜像/腿型特异防呆,同翻保持右手系)。槽位不足 → false
bool HumanoidBasis(const std::vector<ModelNode>& nodes,
const std::vector<glm::mat4>& globals,
glm::vec3& right, glm::vec3& up, glm::vec3& fwd)
{
const i32 head = FindSlotNode(nodes, HumanoidSlot::Head);
const i32 footL = FindSlotNode(nodes, HumanoidSlot::LeftFoot);
const i32 footR = FindSlotNode(nodes, HumanoidSlot::RightFoot);
const i32 armL = FindSlotNode(nodes, HumanoidSlot::LeftUpperArm);
const i32 armR = FindSlotNode(nodes, HumanoidSlot::RightUpperArm);
if (head < 0 || footL < 0 || footR < 0 || armL < 0 || armR < 0) {
return false;
}
const auto pos = [&](i32 i) {
return glm::vec3(globals[static_cast<size_t>(i)][3]);
};
const glm::vec3 midFoot = 0.5f * (pos(footL) + pos(footR));
const glm::vec3 lat = pos(armR) - pos(armL);
up = pos(head) - midFoot;
if (glm::length(up) < 1e-6f || glm::length(lat) < 1e-6f) {
return false;
}
up = glm::normalize(up);
right = lat - up * glm::dot(lat, up); // 去除 up 分量
if (glm::length(right) < 1e-6f) {
return false;
}
right = glm::normalize(right);
// fwd = up × right(右手系:面朝 fwd 时 right 恒在体右侧)。
// 原 right × up 恒指向背后,正常资产(X Bot/Mixamo:脚尖
// +Z、右臂 −X)必触发下方脚趾消歧的 180° 翻转——翻转虽把
// fwd 纠正,却把 right 连带翻成解剖学左,行走基被镜像污染;
// 与未翻转的目标骨架(Lumine 等)对齐后,臂部增量被解释成
// 朝身体内侧——两臂交缠的根因。腿/躯干增量前后对称,镜像
// 下不显形,故此前未暴露
fwd = glm::cross(up, right);
const i32 toeL = FindToeNode(nodes, Side::Left);
const i32 toeR = FindToeNode(nodes, Side::Right);
if (toeL >= 0 && toeR >= 0) {
const glm::vec3 toeDir =
0.5f * (pos(toeL) + pos(toeR)) - midFoot;
if (glm::dot(fwd, toeDir) < 0.0f) {
right = -right;
fwd = -fwd;
}
}
return true;
}
} // namespace
glm::quat EstimateHumanoidRootAlignment(
const std::vector<ModelNode>& srcNodes,
const std::vector<ModelNode>& dstNodes)
{
const glm::quat kIdentity(1.0f, 0.0f, 0.0f, 0.0f);
// rest 世界变换(node 表父先子后,直接链乘)
auto restGlobals = [](const std::vector<ModelNode>& nodes) {
std::vector<glm::mat4> g(nodes.size());
for (size_t i = 0; i < nodes.size(); ++i) {
const i32 p = nodes[i].parent;
g[i] = (p >= 0 ? g[static_cast<size_t>(p)]
: glm::mat4(1.0f)) *
nodes[i].localTransform;
}
return g;
};
const auto srcG = restGlobals(srcNodes);
const auto dstG = restGlobals(dstNodes);
glm::vec3 sr, su, sf, dr, du, df;
if (!HumanoidBasis(srcNodes, srcG, sr, su, sf) ||
!HumanoidBasis(dstNodes, dstG, dr, du, df)) {
return kIdentity;
}
// 列 = [right | up | fwd];A = Bd × Bsᵀ(src 世界 → dst 世界)
const glm::mat3 Bs(sr, su, sf);
const glm::mat3 Bd(dr, du, df);
return glm::quat_cast(Bd * glm::transpose(Bs));
}
std::vector<glm::quat> EstimateHumanoidTPose(
const std::vector<ModelNode>& nodes,
const std::vector<glm::mat4>& restGlobals)
{
// T-pose 参考姿势(每节点世界旋转):
// 人形动画资产(Mixamo 等)的摆动量以 T-pose 为零位,目标骨架
// rest 是 A-pose 时直接搬运"旋转变化"会过摆(臂从已下垂 45°
// 再摆 T-pose 基准的全幅)——手越过中线交叉。此处为目标估计
// T-pose 参考:臂链(upper_arm 子树)绕肩刚体摆到水平侧向,
// 指骨按 seg1 方向伸直(kwy 等 rest 半握骨架与 Mixamo 等
// rest 伸直骨架的手指零位不同源,见下)。其余骨取 rest。
// rest 已是 T-pose 的资产(臂≈水平、指伸直)摆动量≈0,
// 自动退化。
auto rotOf = [](const glm::mat4& m) {
return glm::quat_cast(glm::mat3(
glm::normalize(glm::vec3(m[0])),
glm::normalize(glm::vec3(m[1])),
glm::normalize(glm::vec3(m[2]))));
};
std::vector<glm::quat> g(nodes.size(), glm::quat(1.0f, 0.0f, 0.0f,
0.0f));
for (size_t i = 0; i < nodes.size(); ++i) {
g[i] = rotOf(restGlobals[i]);
}
glm::vec3 right, up, fwd;
if (!HumanoidBasis(nodes, restGlobals, right, up, fwd)) {
return g; // 行走基不可用 → 全 rest(不补偿)
}
const auto posOf = [&](i32 i) {
return glm::vec3(restGlobals[static_cast<size_t>(i)][3]);
};
for (const Side side : {Side::Left, Side::Right}) {
const HumanoidSlot uaSlot = side == Side::Left
? HumanoidSlot::LeftUpperArm : HumanoidSlot::RightUpperArm;
const HumanoidSlot faSlot = side == Side::Left
? HumanoidSlot::LeftLowerArm : HumanoidSlot::RightLowerArm;
const i32 ua = FindSlotNode(nodes, uaSlot);
const i32 fa = FindSlotNode(nodes, faSlot);
if (ua < 0 || fa < 0) {
continue;
}
const glm::vec3 v =
glm::normalize(posOf(fa) - posOf(ua)); // 上臂 rest 指向
glm::vec3 vlat = v - up * glm::dot(v, up) -
fwd * glm::dot(v, fwd);
if (glm::length(vlat) < 1e-6f) {
continue; // 臂纯沿 up(退化)→ 不摆
}
vlat = glm::normalize(vlat);
const float d = glm::clamp(glm::dot(v, vlat), -1.0f, 1.0f);
glm::vec3 axis = glm::cross(v, vlat);
const glm::quat swing =
glm::length(axis) < 1e-6f
? glm::quat(1.0f, 0.0f, 0.0f, 0.0f)
: glm::angleAxis(glm::acos(d),
glm::normalize(axis));
// upper_arm 子树刚体摆动(保持链内相对姿态;含自身)
for (size_t j = 0; j < nodes.size(); ++j) {
for (i32 p = static_cast<i32>(j); p >= 0;
p = nodes[static_cast<size_t>(p)].parent) {
if (p == ua) {
g[j] = swing * g[j];
break;
}
}
}
}
// 手指不参与 T-pose 零位:指根扇形编码在 rest 平移里(旋转
// 搬运无法复刻扇形),绝对复刻语义会让 dst 指根方向偏转 +
// 跨手型穿插。手指走 rest 零位局部增量通道(Unity muscle
// 语义,见 AnimationAttach),各资产保留自然手型,此处取 rest。
return g;
}
} // namespace Engine::Animation } // namespace Engine::Animation
+10
View File
@@ -6,6 +6,8 @@
#include <Engine/Core/Window.h> #include <Engine/Core/Window.h>
#include <Engine/Core/Input.h> #include <Engine/Core/Input.h>
#include <Engine/Core/Log.h> #include <Engine/Core/Log.h>
#include <Engine/Core/LogRing.h>
#include <Engine/Core/Stats.h>
#include <Engine/ECS/Scene.h> #include <Engine/ECS/Scene.h>
#include <Engine/Physics/PhysicsWorld.h> #include <Engine/Physics/PhysicsWorld.h>
#include <Engine/Scripting/ScriptRuntime.h> #include <Engine/Scripting/ScriptRuntime.h>
@@ -112,6 +114,12 @@ namespace Engine {
void Application::Run() void Application::Run()
{ {
// Log Panel 开闸:到此为止之前的启动日志(窗口/Vulkan/UBO/
// IBL/网格构建)只落终端与文件——Unity Console 语义,面板只
// 收运行期日志(场景/物理/脚本),启动噪音不进面板
Engine::Debug::LogRing().SetEnabled(true);
ENGINE_DEBUG("[APP] 主循环启动(Log Panel 开启;此行为 DEBUG 级,"
"勾选 Trace 过滤可见青色 DEBUG 徽标)");
ENGINE_INFO("Application starting"); ENGINE_INFO("Application starting");
m_Running = true; m_Running = true;
@@ -133,6 +141,8 @@ namespace Engine {
while (m_Running) { while (m_Running) {
const auto frameStart = std::chrono::steady_clock::now(); const auto frameStart = std::chrono::steady_clock::now();
STATS.BeginFrame(); // 帧内资源分配清零(面板帧流失真修正)
m_Window->PollEvents(); m_Window->PollEvents();
auto now = std::chrono::steady_clock::now(); auto now = std::chrono::steady_clock::now();
+8 -1
View File
@@ -2,6 +2,7 @@
// Created by atdunbg on 2026/9/20. // Created by atdunbg on 2026/9/20.
// //
#include <Engine/Core/Log.h> #include <Engine/Core/Log.h>
#include <Engine/Core/LogRing.h>
#include "spdlog/sinks/basic_file_sink.h" #include "spdlog/sinks/basic_file_sink.h"
#include "spdlog/sinks/stdout_color_sinks.h" #include "spdlog/sinks/stdout_color_sinks.h"
@@ -26,10 +27,16 @@ namespace Engine::Logger
auto file = std::make_shared<spdlog::sinks::basic_file_sink_mt>("HelloVulkan.log", true); auto file = std::make_shared<spdlog::sinks::basic_file_sink_mt>("HelloVulkan.log", true);
file->set_pattern("[%Y-%m-%d %T.%e] [%l] [%t] %v"); file->set_pattern("[%Y-%m-%d %T.%e] [%l] [%t] %v");
// 环形缓冲(编辑器 Log Panel 数据源):时间 + 消息——级别词
// 不进文本,由面板按 msg.level 画彩色徽标列(文本里重复一遍
// 级别既冗余又抢徽标的视觉作用)
auto ring = std::make_shared<Engine::Debug::RingSink>();
ring->set_pattern("[%H:%M:%S.%e] %v");
// 多sink 组合默认 logger // 多sink 组合默认 logger
// auto logger = std::make_shared<spdlog::logger>("hello", spdlog::sinks_init_list{console, file}); // auto logger = std::make_shared<spdlog::logger>("hello", spdlog::sinks_init_list{console, file});
auto logger = std::make_shared<spdlog::logger>("APP", spdlog::sinks_init_list{console, file}); auto logger = std::make_shared<spdlog::logger>("APP", spdlog::sinks_init_list{console, file, ring});
logger->set_level(spdlog::level::trace); logger->set_level(spdlog::level::trace);
logger->flush_on(spdlog::level::warn); // warn 及以上立即 flush,避免崩溃丢日志 logger->flush_on(spdlog::level::warn); // warn 及以上立即 flush,避免崩溃丢日志
+16 -196
View File
@@ -4,10 +4,6 @@
#include <Engine/Core/Stats.h> #include <Engine/Core/Stats.h>
#include <Engine/Core/Log.h> #include <Engine/Core/Log.h>
#include <fstream>
#include <sstream>
#include <string>
namespace Engine namespace Engine
{ {
Stats& Stats::Get() Stats& Stats::Get()
@@ -125,200 +121,24 @@ namespace Engine
m_TotalDeallocation += bytes; m_TotalDeallocation += bytes;
} }
// ============ 进程内存采样 ============ // ============ 只读快照 ============
Stats::ProcessMemory Stats::ReadProcessMemory() const Stats::Snapshot Stats::TakeSnapshot() const
{
ProcessMemory result;
// ---- 1. 从 /proc/self/status 读 VmRSS 和 VmSize ----
// 这是内核报告的权威 RSS。smaps_rollup 会漏掉某些 NVIDIA 显存映射,
// 但 status 的 VmRSS 包含所有 VMA。
{
std::ifstream status("/proc/self/status");
std::string line;
while (std::getline(status, line))
{
if (line.rfind("VmRSS:", 0) == 0)
{
std::istringstream iss(line.substr(6));
u64 kb = 0;
iss >> kb;
result.Rss = kb * 1024;
}
else if (line.rfind("VmSize:", 0) == 0)
{
std::istringstream iss(line.substr(7));
u64 kb = 0;
iss >> kb;
result.VmSize = kb * 1024;
}
}
}
// ---- 2. 从 /proc/self/smaps_rollup 读 PSS 和分项 ----
// 注意:Rss 不从这里读,用上面的。
{
std::ifstream smaps("/proc/self/smaps_rollup");
std::string line;
while (std::getline(smaps, line))
{
std::istringstream iss(line);
std::string key;
u64 value = 0;
std::string unit;
iss >> key;
if (key.empty()) continue;
if (key.back() == ':') key.pop_back();
if (key == "Pss")
{
iss >> value >> unit;
result.Pss = value * 1024;
}
else if (key == "Shared_Clean")
{
iss >> value >> unit;
result.SharedClean = value * 1024;
}
else if (key == "Private_Dirty")
{
iss >> value >> unit;
result.PrivateDirty = value * 1024;
}
else if (key == "Private_Clean")
{
iss >> value >> unit;
result.PrivateClean = value * 1024;
}
}
}
return result;
}
void Stats::SampleProcessMemory()
{ {
std::lock_guard lock(m_Mutex); std::lock_guard lock(m_Mutex);
m_ProcessMem = ReadProcessMemory(); Snapshot s;
} s.textureCount = m_TextureCount;
s.textureBytes = m_TextureBytes;
// ============ 打印 ============ s.meshCount = m_MeshCount;
s.meshBytes = m_MeshBytes;
static double MB(const u64 bytes) { return bytes / (1024.0 * 1024.0); } s.bufferCount = m_BufferCount;
s.bufferBytes = m_BufferBytes;
void Stats::Print() const s.memoryBlockBytes = m_MemoryBlockBytes;
{ s.memoryUsedBytes = m_MemoryUsedBytes;
std::lock_guard lock(m_Mutex); s.frameAllocation = m_FrameAllocation;
s.frameDeallocation = m_FrameDeallocation;
const auto proc = ReadProcessMemory(); s.totalAllocation = m_TotalAllocation;
s.totalDeallocation = m_TotalDeallocation;
ENGINE_INFO("========== Resource Stats =========="); return s;
ENGINE_INFO("Textures: {:>4} ({:.2f} MB)", m_TextureCount, MB(m_TextureBytes));
ENGINE_INFO("Meshes: {:>4} ({:.2f} MB)", m_MeshCount, MB(m_MeshBytes));
ENGINE_INFO("Buffers: {:>4} ({:.2f} MB)", m_BufferCount, MB(m_BufferBytes));
ENGINE_INFO("--- GPU MemoryAllocator ---");
ENGINE_INFO("Allocated: {:.2f} MB", MB(m_MemoryBlockBytes));
ENGINE_INFO("Used: {:.2f} MB", MB(m_MemoryUsedBytes));
ENGINE_INFO("Wasted: {:.2f} MB", MB(m_MemoryBlockBytes - m_MemoryUsedBytes));
ENGINE_INFO("--- Process Memory ---");
ENGINE_INFO("VmSize: {:.2f} MB", MB(proc.VmSize));
ENGINE_INFO("RSS (resident): {:.2f} MB", MB(proc.Rss));
ENGINE_INFO("PSS: {:.2f} MB", MB(proc.Pss));
ENGINE_INFO(" Shared Clean: {:.2f} MB", MB(proc.SharedClean));
ENGINE_INFO(" Private Dirty: {:.2f} MB", MB(proc.PrivateDirty));
ENGINE_INFO(" Private Clean: {:.2f} MB", MB(proc.PrivateClean));
ENGINE_INFO("====================================");
}
void Stats::PrintDetailed() const
{
std::lock_guard lock(m_Mutex);
auto proc = ReadProcessMemory();
ENGINE_INFO("==================== Detailed Memory Stats ====================");
ENGINE_INFO(" Resources");
ENGINE_INFO(" Textures: {:>6} {:>10.2f} MB", m_TextureCount, MB(m_TextureBytes));
ENGINE_INFO(" Meshes: {:>6} {:>10.2f} MB", m_MeshCount, MB(m_MeshBytes));
ENGINE_INFO(" Buffers: {:>6} {:>10.2f} MB", m_BufferCount, MB(m_BufferBytes));
ENGINE_INFO(" Total: {:>10.2f} MB",
MB(m_TextureBytes + m_MeshBytes + m_BufferBytes));
ENGINE_INFO(" GPU MemoryAllocator");
ENGINE_INFO(" Allocated: {:>10.2f} MB", MB(m_MemoryBlockBytes));
ENGINE_INFO(" Used: {:>10.2f} MB", MB(m_MemoryUsedBytes));
ENGINE_INFO(" Wasted: {:>10.2f} MB",
MB(m_MemoryBlockBytes - m_MemoryUsedBytes));
ENGINE_INFO(" Allocation Churn");
ENGINE_INFO(" This frame: +{:.2f} MB / -{:.2f} MB",
MB(m_FrameAllocation), MB(m_FrameDeallocation));
ENGINE_INFO(" Total: +{:.2f} MB / -{:.2f} MB",
MB(m_TotalAllocation), MB(m_TotalDeallocation));
ENGINE_INFO(" Process Memory (/proc/self/status + smaps_rollup)");
ENGINE_INFO(" VmSize: {:>10.2f} MB", MB(proc.VmSize));
ENGINE_INFO(" RSS: {:>10.2f} MB", MB(proc.Rss));
ENGINE_INFO(" PSS: {:>10.2f} MB", MB(proc.Pss));
ENGINE_INFO(" Shared Clean: {:>10.2f} MB", MB(proc.SharedClean));
ENGINE_INFO(" Private Dirty: {:>10.2f} MB", MB(proc.PrivateDirty));
ENGINE_INFO(" Private Clean: {:>10.2f} MB", MB(proc.PrivateClean));
ENGINE_INFO(" Unaccounted (RSS - GPU Blocks)");
u64 engineTotal = m_MemoryBlockBytes;
if (proc.Rss > engineTotal)
{
ENGINE_INFO(" {:.2f} MB (Vulkan driver, libs, stack, heap, ...)",
MB(proc.Rss - engineTotal));
}
else
{
ENGINE_INFO(" (GPU blocks are the bulk of RSS)");
}
ENGINE_INFO("===============================================================");
}
void Stats::PrintComparison() const
{
std::lock_guard lock(m_Mutex);
auto proc = ReadProcessMemory();
u64 resourceTotal = m_TextureBytes + m_MeshBytes + m_BufferBytes;
ENGINE_INFO("=============== Memory Comparison ===============");
ENGINE_INFO("");
ENGINE_INFO("[Engine 自己统计]");
ENGINE_INFO(" Textures {:>10.2f} MB", MB(m_TextureBytes));
ENGINE_INFO(" Meshes {:>10.2f} MB", MB(m_MeshBytes));
ENGINE_INFO(" Buffers {:>10.2f} MB", MB(m_BufferBytes));
ENGINE_INFO(" ─────────────────────────────────────");
ENGINE_INFO(" 资源合计 {:>10.2f} MB", MB(resourceTotal));
ENGINE_INFO("");
ENGINE_INFO(" Allocator Used {:>10.2f} MB", MB(m_MemoryUsedBytes));
ENGINE_INFO(" Allocator Allocated {:>10.2f} MB", MB(m_MemoryBlockBytes));
ENGINE_INFO(" Allocator Wasted {:>10.2f} MB",
MB(m_MemoryBlockBytes - m_MemoryUsedBytes));
ENGINE_INFO("");
ENGINE_INFO("[系统 /proc 读出的]");
ENGINE_INFO(" VmRSS {:>10.2f} MB", MB(proc.Rss));
ENGINE_INFO(" VmSize {:>10.2f} MB", MB(proc.VmSize));
ENGINE_INFO(" PSS {:>10.2f} MB", MB(proc.Pss));
ENGINE_INFO(" Shared Clean {:>10.2f} MB", MB(proc.SharedClean));
ENGINE_INFO(" Private Dirty {:>10.2f} MB", MB(proc.PrivateDirty));
ENGINE_INFO(" Private Clean {:>10.2f} MB", MB(proc.PrivateClean));
ENGINE_INFO("");
ENGINE_INFO("[差值]");
ENGINE_INFO(" RSS - Allocator {:>10.2f} MB (Vulkan 驱动 + libs + 栈堆)",
MB(proc.Rss > m_MemoryBlockBytes ? proc.Rss - m_MemoryBlockBytes : 0));
ENGINE_INFO(" RSS - 资源合计 {:>10.2f} MB (含分配器碎片)",
MB(proc.Rss > resourceTotal ? proc.Rss - resourceTotal : 0));
ENGINE_INFO(" Allocated - Used {:>10.2f} MB (分配器空闲块)",
MB(m_MemoryBlockBytes - m_MemoryUsedBytes));
ENGINE_INFO("================================================");
} }
} // namespace Engine } // namespace Engine
+17
View File
@@ -23,10 +23,14 @@ namespace Engine {
constexpr i32 kKeyUp = 265, kKeyDown = 264, constexpr i32 kKeyUp = 265, kKeyDown = 264,
kKeyLeft = 263, kKeyRight = 262; kKeyLeft = 263, kKeyRight = 262;
constexpr i32 kKeySpace = 32; constexpr i32 kKeySpace = 32;
constexpr i32 kKeyLShift = 340; // GLFW_KEY_LEFT_SHIFT
// 手柄标准布局 // 手柄标准布局
constexpr i32 kPadBtnA = 0; // GLFW_GAMEPAD_BUTTON_A constexpr i32 kPadBtnA = 0; // GLFW_GAMEPAD_BUTTON_A
constexpr i32 kPadBtnL3 = 9; // GLFW_GAMEPAD_BUTTON_LEFT_THUMB
constexpr i32 kPadAxisLeftX = 0; // GLFW_GAMEPAD_AXIS_LEFT_X constexpr i32 kPadAxisLeftX = 0; // GLFW_GAMEPAD_AXIS_LEFT_X
constexpr i32 kPadAxisLeftY = 1; // GLFW_GAMEPAD_AXIS_LEFT_Y constexpr i32 kPadAxisLeftY = 1; // GLFW_GAMEPAD_AXIS_LEFT_Y
constexpr i32 kPadAxisRightX = 2; // GLFW_GAMEPAD_AXIS_RIGHT_X
constexpr i32 kPadAxisRightY = 3; // GLFW_GAMEPAD_AXIS_RIGHT_Y
constexpr f32 kRadialDeadZone = 0.15f; constexpr f32 kRadialDeadZone = 0.15f;
@@ -139,6 +143,19 @@ namespace Engine {
const size_t jump = DefineAction("Jump", ActionKind::Button); const size_t jump = DefineAction("Jump", ActionKind::Button);
Bind(jump, {SourceKind::Key, kKeySpace, 1.0f, kX}); Bind(jump, {SourceKind::Key, kKeySpace, 1.0f, kX});
Bind(jump, {SourceKind::GamepadButton, kPadBtnA, 1.0f, kX}); Bind(jump, {SourceKind::GamepadButton, kPadBtnA, 1.0f, kX});
// Run:Shift + 手柄 L3(按下左摇杆 = 冲刺,Xbox 惯例)。
// 注意不能用"摇杆推满"判跑——键盘 WASD 经死区/钳制后幅度
// 恒为 1.0,会把走路误判成跑(真实踩坑:WASD 一动就 run)
const size_t run = DefineAction("Run", ActionKind::Button);
Bind(run, {SourceKind::Key, kKeyLShift, 1.0f, kX});
Bind(run, {SourceKind::GamepadButton, kPadBtnL3, 1.0f, kX});
// Look:右摇杆(相机环绕)。鼠标是相对增量不走动作层,
// 相机脚本里 GetMouseDelta 叠加
const size_t look = DefineAction("Look", ActionKind::Axis2D);
Bind(look, {SourceKind::GamepadAxis, kPadAxisRightX, +1.0f, kX});
Bind(look, {SourceKind::GamepadAxis, kPadAxisRightY, +1.0f, kY});
} }
// ---- 运行期 ---- // ---- 运行期 ----
+32 -2
View File
@@ -25,8 +25,10 @@ namespace Engine {
s.mShape = new JPH::CapsuleShape( s.mShape = new JPH::CapsuleShape(
std::max(params.radius, 0.01f), std::max(params.radius, 0.01f),
std::max(params.halfHeight, 0.0f)); std::max(params.halfHeight, 0.0f));
s.mShapeOffset = JPH::Vec3(0.0f, s.mShapeOffset = JPH::Vec3(
params.halfHeight + params.radius, 0.0f); params.shapeOffset.x,
params.shapeOffset.y + params.halfHeight + params.radius,
params.shapeOffset.z);
m_Character = new JPH::CharacterVirtual( m_Character = new JPH::CharacterVirtual(
&s, JPH::RVec3(position.x, position.y, position.z), &s, JPH::RVec3(position.x, position.y, position.z),
@@ -37,6 +39,20 @@ namespace Engine {
m_Ext.mWalkStairsStepUp = JPH::Vec3(0.0f, std::max(params.stepHeight, 0.0f), 0.0f); m_Ext.mWalkStairsStepUp = JPH::Vec3(0.0f, std::max(params.stepHeight, 0.0f), 0.0f);
} }
CharacterController::~CharacterController() {
// 先摘除再释放:列表持裸 CharacterVirtual*,否则悬垂
if (m_VsChar != nullptr) {
m_VsChar->Remove(m_Character.GetPtr());
}
}
void CharacterController::SetCharacterCollision(
JPH::CharacterVsCharacterCollisionSimple &list) {
m_VsChar = &list;
m_Character->SetCharacterVsCharacterCollision(&list);
list.Add(m_Character.GetPtr());
}
void CharacterController::Update(const f32 dt, const JPH::Vec3 &gravity, void CharacterController::Update(const f32 dt, const JPH::Vec3 &gravity,
JPH::TempAllocator &allocator) { JPH::TempAllocator &allocator) {
// 重力积分:着地时清掉向下分量(防贴地累积穿透速度),悬空时累加 // 重力积分:着地时清掉向下分量(防贴地累积穿透速度),悬空时累加
@@ -50,6 +66,10 @@ namespace Engine {
m_Character->SetLinearVelocity(v + gravity * dt); m_Character->SetLinearVelocity(v + gravity * dt);
} }
// 扫掠前速度快照:撞墙判定用。ExtendedUpdate 会抵消朝向陡面/
// 墙面的速度分量,扫掠后再读就归零了(见头文件说明)
m_SweepVelocity = m_Character->GetLinearVelocity();
// 扫掠推进(撞墙滑动 + 斜坡限制 + StickToFloor 贴地 + WalkStairs // 扫掠推进(撞墙滑动 + 斜坡限制 + StickToFloor 贴地 + WalkStairs
// 上台阶)。过滤器默认全放行:角色与静/动层全碰 // 上台阶)。过滤器默认全放行:角色与静/动层全碰
m_Character->ExtendedUpdate(dt, gravity, m_Ext, m_Character->ExtendedUpdate(dt, gravity, m_Ext,
@@ -78,6 +98,16 @@ namespace Engine {
return m_Character->IsSupported(); return m_Character->IsSupported();
} }
const JPH::CharacterVirtual::ContactList&
CharacterController::GetActiveContacts() const {
return m_Character->GetActiveContacts();
}
glm::vec3 CharacterController::GetSweepVelocity() const {
return {m_SweepVelocity.GetX(), m_SweepVelocity.GetY(),
m_SweepVelocity.GetZ()};
}
glm::vec3 CharacterController::GetPosition() const { glm::vec3 CharacterController::GetPosition() const {
const JPH::RVec3 p = m_Character->GetPosition(); const JPH::RVec3 p = m_Character->GetPosition();
return {static_cast<f32>(p.GetX()), static_cast<f32>(p.GetY()), return {static_cast<f32>(p.GetX()), static_cast<f32>(p.GetY()),
+54 -1
View File
@@ -176,6 +176,10 @@ namespace Engine {
// 直接成员会先于 call_once 构造 → 函数指针未注册 → SIGSEGV at 0x0 // 直接成员会先于 call_once 构造 → 函数指针未注册 → SIGSEGV at 0x0
m_DebugDraw = CreateScope<PhysicsDebugDraw>(); m_DebugDraw = CreateScope<PhysicsDebugDraw>();
// 角色间碰撞互查表(内部 Array 走 JPH 分配器,须在 call_once 之后)
m_VsCharacterCollision =
CreateScope<JPH::CharacterVsCharacterCollisionSimple>();
m_TempAllocator = CreateScope<JPH::TempAllocatorImpl>(16 * 1024 * 1024); m_TempAllocator = CreateScope<JPH::TempAllocatorImpl>(16 * 1024 * 1024);
const u32 threads = std::max(1u, std::thread::hardware_concurrency() - 1); const u32 threads = std::max(1u, std::thread::hardware_concurrency() - 1);
m_JobSystem = CreateScope<JPH::JobSystemThreadPool>( m_JobSystem = CreateScope<JPH::JobSystemThreadPool>(
@@ -208,6 +212,7 @@ namespace Engine {
// 释放烘焙几何——必须先于 UnregisterTypes // 释放烘焙几何——必须先于 UnregisterTypes
m_DebugDraw.reset(); m_DebugDraw.reset();
m_Bodies.clear(); m_Bodies.clear();
m_Characters.clear(); // 角色析构回 m_VsCharacterCollision 摘除,先于 Jolt 反注册
m_ContactListener.reset(); // 监听器先于 system 析构(持 m_Owner 引用) m_ContactListener.reset(); // 监听器先于 system 析构(持 m_Owner 引用)
m_System.reset(); m_System.reset();
// 单世界假设:进程退出时统一反注册(多世界时改为引用计数) // 单世界假设:进程退出时统一反注册(多世界时改为引用计数)
@@ -695,20 +700,24 @@ namespace Engine {
p.mass = cc.mass; p.mass = cc.mass;
p.maxSlopeAngle = cc.maxSlopeAngle; p.maxSlopeAngle = cc.maxSlopeAngle;
p.stepHeight = cc.stepHeight; p.stepHeight = cc.stepHeight;
p.shapeOffset = cc.shapeOffset;
auto found = m_Characters.find(bits); auto found = m_Characters.find(bits);
if (found == m_Characters.end()) { if (found == m_Characters.end()) {
CharacterEntry entry; CharacterEntry entry;
entry.cc = std::make_unique<CharacterController>( entry.cc = std::make_unique<CharacterController>(
*m_System, p, tr.GetWorldPosition()); *m_System, p, tr.GetWorldPosition());
entry.cc->SetCharacterCollision(*m_VsCharacterCollision);
entry.lastPos = tr.GetWorldPosition(); entry.lastPos = tr.GetWorldPosition();
m_Characters.emplace(bits, std::move(entry)); m_Characters.emplace(bits, std::move(entry));
continue; continue;
} }
if (found->second.cc->GetParams() != p) { if (found->second.cc->GetParams() != p) {
// 参数变更 → 重建(位置沿用当前 Transform) // 参数变更 → 重建(位置沿用当前 Transform);旧实例析构
// 自动摘除旧条目,新实例重新登记
found->second.cc = std::make_unique<CharacterController>( found->second.cc = std::make_unique<CharacterController>(
*m_System, p, tr.GetWorldPosition()); *m_System, p, tr.GetWorldPosition());
found->second.cc->SetCharacterCollision(*m_VsCharacterCollision);
found->second.lastPos = tr.GetWorldPosition(); found->second.lastPos = tr.GetWorldPosition();
continue; continue;
} }
@@ -742,6 +751,42 @@ namespace Engine {
static_cast<entt::entity>(bits), p, static_cast<entt::entity>(bits), p,
tr->GetWorldRotationQuat(), tr->GetWorldScale()); tr->GetWorldRotationQuat(), tr->GetWorldScale());
entry.lastPos = p; entry.lastPos = p;
// ---- 角色碰撞事件合成(Enter/Exit)----
// CharacterVirtual 非刚体,Jolt 的 ContactListener 不会为角色
// 回调——脚本因此收不到"玩家撞墙"。这里读角色本步活跃接触,
// 与上一步集合对比出边沿,合成与刚体同款的 CollisionEvent
// (a = 角色实体,b = 被撞实体)。只处理刚体接触(mBodyB 有效):
// 角色↔角色由 CharacterVsCharacterCollision 的物理互查处理,
// 不发脚本事件(对方是角色时 mBodyB 无效,天然跳过)
std::unordered_set<u32> now;
now.reserve(entry.contacts.size() + 4);
for (const JPH::CharacterContact& c :
entry.cc->GetActiveContacts()) {
if (c.mBodyB.IsInvalid()) continue; // 对方非刚体(角色)
if (c.mWasDiscarded) continue; // 被丢弃/传感器
if (c.mDistance > 0.0f) continue; // >0 = 预测接触,未真相交
u32 other = 0;
if (!TryBitsByBodyId(c.mBodyB, other)) continue;
now.insert(other);
if (entry.contacts.count(other) == 0) {
const glm::vec3 pt(
static_cast<f32>(c.mPosition.GetX()),
static_cast<f32>(c.mPosition.GetY()),
static_cast<f32>(c.mPosition.GetZ()));
const glm::vec3 n(c.mContactNormal.GetX(),
c.mContactNormal.GetY(),
c.mContactNormal.GetZ());
QueueEvent({bits, other, CollisionEvent::Kind::Added, pt, n});
}
}
for (const u32 prev : entry.contacts) {
if (now.count(prev) == 0) {
QueueEvent(
{bits, prev, CollisionEvent::Kind::Removed, {}, {}});
}
}
entry.contacts = std::move(now);
} }
} }
@@ -1124,6 +1169,14 @@ namespace Engine {
: glm::vec3{0.0f}; : glm::vec3{0.0f};
} }
glm::vec3
PhysicsWorld::GetCharacterImpactVelocity(const u32 entityBits) const {
const auto found = m_Characters.find(entityBits);
return found != m_Characters.end()
? found->second.cc->GetSweepVelocity()
: glm::vec3{0.0f};
}
// ---- 写回:动态体姿态 → Transform(标脏,帧循环统一 Compose)---- // ---- 写回:动态体姿态 → Transform(标脏,帧循环统一 Compose)----
void PhysicsWorld::Writeback() { void PhysicsWorld::Writeback() {
+11 -4
View File
@@ -88,10 +88,17 @@ namespace Engine
",e:" + std::to_string(params.emissiveFactor.x) + ",e:" + std::to_string(params.emissiveFactor.x) +
"," + std::to_string(params.emissiveFactor.y) + "," + std::to_string(params.emissiveFactor.y) +
"," + std::to_string(params.emissiveFactor.z); "," + std::to_string(params.emissiveFactor.z);
// per-slot UV 变换 + unlit 入键:同一张贴图配不同变换是不同材质 // per-slot UV 变换 + unlit + UV 镜像入键:同一张贴图配不同变换是
//(KHR_texture_transform 测试模型:一张棋盘格被 82 个材质以不同 // 不同材质(KHR_texture_transform 测试模型:一张棋盘格被 82 个材质
// 变换共享,键漏了会串成同一份) // 以不同变换共享,键漏了会串成同一份)
key += ",m:" + std::to_string(params.misc.x); key += ",m:" + std::to_string(params.misc.x) +
"," + std::to_string(params.misc.y) +
"," + std::to_string(params.misc.z);
// alpha 叠加遮罩入键:同贴图不同叠加色是不同材质
key += ",o:" + std::to_string(params.alphaOverlay.x) +
"," + std::to_string(params.alphaOverlay.y) +
"," + std::to_string(params.alphaOverlay.z) +
"," + std::to_string(params.alphaOverlay.w);
for (u32 slot = 0; slot < kSlotCount; ++slot) { for (u32 slot = 0; slot < kSlotCount; ++slot) {
key += ",u" + std::to_string(slot) + ":" + key += ",u" + std::to_string(slot) + ":" +
std::to_string(params.uvA[slot].x) + "," + std::to_string(params.uvA[slot].x) + "," +
+4
View File
@@ -93,6 +93,8 @@ namespace Engine
EmitVec4(out, data.params.emissiveFactor); EmitVec4(out, data.params.emissiveFactor);
out << YAML::Key << "Misc" << YAML::Value; out << YAML::Key << "Misc" << YAML::Value;
EmitVec4(out, data.params.misc); EmitVec4(out, data.params.misc);
out << YAML::Key << "AlphaOverlay" << YAML::Value;
EmitVec4(out, data.params.alphaOverlay);
// per-slot UV 变换(KHR_texture_transform):6 槽各 A(4 分量) + T // per-slot UV 变换(KHR_texture_transform):6 槽各 A(4 分量) + T
for (u32 slot = 0; slot < Material::kSlotCount; ++slot) { for (u32 slot = 0; slot < Material::kSlotCount; ++slot) {
out << YAML::Key << ("UvA" + std::to_string(slot)) << YAML::Value; out << YAML::Key << ("UvA" + std::to_string(slot)) << YAML::Value;
@@ -156,6 +158,8 @@ namespace Engine
m["EmissiveFactor"], MaterialParams{}.emissiveFactor); m["EmissiveFactor"], MaterialParams{}.emissiveFactor);
out.params.misc = ReadVec4( out.params.misc = ReadVec4(
m["Misc"], MaterialParams{}.misc); m["Misc"], MaterialParams{}.misc);
out.params.alphaOverlay = ReadVec4(
m["AlphaOverlay"], MaterialParams{}.alphaOverlay);
for (u32 slot = 0; slot < Material::kSlotCount; ++slot) { for (u32 slot = 0; slot < Material::kSlotCount; ++slot) {
out.params.uvA[slot] = ReadVec4( out.params.uvA[slot] = ReadVec4(
m["UvA" + std::to_string(slot)], m["UvA" + std::to_string(slot)],
File diff suppressed because it is too large Load Diff
@@ -529,6 +529,10 @@ namespace Engine::Serialization {
<< cc->stepHeight; << cc->stepHeight;
out << YAML::Key << "GravityScale" << YAML::Value out << YAML::Key << "GravityScale" << YAML::Value
<< cc->gravityScale; << cc->gravityScale;
out << YAML::Key << "ShapeOffset" << YAML::Value
<< YAML::Flow << YAML::BeginSeq << cc->shapeOffset.x
<< cc->shapeOffset.y << cc->shapeOffset.z
<< YAML::EndSeq;
out << YAML::Key << "Active" << YAML::Value << cc->active; out << YAML::Key << "Active" << YAML::Value << cc->active;
out << YAML::EndMap; out << YAML::EndMap;
} }
@@ -681,6 +685,32 @@ namespace Engine::Serialization {
it.as<std::string>("")); it.as<std::string>(""));
} }
} }
// 外部动画并入(幂等重放):动画 clip 本体不落盘,只在
// 此按 Attached 列表把动画容器并入共享 Model 资产。过去
// 仅由 Inspector 选中实体时重放 → 不点击实体/开局直跑
// 就永远不加载。放在共享的 ApplyComponents 里,场景加载
// 与 Prefab 实例化两条路径同时覆盖。
// 时序保证:MeshRenderer 分支(上方)先于本分支解析,
// 故此处 mr.model 已 resolve 就绪
if (!a.attachedAnimPaths.empty() &&
handle.HasComponent<ECS::MeshRendererComponent>()) {
const auto& mr =
handle.GetComponent<ECS::MeshRendererComponent>();
if (Model* model =
mr.model.Valid() ? assets.GetModel(mr.model)
: nullptr) {
for (const auto& p : a.attachedAnimPaths) {
const bool done = std::any_of(
model->GetClips().begin(),
model->GetClips().end(),
[&](const AnimationClip& c)
{ return c.sourcePath == p; });
if (!done) {
assets.AttachAnimationClips(mr.model, p);
}
}
}
}
} }
if (const YAML::Node n = comps["Camera"]) { if (const YAML::Node n = comps["Camera"]) {
@@ -798,6 +828,11 @@ namespace Engine::Serialization {
cc.maxSlopeAngle = n["MaxSlopeAngle"].as<f32>(cc.maxSlopeAngle); cc.maxSlopeAngle = n["MaxSlopeAngle"].as<f32>(cc.maxSlopeAngle);
cc.stepHeight = n["StepHeight"].as<f32>(cc.stepHeight); cc.stepHeight = n["StepHeight"].as<f32>(cc.stepHeight);
cc.gravityScale = n["GravityScale"].as<f32>(cc.gravityScale); cc.gravityScale = n["GravityScale"].as<f32>(cc.gravityScale);
if (const YAML::Node so = n["ShapeOffset"]) {
cc.shapeOffset = glm::vec3(so[0].as<f32>(0.0f),
so[1].as<f32>(0.0f),
so[2].as<f32>(0.0f));
}
cc.active = n["Active"].as<bool>(true); cc.active = n["Active"].as<bool>(true);
// 状态(速度/着地)由 PhysicsWorld 对账重建,不存 // 状态(速度/着地)由 PhysicsWorld 对账重建,不存
} }
+51 -1
View File
@@ -8,11 +8,55 @@
#include <vk_mem_alloc.h> #include <vk_mem_alloc.h>
#include <stdexcept> #include <stdexcept>
#include <algorithm>
#include "Engine/Core/Stats.h" #include "Engine/Core/Stats.h"
namespace Engine { namespace Engine {
u64 EstimateImageBytes(const vk::Format fmt, const u32 w, const u32 h,
const u32 depth, const u32 mips)
{
// 线性格式 = 字节/像素;BC 压缩 = 字节/4x4 块(按块向上取整)。
// 未识别格式按 RGBA8 保守估算。逐 mip 累加(mip 链约占 1/3 额外)
f32 bpp = 0;
u32 blockBytes = 0;
switch (fmt) {
case vk::Format::eR8Unorm:
case vk::Format::eR8Srgb: bpp = 1; break;
case vk::Format::eR8G8Unorm:
case vk::Format::eR8G8Srgb: bpp = 2; break;
case vk::Format::eR16G16B16A16Sfloat: bpp = 8; break;
case vk::Format::eR32G32B32A32Sfloat: bpp = 16; break;
case vk::Format::eB10G11R11UfloatPack32: bpp = 4; break;
case vk::Format::eBc1RgbaUnormBlock:
case vk::Format::eBc1RgbaSrgbBlock:
case vk::Format::eBc4UnormBlock: blockBytes = 8; break;
case vk::Format::eBc2SrgbBlock:
case vk::Format::eBc2UnormBlock:
case vk::Format::eBc3SrgbBlock:
case vk::Format::eBc3UnormBlock:
case vk::Format::eBc5UnormBlock:
case vk::Format::eBc6HUfloatBlock:
case vk::Format::eBc7SrgbBlock:
case vk::Format::eBc7UnormBlock: blockBytes = 16; break;
default: bpp = 4; break;
}
u64 total = 0;
const u32 levels = std::max(1u, mips);
for (u32 mip = 0; mip < levels; ++mip) {
const u64 mw = std::max(1u, w >> mip);
const u64 mh = std::max(1u, h >> mip);
if (blockBytes != 0) {
total += static_cast<u64>((mw + 3) / 4) *
static_cast<u64>((mh + 3) / 4) * blockBytes * depth;
} else {
total += mw * mh * static_cast<u64>(bpp) * depth;
}
}
return total;
}
namespace namespace
{ {
// props → VMA 创建参数: // props → VMA 创建参数:
@@ -88,6 +132,7 @@ namespace Engine {
out.MappedPtr = info.pMappedData; // 非 MAPPED 标记时为 null out.MappedPtr = info.pMappedData; // 非 MAPPED 标记时为 null
STATS.AddMemoryUsed(size); STATS.AddMemoryUsed(size);
STATS.AddMemoryBlock(size); // 1:1 VMA 分配:块 == 占用(无子分配)
return out; return out;
} }
@@ -108,11 +153,15 @@ namespace Engine {
Allocation out; Allocation out;
out.Image = image; out.Image = image;
out.Size = ii.extent.depth * ii.extent.height * ii.extent.width; // 近似值(统计用) // 按真实格式 + mip 链估算(原来 w*h*d 近似值对 HDR/压缩全错)
out.Size = EstimateImageBytes(imageInfo.format,
ii.extent.width, ii.extent.height,
ii.extent.depth, imageInfo.mipLevels);
out.Handle = handle; out.Handle = handle;
out.Valid = true; out.Valid = true;
STATS.AddMemoryUsed(out.Size); STATS.AddMemoryUsed(out.Size);
STATS.AddMemoryBlock(out.Size);
return out; return out;
} }
@@ -128,6 +177,7 @@ namespace Engine {
} }
STATS.RemoveMemoryUsed(alloc.Size); STATS.RemoveMemoryUsed(alloc.Size);
STATS.RemoveMemoryBlock(alloc.Size);
alloc = {}; alloc = {};
} }
+22 -2
View File
@@ -95,6 +95,19 @@ namespace Engine
void Texture::InitFromPixels(const void* pixels, u32 width, u32 height, bool srgb) void Texture::InitFromPixels(const void* pixels, u32 width, u32 height, bool srgb)
{ {
// 重入安全:CreateFromEncodedMemory 先经 ProceduralTag ctor 落 1x1
// 占位(只为确定 device/allocator 成员),再调本函数传真实像素。
// 必须先成对释放占位资源,否则 1x1 的 image/view/sampler 泄漏 →
// allocator 析构时 VMA 块非空断言崩溃(GLB 内嵌贴图模型关闭必崩)
if (m_Image) {
const auto& vkDevice = m_Device.GetDevice();
vkDevice.destroySampler(m_Sampler);
vkDevice.destroyImageView(m_View);
m_Allocator.Destroy(m_ImageAllocation);
m_Sampler = nullptr;
m_View = nullptr;
m_Image = nullptr;
}
m_Width = width; m_Width = width;
m_Height = height; m_Height = height;
m_Format = srgb ? vk::Format::eR8G8B8A8Srgb : vk::Format::eR8G8B8A8Unorm; m_Format = srgb ? vk::Format::eR8G8B8A8Srgb : vk::Format::eR8G8B8A8Unorm;
@@ -299,8 +312,12 @@ namespace Engine
m_Device.GetDevice().destroyCommandPool(pool); m_Device.GetDevice().destroyCommandPool(pool);
m_Allocator.Destroy(stagingAlloc); m_Allocator.Destroy(stagingAlloc);
// 显存占用 = 宽 * 高 * 4 字节(RGBA8) // 显存占用按真实格式 + mip 链估算(与分配器台账同一算法)
m_TextureBytes = static_cast<u64>(m_Width) * m_Height * 4; if (m_TextureBytes != 0) {
STATS.RemoveTexture(m_TextureBytes); // 重建防双计
}
m_TextureBytes = EstimateImageBytes(m_Format, m_Width, m_Height, 1,
m_MipLevels);
STATS.AddTexture(m_TextureBytes); STATS.AddTexture(m_TextureBytes);
} }
@@ -492,6 +509,9 @@ namespace Engine
m_Device.GetDevice().destroyCommandPool(pool); m_Device.GetDevice().destroyCommandPool(pool);
m_Allocator.Destroy(stagingAlloc); m_Allocator.Destroy(stagingAlloc);
if (m_TextureBytes != 0) {
STATS.RemoveTexture(m_TextureBytes); // 重建防双计
}
m_TextureBytes = total; m_TextureBytes = total;
STATS.AddTexture(m_TextureBytes); STATS.AddTexture(m_TextureBytes);
} }
+45
View File
@@ -0,0 +1,45 @@
MIT License
Copyright (c) 2026 ufbx-python contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
This project includes ufbx (https://github.com/ufbx/ufbx), which is licensed under the MIT License:
Copyright (c) 2020-2023 Samuli Raivio
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+33096
View File
File diff suppressed because it is too large Load Diff
+6062
View File
File diff suppressed because it is too large Load Diff
+19 -7
View File
@@ -45,19 +45,25 @@ layout(set = 1, binding = 6) uniform MaterialParams {
vec4 factors; // x = metallic 因子,y = roughness 因子,z = 独立粗糙度图开关,w = MASK 镂空开关 vec4 factors; // x = metallic 因子,y = roughness 因子,z = 独立粗糙度图开关,w = MASK 镂空开关
vec4 baseColor; // glTF baseColorFactor:rgb 调色 albedo,a = BLEND 透明度 vec4 baseColor; // glTF baseColorFactor:rgb 调色 albedo,a = BLEND 透明度
vec4 surface; // x = ior(F0 换算),y = transmission(不透明段恒 0 语义),z = 自发光强度,w = 体积厚度 vec4 surface; // x = ior(F0 换算),y = transmission(不透明段恒 0 语义),z = 自发光强度,w = 体积厚度
vec4 misc; // x = unlit(KHR_materials_unlit,1 = 早退直出 albedo) vec4 misc; // x = unlit(KHR_materials_unlit,1 = 早退直出 albedo),y = UV 左右镜像,z = UV 上下镜像
vec4 emissiveFactor; // 自发光颜色(rgb × emissiveMap × surface.z;黑 = 不发光) vec4 emissiveFactor; // 自发光颜色(rgb × emissiveMap × surface.z;黑 = 不发光)
vec4 uvA[6]; // 每槽 UV 线性变换(KHR_texture_transform,v-up 空间)(a11,a12,a21,a22),槽序 = binding vec4 uvA[6]; // 每槽 UV 线性变换(KHR_texture_transform,v-up 空间)(a11,a12,a21,a22),槽序 = binding
vec4 uvT[6]; // 每槽 UV 平移 (tx, ty, 0, 0) vec4 uvT[6]; // 每槽 UV 平移 (tx, ty, 0, 0)
vec4 alphaOverlay; // rgb = albedo alpha 叠加色,a = 强度(0 = 关闭)
} matParams; } matParams;
// 每槽 UV 变换:uv' = A·uv + t(identity 时退化为直通)。slot 必须 // 每槽 UV 变换:uv' = A·uv + t(identity 时退化为直通)。slot 必须
// 字面量调用点(编译期展开索引);6 = Material::kSlotCount // 字面量调用点(编译期展开索引);6 = Material::kSlotCount
// 末尾叠加材质级镜像(misc.y = 左右,misc.z = 上下),作用全部槽:
// 纠正源资产 UV 朝向(部分角色 rip 与 glTF 上左原点约定相反)
vec2 ApplyUv(int slot, vec2 uv) { vec2 ApplyUv(int slot, vec2 uv) {
vec4 a = matParams.uvA[slot]; vec4 a = matParams.uvA[slot];
vec4 t = matParams.uvT[slot]; vec4 t = matParams.uvT[slot];
return vec2(a.x * uv.x + a.y * uv.y + t.x, vec2 o = vec2(a.x * uv.x + a.y * uv.y + t.x,
a.z * uv.x + a.w * uv.y + t.y); a.z * uv.x + a.w * uv.y + t.y);
if (matParams.misc.y > 0.5) o.x = 1.0 - o.x;
if (matParams.misc.z > 0.5) o.y = 1.0 - o.y;
return o;
} }
// CSM 阴影(sampler2DShadow:单次采样 = 硬件 2×2 深度比较 PCF) // CSM 阴影(sampler2DShadow:单次采样 = 硬件 2×2 深度比较 PCF)
@@ -154,9 +160,13 @@ void main() {
// alphaMode=MASK 镂空(表针/栅栏等硬边贴图):低于阈值的纹素直接丢弃 // alphaMode=MASK 镂空(表针/栅栏等硬边贴图):低于阈值的纹素直接丢弃
if (matParams.factors.w > 0.5 && albedoTex.a < 0.5) discard; if (matParams.factors.w > 0.5 && albedoTex.a < 0.5) discard;
vec3 albedo = albedoTex.rgb * matParams.baseColor.rgb; vec3 albedo = albedoTex.rgb * matParams.baseColor.rgb;
// albedo 贴图 alpha 作细节叠加遮罩(脸部腮红/卧蚕等局部效果):
// 强度 0 = mix 恒等,无副作用
albedo = mix(albedo, matParams.alphaOverlay.rgb,
albedoTex.a * matParams.alphaOverlay.a);
// KHR_materials_unlit:无光照语义(文字标签/示意图),albedo 直出 // KHR_materials_unlit:无光照语义(文字标签/示意图),albedo 直出
if (matParams.misc.x > 0.5) { if (matParams.misc.x > 0.5) {
outColor = vec4(albedo, albedoTex.a * matParams.baseColor.a); outColor = vec4(albedo, 1.0);
outVelocity = fragVelocity; outVelocity = fragVelocity;
return; return;
} }
@@ -259,9 +269,11 @@ void main() {
vec3 ambient = (diffuse + specular) * ubo.ambientColor.rgb vec3 ambient = (diffuse + specular) * ubo.ambientColor.rgb
* mix(0.4, 1.0, shade0); // 阴影区环境同步衰减(简化遮蔽) * mix(0.4, 1.0, shade0); // 阴影区环境同步衰减(简化遮蔽)
// alpha = albedo 贴图覆盖率 × baseColorFactor.a(glTF alphaMode=BLEND // 不透明段覆盖率恒 1:本 pass 只画 alphaMode=OPAQUE/MASK 表面,像素必
// 混合源);不透明资产两者恒 1 行为不变。预乘在 tonemap 段完成。 // 被完全覆盖。绝不能拿 albedo 贴图 alpha 当覆盖率——HI3/Genshin 脸部
outColor = vec4(ambient + Lo + emissive, albedoTex.a * matParams.baseColor.a); // 贴图的 alpha 是腮红/卧蚕遮罩(大面积 0),写进 HDR.a 后会被 tonemap
// 的预乘 (rgb * a) 拉成纯黑。贴图 alpha 的细节用法见 alphaOverlay。
outColor = vec4(ambient + Lo + emissive, 1.0);
// ==== DEBUG B1: 纯 albedo 直出(验证贴图绑定/UV 采样)==== // ==== DEBUG B1: 纯 albedo 直出(验证贴图绑定/UV 采样)====
// outColor = vec4(albedo, 1.0); // outColor = vec4(albedo, 1.0);
outVelocity = fragVelocity; outVelocity = fragVelocity;
+11 -3
View File
@@ -45,18 +45,23 @@ layout(set = 1, binding = 6) uniform MaterialParams {
vec4 factors; // x = metallic 因子,y = roughness 因子,z = 独立粗糙度图开关,w = MASK 镂空开关 vec4 factors; // x = metallic 因子,y = roughness 因子,z = 独立粗糙度图开关,w = MASK 镂空开关
vec4 baseColor; // glTF baseColorFactor:rgb 调色 albedo,a = BLEND 透明度 vec4 baseColor; // glTF baseColorFactor:rgb 调色 albedo,a = BLEND 透明度
vec4 surface; // x = ior,y = transmission(KHR_materials_transmission),z = 自发光强度,w = 体积厚度 vec4 surface; // x = ior,y = transmission(KHR_materials_transmission),z = 自发光强度,w = 体积厚度
vec4 misc; // x = unlit(透射材质不会是 unlit,仅为与 simple_shader 布局一致) vec4 misc; // x = unlit(透射材质不会是 unlit,仅为与 simple_shader 布局一致),y = UV 左右镜像,z = UV 上下镜像
vec4 emissiveFactor; // 自发光颜色(rgb × emissiveMap × surface.z;黑 = 不发光) vec4 emissiveFactor; // 自发光颜色(rgb × emissiveMap × surface.z;黑 = 不发光)
vec4 uvA[6]; // 每槽 UV 线性变换(KHR_texture_transform,v-up 空间)(a11,a12,a21,a22),槽序 = binding vec4 uvA[6]; // 每槽 UV 线性变换(KHR_texture_transform,v-up 空间)(a11,a12,a21,a22),槽序 = binding
vec4 uvT[6]; // 每槽 UV 平移 (tx, ty, 0, 0) vec4 uvT[6]; // 每槽 UV 平移 (tx, ty, 0, 0)
vec4 alphaOverlay; // rgb = albedo alpha 叠加色,a = 强度(0 = 关闭)
} matParams; } matParams;
// 每槽 UV 变换:uv' = A·uv + t(与 simple_shader.frag 同款,6 = kSlotCount) // 每槽 UV 变换:uv' = A·uv + t(与 simple_shader.frag 同款,6 = kSlotCount)
// 末尾叠加材质级镜像(misc.y = 左右,misc.z = 上下),作用全部槽
vec2 ApplyUv(int slot, vec2 uv) { vec2 ApplyUv(int slot, vec2 uv) {
vec4 a = matParams.uvA[slot]; vec4 a = matParams.uvA[slot];
vec4 t = matParams.uvT[slot]; vec4 t = matParams.uvT[slot];
return vec2(a.x * uv.x + a.y * uv.y + t.x, vec2 o = vec2(a.x * uv.x + a.y * uv.y + t.x,
a.z * uv.x + a.w * uv.y + t.y); a.z * uv.x + a.w * uv.y + t.y);
if (matParams.misc.y > 0.5) o.x = 1.0 - o.x;
if (matParams.misc.z > 0.5) o.y = 1.0 - o.y;
return o;
} }
// CSM 阴影(sampler2DShadow:单次采样 = 硬件 2×2 深度比较 PCF) // CSM 阴影(sampler2DShadow:单次采样 = 硬件 2×2 深度比较 PCF)
@@ -152,6 +157,9 @@ vec3 FresnelSchlickRoughness(float cosTheta, vec3 F0, float roughness) {
void main() { void main() {
vec4 albedoTex = texture(albedoMap, ApplyUv(0, fragUV)); vec4 albedoTex = texture(albedoMap, ApplyUv(0, fragUV));
vec3 albedo = albedoTex.rgb * matParams.baseColor.rgb; vec3 albedo = albedoTex.rgb * matParams.baseColor.rgb;
// albedo 贴图 alpha 作细节叠加遮罩(与 simple_shader 同款,0 = 恒等)
albedo = mix(albedo, matParams.alphaOverlay.rgb,
albedoTex.a * matParams.alphaOverlay.a);
vec4 mr = texture(metallicRoughnessMap, ApplyUv(2, fragUV)); vec4 mr = texture(metallicRoughnessMap, ApplyUv(2, fragUV));
// 粗糙度源按 factors.z 切换:glTF 合图工作流读 MR.g; // 粗糙度源按 factors.z 切换:glTF 合图工作流读 MR.g;
// FBX/OBJ specular 工作流(z=1)换用独立粗糙度图 R 通道 // FBX/OBJ specular 工作流(z=1)换用独立粗糙度图 R 通道