Skip to content

SCNMatrix4 Breaking Change Breaks SCNNode.Transform #15094

Description

@praeclarum

Short Version

TLDR; The recent change (#13695) in .NET 6 to make SCNMatrix4 column-major breaks the Transform property of SCNNode. The change correctly identified that SCNMatrix4 was bound incorrectly, but the solution to the problem is currently incomplete and makes the class behave in inconsistent ways.

Long Version

Here is a video of me walking through discovering and explaining the problem: https://www.twitch.tv/videos/1491014116?t=00h15m47s

Linear Algebra Primer

In linear algebra, the order or multiplication is important.

A*B != B*A

If one wishes to use a matrix for transforming points, one must choose if points (vectors) will be multiplied on the left hand side of the matrix or on the right. If the vector is on the left, it must, by dimensional analysis, be a row-vector (shaped 1x4). If it is on the right, it must be a column vector (4x1).

M*vc ~ [4x4]*[4x1] (for col vector)
vr*M ~ [1x4]*[4x4] (for row vector)

This has repercussions in how the matrices are composed. If vectors are multiplied on the left (row vectors)�, then matrices should be composed so the left most operation happens first. If however, vectors are multiplied on the right (column vectors), then matrices should be composed so the first operation is on the right and the last is on the left.

The Column-major vs Row-major Debate

Regardless of what's in a matrix and how it was composed and intended to be used, it can be stored in either row or column major format. This decision is orthogonal to whether left or right vector multiplication will be used for actual transforms.

A History Lesson

There is a strange history in .NET to favor row-vectors over column vectors. I say strange because all of science, engineering, and pretty much every 3D engine in the universe uses column vectors.

SceneKit also uses column vectors. Please note that I am saying column vectors and not column-major element ordering. It does also use column-major ordering, but let's table that for the moment.

When SceneKit was bound 8 years ago, SCNMatrix4 should have been implemented as a Column-vector Column-major matrix. Sadly, it was implemented as a Row-vector Row-major matrix.

A Modern Tragedy

This mistake was caught early on, but it wasn't understood that the mistake was two-fold.

It was noticed that if you add a Transpose to the matrix before it is passed to SceneKit (SCNNode.Transform for example) then the matrix acted the same way as Apple's matrices (that are column-vector, column-major).

Sadly, the conclusion was that the mistake was purely due to the column-major, row-major mixup.

It wasn't recognized that the matrices were actually constructed incorrectly. Because the matrices have a Row-vector interface for construction (for example, SCNMatrix4.CreateTranslation, etc.) the order in which they were composed was backwards (compared to the Apple examples).

For .NET 6, the matrices were correctly switched to be Column-major element ordering. #13695

However the APIs were not fixed to make the matrices Column-vector transformers (they remain row-vector transformers). This puts them in a terribly awkward state where they act like row-vector transformers when used by themselves and with vectors. But when used with SceneKit that is expecting Column-vector, Column-major element ordering, they don't work at all.

To demonstrate this inconsistency I have written some unit tests. Allow me to explain them next.

Unit Tests to Demonstrate Inconsistencies

Demonstrate that SCNMatrix4 is Row-vector, Column-major

I wish to first demonstrate that the matrix class itself isn't broken, but is indeed constructed in a Row-vector manner. Let's first demonstrate that simple translation works fine:

void TranslatePoint ()
{
    // Create test point
    var point = new SCNVector3 (1, 2, 3);
    // Create translation
    var matrix = SCNMatrix4.CreateTranslation (10, 0, 0);
    // Transform the point
    var newPoint = SCNVector3.TransformPosition (point, matrix);
    AssertEqual (new SCNVector3 (11, 2, 3), newPoint);
}

That works. To demonstrate that the matrix assumes left-hand vector multiplication (which means the vectors have to be row vectors), let's compose two transformations, one after the other. We will do a translation followed by a scaling operation. This is a a good test because the order of these operations matters a lot and is very evident.

void TranslateThenScalePoint ()
{
    var point = new SCNVector3 (1, 2, 3);
    var matrix =
        SCNMatrix4.CreateTranslation (-1, 0, 0) *
        SCNMatrix4.Scale (10, 1, 1);
    var newPoint = SCNVector3.TransformPosition (point, matrix);
    AssertEqual (new SCNVector3 (0, 2, 3), newPoint);
}

Note that the translation operation had to come before (on the left) the scale operation. This means it runs first, then the scale. This is proven out in the test because the X coordinate of the point is first translated then scaled:

  1. X = (1 + T)*S
  2. X = (1 + (-1))*10
  3. X = 0*10
  4. X = 0

If you instead put the scale on the left of the translation, then you note that the result is quite different.

  1. X = 1*S + T
  2. X = 1*10 + (-1)
  3. X = 10 - 1
  4. X = 9

We can see from this code and its results that the matrices are indeed constructed assuming row vectors. This is independent of the memory order of elements.

The memory order of the matrices doesn't really make a difference here because the algorithms correctly address the fields based on whether they're rows or columns.

The problem is: this hack doesn't work with SCNNode, because it's incomplete.

Demonstrate that the matrix does not work with SCNNode

The .NET 6 matrix hack does not fix how matrices are used with SCNNodes. This is because, although effort was put into them being Column-major, no effort was put into making them Column-vector constructed. This means the matrices are still the transpose of what they should be. One problem was fixed, while the other, much bigger problem, was ignored.

To demonstrate this confusing state of affairs. Let us now attempt to use one of the .NET 6 matrices with an SCNNode. We will do the simplest thing possible, translate the node using a translation matrix.

void TranslateNode ()
{
    // Create a test node (it defaults to position 0,0,0)
    var node = SCNNode.Create ();
    // Create a translation matrix
    // (we know from before this will be built as a row-vector transformer)
    var matrix = SCNMatrix4.CreateTranslation (1, 2, 3);
    // Use that matrix to transform the node
    node.Transform = matrix;
    // Ask the node to extract just the translation part of the matrix
    var newPoint = node.Position;
    // Verify that it is now positioned at (1,2,3)
    AssertEqual (new SCNVector3 (1, 2, 3), newPoint);
}

This code fails even though we just showed that translation matrices work.

The problem is this: the matrix is constructed as a row-vector transformer. It is encoded column-major. Because it's column-major the Microsoft binding passes it directly to SceneKit. BUT SceneKit wants a column-vector transformer. The result is, the transform is not applied correctly (the translation is actually stored in the homogenous skew coordinates, not a good place).

This means that you still have to do a Transpose operation when using these matrices on SCNNodes BUT you must use the normal (not transposed version) when transforming points.

This is terribly inconsistent and makes writing 3D math impossible.

Proposed Solutions

The Easy Solution

Just go back to row-vector, row-major matrices. I know they're wrong. But they're wrong in an internally consistent way. They work the same way with point transforms as they do with SCNNodes and friends.

Then there would be no broken breaking change and we can all get on with our lives.

The Correct Solution

SCNMatrix4 should be designed and implemented as a Column-vector, Column-major matrix (or a new struct should be introduced). It would match perfectly with SceneKit, OpenGL, mathematics, engineering, and basically everything else in the universe. It would be both internally consistent and work with SceneKit without any transpositions.

I wish this was the breaking change introduced in .NET 6. Instead, the change we got is only a partial solution.

Workarounds

If you are coming into this bug, completely confused by .NET 6's matrices, let me give you this advice about how to work around its new design:

  1. Compose all transformations assuming row vectors (left-most operations happen before right-most).
  2. If you wish to transform points, use those matrices directly.
  3. If you wish to use those transforms with SceneKit, then you have to transpose them before setting them as properties.
  4. If you wish to read transforms from SceneKit, you will have to transpose them before combining them with other matrices or reading data from them.

Conclusion

Thank you for coming to my TED talk. I know that everything I wrote can sound pedantic, but I hope I demonstrated through the tests and examples that the new (.NET 6) SCNMatrix4 operates in a very inconsistent manner. This is due to not fully understanding why the original binding in SceneKit was incorrect and only doing a partial fix (fixed the element ordering but neglected to fix the construction and transformation methods).

Attached Tests and Repro

SCNMatricNET6Boug.zip

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugIf an issue is a bug or a pull request a bug fix

Type

No type

Projects

No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions