Painting river cobbles and gravel bars by The cobbles student

2. The lit rounded stone, in four passes

← read a master's crop · front page · next: form before paint →

When: any rounded stone from about 9 px to 40+ px across, lit by the sun. Below 9 px, use the lozenge on page 4.

boulder, his and mine

Ferrari's lit boulder (left) and my copy (right) at 10×. The dome is the part the critic called "hard to tell from his".

These are the rules I read out of his pixels (page 1), as one function, paint_stone in stone.py. It takes a boolean mask of the stone, a light vector in screen space (x right, y down, z toward you), per-pixel normals, and a map of what's darker around it. It writes steps into an integer canvas.

the passes

The real paint_stone on a 40 px faceted cobble, with its passes switched on one at a time (top: sun behind-left at 40°; bottom: sun front-left at 35°). Made by code/figs.py.

Pass 1: the face is one dither transition

The whole lit face lives between two adjacent lit steps, FD and FL. N·L doesn't map onto the whole ramp. It maps onto a position inside one transition, stretched so the 20th percentile of the lit face sits at the dark end and the 97th at the light end:

s = N @ L
lit = s > P['term']
sm = s[m & lit]
hi, lo = np.percentile(sm, 97), np.percentile(sm, 20)
t = np.clip((s - lo) / max(hi - lo, 1e-6), 0, 1)
f0, f1 = P['face']                         # (2.0, 2.95) in class units: FD .. FL
x = f0 + (f1 - f0) * t
if size >= 8 and P['lump'] > 0:
    x = x + P['lump'] * smooth_noise(m.shape, rng, P['lump_px'])   # broad worn lumps
x = np.clip(x, *P['face_clip'])

ordered() at the end turns x into classes with a Bayer 2×2 matrix, mixing only neighbours:

BAYER2 = np.array([[0, 2], [3, 1]]) / 4.0 + 1 / 8.0
def ordered(v, classes, ox=0, oy=0):
    lo = np.floor(v).astype(int); f = v - lo
    t = BAYER2[(np.arange(H)[:, None] + oy) % 2, (np.arange(W)[None, :] + ox) % 2]
    return np.asarray(classes)[np.where(f > t, np.minimum(lo + 1, len(classes) - 1), lo)]

The lumps are what make it patchy instead of mechanical. His face has whole rows of FD next to rows of checker.

Pass 2: planes, not a gradient (banding)

On a faceted cobble (page 3) a linear map gives a pegboard: evenly spaced light dots over the whole face. Band it, so each step is flat for most of its range and the checker only fills a short seam:

lo_ = np.floor(x); f_ = x - lo_; bw = P.get('band', 0.45)
x = lo_ + np.clip((f_ - (0.5 - bw / 2)) / bw, 0, 1) * 0.999

Then, near the terminator, the surface walks down through the lit family (N·L thresholds, not distances):

ta, tb, tc = P['turn']                    # (0.22, 0.12, 0.05)
x = np.where(s < ta, np.minimum(x, 1.5), x)   # TURN/FD seam
x = np.where(s < tb, np.minimum(x, 1.0), x)   # TURN
x = np.where(s < tc, np.minimum(x, 0.0), x)   # EDGE

Pass 3: the crescent, measured in pixels from the silhouette

This is the thing that took me longest. Ferrari's dark crescent on the side away from the light is thin and even in pixels, not in angle: core for 2–3 px, a seam, turn for about 2 px, a seam, then the face. It's thickest at the rim point opposite the light (a little below it, since a stone sits and its underside always turns away), and it dies out within about 45°.

d = ndi.distance_transform_edt(m) - 1.0            # px from the silhouette (0 on it)
o = outward(m)                                      # unit vector from the centre, per pixel
c2 = -L2 + np.array([0.0, P['cres_down']])          # opposite the light, pushed down 0.35
c2 = c2 / np.linalg.norm(c2)
cc = o @ c2                                         # 1 at the thickest point
wmax = P['wmax'] * size                             # 20% of the stone's minor size
w = wmax * np.clip((cc - P['cres_cut']) / (1 - P['cres_cut']), 0, 1)   # cut 0.7: dies at ~45 deg
u = d / np.maximum(w, 1e-6) + jit * smooth_noise(m.shape, rng, 0.7) * 2.2   # +-1 px jitter
prof = np.select([u < P['u8'], u < P['u8b'], u < P['uB'], u < P['uBD']],
                 [0.0, 0.5, 1.0, 1.5], default=9.0)   # EDGE | EDGE/TURN | TURN | TURN/FD
x = np.minimum(x, np.where(w > 0.9, prof, 9.0))

The band fractions u8=0.45, u8b=0.56, uB=0.75, uBD=0.9 are the ones my class counts converged on against his crop.

Pass 4: crown, found rim, bottom

x = np.where((e_up < P['crown_rows']) & (x >= 2), 3.0, x)
ring = m & ~ndi.binary_erosion(m, CROSS)
ring2 = m & ~ndi.binary_erosion(m, CROSS, iterations=2) & ~ring
nb_dark = ndi.binary_dilation(dark_nb & ~m, CROSS) & m
away_rim = ring & nb_dark & (ca < -0.1) & (e_dn > 0) & lit
rv = np.clip(3.0 - 2.2 * np.clip(down + 0.3, 0, 1.3), 1.0, 3.0)    # FL high up .. TURN low
x = np.where(away_rim, np.minimum(x + 1, np.round(rv)), x)
inner = ring2 & ndi.binary_dilation(away_rim, CROSS) & lit
x = np.where(inner, np.minimum(x, np.where(down < 0.0, 1.0, 0.0)), x)   # core: TURN high, EDGE low
x = np.where(e_dn == 0, 0.0, x)
x = np.where(e_dn == 1, np.minimum(x, 1.0), x)

Outside the stone, contact_under puts CONTACT on the ground row under flat bottom runs (two or more pixels wide), never under a diagonal:

bot = m & (edge_dist_down(m) == 0)
below = shift(bot, 0, 1) & ~m
flat = below & (shift(below, 1, 0) | shift(below, -1, 0))
out[flat] = CONTACT

The shadow side

Where N·L ≤ 0 the stone switches to the shadow family: contact, shadow dark, shadow, shadow light. There the "light" is bounce from the sunlit gravel, which arrives from below and from the side away from the sun. The band just above the contact takes one extra step of it:

bounce = np.clip(0.6 * N[..., 1] + 0.4 * (N[..., :2] @ -L2), 0, 1)      # faces down/away
xs_ = 1.9 + 1.1 * bounce ** 1.5 + 0.25 * smooth_noise(m.shape, rng, 2.0)
xs_ = np.where(e_dn == 0, 0.6, xs_)
xs_ = np.where((e_dn >= 1) & (e_dn <= 2) & (size >= 10), np.maximum(xs_, 2.9), xs_)
vs = ordered(np.clip(xs_, 0, 3), shadow_classes)
v = np.where(lit, v, vs)

four lights

One stone under four suns, 40 px down to 4 (greyscale, nine steps).

Traps

inflate

nb030. My first copy's normals came from inflating the silhouette with a distance transform. That gives a blobby dome, and every shading band follows the outline, so the crescent became a ring all the way round. An ellipsoid dome fitted to the silhouette's image moments (dome_normals) or, better, true 3D normals (page 3) fixes it.