advanceAndApply() never settles after hitting max iterations (60 fps with nothing changing)

オープン
#664 コメント 1 件 リアクション 0 件 担当者 0 名 GitHub で見る

まだ誰も着手していません。

評価

難易度
4/5
見積もり時間
3〜5日
初心者へのやさしさ
30/100
issue の種類
バグ
明瞭さ
明確に書かれている
活発さ
活発
技術スタック
cpp, dart, flutter

調査の方向性

Start in runtime/src/animation/state_machine_instance.cpp: StateMachineLayerInstance::advance() stops at maxIterations and returns false, while advanceAndApply() calls tryChangeState() again and sets keepGoing. Reproduce with the two-transition Any State cycle described in the issue, then check whether the layer keeps reporting true after the cap. Done means the layer settles, the log line stops repeating each frame, and the looping sample files still behave the same. The maintainers still need to decide between fix A and A′.

索引モデルが issue の本文から書いたものです。

説明

known
Submission checklist
  • I have confirmed the issue is present in the latest version of the rive Flutter package
  • I have searched the documentation and forums and could not find an answer
  • I have searched existing issues and this is not a duplicate
Description

A state machine layer cycling through zero-duration transitions hits the 100-iteration cap and returns false, but StateMachineInstance::advanceAndApply() then calls tryChangeState() again, re-enters the same cycle and sets keepGoing = true. So advanceAndApply() returns true on every frame while nothing on screen changes (Artboard::didChange() stays false). FlutterRiveRenderBox only stops its ticker on false, so the app renders 60 fps of an unchanged picture forever.

The root cause in our case is the file (an Any State transition whose condition stays true targets a state that exits immediately) and we are fixing it. But the runtime makes it much worse:

  1. It undoes its own livelock detection: the extra tryChangeState() re-arms keepGoing for a cycle that cannot produce anything new, so the guard never stops anything from one frame to the next.
  2. It waits 100 iterations for a cycle that repeats on its 2nd iteration (~200 StateInstance allocations per frame).
  3. It reports it only on stderr, which doesn't reach Android logcat; the editor doesn't flag the construct either.
Measurements

Device (Galaxy Z Flip6, Android 16, profile, 60 Hz), a tab bar with five Rive icons, one of them with the construct, at rest, 10 s windows:

mode fps GPU busy process CPU
stock 59.3 55 % 80 %
same tab bar, icons replaced by empty boxes 0 0 % 1 %

C++ harness with a ticker that stops on false (600 frames): the icon is advanced on 483 frames with the shipped runtime vs 69 with fix A below; "exceeded max iterations" lines drop from 840 to 6; didChange and dirtied components are identical frame by frame. Across 56 files, 51 produce identical output and the 5 looping ones settle.

Root cause (runtime/src/animation/state_machine_instance.cpp, rive_native 0.1.11, unchanged in 0.2.0-dev.3)
  • StateMachineLayerInstance::advance(): for (i = 0; updateState(); i++); at maxIterations it prints and returns false.
  • advanceAndApply() outer loop: if (tryChangeState()) { advance(0, false); keepGoing = true; } finds the same always-true transition again (canChangeState() only rejects the current state).
Proposed fix A (prototype, ~40 lines)

At the cap, if the cycle had no observable side effect (no event fired, no listener action, no random transition, no spilled time, no scripted condition), updateState() returns false for the rest of that advance(). Everything else still drives keepGoing (component dirt, data binding, nested artboards, other layers), and inputs / view model writes / pointer / layout already restart a stopped ticker. A cycle with side effects keeps looping as today.

Refinement A′: detect the cycle when a state is re-entered within the same frame with no side effect, instead of after 100 iterations (also removes the allocations and the log spam).

Diff against rive_native 0.1.11
--- a/runtime/src/animation/state_machine_instance.cpp
+++ b/runtime/src/animation/state_machine_instance.cpp
@@ -230,6 +230,8 @@ public:
         }
         m_currentState->advance(seconds, m_stateMachineInstance);
         updateMix(seconds);
