Walldye

Lightning

One jagged channel of lightning drops from a dithered cloud base and lights the ground where it lands. Its branches fork downward and thin out.

Made with Claude Opus 5.5

Technique
line art, dithering
Shape
Any screen
Added
27 September 2026

Colours

  • #1C1B1Abackground
  • #DAD8CEforeground
  • #CF6A4Caccent

Export

Format
Shape
Size

Notes

The channel is built by midpoint displacement. The straight line from cloud to ground is split at its middle and the new point pushed sideways at random, then each half is split the same way until the segments are short. Every push scales with the segment it bends, so the large kinks and the small ones look alike. Branches leave the channel twenty to fifty degrees off its heading, always pointing down and shorter the lower they start, and a few fork again.

In a real strike the stepped leader branches as it works its way down, and the branch that touches the ground carries the return stroke, the most luminous part of the flash. Here that channel glows through a stack of strokes, widest and faintest outside, and the dither thickens where the flash reaches the cloud and the ground.

Sources

  1. Alain Fournier, Don Fussell and Loren Carpenter, Computer rendering of stochastic models, 1982.
  2. Lightning.

Source code

wallpapers/lightning/design.py, 182 lines

"""A lightning strike built by midpoint displacement: a haloed return stroke drops from a dithered cloud base to the ground, forked by tapering branches."""

import math

import numpy as np
from numpy.typing import NDArray
from shapely import Polygon, unary_union

from walldye import (
    ACCENT,
    ACCENT_1,
    ACCENT_3,
    ACCENT_5,
    ACCENT_6,
    ACCENT_8,
    ACCENT_HI,
    BG,
    BG_ALT,
    BG_DEEP,
    UI,
    UI_ALT,
    Canvas,
    NpRng,
    P,
    by_regime,
    design,
    mix,
)
from walldye.field import cells, gauss
from walldye.geom import ribbon
from walldye.pixel import dither, grid_runs

type Pts = NDArray[np.float64]

BOLT = 12  # random stream for the channel and its branches
CELL = 3  # dither cell of the cloud and the lit ground
ROUGH = 0.13  # spread of each midpoint's sideways kick, as a fraction of its segment
STEP = 12.0  # finest segment length
GROUND = by_regime(BG_DEEP, BG_ALT)
SKY = (None, BG_ALT, UI, UI_ALT, BG)  # cloud dither rungs, then the veil over the channel
# Lit ground rungs: as SKY in the dark regime; in the light one, paler than GROUND.
LIT = (None, by_regime(BG_ALT, mix(BG, BG_ALT, 0.5)), by_regime(UI, BG), by_regime(UI_ALT, BG))
# Branch tones from root to tip, per order (1, 2), and the root width of each order.
FADE = ((ACCENT_1, ACCENT_3, ACCENT_5), (ACCENT_3, ACCENT_5))
WIDTH = (2.8, 1.8)
# The return stroke's layered strokes, outermost first: (paint, width).
HALO = ((ACCENT_8, 30.0), (ACCENT_6, 16.0), (ACCENT_3, 8.0), (ACCENT, 4.4), (ACCENT_HI, 1.5))


def displace(a: Pts, b: Pts, rng: NpRng) -> Pts:
    """A jagged polyline from `a` to `b`: every segment is split at its midpoint, pushed sideways
    by a Laplace draw of ROUGH times its length (capped at three times that), until no segment
    is longer than STEP. The kick scales with the segment, so the kinks look alike at every
    scale."""
    pts = np.array([a, b], np.float64)
    while True:
        seg = np.diff(pts, axis=0)
        if np.hypot(seg[:, 0], seg[:, 1]).max() <= STEP:
            return pts
        kick = np.clip(rng.laplace(0, ROUGH, len(seg)), -3 * ROUGH, 3 * ROUGH)
        mid = (pts[:-1] + pts[1:]) / 2 + np.stack([-seg[:, 1], seg[:, 0]], 1) * kick[:, None]
        out = np.empty((2 * len(pts) - 1, 2))
        out[0::2], out[1::2] = pts, mid
        pts = out


def along(pts: Pts) -> NDArray[np.float64]:
    """Arc length at each vertex, as a fraction of the whole."""
    d = np.concatenate([[0], np.cumsum(np.hypot(*np.diff(pts, axis=0).T))])
    return d / d[-1]


def forks(
    rng: NpRng,
    pts: Pts,
    order: int,
    length: float,
    floor: float,
    wall: float,
    out: list[tuple[int, Pts]],
) -> None:
    """Grow branches off the strand `pts` into `out` as (order, points), recursing to order 2.
    Each leaves 20-50 degrees off the strand's local heading, mostly on alternate sides, kept
    within 55 degrees of straight down, and is shorter the lower it starts; its end is pulled
    in to stay above y = `floor` and between x = 60 and x = `wall`."""
    t = along(pts)
    count = (5, 2)[order - 1]
    lo, hi = ((0.1, 0.62), (0.1, 0.7))[order - 1]
    side = rng.choice((-1.0, 1.0))
    for u in np.sort(rng.uniform(lo, hi, rng.integers(count - 1, count + 1))):
        i = int(np.searchsorted(t, u))
        d = pts[min(i + 4, len(pts) - 1)] - pts[max(i - 4, 0)]
        side = -side if rng.random() < 0.7 else side
        head = math.atan2(d[1], d[0]) + side * math.radians(rng.uniform(20, 50))
        head = min(max(head, math.radians(35)), math.radians(145))
        span = length * rng.uniform(0.3, 0.6) * (1 - 0.65 * u)
        end = pts[i] + span * np.array([math.cos(head), math.sin(head)])
        if end[1] > floor:
            end = pts[i] + (end - pts[i]) * (floor - pts[i][1]) / (end[1] - pts[i][1])
        edge = wall if end[0] > wall else max(end[0], 60)
        if edge != end[0]:
            end = pts[i] + (end - pts[i]) * (edge - pts[i][0]) / (end[0] - pts[i][0])
        if np.hypot(*(end - pts[i])) < 24:
            continue
        branch = displace(pts[i], end, rng)
        out.append((order, branch))
        if order < 2:
            forks(rng, branch, order + 1, span, floor, wall, out)


