Sponsored By

Topic: SMaskedImage Slate widget

Source for UE4.26: https://github.com/klauth86/UE4Cookery/tree/main/CPP010

Artur Kh, Blogger

June 29, 2021

5 Min Read

Assume that you need to create an Image-like widget, that can be masked by another Image. Engine provides many ways to do that, so it is hard to say which way would be the best one before going into. Let us go around several possible solutions for this case:

 

URetainerBox widget can be used to cache its child widget hierarchy. In simple words, it creates additional Slate SWindow, adds child widget hierarchy to it and at last uses FWidgetRenderer to render this SWindow onto owned UTextureRenderTarget2D. URetainerBox is a good way to cache things that come from complicated and heavy materials and that can be too expensive to recalculate frequently (it can also be used to add dynamic effects to UI!). This approach solve our issue, but we were only wanting an Image to be masked. URetainerBox seemed to be too general and overcomplicated for that simple thing...

 

- You can go around math of Border Margins and create a material below:

Border Material

However, there are several severe cons in this implementation. Big number of material params and necessity in special calculated values for texture size are two most nasty of them. So, not this time!

 

- You can create a new Slate widget class, that can work like a very simple URetainerBox. Ok, why not, lets take a look:

SMaskedImage.h


#pragma once

#include "Widgets/SLeafWidget.h"

class UTexture2D;

class CPP010_API SMaskedImage : public SLeafWidget {

public:

	SLATE_BEGIN_ARGS(SMaskedImage)
		: _Image(FCoreStyle::Get().GetDefaultBrush())
		, _MaskImage(nullptr)
		, _ColorAndOpacity(FLinearColor::White)
		, _FlipForRightToLeftFlowDirection(false) {}

	/** Image resource */
	SLATE_ATTRIBUTE(const FSlateBrush*, Image)
		
	SLATE_ATTRIBUTE(const FSlateBrush*, MaskImage)

	/** Color and opacity */
	SLATE_ATTRIBUTE(FSlateColor, ColorAndOpacity)

	/** Flips the image if the localization's flow direction is RightToLeft */
	SLATE_ARGUMENT(bool, FlipForRightToLeftFlowDirection)

	/** Invoked when the mouse is pressed in the widget. */
	SLATE_EVENT(FPointerEventHandler, OnMouseButtonDown)
	
	SLATE_END_ARGS()

	SMaskedImage() {
		SetCanTick(false);
		bCanSupportFocus = false;
	}

	void Construct(const FArguments& InArgs);

public:

	/** See the ColorAndOpacity attribute */
	void SetColorAndOpacity(const TAttribute<FSlateColor>& InColorAndOpacity);

	/** See the ColorAndOpacity attribute */
	void SetColorAndOpacity(FLinearColor InColorAndOpacity);

	/** See the Image attribute */
	void SetImage(TAttribute<const FSlateBrush*> InImage);

	/** See the MaskImage attribute */
	void SetMaskImage(TAttribute<const FSlateBrush*> InImage);

public:

	// SWidget overrides
	virtual int32 OnPaint(const FPaintArgs& Args, const FGeometry& AllottedGeometry, const FSlateRect& MyCullingRect, FSlateWindowElementList& OutDrawElements, int32 LayerId, const FWidgetStyle& InWidgetStyle, bool bParentEnabled) const override;

	virtual void Tick(const FGeometry& AllottedGeometry, const double InCurrentTime, const float InDeltaTime) override;

protected:
	// Begin SWidget overrides.
	virtual FVector2D ComputeDesiredSize(float) const override;
	// End SWidget overrides.

protected:

	TWeakObjectPtr<UTexture2D> MaskedTexturePtr;

	FSlateBrush MaskedBrush;

	/** The slate brush to draw for the image that we can invalidate. */
	FInvalidatableBrushAttribute Image;

	/** The slate brush to mask the image that we can invalidate. */
	FInvalidatableBrushAttribute MaskImage;

	/** Color and opacity scale for this image */
	TAttribute<FSlateColor> ColorAndOpacity;

	/** Flips the image if the localization's flow direction is RightToLeft */
	bool bFlipForRightToLeftFlowDirection;

	/** Invoked when the mouse is pressed in the image */
	FPointerEventHandler OnMouseButtonDownHandler;
};

 

SMaskedImage.cpp


#include "SMaskedImage.h"
#include "Widgets/Images/SImage.h"
#include "ImageUtils.h"
#include "Engine/TextureRenderTarget2D.h"
#include "Slate/WidgetRenderer.h"

void SMaskedImage::Construct(const FArguments& InArgs) {
	Image = FInvalidatableBrushAttribute(InArgs._Image);
	MaskImage = FInvalidatableBrushAttribute(InArgs._MaskImage);
	ColorAndOpacity = InArgs._ColorAndOpacity;
	bFlipForRightToLeftFlowDirection = InArgs._FlipForRightToLeftFlowDirection;
	SetOnMouseButtonDown(InArgs._OnMouseButtonDown);
	SetCanTick(true);
}

int32 SMaskedImage::OnPaint(const FPaintArgs& Args, const FGeometry& AllottedGeometry, const FSlateRect& MyCullingRect, FSlateWindowElementList& OutDrawElements, int32 LayerId, const FWidgetStyle& InWidgetStyle, bool bParentEnabled) const {
	const FSlateBrush* ImageBrush = Image.GetImage().Get();
	const FSlateBrush* MaskImageBrush = MaskImage.GetImage().Get();

	if ((ImageBrush != nullptr) && (ImageBrush->DrawAs != ESlateBrushDrawType::NoDrawType)) {		
		if (MaskImageBrush != nullptr) {			
			if (MaskedTexturePtr.IsValid()) {
				const bool bIsEnabled = ShouldBeEnabled(bParentEnabled);
				const ESlateDrawEffect DrawEffects = bIsEnabled ? ESlateDrawEffect::None : ESlateDrawEffect::DisabledEffect;

				const FLinearColor FinalColorAndOpacity(InWidgetStyle.GetColorAndOpacityTint() * ColorAndOpacity.Get().GetColor(InWidgetStyle) * ImageBrush->GetTint(InWidgetStyle));

				if (bFlipForRightToLeftFlowDirection && GSlateFlowDirection == EFlowDirection::RightToLeft) {
					const FGeometry FlippedGeometry = AllottedGeometry.MakeChild(FSlateRenderTransform(FScale2D(-1, 1)));
					FSlateDrawElement::MakeBox(OutDrawElements, LayerId, FlippedGeometry.ToPaintGeometry(), &MaskedBrush, DrawEffects, FinalColorAndOpacity);
				}
				else {
					FSlateDrawElement::MakeBox(OutDrawElements, LayerId, AllottedGeometry.ToPaintGeometry(), &MaskedBrush, DrawEffects, FinalColorAndOpacity);
				}
			}
		}
		else {
			const bool bIsEnabled = ShouldBeEnabled(bParentEnabled);
			const ESlateDrawEffect DrawEffects = bIsEnabled ? ESlateDrawEffect::None : ESlateDrawEffect::DisabledEffect;

			const FLinearColor FinalColorAndOpacity(InWidgetStyle.GetColorAndOpacityTint() * ColorAndOpacity.Get().GetColor(InWidgetStyle) * ImageBrush->GetTint(InWidgetStyle));

			if (bFlipForRightToLeftFlowDirection && GSlateFlowDirection == EFlowDirection::RightToLeft) {
				const FGeometry FlippedGeometry = AllottedGeometry.MakeChild(FSlateRenderTransform(FScale2D(-1, 1)));
				FSlateDrawElement::MakeBox(OutDrawElements, LayerId, FlippedGeometry.ToPaintGeometry(), ImageBrush, DrawEffects, FinalColorAndOpacity);
			}
			else {
				FSlateDrawElement::MakeBox(OutDrawElements, LayerId, AllottedGeometry.ToPaintGeometry(), ImageBrush, DrawEffects, FinalColorAndOpacity);
			}
		}
	}

	return LayerId;
}

