From d1437a3923632e73eb70eef094fea1e57ee288b3 Mon Sep 17 00:00:00 2001 From: dhkatz Date: Wed, 28 Oct 2026 21:44:56 -0700 Subject: [PATCH] Move the camera between ticks CameraController::step eases the camera's position, orbit angles and zoom a share k (0.4) of the way to their targets at each tick; between ticks, the adapter's getCameraTransform (its entry in CameraController's exported vtable, replaced after checking it) sets them 1 - (1 - k)^fraction of the way for IMVU's code to make the transform from, then puts them back. k is read from the constant CameraController::step uses, checked to be referenced there. Co-Authored-By: Claude Opus 5.5 --- adapter/scene_hooks.cpp | 94 +++++++++++++++++++++++++++++++++++++++-- adapter/scene_hooks.hpp | 2 + docs/architecture.md | 10 ++++- 3 files changed, 100 insertions(+), 6 deletions(-) diff --git a/adapter/scene_hooks.cpp b/adapter/scene_hooks.cpp index 569da48..ba9dca6 100644 --- a/adapter/scene_hooks.cpp +++ b/adapter/scene_hooks.cpp @@ -4,6 +4,7 @@ #include #include +#include #include #include #include @@ -19,10 +20,15 @@ using Method = void(__thiscall*)(void* self); Method originalStep = nullptr; Method originalUpdateCalSkeleton = nullptr; +// IMVU's CameraController::getCameraTransform(): returns a Transform by value (constructed in result). +using GetTransform = void*(__thiscall*)(const void* self, void* result); +GetTransform originalGetCameraTransform = nullptr; +float cameraEasing = 0; // the share of the way to its target the camera moves at each tick std::atomic stepTime = 0; std::atomic steps = 0; std::atomic stepHooked = false; std::atomic poseHooked = false; +std::atomic cameraHooked = false; // zero.dll's constants: a tick, in milliseconds (33), and an animation frame, in seconds (1/30). const unsigned* millisecondsPerFrame = nullptr; @@ -155,6 +161,53 @@ void __fastcall updateCalSkeleton(void* model, void* /*edx*/) } } +// The camera (SceneWindow.dll's CameraController::step, at each tick): each of these values moves a share k of the way +// to its target (k is 0.4): the camera's position (+0x118, 3 floats, to +0xf0), and its orbit angles and zoom, in +// degrees (+0x140, 3 floats, to +0x134; the difference taken between -180 and 180). +// A fraction f of a tick on, the same easing has moved it 1 - (1 - k)^f of the way: exactly, nothing guessed. +constexpr std::size_t cameraPosition = 0x118; +constexpr std::size_t cameraPositionTarget = 0xf0; +constexpr std::size_t cameraAngles = 0x140; +constexpr std::size_t cameraAnglesTarget = 0x134; +// Nonzero: the camera shows a fixed view ("take"), not eased. +constexpr std::size_t cameraTake = 0xec; + +float degrees180(float degrees) +{ + float wrapped = std::fmod(degrees + 180.0f, 360.0f); + if (wrapped < 0) { + wrapped += 360.0f; + } + return wrapped - 180.0f; +} + +// Our CameraController::getCameraTransform: between ticks, the camera where its easing has brought it since the tick. +// The eased values are set ahead for IMVU's code to make the transform from, then put back. __fastcall takes ECX like +// __thiscall, and like it the callee pops the stack argument. +void* __fastcall getCameraTransform(void* camera, void* /*edx*/, void* result) +{ + const float fraction = tick_fraction(); + if (fraction < 0.01f || field(camera, cameraTake) != 0) { + return originalGetCameraTransform(camera, result); + } + const float moved = 1.0f - std::pow(1.0f - cameraEasing, fraction); + float saved[6]; + for (int i = 0; i < 3; ++i) { + float& position = field(camera, cameraPosition + i * 4); + saved[i] = position; + position += (field(camera, cameraPositionTarget + i * 4) - position) * moved; + float& angle = field(camera, cameraAngles + i * 4); + saved[3 + i] = angle; + angle = degrees180(angle + degrees180(field(camera, cameraAnglesTarget + i * 4) - angle) * moved); + } + void* transform = originalGetCameraTransform(camera, result); + for (int i = 0; i < 3; ++i) { + field(camera, cameraPosition + i * 4) = saved[i]; + field(camera, cameraAngles + i * 4) = saved[3 + i]; + } + return transform; +} + // Replaces the vtable entry at slot, if it holds expected; returns whether it did. bool replace(void** slot, void* expected, void* replacement) { @@ -231,6 +284,38 @@ void install_pose(HMODULE sceneWindow) poseHooked = true; } +void install_camera(HMODULE sceneWindow) +{ + auto* vtable = reinterpret_cast(GetProcAddress(sceneWindow, "??_7CameraController@@6B@")); + auto* get = GetProcAddress(sceneWindow, "?getCameraTransform@CameraController@@UBE?AVTransform@@XZ"); + auto* step = reinterpret_cast(GetProcAddress(sceneWindow, "?step@CameraController@@UAEXXZ")); + if (vtable == nullptr || get == nullptr || step == nullptr) { + log::info("scene hooks: CameraController's vtable, getCameraTransform or step not found"); + return; + } + // The easing share: a float constant CameraController::step reads (at SceneWindow.dll's 0x100cb934 when loaded at + // its preferred address), checked to be referenced by step's code and to be the expected 0.4. + const auto* base = reinterpret_cast(sceneWindow); + const auto* easing = reinterpret_cast(base + 0xcb934); + const auto address = static_cast(reinterpret_cast(easing)); + bool referenced = false; + for (std::size_t i = 0; i + 4 <= 0x700 && !referenced; ++i) { + referenced = std::memcmp(step + i, &address, 4) == 0; + } + if (!referenced || *easing != 0.4f) { + log::info("scene hooks: CameraController::step's easing not where expected"); + return; + } + cameraEasing = *easing; + // CameraController's vtable: getCameraTransform is its 6th entry. + originalGetCameraTransform = reinterpret_cast(get); + if (!replace(&vtable[5], reinterpret_cast(get), reinterpret_cast(&getCameraTransform))) { + log::info("scene hooks: CameraController's vtable doesn't hold its getCameraTransform"); + return; + } + cameraHooked = true; +} + } // namespace void install() @@ -243,11 +328,12 @@ void install() return; } install_step(sceneWindow); - if (stepHooked) { - install_pose(sceneWindow); // posing between ticks needs the ticks + if (stepHooked) { // drawing between ticks needs the ticks + install_pose(sceneWindow); + install_camera(sceneWindow); } - log::info("scene hooks: ticks {}, posing between ticks {}", stepHooked ? "recorded" : "not recorded", - poseHooked ? "on" : "off"); + log::info("scene hooks: ticks {}, posing between ticks {}, camera between ticks {}", + stepHooked ? "recorded" : "not recorded", poseHooked ? "on" : "off", cameraHooked ? "on" : "off"); }); } diff --git a/adapter/scene_hooks.hpp b/adapter/scene_hooks.hpp index af93816..5996717 100644 --- a/adapter/scene_hooks.hpp +++ b/adapter/scene_hooks.hpp @@ -9,6 +9,8 @@ // - 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. +// - CameraController::getCameraTransform (its exported vtable): between ticks, the camera where its easing toward its +// target has brought it since the tick. #include diff --git a/docs/architecture.md b/docs/architecture.md index 3cf88aa..e7d6fb9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -90,8 +90,14 @@ While the renderer process covers the view, the client's renderer doesn't begin wait for the display). The renderer process takes each view's chunks on a thread of its own as they come, so the client never waits for drawing; it draws the newest frame. On a PC with a graphics card a room frame takes the client about 2.5 ms (the client's own work: posing avatars, which it does every frame, and walking the scene), and the -renderer process about 0.5 ms. The extra frames show the same scene until animations, the camera and particles are -drawn at the time between steps. +renderer process about 0.5 ms. + +Between steps, the frames show the scene at the time between them (`adapter/scene_hooks.cpp`): avatars posed with +their animations a fraction of a frame on (ModelInstance::render's call to updateCalSkeleton, redirected; Cal3D poses +between keyframes; animations held, looping or starting are posed as they are), and the camera where its easing (a +share 0.4 of the way to its target per step) has brought it, `1 - 0.6^fraction` of the way (CameraController's +getCameraTransform, in its exported vtable). Nothing is predicted: both are what the next step would give, at the +fraction. Still at 30 per second: particles, crossfades between animations, gaze. Next: sharing pipelines between views (each view's scene compiles its own).