Move the camera between ticks
CI / build (push) Successful in 1m38s

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 <[email protected]>
This commit is contained in:
dhkatzandClaude Opus 5.5 committed 2026-10-28 21:44:56 -07:00
1 parent 2471b1fd4f
commit d1437a3923
3 files changed
+100 -6

No files matched your search

+90 -4
View File
@@ -4,6 +4,7 @@
#include <algorithm> #include <algorithm>
#include <atomic> #include <atomic>
#include <cmath>
#include <cstring> #include <cstring>
#include <mutex> #include <mutex>
#include <unordered_map> #include <unordered_map>
@@ -19,10 +20,15 @@ using Method = void(__thiscall*)(void* self);
Method originalStep = nullptr; Method originalStep = nullptr;
Method originalUpdateCalSkeleton = 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<std::int64_t> stepTime = 0; std::atomic<std::int64_t> stepTime = 0;
std::atomic<std::uint64_t> steps = 0; std::atomic<std::uint64_t> steps = 0;
std::atomic<bool> stepHooked = false; std::atomic<bool> stepHooked = false;
std::atomic<bool> poseHooked = false; std::atomic<bool> poseHooked = false;
std::atomic<bool> cameraHooked = false;
// zero.dll's constants: a tick, in milliseconds (33), and an animation frame, in seconds (1/30). // zero.dll's constants: a tick, in milliseconds (33), and an animation frame, in seconds (1/30).
const unsigned* millisecondsPerFrame = nullptr; 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<std::int32_t>(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<float>(camera, cameraPosition + i * 4);
saved[i] = position;
position += (field<float>(camera, cameraPositionTarget + i * 4) - position) * moved;
float& angle = field<float>(camera, cameraAngles + i * 4);
saved[3 + i] = angle;
angle = degrees180(angle + degrees180(field<float>(camera, cameraAnglesTarget + i * 4) - angle) * moved);
}
void* transform = originalGetCameraTransform(camera, result);
for (int i = 0; i < 3; ++i) {
field<float>(camera, cameraPosition + i * 4) = saved[i];
field<float>(camera, cameraAngles + i * 4) = saved[3 + i];
}
return transform;
}
// Replaces the vtable entry at slot, if it holds expected; returns whether it did. // Replaces the vtable entry at slot, if it holds expected; returns whether it did.
bool replace(void** slot, void* expected, void* replacement) bool replace(void** slot, void* expected, void* replacement)
{ {
@@ -231,6 +284,38 @@ void install_pose(HMODULE sceneWindow)
poseHooked = true; poseHooked = true;
} }
void install_camera(HMODULE sceneWindow)
{
auto* vtable = reinterpret_cast<void**>(GetProcAddress(sceneWindow, "??_7CameraController@@6B@"));
auto* get = GetProcAddress(sceneWindow, "?getCameraTransform@CameraController@@UBE?AVTransform@@XZ");
auto* step = reinterpret_cast<const std::byte*>(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<const std::byte*>(sceneWindow);
const auto* easing = reinterpret_cast<const float*>(base + 0xcb934);
const auto address = static_cast<std::uint32_t>(reinterpret_cast<std::uintptr_t>(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<GetTransform>(get);
if (!replace(&vtable[5], reinterpret_cast<void*>(get), reinterpret_cast<void*>(&getCameraTransform))) {
log::info("scene hooks: CameraController's vtable doesn't hold its getCameraTransform");
return;
}
cameraHooked = true;
}
} // namespace } // namespace
void install() void install()
@@ -243,11 +328,12 @@ void install()
return; return;
} }
install_step(sceneWindow); install_step(sceneWindow);
if (stepHooked) { if (stepHooked) { // drawing between ticks needs the ticks
install_pose(sceneWindow); // posing between ticks needs the ticks install_pose(sceneWindow);
install_camera(sceneWindow);
} }
log::info("scene hooks: ticks {}, posing between ticks {}", stepHooked ? "recorded" : "not recorded", log::info("scene hooks: ticks {}, posing between ticks {}, camera between ticks {}",
poseHooked ? "on" : "off"); stepHooked ? "recorded" : "not recorded", poseHooked ? "on" : "off", cameraHooked ? "on" : "off");
}); });
} }
+2
View File
@@ -9,6 +9,8 @@
// - ModelInstance::render's call to updateCalSkeleton, which poses the model before it's drawn: between ticks, the // - 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 // 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), 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 <cstdint> #include <cstdint>
+8 -2
View File
@@ -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 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 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 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 renderer process about 0.5 ms.
drawn at the time between steps.
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). Next: sharing pipelines between views (each view's scene compiles its own).