Skip to content

tweak(drawable): Interpolate physics transforms - #2055

Open
bobtista wants to merge 2 commits into
TheSuperHackers:mainfrom
bobtista:bobtista/fix-drawable-physics-timing
Open

bobtista wants to merge 2 commits into
TheSuperHackers:mainfrom
bobtista:bobtista/fix-drawable-physics-timing

Conversation

@bobtista

@bobtista bobtista commented Jan 4, 2026

Copy link
Copy Markdown

#1528 already made Drawable physics run on logic frames. At render rates above the logic rate, rendering still holds the last result until the next logic frame, so vehicle tilt, suspension, wobble, and recoil update at 30 Hz.

This saves the previous physics transform and interpolates between it and the current one using fractional sync time. The physics calculation itself is unchanged. The rendered transform trails the simulation by one logic frame (~33 ms).

Todo:

  • Test Rocket Buggy movement at 30 / 60 / uncapped
  • Replicate to Generals

@Caball009

Copy link
Copy Markdown

Can you show a video how it looks before and after?

I'd recommend replicating to Generals as the very last thing you do for any PR. It's easier for the PR creator and reviewer(s).

@xezon

xezon commented Jan 5, 2026

Copy link
Copy Markdown

I remember I worked on this before and it was difficult to get perfectly right and then paused this. I still have the WIP branch. I would be surprised if this change fixed it with no problems at all.

For easy test, take a GLA Buggy and compare its physics with Retail at different FPS. If it is not matching, then there is work left to do.

@bobtista

bobtista commented Jan 5, 2026

Copy link
Copy Markdown
Author

I remember I worked on this before and it was difficult to get perfectly right and then paused this. I still have the WIP branch. I would be surprised if this change fixed it with no problems at all.

For easy test, take a GLA Buggy and compare its physics with Retail at different FPS. If it is not matching, then there is work left to do.

Oh that's right, I remember watching this, there's some buggy jank addressed is in episode 0844. I was just trying to apply changes similar to your decouple stealth fade one for remaining frame based logic, hadn't started testing yet. I'll convert to draft and assume there's more to do

@bobtista
bobtista marked this pull request as draft January 5, 2026 20:46
@bobtista

bobtista commented Jan 5, 2026

Copy link
Copy Markdown
Author

I remember I worked on this before and it was difficult to get perfectly right and then paused this. I still have the WIP branch. I would be surprised if this change fixed it with no problems at all.

For easy test, take a GLA Buggy and compare its physics with Retail at different FPS. If it is not matching, then there is work left to do.

Can you push the latest to your WIP branch?

@xezon

xezon commented Jan 5, 2026

Copy link
Copy Markdown

@bobtista
bobtista force-pushed the bobtista/fix-drawable-physics-timing branch from 2751da9 to 2bb1cb5 Compare January 6, 2026 01:01
@bobtista

bobtista commented Jan 6, 2026

Copy link
Copy Markdown
Author

When I increase render FPS and update physics every render, the buggy doesn't wabble or wheelie as much. At higher render FPS we're getting effectively like a higher resolution of the damped spring math, so less overshoots, less wobble, but same total force is applied. Claude says it's called the Euler integration of a damped spring.

I think it makes sense to only calculate it at logic frames, and we interpolate so it's smoother visually. Otherwise we're messing with the spring math to approximate the overshoots from before, and it's purely visual right? Anyway - I just tested, and it looks right to me.

Note the other changes in this PR don't have this overshoot feedback kind of issue, so they all should work with calculations on render frames. Eg Linear fades like opacity

I've implemented this approach and restored the Generals changes (can replicate once this is approved).

@bobtista
bobtista marked this pull request as ready for review January 6, 2026 01:54
@xezon xezon added this to the Decouple logic and rendering milestone Feb 5, 2026
Comment thread GeneralsMD/Code/GameEngine/Source/GameClient/Drawable.cpp
@bobtista
bobtista force-pushed the bobtista/fix-drawable-physics-timing branch from 2bb1cb5 to 77e6dad Compare June 29, 2026 17:21
@greptile-apps

greptile-apps Bot commented Jun 29, 2026

Copy link
Copy Markdown
Greptile Summary

This PR adds render-frame interpolation to the drawable physics transform (vehicle tilt, suspension wobble, recoil) so that motion stays smooth at render rates above the 30 Hz logic rate, following the groundwork laid in #1528.

  • Four m_prev* fields are added to PhysicsXformInfo and zero-initialized in the constructor; on each WW sync frame the current values are snapshotted into these fields before calcPhysicsXform advances them.
  • A t factor derived from Get_Fractional_Sync_Milliseconds() / MSEC_PER_LOGICFRAME_REAL (clamped to [0, 1]) drives per-channel linear interpolation of pitch, roll, yaw, and Z before applying the matrix transforms; at 30 fps t is always ≈ 0 and the render trails by one logic frame (~33 ms), which the PR description explicitly acknowledges.
  • The change is applied identically to both Generals and Zero Hour builds; PhysicsXformInfo is not serialized so no xfer changes are required.
Confidence Score: 5/5
  • This PR is safe to merge. The interpolation is bounded by an existing clamp(min, value, max) call and falls back gracefully to the previous frame's physics state at 30 fps.
  • The change is a well-contained interpolation pass on purely render-side data. The clamp call prevents any out-of-range t values, the m_prev* fields are zero-initialized in the constructor, and PhysicsXformInfo is not serialized — so no save/load paths are affected. The intentional one-frame render lag at 30 fps is clearly documented in both the PR description and the in-code comments.
  • No files require special attention.
Important Files Changed
Filename Overview
Generals/Code/GameEngine/Include/GameClient/Drawable.h Adds four m_prev* fields to PhysicsXformInfo and zero-initializes them in the constructor. Straightforward struct extension with no issues.
Generals/Code/GameEngine/Source/GameClient/Drawable.cpp Implements physics transform interpolation in applyPhysicsXform: saves previous state on each logic frame, then linearly interpolates using fractional sync time. The clamp(min, value, max) call matches the codebase's convention and correctly bounds t to [0, 1].
GeneralsMD/Code/GameEngine/Include/GameClient/Drawable.h Mirror of the Generals header change — identical m_prev* additions to PhysicsXformInfo. No issues.
GeneralsMD/Code/GameEngine/Source/GameClient/Drawable.cpp Mirror of the Generals .cpp change — identical interpolation logic applied to the Zero Hour build. No issues.
Sequence Diagram
sequenceDiagram
    participant App as Game Loop
    participant WW3D as WW3D Timing
    participant AP as applyPhysicsXform
    participant Calc as calcPhysicsXform

    Note over App,Calc: Logic Frame (Get_Sync_Frame_Time != 0)
    App->>AP: render tick (logic frame)
    AP->>AP: "save m_prev* = m_total*"
    AP->>Calc: calcPhysicsXform()
    Calc-->>AP: "new m_total* computed"
    AP->>WW3D: Get_Fractional_Sync_Milliseconds() ≈ 0
    WW3D-->>AP: t ≈ 0.0
    AP->>AP: "interp = prev + 0*(current - prev) = prev"
    Note right of AP: Renders previous frame values

    Note over App,Calc: Render-only Frame (Get_Sync_Frame_Time == 0)
    App->>AP: render tick (mid-frame)
    Note right of AP: No prev save, no calcPhysicsXform
    AP->>WW3D: Get_Fractional_Sync_Milliseconds() ≈ 16ms
    WW3D-->>AP: t ≈ 0.5
    AP->>AP: "interp = prev + 0.5*(current - prev)"
    Note right of AP: Smoothly interpolated output

    Note over App,Calc: Next Logic Frame
    App->>AP: render tick (logic frame)
    AP->>AP: "save m_prev* = m_total* (from prior logic frame)"
    AP->>Calc: calcPhysicsXform()
    Calc-->>AP: "new m_total*"
    AP->>WW3D: Get_Fractional_Sync_Milliseconds() ≈ 0
    WW3D-->>AP: t ≈ 0.0
Loading

Reviews (6): Last reviewed commit: "tweak(drawable): Replicate physics inter..." | Re-trigger Greptile

Comment thread Generals/Code/GameEngine/Source/GameClient/Drawable.cpp Outdated
@bobtista

Copy link
Copy Markdown
Author

@xezon are you ok with copying the zero hour changes to Generals here? Greptile is right that they used different approaches, and I don't see the benefit in keeping or trying to tweak the Generals' one vs using ZH's interpolation

@bobtista

bobtista commented Jul 19, 2026

Copy link
Copy Markdown
Author

Greptile Summary

This PR decouples drawable physics and visual fade calculations from the render update so they advance at a consistent rate regardless of frame rate. The two game versions take meaningfully different approaches: Generals multiplies each physics delta by a timeScale ratio at the call site, while Zero Hour (GeneralsMD) runs physics exclusively on logic ticks and interpolates the rendered transform between frames using saved previous state.

  • Generals (Drawable.cpp): Spring-damper rates, position integration, overlap Z, bounce/wobble/recoil kicks, wheel suspension, and fade timers are all multiplied by TheFramePacer->getActualLogicTimeScaleOverFpsRatio(). Type fields m_timeElapsedFade, m_framesAirborneCounter, and m_framesAirborne are promoted from integer to Real; xfer version bumped to 7 with backward-compatible load path.
  • Zero Hour (Drawable.cpp): applyPhysicsXform saves previous pitch/roll/yaw/Z before each logic tick, then linearly interpolates to the fractional sync time for rendering. Xfer version bumped to 9 with corresponding backward-compatible load path. Wheel and fade paths also receive timeScale scaling within the logic-tick call.

Confidence Score: 3/5

The Generals physics path has a correctness problem in pitchRate damping that produces wrong behavior at low framerates; the Zero Hour path is significantly cleaner and unaffected.

In calcPhysicsXformTreads and calcPhysicsXformWheels (Generals only), the damping formula 1.0f - 0.5f * timeScale turns negative at timeScale greater than 2.0 (~15 fps equivalent), flipping pitchRate sign rather than attenuating it. This causes a brief but visible chassis kick in the wrong direction at low frame rates. The Zero Hour implementation avoids this via the interpolation approach. The xfer versioning and backward-compat load paths look correct in both codebases.

Generals/Code/GameEngine/Source/GameClient/Drawable.cpp — the pitchDamp formula in calcPhysicsXformTreads (line ~1665) and calcPhysicsXformWheels (line ~1908).

Important Files Changed

Filename Overview
Generals/Code/GameEngine/Source/GameClient/Drawable.cpp Physics decoupling via per-site timeScale multiplication; contains the pitchDamp linear approximation bug that can flip pitchRate sign at ~15 fps or below, plus multiple redundant timeScale queries per function.
GeneralsMD/Code/GameEngine/Source/GameClient/Drawable.cpp Uses a more robust physics-on-logic-tick + render-interpolation approach; minor style nit with compressionFactor2 naming; xfer versioning bump to 9 with correct backward-compatibility handling.
Generals/Code/GameEngine/Include/GameClient/Drawable.h Type changes for m_framesAirborneCounter, m_framesAirborne (Int to Real) and m_timeElapsedFade (UnsignedInt to Real) to support fractional frame accumulation.
GeneralsMD/Code/GameEngine/Include/GameClient/Drawable.h Same type changes as Generals header, plus new m_prev* fields (prevTotalPitch/Roll/Yaw/Z) added to PhysicsXformInfo to support the interpolation approach, zero-initialized in constructor.

Flowchart

mermaid %%{init: {'theme': 'neutral'}}%% flowchart TD A[Render Frame] --> B{WW3D Sync Frame?} subgraph Generals["Generals - scale-at-site approach"] B -->|Yes - logic tick| C[calcPhysicsXform with timeScale applied to every delta] B -->|No - extra render frame| D[applyPhysicsXform uses last calculated values as-is] C --> D end subgraph ZeroHour["Zero Hour - save/interpolate approach"] B -->|Yes - logic tick| E[Save prevTotalPitch/Roll/Yaw/Z then calcPhysicsXform at fixed 30 fps rate] B -->|No - extra render frame| F[Interpolate between prev and current state using fractionalMs / LOGIC_FRAME_MS] E --> F end D --> G[Apply transform to matrix] F --> G Loading %%{init: {'theme': 'neutral'}}%% flowchart TD A[Render Frame] --> B{WW3D Sync Frame?} subgraph Generals["Generals - scale-at-site approach"] B -->|Yes - logic tick| C[calcPhysicsXform with timeScale applied to every delta] B -->|No - extra render frame| D[applyPhysicsXform uses last calculated values as-is] C --> D end subgraph ZeroHour["Zero Hour - save/interpolate approach"] B -->|Yes - logic tick| E[Save prevTotalPitch/Roll/Yaw/Z then calcPhysicsXform at fixed 30 fps rate] B -->|No - extra render frame| F[Interpolate between prev and current state using fractionalMs / LOGIC_FRAME_MS] E --> F end D --> G[Apply transform to matrix] F --> G mermaid %%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%% flowchart TD A[Render Frame] --> B{WW3D Sync Frame?} subgraph Generals["Generals - scale-at-site approach"] B -->|Yes - logic tick| C[calcPhysicsXform with timeScale applied to every delta] B -->|No - extra render frame| D[applyPhysicsXform uses last calculated values as-is] C --> D end subgraph ZeroHour["Zero Hour - save/interpolate approach"] B -->|Yes - logic tick| E[Save prevTotalPitch/Roll/Yaw/Z then calcPhysicsXform at fixed 30 fps rate] B -->|No - extra render frame| F[Interpolate between prev and current state using fractionalMs / LOGIC_FRAME_MS] E --> F end D --> G[Apply transform to matrix] F --> G Loading %%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%% flowchart TD A[Render Frame] --> B{WW3D Sync Frame?} subgraph Generals["Generals - scale-at-site approach"] B -->|Yes - logic tick| C[calcPhysicsXform with timeScale applied to every delta] B -->|No - extra render frame| D[applyPhysicsXform uses last calculated values as-is] C --> D end subgraph ZeroHour["Zero Hour - save/interpolate approach"] B -->|Yes - logic tick| E[Save prevTotalPitch/Roll/Yaw/Z then calcPhysicsXform at fixed 30 fps rate] B -->|No - extra render frame| F[Interpolate between prev and current state using fractionalMs / LOGIC_FRAME_MS] E --> F end D --> G[Apply transform to matrix] F --> G
Prompt To Fix All With AI

