Rendering animated gifs that loop is fun!

Top-tip, you probably want 'Number of dots' % 'Phase divider' to be zero.
Background color
Number of dots
Number of frames
Loop time
Phase multiplier
Phase divider

Rendering animated gifs that loop is fun!

Top-tip, you probably want 'Number of dots' % 'Phase divider' to be zero.
function lerp($t, $a, $b)
{
    return $a + ($t * ($b - $a));
}

class Dot
{
    public function __construct($color, $sequence, $numberDots, $imageWidth, $imageHeight)
    {
        $this->color = $color;
        $this->sequence = $sequence;
        $this->numberDots = $numberDots;
        $this->imageWidth = $imageWidth;
        $this->imageHeight = $imageHeight;

        if ($this->numberDots < 0) {
            $this->numberDots = 0;
        }
    }

    public function calculateFraction($frame, $maxFrames, $timeOffset, $phaseMultiplier, $phaseDivider)
    {
        $frame = -$frame;
        $totalAngle = 2 * $phaseMultiplier;
        $fraction = ($frame / $maxFrames * 2);
        $fraction += $totalAngle * ($this->sequence / $this->numberDots);
        if ($phaseDivider != 0) {
            $fraction += (($this->sequence)) / ($phaseDivider);
        }
        $fraction += $timeOffset;

        while ($fraction < 0) {
            //fmod does not work 'correctly' on negative numbers
            $fraction += 64;
        }

        $fraction = fmod($fraction, 2);
        
        if ($fraction > 1) {
            $unitFraction =  2 - $fraction;
        }
        else {
            $unitFraction = $fraction;
        }

        return $unitFraction * $unitFraction * (3 - 2 * $unitFraction);
    }


    public function render(\ImagickDraw $draw, $frame, $maxFrames, $phaseMultiplier, $phaseDivider)
    {
        $innerDistance = 40;
        $outerDistance = 230;

        $sequenceFraction = $this->sequence / $this->numberDots;
        $angle = 2 * M_PI * $sequenceFraction;
        
        $trailSteps = 5;
        $trailLength = 0.1;
        
        $offsets = [
            100 => 0,
        ];
        
        for ($i=0; $i<=$trailSteps; $i++) {
            $key = intval(50 * $i / $trailSteps);
            $offsets[$key] = $trailLength * ($trailSteps - $i) / $trailSteps;
        }

        //TODO - using a pattern would make the circles look more natural
        //$draw->setFillPatternURL();

        foreach ($offsets as $alpha => $offset) {
            $distanceFraction = $this->calculateFraction($frame, $maxFrames, $offset, $phaseMultiplier, $phaseDivider);
            $distance = lerp($distanceFraction, $innerDistance, $outerDistance);
            $xOffset = $distance * sin($angle);
            $yOffset = $distance * cos($angle);
            $draw->setFillColor($this->color);
            $draw->setFillAlpha($alpha / 100);

            $xOffset = $xOffset * $this->imageWidth / 500;
            $yOffset = $yOffset * $this->imageHeight / 500;

            $xSize = 4 * $this->imageWidth / 500;
            $ySize = 4 * $this->imageHeight / 500;
            
            $draw->circle(
                $xOffset,
                $yOffset,
                $xOffset + $xSize,
                $yOffset + $ySize
            );
        }
    }
}


function whirlyGif($numberDots, $numberFrames, $loopTime, $backgroundColor, $phaseMultiplier, $phaseDivider)
{
    $aniGif = new \Imagick();
    $aniGif->setFormat("gif");

    $width = 500;
    $height = $width;
    
    $numberDots = intval($numberDots);
    if ($numberDots < 1) {
        $numberDots = 1;
    }

    $maxFrames = $numberFrames;
    $frameDelay = ceil($loopTime / $maxFrames);

    $scale = 1;
    $startColor = new \ImagickPixel('red');
    $dots = [];

    for ($i=0; $i<$numberDots; $i++) {
        $colorInfo = $startColor->getHSL();

        //Rotate the hue by 180 degrees
        $newHue = $colorInfo['hue'] + ($i / $numberDots);
        if ($newHue > 1) {
            $newHue = $newHue - 1;
        }

        //Set the ImagickPixel to the new color
        $color = new \ImagickPixel('#ffffff');
        $colorInfo['saturation'] *= 0.95;
        $color->setHSL($newHue, $colorInfo['saturation'], $colorInfo['luminosity']);

        $dots[] = new Dot($color, $i, $numberDots, $width, $height);
    }

    for ($frame = 0; $frame < $maxFrames; $frame++) {
        $draw = new \ImagickDraw();
        $draw->setStrokeColor('none');
        $draw->setFillColor('none');
        $draw->rectangle(0, 0, $width, $height);
        
        $draw->translate($width / 2, $height / 2);

        foreach ($dots as $dot) {
            /** @var $dot Dot */
            $dot->render($draw, $frame, $maxFrames, $phaseMultiplier, $phaseDivider);
        }

        //Create an image object which the draw commands can be rendered into
        $imagick = new \Imagick();
        $imagick->newImage($width * $scale, $height * $scale, $backgroundColor);
        $imagick->setImageFormat("png");

        $imagick->setImageDispose(\Imagick::DISPOSE_PREVIOUS);

        //Render the draw commands in the ImagickDraw object
        //into the image.
        $imagick->drawImage($draw);
                
        $imagick->setImageDelay($frameDelay);
        $aniGif->addImage($imagick);
        $imagick->destroy();
    }

    $aniGif->setImageFormat('gif');
    $aniGif->setImageIterations(0); //loop forever
    $aniGif->mergeImageLayers(\Imagick::LAYERMETHOD_OPTIMIZEPLUS);

    header("Content-Type: image/gif");
    echo $aniGif->getImagesBlob();
}