* 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>
417 lines
13 KiB
C++
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);
|
|
}
|