perf(animation): add parallel batch evaluation and bone LOD

This commit is contained in:
西风逍遥游 committed 2026-08-24 22:25:14 +08:00
1 parent a816043366
commit a63cd414e3
12 files changed
+216 -2

No files matched your search

+2
View File
@@ -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/)
+47
View File
@@ -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 <algorithm>
#include <atomic>
#include <thread>
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<size_t>(workerCount > 0 ? workerCount : hardware));
lastWorkerCount_ = static_cast<int>(count);
if (count == 1) {
for (Job& job : jobs_) job.clip->sampleLod(job.time, job.pose, job.skeleton, job.lodLevel);
return;
}
std::atomic<size_t> next{0};
std::vector<std::thread> 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
+37
View File
@@ -0,0 +1,37 @@
#pragma once
#include <vector>
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<int>(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<Job> jobs_;
int lastWorkerCount_ = 0;
};
} // namespace eve::animation
+12
View File
@@ -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<int>(tracks_.size());
+2
View File
@@ -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;
+23 -2
View File
@@ -3,6 +3,10 @@
#include "common/Exception.h"
#if defined(__SSE2__) || defined(_M_X64) || defined(_M_IX86_FP)
#include <immintrin.h>
#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<size_t>(i)] =
blendTRS(a->locals_[static_cast<size_t>(i)], b->locals_[static_cast<size_t>(i)], t);
const TransformTRS& av = a->locals_[static_cast<size_t>(i)];
const TransformTRS& bv = b->locals_[static_cast<size_t>(i)];
TransformTRS& out = locals_[static_cast<size_t>(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);
}
}
+11
View File
@@ -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<size_t>(boneIndex)].lodLimit = highestLod;
}
int AnimSkeleton::getBoneLodLimit(int boneIndex) const {
requireBone(boneIndex);
return bones_[static_cast<size_t>(boneIndex)].lodLimit;
}
float AnimSkeleton::getBindPositionX(int boneIndex) const {
requireBone(boneIndex);
return bones_[static_cast<size_t>(boneIndex)].bind.px;
+9
View File
@@ -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;
+14
View File
@@ -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<AnimPose>(
@@ -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>(
"AnimBatch", std::function<AnimBatch *()>([]() -> 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>(
"AnimSkin", std::function<AnimSkin *()>([]() -> 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);
+3
View File
@@ -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);
+1
View File
@@ -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
+55
View File
@@ -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 <cmath>
#include <memory>
#include <vector>
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<std::unique_ptr<AnimPose>> poses;
AnimBatch batch;
for (int i = 0; i < 8; ++i) {
poses.push_back(std::make_unique<AnimPose>());
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);
}