Morphing Arbitrary Paths in SVG
Intro
All the animations that you can see above have been created at page load automatically. I copied some paths from SVG Repo, plugged them into a function that converts the paths so that they can be morphed, and then I set the SMIL animations properly. If you want to quickly test your own paths and see the generated SVG yourself, go near the end of this page for an interactive demo.
Paths in SVG can already be morphed with <animate>
for the 'd'
attribute. The catch is that the paths need to have exactly the same number of points and the drawing commands need to be of exactly the same type. You can’t morph a line into a curve, for example.
There are existing solutions for this, like GSAP, Polymorph or others. They are likely more robust than what I have made here, and I believe that they are the best solution if you have SVGs that you want to process on demand.
I’d argue, however, that the more common use case is having some existing paths that you want to morph. In that case, you don’t really need a big JS library loaded at runtime, and you can just generate the animations beforehand. And in this blog post I want to explain how that can be achieved.
You can always look at the code of this post which includes all the important implementation details. Though I’ll note that it came out a bit uglier than what I would’ve wanted.
How To Morph Arbitrary Paths?
Given the limitations that I’ve described above, what can we do so that we can morph two arbitrary paths? The first obvious solution is to manually match the graphics elements of both paths. This could be done in a vector graphics editor, though it’s a relatively elaborate process. This can work when you already have morphing working, but you want to tweak the looks of the animation, and you modify the paths a bit. But doing this to achieve morphing in the first place can take quite a lot of time, and I personally would not recommend it.
Another option is do what we would’ve manually done by matching both paths to be of the same structure, but programmatically. Let’s find out how we can do that.
Paths in SVG are strings that contain many drawing commands. A drawing command is one of M, L, H, V, Q, T, C, S, A, Z
(with lowercase alternatives for relative paths). It’s as if you have a pen that you move on the screen. Let’s take an example:
M20 80 L80 80 Q100 0,50 20 C0 0,0 100,20 80Z
- We
move to
(M
) the point(20, 80)
. A new path is created, and the current point is now(20, 80)
. - We then draw a
line to
(L
) to the point(80, 80)
. The current point becomes(80, 80)
. - Now we draw a quadratic Bézier curve with control point
(100, 0)
to(50, 20)
. The current point becomes(50, 20)
. - We then draw a cubic Bézier curve (
Q
) with control points(0, 0)
and(100, 20)
to(20, 80)
. The current point is(20, 80)
, back where we started. - At last, we close the path with
Z
. This command draws a line from the current point to the starting point of the path. Technically the path is already closed in our case because we finished drawing at the point where we started, but you should still close the path withZ
if you want your path to be filled. If you only want the path to be stroked, you don’t have to close it.
It’s quite difficult to work with paths by hand, but it’s still important to know what they mean in order to understand the rest of the post. If you want to play around with paths, I strongly recommend you use this path editor. If you want a more interactive guide on paths, check this visualizer out.
So we have two paths that we want to morph between, and now we know how these paths are built. It’s not immediately apparent, but we can match paths to be of the same structure to allow morphing with <animate>
by converting every drawing command to a common primitive, for both paths. Then the task transforms into splitting as many primitives as needed so that we can match the number of points between the paths.
The primitive that I’ll choose for implementation is the cubic Bézier curve. It’s powerful enough to represent exactly all other drawing commands except one, that I’ll explain later. It’s also trivial to split into multiple sub-curves. As far as I know, it’s pretty much what GSAP does as well.
Converting Drawing Commands Into Cubics
To convert a path into one that only contains C
drawing commands, we’ll first have to parse it. Unfortunately I haven’t found an API in the browser (that’s not deprecated) which can iterate through drawing commands of a path. So I approached this the hard way and wrote a parser for SVG paths. There are existing solutions already, but I wanted a parser that does not validate the path itself (things like starting with an M
command) and does not alter the input commands. I wanted to implement something that tries to draw things even if the path might not be fully correct according the spec.
“Normalizing” Paths
To make our lives easier, we can first get rid of the graphics primitives that are duplicates of others. By this I mean:
- Converting all relative commands to absolute ones.
- Converting
H
andV
toL
commands. - Converting
T
toQ
. - Converting
S
toC
.
This will mean a bit of duplication of points, but it’s a pragmatic trade-off which will make the implementation much easier. I don’t know if “normalization” is a good term for this, but I couldn’t think of a better one.
Converting relative commands to absolute ones is trivial. We need to interate through the commands, and whenever a relative command is met, we need to add the coordinates to the current point, instead of simply assigning them.
H
and V
mean “horizontal to
” and “vertical to
”, so it’s trivial to convert them into lines. Converting T
to Q
is not difficult. When we iterate through the commands, we need to keep the last two points of the last quadratic we’ve seen. Having those points (let’s call them \(control\) and \(end\)), what we need to do is to mirror \(control\) to the other side of \(end\). If no quadratic was seen before, we can consider both of these points to be equal to the current point.
We can mirror a point on a line easily and intuitively by making use of the parametric form of a line that goes between point \(A\) and \(B\):
\[A + t (B - A)\]Where \(t\) goes from \(0.0\) to \(1.0\). To mirror \(A\) on the other side of \(B\) we can give \(t\) the value of \(2.0\), and we get our mirrored point. We do that for our \(control\) and \(end\) and we can now find out the new control point of the curve. Thus we can convert T
to Q
.
S
can be converted in the exact same way, the only difference being that we need to keep the last to points of the last cubic that we’ve seen, instead of the last quadratic.
Converting Lines To Cubics
Now that we only have M, L, Q, C, A
commands, we can start converting everything to cubics. The easiest primitive to convert is the line. Using the line expression describe above, to convert a line to a cubic curve: we consider the start and end points of the curve to be the same as the start and end points of the line, and the control points can be obtained by giving \(t\) values of \(\frac{1}{3}\) and \(\frac{2}{3}\).
Converting Quadratics To Cubics
Thankfully Bézier curves can be raised to a higher degree without any loss of precision. For quadratics, we can consider their construction:
\[B(t) = (1-t)^2 P_0 + 2t(1 - t)P_1 + t^2 P_2\]Where \(t\) goes, again, from \(0.0\) to \(1.0\). To elevate its degree, we can rewrite it using a basic trick:
\[B(t) = (1-t) B(t) + t B(t)\]With this, we can now obtain the same quadratic but with a third-degree term. By comparing it with the original form, we can obtain the control points for our cubic (the start and end points remain the same as the quadratic). They are:
\[C_0 = \frac{1}{3} (P_0 + 2P_1)\] \[C_1 = \frac{1}{3} (2P_1 + P_2)\]Converting Arcs To Cubics
The one primitive that can’t be fully represented by a cubic is the A
command. This command draws an elliptical arc, which Bézier curves are not able to represent exactly, but they are pretty good at approximating it. So what we can do is approximate an arc with multiple cubics, and this will make it impossible for the user to notice that it’s not actually a real arc.
I’ve already shown how arcs can be converted to cubics in my previous post, but the idea is to approximate arcs no longer than \(\frac{\pi}{4}\) on the unit circle, and scaling them back to the original ellipse.
All of these conversion steps can be merged into the “normalization” step, as it’s relatively easy to combine them once you have the conversion functions implemented.
A Note On M
and Z
It’s important to keep these in our new path, as we will need to know when a new path has started, or when an existing path has ended. This is because a path can have multiple sub-paths, and it’s important to take this into account. We won’t be able to morph properly if one command contains a Z
in the middle, while the other path doesn’t contain the Z
in the exact same place.
Matching The Number Of Points
So now we have two paths that we have “normalized”, and converted to a series of cubics, we can proceed to split the cubics as needed so that we can match the number of points between our paths.
Before doing that though, we have to take care of the case where there are multiple sub-paths in a path. We need to extract all sub-paths for both of our paths, and then work with those directly.
Splitting Sub-Paths
Let’s take an example (the paths would be converted to cubics by this point, but I’m keeping the example simple):
M0 0 L5 10 L10 0Z M3 6 L7 6 L5 4Z
M0 5 L5 5 L2.5 0Z M5 10 L7 5 L3 5Z M5 5 L10 5 L7.5 0Z
The first path contains two sub-paths, and the second one contains three sub-paths. Remember that Z
commands need to be placed at exactly the same places in both paths so that morphing can work. This means that we can’t directly morph between these two paths, even after we convert them to cubics. It might be easy to make it work by tweaking the paths in this case, but for more complex paths that will quickly become unfeasible. We’ll have to create multiple morph animations in parallel to make it work properly. And of course, paths may have many more sub-paths than in this example.
I hope this illustration is intuitive: when the number of sub-paths differ, we make some parallel animations to achieve the morphing effect. For example, if A had 3 sub-paths and B had 7, for every sub-path of A we would morph two sub-paths of B in parallel into it. Since 7 divided by 3 has the remainder of 1, we would include that remainder in the last sub-path of A. Of course the process can be done in the other direction too.
Note that this trick of using multiple animations to the same path might affect the results if transparency is involved. In SVG, since we generate multiple animations, we can set the animation for the fill
attribute to come to a zero opacity value whenever we want to avoid unwanted transparency effects, on any path that we need. It’s not a perfect solution, but it should work well enough for a lot of cases.
Multiple morphs into the same path might also be vulnerable to conflation artifacts. In my experience it shouldn’t affect the result too much, but your mileage may vary. Animating the fill to become transparent, as explained above, should also be able to avoid this.
Matching Points Between Sub-Paths
Now that we have a way of working with only two sub-paths at a time, we can finally process them to make sure we can obtain two paths that have the same number of C
commands. Remember that by this point, our paths have already been converted to cubics.
We can apply an identical approach as we’ve done for splitting sub-paths, and simply look at the path with the smaller number of curves. For each curve, we would split it until it matches the number of cubics in the second path.
If we consider the same illustration as we’ve seen when we split the sub-paths, we could consider that A has three cubics, while B has seven. For every cubic of A, we would split it into two cubics to match what B has. The last cubic would be split into three.
Splitting A Cubic
How exactly can a cubic curve be split into multiple sub-curves? The answer is: De Casteljau subdivision. We can make use of the fact that cubics are nothing more that a series of line interpolations. When computing the point at a \(t\) value on the curve, we can use the intermediate results of interpolations to construct two separate cubics which form the big cubic.
function lineAt(p0, p1, t) {
return {
x: p0.x + t * (p1.x - p0.x),
y: p0.y + t * (p1.y - p0.y),
};
}
function splitCubic(p0, p1, p2, p3, t) {
const p01 = lineAt(p0, p1, t);
const p12 = lineAt(p1, p2, t);
const p23 = lineAt(p2, p3, t);
const c0 = lineAt(p01, p12, t);
const c1 = lineAt(p12, p23, t);
const p = lineAt(c0, c1, t);
return [
{ p0: p0, p1: p01, p2: c0, p3: p },
{ p0: p, p1: c1, p2: p23, p3: p3 },
];
}
With this function we can split a cubic in two at any \(t\) value. To split a curve into as many equally-sized smaller cubics as we want, we can call this repeatedly:
function split(p0, p1, p2, p3, count) {
let result = [];
for (let i = 0; i < count; ++i) {
const t = 1 / (count - i);
const [first, second] = splitCubic(p0, p1, p2, p3, t);
result.push(first);
[p0, p1, p2, p3] = [second.p0, second.p1, second.p2, second.p3];
}
return result;
}
With that said, we now have all the pieces we need to morph arbitrary paths. You can animate the resulting paths with SMIL natively since we have made sure that they satisfy all the requirements regarding the number of points and the structure of the paths.
Interactive Demo
Below you can play around with how morphing looks like, implemented like I’ve tried to explain in this post. The view box for this demo is '0 0 100 100'
.
Morphing Text
If you want to morph text, the first option is to convert the glyphs to paths and use that as the input. Getting the paths can easily be done in something like InkScape. However, in my opinion, the animations don’t look that great unless both texts have the same number of glyphs. In case you want to morph arbitrary text, I’d personally prefer an effect like this instead of the method presented here.
Conclusion
Shape morphing is probably my favorite kind of animation. When I first learned about it around 8 years ago, I remember that I tried to understand how arbitrary shapes could be morphed together, and I couldn’t really figure it out at that time. Over the years I have learned a lot more about vector graphics (and graphics in general), and it became clear how one could achieve it.
I hope that this post can be useful to you. Even if you don’t need this, I hope that this was at least interesting to read!