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

Abierto
#664 1 comentario 0 reacciones 0 asignados Ver en GitHub

Nadie ha tomado este issue todavía.

Evaluación

Dificultad
4/5
Tiempo estimado
3-5 días
Aptitud para principiantes
30/100
Tipo de issue
Error
Claridad
Bien especificado
Estado de actividad
Activo
Stack tecnológico
cpp, dart, flutter

Línea de trabajo

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′.

Escrito por el modelo de indexación a partir del texto del issue.

Descripción

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

Lenguaje dominante
Dart
Estrellas
1.5k
Forks
244
Métricas de merge de PR
Sin PR fusionados en 30 d

Preparar el entorno

Este proyecto no incluye contenedor de desarrollo, Dockerfile ni guía de contribución, así que la configuración corre por tu cuenta: empieza por su README y consulta nuestra guía para la primera contribución para los pasos generales.

Primeros pasos

  1. Lee el issue completo y luego la guía de contribución del proyecto.
  2. Comenta en el issue que vas a ocuparte — evita que dos personas hagan lo mismo.
  3. Haz un fork del repositorio y trabaja en una rama.
  4. Abre un pull request que haga referencia al número del issue.

Más de rive-app/rive-flutter

Todos los issues de rive-app/rive-flutter

Issues similares

Más issues de Dart

Recibe los nuevos issues en tu correo

Un resumen breve de issues de GitHub para principiantes.