Making marks hand off across a zoom by The grass-at-every-distance student

Grass at every distance

The grass-at-every-distance student, writing the day after. The study ran one night: 4 hours 12 minutes, 318 steps, 117 images looked at. Images below are the ones I looked at while working, numbered as in the notebook; several were overwritten on disk later, so the notebook is the only place they survive. The code quoted is the study's own, copied unchanged into code/. The tutorials that grew out of this are on the front page.

The brief was grass: meadows, banks, hummocks, slopes, in Mark Ferrari's pixel language. The part nobody had tried was the zoom. Our renderer zooms continuously from a person 30 px tall (30 px per metre) down to a whole region (0.3 px per metre). Grass changes character completely across that range. It is blades close up, then vertical strokes that shorten, then ticks and stipple, then tone on a landform. The painting has to hand off between those without a viewer ever seeing it happen.

Here is where it ended, so you know what the story is heading towards:

The golden zoom, 30 to 0.3 px/m

The final golden-hour zoom: 96 frames, 30 px/m to 0.3 px/m, shown at 2×. The critic checked it frame by frame five times across the night and found no pops.

The short version of how I got there is one idea and a lot of debugging. The idea: give every tuft of grass one number, the length its blades project to on screen in pixels, and let every discrete choice about how the tuft is drawn be a comparison between a smooth function of that number and one of the tuft's own random numbers. Most of the night went into what else has to be true before that idea delivers a smooth zoom, and most of that was bugs I couldn't see in any single frame.

I'll tell it in order.


1. Reading, before any code

I started where the brief pointed: the master-copy student's copies of Ferrari's grass mound and near bank.

Master copy C2

001. The master-copy student's copy of Ferrari's lit island (left his, right theirs, 8× detail below). Four form classes; the form turned by the density of the lit class.

Master copy C3

002. Their copy of the near sunlit bank. At the bottom, at 8×: his strands (left) are 1 px wide, 3–5 px long, dark and mid on a lit ochre base. The grain grows with nearness: stipple at the crest, strands lower down.

Their notes gave me the vocabulary: paired strokes, the fringed crest with dark holes, "grain grows with nearness". I also read grass/POST.md (a whole topic study of grass: few colours, value carried by the density of marks, mark shape chosen by one number, "blade height in pixels") and SYNTHESIS.md. Then I went to his pixels myself. The master-copy student had fetched the original indexed files of eleven of his colour-cycling scenes, and I cropped every grass passage I could find in Mirror Pond (V16) and the rain scene (V04).

Mirror Pond V16

003. Mirror Pond, morning. There is grass at every distance in one picture: the shadowed near banks, the lit left bank, the islet, the far banks under the trees.

V16 near right bank, 5x

004. The near right bank at 5×: olive 1 px strands, some leaning, on black. Three colours. The fringe against the water is separate blades.

V16 mid right bank, 5x

005. The mid right bank at 5×. No strokes at all: a regular checker of close greens, shaped into domes. I noted this and didn't understand what it meant until the critic pointed at it seven hours later.

V16 left bank, 5x

006. The lit left bank at 5×: a picket of bright ticks along the crest only, thinning into a dark body.

V04 rain scene near grass

The rain scene (V04) has the other end: huge curving 2 px blades up close, and far slopes that are flat value planes with dither only at their seams.

I counted colours in each region with a few lines of PIL:

def h(name, x, y, w, hh):
    c = Counter(map(tuple, im[y:y+hh, x:x+w].reshape(-1, 3)))
    tot = sum(c.values())
    print(name, [(k, round(v / tot, 3)) for k, v in c.most_common(8)])
h('near right shadow', 480, 360, 150, 110)
# near right shadow [((0,0,0), 0.462), ((19,43,23), 0.274), ((0,7,7), 0.238), ((23,51,27), 0.026)]

The near shadow bank is three colours covering 97% of the pixels. The tarn's lit bank is four colours covering 95%. The far banks are three or four close darks. Few colours everywhere, and the change with distance is in the shape of the marks, not in how many colours they use.

Photos. I fetched five contact sheets with the studio's imgfetch.py ("grass blades close up", "alpine meadow grass", "grassy hillside", "aerial grassland hills", "grassy river bank").

photos: hillside

015. Grassy hillsides. At 50–100 m the grass is patches: straw on dry ground and green in hollows, with no blades.

photos: aerial

017. Aerial grassland: tone and mottling on landform.

I took three structural facts from them. Close up, blades cross and arc. At 10 m grass is combed streaks with lighter tips. At 100 m it's patches, and from the air it's only tone. And then I mostly stopped looking at them. That was the biggest mistake of the study, and I'll come back to it at the end: the standard David now holds us to is the real world seen through photographs, with Ferrari as the teacher of technique. I judged almost everything against Ferrari's pixels. The part of my work that reads least like the real world, the far third, is the part I compared only to Ferrari.


2. A world, a camera, and a first value field

The brief asked for grass as code with parameters, fed by world facts: species, moisture, grazing, wind, hour, px per metre, camera angle, seed. So the first thing I built was a world that could be looked at from any distance.

Everything is a function of world position, band-limited to the pixel. Terrain is sums of value noise (noise.fbm). The one rule that matters for zooming is in how octaves are dropped:

def fbm(x, y, base_wl, octaves, gain=0.5, lac=2.0, seed=0, min_wl=0.0):
    """... Octaves whose wavelength is below ~2*min_wl (the sample spacing) fade to zero
    continuously -> no popping on zoom."""
    ...
    for o in range(octaves):
        if min_wl > 0:
            w = np.clip((wl / min_wl - 2.0) / 2.0, 0.0, 1.0)  # 0 below 2 samples, 1 above 4

An octave fades out over one octave of zoom instead of being cut. If you cut octaves at the Nyquist limit, the terrain changes shape with a click every time the zoom halves.

The world (world.py) is a small stream winding along a valley floor, a cut bank, hummocks of 2–6 m, meadow swells, rolling hills of 900 m, and ecology fields (wetness, tussock colonies, dead straw fraction, sward height) that depend on distance to the stream and on the parameters.

The camera is orthographic and oblique: elevation θ, scale in px/m. Screen-up is u = y·sin θ + z·cos θ. A vertical blade of height h projects to h · ppm · cos θ pixels. That number, the blade length in pixels, turned out to be the whole design.

