Legacy/UE4 Transform Calculus - Part 2
- 1 UE4 Transform Calculus - Part 2
- 1.1 Recap
- 1.2 Implementing Transform Calculus With UE4 Types
- 1.3 Concept vs Notation
- 1.4 Examples in UE4
- 1.5 Sidebar: FTransform in UE4
- 1.6 Summary
UE4 Transform Calculus - Part 2
Last post I described a way of looking at a transformation as a vector function that changes from one coordinate system, or frame of reference, to another, along with a logical notation for manipulating them:
Transform Calculus Operations
|Transform Calculus Operations|
|Transformation||TA->B(V)||Evaluating T transforms V from frame A to frame B
|Inversion||TA->B-1 ÔçÆ TB->A||T-1 reverses the input and output frames, so A->B becomes B->A.|
|Composition (⊕)||TA->B ⊕ TB->C ÔçÆ TA->C||composition forms a ÔÇ£chainÔÇØ, going from frame A to frame B to frame C.|
In this post, weÔÇÖll discuss how those operations are implemented using several representations available in UE4. Additionally, weÔÇÖll discover how using a uniform notation like this can convey intent more clearly, particularly when multiple representations are involved.
Implementing Transform Calculus With UE4 Types
To implement the transform calculus using types found in UE4, we must first understand the types available and how they can be interpreted in terms of vector space transformations. These types are found in the Math subdirectory of the Runtime/Core module. WeÔÇÖll start by describing how these types can be interpreted, followed by how the transform calculus can be implemented for each of these interpretations.
UE4 Vector Types
|UE4 Type||Possible Interpretations|
For the most part, the differences in dimensionality of an FVector are immaterial and weÔÇÖll generally ignore it. However, when an FVector represents a non-homogeneous vector, its interpretation as a point or vector is purely contextual and thus must be conveyed explicitly. For instance, FVector(0,0,1) could be a vector representing Z-axis, or point at <0,0,1>. ItÔÇÖs up to the code to specify.
UE4 Transform Types
|UE4 Type||Possible Interpretations|
UE4 Type Ambiguities
As can be seen in the previous tables, UE4 types can be interpreted in several ways with respect to transform calculus, and those interpretations are purely contextual, meaning there is no static typing to distinguish them. The following are the primary interpretation ambiguities in UE4 types:
- row vectors vs column vectors
- points vs vectors
- homogeneous vs non-homogeneous vectors
- FVectors as mathematical vectors vs transformations (translation or scale)
Any implementation of transform calculus for UE4 must provide a way to disambiguate these interpretations.
Transformation using UE4 Types
The following table describes how to implement evaluation of TA->B(V) using UE4 types. Where type ambiguities matter, it will be called out:
|FMatrix||FVector (row)||V * M|
|FMatrix||FVector (col)||M * V|
|FVector (translation)||FVector (non-homogeneous vector)||V|
|FVector (translation)||FVector (non-homogeneous point)||T + V|
|FVector or float (scale)||FVector||S * V|
Inversion using UE4 Types
The following table describes how to implement the inverse, TA->B-1, using UE4 types. Where type ambiguities matter, it will be called out:
|FRotator||EulerAngles = R.Euler();
FRotator::MakeFromEuler(FVector(-EulerAngles.Z, -EulerAngles.Y, -EulerAngles.X));
|FVector (scale)||FVector(1 / S.x, 1 / S.y, 1 / S.z)|
|float (scale)||1 / S|
Composition using UE4 Types
The following table describes how to implement composition, TA->B ⊕ TB->C, using UE4 types. Where type ambiguities matter, it will be called out:
|TypeOf(T)||Implementation (TA->B ⊕ TB->C)|
|FMatrix||M1 * M2|
|FQuat||Q2 * Q1 |
|FRotator||FRotator(FMatrix(M1) * FMatrix(M2))|
|FVector (translation)||T1 + T2|
|FVector or float (scale)||S1 * S2|
Concept vs Notation
At that point it should be clear why I am introducing a separate notation for transformation calculus -- depending on the type, the math required to express a particular transformational concept may differ (or not exist) in UE4. So we need a new way to uniformly express those concepts in UE4, and weÔÇÖll need to adapt the existing UE4 types to support it.
Most of the type ambiguities in UE4 mentioned earlier are about the typeÔÇÖs conceptual use rather than ambiguities in the math. This is because the UE4 FVector and FMatrix classes are math classes, and those mathematical notations are well-defined regardless of the objectÔÇÖs conceptual purpose. As a case in point, the FVector interface mixes different interpretations of itself throughout the API, which can lead to confusion and mistakes. Here are a few examples:
|FVector Interpretation Ambiguities|
|FVector Method||FVector Interpretation|
|FVector::Projection()||2D homogeneous vector|
|FVector::PointPlaneDist()||non-homogeneous point AND vector|
In cases like these, understanding the intent of a type is critical to readability. For instance, if V1 and V2 are FVectors, what does this expression do?
V1 + V2
Mathematically, it means vector addition. Conceptually itÔÇÖs ambiguous. It could be compositing two translations, transforming a vector by a translation, or simply adding two grouped scalars! Commutativity of addition makes this even more ambiguous. But by providing a distinct notation for transform calculus that wraps the math, we can clarify the conceptual use:
- V1A->B(V2) ÔçÆ transformation evaluation
- V1 ⊕ V2 ÔçÆ transformation composition
- V1 + V2 ÔçÆ mathematical addition
Examples in UE4
In isolation, these conceptual vs notational nuances may seem like hair-splitting, but in the context of a math-heavy bit of code, this becomes critically important to comprehension. By supplying a notation specifically for the use of transforming vectors between affine spaces, we can eliminate most or all of this conceptual ambiguity.
For instance, the following was taken from real Slate code that existed in UE4 (see SFxWidget). What is it doing?
const FVector2D CenteringAdjustment = Geometry.AbsoluteScale*ScaleOrigin - RenderScale*ScaleOrigin*Geometry.AbsoluteScale; const FVector2D OffsetAdjustment = Offset*Geometry.AbsoluteScale; const FVector2D NewPos = Geometry.AbsolutePosition + CenteringAdjustment + OffsetAdjustment; const float NewScale = Geometry.AbsoluteScale * RenderScale; FGeometry ChildGeometry(NewPos, Geometry.Size, NewScale);
Even if you have a good understanding of SlateÔÇÖs FGeometry class, you would probably have to work pretty hard to decipher, much less debug this. For simplicity, think of FGeometry as a transform from a Slate widgetÔÇÖs local frame of reference to the desktop frame of reference, TLocal->Desktop, along with an additional SizeLocal that determines the size of the widget. With transform calculus we can then express the code like:
FGeometry ChildGeometry = ScaleOrigin^-1 Ôèò RenderScale Ôèò ScaleOrigin Ôèò Offset Ôèò Geometry;
If you understand transform calculus, thatÔÇÖs much easier. Although perhaps an even better expression of intent would be:
FGeometry ChildGeometry = ScaleAboutPoint(RenderScale, ScaleOrigin) Ôèò Offset Ôèò Geometry
We wrapped the higher notion of ÔÇ£scaling about a pointÔÇØ in another function. Code composition like this is generally discussed in terms of reducing duplication, but it also enhances readability!
And that was relatively simple code. HereÔÇÖs another UE4 Slate example (see STutorialContent):
FPaintGeometry ShadowGeometry( (WidgetGeometry.AbsolutePosition - FVector2D(ShadowBrush->Margin.Left, ShadowBrush->Margin.Top) * ShadowBrush->ImageSize * WidgetGeometry.AbsoluteScale * TutorialConstants::ShadowScale) - WindowPos, ((WidgetGeometry.Size * WidgetGeometry.AbsoluteScale) + (FVector2D(ShadowBrush->Margin.Right * 2.0f, ShadowBrush->Margin.Bottom * 2.0f) * ShadowBrush->ImageSize *WidgetGeometry.AbsoluteScale * ::TutorialConstants::ShadowScale)), WidgetGeometry.AbsoluteScale * TutorialConstants::ShadowScale);
Again, if you are unfamiliar with FGeometry, youÔÇÖll have to take my word for it, but this code is intermingling some basic algebra with transformational logic. It is building a transform to apply a scale about the widget center, and using some simple algebra to determine the scale amount. As written itÔÇÖs really hard to separate them! Also, the innocuous subtraction of the WindowPos in the middle of all that is technically an ADDITIONAL transformation to get from desktop to window space. ThatÔÇÖs an important distinction that easily gets lost in all the raw math! LetÔÇÖs break this code out into the pure algebra needed along with the actual transformations that are taking place:
// This is the algebra auto Center = WidgetGeometry.GetRect().GetCenter(); auto Scale = 1 + ShadowBrush->Margin.GetDesiredSize() * ShadowBrush->ImageSize * TutorialConstants::ShadowScale / WidgetGeometry.Size; // This is the transformation calculus FPaintGeometry = ScaleAboutPoint(Center, Scale) Ôèò WidgetGeometry Ôèò WindowToDesktop^-1;
While thereÔÇÖs still room for improvement (the scale computation is difficult to figure out), the intent (and order of operations) of the code is much more clear. Also, if the transform from window to desktop space becomes more complex in the future (say it adds a scale), we can handle it without really changing the code.
Sidebar: FTransform in UE4
UE4ÔÇÖs FTransform exists to efficiently represent a combination scale ⊕ rotate ⊕ translate by storing each separately. So can FTransform be adapted to work with our transform calculus? Unfortunately, no. Its support for non-uniform scale would impart a shear when inverted or composited, but much of FTransformÔÇÖs efficiency is due to its representation not supporting shear. Luckily, FTransform is not used in situations where this would be a problem, but is unsuitable with transform calculus without significant impact on its efficiency.
WeÔÇÖve discussed how to implement our transform calculus using types found in UE4 and used this to motivate the need for a notation specific to transformations that abstracts the conceptual use from the underlying math, since the math often depends on the transformation representation being used (ie, matrix or quaternion). Finally, we used real-world examples from UE4 to illustrate how this distinct notation can improve code readability by distinguishing the transformation portion from other unrelated math.
Next time IÔÇÖll present the actual Transform Calculus implementation in UE4, how to use it, how it adapts existing UE4 types, and how to implement your own custom transformation types. IÔÇÖll also discuss how the C++ framework supports efficient composition of mixed transform representations when possible. That discussion will be much more focused on C++ implementation details.
Of course, if you just canÔÇÖt wait until then, go check out the implementation (TransformCalculus*.h in Runtime/Core/Math and SlateLayoutTransform.h in Runtime/SlateCore/Rendering). IÔÇÖd love to hear your feedback!
- 1You can always subtract elements to project to the dimensionality you want.
- 2This is really a shorthand for a FVector where all elements are the same. However, since uniform scale is a very common concept in game engines, itÔÇÖs often represented as a single float for efficiency.
- 3This is because a vector has no origin so cannot be translated. Think about the homogeneous math to prove it to yourself.
- 4I am able to lump FVector and float because the implementation of FVector-FVector multiply operator in UE4 is compatible (component-wise multiply). There is no notion of a component-wise vector-vector multiply in mathematics.
- 5Note this is backwards compared to the matrix multiply operator!
- 6row vs column vectors are the primary exception
- 7see FTransformÔÇÖs version of composition, FTransform::Multiply.
- 8This paper gives one way to represent such a decomposition, along with a very detailed proof of correctness. To do it, however, it resorts to a full matrix to support the non-uniform scale and potential shear, defeating most of such a decompositionÔÇÖs efficiency goals. It's essentially using transformation concepts to compute a QR decomposition (or RQ) of the upper-3x3 matrix, while representing Q is a unit quaternion.