[Unreal C++] Printing and Animating Text Per-Character
Overview
This tutorial assumes knowledge of Unreal’s Rich Text, Text Styles, and Decorators. Information about this can be found here.
A staple feature in RPGs and Visual Novels is text that prints letter-by-letter as characters speak, and animations for text to give the characters and world extra personality.
Unfortunately, Unreal does not come with tools to do this, and it’s challenging to get right. Some solutions floating around the Internet involve chopping up text into 1-character-long text blocks. This is obviously not a performant solution, and you’ll soon run into severe drawbacks and visual bugs.
Unreal does come with basic support for animating text in materials (See this blog post by Steve Streeting). But it does not provide data to manipulate characters independently. For example, the “Save” text above from Paper Mario is not possible.
So, let’s do it right!
The purpose of this tutorial is to solve all this once and for all. This was my goal with my PROTO Animated Text plugin, which mostly exists because I was tired of hacking around this problem. This tutorial shows how to implement the most important logic of the plugin, and should give you a good starting point if you want to solve this on your own!
PROTO Animated Text
An Unreal Plugin
The Plan
This solution will print and animate via the the shader and material of our text:
- The text will exist in-full at all times, but not always be visible.
- This will let us support any justification while printing, and never break UI layout.
- We will support per-character animation, and we will animate the print-out as well as support idle animations.
- Animations will be controlled by Font Materials.
- We won’t be modifying Engine code.
There are a few key elements that will make this possible:
- A Custom UWidget
- Packing data into Vertex colors
- A Custom Decorator
- Custom Font Materials
Custom UWidget
This will be our base of operations for our new logic. Our new UWidget will be a child of URichTextBlock
, so it will do everything URichTextBlock
does and add new functionality for printing and animating.
It will keep track of our letter-by-letter printing, and communicate with our Custom Decorator to update text accordingly.
Vertex Colors
This is the center-piece of this whole operation, and what makes this possible without modifying the engine. We’re going to pack data into vertex colors, because it is a chunk of data that can be set per-character in Slate, and it is sent along through shaders and into materials.
Vertex colors are 4-channel (R, G, B, and Alpha), and by default is what allows you to tint your text in the UMG Editor or in a Text Style. What we will do instead is:
- We will hook-in to
OnPaint()
in Slate, and manipulate vertex colors per-character. - When text is printing-out we will set characters’ alpha to 0.0 or 1.0 as they print.
- To perform animations over-time, we will pack 0.0-1.0 information into the R, G, and B channels for a Font Material to process.
One downside will be that the color of the text will be determined solely from the Font Material. There’s no way around this, because vertex colors will no longer be used for the tint of the text.
Custom Decorator
We aren’t modifying Engine code, so we can’t simply jump into FSlateTextRun::OnPaint
and start hacking away.
Instead, we’ll use a custom decorator to write our own OnPaint
function. Any text wrapped in the decorator will use a custom child class of FSlateTextRun
to paint how we want to.
Custom Materials
This is final part, and the easy part. We’ll author font materials that read data from vertex color, and use that to manipulate text and play animations.
There is one gotcha: the text rendering shader is hard-coded to multiply the Final Color
of the font material by the vertex color. This is a strange choice by Epic, but we can easily work around it. Before outputing Final Color
, we will divide our desired RGB color by the vertex color.
Note: Strings and Runs
Slate passes around pointers to shared strings to avoid duplicate strings being created. When Slate generates a string for the visible characters in a text block, various objects will treat it as a series of one or more runs. A single run is specified by a begin and end index (or a begin index and length) in the full string.
Throughout the code you’ll see indices being saved in order to index into strings correctly. In some parts of our new code, we will save indices into the original text with mark-up, and the visible text with mark-up removed.
Part 1 - the UWidget
UMyRichTextBlock Header
A lot of new code is required for our UWidget, so first let’s take a look at the full header file. Then we’ll go through each function implementation separately.
MyRichTextBlock.h
// Fill out your copyright notice in the Description page of Project Settings.
#pragma once
#include "CoreMinimal.h"
#include "Components/RichTextBlock.h"
#include "Framework/Text/RichTextLayoutMarshaller.h"
#include "Framework/Text/SlateTextLayout.h"
#include "MyCharColorDecorator.h"
#include "MyRichTextBlock.generated.h"
struct FTextPrintBlock
{
FString Text;
FRunInfo RunInfo;
// Index into the original content string with markup removed
int32 OriginalContentStringBeginIndex = 0;
// Index into the original content string with:
// * markup removed
// * runs that are not charcolor decorators removed
// This is used to communicate with MyCharColorDecorator
int32 OriginalCharColorDecoratorStringBeginIndex = 0;
bool HasCharColorDecorator = false;
};
/**
*
*/
UCLASS()
class PROTOTUTORIALS_API UMyRichTextBlock : public URichTextBlock
{
GENERATED_BODY()
public:
/**
* Play a letter-by-letter animation revealing the current text in this widget.
*/
UFUNCTION(BlueprintCallable)
virtual void PrintLetterByLetter();
/**
* Sets text alpha to 0.0. Will stop a letter-by-letter print.
*/
UFUNCTION(BlueprintCallable)
virtual void HideText();
/**
* Sets text alpha to 1.0. Will stop a letter-by-letter print.
*/
UFUNCTION(BlueprintCallable)
virtual void ShowText();
/**
* Directly sets the widget text.
* Warning: This will wipe any binding created for the Text property!
* @param InText The text to assign to the widget
*/
virtual void SetText(const FText& InText) override;
void ReleaseSlateResources(bool bReleaseChildren) override;
protected:
virtual void PlayNextLetter();
virtual void FinishLetterByLetterPrint();
virtual void StopLetterByLetterPrint(const bool NoEvent = false);
/** Calculates the PrintBlocks array for the current text.
* Requires a ForceLayoutPrepass() if called on the same frame as creation.
* Contains a SetText, so a ForceLayoutPrepass() is necessary to work
* with decorators afterward this frame.
*/
virtual void InitializePrintBlocks();
//virtual void InitializePrintBlockExtraStyleData(FTextPrintBlock& InOutPrintBlock);
virtual int32 CalculateNextLetterByLetterCharacter();
virtual TSharedRef<SWidget> RebuildWidget() override;
protected:
bool PrintBlocksInitialized = false;
TSharedPtr<FSlateTextLayout> TextLayout;
TSharedPtr<FRichTextLayoutMarshaller> TextMarshaller;
// Special decorators we create automatically and interact with directly
UPROPERTY(Transient)
UMyCharColorDecorator* CharColorDecorator;
TArray<FTextPrintBlock> PrintBlocks;
// Used when printing letter-by-letter
// Index into the full content string (markup removed)
int32 CurrentCharacterIndex = 0;
// Used when printing letter-by-letter
// Index into the full content string (markup removed), but only
// content contained in ProtoRichTextCharColorDecorator runs
int32 CurrentCharColorDecoratorCharacterIndex = 0;
int32 CurrentPrintBlockIndex = 0;
int32 TotalContentCharacters = 0;
float LastCharacterTimestamp; // Used to determine if we need to print multiple characters in 1 frame
float LastCharacterPrintInterval;
bool LetterByLetterPrintInProgress = false;
FTimerHandle LetterTimer;
};
RebuildWidget() / ReleaseSlateResources()
This function is similar to Epic’s URichTextBlock::RebuildWidget()
. However, want to do a couple more things:
- Manually create a
UMyCharColorDecorator
, because we want our Rich Text to always have one, and we want a pointer to interact directly. - Save pointers to our
FSlateTextLayout
andFRichTextLayoutMarshaller
. These are used by Epic’sURichTextBlock
too, but we need to communicate with them directly.
TSharedRef<SWidget> UMyRichTextBlock::RebuildWidget()
{
UpdateStyleData();
// Automatically create our required decorator
if (!CharColorDecorator)
{
CharColorDecorator = NewObject<UMyCharColorDecorator>(this, UMyCharColorDecorator::StaticClass());
InstanceDecorators.Add(CharColorDecorator);
}
TArray< TSharedRef<class ITextDecorator>> CreatedDecorators;
CreateDecorators(CreatedDecorators);
TextMarshaller = FRichTextLayoutMarshaller::Create(CreateMarkupParser(), CreateMarkupWriter(), CreatedDecorators, StyleInstance.Get());
MyRichTextBlock =
SNew(SRichTextBlock)
.TextStyle(bOverrideDefaultStyle ? &DefaultTextStyleOverride : &DefaultTextStyle)
.Marshaller(TextMarshaller)
.CreateSlateTextLayout(
FCreateSlateTextLayout::CreateWeakLambda(this, [this](SWidget* InOwner, const FTextBlockStyle& InDefaultTextStyle) mutable
{
TextLayout = FSlateTextLayout::Create(InOwner, InDefaultTextStyle);
return StaticCastSharedPtr<FSlateTextLayout>(TextLayout).ToSharedRef();
}));
return MyRichTextBlock.ToSharedRef();
}
void UMyRichTextBlock::ReleaseSlateResources(bool bReleaseChildren)
{
Super::ReleaseSlateResources(bReleaseChildren);
TextLayout.Reset();
}
SynchronizeProperties()
This function saves our Text
parameter into a new variable before any other logic starts. We reference the FullText
void UMyRichTextBlock::SynchronizeProperties()
{
Super::SynchronizeProperties();
FullText = Text;
}
InitializePrintBlocks()
This function will be called before we try and print letter-by-letter, and caches a bunch of data we need before printing.
It iterates through the full text and saves out an FTextPrintBlock
for each chunk of text. The full text is divided into ILayoutBlock
s by FSlateTextLayout
, and these blocks (and their “runs”) will be consistent through Decorators.
Especially, notice that we save OriginalContentStringBeginIndex
and OriginalCharColorDecoratorStringBeginIndex
separately on each FTextPrintBlock
. You’ll see these used for different purposes later. The former is for logic related to the full content string, and the latter is for communicating with the UMyCharColorDecorator
we will create, which only knows about text wrapped with that Decorator.
void UMyRichTextBlock::InitializePrintBlocks()
{
// Only initialize print blocks if we need to
if (PrintBlocksInitialized)
{
return;
}
TotalContentCharacters = 0;
PrintBlocks.Empty();
if (TextLayout.IsValid())
{
const FGeometry& TextBoxGeometry = GetCachedGeometry();
const FVector2D TextBoxSize = TextBoxGeometry.GetLocalSize();
// Index into full content string (markup removed)
int32 nextContentCharacterIndex = 0;
// Index into full content string for just char color decorators
int32 nextCharColorDecoratorCharacterIndex = 0;
TArray< FTextLayout::FLineView > LineViews = TextLayout->GetLineViews();
for (int lineIndex = 0; lineIndex < LineViews.Num(); lineIndex++)
{
const FTextLayout::FLineView& View = LineViews[lineIndex];
const FTextLayout::FLineModel& Model = TextLayout->GetLineModels()[View.ModelIndex];
for (int blockIndex = 0; blockIndex < View.Blocks.Num(); blockIndex++)
{
const TSharedRef<ILayoutBlock> Block = View.Blocks[blockIndex];
const TSharedRef<IRun> Run = Block->GetRun();
FTextPrintBlock PrintBlock;
PrintBlock.RunInfo = Run->GetRunInfo();
PrintBlock.HasCharColorDecorator = UMyCharColorDecorator::CheckMarkupName(PrintBlock.RunInfo.Name);
//EXTRA: you could check for additional markup for custom style information here!
FTextRange PrintBlockTextRange = Block->GetTextRange();
Run->AppendTextTo(PrintBlock.Text, PrintBlockTextRange);
if (PrintBlock.Text.Len() == 0 || (PrintBlock.Text.Len() == 1 && PrintBlock.Text[0] == 0x200B))
{
// This block is either empty, or is a decorator with nothing inside.
// Treat as 1 character
PrintBlock.Text.Empty();
TotalContentCharacters += 1;
nextContentCharacterIndex += 1;
}
else if (PrintBlock.Text.Len() > 0)
{
// This block has text
PrintBlock.OriginalContentStringBeginIndex = nextContentCharacterIndex;
nextContentCharacterIndex += PrintBlockTextRange.Len();
if (PrintBlock.HasCharColorDecorator)
{
PrintBlock.OriginalCharColorDecoratorStringBeginIndex = nextCharColorDecoratorCharacterIndex;
nextCharColorDecoratorCharacterIndex += PrintBlockTextRange.Len();
}
TotalContentCharacters += PrintBlock.Text.Len();
}
PrintBlocks.Add(PrintBlock);
}
}
PrintBlocksInitialized = true;
}
else
{
PrintBlocks.Add(FTextPrintBlock{ Text.ToString() });
TotalContentCharacters = PrintBlocks[0].Text.Len();
}
}
PrintLetterByLetter()
This is a blueprint-callable public function that will start a letter-by-letter print. This starts a timer (LetterTimer
) that will call PlayNextLetter()
, which will do the actual printing of a single character.
Notice the ForceLayoutPrepass()
call. This is necessary to get our FSlateTextLayout
and FRichTextLayoutMarshaller
un-dirtied before we call InitializePrintBlocks()
and try and use data from them. Without this, you won’t be able to create or set text and call PrintLetterByLetter()
in a single frame.
void UMyRichTextBlock::PrintLetterByLetter()
{
check(GetWorld());
FTimerManager& TimerManager = GetWorld()->GetTimerManager();
TimerManager.ClearTimer(LetterTimer);
CurrentCharacterIndex = -1;
CurrentCharColorDecoratorCharacterIndex = -1;
CurrentPrintBlockIndex = 0;
// First Prepass gets the Marshaller and TextLayout ready
// (Necessary to let PlayLetterByLetter() be called on the same frame as creation)
ForceLayoutPrepass();
InitializePrintBlocks();
if (Text.IsEmpty())
{
// No text to print, stop immediately
StopLetterByLetterPrint(true);
}
else
{
// Start Letter By Letter printing
HideText();
LetterByLetterPrintInProgress = true;
LastCharacterTimestamp = GetWorld()->GetTimeSeconds();
LastCharacterPrintInterval = 0.1f; // EXTRA: function to decide a print interval for this character
if (LastCharacterPrintInterval > 0.0f)
{
FTimerDelegate Delegate;
Delegate.BindUObject(this, &ThisClass::PlayNextLetter);
TimerManager.SetTimer(LetterTimer, Delegate, LastCharacterPrintInterval, false);
}
else
{
PlayNextLetter();
}
// EXTRA: broadcast begin print event here!
}
}
PlayNextLetter()
This function does more heavy-lifting for the printing. This is called by LetterTimer
after waiting the interval time to print the next character. This is where we communicate with our UMyCharColorDecorator
for each character.
Because a timer being used, we have to account for cases where the time between characters is shorter than our client’s frame time! We might have to print multiple characters in one frame. That’s why this has a while-loop.
Furthermore, if we do print multiple characters, we want any print animations to look smooth and appear as-if the characters were staggered. Pay attention to how elapsedAnimationTime
is calculated.
void UMyRichTextBlock::PlayNextLetter()
{
// Print multiple letters in the case where the print interval is smaller than the frame time
float timeSinceLastLetter = FMath::Max(GetWorld()->GetTimeSeconds() - LastCharacterTimestamp, LastCharacterPrintInterval);
bool printFinished = false;
int numLettersThisFrame = 0;
bool isLongerThanNormalWait = false;
ForceLayoutPrepass();
while (timeSinceLastLetter >= LastCharacterPrintInterval && !printFinished)
{
// Note: CurrentCharacterIndex has not been incremented yet for our next character we're printing
// It is incremented inside CalculateNextLetterByLetterCharacter()
if (CurrentCharacterIndex + 1 < TotalContentCharacters)
{
numLettersThisFrame++;
int32 ThisPrintBlockIndex = CurrentPrintBlockIndex; // Cache this, because it might get incremented in CalculateNextLetterByLetterCharacter()
CalculateNextLetterByLetterCharacter();
// Print the current character
if (PrintBlocks[ThisPrintBlockIndex].HasCharColorDecorator)
{
// Animate the letter printing
// Adjust the elapsedAnimationTime by the timeWaited from last PlayNextLetter call
if (PrintBlocks[ThisPrintBlockIndex].HasCharColorDecorator)
{
const float elapsedAnimationTime = FMath::Max(timeSinceLastLetter - LastCharacterPrintInterval, 0.0f);
const float letterAnimationTime = 0.05f; // EXTRA: function to decide the animation time for this character
CharColorDecorator->SetCurrentLetterByLetterCharacter(CurrentCharColorDecoratorCharacterIndex, letterAnimationTime, elapsedAnimationTime);
}
}
timeSinceLastLetter -= LastCharacterPrintInterval;
LastCharacterPrintInterval = 0.1f; // EXTRA: function to decide a print interval for this character
}
else
{
printFinished = true;
FinishLetterByLetterPrint();
}
}
// Only broadcast OnPrintLetter once per frame
if (numLettersThisFrame > 0)
{
// EXTRA: broadcast print letter event here!
}
// Set timer for next letter
if (!printFinished)
{
// Adjust interval based on leftover time since last letter
LastCharacterPrintInterval -= timeSinceLastLetter;
FTimerDelegate Delegate;
Delegate.BindUObject(this, &ThisClass::PlayNextLetter);
GetWorld()->GetTimerManager().SetTimer(LetterTimer, Delegate, LastCharacterPrintInterval, false);
LastCharacterTimestamp = GetWorld()->GetTimeSeconds();
}
}
CalculateNextLetterByLetterCharacter
The purpose of this function is to increment CurrentCharacterIndex
and CurrentCharColorDecoratorCharacterIndex
. Incrementing these values is all we need to do to move PlayNextLetter()
to the next character. To do so, we also need to traverse our array of FTextPrintBlocks
and increment CurrentPrintBlockIndex
if necessary.
int32 UMyRichTextBlock::CalculateNextLetterByLetterCharacter()
{
int32 GoalLetterIndex = CurrentCharacterIndex + 1;
while (CurrentCharacterIndex < GoalLetterIndex && CurrentPrintBlockIndex < PrintBlocks.Num())
{
const FTextPrintBlock& PrintBlock = PrintBlocks[CurrentPrintBlockIndex];
bool printBlockComplete = false;
if (PrintBlock.HasCharColorDecorator)
{
// Text
if (CurrentCharacterIndex - PrintBlock.OriginalContentStringBeginIndex == 0)
{
// EXTRA: broadcast start of new print block here!
}
CurrentCharacterIndex++;
CurrentCharColorDecoratorCharacterIndex++;
if (CurrentCharacterIndex - PrintBlock.OriginalContentStringBeginIndex == PrintBlock.Text.Len() - 1)
{
printBlockComplete = true;
}
}
else if (PrintBlock.Text.IsEmpty())
{
// PrintBlock is empty, treat as 1 character
printBlockComplete = true;
CurrentCharacterIndex++;
}
else
{
// Something else, so it can't do a printout.
// Skip, but also update CurrentCharacterIndex
CurrentCharacterIndex += PrintBlock.Text.Len();
printBlockComplete = true;
}
if (printBlockComplete)
{
CurrentPrintBlockIndex++;
}
}
return CurrentCharacterIndex;
}
Remaining Functions
The remaining functions in UMyRichTextBlock
are fairly self-explanatory.
Note the ForceLayoutPrepass()
calls. These are important, and prevent edge-case issues with text and decorators. Something could have happened (e.g. a SetText()
) call that dirtied the TextLayout, Marshaller, or Decorator, and their data must be up to date.
void UMyRichTextBlock::HideText()
{
// Do pre-pass first to fix edge-cases where a pre-pass would happen later in the frame, wiping the decorator's text
ForceLayoutPrepass();
InitializePrintBlocks();
// Hide all text
CharColorDecorator->HideText();
if (LetterByLetterPrintInProgress)
{
StopLetterByLetterPrint();
}
}
void UMyRichTextBlock::ShowText()
{
// Do pre-pass first to fix edge-cases where a pre-pass would happen later in the frame, wiping the decorator's text
ForceLayoutPrepass();
CharColorDecorator->ShowText();
if (LetterByLetterPrintInProgress)
{
StopLetterByLetterPrint();
}
}
void UMyRichTextBlock::SetText(const FText& InText)
{
if (MyRichTextBlock.IsValid())
{
if (MyRichTextBlock->GetText().CompareTo(InText) != 0)
{
// We only reset the decorator's cached data if setting to new text.
// For whatever reason, Slate doesn't recreate decorators if doing a SetText
// with the same Text it already had, so if we reset our decorator
// it will not be recreated.
CharColorDecorator->ResetCachedDecorator();
// EXTRA: if you have image or widget decorators, mark them dirty here!
}
}
Super::SetText(InText);
PrintBlocksInitialized = false;
if (LetterByLetterPrintInProgress)
{
StopLetterByLetterPrint();
}
}
void UMyRichTextBlock::StopLetterByLetterPrint(const bool NoEvent)
{
FTimerManager& TimerManager = GetWorld()->GetTimerManager();
TimerManager.ClearTimer(LetterTimer);
LetterByLetterPrintInProgress = false;
if (!NoEvent)
{
// EXTRA: broadcast finish print event here!
}
}
void UMyRichTextBlock::FinishLetterByLetterPrint()
{
FTimerManager& TimerManager = GetWorld()->GetTimerManager();
TimerManager.ClearTimer(LetterTimer);
LetterByLetterPrintInProgress = false;
// EXTRA: broadcast finish print event here!
}
Part 2 - the Decorator
Now we create our Custom Decorator.
As a reminder, our goal with this decorator is to override OnPaint()
behavior with our text. To do this, we’re going to need more code than you might expect, but it is fairly straightforward.
This code follows Epic’s existing RichTextBlockDecorator.h/.cpp
as a guide for how to create a custom decorator. We’re going to be making 3 classes:
- UMyCharColorDecorator : public URichTextBlockDecorator - The UMG version of our decorator. This is what
UMyRichTextBlock
interfaces with. - FMyCharColorDecorator : public ITextDecorator - The Slate version of our decorator. This creates the next object below.
- FMyCharColorTextRun : public FSlateTextRun - Slate object. This is where we override
OnPaint()
.
(Note: I’m only using 2 files for these 3 classes, MyCharColorDecorator.h/.cpp
, and I’m actually putting the last 2 classes all in-line in MyCharColorDecorator.cpp
. Feel free to split them up if you like.)
UMyCharColorDecorator
This is a simple class:
CreateDecorator()
creates theFMyCharColorDecorator
.- The other functions simply call the
FMyCharColorDecorator
version of the same function. CheckMarkupName()
is a static function to check for the Decorator’s text markup, it’s used in a couple places.
Class Definition
UCLASS()
class UMyCharColorDecorator : public URichTextBlockDecorator
{
GENERATED_BODY()
public:
static bool CheckMarkupName(const FString& InString)
{
return (InString == TEXT("text") || InString == TEXT("t"));
}
virtual TSharedPtr<ITextDecorator> CreateDecorator(URichTextBlock* InOwner) override;
/** Called by UMyRichTextBlock at the start of a letter-by-letter print.
*/
virtual void HideText();
/** Can use this to skip a letter-by-letter print to the end.
*/
virtual void ShowText();
/** Set the run at this character index to be shown
*/
virtual void ShowTextAtCharacter(const int32& InCharacterIndex);
/** Sets which character is currently printing in a letter-by-letter animation.
* Should only be called by UMyRichTextBlock
*
* CharIndex should be the index of the character relative to the full content string (i.e. markup stripped out)
* of the UMyRichTextBlock. The decorator runs use ranges relative to this full content string already.
*
* animationTimeSeconds is the length of the animation for the character.
* elapsedAnimationTime (optional) start the animation this many seconds into the animation.
*/
virtual void SetCurrentLetterByLetterCharacter(int32 charIndex, float animationTimeSeconds, float elapsedAnimationTime = 0.0f);
virtual void ResetCachedDecorator();
protected:
FMyCharColorDecorator* FDecorator;
};
Function Definitions
TSharedPtr<ITextDecorator> UMyCharColorDecorator::CreateDecorator(URichTextBlock* InOwner)
{
FSlateFontInfo DefaultFont = InOwner->GetCurrentDefaultTextStyle().Font;
FLinearColor DefaultColor = InOwner->GetCurrentDefaultTextStyle().ColorAndOpacity.GetSpecifiedColor();
FDecorator = new FMyCharColorDecorator(this, DefaultFont, DefaultColor);
return MakeShareable(FDecorator);
}
void UMyCharColorDecorator::HideText()
{
FDecorator->HideText();
}
void UMyCharColorDecorator::ShowText()
{
FDecorator->ShowText();
}
void UMyCharColorDecorator::ShowTextAtCharacter(const int32& InCharacterIndex)
{
FDecorator->ShowTextAtCharacter(InCharacterIndex);
}
void UMyCharColorDecorator::SetCurrentLetterByLetterCharacter(int32 charIndex, float animationTimeSeconds, float elapsedAnimationTime)
{
FDecorator->SetCurrentLetterByLetterCharacter(charIndex, animationTimeSeconds, elapsedAnimationTime);
}
void UMyCharColorDecorator::ResetCachedDecorator()
{
FDecorator->ResetCachedDecorator();
}
FMyCharColorDecorator
This class is goes a little deeper:
- It implements
Supports()
andCreate()
(required for anITextDecorator
to work). Create()
createsFMyCharColorTextRun
objects.- The other functions interface with
FMyCharColorTextRun
to tell it how to manipulate text.
Class Definition
class FMyCharColorDecorator : public ITextDecorator
{
public:
FMyCharColorDecorator(URichTextBlockDecorator* InDecorator, const FSlateFontInfo& InDefaultFont, const FLinearColor& InDefaultColor)
: DefaultFont(InDefaultFont)
, DefaultColor(InDefaultColor)
, Decorator(InDecorator)
{
}
virtual ~FMyCharColorDecorator() {};
virtual bool Supports(const FTextRunParseResults& RunParseResult, const FString& Text) const override;
virtual TSharedRef<ISlateRun> Create(const TSharedRef<FTextLayout>& TextLayout, const FTextRunParseResults& RunParseResult, const FString& OriginalText, const TSharedRef<FString>& InOutModelText, const ISlateStyle* Style) override;
void SetCurrentLetterByLetterCharacter(int32 charIndex, float animationTimeSeconds, float elapsedAnimationTime);
void HideText();
void ShowText();
void ShowTextAtCharacter(const int32& InCharacterIndex);
void ResetCachedDecorator();
protected:
FSlateFontInfo DefaultFont;
FLinearColor DefaultColor;
// We need to tell each FMyCharColorTextRun which character index in the content text they
// begin at, relative to the original full string. The "content" as in the full rich text string
// with the markup stripped out.
// As we create runs, we increment this value to keep track of where the last run ended.
int32 LastContentCharacterIndex = 0;
private:
TWeakObjectPtr<URichTextBlockDecorator> Decorator;
TArray<TSharedPtr<FMyCharColorTextRun>> TextRuns;
};
Function Definitions
bool FMyCharColorDecorator::Supports(const FTextRunParseResults& RunParseResult, const FString& Text) const
{
return UMyCharColorDecorator::CheckMarkupName(RunParseResult.Name);
}
TSharedRef<ISlateRun> FMyCharColorDecorator::Create(const TSharedRef<FTextLayout>& TextLayout, const FTextRunParseResults& RunParseResult, const FString& OriginalText, const TSharedRef<FString>& InOutModelText, const ISlateStyle* Style)
{
FRunInfo RunInfo(RunParseResult.Name);
for (const TPair<FString, FTextRange>& Pair : RunParseResult.MetaData)
{
RunInfo.MetaData.Add(Pair.Key, OriginalText.Mid(Pair.Value.BeginIndex, Pair.Value.EndIndex - Pair.Value.BeginIndex));
}
FTextRange ModelRange;
int32 RunContentLength = RunParseResult.ContentRange.EndIndex - RunParseResult.ContentRange.BeginIndex;
ModelRange.BeginIndex = InOutModelText->Len();
*InOutModelText += OriginalText.Mid(RunParseResult.ContentRange.BeginIndex, RunContentLength);
ModelRange.EndIndex = InOutModelText->Len();
FTextBlockStyle TextBlockStyle;
TextBlockStyle.SetFont(DefaultFont);
TextBlockStyle.SetColorAndOpacity(DefaultColor);
TSharedPtr<FMyCharColorTextRun> MyCharColorRun = FMyCharColorTextRun::Create(RunInfo, InOutModelText, TextBlockStyle, ModelRange, LastContentCharacterIndex);
TextRuns.Add(MyCharColorRun);
LastContentCharacterIndex += RunContentLength;
return MyCharColorRun.ToSharedRef();
}
void FMyCharColorDecorator::SetCurrentLetterByLetterCharacter(int32 charIndex, float animationTimeSeconds, float elapsedAnimationTime)
{
for (int i = 0; i < TextRuns.Num(); i++)
{
TextRuns[i]->SetCurrentLetterByLetterCharacter(charIndex, animationTimeSeconds, elapsedAnimationTime);
}
}
void FMyCharColorDecorator::HideText()
{
for (int i = 0; i < TextRuns.Num(); i++)
{
TextRuns[i]->SetAllCharacterAlpha(0.0f);
TextRuns[i]->ClearLetterByLetterAnimations();
}
}
void FMyCharColorDecorator::ShowText()
{
for (int i = 0; i < TextRuns.Num(); i++)
{
TextRuns[i]->SetAllCharacterAlpha(1.0f);
TextRuns[i]->ClearLetterByLetterAnimations();
}
}
void FMyCharColorDecorator::ShowTextAtCharacter(const int32& InCharacterIndex)
{
for (int i = 0; i < TextRuns.Num(); i++)
{
if (TextRuns[i]->CharactersOriginalContentBeginIndex == InCharacterIndex)
{
TextRuns[i]->SetAllCharacterAlpha(1.0f);
TextRuns[i]->ClearLetterByLetterAnimations();
break;
}
}
}
void FMyCharColorDecorator::ResetCachedDecorator()
{
TextRuns.Empty();
LastContentCharacterIndex = 0;
}
FMyCharColorTextRun
This class is where the important Vertex Color logic happens. The most important bits are the Constructor, OnPaint()
, and SetCurrentLetterByLetterCharacter()
.
The way we pack the vertex colors is as follows:
- R Channel: 0.1 - 1.0 in order in uniform increments. Restarts at 0.1 at VERTEXCOLOR_STEPS characters
- G Channel: 0.1 - 1.0 random value
- B Channel: 0.1 - 1.0 letter-by-letter print anim progress
- A Channel: set to 0.0 or 1.0 by letter-by-letter print
SetCurrentLetterByLetterCharacter()
is called by FMyCharColorDecorator
, and sets a timestamp for a character to begin its print animation. The timestamp is checked inside OnPaint()
to determine what percentage through the animation the character is.
Class Definition
// Vertex color data will be packed between this percentage and 1.0
#define VERTEXCOLOR_MINDATAVALUE 0.1f
// Vertex color data will step by this much between characters (note: smaller steps introduce banding, probably gamma related)
#define VERTEXCOLOR_STEPS 50
struct CharColorInfo
{
CharColorInfo(FLinearColor InColor)
: Color(InColor)
{
}
FLinearColor Color;
float printAnimationTime = 0; // Time in seconds this character takes to animate in a letter-by-letter print animation
double printAnimationStartTime = 0; // Time the animation started ( FPlatformTime::Seconds()! NOT game time!)
};
class FMyCharColorTextRun : public FSlateTextRun
{
public:
static TSharedRef< FMyCharColorTextRun > Create(const FRunInfo& InRunInfo, const TSharedRef< const FString >& InText, const FTextBlockStyle& Style, const FTextRange& InRange, const int32 InOriginalContentRangeBegin)
{
return MakeShareable(new FMyCharColorTextRun(InRunInfo, InText, Style, InRange, InOriginalContentRangeBegin));
}
FMyCharColorTextRun(const FRunInfo& InRunInfo, const TSharedRef< const FString >& InText, const FTextBlockStyle& InStyle, const FTextRange& InRange, const int32 InOriginalContentRangeBegin);
virtual int32 OnPaint(const FPaintArgs& Args, const FTextLayout::FLineView& Line, const TSharedRef< ILayoutBlock >& Block, const FTextBlockStyle& DefaultStyle, const FGeometry& AllottedGeometry, const FSlateRect& MyCullingRect, FSlateWindowElementList& OutDrawElements, int32 LayerId, const FWidgetStyle& InWidgetStyle, bool bParentEnabled) const override;
void SetCurrentLetterByLetterCharacter(int32 charIndex, float animationTimeSeconds, float elapsedAnimationTime);
void ClearLetterByLetterAnimations();
// Set alpha of all characters from 0.0 to 1.0
void SetAllCharacterAlpha(float InAlpha);
public:
int32 CharactersOriginalContentBeginIndex = 0; // first character's index in the original full rich text block content (with markup stripped out)
private:
int32 CharactersBeginIndex = 0; // first character's index in this run's content text. TODO: this seems to always be zero? Not sure why we need this
TArray<CharColorInfo> CharacterColors;
float OutlineAlpha = 1.0f;
float ShadowAlpha = 1.0f;
};
Constructor
The FMyCharColorTextRun
Constructor initializes the colors for each character by making an array of CharColorInfo
structs. It also sets the R and G channels – these only need to be set once and never change while the Decorator exists.
FMyCharColorTextRun::FMyCharColorTextRun(const FRunInfo& InRunInfo, const TSharedRef< const FString >& InText, const FTextBlockStyle& InStyle, const FTextRange& InRange, const int32 InOriginalContentRangeBegin)
: FSlateTextRun(InRunInfo, InText, InStyle, InRange)
{
// Generate color values for each character and cache for use in OnPaint
CharactersBeginIndex = InRange.BeginIndex;
CharactersOriginalContentBeginIndex = InOriginalContentRangeBegin;
int32 numberOfCharacters = InRange.Len();
FMath::SRandInit(1029384756);
for (int32 i = 0; i < numberOfCharacters; i++)
{
// Colorize this character, packing channels with info for a font material.
// * R Channel: 0.1 - 1.0 in order in uniform increments. Restarts at 0.1 at VERTEXCOLOR_STEPS characters
// * G Channel: 0.1 - 1.0 random value
// * B Channel: 0.1 - 1.0 letter-by-letter print anim progress (set by SetCurrentLetterByLetterCharacter())
// * A Channel: set to 0.0 or 1.0 by letter-by-letter print (set by SetCurrentLetterByLetterCharacter())
//
// Notes:
// * We DO NOT use the RGB tint from the containing widget -- we want the color data to be consistent.
// * We DO use the alpha from the containing widget -- we still want the text to fade with its parent.
// * Color the outline and shadow the same RGB color as the text -- they need the same data for font materials.
// * Maintain the alpha of the outline and shadow from the style
FLinearColor styleColor = InStyle.ColorAndOpacity.GetSpecifiedColor();
FLinearColor characterColor = FLinearColor(0.0f, 0.0f, 0.0f, styleColor.A);
characterColor.R = VERTEXCOLOR_MINDATAVALUE + ((i % (VERTEXCOLOR_STEPS)) * ((1.0f - VERTEXCOLOR_MINDATAVALUE) / VERTEXCOLOR_STEPS));
characterColor.G = VERTEXCOLOR_MINDATAVALUE + ((1.0f - VERTEXCOLOR_MINDATAVALUE) * FMath::SRand());
characterColor.B = 1.0f;
characterColor.A = 1.0f;
CharacterColors.Add(CharColorInfo(characterColor));
}
OutlineAlpha = InStyle.Font.OutlineSettings.OutlineColor.A;
ShadowAlpha = InStyle.ShadowColorAndOpacity.A;
}
OnPaint()
OnPaint()
looks intimidating, but it’s actually a modified version of Epic’s FSlateTextRun::OnPaint()
. See the for-loop inside iterating through the characters.
The most important part is how it creates FShapedGlyphSequenceRef ShapedText
. This is the object that contains all the relevant information, and before we create it we calculate the appropriate B and Alpha values for the Vertex Colors.
The rest is pretty much adapting existing logic in the function we’re override, FSlateTextRun::OnPaint()
.
NOTE: This is written in UE4.27. UE5 introduced changes to OnPaint()
’s function signature. The code will work the same, but you will need to match the new signature.
int32 FMyCharColorTextRun::OnPaint(const FPaintArgs& Args, const FTextLayout::FLineView& Line, const TSharedRef< ILayoutBlock >& Block, const FTextBlockStyle& DefaultStyle, const FGeometry& AllottedGeometry, const FSlateRect& MyCullingRect, FSlateWindowElementList& OutDrawElements, int32 LayerId, const FWidgetStyle& InWidgetStyle, bool bParentEnabled) const
{
// Just like FSlateTextRun::OnPaint, but we chop up the string per-character and loop the paint for all characters
const ESlateDrawEffect DrawEffects = bParentEnabled ? ESlateDrawEffect::None : ESlateDrawEffect::DisabledEffect;
const bool ShouldDropShadow = Style.ShadowColorAndOpacity.A > 0.f && Style.ShadowOffset.SizeSquared() > 0.f;
const FVector2D BlockLocationOffset = Block->GetLocationOffset();
FTextRange BlockRange = Block->GetTextRange();
const FLayoutBlockTextContext BlockTextContext = Block->GetTextContext();
// The block size and offset values are pre-scaled, so we need to account for that when converting the block offsets into paint geometry
const float InverseScale = Inverse(AllottedGeometry.Scale);
// A negative shadow offset should be applied as a positive offset to the text to avoid clipping issues
const FVector2D DrawShadowOffset(
(Style.ShadowOffset.X > 0.0f) ? Style.ShadowOffset.X * AllottedGeometry.Scale : 0.0f,
(Style.ShadowOffset.Y > 0.0f) ? Style.ShadowOffset.Y * AllottedGeometry.Scale : 0.0f
);
const FVector2D DrawTextOffset(
(Style.ShadowOffset.X < 0.0f) ? -Style.ShadowOffset.X * AllottedGeometry.Scale : 0.0f,
(Style.ShadowOffset.Y < 0.0f) ? -Style.ShadowOffset.Y * AllottedGeometry.Scale : 0.0f
);
const int32 numCharacters = BlockRange.Len();
const int32 firstCharacterindex = BlockRange.BeginIndex;
FVector2D characterOffset = FVector2D(0.0f, 0.0f);
for (int i = 0; i < numCharacters; i++)
{
// Single out the range for this character
BlockRange.BeginIndex = firstCharacterindex + i;
BlockRange.EndIndex = BlockRange.BeginIndex + 1;
// Animate the Blue value based on letter-by-letter animation (if necessary)
int32 charColorIndex = (firstCharacterindex - CharactersBeginIndex) + i;
float blueValue = CharacterColors[charColorIndex].Color.B;
if (CharacterColors[charColorIndex].printAnimationTime > 0.0f)
{
blueValue = VERTEXCOLOR_MINDATAVALUE + ((1.0f - VERTEXCOLOR_MINDATAVALUE) * ((1.0f / CharacterColors[charColorIndex].printAnimationTime) * (Args.GetCurrentTime() - CharacterColors[charColorIndex].printAnimationStartTime)));
blueValue = FMath::Clamp(blueValue, VERTEXCOLOR_MINDATAVALUE, 1.0f);
}
// Get the final colors for this character (and its outline/shadow)
const FLinearColor characterColor = FLinearColor(CharacterColors[charColorIndex].Color.R, CharacterColors[charColorIndex].Color.G, blueValue, CharacterColors[charColorIndex].Color.A * InWidgetStyle.GetColorAndOpacityTint().A);
const FLinearColor characterOutlineColor = FLinearColor(characterColor.R, characterColor.G, characterColor.B, CharacterColors[charColorIndex].Color.A * OutlineAlpha * InWidgetStyle.GetColorAndOpacityTint().A);
const FLinearColor characterShadowColor = FLinearColor(characterColor.R, characterColor.G, characterColor.B, CharacterColors[charColorIndex].Color.A * ShadowAlpha * InWidgetStyle.GetColorAndOpacityTint().A);
// Make sure we have up-to-date shaped text to work with
// We use the full line view range (rather than the run range) so that text that spans runs will still be shaped correctly
FShapedGlyphSequenceRef ShapedText = ShapedTextCacheUtil::GetShapedTextSubSequence(
BlockTextContext.ShapedTextCache,
FCachedShapedTextKey(Line.Range, AllottedGeometry.GetAccumulatedLayoutTransform().GetScale(), BlockTextContext, Style.Font),
BlockRange,
**Text,
BlockTextContext.TextDirection
);
// Draw the optional shadow
if (ShouldDropShadow)
{
FShapedGlyphSequenceRef ShadowShapedText = ShapedText;
if (Style.ShadowColorAndOpacity != Style.Font.OutlineSettings.OutlineColor)
{
// Copy font info for shadow to replace the outline color
FSlateFontInfo ShadowFontInfo = Style.Font;
ShadowFontInfo.OutlineSettings.OutlineColor = Style.ShadowColorAndOpacity;
ShadowFontInfo.OutlineSettings.OutlineMaterial = nullptr;
if (!ShadowFontInfo.OutlineSettings.bApplyOutlineToDropShadows)
{
ShadowFontInfo.OutlineSettings.OutlineSize = 0;
}
// Create new shaped text for drop shadow
ShadowShapedText = ShapedTextCacheUtil::GetShapedTextSubSequence(
BlockTextContext.ShapedTextCache,
FCachedShapedTextKey(Line.Range, AllottedGeometry.GetAccumulatedLayoutTransform().GetScale(), BlockTextContext, ShadowFontInfo),
BlockRange,
**Text,
BlockTextContext.TextDirection
);
}
FSlateDrawElement::MakeShapedText(
OutDrawElements,
++LayerId,
AllottedGeometry.ToPaintGeometry(TransformVector(InverseScale, Block->GetSize()), FSlateLayoutTransform(TransformPoint(InverseScale, Block->GetLocationOffset() + DrawShadowOffset + characterOffset))),
ShadowShapedText,
DrawEffects,
characterShadowColor,
characterShadowColor
);
}
// Draw the text itself
FSlateDrawElement::MakeShapedText(
OutDrawElements,
++LayerId,
AllottedGeometry.ToPaintGeometry(TransformVector(InverseScale, Block->GetSize()), FSlateLayoutTransform(TransformPoint(InverseScale, Block->GetLocationOffset() + DrawTextOffset + characterOffset))),
ShapedText,
DrawEffects,
characterColor,
characterOutlineColor
);
characterOffset.X += ShapedText->GetMeasuredWidth();
}
return LayerId;
}
Remaining Functions
The remaining functions are more straightforward.
Notice the use of CharactersOriginalContentBeginIndex
in SetCurrentLetterByLetterCharacter()
. We need this to index into the string correctly.
void FMyCharColorTextRun::SetCurrentLetterByLetterCharacter(int32 charIndex, float animationTimeSeconds, float elapsedAnimationTime)
{
// Valid for the currentCharIndex to be out of range, because it could be
// a character from another run
int32 currentCharIndex = charIndex - CharactersOriginalContentBeginIndex;
if (CharacterColors.IsValidIndex(currentCharIndex))
{
// Set values that will allow us to adjust the Blue value in OnPaint
CharacterColors[currentCharIndex].printAnimationTime = animationTimeSeconds;
CharacterColors[currentCharIndex].printAnimationStartTime = FPlatformTime::Seconds() - static_cast<double>(elapsedAnimationTime);
CharacterColors[currentCharIndex].Color.A = 1.0f;
}
}
void FMyCharColorTextRun::ClearLetterByLetterAnimations()
{
for (int i = 0; i < CharacterColors.Num(); i++)
{
CharacterColors[i].printAnimationTime = 0.0f;
CharacterColors[i].printAnimationStartTime = 0.0;
}
}
void FMyCharColorTextRun::SetAllCharacterAlpha(float InAlpha)
{
for (int i = 0; i < CharacterColors.Num(); i++)
{
CharacterColors[i].Color.A = InAlpha;
}
}
Part 3 - the Material
Alright! We now have all the C++ code done for our animated text!
Once our code is in our project and compiling, we can open up the Unreal Editor and make some materials.
Create Our Text
Create a new User Widget, and add a MyRichTextBlock Widget to it. You should now see it in the Pallete along with the other native Widgets.
For now, set up your MyRichTextBlock exactly as you would a normal RichTextBlock. I’m not going to cover all the steps to do that here, so if you’re lost, refer to Epic’s documentation on Rich Text here. But to summarize, you’re going to create a Data Table with at least one Text Style row in it, and plug that in to your MyRichTextBlock.
Your text should now look like this, using the color of whatever was set in your Text Style. It should look exactly like it would in normal Rich Text:
We don’t see any differences yet because our new logic requires our Custom Decorator (MyCharColorDecorator). Recall from the code that the tags for this decorator are <text>My Cool Text</> or <t>My Cool Text</>. Add this decorator to our text, and see how it looks. You should see something like this:
It looks colorful because this our Vertex Color information! This means everything worked. Now we can author a Font Material to process the Vertex Color data and display our text how we want.
But first, let’s create some Material Functions that we’ll use in the Font Material.
Material Functions
We’re going to have data packed into Vertex Colors, so now we need to field this data in Materials.
For our own sanity, let’s put this logic in Material Functions. Then we can reuse it any Font Materials we create. To make a Material Function, In the Content Browser right click and navigate to Materials & Textures > Material Function.
We’re going to make two: AnimatedText_Data and AnimatedText_ColorCompensation
AnimatedText_Data
This function takes the information from Vertex Colors, and converts it to convenient 0.0 - 1.0 values. This will make it easy to work with within Materials.
AnimatedText_ColorCompensation
This function is required because the text rendering shader is hard-coded to multiply the Final Color
of the font material by the vertex color. This is a strange choice by Epic, but we can easily work around it. Before outputing Final Color
, we will divide our desired RGB color by the vertex color.
We feed our desired Color into this function before passing it into the Material output.
Font Material
Now we’re ready to make our Font Material!
Create a new Material, and remember to change its Material Domain to “User Interface”, and its Blend Mode to “Translucent”
Then, in your Text Styles for your Rich Text, plug in your new Material.
In your Material, add your new Material Functions and add a Vector Parameter for Color. This is the most basic requirement for a Font Material to work and print letter-by-letter:
You’ve now got everything you need to start dropping in nodes to animate your text! But first let’s jump back into our UI Editor and check our text block.
Compile our User Widget to see changes, and you should see your text use the color from the Material:
Part 4 - Go Time!
It’s been a long road, but this is it! Now you can start printing your text out and animating!
Let’s test out the printing using Event Pre Construct. This will let us test logic every time we Compile the User Widget.
And here is the result:
Your First Animations
Let’s add some simple flare to our text to test it out!
Go back to your Font Material and try playing around with the new features. Here’s an example that adds some idle waving and a print animation:
And the result:
Congratulations! If you’ve made it this far you’ve done a lot of hard work, and now you get to go wild (or subtle!) with your text. You have the beginnings of a very powerful tool for your game.
Part 5 - Extra
Modifying the Engine
What if you do modify and compile the Unreal Engine for your game project, and want a cleaner implementation?
- You don’t need Vertex Colors, instead you can pass additional animation data to the text shader.
- You don’t need a decorator (or text marked-up with a decorator), instead you can modify all text’s
OnPaint()
functionality.
Of course, it’s up to you if this is a good move for your project or not. If you value easy upgradeability with Engine releases, code delta deep in Slate may not be desireable. On the other hand, if this is a key feature for your game, this might be very worth it!
The Proto Animated Text Plugin
As mentioned at the beginning, this tutorial is outlining the basics of how my PROTO Animated Text plugin works.
PROTO Animated Text
An Unreal Plugin
I really want to share my knowledge of how to tackle hard problems, so that’s why I’m putting this tutorial out! If you want to support me, consider purchasing the plugin. It’ll give you many more features than I covered here, and you’ll save yourself a lot of time chasing edge-cases. But if you want to swing on your own version, that’s very cool too! When you post your cool gif to social media, tag me on Mastodon or Twitter!