阶段性合并:动画重定向、碎裂共享库、第三人称玩法、编辑器面板与 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
+8
View File
@@ -27,6 +27,13 @@ target_include_directories(ImGuizmo PUBLIC ${IMGUIZMO_DIR})
target_compile_definitions(ImGuizmo PUBLIC IMGUI_DEFINE_MATH_OPERATORS)
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 帧分析)
add_library(tracy STATIC ${CMAKE_SOURCE_DIR}/Lib/tracy/public/TracyClient.cpp)
target_include_directories(tracy PUBLIC ${CMAKE_SOURCE_DIR}/Lib/tracy/public)
@@ -80,6 +87,7 @@ target_link_libraries(Engine PUBLIC
spdlog::spdlog
stb
assimp::assimp
ufbx
meshoptimizer
Jolt::Jolt
yaml-cpp::yaml-cpp
@@ -16,6 +16,7 @@
#define HELLOVULKAN_ANIMATIONATTACH_H
#include <Engine/Core/Types.h>
#include <Engine/Renderer/Model.h>
#include <string>
#include <vector>
@@ -51,6 +52,16 @@ namespace Engine {
AttachResult AttachClipsToModel(Model& target, ParsedModel&& src,
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
@@ -64,15 +64,19 @@ namespace Engine::Animation {
// outGlobals 尺寸 = model.GetNodes().size(),父序 DFS 累乘。
// (crossfade 双层 = EvaluateLayers 的 count==2 特例,同一套
// 加权混合数学,消掉历史双份代码)
// inPlace = 丢弃动画子树根节点的位移(Unity in-place 语义:
// 根运动开关关闭,原地播放,旋转保留)
void Evaluate(const Model& model, const AnimationClip& cur,
f32 curTime, const CrossfadeState& xf,
std::vector<glm::mat4>& outGlobals);
std::vector<glm::mat4>& outGlobals,
bool inPlace = false);
// N 层加权混合(BlendTree / 状态机过渡的通用入口):
// 逐层采样局部 TRS → 权重归一化混合(pos/scl lerp、rot 渐进
// slerp)→ global 累乘。全空/全零权重 = rest 姿态
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 原样累乘。
// 与渲染 identity palette 的表现一致(无动画/组件禁用实体的
+112 -15
View File
@@ -1,13 +1,24 @@
//
// HumanoidRig:Unity Avatar 语义的跨骨架重定向映射。
//
// 资产组装现实:角色模型与动画 clip 是两套独立资产,骨骼命名完全
// 不同源(英文/Mixamo/UE/Blender/MMD 日文)。Unity Humanoid 的解法
// 是把任意命名骨架映射到标准人形槽位(Avatar bone mapping),动画
// 经槽位转换后即可驱动任意人形角色。
// 架构分层(对齐 Unity Mecanim / UE IK Retargeter 的做法):
//
// 这里实现其中枢:名字归一化 → 槽位识别(关键词表,en/Mixamo/MMD)
// → 源/目标骨架间生成 node 下标映射对。同名直映是映射的特例。
// ┌─ standard ─ 标准人形拓扑:HumanoidSlot 52 槽(主干 6 + 臂链
// │ 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
@@ -16,13 +27,24 @@
#include <Engine/Core/Types.h>
#include <Engine/Renderer/Model.h>
#include <glm/glm.hpp>
#include <string_view>
#include <utility>
#include <vector>
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 {
Hips = 0,
Spine,
@@ -34,38 +56,113 @@ namespace Engine::Animation {
LeftUpperArm,
LeftLowerArm,
LeftHand,
LeftThumb1, // 指骨区起点(FingerSlot 算术依赖连续布局)
LeftThumb2,
LeftThumb3,
LeftIndex1,
LeftIndex2,
LeftIndex3,
LeftMiddle1,
LeftMiddle2,
LeftMiddle3,
LeftRing1,
LeftRing2,
LeftRing3,
LeftPinky1,
LeftPinky2,
LeftPinky3,
LeftUpperLeg,
LeftLowerLeg,
LeftFoot,
LeftToes,
RightShoulder,
RightUpperArm,
RightLowerArm,
RightHand,
LeftUpperLeg,
LeftLowerLeg,
LeftFoot,
RightThumb1,
RightThumb2,
RightThumb3,
RightIndex1,
RightIndex2,
RightIndex3,
RightMiddle1,
RightMiddle2,
RightMiddle3,
RightRing1,
RightRing2,
RightRing3,
RightPinky1,
RightPinky2,
RightPinky3,
RightUpperLeg,
RightLowerLeg,
RightFoot,
RightToes,
Count
};
// 槽位侧别(左右对称骨骼的识别结果)
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);
// 名字归一化 + 槽位识别。
// ---- mapper:名字归一化 + 槽位识别 ----
// 名字归一化 + 查方言表识别槽位。
// "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,
bool& ok);
// 跨骨架自动映射:返回 (srcNodeIdx, dstNodeIdx) 对列表。
// 同名 node 直映优先(同源拆分资产零损耗),余下按槽位匹配;
// 一槽多候选时取语义关键词优先级表中的顺序(先高后低)。
// 同名 node 直映优先(同源拆分资产零损耗),余下按槽位一对一配对;
// 同槽多候选时取方言表内的优先序(先高后低)。
std::vector<std::pair<u32, u32>> AutoMapHumanoid(
const std::vector<ModelNode>& srcNodes,
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
#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 AddDeallocation(u64 bytes);
// ---- 采样进程真实内存 ----
void SampleProcessMemory();
// ---- 打印 ----
void Print() const;
void PrintDetailed() const;
void PrintComparison() const;
// ---- 只读快照(面板/遥测消费)----
struct Snapshot
{
u64 textureCount = 0;
u64 textureBytes = 0;
u64 meshCount = 0;
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:
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;
// 资源计数
@@ -81,9 +79,6 @@ namespace Engine
u64 m_FrameDeallocation = 0;
u64 m_TotalAllocation = 0;
u64 m_TotalDeallocation = 0;
// 进程快照
ProcessMemory m_ProcessMem{};
};
#define STATS Engine::Stats::Get()
+3
View File
@@ -480,6 +480,9 @@ namespace Engine::ECS {
// 重力倍率(UE CharacterMovement.GravityScale 同位):真实 9.81
// 对人形角色偏"棉花糖"(滞空 1.2s+),游戏手感普遍 2~3 倍
f32 gravityScale = 2.0f;
// 形状附加偏移:叠加在脚底约定(胶囊底端=原点)之上,模型
// pivot 不在脚底时对齐视觉网格用(Y+ 胶囊上抬)
glm::vec3 shapeOffset{0.0f};
bool operator==(const CharacterControllerComponent&) const = default;
// 实体原点 = 脚底(胶囊底端),符合角色 pivot 习惯
@@ -6,6 +6,11 @@
// "看不到"它(无碰撞解算,仅查询)。由 PhysicsWorld 对账创建/销毁并
// 在 PhysicsSystem::Update 之后驱动(Jolt 官方建议序)。
//
// 角色间碰撞(Jolt 设计行为):CharacterVirtual 彼此默认穿透,需经
// CharacterVsCharacterCollision 显式互查。PhysicsWorld 持有一份
// CharacterVsCharacterCollisionSimple 并注入每个角色(SetCharacterCollision),
// 使角色扫掠时也撞其它角色——进/出列表由本类构造/析构自动维护。
//
// 速度语义(游戏逻辑职责分工):
// - 水平移动:游戏逻辑每帧 SetCharacterVelocity 喂意图速度
// - 垂直分量:本类自动积分重力(着地清零向下分量);Jump 置垂直速度
@@ -36,13 +41,21 @@ namespace Engine {
f32 mass = 70.0f;
f32 maxSlopeAngle = 45.0f; // 度
f32 stepHeight = 0.4f; // 台阶攀爬高
// 形状附加偏移(相对"胶囊底端钉实体原点"的脚底约定):
// 模型 pivot 不在脚底时对齐用(Y+ 上抬)
glm::vec3 shapeOffset{0.0f};
bool operator==(const Params&) const = default;
};
CharacterController(JPH::PhysicsSystem& system, const Params& params,
const glm::vec3& position);
~CharacterController() = default; // Ref 引用计数自动释放
// 析构:先从角色间碰撞列表摘除(防悬垂指针),Ref 随即释放
~CharacterController();
// 注入角色间碰撞列表(PhysicsWorld 单份共享):登记本角色并打开
// 互查。构造后由 PhysicsWorld 创建时调用一次;列表须比本对象活得久
void SetCharacterCollision(JPH::CharacterVsCharacterCollisionSimple& list);
// 每物理步调用:重力积分 + ExtendedUpdate(扫掠滑动 + 贴地 +
// 上台阶)。allocator 与 PhysicsSystem::Update 串行复用同一池
@@ -59,6 +72,16 @@ namespace Engine {
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 画不到,须手动
// (脚本在物理位姿/脚底原点处画胶囊线框)
void DebugDraw(JPH::DebugRenderer& renderer) const;
@@ -67,6 +90,8 @@ namespace Engine {
Params m_Params;
JPH::Ref<JPH::CharacterVirtual> m_Character;
JPH::CharacterVirtual::ExtendedUpdateSettings m_Ext;
JPH::CharacterVsCharacterCollisionSimple* m_VsChar = nullptr; // 非拥有
JPH::Vec3 m_SweepVelocity{0.0f, 0.0f, 0.0f}; // 扫掠前速度快照
};
} // namespace Engine
+12 -1
View File
@@ -102,6 +102,9 @@ namespace Engine {
// 角色脚底位置(物理权威世界坐标):调试图元/特效锚点用它,
// 而不是 Transform().GetPosition()——后者在有父级时是本地坐标
glm::vec3 GetCharacterPosition(u32 entityBits) const;
// 角色"撞击强度"速度(上一次扫掠前的速度):撞墙/碎墙等判定用它,
// 而非 GetCharacterVelocity——后者已被 Jolt 抵消掉朝向墙面的分量
glm::vec3 GetCharacterImpactVelocity(u32 entityBits) const;
private:
// 创建时缓存的参数(变更检测 → 重建 body)
@@ -138,7 +141,8 @@ namespace Engine {
// 角色非刚体(不进 PhysicsSystem),无 body 清理负担
void SyncCharacters();
// 每物理步(PhysicsSystem::Update 之后,Jolt 官方建议序):
// 重力积分 + 扫掠 → 写回 TransformComponent(标脏)
// 重力积分 + 扫掠 → 写回 TransformComponent(标脏)+ 合成角色
// 碰撞事件(CharacterVirtual 不进 ContactListener,见函数注释)
void UpdateCharacters(f32 dt);
// Mesh 碰撞体:实体视觉网格 → Jolt 形状(Static=MeshShape /
// Dynamic=ConvexHull)。无 MeshRenderer 或构建失败返回 null(回退 Box)
@@ -165,9 +169,16 @@ namespace Engine {
std::unordered_map<u32, ConstraintRecord> m_Constraints;
// ---- 角色(entityBits → CharacterVirtual 封装)----
// 角色间碰撞互查表:Jolt CharacterVirtual 默认彼此穿透,须显式注入
// CharacterVsCharacterCollisionSimple 才互撞。声明在 m_Characters
// 之前(析构更晚)——角色析构时会回表 Remove,列表须后于角色释放
Scope<JPH::CharacterVsCharacterCollisionSimple> m_VsCharacterCollision;
struct CharacterEntry {
std::unique_ptr<CharacterController> cc;
glm::vec3 lastPos{0.0f}; // 编辑器拖动检测
// 上一步活跃接触的"对方实体"集合:本步对比出角色碰撞
// Enter/Exit(CharacterVirtual 非刚体,ContactListener 不收)
std::unordered_set<u32> contacts;
};
std::unordered_map<u32, CharacterEntry> m_Characters;
+24 -4
View File
@@ -47,7 +47,13 @@ namespace Engine {
// 无填充歧义),加字段必须双端同步(simple_shader*.frag)
struct MaterialParams {
// 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 透明度
glm::vec4 baseColor{1.0f, 1.0f, 1.0f, 1.0f};
// glTF 表面扩展(KHR_materials_ior / transmission / volume /
@@ -55,7 +61,14 @@ namespace Engine {
// y = 透射率(0 = 普通表面),z = 自发光强度,w = 体积厚度
//(折射屏幕偏移的世界距离,0 = 无弯曲直透)
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};
// glTF emissiveFactor:自发光颜色(rgb;黑 = 不发光)。emissive
// 槽默认兜底纹理是纯黑,无贴图时 Emissive Strength 乘出来恒黑
@@ -63,8 +76,10 @@ namespace Engine {
glm::vec4 emissiveFactor{0.0f, 0.0f, 0.0f, 0.0f};
// 每槽 KHR_texture_transform(glTF2 每个 textureInfo 独立声明
// offset/scale/rotation——烘焙单份顶点 UV 无法表达"一材质多槽
// 各异变换",收敛为 shader 侧线性式 uv' = A·uv + t,作用在本引擎
// v-up 顶点 UV 空间,换算推导见 Model.cpp glTF 分支注释)。
// 各异变换",收敛为 shader 侧线性式 uv' = A·uv + t,作用在 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)。
// 长度必须 = Material::kSlotCount;std140 vec4 数组步长 16B 与
// 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}};
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 {
@@ -47,8 +47,9 @@ namespace Engine {
// 材质资产数据段(磁盘格式 ↔ 内存值,无 GPU 状态)。
// texPaths = 文件系统相对 CWD 路径(与模型解析产物同源,glTF/FBX
// 纹理可直接快照);空串 = 该槽用共享默认纹理兜底。
// glb 内嵌纹理无独立文件路径 → 对 glb 模型 Create Override 时
// 纹理槽为空(回退默认纹理),参数/透明照常可编辑。
// glb 内嵌纹理在模型导入期已抽到 <模型目录>/textures/ 落盘并回写为
// 真实路径(Model::ParseFromDisk),故此处与文件纹理同语义——覆盖
// 材质可正常继承
struct MaterialAssetData {
std::array<std::string, Material::kSlotCount> texPaths;
MaterialParams params;
+6
View File
@@ -137,6 +137,12 @@ namespace Engine
// 纯 CPU:读盘 + assimp 解析 → 几何 + 材质引用。无线程状态。
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 缓冲 / 材质描述符。
// 解析失败返回 nullptr。
static Scope<Model> FromParsed(Device& device, MemoryAllocator& allocator,
@@ -31,6 +31,10 @@ namespace Engine {
class Device;
// 显存估算(按格式字节宽与 mip 链逐级累加;BC 压缩按 4x4 块计)——
// 分配器台账与 Texture 资源台账共用同一算法,两边永不漂移
u64 EstimateImageBytes(vk::Format fmt, u32 w, u32 h, u32 depth, u32 mips);
class MemoryAllocator {
public:
explicit MemoryAllocator(Device& device);
+542 -127
View File
@@ -1,17 +1,42 @@
//
// AnimationAttach 实现:外部动画资产 → 目标模型并入。
//
// 轨道映射两级:
// ① 同名直映——同源拆分资产(角色/动画同一骨架导出)零损耗;
// ② Humanoid 槽位重定向——名字不同源的组装资产(角色 FBX +
// 动画 FBX 各自制作),经 Unity Avatar 语义的标准人形槽位配对,
// 逐 key 烘焙"rest→anim 的增量变换"到目标骨架局部空间:
// 平移 out.pos = dstRestPos + (srcPos - srcRestPos)
// 旋转 Δ = srcRot * inv(srcRestRot);out.rot = Δ * dstRestRot
// (Δ 父空间左乘 = 骨骼自身摆动在两套骨架间等价传递)
// 烘焙后 clip 即目标骨架空间数据,评估器零改动。
// 五段管线(映射表一次构建后**冻结**,逐 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>
@@ -28,11 +53,15 @@
#include <filesystem>
#include <unordered_map>
#include <utility>
#include <vector>
namespace Engine::Animation {
namespace {
constexpr u32 kNoNode = ~0u;
const glm::quat kIdentity(1.0f, 0.0f, 0.0f, 0.0f);
// mat4 → TRS 分解(rest 局部变换;引擎 node 无 shear)
struct TRS {
glm::vec3 pos{};
@@ -47,8 +76,7 @@ namespace Engine::Animation {
const glm::vec3 colX(m[0]);
const glm::vec3 colY(m[1]);
const glm::vec3 colZ(m[2]);
t.scl = {glm::length(colX), glm::length(colY),
glm::length(colZ)};
t.scl = {glm::length(colX), glm::length(colY), glm::length(colZ)};
if (t.scl.x > 1e-8f && t.scl.y > 1e-8f && t.scl.z > 1e-8f) {
t.rot = glm::quat_cast(glm::mat3(
colX / t.scl.x, colY / t.scl.y, colZ / t.scl.z));
@@ -56,14 +84,336 @@ namespace Engine::Animation {
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
AttachResult AttachClipsToModel(Model& target, ParsedModel&& src,
const std::string& sourcePath)
AttachResult AttachClipsToNodes(
const std::vector<ModelNode>& dstNodes,
const std::vector<AnimationClip>& existingClips,
ParsedModel&& src, const std::string& sourcePath,
std::vector<AnimationClip>& outExtra)
{
AttachResult res;
// 幂等:该源文件已并入过(自愈重放/重复 attach 直接返回)
for (const auto& c : target.GetClips()) {
for (const auto& c : existingClips) {
if (c.sourcePath == sourcePath) {
return res;
}
@@ -73,150 +423,215 @@ namespace Engine::Animation {
return res;
}
const std::vector<ModelNode>& dstNodes = target.GetNodes();
const std::vector<ModelNode>& srcNodes = src.nodes;
// ① 同名直映表
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 Plan pl = BuildPlan(srcNodes, dstNodes);
const std::string stem =
std::filesystem::path(sourcePath).stem().string();
std::vector<AnimationClip> extra;
extra.reserve(src.clips.size());
for (auto& clip : src.clips) {
AnimationClip out;
out.name = clip.name;
out.name = UniqueName(clip.name, stem, existingClips, outExtra);
out.duration = clip.duration;
out.tps = clip.tps;
out.sourcePath = sourcePath;
// 同名冲突(内嵌 clip 或其它源):源文件名循环加前缀直到
// 唯一。单轮前缀在"前缀名也撞"(如两次 attach 同名资产)时
// 仍会产出重复名——Controller/脚本按 clip 名引用,重名即歧义
{
const auto collides = [&](const std::string& n) {
for (const auto& c : target.GetClips()) {
if (c.name == n) return true;
}
for (const auto& c : extra) {
if (c.name == n) return true;
}
return false;
};
while (collides(out.name)) {
out.name = stem + "_" + out.name;
// 每 clip 的 src 轨道索引表(按 nodeIdx)
std::vector<const AnimChannel*> srcByNode(srcNodes.size(),
nullptr);
for (const auto& ch : clip.channels) {
if (ch.nodeIdx < srcByNode.size() && !ChannelEmpty(ch)) {
srcByNode[ch.nodeIdx] = &ch;
}
}
for (auto& ch : clip.channels) {
if (ch.positions.empty() && ch.rotations.empty() &&
ch.scales.empty()) {
continue; // 空轨道无用
// ---- 本 clip 的根运动源:承载节点里最深且有轨道者 ----
// (更深者世界变换已含其全部祖先运动,取一个即全包括)
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()) {
++res.channelsSkipped;
continue;
}
const SrcSlot& s = pl.bySrc[ch.nodeIdx];
const std::string& boneName = srcNodes[ch.nodeIdx].name;
AnimChannel remapped;
if (auto it = nameToIdx.find(boneName);
it != nameToIdx.end()) {
// ① 同名直搬
remapped.nodeIdx = it->second;
remapped.positions = std::move(ch.positions);
remapped.rotations = std::move(ch.rotations);
remapped.scales = std::move(ch.scales);
out.channels.push_back(std::move(remapped));
if (s.kind == Kind::Skip) {
++res.channelsSkipped;
continue;
}
if (s.kind == Kind::Root && ch.nodeIdx != rootSrc) {
continue; // 更深的承载节点已承载根运动,其余并入
}
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;
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;
// ② 槽位重定向:增量烘焙到目标骨架局部空间。
// 平移增量按骨长比缩放(|dstRest| / |srcRest|)——
// 两资产单位/比例不同源(FBX 常为 cm、glTF 为 m)时
// 不缩放会把模型拽飞;rest≈0 的根骨骼比例取 1
const auto mapIt = retargetMap.find(ch.nodeIdx);
if (mapIt == retargetMap.end()) {
++res.channelsSkipped; // 两级都落不上(手指/道具等)
continue;
}
const TRS srcRest = Decompose(
srcNodes[ch.nodeIdx].localTransform);
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});
const bool newRow = std::none_of(
res.retargetRows.begin(), res.retargetRows.end(),
[&](const RetargetRow& r)
{ return r.srcName == boneName; });
if (newRow) {
const HumanoidSlot slot = SlotAt(srcNodes, ch.nodeIdx);
res.retargetRows.push_back(
{s.kind == Kind::Root
? "RootMotion"
: HumanoidSlotName(slot),
boneName, dstNodes[s.dst].name});
}
}
}
// 全部轨道落不上 = 无可驱动骨骼,clip 不入表
if (out.channels.empty()) {
ENGINE_WARN("AttachClipsToModel: clip '{}' has no matching "
"bones in target (skipped)",
clip.name);
continue;
}
extra.push_back(std::move(out));
outExtra.push_back(std::move(out));
++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()) {
target.AppendClips(std::move(extra));
}
ENGINE_INFO("AttachClipsToModel: '{}' -> {} clips "
"({} named, {} retargeted, {} skipped)",
sourcePath, res.clipsAdded, res.channelsMatched,
res.channelsRetargeted, res.channelsSkipped);
return res;
}
+46 -9
View File
@@ -44,25 +44,62 @@ namespace Engine::Animation {
}
}
if (r <= 1e-4f || ch.empty()) return;
// 覆盖 v 角度的相邻扇区(角度归一 [0,2π),wrap 补全;方向子
// ≤16 个,栈数组零堆分配)
// 覆盖 v 角度的相邻扇区(角度归一 [0,2π);方向子 ≤16 个,
// 栈数组零堆分配)
constexpr f32 kTwoPi = 6.2831853f;
const f32 theta = glm::mod(glm::atan(z, x), kTwoPi);
u32 idx[16];
f32 ang[16];
constexpr u32 kMaxDir = 16;
u32 idx[kMaxDir];
f32 ang[kMaxDir];
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;
idx[nd] = i;
ang[nd] = glm::mod(glm::atan(ch[i].pos.y, ch[i].pos.x), kTwoPi);
++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) {
const u32 j = (i + 1) % nd;
const f32 ai = ang[i];
const f32 aj = (j == 0) ? ang[0] + kTwoPi : ang[j];
if (theta >= ai && theta <= aj) {
const f32 span = aj - ai;
if (j == 0) {
// wrap 扇区 [ai,2π)∪[0,ang0]:用 || 连接,&& 会让
// 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 wi = (1.0f - t) * r;
const f32 wj = t * r;
+23 -3
View File
@@ -128,7 +128,8 @@ namespace Engine::Animation {
void AnimationEvaluator::Evaluate(const Model& model,
const AnimationClip& cur, f32 curTime,
const CrossfadeState& xf,
std::vector<glm::mat4>& outGlobals)
std::vector<glm::mat4>& outGlobals,
bool inPlace)
{
// crossfade 双层 = N 层混合的特例(Direct 模式与 Controller
// 过渡共用同一套加权数学)。两权重和恒为 1,渐进归一化后精确
@@ -141,13 +142,14 @@ namespace Engine::Animation {
layers[1] = {xf.src, xf.srcTime, 1.0f - xf.wCur};
count = 2;
}
EvaluateLayers(model, layers, count, outGlobals);
EvaluateLayers(model, layers, count, outGlobals, inPlace);
}
void AnimationEvaluator::EvaluateLayers(const Model& model,
const PoseLayer* layers,
u32 count,
std::vector<glm::mat4>& outGlobals)
std::vector<glm::mat4>& outGlobals,
bool inPlace)
{
// ---- 逐层采样(容量跨帧复用;nullptr/零权重层跳过)----
if (m_Layers.size() < count) {
@@ -208,6 +210,24 @@ namespace Engine::Animation {
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);
}
+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);
});
@@ -493,7 +494,8 @@ namespace Engine::Animation {
m_Evaluator.EvaluateLayers(*model, m_LayerScratch.data(),
static_cast<u32>(
m_LayerScratch.size()),
m_Globals);
m_Globals,
!anim.applyRootMotion);
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 <glm/glm.hpp>
#include <glm/gtc/quaternion.hpp>
#include <array>
#include <cctype>
#include <string>
#include <unordered_map>
#include <vector>
namespace Engine::Animation {
namespace {
// ==================================================================
// standard:槽位结构化访问
// (概念分层:公共 API 在 Engine::Animation 作用域,与头文件声明
// 一致;真正的 C++ 内层 namespace 只用于 dialects/mapper 内部表)
// ==================================================================
// ---- 名字归一化:拆 (body, side) ----
//
// 规则:
// 1) 去 "mixamorig:" 等前缀
// 2) 小写;分隔符(空格/_/.-:)一律删除
// 3) left/right(Mixamo "LeftArm")与 左/右(MMD "左腕")统一
// 抽成 side,body 里剔除;.L/.R、_L/_R 后缀同上
//
// body 用**精确相等**匹配关键词(非子串)——"lowerarm"
// 不会误命中 "arm"
Side SideOfSlot(HumanoidSlot slot)
{
const u32 s = static_cast<u32>(slot);
if (s < 6) return Side::None; // 主干
return s < 29 ? Side::Left : Side::Right;
}
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;
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;
static const std::vector<BodySlot> kTable = [] {
std::vector<BodySlot> t;
t.reserve(80);
// Mixamo:handthumb1 ... handpinky3
constexpr std::array<std::string_view, 5> kMixamo{
"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);
cut("right", Side::Right);
cut("左", Side::Left); // MMD 前缀
cut("右", Side::Right);
cut(".l", Side::Left); // blender 惯例后缀
cut(".r", Side::Right);
cut("_l", Side::Left);
cut("_r", Side::Right);
return s;
// Rigify:defthumb01 ... deffpinky03(+ 无前缀 master 变体)
constexpr std::array<std::string_view, 5> kRigify{
"thumb", "findex", "fmiddle", "fring", "fpinky"};
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) {
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" 依赖分隔符存在,必须先于分隔符删除识别)
std::string NormalizeLower(std::string_view name)
{
@@ -63,10 +370,11 @@ namespace Engine::Animation {
c = static_cast<char>(std::tolower(
static_cast<unsigned char>(c)));
}
FoldFullWidthDigits(s);
return s;
}
// ② 分隔符删除(在 ExtractSide 之后调用;CJK 多字节原样过)
// ② 分隔符删除(side 抽取之后调用;CJK 多字节原样过)
std::string StripSeparators(const std::string& s)
{
std::string out;
@@ -80,105 +388,99 @@ namespace Engine::Animation {
return out;
}
// ---- body → 左侧槽位 关键词表(右侧 = 左槽 + 段偏移)----
// 精确相等匹配;顺序即优先级(先命中先用)
struct BodyRule {
std::string_view body;
HumanoidSlot left;
};
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)
// 窄 side 抽取:side 只出现在 Mixamo 前缀(LeftHand...)、
// MMD 前缀(左手首)与 Blender 末尾后缀(..._L/.L)。
// 不用任意位置 cut——Rigify "f_ring" 中间的 "_r" 会被误当
// 右侧后缀删掉(body 变 "fing" → Ring 指骨全军覆没的历史
// 陷阱),末尾匹配天然规避
Side ExtractSideNarrow(std::string& body)
{
if (side != Side::Right) return leftSlot;
const u32 s = static_cast<u32>(leftSlot);
const u32 legStart = static_cast<u32>(HumanoidSlot::LeftUpperLeg);
return static_cast<HumanoidSlot>(s + (s >= legStart ? 3 : 4));
Side s = Side::None;
if (body.rfind("left", 0) == 0) {
body.erase(0, 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)
{
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] : "?";
}
} // namespace mapper
// 名字归一化 + 方言查表识别。两轮:窄 side 抽取优先(避开
// f_ring 类中间子串陷阱),查表失败再用宽抽取兜底
HumanoidSlot MatchHumanoidSlot(std::string_view name, Side& side,
bool& ok)
{
side = Side::None;
ok = false;
std::string body = NormalizeLower(name);
side = ExtractSide(body);
body = StripSeparators(body);
if (body.empty()) return HumanoidSlot::Hips;
for (const BodyRule& r : kBodyRules) {
if (body == r.body) {
const std::string lower = mapper::NormalizeLower(name);
for (int pass = 0; pass < 2; ++pass) {
std::string body = lower;
const Side s = pass == 0
? mapper::ExtractSideNarrow(body)
: mapper::ExtractSideWide(body);
body = mapper::StripSeparators(body);
if (body.empty()) {
return HumanoidSlot::Hips;
}
HumanoidSlot slot;
if (dialects::Lookup(body, slot)) {
side = s;
ok = true;
return ToSide(r.left, side);
// 表存左槽名:右 side 经镜像换槽(主干槽镜像原样)
return s == Side::Right ? MirrorSlot(slot) : slot;
}
}
return HumanoidSlot::Hips;
}
// 跨骨架自动映射:同名直映 + 52 槽一对一配对(指/趾骨同为主干
// 槽位走统一通道,无独立特判)
std::vector<std::pair<u32, u32>> AutoMapHumanoid(
const std::vector<ModelNode>& srcNodes,
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);
auto slotOf = [](const std::vector<ModelNode>& nodes,
std::array<i32, kSlotCount>& out)
@@ -218,7 +520,7 @@ namespace Engine::Animation {
}
const u32 s = static_cast<u32>(slot);
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;
}
// ==================================================================
// 行走基 / 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
+10
View File
@@ -6,6 +6,8 @@
#include <Engine/Core/Window.h>
#include <Engine/Core/Input.h>
#include <Engine/Core/Log.h>
#include <Engine/Core/LogRing.h>
#include <Engine/Core/Stats.h>
#include <Engine/ECS/Scene.h>
#include <Engine/Physics/PhysicsWorld.h>
#include <Engine/Scripting/ScriptRuntime.h>
@@ -112,6 +114,12 @@ namespace Engine {
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");
m_Running = true;
@@ -133,6 +141,8 @@ namespace Engine {
while (m_Running) {
const auto frameStart = std::chrono::steady_clock::now();
STATS.BeginFrame(); // 帧内资源分配清零(面板帧流失真修正)
m_Window->PollEvents();
auto now = std::chrono::steady_clock::now();
+8 -1
View File
@@ -2,6 +2,7 @@
// Created by atdunbg on 2026/9/20.
//
#include <Engine/Core/Log.h>
#include <Engine/Core/LogRing.h>
#include "spdlog/sinks/basic_file_sink.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);
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
// 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->flush_on(spdlog::level::warn); // warn 及以上立即 flush,避免崩溃丢日志
+16 -196
View File
@@ -4,10 +4,6 @@
#include <Engine/Core/Stats.h>
#include <Engine/Core/Log.h>
#include <fstream>
#include <sstream>
#include <string>
namespace Engine
{
Stats& Stats::Get()
@@ -125,200 +121,24 @@ namespace Engine
m_TotalDeallocation += bytes;
}
// ============ 进程内存采样 ============
// ============ 只读快照 ============
Stats::ProcessMemory Stats::ReadProcessMemory() 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()
Stats::Snapshot Stats::TakeSnapshot() const
{
std::lock_guard lock(m_Mutex);
m_ProcessMem = ReadProcessMemory();
}
// ============ 打印 ============
static double MB(const u64 bytes) { return bytes / (1024.0 * 1024.0); }
void Stats::Print() const
{
std::lock_guard lock(m_Mutex);
const auto proc = ReadProcessMemory();
ENGINE_INFO("========== Resource Stats ==========");
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("================================================");
Snapshot s;
s.textureCount = m_TextureCount;
s.textureBytes = m_TextureBytes;
s.meshCount = m_MeshCount;
s.meshBytes = m_MeshBytes;
s.bufferCount = m_BufferCount;
s.bufferBytes = m_BufferBytes;
s.memoryBlockBytes = m_MemoryBlockBytes;
s.memoryUsedBytes = m_MemoryUsedBytes;
s.frameAllocation = m_FrameAllocation;
s.frameDeallocation = m_FrameDeallocation;
s.totalAllocation = m_TotalAllocation;
s.totalDeallocation = m_TotalDeallocation;
return s;
}
} // namespace Engine
+17
View File
@@ -23,10 +23,14 @@ namespace Engine {
constexpr i32 kKeyUp = 265, kKeyDown = 264,
kKeyLeft = 263, kKeyRight = 262;
constexpr i32 kKeySpace = 32;
constexpr i32 kKeyLShift = 340; // GLFW_KEY_LEFT_SHIFT
// 手柄标准布局
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 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;
@@ -139,6 +143,19 @@ namespace Engine {
const size_t jump = DefineAction("Jump", ActionKind::Button);
Bind(jump, {SourceKind::Key, kKeySpace, 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(
std::max(params.radius, 0.01f),
std::max(params.halfHeight, 0.0f));
s.mShapeOffset = JPH::Vec3(0.0f,
params.halfHeight + params.radius, 0.0f);
s.mShapeOffset = JPH::Vec3(
params.shapeOffset.x,
params.shapeOffset.y + params.halfHeight + params.radius,
params.shapeOffset.z);
m_Character = new JPH::CharacterVirtual(
&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);
}
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,
JPH::TempAllocator &allocator) {
// 重力积分:着地时清掉向下分量(防贴地累积穿透速度),悬空时累加
@@ -50,6 +66,10 @@ namespace Engine {
m_Character->SetLinearVelocity(v + gravity * dt);
}
// 扫掠前速度快照:撞墙判定用。ExtendedUpdate 会抵消朝向陡面/
// 墙面的速度分量,扫掠后再读就归零了(见头文件说明)
m_SweepVelocity = m_Character->GetLinearVelocity();
// 扫掠推进(撞墙滑动 + 斜坡限制 + StickToFloor 贴地 + WalkStairs
// 上台阶)。过滤器默认全放行:角色与静/动层全碰
m_Character->ExtendedUpdate(dt, gravity, m_Ext,
@@ -78,6 +98,16 @@ namespace Engine {
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 {
const JPH::RVec3 p = m_Character->GetPosition();
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
m_DebugDraw = CreateScope<PhysicsDebugDraw>();
// 角色间碰撞互查表(内部 Array 走 JPH 分配器,须在 call_once 之后)
m_VsCharacterCollision =
CreateScope<JPH::CharacterVsCharacterCollisionSimple>();
m_TempAllocator = CreateScope<JPH::TempAllocatorImpl>(16 * 1024 * 1024);
const u32 threads = std::max(1u, std::thread::hardware_concurrency() - 1);
m_JobSystem = CreateScope<JPH::JobSystemThreadPool>(
@@ -208,6 +212,7 @@ namespace Engine {
// 释放烘焙几何——必须先于 UnregisterTypes
m_DebugDraw.reset();
m_Bodies.clear();
m_Characters.clear(); // 角色析构回 m_VsCharacterCollision 摘除,先于 Jolt 反注册
m_ContactListener.reset(); // 监听器先于 system 析构(持 m_Owner 引用)
m_System.reset();
// 单世界假设:进程退出时统一反注册(多世界时改为引用计数)
@@ -695,20 +700,24 @@ namespace Engine {
p.mass = cc.mass;
p.maxSlopeAngle = cc.maxSlopeAngle;
p.stepHeight = cc.stepHeight;
p.shapeOffset = cc.shapeOffset;
auto found = m_Characters.find(bits);
if (found == m_Characters.end()) {
CharacterEntry entry;
entry.cc = std::make_unique<CharacterController>(
*m_System, p, tr.GetWorldPosition());
entry.cc->SetCharacterCollision(*m_VsCharacterCollision);
entry.lastPos = tr.GetWorldPosition();
m_Characters.emplace(bits, std::move(entry));
continue;
}
if (found->second.cc->GetParams() != p) {
// 参数变更 → 重建(位置沿用当前 Transform)
// 参数变更 → 重建(位置沿用当前 Transform);旧实例析构
// 自动摘除旧条目,新实例重新登记
found->second.cc = std::make_unique<CharacterController>(
*m_System, p, tr.GetWorldPosition());
found->second.cc->SetCharacterCollision(*m_VsCharacterCollision);
found->second.lastPos = tr.GetWorldPosition();
continue;
}
@@ -742,6 +751,42 @@ namespace Engine {
static_cast<entt::entity>(bits), p,
tr->GetWorldRotationQuat(), tr->GetWorldScale());
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
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)----
void PhysicsWorld::Writeback() {
+11 -4
View File
@@ -88,10 +88,17 @@ namespace Engine
",e:" + std::to_string(params.emissiveFactor.x) +
"," + std::to_string(params.emissiveFactor.y) +
"," + std::to_string(params.emissiveFactor.z);
// per-slot UV 变换 + unlit 入键:同一张贴图配不同变换是不同材质
//(KHR_texture_transform 测试模型:一张棋盘格被 82 个材质以不同
// 变换共享,键漏了会串成同一份)
key += ",m:" + std::to_string(params.misc.x);
// per-slot UV 变换 + unlit + UV 镜像入键:同一张贴图配不同变换是
// 不同材质(KHR_texture_transform 测试模型:一张棋盘格被 82 个材质
// 以不同变换共享,键漏了会串成同一份)
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) {
key += ",u" + std::to_string(slot) + ":" +
std::to_string(params.uvA[slot].x) + "," +
+4
View File
@@ -93,6 +93,8 @@ namespace Engine
EmitVec4(out, data.params.emissiveFactor);
out << YAML::Key << "Misc" << YAML::Value;
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
for (u32 slot = 0; slot < Material::kSlotCount; ++slot) {
out << YAML::Key << ("UvA" + std::to_string(slot)) << YAML::Value;
@@ -156,6 +158,8 @@ namespace Engine
m["EmissiveFactor"], MaterialParams{}.emissiveFactor);
out.params.misc = ReadVec4(
m["Misc"], MaterialParams{}.misc);
out.params.alphaOverlay = ReadVec4(
m["AlphaOverlay"], MaterialParams{}.alphaOverlay);
for (u32 slot = 0; slot < Material::kSlotCount; ++slot) {
out.params.uvA[slot] = ReadVec4(
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;
out << YAML::Key << "GravityScale" << YAML::Value
<< 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::EndMap;
}
@@ -681,6 +685,32 @@ namespace Engine::Serialization {
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"]) {
@@ -798,6 +828,11 @@ namespace Engine::Serialization {
cc.maxSlopeAngle = n["MaxSlopeAngle"].as<f32>(cc.maxSlopeAngle);
cc.stepHeight = n["StepHeight"].as<f32>(cc.stepHeight);
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);
// 状态(速度/着地)由 PhysicsWorld 对账重建,不存
}
+51 -1
View File
@@ -8,11 +8,55 @@
#include <vk_mem_alloc.h>
#include <stdexcept>
#include <algorithm>
#include "Engine/Core/Stats.h"
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
{
// props → VMA 创建参数:
@@ -88,6 +132,7 @@ namespace Engine {
out.MappedPtr = info.pMappedData; // 非 MAPPED 标记时为 null
STATS.AddMemoryUsed(size);
STATS.AddMemoryBlock(size); // 1:1 VMA 分配:块 == 占用(无子分配)
return out;
}
@@ -108,11 +153,15 @@ namespace Engine {
Allocation out;
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.Valid = true;
STATS.AddMemoryUsed(out.Size);
STATS.AddMemoryBlock(out.Size);
return out;
}
@@ -128,6 +177,7 @@ namespace Engine {
}
STATS.RemoveMemoryUsed(alloc.Size);
STATS.RemoveMemoryBlock(alloc.Size);
alloc = {};
}
+22 -2
View File
@@ -95,6 +95,19 @@ namespace Engine
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_Height = height;
m_Format = srgb ? vk::Format::eR8G8B8A8Srgb : vk::Format::eR8G8B8A8Unorm;
@@ -299,8 +312,12 @@ namespace Engine
m_Device.GetDevice().destroyCommandPool(pool);
m_Allocator.Destroy(stagingAlloc);
// 显存占用 = 宽 * 高 * 4 字节(RGBA8)
m_TextureBytes = static_cast<u64>(m_Width) * m_Height * 4;
// 显存占用按真实格式 + mip 链估算(与分配器台账同一算法)
if (m_TextureBytes != 0) {
STATS.RemoveTexture(m_TextureBytes); // 重建防双计
}
m_TextureBytes = EstimateImageBytes(m_Format, m_Width, m_Height, 1,
m_MipLevels);
STATS.AddTexture(m_TextureBytes);
}
@@ -492,6 +509,9 @@ namespace Engine
m_Device.GetDevice().destroyCommandPool(pool);
m_Allocator.Destroy(stagingAlloc);
if (m_TextureBytes != 0) {
STATS.RemoveTexture(m_TextureBytes); // 重建防双计
}
m_TextureBytes = total;
STATS.AddTexture(m_TextureBytes);
}