From a63cd414e3fe52bfa748287db8aec5849a666c5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=A5=BF=E9=A3=8E=E9=80=8D=E9=81=A5=E6=B8=B8?= Date: Mon, 24 Aug 2026 22:25:14 +0800 Subject: [PATCH] perf(animation): add parallel batch evaluation and bone LOD --- docs/usr/modules/animation.md | 2 + src/modules/animation/AnimBatch.cpp | 47 ++++++++++++++++++++++ src/modules/animation/AnimBatch.h | 37 +++++++++++++++++ src/modules/animation/AnimClip.cpp | 12 ++++++ src/modules/animation/AnimClip.h | 2 + src/modules/animation/AnimPose.cpp | 25 +++++++++++- src/modules/animation/AnimSkeleton.cpp | 11 ++++++ src/modules/animation/AnimSkeleton.h | 9 +++++ src/modules/animation/Animation.cpp | 14 +++++++ src/modules/animation/Animation.h | 3 ++ test/CMakeLists.txt | 1 + test/animation_batch_lod.cpp | 55 ++++++++++++++++++++++++++ 12 files changed, 216 insertions(+), 2 deletions(-) create mode 100644 src/modules/animation/AnimBatch.cpp create mode 100644 src/modules/animation/AnimBatch.h create mode 100644 test/animation_batch_lod.cpp diff --git a/docs/usr/modules/animation.md b/docs/usr/modules/animation.md index a5a22dc9..2ce23491 100644 --- a/docs/usr/modules/animation.md +++ b/docs/usr/modules/animation.md @@ -412,6 +412,8 @@ Root-motion 位移会补偿 loop 末尾到开头的跳变;旋转返回单位 - 内建 GPU 蒙皮路径只需在 Mesh 创建后调用一次 `skin.bindGpuMesh(gfx, mesh)`,之后每帧在 `pose.computeWorld(skeleton)` 后调用 `skin.updateGpuMesh(mesh, pose)`;顶点保持 Bind Pose,Vulkan/WebGPU 顶点着色器读取四关节权重和最多 128 个骨骼矩阵完成变形,前向、阴影与 GBuffer 路径共享同一调色板。 - 离线导入后可调用 `clip.compress(positionError, rotationErrorDegrees, scaleError)`,以逐轨道曲线误差为上限删除冗余关键帧;首尾关键帧、clip 属性和 gameplay notify 均保留。 - `local targetClip = sourceClip.retarget(sourceSkeleton, targetSkeleton)` 按骨名匹配目标骨架,以源/目标 Bind Pose 的局部 TRS 差量重建轨道,并按对应骨段长度缩放位移。返回的新 clip 可继续压缩、进入状态机或 Animation Graph。 +- 批量角色求值使用 `AnimBatch`:通过 `anim.newBatch()` 创建,逐角色 `add(clip, skeleton, pose, time, lodLevel)`,最后 `evaluate(workerCount)`;另提供 `clear()`、`getCount()` 和 `getLastWorkerCount()`。`workerCount=0` 自动采用硬件并发度,任务以无锁原子索引分发,且拒绝同一 Pose 的并发写入。Pose 混合的平移/缩放在 x86/x64 使用 SSE 路径。 +- 骨骼 LOD 以 0 为最高细节。`skeleton.setBoneLodLimit(bone, highestLod)` 指定骨骼保留到哪一级,`skeleton.getBoneLodLimit(bone)` 可查询;`clip.sampleLod(...)` 和 `AnimBatch` 会让被裁剪轨道回退到目标 Bind Pose。根骨、碰撞骨和 IK 末端应保留较高 limit,手指与装饰骨通常只保留 LOD 0。 - 参数约束、默认值和返回类型以对应模块头文件及 `addFunc` 绑定为准;本文 API 快查与当前源码同步生成。 **源码:** [`src/modules/animation/`](../../../src/modules/animation/) diff --git a/src/modules/animation/AnimBatch.cpp b/src/modules/animation/AnimBatch.cpp new file mode 100644 index 00000000..d95010b2 --- /dev/null +++ b/src/modules/animation/AnimBatch.cpp @@ -0,0 +1,47 @@ +#include "animation/AnimBatch.h" + +#include "animation/AnimClip.h" +#include "animation/AnimPose.h" +#include "animation/AnimSkeleton.h" +#include "common/Exception.h" + +#include +#include +#include + +namespace eve::animation { + +void AnimBatch::add(AnimClip* clip, AnimSkeleton* skeleton, AnimPose* pose, float time, int lodLevel) { + if (!clip || !skeleton || !pose) throw Exception("AnimBatch.add: clip, skeleton and pose are required"); + if (lodLevel < 0) throw Exception("AnimBatch.add: lodLevel must be >= 0"); + for (const Job& job : jobs_) if (job.pose == pose) throw Exception("AnimBatch.add: pose is already queued"); + jobs_.push_back({clip, skeleton, pose, time, lodLevel}); +} + +void AnimBatch::evaluate(int workerCount) { + if (workerCount < 0) throw Exception("AnimBatch.evaluate: workerCount must be >= 0"); + if (jobs_.empty()) { lastWorkerCount_ = 0; return; } + const unsigned hardware = std::max(1u, std::thread::hardware_concurrency()); + const size_t count = std::min(jobs_.size(), static_cast(workerCount > 0 ? workerCount : hardware)); + lastWorkerCount_ = static_cast(count); + if (count == 1) { + for (Job& job : jobs_) job.clip->sampleLod(job.time, job.pose, job.skeleton, job.lodLevel); + return; + } + std::atomic next{0}; + std::vector workers; + workers.reserve(count); + for (size_t worker = 0; worker < count; ++worker) { + workers.emplace_back([&] { + for (;;) { + const size_t index = next.fetch_add(1, std::memory_order_relaxed); + if (index >= jobs_.size()) break; + Job& job = jobs_[index]; + job.clip->sampleLod(job.time, job.pose, job.skeleton, job.lodLevel); + } + }); + } + for (std::thread& worker : workers) worker.join(); +} + +} // namespace eve::animation diff --git a/src/modules/animation/AnimBatch.h b/src/modules/animation/AnimBatch.h new file mode 100644 index 00000000..d5e5f731 --- /dev/null +++ b/src/modules/animation/AnimBatch.h @@ -0,0 +1,37 @@ +#pragma once + +#include + +namespace eve::animation { + +class AnimClip; +class AnimPose; +class AnimSkeleton; + +/** @brief Parallel batch evaluator for independent character clip poses. Script type: `AnimBatch`. */ +class AnimBatch { +public: + AnimBatch() = default; + + /** @brief Queue one clip sample. The clip, skeleton and pose must outlive evaluate(). */ + void add(AnimClip* clip, AnimSkeleton* skeleton, AnimPose* pose, float time, int lodLevel = 0); + /** @brief Remove all queued jobs without deleting referenced objects. */ + void clear() { jobs_.clear(); } + int getCount() const { return static_cast(jobs_.size()); } + /** @brief Evaluate queued poses, using hardware concurrency when workerCount is zero. */ + void evaluate(int workerCount = 0); + int getLastWorkerCount() const { return lastWorkerCount_; } + +private: + struct Job { + AnimClip* clip = nullptr; + AnimSkeleton* skeleton = nullptr; + AnimPose* pose = nullptr; + float time = 0.f; + int lodLevel = 0; + }; + std::vector jobs_; + int lastWorkerCount_ = 0; +}; + +} // namespace eve::animation diff --git a/src/modules/animation/AnimClip.cpp b/src/modules/animation/AnimClip.cpp index 98209b4e..a8190976 100644 --- a/src/modules/animation/AnimClip.cpp +++ b/src/modules/animation/AnimClip.cpp @@ -297,6 +297,18 @@ void AnimClip::sample(float time, AnimPose* out, const AnimSkeleton* skeleton) c sampleClamped(wrapTime(time), out, skeleton); } +void AnimClip::sampleLod(float time, AnimPose* out, const AnimSkeleton* skeleton, int lodLevel) const { + if (!out || !skeleton) throw Exception("AnimClip.sampleLod: pose or skeleton is null"); + if (lodLevel < 0) throw Exception("AnimClip.sampleLod: lodLevel must be >= 0"); + const int boneCount = skeleton->getBoneCount(); + out->resize(boneCount); + time = clampf(wrapTime(time), 0.f, duration_); + for (int i = 0; i < boneCount; ++i) { + const TransformTRS& bind = skeleton->bindLocal(i); + out->local(i) = lodLevel <= skeleton->getBoneLodLimit(i) ? sampleBone(i, time, bind) : bind; + } +} + void AnimClip::sampleClamped(float time, AnimPose* out, const AnimSkeleton* skeleton) const { if (!out) throw Exception("AnimClip.sample: pose is null"); const int boneCount = skeleton ? skeleton->getBoneCount() : static_cast(tracks_.size()); diff --git a/src/modules/animation/AnimClip.h b/src/modules/animation/AnimClip.h index 6245ad97..a39fe5ca 100644 --- a/src/modules/animation/AnimClip.h +++ b/src/modules/animation/AnimClip.h @@ -93,6 +93,8 @@ public: * fall back to bind pose; otherwise identity. */ void sample(float time, AnimPose* out, const AnimSkeleton* skeleton = nullptr) const; + /** @brief Sample only bones enabled for lodLevel; culled tracks use target bind pose. */ + void sampleLod(float time, AnimPose* out, const AnimSkeleton* skeleton, int lodLevel) const; /** @brief Wrap or clamp time according to loop flag. */ float wrapTime(float time) const; diff --git a/src/modules/animation/AnimPose.cpp b/src/modules/animation/AnimPose.cpp index 18b7c1a8..a07a92fe 100644 --- a/src/modules/animation/AnimPose.cpp +++ b/src/modules/animation/AnimPose.cpp @@ -3,6 +3,10 @@ #include "common/Exception.h" +#if defined(__SSE2__) || defined(_M_X64) || defined(_M_IX86_FP) +#include +#endif + namespace eve::animation { namespace { @@ -124,8 +128,25 @@ void AnimPose::blendFrom(const AnimPose* a, const AnimPose* b, float t) { } resize(a->getBoneCount()); for (int i = 0; i < getBoneCount(); ++i) { - locals_[static_cast(i)] = - blendTRS(a->locals_[static_cast(i)], b->locals_[static_cast(i)], t); + const TransformTRS& av = a->locals_[static_cast(i)]; + const TransformTRS& bv = b->locals_[static_cast(i)]; + TransformTRS& out = locals_[static_cast(i)]; +#if defined(__SSE2__) || defined(_M_X64) || defined(_M_IX86_FP) + const __m128 weight = _mm_set1_ps(clampf(t, 0.f, 1.f)); + const __m128 oneMinus = _mm_sub_ps(_mm_set1_ps(1.f), weight); + alignas(16) float values[4]; + _mm_store_ps(values, _mm_add_ps(_mm_mul_ps(_mm_setr_ps(av.px, av.py, av.pz, 0.f), oneMinus), + _mm_mul_ps(_mm_setr_ps(bv.px, bv.py, bv.pz, 0.f), weight))); + out.px = values[0]; out.py = values[1]; out.pz = values[2]; + _mm_store_ps(values, _mm_add_ps(_mm_mul_ps(_mm_setr_ps(av.sx, av.sy, av.sz, 0.f), oneMinus), + _mm_mul_ps(_mm_setr_ps(bv.sx, bv.sy, bv.sz, 0.f), weight))); + out.sx = values[0]; out.sy = values[1]; out.sz = values[2]; +#else + out.px = lerpf(av.px, bv.px, t); out.py = lerpf(av.py, bv.py, t); out.pz = lerpf(av.pz, bv.pz, t); + out.sx = lerpf(av.sx, bv.sx, t); out.sy = lerpf(av.sy, bv.sy, t); out.sz = lerpf(av.sz, bv.sz, t); +#endif + slerpQuat(av.qx, av.qy, av.qz, av.qw, bv.qx, bv.qy, bv.qz, bv.qw, clampf(t, 0.f, 1.f), + out.qx, out.qy, out.qz, out.qw); } } diff --git a/src/modules/animation/AnimSkeleton.cpp b/src/modules/animation/AnimSkeleton.cpp index d05c46ba..1226ba45 100644 --- a/src/modules/animation/AnimSkeleton.cpp +++ b/src/modules/animation/AnimSkeleton.cpp @@ -72,6 +72,17 @@ void AnimSkeleton::setBindScale(int boneIndex, float x, float y, float z) { t.sz = z; } +void AnimSkeleton::setBoneLodLimit(int boneIndex, int highestLod) { + requireBone(boneIndex); + if (highestLod < 0) throw Exception("AnimSkeleton.setBoneLodLimit: highestLod must be >= 0"); + bones_[static_cast(boneIndex)].lodLimit = highestLod; +} + +int AnimSkeleton::getBoneLodLimit(int boneIndex) const { + requireBone(boneIndex); + return bones_[static_cast(boneIndex)].lodLimit; +} + float AnimSkeleton::getBindPositionX(int boneIndex) const { requireBone(boneIndex); return bones_[static_cast(boneIndex)].bind.px; diff --git a/src/modules/animation/AnimSkeleton.h b/src/modules/animation/AnimSkeleton.h index b523cbce..e5bfca67 100644 --- a/src/modules/animation/AnimSkeleton.h +++ b/src/modules/animation/AnimSkeleton.h @@ -45,6 +45,14 @@ public: float getBindScaleY(int boneIndex) const; float getBindScaleZ(int boneIndex) const; + /** + * @brief Set the highest animation LOD at which this bone is sampled (0 = full-detail only). + * Descendants can use lower limits than gameplay-critical roots and effectors. + */ + void setBoneLodLimit(int boneIndex, int highestLod); + /** @brief Highest LOD at which the bone is sampled; defaults to all LODs. */ + int getBoneLodLimit(int boneIndex) const; + const TransformTRS &bindLocal(int boneIndex) const; /** @brief Fill pose locals with bind pose. */ @@ -55,6 +63,7 @@ private: std::string name; int parent = -1; TransformTRS bind; + int lodLimit = 0x7fffffff; }; void requireBone(int boneIndex) const; diff --git a/src/modules/animation/Animation.cpp b/src/modules/animation/Animation.cpp index 3b2d4955..db66c181 100644 --- a/src/modules/animation/Animation.cpp +++ b/src/modules/animation/Animation.cpp @@ -1,6 +1,7 @@ #include "animation/Animation.h" #include "animation/AnimClip.h" #include "animation/AnimClipRegistry.h" +#include "animation/AnimBatch.h" #include "animation/AnimImporter.h" #include "animation/AnimPlayer.h" #include "animation/AnimGraph.h" @@ -309,6 +310,7 @@ AnimSkeleton *Animation::newSkeleton() { return new AnimSkeleton(); } AnimClip *Animation::newClip(const std::string &name) { return new AnimClip(name); } AnimPose *Animation::newPose(int boneCount) { return new AnimPose(boneCount); } +AnimBatch *Animation::newBatch() { return new AnimBatch(); } AnimPlayer *Animation::newPlayer(AnimSkeleton *skeleton) { return new AnimPlayer(skeleton); } AnimGraph *Animation::newGraph(AnimSkeleton *skeleton) { return new AnimGraph(skeleton); } @@ -522,6 +524,8 @@ void Animation::expose(ssq::Table &table) { sk.addFunc("setBindPosition", &AnimSkeleton::setBindPosition); sk.addFunc("setBindRotation", &AnimSkeleton::setBindRotation); sk.addFunc("setBindScale", &AnimSkeleton::setBindScale); + sk.addFunc("setBoneLodLimit", &AnimSkeleton::setBoneLodLimit); + sk.addFunc("getBoneLodLimit", &AnimSkeleton::getBoneLodLimit); sk.addFunc("getBindPositionX", &AnimSkeleton::getBindPositionX); sk.addFunc("getBindPositionY", &AnimSkeleton::getBindPositionY); sk.addFunc("getBindPositionZ", &AnimSkeleton::getBindPositionZ); @@ -558,6 +562,7 @@ void Animation::expose(ssq::Table &table) { clip.addFunc("compress", &AnimClip::compress); clip.addFunc("retarget", &AnimClip::retarget); clip.addFunc("sample", &AnimClip::sample); + clip.addFunc("sampleLod", &AnimClip::sampleLod); clip.addFunc("wrapTime", &AnimClip::wrapTime); auto pose = table.addClass( @@ -591,6 +596,14 @@ void Animation::expose(ssq::Table &table) { pose.addFunc("getWorldRotationW", &AnimPose::getWorldRotationW); pose.addFunc("getWorldMatrixElement", &AnimPose::getWorldMatrixElement); + auto batch = table.addClass( + "AnimBatch", std::function([]() -> AnimBatch * { return nullptr; }), true); + batch.addFunc("add", &AnimBatch::add); + batch.addFunc("clear", &AnimBatch::clear); + batch.addFunc("getCount", &AnimBatch::getCount); + batch.addFunc("evaluate", &AnimBatch::evaluate); + batch.addFunc("getLastWorkerCount", &AnimBatch::getLastWorkerCount); + auto skin = table.addClass( "AnimSkin", std::function([]() -> AnimSkin * { return nullptr; }), true); skin.addFunc("getVertexCount", &AnimSkin::getVertexCount); @@ -1114,6 +1127,7 @@ void Animation::expose(ssq::Class &cls) { cls.addFunc("newSkeleton", &Animation::newSkeleton); cls.addFunc("newClip", &Animation::newClip); cls.addFunc("newPose", &Animation::newPose); + cls.addFunc("newBatch", &Animation::newBatch); cls.addFunc("newPlayer", &Animation::newPlayer); cls.addFunc("newGraph", &Animation::newGraph); cls.addFunc("newStateMachine", &Animation::newStateMachine); diff --git a/src/modules/animation/Animation.h b/src/modules/animation/Animation.h index 6b072ad8..cfcc00da 100644 --- a/src/modules/animation/Animation.h +++ b/src/modules/animation/Animation.h @@ -31,6 +31,7 @@ class ControlPose; class AnimSkin; class AnimLattice; class AnimTrail; +class AnimBatch; class SpriteSheet; class SpriteClip; class SpriteAnim; @@ -105,6 +106,8 @@ public: AnimSkeleton *newSkeleton(); AnimClip *newClip(const std::string &name = ""); AnimPose *newPose(int boneCount = 0); + /** @brief Create a parallel batch clip evaluator. */ + AnimBatch *newBatch(); AnimPlayer *newPlayer(AnimSkeleton *skeleton); /** @brief Create a composable pose graph for the skeleton. */ AnimGraph *newGraph(AnimSkeleton *skeleton); diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index eb5c8b67..fa0a50d3 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -162,6 +162,7 @@ file(GLOB all_test_cpp ik.cpp animation.cpp animation_retarget_compression.cpp + animation_batch_lod.cpp animation_mixamo.cpp animation_skinned.cpp animation_lattice.cpp diff --git a/test/animation_batch_lod.cpp b/test/animation_batch_lod.cpp new file mode 100644 index 00000000..aff933c9 --- /dev/null +++ b/test/animation_batch_lod.cpp @@ -0,0 +1,55 @@ +#include "zeroerr/assert.h" +#include "zeroerr/unittest.h" + +#include "animation/AnimBatch.h" +#include "animation/AnimClip.h" +#include "animation/AnimPose.h" +#include "animation/AnimSkeleton.h" + +#include +#include +#include + +using namespace eve::animation; + +TEST_CASE("animation.batch.parallelEvaluationAndBoneLod") { + AnimSkeleton skeleton; + const int root = skeleton.addBone("root"); + const int spine = skeleton.addBone("spine", root); + const int finger = skeleton.addBone("finger", spine); + skeleton.setBindPosition(finger, 3.f, 0.f, 0.f); + skeleton.setBoneLodLimit(finger, 0); + + AnimClip clip("move"); + clip.setDuration(1.f); + clip.addPositionKey(root, 0.f, 0.f, 0.f, 0.f); + clip.addPositionKey(root, 1.f, 10.f, 0.f, 0.f); + clip.addPositionKey(finger, 0.f, 3.f, 0.f, 0.f); + clip.addPositionKey(finger, 1.f, 9.f, 0.f, 0.f); + + std::vector> poses; + AnimBatch batch; + for (int i = 0; i < 8; ++i) { + poses.push_back(std::make_unique()); + batch.add(&clip, &skeleton, poses.back().get(), 0.25f, 1); + } + batch.evaluate(4); + + CHECK(batch.getLastWorkerCount() == 4); + for (const auto& pose : poses) { + CHECK(std::fabs(pose->getLocalPositionX(root) - 2.5f) < 1e-5f); + CHECK(std::fabs(pose->getLocalPositionX(finger) - 3.f) < 1e-5f); + } +} + +TEST_CASE("animation.pose.simdBlendMatchesTrsBlend") { + AnimPose a(1), b(1), out; + a.setLocalPosition(0, 0.f, 2.f, 4.f); + a.setLocalScale(0, 1.f, 2.f, 3.f); + b.setLocalPosition(0, 10.f, 6.f, 0.f); + b.setLocalScale(0, 3.f, 4.f, 5.f); + out.blendFrom(&a, &b, 0.25f); + CHECK(std::fabs(out.getLocalPositionX(0) - 2.5f) < 1e-6f); + CHECK(std::fabs(out.getLocalPositionY(0) - 3.f) < 1e-6f); + CHECK(std::fabs(out.getLocalScaleZ(0) - 3.5f) < 1e-6f); +}