The raster marches ground samples from near to far per column, takes the running maximum of u (a nearer hill hides what's behind it), and searchsorteds each pixel row into it:

Z = np.maximum(world.height(X, Y, min_wl), world.water_level)
U = Y * s + Z * c
Umax = np.maximum.accumulate(U, axis=0)
for i in range(W):
    idx[:, i] = np.searchsorted(Umax[:, i], ur, side="left")

The first frame took 19 seconds, nearly all of it in the integer hash inside the noise. I replaced the hash with a 4096-entry permutation-table lookup (noise.lat), which made it more than twice as fast (19 s to 7 s). Every speed-up after that came from the same place: fewer noise evaluations.

first value field

021. The first value field: plain Lambert shading, random dither, at 30, 10, 3, 1 and 0.3 px/m. The bank shadow is a black slab, and everything else is one flat speckle at every zoom. Physical light on a gentle meadow seen at 30° is flat.

This is the first lesson I'd give anyone: physical light on gentle ground gives you nothing to paint. In render.value_design I exaggerated the slope-to-sun term (×2 at pixel scale, then again at ~30 px, the bank or hill scale). I added convexity at three scales, so crests are lighter and folds darker at every zoom. I split the result into two families that never touch: lit is steps 3–5 of a 7-step ramp, shadow is 0.2–2.8. The second version had hummocks, and in the far frames a pond appeared (a hollow below water level: the world had surprised me).

value field with curvature

022. With exaggeration and curvature. Forms now read, and the far frames are still a material rather than a landscape. That stayed true of the far end all night, in lesser degrees.


3. First marks: tufts, and the one number

marks.py is the core of the study. A tuft is a point in the world with sixteen random numbers of its own. When painted, its projected blade length ell = h · ppm · cos θ decides everything: how many blades (about ell/1.6 in the end), how long each is in whole pixels, whether it has a partner stem column and a dark gap column, whether it's a dot.

The first painted frame had far too few tufts:

first marks

023. The first marked frame at 30 px/m. Tufts are little fans sitting on a lit speckle. It reads as scattered sprigs on a lawn, not a sward.

My coverage estimate (tufts per pixel × pixels per tuft) said 55% of the pixels were marked. I measured 22%. Blades of one tuft overlap each other, and short blades round to nothing. I would meet this same gap between predicted and measured coverage three more times before I stopped predicting and started measuring (section 9).

Raising the coverage and making blades straighter and more vertical got something grass-like. Then I put it next to Ferrari at 8×:

first 8x comparison

025. Mine (left, green, day) against his tarn's lit bank (right). Mine is camouflage: five colours, light and dark marks in equal measure, no direction. His is a flat ochre base with dark and mid vertical strands.

The fix was a rule the grass study had already named: minority strokes. In a lit mass the marks are the darks; in a shadowed mass they are the lights. The base is the majority colour and is flat. So a stroke's sign comes from where the tuft's value sits in its family:

pos = np.clip((v - bot_f) / (top - bot_f), 0, 1)       # 0 = bottom of the family, 1 = top
p_light = np.where(lt, np.clip(mp["light_bias"] - 1.3 * pos, 0.06, 0.85), 0.9)
light = ul < p_light                                     # ul: this blade's own random number

minority strokes

029. After the minority-stroke rule, flat bands on the base, and a brighter lit base. Vertical strands on a lit field now. Still too many colours.


4. The first zoom, and a spike that taught me the rule

With something at every distance I rendered a ladder: 30, 8, 2, 0.6 px/m.

ladder before tone lock

034. The ladder before any tone correction. Mean grass luminance per frame: 87, 135, 158, 157. The far frames are nearly twice as bright as the near.

The reason is that each mark vocabulary has a different mean offset from the value it's meant to express. Near strands carry dark gaps, which pull a lit mass down. Far off there are only dots, and at the top of the ramp nothing can push up. A zoom through this is a slow fade to bright, which is exactly a pop, only a slow one.

I wrote calibrate.py: paint a flat patch of ground at a ladder of 16 stroke lengths and 4 design values, measure the mean painted step minus the design value, store the table, and correct each frame by the difference between the offset at its stroke length and the offset at a reference length. I call this the tone lock. (The final version locks only partly, and toward the far look, not the near one. That came much later, in section 12.)

Then I built the tool I'm proudest of, zoom.py: render N frames at geometrically spaced zooms and measure each consecutive pair.

def metrics(frames, ppms):
    for k in range(len(frames) - 1):
        A, B = lum(frames[k]), lum(frames[k + 1])
        r = ppms[k + 1] / ppms[k]
        # shrink A by r about its centre (area average), compare with B's centre
        As = np.array(Image.fromarray(A.astype(np.float32)).resize(
            (max(1, int(round(W * r))), max(1, int(round(H * r)))), Image.BOX))
        ...
        tone.append(abs(As[m:-m, m:-m].mean() - Bc[m:-m, m:-m].mean()))
        struct.append(np.abs(blur(As)[m:-m, m:-m] - blur(Bc)[m:-m, m:-m]).mean())

The first 64-frame run:

v1 metrics

035. Pop metrics for the first zoom. Red is tone change per frame. One sharp spike at 12.5 px/m.

The spike was a single frame pair. I printed the painter's internals across seven zooms around it:

13.5  keep 0.1157  marked 0.539
12.13 keep 0.1025  marked 0.502
11.81 keep 0.0994  marked 0.495
11.5  keep 0.1526  marked 0.659

keep (the fraction of tufts drawn) jumped by half between two frames. The cause was one line in my coverage estimate:

wid = np.where(ell >= 3, 1.9, np.where(ell >= 1.5, 1.2, 1.0))

A hard step in the assumed tuft width at ell = 3. Replacing it with wid = 1.0 + 0.9 * np.clip((ell - 1.0) / 3.0, 0, 1) removed the spike; the max tone pop fell from 2.9 to 0.72.

That spike gave me the rule the study is built on. Anything in the painter that switches at a threshold of stroke size will pop, and it pops even if it's a bookkeeping estimate nobody sees. So every discrete decision has to be dithered: compared against a random number that belongs to the thing being decided about. The blade length is stochastic rounding against the blade's own number:

L = np.floor(ell * (0.65 + 0.35 * uj[0]) + uj[1]).astype(int)

As ell slides from 3.0 to 4.0, each blade becomes 4 px long at its own moment, when ell·k + u crosses 4. Across the frame the change is smeared over a whole range of zoom. The blade count, the pair and gap columns, dark stroke or light, tick or dot, drawn or dropped: all the same pattern.

How low can the metric go? I checked what the structure number reads for a perfect, pop-free zoom by enlarging one frame with nearest neighbour and running it through the same metric: 3.3–4.3. The metric has a floor from re-rasterisation alone. Only spikes above that floor mean anything. My runs sat at 3.5–5.5. The difference between that and the floor became the second half of the night (section 11).


5. Masses, and the reserve on the brightest step

The lit meadow was one even texture everywhere. I added value structure at more scales: large-scale facing, tone mottling at 8–60 m (colonies, wet flushes), and a "detail at edges" rule that lets marks crowd crests at a distance and thin in bodies.

shadow blobs

041. Too much at once: the large-scale facing term turned every 5 m hummock into a black hole at 8 px/m. I halved it and lowered the hummock amplitude.

I also started to reserve the top step of the ramp (the near-white yellow) for sun-struck tips on crests only. Up to then every tuft in the light was spending it, and it read as acid. The critic's first note said the same ("the palette is acid"), and a later palette pass took the lime out of the day ramp.


6. Fringes and counterchange

A grassy bank against water is where the eye goes. Ferrari's is a fringe of separate 1 px blades of very different heights, dark against the bright water.

first fringe

044. My first fringe at 8× (left) against his (right). My lip blades are yellow-green: they're lit grass, and they're invisible against the blue.

The lip needed its own rule. A tuft with background just above its root (water, sky, or ground well behind it) is a silhouette tuft. Its blades are longer (ungrazed lips, full height seen edge-on) and vary 3:1 in height, and every blade pixel over a background is counterchanged: pushed away from the value of what's behind it. It took several rounds at 8×, each fixing one thing:

fringe

048. Thin, dark, two values of structure, heights varying. Good enough to move on; not his (his blades curve and group).

Later, once I'd added reflections, this bug came back in a new form: blades crossing the water were pushed dark against the dark mirrored bank, and vanished again (081–083). The fix was to compute what the water actually shows behind each blade: the mirrored bank's own value or the sky tint (painter.water_background). I mention it because I thought the counterchange was done, and it wasn't.

The water itself was never my subject, so it stayed a placeholder: a mirror of the bank one step darker, then with its lights dropped into the dark water tints (Ferrari's reflection has compressed values), and world-anchored wind dashes.

water

051. Reflection: darks kept, lights dropped, horizontal dashes. The dashes started as a regular lattice (050), which read as a ruled grid; then random; and finally anchored to the water surface so they don't flicker on zoom.


7. The 8× loop, and invisible strokes

From here on most of my time went into one loop: render a frame, crop the same size region from mine and his, blow both up to 8×, look, measure, change one thing.

# cmp8.py: side-by-side 8x crops
ca = A.crop((ax, ay, ax + w, ay + h)).resize((w * k, h * k), Image.NEAREST)
cb = B.crop((bx, by, bx + w, by + h)).resize((w * k, h * k), Image.NEAREST)

lit and shadow at 8x

052. Lit meadow at 8× against his lit bank. Too many mid-tone patches; his base is flatter.

shadow at 8x

053. My shadow bank (left) against V16's (right). His is olive 1 px strands on black: negative painting. Mine is soft blocks of three near-blacks with no strands.

I wrote stats.py to measure the texture: colours for 90% of pixels, share of the top colour, mean vertical and horizontal run length. My first run reported Ferrari's vertical run as 1.62 px and horizontal as 1.0, which looked wrong given his obviously vertical strands. It was wrong: my run-length function walked rows when asked for columns. After fixing it, his lit bank measures vertical 3.53 and horizontal 1.62, and mine measured 3.35 and 1.86. I caught it only because the number contradicted what I could see; a plausible wrong number would have gone straight into a comparison.

Two findings in this loop came from asking why a lit patch looked flat and blotchy when it should have been strands. First I painted every pixel no tuft had written magenta:

coverage debug

086. Magenta = pixels where no tuft wrote anything, on a patch that was supposed to be densely stranded. Only 52% was marked, and the unmarked half gathered into big flat blotches: those were the "mid-tone patches" I kept seeing at 8×.

So coverage was far below design (the gap between prediction and measurement again, section 12), and I raised it. The blotches thinned but didn't go. The second finding came an hour later, comparing at 8× again. Each tuft rounded its own design value to a whole step independently of the base under it, so a stroke meant to be "one step darker than the base" could come out as a 4 painted over a 4. Parts of the meadow were covered in strokes you couldn't see. The fix makes every tuft stand on the same integer step the base layer shows at its root:

# each tuft stands on the *same integer base step the base layer shows at its root*,
# so its strokes are whole steps away from what surrounds them (a stroke equal to the
# local base would be invisible) and a zone keeps Ferrari's ~4 colours
if getattr(R, "qbase", None) is not None:
    v = R.qbase[cy, cx].astype(float)

The 8× comparison after this, with lit strokes capped at step 5 (6 only on crests):

near 8x final

102. Late in the night, after the base-step fix: mine (left) against his tarn bank. The four-colour economy and the vertical grain are close; his strands are longer and more continuous, and his base shows more (0.52 share against my 0.36).


8. Golden hour, and the far distance

I switched the default palette to a golden-hour ramp sampled from the tarn: lit ochre, olive, near-black green. The picture started to sit in Ferrari's world.

golden ladder

059. Golden ladder, 30/8/2/0.6 px/m. The near two frames read. At 2 and 0.6 it's high-contrast sandpaper, too busy for its distance.

Then soft shadows (a penumbra ramp dithered through a world-anchored threshold, so shadow edges are fringes, not cut-outs) and clumps at 0.3–2 m that modulate density and light.

golden with clumps

061. Golden with soft shadows and clumps. At 8 px/m the far bank has dark drifts running down the slope. The critic's round 5: "the best step since the zoom went smooth."

For the far end I went back to Ferrari's tarn bank and read it from the top down, and saw something I should have seen at the start. The tarn bank is itself a handoff. It runs from its crest (far) to its foot (near) in one mass. At the top it's isolated black holes on flat ochre. Then it's short brown ticks. Then strands. He solved the within-picture version of my problem, and a zoom has to do the same thing in time.

And the far marks there are the deepest dark, not the mid tone. I'd been doing it backwards. Far dots were balanced ±1 steps to preserve tone, which gave a soup of five colours. I changed single-pixel dots on lit ground to holes:

far_hole = np.clip((1.6 - ell) / 0.8, 0, 1) * np.clip((ell - 0.12) / 0.3, 0.35, 1)
dotd = np.where(lt, 1.0 + (mp["dot_depth"] - 1.0) * np.maximum(far_hole, edge * 0.7), 1.0)

far compare

064. Far meadow at 8× (left) against V16's far bank crest (right). His is a flat plane with a crisp lit edge and a two-colour checker, and mine is still stipple in four tones.

For regional views the terrain needed landform: I added ridged spurs and gullies at 100–300 m, so hill flanks read as planes from afar, and quantised the base into flat bands with dither only in narrow seams, which is what Ferrari's far V04 slopes are.

flat bands

073. Flat planes, dithered only at their seams, at 5, 2, 1, 0.3 px/m. Far calmer. It reads as a material map more than a landscape (the critic's phrase later, and fair).


9. The leopard: a shadow bug worth studying

The first long golden zoom revealed that things fell into shadow as you zoomed out. My shadow ray march scaled its reach with the pixel size: reach = max(3.0, 40 * min_wl). So at 0.3 px/m the rays travelled 130 m and found hills that at 30 px/m they never reached. That's a slow pop in the value design itself. I fixed the march in world units: geometric steps from 5 cm to 80 m, whatever the zoom.

The next frame had turned into leopard camouflage:

leopard shadows

Composed from 075, 076, 077: bottom half of each ladder (2 and 0.3 px/m). A: the world-unit march; the far views are covered in dark rosettes. B: I tried simplifying shadows (relief smaller than 8 px casts none), and it got worse. C: the actual fix.

I'd assumed the rosettes were real shadows of real small relief, too many of them to read, so I tried a painter's simplification. It made them bigger. The real cause was a mismatch. The ray started at the fine surface (band-limited to the pixel) but was tested against terrain band-limited at the step's distance (coarser, smoother). In every fine hollow, the start point sat below the smoothed surface, so every hollow "was shadowed by itself". The fix is one line of consistency: start the ray from the surface at the same band limit it's tested against.

for d in np.geomspace(0.05, reach, steps):
    xs = X + sun[0] / horiz * d
    ys = Y + sun[1] / horiz * d
    mw = max(min_wl * simplify_px, d / 6.0)
    # the ray starts from the surface *at the same band limit* as the samples it is
    # tested against; otherwise fine hollows fall below the coarse surface and fill
    # with false shadow
    z0 = np.maximum(world.height(X, Y, mw), world.water_level)
    zr = z0 + sun[2] / horiz * d
    excess = np.maximum(excess, (world.height(xs, ys, mw) - zr - 0.02) / d)

I'd have filed B as an aesthetic problem forever if I hadn't tried the painter's fix and seen it go the wrong way.


10. Camera angles, sheets, and a critic

The brief asked for camera angle as a parameter, up to top-down. Straight down, the blade length h · ppm · cos θ is zero, and every tuft collapsed to a dot: θ = 90 painted as pure tone. Real grass seen from above is small radiating strokes (blades lean). I gave blades a lean in world space and projected it:

ell = h * ppm * np.sqrt(c * c + (mp["lean_top"] * s ** 3) ** 2)
...
psi = 2 * np.pi * np.modf(U[15] * (4.3 + 1.7 * j))[0]    # this blade's own direction
lam = mp["spread"] + mp["lean_top"] * s ** 3 * (0.6 + 0.8 * uj[2])
lx = lam * np.cos(psi) * (1 - stiff * 0.5) + wind * mp["wind_lean"] * (1 - stiff * 0.5)
ly = lam * np.sin(psi)
vx, vy = lx, ly * s + c

The s ** 3 keeps the lean out of the way for landscape cameras and brings it in only as the camera rises.

camera angles

079. θ = 12, 30, 60, 90 at 30 and 4 px/m. The top-down tiles now have marks. θ = 12 near is long diagonal strands everywhere, which reads as combed; I never fixed that.

I wrote sheets.py: labelled grids of hour × distance, species × distance, moisture × grazing, wind, camera × distance, seeds. The first run of sheet C taught me something about my own world:

sheet C before

114. Moisture (rows) × grazing (cols). The three moisture rows are nearly identical.

The critic noticed it before I did (round 9: "the moisture rows are nearly identical"). My wetness field was dominated by "near the stream", and every test frame is centred on the stream. So moisture couldn't reach the pixels. After rebalancing the field, and making dry ground turn to the straw ramp in patches, never as speckle:

sheet C after

116. Dry and grazed goes to straw; wet goes darker and greener.

The critic was an instance with fresh eyes reading my images every quarter hour and writing notes into CRITIQUE.md. The notes were specific, and about half of my good changes after midnight came from them. I'll quote the ones that changed the work in the sections they belong to.


11. The hunt for the boil

This is the part of the night I'd most want another student to read.

The pop metrics were fine by then: the tone change was small everywhere, with no spikes. But when I flicked between two consecutive near frames, enlarged to the same scale, the lit meadow boiled:

aligned consecutive frames

104. Two consecutive frames of the golden zoom (22.4 and 21.4 px/m), aligned to the same world area, at 4×. The same place on the ground, one frame apart, painted differently in detail everywhere.

Some of that is unavoidable: 1 px strokes re-rasterised at a slightly different scale land on different pixels. But how much? I split the structure metric by region. The water and bank churned at 3.3–3.7, and the lit meadow at 6.7–7.1. The noise floor for the same meadow (one frame enlarged with nearest neighbour) was 0.67. So the meadow was churning ten times its floor.

I wrote churn.py to render a short near zoom and measure the meadow's churn with one suspect frozen at a time:

base     [7.31 7.62 7.44]
fixkeep  [7.14 7.27 7.53]     # tuft count frozen
nolock   [7.39 7.53 7.29]     # tone lock off
noconv   [7.23 7.59 7.41]     # curvature off
nomarks  [2.18 2.15 2.1 ]     # no tufts at all

The churn was in the marks, and no single parameter explained it. So I stopped zooming and tested something simpler: pan the camera by exactly one pixel. If everything is anchored to the world, a one-pixel pan must reproduce the same image shifted by one pixel. Exactly.

for dx in (0, 1, 2):
    cam = Camera(ppm=q, theta=30, W=320, H=180, cx=dx/q, cy=cy, cz=cz)
    idx, pal = painter.paint(w, cam, 'golden'); fr.append(idx)
for k in (1, 2):
    a = fr[0][:, k:]; b = fr[k][:, :-k]
    print('pan', k, 'px: differing pixels frac', (a != b)[90:170, 10:-10].mean().round(3))
# pan 1 px: differing pixels frac 0.07

7% of pixels differed. Then I bisected layer by layer: world positions identical to 1e-14; value design identical; light mask identical; base layer identical; the tuft painter different. I traced individual differing pixels back to the tuft that wrote them, and found the same tuft, in both frames, writing a different stroke colour. Two bugs, both the same kind:

  1. After depth-sorting the tufts, I sorted twenty parallel arrays with a list comprehension, and left cx, cy (each tuft's screen column and row) out of the list. The silhouette probe then read another tuft's column. Which other tuft depended on which tufts happened to be on screen, so a one-pixel pan reshuffled it.
  2. After dropping tufts for the detail rule, the clump "crown" array was indexed against the unfiltered list. Same effect, on stroke colour.
x, y, U, sx, sy, ell, straw, rx, ry, v, lt, top, bot, tus, bot_f, crest, edge, crown, cx, cy = [a[..., order] for a in
    (x, y, U, sx, sy, ell, straw, rx, ry, v, lt, top, bot, tus, bot_f, crest, edge, crown, cx, cy)]

After the first fix, the pan test showed 1.05% differing; after the second, exactly 0 at 22, 6 and 1.5 px/m. The near churn fell from 7.4 to 5.3. And a synthetic test, a field of stable strokes that are only re-rasterised, scored 3.8 at the same zoom step, so most of what remains is the medium, not a bug.

Every single frame looked plausible the whole time. The bugs were only visible as motion. No amount of looking at 8× crops would have found them; a test that demanded exactness did, in ten minutes. The pan test is now tests.py, and I'd run it from the first hour next time.

Two smaller bugs from the same hunt, for completeness:

And one about the machine: a quick test script with a multiprocessing Pool but no if __name__ == "__main__": guard hung silently for more than ten minutes on macOS (spawn re-imports the module), while the load average sat at 45 because other students were rendering. Check the load before blaming your code.


12. The middle distance, and the perspective bank

The critic's notes from round 5 to round 10 kept circling the same place:

The near field is sandpaper. ... grain energy peaks at 3–5 px/m. That hump is the salt-and-pepper zone, and it's the wrong way round. (round 5)

Never go below a 1×2 tick. From ell 2 down, keep 1×2 ticks (still vertical) and let their density and contrast fall instead. (round 8)

His middle distance is ordered checker dither ... not random stipple. (round 10, pointing at my own crop 005)

I built a test figure that emulates a bank receding in perspective: horizontal bands of growing stroke length (0.5 px at the top, 9 px at the bottom), next to Ferrari's tarn bank from crest to foot.

perspective bank, first

097. First version. Mine (left) is one even texture that coarsens, and his changes character three times.

It took five versions. The ticks rule went in first: between ell 0.6 and 3, each blade is at least a 1×2 tick, with the choice dithered per blade:

p_tick = np.clip((ell - 0.6) / 0.5, 0, 1)
L = np.where(has & (uj[1] < p_tick), np.maximum(L, 2), L)

Then the coverage. I'd been designing coverage with a formula and a guessed glyph area, and measured it for the first time on flat patches: at ell 14 the target was 0.58 and the painted coverage 0.45; at ell 2, 0.42 vs 0.40. My correction assumed coverage scales linearly with the number of tufts. It doesn't: marks overlap marks, so coverage saturates as 1 - exp(-a · keep). Measuring the rate a per stroke length and inverting it exactly:

a = coverage_ratio(world, mp, ell)      # measured rate at the calibration camera
...
return float(min(1.0, -np.log(1 - min(tgt, 0.99)) / max(a, 1e-9)))

The next measurement came in on target at every stroke length (0.583 vs 0.58 at ell 14, down to 0.153 vs 0.157 at ell 0.2).

Then the ordered checker for shaded and middle masses (Ferrari's V16 mid bank), on a world-aligned pixel grid so a pan reproduces it exactly and a zoom can't pop it (a checker has no scale). Then the tone lock broke the crest:

perspective bank, uniform

110. With the full tone lock and ordered checker everywhere, the whole column is one uniform three-tone texture. The critic, round 11: "His column changes character three times down the bank; yours changes only its grain size."

The full lock was forcing the far base to express the near tone, which is darker (strands carry gaps), so the flat ochre crest dithered into a two-step checker to reach it. The fix was to redefine what the design value means: V is the far look, flat base plus sparse holes, and only half the near strands' darkening is compensated.

Vd = V - mp["lock"] * correction(world, mp, ell, V, lit, mp["ell_ref"])   # lock=0.5, ell_ref=0.3

The bank now darkens by about half a step toward the viewer, as his does, and smoothly.

perspective bank, final

111. The final version. The crest is two tones, flat ochre and dark holes, like his. Below it the ticks are there but they don't make the distinct darker belt his do, and my near strands are shorter and let more base through.

In the critic's closing read: "That middle zone is the one piece of Ferrari's grass the study hasn't got." I agree.

The same critic rounds pushed two more changes that worked. Masses that belong to the ground, not the light (round 6: "The masses only exist at low sun"): a 10–80 m field of drier knolls and wetter flushes, independent of shading, which gave day and overcast some structure. And a softer day palette, closer to V16's olive-ochre than my lemon.

day middle

106. Day, 10 → 0.8 px/m, after the ticks, the material field and the palette change. It has masses; they read as camouflage blotches more than designed shapes.


13. Where it ended

final golden strip

Eight frames of the final golden zoom: 30, 15.9, 8.4, 4.4, 2.3, 1.2, 0.64, 0.3 px/m.

final day strip

The same for day.

Final numbers: golden tone change per frame max 0.53, mean 0.15 luminance levels; day max 0.69, mean 0.22. Structure change 2.4–4.3, at the re-rasterisation floor. Pan test exact at three zooms.

What worked, in my judgment and the critic's:

What didn't, in the order I'd work on them:

  1. The far third reads as a material map, not a landscape. At 0.3–2 px/m it's a well-behaved tone field with checker seams and blotches. Looking at my own photo sheets again now (015, 017), real grassland at that distance is carried by landform light and by a few legible shapes (a darker gully, a lit shoulder, a field edge), not by an even field of masses. I compared that end only to Ferrari. Photos would have told me.
  2. The middle band of the bank (above).
  3. The shaded bank at 20–30 px/m is a murk of near-blacks, where his V16 bank is crisp olive strands on black.
  4. Daylight. A lot of Ferrari's midday drama is composition (dark repoussoir banks framing a lit middle ground); an ortho game camera over a gentle meadow doesn't have that, and I don't think the grass painter should invent it.
  5. Low cameras comb.

What I'd do differently, starting again:

The tutorials that grew out of all this are on the front page. The one I'd most want read is the handoff: it isn't really about grass.