+        m_settledInCycle = false;
+        m_cycleIsOpaque = false;
 
         if (m_stateFrom != nullptr && m_mix < 1.0f && !m_holdAnimationFrom)
         {
@@ -249,6 +251,13 @@ public:
 
             if (i == maxIterations)
             {
+                // The layer is cycling through zero-duration transitions whose
+                // conditions stay true. When the cycle has no side effect the
+                // host can observe (no event, no listener action, no random
+                // pick, no spilled time, no scripted condition), re-running it
+                // next frame yields the same result: let the layer settle until
+                // an input, a view model property or a pointer wakes it.
+                m_settledInCycle = !m_cycleIsOpaque;
                 auto stateMachineName =
                     m_stateMachineInstance->stateMachine() == nullptr
                         ? "[SM Not found]"
@@ -323,6 +332,10 @@ public:
 
     bool updateState()
     {
+        if (m_settledInCycle)
+        {
+            return false;
+        }
         // Don't allow changing state while a transition is taking place
         // (we're mixing one state onto another) if enableEarlyExit is not true.
         if (isTransitioning() && !m_transition->enableEarlyExit())
@@ -347,6 +360,7 @@ public:
         {
             if (event->occurs() == occurs)
             {
+                m_cycleIsOpaque = true;
                 event->perform(m_stateMachineInstance);
             }
         }
@@ -360,6 +374,7 @@ public:
         {
             if (action->matchesScheduledOccurrence(occurs))
             {
+                m_cycleIsOpaque = true;
                 action->perform(m_stateMachineInstance,
                                 ListenerInvocation::none());
             }
@@ -409,6 +424,19 @@ public:
         return;
     }
 
+    // A scripted condition can change its answer without any input changing,
+    // so a cycle that evaluates one is never treated as settled.
+    void markOpaqueConditions(const StateTransition* transition)
+    {
+        for (size_t c = 0; c < transition->conditionCount(); c++)
+        {
+            if (transition->condition(c)->is<ScriptedTransitionCondition>())
+            {
+                m_cycleIsOpaque = true;
+            }
+        }
+    }
+
     StateTransition* findRandomTransition(StateInstance* stateFromInstance)
     {
         uint32_t totalWeight = 0;
@@ -420,6 +448,7 @@ public:
             if (canChangeState(transition->stateTo()))
             {
 
+                markOpaqueConditions(transition);
                 auto allowed = transition->allowed(stateFromInstance,
                                                    m_stateMachineInstance,
                                                    this);
@@ -456,6 +485,7 @@ public:
                     (double)transition->evaluatedRandomWeight();
                 if (currentWeight + transitionWeight > randomWeight)
                 {
+                    m_cycleIsOpaque = true;
                     transition->useLayerInConditions(m_stateMachineInstance,
                                                      this);
                     return transition;
@@ -484,6 +514,7 @@ public:
             if (canChangeState(transition->stateTo()))
             {
 
+                markOpaqueConditions(transition);
                 auto allowed = transition->allowed(stateFromInstance,
                                                    m_stateMachineInstance,
                                                    this);
@@ -619,6 +650,10 @@ public:
                         advanceTime = instance->spilledTime();
                     }
                 }
+                if (advanceTime != 0.0f)
+                {
+                    m_cycleIsOpaque = true;
+                }
                 m_currentState->advance(advanceTime, m_stateMachineInstance);
             }
             m_mix = 0.0f;
@@ -705,6 +740,10 @@ private:
     bool m_stateMachineChangedOnAdvance = false;
 
     bool m_waitingForExit = false;
+    /// The layer hit maxIterations this frame on a cycle without observable
+    /// side effects; updateState() stops re-entering it until next advance.
+    bool m_settledInCycle = false;
+    bool m_cycleIsOpaque = false;
     /// Used to ensure a specific animation is applied on the next apply.
     const LinearAnimation* m_holdAnimation = nullptr;
     float m_holdTime = 0.0f;
Questions
  1. Is returning true after the cap intended? What change would the extra tryChangeState() catch that doesn't already trigger an advance?
  2. Would you accept fix A or A′? Did we miss a source of non-determinism?
  3. Could "exceeded max iterations" reach the host (callback / __android_log_print) and the editor (warning on this construct)?
Reproduction steps / code
  1. View model: enum iconState (notSelected, selected), bool lightMode.
  2. Animations: Inactive_light / Inactive_dark (static pose), Active (500 ms).
  3. One layer:
Any State ──[iconState == notSelected && lightMode]──▶ Inactive_light
Any State ──[iconState == notSelected && !lightMode]─▶ Inactive_dark
Any State ──[iconState changed to selected]──────────▶ Active
Inactive_light ──(no condition)──▶ Exit
Inactive_dark  ──(no condition)──▶ Exit
  1. Show it with RiveWidget, iconState = notSelected, don't touch it.

Observed: stderr "exceeded max iterations" twice per frame, advanceAndApply() true forever, 60 fps with nothing changing. Expected: the layer settles. Removing the two → Exit transitions makes it settle today.

Rive Flutter package version

rive 0.14.11 (rive_native 0.1.11); same code in rive_native 0.2.0-dev.3.

Flutter version

Flutter 3.47.3 (stable), Dart 3.13.3

Device

Samsung Galaxy Z Flip6 (SM-F741B); C++ harness on macOS.

OS version

Android 16

Additional context

Shared C++ runtime, so every platform is affected; it hurts most on Flutter because FlutterRiveRenderBox stops its ticker as soon as the runtime answers false. A separate issue covers the Flutter draw gate on didChange (parity with iOS/web): #665

主要言語
Dart
スター
1.5k
フォーク
244
PR マージ指標
30日以内にマージされた PR はありません

環境構築

このプロジェクトには開発コンテナ、Dockerfile、コントリビューションガイドがありません。まず README を読み、一般的な手順ははじめてのコントリビューションガイドを参照してください。

はじめの一歩

  1. issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
  2. 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
  3. リポジトリをフォークし、ブランチを切って変更します。
  4. issue 番号を参照したプルリクエストを送ります。

rive-app/rive-flutter のほかの issue

rive-app/rive-flutter の issue をすべて見る

似ている issue

Dart の issue をもっと見る

新しい issue をメールで受け取る

初心者向けの GitHub issue を短くまとめたダイジェスト。