Skip to content

Animations run at 30 Hz due to the decoupled sim tick #20

Description

@philpax

Problem

Character and object animations step at 30 Hz, producing visible judder — most noticeable in first-person view (where Rico's own body animations stutter) and when entering a vehicle (the transition animation steps at the sim rate rather than the render rate). This is inherent to the game's decoupled sim/render design, not specific to VR — it's visible on flatscreen too, though VR amplifies the perception.

Context

JC3's Apex engine decouples the simulation tick from the render rate. The Game struct exposes the relevant fields:

  • m_UpdateFrequency / m_DefaultUpdateFrequency — the fixed sim tick rate (30 Hz).
  • m_DecoupleEnabled — whether sim and render are decoupled.
  • m_InterpolationMethod / m_PrevInterpolationMethod — controls how animation poses are interpolated between sim ticks.

https://github.com/ferrobrew/jc3vrs/blob/947b25fed0235d8709753144f10d4f95901a9e91/jc3gi/pyxis-defs/projects/JustCause3/Steam/20206564/game.pyxis#L1-L25

The mod already patches Game::Update to force UpdateRender every frame (nopping the m_UpdateFlags & 4 check so the engine always takes the UpdateRender path), and the stereo double-Draw drives two renders per real frame:

https://github.com/ferrobrew/jc3vrs/blob/947b25fed0235d8709753144f10d4f95901a9e91/payload/src/hooks/game.rs#L29-L34

But this only forces the render side to run every frame — the simulation (animation sampling, physics, AI) still ticks at the fixed 30 Hz rate. The Clock::Update detour gates the clock to once per real frame to prevent the SPF exponential smoother from double-stepping in stereo:

https://github.com/ferrobrew/jc3vrs/blob/cc77ef89e9c9b3ab5b6a53d54ffccceccff5a673/payload/src/hooks/clock.rs#L7-L21

What was ruled out: Disabling decoupling (m_DecoupleEnabled = false) or raising m_UpdateFrequency to match the render rate causes widespread breakage — the game is designed for 30 Hz, and many systems are missing proper delta-time multiplications, so running them at a higher rate produces incorrect behavior. A global tick-rate change is not viable; whatever fix is needed must be targeted to the animation/pose pipeline specifically.

The pose is finalized in the SIM phase via UpdatePassFinalizePose_Parallel (→ HumanIK, SyncPoses, CalculateModelSpacePose → Character::UpdatePropEffects), then consumed by the camera and skinning in the RENDER phase. The frame order is documented in docs/skeleton.md and docs/rendering.md §1.3–§1.5. The mod already hooks UpdatePropEffects (the last SIM-phase call before the model-space pose is finalized), which is the seam where pose overrides land.

Related: #5 (direct head control — the baked-head-animation-fights-HMD pitfall is the same 30 Hz animation sampling).

Expected vs actual

Expected: Animations update smoothly at the render rate, with the sim tick rate invisible to the player.
Actual: Animations step at 30 Hz, producing visible judder. Most jarring in first-person (Rico's own body) and during vehicle entry transitions.

Proposed approach

Since the global tick rate cannot be changed (the game's systems are designed for 30 Hz and break at higher rates), the fix must be targeted to the animation/pose pipeline. Candidate directions:

A. Pose interpolation between sim ticks. The engine exposes m_InterpolationMethod / m_PrevInterpolationMethod on the Game struct — the engine may already have an interpolation path that is either disabled or not reaching the render path. Investigate what m_InterpolationMethod values do and whether enabling/forcing it smooths animation between the 30 Hz sim ticks. The GameObjectRenderContext carries m_Dt / m_Dtf (frame and filtered deltas), which the animation system may already use for interpolation — the question is whether the mod's forced-UpdateRender path bypasses it.

B. Re-sample the animation between sim ticks. If the engine's interpolation is insufficient or disabled, the mod could re-sample the animation pose between sim ticks in the existing character_update_prop_effects hook — reading the animation state at a sub-tick time and writing a blended pose via SetJoint. This is more invasive but would be scoped to the local player character only.

C. Investigate whether the issue is pose sampling or pose consumption. The pose is finalized in SIM and consumed in RENDER. If the RENDER phase is already interpolating but the camera hook is reading a stale (pre-interpolation) bone matrix, the fix may be in when the camera reads the bones rather than in the animation pipeline itself. The m_WorldMatrixT0 / m_WorldMatrixT1 fields on Character (which the camera hook already reads) suggest a two-buffer interpolation scheme — confirm whether the render path is using T1 (current) or T0 (previous) and whether a blend is expected.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestrenderingRendering pipeline

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions