Skip to content

Enable transform chaining by making scaled/rotated/translated consistent #1336

Closed
godotengine/godot
#55923
@bluenote10

Description

@bluenote10

Describe the project you are working on:

A guitar practicing application (here is an old version but in VR).

I suspect the reasons why I'm facing this issue more than others are:

  • Almost all geometry/meshes are procedurally generated.
  • Because of usage of MultiMeshInstance I can't rely less on node hierarchies, but have to compute complex transformation chains manually.
  • I like fluent interfaces ;)

However the issue I'm facing is a fairly fundamental one, and can affect almost any project.

Describe the problem or limitation you are having in your project:

Basically every time I try to chain .scaled/.rotated/.translated I end up with a bug. I had raised this issue before godotengine/godot#34329, which has been closed by adding a line to the documentation. This line didn't help much, I still run into the same problem a lot. I finally had the time to analyze why that is, and since 4.0 is the chance to get things right, here is my proposal.

Note: I'm discussing the topic based on Transform, but both the problem and solution would also apply for Transform2D as far as I can see.

Also feel free to read the much shorter proposal (down below) first, and only dive into the lengthy reasoning behind it (above) if need be ;).

Terminology

Chaining transformation always requires to be aware of left-to-right or right-to-left thinking, because the "mathematical reading order" is typically opposite to the "transformation order". For instance

x' = C · B · A · x

first transforms x by A, then by B, then by C, opposite to how the mathematical equation is typically written. Put another way:

  • Transform left multiplication

    M' = A · M
    

    means that A is applied after M is applied.

  • Transform right multiplication

    M' = M · A
    

    means that A is applied before M is applied.

Current behavior

Currently the behavior is a mix of left and right multiplication.

  • Scaled:

    var M_new = M.scaled(... S ...)
    

    has left multiplication semantics (i.e., happens after in transformation order):

    M_new = S · M
    
  • Rotated:

    var M_new = M.rotated(... R ...)
    

    has left multiplication semantics (i.e., happens after in transformation order):

    M_new = R · M
    
  • Translated:

    var M_new = M.translated(... T ...)
    

    has right multiplication semantics (i.e., happens before in transformation order):

    M_new = M · T
    

Issue 1: Hard to read

Because of mixing left and right multiplication, I find it fairly hard to look at chained expressions and come up with the underlying mathematical order. Going from the code to the mathematical expression cannot be done by just reading in one direction, but rather requires to switch between left-to-right and right-to-left thinking. For instance:

var M = Transform.IDENTITY\
    .scaled(... S ...)\
    .translated(... T ...)\
    .rotated(... R ...)

is equivalent to (if I didn't get it wrong again)

M = R · S · T

Note how R has moved from last to first, S has moved to the middle, and T ended up at the end. The result feels almost like a random shuffle of the order written in the code. Doing such transformations on longer expressions is a challenging (and unnecessary) mental exercise.

Issue 2: Hard to write

The problem is even more tricky the other way around, when trying to convert a mathematical expression into code.

Example 1: Imagine your goal is to write the following purely with chaining:

M = S · T · R

As far as I can see this actually cannot be written purely with chaining, because having the .translated in the middle breaks the right-to-left flow, and there is no way get the R in the right position.

The only way to write it is in a non-chained way, for instance:

var M = Transform.IDENTITY\
    .scaled(... S ...)
    .translated(... T ...)
M *= Transform.IDENTITY.rotated(... R ...)

Example 2: Imagine implementing longer transform chains like:

M = R_2 · T_2 · R_1 · S_2 · T_1 · S_1

Trying to work out the code becomes more and more awkward, because it is necessary to split the expression into subgroups at each T_*, which break the right-to-left flow. The individual groups can be assembled right-to-left, but need to be assembled
in an outer multiplication left-to-right. An alternative is to manually implement translation left multiplication with the trick to use temporary.offset += T_*. In any case the resulting code is much less clear than a full chaining expression (if .translated would do left-multiplication as well):

var M = Transform.IDENTITY\
    .scaled(... S_1 ...)\
    .translated(... T_1 ...)\
    .scaled(... S_2 ...)\
    .rotated(... R_1 ...)\
    .translated(... T_2 ...)\
    .rotated(... R_2 ...)

Issue 3: Performance aspects

In general writing transforms as chains is faster than using full transform1 * transform2 expressions, because the implementation can exploit the particular matrix properties of .scaled/.rotated/.translated. However, because of the error prone chaining semantics, I have basically replaced many transform chains by transform product expressions, which has performance drawbacks.

I had to refresh my memory about the differences, in case you are interested in the details:

All possible transform operations

Translation

Left multiply

Translation_lm

Right multiply

Translation_rm

Scale

Left multiply

Scale_lm

Right multiply

Scale_rm

Rotation

Left multiply

Rotation_lm

Right multiply

Rotation_rm

Generic transform

Left multiply (rhs only)

Transform_lm

Right multiply (rhs only)

Transform_rm

Counting the number of floating point operations gives:

Operation # Floating point operations available
Translation (left multiply) 3
Translation (right multiply) 18 *
Scale (left multiply) 12 *
Scale (right multiply) 9
Rotation (left multiply) 60 *
Rotation (right multiply) 45
Full transform multiplication 60 *

It is interesting to see how much more costly a full transform1 * transform2 (60 ops) is compared to a simple translation left multiply (3 ops). The other aspect I vaguely remembered: For scale/rotate the faster operation is right multiplication, whereas for translation it is left multiplication. If performance is critical, it can be helpful to build a transform exactly in the way that minimizes floating point operations. Unfortunately, the interface in Godot is not only inconsistent, but also offers only the less efficient variants.

Describe the feature / enhancement and how it helps to overcome the problem or limitation:

The solution I'm proposing is to make the interface consistent and offer all possible operations:

  • .scaled performs left multiplication
  • .rotated performs left multiplication
  • .translated performs left multiplication
  • .pre_scaled performs right multiplication
  • .pre_rotated performs right multiplication
  • .pre_translated performs right multiplication

This is also the solution chosen by Eigen (possibly the most famous library in that area) just the other way around because of using participles instead of infinitives, and to be less of a breaking change. What "pre" means is a matter of convention anyway, and up to the documentation to communicate.

Thus, in terms of documentation it is key to clearly describe what these functions do mathematically. Currently the docs do not even clearly say whether they are performing left or right multiplication, I only figured it out after reading the C++ sources. I could contribute some of the stuff above to the docs.

In terms of breaking changes this would add one item to the Godot 4.0 migration guide: Replace translated by pre_translated.

Describe how your proposal will work, with code, pseudocode, mockups, and/or diagrams:

Should be covered by the section above.

If this enhancement will not be used often, can it be worked around with a few lines of script?:

It affects Transform / Transform2D so the enhancement is probably used often.

Is there a reason why this should be core and not an add-on in the asset library?:

Mainly to avoid headaches for others.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    Status

    Implemented

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions