Animated Math — A Fish’s Journey in Python

Creating animations is a great way to pair math concepts with python programming skills. This article explores an animated fish that swims across the screen, following your mouse. We’ll break down the math principles behind it and show how Python implements them.

An Animated Fish in Python (GIF)

Setting Up the Environment

Before diving into the code, create a virtual environment to keep the project dependencies isolated.

On Windows:

python -m venv fish_env
fish_env\Scripts\activate
pip install pygame scipy

On Linux:

python3 -m venv fish_env
source fish_env/bin/activate
pip install pygame scipy

The Math Behind the Animation

Mathematics is at the core of this fish animation. The code utilizes trigonometry, coordinate transformations, and spline interpolation to bring the fish to life.

1. Trigonometry and Angle Constraints

The fish’s spine consists of connected joints (or segments), calculated dynamically using trigonometry. This creates a chain that mimics the motion of a swimming fish.

Each joint’s position is computed using the angle between two points:

dx = pos[0] - self.joints[0][0]
dy = pos[1] - self.joints[0][1]
angle = math.atan2(dy, dx)

Here, atan2 calculates the angle between the x-axis and the line connecting two points. By iteratively applying this calculation across all spine joints, the fish forms smooth curves as it follows the mouse.

Angle Constraints: To avoid unrealistic bending, the angles between consecutive joints are constrained:

angle = self.constrain_angle(angle, self.angles[i - 1], self.angle_constraint)

The constrain_angle method ensures that each segment bends within a specified range (angle_constraint).

2. Smoothing the Fish’s Body

The fish’s body outline is drawn by tracing the positions of the spine joints and their offsets, forming a loop around the spine. Without smoothing, the body looks jagged as each connection is visible.

The scipy.interpolate.splprep function generates smooth curves:

tck, _ = splprep([x, y], s=2, per=True)
x_smooth, y_smooth = splev(np.linspace(0, 1, 100), tck)

This code takes raw points (x, y) and interpolates them to create a smooth boundary. These points are then used to draw the fish’s body and tail.

3. Animating the Tail

The tail’s motion is calculated from the last four joints of the spine. Its width dynamically changes based on the fish’s bending. To make the tail’s edge smooth, spline interpolation is applied again:

tail_points = [...]
tck, _ = splprep([x, y], s=3)
x_smooth, y_smooth = splev(np.linspace(0, 1, 100), tck)

This smoothing ensures that the tail blends seamlessly with the fish’s body, even during sharp turns.

4. Eyes Following the Head

The eyes are positioned inside the head using trigonometric transformations. Their position depends on the head’s orientation, ensuring they remain symmetrical and aligned with the front:

right_eye_x = head[0] + math.cos(head_angle) * eye_forward_offset - math.sin(head_angle) * eye_side_offset
right_eye_y = head[1] + math.sin(head_angle) * eye_forward_offset + math.cos(head_angle) * eye_side_offset

This calculation shifts the eyes relative to the head’s center, maintaining proportional spacing and avoiding clipping outside the body.

Building the Fish

With the math foundations in place, let’s discuss how the Python code brings it all together.

1. The Chain Class

This class models the fish’s spine as a chain of connected segments:

class Chain:
    def __init__(self, origin, joint_count, link_size, angle_constraint=math.pi):
        self.link_size = link_size
        self.joints = [origin[:]]
        self.angles = [0.0]
        for _ in range(1, joint_count):
            last_joint = self.joints[-1]
            self.joints.append([last_joint[0], last_joint[1] + link_size])
            self.angles.append(0.0)

Each segment is linked to the next, with its position calculated based on the angle and length.

2. The Fish Class

The Fish class handles drawing and animating the fish:

  • Body: The body is drawn by tracing the spine and calculating offsets using trigonometry.
  • Tail: The tail points are dynamically adjusted and smoothed with splines.
  • Eyes: Eyes are positioned relative to the head using transformations.

3. Interaction with the Mouse

The fish follows the mouse using the resolve method:

def resolve(self, mouse_x, mouse_y):
    head = self.spine.joints[0]
    target = [
        head[0] + (mouse_x - head[0]) * 0.2,
        head[1] + (mouse_y - head[1]) * 0.2,
    ]
    self.spine.resolve(target)

