Interpolate ribbon trail vertices (sword slashes, arrows, ...) (#7335)

Weapon trails (EffectBlure) are CPU built vertex blocks, one segment per
sample of the weapon position; matrix interpolation cannot touch them, so
above 20 fps they pop segment by segment.

A trail is a history: its sample indices shift every logical frame, so only
its newest end has to be blended, sliding from the previous sample towards
the current one. Only frames that appended a sample are recorded, otherwise
a finished trail keeps collapsing its last segment.
This commit is contained in:
sharpmantis
2026-10-09 17:45:31 +02:00
committed by GitHub
parent 35297e8635
commit 2884ad6de6
4 changed files with 111 additions and 2 deletions
+5 -2
View File
@@ -1780,9 +1780,12 @@ void RunCommands(Gfx* Commands, int time, int step, int denom, int count) {
ImGui::PushStyleColor(ImGuiCol_TitleBgActive, UIWidgets::ColorValues.at(themeColor));
for (int i = 0; i < count; i++) {
time += step;
const float interpolationStep = (float)time / denom;
std::unordered_map<Mtx*, MtxF> mtx_replacements =
(time == denom) ? std::unordered_map<Mtx*, MtxF>() : FrameInterpolation_Interpolate((float)time / denom);
intp->mInterpolationT = (float)time / denom;
(time == denom) ? std::unordered_map<Mtx*, MtxF>() : FrameInterpolation_Interpolate(interpolationStep);
// Ribbon vertices are shared by every frame of a tick: re-blend them before each run.
FrameInterpolation_UpdateRibbonHeads(interpolationStep);
intp->mInterpolationT = interpolationStep;
wnd->DrawAndRunGraphicsCommands(Commands, mtx_replacements);
intp->mInterpolationIndex++;
}
+62
View File
@@ -1,3 +1,4 @@
#include <algorithm>
#include <vector>
#include <map>
#include <unordered_map>
@@ -222,6 +223,19 @@ void record_mode(u8 mode) {
}
}
// Ribbon trails (sword slashes, boomerang, arrows) are vertices written on the CPU, one
// segment per sample of the weapon position. Only the newest end moves within a gameplay
// frame, so only it gets blended, sliding from the previous sample to the new one.
struct RibbonHead {
Vtx* dest = nullptr;
vector<Vtx> cur;
vector<pair<s16, s16>> pairs; // (moving vertex, vertex it starts from)
uint32_t frame = 0;
};
unordered_map<const void*, RibbonHead> ribbon_heads;
uint32_t record_frame;
Data& append(Op op) {
auto& m = current_path.back()->ops[op];
current_path.back()->items.emplace_back(op, m.size());
@@ -486,8 +500,11 @@ void FrameInterpolation_StartRecord(void) {
current_path.push_back(&current_recording.root_path);
matrix_depth = 0;
new_mtx_depth = -1;
record_frame++;
if (OTRGlobals::Instance->GetInterpolationFPS() != 20) {
is_recording = true;
} else {
ribbon_heads.clear();
}
}
@@ -655,6 +672,51 @@ void FrameInterpolation_RecordSkinMatrixMtxFToMtx(MtxF* src, Mtx* dest) {
FrameInterpolation_RecordMatrixMtxFToMtx(src, dest);
}
void FrameInterpolation_RecordRibbonHead(const void* key, void* dest, u32 vtxCount, u32 pairCount, const s16* pairs) {
if (!is_recording) {
return;
}
RibbonHead& head = ribbon_heads[key];
head.dest = (Vtx*)dest;
head.cur.assign(head.dest, head.dest + vtxCount);
head.pairs.clear();
for (u32 i = 0; i < pairCount; i++) {
head.pairs.emplace_back(pairs[2 * i], pairs[2 * i + 1]);
}
head.frame = record_frame;
}
void FrameInterpolation_UpdateRibbonHeads(float step) {
for (auto it = ribbon_heads.begin(); it != ribbon_heads.end();) {
RibbonHead& head = it->second;
// Not drawn this frame, the trail ended or stopped growing
if (head.frame != record_frame) {
it = ribbon_heads.erase(it);
continue;
}
std::copy(head.cur.begin(), head.cur.end(), head.dest);
if (step < 1.0f) {
float w = 1.0f - step;
for (const auto& [dst, src] : head.pairs) {
const Vtx& from = head.cur[src];
Vtx& to = head.dest[dst];
for (int j = 0; j < 3; j++) {
to.v.ob[j] = (s16)(w * from.v.ob[j] + step * to.v.ob[j]);
}
// Colour carries the fade
for (int j = 0; j < 4; j++) {
to.v.cn[j] = (u8)(w * from.v.cn[j] + step * to.v.cn[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;
+5
View File
@@ -58,6 +58,11 @@ void FrameInterpolation_RecordMatrixRotateAxis(f32 angle, Vec3f* axis, u8 mode);
void FrameInterpolation_RecordSkinMatrixMtxFToMtx(MtxF* src, Mtx* dest);
// pairs holds pairCount (moving vertex, vertex it starts from) index pairs
void FrameInterpolation_RecordRibbonHead(const void* key, void* dest, u32 vtxCount, u32 pairCount, const s16* pairs);
void FrameInterpolation_UpdateRibbonHeads(float step);
#ifdef __cplusplus
}
#endif
+39
View File
@@ -515,6 +515,13 @@ void EffectBlure_SetupSmooth(EffectBlure* this, GraphicsContext* gfxCtx) {
CLOSE_DISPS(gfxCtx);
}
// AddVertex sets a new sample's timer to 1. Only blend on frames that added one,
// otherwise a finished trail keeps shrinking its last segment.
static s32 EffectBlure_HeadIsNew(EffectBlure* this) {
return (this->numElements >= 2) && (this->elements[this->numElements - 1].state == 1) &&
(this->elements[this->numElements - 1].timer == 1);
}
// original name: "SQ_NoInterpolate_disp"
void EffectBlure_DrawElemNoInterpolation(EffectBlure* this, EffectBlureElement* elem, s32 index,
GraphicsContext* gfxCtx) {
@@ -601,6 +608,13 @@ void EffectBlure_DrawElemNoInterpolation(EffectBlure* this, EffectBlureElement*
vtx[3].v.cn[2] = sp78.b;
vtx[3].v.cn[3] = sp78.a;
if ((index == (s32)this->numElements - 2) && EffectBlure_HeadIsNew(this)) {
// Newest end (vtx[2], vtx[3]) slides from the previous sample (vtx[1], vtx[0]).
static const s16 headPairs[] = { 2, 1, 3, 0 };
FrameInterpolation_RecordRibbonHead(this, vtx, 4, 2, headPairs);
}
gSPVertex(POLY_XLU_DISP++, vtx, 4, 0);
gSP2Triangles(POLY_XLU_DISP++, 0, 1, 2, 0, 0, 2, 3, 0);
}
@@ -768,6 +782,17 @@ void EffectBlure_DrawElemHermiteInterpolation(EffectBlure* this, EffectBlureElem
vtx[j2].v.cn[3] = EffectSs_LerpU8(sp1A0.a, sp198.a, temp_f28);
}
if ((index == (s32)this->numElements - 2) && EffectBlure_HeadIsNew(this)) {
// Every spline vertex depends on the moving end cross-section: collapse each onto
// the start of its edge (vtx[0] for the tip, vtx[1] for the base).
static const s16 headPairs[] = {
2, 0, 4, 0, 6, 0, 8, 0, 10, 0, 12, 0, 14, 0, // tip edge
3, 1, 5, 1, 7, 1, 9, 1, 11, 1, 13, 1, 15, 1, // base edge
};
FrameInterpolation_RecordRibbonHead(this, vtx, 16, 14, headPairs);
}
gSPVertex(POLY_XLU_DISP++, vtx, 16, 0);
gSP2Triangles(POLY_XLU_DISP++, 0, 1, 3, 0, 0, 3, 2, 0);
gSP2Triangles(POLY_XLU_DISP++, 2, 3, 5, 0, 2, 5, 4, 0);
@@ -1061,6 +1086,13 @@ void EffectBlure_DrawSimple(EffectBlure* this2, GraphicsContext* gfxCtx) {
}
}
if (EffectBlure_HeadIsNew(this)) {
// Newest end (vtx[2], vtx[3]) slides from the previous sample (vtx[0], vtx[1]).
static const s16 headPairs[] = { 2, 0, 3, 1 };
FrameInterpolation_RecordRibbonHead(this, &vtx[(this->numElements - 2) * 4], 4, 2, headPairs);
}
EffectBlure_DrawSimpleVertices(gfxCtx, this, vtx);
}
}
@@ -1155,6 +1187,13 @@ void EffectBlure_Draw(void* thisx, GraphicsContext* gfxCtx) {
}
}
if ((j >= 4) && EffectBlure_HeadIsNew(this)) {
// Newest pair slides from the previous pair (j - 4, j - 3).
const s16 headPairs[] = { (s16)(j - 2), (s16)(j - 4), (s16)(j - 1), (s16)(j - 3) };
FrameInterpolation_RecordRibbonHead(this, vtx, j, 2, headPairs);
}
j = 0;
gSPVertex(POLY_XLU_DISP++, vtx, 32, 0);