Optimize UISceneNode style invalidation batching

Defer dirty-descendant coalescing until style processing instead of
scanning the dirty set and walking widget ancestry on every invalidation.
Preserve parent-first early-outs and resolve dirty roots against the
current widget tree using shared snapshot storage.

Clear stale style-state animation entries when widgets are deleted and
add regression coverage for large invalidation bursts and reparenting.
This commit is contained in:
Martín Lucas Golini
2026-08-29 19:46:37 -03:00
parent e39e82fe55
commit 780ede23f9
3 changed files with 172 additions and 64 deletions
+48 -59
View File
@@ -1241,6 +1241,7 @@ void UISceneNode::onWidgetDelete( Node* node ) {
mDirtyStyle.erase( widget );
mDirtyStyleState.erase( widget );
mDirtyStyleStateCSSAnimations.erase( widget );
}
}
@@ -1268,6 +1269,17 @@ UIWidget* UISceneNode::getRoot() const {
return mRoot;
}
template <typename DirtyContainer>
static bool hasDirtyWidgetAncestor( const UIWidget* node, const DirtyContainer& dirty ) {
Node* parent = node->getParent();
while ( parent != nullptr ) {
if ( parent->isWidget() && dirty.count( parent->asType<UIWidget>() ) > 0 )
return true;
parent = parent->getParent();
}
return false;
}
void UISceneNode::invalidateStyle( UIWidget* node, bool tryReinsert ) {
eeASSERT( NULL != node );
@@ -1278,27 +1290,8 @@ void UISceneNode::invalidateStyle( UIWidget* node, bool tryReinsert ) {
if ( alreadyExists && !tryReinsert )
return;
// Any parent dirty?
Node* parent = node->getParent();
while ( parent != nullptr ) {
if ( parent->isWidget() && mDirtyStyle.count( parent->asType<UIWidget>() ) > 0 )
return;
parent = parent->getParent();
}
// Now that we know we aren't early-outing, handle the reinsertion erase
if ( alreadyExists && tryReinsert )
mDirtyStyle.erase( node );
SmallVector<UIWidget*> eraseList;
// Any child in list? remove it
for ( auto widget : mDirtyStyle )
if ( NULL == widget || node->isParentOf( widget ) )
eraseList.push_back( widget );
for ( auto widget : eraseList )
mDirtyStyle.erase( widget );
if ( hasDirtyWidgetAncestor( node, mDirtyStyle ) )
return;
mDirtyStyle.insert( node );
}
@@ -1310,33 +1303,12 @@ void UISceneNode::invalidateStyleState( UIWidget* node, bool disableCSSAnimation
if ( node->isClosing() )
return;
// Already invalidated?
if ( mDirtyStyleState.count( node ) > 0 ) {
if ( !tryReinsert )
return;
else
mDirtyStyleState.erase( node );
}
bool alreadyExists = mDirtyStyleState.count( node ) > 0;
if ( alreadyExists && !tryReinsert )
return;
// Any parent dirty?
Node* parent = node->getParent();
while ( parent != nullptr ) {
if ( parent->isWidget() && mDirtyStyleState.count( parent->asType<UIWidget>() ) > 0 )
return;
parent = parent->getParent();
}
SmallVector<UIWidget*> eraseList;
// Any child in list? remove it
for ( auto widget : mDirtyStyleState )
if ( NULL == widget || node->isParentOf( widget ) )
eraseList.push_back( widget );
for ( auto widget : eraseList ) {
mDirtyStyleState.erase( widget );
mDirtyStyleStateCSSAnimations.erase( widget );
}
if ( hasDirtyWidgetAncestor( node, mDirtyStyleState ) )
return;
mDirtyStyleState.insert( node );
mDirtyStyleStateCSSAnimations[node] = disableCSSAnimations;
@@ -1461,11 +1433,26 @@ void UISceneNode::updateDirtyLayouts() {
void UISceneNode::updateDirtyStyles() {
if ( !mDirtyStyle.empty() ) {
Clock clock;
for ( auto& node : mDirtyStyle ) {
node->reloadStyle( true, false, false );
// Coalesce only once per pass. Eagerly searching the complete dirty set for descendants on
// every invalidation makes bursts quadratic and repeatedly pointer-chases unrelated widget
// ancestry. Retaining descendant entries until this point turns queueing into an ancestor
// walk plus an O(1) insertion. The current tree also naturally resolves reparented widgets.
//
// Clear the live set before applying styles: style application may create widgets or change
// selectors, and those invalidations must remain queued for the next invalidation-depth
// pass.
mDirtyStylesSnapshot.clear();
mDirtyStylesSnapshot.reserve( mDirtyStyle.size() );
for ( UIWidget* node : mDirtyStyle ) {
if ( node != nullptr && !hasDirtyWidgetAncestor( node, mDirtyStyle ) )
mDirtyStylesSnapshot.emplace_back( node, false );
}
mDirtyStyle.clear();
for ( const auto& dirtyStyle : mDirtyStylesSnapshot )
dirtyStyle.first->reloadStyle( true, false, false );
if ( mVerbose )
Log::info( "CSS Styles Reloaded in %.2f ms", clock.getElapsedTime().asMilliseconds() );
}
@@ -1475,21 +1462,23 @@ void UISceneNode::updateDirtyStyleStates() {
if ( !mDirtyStyleState.empty() ) {
Clock clock;
// Applying a style state can create widgets (for example a button icon). Widget
// construction invalidates its style state, so iterating mDirtyStyleState directly would
// mutate and potentially reallocate its vector-backed unordered_dense storage. Snapshot the
// current pass and leave new invalidations queued for the outer invalidation-depth loop.
mDirtyStyleStateSnapshot.clear();
mDirtyStyleStateSnapshot.reserve( mDirtyStyleState.size() );
// Applying a style state can create widgets (for example a button icon). Coalesce the
// current roots into the shared snapshot, then leave new invalidations queued for the outer
// invalidation-depth loop. When both an ancestor and descendant are dirty, the ancestor's
// animation policy wins, matching the previous eager-coalescing behavior.
mDirtyStylesSnapshot.clear();
mDirtyStylesSnapshot.reserve( mDirtyStyleState.size() );
for ( UIWidget* node : mDirtyStyleState ) {
auto animations = mDirtyStyleStateCSSAnimations.find( node );
mDirtyStyleStateSnapshot.emplace_back(
node, animations != mDirtyStyleStateCSSAnimations.end() && animations->second );
if ( node != nullptr && !hasDirtyWidgetAncestor( node, mDirtyStyleState ) ) {
auto animations = mDirtyStyleStateCSSAnimations.find( node );
mDirtyStylesSnapshot.emplace_back(
node, animations != mDirtyStyleStateCSSAnimations.end() && animations->second );
}
}
mDirtyStyleState.clear();
mDirtyStyleStateCSSAnimations.clear();
for ( const auto& dirtyState : mDirtyStyleStateSnapshot )
for ( const auto& dirtyState : mDirtyStylesSnapshot )
dirtyState.first->reportStyleStateChangeRecursive( dirtyState.second );
if ( mVerbose )
+116 -2
View File
@@ -29,22 +29,50 @@ UTEST( UISceneNode, CssPointerCursorUsesHandCursor ) {
EXPECT_STREQ( Cursor::toName( Cursor::Arrow ), "arrow" );
}
static UISceneNode* init_test_scene_node() {
static void init_test_scene_node( UISceneNode* sceneNode ) {
FileSystem::changeWorkingDirectory( Sys::getProcessPath() );
FontTrueType* font = FontTrueType::New( "NotoSans-Regular" ).get();
font->loadFromFile( "../assets/fonts/NotoSans-Regular.ttf" );
FontFamily::loadFromRegular( font );
FontTrueType* monoFont = FontTrueType::New( "monospace" ).get();
monoFont->loadFromFile( "../assets/fonts/NotoSans-Regular.ttf" );
UISceneNode* sceneNode = UISceneNode::New();
SceneManager::instance()->add( sceneNode );
SceneManager::instance()->setCurrentUISceneNode( sceneNode );
UIThemeManager* themeManager = sceneNode->getUIThemeManager();
themeManager->setDefaultFont( font );
themeManager->applyDefaultTheme( sceneNode->getRoot() );
}
static UISceneNode* init_test_scene_node() {
UISceneNode* sceneNode = UISceneNode::New();
init_test_scene_node( sceneNode );
return sceneNode;
}
class InvalidationTestSceneNode : public UISceneNode {
public:
static InvalidationTestSceneNode* New() { return eeNew( InvalidationTestSceneNode, () ); }
size_t pendingStyleCount() const { return mDirtyStyle.size(); }
size_t pendingStyleStateCount() const { return mDirtyStyleState.size(); }
size_t pendingStyleStateAnimationCount() const { return mDirtyStyleStateCSSAnimations.size(); }
size_t processedStyleRootCount() const { return mDirtyStylesSnapshot.size(); }
UIWidget* processedStyleRoot( size_t index ) const {
return mDirtyStylesSnapshot[index].first;
}
bool processedStyleRootDisablesAnimations( size_t index ) const {
return mDirtyStylesSnapshot[index].second;
}
protected:
InvalidationTestSceneNode() : UISceneNode() {}
};
UTEST( UISceneNode, ViewportMetricsAreIndependentFromSceneExtent ) {
Engine::instance()->createWindow( WindowSettings( 1024, 768, "Scene Viewport Metrics Test",
WindowStyle::Default, WindowBackend::Default,
@@ -394,6 +422,92 @@ UTEST( UISceneNode, StyleStateUpdateAllowsWidgetCreation ) {
Engine::destroySingleton();
}
UTEST( UISceneNode, StyleInvalidationCoalescesAtProcessingTime ) {
Engine::instance()->createWindow( WindowSettings( 1024, 768, "Deferred Style Invalidation Test",
WindowStyle::Default, WindowBackend::Default,
32, {}, 1, false, true ),
ContextSettings( false, 0, 0, GLv_default, true, false ) );
auto* sceneNode = InvalidationTestSceneNode::New();
init_test_scene_node( sceneNode );
sceneNode->flushDirtyStyleAndLayout();
UIWidget* parent = UIWidget::New();
parent->setParent( sceneNode->getRoot() );
sceneNode->flushDirtyStyleAndLayout();
constexpr size_t childCount = 2048;
UIWidget* firstChild = nullptr;
for ( size_t i = 0; i < childCount; ++i ) {
UIWidget* child = UIWidget::New();
child->setParent( parent );
if ( !firstChild )
firstChild = child;
}
EXPECT_EQ( childCount, sceneNode->pendingStyleCount() );
EXPECT_EQ( childCount, sceneNode->pendingStyleStateCount() );
sceneNode->invalidateStyle( parent );
sceneNode->invalidateStyleState( parent, true );
EXPECT_EQ( childCount + 1, sceneNode->pendingStyleCount() );
EXPECT_EQ( childCount + 1, sceneNode->pendingStyleStateCount() );
sceneNode->updateDirtyStyles();
EXPECT_EQ( size_t{ 1 }, sceneNode->processedStyleRootCount() );
EXPECT_EQ( parent, sceneNode->processedStyleRoot( 0 ) );
EXPECT_EQ( size_t{ 0 }, sceneNode->pendingStyleCount() );
sceneNode->updateDirtyStyleStates();
EXPECT_EQ( size_t{ 1 }, sceneNode->processedStyleRootCount() );
EXPECT_EQ( parent, sceneNode->processedStyleRoot( 0 ) );
EXPECT_TRUE( sceneNode->processedStyleRootDisablesAnimations( 0 ) );
EXPECT_EQ( size_t{ 0 }, sceneNode->pendingStyleStateCount() );
// The existing ancestor fast path still avoids queueing descendants when the parent arrives
// first, and the parent's animation policy governs the recursive state update.
sceneNode->invalidateStyle( parent );
sceneNode->invalidateStyle( firstChild );
sceneNode->invalidateStyleState( parent, false );
sceneNode->invalidateStyleState( firstChild, true );
EXPECT_EQ( size_t{ 1 }, sceneNode->pendingStyleCount() );
EXPECT_EQ( size_t{ 1 }, sceneNode->pendingStyleStateCount() );
sceneNode->updateDirtyStyles();
EXPECT_EQ( parent, sceneNode->processedStyleRoot( 0 ) );
sceneNode->updateDirtyStyleStates();
EXPECT_EQ( parent, sceneNode->processedStyleRoot( 0 ) );
EXPECT_FALSE( sceneNode->processedStyleRootDisablesAnimations( 0 ) );
// Reparenting an already-dirty widget under a dirty parent can leave both entries queued. The
// current tree must decide which root gets processed.
UIWidget* reparentTarget = UIWidget::New();
reparentTarget->setParent( sceneNode->getRoot() );
sceneNode->flushDirtyStyleAndLayout();
sceneNode->invalidateStyle( firstChild );
sceneNode->invalidateStyleState( firstChild );
sceneNode->invalidateStyle( reparentTarget );
sceneNode->invalidateStyleState( reparentTarget, true );
firstChild->setParent( reparentTarget );
sceneNode->updateDirtyStyles();
EXPECT_EQ( size_t{ 1 }, sceneNode->processedStyleRootCount() );
EXPECT_EQ( reparentTarget, sceneNode->processedStyleRoot( 0 ) );
sceneNode->updateDirtyStyleStates();
EXPECT_EQ( size_t{ 1 }, sceneNode->processedStyleRootCount() );
EXPECT_EQ( reparentTarget, sceneNode->processedStyleRoot( 0 ) );
EXPECT_TRUE( sceneNode->processedStyleRootDisablesAnimations( 0 ) );
UIWidget* deletedWidget = UIWidget::New();
deletedWidget->setParent( sceneNode->getRoot() );
sceneNode->flushDirtyStyleAndLayout();
sceneNode->invalidateStyleState( deletedWidget, true );
EXPECT_EQ( size_t{ 1 }, sceneNode->pendingStyleStateAnimationCount() );
eeDelete( deletedWidget );
EXPECT_EQ( size_t{ 0 }, sceneNode->pendingStyleStateCount() );
EXPECT_EQ( size_t{ 0 }, sceneNode->pendingStyleStateAnimationCount() );
Engine::destroySingleton();
}
UTEST( UIWindow, ModalWindowStopsKeyBindingsFromReachingScene ) {
Engine::instance()->createWindow( WindowSettings( 1024, 768, "Modal Key Binding Test",
WindowStyle::Default, WindowBackend::Default,