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)![]()
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?