perf(runtime): cheaper advance for rigs with many hidden shapes (#14255) 6dcbf68a58
* cache skinned path and layout participant lookups on shape

* notify the shape of a path change once per dirt cycle

* skip stroke effect updates while the paint is hidden

* scan for skinned paths and layout participants in editor builds, where children change under us

* keep measuring hidden effects that feed a clip

* keep the shape lookup caches in the never defer flag and spare padding so shape does not grow

* exempt only clip sources from the hidden effect skip, nothing else reads a hidden effect path

Co-authored-by: Luigi Rosso <luigi-rosso@users.noreply.github.com>
diff --git a/.rive_head b/.rive_head
index 02f75b4..6eb0cf1 100644
--- a/.rive_head
+++ b/.rive_head
@@ -1 +1 @@
-f09c4e58ae2128e922a323b1376d2dc75c2fd451
+6dcbf68a587a0d0a29906a9b41cba01dac2c0f8e
diff --git a/include/rive/shapes/path.hpp b/include/rive/shapes/path.hpp
index 4f192b5..1741819 100644
--- a/include/rive/shapes/path.hpp
+++ b/include/rive/shapes/path.hpp
@@ -41,6 +41,8 @@
     Shape* m_Shape = nullptr;
     std::vector<PathVertex*> m_Vertices;
     bool m_deferredPathDirt = false;
+    bool m_shapeNotified = false;
+    void shapePathChanged();
     PathFlags m_pathFlags = PathFlags::none;
     RawPath m_rawPath;
     RenderPathDeformer* deformer() const;
diff --git a/include/rive/shapes/path_composer.hpp b/include/rive/shapes/path_composer.hpp
index 9b398fa..e8605ff 100644
--- a/include/rive/shapes/path_composer.hpp
+++ b/include/rive/shapes/path_composer.hpp
@@ -32,6 +32,7 @@
     ShapePaintPath m_worldPath;
     ShapePaintPath m_localClockwisePath;
     bool m_deferredPathDirt;
+    bool m_shapeNotified = false;
 };
 } // namespace rive
 #endif
diff --git a/include/rive/shapes/shape.hpp b/include/rive/shapes/shape.hpp
index 25da3f1..4438101 100644
--- a/include/rive/shapes/shape.hpp
+++ b/include/rive/shapes/shape.hpp
@@ -34,6 +34,7 @@
     float m_WorldLength = -1;
 
     bool m_WantDifferencePath = false;
+    bool m_hasLayoutParticipant = false;
     RenderPathDeformer* m_deformer = nullptr;
 
     // Scale-to-fit and the memoized intrinsic bounds live on the
@@ -49,6 +50,7 @@
 public:
     Shape();
     void buildDependencies() override;
+    void addChild(Component* component) override;
     bool collapse(bool value) override;
     bool canDeferPathUpdate();
     void addPath(Path* path);
