Crossfade animations between ticks
CI / build (push) Successful in 1m30s

ModelInstance::step ramps the weights of animations fading in and out in tenths
over 10 ticks; between ticks, the posing hook sets them partway to the next
tick's weight, and puts them back with the times. Only if ModelInstance::step
counts the fades where expected (checked when installing); otherwise
crossfades stay at the client's rate.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
dhkatzandClaude Opus 5.5 committed 2026-10-28 22:41:23 -07:00
1 parent 9d0faecf4d
commit db344b7b95
3 files changed
+66 -17

No files matched your search

+62 -15
View File
@@ -30,6 +30,7 @@ std::atomic<std::uint64_t> steps = 0;
std::atomic<int> stepTicks = 1;
std::atomic<bool> stepHooked = false;
std::atomic<bool> poseHooked = false;
bool crossfades = false; // whether ModelInstance::step's crossfades are as expected (checked when installing)
std::atomic<bool> cameraHooked = false;
std::atomic<bool> particlesHooked = false;
@@ -88,13 +89,28 @@ void __fastcall step(void* self, void* /*edx*/)
// - ModelInstance: +0x2c, a flag: its skeleton must be posed again; +0x120, its animation effects, a std::list (a pointer
// to the list's head node; a node: next, previous, then the effect).
// - Effect: +0x34, playing (a flag); +0x3c, its time, in seconds; +0x90, its Cal3D animation.
// - The Cal3D animation: +0x8, its time, which the mixer poses the skeleton at (CalMixer::updateSkeleton).
// - The Cal3D animation: +0x8, its time, which the mixer poses the skeleton at (CalMixer::updateSkeleton); +0xc, its
// weight in the pose.
//
// Crossfades (ModelInstance::step, at each tick): an effect fading in (+0x44 set, +0x18 nonzero) has its weight
// (+0x38) ramped over 10 ticks, counted at +0x48: (n + 1) * 0.1 * weight, then n goes up. One fading out is in the
// model's crossfade list (+0x138, a std::list; a node holds the effect at +0xc and its count at +0x14): m goes up, then
// (1 - m * 0.1) * weight; after 10, it's removed. Between ticks, both are partway to the next tick's weight.
constexpr std::size_t modelPoseDirty = 0x2c;
constexpr std::size_t modelEffects = 0x120;
constexpr std::size_t modelFadingOut = 0x138;
constexpr std::size_t effectPlaying = 0x34;
constexpr std::size_t effectTime = 0x3c;
constexpr std::size_t effectAnimation = 0x90;
constexpr std::size_t effectFadesIn = 0x44;
constexpr std::size_t effectFadeSource = 0x18;
constexpr std::size_t effectFadeCount = 0x48;
constexpr std::size_t effectWeight = 0x38;
constexpr std::size_t fadingOutEffect = 0xc;
constexpr std::size_t fadingOutCount = 0x14;
constexpr std::size_t animationTime = 0x8;
constexpr std::size_t animationWeight = 0xc;
constexpr unsigned fadeTicks = 10;
template <class T>
T& field(void* object, std::size_t offset)
@@ -126,8 +142,9 @@ float tick_fraction()
// Our ModelInstance::updateCalSkeleton, called where ModelInstance::render poses the model before drawing it: between
// ticks, poses it at the time between them. Each playing animation that moved on at the last step is set ahead by the
// fraction of a tick since, at the rate it moved (up to 4 frames a tick), the skeleton posed (by IMVU's), and the
// times put back, with the skeleton marked to be posed again, so everything else sees the tick's pose.
// fraction of a tick since, at the rate it moved (up to 4 frames a tick), and crossfading animations' weights partway
// to the next tick's; the skeleton is posed (by IMVU's), and the times and weights put back, with the skeleton marked to
// be posed again, so everything else sees the tick's pose.
void __fastcall updateCalSkeleton(void* model, void* /*edx*/)
{
const float fraction = tick_fraction();
@@ -139,20 +156,30 @@ void __fastcall updateCalSkeleton(void* model, void* /*edx*/)
const float frame = *secondsPerFrame;
struct Saved {
float* time;
float value;
float* value;
float was;
};
Saved saved[64];
Saved saved[128];
std::size_t count = 0;
const auto set = [&](float& value, float to) {
saved[count++] = {&value, value};
value = to;
};
void* head = field<void*>(model, modelEffects);
for (void* node = head != nullptr ? field<void*>(head, 0) : nullptr; node != nullptr && node != head && count < 64;
for (void* node = head != nullptr ? field<void*>(head, 0) : nullptr; node != nullptr && node != head && count < 126;
node = field<void*>(node, 0)) {
void* effect = field<void*>(node, 8);
if (effect == nullptr || field<std::uint8_t>(effect, effectPlaying) == 0) {
void* animation = effect != nullptr ? field<void*>(effect, effectAnimation) : nullptr;
if (animation == nullptr) {
continue;
}
void* animation = field<void*>(effect, effectAnimation);
if (animation == nullptr) {
const unsigned faded = field<std::uint32_t>(effect, effectFadeCount);
if (crossfades && field<std::uint8_t>(effect, effectFadesIn) != 0 && field<std::int32_t>(effect, effectFadeSource) != 0
&& faded >= 1 && faded < fadeTicks) {
set(field<float>(animation, animationWeight),
(static_cast<float>(faded) + fraction) * 0.1f * field<float>(effect, effectWeight));
}
if (field<std::uint8_t>(effect, effectPlaying) == 0) {
continue;
}
const float time = field<float>(effect, effectTime);
@@ -166,9 +193,18 @@ void __fastcall updateCalSkeleton(void* model, void* /*edx*/)
if (history.previous < 0 || perTick <= frame * 0.01f || perTick > frame * 4) {
continue; // held, looped back, or just started: as it is
}
float& posedTime = field<float>(animation, animationTime);
saved[count++] = {&posedTime, posedTime};
posedTime = time + fraction * perTick;
set(field<float>(animation, animationTime), time + fraction * perTick);
}
void* fadingHead = crossfades ? field<void*>(model, modelFadingOut) : nullptr;
for (void* node = fadingHead != nullptr ? field<void*>(fadingHead, 0) : nullptr;
node != nullptr && node != fadingHead && count < 127; node = field<void*>(node, 0)) {
void* effect = field<void*>(node, fadingOutEffect);
void* animation = effect != nullptr ? field<void*>(effect, effectAnimation) : nullptr;
const unsigned faded = field<std::uint32_t>(node, fadingOutCount);
if (animation != nullptr && faded >= 1 && faded < fadeTicks) {
set(field<float>(animation, animationWeight),
(1.0f - (static_cast<float>(faded) + fraction) * 0.1f) * field<float>(effect, effectWeight));
}
}
if (count == 0) {
originalUpdateCalSkeleton(model);
@@ -176,8 +212,8 @@ void __fastcall updateCalSkeleton(void* model, void* /*edx*/)
}
field<std::uint8_t>(model, modelPoseDirty) = 1;
originalUpdateCalSkeleton(model);
for (std::size_t i = 0; i < count; ++i) {
*saved[i].time = saved[i].value;
for (std::size_t i = count; i-- > 0;) {
*saved[i].value = saved[i].was;
}
field<std::uint8_t>(model, modelPoseDirty) = 1;
@@ -364,6 +400,17 @@ void install_pose(HMODULE sceneWindow)
return;
}
poseHooked = true;
// Crossfades: ModelInstance::step's instructions that count a fade in (cmp dword ptr [eax + 0x48], 10) and a fade
// out (cmp dword ptr [ebx + 0x14], 10), where expected.
auto* step = reinterpret_cast<const std::byte*>(GetProcAddress(sceneWindow, "?step@ModelInstance@@QAEXABVFrameInterval@@@Z"));
constexpr std::uint8_t fadeIn[] = {0x83, 0x78, 0x48, 0x0a};
constexpr std::uint8_t fadeOut[] = {0x83, 0x7b, 0x14, 0x0a};
crossfades = step != nullptr && std::memcmp(step + 0xc6, fadeIn, sizeof(fadeIn)) == 0
&& std::memcmp(step + 0xf1, fadeOut, sizeof(fadeOut)) == 0;
if (!crossfades) {
log::info("scene hooks: ModelInstance::step's crossfades not as expected; crossfades at the client's rate");
}
}
void install_particles(HMODULE sceneWindow)
+2 -1
View File
@@ -8,7 +8,8 @@
// calls stepFrame, which steps each scene viewer). Recorded, for the frame pacer and for drawing between ticks.
// - ModelInstance::render's call to updateCalSkeleton, which poses the model before it's drawn: between ticks, the
// model is posed at the time between them (its animations' times moved on by the fraction of a frame since the
// tick; Cal3D poses between keyframes), then its animations are put back as they were.
// tick; Cal3D poses between keyframes) and crossfading animations' weights partway to the next tick's, then its
// animations are put back as they were.
// - ModelInstance::render's call to ParticleSystem::render: between ticks, particles drawn moved on by their velocity
// times the time since the tick (as the particle library moves them at each tick), then put back.
// - CameraController::getCameraTransform (its exported vtable): between ticks, the camera where its easing toward its