heygrady / transform

jQuery 2d transformation plugin
437 stars 87 forks source link

affine transforms for animations #18

Open jaukia opened 14 years ago

jaukia commented 14 years ago

Hi,

Your 2d transform library is impressive! I'm the author of the Zoomooz library and I've been thinking about replacing animation and transform code of my own with those in you library. My test branch for 2d transform integration is here: https://github.com/jaukia/zoomooz/tree/transformlib

The problem is that it would seem that the more complex transformations do not would with the 2d transform library as well as they work with my previous custom code. I believe this is because in my code the animation is done by interpolating the affine transform elements corresponding to the transformations (instead of interpolating the transformations directly).

Are you aware of the issue and do you think my hypothesis could be correct? Do you have any plans related to this? Feel free to use parts of my code, it is licenced with the standard MIT + GPL2 jquery licences!

heygrady commented 14 years ago

"The problem is that it would seem that the more complex transformations do not [work] with the 2d transform library as well as they work with my previous custom code."

My code is really straight-forward. I took the matrices as defined in Wikipedia and in the SVG Spec. Then I used the full calculations for multiplication instead of using loops. That same optimization was in the WebKit core as well so that seemed like a good choice.

Interestingly, the only browser that ever uses this is IE since the other browsers support this directly in CSS. For IE we need to multiply out all of the composite matrices to get the final matrix and apply that with a filter and work with it to fake the translate and origin features of CSS3 that the IE matrix filter doesn't support.

Why do you propose that we'd need to decompose a matrix? What parts of my code don't work for your use-case? I'm a little lost on the concept of "interpolating the element" as opposed to "interpolating the transformation." In WebKit and Mozilla and Opera the "interpolation" is done by the browser itself. In IE, the matrix is also applied with a filter that does the actual interpolation.

heygrady commented 14 years ago

It should be noted that for my project I'm not at all interested in a full featured matrix calculation library. It only needs to multiply two matrices together. For some rarer use-cases I also needed to be able to calculate an inverse matrix but I've never actually needed decomposition.

jaukia commented 14 years ago

Hi, sorry for not being clear enough in the first message! The problem occurs with animating matrix-transformations. Simply animating each field of a transformation matrix separately does not work correctly, see here for example how 180 degree rotation fails:

http://janne.aukia.com/htmltests/transform-test/

heygrady commented 14 years ago

Cool test. You're specifically talking about if I animate a matrix as opposed to the other functions. Based on your example it looks like you're correct that doing a decomposition is the correct approach for the animation. I'll add this to my list of fixes to make.

Am I correct in assuming that I'd only ever need to decompose in the case that I'm animating a matrix?

If I had something like this: $("#i1").animate({matrix: [-1,0,0,-1], rotate: '45deg'});

The proper thing to do is:

  1. multiply the matrix by the rotate matrix
  2. decompose that matrix
  3. animate the new properties
jaukia commented 14 years ago

Yes, exactly, that should be the proper way.

The decompose is needed at least when animating a matrix, of the other properties I am not fully certain. For example, if the current transformation of an element has a different set of css transformations (or they are in different order), it might make sense to decompose the original transformation as well as the target transformation and then animate these (normalized) new properties. Sorry for the crappy explanation :).

If you like, you can always do the decomposition:

  1. multiply all of the transformations into a matrix
  2. decompose the matrix
  3. animate the decomposed properties

Then, the animations would always work correctly.

heygrady commented 14 years ago

The decomposition will always take time to complete and if it isn't usually necessary then there's no reason to do it except in rare cases.

For instance: // There's no need to decmopose this transform, it's already decomposed $('#example').css({rotate: '30deg'}); $('#example').animate({rotate: '+=30deg'});

There is no reason to decompose the above because it's actually already decomposed. It already animates correctly in Internet Explorer and CSS3 supported browsers. Each step of the animation is animating the rotate itself.

There is a small inefficiency in that if you animate multiple transform properties at once they are animated independently. This is largely due to the nature of how jQuery animate works. Decomposition wouldn't help this. The real solution would be to set the properties all at once.

For instance: // Transform properties are applied independently $('#example').animate({rotate: '+=30deg', scale: 0.75});