Fix the following 3 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 3
Generals/Code/GameEngine/Source/GameClient/Drawable.cpp:1663-1667
**Linear pitchDamp approximation goes negative at low framerates**

`pitchDamp = 1.0f - 0.5f * timeScale` produces a negative value whenever `timeScale > 2.0f` (i.e., roughly below ~15 fps). When that happens and `m_pitchRate > 0.0f`, the multiplication flips the sign of `pitchRate` instead of damping it — the wheel chassis momentarily kicks in the wrong direction. The correct frame-rate-independent equivalent of "multiply by 0.5 every 30-fps frame" is `powf(0.5f, timeScale)`, which stays positive for all positive `timeScale` values and matches exactly at `timeScale = 1.0`. The same pattern appears again in `calcPhysicsXformWheels` around line 1908.

### Issue 2 of 3
Generals/Code/GameEngine/Source/GameClient/Drawable.cpp:1638-1758
**Redundant `getActualLogicTimeScaleOverFpsRatio()` calls within a single function**

`calcPhysicsXformTreads` queries `TheFramePacer->getActualLogicTimeScaleOverFpsRatio()` four separate times using four different local variable names (`overlapTimeScale`, `timeScale`, `hitRecoilTimeScale`, and a second `overlapTimeScale` in a different scope). All four will return the same value during a single render update. Computing it once at the top of the function and reusing it would be both more efficient and easier to follow. The same pattern repeats in `calcPhysicsXformWheels` with `bounceTimeScale`, `timeScale`, `wheelAngleTimeScale`, and `compressionTimeScale`.

### Issue 3 of 3
GeneralsMD/Code/GameEngine/Source/GameClient/Drawable.cpp:2472-2474
The `2` suffix on `compressionTimeScale2` and `compressionFactor2` is unnecessary — these variables are in their own local scope within `calcPhysicsXformMotorcycle`, so they don't conflict with the same-named variables in `calcPhysicsXformWheels`.

```suggestion
		// TheSuperHackers @tweak Wheel compression dampening is now decoupled from the render update.
		const Real compressionTimeScale = TheFramePacer->getActualLogicTimeScaleOverFpsRatio();
		const Real compressionFactor = 0.5f * compressionTimeScale;

Reviews (1): Last reviewed commit: ["Replicate to generals"](https://github.com/thesuperhackers/generalsgamecode/commit/77e6dadca3b80e82d7cc67d9916bb5cbd959084a) | [Re-trigger Greptile](https://app.greptile.com/api/retrigger?id=18986562)

Both gone - the Zero Hour side no longer queries the ratio inside the physics functions at all (they run on logic frames now), and the Generals side will be replaced with the same approach once agreed.

@bobtista

Copy link
Copy Markdown
Author

Found three problems while re-reviewing this, all fixed now:

  1. The interpolation divisor was getLogicTimeStepMilliseconds(), which is the per-render-frame logic advance (16.7ms at 60fps), not a full logic step. FractionalSyncMs accumulates by that same amount, so t reached 1.0 after a single render frame and the result was a one-frame-delayed step, not interpolation. It divides by MSEC_PER_LOGICFRAME_REAL now.
  2. Wheel angle smoothing and wheel compression dampening still multiplied by the render ratio, but calcPhysicsXform* only runs on logic frames now, so they ran at half speed at 60fps. Reverted to the retail math.
  3. Weapon recoil is a one-shot impulse per shot, not a per-frame accumulation, so scaling it by the render ratio just weakened recoil at high fps. Reverted.

With those fixed the spring math always steps at the logic rate with retail constants regardless of render fps, so the buggy behaves like retail at 30fps by construction — tested at [30 / 60 / uncapped]. One caveat: the rendered tilt lags the sim by one logic frame (~33ms) because it interpolates prev→current, so a frame-by-frame retail comparison will show that offset.

I still want others' thoughts on copying this approach to Generals - the timeScale-at-call-site version there changes the spring dynamics with fps (step size alters the overshoot), which is exactly what your buggy test catches.

@bobtista

Copy link
Copy Markdown
Author

Can you show a video how it looks before and after?

I'd recommend replicating to Generals as the very last thing you do for any PR. It's easier for the PR creator and reviewer(s).

Buggy movement at 30fps
https://github.com/user-attachments/assets/c389aa0e-356e-4992-a251-f75ece0708bd

Buggy movement at 120fps
https://github.com/user-attachments/assets/927e18dc-e532-4d5a-97be-210cd00eafea

@bobtista
bobtista force-pushed the bobtista/fix-drawable-physics-timing branch from 04f1c20 to d3c5b8d Compare August 14, 2026 16:59
@bobtista

Copy link
Copy Markdown
Author

Can this get some love?

@bobtista
bobtista force-pushed the bobtista/fix-drawable-physics-timing branch from 0451755 to 7be7c8f Compare August 27, 2026 16:30
Comment thread Generals/Code/GameEngine/Source/GameClient/Drawable.cpp Outdated
@Caball009

Copy link
Copy Markdown

Would you mind including larger videos with more detail?

@bobtista
bobtista force-pushed the bobtista/fix-drawable-physics-timing branch from e7a1573 to b90c640 Compare September 14, 2026 19:41
@bobtista bobtista changed the title tweak(drawable): Decouple physics and fade timing from render update tweak(drawable): Fix fade speed and smooth physics at high render rates Sep 14, 2026
@bobtista

Copy link
Copy Markdown
Author

Would you mind including larger videos with more detail?

Ok I swapped in the Rebel Ambush clip instead of another buggy one - since the other physics decoupling work was merged, this PR just handles the interpolation for physics like the buggy wobble and the fade effect timing - it's harder for me to see the smoother interpolated difference and particularly hard for me to record it on my old windows box because it'll struggle to both record and stay at high FPS lol. Anyway, the fade shows the bug plainly: at a 4:1 render/logic ratio an authored 3000 ms fade runs in 750 ms. Both halves of the PR are from the same issue, something that should advance per unit of time advancing once per render frame, so yeah, the fade is the better demo to share here (in the PR description).

@xezon

xezon commented Sep 15, 2026

Copy link
Copy Markdown

This Pull request has 2 changes. I suggest split them into 2 pulls.

@bobtista

Copy link
Copy Markdown
Author

Yeah, makes sense. I’ll keep this one for the physics interpolation and move the fade timing changes into a separate PR.

@bobtista bobtista changed the title tweak(drawable): Fix fade speed and smooth physics at high render rates tweak(drawable): Interpolate physics transforms Sep 15, 2026
@bobtista

Copy link
Copy Markdown
Author

Split now — this PR is physics interpolation only. The fade timing change moved to #3303.

@@ -1353,13 +1353,29 @@ void Drawable::applyPhysicsXform(Matrix3D* mtx)
// All calculations are originally catered to a 30 fps logic step.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this comment still correct?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 30 fps part still applies to the physics state update, but yeah, “all calculations” is wrong now that interpolation runs every render frame. I updated the comment.

@bobtista
bobtista force-pushed the bobtista/fix-drawable-physics-timing branch from e3606ef to 32ad9ac Compare September 16, 2026 19:48
@coderabbitai

coderabbitai Bot commented Sep 16, 2026

Copy link
Copy Markdown

Review Change StackReview Change Stack

Note

Currently processing new changes in this PR. This may take a few minutes, please wait...

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: 32b31cce-88bd-4481-bc7b-625ce8b247f2

📥 Commits

Reviewing files that changed from the base of the PR and between 039bd17 and 32ad9ac.

📒 Files selected for processing (4)
  • Generals/Code/GameEngine/Include/GameClient/Drawable.h
  • Generals/Code/GameEngine/Source/GameClient/Drawable.cpp
  • GeneralsMD/Code/GameEngine/Include/GameClient/Drawable.h
  • GeneralsMD/Code/GameEngine/Source/GameClient/Drawable.cpp
 ________________________________________________________________
< Fully armed and operationally intelligent code reviewer bunny. >
 ----------------------------------------------------------------
  \
   \   \
        \ /\
        ( )
      .( o ).

Comment @coderabbitai help to get the list of available commands.

@coderabbitai
coderabbitai Bot requested a review from xezon September 16, 2026 19:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants