boxart / boxart-boiler

A boilerplate for Responsive DOM based Open Web Games.
MIT License
13 stars 4 forks source link

Spike - Write efficient understandable animations #97

Open labond opened 8 years ago

labond commented 8 years ago

Write animations that are readable for non-programmers, and also efficient.

Spike then break into subtasks

mzgoddard commented 8 years ago

Have been discussing this with some developers to figure out to take this first working example and formulate a way to write the same but be understandable to a large group. This first example you have to think about the math to realize the end result for each step and figure out what step even means with the 0, 900, 1000 values at the beginning of the rows. Its a pretty dense format and repeats information in the math. If you wanted to change one of the destination values you have to remember to change the math in the row before and the starting value of the concerning destination.

{
  transform: [
    0, t => `scale(${t * 0.1 + 1}, ${t * -0.1 + 1})`,
    900, t => `scale(${t * -0.1 + 1.1}, ${t * 0.1 + 0.9})`,
    1000, t => `translate(0, ${launchT(t) * -50}%) scale(1, ${sinHalfT(launchT(t)) * 0.1 + 1})`,
    1500, t => `translate(0, ${(t * t) * 50 + -50}%) scale(1, ${sinHalfT(t * t) * 0.1 + 1})`,
    2000, t => `scale(${sinQuarterT(t) * 0.1 + 1}, ${sinQuarterT(t) * -0.1 + 1})`,
    2100, t => `scale(${t * -0.1 + 1.1}, ${t * 0.1 + 0.9})`,
  ],
  transformOrigin: [
    0, '50% 100% 0',
  ],
}

A change I thought of was to try and reduce out the math and have some code look over the given data and translate it to the needed string concatenation and math. The result is somewhere between a string template and math as there will remain a need to state easing/timing functions from 0 to 1 for individual components. The implementation needs to guess a lot of things in this version. What do the arrays mean, are they timelines or concatenations? Which are values and which are easing functions?

{
  transform: [
    0, 'scale(1, 1)',
    900, 'scale(1.1, 0.9)',
    1000, ['translate(0, 0%', launchHalf, ') scale(1, 1', stretchHalf, ')'],
    1250, ['translate(0, -50%', launchHalf2, ') scale(1, 1.1', stretchHalf2, ')'],
    1500, ['translate(0, 0%', invLaunchHalf, ') scale(1, 1', invStretchHalf, ')'],
    1750, ['translate(0, -50%', invLaunchHalf2, ') scale(1, 1.1', invStretchHalf2, ')'],
    2000, ['scale(1, 1)', spread],
    2100, 'scale(1.1, 0.9)',
    3000, 'scale(1, 1)',
  ],
  transformOrigin: [
    0, '50% 100% 0',
  ],
}

@tkellen helped a lot in figuring out how we could annotate this data so its on its own contains info for the reader of what the array means, as the last version had two distinct uses for arrays. Namely in this last example a timeline and concatenation of values. This solutions reduces the need of the implementation to make guesses at what the arrays or functions are. The value calls wrap numeric values that will be interpolated from this frame to the next one. The ease calls wrap two functions, passing the result of the second into the first.

v = value;
e = ease;
// Curried version of func('scale', [scalar0, scalar1])
scale = func('scale');
translate = func('translate');

stretchScale = timeline([
  frame(time(0), e(scale([v(1), v(1)]), stretchHalf)),
  frame(time(250), e(scale([v(1), v(1.1)]), stretchHalf2)),
  frame(time(500), scale([v(1), v(1)])),
]);

{
  transform: timeline([
    frame(time(0), scale([v(1), v(1)])),
    frame(time(900), scale([v(1.1), v(0.9)])),
    frame(time(1000), concat([
      translate([v(0), e(v(0, '%'), launch)]),
      stretchScale,
    ])),
    frame(time(1500), concat([
      translate([v(0), e(v(-50, '%'), inverse(launch))]),
      stretchScale,
    ])),
    frame(time(2000), e(scale([v(1), v(1)]), spread),
    frame(time(2100), scale([v(1.1), v(0.9)]),
    frame(time(3000), scale([v(1), v(1)]),
  ]),
  transformOrigin: static('50% 100% 0'),
}

This summary of what has been thought of so far is a bit terse but should be enough for some further commentary from others.

kadamwhite commented 8 years ago

After thinking this over for a bit I like this direction a lot. It's similar to how @arvind builds up Vega expressions in Lyra's primitive code:

test(at() + '||' + at('bottom'))

expands out to

'if((lyra_anchor&&lyra_anchor.target&&lyra_anchor.target.datum &&lyra_anchor.target.mark && lyra_anchor.target.mark.name === "rect_1")||(lyra_anchor&&lyra_anchor.target&&lyra_anchor.target.datum &&lyra_anchor.target.datum.mode === "handles" &&lyra_anchor.target.datum.lyra_id === 52&&test(regexp("bottom", "i"), lyra_anchor.target.datum.key)),undefined,undefined)'

This encapsulation of more complex syntax in easily-comprehensible functions worked well there and I think the potential benefits here are even greater given that the Lyra API I reference is internal, and this would be a public-facing animation construction API.

So, a strong conceptual +1 to this direction; there's still more that could be done to make it obvious what the code is doing in the final example (you need to do math in your head to know in what direction something is moving), but at least the type of motion and the timeline on which it happens are much more clear.

kadamwhite commented 8 years ago

(Line comments would help clarify the motion in that example, as would being able to view it on its own #101 ) We are definitely constrained w/r/t "Write animations that are readable for non-programmers, and also efficient." by the fact that CSS transformation itself is not readable for non-programmers, so there's only so much you can do.

mzgoddard commented 8 years ago

Thanks to @gnarf, @iros, and @pbeshai sharing there thoughts with me on Friday about this example animation script using an animated grammar. I want to record a summary of that discussion here.

const gravity = options.agent.rect.width / 4;
const {rect, lastRect} = options;
const horizontalDuration = Math.abs(rect.left - lastRect.left) / rect.width / 4;
const verticalDuration = Math.sqrt((rect.top - lastRect.top) / gravity);
const duration = Math.max(horizontalDuration, verticalDuration);

const lerp = clip([
  metadata({
    duration,
  }),
  rect({
    left: keyframes([
      frame(0, px(lastRect.left)),
      frame(horizontalDuration, px(rect.left)),
      frame(duration, px(rect.left)),
    ]),
    top: keyframes([
      frame(0, ease(px(lastRect.top), t => t * t)),
      frame(verticalDuration, px(rect.top)),
      frame(duration, px(rect.top)),
    ]),
    width: lastRect.width,
    height: lastRect.height,
    angle: 0,
  }),
  styles({
    zIndex: value(1),
  }),
])();

const elementState = {};
const timer = options.timer();
lerp.loop(timer, options.animatedEl, elementState);
return timer;

@pbeshai and @gnarf brought up points on keyframes and their third frame. What are those time values? The confusing part about them in this example is they are relative in their distinct keyframes calls. 0, horizontalDuration, and duration in the first call all get divided by the last time value to be used as values between 0 and 1. This example sets it up so that both keyframes equate to the same time, but you can nest the keyframes or have some that end at different times.

The argument goes that treating them as absolute in the full clip will help make them less confusing as what their current design does. It'll also remove the need to specify the third frame in this example as their value is the same as the frame before the full duration.

I really like that idea so I'm going to try and build it into the grammar. I do have two thoughts that will make implementing tricky.

I don't want to require all uses of the grammar to be wrapped in a clip call. That clip call would provide the normal path to supply the full duration so that the outer most keyframes can determine their max absolute duration. The current design with its potentially confusing keyframes is self-sufficient. This probably comes up to documenting that keyframes is relative to its parents. Being absolute also helps make nested keyframes use less confusing. Using nested keyframes with a relative api would mean a nested keyframes after a first frame would start at 0. With absolute keyframes the start is determined by the parent and the end by the next frame. In literal use this will read better.

The second part for a tricky absolute implementation is reuse. A relative keyframes implementation makes reuse easy if confusing. You can write a part of animation once and reuse it to repeat part of an animation in a larger animation. An absolute implementation can still support this but will need some helpers to map the absolute times in keyframes calls to some new times to time shift the keyframes use.

So for keyframes this should mean its values will be absolute. Using it separate from clip means being explicit like the above example, but with clip and parent wrapping keyframes the final frame using the value of the last explicit frame is implied. Repeating the use of keyframes will work with helpers to remap the absolute time values in frames.

For the example this leads to a simple change dropping those third frame calls.

const lerp = clip([
  metadata({
    duration,
  }),
  rect({
    left: keyframes([
      frame(0, px(lastRect.left)),
      frame(horizontalDuration, px(rect.left)),
    ]),
    top: keyframes([
      frame(0, ease(px(lastRect.top), t => t * t)),
      frame(verticalDuration, px(rect.top)),
    ]),
    width: lastRect.width,
    height: lastRect.height,
    angle: 0,
  }),
  styles({
    zIndex: value(1),
  }),
])();

@iros brought up a really great point on ease. As an api calling functions around functions around functions is an uncommon api in js. An api structure in popular libraries jQuery and D3 is chaining.

I don't think this would mean all of the grammar would be done with chaining but modifiers would be. Another option would be composing.

That means

ease(px(lastRect.top), t => t * t)

could look like

px(lastRect.top).ease(t => t * t)

or

px(lastRect.top, ease(t => t * t))

I want to include the third as a step to consider

value(lastRect.top, unit('px'), ease(t => t * t))

Whether the grammar supports that I think most users will use

value(lastRect.top).unit('px').ease(t => t * t))

Incidentally I think this also brings up reducing the complexity of value. The example doesn't show but the working implementation I am playing with value also handles the suffix. This brings up splitting that, simplifying it but possibly making it reusable for other functions that may function like value.