Skip to content

Commit 30d7f59

Browse files
Jean-Michaël Celerierclaude
authored andcommitted
tests: L3 regression guards for the 3D object subsystem
12 Catch2 suites pinning the fixes made while building out the 3D processes: animation speed and skinned-mesh deformation, camera state release, cubemap face clamping, compute-extraction SRB layout, extraction index and buffer lifetime, HDR extension handling, instancer clamping and stale buffers, OBJ parse crashes and shader string generation. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE
1 parent 4eb3529 commit 30d7f59

14 files changed

Lines changed: 1468 additions & 0 deletions

tests/CMakeLists.txt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,8 @@ add_subdirectory(hardware)
1414
if(TARGET score_plugin_gfx)
1515
add_subdirectory(gfx)
1616
endif()
17+
18+
# L3 regression guards for the 3D object subsystem.
19+
if(TARGET score_plugin_threedim)
20+
add_subdirectory(threedim)
21+
endif()
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
// L3 regression guard — split/threedim finding #9 (AnimationPlayer never
2+
// deforms skinned meshes: it wrote an ignored buffer and left joint TRS at
3+
// bind pose).
4+
//
5+
// The skinning block recomputed joint_matrix = world x inverse_bind into
6+
// skeleton_component::joint_matrices_buffer, but the sole skinning consumer
7+
// (SceneGPUState) ALWAYS derives its joint matrices from joints[].{translation,
8+
// rotation,scale} via its own forward kinematics and never reads
9+
// joint_matrices_buffer. AnimationPlayer meanwhile animated only the rigid
10+
// scene_transform payloads and never touched joints[] — so the animated pose
11+
// never reached the skinning path and skinned meshes stayed frozen in bind
12+
// pose.
13+
//
14+
// The fix writes each sampled TRS override (keyed by scene_node id) into the
15+
// cloned skeleton's joints[] local TRS, mapped joint -> node via
16+
// joint_node_ids, and bumps the skeleton dirty_index.
17+
//
18+
// This is a pure state test (no GPU): we build a scene with one animated
19+
// rotation channel targeting the node that backs joint 0, tick once, and read
20+
// the OUTPUT scene's skeleton. Post-fix, joints[0].rotation has moved off the
21+
// bind-pose identity quaternion to the sampled value; pre-fix it stays at the
22+
// bind pose (0,0,0,1) (RED). We observe the published skeleton_component state,
23+
// not a rendered image.
24+
25+
#include <Threedim/AnimationPlayer.hpp>
26+
27+
#include <ossia/dataflow/geometry_port.hpp>
28+
29+
#include <catch2/catch_test_macros.hpp>
30+
31+
#include <cmath>
32+
#include <memory>
33+
#include <vector>
34+
35+
namespace
36+
{
37+
// scene_node id that backs the animated joint.
38+
constexpr uint64_t kJointNodeId = 77;
39+
40+
// A 90 degrees rotation about +Z as a glTF (x,y,z,w) quaternion.
41+
constexpr float kQz = 0.70710678f; // sin(45deg) == cos(45deg)
42+
43+
std::shared_ptr<ossia::scene_state> makeSkinnedScene()
44+
{
45+
// Minimal non-empty root (scene_state::empty() gates AnimationPlayer). The
46+
// root does not need to be the joint node — the skinning path resolves joints
47+
// via joint_node_ids against the sampled overrides, independent of the tree.
48+
auto root = std::make_shared<ossia::scene_node>();
49+
root->id.value = 1;
50+
auto roots = std::make_shared<std::vector<ossia::scene_node_ptr>>();
51+
roots->push_back(root);
52+
53+
// One rotation channel on node 77, constant 90deg-about-Z at both keyframes,
54+
// so any sampled t yields the same non-identity quaternion.
55+
auto anim = std::make_shared<ossia::animation_component>();
56+
anim->duration = 1.f;
57+
ossia::animation_channel ch;
58+
ch.target_node_id = kJointNodeId;
59+
ch.target_path = ossia::animation_target::rotation;
60+
ch.interpolation = ossia::animation_interpolation::linear;
61+
ch.times = std::make_shared<std::vector<float>>(std::vector<float>{0.f, 1.f});
62+
ch.values = std::make_shared<std::vector<float>>(
63+
std::vector<float>{0.f, 0.f, kQz, kQz, 0.f, 0.f, kQz, kQz});
64+
anim->channels.push_back(ch);
65+
auto anims
66+
= std::make_shared<std::vector<ossia::animation_component_ptr>>();
67+
anims->push_back(anim);
68+
69+
// One skeleton, one joint at bind pose (identity rotation), mapped to node 77.
70+
auto skel = std::make_shared<ossia::skeleton_component>();
71+
ossia::skeleton_joint joint; // defaults: rotation {0,0,0,1}, scale {1,1,1}
72+
joint.name = "j0";
73+
skel->joints.push_back(joint);
74+
skel->joint_node_ids.push_back(ossia::scene_node_id{kJointNodeId});
75+
skel->dirty_index = 0;
76+
auto skels
77+
= std::make_shared<std::vector<ossia::skeleton_component_ptr>>();
78+
skels->push_back(skel);
79+
80+
auto st = std::make_shared<ossia::scene_state>();
81+
st->roots = roots;
82+
st->animations = anims;
83+
st->skeletons = skels;
84+
return st;
85+
}
86+
87+
const ossia::skeleton_component*
88+
outputSkeleton(Threedim::AnimationPlayer& node)
89+
{
90+
const auto& out = node.outputs.scene_out.scene.state;
91+
if(!out || !out->skeletons || out->skeletons->empty())
92+
return nullptr;
93+
return (*out->skeletons)[0].get();
94+
}
95+
} // namespace
96+
97+
TEST_CASE(
98+
"AnimationPlayer writes sampled TRS into skeleton joints",
99+
"[threedim][animation][f9]")
100+
{
101+
Threedim::AnimationPlayer node;
102+
node.inputs.scene_in.scene.state = makeSkinnedScene();
103+
node.inputs.time.value = 0.f;
104+
node.inputs.speed.value = 1.f;
105+
node.inputs.loop.value = false;
106+
node.inputs.clip_index.value = -1;
107+
108+
node();
109+
110+
const ossia::skeleton_component* skel = outputSkeleton(node);
111+
REQUIRE(skel != nullptr);
112+
REQUIRE(skel->joints.size() == 1u);
113+
114+
// The fix: the joint's LOCAL rotation TRS (the data SceneGPUState's forward
115+
// kinematics actually consumes) is now the sampled 90deg-about-Z quaternion,
116+
// no longer the bind-pose identity. Pre-fix the joint stays at bind pose and
117+
// both component checks fail (RED).
118+
const auto& r = skel->joints[0].rotation; // (x,y,z,w)
119+
CHECK(std::abs(r[2] - kQz) < 1e-4f);
120+
CHECK(std::abs(r[3] - kQz) < 1e-4f);
121+
122+
// It has genuinely moved off the bind-pose identity (0,0,0,1).
123+
const bool movedFromBind
124+
= std::abs(r[0]) > 1e-4f || std::abs(r[1]) > 1e-4f
125+
|| std::abs(r[2]) > 1e-4f || std::abs(r[3] - 1.f) > 1e-4f;
126+
CHECK(movedFromBind);
127+
128+
// And the skeleton is flagged dirty so the renderer re-runs FK.
129+
CHECK(skel->dirty_index > 0);
130+
}

