Painting river cobbles and gravel bars by The cobbles student

3. Fix the form, not the paint

← the lit stone · front page · next: small stones →

When: your stones look like eggs, their terminators like moon phases, or their lit faces like pegboard. Those are form problems, and more dither or more noise won't fix them.

eggs

nb053. Before. Real 3D ellipsoids, lit correctly: every family and every band is there. It's still wrong: every stone is the same perfect ellipse, the terminator is a clean S-curve, and the lit face is an even lattice of light dots.

faceted

After. The same painter, the same lights, the same sizes. The stones are lopsided and cut by planes, so the terminator steps where a facet turns away and the brightest plane is flat.

Look at the photos. A river cobble is a flattened, lopsided disc, a rounded triangle or a loaf. It has a flat top plane, one fatter end, and a straightish underside. Water wear rounds its edges, but it keeps its planes.

photos at pixel sizes

nb026. Photo crops of single cobbles, then area-averaged to 40, 16 and 8 px wide in grey. At 16 px what survives is a flat lit top and a dark underside. At 8 px, a light blob over a dark one.

The 3D cobble (cobble.stone_points)

Build the stone as geometry and use it only as a source of normals for the painter on page 2. Start from a flattened superellipsoid with a few broad lumps, then make it lopsided, then cut it with planes:

def stone_points(D, elong=1.35, flat=0.5, n=2.4, lump=0.06, sink=0.12, yaw_deg=0.0, rng=None, res=220,
                 facets=6, cut=(0.72, 0.92), asym=0.18, round_px=0.06):
    a = D / 2.0; b = a / elong; c = a * flat
    ...
    X = a * sgnpow(np.cos(th), e) * sgnpow(np.cos(ph), e)       # superellipsoid, exponent 2/n
    Y = b * sgnpow(np.cos(th), e) * sgnpow(np.sin(ph), e)
    Z = c * sgnpow(np.sin(th), e) * np.ones_like(ph)
    # lumps: a few low-order bumps in direction space (water-worn: broad, never sharp)
    for _ in range(5):
        v = unit(rng.normal(size=3))
        k += lump * rng.normal() * np.exp((dirs @ v - 1) * 2.5)
    ...
    # lopsided: one end fatter (eggs, loaves, rounded triangles -- never a perfect ellipse)
    if asym:
        g = rng.uniform(-asym, asym)
        h = rng.uniform(-asym, asym) * 0.7
        P = P * np.stack([1 + g * P[..., 0] / a, 1 + h * P[..., 0] / a + g * P[..., 1] / b * 0.5,
                          np.ones_like(P[..., 0])], -1)
    # water-worn facets: planar cuts through the dome (a flat top plane always; a few side planes)
    # soften the crease over a narrow band so worn edges stay rounded but planes stay flat
    if facets:
        Nf = N.copy()
        dirs_c = [np.array([0.0, 0.0, 1.0])]
        for _ in range(facets):
            v = rng.normal(size=3); v[2] = abs(v[2]) * 0.8 + 0.1
            dirs_c.append(unit(v))
        for j, v in enumerate(dirs_c):
            proj = P @ v
            d = proj.max() * (rng.uniform(*cut) if j else rng.uniform(0.78, 0.9))
            ex = proj - d
            P = P - np.clip(ex, 0, None)[..., None] * v        # push points back onto the plane
            w = np.clip(ex / (round_px * D + 1e-6), 0, 1)[..., None]
            Nf = unit_rows(Nf * (1 - w) + v * w)                # normal blends to the plane's
        N = Nf

The first cut is always the top plane (straight up, taken at 78–90% of the height). The others lean upward (v[2] = abs(v[2]) * 0.8 + 0.1), so the planes you see from above are side facets, not undercuts.

Seeing it (stone.cobble_geom)

Sink it into the gravel, look down at 45° with an orthographic camera, z-buffer the samples at 4×4 subpixels, and keep the mask where coverage ≥ 50%. The ground shadow is the same points projected along the sun onto z = 0:

P, Nw, hgt = cb.stone_points(D, elong, flat, n, lump, sink, yaw, rng, res=int(np.clip(D * 5, 40, 220)))
Pa = P[P[:, 2] >= 0]
t = Pa[:, 2] / max(Lw[2], 0.08)
G = Pa - t[:, None] * Lw[None, :]                   # shadow footprint on the ground
cov, Nm, Zm = cb.rasterize(P, Nw, view, W, H, cx, cy)
m = convex_clean(cov >= 0.5)
Ns = world_to_screen(Nm, pitch)                    # screen normals for paint_stone

The defaults I settled on: pitch=45, sink=0.3, flat=0.65, n=2.0, lump=0.12. At 35° and hardly sunk, it's a macaron:

macaron

nb024. My first 3D cobble: pitched 35°, sunk 12%. From the side it reads as a biscuit.

Clean silhouettes: the digital convex hull

Cobbles are convex, and a digitally convex outline has monotone run lengths in each octant, which is exactly Ferrari's clean stepped curve. Replace the rasterised mask by the hull of its pixel centres:

def convex_clean(mask, eps=1e-6):
    ys, xs = np.nonzero(mask)
    hull = ConvexHull(np.stack([xs, ys], 1).astype(float))
    cxy = np.stack([xx, yy], -1).reshape(-1, 2).astype(float)
    inside = np.all(cxy @ hull.equations[:, :2].T + hull.equations[:, 2] <= eps, axis=1)
    return inside.reshape(H, W)

boxy

nb050. The bug: my first hull took all four corners of every pixel and kept anything within 0.35 px of it. Every stone grew half a pixel and got long flat sides. Hull the centres, with no slack.

And the paint half: band the face

Faceting gives flat regions of nearly equal N·L. Map those linearly onto a dither and you get pegboard again. Band the face coordinate, so each step is flat for most of its range with a short checker seam (page 2, pass 2):

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

Trap: trying to paint your way out

Before I touched the geometry, I raised the paint lumps from 0.18 to 0.28. The stones got blotchy and stayed eggs. The critic's note was about the outline, and nothing in the paint pass can change an outline. When a stone looks wrong at 1×, check the shape first.