Polar Noise

This tutorial builds a stack of animated organic shapes from points sampled around a circle. Each point starts as a polar vector, then two-dimensional simplex noise changes its radius. Several versions of every path are generated and animated to create a continuous wave motion.

The final SVG has no background, so the same file can be displayed on both light and dark pages.

Imports and Canvas

Vector.from_polar() converts an angle in degrees into a unit vector. SimplexNoise2D supplies repeatable radius offsets.

import pydreamplet as dp
from pydreamplet.noise import SimplexNoise2D

theme = dp.Theme()
noise = SimplexNoise2D(seed=42)
svg = dp.SVG(1024, 1024)

Using a fixed seed keeps the generated artwork stable between runs. Changing the seed creates a different family of contours while preserving the rest of the composition.

Add a Drop Shadow

SVG filters belong in a <defs> element. Here feDropShadow separates the overlapping shapes without adding a background color.

defs = svg.ensure_defs()
shadow = dp.Filter(
    id="drop-shadow",
    x="-20%",
    y="-20%",
    width="140%",
    height="140%",
)
shadow.append(
    dp.SvgElement(
        "feDropShadow",
        dx=0,
        dy=10,
        stdDeviation=8,
        flood_color="#000000",
        flood_opacity=0.65,
    )
)
defs.append(shadow)

The expanded filter bounds prevent the blurred shadow from being clipped near the edge of a path.

Center the Drawing

All generated coordinates are relative to (0, 0). Translating one group to the center keeps the point-generation code independent from the canvas size.

margin = 64
group = dp.G(pos=(svg.w / 2, svg.h / 2))
svg.append(group)

shape_count = 8
min_radius = 20
max_radius = svg.w / 2 - margin
radius_step = (max_radius - min_radius) / (shape_count - 1)
palette = dp.generate_colors("#db45f9", n=shape_count)

The first shape uses the largest radius. Each following shape moves inward by radius_step, producing nested layers.

Generate One Noisy Contour

For every angle, create two unit vectors. direction determines the position of the point. noise_direction determines where the noise field is sampled.

line = dp.LineGenerator(curve="linear")
offset = dp.Vector(1, 1)

base_radius = max_radius
phase = 0
points: list[tuple[float, float]] = []

angle = 0.0
while angle < 360:
    direction = dp.Vector.from_polar(angle)
    noise_direction = dp.Vector.from_polar(angle + phase)
    radius = base_radius + noise.noise(
        noise_direction.x,
        noise_direction.y,
        frequency=1.5,
        amplitude=50,
    )
    points.append((direction * radius + offset).xy)
    angle += 5

path_data = f"{line(points)} Z"

Sampling noise along a unit circle makes the beginning and end of the contour meet naturally. The small offset avoids sampling the exact same symmetric coordinates around the noise origin.

An angle step of 5 provides enough detail for this composition while keeping the animated SVG compact. Appending Z closes the contour, connecting the last sampled point back to the first so the fill has a clean boundary.

Build Animation Frames

SVG path morphing requires every value to contain a compatible sequence of path commands. Each phase therefore uses the same angle loop and the same number of points.

wave_phases = (0, 90, 180, 270, 360)
wave_paths: list[str] = []

for phase in wave_phases:
    points = []
    angle = 0.0
    while angle < 360:
        direction = dp.Vector.from_polar(angle)
        noise_direction = dp.Vector.from_polar(angle + phase)
        radius = base_radius + noise.noise(
            noise_direction.x,
            noise_direction.y,
            frequency=1.5,
            amplitude=50,
        )
        points.append((direction * radius + offset).xy)
        angle += 5

    wave_paths.append(f"{line(points)} Z")

The final phase is 360, which returns to the orientation used by phase 0. That gives the animation matching start and end states for a seamless loop.

Layer and Animate the Shapes

Create one path for each palette color. The path starts with the first frame, and an SVG <animate> element cycles its d attribute through all generated frames.

wave_duration = "7s"

for shape_index, color in enumerate(palette):
    base_radius = max_radius - shape_index * radius_step
    wave_paths = []

    for phase in wave_phases:
        points = []
        angle = 0.0
        while angle < 360:
            direction = dp.Vector.from_polar(angle)
            noise_direction = dp.Vector.from_polar(angle + phase)
            radius = base_radius + noise.noise(
                noise_direction.x,
                noise_direction.y,
                frequency=1.5,
                amplitude=50,
            )
            points.append((direction * radius + offset).xy)
            angle += 5

        wave_paths.append(f"{line(points)} Z")

    path = dp.Path(
        wave_paths[0],
        opacity=0.75,
        stroke=theme.ink,
        fill=color,
        filter="url(#drop-shadow)",
    )
    path.append(dp.Animate("d", values=wave_paths, dur=wave_duration))
    group.append(path)

The outer path is appended first and the smaller paths are appended afterward, so every inner layer remains visible above the previous one.

Complete Script

import pydreamplet as dp
from pydreamplet.noise import SimplexNoise2D

theme = dp.Theme()
noise = SimplexNoise2D(seed=42)
svg = dp.SVG(1024, 1024)

defs = svg.ensure_defs()
shadow = dp.Filter(
    id="drop-shadow",
    x="-20%",
    y="-20%",
    width="140%",
    height="140%",
)
shadow.append(
    dp.SvgElement(
        "feDropShadow",
        dx=0,
        dy=10,
        stdDeviation=8,
        flood_color="#000000",
        flood_opacity=0.65,
    )
)
defs.append(shadow)

margin = 64
group = dp.G(pos=(svg.w / 2, svg.h / 2))
svg.append(group)

shape_count = 8
line = dp.LineGenerator(curve="linear")
offset = dp.Vector(1, 1)
palette = dp.generate_colors("#db45f9", n=shape_count)
min_radius = 20
max_radius = svg.w / 2 - margin
radius_step = (max_radius - min_radius) / (shape_count - 1)
wave_phases = (0, 90, 180, 270, 360)
wave_duration = "7s"

for shape_index, color in enumerate(palette):
    base_radius = max_radius - shape_index * radius_step
    wave_paths: list[str] = []

    for phase in wave_phases:
        points: list[tuple[float, float]] = []
        angle = 0.0
        while angle < 360:
            direction = dp.Vector.from_polar(angle)
            noise_direction = dp.Vector.from_polar(angle + phase)
            radius = base_radius + noise.noise(
                noise_direction.x,
                noise_direction.y,
                frequency=1.5,
                amplitude=50,
            )
            points.append((direction * radius + offset).xy)
            angle += 5

        wave_paths.append(f"{line(points)} Z")

    path = dp.Path(
        wave_paths[0],
        opacity=0.75,
        stroke=theme.ink,
        fill=color,
        filter="url(#drop-shadow)",
    )
    path.append(dp.Animate("d", values=wave_paths, dur=wave_duration))
    group.append(path)

svg.save("polar_noise.svg")