Incorrect result from `.decompose()` and `getRotation()`?
donmccurdy opened this issue · 18 comments
Thanks to @randName for adding the new decompose() function, I'm very glad to have a simple way to decompose a matrix like this. :)
With apologies for such a vague bug report... both decompose() added in #402, and the existing getRotation / getTranslation / getScaling methods, are breaking for me in a case where three.js' decompose() method does not. I'm still trying to narrow down exactly which matrix in my model is giving unexpected results when decomposed, and will share that when I find it, but I can share my refactor of the three.js decompose() method (adjusted to work with gl-matrix objects) in case there's any obvious difference in behavior:
function decompose( quaternion: vec4, position: vec3, scale: vec3, te: mat4 ): void {
let sx = length([te[0], te[1], te[2]]);
const sy = length([te[4], te[5], te[6]]);
const sz = length([te[8], te[9], te[10]]);
// if determine is negative, we need to invert one scale
const det = determinant(te);
if ( det < 0 ) sx = - sx;
position[0] = te[ 12 ];
position[1] = te[ 13 ];
position[2] = te[ 14 ];
// scale the rotation part
const _m1 = te.slice();
const invSX = 1 / sx;
const invSY = 1 / sy;
const invSZ = 1 / sz;
_m1[ 0 ] *= invSX;
_m1[ 1 ] *= invSX;
_m1[ 2 ] *= invSX;
_m1[ 4 ] *= invSY;
_m1[ 5 ] *= invSY;
_m1[ 6 ] *= invSY;
_m1[ 8 ] *= invSZ;
_m1[ 9 ] *= invSZ;
_m1[ 10 ] *= invSZ;
getRotation( quaternion, _m1 );
scale[0] = sx;
scale[1] = sy;
scale[2] = sz;
}I also know that a model using rotations extracted from the current implementation gives the error, Rotation quaternion must be normalized. when I run it through the glTF-Validator. The implementation above does not give this error.
It seems like gl-matrix has a quite optimized implementation, which makes it a bit hard for me to directly compare it and spot errors that way. I could try going through the process of in-lining and optimizing it all by hand to try to find the error, but I think it'd be significantly easier with a single test case.
Sure, I'll try to track down one of the matrices in my data that are giving different results here. Thanks!
Here are probably more examples than you want. 😅
decompose-vs-getRotation.txt
decompose-vs-threejs.txt
I created this by scanning all the transform matrices in the model, and printing details about any matrices where different methods gave different results. The first file is much shorter, indicating that .decompose() and .getRotation() are (usually) more consistent with one another than with three.js.
For example:
{
matrix: [
0.04151877760887146, 0.020675182342529297,
-6.616836412121074e-9, 0,
6.616837744388704e-9, 1.55635282439448e-9,
0.04638181999325752, 0,
-0.020675182342529297, 0.04151877760887146,
1.5563523803052703e-9, 0,
0, 0,
0, 1
],
threeDecompose: [
-0.16190098719592683,
0.68832263769788,
0.6883226376978792,
-0.16190109082477525
],
glMatrixDecompose: [
0.03808108730530005,
-0.1619009843293327,
0.16190094042158273,
0.6883226498852002
],
glMatrixGetRotation: [
0.038081076591411425,
-0.1619009805887874,
0.16190094157812507,
0.6883226449681689
]
}
The first entry there is the input matrix, and the next three entries are the quaternions extracted by each of the different methods.
Ok, that does look like an error somewhere. Out of curiosity, where are you getting your matrices from?
If three.js had gotten it wrong, I would have guessed that it's because three.js has issues with non-uniform scales.
This is just a haphazard guess, but it could theoretically also be, because in gl-matrix, in a single matrix, scaling happens before rotation. But I'll have to dig further.
The matrices have had a long journey at this point:
- https://3drt.com/store/environments/virtual-city-pack.html
- conversion to -> COLLADA
- conversion to -> glTF
They're now part of the official samples list for glTF. It's quite possible that the matrices include non-uniform scales, I haven't checked on that. But the model does render correctly in three.js and babylon.js using these matrices. I'm writing a tool that reads and writes glTF files, and if I allow my tool to decompose these matrices using gl-matrix, the result does not render correctly anymore. When using three.js' decompose implementation, I don't have the same issue. I don't mind using a custom decompose() method if needed, but wanted to try to understand what might be happening. Thanks!
Okay, I did some more testing and while I'm still not sure what's causing that difference, it seems like gl-matrix is at least self-consistent with itself.
let q = quat.fromValues(-0.16190098719592683, 0.68832263769788, 0.6883226376978792, -0.16190109082477525);
let qResult = quat.create();
quat.normalize(q, q);
mat4.fromQuat(out, q);
mat4.getRotation(qResult, out);
console.log(`${quat.str(q)} - ${quat.str(qResult)} from matrix ${mat4.str(out)}`);Relevant
matrix: [
0.04151877760887146, 0.020675182342529297,
-6.616836412121074e-9, 0,
6.616837744388704e-9, 1.55635282439448e-9,
0.04638181999325752, 0,
-0.020675182342529297, 0.04151877760887146,
1.5563523803052703e-9, 0,
0, 0,
0, 1
],
This matrix is not a pure rotation matrix, it has some scaling on top. You can tell because the first three values do not add up to 1, not even close. The matrix axes need to add up to 1.0 for a pure rotation matrix.
Three.JS's setFromRotationMatrix does not handle scaled matrices, it assumes a pure uniform matrix. I'm not sure exactly what this is ending up with, I suspect you have some errors in other places that cancel things out, but gl-matrix first scales the input matrix to counter-act scaling, which is why you see different results between the two methods.
Shouldn't I still be able to decompose a matrix to TRS, even if it's not a pure rotation matrix? If methods like three.js setFromRotationMatrix and gl-matrix getRotation require pure rotation matrices, that's one thing, but I would hope decomposition would handle more cases.
I was just looking at setFromRotationMatrix since that's what was linked above. I'll have to take a closer look at what decompose does in full.
Ah, I see. The matrix has a negative determinant (i.e. negative area, meaning one or three of its axes are "mirrored"). gl-matrix's decompose doesn't handle this case correctly, and won't divide out the negative scale correctly.
A quick fix would be flipping the X axis's scale if the determinant says the matrix is mirrored, but choosing which axis to mirror is an arbitrary decision. There are multiple configurations that would lead to the same matrix -- decomposition is inexact.
out_s[0] = Math.hypot(m11, m12, m13) * Math.sign(determinant(mat));
One reason why I don't recommend relying on matrix decomposition in production code, though I understand it can be helpful.
I ended up using adapted versions of three.js' compose() and decompose() methods for this purpose, and using gl-matrix methods everywhere else:
There were other cases in which the gl-matrix equivalents didn't render things as expected, and the three.js versions have (somehow, perhaps it is arbitrary...) "just worked" for everything I've needed so far. Apologies for not having a more detailed answer about why that is.
In any case, I don't think I know enough about it to recommend changing your implementation, so I'll close this and just leave the information here in case anyone has a similar issue. :)
I ran into this issue in my own math library as well. And while I don't think I know enough about this either, I did end up banging my head over this for two days. So that could at least count for something 😅
I ran across this ticket when researching existing implementations, so maybe my findings could help someone:
As far I can tell, negating one axis when the determinant is negative is probably fine™. As long as you make sure that the decomposed rotation makes use of this negated scale as well. It took me a good while before I realised my implementation for extracting the rotation was using it's own way of negating axes.
One thing that didn't sit right with me, though, was that I didn't know which axis to negate. I didn't want to negate the x axis arbitrarily for the simple reason that if the user creates a matrix with scale <-1, -1, -1>, they'll be surprised when decompose() magically turns this into a <-1, 1, 1> scale and a rotation of 180 degrees.
Unity and three.js both negate the x axis, regardless of which axis was "actually" negative.
PlayCanvas and gl-matrix ignore the scale when decomposing. So you can clearly get away with either, but I think there is a better way...
So what I wanted to figure out was: How do I "guess" a scale and rotation which most closely matches what the user intended.
There's several situations, and as mentioned earlier in this thread, each of these can be represented in many different decomposed rotations and scales. After all, rotating an identity matrix 360 degrees results in another identity matrix. So in theory a matrix can be decomposed into infinitely many scale/rotation combinations. But for the purposes of solving this issue, you only have to look at a handful of them:
- A matrix created with scale
<1, 1, 1>. This is as easy as it gets. We can extract the scale without negating anything. - A matrix created with two negative components:
<1, -1, -1>,<-1, 1, -1>, or<-1, -1, 1>. Matrices like these will have a positive determinant. So the existing implementation also already works for these. When decomposing, the negative scale is lost, and instead it results in a rotation of 180 degrees, which is probably what the user wanted anyway. With a matrix like that, most users would probably say that an object was rotated as opposed to scaled two times. - A matrix created with a single negative component:
<-1, 1, 1>,<1, -1, 1>, or<1, 1, -1>. This is a little more tricky. We could just flip the X axis. But it would be an arbitrary decision as mentioned magcius. For all we know, the user scaled by<1, -1, 1>and rotated the Z axis 180 degrees. It would lead to the same matrix as one with<-1, 1, 1>scale and no rotation. - A matrix created with only negative components:
<-1, -1, -1>. Here we run into the same issue. If you scale by<-1, 1, 1>and rotate the X by 180 degrees, you end up with the same matrix.
So the first two situations are easy to solve. When the determinant is positive, just keep using the current implementation. For the other two, I was hoping to figure out a scale that would result in the least amount of rotation. After all, if we have to negate an axis of the scale anyway, the user is more likely to only want a negative scale, as opposed to also having a weird 180 degrees rotation somewhere.
Fortunately, it's pretty easy to achieve this!
Say you have a matrix like this:
it's easy to see that only the X axis should be negative.
Similarly, all axes should be negative with a matrix like this:
So all you have to do is look at which of these three components of the matrix are negative. This even seems to hold up when you apply a slight rotation to the matrix. Only when you start rotating further than 90 degrees will you end up with a different result from what you put in. But that is to be expected.
Unfortunately I don't know enough about math to have a formal proof that this always works. But I do have a bunch of tests which are all passing. And what I also know is that, since we were going to pick an arbitrary axis anyway, in the worst case scenario you end up with a surprising rotation and scale. But even in that case, if you use that rotation and scale to create a new matrix, you should still end up with an identical matrix.
Anyway, maybe this is not the best place for a full on blog post. 😅 But I don't own a blog and I figured this could help someone.
You can find my code related to this here:
rendajs/Renda@38d0ec9
I’m way late to the party, but you can’t decompose a matrix into TRS in general. For starters, an affine transform has 12 degrees of freedom whereas TRS has 9. But the deeper problem is that non-uniform scaling and rotation combine to make skew.
If you start with an axis-aligned cube and apply a non-uniform scale, all the angles are still right angles (and this remains true after you rotate and translate). But if you rotate the cube to have a corner up and then scale it vertically, you wind up with non-right-angles.
I’m confused why GLTF requires a matrix be decomposable to TRS, given that the composed matrix won’t be.
A TRS matrix does scale, then rotation. So with a single node's matrix, you're never going to arrive at a skewed result. It's true that the combined composition of two TRS matrices could result in skew; but that's much less important for a decomposition. However, there are two somewhat common ad-hoc solutions for this:
- Never allow non-homogeneous scaling. This is quite common, and it also removes the need to add a separate normal matrix. The lack of non-homogeneous scaling in most data is why this isn't a real problem in practice.
- Compose the scale and TR matrices separately, doing something more like
TR*TR*TR*S*S*Sfor a node chain. This is the solution used by Maya, for instance (though it also has a fancier mode, e.g. Segment Scale Compensation). It's unclear if this is something that glTF can allow for, though.
Interesting! Yes, you’re never going to arrive at a skew with a single matrix, but given that transformations are hierarchical, it’s unavoidable in a GLTF scene in general. It seems pretty crazy that you can’t unparent a node without also distorting it (because keeping the same world transform results in a matrix that may not be TRS-decomposable).
A slightly weaker alternative to (1) is that only leaf nodes have non-uniform scaling. (And it sounds like Segment Scale Compensation might be that - don’t transform children with the non-uniform part of the scale). If so, that’s doable in glTF only by replacing each parent node with two nodes: one uniformly scaled node for non-mesh children and one non-uniform for mesh children.
As for the mirroring, the purist in me would have the same scale on all axes; if there’s a reflection, decompose that as all axes negative instead of just one. But I guess one axis might be simpler to visualize, since that’s what a literal mirror does - reflects about the forward axis!

