Trigger: the two ends of every log, and the butt of every root fan.
Driftwood's ends say what happened to it: sawn by someone, snapped in a flood, splintered, or rolled round by the river. Four kinds cover what I saw in the photographs, and each one is a different drawing problem.

The commonest driftwood end, and the one I forgot at first. The round cone's own cap turns the five classes round the end: cut() simply skips a worn end.
# code/geo.py, Log.cut
if self.ends[e] == 'worn':
continue
A sawn face is one plane. Paint it one tone by how it faces the sun: 6 lit, 5 turned, 3 in shadow. Then add a 1-px darker arris ring and one to three radial checks from near the pith.
# code/render.py, end_faces()
base = np.where(lit, np.where(ndl > 0.6, 6, 5), 3)
rho = buf['rho'] # radius fraction on the face
v = base.copy()
if r_px >= 3:
arris = rho > 1 - 1.0 / max(r_px, 1) # the softened rim: 1 px, 2 steps down
v = np.where(arris, base - 2, v)
if r_px >= 20:
... # growth rings, as broken arcs
if r_px >= 5:
nchk = 1 + int(hash_angle(lg.seed * 3 + e) / (2 * np.pi) * 3)
for q in range(nchk):
ca = hash_angle(lg.seed + e + 17 * q)
rmin = 0.1 + 0.3 * (q > 0)
chk = (np.abs(np.angle(np.exp(1j * (th - ca)))) * rho * r_px < 0.55) & (rho > rmin)
v = np.where(chk, np.maximum(base - 3, 1), v)
The check test |Δθ| · ρ · r_px < 0.55 is the pixel distance from a radial line: a 1-px-wide ray, whatever the face's size.
Two traps. My first sawn face was pure white, the brightest thing in the frame: the critic said "a stick of chalk". It's now the body's own light value. My second drew growth rings at every size, and they came out as a spiral target, the cartoon-log icon. Weathered end grain at this scale shows no rings: a flat lighter face, a soft dark rim, a few radial cracks. Rings appear only at radii of 20 px or more, and even then as broken arcs.

A broken end is the trunk's shell standing out further in a few angular sectors (the teeth), with the break face falling toward the axis between them. As geometry, that's an angular profile prof(θ) of tooth heights, scaled by radial fraction so the face is a rough cone:
# code/geo.py, Log.__init__
nt = rng.integers(3, 6) if kind_e == 'snapped' else rng.integers(6, 10)
for _ in range(nt):
cang = rng.uniform(0, 2 * np.pi)
w = rng.uniform(0.5, 1.1) if kind_e == 'snapped' else rng.uniform(0.35, 0.7)
h = r_end * (rng.uniform(0.35, 1.0) if kind_e == 'snapped' else rng.uniform(0.6, 2.0))
dd = np.abs(np.angle(np.exp(1j * (ang - cang))))
prof = np.maximum(prof, h * np.clip(1 - dd / w, 0, 1))
...
lip = 1.0 + np.abs(np.diff(np.concatenate([prof, prof[:1]]))).max() / (2 * np.pi / 256) / max(r_end, 1e-6)
# code/geo.py, Log.cut
lim = prof[idx] * np.clip(rho, 0, 1) ** 1.5
dc = (v @ out - lim) / (lip * 1.6 if prof.max() > 0 else 1.0)
d = np.maximum(d, dc)
The bug that taught me this. Without the division by lip, the break rendered as speckle: pixels missing in the middle of solid wood.

A sphere tracer steps forward by the distance the SDF reports. A steep angular profile makes the "distance" to the cut surface far larger than the true distance, so the tracer steps through the surface and misses. lip is the profile's steepest slope per unit arc, expressed as a gradient bound. Dividing by it makes the function a true lower bound again, so tracing is conservative. The factor 1.6 is margin. The cost is more tracing steps (I raised the limit to 200).
Before that I'd tried teeth as separate thin cones, which came out as wires, and a cut plane modulated by angle alone, which came out as blobs. The profile-plus-bound is what worked.
Shading the torn face by N·L gave litter: every tiny facet a different tone. What reads is how far a point stands out along the axis. Tooth tips catch the light and the torn floor between them is recessed:
# code/render.py, end_faces()
s_rel = (buf['P'] - c) @ outv
f = np.clip(s_rel / max(prof.max(), 1e-6), 0, 1) # 0 at the break floor, 1 at the tallest tip
lit_v = np.where(f > 0.55, 7, np.where(f > 0.2, 6, 4))
sh_v = np.where(f > 0.55, 4, np.where(f > 0.2, 3, 2))
v = np.where(lit & (ndl > 0.35), lit_v, np.where(ndl > 0.0, np.minimum(lit_v, 5) - 1, sh_v))
...
out = despeckle(out, m) # a pixel unlike all four neighbours takes their majority
Raw wood at the break hasn't silvered, so its tips (7) are the palest thing on the log.
The splinters of a splintered end are thinner than a pixel, so they are drawn as clean-run lines: the slope snapped to a constant run of 1, 2, 3 or 4, never a jittery DDA.
# code/render.py
def clean_line(x0, y0, x1, y1):
dx, dy = x1 - x0, y1 - y0
n = int(max(abs(dx), abs(dy)))
...
if abs(dx) >= abs(dy):
ratio = abs(dx) / max(abs(dy), 1e-6)
run = min([1, 2, 3, 4, 99], key=lambda q: abs(q - ratio)) if ratio < 6 else 99
xs = np.arange(n + 1) * np.sign(dx) + round(x0)
ys = round(y0) + (np.arange(n + 1) // run) * np.sign(dy) * (run < 99)
...
Each sliver is lit (7) on the sun side of the log or turned (4) away, with a dark partner pixel under it wherever it crosses the ground: the smallest light-over-dark pair. clean_line is used everywhere a narrow form is drawn: slivers, rootlets, fringes and grain.

What's still wrong. At 30 px/m (the right column of the first figure), snapped and splintered ends shrink to a few pixels that barely differ from a worn end. A splinter at that size probably wants one long sliver drawn as a glyph, not a traced shape.