Moving on to a more complex tool, the Channel Box. As I primarily use Maya I have decided to more or less copy the structure there as a start, it can be refined later on depending on user experience.

The ChannelBox is high up on my list because in Unreal there is a strong disconnect between the property editor and the graph editor. Animators need to be able to quickly filter between these and set keyframes quickly.
It also bothered me that I could not update multiple values from the property editor when selecting several objects.

As always, I split my requirements into “Minimal” and “Production-Ready” to ensure I don’t get off track.

Minimal Requirements:

  • Display Transform channels
  • Must work for both standard Actors and control rig controls
  • Must allow right click copy/paste values
  • Ability to drag input field to adjust value live
  • Value must auto-update based on editor selection and changes
  • Dragging values must reflect instantly in viewport
  • With multiple objects selected, setting or dragging values should affect all objects relatively
  • Ability to Right-Click attributes to set keyframes

Production-Ready Requirements

  • Easy customization for non-transform channels (eg: visibility, ik/fk)
  • Graph Editor sync option
  • Sequencer Sync option

Implementation:

The ChannelBox was a tricky one to get working, and I’ll admit my code still crashes sometimes when changing selection and the code is not the cleanest, but with a notebook full of lessons I’m in a great place for the subsequent refactor.
There is a lot more code in this one so I will summarize, but I do intend to release the source in the future. (Once it is no longer a mess of notes)

The UI itself is simply a SListView in a SScrollBox with a right click menu, Unreal does all the hard work for us here.

const FGenericCommands& GenericCommands = FGenericCommands::Get();
const FAnimToolkitCommands& Commands = FAnimToolkitCommands::Get();
CommandList = MakeShareable(new FUICommandList);
CommandList->MapAction(
    Commands.KeySelected,
    FExecuteAction::CreateSP(this, &SChannelBox::KeySelectedCB));
CommandList->MapAction(
    Commands.KeyAll,
    FExecuteAction::CreateSP(this, &SChannelBox::KeyAllCB));
CommandList->MapAction(
    GenericCommands.Copy,
    FExecuteAction::CreateSP(this, &SChannelBox::CopyCB));
CommandList->MapAction(
    GenericCommands.Paste,
    FExecuteAction::CreateSP(this, &SChannelBox::PasteCB));

ChildSlot
.VAlign(VAlign_Fill)
.HAlign(HAlign_Fill)
    [
        SNew(SScrollBox)
        + SScrollBox::Slot()
        [
            SAssignNew(ListView, SListView<TSharedPtr<FPropertyMapper>>)
            .SelectionMode(ESelectionMode::Multi)
            .ItemHeight(24)
            .ListItemsSource(&PropertyMappers)
            .OnGenerateRow(this, &SChannelBox::ConstructRow)
            .OnContextMenuOpening(this, &SChannelBox::GenerateContextMenu)
            .HeaderRow(
                SNew(SHeaderRow)
                .Visibility(EVisibility::Collapsed)
                +SHeaderRow::Column(SChannelBoxPropertyColumns::Name)
                .DefaultLabel(LOCTEXT("PropertiesNameColumn", "Name"))
                +SHeaderRow::Column(SChannelBoxPropertyColumns::Value)
                .DefaultLabel(LOCTEXT("PropertiesValueColumn", "Value")))
        ]
    ];

For each row I have used a SMultiColumnTableRow delegate, this acts in part to draw the widgets (a label and a SNumericEntryBox) but also as a middle-man between the user and the underlying data.
Here I have overloaded the GenerateWidgetForColumn to construct the row with some simple wrapper redirects so I can handle my data in one place.

class SPropertyMapperTableRow : public SMultiColumnTableRow<TSharedPtr<FPropertyMapper>>
{
public:
	virtual TSharedRef<SWidget> GenerateWidgetForColumn(const FName& InColumnName) override
	{
		if (InColumnName == SChannelBoxPropertyColumns::Name)
		{
			return SNew(STextBlock).Text(Mapper->DisplayName);
		}
		else if(InColumnName == SChannelBoxPropertyColumns::Value)
		{
            return SAssignNew( Slider, SNumericEntryBox<float> )
                .Font(FEditorStyle::GetFontStyle(TEXT("MenuItem.Font")))
                .AllowSpin(true)
                .Delta(0.0f)
                .MinValue(TOptional<float>())
                .MaxValue(TOptional<float>())
                .MinSliderValue(TOptional<float>())
                .MaxSliderValue(TOptional<float>())
                .Value(this, &SPropertyMapperTableRow::GetMapperValue)
                .OnValueChanged(this, &SPropertyMapperTableRow::SetMapperValue)
                .OnBeginSliderMovement(this, &SPropertyMapperTableRow::OnSliderBegin)
                .OnEndSliderMovement(this, &SPropertyMapperTableRow::OnSliderEnd)
            ;
        }

		return SNullWidget::NullWidget;
	}

private:
	TSharedPtr<FPropertyMapper> Mapper;
	TSharedPtr <SNumericEntryBox<float>> Slider;

    TOptional<float> GetMapperValue() const {
        return Mapper->GetFloatValue();
    };

    void SetMapperValue( float Value) {
        Mapper->SetFloatValue( Value );
    };

    void OnSliderBegin() {
        Mapper->OnSliderBegin();
    };

    void OnSliderEnd( float Value ) {
        Mapper->OnSliderEnd();
    };

};

This is where we need to start working on the underlying data structure. I’m not entirely happy with my structure, but it works for now.

In order to work with both ControlRig proxy controls and regular Actor transforms we will need a wrapper interface.
The way I have dealt with this is split into three parts.

PropertyMapper: Collection of similar properties (ie: translateX) to interface with collectively
PropertyAccessor: Iterable class to find available interfaces
PropertyInterface: Implementation to actually interface with the underlying property

Here is a diagram to show how it is structured.

ChannelBox Structure

The property mapper is basic, it just guides the flow to the contained interfaces and where necessary bundles it into an undo chunk (transaction)
I will note here that the undo chunks do not behave how I expect currently.

class FPropertyMapper
{
public:
    FPropertyMapper( const FText& InDisplayName, const TArray<TSharedPtr<FPropertyInterface>> &InInterfaces )
        : DisplayName( InDisplayName ), Interfaces(InInterfaces) {
    }

    float GetFloatValue() const {
        for ( auto& Interface : Interfaces )
            if ( Interface.Get() )
                return Interface->GetFloatValue();
        return 0.0;
    }

    void SetFloatValue( float InValue ) {
        for ( auto& Interface : Interfaces )
            if ( Interface.Get() )
                Interface->SetFloatValue( InValue );
    }

    void OnSliderBegin() {
        GEditor->BeginTransaction(LOCTEXT("SChannelBoxSetVector", "SChannelBox value change"));
        float CurrentValue = GetFloatValue();
        for ( auto& Interface : Interfaces )
            if ( Interface.Get() )
                Interface->OnSliderBegin( CurrentValue );
    };

    void OnSliderEnd() {
        for ( auto& Interface : Interfaces )
            if ( Interface.Get() )
                Interface->OnSliderEnd();
        GEditor->EndTransaction();

        GUnrealEd->UpdatePivotLocationForSelection();
        GUnrealEd->SetPivotMovedIndependently(false);
        // Redraw
        GUnrealEd->RedrawLevelEditingViewports();
    };
    void SetKey() {
        for ( auto& Interface : Interfaces )
            if ( Interface.Get() )
                Interface->SetKey();
    }
    FText DisplayName;
private:
    TArray<TSharedPtr<FPropertyInterface>> Interfaces;
};

The interface accessor is just hardcoded for now, this will need to be dynamic later, but for now we know that every actor and control has all transform attributes:

class FControlRigPropertyInterfaceAccessor : public FPropertyInterfaceAccessor
{
public:
    virtual bool IsUniqueSelection() const { return true; }
    virtual TArray<TSharedPtr<FPropertyInterface>> GetSelectedProperties() const override
    {
        TArray<TSharedPtr<FPropertyInterface>> Properties;
        for( auto Control : GetSelectedControls() ) {
            Properties.Add( MakeShareable( new FControlRigTransformInterface( Control, TransformAxis::kTranslateX ) ) );
            Properties.Add( MakeShareable( new FControlRigTransformInterface( Control, TransformAxis::kTranslateY ) ) );
            Properties.Add( MakeShareable( new FControlRigTransformInterface( Control, TransformAxis::kTranslateZ ) ) );
            Properties.Add( MakeShareable( new FControlRigTransformInterface( Control, TransformAxis::kRotateX ) ) );
            Properties.Add( MakeShareable( new FControlRigTransformInterface( Control, TransformAxis::kRotateY ) ) );
            Properties.Add( MakeShareable( new FControlRigTransformInterface( Control, TransformAxis::kRotateZ ) ) );
            Properties.Add( MakeShareable( new FControlRigTransformInterface( Control, TransformAxis::kScaleX ) ) );
            Properties.Add( MakeShareable( new FControlRigTransformInterface( Control, TransformAxis::kScaleY ) ) );
            Properties.Add( MakeShareable( new FControlRigTransformInterface( Control, TransformAxis::kScaleZ ) ) );
        }
        return Properties;
    }
};

class FActorPropertyInterfaceAccessor : public FPropertyInterfaceAccessor
{
public:
    virtual TArray<TSharedPtr<FPropertyInterface>> GetSelectedProperties() const override
    {
        TArray<TSharedPtr<FPropertyInterface>> Properties;
        
		Properties.Reserve(GEditor->GetSelectedActorCount());
        for (FSelectionIterator It(GEditor->GetSelectedActorIterator()); It; ++It) {
            AActor* Actor = static_cast<AActor*>(*It);
            if ( !Actor ) continue;
            Properties.Add( MakeShareable( new FActorTransformInterface( Actor, TransformAxis::kTranslateX ) ) );
            Properties.Add( MakeShareable( new FActorTransformInterface( Actor, TransformAxis::kTranslateY ) ) );
            Properties.Add( MakeShareable( new FActorTransformInterface( Actor, TransformAxis::kTranslateZ ) ) );
            Properties.Add( MakeShareable( new FActorTransformInterface( Actor, TransformAxis::kRotateX ) ) );
            Properties.Add( MakeShareable( new FActorTransformInterface( Actor, TransformAxis::kRotateY ) ) );
            Properties.Add( MakeShareable( new FActorTransformInterface( Actor, TransformAxis::kRotateZ ) ) );
            Properties.Add( MakeShareable( new FActorTransformInterface( Actor, TransformAxis::kScaleX ) ) );
            Properties.Add( MakeShareable( new FActorTransformInterface( Actor, TransformAxis::kScaleY ) ) );
            Properties.Add( MakeShareable( new FActorTransformInterface( Actor, TransformAxis::kScaleZ ) ) );
        }
        return Properties;
    }
};

Since we are working with transform data, I need some bitflags to check the current axis:

enum TransformAxis {
    kNoTransform = 0x0,
    kTranslateX = 0x1,
    kTranslateY = 0x2,
    kTranslateZ = 0x4,
    kRotateX = 0x8,
    kRotateY = 0x10,
    kRotateZ = 0x20,
    kScaleX = 0x40,
    kScaleY = 0x80,
    kScaleZ = 0x100,

    kTranslation = kTranslateX | kTranslateY | kTranslateZ,
    kRotation = kRotateX | kRotateY | kRotateZ,
    kScale = kScaleX | kScaleY | kScaleZ,

    kAxisX = kTranslateX | kRotateX | kScaleX,
    kAxisY = kTranslateY | kRotateY | kScaleY,
    kAxisZ = kTranslateZ | kRotateZ | kScaleZ
};

We can use that TransformAxis flag to simplify subsequent queries. Here I have the FAbstractTransformInterface class where I remove as much duplicate code from the child classes as I can.
What is unique between controlrig and actors is how we get and set the transform data. Originally I had just an override for GetTransform/SetTransform, but that didn’t play well with how AActor gets/sets transforms by component (translate/rotate/scale3D) which added additional overhead. So I’ve opted to provide translate/rotate/scale separately here.

class FAbstractTransformInterface : public FPropertyInterface
{
public:
    FAbstractTransformInterface( TransformAxis InAxis)
        : Axis( InAxis )
    {
        switch( InAxis ) {
            case kTranslateX:
                DisplayName = LOCTEXT("TranslateX", "Translate X");
                break;
            case kTranslateY:
                DisplayName = LOCTEXT("TranslateY", "Translate Y");
                break;
            case kTranslateZ:
                DisplayName = LOCTEXT("TranslateZ", "Translate Z");
                break;
            case kRotateX:
                DisplayName = LOCTEXT("RotateX", "Rotate X");
                break;
            case kRotateY:
                DisplayName = LOCTEXT("RotateY", "Rotate Y");
                break;
            case kRotateZ:
                DisplayName = LOCTEXT("RotateZ", "Rotate Z");
                break;
            case kScaleX:
                DisplayName = LOCTEXT("ScaleX", "Scale X");
                break;
            case kScaleY:
                DisplayName = LOCTEXT("ScaleY", "Scale Y");
                break;
            case kScaleZ:
                DisplayName = LOCTEXT("ScaleZ", "Scale Z");
                break;
            default:
                DisplayName = LOCTEXT("UnknownAttr", "Unknown Attr");
                break;
        }
    }
    FVector GetVector() const {
        if ( Axis & kTranslation )
            return GetTranslation();
        else if ( Axis & kRotation )
            return FRotator( GetRotation() ).Euler();
        else if ( Axis & kScale )
            return GetScale3D();
        return FVector();
    }
    void SetVector( const FVector& Vector ) {
        if ( Axis & kTranslation )
           SetTranslation( Vector );
        else if ( Axis & kRotation )
            SetRotation( FQuat::MakeFromEuler( Vector ) );
        else if ( Axis & kScale )
            SetScale3D( Vector );
    }
    virtual float GetFloatValue() const override
    {
        FVector Vector = GetVector();
        
        if ( Axis & kAxisX )
            return Vector.X;
        else if ( Axis & kAxisY )
            return Vector.Y;
        else if ( Axis & kAxisZ )
            return Vector.Z;
        return 0.0;
    }
    virtual void SetFloatValue( float Value ) override
    {
        if ( IsSliding )
            Value += ValueOffset;
        
        FVector Vector = GetVector();
        if ( Axis & kAxisX )
            Vector.X = Value;
        else if ( Axis & kAxisY )
            Vector.Y = Value;
        else if ( Axis & kAxisZ )
            Vector.Z = Value;
        
        SetVector( Vector );
    }

    virtual void OnSliderBegin( float CurrentValue) override
    {
        AActor* Actor = GetActor();
        if ( !Actor )
            return;
        IsSliding = true;
        ValueOffset = GetFloatValue() - CurrentValue;
        GEditor->BroadcastBeginObjectMovement(*Actor);
    }
    virtual void OnSliderEnd() override
    {
        AActor* Actor = GetActor();
        if ( !Actor )
            return;
        IsSliding = false;
        ValueOffset = 0.f;
        GEditor->BroadcastEndObjectMovement(*Actor);

    }
    TransformAxis Axis;

protected:
    virtual AActor* GetActor() const = 0;
    virtual FVector GetTranslation() const = 0;
    virtual FQuat GetRotation() const = 0;
    virtual FVector GetScale3D() const = 0;

    virtual void SetTranslation( const FVector& Vector ) = 0;
    virtual void SetRotation( const FQuat& Quat ) = 0;
    virtual void SetScale3D( const FVector& Vector ) = 0;

private:
    float ValueOffset = 0.f;
    bool IsSliding = false;
};

Now we all know how to get/set transforms on an actor:

// Get
const FTransform& Transform = Actor->GetRootComponent()->GetComponentTransform();
// Set
Actor->GetRootComponent()->SetRelativeLocation( Vector, false, nullptr, ETeleportType::TeleportPhysics );

But ControlRig Controls are not so clear, so I’ve wrapped this in a simple class, ControlRig is name based in some areas, index based in others, so wrapping it at least makes it consistent

class ControlRigControl
{
public:
    ControlRigControl( UControlRig* ControlRig, FName ControlName );
    const FName& GetName() const;
    UControlRig* GetControlRig() const;
    int32 GetIndex() const;

    FVector GetTranslation() const;
    FQuat GetRotation() const;
    FVector GetScale3D() const;
    FTransform GetTransform() const;

    void SetTranslation( const FVector& Vector );
    void SetRotation( const FQuat& Quat );
    void SetScale3D( const FVector& Vector );
    void SetTransform( const FTransform& Transform );

private:
    TWeakObjectPtr<UControlRig> ControlRig;
    FName ControlName;
    int32 Index;
};

// part of source:

ControlRigControl::ControlRigControl( UControlRig* InControlRig, FName InControlName )
	: ControlRig( InControlRig ),
	  ControlName( InControlName ),
	  Index( InControlRig->GetControlHierarchy().GetIndex(InControlName) )
{
}
UControlRig* ControlRigControl::GetControlRig() const
{
	return ControlRig.Get();
}
FTransform ControlRigControl::GetTransform() const
{
	if ( !ControlRig.IsValid() )
		return FTransform();
	const FRigControl& RigControl = ControlRig->GetControlHierarchy().GetControls()[Index];
	return RigControl.GetValue().Get<FTransform>();
}

void ControlRigControl::SetTranslation( const FVector& Vector )
{
	if ( !ControlRig.IsValid() )
		return;
	FTransform Transform = GetTransform();
	Transform.SetTranslation( Vector );
	ControlRig->SetControlValue<FTransform>(ControlName, Transform);
}
void ControlRigControl::SetRotation( const FQuat& Quat )
{
	if ( !ControlRig.IsValid() )
		return;
	FTransform Transform = GetTransform();
	Transform.SetRotation( Quat );
	ControlRig->SetControlValue<FTransform>(ControlName, Transform);
}
void ControlRigControl::SetScale3D( const FVector& Vector )
{
	if ( !ControlRig.IsValid() )
		return;
	FTransform Transform = GetTransform();
	Transform.SetScale3D( Vector );
	ControlRig->SetControlValue<FTransform>(ControlName, Transform);
}

This is where things get interesting(hard), how keyframes are set in unreal is very different to Maya/Blender as it depends on what sequence is currently active and if there is a track setup. For now my code simply assumes that a track already exists for the control/actor you are trying to key. I’ll address creating new tracks for selected items later.

Here is my implementation of FActorTransformInterface::SetKey()
I’ve shifted much of the code to utilities, but in essence the data lives at:
Sequencer > LevelSequence > Track > Section > FloatChannels
We can then add the key there.
Now because the transform curves all live together, we need to offset them based on the axis for the correct index. I looked at the source code to find the order but perhaps there is a dynamic way to query this somewhere.

virtual void SetKey() override
{
    int32 ChannelOffset = 0;
    switch( Axis ) {
        case kTranslateX:
            ChannelOffset = 0;
            break;
        case kTranslateY:
            ChannelOffset = 1;
            break;
        case kTranslateZ:
            ChannelOffset = 2;
            break;
        case kRotateX:
            ChannelOffset = 3;
            break;
        case kRotateY:
            ChannelOffset = 4;
            break;
        case kRotateZ:
            ChannelOffset = 5;
            break;
        case kScaleX:
            ChannelOffset = 6;
            break;
        case kScaleY:
            ChannelOffset = 7;
            break;
        case kScaleZ:
            ChannelOffset = 8;
            break;
        default:
            return;
    }
    FFrameNumber FrameNumber = GetCurrentFrame();
    UMovieScene3DTransformSection* Section = FindTransformSection( GetActor(), FrameNumber );
    if ( !Section )
        return;  // TODO
    
    TArrayView<FMovieSceneFloatChannel*> FloatChannels = Section->GetChannelProxy().GetChannels<FMovieSceneFloatChannel>();

    float Value = GetFloatValue();
    FloatChannels[ChannelOffset]->AddCubicKey(FrameNumber, Value);
}

The FControlRigTransformInterface::SetKey implementation is very similar but since the entire control rig stores it’s curves together, we also need to offset the channel but the control index:

UMovieSceneControlRigParameterSection* Section = nullptr;
FFrameNumber FrameNumber = GetCurrentFrame( Sequencer );
bool bSectionAdded;
Section = Cast<UMovieSceneControlRigParameterSection>(Track->FindOrAddSection(FrameNumber, bSectionAdded));
if ( Section ) {
    FChannelMapInfo* pChannelIndex = Section->ControlChannelMap.Find(Control->GetName());
    if (pChannelIndex == nullptr)
        return;
    int32 ChannelIndex = pChannelIndex->ChannelIndex;
    int32 ChannelOffset = ChannelIndex;

And that is the UI code!
I did also make a bunch of utilities for getting the current sequence/track/section.
I’ll paste them in here in case it’s helpful for someone, but they are fairly crude at this stage:

TSharedPtr<ISequencer> GetSequencer( ULevelSequence* Sequence ) {
    if ( !Sequence ) {
        TArray<ULevelSequence*> Sequences = GetLevelSequences();
        if ( Sequences.Num() )
            Sequence = Sequences[0];
        else
            return nullptr;
    }
    IAssetEditorInstance* AssetEditor = GEditor->GetEditorSubsystem<UAssetEditorSubsystem>()->FindEditorForAsset(Sequence, false);
    if ( !AssetEditor )
        return nullptr;
    ILevelSequenceEditorToolkit* LevelSequenceEditor = static_cast<ILevelSequenceEditorToolkit*>(AssetEditor);
    if ( !LevelSequenceEditor )
        return nullptr;
    return LevelSequenceEditor->GetSequencer();
}

FFrameNumber GetCurrentFrame( const TSharedPtr<ISequencer>& Sequencer ) {
    auto Time = Sequencer->GetLocalTime();
    return Time.ConvertTo( Time.Rate ).RoundToFrame();
}
FFrameNumber GetCurrentFrame()
{
    return GetCurrentFrame( GetSequencer() );
}

TArray<ULevelSequence*> GetLevelSequences() {
    TArray<ULevelSequence*> LevelSequences;
    FAssetRegistryModule& AssetRegistryModule = FModuleManager::LoadModuleChecked<FAssetRegistryModule>(FName("AssetRegistry"));
    IAssetRegistry& AssetRegistry = AssetRegistryModule.Get();
    TArray<FAssetData> Sequences;
    AssetRegistry.GetAssetsByClass(ULevelSequence::StaticClass()->GetFName(), Sequences);
    for (FAssetData SequenceAsset : Sequences) {
        // Filter for level sequences
        if ( ULevelSequence* LevelSequence = Cast<ULevelSequence>(SequenceAsset.GetAsset()) ) {
            LevelSequences.Add(LevelSequence);
        }
    }
    return LevelSequences;
}
UMovieScene3DTransformSection* FindTransformSection( const AActor* Actor, FFrameNumber FrameNumber ) {
    for (ULevelSequence* LevelSequence : GetLevelSequences()) {
        if ( UMovieScene3DTransformSection* Section = FindTransformSection( Actor, LevelSequence, FrameNumber ) )
            return Section;
    }
    return nullptr;
}

UMovieScene3DTransformSection* FindTransformSection( const AActor* Actor, ULevelSequence* Sequence, FFrameNumber FrameNumber ) {
    for (const FMovieSceneBinding& Binding : Sequence->MovieScene->GetBindings() ) {
        UMovieScene3DTransformTrack* Track = Cast<UMovieScene3DTransformTrack>(
            Sequence->MovieScene->FindTrack(UMovieScene3DTransformTrack::StaticClass(),
                                                Binding.GetObjectGuid(), NAME_None));
        if ( Track ) {
            TArray< UObject*, TInlineAllocator< 1 > > BoundObjects;
            Sequence->LocateBoundObjects(Binding.GetObjectGuid(), nullptr, BoundObjects);
            for ( UObject* BoundObject : BoundObjects ) {
                if ( Cast< AActor >( BoundObject ) == Actor ) {
                    // TODO: Shouldn't create it here, make a separate method
                    bool bSectionAdded;
                    UMovieScene3DTransformSection* Section = Cast<UMovieScene3DTransformSection>(Track->FindOrAddSection( FrameNumber, bSectionAdded ));
                    return Section;
                }
            }
        }
    }
    return nullptr;
}

Final Result:

Unreal Animator ChannelBox Prototype

Closing thoughts

In some ways, Unreal is much easier to work with than I anticipated, but in others it’s an absolute pain.
I like the flexibility of slate and the fact that artists get so much control over their animation, but the API is just not at a point where it’s ready for film production use. I can see I have my work cut out for myself getting the rest of these tools operational.

Next on my list is a simplified constraint tool, it was going to be Anim Layers, but I feel that would benefit waiting until I have Constraints/Attachments understood more.
I’ll aim to have this up in the next two weeks.

Regards,
Mr MinimalEffort