From db344b7b95ac881a0951eef8a546fda828c82f8a Mon Sep 17 00:00:00 2001 From: dhkatz Date: Wed, 28 Oct 2026 22:41:23 -0700 Subject: [PATCH] Crossfade animations between ticks 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 --- adapter/scene_hooks.cpp | 77 +++++++++++++++++++++++++++++++++-------- adapter/scene_hooks.hpp | 3 +- docs/architecture.md | 3 +- 3 files changed, 66 insertions(+), 17 deletions(-) diff --git a/adapter/scene_hooks.cpp b/adapter/scene_hooks.cpp index e7dc608..fe0b769 100644 --- a/adapter/scene_hooks.cpp +++ b/adapter/scene_hooks.cpp @@ -30,6 +30,7 @@ std::atomic steps = 0; std::atomic stepTicks = 1; std::atomic stepHooked = false; std::atomic poseHooked = false; +bool crossfades = false; // whether ModelInstance::step's crossfades are as expected (checked when installing) std::atomic cameraHooked = false; std::atomic 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 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(model, modelEffects); - for (void* node = head != nullptr ? field(head, 0) : nullptr; node != nullptr && node != head && count < 64; + for (void* node = head != nullptr ? field(head, 0) : nullptr; node != nullptr && node != head && count < 126; node = field(node, 0)) { void* effect = field(node, 8); - if (effect == nullptr || field(effect, effectPlaying) == 0) { + void* animation = effect != nullptr ? field(effect, effectAnimation) : nullptr; + if (animation == nullptr) { continue; } - void* animation = field(effect, effectAnimation); - if (animation == nullptr) { + const unsigned faded = field(effect, effectFadeCount); + if (crossfades && field(effect, effectFadesIn) != 0 && field(effect, effectFadeSource) != 0 + && faded >= 1 && faded < fadeTicks) { + set(field(animation, animationWeight), + (static_cast(faded) + fraction) * 0.1f * field(effect, effectWeight)); + } + if (field(effect, effectPlaying) == 0) { continue; } const float time = field(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(animation, animationTime); - saved[count++] = {&posedTime, posedTime}; - posedTime = time + fraction * perTick; + set(field(animation, animationTime), time + fraction * perTick); + } + void* fadingHead = crossfades ? field(model, modelFadingOut) : nullptr; + for (void* node = fadingHead != nullptr ? field(fadingHead, 0) : nullptr; + node != nullptr && node != fadingHead && count < 127; node = field(node, 0)) { + void* effect = field(node, fadingOutEffect); + void* animation = effect != nullptr ? field(effect, effectAnimation) : nullptr; + const unsigned faded = field(node, fadingOutCount); + if (animation != nullptr && faded >= 1 && faded < fadeTicks) { + set(field(animation, animationWeight), + (1.0f - (static_cast(faded) + fraction) * 0.1f) * field(effect, effectWeight)); + } } if (count == 0) { originalUpdateCalSkeleton(model); @@ -176,8 +212,8 @@ void __fastcall updateCalSkeleton(void* model, void* /*edx*/) } field(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(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(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) diff --git a/adapter/scene_hooks.hpp b/adapter/scene_hooks.hpp index 7a6cfa2..4f14a0a 100644 --- a/adapter/scene_hooks.hpp +++ b/adapter/scene_hooks.hpp @@ -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 diff --git a/docs/architecture.md b/docs/architecture.md index d27c832..758db6e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -99,7 +99,8 @@ share 0.4 of the way to its target per step) has brought it, `1 - 0.6^fraction` getCameraTransform, in its exported vtable). Nothing is predicted: both are what the next step would give, at the fraction; and particles moved on by their velocity times the time since the step (ModelInstance::render's call to ParticleSystem::render, redirected), then back by the same (the client sorts them by distance as it draws them, which -reorders them). Still at 30 per second: crossfades between animations, gaze. +reorders them); and crossfades between animations (weights ramped in tenths over 10 steps by ModelInstance::step), +partway to the next step's weights. Still at 30 per second: gaze (heads turning to whoever's talking). Next: sharing pipelines between views (each view's scene compiles its own).