Tips for rendering
Introduction
This page will give tips and example code on how to render certain objects within lottie.
Lottie has several implementations and some things might vary from player to player, this guide tries to follow the behaviour of lottie web which is the reference implementation.
For shapes, it ensures the stroke order is the same as in lottie web, which is crucial for Trim Path to work correctly.
All shapes have the d
attribute that if has the value 3
the path should be reversed.
Code
The code examples take some shortcuts for readablility: all animated properties are shown as static, of course you'd need to get the correct values to render shapes at a given frame.
When adding points to a bezier, there are calls to bezier.add_vertex()
.
Assume the in/out tangents are [0, 0]
if not specified.
When they are specified they show as set_out_tangent
immediately following
the corresponding add_vertex
.
Bezier tangents are assumed to be relative to their vertex since that's how lottie works but it might be useful to keep them as absolute points when rendering.
All the examples show the original on the left and the bezier on the right.
Explanation for bezier operations is outside the scope of this guide, the code below use a simple bezier library for some operations, you can check its sources for some context on what the various functions do.
Rectangle
See Rectangle.
Note that unlike other shapes, on lottie web when the d
attribute is missing,
the rectangle defaults as being reversed.
Rectangle Inputs: Set shape closed If The rectangle is rendered from the top-right going clockwise Add vertex Add vertex Add vertex Add vertex Otherwise Rounded corners must be taken into account Add vertex Set in tangent Add vertex Set out tangent Add vertex Set in tangent Add vertex Set out tangent Add vertex Set in tangent Add vertex Set out tangent Add vertex Set in tangent Add vertex Set out tangent
Ellipse
See Ellipse.
The stroke direction should start at the top. If you think of the ellipse as a clock, start at 12 go clockwise.
The magic number 0.5519
is what lottie uses for this, based on this article.
Ellipse Inputs: An ellipse is drawn from the top quandrant point going clockwise: Set shape closed Add vertex Set in tangent Set out tangent Add vertex Set in tangent Set out tangent Add vertex Set in tangent Set out tangent Add vertex Set in tangent Set out tangent
PolyStar
Pseudocode for rendering a PolyStar.
Polystar Inputs: Set shape closed For each in Add vertex If We need to add bezier tangents Set in tangent Set out tangent If We need to add a vertex towards the inner radius to make a star Add vertex If We need to add bezier tangents Set in tangent Set out tangent
Pucker Bloat
See Pucker / Bloat.
50 |
Rounded Corners
See Rounded Corners.
It approximates rounding using circular arcs.
The magic number 0.5519
is what lottie uses for this, based on this article.
50 |
Zig Zag
See Zig Zag.
10 | |
10 | |
Star | |
0 | |
0 | |
5 | |
3 |
Offset Path
See Offset Path.
10 | |
100 | |
0 |
let star = lottie.layers[0].shapes[0].it[0];
bezier_lottie.layers[0].shapes[0].it[1].w.k = 3;
</script>
<script func="offset_path([convert_shape(star)], modifier.a.k, modifier.lj, modifier.ml.k)" varname="modifier">
/*
Simple offset of a linear segment
*/
function linear_offset(p1, p2, amount)
{
let angle = Math.atan2(p2.x - p1.x, p2.y - p1.y);
return [
p1.add_polar(angle, amount),
p2.add_polar(angle, amount)
];
}
/*
Offset a bezier segment
only works well if the segment is flat enough
*/
function offset_segment(segment, amount)
{
let [p0, p1a] = linear_offset(segment.points[0], segment.points[1], amount);
let [p1b, p2b] = linear_offset(segment.points[1], segment.points[2], amount);
let [p2a, p3] = linear_offset(segment.points[2], segment.points[3], amount);
let p1 = line_intersection(p0, p1a, p1b, p2b) ?? p1a;
let p2 = line_intersection(p2a, p3, p1b, p2b) ?? p2a;
return new BezierSegment(p0, p1, p2, p3);
}
/*
Join two segments
*/
function join_lines(output_bezier, seg1, seg2, line_join, miter_limit)
{
let p0 = seg1.points[3];
let p1 = seg2.points[0];
// Bevel
if ( line_join == 3 )
return p0;
// Connected, they don't need a joint
if ( p0.is_equal(p1) )
return p0;
let last_point = output_bezier.points[output_bezier.points.length - 1];
// Round
if ( line_join == 2 )
{
const ellipse_constant = 0.5519;
let angle_out = seg1.tangent_angle(1);
let angle_in = seg2.tangent_angle(0) + Math.PI;
let center = line_intersection(
p0, p0.add_polar(angle_out + Math.PI / 2, 100),
p1, p1.add_polar(angle_out + Math.PI / 2, 100)
);
let radius = center ? center.distance(p0) : p0.distance(p1) / 2;
last_point.set_out_tangent(Point.polar(angle_out, 2 * radius * ellipse_constant));
output_bezier.add_vertex(p1)
.set_in_tangent(Point.polar(angle_in, 2 * radius * ellipse_constant));
return p1;
}
// Miter
let t0 = p0.is_equal(seg1.points[2]) ? seg1.points[0] : seg1.points[2];
let t1 = p1.is_equal(seg2.points[1]) ? seg2.points[3] : seg2.points[1];
let intersection = line_intersection(t0, p0, p1, t1);
if ( intersection && intersection.distance(p0) < miter_limit )
{
output_bezier.add_vertex(intersection);
return intersection;
}
return p0;
}
function get_intersection(a, b)
{
let intersect = a.intersections(b);
if ( intersect.length && fuzzy_compare(intersect[0], 1) )
intersect.shift();
if ( intersect.length )
return intersect[0];
return null;
}
function prune_segment_intersection(a, b)
{
let out_a = [...a];
let out_b = [...b];
let intersect = get_intersection(a[a.length-1], b[0]);
if ( intersect )
{
out_a[a.length-1] = a[a.length-1].split(intersect[0])[0];
out_b[0] = b[0].split(intersect[1])[1];
}
if ( a.length > 1 && b.length > 1 )
{
intersect = get_intersection(a[0], b[b.length - 1]);
if ( intersect )
{
return [
[a[0].split(intersect[0])[0]],
[b[b.length-1].split(intersect[1])[1]],
];
}
}
return [out_a, out_b];
}
function prune_intersections(segments)
{
for ( let i = 1; i < segments.length; i++ )
{
[segments[i-1], segments[i]] = prune_segment_intersection(segments[i - 1], segments[i]);
}
if ( segments.length > 1 )
[segments[segments.length - 1], segments[0]] = prune_segment_intersection(segments[segments.length - 1], segments[0]);
return segments;
}
function offset_segment_split(segment, amount)
{
/*
We split each bezier segment into smaller pieces based
on inflection points, this ensures the control point
polygon is convex.
(A cubic bezier can have none, one, or two inflection points)
*/
let flex = segment.inflection_points();
if ( flex.length == 0 )
{
return [offset_segment(segment, amount)];
}
else if ( flex.length == 1 || flex[1] == 1 )
{
let [left, right] = segment.split(flex[0]);
return [
offset_segment(left, amount),
offset_segment(right, amount)
];
}
else
{
let [left, mid_right] = segment.split(flex[0]);
let t = (flex[1] - flex[0]) / (1 - flex[0]);
let [mid, right] = mid_right.split(t);
return [
offset_segment(left, amount),
offset_segment(mid, amount),
offset_segment(right, amount)
];
}
}
function offset_path(
// Beziers as collected from the other shapes
collected_shapes,
amount,
line_join,
miter_limit,
)
{
let result = [];
for ( let input_bezier of collected_shapes )
{
let output_bezier = new Bezier();
output_bezier.closed = input_bezier.closed;
let count = input_bezier.segment_count();
let multi_segments = [];
for ( let i = 0; i < count; i++ )
multi_segments.push(offset_segment_split(input_bezier.segment(i), amount));
// Open paths are stroked rather than being simply offset
if ( !input_bezier.closed )
{
for ( let i = count - 1; i >= 0; i-- )
multi_segments.push(offset_segment_split(input_bezier.inverted_segment(i), amount));
}
multi_segments = prune_intersections(multi_segments);
// Add bezier segments to the output and apply line joints
let last_point = null;
let last_seg = null;
for ( let multi_segment of multi_segments )
{
if ( last_seg )
last_point = join_lines(output_bezier, last_seg, multi_segment[0], line_join, miter_limit);
last_seg = multi_segment[multi_segment.length - 1];
for ( let segment of multi_segment )
{
if ( segment.points[0].is_equal(last_point) )
{
output_bezier.points[output_bezier.points.length - 1]
.set_out_tangent(segment.points[1].sub(segment.points[0]));
}
else
{
output_bezier.add_vertex(segment.points[0])
.set_out_tangent(segment.points[1].sub(segment.points[0]));
}
output_bezier.add_vertex(segment.points[3])
.set_in_tangent(segment.points[2].sub(segment.points[3]));
last_point = segment.points[3];
}
}
if ( multi_segments.length )
join_lines(output_bezier, last_seg, multi_segments[0][0], line_join, miter_limit);
result.push(output_bezier);
}
return result;
}
</script>
Trim Path
0 | |
50 | |
0 | |
let siblings = bezier_lottie.layers[0].shapes[0].it;
siblings[siblings.length-2].w.k = 20;
let shapes = [];
for ( let i = 0; i < 4; i++ )
shapes.push(convert_shape(lottie.layers[0].shapes[i]));
</script>
<script func="trim_path(shapes, modifier.s.k, modifier.e.k, modifier.o.k, modifier.m)" varname="modifier">
function trim_path_gather_chunks(collected_shapes, multiple)
{
let chunks = [];
// Shapes are handled as a single unit
if ( multiple === 2 )
chunks.push({segments: [], length: 0});
for ( let input_bezier of collected_shapes )
{
// Shapes are all affected separately
if ( multiple === 1 )
chunks.push({segments: [], length: 0});
let chunk = chunks[chunks.length-1];
for ( let i = 0; i < input_bezier.segment_count(); i++ )
{
let segment = input_bezier.segment(i);
let length = segment.get_length();
chunk.segments.push(segment);
chunk.length += length;
}
// Use null as a marker to start a new bezier
if ( multiple == 2 )
chunk.segments.push(null);
}
return chunks;
}
function trim_path_chunk(chunk, start, end, output_shapes)
{
// Note: start and end have been normalized and have the offset applied
// The offset itself was normalized into [0, 1] so this is always true:
// 0 <= start < end <= 2
// Some offsets require us to handle different "splits"
// We want each split to be a pair [s, e] such that
// 0 <= s < e <= 1
var splits = [];
if ( end <= 1 )
{
// Simplest case, the segment is in [0, 1]
splits.push([start, end]);
}
else if ( start > 1 )
{
// The whole segment is outside [0, 1]
splits.push([start-1, end-1]);
}
else
{
// The segment goes over the end point, so we need two splits
splits.push([start, 1]);
splits.push([0, end-1]);
}
// Each split is a separate bezier, all left to do is finding the
// bezier segment to add to the output
for ( let [s, e] of splits )
{
let start_length = s * chunk.length;
let start_t;
let end_length = e * chunk.length;
let prev_length = 0;
let output_bezier = new Bezier(false);
output_shapes.push(output_bezier);
for ( let i = 0; i < chunk.segments.length; i++ )
{
let segment = chunk.segments[i];
// New bezier marker found
if ( segment === null )
{
output_bezier = new Bezier(false);
output_shapes.push(output_bezier);
continue;
}
if ( segment.length >= end_length )
{
let end_t = segment.t_at_length(end_length);
if ( segment.length >= start_length )
{
start_t = segment.t_at_length(start_length);
segment = segment.split(start_t)[1];
end_t = (end_t - start_t) / (1 - start_t);
}
output_bezier.add_segment(segment.split(end_t)[0], false);
break;
}
if ( start_t === undefined )
{
if ( segment.length >= start_length )
{
start_t = segment.t_at_length(start_length);
output_bezier.add_segment(segment.split(start_t)[1], false);
}
}
else
{
output_bezier.add_segment(segment, true);
}
start_length -= segment.length;
end_length -= segment.length;
}
}
}
function trim_path(
collected_shapes,
start,
end,
offset,
multiple
)
{
// Normalize Inputs
offset = offset / 360 % 1;
if ( offset < 0 )
offset += 1;
start = Math.min(1, Math.max(0, start / 100));
end = Math.min(1, Math.max(0, end / 100));
if ( end < start )
[start, end] = [end, start];
// Apply offset
start += offset;
end += offset;
// Handle the degenerate cases
if ( fuzzy_compare(start, end) )
return [new Bezier(false)];
if ( fuzzy_zero(start) && fuzzy_compare(end, 1) )
return collected_shapes;
// Gather up the segments to trim
let chunks = trim_path_gather_chunks(collected_shapes, multiple);
let output_shapes = [];
for ( let chunk of chunks )
trim_path_chunk(chunk, start, end, output_shapes);
return output_shapes;
}
</script>
Transform
This is how to convert a transform object into a matrix.
Assuming the matrix
a | c | 0 | 0 |
b | d | 0 | 0 |
0 | 0 | 1 | 0 |
tx | ty | 0 | 1 |
The names a
, b
, etc are the ones commonly used for CSS transforms.
4D matrix to allow for 3D transforms, even though currently lottie only supports 2D graphics.
Multiplications are right multiplications (Next = Previous * StepOperation
).
If your transform is transposed (tx
, ty
are on the last column), perform left multiplication instead.
Perform the following operations on a matrix starting from the identity matrix (or the parent object's transform matrix):
Translate by -a
:
1 | 0 | 0 | 0 |
0 | 1 | 0 | 0 |
0 | 0 | 1 | 0 |
-a[0] | -a[1] | 0 | 1 |
Scale by s/100
:
s[0]/100 | 0 | 0 | 0 |
0 | s[1]/100 | 0 | 0 |
0 | 0 | 1 | 0 |
0 | 0 | 0 | 1 |
Rotate by -sa
(can be skipped if not skewing)
cos(-sa) | sin(-sa) | 0 | 0 |
-sin(-sa) | cos(-sa) | 0 | 0 |
0 | 0 | 1 | 0 |
0 | 0 | 0 | 1 |
Skew by sk
(can be skipped if not skewing)
1 | tan(-sk) | 0 | 0 |
0 | 1 | 0 | 0 |
0 | 0 | 1 | 0 |
0 | 0 | 0 | 1 |
Rotate by sa
(can be skipped if not skewing)
cos(sa) | sin(sa) | 0 | 0 |
-sin(sa) | cos(sa) | 0 | 0 |
0 | 0 | 1 | 0 |
0 | 0 | 0 | 1 |
Rotate by -r
cos(-r) | sin(-r) | 0 | 0 |
-sin(-r) | cos(-r) | 0 | 0 |
0 | 0 | 1 | 0 |
0 | 0 | 0 | 1 |
If you are handling an auto orient layer, evaluate and apply auto-orient rotation.
Translate by p
1 | 0 | 0 | 0 |
0 | 1 | 0 | 0 |
0 | 0 | 1 | 0 |
p[0] | p[1] | 0 | 1 |
256 | |
256 | |
256 | |
256 | |
100 | |
100 | |
0 | |
0 | |
0 | |
100 |
lottie.layers[0].ks.p.k[0] = data["Anchor X"]; lottie.layers[2].ks.a.k[0] = data["Anchor X"]; lottie.layers[0].ks.p.k[1] = data["Anchor Y"]; lottie.layers[2].ks.a.k[1] = data["Anchor Y"]; lottie.layers[2].ks.p.k[0] = data["Position X"]; lottie.layers[2].ks.p.k[1] = data["Position Y"]; lottie.layers[2].ks.s.k[0] = data["Scale X"]; lottie.layers[2].ks.s.k[1] = data["Scale Y"]; lottie.layers[2].ks.r.k = data["Rotation"]; lottie.layers[2].ks.sk.k = data["Skew"]; lottie.layers[2].ks.sa.k = data["Skew Angle"]; lottie.layers[2].ks.o.k = data["Opacity"];
var transform = new LottieMatrix(); transform.translate(-data["Anchor X"], -data["Anchor Y"]); transform.scale(data["Scale X"] / 100, data["Scale Y"] / 100); transform.skew(data["Skew"] * Math.PI / 180, data["Skew Angle"] * Math.PI / 180); transform.rotate(-data["Rotation"] * Math.PI / 180); transform.translate(data["Position X"], data["Position Y"]);
var cx = lottie.layers[2].shapes[0].p.k[0]; var cy = lottie.layers[2].shapes[0].p.k[1]; var rx = lottie.layers[2].shapes[0].s.k[0] / 2; var ry = lottie.layers[2].shapes[0].s.k[1] / 2;
lottie.layers[1].shapes[0].ks.k.v = [ transform.map(cx - rx, cy - ry).slice(0, 2), transform.map(cx + rx, cy - ry).slice(0, 2), transform.map(cx + rx, cy + ry).slice(0, 2), transform.map(cx - rx, cy + ry).slice(0, 2) ];
3D Transform
If you have a 3D transform, the process is similar, with a
, p
, s
,
using their 3D matrices, note that for p
and a
the Z axis is inverted.
The rotation step is a bit more complicated, with the 2D rotation being equivalent to a Z rotation.
The rotation step above is replaced with the following set of steps:
Rotate by -rz
cos(-r) | sin(-r) | 0 | 0 |
-sin(-r) | cos(-r) | 0 | 0 |
0 | 0 | 1 | 0 |
0 | 0 | 0 | 1 |
Rotate by ry
cos(r) | 0 | sin(r) | 0 |
0 | 1 | 0 | 0 |
-sin(r) | 0 | cos(-r) | 0 |
0 | 0 | 1 |
Rotate by rx
1 | 0 | 0 | 0 |
0 | cos(r) | -sin(r) | 0 |
0 | -sin(r) | cos(-r) | 0 |
0 | 0 | 0 | 1 |
Then repeat the steps for or
:
Rotate by -or[2]
(Z axis)
cos(-r) | sin(-r) | 0 | 0 |
-sin(-r) | cos(-r) | 0 | 0 |
0 | 0 | 1 | 0 |
0 | 0 | 0 | 1 |
Rotate by or[1]
(Y axis)
cos(r) | 0 | sin(r) | 0 |
0 | 1 | 0 | 0 |
-sin(r) | 0 | cos(-r) | 0 |
0 | 0 | 1 |
Rotate by or[0]
(X axis)
1 | 0 | 0 | 0 |
0 | cos(r) | -sin(r) | 0 |
0 | -sin(r) | cos(-r) | 0 |
0 | 0 | 0 | 1 |
Auto Orient
Auto-orient is only relevant for layers that have ao
set to 1
an animated position.
You get the derivative of the position property at the current time as a
pair (dx
, dy
), and find the angle with atan2(dy, dx)
, then rotate
by that angle clockwise:
cos(-r) | sin(-r) | 0 | 0 |
-sin(-r) | cos(-r) | 0 | 0 |
0 | 0 | 1 | 0 |
0 | 0 | 0 | 1 |
Animated Properties
Assuming a 1D property, a keyframe looks something like this:
{
"t": start_time,
"s": [
start_value
],
"o": {
"x": [
ox
],
"y": [
oy
]
},
"i": {
"x": [
ix
],
"y": [
iy
]
}
}
Where:
t
is the time at the start of the keyframe (in frames),s
is the value at that timei
ando
are the in/out bezier tangents
The transition between keyframes is defined by two keyframes,
for simplicity we'll refer to the named values above plus end_time
and
end_value
corresponding to t
and s
on the keyframe after the one listed.
The transition is given as a cubic bezier curve whose x axis is time and
the y axis is the interpolating factor between start_value
and end_value
.
The four points of this bezier curve are: (0, 0), (ox, oy), (iy, iy), (1, 1).
x
is given by x = (current_time - start_time) / (end_time - start_time)
.
If the bezier is defined as
a t3 + b t2 + c t + d = 0
then you need to find the cubic roots of
a t3 + b t2 + c t + d - x
= 0
to find the t
corresponding to that x
, (You only need to consider real roots in [0, 1]).
Then you can find the y
by evaluating the bezier at t
.
The final value is as follows: lerp(y, start_value, end_value)
.
Effects
Fill Effect
Tritone Effect
Gaussian Blur
This is a two-pass shader, the uniform pass
is has value 0
on the first pass and value 1
on the second pass.
Drop Shadow Effect
The effect below is split into multiple shaders:
- First it generates the shadow
- Then it has a 2 pass gaussian blur (simplified from the example above)
- Finally, it composites the original image on top of the blurred shadow
Pro Levels Effect
Matte3
Bulge
Wave Warp
This effect is animated by default, so it has a "time" slider (in seconds).