javaswinganimationtrident

Why does the locaton of my animated circles flicker?


With trident I created a (seemingly) simple animation. A number of circles are moving from bottom to top and back again with a sine interpolation:

Still image

The animation itself seems to work, but there is one or two frames where all my spheres flicker all to the topmost location.

Why does it flicker? Who is invoking the setY method with seemingly wrong values?

I've made a testclass to reproduce the behavior. You need radiance-trident 3.0 to make it work:

import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.FlowLayout;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.geom.Ellipse2D;
import java.util.ArrayList;
import java.util.List;

import javax.swing.JDialog;
import javax.swing.JPanel;

import org.pushingpixels.trident.api.Timeline;
import org.pushingpixels.trident.api.Timeline.RepeatBehavior;
import org.pushingpixels.trident.api.ease.TimelineEase;
import org.pushingpixels.trident.api.swing.SwingRepaintTimeline;

public class MovingSpheresTest extends JDialog {

    private final double sphereRadius = 3d;

    private final double sphereCount = 12d;

    private final double helixHeight = 100d;

    private final double size = 200d;

    private final double animationSpeed = 0.5d;

    private List<CenteredSphere> spheres;

    private SwingRepaintTimeline repaintTimeline;

    private final JPanel contentPanel = new JPanel() {
        protected void paintComponent(Graphics g) {
            super.paintComponent(g);
            paintFrame((Graphics2D) g);
        }

        private void paintFrame(Graphics2D g) {
            Graphics2D create = (Graphics2D) g.create();
            try {
                create.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
                create.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
                create.setColor(Color.BLACK);
                for (CenteredSphere centeredSphere : spheres) {
                    create.fill(centeredSphere);
                }
            } finally {
                create.dispose();
            }
        }
    };

    /**
     * Launch the application.
     */
    public static void main(String[] args) {
        try {
            MovingSpheresTest dialog = new MovingSpheresTest();
            dialog.setDefaultCloseOperation(JDialog.DISPOSE_ON_CLOSE);
            dialog.setVisible(true);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    /**
     * Create the dialog.
     */
    public MovingSpheresTest() {
        setBounds(100, 100, 450, 300);
        getContentPane().setLayout(new BorderLayout());
        contentPanel.setLayout(new FlowLayout());
        getContentPane().add(contentPanel, BorderLayout.CENTER);
        installSpheres();
        installRepaintTimeline();
    }

    private void installSpheres() {
        double helixRadius = helixHeight / 2;
        double effectiveWidth = size - (2 * sphereRadius);
        double sphereDistance = effectiveWidth / (sphereCount - 1);
        double sphereCenterX = sphereRadius;
        double sphereCenterY = size / 2d;
        double sphereCenterYInitial = sphereCenterY - helixRadius;

        spheres = new ArrayList<>();
        for (int sphereIndex = 0; sphereIndex < sphereCount; sphereIndex++) {
            CenteredSphere sphere = new CenteredSphere(sphereCenterX, sphereRadius);
            spheres.add(sphere);
            sphereCenterX += sphereDistance;
            Timeline.builder()
                    .addPropertyToInterpolate(Timeline.<Double>property("y").on(sphere).from(sphereCenterYInitial)
                            .to(sphereCenterY + helixRadius))
                    .setEase(new FullSine((float) (sphereIndex * 2 * Math.PI / sphereCount)))
                    .setDuration((long) (animationSpeed * 3000d)).playLoop(RepeatBehavior.LOOP);
        }

    }

    private class FullSine implements TimelineEase {

        private float horizontalOffset;

        private FullSine(float horizontalOffset) {
            this.horizontalOffset = horizontalOffset;
        }

        @Override
        public float map(float durationFraction) {
            return ((float) Math.sin(durationFraction * Math.PI * 2f + horizontalOffset) + 1f) / 2f;
        }
    }

    private void installRepaintTimeline() {
        repaintTimeline = SwingRepaintTimeline.repaintBuilder(contentPanel).build();
        repaintTimeline.playLoop(RepeatBehavior.LOOP);
    }

    public class CenteredSphere extends Ellipse2D.Double {

        private double sphereCenterX;
        private double sphereRadius;

        public CenteredSphere(double sphereCenterX, double sphereRadius) {
            this.sphereCenterX = sphereCenterX;
            this.sphereRadius = sphereRadius;
        }

        public void setY(double y) {
            setFrameFromCenter(sphereCenterX, y, sphereCenterX + sphereRadius, y + sphereRadius);
        }
    }
}


Solution

  • As noted here and fixed here,

    This is interesting. It's because of the underlying assumption that the TimelineEase always maps the [0.0-1.0] interval without "warping" the end points. In this particular case, during each animation loop, the custom FullSine is used to remap that interval based on the sphere offset, but during the loop reset, the "end" points are not mapped.