diff --git a/src/shapes/paint/shape_paint.cpp b/src/shapes/paint/shape_paint.cpp
index af8e32c..c85ee8b 100644
--- a/src/shapes/paint/shape_paint.cpp
+++ b/src/shapes/paint/shape_paint.cpp
@@ -50,6 +50,13 @@
     if (hasDirt(value, ComponentDirt::Path) && shapeEffects->size() > 0)
     {
         auto container = ShapePaintContainer::from(parent());
+        // Hidden paints are re-invalidated when shown, so measuring now is
+        // wasted, unless a clip still reads the result.
+        if (renderOpacity() == 0 &&
+            (container->pathFlags() & PathFlags::clipping) == PathFlags::none)
+        {
+            return;
+        }
         auto path = pickPath(container);
         for (auto& effect : *shapeEffects)
         {
diff --git a/src/shapes/path.cpp b/src/shapes/path.cpp
index d249a5c..4359e1f 100644
--- a/src/shapes/path.cpp
+++ b/src/shapes/path.cpp
@@ -431,19 +431,26 @@
 void Path::markPathDirty(bool sendToLayout)
 {
     addDirt(ComponentDirt::Path);
-    if (m_Shape != nullptr)
+    shapePathChanged();
+}
+
+// Dirt accumulates until update, so the shape only needs telling once per
+// cycle.
+void Path::shapePathChanged()
+{
+    if (m_shapeNotified || m_Shape == nullptr)
     {
-        m_Shape->pathChanged();
+        return;
     }
+    m_shapeNotified = true;
+    m_Shape->pathChanged();
 }
 
 void Path::onDirty(ComponentDirt value)
 {
-    if (hasDirt(value,
-                ComponentDirt::WorldTransform | ComponentDirt::NSlicer) &&
-        m_Shape != nullptr)
+    if (hasDirt(value, ComponentDirt::WorldTransform | ComponentDirt::NSlicer))
     {
-        m_Shape->pathChanged();
+        shapePathChanged();
     }
     if (m_deferredPathDirt)
     {
@@ -453,6 +460,7 @@
 
 void Path::update(ComponentDirt value)
 {
+    m_shapeNotified = false;
     Super::update(value);
 
     bool pathChanged = hasDirt(value, ComponentDirt::Path);
diff --git a/src/shapes/path_composer.cpp b/src/shapes/path_composer.cpp
index 4d037be..344467b 100644
--- a/src/shapes/path_composer.cpp
+++ b/src/shapes/path_composer.cpp
@@ -28,17 +28,19 @@
 
 void PathComposer::onDirty(ComponentDirt dirt)
 {
-    if (m_deferredPathDirt)
+    if (m_deferredPathDirt && !m_shapeNotified)
     {
         // We'd deferred the update, let's make sure the rest of our
         // dependencies update too. Constraints need to update too, stroke
         // effects, etc.
+        m_shapeNotified = true;
         m_shape->pathChanged();
     }
 }
 
 void PathComposer::update(ComponentDirt value)
 {
+    m_shapeNotified = false;
     if (hasDirt(value, ComponentDirt::Path | ComponentDirt::NSlicer))
     {
         if (m_shape->canDeferPathUpdate())
diff --git a/src/shapes/shape.cpp b/src/shapes/shape.cpp
index dc9b776..d4db451 100644
--- a/src/shapes/shape.cpp
+++ b/src/shapes/shape.cpp
@@ -27,6 +27,11 @@
     // Make sure the path is not already in the shape.
     assert(std::find(m_Paths.begin(), m_Paths.end(), path) == m_Paths.end());
     m_Paths.push_back(path);
+    if (path->is<PointsPath>() && path->as<PointsPath>()->skin() != nullptr)
+    {
+        // Bones deform a skinned path whether or not the shape is visible.
+        addFlags(PathFlags::neverDeferUpdate);
+    }
     invalidateIntrinsicBounds();
 }
 
@@ -38,21 +43,22 @@
 
 bool Shape::canDeferPathUpdate()
 {
-    auto canDefer =
-        renderOpacity() == 0 &&
-        !isFlagged(PathFlags::clipping | PathFlags::neverDeferUpdate);
-    if (canDefer)
+    if (renderOpacity() != 0 ||
+        isFlagged(PathFlags::clipping | PathFlags::neverDeferUpdate))
     {
-        // If we have a dependent Skin, don't defer the update
-        for (auto d : dependents())
+        return false;
+    }
+#ifdef WITH_RIVE_EDITOR
+    // Skins come and go under the editor, after addPath set its flag.
+    for (auto path : m_Paths)
+    {
+        if (path->is<PointsPath>() && path->as<PointsPath>()->skin() != nullptr)
         {
-            if (d->is<PointsPath>() && d->as<PointsPath>()->skin() != nullptr)
-            {
-                return false;
-            }
+            return false;
         }
     }
-    return canDefer;
+#endif
+    return true;
 }
 
 void Shape::update(ComponentDirt value)
@@ -627,10 +633,26 @@
     }
 }
 
+void Shape::addChild(Component* component)
+{
+    Super::addChild(component);
+    if (component->is<LayoutParticipant>())
+    {
+        m_hasLayoutParticipant = true;
+    }
+}
+
 // The whole shape scales to fit, so place the scaled combined-bounds top-left
 // at the slot (the origin is irrelevant once we scale).
 LayoutParticipant* Shape::layoutParticipant() const
 {
+#ifndef WITH_RIVE_EDITOR
+    // Children are fixed at runtime and few shapes have one to look for.
+    if (!m_hasLayoutParticipant)
+    {
+        return nullptr;
+    }
+#endif
     for (auto* child : children())
     {
         if (child->is<LayoutParticipant>())
diff --git a/tests/unit_tests/runtime/trim_test.cpp b/tests/unit_tests/runtime/trim_test.cpp
index 5dca83b..52e13a2 100644
--- a/tests/unit_tests/runtime/trim_test.cpp
+++ b/tests/unit_tests/runtime/trim_test.cpp
@@ -194,3 +194,56 @@
                                            rive::PathVerb::cubic};
     testRawPath(artboard, "mixed-shapes-100", verbs10);
 }
+
+static std::vector<rive::Vec2D> trimPoints(rive::Artboard* artboard,
+                                           const char* shapeName)
+{
+    auto shape = artboard->find<rive::Shape>(shapeName);
+    for (auto& child : shape->children())
+    {
+        if (child->is<rive::Stroke>())
+        {
+            auto stroke = child->as<rive::Stroke>();
+            auto points =
+                stroke->effect()->effectPath(stroke)->rawPath()->points();
+            return std::vector<rive::Vec2D>(points.begin(), points.end());
+        }
+    }
+    return {};
+}
+
+TEST_CASE("Hidden stroke picks up path changes once shown", "[trim]")
+{
+    auto file = ReadRiveFile("assets/trim_path.riv");
+    auto artboard = file->artboard("artboard-2")->instance();
+    auto control = file->artboard("artboard-2")->instance();
+    auto shape = artboard->find<rive::Shape>("clipped-rect");
+    REQUIRE(shape != nullptr);
+    REQUIRE(shape->paths()[0]->is<rive::Rectangle>());
+    auto rect = shape->paths()[0]->as<rive::Rectangle>();
+    artboard->advance(0.0f);
+    auto before = trimPoints(artboard.get(), "clipped-rect");
+    REQUIRE(!before.empty());
+
+    shape->opacity(0.0f);
+    artboard->advance(0.0f);
+    rect->width(rect->width() * 2);
+    artboard->advance(0.0f);
+    shape->opacity(1.0f);
+    artboard->advance(0.0f);
+
+    control->find<rive::Shape>("clipped-rect")
+        ->paths()[0]
+        ->as<rive::Rectangle>()
+        ->width(rect->width());
+    control->advance(0.0f);
+    auto expected = trimPoints(control.get(), "clipped-rect");
+    auto after = trimPoints(artboard.get(), "clipped-rect");
+    REQUIRE(after.size() == expected.size());
+    REQUIRE(after != before);
+    for (size_t i = 0; i < after.size(); i++)
+    {
+        CHECK(after[i].x == Approx(expected[i].x));
+        CHECK(after[i].y == Approx(expected[i].y));
+    }
+}