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 without rounded corners:

256
256
256
256
function rect(position, size)
{
    let left = position[0] - size[0] / 2;
    let right = position[0] + size[0] / 2;
    let top = position[1] - size[1] / 2;
    let bottom = position[1] + size[1] / 2;

    let bezier = new Bezier();

    bezier.add_vertex(right, top);
    bezier.add_vertex(right, bottom);
    bezier.add_vertex(left, bottom);
    bezier.add_vertex(left, top);

    return bezier;
}

// Example invocation
rect(shape.p.k, shape.s.k);

With rounded corners:

256
256
256
256
50
function rounded_rect(position, size, roundness)
{
    let left = position[0] - size[0] / 2;
    let right = position[0] + size[0] / 2;
    let top = position[1] - size[1] / 2;
    let bottom = position[1] + size[1] / 2;

    let rounded = Math.min(size[0] / 2, size[1] / 2, roundness);

    let bezier = new Bezier();

    // top right, going down
    bezier.add_vertex(right, top + rounded)
        .set_in_tangent(0, -rounded/2);

    // bottom right
    bezier.add_vertex(right, bottom - rounded)
        .set_out_tangent(0, rounded/2);

    bezier.add_vertex(right - rounded, bottom)
        .set_in_tangent(rounded/2, 0);

    // bottom left
    bezier.add_vertex(left + rounded, bottom)
        .set_out_tangent(-rounded/2, 0);

    bezier.add_vertex(left, bottom - rounded)
        .set_in_tangent(0, rounded/2);

    // top left
    bezier.add_vertex(left, top + rounded)
        .set_out_tangent(0, -rounded/2);

    bezier.add_vertex(left + rounded, top)
        .set_in_tangent(-rounded/2, 0);


    // back to top right
    bezier.add_vertex(right - rounded, top)
        .set_out_tangent(rounded/2, 0);

    return bezier;
}

// Example invocation
rounded_rect(shape.p.k, shape.s.k, shape.r.k);

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.

256
256
256
256
function ellipse(position, size)
{
    const ellipse_constant = 0.5519;

    let x = position[0];
    let y = position[1];
    let radius_x = size[0] / 2;
    let radius_y = size[1] / 2;
    let tangent_x = radius_x * ellipse_constant;
    let tangent_y = radius_y * ellipse_constant;

    let bezier = new Bezier();

    bezier.add_vertex(x, y - radius_y)
        .set_in_tangent(-tangent_x, 0)
        .set_out_tangent(tangent_x, 0);

    bezier.add_vertex(x + radius_x, y)
        .set_in_tangent(0, -tangent_y)
        .set_out_tangent(0, tangent_y);

    bezier.add_vertex(x, y + radius_y)
        .set_in_tangent(tangent_x, 0)
        .set_out_tangent(-tangent_x, 0);

    bezier.add_vertex(x - radius_x, y)
        .set_in_tangent(0, tangent_y)
        .set_out_tangent(0, -tangent_y);

    return bezier;
}

// Example invocation
ellipse(shape.p.k, shape.s.k);

PolyStar

Pseudocode for rendering a PolyStar.

5
0
200
100
0
0
function polystar(
    position,
    type,
    points,
    rotation,
    outer_radius,
    outer_roundness,
    inner_radius,
    inner_roundness
)
{
    let result = new Bezier();

    let half_angle = Math.PI / points;
    let angle_radians = rotation / 180 * Math.PI

    // Tangents for rounded courners
    let tangent_len_outer = outer_roundness * outer_radius * 2 * Math.PI / (points * 4 * 100);
    let tangent_len_inner = inner_roundness * inner_radius * 2 * Math.PI / (points * 4 * 100);

    for ( let i = 0; i < points; i++ )
    {
        let main_angle = -Math.PI / 2 + angle_radians + i * half_angle * 2;

        let outer_vertex = new Point(
            outer_radius * Math.cos(main_angle),
            outer_radius * Math.sin(main_angle)
        );

        let outer_tangent = new Point(0, 0);
        if ( outer_radius != 0 )
            outer_tangent = new Point(
                outer_vertex.y / outer_radius * tangent_len_outer,
                -outer_vertex.x / outer_radius * tangent_len_outer
            );

        result.add_vertex(position.add(outer_vertex))
            .set_in_tangent(outer_tangent)
            .set_out_tangent(outer_tangent.neg());

        // Star inner radius
        if ( type == 1 )
        {
            let inner_vertex = new Point(
                inner_radius * Math.cos(main_angle + half_angle),
                inner_radius * Math.sin(main_angle + half_angle)
            );

            let inner_tangent = new Point(0, 0);
            if ( inner_radius != 0 )
                inner_tangent = new Point(
                    inner_vertex.y / inner_radius * tangent_len_inner,
                    -inner_vertex.x / inner_radius * tangent_len_inner
                );

            result.add_vertex(position.add(inner_vertex))
                .set_in_tangent(inner_tangent)
                .set_out_tangent(inner_tangent.neg());
        }
    }

    return result;
}

// Example invocation
polystar(new Point(shape.p.k), shape.sy, shape.pt.k, shape.r.k, shape.or.k, shape.os.k, shape.ir?.k, shape.is?.k);

Pucker Bloat

See Pucker / Bloat.

50
function pucker_bloat(
    // Beziers as collected from the other shapes
    collected_shapes,
    // "a" property from the Pucker/Bloat modifier
    amount
)
{
    // Normalize to [0, 1]
    amount /= 100;

    // Find the mean of the bezier vertices
    let center = new Point(0, 0);
    let number_of_vertices = 0;
    for ( let input_bezier of collected_shapes )
    {
        for ( let point of input_bezier.points )
        {
            center.x += point.pos.x;
            center.y += point.pos.y;
            number_of_vertices += 1;
        }
    }

    center.x /= number_of_vertices;
    center.y /= number_of_vertices;

    let result = [];

    for ( let input_bezier of collected_shapes )
    {
        let output_bezier = new Bezier();
        for ( let point of input_bezier.points )
        {
            // Here we convert tangents to global coordinates
            let vertex = lerp(point.pos, center, amount);
            let in_tangent = lerp(point.in_tangent.add(point.pos), center, -amount).sub(vertex);

            let out_tangent = lerp(point.out_tangent.add(point.pos), center, -amount).sub(vertex);
            output_bezier.add_vertex(vertex)
                .set_in_tangent(in_tangent)
                .set_out_tangent(out_tangent);
        }

        output_bezier.closed = input_bezier.closed;

        result.push(output_bezier);
    }

    return result;
}

// Example invocation
pucker_bloat([convert_shape(star)], modifier.a.k);

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
// Helper function to perform rounding on a single vertex
function get_vertex_tangent(
    // Bezier to round
    bezier,
    // Vertex in the bezier we are rounding
    current_vertex,
    // Index of the next point along the curve
    closest_index,
    // Rounding radius
    round_distance
)
{
    const tangent_length = 0.5519;

    // closest_index module bezier.length
    closest_index = closest_index % bezier.points.length;
    if ( closest_index < 0 )
        closest_index += bezier.points.length;


    let closest_vertex = bezier.points[closest_index].pos;
    let distance = current_vertex.distance(closest_vertex);
    let new_pos_perc = distance != 0 ? Math.min(distance/2, round_distance) / distance : 0;
    let vertex = closest_vertex.sub(current_vertex).mul(new_pos_perc).add(current_vertex);
    let tangent = vertex.sub(current_vertex).neg().mul(tangent_length);
    return [vertex, tangent];
}

// Rounding for a single continuos curve
function round_bezier_corners(
    // Bezier to round
    original,
    // Rounding radius
    round_distance
)
{
    let result = new Bezier()
    result.closed = original.closed;

    for ( let i = 0; i < original.points.length; i++ )
    {
        let point = original.points[i];

        // Start and end of a non-closed path don't get rounded
        if ( !original.closed && (i == 0 || i == original.points.length - 1) )
        {
            result.add_vertex(point.pos)
                .set_in_tangent(point.in_tangent)
                .set_out_tangent(point.out_tangent);
        }
        else
        {
            let [vert1, out_t] = get_vertex_tangent(original, point.pos, i - 1, round_distance);
            result.add_vertex(vert1)
                .set_out_tangent(out_t);

            let [vert2, in_t] = get_vertex_tangent(original, point.pos, i + 1, round_distance);
            result.add_vertex(vert2)
                .set_in_tangent(in_t);
        }
    }

    return result;
}

// Rounding on multiple bezier
function round_corners(
    // Beziers as collected from the other shapes
    collected_shapes,
    // "r" property from lottie
    r
)
{
    let result = []

    for ( let input_bezier of collected_shapes )
        result.push(round_bezier_corners(input_bezier, r));

    return result;
}

// Example invocation
round_corners([convert_shape(star)], modifier.r.k);

Zig Zag

See Zig Zag.

Zig Zag
10
10
Star
0
0
5
3

function angle_mean(a, b)
{
    if ( Math.abs(a-b) > Math.PI )
        return (a + b) / 2 + Math.PI;

    return (a + b) / 2;
}

function zig_zag_corner(output_bezier, segment_before, segment_after, amplitude, direction, tangent_length)
{
    let point;
    let angle;
    let tan_angle;

    // We use 0.01 and 0.99 instead of 0 and 1 because they yield better results
    if ( !segment_before )
    {
        point = segment_after.points[0];
        angle = segment_after.normal_angle(0.01);
        tan_angle = segment_after.tangent_angle(0.01);
    }
    else if ( !segment_after )
    {
        point = segment_before.points[3];
        angle = segment_before.normal_angle(0.99);
        tan_angle = segment_before.tangent_angle(0.99);
    }
    else
    {
        point = segment_after.points[0];
        angle = angle_mean(segment_after.normal_angle(0.01), segment_before.normal_angle(0.99));
        tan_angle = angle_mean(segment_after.tangent_angle(0.01), segment_before.tangent_angle(0.99));
    }

    let vertex = output_bezier.add_vertex(point.add_polar(angle, direction * amplitude));
    if ( tangent_length !== 0 )
    {
        vertex.set_in_tangent(Point.polar(tan_angle, -tangent_length));
        vertex.set_out_tangent(Point.polar(tan_angle, tangent_length));
    }
}

function zig_zag_segment(output_bezier, segment, amplitude, frequency, direction, tangent_length)
{
    for ( let i = 0; i < frequency; i++ )
    {
        let f = (i + 1) / (frequency + 1);
        let t = segment.t_at_length_percent(f);
        let angle = segment.normal_angle(t);
        let point = segment.point(t);

        let vertex = output_bezier.add_vertex(point.add_polar(angle, direction * amplitude));
        if ( tangent_length !== 0 )
        {
            let tan_angle = segment.tangent_angle(t);
            vertex.set_in_tangent(Point.polar(tan_angle, -tangent_length));
            vertex.set_out_tangent(Point.polar(tan_angle, tangent_length));
        }

        direction = -direction;
    }

    return direction;
}

function zig_zag_bezier(input_bezier, amplitude, frequency, smooth)
{
    let output_bezier = new Bezier();

    output_bezier.closed = input_bezier.closed;
    let count = input_bezier.segment_count();

    if ( count == 0 )
        return output_bezier;

    let direction = -1;
    let segment = input_bezier.closed ? input_bezier.segment(count - 1) : null;
    let next_segment = input_bezier.segment(0);

    next_segment.calculate_length_data();
    let tangent_length = smooth ? next_segment.length / (frequency + 1) / 2 : 0;

    zig_zag_corner(output_bezier, segment, next_segment, amplitude, -1, tangent_length);

    for ( let i = 0; i < count; i++ )
    {
        segment = next_segment;

        direction = zig_zag_segment(output_bezier, segment, amplitude, frequency, -direction, tangent_length);

        if ( i == count - 1 && !input_bezier.closed )
            next_segment = null;
        else
            next_segment = input_bezier.segment((i + 1) % count);

        zig_zag_corner(output_bezier, segment, next_segment, amplitude, direction, tangent_length);
    }

    return output_bezier;
}

