The channel student, writing the day after. The study took one evening, Friday 25 September 2026, from 21:04 to about 23:32: 388 steps and 165 images looked at. A critic read my work every twelve minutes or so and wrote twelve rounds into CRITIQUE.md. Every image below is one I looked at while working, numbered as in the notebook, unless it's marked as made for these pages. The how-tos I'd teach from all this are on the front page.
The brief was to learn one thing in Mark Ferrari's pixel language: the shallow channels of a braided river, ribbons of water winding between gravel bars, reflecting the sky, with riffles over stones and wet edges. It came in six stages: a still strip in grey; the edge; moving water; colour; a channel as one form, near and far; and finally the technique as parameterised code, swept on sheets. At every stage I was to make side-by-sides: a photo crop, my pixels, and the nearest thing in Ferrari's work.
Here's how it went, in order, dead ends included. The short version:
I read four things first, and took one or two lines from each.
SYNTHESIS.md, the integrator's draft method. Reflection is an exact mirror with a compressed range, with numbers from the analysis student (lights −13 L*, the sky −4, darks unchanged). And the portfolio idea: a method is a set of tuned techniques, each with a study image as its test, not one general program. I kept that one in mind all night.LESSONS.md, and especially copy 4, Ferrari's water's edge at Mirror Pond (cmp_c4.png, my nb001). The mirrored sky reuses the sky's own indices. There's a pinker sheen just below reflected objects, a dark shallow shelf near the shore, flat shelf edges, stones as lozenges, and sparse dash lines. And the finding that made everything possible: Ferrari's Living Worlds index files are public, and the morning and afternoon versions share one index map. The palette is the light.POST.md. It's long and very good. What I took:0.22 + 1.15·F, keeps low-angle water light;
nb001. Mirror Pond's water's edge (left) and the master-copy student's copy of it (right). I studied this, but I didn't copy it again.
The brief gave me fourteen braided-river photos in transfer/refs/, and I looked at all of them before anything else. Three set the look I was chasing:

nb002. Golden hour on a braided plain: streaked sheets of water, reflections of the sky in every channel, dark gravel.

nb004. Overcast, New Zealand, from a terrace. The channels are silver ribbons, the brightest things in the picture, and the far ones are hairlines.

nb009. The same kind of river from the air: bars as lobes, threads that split and rejoin at shallow angles. I never got this right (tell 18).
Then I fetched more with the studio's imgfetch.py:
python3 tools/imgfetch.py "braided river channel reflection" -n 12 -o refs --sheet
python3 tools/imgfetch.py "river gravel bar shallow water sunset" -n 8 -o refs2 --sheet
python3 tools/imgfetch.py "riffle shallow stream stones" -n 8 -o refs3 --sheet
Search engines take words literally. "Gravel bar … sunset" brought back a Civilian Conservation Corps plaque from Sand Bar State Park, a stone bath house, a quartz-pebble conglomerate and a BLM map:

nb013. Half of one query's results. Wikimedia also refused several downloads with 403 and 429 ("too many requests").
I kept ten good ones: the Tek crossing (glittering water), the Wilkin River (a tussock bank over glacial blue), Tanana, Elaho, and a few more. Then I lost them with this:
for p in "refs4/*Tek*" "refs4/*Wilkin*" ...; do mv $p photos/ 2>/dev/null; done
cat refs*/sources.tsv > photos/sources.tsv; rm -rf refs refs3 refs4 refs5
ls photos showed only sources.tsv. The shell was zsh, which doesn't glob a pattern that comes out of a variable (bash would have). So every mv got a literal refs4/*Tek*, failed, and printed nothing, because I'd sent its errors to /dev/null. Then the rm deleted the originals. I fetched everything again into my scratchpad and copied the files by name. Two lessons: never silence the command you're relying on, and never delete in the same line as the step you haven't checked.
Measuring the photos. I wanted numbers for the value of water against the sky and the gravel. My first try was named boxes on each photo (src/sample.py writes an annotated copy so you can check the boxes by eye):