@design(aspects="any")
def draw(s: Canvas) -> None:
    rng = s.np_rng(BOLT)
    noise = s.noise(5)
    if s.landscape:
        top, ground = 200.0, s.h - 150.0
        xr = 0.635 * s.w
        xg = xr - 90
    else:
        top, ground = 0.15 * s.h, 0.86 * s.h
        xr, xg = 0.6 * s.w, 0.5 * s.w

    def base(x: NDArray[np.float64]) -> NDArray[np.float64]:
        """The cloud base's height at x: ragged, and a little lower over the strike."""
        return top + 120 * noise.fbm(x / 340, 0.5, octaves=4) + 30 * gauss(x - xr, 500)

    root = np.array([xr, float(base(np.array([xr]))[0]) - 50])
    hill = ground - 10 * np.clip(noise.fbm(xg / 400, 3.5), 0, 1)
    main = displace(root, np.array([xg, hill]), rng)
    branches: list[tuple[int, Pts]] = []
    forks(rng, main, 1, ground - root[1], ground - 40, s.w - 60, branches)
    tones: dict[tuple[int, int], list[Polygon]] = {}
    for order, pts in branches:
        k = order - 1
        t = along(pts)
        w = np.maximum(0.45, WIDTH[k] * (1 - t) ** 0.8)
        n = len(FADE[k])
        for j in range(n):
            sel = (t >= j / n - 1e-9) & (t <= (j + 1) / n + 0.02)
            if sel.sum() > 1:
                tones.setdefault((k, j), []).append(Polygon(ribbon(pts[sel], w[sel])).buffer(0))

    # the bolt is cut a little inside the cloud base, and the cloud's edge drawn over the cut
    tx = np.linspace(0, s.w, int(s.w // 8) + 1)
    with s.clip() as below:
        below.add(P().poly([(0, s.h), *zip(tx, base(tx) - 25), (s.w, s.h)], closed=True))
    with s.group(clip_path=below.ref, stroke_linecap="round", stroke_linejoin="round"):
        # the return stroke's halo, then the branches, then its core
        for paint, w in HALO[:3]:
            s.stroke(P().poly(main), paint, w)
        for k, j in sorted(tones, key=lambda kj: (-kj[0], -kj[1])):
            s.fill(P().shape(unary_union(tones[k, j])), FADE[k][j])
        for paint, w in HALO[3:]:
            s.stroke(P().poly(main), paint, w)

    # ground over the foot of the halo, lit round the strike
    horizon = np.stack([tx, ground - 10 * np.clip(noise.fbm(tx / 400, 3.5), 0, 1)], 1)
    land = P().poly([(0, s.h), *horizon.tolist(), (s.w, s.h)], closed=True)
    s.fill(land, GROUND)
    with s.clip() as clip:
        clip.add(land)
    xs, ys = cells(s.inset(0), CELL)
    band = ys[:, 0] > ground - 20
    gx, gy = xs[band], ys[band]
    lamp = np.clip(0.72 * gauss(np.hypot(gx - xg, (gy - hill) * 3), 320) - 0.08, 0, 1)
    with s.group(clip_path=clip.ref):
        grid = dither(lamp, 4, method="bluenoise", rng=s.np_rng(8))
        grid_runs(s, grid, LIT, CELL, (0, float(gy[0, 0]) - CELL / 2))

    # cloud: dithered density under a ragged base, lit round the channel's root
    rows = int((top + 200) // CELL)
    cx, cy = xs[:rows], ys[:rows]
    depth = np.clip((base(cx[0])[None, :] - cy) / 70, 0, 1)
    body = depth * (0.5 + 0.5 * gauss(cx - xr, 900))
    body *= 0.5 + 0.3 * noise.fbm(cx / 180, cy / 90 + 7, octaves=3)
    lit = gauss(np.hypot(cx - xr, (cy - root[1] - 60) * 2.4), 320) * depth**0.5
    grid = dither(np.clip(0.75 * body + 0.6 * lit, 0, 1), 4, method="bluenoise", rng=s.np_rng(7))
    # empty cells over the channel are veiled as often as the cloud is deep, so it fades into
    # the cloud instead of showing through every gap
    veil = dither(depth, 2, method="bluenoise", rng=s.np_rng(9)) == 1
    grid[(grid == 0) & veil & (np.abs(cx - xr) < 160)] = 4
    grid_runs(s, grid, SKY, CELL)

Run it yourself

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