tests/threedim/AnimationSpeed.cpp

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
// L3 regression guard — split/threedim finding #10 (AnimationPlayer Speed
2+
// never advances / ping-pongs).
3+
//
4+
// The Speed integration gated on `t == m_prev_time` but then overwrote
5+
// m_prev_time with the freshly-advanced t. With Time held at 0 and Speed set,
6+
// playback ping-ponged 0 -> speed/60 -> 0 forever instead of accumulating. The
7+
// fix keeps a dedicated m_playback_time accumulator decoupled from the change-
8+
// detection value.
9+
//
10+
// This drives the node directly (no GPU) with a one-node scene animated by a
11+
// single linear translation channel whose X maps t -> t over [0,10]. Leaving
12+
// Time at 0 and Speed at 2, we tick N frames and read the OUTPUT scene's
13+
// animated translation. Post-fix it advances monotonically; the pre-fix engine
14+
// alternates it back to ~0 every other frame (RED). We assert against the
15+
// public OUTPUT (not m_playback_time) so the test compiles against BOTH the
16+
// fixed and reverted headers — only the assertion flips.
17+
18+
#include <Threedim/AnimationPlayer.hpp>
19+
20+
#include <ossia/dataflow/geometry_port.hpp>
21+
22+
#include <catch2/catch_test_macros.hpp>
23+
24+
#include <memory>
25+
#include <vector>
26+
27+
namespace
28+
{
29+
// Build a scene: one node (id 42) with an identity scene_transform child, plus
30+
// one animation clip translating node 42's X from 0 to 10 across t in [0,10].
31+
std::shared_ptr<ossia::scene_state> makeAnimatedScene()
32+
{
33+
auto root = std::make_shared<ossia::scene_node>();
34+
root->id.value = 42;
35+
36+
ossia::scene_transform xf{};
37+
xf.rotation[3] = 1.f;
38+
xf.scale[0] = xf.scale[1] = xf.scale[2] = 1.f;
39+
40+
auto children = std::make_shared<std::vector<ossia::scene_payload>>();
41+
children->push_back(xf);
42+
root->children = children;
43+
44+
auto roots = std::make_shared<std::vector<ossia::scene_node_ptr>>();
45+
roots->push_back(root);
46+
47+
auto anim = std::make_shared<ossia::animation_component>();
48+
anim->duration = 10.f;
49+
ossia::animation_channel ch;
50+
ch.target_node_id = 42;
51+
ch.target_path = ossia::animation_target::translation;
52+
ch.interpolation = ossia::animation_interpolation::linear;
53+
ch.times = std::make_shared<std::vector<float>>(std::vector<float>{0.f, 10.f});
54+
ch.values = std::make_shared<std::vector<float>>(
55+
std::vector<float>{0.f, 0.f, 0.f, 10.f, 0.f, 0.f});
56+
anim->channels.push_back(ch);
57+
58+
auto anims
59+
= std::make_shared<std::vector<ossia::animation_component_ptr>>();
60+
anims->push_back(anim);
61+
62+
auto st = std::make_shared<ossia::scene_state>();
63+
st->roots = roots;
64+
st->animations = anims;
65+
return st;
66+
}
67+
68+
// Pull the animated node's translation.x out of the emitted output scene.
69+
float outputTranslationX(Threedim::AnimationPlayer& node)
70+
{
71+
const auto& out = node.outputs.scene_out.scene.state;
72+
REQUIRE(out);
73+
REQUIRE(out->roots);
74+
REQUIRE(!out->roots->empty());
75+
const auto& n0 = (*out->roots)[0];
76+
REQUIRE(n0);
77+
REQUIRE(n0->children);
78+
REQUIRE(!n0->children->empty());
79+
const auto* tf = ossia::get_if<ossia::scene_transform>(&(*n0->children)[0]);
80+
REQUIRE(tf);
81+
return tf->translation[0];
82+
}
83+
} // namespace
84+
85+
TEST_CASE(
86+
"AnimationPlayer Speed advances playback monotonically",
87+
"[threedim][animation][f10]")
88+
{
89+
Threedim::AnimationPlayer node;
90+
node.inputs.scene_in.scene.state = makeAnimatedScene();
91+
node.inputs.time.value = 0.f; // Time held constant at 0
92+
node.inputs.speed.value = 2.f; // Speed engaged (2x)
93+
node.inputs.loop.value = false;
94+
node.inputs.clip_index.value = -1;
95+
96+
constexpr int frames = 12;
97+
std::vector<float> xs;
98+
xs.reserve(frames);
99+
for(int i = 0; i < frames; ++i)
100+
{
101+
node();
102+
xs.push_back(outputTranslationX(node));
103+
}
104+
105+
// Strictly monotonic increase: each frame must be past the previous one.
106+
for(std::size_t i = 1; i < xs.size(); ++i)
107+
CHECK(xs[i] > xs[i - 1]);
108+
109+
// And it must have genuinely progressed, not jittered at the very start.
110+
CHECK(xs.back() > xs.front() + 0.1f);
111+
}

0 commit comments

Comments
 (0)