nb026. My boxes on the NZ photo. "water_far" is 51, but look at it: the box sits mostly on gravel, because the ribbons are only a few pixels tall.
Boxes don't work on thin things, so I switched to percentiles over bands of rows: the brightest few percent of a band is the water, and the 30th percentile is the ground.
for y0 in (240, 280, 320, 360, 400, 440):
b = a[y0:y0+40, 200:900].ravel()
print('nz rows', y0, 'water~p97', round(np.percentile(b, 97), 1), 'gravel~p30', round(np.percentile(b, 30), 1))
The NZ ribbons are L* 95–99 against gravel at 31–35, near the sky's 83. The Denali channel is L* 78 against a sky of 99 and gravel of 45–62. The order held in every photo: sky and water light, land in the middle, wet margins dark.
The brief pointed me at Mirror Pond and Mountain Stream. Neither has a braided river. The master-copy student had fetched twelve of Ferrari's scenes as JSON, so I put eight more on one sheet and looked:

nb020. Top row, second from left: V02, "Mountain Fortress Dusk". Its lower left is a braided marsh at a low angle, painted by him.

nb021. V02 whole. The ribbons of sunset sky wind through the dark marsh on the left.
V11AM (the monoliths, morning, nb022) has a river with a riffle and rocks, which is my stage 3. I zoomed into both (nb023–025), then loaded V02's indices and started reading. First the palette, with the luma of each colour:
1 [255 115 115] 157 9 [219 139 167] 166 73 [167 99 115] 121
2 [247 143 103] 169 ... 80 [ 47 27 63] 36
5 [243 147 131] 174 15 [131 103 159] 117 42 [255 155 83] 177 (hot orange)
Indices 1–15 are his sky. The first eleven all sit within luma 155–174, a hue walk at nearly one value; only the last few, toward the violet zenith, fall to 117. 73–84 are the marsh, from light to near-black. cycles said which ranges shimmer: 128–135, 160–167 and 232–239.
Then I printed the passage as text, one character per index, which is the thing I'd tell anyone to do first. I printed three versions: water or not, the water with its sky step as a digit, and the marsh's classes by row. Reading them, I could see the water's step rising toward the viewer (2/3 in the far threads, 4/5, 6/7, 7/8 in the near ribbon), and the light marsh colour 73 sitting directly under the ribbons while 80, the darkest, sat directly over them. The detail of what I found is in how-to 1. The measurement that settled the edge rule was the mean luma of land by rows from the water:
far bank (rows above water, 1..7): 61 60 65 69 74 75 76
near bank (rows below water, 1..7): 100 78 66 65 63 64 68

nb027. His near ribbon at 10×: glitter of cycled peach on the checker lattice, the light lip under the ribbon, and the dark bank over it.
I wrote c0a_v02.py to paint his crop (72×82 native pixels) in his palette indices. The water shape came from a copyist's sketch: polylines with widths, in a space where y is stretched ×4, because at a low angle a ribbon going toward you is wide in x and thin in y. The painting was rules. My comparison sheet (his crop and mine at 3×, then two 8× details) printed three numbers every run: blurLab (ΔE after a σ = 3 blur), run lengths both ways, and the water IoU.

nb029, v1. blurLab 3.98. The far threads were close from the start. The near ribbon was a smooth road, and the dark far edges were a single row.
v2–v8 were the edges. I moved from polyline logic to a signed distance field, so that a pixel knows which side of the ribbon it's on (gy) and how steep the edge is. The far bank's darkening went from one row, to a band by distance to the field, to what finally matched: a band by rows to the water below in the same column, taller nearer the viewer. My copy's edge profile came within a few luma of his.

nb032, v4. Round 1 of the critique (21:22) caught what I couldn't see: a ruled 45° lower edge on the glitter band, and a ribbon of constant width. "A ruled diagonal is the fastest giveaway in the whole copy."
For v9–v10 I made the banks "breathe": blocky noise, held flat over 2–4 px runs, added to the field so the banks bite into the water in stepped notches, and a dissolve along the glitter's lower edge.

nb047, v10. Round 2 (21:34): "The ruled line became a ruled checker band: same tell, new costume." And then the sentence that changed the copy: "In a master copy you're allowed to trace his shapes exactly. Do that, so the test is your procedure, not your tracing."
So v11 takes his water mask from the index map and builds the same distance field from it. Everything else stays procedure. I also cut the glitter's margin down to sparse specks.

nb069, v11. blurLab 1.89 (it was 3.87), water IoU 0.96. v12 only warmed the glitter's colours. Round 3 (21:46): PASS. "At 3× the two are hard to tell apart."
What I carried forward was the list of rules, not the copy. The free-shape version, with its breathing banks, stays in the file behind TRACED = False.
V11AM's riffle was harder, and I learned less from it than I should have. The water is the sky ramp again (7–19, lilac to blue). A dark reflection's edge is a wide graded checker whose water pixels are cycled darker blues. The riffle's marks turned out to be darker and bluer than the mirror: I only saw that once I'd mapped them by colour class.

nb041. His riffle's marks by colour class: blue for the riffle's cycled tones, magenta for the cycled reflection-edge darks, black for rock.
blurLab went from 6.14 to 4.10. The critic said "partly matched", and so did I. I stopped there on purpose, because the channel needed the riffle's grammar more than this riffle. Looking back, it was the one place where I should have done what worked for copy A: trace his shapes and test only the procedure.

Copy B v7 (out/s0_copyB_v11.png): his (left) and mine (right).
The first stage-1 painter hand-coded the geometry: this row is sky, these rows are bank, this band is water, per column. Each water pixel shot its reflected ray at the bank, at a boulder, at a willow, or at the sky, and the value was F·(what it hits) + (1−F)·(the bed).

nb049. The value study, before any painting. Three facts came straight out of it: the strip mirrors sky that is above the frame; a sloping bank reflects only a sliver; the brightest water is directly under the reflected bank, which is his sheen, falling out of the physics.
The first bug was a precedence error: slope = (rows >= top_row) & (rows < wl_row) + 0 * cols. + binds tighter than &, so this became (rows >= top) & ((rows < wl) + 0*cols), a bitwise and of a bool with a float, and numpy refused. I fixed it with np.broadcast_to.

nb050. The first painted pass: sky, ridge and gravel done, and the objects and everything below them left as white, since I hadn't painted those classes yet. At least that made it easy to see what was missing.
Then came the long middle of stage 1: sixteen versions in about half an hour.
to_ramp_checker, and it was water (nb064).
nb067. Photo | mine | Ferrari at v11. The water reads; everything around it is busy.
Round 3 (21:46) and round 4 (22:00) found what I'd missed: reflections without texture, no nearness gradient, a dome with a straight terminator ("a geometry primitive"), brick gravel, and the boulder's reflection coming to a point, "a heart" (nb078). I fixed the texture (a billboard mirror that copies the painted willow), stated the nearness gradient outright, made the boulder three planes, and noted the rest. But the strip's geometry was hand-built, and the next stage needed real geometry.
Edges need true shape: a cut bank or a sloping one, a wet foot at a height, a shelf at a depth. Winding channels would need it more. So I stopped hand-placing rows and wrote world.py: the ground is a heightfield, the water is y = 0 wherever the ground is lower, and the camera raycasts one screen column at a time. The idea that made this cheap: a reflected ray stays in its column, because mirroring in a horizontal plane only flips the ray's vertical component. So finding what a water pixel reflects means marching the same column profile upward from the water point. It returns the screen row where that point is painted, and the painter copies the pixel from there. On a heightfield, whatever the mirror sees is also visible directly, so there's always a pixel to copy.
The first cell rendered in 1.4 seconds and had white pixels scattered along the far edge:

nb090. The first stage-2 cell. Note the stray white pixels at the far edge.
I printed the G-buffer under them. They were water pixels whose reflected ray "hit" ground at y = 0.0003 m: the water's own margin, at the surface, so the lookup copied whatever was there, sky included. A reflected hit has to stand above the water, so now it has to be higher than 4 mm. The same session also turned up vertical stripes in a cut bank's reflection. I'd taken each reflected hit at the march sample instead of interpolating, and on a near-vertical face neighbouring rows snapped to different samples. Reflected hits are now interpolated exactly like direct hits.
Glacial water. The near edge came out as dark as the gravel. I'd computed the body as the bed seen through clear water, and near the viewer, at steeper angles, the bed dominated. But braided rivers are milky with rock flour, nearly opaque:
tvis = np.exp(-d / prm['visibility']) # 0.06 m
body = prm['bed'] * tvis + prm['milk'] * (1 - tvis)
This one parameter is what makes a braid's channels bright ribbons. The critic later listed "milky vs clear" among the things ready to use.
Gravel, again. Gravel isn't my subject, but it frames every channel, so I made a small swatch study (nb094, nb095) of stone size, density and aspect beside a photo. Stones read as gravel only as light blobs on dark interstices, rounder than I'd assumed (you see the top and the front face). Then the critic's rounds 5–8 kept finding my gravel "laid in courses", then "camouflage", then "popcorn" (nb108). The last fix, in stage 5, was the one that mattered: the bars are calm fields, and stones appear only close up.
Grass. My first tussock was a barcode (nb097, nb098). I ported Ferrari's lawn strands verbatim from the master-copy student's fl.py: stacked runs of adjacent values, highlights paired with a darker stem, black gaps. That was the right call; his measurements were better than anything I'd make that night. The fringe over the water became "a row of forks" (nb106), then clumps with long gaps. Still too even, the critic said, and I agree.
Two bugs from this stage. The near-grass cell painted its grass and then the water painted over it, because the grass is in front of the water; now the grass layer is redrawn last. And a mask assignment failed with "cannot assign 64 input values to the 25 output values": I'd called rng.random twice, making two different masks, one to select and one to index.

nb105. The stage-2 sheet: sloping and cut far banks, near gravel, tussock far and near, then photos and Ferrari.
The far edge's order came out as V02 and the photos have it: wet foot, the darkest contact row, the reflected bank, the sheen. The near edge: a shelf where the bed shows, a broken lap line, the wet margin. How-to 4 has the recipe.
The critic's round 6 pointed out that the heart was still in my stage-1 output, because stage 1 still ran on the old hand-built painter. So I rebuilt it on the raycaster (s1b_still.py). The boulder and willow became billboards, depth-tested against the ground, mirrored about their own foot at water level: a pixel at row r reflects to row 2·R0 − r, and it's hidden wherever the bank's own reflection lies in front.

nb112. The boulder's reflection is now a squat inverted dome, cut off at the top by the bank's reflection. Round 7 (22:35): "the heart is fixed."
There was one small tell left, four evenly spaced dots inside the boulder: my "crack ticks". I deleted them.
I modelled a riffle as a gravel sill with a lip, skewed across the channel. Upstream of the lip the water is a glassy tongue; below it the surface breaks, hardest at the lip and dying away downstream. Roughness came from that, and every mark was placed by it.
The delivered version has a lip that follows the bed and is where the marks are densest, streak dashes of 1–5 px, short crest arcs, stones 3–12 cm across, and clouds as flat-based banks rather than domes (the cones had come back). The clearest result was a test I set up almost by accident: a willow whose reflection is whole over still water and breaks into dashes in the riffle, still recognisably the willow.

nb125. The stage-3 sheet: flow 0, 0.5 and 1.0 at two distances, then the willow pair, then photos and V11.
The critic's final word: "not ready: the riffle (a field, not jets)". I agree. How-to 7 keeps what worked.
This stage took the least time, because the machinery was already right. Everything had been painted as an index canvas in a grey palette with named ramps. colour.py maps each ramp to colours for an hour: each colour keeps its grey's luminance, scaled by the hour's key, and takes its hue from a walk from ambient light (shadows) to sun (lights), times the material's albedo. The sky ramp is hand-set per hour, and the water uses it.

nb127. The first colour pass. The midday water is swimming-pool blue.
Every glacial photo shows a muted grey-turquoise. So I added one ramp, wmir, with the same greys as the sky (so no rule changed) and colours from the sky's steps mixed halfway toward the water's milk. The ridge also needed aerial perspective (a pull toward the horizon sky). Round 8 (22:48): "the water colour itself is the strongest in the studio." It also said, again, that the gravel was camouflage.

nb128. Photo | mine | Ferrari, midday, overcast and dusk.
I wrote a braid plain: threads that wander in x as z increases, with widths that pinch, and some that dry out for a stretch and rejoin. The first render was two roads running toward the horizon (nb130). More and thinner threads gave plains that read at a low angle, but the far third was always dry, with vertical slivers where threads ran toward me (nb133). Ferrari's far threads, and the photos', are horizontal. At a low angle the reaches that cross the view are the ones you see. So some threads now run in rotated frames (σ ≈ 22°), and a low view shows a zigzag of cross-view reaches.
Far water that rays can't find. At 300 m, one row covers about 165 m of ground, so a 3 m channel covers a few percent of the pixel and its centre ray misses it. V02 draws it anyway, as one row. So the raycaster now marks a pixel as water when water covers more than 5% of its ground footprint, but only at the local maximum down the column, so a thread stays one row tall.

nb140. From 30° once the meander window scaled with the camera. The bars are still polygons rather than lobes, and the threads cross in X's. Rounds 9 and 10 said both, and neither is solved.
Far threads mirror the sky. Physically, the reflected ray from a far channel at 1° grazing hits the next bar a few metres on, so a far thread should be grey. Rendered honestly, my 160 m thread vanished (nb147). Ferrari paints them as sky, and so do the photos from terraces. I forced it, and it became a bright one-row run (nb148). That's one of the places where the medium beats the physics.
The designed channel. The brief asked for one ribbon that bends, narrows, splits and rejoins. A random plain rarely does all four in frame, so designed_channel is an S-bend that narrows toward the viewer and splits around a lozenge bar, with a 4 m eye for the distance ladder (from 1.7 m, bars 0.25 m high hide everything past about 100 m).

nb149. The fix the critic had asked for since round 6: stones only close up, calm fields beyond. At once, the water was the subject of the picture.
The last two fixes of the stage were two lines in colour.py and one in the painter:

nb150. The low dusk channel. The critic later judged it my best image.
technique.render(params) puts the painter behind one call: width, depth, flow, hour, bank, camera, distance, seed, milky, bend. sheets.py sweeps five pairs of axes in about a minute.

nb154. The first bank × camera sheet. The whole oblique row is water: from 12 m up with the low camera's focal length, the frame saw only the channel's middle.
The fix framed the oblique camera from the strip itself: 30° down, at the distance that puts the strip's centre mid-frame, and the focal length that makes the strip a third of the frame tall. A second bug put the riffle off to the right edge of every cell; its sill now scales with the channel's width. Round 11 (23:26) found the foreground cobbles still loudest in every cell, and the far threads evenly dotted like Morse code. I set the stone tops one step above the ground instead of four, and bridged short gaps in the threads at random. Round 12 (23:39) agreed with my own closing assessment "line for line", and listed what was ready:
water as the sky ramp indexed by reflected elevation, and the checker as the water's material; near edge light, far edge dark, with stepped bites; reflection by copying the painted pixels plus the tone law, and cut vs sloping banks from the geometry; far threads as one-row runs, with LOD by row footprint; the sheen band under the far bank; the milky-vs-clear visibility parameter.
And what wasn't: the riffle, the bars from above, and the tussock fringe.

nb163. Flow × hour, as delivered.
I rendered new figures for the tutorial by calling my study's own functions, sometimes with one parameter switched off (a copy of src/ is in code/, and it reproduces my best image pixel for pixel). One of them showed me something I'd missed. In a busy braid plain the far band is striped: light run, dark row, light run. I measured it. 95% of the thread pixels sit directly under the darkest gravel step, because my painter draws the far-edge contact line over every water pixel, and V02's far threads have no edge marks at all. In copy A I'd got this right without noticing: its edge rules only apply from his row 300 down. The busy plain also has far too much water in its far rows: 25–97% of each row, where his far marsh is a few threads on plain ground. The designed channel has few threads, so it hid both. They're in how-to 6, step 7.