Monday, February 28, 2022

Animating pulsing hexagons

Now that we can draw some hexagons, I'd like to animate them cross-fading occasionally from one color to another. This process will involve a few steps; we need to set up a rudimentary animation framework, and from there we'll take advantage of it to control the color of specific hexes and update them as they cross-fade back and forth.

The framework

The animation framework is pretty simple: * A HexAnimate abstract object to track animation state and update the canvas * A PulseHex concrete object that cross-fades a hex from one color to another and back * A list of currently-running animations * An update loop * A system for spawning new animations when the previous one is done

HexAnimate

A HexAnimate object is just an object that knows how to animate itself:

public abstract animate(timestamp: number, ctx: CanvasRenderingContext2D): boolean;

The timestamp is passed in so every animating element is sync'd to the same time, ctx is where to render to. Finally, the whole animate method returns true if it should be scheduled again next animation frame or false if it is done animating and can be discarded.

PulseHex

The PulseHex is a non-generalized animation to cross-fade one hex's color from a start point to a "peak" point and back over time.

export class PulseHex extends HexAnimate {
    constructor(
        private xIndex: number,
        private yIndex: number,
        private startTime: number,
        private peakTime: number,
        private endTime: number,
        private startColor: Color,
        private endColor: Color) {
        super();
    }

    public animate(timestamp: number, ctx: CanvasRenderingContext2D): boolean {
        if (timestamp > this.endTime) {
            // Render one last time to clear all the way to the initial color
            ctx.save();
            HEX_TEMPLATE.offsetToHex(ctx, this.xIndex, this.yIndex);
            HEX_TEMPLATE.render(ctx, colorToCode(this.startColor));
            ctx.restore();

            return false;
        }
        const color = timestamp < this.peakTime ?
            interpolate(this.startColor, this.endColor, (timestamp - this.startTime) / (this.peakTime - this.startTime)) :
            interpolate(this.startColor, this.endColor, (this.endTime - timestamp) / (this.endTime - this.peakTime));


        let stringRep = colorToCode(color);

        ctx.save();
        HEX_TEMPLATE.offsetToHex(ctx, this.xIndex, this.yIndex);
        HEX_TEMPLATE.render(ctx, stringRep);
        ctx.restore();

        return true;
    }
}

The constructor takes in the X and Y index of what hex to animate, a start, peak, and end timestamp, and what color we should start (and end) at and the color we should reach at peak time.

The animation logic splits into a terminator and a continuation: the first condition checks to see if we're past end time, and if we are we draw one last hexagon at start color to reset the hex to initial conditions. If we're not, we pick a color by interpolating between start and end (if before peak time) or end and start (if after peak time). The interpolation function is a very simple a + t(b-a) interpolation of each of the red, green, and blue components; not the fanciest (or necessarily the best-looking from a human perception standpoint), but it's a reasonable starting point. Once color is chosen, we draw the relevant hexagon.

List of currently-running animations

let animations: HexAnimate[] = [];

Nothing fancy here; literally an array of animation objects. Worth noting is that this allows more than one to be running at a time; we don't take advantage of that right now, but we will!

Update loop

const timestamp = Date.now();

// ...

animations = animations.filter((animation) => animation.animate(timestamp, context));

updateBackground(canvas);

setTimeout(updateRender, 1000 / FPS);

The update loop is very simple: we ask every animation to run, and if it returns false we drop it out of the list of animations for next loop. Once they've run, we push the new canvas to the background and schedule ourselves to run again.

A system for spawning new animations when the previous one is done

if (animations.length === 0) {
    animations.push(new PulseHex(
        Math.floor(Math.random() * (CANVAS_HEXES_ACROSS - 2)) + 1,
        Math.floor(Math.random() * (CANVAS_HEXES_DOWN - 2)) + 1,
        timestamp,
        timestamp + 1000,
        timestamp + 3000,
        codeToColor(DEFAULT_HEX_COLOR)!,
        {
            r: Math.random() * 256,
            g: Math.random() * 256,
            b: Math.random() * 256,
        }
    ));
}

When our animation queue is empty, we construct a new one. This approach will guarantee us exactly one running at all times. We cheat a bit on the logic of the X and Y coordinates to inset from the edges so that we don't reveal the repetition seam of the background image.

With all those pieces put together, I'm pretty happy with the end result!


The full code is available on GitHub.

No comments:

Post a Comment