function zig_zag(
    // Beziers as collected from the other shapes
    collected_shapes,
    amplitude,
    frequency,
    point_type
)
{
    // Ensure we have an integer number of segments
    frequency = Math.max(0, Math.round(frequency));

    let result = [];

    for ( let input_bezier of collected_shapes )
        result.push(zig_zag_bezier(input_bezier, amplitude, frequency, point_type === 2));

    return result;
}

// Example invocation
zig_zag([convert_shape(star)], modifier.s.k, modifier.r.k, modifier.pt.k);

Offset Path

See Offset Path.

Offset Path
10
100
Star
0
/*
    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;
}

// Example invocation
offset_path([convert_shape(star)], modifier.a.k, modifier.lj, modifier.ml.k);

Trim Path

0
50
0

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;

}

// Example invocation
trim_path(shapes, modifier.s.k, modifier.e.k, modifier.o.k, modifier.m);

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

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 time
  • i and o 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

1
Color
1
0.9
0
#version 100

uniform highp vec4 color;

uniform mediump vec2 canvas_size;
uniform sampler2D texture_sampler;

void main()
{
    highp vec2 uv = vec2(gl_FragCoord.x / canvas_size.x, gl_FragCoord.y / canvas_size.y);
    highp vec4 pixel = texture2D(texture_sampler, uv);

    gl_FragColor = color;
    gl_FragColor.a = 1.0;
    gl_FragColor *= pixel.a * color.a;
}

Tritone Effect

Bright
1
1
1
Mid
0.3
0.8
0.3
Dark
0
0
0
#version 100

uniform highp vec4 bright;
uniform highp vec4 mid;
uniform highp vec4 dark;

uniform mediump vec2 canvas_size;
uniform sampler2D texture_sampler;

void main()
{
    highp vec2 uv = vec2(gl_FragCoord.x / canvas_size.x, gl_FragCoord.y / canvas_size.y);
    highp vec4 pixel = texture2D(texture_sampler, uv);

    highp float lightness = sqrt(pixel.r * pixel.r * 0.299 + pixel.g * pixel.g * 0.587 + pixel.b * pixel.b * 0.114);
    // If you want results more similar to lottie-web use the lightness below
    // (this shader has a more accurate lightness calculation)
    // lightness = sqrt((pixel.r * pixel.r + pixel.g * pixel.g + pixel.b * pixel.b) / 3.0);

    if ( lightness < 0.5 )
    {
        lightness *= 2.0;
        gl_FragColor = dark * (1.0 - lightness) + mid * lightness;
    }
    else
    {
        lightness = (lightness - 0.5) * 2.0;
        gl_FragColor = mid * (1.0 - lightness) + bright * lightness;
    }

    gl_FragColor *= pixel.a;
}

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.

25
#version 300 es

#define PI 3.1415926538
precision highp float;

uniform float sigma;
uniform int direction;
uniform int kernel_size;
uniform bool wrap;

uniform mediump vec2 canvas_size;
uniform sampler2D texture_sampler;
uniform int pass;

out vec4 FragColor;


vec4 texture_value(vec2 uv)
{
    if ( wrap )
    {
        if ( uv.x < 0. ) uv.x = 1. - uv.x;
        if ( uv.x > 1. ) uv.x = uv.x - 1.;
        if ( uv.y < 0. ) uv.y = 1. - uv.y;
        if ( uv.y > 1. ) uv.y = uv.y - 1.;
    }
    else if ( uv.x < 0. || uv.x > 1. || uv.y < 0. || uv.y > 1. )
    {
        return vec4(0.0);
    }

    return texture(texture_sampler, uv);
}


vec4 blur_pass(float sigma, int kernel_size, vec2 uv, bool horizontal)
{
    float side = float(kernel_size / 2);

    vec2 direction_vector = horizontal ?
        vec2(1.0, 0.0) / canvas_size.x :
        vec2(0.0, 1.0) / canvas_size.y;

    vec3 delta_gauss;
    delta_gauss.x = 1.0 / (sqrt(2.0 * PI) * sigma);
    delta_gauss.y = exp(-0.5 / (sigma * sigma));
    delta_gauss.z = delta_gauss.y * delta_gauss.y;

    vec4 avg = vec4(0.0, 0.0, 0.0, 0.0);
    float sum = 0.0;

    vec4 pixel = texture_value(uv);
    avg += pixel * delta_gauss.x;
    sum += delta_gauss.x;
    delta_gauss.xy *= delta_gauss.yz;

    for ( float i = 1.0; i <= side; i++)
    {
        for ( float s = -1.0; s <= 1.0; s += 2.0 )
        {
            vec2 pos = uv + s * i * direction_vector;
            pixel = texture_value(pos);
            avg += pixel * delta_gauss.x;
        }
        sum += 2.0 * delta_gauss.x;
        delta_gauss.xy *= delta_gauss.yz;
    }

    avg /= sum;

    return avg;
}


void main()
{
    highp vec2 uv = vec2(gl_FragCoord.x / canvas_size.x, gl_FragCoord.y / canvas_size.y);

    int actual_kernel_size = kernel_size == 0 ? int(0.5 + 6.0 * sigma) : kernel_size;

    const float multiplier = 0.25;

    if ( sigma == 0.0 )
    {
        FragColor = texture(texture_sampler, uv);
    }
    else if ( pass == 0 )
    {
        if ( direction != 3 )
            FragColor = blur_pass(sigma * multiplier, actual_kernel_size, uv, true);
        else
            FragColor = texture(texture_sampler, uv);
    }
    else if ( pass == 1 )
    {
        if ( direction != 2 )
            FragColor = blur_pass(sigma * multiplier, actual_kernel_size, uv, false);
        else
            FragColor = texture(texture_sampler, uv);
    }
}

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
0
0
0
128
135
10
7
#version 300 es
#define PI 3.1415926538

uniform highp vec4 color;
uniform mediump float angle;
uniform mediump float distance;

uniform mediump vec2 canvas_size;
uniform sampler2D texture_sampler;

out highp vec4 FragColor;

void main()
{
    // Base pixel value
    highp vec2 uv = vec2(gl_FragCoord.x / canvas_size.x, gl_FragCoord.y / canvas_size.y);
    highp vec4 pixel = texture(texture_sampler, uv);

    // Pixel value at the given offset
    mediump float radians = -angle * PI / 180.0 + PI / 2.0;
    highp vec2 shadow_uv = vec2(
        (gl_FragCoord.x - distance * cos(radians)) / canvas_size.x,
        1.0 - (gl_FragCoord.y - distance * sin(radians)) / canvas_size.y
    );
    highp vec4 shadow_pixel = texture(texture_sampler, shadow_uv);

    // Colorize shadow
    highp vec4 shadow_color;

    if ( shadow_uv.x >= 0.0 && shadow_uv.x <= 1.0 && shadow_uv.y >= 0.0 && shadow_uv.y <= 1.0 )
    {
        shadow_color = color;
        shadow_color.a = 1.0;
        shadow_color *= shadow_pixel.a * color.a / 255.0;
    }

    // Apply shadow below the base pixel
    FragColor = shadow_color; //pixel * pixel.a + shadow_color * (1.0 - pixel.a);
}
#version 300 es

#define PI 3.1415926538
precision highp float;

uniform float sigma;

uniform mediump vec2 canvas_size;
uniform sampler2D texture_sampler;
uniform int pass;

out vec4 FragColor;

vec4 blur_pass(float sigma, int kernel_size, vec2 uv, bool horizontal)
{
    float side = float(kernel_size / 2);

    vec2 direction_vector = horizontal ?
        vec2(1.0, 0.0) / canvas_size.x :
        vec2(0.0, 1.0) / canvas_size.y;

    vec3 delta_gauss;
    delta_gauss.x = 1.0 / (sqrt(2.0 * PI) * sigma);
    delta_gauss.y = exp(-0.5 / (sigma * sigma));
    delta_gauss.z = delta_gauss.y * delta_gauss.y;

    vec4 avg = vec4(0.0, 0.0, 0.0, 0.0);
    float sum = 0.0;

    vec4 pixel = texture(texture_sampler, uv);
    avg += pixel * delta_gauss.x;
    sum += delta_gauss.x;
    delta_gauss.xy *= delta_gauss.yz;

    for ( float i = 1.0; i <= side; i++)
    {
        for ( float s = -1.0; s <= 1.0; s += 2.0 )
        {
            vec2 pos = uv + s * i * direction_vector;
            pixel = texture(texture_sampler, pos);
            avg += pixel * delta_gauss.x;
        }
        sum += 2.0 * delta_gauss.x;
        delta_gauss.xy *= delta_gauss.yz;
    }

    avg /= sum;

    return avg;
}


void main()
{
    highp vec2 uv = vec2(gl_FragCoord.x / canvas_size.x, gl_FragCoord.y / canvas_size.y);

    int kernel_size = int(0.5 + 6.0 * sigma);

    const float multiplier = 0.25;

    if ( sigma == 0.0 )
        FragColor = texture(texture_sampler, uv);
    else if ( pass == 1 )
        FragColor = blur_pass(sigma * multiplier, kernel_size, uv, true);
    else if ( pass == 2 )
        FragColor = blur_pass(sigma * multiplier, kernel_size, uv, false);
}
#version 300 es

precision highp float;

uniform sampler2D original;

uniform mediump vec2 canvas_size;
uniform sampler2D texture_sampler;

out vec4 FragColor;

vec4 alpha_blend(vec4 top, vec4 bottom)
{
    float comp_alpha = bottom.a * (1.0 - top.a);
    vec4 result;
    result.a = top.a + comp_alpha;
    result.rgb = (top.rgb * top.a + bottom.rgb * comp_alpha) / result.a;
    return result;
}

void main()
{
    highp vec2 uv = vec2(gl_FragCoord.x / canvas_size.x, gl_FragCoord.y / canvas_size.y);

    FragColor = alpha_blend(
        texture(original, uv),
        texture(texture_sampler, vec2(uv.x, 1.0 - uv.y))
    );
}

Pro Levels Effect

Composite
0
1
1
0
1
Red
0
1
1
0
1
Green
0
1
1
0
1
Blue
0
1
1
0
1
#version 100
precision highp float;

uniform highp float composite_in_black;
uniform highp float composite_in_white;
uniform highp float composite_gamma;
uniform highp float composite_out_black;
uniform highp float composite_out_white;

uniform highp float red_in_black;
uniform highp float red_in_white;
uniform highp float red_gamma;
uniform highp float red_out_black;
uniform highp float red_out_white;

uniform highp float green_in_black;
uniform highp float green_in_white;
uniform highp float green_gamma;
uniform highp float green_out_black;
uniform highp float green_out_white;

uniform highp float blue_in_black;
uniform highp float blue_in_white;
uniform highp float blue_gamma;
uniform highp float blue_out_black;
uniform highp float blue_out_white;

uniform mediump vec2 canvas_size;
uniform sampler2D texture_sampler;


float adjust_channel(float value, float in_black, float in_white, float gamma, float out_black, float out_white)
{
    float in_delta = in_white - in_black;
    float out_delta = out_white - out_black;
    if ( in_delta == 0.0 )
        return out_black;

    // Clamp to input range
    if ( value <= in_black && value <= in_white )
        return out_black;

    if ( value >= in_black && value >= in_white )
        return out_white;

    // Apply adjustment
    return out_black + out_delta * pow((value - in_black) / in_delta, 1.0 / gamma);
}


void main()
{
    // Base pixel value
    highp vec2 uv = vec2(gl_FragCoord.x / canvas_size.x, gl_FragCoord.y / canvas_size.y);
    highp vec4 pixel = texture2D(texture_sampler, uv);

    // First Pass: composite
    pixel.rgb = vec3(
        adjust_channel(pixel.r, composite_in_black, composite_in_white, composite_gamma, composite_out_black, composite_out_white),
        adjust_channel(pixel.g, composite_in_black, composite_in_white, composite_gamma, composite_out_black, composite_out_white),
        adjust_channel(pixel.b, composite_in_black, composite_in_white, composite_gamma, composite_out_black, composite_out_white)
    );

    // Second Pass: individual Channels
    pixel.rgb = vec3(
        adjust_channel(pixel.r, red_in_black, red_in_white, red_gamma, red_out_black, red_out_white),
        adjust_channel(pixel.g, green_in_black, green_in_white, green_gamma, green_out_black, green_out_white),
        adjust_channel(pixel.b, blue_in_black, blue_in_white, blue_gamma, blue_out_black, blue_out_white)
    );

    gl_FragColor.rgb = pixel.rgb * pixel.a;
    gl_FragColor.a = pixel.a;
}

Matte3

#version 100
precision highp float;

uniform int channel;
uniform int invert;
uniform int premultiply_mask;
uniform int show_mask;
uniform sampler2D mask_layer;

uniform mediump vec2 canvas_size;
uniform sampler2D texture_sampler;

highp vec3 hsl(vec4 c)
{
    float maxc = max(c.r, max(c.g, c.b));
    float minc = min(c.r, min(c.g, c.b));
    float h = 0.0;
    float s = 0.0;
    float l = (maxc + minc) / 2.0;

    if ( maxc != minc)
    {
        float d = maxc - minc;
        s = l > 0.5 ? d / (2.0 - d) : d / (maxc + minc);
        if ( maxc == c.r )
            h = (c.g - c.b) / d + (c.g < c.b ? 6.0 : 0.0);
        else if ( maxc == c.g )
            h = (c.b - c.r) / d + 2.0;
        else if ( maxc == c.b )
            h = (c.r - c.g) / d + 4.0;

        h /= 6.0;
    }

    return vec3(h, s, l);
}

highp float opacity(vec4 pixel, int channel, int invert, int premultiply)
{
    if ( premultiply == 1 )
        pixel *= pixel.a;

    highp float opacity;

    if ( channel == 1 )
        opacity = pixel.r;
    else if ( channel == 2 )
        opacity = pixel.g;
    else if ( channel == 3 )
        opacity = pixel.b;
    else if ( channel == 4 )
        opacity = pixel.a;
    else if ( channel == 5 )
        opacity = sqrt(pixel.r * pixel.r * 0.299 + pixel.g * pixel.g * 0.587 + pixel.b * pixel.b * 0.114);
    else if ( channel == 6 )
        opacity = hsl(pixel).x;
    else if ( channel == 7 )
        opacity = hsl(pixel).z;
    else if ( channel == 8 )
        opacity = hsl(pixel).y;
    else if ( channel == 9 )
        opacity = 1.0;
    else if ( channel == 10 )
        opacity = 0.0;


    return invert == 1 ? 1.0 - opacity : opacity;
}

vec4 alpha_blend(vec4 top, vec4 bottom)
{
    float comp_alpha = bottom.a * (1.0 - top.a);
    vec4 result;
    result.a = top.a + comp_alpha;
    result.rgb = (top.rgb * top.a + bottom.rgb * comp_alpha) / result.a;
    return result;
}

void main()
{
    // Base pixel value
    highp vec2 uv = vec2(gl_FragCoord.x / canvas_size.x, gl_FragCoord.y / canvas_size.y);
    highp vec4 pixel = texture2D(texture_sampler, uv);

    highp vec4 mask = texture2D(mask_layer, uv);


    gl_FragColor.a = pixel.a * opacity(mask, channel, invert, premultiply_mask);
    gl_FragColor.rgb = pixel.rgb * gl_FragColor.a;

    if ( show_mask == 1 )
        gl_FragColor = alpha_blend(gl_FragColor, mask);
}

Bulge

286
277
197
179
1.9
#version 100

precision highp float;

uniform vec2 center;
uniform vec2 radius;
uniform float height;

uniform mediump vec2 canvas_size;
uniform sampler2D texture_sampler;

vec2 normalize_uv(vec2 coord)
{
    return vec2(coord.x / canvas_size.x, coord.y / canvas_size.y);
}

vec2 exponential_displacement(vec2 uv, float magnitude)
{
    return uv * pow(dot(uv, uv), magnitude) - uv;
}


vec2 spherical_displacement(vec2 uv, float magnitude)
{
    float radius = (1.0 + magnitude) / (2.0 * sqrt(magnitude));


    float arc_ratio = asin(length(uv) / radius) / asin(1.0 / radius);
    return normalize(uv) * arc_ratio - uv;
}

vec2 displace(vec2 owo)
{
    float t = dot(owo, owo);
    if (t >= 1.0)
        return owo;

    float magnitude = abs(height);
    // We modify the magniture to more closely match AE
    magnitude = (2.0/(1.0+exp(-3.0*magnitude))-1.0) * (0.23 * magnitude + 0.14);
    // If the above is too expensive, you can use this instead:
    // magnitude = magnitude * 0.275;
    // Both of the above were derived by interpolating sample points

    float sign = height > 0.0 ? 1.0 : -1.0;
    vec2 displacement =
        exponential_displacement(owo, magnitude) +
        spherical_displacement(owo, magnitude)
    ;
    return owo + displacement * magnitude * sign;

}

void main()
{
    highp vec2 uv = normalize_uv(gl_FragCoord.xy);
    vec2 norm_center = normalize_uv(center);
    vec2 norm_radius = normalize_uv(radius);

    // forward transform
    uv = (uv - norm_center) / norm_radius;
    //displace
    uv = displace(uv);
    // backward transform
    uv = uv * norm_radius + norm_center;

    gl_FragColor = texture2D(texture_sampler, uv);
}

Wave Warp

This effect is animated by default, so it has a "time" slider (in seconds).

10
40
90
0
1
0
#version 100

#define PI 3.1415926538
#define TAU 6.283185307

precision highp float;

uniform int shape;
uniform float amplitude;
uniform float wavelength;
uniform float angle;
uniform float speed;
uniform float phase;
uniform float time;

uniform mediump vec2 canvas_size;
uniform sampler2D texture_sampler;


vec2 normalize_uv(vec2 coord)
{
    return vec2(coord.x / canvas_size.x, coord.y / canvas_size.y);
}

float clamp_angle(float angle)
{
    return mod(angle, TAU);
}

vec2 project(vec2 a , vec2 b)
{
    return dot(a, b) / dot(b, b) * b;
}

float semicircle(float x)
{
    return sqrt(1.0 - pow(clamp_angle(x) / PI - 1.0, 2.0));
}

// Adapted from http://byteblacksmith.com/improvements-to-the-canonical-one-liner-glsl-rand-for-opengl-es-2-0/
highp float noise(float x)
{
    highp float a = 12.9898;
    highp float b = 78.233;
    highp float c = 43758.5453;
    highp float dt = x * a;
    highp float sn = mod(dt, PI);
    return fract(sin(sn) * c) * 2.0 - 1.0;
}

// Interpolate between two random points
float smooth_noise(float x)
{
    float x_fract = fract(x);
    float x_int = x - x_fract;
    float n1 = noise(x_int);
    float n2 = noise(x_int + 1.0);
    return (n1 * (1.0 - x_fract) + n2 * x_fract);
}

vec2 displace(vec2 uv)
{
    float rad = angle / 180.0 * PI;
    vec2 normal = vec2(cos(rad), sin(rad));
    rad -= PI /2.0;
    vec2 direction = vec2(cos(rad), sin(rad));
    float x = length(project(uv, direction));

    x = x / wavelength * PI - time * speed * TAU + phase / 180.0 * PI;


    float y;

    if ( shape == 1 ) // sine
        y = sin(x);
    else if ( shape == 2 ) // square
        y = clamp_angle(x) < PI ? 1.0 : -1.0;
    else if ( shape == 3 ) // triangle
        y = 1.0 - abs(clamp_angle(x) - PI) / PI * 2.0;
    else if ( shape == 4 ) // sawtooth
        y = 1.0 - clamp_angle(x) / PI;
    else if ( shape == 5 ) // circle
        y = sign(clamp_angle(x) -  PI) * semicircle(2.0 * x);
    else if ( shape == 6 ) // semi circle
        y = 2.0 * semicircle(x) - 1.0;
    else if ( shape == 7 ) // uncircle
        y = sign(clamp_angle(-x) -  PI) * (semicircle(2.0 * x) - 1.0);
    else if ( shape == 8 ) // noise
        y = noise(x);
    else if ( shape == 9 ) // smooth noise
        y = smooth_noise(x * 4.0) ;

    return uv + y * normal * amplitude;

}

void main()
{
    vec2 uv = displace(gl_FragCoord.xy);
    gl_FragColor = texture2D(texture_sampler, normalize_uv(uv));
}