From 316c02f70a950010e4f724f4b3b71ebbd5b01a7f Mon Sep 17 00:00:00 2001 From: sharpmantis <43401382+sharpmantis@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:01:40 +0200 Subject: [PATCH] Interpolate skinned limb vertices (horse body and legs) (#7282) Matrix interpolation cannot cover SKIN_LIMB_TYPE_ANIMATED limbs: their vertices are deformed on the CPU and referenced through gSPSegment(0x08, ...) without any recorded matrix, so recorded matrix interpolation never moves them. Record each skinned limb's pose (double-buffered, keyed by the vertex entry) and blend the previous and current poses for every displayed frame, right before the graphics commands are run. Ties the blend to a logical-frame counter so a limb that was skipped for a frame falls back to its exact pose instead of blending against a stale one. --- soh/soh/OTRGlobals.cpp | 2 ++ soh/soh/frame_interpolation.cpp | 60 +++++++++++++++++++++++++++++++++ soh/soh/frame_interpolation.h | 4 +++ soh/src/code/z_skin.c | 3 ++ 4 files changed, 69 insertions(+) diff --git a/soh/soh/OTRGlobals.cpp b/soh/soh/OTRGlobals.cpp index 2638720f80..d336c3c5bf 100644 --- a/soh/soh/OTRGlobals.cpp +++ b/soh/soh/OTRGlobals.cpp @@ -1785,6 +1785,8 @@ void RunCommands(Gfx* Commands, int time, int step, int denom, int count) { (time == denom) ? std::unordered_map() : FrameInterpolation_Interpolate(interpolationStep); // Ribbon vertices are shared by every frame of a tick: re-blend them before each run. FrameInterpolation_UpdateRibbonHeads(interpolationStep); + // Skinned vertices are shared by every displayed frame, so blend them before each run + FrameInterpolation_UpdateSkinnedVertices(interpolationStep); intp->mInterpolationT = interpolationStep; wnd->DrawAndRunGraphicsCommands(Commands, mtx_replacements); intp->mInterpolationIndex++; diff --git a/soh/soh/frame_interpolation.cpp b/soh/soh/frame_interpolation.cpp index b27e05df6f..811f443ab6 100644 --- a/soh/soh/frame_interpolation.cpp +++ b/soh/soh/frame_interpolation.cpp @@ -234,6 +234,18 @@ struct RibbonHead { }; unordered_map ribbon_heads; + +// Skinned limbs (horse body and legs) have their vertices rewritten on the CPU each +// gameplay frame, so matrix interpolation can't move them. Keep the last two poses and +// write a blend of them into the game's buffer before each displayed frame. +struct SkinnedLimb { + Vtx* dest = nullptr; + vector prev; + vector cur; + uint32_t frame = 0; +}; + +unordered_map skinned_limbs; uint32_t record_frame; Data& append(Op op) { @@ -505,6 +517,7 @@ void FrameInterpolation_StartRecord(void) { is_recording = true; } else { ribbon_heads.clear(); + skinned_limbs.clear(); } } @@ -717,6 +730,53 @@ void FrameInterpolation_UpdateRibbonHeads(float step) { } } +void FrameInterpolation_RecordSkinnedLimb(const void* key, void* dest, u32 vtxCount) { + if (!is_recording) { + return; + } + + SkinnedLimb& limb = skinned_limbs[key]; + // Only blend against a pose from the frame right before this one + if (limb.frame + 1 == record_frame && limb.cur.size() == vtxCount) { + limb.prev.swap(limb.cur); + } else { + limb.prev.clear(); + } + limb.dest = (Vtx*)dest; + limb.cur.assign(limb.dest, limb.dest + vtxCount); + limb.frame = record_frame; +} + +void FrameInterpolation_UpdateSkinnedVertices(float step) { + for (auto it = skinned_limbs.begin(); it != skinned_limbs.end();) { + SkinnedLimb& limb = it->second; + + // Not drawn this frame, its buffer may be freed + if (limb.frame != record_frame) { + it = skinned_limbs.erase(it); + continue; + } + + if (limb.prev.empty() || step >= 1.0f) { + std::copy(limb.cur.begin(), limb.cur.end(), limb.dest); + } else { + float w = 1.0f - step; + for (size_t i = 0; i < limb.cur.size(); i++) { + const Vtx& p = limb.prev[i]; + const Vtx& c = limb.cur[i]; + Vtx& d = limb.dest[i]; + d = c; + for (int j = 0; j < 3; j++) { + d.n.ob[j] = (s16)(w * p.n.ob[j] + step * c.n.ob[j]); + d.n.n[j] = (s8)(w * p.n.n[j] + step * c.n.n[j]); + } + } + } + + ++it; + } +} + // https://stackoverflow.com/questions/1148309/inverting-a-4x4-matrix static bool invert_matrix(const float m[16], float invOut[16]) { float inv[16], det; diff --git a/soh/soh/frame_interpolation.h b/soh/soh/frame_interpolation.h index 135c174f56..7143931b97 100644 --- a/soh/soh/frame_interpolation.h +++ b/soh/soh/frame_interpolation.h @@ -63,6 +63,10 @@ void FrameInterpolation_RecordRibbonHead(const void* key, void* dest, u32 vtxCou void FrameInterpolation_UpdateRibbonHeads(float step); +void FrameInterpolation_RecordSkinnedLimb(const void* key, void* dest, u32 vtxCount); + +void FrameInterpolation_UpdateSkinnedVertices(float step); + #ifdef __cplusplus } #endif diff --git a/soh/src/code/z_skin.c b/soh/src/code/z_skin.c index c8d0ce8dfc..fa53169b8e 100644 --- a/soh/src/code/z_skin.c +++ b/soh/src/code/z_skin.c @@ -1,4 +1,5 @@ #include "global.h" +#include "soh/frame_interpolation.h" MtxF gSkinLimbMatrices[60]; // holds matrices for each limb of the skeleton currently being drawn @@ -126,6 +127,8 @@ void Skin_ApplyLimbModifications(GraphicsContext* gfxCtx, Skin* skin, s32 limbIn vtxBuf, &vtxPoint); } + FrameInterpolation_RecordSkinnedLimb(vtxEntry, vtxBuf, data->totalVtxCount); + gSPSegment(POLY_OPA_DISP++, 0x08, vtxEntry->buf[vtxEntry->index]); vtxEntry->index = (vtxEntry->index == 0) ? 1 : 0;