Shopify / react-native-skia

High-performance React Native Graphics using Skia
https://shopify.github.io/react-native-skia
MIT License
6.8k stars 436 forks source link

Change Path interpolation so that it can interpolate between paths of different sizes #990

Closed Jorundur closed 1 year ago

Jorundur commented 1 year ago

Description

Current functionality

Desired functionality

Current workaround

Can the algorithm that is used in e.g. Polymorph or another similar path morphing library be added to the path interpolate function we have in Skia so as to do it natively?

wcandillon commented 1 year ago

Optimizing Flubber to work with the Skia API a bit deeper might be easier than expected. the interpolate function has a string boolean option that returns the points instead of the SVG string. So instead of using a Path (with SVG serialization/parsing/creation = SLOW), you can use <Vertices /> directly which what Flubber used behind the scene: https://shopify.github.io/react-native-skia/docs/shapes/vertices

fun no?

I'll keep this issue open for now as we might provide an example in the near future on how to achieve this.

wcandillon commented 1 year ago

If you are interested of what flubber does behing the scene, this article is a create intro: https://css-tricks.com/rendering-svg-paths-in-webgl/ And this is the function they use to make sure that the meshes can be interpolated: https://github.com/veltman/flubber/blob/master/src/interpolate.js#L40

wcandillon commented 1 year ago

Did you try to generate the path at t=0 and the path at t=1 as SkPath and then use path.interpolate on it. That may work?

Jorundur commented 1 year ago

Did you try to generate the path at t=0 and the path at t=1 as SkPath and then use path.interpolate on it. That may work?

Yeah, I have something like (I think this is what you're referring to)

const start = graphData[graphState.current.current].lineGraph;
const end = graphData[graphState.current.next].lineGraph;
return end.interpolate(start, graphTransition.current) ?? Skia.Path.Make();

and it's not working. I can also see when I check with isInterpolatable that it returns false.

This is probably because the two lineGraphs (both SkPath) are of varying length.

Thanks for the other suggestions as well. I'm not sure how I'll change my code from the <Path>s to <Vertices> but I can try to work it out.

wcandillon commented 1 year ago

That's quite interesting. This is because:

 return t => {
    if (t < 1e-4 && typeof fromShape === "string") {
      return fromShape;
    }
    if (1 - t < 1e-4 && typeof toShape === "string") {
      return toShape;
    }
    return interpolator(t);
  };

So

const leftInterpolator0 = Skia.Path.MakeFromSVGString(leftInterpolator(0.01))!;
const leftInterpolator1 = Skia.Path.MakeFromSVGString(leftInterpolator(0.99))!;
console.log(leftInterpolator0.isInterpolatable(leftInterpolator1)); // Returns true

And

const leftInterpolator0 = Skia.Path.MakeFromSVGString(leftInterpolator(0))!;
const leftInterpolator1 = Skia.Path.MakeFromSVGString(leftInterpolator(1))!;
console.log(leftInterpolator0.isInterpolatable(leftInterpolator1)); // Returns false

Which makes a lot of sense. At 0 and 1 you want to display the original path, not the triangulated one. Can you infer the proper function you need to build based on this information?

wcandillon commented 1 year ago

this function seems to work like a charm:

const Flubber2SkiaInterpolator = (interpolator: (t: number) => string) => {
  const d = 1e-3;
  const i0 = Skia.Path.MakeFromSVGString(interpolator(0))!;
  const i01 = Skia.Path.MakeFromSVGString(interpolator(d))!;
  const i1 = Skia.Path.MakeFromSVGString(interpolator(1))!;
  const i11 = Skia.Path.MakeFromSVGString(interpolator(1 - d))!;
  console.log(i01.isInterpolatable(i11));
  return (t: number) => {
    if (t < d) {
      return i0;
    }
    if (1 - t < d) {
      return i1;
    }
    return i11.interpolate(i01, t)!;
  };
};

const leftInterpolator = Flubber2SkiaInterpolator(
  interpolate(
    "M 8 125 C 3.5 123 0.4 118.6 0 113.6 V 12.7 C 0.2 10.3 1 7.9 2.4 5.9 C 3.9 3.9 5.8 2.3 8 1.3 C 9.8 0.4 11.8 0 13.7 0 C 14.2 0 14.7 0 15.1 0.1 C 17.6 0.3 19.9 1.2 21.9 2.7 L 50 22 V 104.3 L 21.9 123.6 C 20.3 124.8 18.5 125.6 16.6 126 H 10.9 C 9.9 125.8 8.9 125.5 8 125 Z",
    "M 16.7 0 C 12.2 0 8 1.8 4.9 4.9 C 1.8 8 0 12.2 0 16.7 V 105.6 C 0 110 1.8 114.2 4.9 117.3 C 8 120.5 12.2 122.2 16.7 122.2 C 21.1 122.2 25.3 120.5 28.5 117.3 C 31.6 114.2 33.3 110 33.3 105.6 V 16.7 C 33.3 12.2 31.6 8 28.5 4.9 C 25.3 1.8 21.1 0 16.7 0 Z"
  )
);

  const left = useComputedValue(() => {
    const pathLeft = leftInterpolator(progress.current);
    return pathLeft;
  }, [progress]);

This is like super fun no?

I'm closing the issue for now but feel free to reopen if needed.

Jorundur commented 1 year ago

@wcandillon

This is very interesting and fun indeed! Thanks for taking the time to write this.

So the way I understand this is that with Flubber, even though the start and end paths are of different sizes, all the intermediate paths (during the transition) are of the same size and that's the reason why we can use Skia to interpolate the intermediate paths.

I did manage to run this in my project but unfortunately the Flubber algorithm doesn't work nicely with my use case (animating line graphs) - see https://github.com/veltman/flubber/issues/99. In my case Flubber added a connection between the start and end point of the line graph, which looked odd during the transition.

The algorithm provided by Polymorph looks a lot nicer - i.e. it handles polylines better. But I don't think I can use that library in a similar way as you described since it differs from Flubber in that the intermediate paths can also have different sizes. I.e. in the case of Polymorph, i01.isInterpolatable(i11) would return false while it's true with Flubber. At least that's what I think is going on.

In any case, all I wanted was a nice animation when transitioning from one line graph to another and I ended up Easing the end value of the line path when switching between graphs which looks nice.

wcandillon commented 1 year ago

I feel like you're not trying to interpolate heterogeneous shapes but rather charts which have different number of data points. Maybe it would be easier to add the missing datapoints manually? I'm sure there must be some simple algorithm/lib that can help you to do it.

Jorundur commented 1 year ago

Yeah, that's a good idea. So if line graph A has 10 points and line graph B has 100 points, I could "fake" A during the transition so that it now has 100 points but looks the same way as before and then use Skia to interpolate as normal.

marcocaldera commented 1 year ago

@Jorundur Have you end up doing some tests for adding data points before the interpolation? I'm planning to give it a try and solve the issue this way.

Jorundur commented 1 year ago

@marcocaldera Unfortunately I haven't done that, I figured out a different way of animating the path change which I was happy with and which didn't require interpolation (by animating the end prop of the Path so that it appears smoothly from left to right).

But I'm curious to see your implementation if you were successful in trying this out!

Yorik0512 commented 1 year ago

Hi @wcandillon , I have been trying to realize real time data chart with animation but I got stack on this point, I caught an error when I tried interpolate 2 variants of path of chart: prev data state (without last tick) and actual data. I have made paths from svg string. Issue from React Native stack trace is "Could not parse path from string". I would like to ask you for help to resolve this issue or maybe I am on the wrong way and you know better approach, so please share it with me =)

Thanks in advance.

gtokman commented 1 year ago

I've been trying to get this to work with the interpolating example in the repo and I'm not sure how to make it work with the recipe. @Jorundur You mentioned you were able to get polymorph-js to work. Could you let me know if you took an approach like the one below?

@wcandillon You mentioned polymorph-js in your YT video about drawing bezier curves. Did you ever end up using it for this scenario?

 if (!path.isInterpolatable(currentPathRef.current)) {
      const pointsToAdd = path.countPoints() - currentPathRef.current.countPoints()
      console.warn('Paths must have the same length. Skipping interpolation.', pointsToAdd)

      const path1 = new P.Path(animatedPath.current.toSVGString()) // polymorph-js
      console.log('path1', path1)

      const path2 = new P.Path(path.toSVGString())
      console.log('path2', path2)

      const newPath = P.interpolate([path1, path2], { // crashes
        addPoints: pointsToAdd,
        origin: { x: 0, y: 0 },
        optimize: 'fill',
        precision: 0,
      })(0.5)

      return
    }
    currentPathRef.current = animatedPath.current
    nextPathRef.current = path
    runSpring(
      progress,
      { from: 0, to: 1 },
      {
        mass: 1,
        stiffness: 500,
        damping: 400,
        velocity: 0,
      },
    )