// This doesn't work yet as of 0.9.0 and still wouldn't require decomposition but would potentially be more efficient
$('#example').animate({transform: {rotate: '+=30deg', scale: 0.75}});

But, from what you've shown me, if a matrix value is set then the transform needs to be decomposed in order to animate correctly. Even then, though, only the matrix itself needs to be decomposed. In the case of animation I'm wary of trying to do too many calculations on each animation step. But it's probably unavoidable when animating a matrix.

// Reflect applies matrix(-1, 0, 0, -1, 0, 0), should decompose the matrix to make it look correct when it animates
$('#example').animate({reflect: true});

// Animating seperately is an advantage here.
// Essentially the reflect matrix would be decomposed and then animated
// At each step in the animation it would be recomposed to a matrix for simplicity
$('#example').animate({reflect: true, scale: 0.75});
jaukia commented 14 years ago

It is enough to do the calculation (affine transform) for the matrix only when starting the animation. From there on, you can just animate between the initial affine transform values and the final values.

For example, if you have:

 matrix(0.000000, 0.707107, -1.414213, 0.707107, 0.000000, 0.000000)

This can be normalized with:

    function affineTransform(m) {
        var a=m[0],b=m[1],c=m[2],d=m[3],e=m[4],f=m[5];

        if(Math.abs(a*d-b*c)<0.01) {
            console.log("fail!");
            return;
        }
        
        var tx = e, ty = f;
        
        var sx = Math.sqrt(a*a+b*b);
        a = a/sx;
        b = b/sx;
        
        var k = a*c+b*d;
        c -= a*k;
        d -= b*k;
        
        var sy = Math.sqrt(c*c+d*d);
        c = c/sy;
        d = d/sy;
        k = k/sy;
        
        if((a*d-b*c)<0.0) {
            a = -a;
            b = -b;
            c = -c;
            d = -d;
            sx = -sx;
            sy = -sy;
        }
    
        var r = Math.atan2(b,a);
        return {"tx":tx, "ty":ty, "r":r, "k":Math.atan(k), "sx":sx, "sy":sy};
    }

You may use it like this:

m = [0.000000, 0.707107, -1.414213, 0.707107, 0.000000, 0.000000];
var aff = affineTransform(m);

As output, in var aff, you get:

{ k: 0.46364789184359106,
  r: 1.5707963267948966,
  sx: 0.707107,
  sy: 1.414213,
  tx: 0,
  ty: 0}

These params correspond directly to css transform statements and can be converted back with:

    function matrixCompose(ia) {
        var ret = "translate("+roundNumber(ia.tx,6)+"px,"+roundNumber(ia.ty,6)+"px) ";
        ret += "rotate("+roundNumber(ia.r,6)+"rad) skewX("+roundNumber(ia.k,6)+"rad) ";
        ret += "scale("+roundNumber(ia.sx,6)+","+roundNumber(ia.sy,6)+")";
        return ret;
    }
    
    function roundNumber(number, precision) {
        precision = Math.abs(parseInt(precision,10)) || 0;
        var coefficient = Math.pow(10, precision);
        return Math.round(number*coefficient)/coefficient;
    }

Example with aff:

matrixCompose(aff);

The output of this is:

"translate(0px,0px) rotate(1.570796rad) skewX(0.463648rad) scale(0.707107,1.414213)"

So basically, this is a way to transform any simple or complex set of transforms into a combination of translate, rotate, skewX and scale. And when you do this conversion to both the animation start and end states, then you can be quite sure that the animation is reasonable and there are no weirdnesses (at least when the corresponding angles are less than PI apart from each other).

Disclaimer: This is as far as I understand this, there might be errors :).

heygrady commented 14 years ago

I committed 0.9.1pre with your suggestions. Please test it out. Notice that I had to make a small change to your decomposition script when you were negating everything in certain circumstances, the sy variable should not ever be negated (based on testing the reflect matrices).

heygrady commented 14 years ago

BTW, I'm curious why you're using a custom round function. Couldn't you do this instead:

function roundNumber(number, precision) {
    return parseFloat(number.toFixed(precision));
}
jaukia commented 14 years ago

Cool, I'll have to try out the 0.9.1pre version!

Good question about the rounding function! I honestly have no idea why I didn't use toFixed. I probably hadn't noticed it around :). I don't know which one is faster, though. With toFixed there is an extra float-string-float conversion.