This adds a subtle delay, giving the fish a natural, fluid motion.

Running the Animation

Run the code by typing:

python animated_fish.py

Move your mouse across the screen, and watch the fish follow. Notice how its body curves and tail sways. These motions are powered by the math we explored.

Extensions

This animation is a great starting point for learning about math in graphics. Here are a few ideas for extending the project:

  • Add textures or patterns to the fish body.
  • Introduce more fish that interact with each other.
  • Create a background scene with water effects.

The “Animated Math Fish” shows how math can bring programming to life. By combining trigonometry, splines, and Python libraries, you’ve not only animated a fish but also deepened your understanding of mathematical modeling.

The Full Code:

import pygame
import math
from scipy.interpolate import splprep, splev  # For smoothing the body outline

# Initialize pygame
pygame.init()

# Screen dimensions
WIDTH, HEIGHT = 800, 600
screen = pygame.display.set_mode((WIDTH, HEIGHT))
pygame.display.set_caption("Animated Math Fish")

# Colors
BODY_COLOR = (58, 124, 165)
FIN_COLOR = (129, 195, 215)
WHITE = (255, 255, 255)

# Clock for frame rate
clock = pygame.time.Clock()


class Chain:
    def __init__(self, origin, joint_count, link_size, angle_constraint=math.pi):
        self.link_size = link_size
        self.angle_constraint = angle_constraint
        self.joints = [origin[:]]  # List of [x, y] positions
        self.angles = [0.0]  # List of angles for each joint
        for _ in range(1, joint_count):
            last_joint = self.joints[-1]
            self.joints.append([last_joint[0], last_joint[1] + link_size])
            self.angles.append(0.0)

    def resolve(self, pos):
        # Forward pass: Set head position and propagate
        dx = pos[0] - self.joints[0][0]
        dy = pos[1] - self.joints[0][1]
        self.angles[0] = math.atan2(dy, dx)
        self.joints[0] = pos[:]
        for i in range(1, len(self.joints)):
            dx = self.joints[i - 1][0] - self.joints[i][0]
            dy = self.joints[i - 1][1] - self.joints[i][1]
            angle = math.atan2(dy, dx)
            angle = self.constrain_angle(angle, self.angles[i - 1], self.angle_constraint)
            self.angles[i] = angle
            self.joints[i][0] = self.joints[i - 1][0] - math.cos(angle) * self.link_size
            self.joints[i][1] = self.joints[i - 1][1] - math.sin(angle) * self.link_size

    @staticmethod
    def constrain_angle(angle, target_angle, constraint):
        diff = (angle - target_angle + math.pi) % (2 * math.pi) - math.pi
        return target_angle + max(-constraint, min(constraint, diff))