void SMaskedImage::Tick(const FGeometry& AllottedGeometry, const double InCurrentTime, const float InDeltaTime) {
	SLeafWidget::Tick(AllottedGeometry, InCurrentTime, InDeltaTime);

	const FSlateBrush* ImageBrush = Image.GetImage().Get();
	const FSlateBrush* MaskImageBrush = MaskImage.GetImage().Get();

	if ((ImageBrush != nullptr) && (ImageBrush->DrawAs != ESlateBrushDrawType::NoDrawType)) {
		if (MaskImageBrush != nullptr) {
			if (!MaskedTexturePtr.IsValid()) {

				auto localSize = AllottedGeometry.GetLocalSize();

				TArray<FColor> imageData;
				auto widgetRenderer = new FWidgetRenderer(true);
				auto imageRenderTarget2D = widgetRenderer->DrawWidget(SNew(SImage).Image(ImageBrush), localSize);
				imageRenderTarget2D->GameThread_GetRenderTargetResource()->ReadPixels(imageData);

				TArray<FColor> maskImageData;
				auto widgetRenderer2 = new FWidgetRenderer(true);
				auto maskImageRenderTarget2D = widgetRenderer2->DrawWidget(SNew(SImage).Image(MaskImageBrush), localSize);
				maskImageRenderTarget2D->GameThread_GetRenderTargetResource()->ReadPixels(maskImageData);

				auto sizeX = imageRenderTarget2D->SizeX;
				auto sizeY = imageRenderTarget2D->SizeY;
				auto size = sizeX * sizeY;

				TArray<FColor> maskedData;
				maskedData.SetNum(size);

				if (sizeX > 0 && sizeY > 0) {
					for (size_t i = 0; i < size; i++) {
						maskedData[i].R = imageData[i].R;
						maskedData[i].G = imageData[i].G;
						maskedData[i].B = imageData[i].B;
						maskedData[i].A = imageData[i].A * maskImageData[i].A / 255;
					}
				}

				FCreateTexture2DParameters params;
				params.bUseAlpha = true;
				params.bDeferCompression = true;

				auto texture = FImageUtils::CreateTexture2D(sizeX, sizeY, maskedData, GetTransientPackage(), "MyTexture", EObjectFlags::RF_NoFlags, params);
				MaskedBrush = FSlateBrush();
				MaskedBrush.SetResourceObject(texture);

				texture->AddToRoot();
				MaskedTexturePtr = texture;
			}
		}
	}

	SetCanTick(false);
}

FVector2D SMaskedImage::ComputeDesiredSize(float) const {
	const FSlateBrush* ImageBrush = Image.Get();
	return ImageBrush ? ImageBrush->ImageSize : FVector2D::ZeroVector;
}

void SMaskedImage::SetColorAndOpacity(const TAttribute<FSlateColor>& InColorAndOpacity) {
	SetAttribute(ColorAndOpacity, InColorAndOpacity, EInvalidateWidgetReason::Paint);
}

void SMaskedImage::SetColorAndOpacity(FLinearColor InColorAndOpacity) {
	SetColorAndOpacity(TAttribute<FSlateColor>(InColorAndOpacity));
}

void SMaskedImage::SetImage(TAttribute<const FSlateBrush*> InImage) {
	if (MaskedTexturePtr.IsValid()) {
		MaskedTexturePtr.Get()->RemoveFromRoot();
		MaskedTexturePtr.Reset();
	}
	Image.SetImage(*this, InImage);
	SetCanTick(true);
}

void SMaskedImage::SetMaskImage(TAttribute<const FSlateBrush*> InImage) {
	if (MaskedTexturePtr.IsValid()) {
		MaskedTexturePtr.Get()->RemoveFromRoot();
		MaskedTexturePtr.Reset();
	}
	MaskImage.SetImage(*this, InImage);
	SetCanTick(true);
}

 

The principle that is used is obvious. Be like an SImage if you have nothing in your MaskImage field. But if you have something inside it, then start to Tick. In Tick body we can make use of Tick Geometry. So, we create two separate FWidgetRenderer objects that render brush from Image field and brush from MaskImage field on Tick Geometry. After that we can extract FColor data from both of them and mix colors as we need to. Final step is to create new UTexture2D object with appropriate size and from mixed FColor data, save it from GC by adding to Root and setting it as a Resource for MaskedBrush. Now we are done, we can disable Tick and use MaskedBrush for painting instead of unmasked one!

 

As a result we have this - we have cut off Border from full image:

Rendered SMaskedImage widget

 

PS. As you can see, if DPI, screen resolution or some other screen parameter will change, SMaskedImage widget will need to refresh its cached texture...

Read more about:

Blogs

About the Author(s)

Daily news, dev blogs, and stories from Game Developer straight to your inbox

You May Also Like