Friday 25 September 2026, 23:58 (PDT), for 2 h 59 min. 280 steps, 121 images looked at.
This is the student's own thread: the brief it was given, what it wrote, each step it took, and every image it opened, shown as it saw it (possibly downscaled). Some of these images were later overwritten in the study folder, so this is the only place they survive. The raw record is the transcript.
You're a student learning to paint one thing beautifully, in the painterly pixel-art language of Mark Ferrari: the ground itself. That means earth, turf, gravel, forest floor and meadow floor as big, designed value masses, the stage everything else stands on.
Why this matters. A studio of students has been painting elements (cobbles, driftwood, willow, water, grass) in ~/work/pixelart-studies/. After 13 rounds of close comparison, their critic found one weakness common to all of them: the ground is texture everywhere at a single scale, with no big value masses. Uniform speckle under beautiful objects. Ferrari's ground is never like that. It's built from a few large shapes: shadowed banks, lit ledges, pools of light and dark, slopes turning. Texture lives inside those masses and follows them. The driftwood student proposed copying his near-ground masses (the ledges in Mountain Stream, V19; the banks in Mirror Pond, V16) as a starting point. The critic's full read is in ~/work/pixelart-studies/READY.md and elements/*/CRITIQUE.md.
The studio around you (read freely):
How the best students worked (take what helps; you own your approach):
python3 ~/work/pixelart-studies/tools/imgfetch.py "<query>" -n 12 -o <dir> --sheet.A useful outcome for the whole studio:
The bar, stated plainly: ground that could sit beside Ferrari's at 8× and at a glance. "Improved" isn't done. Expect many iterations per stage, and honest "not there yet, here's why" notes are valued. Time and compute are generous.
Deliverables in ~/work/pixelart-studies/elements/ground/:
Read anywhere under ~/work/pixelart-studies/, write only in your folder. Resources: nice -n 10, at most 4 threads, OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 for numpy. If anything would help, say so in LESSONS.md.
Bash: Survey the studio directory and READY.mdcd ~/work/pixelart-studies && ls; echo; cat READY.md | head -150; ls elements pedagogy exemplars tools; ls elements/ground 2>/dev/null # output: NIGHT_LOG.md READY.md SYNTHESIS.md aerial braid elements exemplars forest-floor grass massing moodboards pedagogy rock shared sky snow tools transfer water # Ready to use: techniques from the studio (2026-09-25 night) Techniques that the studio critic, after 13 rounds of 4–10× comparison against photos and Ferrari's own pixels, judged Ferrari-grade or ready. Each links to the student's work, which has the code and the lessons. Offered to the voxsim painting lanes as a resource, not a spec. **Palette as light** (all students). One index map, many hours or seasons: swapping the palette relights the scene, as Ferrari's own files do. - elements/cobbles (hour palettes) - elements/willow (season × hour sheet) - elements/channel (midday, overcast, dusk) **Water** (elements/channel; LESSONS.md, src/technique.py; best image: out/s5_low_dusk_4x.png) - Water is the sky's own ramp, indexed by reflected elevation, with checker as the water's material. - Near edge light, far edge dark, with stepped bites. - Reflections copy the painted pixels, then compress the values. - A sheen band under the far bank. - Far channels are one-row runs; detail comes from the row footprint. - A "milky vs clear" setting: glacial water is nearly opaque, and a milky mirror palette stops midday water going swimming-pool blue. **Stones and gravel** (elements/cobbles; cobbles_api.py; best: img/sheet_A_hour_x_distance.png) - Darkness goes only in the pockets between stones, never as an outline. - Stones are lopsided, faceted 3-D shapes, not ellipses. - A stone → tick → grain distance ladder with no pop. - The Ferrari boulder rules: a Bayer 2×2 lit face, a solid crown, a dark crescent ~20% of the minor size, and reflected-light edge pixels against dark neighbours. **Cast shadows** (elements/willow, elements/driftwood). The ground itself, two steps darker, with its texture kept inside. **Contact** (elements/willow). Grass overlapping the foot of a bush or stone. **Willow leaves** (elements/willow). A narrow leaf stroke at willow scale, and silver undersides as pale flecks on dark. David thinks the willow may exceed the existing trees. **Wood** (elements/driftwood; src/driftwood.py) - A five-class cylinder shading order proven against Ferrari's own V09 trunk, with the lit band kept free of checker. - Backlit rims. - Grain, checks and knots drawn as clean runs. - Piles separated by value. - Wet wood. **Grass zoom** (elements/grass-lod). A 64-frame zoom with no pops; contrast compresses steadily with distance. **Far and iso** (aerial/; lib/tech_*.py; measure/). Measured canopy rules (crown spacing 2.2–2.9 lit-cap radii, lit share 0.35–0.56, conifers still read as upright spires), rivers from above as dark mirrors, and haze that lifts the black point and shrinks the value range. ## Not ready (from the critic) - Cast shadows across stones. - Riffles (a field of marks, not a few jets with plain mirror between). - Braid bars from above (cut paper, not lobes). - Evenly spaced grass f
Bash: Check ground folder, read synthesis and exemplars notescd ~/work/pixelart-studies && ls -la elements/ground; cat SYNTHESIS.md | head -200; cat exemplars/README.md
# output:
total 0
drwxr-xr-x 2 user staff 64 Sep 25 23:57 .
drwxr-xr-x 15 user staff 480 Sep 25 23:57 ..
# What earlier students discovered: notes on their craft
These are notes on the painstaking work of the students who came before you:
- four who each spent hours learning Ferrari's language on one small scene (`pedagogy/{atelier,master-copy,analysis,observation}`);
- seven who studied single topics.
They copied his pixels crop by crop, measured his grass against real grass, built a boulder up from a lit sphere, and diagrammed his picture before daring to paint it. What they found is here to inspire you and save you time. It isn't a specification. You'll see things they didn't, and some of what they wrote will turn out wrong or incomplete for your subject. Follow your own eyes. Their best work is in their intermediate studies (e.g. `master-copy/out/cmp_c*.png`, `atelier/stage2/`, `observation/studies/translate_*.png`, `analysis/img/`); those are worth looking at more than their final painters.
The tags below say how each finding came about:
- **[shown]**: a student built it and it worked.
- **[measured]**: a student measured it in Ferrari's pixels or in photos.
- **[mine]**: the integrator's reading, not yet tried by anyone.
Striking to us: the four students, working apart, found much the same underlying ideas.
## The convergence
Working alone, all four arms landed on the same architecture:
- **Paint indices, not colours.**
- Master copy found that Ferrari's index map is literally a form map: indices are bins of surface direction, and "the palette is the light". The same index image serves morning and afternoon. [measured]
- Atelier paints in "step space" along one material ramp. [shown]
- Sky found that two of Ferrari's hour pairs are identical pixels. [measured]
- Analysis found that value is the structure and colour is temperature and distance. [measured]
- **Two light families that never touch.** Atelier's terminator rule [shown], analysis's three-value picture plan with every mass kept inside its group [measured], and rock's lit and shadow groups [shown] are the same idea at two scales.
- **Dither mixes only adjacent steps of one ramp.** All four arms use it. Ordered 2×2 dither suits hard or precise things (rock, sky seams, water rows); random or diffusion dither suits organic texture (lawns, foam). Master copy measured this [measured]; analysis cites Ferrari's GDC talk for the same split.
- **The medium's own geometry.** Contours are clean runs (1:1, 2:1, 4-4-4 flats), measured on Ferrari's crests by analysis and atelier [measured]. Atelier redraws every major contour by snapping edges to clean ratios [shown]. Masses meet level ground or water in a dead-flat run with a 1-px darker contact line [measured: analysis, observation].
- **Clean runs are for narrow forms; mass boundaries are where texture ends.** David's correction (2026-09-25): the clean-run rule came from the *grass strokes*. It applies to anything nBash: List element folders and Ferrari referencescd ~/work/pixelart-studies && ls elements/*/; ls elements/_critic; ls pedagogy/master-copy pedagogy/master-copy/ref pedagogy/master-copy/ref/lw | head -80; ls aerial/refs/ferrari_lw | head -50 # output: elements/_critic/: LOG.md cal_V09_2x.png cal_V09_bush_7x.png cal_V09_trunk_7x.png cal_V19_2x.png cal_V19_rocks_7x.png r10 r11 r11_grass_f08-11.png r11_grass_f23-26.png r11_grass_f40-43.png r12 r13 r1_drift_s1_left_8x.png r2 r3 r3_cobbles_nestled_8x.png r4 r5 r6 r7 r8 r9 wait.sh zoom.py elements/broadleaf/: __pycache__ bark.py bl.py glyphs.py lw.py moss.py palette.py refs render.py s0_copy.py s1_clump.py stage0 stage1 tmp tree.py elements/channel/: CRITIQUE.md LESSONS.md out photos src study elements/cobbles/: CRITIQUE.md LESSONS.md __pycache__ bar.py cobble.py cobbles_api.py compare_sheets.py edges.py img materials.py palette.py refs run.sh scene.py scratch stage0_copy.py stage1.py stage2.py stage3.py stage4.py stage5.py stage6.py stage7.py stone.py util.py water.py elements/conifers/: __pycache__ cmp.py grow.py lw.py paint.py palette.py refs render.py s0_copy.py s0_tune.py s1_branch.py s1_col.py s1_grey.py s1_near.py s2_ages.py stage0 stage1 stage2 stats.py t0.py tgrey.py tmp tree.py elements/deer-elk/: refs sheets src stages elements/driftwood/: CRITIQUE.md LESSONS.md refs scratch sheets src stages elements/grass-lod/: CRITIQUE.md cache img photos ref src elements/ground/: elements/people/: refs sheets src stages elements/steppe/: __pycache__ lw.py refs s0_tufts.py sp.py stage0 elements/understory/: __pycache__ fern.py leaves.py lwload.py refs stage0 stage0_leaf.py ucore.py work elements/willow/: CRITIQUE.md LESSONS.md __pycache__ bush.py context.py copyharness.py foliage.py glyphstats.py lw.py palette.py refs s1_cal.py s1_form.py s1_tex.py s_try.py search0.py search_c2.py sheets sheets.py stage0 stage0_copy.py stage1 stage1.py stage2 stage2.py stage3 stage3.py stage4 stage4.py stage5 stage5.py transfer.py willow.py wpaint.py LOG.md cal_V09_2x.png cal_V09_bush_7x.png cal_V09_trunk_7x.png cal_V19_2x.png cal_V19_rocks_7x.png r10 r11 r11_grass_f08-11.png r11_grass_f23-26.png r11_grass_f40-43.png r12 r13 r1_drift_s1_left_8x.png r2 r3 r3_cobbles_nestled_8x.png r4 r5 r6 r7 r8 r9 wait.sh zoom.py pedagogy/master-copy: LESSONS.md crops out ref src pedagogy/master-copy/ref: lw native.png pedagogy/master-copy/ref/lw: V01.json V01.png V02.json V02.png V09.json V09.png V11AM.json V11AM.png V13.json V13.png V14.json V14.png V16.json V16.png V16PM.json V16PM.png V19.json V19.png V19PM.json V19PM.png V29PM.json V29PM.png V30.json V30.png CORAL.json CORAL.png V01.json V01.png V02.json V02.png V03.json V03.png V04.json V04.png V05HAUNT.json V05HAUNT.png V05RAIN.json V05RAIN.png V07.json V07.png V08.json V08.png V08AM.json V08AM.png V09.json V09.png V10.json V10.png V11AM.json V11AM.png V13.json V13.png V14.json V14.png V15.json V15.png V16.json V16.png V16PM.json V16PM.png V17.json V17.png V19.json V19.png V19PM.json V19PM.png V20.json V20.png V25HEAT.json V25HEAT.png V26SNOW.json V26SNOW.png V27.json V27.png
Bash: Read the critic log tailcd ~/work/pixelart-studies && tail -120 elements/_critic/LOG.md; ls elements/_critic/r13 # output: # Critic log Viewing tool: `_critic/zoom.py` (nearest-neighbour crops). Calibration crops of Ferrari natives: `cal_*.png`. ## Round 0 (2026-09-25 ~21:10) No stage images yet in any folder. cobbles/driftwood/willow have refs + scratch; channel has photos only. Calibrated on V19 rocks (checker midtones inside clean rock silhouettes, dark cast pockets between stones) and V09 bushes (2-4 px leaf clusters, dark gaps, 3-4 greens). ## Round 1 (2026-09-25 21:22) Coordinator notes received: bar is Ferrari-indistinguishable at 8×; tone should be generous and specific, and the student owns the call. I kept a verdict line, framed as my read. - cobbles stage0 v3 (V19 boulder): REVISE. Uniform checker, even rim ring, flat shadow field. Silhouette good. - channel c0a_v4 (V02 ribbons): REVISE. Far ribbons near-indistinguishable; near has a ruled 45° glint edge and a road-like ribbon. - driftwood s0a/s0a2 (V09 pale trunk): REVISE. Silhouette borrowed; the limb fork was missed; wrong passage for driftwood. s1 log: REVISE (steel pipe, static ground, floats). Advanced without a verdict. - willow c1_fgbush (V09 bush): REVISE. No clump hierarchy, too little light. s1a: REVISE (sponge, cut-out, black polygon shadow). Advanced without a verdict. ## Round 2 (2026-09-25 21:34) - cobbles: s0 REVISE (close; concentric rim bands, tablecloth checker on flat stones). s1 REVISE (perfect ellipses, S-curve terminator, pegboard dots). - channel: s0 A REVISE (checker zipper replaced the ruled edge; traced-shape wedge). B REVISE (fizzy riffle, stitched rock edges). LESSONS.md is good. - driftwood: s0b V02 fallen tree REVISE (straight, disconnected branches). s1 REVISE (crayon with a white end cap, sawtooth lit edge, static ground). - willow: c2 hedge REVISE (close; crest continuous, low contrast inside clumps). s1b REVISE (haystack, horizontal marks, black polygons). ## Round 3 (2026-09-25 21:46) - cobbles: jumped to stages 2–4 with no pass on 0/1. REVISE all (identical ellipses, not nestled, ruled cast shadows, non-mirror reflection). Colour hours are good. - channel: **copy A v12 PASS** (traced shapes, procedure indistinguishable). s1 paint REVISE (flat reflections, brick gravel, primitive dome). - driftwood: s1 REVISE (three stripes, spiral end, pixel-litter splinters, dashed rim). Bends/tapers/shadows improved. - willow: c2f REVISE (light inverted on crest clumps). t2: right direction, needs clumps. ## Round 4 (2026-09-25 22:00) - cobbles: s4 redone packed and s5 bar: REVISE, but a big jump; reads as a gravel bar at a glance. Tells: leaded-glass outlines, camo streaks, pink slivers, ruled veins. - channel: s1 REVISE (heart-shaped reflection, flat willow reflection, striping). Copy B v7 REVISE (X-crossing chains, vertical maroon bars). - driftwood: s2 REVISE. Grain in and reads as wood at 5×; strokes parallel and square-ended, ruled check, tube silhouette, no knots. - willow: c1 closer, c2f unchanged; s1 REVISE (outcrop silhouette, uniform speckle), but stems and fan now re
Bash: Read closing critiques regarding groundcd ~/work/pixelart-studies && cat elements/_critic/r13/driftwood.md; grep -n -i -A3 "ground" elements/*/CRITIQUE.md | head -120 # output: ## Round 13 — 2026-09-25 23:50 — final sheets and "Where it stands" (finished) **My closing read: your assessment is accurate, and your number 1 (the ground) is also my number 1.** The latest distance × seed sheet is your best work: the near piles read as silvered driftwood with pale root fans and end faces, the Morse check is gone, and at 16 and 6 px/m the pile holds as a readable cluster. (My read; your call.) Ready to use: - the cylinder class order (proven against his trunk), with the lit band kept clean of checker and the form turned by grain density - contre-jour rims (stage 0B language) - grain, checks and knots as projected clean-run curves on the 3-D surface - value separation in piles: contact cracks, occlusion steps, and a mix of bleached and grey logs (`s5_grey_3x`, and distance × seed near) - wet driftwood (dark brown with sharp glints) - cast shadows as the ground two steps down Not ready: the ground (texture, no masses: agreed, and it's the first thing anyone would catch); big logs at 8× (plateaus, and block-ended dashes); root fans too regular and too sparse on the root-wad sheet (two prongs read as a fork); pile intersections. Your proposed next study, a stage-0 copy of his near-ground masses (V19 ledges, V16 banks), is the right one. It would help every element student, not just you. elements/grass-lod/CRITIQUE.md:23:1. **The stroke vocabulary is busier than his.** In `cmp_wip` his mound is a *flat* lit ochre ground with dark vertical strands (1 px wide, 2–6 tall) and a few mid-tone strands: two or three values, with most of the surface left flat, and the dark strands carrying all the texture. Yours mixes four or five greens plus a lemon yellow, with light dashes and dark dashes in equal measure, so it reads as camouflage. Try his economy: a flat lit base, dark strands as the grass, and light only as a paired highlight beside a dark gap (SYNTHESIS: the paired blade stroke). elements/grass-lod/CRITIQUE.md-24-2. **Far contrast doesn't compress.** At 2 and 0.6 px/m the field is the same high-contrast yellow/olive stipple as at 8 px/m, just finer, so it reads as sponge or sand. Ferrari's far grass (`ref/v16_farbank_6x`, `ref/v04_farhill_5x`) drops most of its texture: near-flat bands of colour with sparse marks, and the shadow colour lifting toward the sky. With distance, the *share* of marks and their contrast should fall, not just their size. elements/grass-lod/CRITIQUE.md-25-3. **The palette is acid.** The lit yellow is a lemon (almost 255,230,80) that no Ferrari grass uses. His lit grass is ochre or olive, and saturation drops with distance. elements/grass-lod/CRITIQUE.md-26-4. **The zoom test needs smaller steps.** Your ladder jumps 4× at a time (30 → 8 → 2 → 0.6), and at that spacing any handoff will look like a pop, or hide one. For the handoff you're studying, render consecutive steps at about 1.25× apart across each mark-class boundary (e.g. 12, 10, 8, 6.5, 5 px/m) and put them side by side. I'll look at those for pops. e
Bash: Inspect Ferrari reference image formatcd ~/work/pixelart-studies/pedagogy/master-copy/ref/lw && python3 -c "
import json;from PIL import Image
for n in ['V19','V16','V02','V09','V01','V13','V14','V30']:
im=Image.open(n+'.png'); d=json.load(open(n+'.json')); print(n, im.size, im.mode, list(d.keys())[:10])
"; head -c 600 V19.json; echo; ls ../../src ../../out | head -80
# output:
Traceback (most recent call last):
File "<string>", line 4, in <module>
File "~/miniconda3/lib/python3.12/json/__init__.py", line 293, in load
return loads(fp.read(),
^^^^^^^^^^^^^^^^
File "~/miniconda3/lib/python3.12/json/__init__.py", line 346, in loads
return _default_decoder.decode(s)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "~/miniconda3/lib/python3.12/json/decoder.py", line 337, in decode
obj, end = self.raw_decode(s, idx=_w(s, 0).end())
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "~/miniconda3/lib/python3.12/json/decoder.py", line 353, in raw_decode
obj, end = self.scan_once(s, idx)
^^^^^^^^^^^^^^^^^^^^^^
json.decoder.JSONDecodeError: Expecting property name enclosed in double quotes: line 1 column 2 (char 1)
{filename:'V19.LBM',width:640,height:480,colors:[[0,0,0],[227,227,255],[211,219,255],[207,215,255],[187,211,255],[167,207,255],[143,207,255],[115,203,255],[87,195,255],[83,183,251],[79,171,247],[75,159,243],[75,147,239],[75,139,235],[71,127,235],[75,111,231],[75,95,227],[79,79,227],[255,255,255],[255,255,255],[255,255,255],[255,255,255],[243,187,135],[243,187,135],[171,183,247],[239,159,159],[187,147,163],[155,139,171],[255,187,127],[255,187,127],[255,187,127],[235,151,99],[235,151,99],[235,151,99],[211,119,107],[211,119,107],[115,95,147],[131,147,207],[159,175,227],[179,195,255],[255,255,255]
../../out:
cell_10m_right_3x.png
cell_close_back_3x.png
cell_close_left_3x.png
cell_large_front_3x.png
cmp_c1.png
cmp_c2.png
cmp_c3.png
cmp_c3_far.png
cmp_c4.png
cmp_c4_bank.png
cmp_c5.png
copy_c1.png
copy_c2.png
copy_c3.png
copy_c4.png
copy_c5.png
honest_compare.png
palette_tarn.png
sheet_distance_light.png
sheet_distance_light_3x.png
sheet_seeds.png
sheet_seeds_3x.png
sheet_size_distance.png
sheet_size_distance_3x.png
sheet_size_light.png
sheet_size_light_3x.png
../../src:
__pycache__
c1_sky_rock.py
c2_mound.py
c3_bank.py
c4_water.py
c5_boulder.py
compare.py
cropinfo.py
fl.py
lw2png.py
lwload.py
pair.py
palsheet.py
runs.py
sheets.py
strandstats.py
tarn.py
zoom.pyBash: Read LW loader and bank copy codecd ~/work/pixelart-studies/pedagogy/master-copy/src && cat lwload.py | head -60; sed -n 1,80p c3_bank.py
# output:
import re, json, numpy as np
def load(name):
t=open(f'ref/lw/{name}.json').read()
t=re.sub(r'([{,])\s*([A-Za-z_]+)\s*:', r'\1"\2":', t).replace("'",'"')
d=json.loads(t)
d['pal']=np.array(d['colors'],dtype=np.uint8)
d['idx']=np.array(d['pixels'],dtype=np.int32).reshape(d['height'],d['width'])
return d
"""Copy 3: the near, sun-lit grass bank (left foreground). Ref crop native (0,250,150,219).
Discovered procedure (index map statistics, V16PM rows 254..473):
* DEPTH PALETTES. The bank is not one ramp; each depth band owns a tiny class set:
far (crest, y<48): black-green, olive, halftone ochre, lit ochre (4)
mid (48..140): lit ochre + a *muted* olive (78) + a little mid (83)
near (140..): dark green (82), olive (83), lit ochre (84)
The lit ochre is shared by all bands -- light is constant; what changes with distance
is the shadow colour, which gets darker and greener toward the viewer.
* GRAIN GROWS WITH NEARNESS. Far: isotropic stipple (runs ~1.6 both ways). Mid and
near: 1px vertical strands, mean run 3.8 -> 5 px, horizontal runs stay ~1.5.
* No lit-tip rule, no lean bias: transitions up/down are symmetric; blades are simply
vertical 1px runs of colour whose class is drawn from the band's mixture.
* Mixture drifts smoothly with depth (the value study): far crest has dark 'holes'
(40% black-green), lit peaks just behind the crest, mid bank ~50% lit, near bank
shifts to ~50% olive + ~30% dark green (self-shadowing in dense, close grass).
"""
import sys; sys.path.insert(0, 'src')
import numpy as np
from PIL import Image
import fl, compare
W, H = 150, 219
# classes (dusk colours)
C = {75: (28, 31, 20), 74: (60, 63, 45), 76: (106, 80, 40), 77: (196, 134, 76),
78: (62, 59, 23), 82: (10, 34, 10), 83: (115, 92, 35), 84: (196, 131, 70)}
KEYS = list(C)
PAL = [C[k] for k in KEYS]
K = {k: i for i, k in enumerate(KEYS)}
# value study: class mixture keyed by depth row (copyist's measurement of masses)
STUDY = [ # y, {class: share}
(0, {75: .42, 77: .38, 74: .15, 76: .05}),
(12, {77: .65, 75: .22, 76: .10, 74: .03}),
(24, {77: .66, 76: .31, 75: .03}),
(36, {76: .53, 77: .40, 84: .05, 78: .02}),
(48, {76: .39, 78: .29, 84: .21, 77: .11}),
(60, {78: .45, 84: .35, 76: .12, 77: .08}),
(72, {84: .55, 78: .38, 83: .03, 82: .04}),
(90, {84: .44, 78: .26, 83: .21, 82: .09}),
(120, {84: .52, 78: .23, 83: .22, 82: .03}),
(144, {83: .47, 84: .45, 78: .07, 82: .01}),
(170, {83: .50, 84: .34, 82: .14, 78: .02}),
(195, {83: .48, 82: .24, 84: .28}),
(219, {83: .46, 84: .32, 82: .22}),
]
def mixture_at(y, study=STUDY):
ys = [s[0] for s in study]
j = np.searchsorted(ys, y, side='right') - 1
j = int(np.clip(j, 0, len(study) - 2))
y0, a = study[j]; y1, b = study[j + 1]
t = np.clip((y - y0) / (y1 - y0), 0, 1)
keys = set(a) | set(b)
return {k: (1 - t) * a.get(k, 0) + t * b.get(k, 0) Bash: Create loader and render Ferrari scenes at 2xmkdir -p ~/work/pixelart-studies/elements/ground/{src,ref,stages,sheets,photos} && cd ~/work/pixelart-studies/elements/ground && cat > src/lw.py <<'EOF'
"""Load Ferrari LBM-json scenes (index map + palette)."""
import re, json, os, numpy as np
from PIL import Image
ROOTS = [os.path.expanduser('~/work/pixelart-studies/pedagogy/master-copy/ref/lw'),
os.path.expanduser('~/work/pixelart-studies/aerial/refs/ferrari_lw')]
def load(name):
for r in ROOTS:
p = f'{r}/{name}.json'
if os.path.exists(p):
t = open(p).read(); break
t = re.sub(r'([{,])\s*([A-Za-z_]+)\s*:', r'\1"\2":', t).replace("'", '"')
d = json.loads(t)
d['pal'] = np.array(d['colors'], dtype=np.uint8)
d['idx'] = np.array(d['pixels'], dtype=np.int32).reshape(d['height'], d['width'])
return d
def rgb(d, box=None):
a = d['pal'][d['idx']]
if box: x0, y0, x1, y1 = box; a = a[y0:y1, x0:x1]
return a
def up(a, k):
return np.repeat(np.repeat(a, k, 0), k, 1)
def save(a, path, k=1):
Image.fromarray(up(np.asarray(a, np.uint8), k)).save(path)
EOF
cd src && python3 -c "
import lw
for n in ['V19','V16','V02','V09','V01','V13','V14','V30','V11AM','V29PM']:
d=lw.load(n); lw.save(lw.rgb(d),'../ref/'+n+'_2x.png',2)
"; ls ../ref
# output:
V01_2x.png
V02_2x.png
V09_2x.png
V11AM_2x.png
V13_2x.png
V14_2x.png
V16_2x.png
V19_2x.png
V29PM_2x.png
V30_2x.pngRead: elements/ground/ref/V19_2x.pngRead: elements/ground/ref/V16_2x.png
1. elements/ground/ref/V16_2x.png
2. elements/ground/ref/V19_2x.png
Read: elements/ground/ref/V02_2x.png
3. elements/ground/ref/V02_2x.png
Read: elements/ground/ref/V09_2x.png
4. elements/ground/ref/V09_2x.png
Bash: Contact sheet of all Ferrari scenescd ~/work/pixelart-studies/elements/ground/src && ls ~/work/pixelart-studies/aerial/refs/ferrari_lw/ | sed 's/\..*//' | sort -u | tr '\n' ' '; python3 -c "
import lw,os,numpy as np
from PIL import Image
names=sorted(set(f.split('.')[0] for f in os.listdir(os.path.expanduser('~/work/pixelart-studies/aerial/refs/ferrari_lw')) if f.endswith('.json')))
ims=[]
for n in names:
try: ims.append((n,lw.rgb(lw.load(n))))
except Exception as e: print(n,e)
W=4; rows=(len(ims)+W-1)//W
sheet=Image.new('RGB',(W*640,rows*480))
from PIL import ImageDraw
dr=ImageDraw.Draw(sheet)
for i,(n,a) in enumerate(ims):
x,y=(i%W)*640,(i//W)*480
sheet.paste(Image.fromarray(a[:480,:640]),(x,y)); dr.text((x+5,y+5),n,fill=(255,255,0))
sheet.resize((W*320,rows*240)).save('../ref/all_scenes.png'); print(len(ims))
"
# output:
CORAL V01 V02 V03 V04 V05HAUNT V05RAIN V07 V08 V08AM V09 V10 V11AM V13 V14 V15 V16 V16PM V17 V19 V19PM V20 V25HEAT V26SNOW V27 V28 V29 V29PM V30 29Read: elements/ground/ref/all_scenes.png
5. elements/ground/ref/all_scenes.png
Bash: Render more ground scenes at 2xcd ~/work/pixelart-studies/elements/ground/src && python3 -c "
import lw
for n in ['V13','V30','V25HEAT','V04','V27','V16PM','V26SNOW','V29','V03']:
d=lw.load(n); lw.save(lw.rgb(d),'../ref/'+n+'_2x.png',2)
"
# output:
(Bash completed with no output)Read: elements/ground/ref/V13_2x.png
6. elements/ground/ref/V13_2x.png
Read: elements/ground/ref/V30_2x.png
7. elements/ground/ref/V30_2x.png
Read: elements/ground/ref/V25HEAT_2x.png
8. elements/ground/ref/V25HEAT_2x.png
Read: elements/ground/ref/V16PM_2x.png
9. elements/ground/ref/V16PM_2x.png
Read: pedagogy/master-copy/out/cmp_c2.png
10. pedagogy/master-copy/out/cmp_c2.png
Read: pedagogy/master-copy/out/cmp_c3.png
11. pedagogy/master-copy/out/cmp_c3.png
Bash: List analysis and atelier imagescd ~/work/pixelart-studies/pedagogy && ls analysis analysis/img atelier atelier/stage* | head -80 # output: atelier/stage1_forms.py atelier/stage2_boulder.py atelier/stage3_setting.py atelier/stage4_material.py analysis: LESSONS.md img out sheets src analysis/img: 00_native.png 01_grid.png 02_planes.png 03_grey.png 03_notan.png 03_valueplan.png 03_valueplanes.png 05_light_mountain.png 05_light_tree.png 05_lightmap.png 06_reflection_compare.png 07_form.png 07_form_mid.png 07_profiles.png 08_edges.png 09_cluster_atlas.png 10_palette_ab.png 10_palette_groups.png 11_checker_map.png 12_depth.png 20_notan_compare.png 21_cluster_atlas_mine.png 22_edges_mine.png 23_grass_mine_vs_ferrari.png c_grass_left.png c_grass_right.png c_hummock_base.png c_island_bottom.png c_midbanks.png c_midzone.png c_mountain.png c_rightmid.png c_shore.png c_sky.png c_stones.png c_tree_left.png c_water.png chk_large_left_10m_s1_depth.png chk_large_left_10m_s1_grey.png chk_large_left_10m_s1_grey_vs.png chk_large_left_10m_s1_notan.png chk_large_left_10m_s1_notan_vs.png chk_large_left_10m_s1_valueplan.png chk_large_left_10m_s1_valueplanes.png chk_medium_back_10m_s1_depth.png chk_medium_back_10m_s1_grey.png chk_medium_back_10m_s1_grey_vs.png chk_medium_back_10m_s1_notan.png chk_medium_back_10m_s1_notan_vs.png chk_medium_back_10m_s1_valueplan.png chk_medium_back_10m_s1_valueplanes.png chk_medium_back_40m_s1_depth.png chk_medium_back_40m_s1_grey.png chk_medium_back_40m_s1_grey_vs.png chk_medium_back_40m_s1_notan.png chk_medium_back_40m_s1_notan_vs.png chk_medium_back_40m_s1_valueplan.png chk_medium_back_40m_s1_valueplanes.png chk_medium_back_close_s1_depth.png chk_medium_back_close_s1_grey.png chk_medium_back_close_s1_grey_vs.png chk_medium_back_close_s1_notan.png chk_medium_back_close_s1_notan_vs.png chk_medium_back_close_s1_valueplan.png chk_medium_back_close_s1_valueplanes.png chk_medium_front_10m_s1_depth.png chk_medium_front_10m_s1_grey.png
Read: pedagogy/analysis/img/03_valueplan.pngBash: Read analysis LESSONScd ~/work/pixelart-studies/pedagogy && sed -n 1,200p analysis/LESSONS.md # output: # Learning to paint like Mark Ferrari: a study of *Alpine Tarn at Dusk* Everything below comes from programs in `src/`, and every figure is in `img/`. I looked at each figure myself before writing about it. The reference arrived as a 1268×938 PNG. It is an exact 2× nearest-neighbour upscale (every 2×2 block is one colour, with no phase error), so the native picture is **634×469 in 172 colours** (`img/00_native.png`). All measurements below are made on the native pixels. Contents 1. Analysis, drawn: planes, values, light, form, edges, clusters, palette, depth, pixel geometry 2. What it means: Ferrari's method, as a painter would teach it 3. The method: `src/paint/ferrari_method.py`, parameters and sheets 4. Checking my sheets with the same analyses, one by one 5. What I would do next --- ## 1. The analysis ### 1.1 Planes and big masses (`img/02_planes.png`) I traced ten planes by hand, using polygons for the ground and colour tests to split sky from tree and rock. Share of the picture: | plane | area | | plane | area | |---|---|---|---|---| | sky | 17.9% | | water | 16.5% | | mountain | 4.0% | | left meadow (mid) | 4.6% | | far forest | 9.0% | | left bank (fore) | 10.8% | | framing trees | 15.7% | | right bank (fore) | 13.1% | | far banks / hummocks | 6.6% | | island | 1.8% | The composition is a **V of foreground banks opening onto water**. The lit left bank drops from the middle-left down to the bottom centre, and the shadowed right bank rises diagonally from the bottom centre to the right edge. The water sits in the V. Above it a band of low hummocks carries the far shore, then a dark forest band, a small peak and a large sky. Tall framing conifers on both sides close the rectangle. The peak, the only warm-and-cool jewel, sits almost dead centre, in a notch the conifers leave open. Its reflection lies exactly under it, inside the V, so the V points the eye at the mirrored peak. ### 1.2 Value structure (`img/03_grey.png`, `img/03_notan.png`, `img/03_valueplanes.png`, `img/03_valueplan.png`) The grey version uses CIE L\*. Beside it is a squint version (Gaussian σ=2.5 px), because dithering means individual pixels lie about the value a region reads as. * **Two-value notan** (cut at L\*≈40): light = sky + snowfield + the reflected sky in the water + the lit left bank. Dark = every tree, the far banks and the whole right bank. It reads as an hourglass. A light field at the top pours through the notch around the peak into a light field at the bottom (water plus the left bank), pinched by two dark masses of trees and banks. * **Three-value notan**, with k-means centres at L\* **16 / 42 / 70**. Every plane stays inside one or two of the three groups (squint median, then the 5–95% spread): | plane | median | 5–95% | group | |---|---|---|---| | sky | 71 | 45–75 (the spread is only the tree edges) | light | | mountain | 48 | 17–72 | all three: the one place that uses the full range | | far forest | 20 | 9–44 | dark | | framing trees |
12. pedagogy/analysis/img/03_valueplan.png
Read: exemplars/student_boulder_dusk_tarn.png
13. exemplars/student_boulder_dusk_tarn.png
Bash: List atelier stages and lessonscd ~/work/pixelart-studies/pedagogy/atelier && ls; ls stage*/ | head -60; sed -n 1,80p LESSONS.md 2>/dev/null # output: LESSONS.md __pycache__ backdrop_util.py core.py drawing.py figs geometry.py lessons_figs.py lines.py method.py paint.py palette.py ref scene.py sheets stage1 stage1_forms.py stage2 stage2_boulder.py stage3 stage3_setting.py stage4 stage4_material.py stage5 valuemodel.py stage1/: forms_6v.png forms_lowsun_dirs.png stage2/: boulder_seed1.png boulder_seed2.png boulder_seed3.png boulder_seed4.png boulder_v2_all.png hull_all.png hull_seed1.png hull_seed2.png hull_seed3.png hull_seed4.png stage3/: buf_large_back_10m_s1.pkl buf_large_left_close_s1.pkl buf_medium_back_10m_s0.pkl buf_medium_front_10m_s0.pkl buf_medium_left_10m_s0.pkl buf_medium_left_40m_s0.pkl buf_medium_left_close_s0.pkl buf_medium_right_10m_s0.pkl buf_small_right_10m_s2.pkl crop_mtn.png dbg_map.png dbg_ndl.png dbg_shadow.png masses_large_back_10m_s1.png masses_large_left_close_s1.png masses_medium_back_10m_s0.png masses_medium_front_10m_s0.png masses_medium_left_10m_s0.png masses_medium_left_40m_s0.png masses_medium_left_close_s0.png masses_medium_right_10m_s0.png masses_small_right_10m_s2.png notan_large_back_10m_s1.png notan_large_left_close_s1.png notan_medium_back_10m_s0.png notan_medium_front_10m_s0.png notan_medium_left_10m_s0.png notan_medium_left_40m_s0.png notan_medium_left_close_s0.png notan_medium_right_10m_s0.png notan_small_right_10m_s2.png setting_large_back_10m_s1.png setting_large_left_close_s1.png setting_medium_back_10m_s0.png setting_medium_front_10m_s0.png setting_medium_left_10m_s0.png setting_medium_left_40m_s0.png setting_medium_left_close_s0.png setting_medium_right_10m_s0.png setting_small_right_10m_s2.png v1_lights.png v2_dists.png v2_lights.png # Lessons from the atelier: a boulder by a tarn at dusk, after Mark Ferrari I worked value first and colour last. Each stage below says what I learned, points to the images, and says what went wrong. After the stages come the method (the program) and an honest comparison of my sheets with Ferrari's tarn. Everything is in `~/work/pixelart-studies/pedagogy/atelier/`. To regenerate every sheet, run `nice -n 10 python3 method.py sheets`: four single-thread worker processes, about 4 minutes. **Research, and its limits.** I studied Ferrari's `ferrari_alpine_tarn_dusk.png` closely: - palette statistics; - crops at his native pixel scale (the file is his 634×469 frame doubled); - measured run lengths of his contours. I read his Q&A (EffectGames) on: - palette economy; - patterned dither for smooth ramps and diffusion dither for chaos; - colour cycling. On form and light I used Proko's value families (the terminator, core, reflected light that never reaches the halftone, cast shadow, occlusion). I also read Pedro Medeiros and Slynyrd on clustering and on rocks, and the notes on Kazuo Oga's poster-colour method (big soft masses first, detail last, about 21 colours). I did **not** look closely at Ferrari's other colour-cycling scenes or at Ghibli plates. Saving images from the web counts as a download, which needs permissi
Bash: Make squint mass maps of Ferrari scenescd ~/work/pixelart-studies/elements/ground/src && cat > analyze.py <<'EOF'
import lw, numpy as np, sys
from PIL import Image
from scipy import ndimage as ndi
def Lstar(rgb):
c = rgb.astype(float)/255; c = np.where(c>0.04045, ((c+0.055)/1.055)**2.4, c/12.92)
Y = c@np.array([0.2126,0.7152,0.0722]); return np.where(Y>0.008856, 116*np.cbrt(Y)-16, 903.3*Y)
def squint(rgb, s=3): return ndi.gaussian_filter(Lstar(rgb), s)
if __name__=='__main__':
for n in sys.argv[1:]:
d=lw.load(n); a=lw.rgb(d); L=squint(a,3)
q=np.digitize(L,np.percentile(L[240:],[25,50,75])) # quartiles of the lower half
g=(np.array([30,90,160,230])[q]).astype(np.uint8)
out=np.concatenate([a,np.stack([g]*3,-1)],1)
lw.save(out,f'../ref/mass_{n}.png',1)
EOF
python3 analyze.py V13 V16 V09 V30 V25HEAT V04 V27 V16PM V29
# output:
(Bash completed with no output)Read: elements/ground/ref/mass_V13.pngRead: elements/ground/ref/mass_V16.png
14. elements/ground/ref/mass_V16.png
15. elements/ground/ref/mass_V13.png
Bash: Crop Ferrari ground passages at 4xcd ~/work/pixelart-studies/elements/ground/src && python3 -c "
import lw
d=lw.load('V13'); a=lw.rgb(d)
lw.save(a[240:360,100:420],'../ref/v13_terraces_4x.png',4)
lw.save(a[360:480,240:560],'../ref/v13_path_4x.png',4)
d=lw.load('V16'); a=lw.rgb(d)
lw.save(a[215:300,200:520],'../ref/v16_hummocks_4x.png',4)
lw.save(a[380:480,380:640],'../ref/v16_rightbank_4x.png',4)
"
# output:
(Bash completed with no output)Read: elements/ground/ref/v13_terraces_4x.png
16. elements/ground/ref/v13_terraces_4x.png
Bash: Stretched crops of V13 terraces and pathcd ~/work/pixelart-studies/elements/ground/src && cat > crop.py <<'EOF'
"""crop.py NAME x0 y0 x1 y1 k [stretch] -> ../ref/crop_NAME_x0_y0_k.png"""
import lw, sys, numpy as np
n=sys.argv[1]; x0,y0,x1,y1,k=map(int,sys.argv[2:7]); st=len(sys.argv)>7
a=lw.rgb(lw.load(n))[y0:y1,x0:x1].astype(float)
if st:
lo,hi=np.percentile(a,1),np.percentile(a,99.5); a=np.clip((a-lo)/(hi-lo),0,1)**0.8*255
out=f'../ref/crop_{n}_{x0}_{y0}_{k}{"s" if st else ""}.png'; lw.save(a,out,k); print(out)
EOF
python3 crop.py V13 100 240 420 360 4 s; python3 crop.py V13 240 360 560 480 4 s
# output:
../ref/crop_V13_100_240_4s.png
../ref/crop_V13_240_360_4s.pngRead: elements/ground/ref/crop_V13_100_240_4s.pngRead: elements/ground/ref/crop_V13_240_360_4s.png
17. elements/ground/ref/crop_V13_100_240_4s.png
18. elements/ground/ref/crop_V13_240_360_4s.png
Read: elements/ground/ref/v16_hummocks_4x.pngRead: elements/ground/ref/v16_rightbank_4x.png
19. elements/ground/ref/v16_rightbank_4x.png
20. elements/ground/ref/v16_hummocks_4x.png
Bash: List other students' sheets and srccd ~/work/pixelart-studies/elements && ls driftwood/sheets driftwood/src cobbles/img willow/sheets channel/out | head -80 # output: channel/out: s0_copyA_v02.png s0_copyB_v11.png s0_triptych_A.png s1_still.png s1_still_8x_left.png s1_still_8x_right.png s1_triptych.png s2_a_far_slope_8x.png s2_b_far_cut_8x.png s2_c_near_gravel_8x.png s2_d_far_grass_8x.png s2_e_near_grass_8x.png s2_edges.png s3_flow.png s3_riffle_8x.png s4_colour.png s4_palettes.png s4_still_dusk_8x.png s4_still_midday_8x.png s4_still_overcast_8x.png s4_triptych.png s5_braid_low_midday_4x.png s5_braid_oblique_4x.png s5_channel.png s5_low_dusk_4x.png s5_low_dusk_8x.png sheet_1_width_x_depth.png sheet_2_flow_x_hour.png sheet_3_bank_x_camera.png sheet_4_distance_x_hour.png sheet_5_seeds_milky.png cobbles/img: compare_stage2.png compare_stage3.png compare_stage4.png compare_stage4_wet.png compare_stage5.png compare_stage6.png sheet_A_hour_x_distance.png sheet_B_rock_x_wetness.png sheet_C_density_x_seed.png sheet_D_light_direction.png stage0_boulder_zoom.png stage0_copy.png stage1_compare.png stage1_lights_sizes.png stage2_materials.png stage3_colour_hours.png stage4_nestled.png stage4_waterline.png stage5_bar.png stage5_bar_zoom_far.png stage5_bar_zoom_head.png stage6_edges.png stage6_grass_zoom.png stage6_water_zoom.png driftwood/sheets: sheet_distance_seed_1x.png sheet_distance_seed_3x.png sheet_length_diameter_1x.png sheet_length_diameter_3x.png sheet_light_hour_1x.png sheet_light_hour_3x.png sheet_orientation_camera_1x.png sheet_orientation_camera_3x.png sheet_rootwad_weathering_wetness_1x.png sheet_rootwad_weathering_wetness_3x.png driftwood/src: __pycache__ driftwood.py geo.py gravel.py gtest.py marks.py paint.py palette.py
Read: elements/driftwood/sheets/sheet_distance_seed_3x.png
21. elements/driftwood/sheets/sheet_distance_seed_3x.png
Read: elements/cobbles/img/sheet_A_hour_x_distance.png
22. elements/cobbles/img/sheet_A_hour_x_distance.png
Bash: Palette usage statistics in V16 ground regionscd ~/work/pixelart-studies/elements/ground/src && python3 -c "
import lw,numpy as np
from analyze import Lstar
d=lw.load('V16'); I=d['idx']; P=d['pal']
def rep(box,name):
x0,y0,x1,y1=box; s=I[y0:y1,x0:x1]; u,c=np.unique(s,return_counts=True); o=np.argsort(-c)
print(name, s.shape)
for k in o[:14]: print(' idx',u[k],P[u[k]],'L=%.0f'%Lstar(P[u[k]][None])[0],'%.1f%%'%(100*c[k]/s.size))
rep((265,270,420,300),'islet')
rep((340,225,640,260),'far hummocks')
rep((0,240,200,300),'left lit bank')
rep((0,330,200,480),'left near bank')
rep((420,330,640,480),'right bank')
"
# output:
islet (30, 155)
idx 76 [ 0 11 0] L=2 25.5%
idx 75 [59 79 39] L=31 21.4%
idx 77 [ 0 23 19] L=6 18.0%
idx 74 [223 211 67] L=83 14.3%
idx 62 [0 0 0] L=0 2.3%
idx 59 [ 0 31 15] L=9 1.8%
idx 61 [0 7 0] L=1 1.8%
idx 43 [71 75 95] L=32 1.5%
idx 44 [ 79 103 111] L=42 1.4%
idx 69 [31 67 39] L=25 1.1%
idx 56 [23 47 0] L=17 0.7%
idx 127 [0 7 7] L=2 0.7%
idx 120 [ 0 23 23] L=6 0.6%
idx 57 [0 0 0] L=0 0.6%
far hummocks (35, 300)
idx 70 [ 0 23 7] L=6 29.2%
idx 69 [31 67 39] L=25 27.6%
idx 71 [ 0 7 15] L=2 15.7%
idx 72 [ 0 31 19] L=9 14.1%
idx 61 [0 7 0] L=1 2.3%
idx 235 [31 67 39] L=25 1.8%
idx 60 [ 0 11 0] L=2 1.4%
idx 59 [ 0 31 15] L=9 1.3%
idx 53 [0 0 0] L=0 1.2%
idx 62 [0 0 0] L=0 0.9%
idx 56 [23 47 0] L=17 0.8%
idx 57 [0 0 0] L=0 0.8%
idx 52 [0 0 0] L=0 0.5%
idx 51 [23 47 0] L=17 0.4%
left lit bank (60, 200)
idx 77 [ 0 23 19] L=6 37.2%
idx 76 [ 0 11 0] L=2 20.7%
idx 71 [ 0 7 15] L=2 12.4%
idx 75 [59 79 39] L=31 11.2%
idx 74 [223 211 67] L=83 4.9%
idx 72 [ 0 31 19] L=9 3.0%
idx 61 [0 7 0] L=1 2.9%
idx 62 [0 0 0] L=0 2.4%
idx 84 [11 31 15] L=10 1.0%
idx 60 [ 0 11 0] L=2 0.6%
idx 111 [0 7 7] L=2 0.5%
idx 78 [19 43 23] L=15 0.5%
idx 104 [ 0 27 31] L=8 0.5%
idx 106 [23 55 79] L=22 0.5%
left near bank (150, 200)
idx 83 [ 0 15 11] L=3 36.9%
idx 84 [11 31 15] L=10 36.1%
idx 82 [0 7 7] L=2 12.4%
idx 78 [19 43 23] L=15 10.3%
idx 80 [0 7 7] L=2 0.7%
idx 81 [0 0 0] L=0 0.5%
idx 7 [ 47 191 255] L=73 0.3%
idx 61 [0 7 0] L=1 0.3%
idx 8 [ 35 183 255] L=70 0.2%
idx 23 [ 87 83 131] L=38 0.2%
idx 62 [0 0 0] L=0 0.2%
idx 22 [ 47 67 107] L=29 0.2%
idx 59 [ 0 31 15] L=9 0.1%
idx 58 [143 151 35] L=60 0.1%
right bank (150, 220)
idx 81 [0 0 0] L=0 34.0%
idx 80 [0 7 7] L=2 27.7%
idx 78 [19 43 23] L=15 22.3%
idx 79 [23 51 27] L=18 11.8%
idx 62 [0 0 0] L=0 0.9%
idx 61 [0 7 0] L=1 0.7%
idx 73 [79 83 19] L=34 0.6%
idx 59 [ 0 31 15] L=9 0.2%
idx 124 [19 47 67] L=18 0.1%
idx 121 [ 7 35 31] L=12 0.1%
idx 123 [43 63 91] L=26 0.1%
idx 122 [19 51 63] L=19 0.1%
idx 125 [ 7 35 43] L=12 0.1%
idx 126 [ 0 23 23] L=6 0.1%Bash: Visualize index maps with random colourscd ~/work/pixelart-studies/elements/ground/src && python3 -c "
import lw,numpy as np
rng=np.random.default_rng(3)
for n in ['V16','V13','V09','V30','V25HEAT','V04']:
d=lw.load(n); I=d['idx']
# group indices into ramps of consecutive indices: colour by idx//4-ish hashed, lightness by idx%4
cols=rng.integers(40,255,(256,3))
a=cols[I].astype(np.uint8)
lw.save(np.concatenate([lw.rgb(d),a],1),'../ref/idxmap_'+n+'.png',1)
"
# output:
(Bash completed with no output)Read: elements/ground/ref/idxmap_V16.png
23. elements/ground/ref/idxmap_V16.png
Bash: Read imgfetch tool headercd ~/work/pixelart-studies && sed -n 1,40p tools/imgfetch.py
# output:
#!/usr/bin/env python3
"""imgfetch: fast, polite reference-image sourcing (Wikimedia Commons + Openverse).
python3 imgfetch.py "cinder cone scoria slope" -n 12 -o refs/cinder
python3 imgfetch.py -f queries.txt -n 8 -o refs # one query per line; subdir per query
options: --src commons,openverse --width 1200 --sheet (writes <out>/_sheet.jpg)
Writes images plus <out>/sources.tsv (file, title, license, author, page url).
Cached: files already present are skipped. Handles 429/5xx with Retry-After backoff.
Parallel downloads (4 workers) with a shared per-host rate limiter (~3 req/s; Wikimedia's media
host is kept to 1 per 2 s and slows further on any 429, since it throttles hard per IP after bursts).
Openverse (mostly Flickr-hosted CC images) is the primary source because it stays fast.
"""
import argparse, concurrent.futures as cf, json, os, re, sys, threading, time, urllib.error, urllib.parse, urllib.request
UA = "pixelart-studies-imgfetch/1.0 (research; contact: local)"
_locks = {}; _last = {}; _glock = threading.Lock()
SLOW_HOSTS = {"upload.wikimedia.org": 2.0} # Wikimedia media host: ~1 req/s or it 429s
def _throttle(host, min_gap=0.34):
min_gap = SLOW_HOSTS.get(host, min_gap)
with _glock:
lk = _locks.setdefault(host, threading.Lock())
with lk:
dt = time.time() - _last.get(host, 0)
if dt < min_gap:
time.sleep(min_gap - dt)
_last[host] = time.time()
def get(url, tries=4, timeout=25):
host = urllib.parse.urlparse(url).netloc
if host == "upload.wikimedia.org":
tries = 2 # fail fast; other sources fill in
for i in range(tries):
_throttle(host)
try:
req = urllib.request.Request(url, headers={"User-Agent": UA})
return urllib.request.urlopen(req, timeout=timeout).read()
except urllib.error.HTTPError as e:Bash: Fetch reference photos of ground typescd ~/work/pixelart-studies/elements/ground/photos && cat > q.txt <<'EOF' alpine meadow rolling hills late afternoon shadows grassy hillside low sun long shadows dirt path through meadow forest floor sunlight patches dappled light forest floor moss rolling pasture evening light gravel river bar bare earth slope erosion muddy trail puddles sand dunes ripples low sun EOF timeout 600 python3 ../../../tools/imgfetch.py -f q.txt -n 10 -o . --sheet 2>&1 | tail -15; ls # output: (eval):13: command not found: timeout q.txt
Bash: Fetch reference photos of ground typescd ~/work/pixelart-studies/elements/ground/photos && python3 ../../../tools/imgfetch.py -f q.txt -n 10 -o . --sheet 2>&1 | tail -15; ls # output: download failed: HTTP Error 429: Too many requests - please contact noc@wikimedia.org to discuss a less disruptive approach or instead use thumbnail images in sizes listed on https://w.wiki/GHai. (0c640b1) download failed: HTTP Error 429: Too many requests - please contact noc@wikimedia.org to discuss a less disruptive approach or instead use thumbnail images in sizes listed on https://w.wiki/GHai. (0c640b1) download failed: HTTP Error 429: Too many requests - please contact noc@wikimedia.org to discuss a less disruptive approach or instead use thumbnail images in sizes listed on https://w.wiki/GHai. (0c640b1) download failed: HTTP Error 429: Too many requests - please contact noc@wikimedia.org to discuss a less disruptive approach or instead use thumbnail images in sizes listed on https://w.wiki/GHai. (0c640b1) download failed: HTTP Error 403: Forbidden 'alpine meadow rolling hills late afternoon shadows': 9 images -> ./alpine_meadow_rolling_hills_late_afterno 'grassy hillside low sun long shadows': 3 images -> ./grassy_hillside_low_sun_long_shadows 'dirt path through meadow': 8 images -> ./dirt_path_through_meadow 'forest floor sunlight patches': 9 images -> ./forest_floor_sunlight_patches 'dappled light forest floor moss': 7 images -> ./dappled_light_forest_floor_moss 'rolling pasture evening light': 7 images -> ./rolling_pasture_evening_light 'gravel river bar': 8 images -> ./gravel_river_bar 'bare earth slope erosion': 9 images -> ./bare_earth_slope_erosion 'muddy trail puddles': 8 images -> ./muddy_trail_puddles 'sand dunes ripples low sun': 9 images -> ./sand_dunes_ripples_low_sun alpine_meadow_rolling_hills_late_afterno bare_earth_slope_erosion dappled_light_forest_floor_moss dirt_path_through_meadow forest_floor_sunlight_patches grassy_hillside_low_sun_long_shadows gravel_river_bar muddy_trail_puddles q.txt rolling_pasture_evening_light sand_dunes_ripples_low_sun
Read: elements/ground/photos/alpine_meadow_rolling_hills_late_afterno/_sheet.jpg
24. elements/ground/photos/alpine_meadow_rolling_hills_late_afterno/_sheet.jpg
Read: elements/ground/photos/rolling_pasture_evening_light/_sheet.jpg
25. elements/ground/photos/rolling_pasture_evening_light/_sheet.jpg
Read: elements/ground/photos/forest_floor_sunlight_patches/_sheet.jpg
26. elements/ground/photos/forest_floor_sunlight_patches/_sheet.jpg
Read: elements/ground/photos/dirt_path_through_meadow/_sheet.jpg
27. elements/ground/photos/dirt_path_through_meadow/_sheet.jpg
Bash: Close crops of V16 islet and hummockscd ~/work/pixelart-studies/elements/ground/src && python3 crop.py V16 262 262 425 305 6 && python3 crop.py V16 430 218 640 275 5 # output: ../ref/crop_V16_262_262_6.png ../ref/crop_V16_430_218_5.png
Read: elements/ground/ref/crop_V16_262_262_6.pngRead: elements/ground/ref/crop_V16_430_218_5.png
28. elements/ground/ref/crop_V16_262_262_6.png
29. elements/ground/ref/crop_V16_430_218_5.png
Bash: Character map of the V16 islet index pixelscd ~/work/pixelart-studies/elements/ground/src && python3 -c "
import lw,numpy as np
d=lw.load('V16'); I=d['idx']
s=I[262:305,262:425]
# order classes by L
from analyze import Lstar
P=d['pal']
# checker stats among lit(74) pixels in transition zone
lit=(s==74)
# fraction of lit pixels whose 4-neighbours are lit
n4=(np.roll(lit,1,0)&lit).sum()+(np.roll(lit,1,1)&lit).sum()
diag=(np.roll(np.roll(lit,1,0),1,1)&lit).sum()+(np.roll(np.roll(lit,1,0),-1,1)&lit).sum()
print('lit',lit.sum(),'4-adj pairs',n4,'diag pairs',diag)
# print a char map
ch={74:'#',75:'+',76:'.',77:':',62:' ',59:'-',61:' '}
for r in s[:43]:
print(''.join(ch.get(v,'?') for v in r[:160]))
"
# output:
lit 846 4-adj pairs 784 diag pairs 1064
????????????- -?? -?-- - ?-??-??????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????
?????????????- ?- ??-? ?-- ?-??--?---??? ????????????????????????????????????????????????????????????????????????????????????????????????????????????????????
????????????- -?- ? - ?? -?-?--? ---?? ? ?? ?: ? : : :?: ?????????????????????????????????????????????????????????????????????????????????????????????????
??????????- ?- -?? -? ?-? ? ???? ??-? ? ?:?:??:? :????:?:?:?????????????????????????????????????????????????????????????????????????????????????????????
???????????---- ?? - ? ? ?-???-?? ??? ? ????##:#:#:#:#:#::#::::::???????????????????????????????????????????????????????????????????????????????????????
??????????????-- --- # ??#??-?####?##???####:#:#:#:#:+:+:::.:::::::::::::????????????????????????????????????????????????????????????????????????????????
???????????????-?--?-?? -?-# #+??+###+??#+?+###+#+#+#.#++::#::.:.:.:.:::.::::::::::::?:??:?:???????????????????????????????????????????????????????????????????
????????????????---- -?#-##+#+?###?+#+###+#+###+#+#.#.#.#.+.+.+.+:.:.:.:.:.:.:.:.:::::::.::::?:?:?:?:??:????????????????????????????????????????????????????????
???????????????-???-- # #?######+###?#+#+###+#++++++++++.+.+.#.:..:::.:.::.::.::::::::::::::::::::??:?:??:??????????????????????????????????????????????????????
?????????????????- ?- #?##+##+#+#+###+#+#+###+#+#+#+#.#.+.+.+.+.+.+.+.+:..:..:.:.:::.:.:::.:.:.::::::::??:??:???????????????????????????????????????????????????
??????????????????-#- ##+#?##?#############+##+#++++++++++.#++.+++.......::::.:...:.:.:.:::::.::::::::::::?:?:??:???????????????????????????????????????????????
????????????????? -#?######+?+###+#+#########++##+#.+#+.#.+.+.+.+.+..:+.+.:..:.:.:.:::.:.:::.:.:.:::.:::.:::::?:???:????????????????????????????????????????????
????????????????-?#+##+###########+#####+++++#++++++#+++++++.+...+.+......:..........:..:.:::::::::::::::::::::?:?:??::?:?????????? ---? - ??????????????
??????????????#-# #####+#+#+#+#+#+###+#+#+#+###+++#++.+.+.+.+.+.+.+++.+.+:.:..:..:.:.:::.:.:::::.:.:::.::.:.::.::::?:?::???????????? --?? --? --??
???????????????#?###############+#+#+#+++#++#+++++++++++#+.+++.+.+..........+...:...:::.:.:.:.:::.:::::::::::.::::::::?:?:???:-?????-??--? --? ? -?-?
?????????????#?+##+##+#+###+#+#+#+++#+#+#####+++#+++#++.+++.+.+#+.+.+.+...+.....+......:::::.:.:::::::.:.:.:.:::.::.::::?:?::?? ?---??-? ---?- - --??
?????????????#-#+#############+++#+#+#+#+#+#+++++++#+#++++++++.+...+...++...:.:...:...:..:......::..:::.:::.:::::::.:.:::::?:?:--- -?---?- -?? ?- ?? -?--
???????????#?#+######+###+#+##++#+#+#+++#+###+#++++++++++.+.+.+.+.+.+...+.+...+.+........:..::.:::.:.:.:.:.:.:.:.:.:::::.:::::?: : --- -- ? -?
??????????#?##############+##+#++#+++#+++##+++++++#++.#+++++.+Bash: Write dither module and stage 0a islet copycd ~/work/pixelart-studies/elements/ground/src && cat > dither.py <<'EOF'
"""Quantizers: continuous step coordinate v in [0, n-1] -> integer step, mixing only adjacent steps.
ordered(v, jitter): DPaint-style gradient-fill dither. 2x2 Bayer thresholds (so 50% = checker),
plus a random jitter on the threshold (DPaint's 'dither' slider).
"""
import numpy as np
B2 = np.array([[0.125, 0.625], [0.875, 0.375]])
B4 = (np.array([[0, 8, 2, 10], [12, 4, 14, 6], [3, 11, 1, 9], [15, 7, 13, 5]]) + 0.5) / 16
def tile(m, h, w, ox=0, oy=0):
k = m.shape[0]
return np.tile(m, (h // k + 2, w // k + 2))[oy % k: oy % k + h, ox % k: ox % k + w]
def ordered(v, jitter=0.25, rng=None, bayer=B2, n=None):
h, w = v.shape
rng = rng or np.random.default_rng(0)
t = tile(bayer, h, w) + jitter * (rng.random((h, w)) - 0.5)
base = np.floor(v)
s = base + ((v - base) > t)
if n is not None: s = np.clip(s, 0, n - 1)
return s.astype(int)
def stochastic(v, rng=None, n=None, clump=None):
"""random threshold (optionally from a clumped noise field in [0,1])"""
h, w = v.shape
rng = rng or np.random.default_rng(0)
t = rng.random((h, w)) if clump is None else clump
base = np.floor(v)
s = base + ((v - base) > t)
if n is not None: s = np.clip(s, 0, n - 1)
return s.astype(int)
EOF
cat > s0_islet.py <<'EOF'
"""Stage 0a: copy of V16's lit islet by procedure.
A: his value field (blurred step map) + my quantizer -> isolates the dither procedure
B: my own field (DPaint contour-gradient with highlight point) + my quantizer -> full procedure"""
import lw, numpy as np, dither
from scipy import ndimage as ndi
from PIL import Image, ImageDraw
d = lw.load('V16'); I = d['idx']; P = d['pal']
x0, y0, x1, y1 = 262, 262, 425, 305
S = I[y0:y1, x0:x1]
RAMP = [77, 76, 75, 74] # step 0..3 (index order: 74 lit ... 77 darkest-cool)
rank = {k: i for i, k in enumerate(RAMP)}
mask = np.isin(S, RAMP)
st = np.vectorize(lambda k: rank.get(k, 0))(S).astype(float)
# his field: normalized blur of the step map inside the mask
sg = 2.5
num = ndi.gaussian_filter(st * mask, sg); den = ndi.gaussian_filter(mask.astype(float), sg) + 1e-6
field = num / den
rng = np.random.default_rng(1)
A = dither.ordered(field, jitter=0.35, rng=rng, n=4)
# B: my own field. mask = his silhouette (we test fill, not drawing, here)
h, w = S.shape
yy, xx = np.mgrid[0:h, 0:w]
dist_in = ndi.distance_transform_edt(mask)
hx, hy = 8, 14 # highlight point (upper-left, where the sun pool sits)
r = np.hypot((xx - hx) / 1.0, (yy - hy) * 2.2) # oval falloff, squashed vertically (a flat-ish mound)
vB = 3.35 - r / 34.0 # linear falloff from the highlight
vB = vB - 0.6 * np.exp(-dist_in / 2.0) * (yy > h * 0.55) # darker foot rim at the waterline
vB = np.clip(vB, 0, 3)
B = dither.ordered(vB, jitter=0.35, rng=np.random.default_rng(2), n=4)
def paint(steps):
out = P[S].copy()
out[mask] = P[np.array(RAMP)[steps]][mask]
return out
ref = P[S]
row = lambda *ims: np.concatenate([np.pad(i, ((0, 0), (0, 3), (0, 0))) for i in ims], 1)
lw.save(row(ref, paint(A), paint(B)), '../stages/s0a_islet_6x.png', 6)
# field diagnostic
g = lambda f: np.stack([(f / 3 * 255).astype(np.uint8)] * 3, -1)
lw.save(row(g(field), g(vB)), '../stages/s0a_islet_fields_4x.png', 4)
EOF
python3 s0_islet.py
# output:
(Bash completed with no output)Read: elements/ground/stages/s0a_islet_6x.png
30. elements/ground/stages/s0a_islet_6x.png
Bash: Compare dither jitter levels against Ferrari isletcd ~/work/pixelart-studies/elements/ground/src && cat > s0_islet_j.py <<'EOF'
import lw, numpy as np, dither
exec(open('s0_islet.py').read().split("rng = np.random")[0])
outs=[P[S]]
for j in [0.5,0.8,1.1]:
A = dither.ordered(field, jitter=j, rng=np.random.default_rng(1), n=4)
o=P[S].copy(); o[mask]=P[np.array(RAMP)[A]][mask]; outs.append(o)
outs=[o[:, :110] for o in outs]
lw.save(np.concatenate([np.pad(i,((0,3),(0,0),(0,0))) for i in outs],0),'../stages/s0a_jitter_6x.png',6)
EOF
python3 s0_islet_j.py
# output:
(Bash completed with no output)Read: elements/ground/stages/s0a_jitter_6x.png
31. elements/ground/stages/s0a_jitter_6x.png
Bash: Check the cobbles and driftwood APIs for integrationcd ~/work/pixelart-studies && ls elements/cobbles/cobbles_api.py && sed -n 1,60p elements/cobbles/cobbles_api.py; grep -n "^def " elements/cobbles/cobbles_api.py elements/driftwood/src/driftwood.py elements/driftwood/src/paint.py | head -40
# output:
elements/cobbles/cobbles_api.py
"""cobbles_api.py -- the technique as one call with parameters (stage 7).
paint_patch(W, H, rock, size_m, wet, density, sun, hour, px_per_stone, seed) -> RGB uint8
rock 'granite' | 'greywacke' | 'basalt' | 'mixed' (a braided-river bar: mostly
greywacke, some granite, some basalt)
size_m median clast size in metres (sets the lognormal spread's centre)
px_per_stone the median stone's size in PIXELS -- the distance knob. >= 5: stones;
2-5: ticks; < 2: grain
wet 0 dry .. 1 all wet (stones on the wet ramp: darker, saturated, glints)
density packing: 1 = clast-supported (touching), < 1 opens matrix between
sun (azimuth, elevation) in degrees; hour picks the palette (the light's colour)
seed everything random
"""
import numpy as np
from bar import bar
from palette import water_ramp
MIXES = {'granite': [('granite', 1.0)], 'greywacke': [('greywacke', 1.0)], 'basalt': [('basalt', 1.0)],
'mixed': [('greywacke', 0.55), ('granite', 0.25), ('basalt', 0.2)]}
HOUR_SUN = {'noon': (120, 62), 'morning': (30, 30), 'golden': (160, 16), 'dusk': (175, 7),
'overcast': (100, 80)}
def paint_patch(W=96, H=64, rock='mixed', size_m=0.12, wet=0.0, density=1.0, sun=None, hour='noon',
px_per_stone=16, seed=0, depth_ratio=1.0):
sun = sun or HOUR_SUN[hour]
ppm = px_per_stone / size_m
cv, info = bar(W=W, H=H, sun=sun, seed=seed, head_m=size_m, tail_m=size_m,
px_per_m_far=ppm / depth_ratio, px_per_m_near=ppm, density=density, mix=MIXES[rock],
size_sd=0.4)
if wet > 0:
rng = np.random.default_rng(seed + 7)
st = cv.mat >= 0
cv.wet[st & (rng.random(cv.step.shape) < 1.0)] = 1 if wet >= 0.5 else 0
if wet < 0.5 and info is not None:
# damp: whole stones whose base lies in the lower part of the patch (per stone,
# never a ruled line through a stone)
cv.wet[:] = 0
for i in info['order']:
m = info['vis'][i]
ys = np.nonzero(m)[0]
if len(ys) and ys.max() > H * (1 - 2 * wet):
cv.wet[m] = 1
g = cv.mat < 0
cv.step[g] = np.maximum(cv.step[g] - int(round(wet * 2)), 0)
return cv.render(hour)
elements/cobbles/cobbles_api.py:25:def paint_patch(W=96, H=64, rock='mixed', size_m=0.12, wet=0.0, density=1.0, sun=None, hour='noon',
elements/driftwood/src/driftwood.py:34:def paint(length=3.0, diameter=0.35, orientation=15.0, rootwad=False, weathering=1.0, wetness=0.0,
elements/driftwood/src/driftwood.py:70:def _cell(args):
elements/driftwood/src/driftwood.py:78:def sheet(name, cells, cols, title, scale=3):
elements/driftwood/src/driftwood.py:91:def _def(name):
elements/driftwood/src/driftwood.py:99:def s_len_diam():
elements/driftwood/src/driftwood.py:108:def s_orient_cam():
elements/driftwood/src/driftwood.py:118:def s_Write: elements/ground/src/ground.py"""ground.py -- the ground as big designed value masses, with texture living inside them.
g = paint_ground(W, H, ground='turf', landform='rolling', hour='golden', sun=None,
moisture=0.2, ppm=24, pitch=14, cam_h=1.7, canopy=0.0, seed=0)
g.rgb (H, W, 3) uint8
g.mat, g.band, g.step the index canvas (material, depth band, ramp step) -- palette-swappable
g.depth, g.wx, g.wz, g.h per-pixel world buffers, so other students can stand objects on it
g.relight(hour) new rgb from the same index canvas
Layers (each a separate function so it can be tuned against a reference):
1 LANDFORM analytic height h(x,z) in metres built from a few big features
(rolling swells, hummocks, bank, ledges, path) -- the masses come from here
2 RENDER voxel-space column march: per pixel world x,z,h,normal,depth,px-per-metre
3 LIGHT sun visibility (terrain horizon shadow x off-frame canopy shadow) and N.L,
sky ambient, hollow occlusion; *smoothed at mass scale* so light makes shapes
4 VALUE PLAN two families that never touch: shadow steps 0..2, light steps 3..5; the
terminator is a jump. crest separation: land just behind a crest drops a step.
5 MATERIAL turf / meadow / earth / gravel / forest floor; path and bare risers; moisture
6 MARKS offsets in step space whose size follows px-per-metre and whose contrast follows
the family and distance: strands -> ticks -> DPaint dither -> flat
7 QUANTIZE ordered 2x2 + jitter (the V16 islet recipe), adjacent steps only
8 PALETTE per material x depth band x hour ramp, generated from albedo x light colour
"""
import numpy as np
from scipy import ndimage as ndi
import dither
# ------------------------------------------------------------------ 1 LANDFORM
class Landform:
"""h(x, z) as a sum of big features. Everything analytic, so it can be sampled anywhere."""
def __init__(self, kind='rolling', seed=0, relief=1.0, extent=(-200, 200, 0, 400), path=False,
water=None):
self.kind, self.seed, self.relief = kind, seed, relief
r = np.random.default_rng(seed)
self.r = r
# low-frequency swells: random-direction sinusoids (smooth, big)
n = 7
ang = r.uniform(0, np.pi, n)
wl = r.uniform(18, 60, n)
self.sw = [(np.cos(a) * 2 * np.pi / l, np.sin(a) * 2 * np.pi / l, r.uniform(0, 6.3), l / 60.0)
for a, l in zip(ang, wl)]
# micro relief (only drives marks/light breakup, small amplitude)
m = 9
ang = r.uniform(0, np.pi, m); wl = r.uniform(1.5, 6, m)
self.micro = [(np.cos(a) * 2 * np.pi / l, np.sin(a) * 2 * np.pi / l, r.uniform(0, 6.3), l / 6.0)
for a, l in zip(ang, wl)]
# hummocks: lozenges
self.hum = []
if kind in ('hummocks', 'bank'):
for _ in range(60):
self.hum.append((r.uniform(-60, 60), r.uniform(3, 140), r.uniform(3, 9), r.uniform(1.5, 3.5),
r.uniform(0.4, 1.3), r.uniform(-0.5, 0.5)))
self.path = path
self.path_a, self.path_b, self.path_c = r.uniform(2, 5), r.uniform(12, 25), r.uniform(-1.5, 1.5)
self.water = water
def path_center(self, z):
return self.path_c + self.path_a * np.sin(z / self.path_b + 1.3) + 0.02 * z
def path_dist(self, x, z):
return np.abs(x - self.path_center(z))
def h(self, x, z, micro=True):
k = self.kind
H = np.zeros(np.broadcast(x, z).shape)
amp = {'flat': 0.25, 'rolling': 1.6, 'hummocks': 0.5, 'bank': 0.6, 'ledges': 0.4}[k] * self.relief
for kx, kz, ph, a in self.sw:
H = H + amp * a * np.sin(kx * x + kz * z + ph)
if k == 'rolling':
# rolling land swells more with distance (terraces of V13)
pass
if k in ('hummocks', 'bank'):
for cx, cz, ax, az, ht, rot in self.hum:
c, s = np.cos(rot), np.sin(rot)
u = ((x - cx) * c + (z - cz) * s) / ax
v = (-(x - cx) * s + (z - cz) * c) / az
q = u * u + v * v
H = H + ht * self.relief * np.where(q < 1, (1 - q) ** 1.5, 0)
if k == 'bank':
# the land falls toward a shoreline at z = zw (the viewer stands on a bank)
zw = self.water if self.water is not None else 14.0
H = H - 1.8 * self.relief * (1 / (1 + np.exp(-(z - zw) / 2.5)))
if k == 'ledges':
# a slope rising away from the viewer, cut into terraces with risers facing us
base = 0.18 * z
step = 2.4 * self.relief
q = base / step
fr = q - np.floor(q)
riser = 1 / (1 + np.exp(-(fr - 0.82) * 22))
H = H + step * (np.floor(q) + riser) + 0.4 * np.sin(x / 9 + np.floor(q) * 2.1)
if micro:
for kx, kz, ph, a in self.micro:
H = H + 0.05 * a * np.sin(kx * x + kz * z + ph)
if self.path:
d = self.path_dist(x, z)
H = H - 0.12 * np.exp(-(d / 1.0) ** 2)
return H
def normal(self, x, z, e=0.15, micro=True):
hx = (self.h(x + e, z, micro) - self.h(x - e, z, micro)) / (2 * e)
hz = (self.h(x, z + e, micro) - self.h(x, z - e, micro)) / (2 * e)
n = np.stack([-hx, np.ones_like(hx), -hz], -1)
return n / np.linalg.norm(n, axis=-1, keepdims=True)
# ------------------------------------------------------------------ 2 RENDER
def render(land, W, H, ppm=24, pitch=14, cam_h=1.7, steps=900):
"""Column march. ppm = pixels per metre at the ground point under the screen centre."""
p = np.radians(pitch)
zc0 = cam_h / np.sin(p) # camera-space depth of the centre ground point
F = ppm * zc0
cx, cy = W / 2, H / 2
cols = np.arange(W) + 0.5
rows = np.arange(H)[:, None] + 0.5
# nearest ground: ray through the bottom row
ang_bot = p + np.arctan((H - cy) / F)
z_near = max(0.05, cam_h / np.tan(min(ang_bot, 1.5)) * 0.7)
ang_top = p - np.arctan(cy / F)
z_far = 2000.0 if ang_top <= 0.002 else min(2000.0, cam_h / np.tan(ang_top) * 3 + 50)
zs = z_near * (z_far / z_near) ** (np.arange(steps) / (steps - 1))
Z = np.full((H, W), np.inf); X = np.zeros((H, W))
ymin = np.full(W, H + 0.0)
for z in zs:
zc_est = z * np.cos(p) + cam_h * np.sin(p)
x = (cols - cx) * zc_est / F
h = land.h(x, np.full_like(x, z))
y = h - cam_h
zc = -y * np.sin(p) + z * np.cos(p)
yc = y * np.cos(p) + z * np.sin(p)
row = cy - F * yc / np.maximum(zc, 1e-3)
m = (rows >= row[None, :]) & (rows < ymin[None, :])
if m.any():
Z = np.where(m, z, Z)
X = np.where(m, x[None, :], X)
ymin = np.minimum(ymin, row)
sky = ~np.isfinite(Z)
Zs = np.where(sky, z_far, Z)
buf = dict(z=Zs, x=X, sky=sky, F=F, pitch=p, cam_h=cam_h)
zc = Zs * np.cos(p) + cam_h * np.sin(p)
buf['ppm'] = F / zc # local pixels per metre
buf['h'] = land.h(X, Zs)
buf['n'] = land.normal(X, Zs)
buf['n_macro'] = land.normal(X, Zs, e=0.8, micro=False)
return buf
# ------------------------------------------------------------------ 3 LIGHT
HOURS = {
# sun az, el sun colour sky/shade colour haze colour
'noon': (35, 58, (1.00, 0.97, 0.88), (0.42, 0.55, 0.80), (0.70, 0.80, 0.92)),
'morning': (-30, 24, (1.00, 0.90, 0.72), (0.40, 0.50, 0.75), (0.78, 0.80, 0.88)),
'golden': (20, 14, (1.00, 0.72, 0.42), (0.36, 0.40, 0.66), (0.90, 0.72, 0.58)),
'dusk': (70, 5, (0.95, 0.52, 0.40), (0.34, 0.30, 0.52), (0.70, 0.52, 0.62)),
'overcast': (40, 70, (0.62, 0.64, 0.66), (0.55, 0.58, 0.63), (0.74, 0.76, 0.78)),
}
def sun_vec(az, el):
"""az: 0 = sun on the left of the frame, 90 = behind the scene (contre-jour), -90 = behind camera."""
a, e = np.radians(az), np.radians(el)
return np.array([-np.cos(a) * np.cos(e), np.sin(e), np.sin(a) * np.cos(e)])
def canopy_field(x, z, cover, scale, sunv, seed):
"""Shadows of off-frame trees: soft union of crown shadows, stretched along the sun's azimuth
(longer when the sun is low). Returns sun visibility 0..1 (continuous; the dither makes edges)."""
if cover <= 0:
return np.ones_like(x)
r = np.random.default_rng(seed + 991)
n = 900
cxs = r.uniform(-120, 120, n); czs = r.uniform(-10, 400, n)
rad = scale * r.uniform(0.5, 1.4, n)
sx, sz = sunv[0], sunv[2]
hl = np.hypot(sx, sz) + 1e-6
ux, uz = sx / hl, sz / hl # horizontal sun direction
stretch = np.clip(1 / np.tan(np.arcsin(np.clip(sunv[1], 0.05, 1))), 1, 4)
acc = np.zeros_like(x)
for cx, cz, rr in zip(cxs, czs, rad):
dx, dz = x - cx, z - cz
if np.abs(dz).min() > rr * 5 and np.abs(dx).min() > rr * 5:
continue
a = (dx * ux + dz * uz) / (rr * stretch)
b = (-dx * uz + dz * ux) / rr
acc = acc + np.exp(-(a * a + b * b) * 1.5)
# threshold so that 'cover' of the ground is in shade; soft band gives dither in the edge
thr = np.quantile(acc, 1 - cover) if cover < 1 else acc.min() - 1
return 1 - 1 / (1 + np.exp(-(acc - thr) / 0.08))
def light(land, buf, az, el, canopy=0.0, canopy_scale=3.0, seed=0, mass_blur=1.2):
s = sun_vec(az, el)
n = buf['n']; nm = buf['n_macro']
X, Z, Hh = buf['x'], buf['z'], buf['h']
ndl_macro = np.clip((nm * s).sum(-1), 0, 1)
ndl_micro = np.clip((n * s).sum(-1), 0, 1)
# terrain horizon shadow: march toward the sun
vis = np.ones_like(X)
hor = np.hypot(s[0], s[2]) + 1e-6
tan_el = s[1] / hor
for t in [0.5, 1, 2, 3.5, 5.5, 8, 12, 18, 26, 38, 55, 80]:
xs = X + s[0] / hor * t; zs = Z + s[2] / hor * t
hs = land.h(xs, zs, micro=False)
occl = hs - (Hh + tan_el * t)
vis = np.minimum(vis, np.clip(0.5 - occl / (0.08 + 0.02 * t), 0, 1))
can = canopy_field(X, Z, canopy, canopy_scale, s, seed)
sunvis = vis * can
# hollow occlusion: height below the local mean (large-scale) -> less sky
hl = land.h(X, Z, micro=False)
ring = np.mean([land.h(X + dx, Z + dz, micro=False) for dx, dz in
[(3, 0), (-3, 0), (0, 3), (0, -3), (2, 2), (-2, 2), (2, -2), (-2, -2)]], 0)
hollow = np.clip((ring - hl) / 0.5, -1, 1)
sky = np.clip(0.55 + 0.45 * n[..., 1] - 0.25 * hollow, 0, 1)
return dict(ndl=ndl_macro, ndl_micro=ndl_micro, sunvis=sunvis, terrain_vis=vis, canopy=can,
sky=sky, hollow=hollow, s=s)
# ------------------------------------------------------------------ 4 VALUE PLAN
def value_plan(buf, L, key='golden', sep=0.9, sheen=0.5, lit_floor=0.10):
"""Continuous step coordinate v in [0, 5.99]: shadow family 0..2.99, light family 3..5.99."""
direct = L['sunvis'] * L['ndl']
# a flat plane at this sun takes N.L = s_y; normalize so flat ground sits mid-light family
sy = max(L['s'][1], 0.06)
rel = np.clip(direct / max(sy * 1.35, 0.12), 0, 1.25)
lit = (L['sunvis'] > 0.5) & (L['ndl'] > lit_floor * sy)
# light family: 3.0 .. 5.99 ; strongly turned toward the sun -> top step
v_lit = 3.0 + 2.99 * np.clip(rel ** 0.8, 0, 1)
# shadow family: 0 .. 2.99 from sky ambient; faces turned to the sun but shadowed (cast)
# keep a little reflected light (they are the 'lit' plane in shadow) -> v up to 2.7
v_sh = 0.25 + 2.3 * L['sky'] + 0.4 * np.clip(L['ndl'] / sy, 0, 1) * (1 - L['terrain_vis'] * L['canopy'])
v_sh = np.clip(v_sh - 0.5, 0, 2.99)
v = np.where(lit, v_lit, v_sh)
# the soft canopy edge: blend across the family boundary only inside the penumbra
pen = (L['sunvis'] > 0.15) & (L['sunvis'] < 0.85) & (L['ndl'] > lit_floor * sy)
t = np.clip((L['sunvis'] - 0.15) / 0.7, 0, 1)
v = np.where(pen, (1 - t) * v_sh + t * v_lit, v)
# grazing sheen: ground brightens as it recedes (Ferrari's receding planes, V16 banks)
d = buf['z']
v = v + sheen * np.clip(np.log(d / 3) / 4, 0, 1) * (v >= 3)
# crest separation: land just behind (above on screen) a crest silhouette drops a step
Hh, W = v.shape
z = buf['z']
jump = np.zeros_like(v, bool)
jump[:-1] = (z[:-1] > z[1:] * 1.25) & ~buf['sky'][:-1]
k = 0
sepm = np.zeros_like(v)
cur = jump.copy()
for k in range(1, 7):
sepm = np.maximum(sepm, cur * (1 - k / 7))
cur = np.roll(cur, -1, 0); cur[-1] = False
v = v - sep * sepm
return np.clip(v, 0, 5.99), lit, sepm
# ------------------------------------------------------------------ 5 MATERIAL
MATS = ['turf', 'meadow', 'earth', 'gravel', 'forest', 'moss', 'water']
MI = {m: i for i, m in enumerate(MATS)}
def materials(land, buf, ground, moisture, L, seed=0):
X, Z = buf['x'], buf['z']
mat = np.full(X.shape, MI[ground], int)
slope = 1 - buf['n_macro'][..., 1]
wet = np.clip(moisture + 0.6 * np.clip(L['hollow'], 0, 1) - 0.15, 0, 1)
if ground in ('turf', 'meadow'):
mat[slope > 0.22] = MI['earth'] # bare risers and cut banks
if land.path:
pd = land.path_dist(X, Z)
# ragged edge: the path's width breathes and its border is broken by the grass
r = np.random.default_rng(seed + 5)
edge = 0.75 + 0.15 * np.sin(Z * 1.7 + 1) + 0.1 * np.sin(Z * 4.3 + X)
mat[pd < edge] = MI['earth']
if ground == 'forest':
mat[(wet > 0.45) & (L['canopy'] < 0.7)] = MI['moss']
mat[(wet > 0.6)] = MI['moss']
# puddles: earth in hollows when wet
puddle = (mat == MI['earth']) & (wet > 0.72) & (L['hollow'] > 0.25)
mat[puddle] = MI['water']
if land.water is not None:
mat[buf['h'] < -1.2] = MI['water']
return mat, wet
# ------------------------------------------------------------------ 6 MARKS
def runs_field(Hh, W, runlen, rng, vertical=True, amp=None):
"""Random offsets constant along runs (strands if vertical, streaks if horizontal).
runlen: per-pixel mean run length (px). Returns offsets ~ N(0,1) per run."""
out = np.zeros((Hh, W))
if not vertical:
return runs_field(W, Hh, runlen.T, rng, True).T
for x in range(W):
y = Hh - 1
col = runlen[:, x]
while y >= 0:
L = max(1, int(round(col[y] * rng.uniform(0.45, 1.4))))
o = rng.normal()
out[max(0, y - L + 1): y + 1, x] = o
y -= L
return out
def stones_field(buf, size_m, rng, density=0.7):
"""Gravel / litter: small blobs with a light top row and a dark lower rim (offsets)."""
Hh, W = buf['z'].shape
out = np.zeros((Hh, W))
ppm = buf['ppm']
n = int(Hh * W * density / 6)
ys = rng.integers(0, Hh, n); xs = rng.integers(0, W, n)
order = np.argsort(ys)
for i in order:
y, x = ys[i], xs[i]
s = size_m * ppm[y, x] * rng.uniform(0.6, 1.5)
if s < 1.6:
out[y, x] += rng.choice([-1, 1]) * 0.8
continue
a = max(1, int(round(s / 2))); b = max(1, int(round(s / 3.2)))
y0, y1 = max(0, y - b), min(Hh, y + b + 1)
x0, x1 = max(0, x - a), min(W, x + a + 1)
yy, xx = np.mgrid[y0:y1, x0:x1]
q = ((xx - x) / (a + 0.5)) ** 2 + ((yy - y) / (b + 0.5)) ** 2
m = q < 1
tone = rng.normal(0.3, 0.5)
top = (yy - y) < -b * 0.2
bot = (yy - y) > b * 0.4
blk = np.where(top, tone + 0.8, np.where(bot, tone - 0.5, tone))
sub = out[y0:y1, x0:x1]
sub[m] = blk[m]
# pocket under the stone
yb = min(Hh - 1, y + b + 1)
out[yb, x0:x1][np.abs(np.arange(x0, x1) - x) < a * 0.8] = -1.3
return out
def marks(buf, mat, v, lit, rng, contrast=1.0):
"""Offsets in step space. Size follows px-per-metre; contrast follows family & distance."""
Hh, W = v.shape
ppm = buf['ppm']
off = np.zeros((Hh, W))
# grass blades: vertical strands, height ~0.22 m turf / 0.5 m meadow
for m, bh_m in (('turf', 0.2), ('meadow', 0.45), ('moss', 0.05)):
sel = mat == MI[m]
if not sel.any():
continue
bh = bh_m * ppm
rl = np.clip(bh * 0.9, 1, 14)
o = runs_field(Hh, W, rl, rng, True)
# contrast grows with nearness (blade px), strong in light, weaker in shadow
a = np.clip((bh - 0.6) / 4.0, 0.12, 1.0) * np.where(lit, 0.95, 0.6)
# in light: marks are mostly *dark* (strands and gaps on a lit base) -- Ferrari's economy
o = np.where(lit, np.where(o < 0.35, o * 1.25, o * 0.45), o)
off[sel] = (a * o)[sel]
# earth: horizontal streaks (ruts, the grain of the ground plane) + pebbles
sel = mat == MI['earth']
if sel.any():
rl = np.clip(0.5 * ppm, 1, 18)
o = runs_field(Hh, W, rl, rng, False)
a = np.clip((0.3 * ppm - 0.5) / 4, 0.1, 0.7)
st = stones_field(buf, 0.035, rng, density=0.18)
off[sel] = (a * o * 0.8 + 0.8 * np.clip(0.03 * ppm, 0.15, 1) * st)[sel]
sel = mat == MI['gravel']
if sel.any():
st = stones_field(buf, 0.06, rng, density=0.9)
off[sel] = (np.clip(0.05 * ppm / 2, 0.2, 1) * st)[sel]
sel = mat == MI['forest']
if sel.any():
st = stones_field(buf, 0.05, rng, density=0.8) # leaf litter clumps
rl = np.clip(0.08 * ppm, 1, 6)
o = runs_field(Hh, W, rl, rng, True) * 0.4
off[sel] = (np.clip(0.05 * ppm / 1.5, 0.2, 1) * st * np.where(lit, 1.0, 0.55) + o)[sel]
return off * contrast
# ------------------------------------------------------------------ 8 PALETTE
ALBEDO = { # linear-ish albedo, hand-picked; hue drift across the ramp added below
'turf': (0.20, 0.30, 0.07), 'meadow': (0.36, 0.33, 0.10), 'earth': (0.34, 0.22, 0.13),
'gravel': (0.34, 0.32, 0.29), 'forest': (0.26, 0.17, 0.09), 'moss': (0.18, 0.30, 0.05),
'water': (0.5, 0.5, 0.5)}
def srgb(c):
c = np.clip(c, 0, 1)
return np.where(c <= 0.0031308, 12.92 * c, 1.055 * c ** (1 / 2.4) - 0.055)
def ramp(mat, hour, band=0, wet=0.0, nb=3):
"""6 steps: 0 core/deep shadow, 1 shadow, 2 reflected/halftone-in-shadow | 3 halftone, 4 light, 5 top light."""
az, el, sunc, skyc, haze = HOURS[hour]
alb = np.array(ALBEDO[mat])
if wet > 0:
alb = alb * (1 - 0.45 * wet) ** 1.0
alb = alb ** (1 + 0.25 * wet) * (1 + 0.4 * wet) # saturate a little
sunc, skyc, haze = map(np.array, (sunc, skyc, haze))
if mat == 'water':
c = [haze * k for k in (0.35, 0.5, 0.62, 0.75, 0.88, 1.0)]
else:
shade = [alb * skyc * k for k in (0.05, 0.13, 0.24)]
litv = [alb * (sunc * k + skyc * 0.18) for k in (0.55, 0.85, 1.15)]
# the top light shifts warm/yellow (the lit ochre of V16PM, the yellow islet of V16)
litv[2] = litv[2] * np.array([1.08, 1.05, 0.8])
# the core shifts cool-dark (the teal darks of V16)
shade[0] = shade[0] * np.array([0.7, 0.95, 1.4])
c = shade + litv
c = np.array(c)
out = srgb(c * 2.2)
# depth band: far bands lift the black point toward the haze and compress
f = [0.0, 0.22, 0.45][band]
out = out * (1 - f) + srgb(haze * 0.8)[None] * f * np.linspace(1.0, 0.5, 6)[:, None]
return (np.clip(out, 0, 1) * 255).astype(np.uint8)
# ------------------------------------------------------------------ assemble
class Ground:
pass
def paint_ground(W=320, H=200, ground='turf', landform='rolling', hour='golden', sun=None,
moisture=0.2, ppm=24, pitch=14, cam_h=1.7, canopy=0.0, canopy_scale=3.0,
path=False, relief=1.0, sep=0.9, sheen=0.5, contrast=1.0, jitter=0.5,
band_z=(12, 45), seed=0, water=None):
rng = np.random.default_rng(seed)
land = Landform(landform, seed, relief, path=path, water=water)
buf = render(land, W, H, ppm, pitch, cam_h)
az, el = (sun if sun else HOURS[hour][:2])
L = light(land, buf, az, el, canopy, canopy_scale, seed)
v, lit, sepm = value_plan(buf, L, hour, sep=sep, sheen=sheen)
mat, wet = materials(land, buf, ground, moisture, L, seed)
# moisture darkens the mass (a step at full wet) -- a mass-level change, not a texture
v = v - 0.9 * wet * (v > 0.5)
off = marks(buf, mat, v, lit, rng, contrast)
# distance: mark contrast compresses with depth beyond the near band
fade = np.clip(1.2 - np.log(buf['z'] / band_z[0] + 1e-6) / 2.2, 0.15, 1)
vv = v + off * fade
# keep the families apart: marks may not cross the terminator (only a thin fringe may)
vv = np.where(lit, np.clip(vv, 2.6, 5.99), np.clip(vv, 0, 3.1))
step = dither.ordered(vv, jitter=jitter, rng=rng, n=6)
band = np.digitize(buf['z'], band_z)
mat[buf['sky']] = -1
g = Ground()
g.__dict__.update(mat=mat, band=band, step=step, v=v, vv=vv, lit=lit, wet=wet, buf=buf, L=L,
land=land, depth=buf['z'], wx=buf['x'], wz=buf['z'], h=buf['h'], sepm=sepm,
hour=hour, moisture=moisture)
g.relight = lambda hr, g=g: colour(g, hr)
g.rgb = colour(g, hour)
return g
def colour(g, hour, sky_rgb=None):
Hh, W = g.step.shape
out = np.zeros((Hh, W, 3), np.uint8)
az, el, sunc, skyc, haze = HOURS[hour]
for mi, m in enumerate(MATS):
for b in range(3):
for wetk, wsel in ((0, g.wet < 0.5), (1, g.wet >= 0.5)):
sel = (g.mat == mi) & (g.band == b) & wsel
if sel.any():
R = ramp(m, hour, b, wet=0.8 * wetk if g.moisture > 0 else 0)
out[sel] = R[g.step[sel]]
sky = g.mat < 0
if sky.any():
out[sky] = (srgb(np.array(haze) * 0.9) * 255).astype(np.uint8)
return out
Bash: First render test of ground enginecd ~/work/pixelart-studies/elements/ground/src && cat > try1.py <<'EOF'
import ground, lw, numpy as np, time, sys
t=time.time()
cells=[]
for lf,hr,kw in [('rolling','golden',{}),('hummocks','noon',{}),('bank','golden',dict(ppm=30)),('rolling','noon',dict(path=True,ppm=40,pitch=20))]:
g=ground.paint_ground(240,150,landform=lf,hour=hr,seed=3,**kw); cells.append(g.rgb)
print(lf,hr,time.time()-t)
a=np.concatenate([np.concatenate(cells[:2],1),np.concatenate(cells[2:],1)],0)
lw.save(a,'../stages/try1_3x.png',3)
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 try1.py
# output:
~/work/pixelart-studies/elements/ground/src/ground.py:78: RuntimeWarning: invalid value encountered in power
H = H + ht * self.relief * np.where(q < 1, (1 - q) ** 1.5, 0)
rolling golden 1.7065777778625488
hummocks noon 14.984162092208862
bank golden 26.13091492652893
rolling noon 28.021697998046875Read: elements/ground/stages/try1_3x.png
32. elements/ground/stages/try1_3x.png
Bash: Rework palette to L* targets and value mappingcd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
import re
s=open('ground.py').read()
s=s.replace("H = H + ht * self.relief * np.where(q < 1, (1 - q) ** 1.5, 0)","H = H + ht * self.relief * np.clip(1 - q, 0, None) ** 1.5")
# value plan lit mapping
s=s.replace(""" v_lit = 3.0 + 2.99 * np.clip(rel ** 0.8, 0, 1)""",""" r = L['ndl'] * L['sunvis'] / sy # 1 = a flat plane in full sun
v_lit = 3.0 + 3.0 * np.clip(0.42 + 0.85 * (r - 1), 0, 0.999)""")
# palette by L* targets
old=s[s.index("def ramp(mat"):s.index("# ------------------------------------------------------------------ assemble")]
new='''LSTEP = { # target CIE L* per step: few steps, far apart; darks near black (V16 / V16PM ramps)
'turf': (4, 11, 21, 34, 49, 66),
'meadow': (6, 14, 25, 40, 56, 72),
'earth': (7, 15, 25, 37, 50, 63),
'gravel': (8, 17, 28, 42, 57, 72),
'forest': (4, 10, 19, 31, 45, 60),
'moss': (5, 12, 22, 36, 52, 70),
'water': (30, 40, 50, 60, 70, 80)}
def _lin(c):
c = np.clip(c, 0, 1)
return np.where(c <= 0.04045, c / 12.92, ((c + 0.055) / 1.055) ** 2.4)
def _Y2L(Y):
return np.where(Y > 0.008856, 116 * np.cbrt(Y) - 16, 903.3 * Y)
def _L2Y(L):
return np.where(L > 8, ((L + 16) / 116) ** 3, L / 903.3)
def _set_L(rgb_lin, L):
"""scale a linear colour to hit luminance L* (clipping pushes toward white gracefully)"""
Y = (rgb_lin * np.array([0.2126, 0.7152, 0.0722])).sum()
c = rgb_lin * (_L2Y(L) / max(Y, 1e-6))
for _ in range(6): # redistribute clipped energy
over = np.clip(c - 1, 0, None).sum()
c = np.clip(c, 0, 1)
if over < 1e-4: break
c = c + (1 - c) * over / 3
return c
def ramp(mat, hour, band=0, wet=0.0):
"""6 steps: 0 core, 1 shadow, 2 reflected light | 3 halftone, 4 light, 5 top light."""
az, el, sunc, skyc, haze = HOURS[hour]
alb = np.array(ALBEDO[mat]); sunc, skyc, haze = map(np.array, (sunc, skyc, haze))
Ls = np.array(LSTEP[mat], float)
# the light family's value follows the light's strength (dusk and overcast are low-key)
key = {'noon': 1.0, 'morning': 0.95, 'golden': 0.92, 'dusk': 0.72, 'overcast': 0.82}[hour]
Ls[3:] = Ls[3:] * key
if hour == 'overcast':
Ls[:3] = Ls[:3] + np.array([6, 6, 4]) # soft light: shadows open up, no terminator
if wet > 0:
Ls = Ls * (1 - 0.3 * wet)
alb = alb ** (1 + 0.6 * wet) # darker and more saturated
out = []
for i in range(6):
if mat == 'water':
c = haze * (0.8 + 0.05 * i)
elif i < 3:
c = alb * skyc * (1.0 if i else 0.9)
if i == 0: c = c * np.array([0.75, 0.95, 1.35]) # cool core (V16's teal darks)
else:
c = alb * (sunc + skyc * 0.15)
if i == 5: c = c * np.array([1.12, 1.06, 0.62]) # top light goes yellow/ochre
if i == 3: c = c * 0.7 + alb * skyc * 0.6 # halftone keeps some sky
out.append(_set_L(c, Ls[i]))
out = np.array(out)
f = [0.0, 0.18, 0.4][band]
hz = _lin(np.array([0, 0, 0]) + 0) + haze * 0.55
w = f * np.linspace(1.0, 0.35, 6)[:, None] # far: black point lifts toward haze
out = out * (1 - w) + hz[None] * w
return (np.clip(srgb(out), 0, 1) * 255).astype(np.uint8)
def palette_sheet(k=12):
rows = []
for hr in HOURS:
row = []
for m in MATS:
for b in range(3):
row.append(np.repeat(ramp(m, hr, b)[None], 1, 0))
row.append(np.zeros((1, 1, 3), np.uint8))
rows.append(np.concatenate(row, 1))
return np.concatenate(rows, 0)
'''
s=s.replace(old,new)
open('ground.py','w').write(s)
EOF
python3 -c "
import ground,lw; lw.save(ground.palette_sheet(),'../stages/palettes_16x.png',16)"
# output:
[This command modified 1 file you've previously read: ../pixelart-studies/elements/ground/src/ground.py. Call Read before editing.]Read: elements/ground/stages/palettes_16x.png
33. elements/ground/stages/palettes_16x.png
Bash: Re-render test gridcd ~/work/pixelart-studies/elements/ground/src && OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 try1.py 2>&1 | tail -1 # output: rolling noon 21.784557819366455
Read: elements/ground/stages/try1_3x.png
34. elements/ground/stages/try1_3x.png
Bash: Show paint_ground current codecd ~/work/pixelart-studies/elements/ground/src && grep -n "def canopy_field" -A 30 ground.py | head -5; grep -n "def paint_ground" -A 40 ground.py # output: 162:def canopy_field(x, z, cover, scale, sunv, seed): 163- """Shadows of off-frame trees: soft union of crown shadows, stretched along the sun's azimuth 164- (longer when the sun is low). Returns sun visibility 0..1 (continuous; the dither makes edges).""" 165- if cover <= 0: 166- return np.ones_like(x) 456:def paint_ground(W=320, H=200, ground='turf', landform='rolling', hour='golden', sun=None, 457- moisture=0.2, ppm=24, pitch=14, cam_h=1.7, canopy=0.0, canopy_scale=3.0, 458- path=False, relief=1.0, sep=0.9, sheen=0.5, contrast=1.0, jitter=0.5, 459- band_z=(12, 45), seed=0, water=None): 460- rng = np.random.default_rng(seed) 461- land = Landform(landform, seed, relief, path=path, water=water) 462- buf = render(land, W, H, ppm, pitch, cam_h) 463- az, el = (sun if sun else HOURS[hour][:2]) 464- L = light(land, buf, az, el, canopy, canopy_scale, seed) 465- v, lit, sepm = value_plan(buf, L, hour, sep=sep, sheen=sheen) 466- mat, wet = materials(land, buf, ground, moisture, L, seed) 467- # moisture darkens the mass (a step at full wet) -- a mass-level change, not a texture 468- v = v - 0.9 * wet * (v > 0.5) 469- off = marks(buf, mat, v, lit, rng, contrast) 470- # distance: mark contrast compresses with depth beyond the near band 471- fade = np.clip(1.2 - np.log(buf['z'] / band_z[0] + 1e-6) / 2.2, 0.15, 1) 472- vv = v + off * fade 473- # keep the families apart: marks may not cross the terminator (only a thin fringe may) 474- vv = np.where(lit, np.clip(vv, 2.6, 5.99), np.clip(vv, 0, 3.1)) 475- step = dither.ordered(vv, jitter=jitter, rng=rng, n=6) 476- band = np.digitize(buf['z'], band_z) 477- mat[buf['sky']] = -1 478- g = Ground() 479- g.__dict__.update(mat=mat, band=band, step=step, v=v, vv=vv, lit=lit, wet=wet, buf=buf, L=L, 480- land=land, depth=buf['z'], wx=buf['x'], wz=buf['z'], h=buf['h'], sepm=sepm, 481- hour=hour, moisture=moisture) 482- g.relight = lambda hr, g=g: colour(g, hr) 483- g.rgb = colour(g, hour) 484- return g 485- 486-def colour(g, hour, sky_rgb=None): 487- Hh, W = g.step.shape 488- out = np.zeros((Hh, W, 3), np.uint8) 489- az, el, sunc, skyc, haze = HOURS[hour] 490- for mi, m in enumerate(MATS): 491- for b in range(3): 492- for wetk, wsel in ((0, g.wet < 0.5), (1, g.wet >= 0.5)): 493- sel = (g.mat == mi) & (g.band == b) & wsel 494- if sel.any(): 495- R = ramp(m, hour, b, wet=0.8 * wetk if g.moisture > 0 else 0) 496- out[sel] = R[g.step[sel]]
Now add the design layer: big off-frame shadows, vignette, crest fringe.
Bash: Add shade, vignette, crest fringe; rendercd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
old=s[s.index(" r = np.random.default_rng(seed + 991)\n n = 900"):s.index(" acc = np.zeros_like(x)")]
new=''' r = np.random.default_rng(seed + 991)
x0, x1 = np.percentile(x, 1) - 3 * scale, np.percentile(x, 99) + 3 * scale
z0, z1 = np.percentile(z, 1) - 3 * scale, np.percentile(z, 97) + 3 * scale
area = (x1 - x0) * (z1 - z0)
n = int(np.clip(area / (np.pi * scale ** 2) * 1.2, 8, 700))
cxs = r.uniform(x0, x1, n); czs = r.uniform(z0, z1, n)
rad = scale * r.uniform(0.5, 1.4, n)
'''
s=s.replace(old,new)
# pass big shadow ("shade") as a second canopy field in light()
s=s.replace("def light(land, buf, az, el, canopy=0.0, canopy_scale=3.0, seed=0, mass_blur=1.2):",
"def light(land, buf, az, el, canopy=0.0, canopy_scale=3.0, seed=0, shade=0.0, shade_scale=12.0):")
s=s.replace(" can = canopy_field(X, Z, canopy, canopy_scale, s, seed)\n",
" can = canopy_field(X, Z, canopy, canopy_scale, s, seed)\n can = can * canopy_field(X, Z, shade, shade_scale, s, seed + 17)\n")
old=s[s.index("def paint_ground("):s.index("def colour(g, hour")]
new='''def crest_fringe(step, mat, band, buf, lit, rng, tip=0.2, holes=0.3):
"""Blade tips break the top of every crest: the near mass's grass pokes up over the land
behind it (lit peaks), and dark holes open along the crest line (V16PM C2/C3 mound)."""
z = buf['z']; Hh, W = z.shape
ppm = buf['ppm']
crest = np.zeros_like(z, bool)
crest[1:] = (z[:-1] > z[1:] * 1.25) | buf['sky'][:-1]
crest &= ~buf['sky']
ys, xs = np.nonzero(crest)
for y, x in zip(ys, xs):
m = mat[y, x]
if m not in (MI['turf'], MI['meadow'], MI['moss'], MI['forest']):
continue
bh = tip * (2.2 if m == MI['meadow'] else 1) * ppm[y, x]
if bh < 0.8:
continue
u = rng.random()
if u < holes:
step[y, x] = max(0, step[y, x] - 2)
continue
if u < holes + 0.45:
L = int(np.clip(round(bh * rng.uniform(0.3, 1.0)), 1, 12))
s0 = step[y, x] + (1 if lit[y, x] and rng.random() < 0.5 else 0)
for k in range(1, L + 1):
if y - k < 0: break
step[y - k, x] = min(5, s0); mat[y - k, x] = m; band[y - k, x] = band[y, x]
return step, mat, band
def paint_ground(W=320, H=200, ground='turf', landform='rolling', hour='golden', sun=None,
moisture=0.2, ppm=24, pitch=14, cam_h=1.7, canopy=0.0, canopy_scale=3.0,
shade=0.0, shade_scale=12.0, vignette=0.6, path=False, relief=1.0, sep=0.9,
sheen=0.5, contrast=1.0, jitter=0.5, band_z=(12, 45), seed=0, water=None):
"""ground: turf|meadow|earth|gravel|forest landform: flat|rolling|hummocks|bank|ledges
hour: noon|morning|golden|dusk|overcast (or sun=(az,el)) moisture 0..1
ppm: px per metre at screen centre (the distance knob) pitch/cam_h: camera
canopy (+canopy_scale m): dapple from off-frame trees; shade (+shade_scale m): big cast
shadows from off-frame forest/cloud -- Ferrari's compositional shadow masses
vignette: darkens the bottom corners inside each family (repoussoir)"""
rng = np.random.default_rng(seed)
land = Landform(landform, seed, relief, path=path, water=water)
buf = render(land, W, H, ppm, pitch, cam_h)
az, el = (sun if sun else HOURS[hour][:2])
L = light(land, buf, az, el, canopy, canopy_scale, seed, shade, shade_scale)
v, lit, sepm = value_plan(buf, L, hour, sep=sep, sheen=sheen)
mat, wet = materials(land, buf, ground, moisture, L, seed)
# moisture darkens the mass (a step at full wet) -- a mass-level change, not a texture
v = v - 0.9 * wet * (v > 0.5)
# repoussoir: bottom corners sink, inside each family
yy, xx = np.mgrid[0:H, 0:W]
vg = np.clip((yy / H - 0.55) / 0.45, 0, 1) ** 1.5 * (0.45 + 0.55 * np.abs(xx - W / 2) / (W / 2))
v = v - vignette * 1.6 * vg
off = marks(buf, mat, v, lit, rng, contrast)
# distance: mark contrast compresses with depth beyond the near band
fade = np.clip(1.2 - np.log(buf['z'] / band_z[0] + 1e-6) / 2.2, 0.15, 1)
vv = v + off * fade
# keep the families apart: marks may not cross the terminator (only a thin fringe may)
vv = np.where(lit, np.clip(vv, 2.6, 5.99), np.clip(vv, 0, 3.1))
step = dither.ordered(vv, jitter=jitter, rng=rng, n=6)
band = np.digitize(buf['z'], band_z)
mat[buf['sky']] = -1
step, mat, band = crest_fringe(step, mat, band, buf, lit, rng)
g = Ground()
g.__dict__.update(mat=mat, band=band, step=step, v=v, vv=vv, lit=lit, wet=wet, buf=buf, L=L,
land=land, depth=buf['z'], wx=buf['x'], wz=buf['z'], h=buf['h'], sepm=sepm,
hour=hour, moisture=moisture)
g.relight = lambda hr, g=g: colour(g, hr)
g.rgb = colour(g, hour)
return g
'''
s=s.replace(old,new)
open('ground.py','w').write(s)
EOF
cat > try2.py <<'EOF'
import ground, lw, numpy as np, time
t=time.time(); cells=[]
for lf,hr,kw in [('rolling','golden',dict(shade=0.3)),('hummocks','noon',dict(shade=0.4)),
('bank','golden',dict(ppm=30,shade=0.35)),('rolling','noon',dict(path=True,ppm=40,pitch=20,shade=0.3))]:
g=ground.paint_ground(240,150,landform=lf,hour=hr,seed=3,**kw); cells.append(g.rgb); print(lf,time.time()-t)
lw.save(np.concatenate([np.concatenate(cells[:2],1),np.concatenate(cells[2:],1)],0),'../stages/try2_3x.png',3)
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 try2.py 2>&1|tail -2
# output:
^^
NameError: name 'ux' is not defined. Did you mean: 'x'?Read: elements/ground/stages/try2_3x.pngBash: Inspect canopy_fieldcd ~/work/pixelart-studies/elements/ground/src && sed -n 162,195p ground.py
# output:
def canopy_field(x, z, cover, scale, sunv, seed):
"""Shadows of off-frame trees: soft union of crown shadows, stretched along the sun's azimuth
(longer when the sun is low). Returns sun visibility 0..1 (continuous; the dither makes edges)."""
if cover <= 0:
return np.ones_like(x)
r = np.random.default_rng(seed + 991)
x0, x1 = np.percentile(x, 1) - 3 * scale, np.percentile(x, 99) + 3 * scale
z0, z1 = np.percentile(z, 1) - 3 * scale, np.percentile(z, 97) + 3 * scale
area = (x1 - x0) * (z1 - z0)
n = int(np.clip(area / (np.pi * scale ** 2) * 1.2, 8, 700))
cxs = r.uniform(x0, x1, n); czs = r.uniform(z0, z1, n)
rad = scale * r.uniform(0.5, 1.4, n)
acc = np.zeros_like(x)
for cx, cz, rr in zip(cxs, czs, rad):
dx, dz = x - cx, z - cz
if np.abs(dz).min() > rr * 5 and np.abs(dx).min() > rr * 5:
continue
a = (dx * ux + dz * uz) / (rr * stretch)
b = (-dx * uz + dz * ux) / rr
acc = acc + np.exp(-(a * a + b * b) * 1.5)
# threshold so that 'cover' of the ground is in shade; soft band gives dither in the edge
thr = np.quantile(acc, 1 - cover) if cover < 1 else acc.min() - 1
return 1 - 1 / (1 + np.exp(-(acc - thr) / 0.08))
def light(land, buf, az, el, canopy=0.0, canopy_scale=3.0, seed=0, shade=0.0, shade_scale=12.0):
s = sun_vec(az, el)
n = buf['n']; nm = buf['n_macro']
X, Z, Hh = buf['x'], buf['z'], buf['h']
ndl_macro = np.clip((nm * s).sum(-1), 0, 1)
ndl_micro = np.clip((n * s).sum(-1), 0, 1)
# terrain horizon shadow: march toward the sun
vis = np.ones_like(X)
hor = np.hypot(s[0], s[2]) + 1e-6
tan_el = s[1] / horBash: Fix canopy field and rendercd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace(""" rad = scale * r.uniform(0.5, 1.4, n)
acc = np.zeros_like(x)""",""" rad = scale * r.uniform(0.5, 1.4, n)
sx, sz = sunv[0], sunv[2]
hl = np.hypot(sx, sz) + 1e-6
ux, uz = sx / hl, sz / hl # horizontal sun direction
stretch = np.clip(1 / np.tan(np.arcsin(np.clip(sunv[1], 0.05, 1))), 1, 4)
acc = np.zeros_like(x)""")
open('ground.py','w').write(s)
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 try2.py 2>&1|tail -2
# output:
bank 22.55807113647461
rolling 24.094210147857666Read: elements/ground/stages/try2_3x.png
35. elements/ground/stages/try2_3x.png
Bash: Fix shadow family and band seam; compare to V16PMcd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace(""" v_sh = 0.25 + 2.3 * L['sky'] + 0.4 * np.clip(L['ndl'] / sy, 0, 1) * (1 - L['terrain_vis'] * L['canopy'])
v_sh = np.clip(v_sh - 0.5, 0, 2.99)""",""" v_sh = 0.2 + 1.3 * L['sky'] + 0.7 * np.clip(L['ndl'] / sy, 0, 1.3) * (1 - L['sunvis'])
v_sh = np.clip(v_sh, 0, 2.6)""")
s=s.replace(" band = np.digitize(buf['z'], band_z)\n mat[buf['sky']] = -1",
""" # depth bands meet in a dithered seam, never a ruled line
lz = np.log(buf['z']) + 0.35 * (dither.tile(dither.B4, H, W) - 0.5) + 0.1 * (rng.random((H, W)) - 0.5)
band = np.digitize(lz, np.log(band_z))
mat[buf['sky']] = -1""")
open('ground.py','w').write(s)
EOF
cat > s1_v16.py <<'EOF'
"""Stage 1: my bank at golden hour beside V16PM's left bank, same pixel scale."""
import ground, lw, numpy as np
d=lw.load('V16PM'); ref=lw.rgb(d)[250:450, 0:300]
g=ground.paint_ground(300,200,landform='bank',hour='golden',sun=(15,10),ppm=26,pitch=9,cam_h=1.6,
shade=0.3,shade_scale=10,seed=5,water=30)
pad=np.zeros((200,6,3),np.uint8)
lw.save(np.concatenate([ref,pad,g.rgb],1),'../stages/s1_v16pm_3x.png',3)
lw.save(np.concatenate([ref[100:200,60:180],pad[:100],g.rgb[100:200,60:180]],1),'../stages/s1_v16pm_8x.png',8)
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 s1_v16.py
# output:
(Bash completed with no output)Read: elements/ground/stages/s1_v16pm_3x.png
36. elements/ground/stages/s1_v16pm_3x.png
Bash: Show runs_field and marks codecd ~/work/pixelart-studies/elements/ground/src && grep -n "def runs_field" -A 16 ground.py && grep -n "def marks" -A 22 ground.py
# output:
285:def runs_field(Hh, W, runlen, rng, vertical=True, amp=None):
286- """Random offsets constant along runs (strands if vertical, streaks if horizontal).
287- runlen: per-pixel mean run length (px). Returns offsets ~ N(0,1) per run."""
288- out = np.zeros((Hh, W))
289- if not vertical:
290- return runs_field(W, Hh, runlen.T, rng, True).T
291- for x in range(W):
292- y = Hh - 1
293- col = runlen[:, x]
294- while y >= 0:
295- L = max(1, int(round(col[y] * rng.uniform(0.45, 1.4))))
296- o = rng.normal()
297- out[max(0, y - L + 1): y + 1, x] = o
298- y -= L
299- return out
300-
301-def stones_field(buf, size_m, rng, density=0.7):
332:def marks(buf, mat, v, lit, rng, contrast=1.0):
333- """Offsets in step space. Size follows px-per-metre; contrast follows family & distance."""
334- Hh, W = v.shape
335- ppm = buf['ppm']
336- off = np.zeros((Hh, W))
337- # grass blades: vertical strands, height ~0.22 m turf / 0.5 m meadow
338- for m, bh_m in (('turf', 0.2), ('meadow', 0.45), ('moss', 0.05)):
339- sel = mat == MI[m]
340- if not sel.any():
341- continue
342- bh = bh_m * ppm
343- rl = np.clip(bh * 0.9, 1, 14)
344- o = runs_field(Hh, W, rl, rng, True)
345- # contrast grows with nearness (blade px), strong in light, weaker in shadow
346- a = np.clip((bh - 0.6) / 4.0, 0.12, 1.0) * np.where(lit, 0.95, 0.6)
347- # in light: marks are mostly *dark* (strands and gaps on a lit base) -- Ferrari's economy
348- o = np.where(lit, np.where(o < 0.35, o * 1.25, o * 0.45), o)
349- off[sel] = (a * o)[sel]
350- # earth: horizontal streaks (ruts, the grain of the ground plane) + pebbles
351- sel = mat == MI['earth']
352- if sel.any():
353- rl = np.clip(0.5 * ppm, 1, 18)
354- o = runs_field(Hh, W, rl, rng, False)Bash: Run-coherent strand thresholds, warmer turfcd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace(''' out = np.zeros((Hh, W))
if not vertical:
return runs_field(W, Hh, runlen.T, rng, True).T
for x in range(W):
y = Hh - 1
col = runlen[:, x]
while y >= 0:
L = max(1, int(round(col[y] * rng.uniform(0.45, 1.4))))
o = rng.normal()
out[max(0, y - L + 1): y + 1, x] = o
y -= L
return out''',''' out = np.zeros((Hh, W)); thr = np.zeros((Hh, W))
if not vertical:
o, t = runs_field(W, Hh, runlen.T, rng, True)
return o.T, t.T
for x in range(W):
y = Hh - 1
col = runlen[:, x]
while y >= 0:
L = max(1, int(round(col[y] * rng.uniform(0.45, 1.4))))
out[max(0, y - L + 1): y + 1, x] = rng.normal()
thr[max(0, y - L + 1): y + 1, x] = rng.random()
y -= L
return out, thr''')
s=s.replace(''' off = np.zeros((Hh, W))
# grass blades''',''' off = np.zeros((Hh, W)); thr = np.full((Hh, W), 0.5); coh = np.zeros((Hh, W))
# grass blades''')
s=s.replace(''' o = runs_field(Hh, W, rl, rng, True)
# contrast grows''',''' o, t = runs_field(Hh, W, rl, rng, True)
thr[sel] = t[sel]; coh[sel] = np.clip((bh - 1.0) / 2.5, 0, 1)[sel]
# contrast grows''')
s=s.replace(" a = np.clip((bh - 0.6) / 4.0, 0.12, 1.0) * np.where(lit, 0.95, 0.6)",
" a = np.clip((bh - 0.6) / 4.0, 0.12, 1.0) * np.where(lit, 1.25, 0.8)")
s=s.replace(''' o = runs_field(Hh, W, rl, rng, False)
a = np.clip((0.3 * ppm - 0.5) / 4, 0.1, 0.7)''',''' o, t = runs_field(Hh, W, rl, rng, False)
thr[sel] = t[sel]; coh[sel] = np.clip((0.5 * ppm - 1.5) / 3, 0, 0.8)[sel]
a = np.clip((0.3 * ppm - 0.5) / 4, 0.1, 0.7)''')
s=s.replace(''' o = runs_field(Hh, W, rl, rng, True) * 0.4''',''' o, t = runs_field(Hh, W, rl, rng, True); o = o * 0.4''')
s=s.replace(" return off * contrast\n"," return off * contrast, thr, coh\n")
s=s.replace(" off = marks(buf, mat, v, lit, rng, contrast)"," off, thr, coh = marks(buf, mat, v, lit, rng, contrast)")
s=s.replace(" step = dither.ordered(vv, jitter=jitter, rng=rng, n=6)",
""" # run-coherent threshold for strands (a blade is one colour top to bottom), DPaint ordered
# dither for the mass everywhere else (the V16 islet recipe)
tb = dither.tile(dither.B2, H, W) + jitter * (rng.random((H, W)) - 0.5)
t = coh * thr + (1 - coh) * tb
step = np.clip(np.floor(vv) + ((vv - np.floor(vv)) > t), 0, 5).astype(int)""")
s=s.replace("'turf': (0.20, 0.30, 0.07), 'meadow': (0.36, 0.33, 0.10)","'turf': (0.30, 0.31, 0.07), 'meadow': (0.40, 0.33, 0.10)")
open('ground.py','w').write(s)
EOF
sed -i '' "s/seed=5,water=30)/seed=5)/" s1_v16.py; OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 s1_v16.py
# output:
(Bash completed with no output)Read: elements/ground/stages/s1_v16pm_3x.png
37. elements/ground/stages/s1_v16pm_3x.png
Bash: Tune golden palette and try shade variantscd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace("'golden': (20, 14, (1.00, 0.72, 0.42)","'golden': (20, 14, (1.00, 0.58, 0.30)")
s=s.replace("'turf': (0.30, 0.31, 0.07)","'turf': (0.33, 0.31, 0.05)")
s=s.replace(""" if i == 3: c = c * 0.7 + alb * skyc * 0.6 # halftone keeps some sky
out.append(_set_L(c, Ls[i]))""",""" if i == 3: c = c * 0.7 + alb * skyc * 0.6 # halftone keeps some sky
g = (c * np.array([0.2126, 0.7152, 0.0722])).sum()
c = np.clip(g + (c - g) * 1.35, 0, None) # lit colours carry the chroma
out.append(_set_L(c, Ls[i]))""")
s=s.replace("shade=0.0, shade_scale=12.0, vignette=0.6,","shade=0.0, shade_scale=12.0, vignette=0.35,")
open('ground.py','w').write(s)
EOF
cat > s1_grid.py <<'EOF'
import ground, lw, numpy as np, sys
d=lw.load('V16PM'); ref=lw.rgb(d)[280:430, 0:220]
cells=[ref]
for kw in [dict(shade=0.3,shade_scale=25),dict(shade=0.45,shade_scale=35,sun=(40,8)),dict(shade=0.0,sun=(-20,12))]:
g=ground.paint_ground(220,150,landform='bank',hour='golden',ppm=30,pitch=12,cam_h=1.6,seed=5,**{**dict(sun=(15,10)),**kw})
cells.append(g.rgb)
pad=np.zeros((150,4,3),np.uint8)
lw.save(np.concatenate(sum([[c,pad] for c in cells],[]),1),'../stages/s1_grid_3x.png',3)
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 s1_grid.py
# output:
(Bash completed with no output)Read: elements/ground/stages/s1_grid_3x.png
38. elements/ground/stages/s1_grid_3x.png
Bash: Stats of near-field valuescd ~/work/pixelart-studies/elements/ground/src && OPENBLAS_NUM_THREADS=1 nice -n 10 python3 -c "
import ground,numpy as np
g=ground.paint_ground(220,150,landform='bank',hour='golden',ppm=30,pitch=12,cam_h=1.6,seed=5,sun=(-20,12))
b=slice(100,150)
print('lit share',g.lit[b].mean()); print('v mean',g.v[b].mean(), np.percentile(g.v[b],[10,50,90]))
print(np.bincount(g.step[b].ravel(),minlength=6)/g.step[b].size)
print('ppm',g.buf['ppm'][b].mean(), 'ndl',g.L['ndl'][b].mean(),'sy',g.L['s'][1])
"
# output:
lit share 0.7038181818181818
v mean 2.3411453983278236 [1.53370734 2.55180942 2.83725142]
[0.02809091 0.10581818 0.28554545 0.51281818 0.06709091 0.00063636]
ppm 47.33255738925834 ndl 0.050690239247572315 sy 0.20791169081775934Bash: Show value_plan headcd ~/work/pixelart-studies/elements/ground/src && grep -n "def value_plan" -A 12 ground.py | head -14 # output: 219:def value_plan(buf, L, key='golden', sep=0.9, sheen=0.5, lit_floor=0.10): 220- """Continuous step coordinate v in [0, 5.99]: shadow family 0..2.99, light family 3..5.99.""" 221- direct = L['sunvis'] * L['ndl'] 222- # a flat plane at this sun takes N.L = s_y; normalize so flat ground sits mid-light family 223- sy = max(L['s'][1], 0.06) 224- rel = np.clip(direct / max(sy * 1.35, 0.12), 0, 1.25) 225- lit = (L['sunvis'] > 0.5) & (L['ndl'] > lit_floor * sy) 226- # light family: 3.0 .. 5.99 ; strongly turned toward the sun -> top step 227- r = L['ndl'] * L['sunvis'] / sy # 1 = a flat plane in full sun 228- v_lit = 3.0 + 3.0 * np.clip(0.42 + 0.85 * (r - 1), 0, 0.999) 229- # shadow family: 0 .. 2.99 from sky ambient; faces turned to the sun but shadowed (cast) 230- # keep a little reflected light (they are the 'lit' plane in shadow) -> v up to 2.7 231- v_sh = 0.2 + 1.3 * L['sky'] + 0.7 * np.clip(L['ndl'] / sy, 0, 1.3) * (1 - L['sunvis'])
Bash: Material-dependent normal response in value plancd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace("def value_plan(buf, L, key='golden', sep=0.9, sheen=0.5, lit_floor=0.10):",
"""NRESP = {'turf': 0.3, 'meadow': 0.2, 'earth': 1.0, 'gravel': 0.8, 'forest': 0.6, 'moss': 0.5, 'water': 0.0}
def value_plan(buf, L, mat=None, sep=0.9, sheen=0.5, lit_floor=0.10, lit_base=0.6):
\"\"\"lit_base: where a flat lit plane sits inside the light family (0..1).
Grass is a forest of vertical blades: it catches a low sun whatever the ground slope, so
its N.L response is weak (NRESP) -- turf masses come from cast shadow, not from shading.
Bare earth, sand and snow take the full N.L (the dunes of V25).\"\"\"""")
s=s.replace(""" r = L['ndl'] * L['sunvis'] / sy # 1 = a flat plane in full sun
v_lit = 3.0 + 3.0 * np.clip(0.42 + 0.85 * (r - 1), 0, 0.999)""",""" r = L['ndl'] * L['sunvis'] / sy # 1 = a flat plane in full sun
k = np.ones_like(r) if mat is None else np.vectorize(lambda m: NRESP[MATS[m]] if m >= 0 else 1.0)(mat)
r = 1 + k * (np.minimum(r, 3) - 1)
v_lit = 3.0 + 3.0 * np.clip(lit_base + 0.5 * (r - 1), 0, 0.999)""")
s=s.replace(""" v, lit, sepm = value_plan(buf, L, hour, sep=sep, sheen=sheen)
mat, wet = materials(land, buf, ground, moisture, L, seed)""",""" mat, wet = materials(land, buf, ground, moisture, L, seed)
v, lit, sepm = value_plan(buf, L, mat, sep=sep, sheen=sheen, lit_base=lit_base)""")
s=s.replace(" v = v - vignette * 1.6 * vg\n"," v = v - vignette * 1.6 * vg\n v = np.where(lit, np.maximum(v, 3.0), np.minimum(v, 2.9)) # families never touch\n")
s=s.replace("sheen=0.5, contrast=1.0, jitter=0.5, band_z=(12, 45), seed=0, water=None):",
"sheen=0.5, contrast=1.0, jitter=0.5, band_z=(12, 45), seed=0, water=None, lit_base=0.6):")
# lit families: lit mask should use grass-softened ndl too: faces away but still sunlit grass
s=s.replace(" lit = (L['sunvis'] > 0.5) & (L['ndl'] > lit_floor * sy)\n"," lit = (L['sunvis'] > 0.5) & (L['ndl'] > lit_floor * sy)\n lit_ndl_floor = lit_floor * sy\n")
open('ground.py','w').write(s)
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 s1_grid.py
# output:
(Bash completed with no output)Read: elements/ground/stages/s1_grid_3x.png
39. elements/ground/stages/s1_grid_3x.png
Bash: Add tuft clustering, fewer hummocks on bankcd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace("# ------------------------------------------------------------------ 6 MARKS\n","""# ------------------------------------------------------------------ 6 MARKS
def smooth_noise(Hh, W, sy, sx, rng):
\"\"\"Gaussian-filtered white noise, unit std, correlation lengths sy, sx (px).\"\"\"
n = ndi.gaussian_filter(rng.normal(size=(Hh, W)), (sy, sx), mode='wrap')
return (n - n.mean()) / (n.std() + 1e-9)
""")
s=s.replace(""" o, t = runs_field(Hh, W, rl, rng, True)
thr[sel] = t[sel]; coh[sel] = np.clip((bh - 1.0) / 2.5, 0, 1)[sel]""",""" o, t = runs_field(Hh, W, rl, rng, True)
# tufts: tall narrow noise gathers darks into pockets and lights onto tuft tops
tuft = smooth_noise(Hh, W, 3.0, 1.2, rng)
o = 0.5 * tuft + 0.87 * o
from scipy.special import ndtr
thr_t = ndtr(0.6 * tuft + 0.8 * (t - 0.5) * 3.4)
thr[sel] = thr_t[sel]; coh[sel] = np.clip((bh - 1.0) / 2.5, 0, 1)[sel]""")
s=s.replace(""" for _ in range(60):
self.hum.append""",""" for _ in range(60 if kind == 'hummocks' else 14):
self.hum.append""")
open('ground.py','w').write(s)
EOF
grep -n "for _ in range(60" ground.py; cat >> s1_grid.py <<'EOF'
g=ground.paint_ground(220,150,landform='bank',hour='golden',ppm=30,pitch=12,cam_h=1.6,seed=5,sun=(-20,12))
lw.save(np.concatenate([ref[90:150,40:140],np.zeros((60,3,3),np.uint8),g.rgb[90:150,40:140]],1),'../stages/s1_near_8x.png',8)
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 s1_grid.py
# output:
50: for _ in range(60 if kind == 'hummocks' else 14):Read: elements/ground/stages/s1_near_8x.png
40. elements/ground/stages/s1_near_8x.png
Bash: Step-skipping gap and stroke marks for grasscd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
old=s[s.index(" bh = bh_m * ppm\n rl = np.clip(bh * 0.9, 1, 14)"):s.index(" # earth: horizontal streaks")]
new=''' bh = bh_m * ppm
rl = np.clip(bh * 0.45, 1, 6.5) # measured: 1.6 far, 3.8 mid, 5 near (V16PM)
o, t = runs_field(Hh, W, rl, rng, True)
# tufts: tall narrow noise gathers darks into pockets and lights onto tuft tops
tuft = smooth_noise(Hh, W, 3.0, 1.2, rng)
from scipy.special import ndtr
u = ndtr(0.55 * tuft + 0.83 * o) # run quantile, tuft-correlated
thr[sel] = u[sel]; coh[sel] = np.clip((bh - 1.0) / 2.5, 0, 1)[sel]
near = np.clip((bh - 1.5) / 4.0, 0, 1)
# MARKS SKIP STEPS: the dark gap between blades in light is a near-black green (step 1);
# in shadow the blades are 'negative painted' olive strokes on the dark (step 2 on 0)
gap[sel] = ((u < 0.13 * near) & lit)[sel]
strk[sel] = ((u > 1 - 0.42 * near) & ~lit)[sel]
# the mass's own mixture of base and one-step-down strands (density carries the turn)
a = np.where(lit, 0.9, 0.5) * np.clip(bh / 3, 0.25, 1)
off[sel] = (a * (u - 0.5) * 1.6)[sel]
'''
s=s.replace(old,new)
s=s.replace(" off = np.zeros((Hh, W)); thr = np.full((Hh, W), 0.5); coh = np.zeros((Hh, W))",
" off = np.zeros((Hh, W)); thr = np.full((Hh, W), 0.5); coh = np.zeros((Hh, W))\n gap = np.zeros((Hh, W), bool); strk = np.zeros((Hh, W), bool)")
s=s.replace(" return off * contrast, thr, coh\n"," return off * contrast, thr, coh, gap, strk\n")
s=s.replace(" off, thr, coh = marks(buf, mat, v, lit, rng, contrast)"," off, thr, coh, gap, strk = marks(buf, mat, v, lit, rng, contrast)")
s=s.replace(" step = np.clip(np.floor(vv) + ((vv - np.floor(vv)) > t), 0, 5).astype(int)",
""" step = np.clip(np.floor(vv) + ((vv - np.floor(vv)) > t), 0, 5).astype(int)
step[gap] = 1
step[strk] = np.minimum(np.maximum(step[strk] + 2, 2), 2)""")
open('ground.py','w').write(s)
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 s1_grid.py
# output:
[This command modified 1 file you've previously read: ../pixelart-studies/elements/ground/src/ground.py. Call Read before editing.]Read: elements/ground/stages/s1_near_8x.png
41. elements/ground/stages/s1_near_8x.png
Bash: Remove offset/threshold cancellationcd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace(" off[sel] = (a * (u - 0.5) * 1.6)[sel]\n"," off[sel] = (a * (u - 0.5) * 1.6 * (1 - coh))[sel] # far: texture via ordered dither\n")
open('ground.py','w').write(s)
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 s1_grid.py
# output:
(Bash completed with no output)Read: elements/ground/stages/s1_near_8x.pngRead: elements/ground/stages/s1_grid_3x.png
42. elements/ground/stages/s1_near_8x.png
43. elements/ground/stages/s1_grid_3x.png
Bash: Rounding strands with spread; texture in far shadowcd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace(" thr[sel] = u[sel]; coh[sel] = np.clip((bh - 1.0) / 2.5, 0, 1)[sel]",
" thr[sel] = 0.5; coh[sel] = np.clip((bh - 1.0) / 2.5, 0, 1)[sel]")
s=s.replace(""" a = np.where(lit, 0.9, 0.5) * np.clip(bh / 3, 0.25, 1)
off[sel] = (a * (u - 0.5) * 1.6 * (1 - coh))[sel] # far: texture via ordered dither""",
""" # near: each blade run takes the mass value +- a spread and rounds (always two classes,
# their shares carry the mass); far: a finer stipple through the ordered dither
a = np.where(lit, 1.5, 1.1) * np.clip(bh / 3, 0.35, 1)
off[sel] = (a * (u - 0.5) * 1.7)[sel]""")
s=s.replace("if kind in ('hummocks', 'bank'):\n for _ in range(60 if kind == 'hummocks' else 14):",
"if kind in ('hummocks',):\n for _ in range(60):")
open('ground.py','w').write(s)
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 s1_grid.py
# output:
(Bash completed with no output)Read: elements/ground/stages/s1_grid_3x.pngRead: elements/ground/stages/s1_near_8x.png
44. elements/ground/stages/s1_near_8x.png
45. elements/ground/stages/s1_grid_3x.png
Bash: Materials by hour quick gridcd ~/work/pixelart-studies/elements/ground/src && cat > quick.py <<'EOF'
import ground, lw, numpy as np, sys, json
from concurrent.futures import ProcessPoolExecutor
def cell(kw):
kw=dict(kw); W=kw.pop('W',200); H=kw.pop('H',130)
return ground.paint_ground(W,H,**kw).rgb
def grid(rows, out, k=3):
with ProcessPoolExecutor(4) as ex:
ims=[list(ex.map(cell,r)) for r in rows]
pad=lambda a: np.pad(a,((0,4),(0,4),(0,0)))
lw.save(np.concatenate([np.concatenate([pad(i) for i in r],1) for r in ims],0),out,k)
if __name__=='__main__':
base=dict(landform='rolling',ppm=28,pitch=12,seed=4,shade=0.3,shade_scale=20)
rows=[[{**base,'ground':g,'hour':h} for h in ['noon','golden','dusk']] for g in ['turf','earth','gravel','forest']]
rows[3]=[{**r,'canopy':0.6,'shade':0} for r in rows[3]]
grid(rows,'../stages/quick_mats_3x.png',2)
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 quick.py
# output:
(Bash completed with no output)Read: elements/ground/stages/quick_mats_3x.png
46. elements/ground/stages/quick_mats_3x.png
Bash: Show ramp internalscd ~/work/pixelart-studies/elements/ground/src && grep -n "out.append(_set_L" -B 16 ground.py | head -40; grep -n "f = \[0.0" -A 5 ground.py # output: 448- if wet > 0: 449- Ls = Ls * (1 - 0.3 * wet) 450- alb = alb ** (1 + 0.6 * wet) # darker and more saturated 451- out = [] 452- for i in range(6): 453- if mat == 'water': 454- c = haze * (0.8 + 0.05 * i) 455- elif i < 3: 456- c = alb * skyc * (1.0 if i else 0.9) 457- if i == 0: c = c * np.array([0.75, 0.95, 1.35]) # cool core (V16's teal darks) 458- else: 459- c = alb * (sunc + skyc * 0.15) 460- if i == 5: c = c * np.array([1.12, 1.06, 0.62]) # top light goes yellow/ochre 461- if i == 3: c = c * 0.7 + alb * skyc * 0.6 # halftone keeps some sky 462- g = (c * np.array([0.2126, 0.7152, 0.0722])).sum() 463- c = np.clip(g + (c - g) * 1.35, 0, None) # lit colours carry the chroma 464: out.append(_set_L(c, Ls[i])) 466: f = [0.0, 0.18, 0.4][band] 467- hz = _lin(np.array([0, 0, 0]) + 0) + haze * 0.55 468- w = f * np.linspace(1.0, 0.35, 6)[:, None] # far: black point lifts toward haze 469- out = out * (1 - w) + hz[None] * w 470- return (np.clip(srgb(out), 0, 1) * 255).astype(np.uint8) 471-
Bash: Coloured shadows, gentler depth bandscd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace(""" c = alb * skyc * (1.0 if i else 0.9)
if i == 0: c = c * np.array([0.75, 0.95, 1.35]) # cool core (V16's teal darks)""",""" c = alb * skyc * (1.0 if i else 0.9)
if i == 0: c = c * np.array([0.75, 0.95, 1.35]) # cool core (V16's teal darks)
g = (c * np.array([0.2126, 0.7152, 0.0722])).sum()
c = np.clip(g + (c - g) * 1.5, 0, None) # shadows stay coloured, never grey""")
s=s.replace(" f = [0.0, 0.18, 0.4][band]"," f = [0.0, 0.07, 0.2][band]")
s=s.replace("w = f * np.linspace(1.0, 0.35, 6)[:, None]","w = f * np.linspace(1.0, 0.2, 6)[:, None]")
open('ground.py','w').write(s)
EOF
echo ok
# output:
okBash: Remove small shadow islands; rerender materials gridcd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace(" can = can * canopy_field(X, Z, shade, shade_scale, s, seed + 17)\n",""" sh = canopy_field(X, Z, shade, shade_scale, s, seed + 17)
if shade > 0:
sh = design_masses(sh, min_frac=0.02)
can = can * sh
""")
s=s.replace("def light(land, buf,","""def design_masses(f, min_frac=0.02):
\"\"\"Designed masses: no shadow island and no lit hole smaller than min_frac of the frame.
Ferrari's shadow shapes are a few big ones; small isolated ovals read as holes in the ground.\"\"\"
m = f < 0.5
a = m.size * min_frac
for val in (True, False):
lab, n = ndi.label(m == val)
if n == 0: continue
sizes = ndi.sum(np.ones_like(f), lab, range(1, n + 1))
small = np.isin(lab, np.nonzero(sizes < a)[0] + 1)
m[small] = not val
changed = (m != (f < 0.5))
return np.where(changed, np.where(m, 0.0, 1.0), f)
def light(land, buf,""")
open('ground.py','w').write(s)
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 quick.py
# output:
(Bash completed with no output)Read: elements/ground/stages/quick_mats_3x.png
47. elements/ground/stages/quick_mats_3x.png
Bash: Apply design masses to combined terrain+shadecd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace(""" if shade > 0:
sh = design_masses(sh, min_frac=0.02)
can = can * sh
sunvis = vis * can""",""" # designed masses: terrain and big cast shadows together, small islands absorbed;
# the dapple of a near canopy is added after (it is meant to be small)
big = design_masses(vis * sh, min_frac=0.012)
sunvis = big * can""")
open('ground.py','w').write(s)
EOF
grep -n "sunvis = big" ground.py; grep -n "terrain_vis=vis" ground.py
# output:
224: sunvis = big * can
231: return dict(ndl=ndl_macro, ndl_micro=ndl_micro, sunvis=sunvis, terrain_vis=vis, canopy=can,Bash: Directional skylight for shadow family; rerendercd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace(" sky = np.clip(0.55 + 0.45 * n[..., 1] - 0.25 * hollow, 0, 1)",
""" # skylight comes mostly from the bright side of the sky (the sun's side): surfaces facing
# the glow are the 'light' of the shadow family -- at dusk this is what shapes the land (V13)
hs = np.array([s[0], 0, s[2]]); hs = hs / (np.linalg.norm(hs) + 1e-6)
skyv = np.array([0, 1, 0]) + 0.9 * hs; skyv = skyv / np.linalg.norm(skyv)
nm2 = buf['n_macro']
sky = np.clip(0.3 + 0.7 * (nm2 * skyv).sum(-1) - 0.3 * hollow, 0, 1)""")
s=s.replace(" v = v + sheen * np.clip(np.log(d / 3) / 4, 0, 1) * (v >= 3)",
""" low = np.clip((12 - np.degrees(np.arcsin(sy))) / 10, 0, 1) # 1 when the sun is near the horizon
v = v + sheen * np.clip(np.log(d / 3) / 4, 0, 1) * np.where(v >= 3, 1.0, 0.8 * low)""")
s=s.replace(" v_sh = 0.2 + 1.3 * L['sky'] +"," v_sh = 0.1 + 1.9 * L['sky'] ** 1.3 +")
open('ground.py','w').write(s)
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 quick.py
# output:
(Bash completed with no output)Read: elements/ground/stages/quick_mats_3x.png
48. elements/ground/stages/quick_mats_3x.png
Bash: Terrace-biased swells; V13 comparison studycd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace(""" n = 7
ang = r.uniform(0, np.pi, n)""",""" n = 7
# swells mostly run across the view, so the land recedes in layered crests (V13 terraces)
ang = np.pi / 2 + r.normal(0, 0.45, n) if kind in ('rolling', 'bank') else r.uniform(0, np.pi, n)""")
s=s.replace("'dusk': (70, 5,","'dusk': (75, 4,")
open('ground.py','w').write(s)
EOF
cat > s2_v13.py <<'EOF'
"""Stage 2: rolling terraces + path at dusk/dawn beside V13."""
import ground, lw, numpy as np
d=lw.load('V13'); ref=lw.rgb(d)[240:480, 0:320]
out=[ref]
for sd in (4,7):
g=ground.paint_ground(320,240,landform='rolling',hour='dusk',sun=(80,3),ppm=14,pitch=6,cam_h=2.0,
path=True,seed=sd,relief=1.3)
out.append(g.rgb)
pad=np.zeros((240,4,3),np.uint8)
a=np.concatenate([out[0],pad,out[1],pad,out[2]],1)
lw.save(a,'../stages/s2_v13_2x.png',2)
def st(a):
a=a.astype(float); lo,hi=np.percentile(a,1),np.percentile(a,99.5); return np.clip((a-lo)/(hi-lo),0,1)**0.8*255
lw.save(st(a),'../stages/s2_v13_2x_stretch.png',2)
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 s2_v13.py
# output:
(Bash completed with no output)Read: elements/ground/stages/s2_v13_2x_stretch.png
49. elements/ground/stages/s2_v13_2x_stretch.png
Bash: Sky-dominant shadow hue at dusk; higher cameracd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace(""" c = alb * skyc * (1.0 if i else 0.9)
if i == 0:""",""" # in shadow the local colour weakens and the sky's hue takes over (strongest at dusk)
dom = {'noon': 0.25, 'morning': 0.4, 'golden': 0.5, 'dusk': 0.85, 'overcast': 0.2}[hour]
an = alb / alb.max()
c = (an ** (1 - dom)) * skyc * (1.0 if i else 0.9)
if i == 0:""")
s=s.replace(""" if hour == 'overcast':""",""" if hour == 'dusk':
Ls[:3] = Ls[:3] * 0.8
if hour == 'overcast':""")
open('ground.py','w').write(s)
EOF
sed -i '' "s/ppm=14,pitch=6,cam_h=2.0/ppm=10,pitch=7,cam_h=6.0/" s2_v13.py
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 s2_v13.py
# output:
(Bash completed with no output)Read: elements/ground/stages/s2_v13_2x_stretch.png
50. elements/ground/stages/s2_v13_2x_stretch.png
Bash: Measure V13 ground palettecd ~/work/pixelart-studies/elements/ground/src && python3 -c "
import lw,numpy as np
from analyze import Lstar
d=lw.load('V13'); I=d['idx']; P=d['pal']
for name,(x0,y0,x1,y1) in {'near grass R':(400,380,640,480),'terrace mid':(100,280,500,340),'far land':(200,215,600,250),'path':(300,420,420,470)}.items():
s=I[y0:y1,x0:x1]; u,c=np.unique(s,return_counts=True); o=np.argsort(-c)
print(name, [(tuple(P[u[k]]),round(float(Lstar(P[u[k]][None])[0])),round(100*c[k]/s.size)) for k in o[:6]])
"
# output:
near grass R [((0, 0, 11), 0, 27), ((0, 11, 19), 3, 16), ((7, 7, 19), 2, 14), ((0, 0, 7), 0, 13), ((11, 7, 15), 2, 7), ((31, 19, 27), 8, 5)]
terrace mid [((0, 7, 19), 2, 19), ((11, 7, 19), 2, 19), ((11, 15, 31), 5, 16), ((0, 0, 15), 0, 15), ((27, 31, 47), 12, 6), ((7, 19, 35), 6, 6)]
far land [((19, 19, 51), 8, 32), ((15, 15, 47), 6, 16), ((27, 23, 55), 10, 11), ((31, 27, 63), 12, 10), ((59, 31, 87), 18, 4), ((39, 31, 75), 15, 3)]
path [((39, 23, 27), 10, 25), ((31, 19, 23), 7, 18), ((31, 19, 27), 8, 15), ((47, 31, 31), 14, 15), ((0, 11, 19), 3, 5), ((0, 0, 7), 0, 5)]Bash: Darker blue dusk keycd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace(""" if hour == 'dusk':
Ls[:3] = Ls[:3] * 0.8""",""" if hour == 'dusk':
Ls[:3] = Ls[:3] * 0.45 # measured V13: dusk ground lives at L* 0..12""")
s=s.replace("'dusk': (75, 4, (0.95, 0.52, 0.40), (0.34, 0.30, 0.52), (0.70, 0.52, 0.62))",
"'dusk': (75, 4, (0.95, 0.52, 0.40), (0.22, 0.20, 0.55), (0.55, 0.40, 0.75))")
open('ground.py','w').write(s)
EOF
grep -n "'dusk': (75" ground.py; OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 s2_v13.py
# output:
154: 'dusk': (75, 4, (0.95, 0.52, 0.40), (0.22, 0.20, 0.55), (0.55, 0.40, 0.75)),Read: elements/ground/stages/s2_v13_2x.png
51. elements/ground/stages/s2_v13_2x.png
Bash: Hour-keyed far haze; sun below horizoncd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace(""" hz = _lin(np.array([0, 0, 0]) + 0) + haze * 0.55
w = f * np.linspace(1.0, 0.2, 6)[:, None] # far: black point lifts toward haze""",""" # far: the black point lifts toward the haze -- by an amount keyed to the hour (at dusk the
# far land is still dark, L* 6..18 in V13; at noon it pales)
hz = _set_L(haze, max(Ls[2] * 1.5, 8))
w = f * 2.0 * np.linspace(1.0, 0.1, 6)[:, None]""")
s=s.replace(" sunvis = big * can\n"," sunvis = big * can * (1.0 if el > 0 else 0.0)\n")
open('ground.py','w').write(s)
EOF
sed -i '' "s/sun=(80,3)/sun=(80,-2)/" s2_v13.py; OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 s2_v13.py
# output:
[This command modified 1 file you've previously read: ../pixelart-studies/elements/ground/src/ground.py. Call Read before editing.]Read: elements/ground/stages/s2_v13_2x.png
52. elements/ground/stages/s2_v13_2x.png
Bash: Exaggerate skylight form turningcd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace(""" sky = np.clip(0.3 + 0.7 * (nm2 * skyv).sum(-1) - 0.3 * hollow, 0, 1)""",""" # exaggerated (Ferrari turns the land more than nature): relative to a flat plane
ns = (nm2 * skyv).sum(-1); n0 = skyv[1]
sky = np.clip(0.55 + 0.45 * np.tanh(6 * (ns - n0)) - 0.3 * hollow, 0, 1)""")
s=s.replace("vv = np.where(lit, np.clip(vv, 2.6, 5.99), np.clip(vv, 0, 3.1))","vv = np.where(lit, np.clip(vv, 2.6, 5.99), np.clip(vv, 0, 2.99))")
open('ground.py','w').write(s)
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 s2_v13.py
# output:
(Bash completed with no output)Read: elements/ground/stages/s2_v13_2x_stretch.png
53. elements/ground/stages/s2_v13_2x_stretch.png
Bash: Clamp families after quantize; mid crop comparecd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace(""" step[gap] = 1""",""" step = np.where(lit, np.maximum(step, 3), np.minimum(step, 2)) # families never touch
step[gap] = 1""")
s=s.replace(""" a = np.where(lit, 1.5, 1.1) * np.clip(bh / 3, 0.35, 1)""",""" a = np.where(lit, 1.5, 1.1) * np.clip(bh / 3, 0.2, 1) * np.where(lit, 1, np.clip(bh / 2, 0.35, 1))""")
open('ground.py','w').write(s)
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 s2_v13.py && python3 -c "
import lw,numpy as np
from PIL import Image
a=np.array(Image.open('../stages/s2_v13_2x.png'))[::2,::2]
lw.save(np.concatenate([a[130:230,0:160],a[130:230,648:808]],1),'../stages/s2_v13_mid_6x.png',6)"
# output:
(Bash completed with no output)Read: elements/ground/stages/s2_v13_mid_6x.png
54. elements/ground/stages/s2_v13_mid_6x.png
Bash: Near shadows keep local colourcd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace(""" dom = {'noon': 0.25, 'morning': 0.4, 'golden': 0.5, 'dusk': 0.85, 'overcast': 0.2}[hour]""",
""" dom = {'noon': 0.25, 'morning': 0.4, 'golden': 0.5, 'dusk': 0.85, 'overcast': 0.2}[hour]
dom = dom * (0.55, 0.85, 1.0)[band] # near shadows keep their local green (V13, V16PM)""")
open('ground.py','w').write(s)
EOF
echo
# output:
(Bash completed with no output)Bash: Show earth mark codecd ~/work/pixelart-studies/elements/ground/src && grep -n " # earth: horizontal streaks" -A 12 ground.py # output: 402: # earth: horizontal streaks (ruts, the grain of the ground plane) + pebbles 403- sel = mat == MI['earth'] 404- if sel.any(): 405- rl = np.clip(0.5 * ppm, 1, 18) 406- o, t = runs_field(Hh, W, rl, rng, False) 407- thr[sel] = t[sel]; coh[sel] = np.clip((0.5 * ppm - 1.5) / 3, 0, 0.8)[sel] 408- a = np.clip((0.3 * ppm - 0.5) / 4, 0.1, 0.7) 409- st = stones_field(buf, 0.035, rng, density=0.18) 410- off[sel] = (a * o * 0.8 + 0.8 * np.clip(0.03 * ppm, 0.15, 1) * st)[sel] 411- sel = mat == MI['gravel'] 412- if sel.any(): 413- st = stones_field(buf, 0.06, rng, density=0.9) 414- off[sel] = (np.clip(0.05 * ppm / 2, 0.2, 1) * st)[sel]
Bash: LIC streaks for earth; path mass; render path studycd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace("def marks(buf, mat, v, lit, rng, contrast=1.0):","""def lic(noise, phi, length, steps=None):
\"\"\"Line-integral convolution: smear noise along the iso-lines of phi (screen space).
Streaks then follow the form -- a path's length, a slope's contours, a dune's crest.\"\"\"
Hh, W = noise.shape
gy, gx = np.gradient(phi)
tx, ty = -gy, gx
nrm = np.hypot(tx, ty) + 1e-9
tx, ty = tx / nrm, ty / nrm
tx = np.where(tx < 0, -tx, tx); ty = np.where(tx == 0, np.abs(ty), np.where(tx < 0, -ty, ty))
L = int(np.ceil(np.max(length))) if steps is None else steps
acc = noise.copy(); cnt = np.ones_like(noise)
for sgn in (1, -1):
py, px = np.mgrid[0:Hh, 0:W].astype(float)
for k in range(1, L + 1):
iy = np.clip(np.round(py).astype(int), 0, Hh - 1); ix = np.clip(np.round(px).astype(int), 0, W - 1)
px = px + sgn * tx[iy, ix]; py = py + sgn * ty[iy, ix]
iy = np.clip(np.round(py).astype(int), 0, Hh - 1); ix = np.clip(np.round(px).astype(int), 0, W - 1)
w = (k <= length)
acc += noise[iy, ix] * w; cnt += w
o = acc / cnt
return (o - o.mean()) / (o.std() + 1e-9)
def marks(buf, mat, v, lit, rng, contrast=1.0, phi=None):""")
s=s.replace(""" rl = np.clip(0.5 * ppm, 1, 18)
o, t = runs_field(Hh, W, rl, rng, False)
thr[sel] = t[sel]; coh[sel] = np.clip((0.5 * ppm - 1.5) / 3, 0, 0.8)[sel]
a = np.clip((0.3 * ppm - 0.5) / 4, 0.1, 0.7)
st = stones_field(buf, 0.035, rng, density=0.18)
off[sel] = (a * o * 0.8 + 0.8 * np.clip(0.03 * ppm, 0.15, 1) * st)[sel]""",""" # streaks along the form (LIC along phi's iso-lines), few and quiet; a few pebbles near
rl = np.clip(0.25 * ppm, 1, 9)
base = rng.normal(size=(Hh, W))
base = np.where(rng.random((Hh, W)) < 0.25, base * 2.2, base * 0.5) # sparse strong grains
o = lic(base, phi if phi is not None else buf['z'], rl)
a = np.clip((0.3 * ppm - 0.3) / 3, 0.25, 0.8) * np.where(lit, 0.9, 0.6)
st = stones_field(buf, 0.03, rng, density=0.06)
off[sel] = (a * o + 0.8 * np.clip(0.03 * ppm, 0.1, 1) * st)[sel]""")
s=s.replace(" off, thr, coh, gap, strk = marks(buf, mat, v, lit, rng, contrast)",""" phi = buf['z'].copy()
if path:
pd = land.path_dist(buf['x'], buf['z'])
phi = np.where(pd < 2.0, pd * 40, phi)
# the path's mass: a packed, lighter crown; grass-shaded margins drop a step
onp = (mat == MI['earth']) & (pd < 1.2)
v = np.where(onp, v + 0.5 * np.exp(-(pd / 0.35) ** 2) - 0.9 * np.clip((pd - 0.45) / 0.3, 0, 1), v)
v = np.where(lit, np.maximum(v, 3.0), np.minimum(v, 2.9))
off, thr, coh, gap, strk = marks(buf, mat, v, lit, rng, contrast, phi)""")
open('ground.py','w').write(s)
EOF
cat > s3_path.py <<'EOF'
import ground, lw, numpy as np
from quick import grid
base=dict(landform='rolling',path=True,ppm=40,pitch=18,cam_h=1.7,relief=0.6,W=220,H=150,seed=11)
grid([[{**base,'hour':'noon','shade':0.3},{**base,'hour':'golden','sun':(10,14)},{**base,'hour':'dusk','sun':(80,-2)}]],'../stages/s3_path_3x.png',3)
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 s3_path.py
# output:
Exit code 1
Traceback (most recent call last):
File "<string>", line 1, in <module>
File "~/miniconda3/lib/python3.12/multiprocessing/spawn.py", line 122, in spawn_main
exitcode = _main(fd, parent_sentinel)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "~/miniconda3/lib/python3.12/multiprocessing/spawn.py", line 131, in _main
prepare(preparation_data)
File "~/miniconda3/lib/python3.12/multiprocessing/spawn.py", line 246, in prepare
_fixup_main_from_path(data['init_main_from_path'])
File "~/miniconda3/lib/python3.12/multiprocessing/spawn.py", line 297, in _fixup_main_from_path
main_content = runpy.run_path(main_path,
^^^^^^^^^^^^^^^^^^^^^^^^^
File "<frozen runpy>", line 286, in run_path
File "<frozen runpy>", line 98, in _run_module_code
File "<frozen runpy>", line 88, in _run_code
File "~/work/pixelart-studies/elements/ground/src/s3_path.py", line 4, in <module>
grid([[{**base,'hour':'noon','shade':0.3},{**base,'hour':'golden','sun':(10,14)},{**base,'hour':'dusk','sun':(80,-2)}]],'../stages/s3_path_3x.png',3)
File "~/work/pixelart-studies/elements/ground/src/quick.py", line 8, in grid
ims=[list(ex.map(cell,r)) for r in rows]
^^^^^^^^^^^^^^
File "~/miniconda3/lib/python3.12/concurrent/futures/process.py", line 859, in map
results = super().map(partial(_process_chunk, fn),
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "~/miniconda3/lib/python3.12/concurrent/futures/_base.py", line 608, in map
fs = [self.submit(fn, *args) for args in zip(*iterables)]
^^^^^^^^^^^^^^^^^^^^^^
File "~/miniconda3/lib/python3.12/concurrent/futures/process.py", line 830, in submit
self._adjust_process_count()
File "~/miniconda3/lib/python3.12/concurrent/futures/process.py", line 789, in _adjust_process_count
self._spawn_process()
File "~/miniconda3/lib/python3.12/concurrent/futures/process.py", line 807, in _spawn_process
p.start()
File "~/miniconda3/lib/python3.12/multiprocessing/process.py", line 121, in start
self._popen = self._Popen(self)
^^^^^^^^^^^^^^^^^
File "~/miniconda3/lib/python3.12/multiprocessing/context.py", line 289, in _Popen
return Popen(process_obj)
^^^^^^^^^^^^^^^^^^
File "~/miniconda3/lib/python3.12/multiprocessing/popen_spawn_posix.py", line 32, in __init__
super().__init__(process_obj)
File "~/miniconda3/lib/python3.12/multiprocessing/popen_fork.py", line 19, in __init__
self._launch(process_obj)
File "~/miniconda3/lib/python3.12/multiprocessing/popen_spawn_posix.py", line 42, in _launch
prep_data = spawn.get_preparation_data(process_obj._name)
^^^^^^^^^^^^^^^^^^^^Read: elements/ground/stages/s3_path_3x.pngI need the __main__ guard in scripts that use the pool.
Bash: Rerun path study with main guardcd ~/work/pixelart-studies/elements/ground/src && cat > s3_path.py <<'EOF'
import ground, lw, numpy as np
from quick import grid
if __name__=='__main__':
base=dict(landform='rolling',path=True,ppm=40,pitch=18,cam_h=1.7,relief=0.6,W=220,H=150,seed=11)
grid([[{**base,'hour':'noon','shade':0.3},{**base,'hour':'golden','sun':(10,14)},{**base,'hour':'dusk','sun':(80,-2)}]],'../stages/s3_path_3x.png',3)
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 s3_path.py 2>&1 | tail -3
# output:
(Bash completed with no output)Read: elements/ground/stages/s3_path_3x.png
55. elements/ground/stages/s3_path_3x.png
Bash: Locate lines to modifycd ~/work/pixelart-studies/elements/ground/src && grep -n "gap\[sel\]" ground.py; grep -n "LSTEP = {" -A 8 ground.py | head -9; grep -n "c = alb \* (sunc + skyc \* 0.15)" ground.py
# output:
417: gap[sel] = ((u < 0.13 * near) & lit)[sel]
458:LSTEP = { # target CIE L* per step: few steps, far apart; darks near black (V16 / V16PM ramps)
459- 'turf': (4, 11, 21, 34, 49, 66),
460- 'meadow': (6, 14, 25, 40, 56, 72),
461- 'earth': (7, 15, 25, 37, 50, 63),
462- 'gravel': (8, 17, 28, 42, 57, 72),
463- 'forest': (4, 10, 19, 31, 45, 60),
464- 'moss': (5, 12, 22, 36, 52, 70),
465- 'water': (30, 40, 50, 60, 70, 80)}
466-
517: c = alb * (sunc + skyc * 0.15)Bash: Scattered gaps, greener lit steps, darker grass shadowscd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace(" gap[sel] = ((u < 0.13 * near) & lit)[sel]",""" ug = runs_field(Hh, W, np.clip(rl * 0.8, 1, 5), rng, True)[1] # gaps: independent, scattered
gap[sel] = ((ug < 0.11 * near) & lit)[sel]""")
s=s.replace(""" c = alb * (sunc + skyc * 0.15)""",""" a2 = np.array(ALBEDO_LIT.get(mat, alb)) if i < 5 else alb
c = a2 * (sunc + skyc * 0.15)""")
s=s.replace("LSTEP = { #","""# the light family's lower steps keep a greener local colour; only the top light goes yellow/ochre
ALBEDO_LIT = {'turf': (0.20, 0.30, 0.07), 'meadow': (0.34, 0.31, 0.10), 'moss': (0.14, 0.30, 0.05)}
LSTEP = { #""")
s=s.replace(""" if hour == 'dusk':
Ls[:3] = Ls[:3] * 0.45""",""" if mat in ('turf', 'meadow'):
Ls[:3] = Ls[:3] * 0.8 # under skylight grass is darker than bare soil (self-occlusion)
if hour == 'dusk':
Ls[:3] = Ls[:3] * (0.35 if mat in ('turf', 'meadow') else 0.5)""")
open('ground.py','w').write(s)
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 s3_path.py 2>&1 | tail -3
# output:
(Bash completed with no output)Read: elements/ground/stages/s3_path_3x.png
56. elements/ground/stages/s3_path_3x.png
Bash: Near grass 6x comparison with V16/V16PMcd ~/work/pixelart-studies/elements/ground/src && sed -i '' 's/ a2 = np.array(ALBEDO_LIT.get(mat, alb)) if i < 5 else alb/ a2 = np.array(ALBEDO_LIT.get(mat, alb)) if i == 3 else (0.5 * (np.array(ALBEDO_LIT.get(mat, alb)) + alb) if i == 4 else alb)/' ground.py && cat > s3_near.py <<'EOF'
import ground, lw, numpy as np
d=lw.load('V16'); ref=lw.rgb(d)[240:300, 0:110]
g=ground.paint_ground(220,150,landform='rolling',path=True,ppm=40,pitch=18,cam_h=1.7,relief=0.6,seed=11,hour='noon',shade=0.3)
d2=lw.load('V16PM'); ref2=lw.rgb(d2)[380:440,0:110]
g2=ground.paint_ground(220,150,landform='rolling',path=True,ppm=40,pitch=18,cam_h=1.7,relief=0.6,seed=11,hour='golden',sun=(10,14))
p=np.zeros((60,3,3),np.uint8)
lw.save(np.concatenate([ref,p,g.rgb[20:80,100:210],p,ref2,p,g2.rgb[20:80,0:110]],1),'../stages/s3_near_8x.png',6)
EOF
OPENBLAS_NUM_THREADS=1 nice -n 10 python3 s3_near.py
# output:
(Bash completed with no output)Read: elements/ground/stages/s3_near_8x.png
57. elements/ground/stages/s3_near_8x.png
Bash: Longer strands, narrower tuftscd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace(" rl = np.clip(bh * 0.45, 1, 6.5)"," rl = np.clip(bh * 0.6, 1, 7.5)")
s=s.replace(" tuft = smooth_noise(Hh, W, 3.0, 1.2, rng)\n from scipy.special import ndtr\n u = ndtr(0.55 * tuft + 0.83 * o)",
" tuft = smooth_noise(Hh, W, 5.0, 0.8, rng)\n from scipy.special import ndtr\n u = ndtr(0.45 * tuft + 0.89 * o)")
s=s.replace("ug = runs_field(Hh, W, np.clip(rl * 0.8, 1, 5), rng, True)[1]","ug = runs_field(Hh, W, np.clip(rl * 1.0, 2, 6), rng, True)[1]")
open('ground.py','w').write(s)
EOF
grep -c "smooth_noise(Hh, W, 5.0" ground.py; OPENBLAS_NUM_THREADS=1 nice -n 10 python3 s3_near.py
# output:
1Read: elements/ground/stages/s3_near_8x.png
58. elements/ground/stages/s3_near_8x.png
Bash: Set lit_base 0.5 and sheen 0.35 defaultscd ~/work/pixelart-studies/elements/ground/src && sed -i '' 's/seed=0, water=None, lit_base=0.6):/seed=0, water=None, lit_base=0.5):/; s/shade_scale=12.0, vignette=0.35, path=False, relief=1.0, sep=0.9,/shade_scale=12.0, vignette=0.35, path=False, relief=1.0, sep=0.9,/' ground.py && grep -n "sheen=0.5, contrast" ground.py && sed -i '' 's/ sheen=0.5, contrast=1.0/ sheen=0.35, contrast=1.0/' ground.py && OPENBLAS_NUM_THREADS=1 nice -n 10 python3 s3_near.py # output: 587: sheen=0.5, contrast=1.0, jitter=0.5, band_z=(12, 45), seed=0, water=None, lit_base=0.5):
Read: elements/ground/stages/s3_near_8x.png
59. elements/ground/stages/s3_near_8x.png
Bash: Measure lit turf step sharescd ~/work/pixelart-studies/elements/ground/src && OPENBLAS_NUM_THREADS=1 nice -n 10 python3 -c "
import ground,numpy as np
g=ground.paint_ground(220,150,landform='rolling',path=True,ppm=40,pitch=18,cam_h=1.7,relief=0.6,seed=11,hour='golden',sun=(10,14))
m=g.lit&(g.mat==0); print(np.bincount(g.step[m],minlength=6)/m.sum()); print(np.percentile(g.v[m],[10,50,90]))
print(ground.ramp('turf','golden'))
"
# output:
[0.00000000e+00 6.93281979e-02 2.96274350e-04 1.79912599e-01
3.77823865e-01 3.72639064e-01]
[3.95274527 4.3335215 4.44838903]
[[ 8 12 6]
[ 24 26 6]
[ 41 43 14]
[ 78 75 0]
[129 104 0]
[193 135 0]]Bash: Tame chroma and spreadcd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace("c = np.clip(g + (c - g) * 1.35, 0, None) # lit colours carry the chroma","c = np.clip(g + (c - g) * 1.1, 0, None) # lit colours carry the chroma")
s=s.replace("if i == 5: c = c * np.array([1.12, 1.06, 0.62])","if i == 5: c = c * np.array([1.10, 1.04, 0.85])")
s=s.replace("a = np.where(lit, 1.5, 1.1) * np.clip(bh / 3, 0.2, 1)","a = np.where(lit, 1.0, 1.1) * np.clip(bh / 3, 0.2, 1)")
open('ground.py','w').write(s)
EOF
OPENBLAS_NUM_THREADS=1 nice -n 10 python3 s3_near.py; python3 -c "
import ground; print(ground.ramp('turf','golden')); print(ground.ramp('turf','noon'))"
# output:
[[ 8 12 6]
[ 24 26 6]
[ 41 43 14]
[ 77 75 21]
[126 105 14]
[187 138 0]]
[[ 6 13 2]
[ 22 27 0]
[ 37 44 0]
[ 71 85 32]
[113 121 40]
[173 163 32]]Read: elements/ground/stages/s3_near_8x.png
60. elements/ground/stages/s3_near_8x.png
Bash: Adjust turf albedo and golden suncd ~/work/pixelart-studies/elements/ground/src && sed -i '' "s/'turf': (0.33, 0.31, 0.05)/'turf': (0.33, 0.30, 0.10)/; s/'golden': (20, 14, (1.00, 0.58, 0.30)/'golden': (20, 14, (1.00, 0.60, 0.38)/" ground.py && python3 -c "
import ground; print(ground.ramp('turf','golden')[3:])"
# output:
[[ 76 75 34]
[125 105 35]
[186 137 45]]Bash: Material grid: forest, earth, gravel, meadowcd ~/work/pixelart-studies/elements/ground/src && cat > s4_mats.py <<'EOF'
import ground, lw, numpy as np
from quick import grid
if __name__=='__main__':
b=dict(landform='rolling',ppm=40,pitch=20,cam_h=1.7,relief=0.5,W=200,H=130,seed=21)
rows=[[{**b,'ground':'forest','hour':h,'shade':0.72,'shade_scale':4,'canopy':0.25,'canopy_scale':0.7,'moisture':0.3} for h in ['noon','golden','overcast']],
[{**b,'ground':'earth','hour':h,'shade':0.3,'shade_scale':6} for h in ['noon','golden','overcast']],
[{**b,'ground':'gravel','hour':h,'shade':0.3,'shade_scale':6} for h in ['noon','golden','overcast']],
[{**b,'ground':'meadow','hour':h,'shade':0.3,'shade_scale':10} for h in ['noon','golden','overcast']]]
grid(rows,'../stages/s4_mats_3x.png',2)
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 s4_mats.py 2>&1|tail -3
# output:
(Bash completed with no output)Read: elements/ground/stages/s4_mats_3x.png
61. elements/ground/stages/s4_mats_3x.png
Bash: Locate value plan linescd ~/work/pixelart-studies/elements/ground/src && grep -n "def value_plan" -A 30 ground.py | grep -n "lit = \|pen\|v_sh =" # output: 11:256- lit = (L['sunvis'] > 0.5) & (L['ndl'] > lit_floor * sy) 17:262- v_lit = 3.0 + 3.0 * np.clip(lit_base + 0.5 * (r - 1), 0, 0.999) 20:265- v_sh = 0.1 + 1.9 * L['sky'] ** 1.3 + 0.7 * np.clip(L['ndl'] / sy, 0, 1.3) * (1 - L['sunvis']) 21:266- v_sh = np.clip(v_sh, 0, 2.6) 23:268- # the soft canopy edge: blend across the family boundary only inside the penumbra 24:269- pen = (L['sunvis'] > 0.15) & (L['sunvis'] < 0.85) & (L['ndl'] > lit_floor * sy) 26:271- v = np.where(pen, (1 - t) * v_sh + t * v_lit, v)
Bash: Broken terminators and overcast single familycd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace(""" lit = (L['sunvis'] > 0.5) & (L['ndl'] > lit_floor * sy)
""",""" # the terminator of a cast shadow is broken by the material: blade tips cross it on turf,
# grains on earth -- never a clean cut-paper curve
Hh, W = L['sunvis'].shape
rng = np.random.default_rng(12345)
en = np.zeros((Hh, W))
if mat is not None:
nt = ndi.gaussian_filter(rng.normal(size=(Hh, W)), (4, 0.7)); nt /= nt.std() + 1e-9
ne = ndi.gaussian_filter(rng.normal(size=(Hh, W)), (1.3, 1.3)); ne /= ne.std() + 1e-9
grass = np.isin(mat, [MI['turf'], MI['meadow'], MI['moss']])
en = np.where(grass, 0.22 * nt, 0.15 * ne) + 0.06 * rng.normal(size=(Hh, W))
sv = L['sunvis'] + en * (L['sunvis'] * (1 - L['sunvis']) * 4 + 0.15)
lit = (sv > 0.5) & (L['ndl'] > lit_floor * sy)
""")
s=s.replace(""" pen = (L['sunvis'] > 0.15) & (L['sunvis'] < 0.85) & (L['ndl'] > lit_floor * sy)""",""" pen = np.zeros_like(lit)""")
s=s.replace("def value_plan(buf, L, mat=None, sep=0.9, sheen=0.5, lit_floor=0.10, lit_base=0.6):",
"def value_plan(buf, L, mat=None, sep=0.9, sheen=0.5, lit_floor=0.10, lit_base=0.6, overcast=False):")
s=s.replace(""" v = np.where(pen, (1 - t) * v_sh + t * v_lit, v)""",""" v = np.where(pen, (1 - t) * v_sh + t * v_lit, v)
if overcast:
# no cast shadows, no terminator: one soft family across the whole ramp, shaped by the sky
v = 0.8 + 3.6 * L['sky'] ** 1.2
lit = v >= 3.0""")
s=s.replace("v, lit, sepm = value_plan(buf, L, mat, sep=sep, sheen=sheen, lit_base=lit_base)",
"v, lit, sepm = value_plan(buf, L, mat, sep=sep, sheen=sheen, lit_base=lit_base, overcast=(hour == 'overcast'))")
open('ground.py','w').write(s)
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 s4_mats.py 2>&1|tail -3
# output:
(Bash completed with no output)Read: elements/ground/stages/s4_mats_3x.png
62. elements/ground/stages/s4_mats_3x.png
Bash: Sharper shade edges, subtler edge noisecd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace("return 1 - 1 / (1 + np.exp(-(acc - thr) / 0.08))","return 1 - 1 / (1 + np.exp(-(acc - thr) / 0.025))")
s=s.replace("en = np.where(grass, 0.22 * nt, 0.15 * ne) + 0.06 * rng.normal(size=(Hh, W))","en = np.where(grass, 0.12 * nt, 0.08 * ne) + 0.04 * rng.normal(size=(Hh, W))")
s=s.replace("sv = L['sunvis'] + en * (L['sunvis'] * (1 - L['sunvis']) * 4 + 0.15)","sv = L['sunvis'] + en * (L['sunvis'] * (1 - L['sunvis']) * 4 + 0.05)")
s=s.replace(" v = 0.8 + 3.6 * L['sky'] ** 1.2\n"," v = 0.6 + 3.6 * L['sky'] ** 1.2 + 0.9 * np.clip(np.log(buf['z'] / 4) / 4, 0, 1)\n")
open('ground.py','w').write(s)
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 s4_mats.py 2>&1|tail -3
# output:
(Bash completed with no output)Read: elements/ground/stages/s4_mats_3x.png
63. elements/ground/stages/s4_mats_3x.png
Bash: Distance ladder sheetcd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace(" v = 0.6 + 3.6 * L['sky'] ** 1.2 +"," v = 1.3 + 3.2 * L['sky'] ** 1.2 +")
open('ground.py','w').write(s)
EOF
cat > s5_dist.py <<'EOF'
import ground, lw, numpy as np
from quick import grid
if __name__=='__main__':
rows=[]
for hr,kw in [('golden',dict(sun=(15,12))),('noon',{})]:
rows.append([dict(landform='rolling',path=True,ground='turf',hour=hr,ppm=p,pitch=pi,cam_h=1.7*(40/p)**0.6,relief=0.8,
shade=0.3,shade_scale=12,W=200,H=130,seed=8,**kw) for p,pi in [(120,35),(40,20),(12,12),(4,8),(1.2,5)]])
grid(rows,'../stages/s5_distance_2x.png',2)
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 s5_dist.py 2>&1|tail -3
# output:
(Bash completed with no output)Read: elements/ground/stages/s5_distance_2x.png
64. elements/ground/stages/s5_distance_2x.png
Bash: More texture in far shadows, bigger min massescd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace("big = design_masses(vis * sh, min_frac=0.012)","big = design_masses(vis * sh, min_frac=0.03)")
s=s.replace("np.where(v >= 3, 1.0, 0.8 * low)","np.where(v >= 3, 1.0, 0.5 + 0.5 * low)")
s=s.replace(" a = np.where(lit, 1.0, 1.1) * np.clip(bh / 3, 0.2, 1) * np.where(lit, 1, np.clip(bh / 2, 0.35, 1))",
" a = np.where(lit, 1.0, 1.1) * np.clip(bh / 3, 0.45, 1)")
open('ground.py','w').write(s)
EOF
grep -n "fade = " ground.py
# output:
633: fade = np.clip(1.2 - np.log(buf['z'] / band_z[0] + 1e-6) / 2.2, 0.15, 1)Bash: Keep far stipple; rerender distancecd ~/work/pixelart-studies/elements/ground/src && sed -i '' 's| fade = np.clip(1.2 - np.log(buf\[.z.\] / band_z\[0\] + 1e-6) / 2.2, 0.15, 1)| fade = np.clip(1.2 - np.log(buf["z"] / band_z[0] + 1e-6) / 2.2, 0.45, 1)|' ground.py && grep -n "fade = " ground.py && OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 s5_dist.py 2>&1|tail -3 # output: 633: fade = np.clip(1.2 - np.log(buf["z"] / band_z[0] + 1e-6) / 2.2, 0.45, 1)
Read: elements/ground/stages/s5_distance_2x.png
65. elements/ground/stages/s5_distance_2x.png
Bash: Lift shadow family centre and top stepcd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace(" v_sh = 0.1 + 1.9 * L['sky'] ** 1.3 +"," v_sh = 0.5 + 1.8 * L['sky'] ** 1.2 +")
s=s.replace(" 'turf': (4, 11, 21, 34, 49, 66),"," 'turf': (4, 12, 28, 36, 50, 66), # V16 far hummocks: L 2 / 6-9 / 25 in shadow")
open('ground.py','w').write(s)
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 s5_dist.py 2>&1|tail -3
# output:
(Bash completed with no output)Read: elements/ground/stages/s5_distance_2x.png
66. elements/ground/stages/s5_distance_2x.png
Bash: Read driftwood paint APIcd ~/work/pixelart-studies/elements/driftwood/src && sed -n 25,70p driftwood.py; sed -n 1,40p ../LESSONS.md | head -60
# output:
from util import board
ROOT = os.path.expanduser('~/work/pixelart-studies/elements/driftwood')
LIGHTS = {'left-front': (300, 40), 'right-front': (60, 40), 'back': (180, 22), 'high': (330, 68)}
HOUR_EL = {'golden': 10, 'morning': 22}
DIST = {'near': 40.0, 'mid': 16.0, 'far': 6.0}
PITCH = {'30': 30.0, 'low': 10.0}
def paint(length=3.0, diameter=0.35, orientation=15.0, rootwad=False, weathering=1.0, wetness=0.0,
light='left-front', hour='noon', camera='30', distance='near', seed=0, pile=0,
ground='gravel', W=160, H=96, grey=False):
scale = DIST.get(distance, distance) if isinstance(distance, str) else float(distance)
pitch = PITCH[camera]
r = diameter / 2
az, el = LIGHTS[light]
if hour in HOUR_EL:
el = min(el, HOUR_EL[hour] if light != 'high' else HOUR_EL[hour] * 2.2)
rng = np.random.default_rng(seed)
rw = dict(n=6, end=0) if rootwad else None
lg = geo.make_log(L=length, r=r, yaw=orientation, seed=seed, rootwad=rw,
ends=('snapped' if rootwad else str(rng.choice(['sawn', 'worn', 'worn', 'snapped'])),
str(rng.choice(['snapped', 'worn', 'splinter']))),
n_stubs=int(round(3 * weathering)), wet=1.0 if wetness >= 0.5 else 0.0,
n_knots=int(round(3 * weathering)))
logs = [lg]
if pile:
extra = pilemod.make_pile(seed=seed + 1, n=pile, extent=(length * 1.6, length * 0.8),
flow_yaw=orientation, r_range=(r * 0.4, r * 0.9))
placed = [lg]
for q in extra:
placed.append(pilemod.settle(q, placed))
logs = placed
cz = float(np.mean([q.pts[:, 2].mean() for q in logs]))
cx = float(np.mean(lg.pts[:, 0])); cy = float(np.mean(lg.pts[:, 1]))
cam = geo.Camera(W, H, scale, pitch=pitch, look=(cx, cy, cz))
li = Light(cam, az=az, el=el, overcast=(hour == 'overcast'))
cv, buf, sh = render.render(logs, cam, li, seed=seed, stone_m=max(0.06, 1.6 / scale),
opts=dict(gravel=(ground == 'gravel'), weather=weathering))
if grey:
return render.to_rgb(cv, render.grey_ramps())
return render.to_rgb(cv, palette.ramps(hour, 1.0 if wetness >= 0.5 else 0.0))
# ------------------------------------------------------------------ sheets
def _cell(args):
# Driftwood, after Mark Ferrari
Whole trees and logs stranded on river gravel bars: silvered, barkless trunks with root wads and
snapped stubs, piled against each other. Everything here is made by the programs in `src/`; the
stage images are in `stages/`, the parameter sheets in `sheets/`. Photos are in `refs/` (sources
in `refs/sources.tsv`) plus `~/work/pixelart-studies/transfer/refs/braided_driftwood_bar.jpg`.
src/run_all.sh # every stage image and sheet (~9 min, 4 processes)
nice -n 10 python3 src/driftwood.py sheets # just the sheets
The technique code:
- `src/geo.py` is the world: logs as bent, tBash: Inspect driftwood render outputscd ~/work/pixelart-studies/elements/driftwood/src && grep -n "^def render" -A 30 render.py | head -60; grep -n "return cv" render.py; grep -n "class\|def to_rgb" -A6 render.py | head -40
# output:
40:def render(logs, cam, light, seed=0, stone_m=0.06, opts=None):
41- o = dict(contact=True, bottom_row=True, gravel=True, cast=True)
42- o.update(opts or {})
43- H, W = cam.H, cam.W
44- # what is below a pixel does not exist: limbs thinner than ~0.6 px and rootlets on logs under
45- # ~3 px radius are dropped (critic r10: stubs read as antennae at 6-9 px/m)
46- for lg in logs:
47- lg.limbs = [l for l in lg.limbs if l[2] * cam.scale >= 0.6]
48- if lg.rad.mean() * cam.scale < 3:
49- lg.rootlets = []
50- buf = geo.trace(cam, logs)
51- sh = geo.shadow(buf, light.L, logs) if o['cast'] and not light.overcast else np.zeros((H, W), bool)
52- cv = Canvas(H, W)
53- V = -cam.f
54- obj = buf['obj']
55- wood = obj >= 0
56- # ---------- wood form: five classes around the axis
57- d, phL = tech.normal_plane_angles(buf['N'], V, light.L, buf['T'])
58- v = tech.cylinder_classes(d, phL, sh=sh)
59- if light.overcast:
60- # overcast: the sky is the light; peak straight up
61- d2, ph2 = tech.normal_plane_angles(buf['N'], V, np.array([0, 0, 1.0]), buf['T'])
62- v = tech.cylinder_classes(d2, ph2)
63- diam = 2 * buf['rad'] * cam.scale
64- # with marks on, big logs turn their form by MARK DENSITY, not by checker: the classes are
65- # quantized nearly pure (seams at most 1 px) and the grain supplies the gradation
66- if o.get('marks', True):
67- v = tech.sharpen(v, diam, max_gain=12.0)
68- v = np.where(diam >= 7, tech.sharpen(v, np.full_like(diam, 1.0), max_gain=12.0), v)
69- else:
70- v = tech.sharpen(v, diam)
153: return cv, buf, sh
4: 1 form geo.trace + tech.cylinder_classes (wood), pebble domes (gravel)
5- 2 value families: cast shadow forces the shadow family; contact line at 0
6- 3 marks (stage 2+) grain, cracks, knots
7- 4 drawing contact line, bottom row of the log, silhouette runs
8: 5 quantize class checker (wood), ordered 2x2 (gravel)
9- 6 palette (stage 4) ramps per material
10-"""
11-import numpy as np
12-import geo, tech
13-from paint import (WOOD, GRAVEL, RAW, BARK, Light, gravel_field, hash2, BAYER2)
14-ROOTM = 5
--
34:class Canvas:
35- def __init__(self, H, W):
36- self.mat = np.full((H, W), GRAVEL)
37- self.step = np.zeros((H, W), int)
38-
39-
40-def render(logs, cam, light, seed=0, stone_m=0.06, opts=None):
--
56: # ---------- wood form: five classes around the axis
57- d, phL = tech.normal_plane_angles(buf['N'], V, light.L, buf['T'])
58: v = tech.cylinder_classes(d, phL, sh=sh)
59- if light.overcast:
60- # overcast: the sky is the light; peak straight up
61- d2, ph2 = tech.normal_plane_angles(buf['N'], V, np.array([0, 0, 1.0]), buf['T'])
62: v = tech.cylinder_classes(d2, ph2)
63- diam = 2 * buf['rad'] * cam.scale
64: # with marks on, big logs turn their form by MARK DENSITY, not by checker: the classes are
65- # quantized nearly pure (seams at mBash: Driftwood-on-ground integration democd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace("sheen=0.35, contrast=1.0, jitter=0.5, band_z=(12, 45), seed=0, water=None, lit_base=0.5):",
"sheen=0.35, contrast=1.0, jitter=0.5, band_z=(12, 45), seed=0, water=None, lit_base=0.5,\n shadow_mask=None):")
s=s.replace(" L = light(land, buf, az, el, canopy, canopy_scale, seed, shade, shade_scale)\n",
""" L = light(land, buf, az, el, canopy, canopy_scale, seed, shade, shade_scale)
if shadow_mask is not None:
# cast shadows of objects standing on the ground (a log, a bush, a stone): the ground
# itself in its shadow family, texture kept inside (READY.md: cast shadows)
L['sunvis'] = L['sunvis'] * (1 - shadow_mask.astype(float))
""")
open('ground.py','w').write(s)
EOF
cat > s6_driftwood.py <<'EOF'
"""Integration: the driftwood student's logs standing on this ground."""
import sys, os, numpy as np
sys.path.insert(0, os.path.expanduser('~/work/pixelart-studies/elements/driftwood/src'))
here = os.path.dirname(os.path.abspath(__file__)); sys.path.insert(0, here)
import ground, lw
import driftwood as dw, geo, render as dwr, palette as dwp, pile as pilemod
from paint import Light, GRAVEL
def logs_on_ground(W=240, H=150, hour='golden', ground_kw=None, seed=24, scale=40.0, pitch=30.0, pile=4):
rng = np.random.default_rng(seed)
lg = geo.make_log(L=3.0, r=0.17, yaw=15.0, seed=seed, rootwad=dict(n=6, end=0), ends=('snapped', 'worn'),
n_stubs=3, wet=0.0, n_knots=3)
logs = [lg]; placed = [lg]
for q in pilemod.make_pile(seed=seed + 1, n=pile, extent=(4.8, 2.4), flow_yaw=15.0, r_range=(0.07, 0.15)):
placed.append(pilemod.settle(q, placed))
logs = placed
cz = float(np.mean([q.pts[:, 2].mean() for q in logs])); cx = float(np.mean(lg.pts[:, 0])); cy = float(np.mean(lg.pts[:, 1]))
cam = geo.Camera(W, H, scale, pitch=pitch, look=(cx, cy, cz))
az, el = {'golden': (300, 10), 'noon': (300, 40), 'overcast': (300, 40)}[hour]
li = Light(cam, az=az, el=el, overcast=(hour == 'overcast'))
cv, buf, sh = dwr.render(logs, cam, li, seed=seed, stone_m=0.06, opts=dict(gravel=True, weather=1.0))
wood_rgb = dwr.to_rgb(cv, dwp.ramps(hour, 0.0))
wood = cv.mat != GRAVEL
# contact: darkest ground pixels the log student drew right under the wood
near = np.zeros_like(wood)
near[:-1] |= wood[1:]; near[1:] |= wood[:-1]
contact = (~wood) & (cv.step == 0) & near
kw = dict(ground='gravel', landform='flat', ppm=scale, pitch=pitch, cam_h=4.0, hour=hour, seed=seed,
sun=(20 if az > 180 else 160, el))
kw.update(ground_kw or {})
g = ground.paint_ground(W, H, shadow_mask=sh & ~wood, **kw)
out = g.rgb.copy()
out[wood | contact] = wood_rgb[wood | contact]
return out, g.rgb, wood_rgb
if __name__ == '__main__':
rows = []
for hr in ('golden', 'noon'):
a, gr, old = logs_on_ground(hour=hr, ground_kw=dict(shade=0.35, shade_scale=3, moisture=0.3))
b, _, _ = logs_on_ground(hour=hr, ground_kw=dict(ground='turf', shade=0.3, shade_scale=3))
p = np.zeros((a.shape[0], 4, 3), np.uint8)
rows.append(np.concatenate([old, p, a, p, b], 1))
q = np.zeros((4, rows[0].shape[1], 3), np.uint8)
lw.save(np.concatenate([rows[0], q, rows[1]], 0), os.path.join(here, '../sheets/integration_driftwood_3x.png'), 3)
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 s6_driftwood.py 2>&1 | tail -5
# output:
(Bash completed with no output)Read: elements/ground/sheets/integration_driftwood_3x.png
67. elements/ground/sheets/integration_driftwood_3x.png
Bash: Ragged shadow edges; quieter gravelcd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace(""" # threshold so that 'cover' of the ground is in shade; soft band gives dither in the edge""",""" # world-anchored ragged edge: a few octaves of sinusoids at crown-to-leaf scale, so shadow
# shapes are lobed and notched (foliage), never a clean ellipse
rr_ = np.random.default_rng(seed + 5)
amp = acc.std() + 1e-6
for k, wl in enumerate([scale * 1.2, scale * 0.55, scale * 0.25, scale * 0.12]):
for _ in range(3):
a0 = rr_.uniform(0, np.pi); ph = rr_.uniform(0, 6.3)
acc = acc + amp * 0.35 * 0.7 ** k * np.sin((np.cos(a0) * x + np.sin(a0) * z) * 2 * np.pi / wl + ph)
# threshold so that 'cover' of the ground is in shade; soft band gives dither in the edge""")
s=s.replace("return 1 - 1 / (1 + np.exp(-(acc - thr) / 0.025))","return 1 - 1 / (1 + np.exp(-(acc - thr) / (0.03 * amp)))")
# gravel: quieter
s=s.replace(""" off[sel] = (np.clip(0.05 * ppm / 2, 0.2, 1) * st)[sel]""",""" off[sel] = (np.clip(0.05 * ppm / 2, 0.2, 1) * st * np.where(lit, 0.6, 0.45))[sel]""")
open('ground.py','w').write(s)
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 s6_driftwood.py 2>&1 | tail -5
# output:
(Bash completed with no output)Read: elements/ground/sheets/integration_driftwood_3x.png
68. elements/ground/sheets/integration_driftwood_3x.png
Bash: Labelled sheets script; render landform sheetcd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace("acc = acc + amp * 0.35 * 0.7 ** k *","acc = acc + amp * 0.25 * 0.7 ** k *")
s=s.replace("c = np.clip(g + (c - g) * 1.5, 0, None) # shadows stay coloured, never grey",
"c = np.clip(g + (c - g) * 1.25, 0, None) # shadows stay coloured, never grey")
open('ground.py','w').write(s)
EOF
cat > sheets.py <<'EOF'
"""Labelled parameter sheets. nice -n 10 python3 sheets.py [name ...]"""
import sys, os, numpy as np
from PIL import Image, ImageDraw
from concurrent.futures import ProcessPoolExecutor
import ground
HERE = os.path.dirname(os.path.abspath(__file__)); OUT = os.path.join(HERE, '../sheets')
def cell(kw):
kw = dict(kw); W = kw.pop('W', 200); H = kw.pop('H', 130); kw.pop('label', None)
return ground.paint_ground(W, H, **kw).rgb
def sheet(name, rows, rlabels, clabels, title, k=3):
flat = [c for r in rows for c in r]
with ProcessPoolExecutor(4) as ex:
ims = list(ex.map(cell, flat))
H, W = ims[0].shape[:2]
nr, nc = len(rows), len(rows[0])
top, left, gap = 40, 110, 8
S = Image.new('RGB', (left + nc * (W * k + gap), top + nr * (H * k + gap) + 20), (22, 22, 26))
d = ImageDraw.Draw(S)
d.text((8, 8), title, fill=(230, 230, 230))
for j, cl in enumerate(clabels):
d.text((left + j * (W * k + gap) + 4, top - 14), cl, fill=(200, 200, 200))
for i, rl in enumerate(rlabels):
d.text((6, top + i * (H * k + gap) + 6), rl, fill=(200, 200, 200))
for j in range(nc):
im = Image.fromarray(ims[i * nc + j]).resize((W * k, H * k), Image.NEAREST)
S.paste(im, (left + j * (W * k + gap), top + i * (H * k + gap)))
S.save(os.path.join(OUT, f'{name}.png'))
print('wrote', name)
BASE = dict(landform='rolling', ppm=24, pitch=12, cam_h=1.7, seed=8, W=200, H=130)
def s_ground_hour():
grounds = ['turf', 'meadow', 'earth', 'gravel', 'forest']
hours = ['morning', 'noon', 'golden', 'dusk', 'overcast']
extra = {'forest': dict(shade=0.7, shade_scale=4, canopy=0.2, canopy_scale=0.7, moisture=0.3)}
rows = [[{**BASE, 'ground': g, 'hour': h, 'shade': 0.3, 'shade_scale': 10, **extra.get(g, {}),
**({'sun': (80, 2)} if h == 'dusk' else {})} for h in hours] for g in grounds]
sheet('A_ground_x_hour', rows, grounds, hours, 'Sheet A: ground type (rows) x hour (cols); rolling land, seed 8, ppm 24, off-frame shade 0.3', 2)
def s_landform_light():
lfs = ['flat', 'rolling', 'hummocks', 'bank', 'ledges']
suns = [(-60, 35), (0, 20), (60, 12), (85, 5)]
rows = [[{**BASE, 'ground': 'turf', 'hour': 'golden', 'landform': lf, 'sun': s, 'relief': 1.2} for s in suns] for lf in lfs]
sheet('B_landform_x_sun', rows, lfs, ['sun behind camera 35', 'sun left 20', 'sun left-behind 12', 'contre-jour 5'],
'Sheet B: landform (rows) x sun (cols); turf, golden palette, no off-frame shade -- masses from land and light alone', 2)
def s_distance():
ps = [(120, 35), (40, 20), (12, 12), (4, 8), (1.2, 5)]
gs = ['turf', 'earth', 'gravel', 'forest']
extra = {'forest': dict(shade=0.7, shade_scale=4, canopy=0.2, canopy_scale=0.7)}
rows = [[dict(landform='rolling', path=(g == 'turf'), ground=g, hour='golden', sun=(15, 14), ppm=p, pitch=pi,
cam_h=1.7 * (40 / p) ** 0.6, relief=0.8, shade=0.3, shade_scale=12, W=200, H=130, seed=8,
**extra.get(g, {})) for p, pi in ps] for g in gs]
sheet('C_distance', rows, gs, [f'{p} px/m, pitch {pi}' for p, pi in ps],
'Sheet C: distance ladder (px per metre at screen centre); golden, seed 8', 2)
def s_moisture():
ms = [0.0, 0.35, 0.7, 1.0]
gs = ['earth', 'turf', 'forest']
rows = [[dict(landform='rolling', ground=g, hour='noon', ppm=30, pitch=16, cam_h=1.7, relief=0.8, moisture=m,
path=(g == 'turf'), shade=0.25, shade_scale=8, W=200, H=130, seed=5) for m in ms] for g in gs]
sheet('D_moisture', rows, gs, [f'moisture {m}' for m in ms], 'Sheet D: moisture (cols); wet ground drops a step, puddles in hollows take the sky', 2)
def s_seeds():
rows = [[{**BASE, 'ground': 'turf', 'hour': 'golden', 'seed': sd, 'shade': 0.35, 'shade_scale': 12, 'path': True, 'sun': (15, 12)}
for sd in range(1, 6)]]
rows.append([{**r, 'hour': 'noon', 'sun': None} for r in rows[0]])
sheet('E_seeds', rows, ['golden', 'noon'], [f'seed {i}' for i in range(1, 6)], 'Sheet E: seeds; rolling turf with a path and off-frame shade', 2)
SHEETS = dict(A=s_ground_hour, B=s_landform_light, C=s_distance, D=s_moisture, E=s_seeds)
if __name__ == '__main__':
for n in (sys.argv[1:] or SHEETS):
SHEETS[n]()
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 sheets.py B 2>&1 | tail -3
# output:
wrote B_landform_x_sunRead: elements/ground/sheets/B_landform_x_sun.png
69. elements/ground/sheets/B_landform_x_sun.png
Bash: Stronger in-family form turningcd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace("NRESP = {'turf': 0.3, 'meadow': 0.2,","NRESP = {'turf': 0.6, 'meadow': 0.5,")
s=s.replace("v_lit = 3.0 + 3.0 * np.clip(lit_base + 0.5 * (r - 1), 0, 0.999)","v_lit = 3.0 + 3.0 * np.clip(lit_base + 1.0 * (r - 1), 0, 0.999)")
open('ground.py','w').write(s)
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 sheets.py B 2>&1 | tail -1
# output:
wrote B_landform_x_sunRead: elements/ground/sheets/B_landform_x_sun.png
70. elements/ground/sheets/B_landform_x_sun.png
Bash: Cluster gaps; render sheets A and Ccd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace("gap[sel] = ((ug < 0.11 * near) & lit)[sel]","gap[sel] = ((ug < 0.22 * near) & (tuft < -0.35) & lit)[sel] # gaps sit in the tufts' dark pockets")
open('ground.py','w').write(s)
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 sheets.py A C 2>&1 | tail -2
# output:
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
TypeError: dict() got multiple values for keyword argument 'shade'Read: elements/ground/sheets/A_ground_x_hour.png
71. elements/ground/sheets/A_ground_x_hour.png
Bash: Dusk range, crest tips, overcast gradientcd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace("Ls[:3] = Ls[:3] * (0.35 if mat in ('turf', 'meadow') else 0.5)","Ls[:3] = Ls[:3] * (0.5 if mat in ('turf', 'meadow') else 0.6)")
s=s.replace(" L = int(np.clip(round(bh * rng.uniform(0.3, 1.0)), 1, 12))"," L = int(np.clip(round(bh * rng.uniform(0.15, 0.6) ** 1.5 * 1.6), 1, 8))")
s=s.replace(" v = 1.3 + 3.2 * L['sky'] ** 1.2 + 0.9 * np.clip(np.log(buf['z'] / 4) / 4, 0, 1)\n",
" v = 0.9 + 3.0 * L['sky'] ** 1.2 + 1.6 * np.clip(np.log(buf['z'] / 4) / 4, 0, 1)\n")
open('ground.py','w').write(s)
EOF
grep -n "def s_distance" -A 6 sheets.py | head -8
# output:
49:def s_distance():
50- ps = [(120, 35), (40, 20), (12, 12), (4, 8), (1.2, 5)]
51- gs = ['turf', 'earth', 'gravel', 'forest']
52- extra = {'forest': dict(shade=0.7, shade_scale=4, canopy=0.2, canopy_scale=0.7)}
53- rows = [[dict(landform='rolling', path=(g == 'turf'), ground=g, hour='golden', sun=(15, 14), ppm=p, pitch=pi,
54- cam_h=1.7 * (40 / p) ** 0.6, relief=0.8, shade=0.3, shade_scale=12, W=200, H=130, seed=8,
55- **extra.get(g, {})) for p, pi in ps] for g in gs]Bash: Render distance sheet and debug earth ringscd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('sheets.py').read()
s=s.replace(""" cam_h=1.7 * (40 / p) ** 0.6, relief=0.8, shade=0.3, shade_scale=12, W=200, H=130, seed=8,
**extra.get(g, {})) for p, pi in ps] for g in gs]""",""" cam_h=1.7 * (40 / p) ** 0.6, relief=0.8, W=200, H=130, seed=8,
**{'shade': 0.3, 'shade_scale': 12, **extra.get(g, {})}) for p, pi in ps] for g in gs]""")
open('sheets.py','w').write(s)
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 sheets.py C 2>&1 | tail -1; python3 -c "
import ground,lw
g=ground.paint_ground(200,130,landform='rolling',ground='earth',hour='morning',ppm=24,pitch=12,seed=8,shade=0.3,shade_scale=10)
lw.save(g.rgb[70:130,0:100],'../stages/dbg_earth_8x.png',8)"
# output:
wrote C_distanceRead: elements/ground/stages/dbg_earth_8x.png
72. elements/ground/stages/dbg_earth_8x.png
Bash: Headroom for marks; rerender sheetscd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace(""" off, thr, coh, gap, strk = marks(buf, mat, v, lit, rng, contrast, phi)""",""" # headroom: keep the mass off the ends of its family so marks can go both ways (a mass
# pinned at the top step goes flat -- the flat-tone failure)
v = np.where(lit, 3.15 + (v - 3.0) * 0.78, 0.25 + v * 0.8)
off, thr, coh, gap, strk = marks(buf, mat, v, lit, rng, contrast, phi)""")
open('ground.py','w').write(s)
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 sheets.py C A 2>&1 | tail -1
# output:
wrote A_ground_x_hourRead: elements/ground/sheets/C_distance.png
73. elements/ground/sheets/C_distance.png
Bash: Show stones_fieldcd ~/work/pixelart-studies/elements/ground/src && grep -n "def stones_field" -A 32 ground.py | head -36 # output: 365:def stones_field(buf, size_m, rng, density=0.7): 366- """Gravel / litter: small blobs with a light top row and a dark lower rim (offsets).""" 367- Hh, W = buf['z'].shape 368- out = np.zeros((Hh, W)) 369- ppm = buf['ppm'] 370- n = int(Hh * W * density / 6) 371- ys = rng.integers(0, Hh, n); xs = rng.integers(0, W, n) 372- order = np.argsort(ys) 373- for i in order: 374- y, x = ys[i], xs[i] 375- s = size_m * ppm[y, x] * rng.uniform(0.6, 1.5) 376- if s < 1.6: 377- out[y, x] += rng.choice([-1, 1]) * 0.8 378- continue 379- a = max(1, int(round(s / 2))); b = max(1, int(round(s / 3.2))) 380- y0, y1 = max(0, y - b), min(Hh, y + b + 1) 381- x0, x1 = max(0, x - a), min(W, x + a + 1) 382- yy, xx = np.mgrid[y0:y1, x0:x1] 383- q = ((xx - x) / (a + 0.5)) ** 2 + ((yy - y) / (b + 0.5)) ** 2 384- m = q < 1 385- tone = rng.normal(0.3, 0.5) 386- top = (yy - y) < -b * 0.2 387- bot = (yy - y) > b * 0.4 388- blk = np.where(top, tone + 0.8, np.where(bot, tone - 0.5, tone)) 389- sub = out[y0:y1, x0:x1] 390- sub[m] = blk[m] 391- # pocket under the stone 392- yb = min(Hh - 1, y + b + 1) 393- out[yb, x0:x1][np.abs(np.arange(x0, x1) - x) < a * 0.8] = -1.3 394- return out 395- 396-def lic(noise, phi, length, steps=None): 397- """Line-integral convolution: smear noise along the iso-lines of phi (screen space).
Bash: Only significant crests get separation/fringecd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
a="jump[:-1] = (z[:-1] > z[1:] * 1.25) & ~buf['sky'][:-1]"
assert a in s
s=s.replace(a,"jump[:-1] = (z[:-1] > z[1:] * 1.25) & (z[:-1] - z[1:] > 2.0) & ~buf['sky'][:-1]")
b="crest[1:] = (z[:-1] > z[1:] * 1.25) | buf['sky'][:-1]"
assert b in s
s=s.replace(b,"crest[1:] = ((z[:-1] > z[1:] * 1.25) & (z[:-1] - z[1:] > 2.0)) | buf['sky'][:-1]")
open('ground.py','w').write(s)
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 sheets.py C A D 2>&1 | tail -1
# output:
wrote D_moistureRead: elements/ground/sheets/D_moisture.png
74. elements/ground/sheets/D_moisture.png
Bash: Locate puddle codecd ~/work/pixelart-studies/elements/ground/src && grep -n "puddle" ground.py; grep -n "if mat == 'water':" -A 2 ground.py # output: 334: # puddles: earth in hollows when wet 335: puddle = (mat == MI['earth']) & (wet > 0.72) & (L['hollow'] > 0.25) 336: mat[puddle] = MI['water'] 534: if mat == 'water': 535- c = haze * (0.8 + 0.05 * i) 536- elif i < 3:
Bash: Lobed puddles with wet rims; sky ramp watercd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace(""" puddle = (mat == MI['earth']) & (wet > 0.72) & (L['hollow'] > 0.25)
mat[puddle] = MI['water']""",""" # puddles: small, lobed pools where bare earth dips (fine-scale hollows + world noise),
# growing with moisture; never a band across the valley
X, Z = buf['x'], buf['z']
hf = land.h(X, Z)
ringf = np.mean([land.h(X + dx, Z + dz) for dx, dz in [(0.7, 0), (-0.7, 0), (0, 0.7), (0, -0.7)]], 0)
rr = np.random.default_rng(seed + 77)
wn = sum(np.sin((np.cos(a) * X + np.sin(a) * Z) * 2 * np.pi / wl + p)
for a, wl, p in zip(rr.uniform(0, np.pi, 6), rr.uniform(0.6, 2.5, 6), rr.uniform(0, 6.3, 6))) / 2.5
pscore = 30 * (ringf - hf) + 0.5 * wn + 1.2 * np.clip(L['hollow'], 0, 1) + 1.6 * moisture
puddle = (mat == MI['earth']) & (pscore > 2.4)
mat[puddle] = MI['water']""")
s=s.replace(""" if land.water is not None:""",""" # the wet rim: mud darkens around every pool
rim = ndi.binary_dilation(puddle, iterations=2) & ~puddle & (mat == MI['earth'])
wet = np.where(rim, 1.0, wet)
if land.water is not None:""")
s=s.replace(""" if mat == 'water':
c = haze * (0.8 + 0.05 * i)""",""" if mat == 'water':
# a puddle is the sky, lighter than the ground around it, cooled; steps are the
# sky's own ramp from zenith (low) to horizon (high)
c = (skyc * (1 - i / 6) + haze * (i / 6)) * 1.0""")
s=s.replace(" 'water': (30, 40, 50, 60, 70, 80)}"," 'water': (38, 46, 54, 62, 70, 78)}")
s=s.replace("'moss': (0.18, 0.30, 0.05)","'moss': (0.20, 0.26, 0.07)")
open('ground.py','w').write(s)
EOF
grep -n "def materials" ground.py; grep -n "mat, wet = materials" ground.py
# output:
318:def materials(land, buf, ground, moisture, L, seed=0):
641: mat, wet = materials(land, buf, ground, moisture, L, seed)Bash: Puddle sky-reflection values; rerender moisture sheetcd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
a=""" step = np.where(lit, np.maximum(step, 3), np.minimum(step, 2)) # families never touch"""
assert a in s
s=s.replace(a,a+"""
wat = mat == MI['water']
if wat.any():
# puddles reflect the sky: nearer water sees higher (darker) sky; 1-px ripple rows
vw = 1.8 + 3.0 * np.clip(np.log(buf['z'] / 2) / 3.5, 0, 1)
rows = (np.arange(H)[:, None] % 3 == 0) & (rng.random((H, W)) < 0.35)
vw = vw - 0.6 * rows
tw = dither.tile(dither.B2, H, W)
sw = np.clip(np.floor(vw) + ((vw - np.floor(vw)) > tw), 0, 5).astype(int)
step[wat] = sw[wat]""")
open('ground.py','w').write(s)
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 sheets.py D 2>&1 | tail -1
# output:
wrote D_moistureRead: elements/ground/sheets/D_moisture.png
75. elements/ground/sheets/D_moisture.png
Bash: Far-bank dark band in puddles; zoom checkcd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace("puddle = (mat == MI['earth']) & (pscore > 2.4)","puddle = (mat == MI['earth']) & (pscore > 2.4) & (moisture > 0.25)")
s=s.replace(""" step[wat] = sw[wat]""",""" # the far bank reflects as a dark band under the pool's top edge (the channel lesson:
# far edge dark, near edge light), with stepped bites
k = np.clip(np.round(0.06 * buf['ppm']), 1, 4).astype(int)
band_ = np.zeros((H, W), bool)
for d in range(1, 5):
above = np.zeros((H, W), bool); above[d:] = ~wat[:-d]
band_ |= above & (d <= k + (rng.random((H, W)) < 0.3))
sw = np.where(band_, np.maximum(sw - 3, 0), sw)
step[wat] = sw[wat]""")
open('ground.py','w').write(s)
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 -c "
import ground,lw
g=ground.paint_ground(200,130,landform='rolling',ground='earth',hour='noon',ppm=30,pitch=16,seed=5,moisture=0.7,shade=0.25,shade_scale=8,relief=0.8)
lw.save(g.rgb[40:130,0:140],'../stages/dbg_puddle_6x.png',6)"
# output:
(Bash completed with no output)Read: elements/ground/stages/dbg_puddle_6x.png
76. elements/ground/stages/dbg_puddle_6x.png
Bash: Re-render V16PM and V13 comparisonscd ~/work/pixelart-studies/elements/ground/src && OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 s1_grid.py && OPENBLAS_NUM_THREADS=1 nice -n 10 python3 s2_v13.py # output: (Bash completed with no output)
Read: elements/ground/stages/s1_grid_3x.pngRead: elements/ground/stages/s2_v13_2x.png
77. elements/ground/stages/s2_v13_2x.png
78. elements/ground/stages/s1_grid_3x.png
Bash: Darker far haze at duskcd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace(" hz = _set_L(haze, max(Ls[2] * 1.5, 8))"," hz = _set_L(haze, max(Ls[2] * 1.5, 8) if hour != 'dusk' else Ls[2] * 1.3)")
open('ground.py','w').write(s)
EOF
echo
# output:
(Bash completed with no output)Bash: Hero blades for near grasscd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace("def paint_ground(","""def hero_blades(step, mat, band, buf, lit, rng, density=0.05, min_px=7):
\"\"\"The nearest grass carries a few long single blades drawn as strokes (V13's foreground,
V16's right bank): in light a dark stem with a lit pixel beside it (the paired stroke); in
shadow an olive stroke on the dark (negative painting). Rasterized one row at a time so the
lean accumulates into clean 1:1 / 2:1 runs.\"\"\"
H, W = step.shape
ppm = buf['ppm']
ys, xs = np.nonzero(np.isin(mat, [MI['turf'], MI['meadow']]) & (0.2 * ppm >= min_px))
if len(ys) == 0:
return step, mat, band
n = int(len(ys) * density / max(1.0, 0.2 * ppm[ys].mean()))
pick = rng.choice(len(ys), size=min(n, len(ys)), replace=False)
for i in pick:
y, x = ys[i], xs[i]
bh = 0.2 * ppm[y, x] * (2.2 if mat[y, x] == MI['meadow'] else 1.0)
L = int(bh * rng.uniform(1.0, 2.3))
lean = rng.normal(0, 0.35); bend = rng.choice([-1, 1]) * rng.uniform(0.2, 1.2)
m0, b0, lt = mat[y, x], band[y, x], lit[y, x]
fx = float(x)
for k in range(L):
t = k / max(L, 1)
fx += lean + bend * t * t * 1.5
yy, xx = y - k, int(round(fx))
if yy < 0 or xx < 0 or xx >= W: break
if lt:
step[yy, xx] = 1 if t < 0.85 else 2
if xx + 1 < W and rng.random() < 0.7: step[yy, xx + 1] = 5
else:
step[yy, xx] = 2 if t < 0.9 else 1
mat[yy, xx] = m0; band[yy, xx] = b0
return step, mat, band
def paint_ground(""")
s=s.replace(" step, mat, band = crest_fringe(step, mat, band, buf, lit, rng)\n",
" step, mat, band = crest_fringe(step, mat, band, buf, lit, rng)\n if blades > 0:\n step, mat, band = hero_blades(step, mat, band, buf, lit, rng, density=blades)\n")
s=s.replace(" shadow_mask=None):"," shadow_mask=None, blades=0.05):")
open('ground.py','w').write(s)
EOF
OPENBLAS_NUM_THREADS=1 nice -n 10 python3 -c "
import ground,lw,numpy as np
a=ground.paint_ground(200,130,landform='bank',hour='golden',ppm=60,pitch=14,seed=5,sun=(-20,12),shade=0.3,shade_scale=6).rgb
b=ground.paint_ground(200,130,landform='bank',hour='noon',ppm=60,pitch=14,seed=5,shade=0.4,shade_scale=6).rgb
lw.save(np.concatenate([a,b],1),'../stages/s7_blades_3x.png',3)"
# output:
(Bash completed with no output)Read: elements/ground/stages/s7_blades_3x.png
79. elements/ground/stages/s7_blades_3x.png
Bash: Scenes sheetcd ~/work/pixelart-studies/elements/ground/src && sed -i '' 's/shadow_mask=None, blades=0.05):/shadow_mask=None, blades=0.035):/' ground.py && cat >> sheets.py <<'EOF'
def s_scenes():
sc = [
('meadow bank, golden, sun left-behind', dict(ground='turf', landform='bank', hour='golden', sun=(25, 10), ppm=30, pitch=10, cam_h=1.7, shade=0.35, shade_scale=14, seed=5)),
('rolling turf + path, noon, cloud/forest shade', dict(ground='turf', landform='rolling', path=True, hour='noon', ppm=22, pitch=11, cam_h=2.0, shade=0.4, shade_scale=16, seed=3)),
('forest floor, morning pools of light', dict(ground='forest', landform='rolling', hour='morning', ppm=40, pitch=18, cam_h=1.7, shade=0.72, shade_scale=4, canopy=0.2, canopy_scale=0.7, moisture=0.35, relief=0.5, seed=21)),
('wet earth track, overcast', dict(ground='turf', landform='rolling', path=True, hour='overcast', ppm=36, pitch=16, cam_h=1.7, moisture=0.8, relief=0.7, seed=5)),
('gravel bar, golden, contre-jour', dict(ground='gravel', landform='flat', hour='golden', sun=(80, 8), ppm=30, pitch=14, cam_h=2.0, shade=0.25, shade_scale=10, seed=9)),
('terraces at dusk (after V13)', dict(ground='turf', landform='rolling', path=True, hour='dusk', sun=(80, -2), ppm=10, pitch=7, cam_h=6.0, relief=1.3, seed=7)),
]
rows = [[{**kw, 'W': 320, 'H': 200} for _, kw in sc[i:i + 3]] for i in (0, 3)]
sheet('F_scenes', rows, ['', ''], [s[0] for s in sc[:3]], 'Sheet F: designed scenes (320x200 native, 2x). Row 2: ' + ' | '.join(s[0] for s in sc[3:]), 2)
SHEETS['F'] = s_scenes
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 sheets.py F 2>&1 | tail -1
# output:
KeyError: 'F'Read: elements/ground/sheets/F_scenes.pngBash: Fix sheets main ordering; render scenescd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('sheets.py').read()
main="""if __name__ == '__main__':
for n in (sys.argv[1:] or SHEETS):
SHEETS[n]()
"""
s=s.replace(main,"")+"\n"+main
open('sheets.py','w').write(s)
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 sheets.py F 2>&1 | tail -1
# output:
wrote F_scenesRead: elements/ground/sheets/F_scenes.png
80. elements/ground/sheets/F_scenes.png
Bash: Debug ring artefactscd ~/work/pixelart-studies/elements/ground/src && OPENBLAS_NUM_THREADS=1 nice -n 10 python3 -c " import ground,lw,numpy as np g=ground.paint_ground(320,200,ground='gravel', landform='flat', hour='golden', sun=(80, 8), ppm=30, pitch=14, cam_h=2.0, shade=0.25, shade_scale=10, seed=9) f=lambda a:(np.stack([np.clip(a,0,1)*255]*3,-1)).astype(np.uint8) lw.save(np.concatenate([f(g.lit*1.0),f(g.v/6),f(g.L['terrain_vis']),f(g.sepm)],1),'../stages/dbg_rings.png',1) " # output: (Bash completed with no output)
Read: elements/ground/stages/dbg_rings.png
81. elements/ground/stages/dbg_rings.png
Bash: Absorb islands with their penumbracd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
old=s[s.index("def design_masses"):s.index("def light(")]
new='''def design_masses(f, min_frac=0.02):
"""Designed masses: no shadow island and no lit hole smaller than min_frac of the frame.
Ferrari's shadow shapes are a few big ones; small isolated ovals read as holes in the ground.
An absorbed island takes its soft penumbra with it (else a ring of edge is left behind)."""
f = f.copy()
a = f.size * min_frac
for val in (True, False):
m = f < 0.5
lab, n = ndi.label(m == val)
if n == 0: continue
sizes = ndi.sum(np.ones_like(f), lab, range(1, n + 1))
small = np.isin(lab, np.nonzero(sizes < a)[0] + 1)
if not small.any(): continue
grow = ndi.binary_dilation(small, iterations=4)
if val: # shadow island -> full light, including its soft rim
f[grow & (f < 0.999)] = np.maximum(f[grow & (f < 0.999)], 1.0)
else: # small lit hole -> full shadow
f[grow & (f > 0.001)] = 0.0
return f
'''
s=s.replace(old,new)
open('ground.py','w').write(s)
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 sheets.py F 2>&1 | tail -1
# output:
wrote F_scenesRead: elements/ground/sheets/F_scenes.png
82. elements/ground/sheets/F_scenes.png
Bash: Composition plan bias for shade massescd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace("def canopy_field(x, z, cover, scale, sunv, seed):","def canopy_field(x, z, cover, scale, sunv, seed, bias=None):")
s=s.replace(""" # threshold so that 'cover' of the ground is in shade; soft band gives dither in the edge""",""" if bias is not None:
acc = acc + bias * amp * 2.5 # the composition plan: where shadow is wanted
# threshold so that 'cover' of the ground is in shade; soft band gives dither in the edge""")
s=s.replace("def light(land, buf, az, el, canopy=0.0, canopy_scale=3.0, seed=0, shade=0.0, shade_scale=12.0):",
"def light(land, buf, az, el, canopy=0.0, canopy_scale=3.0, seed=0, shade=0.0, shade_scale=12.0, bias=None):")
s=s.replace(" sh = canopy_field(X, Z, shade, shade_scale, s, seed + 17)"," sh = canopy_field(X, Z, shade, shade_scale, s, seed + 17, bias)")
s=s.replace(" L = light(land, buf, az, el, canopy, canopy_scale, seed, shade, shade_scale)\n",""" L = light(land, buf, az, el, canopy, canopy_scale, seed, shade, shade_scale, plan_bias(plan, H, W, buf))
""")
s=s.replace("def paint_ground(","""def plan_bias(plan, H, W, buf):
\"\"\"Screen-space composition of the big shadow masses (Ferrari designs them; V16: the right
bank in shadow, the left lit, a pool of light on the islet; V09: a lit path in a dark floor).
None | 'side-left' | 'side-right' | 'foreground' | 'pool' | 'V' | 'far' \"\"\"
if plan is None:
return None
yy, xx = np.mgrid[0:H, 0:W]
u = xx / W - 0.5; t = yy / H
ground_top = np.argmax(~buf['sky'], axis=0).mean() / H
tg = np.clip((t - ground_top) / max(1 - ground_top, 0.1), 0, 1) # 0 at horizon, 1 at bottom
b = {'side-left': -u * 2, 'side-right': u * 2, 'foreground': (tg - 0.6) * 2.5,
'far': (0.35 - tg) * 2.5,
'pool': (np.hypot(u * 1.6, (tg - 0.5) * 2.2) - 0.45) * 2.5,
'V': np.abs(u) * 2 + (tg - 0.8) * 1.5 - 0.3}[plan]
return b
def paint_ground(""")
s=s.replace(" shadow_mask=None, blades=0.035):"," shadow_mask=None, blades=0.035, plan=None):")
open('ground.py','w').write(s)
EOF
python3 - <<'EOF'
p='~/work/pixelart-studies/elements/ground/src/sheets.py'
s=open(p).read()
s=s.replace("sun=(25, 10), ppm=30, pitch=10, cam_h=1.7, shade=0.35, shade_scale=14, seed=5)","sun=(25, 10), ppm=30, pitch=10, cam_h=1.7, shade=0.4, shade_scale=14, seed=5, plan='side-right')")
s=s.replace("shade=0.72, shade_scale=4, canopy=0.2, canopy_scale=0.7, moisture=0.35, relief=0.5, seed=21)","shade=0.72, shade_scale=4, canopy=0.2, canopy_scale=0.7, moisture=0.35, relief=0.5, seed=21, plan='pool')")
s=s.replace("sun=(80, 8), ppm=30, pitch=14, cam_h=2.0, shade=0.25, shade_scale=10, seed=9)","sun=(80, 8), ppm=30, pitch=14, cam_h=2.0, shade=0.4, shade_scale=10, seed=9, plan='foreground')")
open(p,'w').write(s)
EOF
cd ~/work/pixelart-studies/elements/ground/src && OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 sheets.py F 2>&1 | tail -1
# output:
wrote F_scenesRead: elements/ground/sheets/F_scenes.png
83. elements/ground/sheets/F_scenes.png
Bash: Crop Ferrari forest floor passagescd ~/work/pixelart-studies/elements/ground/src && python3 crop.py V30 300 250 560 340 5 && python3 crop.py V09 0 380 200 470 5 # output: ../ref/crop_V30_300_250_5.png ../ref/crop_V09_0_380_5.png
Read: elements/ground/ref/crop_V30_300_250_5.pngRead: elements/ground/ref/crop_V09_0_380_5.png
84. elements/ground/ref/crop_V09_0_380_5.png
85. elements/ground/ref/crop_V30_300_250_5.png
Bash: Show forest markscd ~/work/pixelart-studies/elements/ground/src && grep -n "sel = mat == MI\['forest'\]" -A 7 ground.py # output: 483: sel = mat == MI['forest'] 484- if sel.any(): 485- st = stones_field(buf, 0.05, rng, density=0.8) # leaf litter clumps 486- rl = np.clip(0.08 * ppm, 1, 6) 487- o, t = runs_field(Hh, W, rl, rng, True); o = o * 0.4 488- off[sel] = (np.clip(0.05 * ppm / 1.5, 0.2, 1) * st * np.where(lit, 1.0, 0.55) + o)[sel] 489- return off * contrast, thr, coh, gap, strk 490-
Bash: Leaf-lozenge forest floor markscd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace(""" st = stones_field(buf, 0.05, rng, density=0.8) # leaf litter clumps
rl = np.clip(0.08 * ppm, 1, 6)
o, t = runs_field(Hh, W, rl, rng, True); o = o * 0.4
off[sel] = (np.clip(0.05 * ppm / 1.5, 0.2, 1) * st * np.where(lit, 1.0, 0.55) + o)[sel]""",""" # ground-cover leaves (V30): flat horizontal lozenges, lit top row, dark underside,
# overlapping; bold in light, nearly gone in shade
st = leaves_field(buf, 0.09, rng, density=1.2)
a = np.clip(0.09 * ppm / 3, 0.3, 1.2)
off[sel] = (a * st * np.where(lit, 1.0, 0.45))[sel]""")
s=s.replace("def lic(noise, phi, length, steps=None):","""def leaves_field(buf, size_m, rng, density=1.0):
Hh, W = buf['z'].shape
out = np.zeros((Hh, W)); ppm = buf['ppm']
n = int(Hh * W * density / 8)
ys = rng.integers(0, Hh, n); xs = rng.integers(0, W, n)
for i in np.argsort(ys): # far to near: nearer leaves overlap
y, x = ys[i], xs[i]
s = size_m * ppm[y, x] * rng.uniform(0.6, 1.4)
if s < 1.5:
out[y, x] = rng.choice([-1.0, 0.8]); continue
a = max(1, int(round(s / 2))); b = max(1, int(round(s / 7)))
tilt = rng.normal(0, 0.15)
y0, y1 = max(0, y - b - 1), min(Hh, y + b + 2); x0, x1 = max(0, x - a - 1), min(W, x + a + 2)
yy, xx = np.mgrid[y0:y1, x0:x1]
yc = y + tilt * (xx - x)
q = ((xx - x) / (a + 0.5)) ** 2 + ((yy - yc) / (b + 0.5)) ** 2
m = q < 1
tone = rng.normal(0.2, 0.4)
blk = np.where(yy - yc < -0.3 * b, tone + 0.9, np.where(yy - yc > 0.3 * b, tone - 0.6, tone + 0.2))
out[y0:y1, x0:x1][m] = blk[m]
yb = int(round(y + b + 1))
if yb < Hh:
xs_ = np.arange(max(0, x - a + 1), min(W, x + a))
out[yb, xs_] = np.minimum(out[yb, xs_], -1.2) # the dark gap under each leaf
return out
def lic(noise, phi, length, steps=None):""")
s=s.replace("'forest': (0.26, 0.17, 0.09)","'forest': (0.24, 0.22, 0.08)")
s=s.replace("ALBEDO_LIT = {'turf': (0.20, 0.30, 0.07),","ALBEDO_LIT = {'forest': (0.16, 0.30, 0.05), 'turf': (0.20, 0.30, 0.07),")
open('ground.py','w').write(s)
EOF
python3 - <<'EOF'
p='~/work/pixelart-studies/elements/ground/src/sheets.py'
s=open(p).read()
s=s.replace("moisture=0.35, relief=0.5, seed=21, plan='pool')","moisture=0.35, relief=0.5, seed=21, plan='side-left')")
open(p,'w').write(s)
EOF
cd ~/work/pixelart-studies/elements/ground/src && OPENBLAS_NUM_THREADS=1 nice -n 10 python3 -c "
import ground,lw,numpy as np
a=ground.paint_ground(240,150,ground='forest', landform='rolling', hour='morning', ppm=40, pitch=18, cam_h=1.7, shade=0.65, shade_scale=4, canopy=0.2, canopy_scale=0.7, moisture=0.35, relief=0.5, seed=21, plan='side-left').rgb
lw.save(a,'../stages/s8_forest_3x.png',3)"
# output:
(Bash completed with no output)Read: elements/ground/stages/s8_forest_3x.png
86. elements/ground/stages/s8_forest_3x.png
Bash: Greener forest floor; rerender sheetscd ~/work/pixelart-studies/elements/ground/src && sed -i '' "s/'forest': (0.24, 0.22, 0.08)/'forest': (0.19, 0.25, 0.06)/; s/off\[sel\] = (a \* st \* np.where(lit, 1.0, 0.45))\[sel\]/off[sel] = (a * st * np.where(lit, 1.0, 0.7))[sel]/" ground.py && OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 sheets.py F A 2>&1 | tail -1 # output: wrote A_ground_x_hour
Read: elements/ground/sheets/F_scenes.png
87. elements/ground/sheets/F_scenes.png
Bash: Build Ferrari side-by-side comparisoncd ~/work/pixelart-studies/elements/ground/src && cat > compare.py <<'EOF'
"""Honest side-by-sides at 4x: Ferrari crop | mine, same native size."""
import ground, lw, numpy as np
from PIL import Image, ImageDraw
def crop(name, box): return lw.rgb(lw.load(name))[box[1]:box[3], box[0]:box[2]]
pairs = []
# 1 lit near bank (V16PM) vs my golden bank
g = ground.paint_ground(320, 200, ground='turf', landform='bank', hour='golden', sun=(25, 10), ppm=30, pitch=10, cam_h=1.7, shade=0.4, shade_scale=14, seed=5, plan='side-right')
pairs.append(('V16PM near lit bank', crop('V16PM', (0, 330, 150, 430)), 'mine: golden bank', g.rgb[100:200, 20:170]))
# 2 mid-distance masses: V16 far hummocks vs my rolling noon
g2 = ground.paint_ground(320, 200, ground='turf', landform='rolling', path=True, hour='noon', ppm=22, pitch=11, cam_h=2.0, shade=0.4, shade_scale=16, seed=3)
pairs.append(('V16 hummocks (mid)', crop('V16', (430, 218, 580, 318)), 'mine: rolling noon, mid', g2.rgb[40:140, 100:250]))
# 3 dusk terraces
g3 = ground.paint_ground(320, 200, ground='turf', landform='rolling', path=True, hour='dusk', sun=(80, -2), ppm=10, pitch=7, cam_h=6.0, relief=1.3, seed=7)
pairs.append(('V13 terraces + path (dusk)', crop('V13', (150, 260, 300, 360)), 'mine: dusk terraces', g3.rgb[60:160, 120:270]))
# 4 forest pool
g4 = ground.paint_ground(320, 200, ground='forest', landform='rolling', hour='morning', ppm=40, pitch=18, cam_h=1.7, shade=0.72, shade_scale=4, canopy=0.2, canopy_scale=0.7, moisture=0.35, relief=0.5, seed=21, plan='side-left')
pairs.append(('V09 lit moss path', crop('V09', (0, 380, 150, 480)), 'mine: forest pool', g4.rgb[100:200, 170:320]))
k = 4
rows = []
for a, A, b, B in pairs:
pad = np.full((A.shape[0], 3, 3), 20, np.uint8)
rows.append(np.concatenate([A, pad, B], 1))
rows.append(np.full((3, rows[-1].shape[1], 3), 20, np.uint8))
img = Image.fromarray(lw.up(np.concatenate(rows, 0), k))
d = ImageDraw.Draw(img)
y = 0
for a, A, b, B in pairs:
d.text((6, y + 4), a, fill=(255, 255, 0)); d.text((A.shape[1] * k + 18, y + 4), b, fill=(255, 255, 0))
y += (A.shape[0] + 3) * k
img.save('../stages/compare_ferrari_4x.png')
EOF
OPENBLAS_NUM_THREADS=1 nice -n 10 python3 compare.py
# output:
(Bash completed with no output)Read: elements/ground/stages/compare_ferrari_4x.png
88. elements/ground/stages/compare_ferrari_4x.png
Bash: Blade tones, earlier dither handoff, dusk quietingcd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace(""" if lt:
step[yy, xx] = 1 if t < 0.85 else 2
if xx + 1 < W and rng.random() < 0.7: step[yy, xx + 1] = 5""",""" if lt:
step[yy, xx] = 3 if t < 0.8 else 4 # a mid-tone stem, not a black crack
if xx + 1 < W and rng.random() < 0.5: step[yy, xx + 1] = 5""")
s=s.replace("thr[sel] = 0.5; coh[sel] = np.clip((bh - 1.0) / 2.5, 0, 1)[sel]","thr[sel] = 0.5; coh[sel] = np.clip((bh - 3.0) / 2.5, 0, 1)[sel] # mid distance: checker, not strands (V16)")
s=s.replace("'dusk': (75, 4, (0.95, 0.52, 0.40), (0.22, 0.20, 0.55), (0.55, 0.40, 0.75))","'dusk': (75, 4, (0.95, 0.52, 0.40), (0.16, 0.20, 0.55), (0.45, 0.38, 0.75))")
s=s.replace(" off, thr, coh, gap, strk = marks(buf, mat, v, lit, rng, contrast, phi)\n",""" off, thr, coh, gap, strk = marks(buf, mat, v, lit, rng, contrast, phi)
if hour == 'dusk': # low light: texture sinks, masses carry the picture (V13)
off = off * 0.45; strk &= rng.random(strk.shape) < 0.35
""")
open('ground.py','w').write(s)
EOF
OPENBLAS_NUM_THREADS=1 nice -n 10 python3 compare.py
# output:
[This command modified 1 file you've previously read: ../pixelart-studies/elements/ground/src/ground.py. Call Read before editing.]Read: elements/ground/stages/compare_ferrari_4x.png
89. elements/ground/stages/compare_ferrari_4x.png
Bash: Green-teal shadow albedo for green materialscd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace(""" an = alb / alb.max()""",""" ash = np.array(ALBEDO_SH.get(mat, alb))
an = ash / ash.max()""")
s=s.replace("ALBEDO_LIT = {","""# shadows of green things go green-teal, not olive-grey (V16 noon: 0,23,7 / 31,67,39 / 0,7,15)
ALBEDO_SH = {'turf': (0.10, 0.30, 0.13), 'meadow': (0.16, 0.28, 0.10), 'forest': (0.08, 0.26, 0.12),
'moss': (0.08, 0.28, 0.10)}
ALBEDO_LIT = {""")
open('ground.py','w').write(s)
EOF
OPENBLAS_NUM_THREADS=1 nice -n 10 python3 compare.py && python3 -c "
import ground
for h in ['noon','golden','dusk']: print(h, ground.ramp('turf',h)[:3].tolist())"
# output:
noon [[1, 14, 14], [6, 30, 23], [20, 60, 48]]
golden [[2, 13, 16], [11, 29, 27], [28, 58, 54]]
dusk [[1, 6, 16], [6, 17, 27], [14, 31, 45]]Read: elements/ground/stages/compare_ferrari_4x.png
90. elements/ground/stages/compare_ferrari_4x.png
Bash: Regenerate all sheets and comparisonscd ~/work/pixelart-studies/elements/ground/src && sed -i '' "s/ALBEDO_SH = {'turf': (0.10, 0.30, 0.13)/ALBEDO_SH = {'turf': (0.10, 0.30, 0.09)/" ground.py && (OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 sheets.py 2>&1 | tail -6; OPENBLAS_NUM_THREADS=1 nice -n 10 python3 compare.py; OPENBLAS_NUM_THREADS=1 nice -n 10 python3 s6_driftwood.py; OPENBLAS_NUM_THREADS=1 nice -n 10 python3 s0_islet.py) 2>&1 | tail -3
# output:
wrote D_moisture
wrote E_seeds
wrote F_scenesRead: elements/ground/sheets/A_ground_x_hour.pngRead: elements/ground/sheets/E_seeds.png
91. elements/ground/sheets/E_seeds.png
92. elements/ground/sheets/A_ground_x_hour.png
Bash: Absorb floating shadow islands; break form terminatorcd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace(""" sizes = ndi.sum(np.ones_like(f), lab, range(1, n + 1))
small = np.isin(lab, np.nonzero(sizes < a)[0] + 1)""",""" sizes = ndi.sum(np.ones_like(f), lab, range(1, n + 1))
edge = np.unique(np.concatenate([lab[0], lab[-1], lab[:, 0], lab[:, -1]]))
floating = ~np.isin(np.arange(1, n + 1), edge)
# a shadow floating free of the frame reads as a hole unless it is big
lim = np.where(floating & val, a * 3.5, a)
small = np.isin(lab, np.nonzero(sizes < lim)[0] + 1)""")
s=s.replace(" lit = (sv > 0.5) & (L['ndl'] > lit_floor * sy)\n"," lit = (sv > 0.5) & (L['ndl'] + en * sy * 1.5 > lit_floor * sy) # the form terminator is broken too\n")
open('ground.py','w').write(s)
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 sheets.py E 2>&1 | tail -1
# output:
wrote E_seedsRead: elements/ground/sheets/E_seeds.png
93. elements/ground/sheets/E_seeds.png
Bash: Debug straight vertical shadow edgecd ~/work/pixelart-studies/elements/ground/src && OPENBLAS_NUM_THREADS=1 nice -n 10 python3 -c "
import ground,numpy as np
g=ground.paint_ground(200,130,landform='rolling',ppm=24,pitch=12,cam_h=1.7,seed=1,ground='turf',hour='noon',shade=0.35,shade_scale=12,path=True)
c=slice(55,85)
print('sunvis',g.L['sunvis'][60,c].round(2)); print('terr',g.L['terrain_vis'][60,c].round(2)); print('ndl',g.L['ndl'][60,c].round(2)); print('z',g.buf['z'][60,c].round(1)); print('x', g.buf['x'][60,c].round(2))
"
# output:
sunvis [0. 0. 0. 0. 0. 0. 0. 0. 0.01 0.02 0.03 0.07 0.13 0.23
0.38 0.56 0.73 0.85 0.92 0.96 0.98 0.99 1. 1. 1. 1. 1. 1.
1. 1. ]
terr [1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1.
1. 1. 1. 1. 1. 1.]
ndl [0.91 0.91 0.91 0.91 0.91 0.91 0.91 0.91 0.91 0.91 0.91 0.91 0.91 0.91
0.91 0.91 0.91 0.91 0.91 0.91 0.91 0.91 0.91 0.91 0.91 0.91 0.91 0.91
0.91 0.91]
z [2. 2. 2. 2. 2. 2. 2. 2. 2. 2. 2. 2. 2. 2. 2. 2. 2. 2. 2. 2. 2. 2. 2. 2.
2. 2. 2. 2. 2. 2.]
x [-0.53 -0.52 -0.51 -0.5 -0.48 -0.47 -0.46 -0.45 -0.44 -0.42 -0.41 -0.4
-0.39 -0.38 -0.36 -0.35 -0.34 -0.33 -0.32 -0.3 -0.29 -0.28 -0.27 -0.26
-0.24 -0.23 -0.22 -0.21 -0.2 -0.19]Bash: Camera height relative to local groundcd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace(""" zs = z_near * (z_far / z_near) ** (np.arange(steps) / (steps - 1))""",""" zs = z_near * (z_far / z_near) ** (np.arange(steps) / (steps - 1))
# the camera stands cam_h above the ground under it (and above the next few metres ahead)
zz = np.linspace(0, 4, 9)
base = float(max(land.h(np.zeros(1), np.full(1, q), micro=False)[0] for q in zz))""")
s=s.replace(""" h = land.h(x, np.full_like(x, z))
y = h - cam_h""",""" h = land.h(x, np.full_like(x, z))
y = h - cam_h - base""")
s=s.replace(""" buf = dict(z=Zs, x=X, sky=sky, F=F, pitch=p, cam_h=cam_h)""",""" buf = dict(z=Zs, x=X, sky=sky, F=F, pitch=p, cam_h=cam_h, cam_y=base + cam_h)""")
open('ground.py','w').write(s)
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 sheets.py E 2>&1 | tail -1
# output:
wrote E_seedsRead: elements/ground/sheets/E_seeds.png
94. elements/ground/sheets/E_seeds.png
Bash: Regenerate all outputscd ~/work/pixelart-studies/elements/ground/src && (OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 sheets.py; OPENBLAS_NUM_THREADS=1 nice -n 10 python3 compare.py; OPENBLAS_NUM_THREADS=1 nice -n 10 python3 s6_driftwood.py; OPENBLAS_NUM_THREADS=1 nice -n 10 python3 s2_v13.py; OPENBLAS_NUM_THREADS=1 nice -n 10 python3 s1_grid.py) 2>&1 | tail -3; ls ../sheets ../stages # output: wrote D_moisture wrote E_seeds wrote F_scenes ../sheets: A_ground_x_hour.png B_landform_x_sun.png C_distance.png D_moisture.png E_seeds.png F_scenes.png integration_driftwood_3x.png ../stages: compare_ferrari_4x.png dbg_earth_8x.png dbg_puddle_6x.png dbg_rings.png palettes_16x.png quick_mats_3x.png s0a_islet_6x.png s0a_islet_fields_4x.png s0a_jitter_6x.png s1_grid_3x.png s1_near_8x.png s1_v16pm_3x.png s1_v16pm_8x.png s2_v13_2x.png s2_v13_2x_stretch.png s2_v13_mid_6x.png s3_near_8x.png s3_path_3x.png s4_mats_3x.png s5_distance_2x.png s7_blades_3x.png s8_forest_3x.png try1_3x.png try2_3x.png
Read: elements/ground/sheets/F_scenes.pngRead: elements/ground/sheets/C_distance.png
95. elements/ground/sheets/F_scenes.png
96. elements/ground/sheets/C_distance.png
Read: elements/ground/sheets/integration_driftwood_3x.png
97. elements/ground/sheets/integration_driftwood_3x.png
Bash: Remove debug images and scratch scriptscd ~/work/pixelart-studies/elements/ground && rm -f stages/dbg_* stages/try1_3x.png stages/try2_3x.png src/try1.py src/try2.py src/s0_islet_j.py && rm -rf src/__pycache__ && ls stages src # output: src: analyze.py compare.py crop.py dither.py ground.py lw.py quick.py s0_islet.py s1_grid.py s1_v16.py s2_v13.py s3_near.py s3_path.py s4_mats.py s5_dist.py s6_driftwood.py sheets.py stages: compare_ferrari_4x.png palettes_16x.png quick_mats_3x.png s0a_islet_6x.png s0a_islet_fields_4x.png s0a_jitter_6x.png s1_grid_3x.png s1_near_8x.png s1_v16pm_3x.png s1_v16pm_8x.png s2_v13_2x.png s2_v13_2x_stretch.png s2_v13_mid_6x.png s3_near_8x.png s3_path_3x.png s4_mats_3x.png s5_distance_2x.png s7_blades_3x.png s8_forest_3x.png
Write: elements/ground/LESSONS.md# The ground, after Mark Ferrari What I was asked to fix: across the studio the ground is texture at a single scale everywhere, with no big value masses. What I built is a ground painter where **the masses come first, from landform and light, and texture lives inside them**. It works as a mass designer. As a pixel surface it is **not yet** Ferrari-grade at 8×, and I say where below. Everything here comes from `src/`. Stage images are in `stages/`, labelled sheets in `sheets/`, and Ferrari reference crops in `ref/`.--- ## Stage 0: what Ferrari's ground is made of (read from his index maps) I loaded his scenes as index maps (`src/lw.py`) and made squint maps (`src/analyze.py` → `ref/mass_*.png`), random-colour index maps (`ref/idxmap_*.png`) and 4–6× crops (`ref/crop_*.png`). What I found: 1. **Every ground passage is 2–4 big shapes before it is any texture.** In V16 there are four: the lit islet, a shadowed right bank, a lit left bank and dark far hummocks. In V13 the land is 5–6 terraces, each one a single dark value with a slightly lighter crest and a lighter path cutting across. In V09 the moss path is one lit ribbon in a black-green floor. Squinted, each of these reads as a flat poster shape. 2. **The shapes come from light, not from local colour.** The right bank of V16 is shadowed by forest outside the frame. Ferrari uses cast shadow from off-frame objects as a *compositional* tool (the analysis student found this too). 3. **Shadow masses are nearly black and hold very few values.** Measured in V16 (`L*`): the far hummocks are 2 / 6–9 / 25, the right bank 0 / 2 / 15–18, and the near left bank 3 / 10 / 15. In V13 the whole dusk ground lives between L\* 0 and 12. The light family is where the colours are. 4. **Inside a mass the turn is carried by an ordered 2×2 dither between adjacent steps.** On the V16 islet the checkered transition zone is textbook DPaint gradient fill: 50% becomes a clean checker, with a little jitter. I reproduced it (`stages/s0a_islet_6x.png`). Left is his islet. Middle is his value field put through my quantizer, which is indistinguishable at 6× once jitter is about 0.35 (`s0a_jitter_6x.png`). Right is my own radial field with his silhouette, which is close but has a rounder falloff than his flat-topped lozenge. 5. **Grain grows with nearness** (the master-copy student measured this, and I confirm it): 1-px checker stipple far off, then 1-px vertical strands 2–7 px tall, then drawn single blades in the nearest metre (the V13 foreground and the V16 right bank). 6. **Marks skip steps.** In lit grass the dark gap between blades is a near-black green, two or three steps below the base. In shade the blades are olive strokes on black (negative painting). This is where the contrast lives, inside the mass, and it isn't produced by the mass's own dither. ## Stage 1: masses from landform and light (the core of the technique) `ground.py`, layers 1–4. The land is an analytic height field built from a few big features (rolling swells biased to run across the view so they stack into terraces, hummocks, a bank, ledges, a path). It is rendered by a column march, so every pixel knows its world x, z, height, normal and pixels per metre. Light makes the masses: - **Terrain horizon shadow**, marched toward the sun. - **Off-frame shade** (`shade`, `shade_scale`). Soft unions of stretched crown shadows along the sun's azimuth, with a world-anchored ragged edge of octave sinusoids, so the shapes are lobed and notched like foliage shadows. - **`design_masses`**: no shadow island or lit hole smaller than about 3% of the frame, and floating shadows must be bigger still. This single step did more against "speckle" than anything else. A mass is a *designed* shape, and small ovals read as holes in the ground. - **`plan`** (`side-left | side-right | foreground | far | pool | V`) biases where shade falls, so the director can compose the masses as Ferrari does (V16: shaded right bank, lit left bank). The value plan uses **two families that never touch**: shadow steps 0–2, light steps 3–5. The families are clamped both before and after quantization. The terminator is broken by the material: blade tips on turf, grains on earth. Inside the light family, grass responds weakly to slope (it's a forest of vertical blades that catches a low sun at any slope), while bare earth and gravel take full N·L. Inside the shadow family, form comes from **directional skylight**: surfaces facing the bright side of the sky are the "light" of the shadow family. At dusk that is all that shapes the land (V13). I exaggerate it with a tanh, since Ferrari turns land more than nature does. Then come the crest separation (land just behind a crest drops a step), receding sheen, and a repoussoir vignette inside each family. See `sheets/B_landform_x_sun.png` (masses from land and light alone, with no off-frame shade) and `sheets/E_seeds.png`. What I learned: - A physical light model alone gives *either* nothing (a front sun makes everything one lit value) *or* too much (a low side sun makes everything shadow). Ferrari's masses are designed shapes. The fix was design_masses plus plan, not more physics. - The 3.0/2.9 family clamp has to happen after the marks as well, or the ordered dither will mix a lit step into a shadow and the notan goes grey. - The camera must stand `cam_h` above the ground *under it*. Otherwise the near ground can rise above the eye, and I got one seed whose frame was half a wall of dark grass (fixed). ## Stage 2: texture inside the masses, by material Layers 5–7. Offsets in step space whose **size follows pixels per metre** and whose **contrast follows the family and the distance**: - **turf/meadow**: vertical runs (length ≈ 0.6 × blade px, capped at 7.5), tuft-correlated so darks gather into pockets. Near: each run rounds to a whole step, so there are always two classes. Mid: ordered checker (V16). Near-black gaps sit in the tufts' dark pockets. In shade: olive negative strokes. A crest fringe puts blade tips over the land behind and dark holes along crests. In the nearest metre, a few `hero_blades` are drawn as leaning clean runs. - **earth/path**: LIC streaks along the form (the path's length, the ground's contours), sparse and strong, plus a few pebbles. The path is its own mass: a packed, lighter crown and grass-shaded margins a step down. - **gravel**: small stones with a light top row, dark lower row and dark pocket, deliberately quiet (1–2 steps in light, less in shade). - **forest floor**: flat, overlapping leaf lozenges (V30's ground cover), bold in light and near-gone in shade. - **moisture**: a mass-level darkening, plus puddles. Puddles are lobed pools in fine hollows that take the sky's ramp, with a dark far-edge band (the channel student's rule) and a wet rim. Palettes (layer 8) are generated per material × depth band × hour from L\* targets, not from shading maths. The shadow albedo of green things is shifted to teal-green (measured in V16). The top light step goes ochre. The far bands lift the black point by an hour-keyed amount (at dusk the far land stays dark). `g.relight(hour)` repaints the same index canvas: `sheets/A_ground_x_hour.png`, `stages/palettes_16x.png`. ## Stage 3: distance `sheets/C_distance.png`, from 120 px/m to 1.2 px/m. Strands hand off to checker at about 3 px of blade height. Mark contrast fades with log distance but keeps a floor, so far ground stays stippled rather than flat. The masses are what hold the image together at every scale. I did **not** run a 1.25× step zoom for pops (the grass-lod student's test). The handoffs are continuous functions of pixels per metre, so there's no switch to pop, but that is inference, not a check. ## Honest comparison with Ferrari (`stages/compare_ferrari_4x.png`, 4×) | passage | verdict | why | |---|---|---| | V16PM near lit bank vs my golden bank | **close at 1×, not at 4×** | Value economy and strand scale are right. My palette is more olive and his is salmon-orange (a key choice, easy to change). My hero blades read as a drawn overlay. His near bank has none; they belong only in the front metre or on dark ground (V13, V16 right bank). | | V16 hummocks vs my rolling noon | **not there** | His mid ground is *lozenge-shaped hummocks with a lit band under each crest and a dithered falloff*, a sculpted form turned by ordered dither. Mine is a flat shadow value with strand texture. My landforms are too smooth to make his hummock shapes; the hummocks landform exists but doesn't turn this way yet. | | V13 dusk terraces vs mine | **masses right, surface not** | The terrace layering and path work. His terraces have a lighter crest band each, and the grass texture shows only on crests. Mine are more uniform. | | V09 lit moss path vs my forest pool | **not there** | His light is a *ribbon*, 8–12 px wide, snaking. Mine is a broad pool with leaf texture that is too regular and too bright. Ferrari's forest light is narrower and darker, and it follows paths. | The sheets beat the old studio ground on the stated failure: every cell is 2–4 designed value masses first. They do **not** yet sit beside Ferrari at 8×. The three biggest gaps: 1. **Sculpted mid-ground forms.** Ferrari turns every hummock and terrace with a lit crest band and a dithered falloff (the islet recipe, applied per landform unit). I have the quantizer and the islet copy, but my landform-to-value mapping at mid distance collapses to one flat shadow value. Next step: a per-crest "lit band" term keyed to distance below the crest line, to generalize V16's islet. 2. **Forest light shapes.** These should be ribbons and pools that follow paths (V09), not blobs. 3. **Hero blades on lit ground** read as scratches. They should be restricted to the front metre and to shadow. ## Using this ground (for cobbles, driftwood, willow, channel)cd src nice -n 10 python3 sheets.py # sheets A-F (4 processes, about 2 min) nice -n 10 python3 compare.py # stages/compare_ferrari_4x.png nice -n 10 python3 s6_driftwood.py # sheets/integration_driftwood_3x.pngParameters: - `ground`: turf | meadow | earth | gravel | forest - `landform`: flat | rolling | hummocks | bank | ledges, plus `path` - `hour`: morning | noon | golden | dusk | overcast, or `sun=(az, el)`. az 0 = sun on the left, 90 = contre-jour. - `moisture`: 0–1 - `ppm`: px per metre at screen centre; this is the distance knob - `pitch`, `cam_h`: camera - `shade`, `shade_scale`, `plan`: designed off-frame shadow masses - `canopy`: small dapple - `shadow_mask`: your object's cast shadow, painted as the ground in its shadow family - `blades`, `vignette`, `sheen`, `sep`, `contrast`, `jitter`, `lit_base`, `seed` **Demonstrated**: `sheets/integration_driftwood_3x.png`. The driftwood student's logs, root wad and pile (their `render` and palette, unmodified) sit on this ground. The left column is their original gravel. The middle column is my gravel with off-frame shade and moisture, and the right is my turf. The logs' own cast shadows pass through `shadow_mask`, and their contact pixels are kept. The difference is the point of the study: their ground was even speckle as loud as the logs, and mine is two big quiet masses with the logs as the brightest shapes. Caveat: inside a big shade mass a log's own shadow disappears (correctly, but compositionally you may want the log in the light: use `plan`). The rules to take even without the code: 1. Decide 2–4 masses first, lit versus shadow, from off-frame casters and landform. Delete anything smaller than about 3% of the frame. 2. Keep shadow masses near black with 2–3 values and put the colour in the light. 3. Marks go inside a mass, sized by pixels per metre, and never cross the terminator except as a fringe of tips. 4. Use ordered 2×2 plus jitter for turns in mid and far ground, and runs only near. 5. Give contrast to the marks (gaps, stems), not to the mass. ## Requests - A critic pass on `compare_ferrari_4x.png` and `F_scenes.png`, especially on whether the masses read as designed or as random blobs. - The next study I would do: a stage-0 copy of the V16 far hummocks (lozenge, lit band under crest, dithered falloff), then fold it into the value plan as a per-crest term. That is gap 1 above.import sys; sys.path.insert(0, '~/work/pixelart-studies/elements/ground/src') import ground g = ground.paint_ground(W, H, ground='gravel', landform='flat', hour='golden', sun=(20, 10), ppm=40, pitch=30, cam_h=4, shade=0.35, shade_scale=3, plan='side-right', moisture=0.3, seed=24, shadow_mask=my_cast_shadow) rgb = g.rgb; g.depth, g.wx, g.wz, g.h # world buffers to place objects g.mat, g.band, g.step # index canvas; g.relight('noon') repaints
SubagentHandback: I've sent the ground study to the agent that launched it.
Bash: Character map of V16 far hummockscd ~/work/pixelart-studies/elements/ground/src && python3 -c "
import lw,numpy as np
from analyze import Lstar
d=lw.load('V16'); I=d['idx']; P=d['pal']
s=I[215:275,430:640]
L=Lstar(P[s].reshape(-1,3)).reshape(s.shape)
ch={}
u=np.unique(s); order=sorted(u,key=lambda k:Lstar(P[k][None])[0])
sym=' .:-=+*#%@&'
m={k:sym[min(i,len(sym)-1)] for i,k in enumerate(order)}
for r in s: print(''.join(m[v] for v in r[:160]))
print([(k,tuple(P[k]),round(float(Lstar(P[k][None])[0]))) for k in order])
"
# output:
&--:*-*--:-:-:-&::-:-::&*-:**--:&&-:&&&-&&-::::&:&&&&&&& & &*:-&&-:*-:&&**::&-:&&**-&*&&*--:*-*-*&&*&-&-&&&:&*-:-&-:-:-**--:::-:-:*-*-&**--:::**-******-***&*-
&&****---&***--:&::::::-&:--&&**--&&**----:::&&&&&&&&& &...&-&*&&:&*-:&&*-:-&:&-&&*&&&&****-*-**&&&*-&-&**:&---&--*--------:-:-:::***-*:::::&*-:*****-*-*&&-:
&**-*--:-&&**--::&:&-::--&&&&**--::-&&:**--:-:::.&&&&&& .....&-&&&&:*&*-:&-&&&&#&*&&&&&&&**:*:*-*&&&&*&*&-&*--&--&----*-*--:-:-:-:::*--**-*--:&&::-**--**-*&&--:
***---::&&**----:---::::--&&***---:&:&:&**----::&.....&& &.. &:&&*::&*&&#&&&#&#&*&&&-&&&***:*&***&&&&&***-&&&&-:&--&---*------:-::::::--::**-&*-**-**--*-**--&-*
&-*:-:-:&**::-*-*--:-:-::--&&-:*&&&--&*:&**-&:-::..&....&..&&&:-*&&&#&#&&#&#&#&&&*&&&**-*-*-&*&*&&*:&*&-*-----&&&:&---*-*&*--:-:-:::-:::::*-&**-*****---*--**-&*
*--::-::*-::-*------:-::::-&*-:*&&&*--&&:&--:&:::::..&&&&&& & :*##&&&##&#&#&&&&&**&&**-*--:*&&*&*&::&&**::-&---&:&----&**-**--::**--::::&&**&&**-*-*--**&--&&&-&
&**--::**--:*------:-:-:-::---&&&&&-&**:::-:-:-::::&&&&&&&&&&&&#&&#&##&#&&&&&&&**&&**---*--:&*&-*::&:&&:::&&-&-&&--:&---&--:-**::**-*-&*&**-&**-*---*-*&*-*--*&-
&*-:*&***---:-----:::::-------&&&&&&***---::::::::::::&&&&&&&&&#&&******&&****-&&&**-------*::-&--&&:&*::&::&-&-----&&--&::::*:-::**&&*&****--****----**------&&
-:&**&&**--::----:-:-::-&*&*&:&**&&&&**-*&-:-:*:-::::.&...&&&.#&#&&**-&*&*&**-*&&**---*--:*--&&--&&:&*::&:&:-&&----&&---&:-:::*--::**&&**-*--*--**--**&*--*---&-
&&**--&&**--::----:&:::&&&&&&&**&&&*&&**--::::&*-:::...&.&&&&#&#&&***&&&******&&**&**&---**-&&-----&**-&&&:-&&---&:&&---&:::::**---&**&**-*&**-&&-****-&-&---&&-
&**-*--&&**:-&&:&:&:&+&&&&&&&*&-&*&&---::&::&:-:::&:&#&&&#&&#&#&####&&&*&**-*-&&&***&**----:&-----&-&&&**--&&---&--&&---&:-:-::**-&-&&-&&*****&---*-&&----&&&&&&
***---:&**&&&&&&&&&&&&&&&&+&&&&&**&-&:*&----:&:&&#&#&&&#&#&#&#&#&#######&&**&**-***&&***----*----&--&-&&**-&----&-&&---&-::::&&&*&&&&&#&&#&&**&-&&---&&&-&&&&#-&
:##&&#&&#&#&#&#&#&#&&&#&&+&&&&&&&&&&&&&*&&:&#&#&#&#&&&&&#&############&#&&&**-*-*-&*&**-*--:***----&&&&-&--&-&-:-&&**-&&&:&&-&#&&&&#&&&&&&&&#&&&#&#&&&&&&-&&&&#&
#&###&####&#&#&#&#&#&&&&&&&+&&&+&&&&&&&&&&&#&#&#&&&#&#&#&#&##+&########&&&****--&&&&***-*--***----&*&&--&&-&--&--&&&&#:&&&##&#&&&&&&&&&&&&&#&#&&&&&&&&#&#&&&&#&&
###+#+#+#+#+#+#&#&+&+&++&&+&+&&&&&&&&&&&&&&&&&&&#&#&&&#&#&############&*&**&***-&*&**-***&&**----:&&&-&&&&&&&&#&&&&#&&&#&#&&&&&&#&&&&&&&&#&&&&&&&&&#&&&#&&&#&&&#
####+#+#+#+#+#++++&+&+&&&&&&&&&&&+&+&&&&&&&&&&&&&&#&&&&&&&#&#&#&######&&**&&****&&**-***-&&*&&-&:*&&&#&#&#&&&#&&&&&&&#&&&&&&&##&&&&#&&#&#&&#&#&#&&&&&&&&&#&&&#&&
#+#+#+#+#+#+#++++&+&+&+&+&+&+&+&+&&&&&++&&&&&&&&&&&&&&#&&#&#&#&&##&#######&*&*&*&-&--*:-&&&*#&&&&#&&&&&&&&&&#&#&&&&&&&#&&#&&&#&&&&&&&&##&&&&&&&&&&&&&##&&&&&#&&&
##+#+#+++#++++#+++++++&+++&&&+&&&&&&&&&&&&&&&&&&&&&&&&&&&&&#&&&&&&#&#&#####&&**--&----&&##&&&&&&&&#&&&#&&&&&&&&&#&&&#&&&#&#&&&&&#&&&&&&&#&&&&&&&#&&#&&&&#&#&&&&&
#+#+###+#+++#+#+#++&+&+&+&+&+&+&+&+&+&&&+&&&+&+&+&&&&&&&&&&&&&&&&&&&&&&&#&#&#&*&*&&:-:#&&&&&#&&&#&&&&&Bash: Measure hummock value profile below crestcd ~/work/pixelart-studies/elements/ground/src && python3 -c "
import lw,numpy as np
d=lw.load('V16'); I=d['idx']
s=I[215:280,300:640]
ramp=[69,235,72,59,70,120,126,71,127,60,61,62,52,53]
# order by value: 69/235 L25, 72/59 L9, 70 L6, 71 L2, darker
val={69:3,235:3,72:2,59:2,70:1,120:1,126:1,71:0,127:0,60:0,61:0,62:0,52:0,53:0}
isr=np.isin(s,list(val))
v=np.vectorize(lambda k: val.get(k,-1))(s)
# crest: ramp pixel whose above pixel is not ramp (water/sky/other)
H,W=s.shape
prof={}
for x in range(W):
col=v[:,x]; y=1
while y<H:
if col[y]>=0 and col[y-1]<0:
y0=y
while y<H and col[y]>=0: y+=1
L=y-y0
if L>=6:
for k in range(L): prof.setdefault((min(L,40)//10,k),[]).append(col[y0+k])
y+=1
for Lb in range(5):
row=[]
for k in range(0,40,2):
a=prof.get((Lb,k))
if a: row.append('%.1f'%np.mean(a))
print('segLen~%d0:'%Lb,' '.join(row))
"
# output:
segLen~00: 1.2 1.6 1.5 1.7 0.8
segLen~10: 2.0 1.6 2.2 1.0 0.0 0.5 0.0
segLen~20: 1.4 1.7 1.7 1.7 1.9 1.7 1.6 1.6 1.5 1.4 1.1 1.0 1.0 0.8 0.5
segLen~30: 1.7 1.9 1.9 1.8 2.0 2.2 1.7 1.2 0.9 0.9 1.1 1.0 0.8 0.8 0.8 1.2 1.2 1.3 0.7
segLen~40: 0.8 1.1 1.2 1.1 1.5 1.9 1.8 1.6 1.9 1.9 1.9 1.8 1.9 1.9 1.8 2.6 2.3 2.2 2.1 1.8Bash: Stage 0b: hummock crest-band copycd ~/work/pixelart-studies/elements/ground/src && cat > s9_hummock.py <<'EOF'
"""Stage 0b: V16 far hummocks, copied by procedure.
His silhouettes (the index mask), my fill: per column, a value profile below each crest
(dark accent on the crest line, lit band ~25% down, falloff to a dark base), smoothed
across columns, quantized with the islet's ordered 2x2 + jitter."""
import lw, numpy as np, dither
from scipy import ndimage as ndi
d = lw.load('V16'); I = d['idx']; P = d['pal']
x0, y0, x1, y1 = 300, 215, 640, 280
S = I[y0:y1, x0:x1]
VAL = {69: 3, 235: 3, 72: 2, 59: 2, 70: 1, 120: 1, 126: 1, 71: 0, 127: 0, 60: 0, 61: 0, 62: 0, 52: 0, 53: 0}
RAMP = [71, 70, 72, 69]
m = np.isin(S, list(VAL))
H, W = S.shape
def crest_profile(t, accent=0.35, peak=0.25):
"""0..1 value shape below a crest, t = fraction of the way from crest to base"""
up = accent + (1 - accent) * np.clip(t / peak, 0, 1) ** 0.7
down = np.clip(1 - (t - peak) / (1 - peak), 0, 1) ** 0.9
return np.where(t < peak, up, down)
def crest_t(mask):
"""for each masked pixel: rows below the top of its vertical run, and run length"""
k = np.zeros(mask.shape); L = np.zeros(mask.shape)
for x in range(mask.shape[1]):
col = mask[:, x]; y = 0
while y < len(col):
if col[y]:
y0_ = y
while y < len(col) and col[y]: y += 1
k[y0_:y, x] = np.arange(y - y0_); L[y0_:y, x] = y - y0_
else:
y += 1
return k, L
k, L = crest_t(m)
t = k / np.maximum(L, 1)
f = crest_profile(t)
# smooth across columns so the band reads as a form, not per-column noise
num = ndi.gaussian_filter(f * m, (0.6, 2.5)); den = ndi.gaussian_filter(m * 1.0, (0.6, 2.5)) + 1e-6
f = num / den
v = 0.3 + 2.4 * f
st = dither.ordered(v, jitter=0.4, rng=np.random.default_rng(3), n=4)
out = P[S].copy(); out[m] = P[np.array(RAMP)[st]][m]
ref = P[S]
pad = np.zeros((3, W, 3), np.uint8)
lw.save(np.concatenate([ref, pad, out], 0), '../stages/s9_hummocks_5x.png', 5)
EOF
python3 s9_hummock.py
# output:
(Bash completed with no output)Read: elements/ground/stages/s9_hummocks_5x.png
98. elements/ground/stages/s9_hummocks_5x.png
Bash: Cut runs at his crest linescd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('s9_hummock.py').read()
s=s.replace("k, L = crest_t(m)","""# his crest lines: where his squinted value steps UP going down the column (dark base of the
# hummock behind, lit band of the one in front). These are the silhouettes I must draw myself
# in my own ground; here I take them from him, to test only the fill.
vs = np.vectorize(lambda q: VAL.get(q, 0))(S).astype(float)
sq = ndi.gaussian_filter(vs, (1.2, 3.0))
dy = np.zeros_like(sq); dy[1:] = sq[1:] - sq[:-1]
crest = (dy > 0.12) & (ndi.maximum_filter(dy, size=(5, 1)) == dy) & m
m_cut = m & ~crest
k, L = crest_t(m_cut)""")
s=s.replace("lw.save(np.concatenate([ref, pad, out], 0)","dbg = ref.copy(); dbg[crest] = (255, 0, 0)\nlw.save(np.concatenate([ref, pad, out, pad, dbg], 0)")
open('s9_hummock.py','w').write(s)
EOF
python3 s9_hummock.py
# output:
(Bash completed with no output)Read: elements/ground/stages/s9_hummocks_5x.png
99. elements/ground/stages/s9_hummocks_5x.png
Bash: Keep only long crest linescd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('s9_hummock.py').read()
s=s.replace("m_cut = m & ~crest","""lab, n = ndi.label(ndi.binary_dilation(crest, np.ones((3, 3))))
sz = ndi.sum(crest, lab, range(1, n + 1))
crest = crest & np.isin(lab, np.nonzero(sz >= 25)[0] + 1)
m_cut = m & ~crest""")
open('s9_hummock.py','w').write(s)
EOF
python3 s9_hummock.py
# output:
(Bash completed with no output)Read: elements/ground/stages/s9_hummocks_5x.png
100. elements/ground/stages/s9_hummocks_5x.png
Bash: Check his checker pairs; use 3-step rampcd ~/work/pixelart-studies/elements/ground/src && python3 -c "
import lw,numpy as np
d=lw.load('V16'); s=d['idx'][225:275,450:640]
u,c=np.unique(s,return_counts=True); print(sorted(zip(-c,u))[:8])
# checker pairs: horizontally adjacent class pairs counts
a=s[:,:-1];b=s[:,1:]; from collections import Counter
print(Counter(zip(a[a!=b],b[a!=b])).most_common(8))"
sed -i '' 's/RAMP = \[71, 70, 72, 69\]/RAMP = [71, 70, 69]/; s/v = 0.3 + 2.4 \* f/v = 0.1 + 1.85 * f/; s/n=4)/n=3)/' s9_hummock.py && python3 s9_hummock.py
# output:
[(-3367, 69), (-3121, 70), (-1339, 71), (-1082, 72), (-204, 61), (-104, 60), (-98, 59), (-52, 62)]
[((69, 70), 1402), ((70, 69), 1376), ((71, 69), 380), ((69, 71), 363), ((70, 71), 311), ((71, 70), 294), ((71, 72), 181), ((72, 71), 165)]Read: elements/ground/stages/s9_hummocks_5x.png
101. elements/ground/stages/s9_hummocks_5x.png
Bash: Locate insertion pointscd ~/work/pixelart-studies/elements/ground/src && grep -n " v = np.where(lit, np.maximum(v, 3.0), np.minimum(v, 2.9)) # families never touch" ground.py; grep -n "def value_plan" ground.py; grep -n " return np.clip(v, 0, 5.99), lit, sepm" ground.py # output: 743: v = np.where(lit, np.maximum(v, 3.0), np.minimum(v, 2.9)) # families never touch 269:def value_plan(buf, L, mat=None, sep=0.9, sheen=0.5, lit_floor=0.10, lit_base=0.6, overcast=False): 326: return np.clip(v, 0, 5.99), lit, sepm
Bash: Add crest band and mass turn termscd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace("def paint_ground(","""def crest_profile(t, accent=0.35, peak=0.25):
\"\"\"Measured on V16's far hummocks: value below a crest line, t = 0 at the crest, 1 at the
base. A thin darker accent on the crest, the lit band ~25% down, falloff to a dark base.\"\"\"
up = accent + (1 - accent) * np.clip(t / peak, 0, 1) ** 0.7
down = np.clip(1 - (t - peak) / (1 - peak), 0, 1) ** 0.9
return np.where(t < peak, up, down)
def crest_runs(buf, cap):
\"\"\"Per pixel: rows below the crest line above it (an occlusion edge or the skyline) and the
length of that run down to the next crest; runs longer than cap px use cap (near ground has
no single form reaching the frame bottom).\"\"\"
z = buf['z']; H, W = z.shape
brk = np.zeros((H, W), bool)
brk[1:] = ((z[:-1] > z[1:] * 1.25) & (z[:-1] - z[1:] > 2.0)) | buf['sky'][:-1]
brk[0] = True
brk &= ~buf['sky']
k = np.zeros((H, W)); Ln = np.ones((H, W))
for x in range(W):
ys = list(np.nonzero(brk[:, x])[0]) + [H]
for a, b in zip(ys[:-1], ys[1:]):
k[a:b, x] = np.arange(b - a); Ln[a:b, x] = b - a
return k, np.minimum(Ln, cap), Ln
def turn_masses(v, lit, buf, crest_band=1.0, mass_turn=1.0, cap=None):
\"\"\"Turn the flat masses into forms, inside each family:
crest band -- every land unit gets the V16 hummock profile below its crest line
mass turn -- a lit pool is hottest in its interior and falls off (dithered) toward its
edge (the V16 islet); a shadow is darkest along its edge and lifts inside
(reflected light), so no mass is a flat colour field with a noisy edge\"\"\"
H, W = v.shape
out = v.copy()
if crest_band > 0:
cap = cap or max(24, H * 0.35)
k, Lc, Ln = crest_runs(buf, cap)
t = np.clip(k / Lc, 0, 1)
f = crest_profile(t)
f = np.where(k > Lc, 0.0, f) # beyond the capped run the form is done
f = ndi.gaussian_filter(f, (0.6, 2.0))
amp = crest_band * np.where(lit, 0.9, 1.3)
out = out + amp * (f - 0.45) * (k <= Lc * 1.2)
if mass_turn > 0:
dl = ndi.distance_transform_edt(lit); ds = ndi.distance_transform_edt(~lit)
D = max(6.0, 0.06 * min(H, W))
out = out + mass_turn * np.where(lit, 0.8 * (np.clip(dl / D, 0, 1) - 0.5),
0.6 * (np.clip(ds / (1.5 * D), 0, 1) - 0.6))
return np.where(lit, np.clip(out, 3.0, 5.99), np.clip(out, 0, 2.9))
def paint_ground(""")
s=s.replace(" v = np.where(lit, np.maximum(v, 3.0), np.minimum(v, 2.9)) # families never touch\n off, thr",
""" v = np.where(lit, np.maximum(v, 3.0), np.minimum(v, 2.9)) # families never touch
v = turn_masses(v, lit, buf, crest_band, mass_turn)
off, thr""",1)
s=s.replace("shadow_mask=None, blades=0.035, plan=None):","shadow_mask=None, blades=0.035, plan=None, crest_band=1.0, mass_turn=1.0):")
open('ground.py','w').write(s)
EOF
grep -n "turn_masses(v, lit" ground.py; sed -n 735,760p ground.py
# output:
738:def turn_masses(v, lit, buf, crest_band=1.0, mass_turn=1.0, cap=None):
k[a:b, x] = np.arange(b - a); Ln[a:b, x] = b - a
return k, np.minimum(Ln, cap), Ln
def turn_masses(v, lit, buf, crest_band=1.0, mass_turn=1.0, cap=None):
"""Turn the flat masses into forms, inside each family:
crest band -- every land unit gets the V16 hummock profile below its crest line
mass turn -- a lit pool is hottest in its interior and falls off (dithered) toward its
edge (the V16 islet); a shadow is darkest along its edge and lifts inside
(reflected light), so no mass is a flat colour field with a noisy edge"""
H, W = v.shape
out = v.copy()
if crest_band > 0:
cap = cap or max(24, H * 0.35)
k, Lc, Ln = crest_runs(buf, cap)
t = np.clip(k / Lc, 0, 1)
f = crest_profile(t)
f = np.where(k > Lc, 0.0, f) # beyond the capped run the form is done
f = ndi.gaussian_filter(f, (0.6, 2.0))
amp = crest_band * np.where(lit, 0.9, 1.3)
out = out + amp * (f - 0.45) * (k <= Lc * 1.2)
if mass_turn > 0:
dl = ndi.distance_transform_edt(lit); ds = ndi.distance_transform_edt(~lit)
D = max(6.0, 0.06 * min(H, W))
out = out + mass_turn * np.where(lit, 0.8 * (np.clip(dl / D, 0, 1) - 0.5),
0.6 * (np.clip(ds / (1.5 * D), 0, 1) - 0.6))
return np.where(lit, np.clip(out, 3.0, 5.99), np.clip(out, 0, 2.9))Bash: Smooth crest run ends; render scenescd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace(""" t = np.clip(k / Lc, 0, 1)
f = crest_profile(t)
f = np.where(k > Lc, 0.0, f) # beyond the capped run the form is done
f = ndi.gaussian_filter(f, (0.6, 2.0))
amp = crest_band * np.where(lit, 0.9, 1.3)
out = out + amp * (f - 0.45) * (k <= Lc * 1.2)""",""" t = np.clip(k / Lc, 0, 1)
f = crest_profile(t)
# a run cut short by the next crest ends in the dark base (the hummock meets the one in
# front); a long run (near ground) just relaxes back to neutral after its band
open_ = Ln > Lc
relax = 0.45 + (1 - 0.45) * np.exp(-np.clip(t - 0.25, 0, None) / 0.3)
f = np.where(open_ & (t > 0.25), relax, f)
f = ndi.gaussian_filter(f, (0.6, 2.0))
amp = crest_band * np.where(lit, 0.9, 1.3)
out = out + amp * (f - 0.45)""")
open('ground.py','w').write(s)
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 sheets.py F 2>&1|tail -1
# output:
wrote F_scenesRead: elements/ground/sheets/F_scenes.png
102. elements/ground/sheets/F_scenes.png
Bash: Check the turn term's effect on vcd ~/work/pixelart-studies/elements/ground/src && OPENBLAS_NUM_THREADS=1 nice -n 10 python3 -c " import ground,lw,numpy as np kw=dict(ground='gravel', landform='flat', hour='golden', sun=(80, 8), ppm=30, pitch=14, cam_h=2.0, shade=0.4, shade_scale=10, seed=9, plan='foreground') a=ground.paint_ground(320,200,crest_band=0,mass_turn=0,**kw); b=ground.paint_ground(320,200,**kw) f=lambda v:np.stack([(v/6*255).astype(np.uint8)]*3,-1) lw.save(np.concatenate([f(a.v),f(b.v)],1),'../stages/dbg_turn.png',1) print(np.abs(a.v-b.v).mean()) " # output: 0.0
Read: elements/ground/stages/dbg_turn.png
103. elements/ground/stages/dbg_turn.png
Bash: Find where to insert turn callcd ~/work/pixelart-studies/elements/ground/src && grep -n "families never touch\|headroom\|v = np.where(lit, 3.15" ground.py # output: 794: v = np.where(lit, np.maximum(v, 3.0), np.minimum(v, 2.9)) # families never touch 803: # headroom: keep the mass off the ends of its family so marks can go both ways (a mass 805: v = np.where(lit, 3.15 + (v - 3.0) * 0.78, 0.25 + v * 0.8) 819: step = np.where(lit, np.maximum(step, 3), np.minimum(step, 2)) # families never touch
Bash: Show context around headroomcd ~/work/pixelart-studies/elements/ground/src && sed -n 790,808p ground.py
# output:
# repoussoir: bottom corners sink, inside each family
yy, xx = np.mgrid[0:H, 0:W]
vg = np.clip((yy / H - 0.55) / 0.45, 0, 1) ** 1.5 * (0.45 + 0.55 * np.abs(xx - W / 2) / (W / 2))
v = v - vignette * 1.6 * vg
v = np.where(lit, np.maximum(v, 3.0), np.minimum(v, 2.9)) # families never touch
phi = buf['z'].copy()
if path:
pd = land.path_dist(buf['x'], buf['z'])
phi = np.where(pd < 2.0, pd * 40, phi)
# the path's mass: a packed, lighter crown; grass-shaded margins drop a step
onp = (mat == MI['earth']) & (pd < 1.2)
v = np.where(onp, v + 0.5 * np.exp(-(pd / 0.35) ** 2) - 0.9 * np.clip((pd - 0.45) / 0.3, 0, 1), v)
v = np.where(lit, np.maximum(v, 3.0), np.minimum(v, 2.9))
# headroom: keep the mass off the ends of its family so marks can go both ways (a mass
# pinned at the top step goes flat -- the flat-tone failure)
v = np.where(lit, 3.15 + (v - 3.0) * 0.78, 0.25 + v * 0.8)
off, thr, coh, gap, strk = marks(buf, mat, v, lit, rng, contrast, phi)
if hour == 'dusk': # low light: texture sinks, masses carry the picture (V13)
off = off * 0.45; strk &= rng.random(strk.shape) < 0.35Bash: Actually insert turn call; comparecd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
a=""" v = np.where(lit, np.maximum(v, 3.0), np.minimum(v, 2.9)) # families never touch
phi = buf['z'].copy()"""
assert a in s
s=s.replace(a,""" v = np.where(lit, np.maximum(v, 3.0), np.minimum(v, 2.9)) # families never touch
v = turn_masses(v, lit, buf, crest_band, mass_turn) # masses become forms
phi = buf['z'].copy()""")
open('ground.py','w').write(s)
EOF
OPENBLAS_NUM_THREADS=1 nice -n 10 python3 -c "
import ground,lw,numpy as np
kw=dict(ground='gravel', landform='flat', hour='golden', sun=(80, 8), ppm=30, pitch=14, cam_h=2.0, shade=0.4, shade_scale=10, seed=9, plan='foreground')
a=ground.paint_ground(320,200,crest_band=0,mass_turn=0,**kw); b=ground.paint_ground(320,200,**kw)
f=lambda v:np.stack([(v/6*255).astype(np.uint8)]*3,-1)
lw.save(np.concatenate([f(a.v),f(b.v)],1),'../stages/dbg_turn.png',1)
lw.save(np.concatenate([a.rgb,b.rgb],1),'../stages/dbg_turn_rgb.png',2)
"
# output:
(Bash completed with no output)Read: elements/ground/stages/dbg_turn_rgb.png
104. elements/ground/stages/dbg_turn_rgb.png
Bash: Shadow family value distributioncd ~/work/pixelart-studies/elements/ground/src && OPENBLAS_NUM_THREADS=1 nice -n 10 python3 -c " import ground,numpy as np kw=dict(ground='gravel', landform='flat', hour='golden', sun=(80, 8), ppm=30, pitch=14, cam_h=2.0, shade=0.4, shade_scale=10, seed=9, plan='foreground') a=ground.paint_ground(320,200,crest_band=0,mass_turn=0,**kw) m=~a.lit&~a.buf['sky']; print(np.percentile(a.v[m],[5,25,50,75,95])) m=a.lit; print(np.percentile(a.v[m],[5,25,50,75,95]))" # output: [1.60567487 1.7248309 1.81313887 1.87761914 1.95426293] [3.45794215 4.0081161 4.54700021 5.05158216 5.05158216]
Bash: Stronger mass turns; rerender scenescd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
a=""" out = out + mass_turn * np.where(lit, 0.8 * (np.clip(dl / D, 0, 1) - 0.5),
0.6 * (np.clip(ds / (1.5 * D), 0, 1) - 0.6))"""
assert a in s
s=s.replace(a,""" # lit: the islet recipe, a hot interior and a dithered falloff to the edge.
# shadow: a dark core just inside the terminator (Ferrari's accent where shade meets
# light), then reflected light lifting the broad interior
out = out + mass_turn * np.where(lit, 1.0 * (np.clip(dl / D, 0, 1) - 0.55),
-0.8 * np.exp(-ds / 3.0) + 0.7 * (np.clip(ds / (2.5 * D), 0, 1) - 0.4))""")
open('ground.py','w').write(s)
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 sheets.py F 2>&1|tail -1
# output:
wrote F_scenesRead: elements/ground/sheets/F_scenes.png
105. elements/ground/sheets/F_scenes.png
Bash: Hummocks landform with/without crest band vs V16cd ~/work/pixelart-studies/elements/ground/src && cat > s10_hum.py <<'EOF'
"""Stage 4: V16's mid-ground hummocks, in my own ground, with and without the crest band."""
import ground, lw, numpy as np
ref = lw.rgb(lw.load('V16'))[215:295, 330:640]
kw = dict(ground='turf', landform='hummocks', hour='noon', sun=(60, 30), ppm=14, pitch=6, cam_h=2.2, relief=1.6, seed=4)
a = ground.paint_ground(310, 160, crest_band=0, mass_turn=0, **kw).rgb[50:130]
b = ground.paint_ground(310, 160, **kw).rgb[50:130]
p = np.zeros((3, 310, 3), np.uint8)
lw.save(np.concatenate([ref, p, a, p, b], 0), '../stages/s10_hummocks_4x.png', 4)
EOF
OPENBLAS_NUM_THREADS=1 nice -n 10 python3 s10_hum.py
# output:
(Bash completed with no output)Read: elements/ground/stages/s10_hummocks_4x.png
106. elements/ground/stages/s10_hummocks_4x.png
Bash: Hummocks in shadow familycd ~/work/pixelart-studies/elements/ground/src && sed -i '' "s/sun=(60, 30), ppm=14, pitch=6, cam_h=2.2, relief=1.6, seed=4)/sun=(80, -2), ppm=14, pitch=4, cam_h=1.5, relief=2.5, seed=4)/" s10_hum.py && OPENBLAS_NUM_THREADS=1 nice -n 10 python3 s10_hum.py # output: (Bash completed with no output)
Read: elements/ground/stages/s10_hummocks_4x.png
107. elements/ground/stages/s10_hummocks_4x.png
Bash: Distinct lozenge hummocks + convexity skylightcd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
a=""" if kind in ('hummocks',):
for _ in range(60):
self.hum.append((r.uniform(-60, 60), r.uniform(3, 140), r.uniform(3, 9), r.uniform(1.5, 3.5),
r.uniform(0.4, 1.3), r.uniform(-0.5, 0.5)))"""
assert a in s
s=s.replace(a,""" if kind in ('hummocks',):
# V16's far bank: separate lozenges, wide and flat-bottomed, long axis across the
# view, with gaps of flat ground between them (not an overlapping bumpy mush)
tries = 0
while len(self.hum) < 40 and tries < 2000:
tries += 1
cz = 8 * (140 / 8) ** r.random(); cx = r.uniform(-0.8, 0.8) * (10 + cz * 0.7)
ax = r.uniform(3, 10) * (1 + cz / 80); az = ax * r.uniform(0.3, 0.55)
if any(np.hypot((cx - q[0]) / (ax + q[2]), (cz - q[1]) / (az + q[3])) < 0.8 for q in self.hum):
continue
self.hum.append((cx, cz, ax, az, r.uniform(0.7, 1.6) * (1 + cz / 100), r.uniform(-0.25, 0.25)))""")
s=s.replace("amp = {'flat': 0.25, 'rolling': 1.6, 'hummocks': 0.5,","amp = {'flat': 0.25, 'rolling': 1.6, 'hummocks': 0.12,")
s=s.replace(" H = H + ht * self.relief * np.clip(1 - q, 0, None) ** 1.5"," H = H + ht * self.relief * np.clip(1 - q, 0, None) ** 0.6 # domed top, steep flanks")
# convexity term in turn_masses
a=""" if mass_turn > 0:
dl = ndi.distance_transform_edt(lit)"""
s=s.replace(a,""" if crest_band > 0 and 'convex' in buf:
# skylight on convex land: mound tops take the sky, the feet sink (V16 hummock bodies)
out = out + crest_band * np.where(lit, 0.5, 0.9) * np.tanh(2.5 * buf['convex'])
""" + a)
s=s.replace(""" buf['n_macro'] = land.normal(X, Zs, e=0.8, micro=False)""",""" buf['n_macro'] = land.normal(X, Zs, e=0.8, micro=False)
hm = land.h(X, Zs, micro=False)
ring = np.mean([land.h(X + 3 * np.cos(a), Zs + 3 * np.sin(a), micro=False) for a in np.linspace(0, 6.28, 8, endpoint=False)], 0)
buf['convex'] = np.where(sky, 0.0, (hm - ring) / 0.6)""")
open('ground.py','w').write(s)
EOF
OPENBLAS_NUM_THREADS=1 nice -n 10 python3 s10_hum.py
# output:
(Bash completed with no output)Read: elements/ground/stages/s10_hummocks_4x.png
108. elements/ground/stages/s10_hummocks_4x.png
Bash: Keep hummocks turf; pull camera backcd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
a=" mat[slope > 0.22] = MI['earth'] # bare risers and cut banks"
assert a in s
s=s.replace(a," if land.kind in ('ledges', 'rolling'):\n mat[slope > 0.22] = MI['earth'] # bare risers and cut banks (turf hummocks stay turf)")
open('ground.py','w').write(s)
EOF
sed -i '' "s/ppm=14, pitch=4, cam_h=1.5, relief=2.5, seed=4)/ppm=8, pitch=5, cam_h=3.0, relief=1.5, seed=4)/" s10_hum.py
OPENBLAS_NUM_THREADS=1 nice -n 10 python3 s10_hum.py
# output:
(Bash completed with no output)Read: elements/ground/stages/s10_hummocks_4x.png
109. elements/ground/stages/s10_hummocks_4x.png
Bash: Regenerate everythingcd ~/work/pixelart-studies/elements/ground/src && (OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 sheets.py; OPENBLAS_NUM_THREADS=1 nice -n 10 python3 compare.py; OPENBLAS_NUM_THREADS=1 nice -n 10 python3 s6_driftwood.py) 2>&1 | tail -2 # output: wrote E_seeds wrote F_scenes
Read: elements/ground/sheets/B_landform_x_sun.png
110. elements/ground/sheets/B_landform_x_sun.png
Bash: Add stage 4 to LESSONS; clean debugcd ~/work/pixelart-studies/elements/ground && rm -f stages/dbg_* && python3 - <<'EOF'
p='LESSONS.md'
s=open(p).read()
add='''
## Stage 4: turning the masses (the V16 hummocks and the "construction paper" problem)
The coordinator's note on F_scenes was right: at 1× some masses, like the forest pool and the gravel-bar shadow, read as flat colour fields with a noisy edge. That is the construction-paper look. Ferrari's masses are *forms*: each one turns inside its family.
**Stage 0b copy** (`src/s9_hummock.py` → `stages/s9_hummocks_5x.png`). I measured the value profile below each crest line on V16's far hummocks. There is a thin darker accent on the crest, the lit band sits about 25% of the way down, and the value falls off to a dark base where the hummock meets the one in front. The checker is almost entirely between two classes, 69/70, which is a jump from L\* 6 to L\* 25. The middle step 72 is barely used, so the band is a *two-value* checker with a big gap. I used his crest lines (found as upward steps in his squinted value) and filled with my profile plus the 2×2 ordered dither on a 3-step ramp. The vertical rhythm matches. **What's missing:** his big hummock also turns *horizontally*, lit on its upper right and dark on its left flank. A 1-D profile can't do that; in my own ground it has to come from the geometry.
**Into the engine** (`turn_masses` in ground.py, parameters `crest_band` and `mass_turn`):
- **Crest band**: every land unit below an occlusion crest or the skyline gets the measured profile. A run cut short by the next crest ends in the dark base, and a long open run relaxes back to neutral after its band.
- **Convexity skylight**: mound tops take the sky and the feet sink. This gives the horizontal turn the copy lacked, from real geometry.
- **Mass turn**: a lit pool is hottest in its interior and falls off to its edge through dither (the islet). A shadow has a dark core just inside the terminator and lifts in its broad interior (reflected light).
- **The hummocks landform was rebuilt** as separate, flat-bottomed lozenges with the long axis across the view and flat ground between them. It was an overlapping bumpy mush that made no occlusion crests. Turf hummocks no longer turn into bare earth on their steep flanks.
`stages/s10_hummocks_4x.png` shows V16 on top, then mine without the new terms, then mine with them. The bottom row now reads as hummocks: lit bands under crests, dark feet, layered. See also the hummocks row of `sheets/B_landform_x_sun.png`, where the contre-jour cell rim-lights the crests.
**Where it stands:**
- The band's *value structure* is right.
- The texture is not. My band is a near-flat pale teal where his is a lively L6/L25 checker. The fix is a two-class ramp for the far shadow band, skipping the middle step as he does.
- The forest pool and gravel shadow now have a hot interior and a dark core edge. They are better, but the forest pool is still the wrong *shape* (it should be a ribbon, as in V09).
- I haven't re-checked these at 8× against V16.
'''
s=s.replace("## Honest comparison with Ferrari", add + "\n## Honest comparison with Ferrari")
s=s.replace("| V16 hummocks vs my rolling noon | **not there** |","| V16 hummocks vs my rolling noon | **not there (see stage 4 for the hummocks landform, now closer)** |")
s=s.replace("1. **Sculpted mid-ground forms.**","1. **Sculpted mid-ground forms** (partly addressed in stage 4).")
open(p,'w').write(s)
EOF
grep -n "Stage 4" LESSONS.md
# output:
<stdin>:3: SyntaxWarning: invalid escape sequence '\*'
62:## Stage 4: turning the masses (the V16 hummocks and the "construction paper" problem)SubagentHandback: I've done the follow-up the coordinator asked for and sent the report. Mid-ground hummocks now read as forms: each has a lit band under its crest and dark feet. The band's surface still isn't Ferrari's, and I haven't checked any of it at 8×.
stages/s10_hummocks_4x.png shows V16, then my ground without the new band, then with it. The last row is the one that reads as hummocks.
What's still wrong:
All sheets, the Ferrari comparison and the driftwood-on-ground sheet are regenerated. LESSONS.md has a new stage 4 section. Everything is in ~/work/pixelart-studies/elements/ground/.
Read: voxsim-wt/paint-near/renders/near/st8/dusk.png
111. voxsim-wt/paint-near/renders/near/st8/dusk.png
Bash: Measure my hummock band step sharescd ~/work/pixelart-studies/elements/ground/src && OPENBLAS_NUM_THREADS=1 nice -n 10 python3 -c "
import ground,numpy as np
kw=dict(ground='turf', landform='hummocks', hour='noon', sun=(80, -2), ppm=8, pitch=5, cam_h=3.0, relief=1.5, seed=4)
g=ground.paint_ground(310,160,**kw)
s=g.step[50:130]; m=g.mat[50:130]>=0
print('mine shares',np.bincount(s[m],minlength=6)/m.sum())
print('v pct',np.percentile(g.v[50:130][m],[5,25,50,75,95]))
print('ramp',ground.ramp('turf','noon',1)[:3].tolist())
"
# output:
mine shares [0.05296182 0.11661507 0.83042312 0. 0. 0. ]
v pct [0.45645466 2.01837467 2.33316605 2.56176414 2.57 ]
ramp [[25, 32, 33], [25, 39, 34], [31, 62, 46]]Bash: Two-class far band: gentler dark lift, lower shadow keycd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
a=" w = f * 2.0 * np.linspace(1.0, 0.1, 6)[:, None]"
assert a in s
s=s.replace(a,""" # the lift is gentle on the darks: V16's far hummocks still hold L* 2 in their bases, and a
# lifted black point merges steps 0 and 1 into one flat colour (the pale-teal band failure)
w = f * np.array([0.6, 0.7, 0.9, 1.4, 1.1, 0.6])[:, None]""")
a=" v = np.where(lit, 3.15 + (v - 3.0) * 0.78, 0.25 + v * 0.8)"
assert a in s
s=s.replace(a,""" # the shadow family sits so that its body is the 1/2 checker and only crest bands reach a
# solid step 2 (V16 hummocks: L6/L25 checker ~70% of the mass, solid lights rare)
v = np.where(lit, 3.15 + (v - 3.0) * 0.78, 0.2 + v * shadow_key)""")
s=s.replace("plan=None, crest_band=1.0, mass_turn=1.0):","plan=None, crest_band=1.0, mass_turn=1.0,\n shadow_key=0.66):")
open('ground.py','w').write(s)
EOF
OPENBLAS_NUM_THREADS=1 nice -n 10 python3 -c "
import ground,numpy as np
kw=dict(ground='turf', landform='hummocks', hour='noon', sun=(80, -2), ppm=8, pitch=5, cam_h=3.0, relief=1.5, seed=4)
g=ground.paint_ground(310,160,**kw)
s=g.step[50:130]; m=g.mat[50:130]>=0
print('mine shares',np.bincount(s[m],minlength=6)/m.sum())
print('ramp',ground.ramp('turf','noon',1)[:3].tolist())
" && OPENBLAS_NUM_THREADS=1 nice -n 10 python3 s10_hum.py
# output:
mine shares [0.06158927 0.26439628 0.67401445 0. 0. 0. ]
ramp [[11, 21, 20], [17, 35, 26], [29, 61, 45]]Read: elements/ground/stages/s10_hummocks_4x.png
112. elements/ground/stages/s10_hummocks_4x.png
Bash: Quantile-keyed shadow familycd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
a=""" v = np.where(lit, 3.15 + (v - 3.0) * 0.78, 0.2 + v * shadow_key)"""
s=s.replace(a,""" v = np.where(lit, 3.15 + (v - 3.0) * 0.78, v)
sm = (~lit) & (mat >= 0) & (mat != MI['water'])
if sm.sum() > 50:
# key the shadow family by its own quantiles (a value plan, not a lighting formula):
# the body of the shadow is the 1/2 checker, the crest bands reach solid 2, and only
# the feet and cores reach 0 -- V16 hummocks: L2 15% / L6 35% / L25 38%
q = np.percentile(v[sm], [3, 50, 97])
tgt = np.array([0.35, 1.0 + shadow_key, 2.45])
v = np.where(sm, np.interp(v, q, tgt, left=0.2, right=2.6), v)""")
s=s.replace("shadow_key=0.66):","shadow_key=0.55):")
open('ground.py','w').write(s)
EOF
OPENBLAS_NUM_THREADS=1 nice -n 10 python3 -c "
import ground,numpy as np
kw=dict(ground='turf', landform='hummocks', hour='noon', sun=(80, -2), ppm=8, pitch=5, cam_h=3.0, relief=1.5, seed=4)
g=ground.paint_ground(310,160,**kw)
s=g.step[50:130]; m=g.mat[50:130]>=0
print('mine shares',np.bincount(s[m],minlength=6)/m.sum())" && OPENBLAS_NUM_THREADS=1 nice -n 10 python3 s10_hum.py
# output:
mine shares [0.06431373 0.14662539 0.78906089 0. 0. 0. ]Read: elements/ground/stages/s10_hummocks_4x.png
113. elements/ground/stages/s10_hummocks_4x.png
Bash: Debug shadow v and vv distributionscd ~/work/pixelart-studies/elements/ground/src && OPENBLAS_NUM_THREADS=1 nice -n 10 python3 -c "
import ground,numpy as np
kw=dict(ground='turf', landform='hummocks', hour='noon', sun=(80, -2), ppm=8, pitch=5, cam_h=3.0, relief=1.5, seed=4)
g=ground.paint_ground(310,160,**kw)
m=(g.mat>=0)
print('v',np.percentile(g.v[m],[3,25,50,75,97]).round(2)); print('vv',np.percentile(g.vv[m],[3,25,50,75,97]).round(2)); print('lit frac',g.lit[m].mean())"
# output:
v [0.2 1.51 2.01 2.35 2.45]
vv [0.24 1.38 1.93 2.41 2.99]
lit frac 0.0Bash: Check keying code placementcd ~/work/pixelart-studies/elements/ground/src && grep -n "shadow_key\|tgt = \|np.interp(v, q" ground.py; grep -n "v = v - 0.9 \* wet\|dusk.*off = off" ground.py # output: 790: shadow_key=0.55): 835: tgt = np.array([0.35, 1.0 + shadow_key, 2.45]) 836: v = np.where(sm, np.interp(v, q, tgt, left=0.2, right=2.6), v) 809: v = v - 0.9 * wet * (v > 0.5)
Bash: Exclude sky from shadow keyingcd ~/work/pixelart-studies/elements/ground/src && sed -i '' "s/ sm = (~lit) \& (mat >= 0) \& (mat != MI\['water'\])/ sm = (~lit) \& (mat >= 0) \& (mat != MI['water']) \& ~buf['sky']/" ground.py && grep -n "sm = (~lit)" ground.py && OPENBLAS_NUM_THREADS=1 nice -n 10 python3 -c "
import ground,numpy as np
kw=dict(ground='turf', landform='hummocks', hour='noon', sun=(80, -2), ppm=8, pitch=5, cam_h=3.0, relief=1.5, seed=4)
g=ground.paint_ground(310,160,**kw)
s=g.step[50:130]; m=g.mat[50:130]>=0
print('mine shares',np.bincount(s[m],minlength=6)/m.sum())" && OPENBLAS_NUM_THREADS=1 nice -n 10 python3 s10_hum.py
# output:
829: sm = (~lit) & (mat >= 0) & (mat != MI['water']) & ~buf['sky']
mine shares [0.06410733 0.24301342 0.69287926 0. 0. 0. ]Read: elements/ground/stages/s10_hummocks_4x.png
114. elements/ground/stages/s10_hummocks_4x.png
Bash: Debug v vs step sharescd ~/work/pixelart-studies/elements/ground/src && OPENBLAS_NUM_THREADS=1 nice -n 10 python3 -c "
import ground,numpy as np
kw=dict(ground='turf', landform='hummocks', hour='noon', sun=(80, -2), ppm=8, pitch=5, cam_h=3.0, relief=1.5, seed=4)
g=ground.paint_ground(310,160,**kw)
m=(g.mat>=0)&~g.buf['sky']
print('v',np.percentile(g.v[m],[3,25,50,75,97]).round(2)); print('vv',np.percentile(g.vv[m],[3,25,50,75,97]).round(2))
print('steps all',np.bincount(g.step[m],minlength=6)/m.sum())"; grep -n "g.__dict__.update(mat=mat" ground.py; grep -n " v = np.where(lit, np.clip(v, 3.0, 5.99)\|turn_masses(v, lit, buf, crest_band, mass_turn)" ground.py
# output:
v [0.33 1.23 1.55 2.25 2.45]
vv [0.24 1.1 1.68 2.24 2.99]
steps all [0.07615607 0.29901185 0.62483207 0. 0. 0. ]
878: g.__dict__.update(mat=mat, band=band, step=step, v=v, vv=vv, lit=lit, wet=wet, buf=buf, L=L,
815: v = turn_masses(v, lit, buf, crest_band, mass_turn) # masses become formsBash: Lower shadow key targetcd ~/work/pixelart-studies/elements/ground/src && sed -i '' 's/ tgt = np.array(\[0.35, 1.0 + shadow_key, 2.45\])/ tgt = np.array([0.15, 1.0 + shadow_key, 2.3])/; s/shadow_key=0.55):/shadow_key=0.3):/' ground.py && OPENBLAS_NUM_THREADS=1 nice -n 10 python3 -c "
import ground,numpy as np
kw=dict(ground='turf', landform='hummocks', hour='noon', sun=(80, -2), ppm=8, pitch=5, cam_h=3.0, relief=1.5, seed=4)
g=ground.paint_ground(310,160,**kw)
m=(g.mat>=0)&~g.buf['sky']
print('steps all',np.bincount(g.step[m],minlength=6)/m.sum())" && OPENBLAS_NUM_THREADS=1 nice -n 10 python3 s10_hum.py
# output:
steps all [0.12681733 0.3121175 0.56106517 0. 0. 0. ]Read: elements/ground/stages/s10_hummocks_4x.png
115. elements/ground/stages/s10_hummocks_4x.png
Bash: Wider gap between shadow steps 1 and 2cd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
a=" 'turf': (4, 12, 28, 36, 50, 66),"
assert a in s
s=s.replace(a," 'turf': (3, 8, 29, 36, 50, 66),")
a=" Ls[:3] = Ls[:3] * 0.8 # under skylight grass is darker than bare soil (self-occlusion)"
assert a in s
s=s.replace(a," Ls[:2] = Ls[:2] * 0.8 # under skylight grass is darker than bare soil (self-occlusion)\n # (step 2 is kept high: the shadow band is a two-value checker with a big gap, L6/L25)")
open('ground.py','w').write(s)
EOF
OPENBLAS_NUM_THREADS=1 nice -n 10 python3 s10_hum.py
# output:
(Bash completed with no output)Read: elements/ground/stages/s10_hummocks_4x.png
116. elements/ground/stages/s10_hummocks_4x.png
Bash: High-angle meadow with/without land-following termscd ~/work/pixelart-studies/elements/ground/src && cat > s11_sweeps.py <<'EOF'
"""For the voxsim near-paint lane: a high-angle meadow (their camera), golden hour, long tree
shadows. Left: masses from cast shadow only. Right: + the land-following terms
(convexity skylight, sky-facing turn, crest band, mass turn, quantile keying)."""
import ground, lw, numpy as np
from PIL import Image, ImageDraw
kw = dict(ground='turf', landform='rolling', hour='golden', sun=(-35, 16), ppm=10, pitch=40, cam_h=40,
relief=2.2, shade=0.3, shade_scale=3, seed=6, blades=0)
rows = []
for sd in (6, 11):
a = ground.paint_ground(300, 180, crest_band=0, mass_turn=0, **{**kw, 'seed': sd}).rgb
b = ground.paint_ground(300, 180, **{**kw, 'seed': sd}).rgb
rows.append(np.concatenate([a, np.zeros((180, 4, 3), np.uint8), b], 1))
img = np.concatenate([rows[0], np.zeros((4, rows[0].shape[1], 3), np.uint8), rows[1]], 0)
lw.save(img, '../stages/s11_sweeps_3x.png', 3)
EOF
OPENBLAS_NUM_THREADS=1 nice -n 10 python3 s11_sweeps.py
# output:
(Bash completed with no output)Read: elements/ground/stages/s11_sweeps_3x.png
117. elements/ground/stages/s11_sweeps_3x.png
Bash: Retry sweeps with moderate cameracd ~/work/pixelart-studies/elements/ground/src && sed -i '' "s/ppm=10, pitch=40, cam_h=40,/ppm=18, pitch=38, cam_h=9, moisture=0.0,/; s/relief=2.2, shade=0.3, shade_scale=3,/relief=1.0, shade=0.3, shade_scale=5,/" s11_sweeps.py && OPENBLAS_NUM_THREADS=1 nice -n 10 python3 s11_sweeps.py # output: (Bash completed with no output)
Read: elements/ground/stages/s11_sweeps_3x.png
118. elements/ground/stages/s11_sweeps_3x.png
Bash: Lit family keying and exaggerated turncd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
a=""" sm = (~lit) & (mat >= 0) & (mat != MI['water']) & ~buf['sky']"""
s=s.replace(a,""" lm = lit & (mat >= 0) & (mat != MI['water']) & ~buf['sky']
if lm.sum() > 50 and lit_key > 0:
# key the light family the same way: whatever turning the land gives (N.L, skylight,
# crest bands, convexity) is stretched to fill 3..5.9, so a lit meadow shows its
# swells as sweeps of halftone and top light instead of one flat ochre
q = np.percentile(v[lm], [3, 50, 97])
if q[2] - q[0] > 1e-3:
tgt = np.array([3.05, 4.35, 5.75])
vk = np.interp(v, q, tgt, left=3.0, right=5.9)
v = np.where(lm, (1 - lit_key) * v + lit_key * vk, v)
""" + a)
s=s.replace(" shadow_key=0.3):"," shadow_key=0.3, lit_key=0.8):")
# stronger in-family N.L for lit, exaggerated relative to flat
a=" v_lit = 3.0 + 3.0 * np.clip(lit_base + 1.0 * (r - 1), 0, 0.999)"
assert a in s
s=s.replace(a,""" # exaggerated like the skylight: Ferrari turns land more than nature (tanh around flat)
v_lit = 3.0 + 3.0 * np.clip(lit_base + 0.45 * np.tanh(2.5 * (r - 1)), 0, 0.999)
# skylight also turns the lit family a little (faces toward the bright sky read warmer-lighter)
v_lit = v_lit + 0.5 * (L['sky'] - 0.55)""")
open('ground.py','w').write(s)
EOF
OPENBLAS_NUM_THREADS=1 nice -n 10 python3 s11_sweeps.py
# output:
(Bash completed with no output)Read: elements/ground/stages/s11_sweeps_3x.png
119. elements/ground/stages/s11_sweeps_3x.png
Bash: Fair off/on comparison with more reliefcd ~/work/pixelart-studies/elements/ground/src && sed -i '' "s/crest_band=0, mass_turn=0, \*\*{\*\*kw/crest_band=0, mass_turn=0, lit_key=0, shadow_key=0.3, **{**kw/; s/relief=1.0, shade=0.3, shade_scale=5,/relief=1.8, shade=0.3, shade_scale=5,/" s11_sweeps.py && grep -n lit_key s11_sweeps.py && OPENBLAS_NUM_THREADS=1 nice -n 10 python3 s11_sweeps.py && python3 -c "import ground; print(ground.ramp('turf','golden').tolist())"
# output:
10: a = ground.paint_ground(300, 180, crest_band=0, mass_turn=0, lit_key=0, shadow_key=0.3, **{**kw, 'seed': sd}).rgb
[[1, 10, 10], [8, 23, 16], [38, 75, 59], [81, 79, 37], [127, 107, 36], [186, 137, 45]]Read: elements/ground/stages/s11_sweeps_3x.png
120. elements/ground/stages/s11_sweeps_3x.png
Bash: Regenerate sheets after keying changescd ~/work/pixelart-studies/elements/ground/src && (OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 sheets.py; OPENBLAS_NUM_THREADS=1 nice -n 10 python3 compare.py; OPENBLAS_NUM_THREADS=1 nice -n 10 python3 s6_driftwood.py) 2>&1 | tail -1 # output: wrote F_scenes
Read: elements/ground/sheets/F_scenes.png
121. elements/ground/sheets/F_scenes.png
Bash: Add stage 5 and voxsim note to LESSONScd ~/work/pixelart-studies/elements/ground && python3 - <<'EOF'
p='LESSONS.md'
s=open(p).read()
add=r'''
## Stage 5: the two-value far band, and keying each family (+ a note for voxsim's near-paint lane)
**The pale-teal band, diagnosed by measurement.** On the hummock test my shadow was 83% step 2, while his is roughly 15% L2, 47% L6 and 38% L25. There were three causes:
1. The far-band haze lifted the black point so far that steps 0 and 1 became nearly the same colour.
2. Skylight on flat ground put the shadow body right at the top of its family.
3. My grass shadow ramp had a small gap between steps 1 and 2. His has a big one: L6 to L25.
Fixes:
- The haze lift is now gentle on the darks.
- The turf shadow ramp is L\* 3/8/29, with self-occlusion darkening applied to steps 0 and 1 only.
- **Each family is keyed by its own quantiles.** The shadow's 3rd, 50th and 97th percentiles map to steps 0.15, 1.3 and 2.3. The result is 13/31/56% against his 15/47/38%. `stages/s10_hummocks_4x.png` now reads as V16's mechanism: bright crest bands in a big-gap checker over dark feet.
This is a value plan rather than a lighting formula: whatever turning the land and light give, the family is stretched to fill its designed range.
**Sweeps in a lit meadow** (prompted by voxsim `renders/near/st8/dusk.png`, whose sward has lovely long tree shadows but a flat lit field). My lit family had the same flaw. Grass takes a weak N·L, so a sunny meadow on gentle swells came out as one ochre. Fixes:
- The lit turn is now exaggerated around flat, as the skylight is: `0.45·tanh(2.5·(N·L/sun_y − 1))`.
- Lit faces toward the bright sky get a little lift.
- The lit family is quantile-keyed too (`lit_key`, default 0.8): 3rd/50th/97th percentiles map to 3.05/4.35/5.75.
`stages/s11_sweeps_3x.png` uses a high camera like theirs, golden hour, with off-frame tree shadows. The left column has none of the new terms; the right has all of them. On the right, halftone swales and lit rises sweep across the meadow following the land, and the shadows turn with the skylight.
Honest note: the effect is real but modest where the land is gentle (bottom row). At 8× I also see a ruled edge where a sharp ridge crosses a shadow (top right, the skylight tanh), which needs softening.
### For voxsim's near-paint lane (the terms, in world space, iso-safe)
All of these are per-pixel values computed from the height field. Apply them *before* marks, inside each family, then quantize with ordered 2×2 plus jitter.
1. **convex** `= tanh(2.5·(h − mean(h on a 3 m ring)) / 0.6)`. Add about 0.9 step in shadow and 0.5 in light. Mound tops take the sky, feet sink. Scale the ring to the land's feature size.
2. **skylight turn**: `sky = 0.55 + 0.45·tanh(6·(n·s_sky − s_sky.y))`, where `s_sky` is up tilted about 40° toward the sun's azimuth. This is the whole form language of the shadow family, and at dusk the only one.
3. **lit turn**: `0.45·tanh(2.5·(n·sun / sun.y − 1))` steps, even for grass. The exaggeration is the point.
4. **crest band** (screen space, below occlusion edges and skylines): the V16 profile. There's a darker accent on the crest, the band peaks 25% of the way down, and it falls to a dark base where the next unit occludes. In iso, every place the land hides land behind it is such an edge.
5. **mass turn**: lit pools are hot inside and dither down about 6% of the frame toward their edge. Shadows get a dark core line about 3 px wide just inside the terminator, then lift inside.
6. **key each family by quantiles** so whatever of 1–5 exists fills the family's range. Target shares near Ferrari's: shadow about 15/45/40 across its three steps; light centred on the middle step with about 10% top light.
Code: `turn_masses`, `crest_profile`, and the keying block in `paint_ground` (search "key the").
'''
s=s.replace("\n## Honest comparison with Ferrari", add + "\n## Honest comparison with Ferrari",1)
open(p,'w').write(s)
EOF
grep -n "^## " LESSONS.md
# output:
16:## Stage 0: what Ferrari's ground is made of (read from his index maps)
27:## Stage 1: masses from landform and light (the core of the technique)
46:## Stage 2: texture inside the masses, by material
57:## Stage 3: distance
62:## Stage 4: turning the masses (the V16 hummocks and the "construction paper" problem)
82:## Stage 5: the two-value far band, and keying each family (+ a note for voxsim's near-paint lane)
118:## Honest comparison with Ferrari (`stages/compare_ferrari_4x.png`, 4×)
132:## Using this ground (for cobbles, driftwood, willow, channel)
165:## RequestsSubagentHandback: The two-colour far band is in and I've sent the report. The flat lit meadow in the voxsim frame is improved too, but only a little where the land is gentle. I haven't checked any of this at 8×.
The main fix is that each value family (lit and shadow) is now scaled by its own spread. Whatever shape the land gives then fills the whole range instead of piling into one colour. My shares are now 13 / 31 / 56% against his 15 / 47 / 38%. stages/s10_hummocks_4x.png now works like V16: bright bands under each crest, checkered against dark feet.
- Lit meadow sweeps: My own lit meadow had the same flaw as the voxsim render. Grass barely responds to slope, so gentle swells came out one flat ochre. I exaggerated the lit side's response to slope and scaled the lit family the same way. stages/s11_sweeps_3x.png uses a high camera like theirs, before on the left and after on the right. The after column shows darker hollows and lit rises sweeping across along the land. Two limits:
- On gentle land (bottom row) the effect is small.
- A sharp ridge crossing a shadow leaves a hard straight edge (top right) that needs softening.
- For the renderer: I can only write in my own folder, so I changed nothing in the voxsim worktree. The Stage 5 section of LESSONS.md ends with a self-contained note for the near-paint lane. It lists six terms computed from the height field, each with its formula and pointers into my code.
- Still open: The forest pool of light is still the wrong shape. It should be a narrow ribbon along a path, as in Ferrari's V09.
All the sheets, the Ferrari comparison and the driftwood-on-ground sheet are regenerated. The noon hill and dusk terraces in sheets/F_scenes.png now show more form inside their shadows. Everything is in ~/work/pixelart-studies/elements/ground/.