jaukia commented 14 years ago

At least on my Safari the custom round function seems to be faster:

function roundNumber1(number, precision) {
    return parseFloat(number.toFixed(precision));
}

function roundNumber2(number, precision) {
    precision = Math.abs(parseInt(precision,10)) || 0;
    var coefficient = Math.pow(10, precision);
    return Math.round(number*coefficient)/coefficient;
}

function testRoundingFunction(func) {
    var st=(new Date()).getTime();
    for(var i=0;i<1000000;i++) {
        func(i/3,6);
    };
    return (new Date()).getTime()-st;
}

console.log("round with toFixed", testRoundingFunction(roundNumber1));
console.log("round with Math.pow", testRoundingFunction(roundNumber2));

Output in Safari:

round with toFixed 1367
round with Math.pow 163

On latest WebKit:

round with toFixed 912
round with Math.pow 137

On latest Firefox nightly:

round with toFixed 844
round with Math.pow 498

However, on Firefox 3.6 they are almost the same:

round with toFixed 2524
round with Math.pow 2445

Interesting :)

jaukia commented 13 years ago

Hi, I tested now out the 0.9.1 version. There seem to be still issues with the transformations, see these examples (which work in Safari/Webkit):

Skew is performed in wrong direction, or something: http://janne.aukia.com/htmltests/transform-test/skew.html

Translate in matrix not working: http://janne.aukia.com/htmltests/transform-test/translate.html

heygrady commented 13 years ago

Just committed 0.9.2. There was a pretty serious typo. Should be better now. Thanks for your continued testing!!

jaukia commented 13 years ago

Great that you have had the energy to spend your time on this! I'm still having some issues with the matrix transformations. They might be related to these:

Your library would not seem to care about the initial css transformation of the element. This might be intentional and not a bug: http://janne.aukia.com/htmltests/transform-test/respectstate.html

The transforms for an element are apparently applied on top of each other, see this example: http://janne.aukia.com/htmltests/transform-test/previoustransform.html (is there a way to rewrite the transformation for an element, so that it would work in the same way as the css version?)

heygrady commented 13 years ago

You're really putting me through the paces :) Thanks for these examples, it really helps give me something concrete to work with.

The first one is intentional because until you showed me about decomposing the matrix I was a little stumped about how to handle that. Most browsers return matrix as the only function no matter what you had set previously which is pretty worthless to the average user. I was planning on adding that in soon.

On the second one, that's also intentional. I'm not entirely sure how to deal with this one. I had a few bugs a while ago about this same issue and decided the way I designed it was preferable.

Option 1: You should keep track of what's set before you animate and animate it out.

// Same as your example
$('#example').css({rotate: '45deg'}); // using CSS respects previously set properties
$('#example').animate({translateX: '300px'}); // so does animate

// Achieves the desired effect (and actually what the browser is doing)
$('#example').transform({rotate: '45deg'});  // transform resets the transform
$('#example').animate({translateX: '300px', rotate: 0}); // just remove the rotate

Option 2: I can keep track of that in my library and handle it automatically. My big question is if that's the right behavior. In your CSS-only example, you're literally switching transform properties which is nice. But the way I've exposed the different transform properties they're meant to work like height and width: independently. I think that's what most users are expecting to do. You're clearly a more advanced user. This is why I added an option to preserve the previous transformations. It is set to true for css and animate.

$('#example').transform({rotate: '35deg'}); // overwrite
$('#example').transform({skewX: '15deg'}); // overwrites rotate
$('#example').transform({rotate: '35deg'}, {preserve: true}); // keeps skewX and tacks rotate to the end
$('#example').css({skewY: '15deg'});  // always preserves previously set transforms
$('#example').animate({scale: 1.5});  // this does too
$('#example').css({skewY: '-15deg'});  // but this will overwrite skewY

Solution: What I've had planned is adding "transform" as an animation-ready property. I'll work on this next.

$('#example').css({transform: {rotate: '45deg'}}); // this would overwrite
$('#example').animate({transform: {translateX: '300px'}}); // this is what you're looking for
jaukia commented 13 years ago

Ok, thanks for the info, these kinds of issues are a bit difficult to implement in a smart way, I'm sure. I'll have to see, how these fit in the stuff i'm doing!