Files
EVEngine/test/animation_motion.cpp
西风逍遥游andCursor Agent ba9b17304e feat(animation): LitMotion-style Motion APIs (Phases 1–4) (#389)
* docs(animation): research LitMotion vs EVEngine Tween roadmap

Map Unity LitMotion's builder/bind/sequence/punch model onto the
existing animation Tween and ControlAnim stack, and outline a phased
Motion + Capability-binder plan that keeps SimulationStep and module
layering intact.

Co-authored-by: 西风逍遥游 <sunxfancy@gmail.com>

* feat(animation): add LitMotion-style Motion builder, runtime, and sinks

Introduce typed float/Vec2/Vec3 motion storage with generation-checked
handles, fluent builders, and borrowed push sinks. Animation::advance
now pumps MotionRuntime alongside Tween. Phase 1 covers bind/run,
complete/cancel, delay/loops/yoyo, and shared ease kinds.

Co-authored-by: 西风逍遥游 <sunxfancy@gmail.com>

* feat(animation): Phase 2 MotionSequence, ease extensions, and script bindings

Add LitMotion-style Append/Join/Insert/AppendInterval sequencing on MotionRuntime,
Back/Elastic/Bounce ease kinds, builder to()/takeDesc for unspawned scheduling,
and Squirrel newMotion/newMotionSequence wrappers with matching docs and tests.

Co-authored-by: 西风逍遥游 <sunxfancy@gmail.com>

* docs(animation): document Motion script bindings for Source quality gate

List cancelOnError, childCount, cursor, getMotionCount, and itemCount in
animation.md so check_bindings --strict no longer reports NEW gaps.

Co-authored-by: 西风逍遥游 <sunxfancy@gmail.com>

* docs(animation): add ownership/lifetime contracts on MotionRuntime resolvers

Document borrowed-pointer lifetime for resolveFloat/Vec2/Vec3 so the
architecture api-lifetime gate no longer flags them as undocumented.

Co-authored-by: 西风逍遥游 <sunxfancy@gmail.com>

* feat(animation): Phase 3 Punch/Shake adapters and Color/Quat motions

Add LitMotion-style Punch/Shake oscillation evaluation on float/Vec2/Vec3
motions, plus Color lerp and Quat slerp pools with builders, Animation
factories, script bindings, tests, and docs.

Co-authored-by: 西风逍遥游 <sunxfancy@gmail.com>

* feat(animation): Phase 4 motion pool ensureCapacity and free-list recycle

Pre-size float/typed motion pools with ensureCapacity, recycle completed
slots via O(1) free-lists, add FloatBufferSink for contiguous batch writes,
expose capacity queries to script, and cover with recycle/buffer/bench tests.

Co-authored-by: 西风逍遥游 <sunxfancy@gmail.com>

* ci(linux): raise Build linux debug timeout from 40m to 60m

Cold third-party + sccache misses were hitting the 40-minute step timeout
at ~94% of unit_test compile (PR #389). Job budget remains 90m.

Co-authored-by: 西风逍遥游 <sunxfancy@gmail.com>

* ci(linux): raise ASan RelWithDebInfo build timeout from 100m to 130m

Cold PR builds with sccache READ_ONLY miss hit the 100-minute step timeout
at ~94% of unit_test compile (run 34823491554). Job budget remains 270m.

Co-authored-by: 西风逍遥游 <sunxfancy@gmail.com>

* ci(macos): retry Dawn Metal configure after incomplete abseil clone

Dawn's googlesource shallow fetch can leave third_party/abseil-cpp without
CMakeLists.txt (macOS job 103997514884). Retry configure up to 3 times after
clearing the incomplete checkout; apply the same guard on the Windows Dawn
parity lane.

Co-authored-by: 西风逍遥游 <sunxfancy@gmail.com>

* ci: retrigger after transient GitHub/Vulkan download 504s

macOS passed with the Dawn abseil retry. Windows/iOS/ASan/WebGPU failed
early on versions.json / utf8proc / zstd / emdawnwebgpu HTTP 504s unrelated
to this PR; re-run permissions are unavailable so nudge CI with an empty
commit.

Co-authored-by: 西风逍遥游 <sunxfancy@gmail.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-09-15 00:27:46 +08:00

417 lines
13 KiB
C++

#include "zeroerr/assert.h"
#include "zeroerr/unittest.h"
#include "animation/Animation.h"
#include "animation/MotionBuilder.h"
#include "animation/MotionSequence.h"
#include "animation/MotionTypes.h"
#include "common/Time.h"
#include <chrono>
#include <cmath>
#include <cstdlib>
#include <iostream>
#include <memory>
#include <vector>
#include <utility>
using namespace eve::animation;
namespace {
eve::SimulationStep stepAt(std::uint64_t tick, double seconds) {
return eve::SimulationStep{eve::SimulationTick{tick},
eve::Duration::fromSeconds(seconds).expect("motion test dt")};
}
} // namespace
TEST_CASE("animation.motion.floatBindLinear") {
auto *anim = Animation::create();
float out = -1.f;
FloatPointerSink sink(&out);
auto handle = anim->motion(0.f, 100.f, 1.f).ease("linear").bind(sink).expect("spawn");
CHECK(anim->motions().isActive(handle));
CHECK(std::fabs(out - 0.f) < 1e-5f);
{
auto advanced = anim->advance(stepAt(1, 0.5));
REQUIRE(advanced.ok());
}
CHECK(std::fabs(out - 50.f) < 1e-3f);
{
auto advanced = anim->advance(stepAt(2, 0.5));
REQUIRE(advanced.ok());
}
CHECK(std::fabs(out - 100.f) < 1e-3f);
CHECK(!anim->motions().isActive(handle));
CHECK_EQ(anim->getMotionCount(), 0);
}
TEST_CASE("animation.motion.completeAndCancel") {
auto *anim = Animation::create();
float out = 0.f;
FloatPointerSink sink(&out);
int completed = 0;
int cancelled = 0;
auto a = anim->motion(0.f, 10.f, 1.f)
.onComplete([&] { ++completed; })
.bind(sink)
.expect("spawn a");
{
auto advanced = anim->advance(stepAt(1, 0.25));
REQUIRE(advanced.ok());
}
{
auto done = anim->motions().complete(a);
REQUIRE(done.ok());
}
CHECK(std::fabs(out - 10.f) < 1e-4f);
CHECK_EQ(completed, 1);
CHECK(!anim->motions().isActive(a));
auto b = anim->motion(0.f, 10.f, 1.f)
.onCancel([&] { ++cancelled; })
.bind(sink)
.expect("spawn b");
{
auto advanced = anim->advance(stepAt(2, 0.1));
REQUIRE(advanced.ok());
}
{
auto cancelledResult = anim->motions().cancel(b);
REQUIRE(cancelledResult.ok());
}
CHECK_EQ(cancelled, 1);
CHECK(!anim->motions().isActive(b));
}
TEST_CASE("animation.motion.yoyoLoopsAndVec2") {
auto *anim = Animation::create();
float x = 0.f, y = 0.f;
Vec2PointerSink sink(&x, &y);
auto handle = anim->motionVec2(MotionVec2{0.f, 0.f}, MotionVec2{10.f, 20.f}, 1.f)
.ease("linear")
.loops(2, MotionLoopMode::Yoyo)
.bind(sink)
.expect("spawn vec2");
{
auto advanced = anim->advance(stepAt(1, 1.0));
REQUIRE(advanced.ok());
}
CHECK(std::fabs(x - 10.f) < 1e-3f);
CHECK(std::fabs(y - 20.f) < 1e-3f);
CHECK(anim->motions().isActive(handle));
{
auto advanced = anim->advance(stepAt(2, 1.0));
REQUIRE(advanced.ok());
}
CHECK(std::fabs(x - 0.f) < 1e-3f);
CHECK(std::fabs(y - 0.f) < 1e-3f);
CHECK(!anim->motions().isActive(handle));
}
TEST_CASE("animation.motion.staleHandleRejected") {
auto *anim = Animation::create();
auto handle = anim->motion(0.f, 1.f, 0.1f).run().expect("spawn");
{
auto advanced = anim->advance(stepAt(1, 0.1));
REQUIRE(advanced.ok());
}
CHECK(!anim->motions().isActive(handle));
auto value = anim->motions().floatValue(handle);
CHECK(!value.ok());
value.ignore("expected stale handle");
}
TEST_CASE("animation.motion.easeBackElasticBounce") {
CHECK(evaluateMotionEase(0.f, "inBack") == 0.f);
CHECK(evaluateMotionEase(1.f, "outBack") == 1.f);
CHECK(evaluateMotionEase(0.5f, "outBack") > 1.f);
CHECK(evaluateMotionEase(0.f, "inElastic") == 0.f);
CHECK(evaluateMotionEase(1.f, "outElastic") == 1.f);
CHECK(evaluateMotionEase(0.f, "inBounce") == 0.f);
CHECK(evaluateMotionEase(1.f, "outBounce") == 1.f);
CHECK(evaluateMotionEase(0.5f, "inOutBounce") > 0.f);
}
TEST_CASE("animation.motion.sequenceAppendJoinInterval") {
auto *anim = Animation::create();
float a = -1.f;
float b = -1.f;
FloatPointerSink sinkA(&a);
FloatPointerSink sinkB(&b);
auto seq = anim->sequence();
{
auto scheduled =
seq.append(std::move(anim->motion(0.f, 10.f, 1.f).ease("linear").to(sinkA)));
REQUIRE(scheduled.ok());
}
{
auto scheduled = seq.appendInterval(0.5f);
REQUIRE(scheduled.ok());
}
{
auto scheduled =
seq.join(std::move(anim->motion(0.f, 20.f, 1.f).ease("linear").to(sinkB)));
REQUIRE(scheduled.ok());
}
CHECK_EQ(seq.itemCount(), 2);
CHECK(std::fabs(seq.duration() - 1.5f) < 1e-4f);
auto playback = seq.run().expect("sequence run");
CHECK_EQ(playback.childCount(), 2);
CHECK(playback.isActive());
// Join starts at the last Append start (t=0), so A and B advance together.
{
auto advanced = anim->advance(stepAt(1, 0.5));
REQUIRE(advanced.ok());
}
CHECK(std::fabs(a - 5.f) < 1e-3f);
CHECK(std::fabs(b - 10.f) < 1e-3f);
{
auto advanced = anim->advance(stepAt(2, 0.5));
REQUIRE(advanced.ok());
}
CHECK(std::fabs(a - 10.f) < 1e-3f);
CHECK(std::fabs(b - 20.f) < 1e-3f);
CHECK(!playback.isActive());
}
TEST_CASE("animation.motion.sequenceInsertAndComplete") {
auto *anim = Animation::create();
float x = 0.f;
float y = 0.f;
FloatPointerSink sinkX(&x);
FloatPointerSink sinkY(&y);
auto seq = anim->sequence();
{
auto scheduled =
seq.append(std::move(anim->motion(0.f, 10.f, 1.f).ease("linear").to(sinkX)));
REQUIRE(scheduled.ok());
}
{
auto scheduled = seq.insert(
0.5f, std::move(anim->motion(0.f, 100.f, 0.5f).ease("linear").to(sinkY)));
REQUIRE(scheduled.ok());
}
auto playback = seq.run().expect("sequence run");
{
auto advanced = anim->advance(stepAt(1, 0.5));
REQUIRE(advanced.ok());
}
CHECK(std::fabs(x - 5.f) < 1e-3f);
CHECK(std::fabs(y - 0.f) < 1e-3f);
{
auto advanced = anim->advance(stepAt(2, 0.25));
REQUIRE(advanced.ok());
}
CHECK(std::fabs(y - 50.f) < 1e-2f);
{
auto done = playback.complete();
REQUIRE(done.ok());
}
CHECK(std::fabs(x - 10.f) < 1e-3f);
CHECK(std::fabs(y - 100.f) < 1e-3f);
CHECK(!playback.isActive());
}
TEST_CASE("animation.motion.sequenceRejectsInfiniteLoops") {
auto *anim = Animation::create();
auto seq = anim->sequence();
auto result =
seq.append(std::move(anim->motion(0.f, 1.f, 0.2f).loops(-1, MotionLoopMode::Restart)));
CHECK(!result.ok());
result.ignore("expected infinite-loop rejection");
}
TEST_CASE("animation.motion.punchShakeFloat") {
auto *anim = Animation::create();
float out = -1.f;
FloatPointerSink sink(&out);
auto punch = anim->punch(0.f, 10.f, 1.f).frequency(2).dampingRatio(0.f).bind(sink).expect("punch");
CHECK(anim->motions().isActive(punch));
CHECK(std::fabs(out - 0.f) < 1e-5f); // t=0 envelope is 0
{
auto advanced = anim->advance(stepAt(1, 0.25));
REQUIRE(advanced.ok());
}
// frequency=2, undamped: sin(2*pi*0.25)=sin(pi/2)=1 => value ~= 10
CHECK(std::fabs(out - 10.f) < 1e-2f);
{
auto advanced = anim->advance(stepAt(2, 0.75));
REQUIRE(advanced.ok());
}
CHECK(std::fabs(out - 0.f) < 1e-3f); // settles at base
CHECK(!anim->motions().isActive(punch));
out = 0.f;
auto shake = anim->shake(0.f, 5.f, 0.5f).frequency(4).dampingRatio(0.f).seed(7).bind(sink).expect("shake");
{
auto advanced = anim->advance(stepAt(3, 0.1));
REQUIRE(advanced.ok());
}
CHECK(std::fabs(out) > 1e-3f); // moved off base
{
auto advanced = anim->advance(stepAt(4, 0.4));
REQUIRE(advanced.ok());
}
CHECK(std::fabs(out - 0.f) < 1e-3f);
CHECK(!anim->motions().isActive(shake));
}
TEST_CASE("animation.motion.colorLerpAndQuatSlerp") {
auto *anim = Animation::create();
float r = 0, g = 0, b = 0, a = 0;
ColorPointerSink colorSink(&r, &g, &b, &a);
auto color = anim->motionColor(MotionColor{0.f, 0.f, 0.f, 1.f}, MotionColor{1.f, 0.f, 0.f, 1.f}, 1.f)
.ease("linear")
.bind(colorSink)
.expect("color");
{
auto advanced = anim->advance(stepAt(1, 0.5));
REQUIRE(advanced.ok());
}
CHECK(std::fabs(r - 0.5f) < 1e-3f);
CHECK(std::fabs(a - 1.f) < 1e-5f);
{
auto advanced = anim->advance(stepAt(2, 0.5));
REQUIRE(advanced.ok());
}
CHECK(std::fabs(r - 1.f) < 1e-3f);
CHECK(!anim->motions().isActive(color));
float qx = 0, qy = 0, qz = 0, qw = 1;
QuatPointerSink quatSink(&qx, &qy, &qz, &qw);
// 180 deg about Y: (0,1,0,0) from identity
auto quat = anim->motionQuat(MotionQuat{0.f, 0.f, 0.f, 1.f}, MotionQuat{0.f, 1.f, 0.f, 0.f}, 1.f)
.ease("linear")
.bind(quatSink)
.expect("quat");
{
auto advanced = anim->advance(stepAt(3, 0.5));
REQUIRE(advanced.ok());
}
// slerp mid of identity -> (0,1,0,0) is ~ (0, sin(pi/4), 0, cos(pi/4))
CHECK(std::fabs(qy) > 0.1f); // mid-slerp has Y component
CHECK(std::fabs(std::sqrt(qx * qx + qy * qy + qz * qz + qw * qw) - 1.f) < 1e-3f);
{
auto advanced = anim->advance(stepAt(4, 0.5));
REQUIRE(advanced.ok());
}
CHECK(std::fabs(qy - 1.f) < 1e-3f);
CHECK(std::fabs(qw - 0.f) < 1e-3f);
CHECK(!anim->motions().isActive(quat));
}
TEST_CASE("animation.motion.oscillationHelpers") {
CHECK(std::fabs(evaluateMotionOscillation(0.f, 10, 0.f)) < 1e-6f);
CHECK(std::fabs(evaluateMotionOscillation(1.f, 10, 0.f)) < 1e-6f);
CHECK(std::fabs(evaluateMotionOscillation(0.25f, 2, 0.f) - 1.f) < 1e-4f);
const float a = evaluateMotionShakeSign(42u, 8, 0.3f, 0);
const float b = evaluateMotionShakeSign(42u, 8, 0.3f, 0);
CHECK(std::fabs(a - b) < 1e-6f); // deterministic
CHECK(a >= -1.f);
CHECK(a <= 1.f);
}
TEST_CASE("animation.motion.ensureCapacityRecycle") {
auto *anim = Animation::create();
anim->ensureMotionCapacity(128);
CHECK_EQ(anim->getMotionFloatCapacity(), 128);
CHECK_EQ(anim->getMotionFloatFreeCount(), 128);
float out = 0.f;
FloatPointerSink sink(&out);
auto handle = anim->motion(0.f, 1.f, 0.1f).ease("linear").bind(sink).expect("spawn");
CHECK_EQ(anim->getMotionFloatFreeCount(), 127);
CHECK(anim->motions().isActive(handle));
{
auto advanced = anim->advance(stepAt(1, 0.1));
REQUIRE(advanced.ok());
}
CHECK(!anim->motions().isActive(handle));
CHECK_EQ(anim->getMotionFloatFreeCount(), 128); // recycled
// churn: spawn/complete many times without growing capacity
for (int i = 0; i < 64; ++i) {
auto h = anim->motion(0.f, 1.f, 0.05f).ease("linear").bind(sink).expect("churn");
auto advanced = anim->advance(stepAt(static_cast<std::uint64_t>(2 + i), 0.05));
REQUIRE(advanced.ok());
CHECK(!anim->motions().isActive(h));
}
CHECK_EQ(anim->getMotionFloatCapacity(), 128);
}
TEST_CASE("animation.motion.floatBufferSinkBatch") {
auto *anim = Animation::create();
std::vector<float> buffer(8, -1.f);
std::vector<std::unique_ptr<FloatBufferSink>> sinks;
anim->ensureMotionCapacity(8);
for (std::size_t i = 0; i < buffer.size(); ++i) {
sinks.push_back(std::make_unique<FloatBufferSink>(buffer.data(), i));
anim->motion(0.f, static_cast<float>(i + 1), 1.f)
.ease("linear")
.bind(*sinks.back())
.expect("batch spawn");
}
{
auto advanced = anim->advance(stepAt(1, 0.5));
REQUIRE(advanced.ok());
}
for (std::size_t i = 0; i < buffer.size(); ++i) {
CHECK(std::fabs(buffer[i] - 0.5f * static_cast<float>(i + 1)) < 1e-3f);
}
}
TEST_CASE("animation.motion.bench10kFloatAdvance") {
if (!std::getenv("EVENGINE_MOTION_BENCH")) return;
constexpr int kCount = 10000;
constexpr int kFrames = 120;
auto *anim = Animation::create();
anim->ensureMotionCapacity(static_cast<std::size_t>(kCount));
std::vector<float> buffer(static_cast<std::size_t>(kCount), 0.f);
std::vector<std::unique_ptr<FloatBufferSink>> sinks;
sinks.reserve(static_cast<std::size_t>(kCount));
for (int i = 0; i < kCount; ++i) {
sinks.push_back(std::make_unique<FloatBufferSink>(buffer.data(), static_cast<std::size_t>(i)));
anim->motion(0.f, 1.f, 1.f).ease("linear").bind(*sinks.back()).expect("bench spawn");
}
CHECK_EQ(anim->getMotionCount(), kCount);
using clock = std::chrono::steady_clock;
const auto t0 = clock::now();
for (int f = 0; f < kFrames; ++f) {
auto advanced = anim->advance(stepAt(static_cast<std::uint64_t>(1 + f), 1.0 / 60.0));
REQUIRE(advanced.ok());
}
const auto t1 = clock::now();
const double ms =
std::chrono::duration_cast<std::chrono::duration<double, std::milli>>(t1 - t0).count();
std::cout << "[motion-bench] N=" << kCount << " frames=" << kFrames << " total_ms=" << ms
<< " per_frame_ms=" << (ms / kFrames) << " active=" << anim->getMotionCount()
<< std::endl;
CHECK(ms > 0.0);
}