class Fish:
    def __init__(self, x, y):
        self.spine = Chain([x, y], 12, 64, math.pi / 8)
        self.body_width = [68, 81, 84, 83, 77, 64, 51, 38, 32, 19]

    def resolve(self, mouse_x, mouse_y):
        head = self.spine.joints[0]
        target = [
            head[0] + (mouse_x - head[0]) * 0.2,
            head[1] + (mouse_y - head[1]) * 0.2,
        ]
        self.spine.resolve(target)

    def smooth_outline(self, points):
        # Smooth points using spline interpolation
        x, y = zip(*points)
        tck, _ = splprep([x, y], s=2, per=True)
        x_smooth, y_smooth = splev(np.linspace(0, 1, 100), tck)
        return list(zip(x_smooth, y_smooth))

    def draw_eyes(self, head, head_angle):
        # Head circle radius
        radius = self.body_width[0] / 2  # Radius of the head (blue area)

        # Place eyes fully inside the head circle
        eye_forward_offset = radius * -1.5  # Slightly forward within the head
        eye_side_offset = radius * 0.5    # Constrain lateral distance inside the head

        # Right eye
        right_eye_x = head[0] + math.cos(head_angle) * eye_forward_offset - math.sin(head_angle) * eye_side_offset
        right_eye_y = head[1] + math.sin(head_angle) * eye_forward_offset + math.cos(head_angle) * eye_side_offset

        # Left eye
        left_eye_x = head[0] + math.cos(head_angle) * eye_forward_offset + math.sin(head_angle) * eye_side_offset
        left_eye_y = head[1] + math.sin(head_angle) * eye_forward_offset - math.cos(head_angle) * eye_side_offset

        # Draw the eyes
        pygame.draw.circle(screen, WHITE, (int(right_eye_x), int(right_eye_y)), 6)
        pygame.draw.circle(screen, WHITE, (int(left_eye_x), int(left_eye_y)), 6)

    def draw(self):
        # Helper functions for positioning
        def get_pos_x(i, angle_offset, length_offset):
            return self.spine.joints[i][0] + math.cos(self.spine.angles[i] + angle_offset) * (self.body_width[i] + length_offset)

        def get_pos_y(i, angle_offset, length_offset):
            return self.spine.joints[i][1] + math.sin(self.spine.angles[i] + angle_offset) * (self.body_width[i] + length_offset)

        # Draw fins first (underneath the body)
        pygame.draw.ellipse(
            screen,
            FIN_COLOR,
            (
                get_pos_x(3, math.pi / 3, 0) - 80,
                get_pos_y(3, math.pi / 3, 0) - 32,
                160,
                64,
            ),
        )
        pygame.draw.ellipse(
            screen,
            FIN_COLOR,
            (
                get_pos_x(3, -math.pi / 3, 0) - 80,
                get_pos_y(3, -math.pi / 3, 0) - 32,
                160,
                64,
            ),
        )

        # Draw body outline
        points = []
        for i in range(10):
            points.append((get_pos_x(i, math.pi / 2, 0), get_pos_y(i, math.pi / 2, 0)))
        points.append((get_pos_x(9, math.pi, 0), get_pos_y(9, math.pi, 0)))
        for i in range(9, -1, -1):
            points.append((get_pos_x(i, -math.pi / 2, 0), get_pos_y(i, -math.pi / 2, 0)))

        # Smooth outline
        smoothed_points = self.smooth_outline(points)
        pygame.draw.polygon(screen, BODY_COLOR, smoothed_points)

        # Draw tail
        # Draw tail with smoother transitions
        tail_points = []
        for i in range(8, 12):
            tail_width = 1.5 * (i - 8) ** 2
            tail_points.append(
                (
                    self.spine.joints[i][0] + math.cos(self.spine.angles[i] - math.pi / 2) * tail_width,
                    self.spine.joints[i][1] + math.sin(self.spine.angles[i] - math.pi / 2) * tail_width,
                )
            )
        for i in range(11, 7, -1):
            tail_width = 13  # Ensure symmetry in width
            tail_points.append(
                (
                    self.spine.joints[i][0] + math.cos(self.spine.angles[i] + math.pi / 2) * tail_width,
                    self.spine.joints[i][1] + math.sin(self.spine.angles[i] + math.pi / 2) * tail_width,
                )
            )

        # Smooth the tail points using splprep
        x, y = zip(*tail_points)
        tck, _ = splprep([x, y], s=3)  # Adjust s for smoother curves
        x_smooth, y_smooth = splev(np.linspace(0, 1, 100), tck)
        smooth_tail_points = list(zip(x_smooth, y_smooth))

        pygame.draw.polygon(screen, FIN_COLOR, smooth_tail_points)


        # Draw eyes based on head angle
        head = self.spine.joints[0]
        head_angle = self.spine.angles[0]
        self.draw_eyes(head, head_angle)


def main():
    fish = Fish(WIDTH // 2, HEIGHT // 2)
    running = True

    while running:
        screen.fill((0, 0, 0))
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                running = False

        mouse_x, mouse_y = pygame.mouse.get_pos()
        fish.resolve(mouse_x, mouse_y)
        fish.draw()

        pygame.display.flip()
        clock.tick(60)

    pygame.quit()


if __name__ == "__main__":
    import numpy as np
    main()

Thank you for reading this article. I hope you found it helpful and informative. If you have any questions, or if you would like to suggest new Python code examples or topics for future tutorials, please feel free to reach out. Your feedback and suggestions are always welcome!

Happy coding!
C. C. Python Programming

You can also find this article at Medium.com

Leave a Reply