Walldye

Ribbons

inspired by Tyler Hobbs, Fidenza1

Mostly edge to edge, ribbons of mixed widths follow a flow field without touching. Five high on the right are lit.

Made with Claude Opus 5.5

Technique
flow fields and contours
Inspired by
creative coding
Shape
Any screen
Added
27 September 2026

Colours

  • #1C1B1Abackground
  • #DAD8CEforeground
  • #CF6A4Caccent

Export

Format
Shape
Size

Notes

The method comes from Tyler Hobbs’s Fidenza. Each ribbon is a streamline of a Perlin noise field, grown both ways from its starting point until it comes within a set gap of a neighbour. The school is laid first with a wider gap around it, then the ribbons that start near the middle, so most cross the whole frame. Towards the left edge the field turns less and the current enters almost level. On narrow screens the school shortens and moves inwards so both of its ragged ends stay in view.

Sources

  1. Tyler Hobbs, Fidenza, 2021. ↑
  2. Tyler Hobbs, Flow Fields, 2020.

Source code

wallpapers/ribbons/design.py, 130 lines

"""Fidenza-style ribbons: streamlines grown through a Perlin flow field, kept apart by an occupancy grid, with one school picked out."""

import math

import numpy as np
from numpy.typing import NDArray
from shapely import LineString
from shapely.ops import unary_union

from walldye import (
    ACCENT,
    ACCENT_1,
    ACCENT_3,
    BG_ALT,
    UI,
    Canvas,
    Rect,
    Vec,
    design,
    polar,
    smoothstep,
)
from walldye.geom import poisson_disk

CELL = 4  # occupancy grid resolution
STEP = 6  # streamline step length
PAD = 80  # ribbons may run this far past the frame so they leave it cleanly
MARGIN = 10  # gap between neighbouring ribbons
HALO = 26  # wider gap kept clear around the picked-out school
FIELD = 8  # flow field sample spacing
TONES = (BG_ALT, UI, ACCENT_3, ACCENT_1, ACCENT)  # paint order; ribbons refer to these by index
# the school, laid across the flow: width, tone index, length back and forward along it
SCHOOL = (
    (8, 2, 400, 360),
    (14, 3, 440, 470),
    (28, 4, 470, 420),
    (12, 3, 430, 330),
    (6, 2, 460, 400),
)
WIDTHS = (6, 8, 10, 12, 16, 20, 24, 30)

type Line = list[tuple[float, float]]


@design(aspects="any")
def draw(s: Canvas) -> None:
    width, height = s.w, s.h
    rng, noise = s.rng(11), s.noise(11)
    xs = np.arange(-PAD, width + PAD + FIELD, FIELD)
    ys = np.arange(-PAD, height + PAD + FIELD, FIELD)
    field = noise.fbm(xs[None, :] / 880, ys[:, None] / 880, 2, gain=0.35)
    field -= field.mean()
    occ = np.zeros(((height + 2 * PAD) // CELL + 2, (width + 2 * PAD) // CELL + 2), bool)

    def heading(x: float, y: float) -> float:
        """The flow direction at (x, y) in radians: calm on the left, gathering swirl across
        the first half of the frame."""
        gain = 1.5 * (0.3 + 0.7 * smoothstep(0.05 * width, 0.47 * width, x))
        return gain * float(field[int((y + PAD) / FIELD), int((x + PAD) / FIELD)])

    def disk(x: float, y: float, rad: float) -> tuple[tuple[slice, slice], NDArray[np.bool_]]:
        """The slice of `occ` around (x, y) and the boolean disk of radius `rad` within it."""
        i0, j0 = max(int((y + PAD - rad) / CELL), 0), max(int((x + PAD - rad) / CELL), 0)
        i1 = min(int((y + PAD + rad) / CELL) + 2, occ.shape[0])
        j1 = min(int((x + PAD + rad) / CELL) + 2, occ.shape[1])
        yy, xx = np.ogrid[i0:i1, j0:j1]
        inside = (yy * CELL - PAD - y) ** 2 + (xx * CELL - PAD - x) ** 2 <= rad * rad
        return (slice(i0, i1), slice(j0, j1)), inside

    def free(x: float, y: float, rad: float) -> bool:
        sl, mask = disk(x, y, rad)
        return -PAD < x < width + PAD and -PAD < y < height + PAD and not (occ[sl] & mask).any()

    def grow(x: float, y: float, w: float, back: float, fwd: float) -> Line | None:
        """The streamline through (x, y), up to `back` and `fwd` units each way, stopping at
        any neighbour; None when (x, y) itself is taken."""
        if not free(x, y, w / 2):
            return None
        halves: list[Line] = []
        for sign, length in ((1, fwd), (-1, back)):
            px, py, half = x, y, []
            for _ in range(int(length / STEP)):
                a = heading(px, py)
                px, py = px + sign * STEP * math.cos(a), py + sign * STEP * math.sin(a)
                if not free(px, py, w / 2):
                    break
                half.append((px, py))
            halves.append(half)
        return [*halves[1][::-1], (x, y), *halves[0]]

    def stamp(line: Line, w: float, margin: float) -> None:
        for x, y in line:
            sl, mask = disk(x, y, w / 2 + margin)
            occ[sl] |= mask

    # The school sits high on the right; on narrow screens it shortens and slides inwards so
    # both ragged ends stay in frame. It is laid first so the current parts around it.
    k = min(1.0, width / 1500)
    anchor = Vec(min(0.72 * width, width - 520 * k), 0.24 * height)
    across = heading(*anchor) + math.pi / 2  # stack the ribbons across the local flow
    ribbons: list[tuple[Line, float, int]] = []
    off = 0.0
    for w, t, back, fwd in SCHOOL:
        off += w / 2
        start = polar(anchor, off, rad=across)
        line = grow(start.x, start.y, w, back * k, fwd * k)
        off += w / 2 + MARGIN + 3
        if line:
            ribbons.append((line, w, t))
    for line, w, _ in ribbons:
        stamp(line, w, HALO)
    halo = unary_union([LineString(line).buffer(w / 2) for line, w, _ in ribbons])

    # Seeds near the vertical centre line go first and run long, so most ribbons cross the
    # frame edge to edge.
    seeds = poisson_disk(Rect(-PAD, -PAD, width + 2 * PAD, height + 2 * PAD), 52, rng).tolist()
    for x, y in sorted(seeds, key=lambda p: abs(p[0] - width / 2) + rng.uniform(0, 0.36 * width)):
        w = rng.choice(WIDTHS)
        length = rng.uniform(0.47, 1.25) * width + 400
        line = grow(x, y, w, length / 2, length / 2)
        if line and len(line) * STEP > min(480, 0.4 * width):
            stamp(line, w, MARGIN)
            # a few thin ribbons are lifted to UI so the field has some depth, but never next
            # to the school
            lifted = w <= 12 and rng.random() < 0.15 and LineString(line).distance(halo) > 150
            ribbons.append((line, w, 1 if lifted else 0))

    with s.buckets(TONES, "fill") as fills:
        for line, w, t in ribbons:
            fills[t].shape(LineString(line).buffer(w / 2).simplify(0.5))

Run it yourself

$ git clone https://github.com/nickolaj-jepsen/walldye && cd walldye$ uv run walldye render ribbons --theme fireproof -o ribbons-fireproof-16x9.svg