Files
EVEngine/test/animation_sprite_spine.cpp
T

566 lines
20 KiB
C++

#include "zeroerr/assert.h"
#include "zeroerr/unittest.h"
#include "animation/Animation.h"
#include "animation/SpineAnim.h"
#include "animation/SpineAtlas.h"
#include "animation/SpineSkeleton.h"
#include "animation/SpineSkeletonData.h"
#include "animation/SpriteAnim.h"
#include "animation/SpriteClip.h"
#include "animation/SpriteSheet.h"
#include "filesystem/Filesystem.h"
#include "graphics/AmbientOcclusion.h"
#include "graphics/AntiAliasing.h"
#include "graphics/Canvas.h"
#include "graphics/DrawItem2D.h"
#include "graphics/Font.h"
#include "graphics/GBuffer.h"
#include "graphics/GlobalIllumination.h"
#include "graphics/Graphics.h"
#include "graphics/Grass.h"
#include "graphics/Light.h"
#include "graphics/Material.h"
#include "graphics/Mesh.h"
#include "graphics/Outline.h"
#include "graphics/Quad.h"
#include "graphics/RenderControl.h"
#include "graphics/RenderSystem.h"
#include "graphics/ScreenSpaceReflection.h"
#include "graphics/Shader.h"
#include "graphics/Texture.h"
#include "graphics/Volumetric.h"
#include "graphics/Water.h"
#include "graphics/Waterfall.h"
#include "window/Window.h"
// Color lives in eve::graphics (see graphics/Canvas.h); keep the unqualified form.
using eve::graphics::Color;
#include <SDL2/SDL.h>
#include "common/Exception.h"
#include <cmath>
#include <cstdio>
#include <filesystem>
#include <memory>
#include <string>
#include <vector>
using namespace eve::animation;
TEST_CASE("animation.sprite.sheetGridAndNamed") {
std::unique_ptr<SpriteSheet> sheet(new SpriteSheet());
int added = sheet->setGrid(4, 2, 32, 48, 0, 0, 0, 0);
CHECK_EQ(added, 8);
CHECK_EQ(sheet->getFrameCount(), 8);
CHECK_EQ(sheet->getFrameX(0), 0);
CHECK_EQ(sheet->getFrameY(0), 0);
CHECK_EQ(sheet->getFrameWidth(0), 32);
CHECK_EQ(sheet->getFrameHeight(0), 48);
CHECK_EQ(sheet->getFrameX(5), 32); // row1 col1
CHECK_EQ(sheet->getFrameY(5), 48);
sheet->clear();
int i0 = sheet->addFrame("idle0", 10, 20, 16, 16);
int i1 = sheet->addFrame("idle1", 26, 20, 16, 16);
CHECK_EQ(i0, 0);
CHECK_EQ(i1, 1);
CHECK_EQ(sheet->findFrame("idle1"), 1);
CHECK_EQ(sheet->findFrame("missing"), -1);
eve::graphics::Quad quad;
sheet->applyToQuad(&quad, 1);
CHECK_EQ(quad.getX(), 26);
CHECK_EQ(quad.getY(), 20);
CHECK_EQ(quad.getWidth(), 16);
CHECK_EQ(quad.getHeight(), 16);
}
TEST_CASE("animation.sprite.clipAndPlayer") {
auto *anim = Animation::create();
std::unique_ptr<SpriteSheet> sheet(anim->newSpriteSheet());
sheet->setGrid(4, 1, 16, 16);
std::unique_ptr<SpriteClip> clip(anim->newSpriteClip("walk"));
clip->setLoop(true);
clip->addFrame(0, 0.1f);
clip->addFrame(1, 0.1f);
clip->addFrame(2, 0.1f);
clip->addFrame(3, 0.1f);
CHECK(std::fabs(clip->getDuration() - 0.4f) < 1e-5f);
CHECK_EQ(clip->frameAtTime(0.05f), 0);
CHECK_EQ(clip->frameAtTime(0.15f), 1);
CHECK_EQ(clip->frameAtTime(0.45f), 0); // loop
std::unique_ptr<SpriteAnim> player(anim->newSpriteAnim());
player->setSheet(sheet.get());
eve::graphics::Quad quad;
player->bindQuad(&quad);
player->play(clip.get());
CHECK(player->isPlaying());
CHECK_EQ(player->getSheetFrame(), 0);
CHECK_EQ(quad.getX(), 0);
anim->update(0.15f);
CHECK_EQ(player->getClipFrame(), 1);
CHECK_EQ(player->getSheetFrame(), 1);
CHECK_EQ(quad.getX(), 16);
player->setLoop(false);
player->setTime(0.f);
player->play(clip.get());
player->setLoop(false);
// finish
for (int i = 0; i < 10; ++i) player->update(0.1f);
CHECK(player->isFinished());
CHECK(!player->isPlaying());
CHECK_EQ(player->getSheetFrame(), 3);
}
TEST_CASE("animation.sprite.uniformFpsRangeReverseAndEvents") {
auto *anim = Animation::create();
std::unique_ptr<SpriteClip> clip(anim->newSpriteClip("fx"));
clip->addRange(2, 5, 20.f);
CHECK_EQ(clip->getFrameCount(), 4);
CHECK(std::fabs(clip->getFPS() - 20.f) < 1e-5f);
clip->setFPS(10.f);
CHECK(std::fabs(clip->getDuration() - 0.4f) < 1e-5f);
std::unique_ptr<SpriteAnim> player(anim->newSpriteAnim());
player->playReverse(clip.get());
CHECK(player->getSpeed() < 0.f);
CHECK_EQ(player->getSheetFrame(), 5);
player->update(0.11f);
CHECK_EQ(player->getSheetFrame(), 4);
player->update(0.35f);
CHECK(player->consumeLooped() >= 1);
CHECK(player->getLoopCount() >= 1);
player->setLoop(false);
player->playReverse(clip.get());
player->setLoop(false);
player->update(1.f);
CHECK(player->isFinished());
CHECK(player->consumeCompleted());
CHECK(!player->consumeCompleted());
CHECK_EQ(player->getSheetFrame(), 2);
player->setSpeed(1.f);
player->clearSpeedCurve();
player->addSpeedCurveKey(0.f, 0.5f);
player->addSpeedCurveKey(1.f, 2.f);
player->setSpeedCurveLoop(false);
player->play(clip.get());
CHECK(std::fabs(player->getSpeedCurveValue() - 0.5f) < 1e-5f);
player->update(0.5f);
CHECK(player->getSpeedCurveValue() > 1.2f);
CHECK(player->getTime() > 0.5f);
player->setSpeedCurveInterpolation("cubic");
clip->addEvent(2, "impact");
player->setFrame(2);
CHECK_EQ(player->consumeEvent(), std::string("impact"));
player->step(1);
CHECK_EQ(player->getClipFrame(), 3);
SpriteSheet trimmed;
const int tf = trimmed.addFrameTrimmed("trim", 2, 3, 10, 12, 32, 40, 7, 9);
CHECK_EQ(trimmed.getFrameSourceWidth(tf), 32);
CHECK_EQ(trimmed.getFrameSourceHeight(tf), 40);
CHECK_EQ(trimmed.getFrameOffsetX(tf), 7);
CHECK_EQ(trimmed.getFrameOffsetY(tf), 9);
}
TEST_CASE("animation.spine.atlasParse") {
const char *atlasText =
"hero.png\n"
"size: 128,64\n"
"format: RGBA8888\n"
"filter: Linear,Linear\n"
"repeat: none\n"
"body\n"
" rotate: false\n"
" xy: 2, 2\n"
" size: 40, 50\n"
" orig: 40, 50\n"
" offset: 0, 0\n"
" index: -1\n"
"head\n"
" rotate: false\n"
" xy: 50, 4\n"
" size: 24, 24\n"
" orig: 24, 24\n"
" offset: 0, 0\n"
" index: -1\n";
std::unique_ptr<SpineAtlas> atlas(new SpineAtlas());
std::string err;
CHECK(atlas->loadFromText(atlasText, &err));
CHECK_EQ(atlas->getPageCount(), 1);
CHECK_EQ(atlas->getPageName(0), std::string("hero.png"));
CHECK_EQ(atlas->getPageWidth(0), 128);
CHECK_EQ(atlas->getPageHeight(0), 64);
CHECK_EQ(atlas->getRegionCount(), 2);
CHECK_EQ(atlas->findRegion("body"), 0);
CHECK_EQ(atlas->getRegionWidth(0), 40);
CHECK_EQ(atlas->getRegionHeight(0), 50);
CHECK_EQ(atlas->getRegionX(1), 50);
float u0, v0, u1, v1;
atlas->getRegionUV(0, 128, 64, u0, v0, u1, v1);
CHECK(std::fabs(u0 - 2.f / 128.f) < 1e-5f);
CHECK(std::fabs(v0 - 2.f / 64.f) < 1e-5f);
}
TEST_CASE("animation.spine.skeletonAndAnim") {
const char *json = R"JSON({
"skeleton": { "spine": "4.1.00" },
"bones": [
{ "name": "root" },
{ "name": "body", "parent": "root", "y": 40 },
{ "name": "head", "parent": "body", "y": 30 }
],
"slots": [
{ "name": "body", "bone": "body", "attachment": "body" },
{ "name": "head", "bone": "head", "attachment": "head" }
],
"skins": [
{
"name": "default",
"attachments": {
"body": { "body": { "width": 40, "height": 50 } },
"head": { "head": { "width": 24, "height": 24, "y": 5 } }
}
}
],
"animations": {
"idle": {
"bones": {
"body": {
"rotate": [
{ "time": 0, "value": 0 },
{ "time": 0.5, "value": 20 },
{ "time": 1.0, "value": 0 }
]
}
}
},
"nod": {
"bones": {
"head": {
"translate": [
{ "time": 0, "x": 0, "y": 0 },
{ "time": 0.25, "x": 0, "y": -8 }
]
}
}
}
}
})JSON";
const char *atlasText =
"hero.png\n"
"size: 128,64\n"
"body\n"
" xy: 2, 2\n"
" size: 40, 50\n"
"head\n"
" xy: 50, 4\n"
" size: 24, 24\n";
auto *animMod = Animation::create();
SpineSkeletonData *dataRaw = animMod->newSpineSkeletonDataFromJson(json);
std::unique_ptr<SpineSkeletonData> data(dataRaw);
CHECK(dataRaw != nullptr);
if (!dataRaw) return;
CHECK_EQ(data->getBoneCount(), 3);
CHECK_EQ(data->findBone("head"), 2);
CHECK_EQ(data->getSlotCount(), 2);
CHECK_EQ(data->getAnimationCount(), 2);
CHECK(std::fabs(data->getAnimationDuration(data->findAnimation("idle")) - 1.f) < 1e-4f);
SpineAtlas *atlasRaw = animMod->newSpineAtlasFromText(atlasText);
std::unique_ptr<SpineAtlas> atlas(atlasRaw);
CHECK(atlasRaw != nullptr);
if (!atlasRaw) return;
std::unique_ptr<SpineSkeleton> sk(animMod->newSpineSkeleton(data.get()));
sk->updateWorldTransform();
CHECK(std::fabs(sk->getBoneWorldY(1) - 40.f) < 1e-3f);
CHECK(std::fabs(sk->getBoneWorldY(2) - 70.f) < 1e-3f);
std::unique_ptr<SpineAnim> player(animMod->newSpineAnim(sk.get()));
player->setAtlas(atlas.get());
player->setFlipY(false); // keep Spine Y-up for assertions
player->setLoop(true);
CHECK(player->play("idle"));
player->setTime(0.5f);
player->apply();
// body setup rot 0 + key 20
CHECK(std::fabs(sk->getBoneLocalRotation(1) - 20.f) < 1e-3f);
CHECK_EQ(player->getDrawSlotCount(), 2);
CHECK(player->play("nod"));
player->setLoop(false);
player->setTime(0.25f);
player->apply();
// head setup y=30 + translate -8
CHECK(std::fabs(sk->getBoneLocalY(2) - 22.f) < 1e-3f);
std::vector<eve::graphics::DrawItem2D> items;
player->setFlipY(true);
player->setPosition(100, 200);
player->collectDrawItems(items);
CHECK_EQ(static_cast<int>(items.size()), 2);
CHECK_EQ(items[0].layer, 0);
std::vector<eve::graphics::DrawItem2D> mirrored;
player->setScale(-1.f, 1.f);
player->collectDrawItems(mirrored);
REQUIRE_EQ(static_cast<int>(mirrored.size()), 2);
// Negative X scale reconstructs the reflected quad with a 180-degree
// rotation. A V-axis UV compensation keeps attachments upright while
// preserving the requested left/right mirror.
CHECK(std::fabs(mirrored[0].u0 - items[0].u0) < 1e-6f);
CHECK(std::fabs(mirrored[0].u1 - items[0].u1) < 1e-6f);
CHECK(std::fabs(mirrored[0].v0 - items[0].v1) < 1e-6f);
CHECK(std::fabs(mirrored[0].v1 - items[0].v0) < 1e-6f);
}
TEST_CASE("animation.spine.modulePump") {
auto *anim = Animation::create();
const char *json = R"JSON({
"bones": [ { "name": "root" } ],
"slots": [ { "name": "slot", "bone": "root", "attachment": "a" } ],
"skins": [ { "name": "default", "attachments": {
"slot": { "a": { "width": 10, "height": 10 } }
}}],
"animations": {
"spin": { "bones": { "root": { "rotate": [
{ "time": 0, "value": 0 }, { "time": 1, "value": 90 }
]}}}
}
})JSON";
SpineSkeletonData *dataRaw = anim->newSpineSkeletonDataFromJson(json);
std::unique_ptr<SpineSkeletonData> data(dataRaw);
CHECK(dataRaw != nullptr);
if (!dataRaw) return;
std::unique_ptr<SpineSkeleton> sk(anim->newSpineSkeleton(data.get()));
std::unique_ptr<SpineAnim> player(anim->newSpineAnim(sk.get()));
player->play("spin");
CHECK_EQ(anim->getSpineAnimCount(), 1);
anim->update(0.5f);
CHECK(std::fabs(sk->getBoneLocalRotation(0) - 45.f) < 1e-2f);
}
namespace {
std::string spineAssetsDir() {
std::string here = __FILE__;
const auto slash = here.find_last_of("/\\");
const std::string dir = (slash == std::string::npos) ? std::string(".") : here.substr(0, slash);
return dir + "/assets/spine";
}
bool spineAssetsReady() {
return std::filesystem::is_regular_file(spineAssetsDir() + "/.downloaded");
}
struct SpineDemoModel {
const char *relPath; // virtual path under the mounted spine assets dir
const char *atlasRel;
const char *animation;
float cx = 0.f, cy = 0.f; // target CENTER of the model's bounding box on screen
float scale = 1.f;
};
struct SpineDemoPlayer {
std::unique_ptr<SpineSkeletonData> data;
std::unique_ptr<SpineAtlas> atlas;
std::unique_ptr<SpineSkeleton> skeleton;
std::unique_ptr<SpineAnim> anim;
std::vector<eve::graphics::Texture *> pageTextures;
};
/** Centroid of the current pose's draw quads (position at 0,0) — used to
* place each model by its own bounds instead of assuming the root anchor. */
void measurePoseCenter(SpineAnim *anim, float &cx, float &cy) {
std::vector<eve::graphics::DrawItem2D> items;
anim->setPosition(0.f, 0.f);
anim->collectDrawItems(items);
float minX = 1e30f, minY = 1e30f, maxX = -1e30f, maxY = -1e30f;
for (const auto &it : items) {
minX = std::min(minX, it.x);
minY = std::min(minY, it.y);
maxX = std::max(maxX, it.x + it.w);
maxY = std::max(maxY, it.y + it.h);
}
if (items.empty()) {
cx = 0.f;
cy = 0.f;
return;
}
cx = (minX + maxX) * 0.5f;
cy = (minY + maxY) * 0.5f;
}
} // namespace
/**
* Renders three real Spine models (downloaded via scripts/download_spine_models.py)
* into a window for a few seconds, each playing a looped animation.
* Skips (returns) when the assets have not been downloaded.
*/
TEST_CASE("animation.spine.windowDemoRealModels") {
if (!spineAssetsReady()) {
std::printf("animation.spine.windowDemoRealModels: missing %s — run "
"`cmake --build --target download_spine_models` (or "
"`python3 scripts/download_spine_models.py`)\n",
spineAssetsDir().c_str());
return;
}
auto *win = eve::window::Window::create();
auto *gfx = eve::graphics::Graphics::create();
REQUIRE(win != nullptr);
REQUIRE(gfx != nullptr);
eve::window::WindowSettings s;
s.width = 1280;
s.height = 720;
s.centered = true;
REQUIRE(win->setWindowSettings(s));
auto *fs = eve::filesystem::Filesystem::create();
REQUIRE(fs != nullptr);
REQUIRE(fs->setIdentity("ev_ut_spine_demo", true));
REQUIRE(fs->setupWriteDirectory());
const std::string root = spineAssetsDir();
fs->allowMountingForPath(root);
REQUIRE(fs->mount(root, "", false));
// Module singleton; must outlive all players (each SpineAnim unregisters
// itself from its owner in the destructor).
auto *anim = Animation::create();
REQUIRE(anim != nullptr);
const SpineDemoModel models[] = {
// (cx, cy) is the desired on-screen CENTER of each model's bounding box;
// the actual root-anchor offset is measured at runtime so all three
// characters are laid out side by side without overlap.
{"spineboy/export/spineboy.json", "spineboy/export/spineboy.atlas", "run",
250.f, 420.f, 0.55f},
{"hero/export/hero.json", "hero/export/hero.atlas", "Walk", 640.f, 430.f, 1.2f},
{"dragon/export/dragon.json", "dragon/export/dragon.atlas", "flying", 1030.f, 400.f, 0.5f},
};
std::vector<SpineDemoPlayer> players;
for (const auto &m : models) {
SpineDemoPlayer p;
p.data.reset(anim->newSpineSkeletonDataFromFile(m.relPath));
REQUIRE(p.data.get() != nullptr);
p.atlas.reset(anim->newSpineAtlasFromFile(m.atlasRel));
REQUIRE(p.atlas.get() != nullptr);
p.skeleton.reset(anim->newSpineSkeleton(p.data.get()));
REQUIRE(p.skeleton.get() != nullptr);
p.anim.reset(anim->newSpineAnim(p.skeleton.get()));
REQUIRE(p.anim.get() != nullptr);
p.anim->setAtlas(p.atlas.get());
for (int page = 0; page < p.atlas->getPageCount(); ++page) {
const std::string pageName = p.atlas->getPageName(page);
// The atlas page name is the image basename (e.g. "spineboy.png").
const std::string texRel = std::string(m.relPath);
const auto slash = texRel.find_last_of('/');
const std::string texPath =
(slash == std::string::npos) ? pageName : texRel.substr(0, slash + 1) + pageName;
eve::graphics::Texture *tex = gfx->newTextureFromFile(texPath);
REQUIRE(tex != nullptr);
p.anim->setPageTexture(page, tex);
p.pageTextures.push_back(tex);
}
p.anim->setScale(m.scale, m.scale);
p.anim->setLoop(true);
// Distinct layer per model so slot order never interleaves across models.
p.anim->setLayer(static_cast<int>(players.size()));
REQUIRE(p.anim->play(m.animation));
// Center this model's actual bounding box on its target position.
float poseCx = 0.f, poseCy = 0.f;
measurePoseCenter(p.anim.get(), poseCx, poseCy);
p.anim->setPosition(m.cx - poseCx, m.cy - poseCy);
players.push_back(std::move(p));
}
gfx->setBackgroundColorRGBA(0.10f, 0.12f, 0.15f, 1.f);
std::vector<eve::graphics::DrawItem2D> items;
std::vector<size_t> perPlayerItems(players.size(), 0);
std::vector<bool> perPlayerRotated(players.size(), false);
const int frames = 60 * 3; // ~3 seconds at 60 fps
for (int i = 0; i < frames; ++i) {
items.clear();
for (size_t pi = 0; pi < players.size(); ++pi) {
players[pi].anim->update(1.f / 60.f);
const size_t before = items.size();
players[pi].anim->collectDrawItems(items);
perPlayerItems[pi] += items.size() - before;
for (size_t k = before; k < items.size(); ++k) {
if (items[k].rotation != 0.f) perPlayerRotated[pi] = true;
}
}
eve::graphics::RenderSystem::drawItems(*gfx, items, true);
SDL_Event e;
while (SDL_PollEvent(&e)) {
if (e.type == SDL_QUIT) {
win->close();
return;
}
}
SDL_Delay(16);
}
// Objectively verify each character is visible on screen: render one extra
// frame with screen readback and count pixels differing from the background
// corner color around each model's target center.
gfx->setScreenReadbackEnabled(true);
items.clear();
for (auto &p : players) p.anim->update(1.f / 60.f);
for (auto &p : players) p.anim->collectDrawItems(items);
eve::graphics::RenderSystem::drawItems(*gfx, items, true);
gfx->setScreenReadbackEnabled(false);
std::vector<int> perPlayerLitPixels(players.size(), 0);
const Color bg = gfx->getPixel(4, 4);
for (size_t pi = 0; pi < players.size(); ++pi) {
const int cx = static_cast<int>(models[pi].cx);
const int cy = static_cast<int>(models[pi].cy);
for (int dy = -24; dy <= 24; dy += 8) {
for (int dx = -24; dx <= 24; dx += 8) {
const Color c = gfx->getPixel(cx + dx, cy + dy);
const float dist =
std::fabs(c.r - bg.r) + std::fabs(c.g - bg.g) + std::fabs(c.b - bg.b);
if (dist > 0.15f) ++perPlayerLitPixels[pi];
}
}
}
// Every model must emit visible quads, actually rotate limbs (proves the
// region-attachment animation drives real motion), sit centered on its
// target position (no overlap / no off-screen scatter), and draw visible
// non-background pixels around that center.
for (size_t pi = 0; pi < players.size(); ++pi) {
CHECK_GT(perPlayerItems[pi], 0u);
CHECK(perPlayerRotated[pi]);
CHECK_GT(perPlayerLitPixels[pi], 0);
float poseCx = 0.f, poseCy = 0.f;
measurePoseCenter(players[pi].anim.get(), poseCx, poseCy);
const float onScreenCx = players[pi].anim->getX() + poseCx;
const float onScreenCy = players[pi].anim->getY() + poseCy;
CHECK(std::fabs(onScreenCx - models[pi].cx) < 40.f);
CHECK(std::fabs(onScreenCy - models[pi].cy) < 40.f);
}
win->close();
}