SRetainerWidget bakes inherited ColorAndOpacity into the cached render target

SRetainerWidget incorporates the inherited FWidgetStyle (the ancestor-accumulated ColorAndOpacityTint) into the offscreen pass that fills its render target, and applies that same inherited tint again when compositing the render target to screen. Two consequences:

Double application (visual/correctness). A subtree wrapped in a Retainer Box renders with its inherited color/opacity applied twice. With a parent (or the box itself) at RenderOpacity = 0.5, the result is ~0.25 effective — the retained subtree looks noticeably darker / more transparent than the identical subtree without a retainer. This breaks visual parity between retained and non-retained widgets.

Cache invalidation on opacity change (performance). Because the inherited tint participates in the retained content, changing it (e.g. fading the box or any ancestor) invalidates and requires a re-rasterizeing every frame during the animation. The Retainer Box provides no benefit during fades/transitions, and makes them more expensive than no retainer at all.

The offscreen render context is built from the incoming InWidgetStyle, which Slate has already multiplied by every ancestor’s opacity (and the widget’s own RenderOpacity) via SWidget::Paint → FWidgetStyle::BlendOpacity

FSlateInvalidationContext Context(OutDrawElements, InWidgetStyle)

That context is used to fill the RT, so the inherited tint is baked into the texture:

PaintRetainedContentImpl → WidgetRenderer->DrawInvalidationRoot → Root.PaintInvalidationRoot → SRetainerWidget::PaintSlowPath → SCompoundWidget::OnPaint

The same inherited tint is then applied again at composite in OnPaint

const FLinearColor ComputedColorAndOpacity(Context.WidgetStyle.GetColorAndOpacityTint() * GetColorAndOpacity() * SurfaceBrush.GetTint(Context.WidgetStyle));

The re-render trigger keys off the inherited tint (const FColor ColorAndOpacityTint = Context.WidgetStyle.GetColorAndOpacityTint().ToFColor(false):wink:

if (LayerId != LastIncomingLayerId || ColorAndOpacityTint != PreviousColorAndOpacity)
{
  GetRootWidget()->Invalidate(EInvalidateWidgetReason::Paint); // re-raster the RT
}

→ any inherited-opacity change forces a full content repaint into the RT.

The other FWidgetRenderer::DrawInvalidationRoot overload — the one used by UWidgetComponent for its widget-to-RT rendering — deliberately constructs a fresh, neutral FWidgetStyle for the offscreen pass

FWidgetStyle InitialWidgetStyle;

FSlateInvalidationContext InvalidationContext(WindowElementList, InitialWidgetStyle);

So Epic already renders widget-to-RT with a neutral style elsewhere; so SRetainerWidget is inconsistent.

Suggested fix

Render the retained content into the RT with a neutral FWidgetStyle, and apply the inherited ColorAndOpacityTint only once, OnPaint. The composite path already multiplies the inherited tint, so we get a stable cache during fades.

Questions

Is feeding the inherited FWidgetStyle into the retained RT pass deliberate? Is there a use case I’m not seeing?

Would you accept a PR capturing at native opacity + composite-only tint as the default, or behind an opt-in flag, so retainer-wrapped subtrees achieve visual parity and stay cached during opacity animations?

Steps to Reproduce

Repro

Widget BP: a Border (opaque color) containing some Text.

Wrap that content in a Retainer Box (bRetainRender = true && RenderOnInvalidation = true).

Animate RenderOpacity 0.0 → 1.0 on the Retainer Box (or any ancestor).

Duplicate the widget without the Retainer Box and animate it identically, side by side.

Expected: Both fade identically (the retainer should be visually transparent — same pixels, just cached).

Actual: The retained copy finished the animation with the original opacity

Hi,

We’ve seen some reports on inconsistency in retained widgets in the past, but it’s likely just an area that nobody has looked into in depth. I’d agree with your assessment here, so if you’d be interested in sending in a pull request I can get that in front of the team to see if we can get it integrated. Changing the default here worries me a bit since legacy projects may have compensated for this already, but since your fix also helps out with invalidation I think it’s worth the risk. Perhaps a CVar to restore legacy behavior project-wide would be helpful as a back-up option.

Let me know if you do send in that PR, I’ll link this ticket to our internal tracking and try to see if I can get some eyes on it ASAP.

Best,

Cody

https://github.com/EpicGames/UnrealEngine/pull/14932/

This is what I have locally that seems to work.

Perfect, thanks! We’ll review that change and send along an update directly on the PR. Note that Epic will be closed for the next two weeks for our summer break, but I’ll make a note to revisit this once we return so it doesn’t fall through the cracks.