Flora: grass and flowers for Eventyrland
Research portfolio, 2026-10-01. For the agent who builds it: everything needed is in this directory. Read sections 1, 4 and 5 before touching code; 2 and 3 are the reasons.
art/: 29 kept images, 7 rejected, 2 mock-ups, 4 texture previews,layers.svgtextures/gust.png,textures/wash.pngandtools/make_textures.py: the two textures the design needsprompt-blocks.md,prompts.json: the promptssources.md,notes/: where the findings come from, and what could not be confirmedopen-questions.md: what Petter decides
Nothing here was run in the game. The shader code is written against the game's files as they were on 2026-10-01 and has not been compiled. Every GPU time for the new design is an estimate; section 4.9 says how each was made.
1. What is drawn today
Read from src/world/grass.ts, grass-fill.ts, flora-cards.ts, wind.ts, tiles/meshes.ts and the stills in
a local file and artdir/look2/sheets/.
How. three.js 0.186, the WebGPU renderer, TSL node materials (MeshLambertNodeMaterial). Three layers, each a pool
of 20 m chunks refilled round the camera, one instanced draw a chunk:
| Layer | Geometry | A chunk holds (high / medium / low) | Sinks away at | Pool (medium) |
|---|---|---|---|---|
| grass | a clump of 3 blades: 13 vertices, 7 triangles | 2 600 / 1 690 / 910 clumps | 34 / 30 / 24 m | 25 chunks |
| flowers | two crossed quads, alpha test 0.5 with alpha-to-coverage | 400 / 260 / 200 cards | fade + 6 m | 37 chunks |
| reeds | 21 triangles | 360 / 234 / 180 | fade + 10 m | 37 chunks |
- Colour: a clump's colour is the terrain colour map at its root (
tileColour, per instance on the CPU), times a random 0.85–1.15. The root is that colour × 0.5, the tip × (1.16, 1.16, 1.02). - Light: the normal is world up for every blade (
transformNormalToView(vec3(0, 1, 0))), so grass on a slope is lit as flat ground while the ground under it is lit by its own slope. One shadow tap. Backlit tips glow (0.22). - Wind:
windGustis two sines of the position along the wind (wavelengths 84 m and 203 m, moving at 17–19 m/s), so gusts are soft parallel swells with no front. A gust adds (0.20, 0.19, 0.10) × gust² to the grass and (0.07, 0.07, 0.04) to the ground. - Her feet:
uPusheris read by every grass, flower and reed vertex and never written (docs/perf-review.md5). It stays at the origin, 999 m down. - Beyond the fade: the ground: colour map × two luma-only noises (9 m and 47 m) × cloud shade × gust shimmer, with the flower drifts as baked speckles.
- Flowers: eight kinds, a cute and a painterly painting of each, 256 px cells. A card is a whole clump with its leaves. A drift's middle holds 2–3 cards a square metre, all one size ± 15 %.
What it costs (derived by replaying poolOffsets and the keep rule; medium tier):
- About 13 chunk-equivalents of grass in view: 22 000 clumps, 154 000 triangles, 286 000 vertex invocations a
frame, 13–17 draws.
docs/perf-review.mdderives 201 000 on high. - A chunk is visible until its centre is 45 m away while the fade ends at 30 m, so 15–37 % of the clumps run their
vertices and draw nothing (
docs/perf-review.md, open point 4). - A full grass chunk takes 3.44 ms to fill and a flower chunk 2.0 ms on the server; near her the fill ignores its budget
(
stop = near && urgent < 2 ? Infinity : until), which gave frames of 5 ms for each layer. - GPU time of the grass alone has never been measured. Two lines in
docs/lighting.mdgive a rough size: one shadow tap instead of five saved 0.05 ms in Chrome on the M3 Pro and was "about 40 %" of the grass's GPU time, so the grass is of the order of 0.1 ms there and 0.3–0.5 ms on the A16 by the ×3 rule. Treat it as a guess until?hide=grassis measured (section 7). cloudShade(p.root.x, p.root.z)sits incolorNodewithoutvarying(). Check the generated WGSL: if it runs in the fragment stage, every grass pixel pays a texture read that could be one per vertex.
What is wrong with the picture, from the stills:
- The grass reads as single spikes. The root at half the tip's value puts a dark and a light stripe on every blade, so a meadow is noise at the blade's scale and has no shapes at the scale of a metre.
- One green. Nothing changes hue across a field; only the baked map and a little luma noise.
- The field ends. At 30 m the blades sink into a flat mottled plane.
- Gusts cannot be seen as shapes: a swell of + 20 % with no edge.
- The flowers are stickers: one card repeated at one size on an even spacing, each with its own dark leaf rosette
in a green that is not the grass's (
art/mock-sit-inside.webp, left half). - Outside, the painted cards are dark and dull (lupin
#454B8F, clover#7F1F36) and the paint pass mutes them further. - Nothing answers her.
2. What "painterly and dynamic" looks like
Sources and their limits are in sources.md and notes/painted-meadows-research.md. No craft source was found in
which the artists of Amulet, Nordlys or the films describe how they paint grass; the rules below rest on what the
sources do say about backgrounds, on landscape-painting teaching, and on the target paintings made here. Lines
marked observation are a description of the published art from memory and were not checked against a page.
The rules
Each rule can be checked against a frame.
R1. Masses before blades. A meadow is two or three colour shapes. Blades are drawn only where the grass has an edge against something else: the nearest 3–4 m, the crest of a rise, the rim of a path or a stone. In the middle of a field there is colour and a few brush marks, no blades. Check: in a lit patch of meadow 8–20 m away, the standard deviation of display luma inside a 64 × 64 px window is under 0.035 (measure today's first; the target is at most 60 % of it). From: Gurney, "big shapes first to small shapes last"; Wish, "They tend to paint things with less detail they don't want you to focus on"; Kibuishi's backgrounds, "more implied than realized" (a reviewer's words). Amulet's fields as soft bands with a ragged fringe at the bottom edge and on crests is observation.

R2. A blade's foot is the ground's colour. The darkest thing in a tuft is its foot, and the foot is only a little darker and cooler than the ground beside it: × 0.86 inside, × 0.78 outside, never × 0.5. Light sits at the outer and upper edges. From: Gurney, foliage is "lightest at its outer and upper edges, and darkest at its base or its core".
R3. Vary hue, hold value. Across a lit field the value stays within ± 0.05 display luma; the hue wanders between
a warm and a cool version of the zone's green in patches 10–40 m across, with dry, yellowed patches 5–15 m across.
Check: sampled every 4 m across a lit field, hue spreads at least 6° inside and 4° outside; luma within ± 0.05.
From: Dan Scott on grass (a compressed value range, vary hue and saturation); the target paintings, where sunlit
grass measures #BAB240 to #D3C558 inside and #7F794F to #89814B outside: red nearly equal to green, far
yellower than the game's #6DB33F.
R4. Four values in a meadow frame. Sky lightest, the lit ground next, slopes and cloud shade next, upright things (trees, the wall, her) darkest. A lit meadow is lighter than any tree beside it. From: Carlson's value planes (via Mitchell Albala). The paint pass's bands agree: lit meadow 0.30–0.35, shade 0.15–0.24.
R5. Light crosses the field as shapes. A cloud shadow is one flat shape with a soft edge, cool, never dark
(display luma 0.24 outside, measured #2F4243 in art/target-gust.webp). Use one of Gurney's two schemes: shade near
with a patch of light in the middle distance, or a fringe of light near with the shade behind it.
R6. A gust is a band of pale grass with a front. The leading edge is sharp (1–2 m), the tail fades over
15–35 m, bands lie 20–40 m apart and bend with the ground. In the band the grass is lighter by about 0.15 luma and
less saturated (#9B9771 against #777141 between the bands), because the blades show their pale sides. Flowers in
the band lean, and seeds and petals travel with it.
From: art/target-gust.webp. Ghost of Tsushima varies the wind's strength over the land with moving Perlin noise
"which is what shows as gusts on large fields" (Rockenbeck). Brightening the grass in a gust is a tutorial
convention; no shipped game's source was found for it.

R7. Flowers are masses too. A drift is one kind and one colour, with a ragged edge and a thick middle. "A mass of one shape, not a collection of dots" (Karen Margulis); avoid "polka dot meadows". Inside, drifts are small (3–6 m across) and many; outside, few and big (10–30 m), and at least two thirds of a meadow is open grass on both sides of the wall. Check: no flower kind is spread evenly over a field; the share of meadow ground under flower cards or dabs is at most a third.
R8. Three sizes of flower. Near (to 8 m): a few big simple shapes, in groups of three, one large and two small.
Middle (8–40 m): dabs, a hand's width each. Far: a patch of colour with a soft edge, no dots. The same drift is all
three as she walks up to it.
From: art/target-inside-eye.webp, art/target-far-drifts.webp; Margulis, masses shrink and change colour with
distance.

R9. Detail is spent at her feet and on the skyline. The fringe of drawn blades is the nearest 4 m and the crests. Flower cards are hero shapes within 8 m. Nothing else gets detail. From: Frozen II, clearing the ground where the characters are and lighting for their silhouettes; Arcane, detail kept where characters touch the set (80.lv).
R10. The far field is a wash. Beyond the blades: flat colour shapes that follow the land, cooler and lighter with distance, the warm colours dropping out first. From: Wish, "a card that had a colored wash with a bit of texture to it and that would suffice"; Gurney on distance.
R11. No line on grass. Line belongs to figures and silhouettes; grass and flowers are paint. From: Nordlys (the figures' contours stand out "foran de maleriske bakgrunnene"), Amulet (thin single-weight line on characters, painted backgrounds), Wolfwalkers' forest ("The line was almost disappearing in places"), and the rulebook already.
R12. The wall is the change of hand. Inside: soft, blended, round, bright, tidy. Outside: visible strokes, combed by the wind, muted, with flowers as the glow. Near the gates the two meet over a band, as Wolfwalkers' forest edge does ("some of the trees were angular and had more of a thick line"). From: Wolfwalkers (lino-cut town, watercolour forest); The Sea Beast ("controlled gardens are the perfect counter point to the wild nature").
R13. Dusk keeps the colour. At dusk the meadow is three greens (the glow-facing olive, the body, blue-violet hollows), the fringe takes a peach rim, the flowers go a little deeper and the white ones glow. The game's rule (ground luma ≥ 0.2, hue green) stands.

What the games do
| Game or source | What is confirmed | Use here |
|---|---|---|
| Ghost of Tsushima (Wohllaib, GDC 2021, read through two write-ups) | 83 000 blades on screen in 2.5 ms on a PS4; 15 vertices a blade near, 7 far; far tiles twice as big with the same blade count; clumps from voronoi cells with shared height, facing and tilt; wind from scrolling 2D noise; normals rounded near and blended to the terrain's far off | clumps with shared parameters; a cheaper far version in the same tile; a scrolling gust texture |
| Breath of the Wild (data-mined, community) | a 64 × 64 grid a tile holding blade height and r, g, b; "its color automatically matches the ground textures" | grass colour is the ground's colour; the far field is the ground |
| Stylised-grass recipe (Cyanilux, Roystan, Ilett, Smyth) | normals up or the terrain's; two colours root to tip; a scrolling wind texture; a scrolling cloud texture | what the game already half does |
| Hexaquo's Godot series | blades fade out over 10–20 m while a ground stand-in fades in; from above the real meadow is darker than the stand-in | match the ground to the grass seen from above, not to the tips |
| Horizon Zero Dawn (80.lv interview) | alpha-tested vegetation drawn first depth-only, then with a plain depth test | not available here without a prepass; keep alpha-tested cards few |
| Apple (WWDC 2020, 10602) | hidden surface removal is "pixel perfect and submission order independent" for opaque; draw "First, opaque. Second, alpha test, discard or depth feedback. And third, and finally, translucent" | grass stays opaque triangles; cards are drawn after it |
| Sable (GDC 2022) | fog "had the biggest impact overall"; outline fades with distance | haze carries the far field |
Not confirmed: anything on the grass of Genshin Impact, Tears of the Kingdom, Ori, Kena or Tunic (no technical source found); BotW's far-field treatment and bands (community observation only).
3. Techniques for three.js on an A16
The iPad (A16) has a 4-core GPU, 2360 × 1640, and draws the game at pixel ratio 1.35 on 1180 × 820 (1593 × 1107) with 4× MSAA. It is a tile-based deferred renderer: opaque triangles are sorted by the GPU itself, and any shader that discards breaks that for its pixels.
| Question | Options | Choice and why | Cost |
|---|---|---|---|
| Blades, cards or clumps | single blades (SimonDev: 3 072 a 10 m patch, 14 vertices near, 4 far); alpha cards of grass; clumps of opaque blades | clumps of opaque blades. Opaque keeps hidden-surface removal; a clump shares one colour and one lean, so it reads as a brush mark; one instance carries five blades | triangles and vertices, no overdraw beyond the nearest blade |
| Normals | per blade; rounded; world up; the terrain's | the terrain's normal, one a tuft. The field then shades as one surface and follows its slope | 2 bytes an instance, 0 shader cost |
| Colour | fixed two-colour gradient; from the terrain map | root = the ground's exact colour, tip = the zone's tip colour, mixed along the blade | 4 more bytes an instance |
| Large-scale colour | per-instance random; a noise texture | a wash texture (value, warm/cool, dry patches) read by the ground per pixel and baked into the tuft's root on the CPU | 0 for grass; the ground swaps two noise reads for two wash reads |
| Wind | sines (today); scrolling noise; a painted gust texture | a gust texture with fronts, scrolled downwind, one read a vertex; the same bytes read on the CPU for the motes | one vertex texture read instead of five sines |
| Her feet | one uniform; an array; a trample render target | an array of four pushers (her now, her 0.3 s and 0.6 s ago, one companion). A render target is a pass and a target the frame cannot afford | 4 × (length, smoothstep) a vertex |
| Cloud shadows | have them (cloudShade) | keep; make sure it is per vertex on grass | none new |
| Tips | backlit glow (have); a specular sheen toward the sun | keep the glow and tie it to the gust; no sheen: the paint pass's bands would flatten a highlight that is only brighter | none new |
| Beyond the fade | impostor cards; shells; the ground itself | the ground itself: same colour function as the blades' roots, gust light that grows where no blades show it, drifts baked as patches | none new |
| Flowers | crossed cards (today); one camera-facing quad; meshes | one camera-facing quad (as the drawn trees), plus dab flowers as opaque geometry on the tufts | half the card triangles and overdraw; 10 triangles a near tuft |
| Alpha | blending; alpha test; alpha-to-coverage | alpha test with alpha-to-coverage, the alpha sharpened by its screen derivative (Golus), mips built to keep coverage | as today |
| Draws | one a chunk and layer | unchanged: ≤ 17 for grass, ≤ 12 for flowers in a rich meadow | within the 150-draw rule |
Sources for each row: sources.md. Published numbers for grass on an Apple mobile GPU or in Safari's WebGPU were
looked for and not found.
4. The proposal: washes, tufts, dabs and sprays
One system for the whole world. A tuft is a brush mark; a field is the ground's wash showing through tufts of its own colour; flowers come in three sizes; one gust texture moves all of it.
4.1 The numbers at a glance (medium tier, the iPad)
| Layer | Geometry | Instances a chunk | In view | Textures | Fades |
|---|---|---|---|---|---|
| 0 Bake | none (the tile's colour map) | the colour maps, as today | never | ||
| 1 Ground wash | none (the ground shader) | wash 256² RGBA8, gust 256² RG8, both made at boot | never | ||
| 2 Tufts | near: 39 vertices, 23 triangles; far: 9 vertices, 3 triangles (the same buffers, another index range) | 1 144 at most (4.4 a m² × 0.65); Hjemdalen and Nordalen 1 040 | ≤ 5 near chunks, ≈ 8 far chunk-equivalents: 145 000–159 000 triangles, 278 000–306 000 vertex invocations, 13–17 draws | gust (vertex), the noise texture (cloud shade, vertex) | sinks 22–30 m |
| 3 Dab flowers | in the tuft: two stalks with a 5-vertex head each (16 vertices, 10 triangles of the 39 and 23) | on 30–35 % of the tufts inside a drift | counted in layer 2 | none | with the near chunks (24 m) |
| 4 Spray cards | one quad, 4 vertices, 2 triangles | ≤ 200 | ≤ 2 400 cards, ≤ 4 800 triangles, ≤ 12 draws | the flower atlas, 2048 × 1024 KTX2 (≈ 2.7 MB on the GPU, up from 1.35) | shrinks 30–40 m |
| 5 Air | the motes pool | 24 of 200 quads | none new |
Today: 154 000 triangles, 286 000 vertex invocations, and up to 260 cards a chunk as 4-triangle crosses. The design is triangle-neutral by construction; what it buys is spent on shape (five blades a tuft instead of three, at 62 % of the instances) and paid for by the far chunks' three triangles a tuft.
4.2 Layer 0 and 1: the bake and the ground wash
The ground carries the meadow's colour everywhere, and the blades agree with it.
One colour function.
meadowWash(x, z)in a new pure filesrc/world/meadow.ts, with a TSL twin:w = wash texture at (x, z) / 128 m // R broad value, G warm/cool, B dry patch, A tuft mottle value = 0.87 + 0.22 · w.r + 0.08 · (A at (x, z) / 9 m − 0.5) hue = mix(COOL, WARM, smoothstep(0.3, 0.7, w.g)) COOL = (0.94, 1.00, 1.05) WARM = (1.07, 1.00, 0.88) dry = mix(1, (1.12, 1.02, 0.78), 0.35 · w.b) ground = map.rgb · value · mix(1, hue, K) · dry K = 0.6 inside the wall, 1 outside (wallOutside())The ground shader (
tiles/meshes.tsgroundTint) uses it per pixel in place of today'smottleandbroadreads. The grass fill evaluates the same thing in JS at each tuft's root and writes the result into the tuft's root colour, so a blade's foot is the ground's colour to the byte. Preview from above, 128 m across, with one gust and one cloud shadow:art/wash-preview-hjemdalen.webp,-nordalen,-sormarka.
Gust light on the ground.
× (1 + (0.12, 0.10, 0.02) · g² · k)withgthe gust texture andkrising from 0.6 under the blades to 1.8 beyond the fade (per vertex, by distance), so a band that bends the tufts at her feet carries on across the far field as a band of light (R6, R10).The match from above. Seen from above, a meadow shows more foot than tip, so it is darker than its tips. The ground under blades is therefore the foot's colour (× 0.86 or 0.78) within the fade distance, easing to × 1 over the last 8 m of the fade, where the blades sink. Without this the field gets lighter where the grass ends.
Drifts in the bake.
field.tsbakeColorandtools/world/lib/colour.tsalready bake speckles. Change: a drift first tints its whole footprint 18 % toward its speckle colour (soft-edged, the drift's own density), then lays the speckles on top. From 100 m a drift is then a patch (R8), not a dotted lawn. World rebuild on deploy.Zone colour. The bake's meadow colour per region becomes the
bodycolumn of section 5's table.
4.3 Layer 2: tufts
tuftGeometry() replaces clumpGeometry() in grass.ts. Metres at height 1 and width 1; aT is the height
fraction, aPart 0 on blades, 1 on a stalk, 2 on a head's rim, 3 on a head's middle.
| Blade | Angle | Height | Root width | Lean at the tip | Rows | Vertices | Near triangles | Far triangles |
|---|---|---|---|---|---|---|---|---|
| A | 0.2 rad | 1.00 | 0.050 | 0.26 | root pair, pair at 0.5 (0.62 wide), tip | 5 | 3 | 1 (root pair + tip) |
| B | 2.3 | 0.82 | 0.046 | 0.34 | same | 5 | 3 | 1 |
| C | 4.3 | 0.66 | 0.046 | 0.30 | same | 5 | 3 | 1 |
| D | 1.3 | 0.48 | 0.042 | 0.38 | same | 5 | 3 | 0 |
| E | 5.4 | 0.34 | 0.040 | 0.20 | root pair, tip | 3 | 1 | 0 |
| stalk 1, 2 | 3.3, 0.9 | 0.92, 0.70 | 0.012 | 0.18 | root pair, top | 3 each | 1 each | 0 |
| head 1, 2 | on the stalk's top | 1 across | a middle and four rim points | 5 each | 4 each | 0 |
Total 39 vertices. The index buffer holds the far triangles first (9 indices), then the near ones (69): a chunk
whose centre is nearer than 24 m draws setDrawRange(9, 69), the rest setDrawRange(0, 9). Far tufts are widened
as today (widen = 1/36 a metre) and thinned by today's keep. To verify in three 0.186: that drawRange on an
indexed InstancedBufferGeometry limits the indices of every instance on the WebGPU backend; if it does not, give
the pool two index buffers and swap with setIndex.
Rule for the paint pass: outside the wall a blade is at most 0.035 m wide at half its height (A: 0.050 × 0.62 × width, so the instance's width is 0.7–1.1 outside; inside, where the pass is off, 0.9–1.3). At the game's 50° lens on the iPad (focal length 879 CSS px) that is under 8 px beyond 3.9 m, which is the pass's test for "too thin for ink".
Instances (grass-fill.ts), four attributes of today plus two:
| Attribute | Type | Holds |
|---|---|---|
aPos | 4 × f32 | x, y, z, yaw (as today) |
aProp | 4 × f32 | height, width, phase, look (0 inside the wall … 1 outside, wallLook; was warmth) |
aCol | 4 × u8 | the ground's colour at the root (map × wash, linear bytes), a: the ground normal's x (0..1 for −1..1) |
aTip | 4 × u8 | the tip colour (below), a: the ground normal's z |
aBloom | 4 × u8 | the dab flower's petal colour, a: head size (0 none; × 0.10 m) |
Seven vertex buffers of WebGPU's eight (position, aT+aPart as one vec2, and five instanced). 44 bytes an
instance, 1.3 MB for the pool.
Where tufts grow. As today, plus clumping: a candidate survives with probability
c0 + (1 − c0) · smoothstep(0.35, 0.6, noise2(x / 1.7, z / 1.7)), c0 0.45 inside and 0.2 outside, so tufts stand
in tussocks about 1.5 m across with the wash showing between them (Frozen II's nested clumps; Ghost of Tsushima's
clump cells). Height, per zone and kind of ground: section 5. All blades of a tuft share its height, colour and
lean.
Tip colour, on the CPU: tip = mix(root, zoneTip, 0.75), then toward the zone's dry colour by
0.5 · wash.b, then toward straw by tallGrassAt. The map's own variation survives in it.
The shader (replaces the grass block of createGrass; TSL as grass.ts uses it, not compiled):
const aProp: N = attribute('aProp', 'vec4'), aCol: N = attribute('aCol', 'vec4'), aTip: N = attribute('aTip', 'vec4')
const aBloom: N = attribute('aBloom', 'vec4'), tt: N = attribute('aT', 'vec2').x, part: N = attribute('aT', 'vec2').y
const look: N = aProp.w
const p = placeInstance(positionGeometry, aProp.x, aProp.y, tt.mul(tt), aProp.z, fadeEnd - 8, fadeEnd, 0.42, 1 / 36)
mat.positionNode = p.position // placeInstance (4.4): the new gust and pushers; it also returns `gust`
// One normal a tuft: the ground's, so a field shades as one surface and follows its slope.
const nx: N = aCol.a.mul(2).sub(1), nz: N = aTip.a.mul(2).sub(1)
mat.normalNode = transformNormalToView(normalize(vec3(nx, float(1).sub(nx.mul(nx)).sub(nz.mul(nz)).max(0.2).sqrt(), nz)))
// Foot = the ground's colour, a little darker; tip = the zone's; in a gust the upper half turns pale.
const footK: N = mix(float(0.86), float(0.78), look)
const gustTip: N = mix(vec3(0.84, 0.91, 0.56), vec3(0.76, 0.78, 0.60), look) // #D6E88F inside, #C2C89A outside
const g2: N = p.gust.mul(p.gust)
const tip: N = mix(aTip.rgb, gustTip, g2.mul(mix(float(0.45), float(0.7), look)))
const blade: N = mix(aCol.rgb.mul(footK), tip, smoothstep(0.1, 0.9, tt))
// The dab flower: the stalk in the blade's colour, the head's rim the petal colour, its middle a warm eye.
const head: N = mix(aBloom.rgb, vec3(1.0, 0.59, 0.05), smoothstep(2.5, 3.0, part).mul(0.85))
const col: N = mix(blade, head, smoothstep(1.5, 2.0, part))
mat.colorNode = varying(col.mul(cloudShade(p.root.x, p.root.z))) // per vertex: no fragment texture read
const back: N = pow(max(dot(normalize(p.root.sub(cameraPosition)), uSunDir), 0), 3)
setEmissive(mat, varying(tip.mul(vec3(uSunLight)).mul(back.mul(tt).mul(p.gust.mul(0.4).add(0.6)).mul(0.22))))The heads are placed in placeInstance's caller: a rim vertex is moved from the stalk's top by
(right · x + up' · y) · aBloom.a · 0.10, with right = normalize(cross(vec3(0, 1, 0), view)) and up' world up
leaned a third toward the camera, so a head is a small disc turned to her; aBloom.a = 0 collapses it. Head sizes:
0.06–0.09 m inside (chunky), 0.04–0.06 m outside.
4.4 Wind and her feet (wind.ts)
// The gust field: a 256² texture with fronts (tools/make_textures.py is the reference), 128 m a tile, scrolled downwind.
export const gustData = buildGust() // Uint8Array(256 * 256 * 2): R gust, G flutter
export const gustTexture = new DataTexture(gustData, 256, 256, RGFormat) // RepeatWrapping, LinearFilter, no mipmaps
export const uGustOffset: N = uniform(0) // metres the field has travelled; += speed · strength · dt
export const GUST = { tile: 128, speed: 9 } // a front passes a spot every ~4.7 s at strength 1
export function windGust(px: N, pz: N): N {
const along = px.mul(uWindDir.x).add(pz.mul(uWindDir.y)), across = pz.mul(uWindDir.x).sub(px.mul(uWindDir.y))
return texture(gustTexture, vec2(along.sub(uGustOffset), across).div(GUST.tile)).r
}
/** The same field on the CPU (motes.ts): bilinear in gustData. Keep in step by construction: one array. */
export function windGustAt(px: number, pz: number, _t: number, wx: number, wz: number): number { /* sample gustData */ }
/** Who bends the grass: x, z, radius, strength. Slot 0 her feet, 1 and 2 where she was 0.3 and 0.6 s ago, 3 a companion. */
export const uPushers: N = uniformArray([new Vector4(0, 0, 1, 0), new Vector4(0, 0, 1, 0), new Vector4(0, 0, 1, 0), new Vector4(0, 0, 1, 0)])
export function pushAt(root: N, bend: N): { xz: N; down: N } {
let xz: N = vec2(0, 0), down: N = float(0)
for (let i = 0; i < 4; i++) {
const q: N = uPushers.element(i), away: N = root.xz.sub(q.xy)
const near: N = float(1).sub(smoothstep(q.z.mul(0.15), q.z, away.length())).mul(q.w)
xz = xz.add(normalize(away.add(0.001)).mul(near)); down = down.add(near)
}
return { xz: xz.mul(bend).mul(0.75), down: down.min(1).mul(bend).mul(0.3) }
}windGustkeeps its name and signature, so the ground, the water, the drawn trees and the bushes follow the new fronts with no change of their own. In the vertex stage three samples at level 0 (assteep.ts's ring texture already does); the ground and the water read it per pixel as they do today.windSway:0.18 + 1.1 · g² + flutter, the flutter's phase offset by the texture's G so neighbouring tufts are out of step. The front's 1.6 m edge passing at 9 m/s whips a tuft over in 0.18 s; the tail lets it up over 2–4 s.createWindadvancesuGustOffset, sets the region's wind strength (section 5, eased like the grade) and writes the pushers fromgame.hero.position: radius 1.3 m on foot, 2.2 m at a run or riding; strengths 1, 0.55, 0.25. This fixes the deaduPusher: delete it.- Flowers take the same push, and their upper half wobbles for as long as a trail slot is near:
sin(time · 7 + phase) · 0.03 · h · (pushers 1 and 2). Elf flowers brighten by1 + 1.5 · near(pusher 0, 4 m). - Permanent lean: outside, a tuft's tip is offset downwind by
lean · height(section 5), so a still frame already shows which way the wind blows (art/tufts-design.webp, bottom row).

4.5 Layer 4: spray cards
Geometry: one quad, root at the bottom middle, turned to the camera round the vertical, its up leaned a third toward the view's up, as the drawn trees' cards (
painted-trees.ts). Half today's triangles and overdraw, and no cross seen from her camera above. Half the cards are mirrored (phasebit).Groups of three: an accepted candidate places one card at size 1.0–1.25 and two more within 0.35 m at 0.5–0.75. A drift's middle holds 0.7 groups a square metre (2.1 cards, as today, but grouped). Kinds, sizes and cells: section 6.
The foot is the grass: the lower 42 % of a card is pulled 75 % toward the ground's colour at its root (kept in
aCol), keeping the painting's own light and dark, and its alpha thins toward the bottom. Mock-up, today's card left, the proposal right (a 2D composite made with PIL, not a render):// The card's aCol: rgb the ground's colour at its root, a the look (0 inside, 1 outside). const look: N = aCol.a, back3: N = pow(max(dot(normalize(p.root.sub(cameraPosition)), uSunDir), 0), 3) const tex: N = texture(atlas.flowers, atlasUV(uv(), aProp.y, ATLAS.flowers.cols, ATLAS.flowers.rows)) const footM: N = float(1).sub(smoothstep(0.0, 0.42, uv().y)).mul(0.75) const lum: N = dot(tex.rgb, vec3(0.2126, 0.7152, 0.0722)).div(dot(aCol.rgb, vec3(0.2126, 0.7152, 0.0722)).max(0.02)).clamp(0.75, 1.25) const body: N = mix(tex.rgb, aCol.rgb.mul(lum), footM) mat.colorNode = body.mul(cloudShade(p.root.x, p.root.z)).mul(p.lit) // Alpha kept one pixel wide at any distance (Golus), then alpha-to-coverage as today. const a: N = tex.a.mul(float(1).sub(footM.mul(footM).mul(0.55))) mat.opacityNode = a.sub(0.5).div(max(fwidth(a), 1e-4)).add(0.5).clamp(0, 1) mat.alphaTest = 0.5; mat.alphaToCoverage = true // Outside, the blossoms are the frame's accents: a steady lift in the upper part, so the paint pass keeps them. setEmissive(mat, tex.rgb.mul(smoothstep(0.35, 0.8, uv().y)).mul(back3.mul(0.3).add(mix(float(0.04), float(0.14), look))))Heads nod: above
uv.y = 0.5a card takeswindSway × 1.6(today × 1 throughhy²·0.5) and a slow nod,sin(time · 2.1 + phase) · 0.02 · h.Fade: whole to 30 m, shrinking (not sinking) to nothing at 40 m. Pool 37 chunks, as today.
The build (
pipeline/flora/build.mjs): mips made so each level keeps the base level's coverage at alpha 0.5 (scale the level's alpha until it does); cells stay 256², the atlas becomes 2048 × 1024 (8 × 4).
4.6 Layer 5: the air
motes.ts already flies pollen, seeds and petals on windGustAt. Reserve 24 of medium's 200: every 0.4 s, for the
drift under her or the nearest one within 20 m, spawn one petal in the drift's speckle colour where
windGustAt > 0.6; spawn six at once at her feet when pusher 0 enters a drift's core, and dandelion seeds (white)
if the kind is dandelion (art/target-interaction.webp).

4.7 The paint pass and the grass
paint.ts is not changed by this design (one optional line, below). What its steps do to grass, and what the grass
must therefore be:
| Step | Effect on grass | Rule for the flora |
|---|---|---|
| Kuwahara (patches about 12 px) | melts blades into patches: R1 for free outside | make colour change at the scale of metres, which survives it; blade-scale contrast is wasted |
| Brush dabs (18 px cells, ± 34 %, world-anchored on the ground plane) | lays strokes over the field | no stroke texture in the grass or the ground; the tufts and the wash are the under-painting |
| Bands (pull 70 % toward 0.19 / 0.35 / 0.68 within 60 m) | flattens luma inside a band: a gust that is only lighter by 0.15 is squeezed to about 0.05 | outside, a gust is carried by hue and saturation: the pale, greyed gustTip (#C2C89A), not by brightness alone. A cloud shadow (× 0.74 on 0.33 = 0.24) lands on the lower threshold and becomes a painted shape, which is wanted |
| Palette (mute 30 % of what is not an accent) | mutes the greens further | zone greens in section 5 are the unmuted albedo; do not pre-mute |
Accent test (spread 0.3–0.6 × luma 0.5–0.85) | gold (#F6BE43, luma 0.76) and cyan pass; violet-blue and rose do not and are muted | outside blossoms are painted at luma ≥ 0.55; see open question 3 for the one-line change that lets violet through |
| Ink (never on things narrower than 8 line widths) | none, if blades are thin enough | blade ≤ 0.035 m wide at half height; check her camera's view for ink on near blades and raise nothing in the pass before trying a narrower blade |
| Rim | a warm edge on silhouettes against the far land | the fringe on a crest gets it; wanted (R13) |
Inside the wall the look is 0 and the picture is the grass's own, so everything painterly inside comes from the wash, the tufts' shape and the cards.
4.8 What replaces what, in build order
Each step leaves the game working. Scope in lines changed.
| # | File | Change | Scope |
|---|---|---|---|
| 1 | src/world/wind.ts, the hero's late update | pushers written each frame (the bug), uPusher removed; pushAt used by placeInstance | small, ~40 lines |
| 2 | src/world/wind.ts, motes.ts | the gust texture, uGustOffset, windGust and windGustAt on it; region wind strength | small, ~70 lines + tests/wind.test.mjs |
| 3 | new src/world/meadow.ts (pure) + its TSL part; tiles/meshes.ts groundTint | the wash texture, meadowWash, REGION_FLORA (section 5), the ground using it | medium, ~160 lines + tests |
| 4 | src/world/grass-fill.ts | tufts: counts, tussocks, zone height and density, root = ground colour, aTip, the normal, aBloom; cards in groups of three with three sizes | medium, ~140 lines; tests/flora-cards.test.mjs grows |
| 5 | src/world/grass.ts | tuftGeometry, the two index ranges, the tuft shader, the card as one quad with the foot tint; fill budget near her (below) | medium, ~200 lines |
| 6 | tools/world/lib/colour.ts, src/world/field.ts | zone body colours, drifts as tinted patches; world rebuild on deploy | small, ~60 lines |
| 7 | pipeline/flora/subjects.mjs, build.mjs, speckle.mjs, src/world/flora-cards.ts | the 32-card set, the 8 × 4 atlas, coverage-keeping mips | medium, ~120 lines + 32 paintings |
| 8 | src/world/motes.ts | petals and seeds from drifts | small, ~40 lines |
| 9 | docs/flora.md, docs/ground.md, docs/art-direction.md | describe the new state |
Steps 1–2 change nothing in the art and already make the meadow move. Steps 3–5 are the lift. Step 7 can run on the painter while 3–6 are built; until it lands, today's 16 cards work in the new shader (the foot tint already helps).
Two fixes from docs/perf-review.md belong in step 5:
- The fill near her:
const stop = near && urgent < 2 ? t0 + 2.5 : until. A near chunk that is not done in 2.5 ms is published as it is (the stream is in random order, so a part of it is a thin, even fill) and kept inthinfor the top-up that already exists.L.fill.startmoves inside the budget check. - Wasted vertices past the fade: far chunks now cost 9 vertices a tuft instead of 13 of 13, which removes most of it; nothing else is needed.
A switch for A/B, as the trees have: ?flora=0 draws today's grass and flowers, ?flora=ab builds both
(window.__floraAB(on)), for the stills and measure.mjs --ab flora.
4.9 Budget on the iPad, and what to cut
Estimates. Method: triangle and vertex counts from the tables above; fragment work unchanged per pixel (Lambert, one shadow tap, no texture read in the tuft's fragment stage); M3 Pro time × 3.
| Layer | Triangles | Draws | Estimated GPU on the A16 | Basis |
|---|---|---|---|---|
| Ground wash | 0 new | 0 | ± 0.05 ms | two wash reads and one gust read replace two noise reads and five sines a pixel |
| Tufts with dab flowers | 145 000–159 000 | 13–17 | 0.3–0.6 ms, as today's grass | same triangles and vertex invocations within 7 %; a vertex does two texture reads and four pusher tests where it did five sines |
| Spray cards | ≤ 4 800 | ≤ 12 | 0.1–0.2 ms | today's cards cost + 0.3–0.5 ms on the M3 in a drift (docs/flora.md, within its run-to-run noise); one quad instead of two halves the overdraw |
| Air | 48 | 0 new | 0 | the motes' pool |
| All flora | ≈ 165 000 | ≤ 30 | budget 1.0 ms, hard limit 1.5 ms | to be replaced by measure.mjs --ab flora and ?hide=grass,flowers |
If the iPad is over, cut in this order; each is a constant:
- The gust term of the backlight and the cards' nod (nothing else depends on them).
- Pusher slots 1–3 (keep her feet).
- Near chunks at 18 m instead of 24 m (three or four near chunks instead of five: − 25 000 triangles).
- Dab-flower heads on the low and medium tiers (a second index range without them: − 10 triangles a near tuft, − 50 000 triangles). The cards and the bake still carry the drifts.
- Tufts a square metre × 0.8, widths × 1.12.
- The fade at 26 m instead of 30 m.
5. Two sides, seven zones
The same code everywhere. What differs is a row of REGION_FLORA, the look value at each tuft (0 inside the
wall, 1 from 36 m outside it, the same LOOK_BLEND the paint pass uses, so the grass changes hand across the same
band as the picture does), and the card set.
Inside the wall (look 0) | Outside (look 1) | |
|---|---|---|
| Tuft | short, broad (width 0.9–1.3), upright | longer, narrower (width 0.7–1.1), leaning downwind |
| Foot | × 0.86, warm | × 0.78, toward teal |
| Tussocks | soft (c0 0.45): an even, tidy lawn | strong (c0 0.2): open ground between |
| Hue wander | 60 % | 100 % |
| Gust | a soft warm lightening, #D6E88F, 45 % | a pale greyed band with a hard front, #C2C89A, 70 %; the ground beyond shows it at × 1.8 |
| Drifts | 6 m grid, 80 % of cells, radius 1.4–3 m; dabs on 30 % of the tufts in a drift and 6 % outside one | the zone's row below; dabs only in drifts |
| Cards | the cute set: chunky, big heads, thick stems | the painterly set: flat strokes, leaning, blossoms in accent colours with a steady glow of 0.14 |
| Flower share of a meadow | up to a third | up to a sixth |
Zones
Colours are sRGB mid-tones of the albedo, as docs/art-direction.md gives its palettes. body is the meadow's
colour in the bake and so a tuft's root; tip, gust and dry as section 4. Heights are a tuft's blade A in
metres before the ground-kind factors; tall is the cap in patches of seed grass. Density is tufts a square metre at
vegetation 1 (× 0.65 on the iPad). Outside bodies hold 55–70 % of Hjemdalen's saturation, as the rulebook asks;
Alvheim's teal is at 72 % and is the exception.
| Zone | body | tip | gust | dry | Height (tall) | Density | Wind × | Lean | Flowers and drifts | Special plants |
|---|---|---|---|---|---|---|---|---|---|---|
| Hjemdalen (inside) | #6DB33F | #A4CF52 | #D6E88F | #B9BE4E | 0.20–0.38 (0.60) | 4.0 | 0.7 | 0 | lupin pink and violet, oxeye daisy, buttercup, harebell, red clover, forget-me-not, dandelion by lanes, cow parsley by hedges; drifts 1.4–3 m radius on a 6 m grid, 80 % | heather and cotton grass in the cute paintings on its moor and bog; thrift on its dunes; wood anemone under its birches |
| Nordalen | #5E7A43 | #8C9A52 | #C2C89A | #9A9450 | 0.35–0.60 (0.90) | 4.0 | 1.0 | 0.15 | globeflower (gold), harebell, oxeye daisy, violet lupin; drifts 5–12 m on a 24 m grid, 45 % | cotton grass round Speilvatnet's wet shore; wood anemone as white dabs in the birch wood; fireweed along Speilelva |
| Fjellfoten | #547246 | #7E9060 | #B4C0A0 | #8A8A56 | 0.25–0.45 (0.60) | 3.2 | 0.85 | 0.08 | harebell in the sun; wood sorrel as white dabs on moss; foxglove in threes at a wood's edge; drifts 3–7 m on a 20 m grid, 35 % | heather on Høgsetra's crest; ferns and moss carry Gammelskogen, grass there at a third |
| Sørvika | #6C8852 | #A3AD78 | #D2D6B4 | #B4AA72 | 0.30–0.50; marram 0.75–1.20 | 3.0 | 1.35 | 0.30 | Blomsterenga: lupin, fireweed, oxeye, globeflower, drifts 6–14 m on a 20 m grid, 70 % (its look is 0.85: the richest field outside); elsewhere few | marram on the crests, none in the hollows; thrift cushions in the lee |
| Sørmarka | #7A8048 | #B09A4A | #E2CF8E | #C09A4C | 0.45–0.70 (0.95); pasture 0.12–0.20 | 4.4 | 0.85 | 0.12 | buttercup and clover as dabs in pasture; oxeye; poppy and cornflower only within 4 m of a wall, a plot or a way; drifts 4–10 m on a 22 m grid, 40 % | seed-grass sprays in the hay; cotton grass on its bog; heather leaning ochre on its heath |
| Fjellet | #6E7448 | #A39258 | #D0C69A | #B0864B | 0.10–0.22 | 1.6 | 1.5 | 0.40 | moss campion cushions, mountain avens and glacier buttercup (white), in the lee of stones; drifts 0.8–2 m on an 8 m grid, 30 %, only where the slope is under 0.3 | cotton grass by Fjelltjernet and the snow-melt; dwarf willow and crowberry red in the ground's clumps (tiles/detail.ts) |
| Alvheim | #33605A | #7FCDB6 | #BFEFE0 | #4E8F7A | 0.20–0.35, width × 0.6 | 2.0 | 0.4 | 0 | elf bell (cyan #4FF3E0), elf star (violet #C77DFF), 4 bells to a star; ribbons 1.3–5 m from a way, rings 2–4 m round the old trees and Månebrønnen | the glow: emissive × uGlow, a slow pulse, brighter within 4 m of her; gold pollen from the motes |
REGION_FLORA is read like REGION_GRADE (world/grade.ts): by region id at the tuft's root in the fill, eased
across a border by the look value and the drift of the bake's colour, with Alvheim as a place over Fjellet
(LOOK_PLACES' circle).
Kinds of ground, in every zone
These factors exist in grass-fill.ts today; the numbers change as marked.
| Ground | Tufts | Colour | Flowers | Target |
|---|---|---|---|---|
| meadow | the zone's row | the zone's row | the zone's drifts | |
| pasture | height × 0.45 | tip toward #B8C050 | buttercup, clover, daisy as dabs, no cards | |
| moor | half the tufts (as today), height 0.22–0.35, width × 0.6 | straw: tip #B59A5C, no gust tint | heather cards as a carpet (floor 0.35, as today), bloom thickest where wash.g is warm, so it lies in drifts | art/target-moor-heather.webp |
| bog | height × 1.35 | sedge: body toward #6B6A3A, tip #A8843C, wine red #7A3A34 in the ground's clumps | cotton grass cards (floor 0.15), in bands | art/target-bog-cotton.webp |
| dune | marram: height 0.75–1.20, width × 0.75, lean × 2, only where dune > 0.25 and by today's thinning | #8EA078 to tip #C9C08A | thrift cushions, 1 a 6 m², in the lee (noise) | art/target-dunes-marram.webp |
| birch | × 0.8 | tip 10 % lighter | wood anemone and forget-me-not as dabs, harebell cards | |
| grove, oldwood | a third | moss, the map's own | harebell | art/zone-fjellfoten.webp |
6. Flower art
Is the painted clump card the right unit? The painted card is right; the clump is not. A clump with its leaf rosette brings its own green and its own shade into a field that already has grass, and repeated at one size it is a sticker. The unit becomes a spray: stems and blossoms, with the grass as its leaves.
Rules for a card (each checkable on the cut-out):
- Blossoms cover more of the card's solid pixels than stems and leaves do.
- A stem is at least 4 px wide in the 256 px cell (1.6 % of the cell). The first round's stems were 6–8 px at 1024
and vanished in the mips (
art/rejected/card-inside-lupin-v1.webp). - Three to six stems of uneven height from one point at the bottom middle; one leans out.
- No leaf rosette, no dark leaves. Any leaf is in the zone's tip colour and sits in the lower 42 %, which the shader tints.
- Three values on a blossom: light, middle and a coloured shade (violet-blue inside, teal outside). Nothing under 0.2 luma.
- Outside, blossoms are at least 0.55 display luma and leaning right (the game mirrors half and the wind does the rest).
- Lit from above, no cast shadow, plain light grey behind, as
FRAME_FLOWERasks today.
The two looks, as painted here:
Single cards, which is how the pipeline paints them:
On hosted Krea v2 (Venice's krea-v2-large, which may differ from the Mac's Krea 2) the same blocks give a flatter,
bolder card, closer to the outside look than gpt-image's:
The set: 32 cells of 256², one 2048 × 1024 atlas, rows 0–1 inside, rows 2–3 outside. h is the card's height in
metres at size 1; a group is one card at 1.0–1.25 and two at 0.5–0.75.
| Cell | Inside (cute) | h | Cell | Outside (painterly) | h |
|---|---|---|---|---|---|
| 0 | lupin, pink and cream | 0.68 | 16 | lupin, violet-blue | 0.68 |
| 1 | lupin, violet | 0.62 | 17 | harebell | 0.38 |
| 2 | oxeye daisy | 0.50 | 18 | globeflower | 0.50 |
| 3 | oxeye daisy, small | 0.36 | 19 | oxeye daisy, cream | 0.50 |
| 4 | buttercup | 0.45 | 20 | fireweed | 0.95 |
| 5 | harebell | 0.38 | 21 | foxglove | 1.00 |
| 6 | red clover | 0.24 | 22 | heather | 0.32 |
| 7 | forget-me-not | 0.26 | 23 | cotton grass | 0.45 |
| 8 | wood anemone | 0.20 | 24 | seed grass | 0.85 |
| 9 | dandelion with seed clocks | 0.30 | 25 | thrift (cushion) | 0.18 |
| 10 | cow parsley | 0.80 | 26 | moss campion (cushion) | 0.10 |
| 11 | poppy | 0.50 | 27 | mountain avens | 0.12 |
| 12 | heather | 0.32 | 28 | poppy | 0.50 |
| 13 | cotton grass | 0.45 | 29 | cornflower | 0.50 |
| 14 | thrift (cushion) | 0.18 | 30 | elf bell (glows) | 0.40 |
| 15 | seed grass | 0.60 | 31 | elf star (glows) | 0.60 |
Red clover, buttercup, daisy, forget-me-not, wood anemone and wood sorrel also exist as dab flowers (petal colour,
warm eye): #DF4A6A, #FFC405, #FDFCF7, #78B2F5, #FDFCF7, #F5EFD1; outside gold #F6BE43, harebell
#8A8CF0, cream #F5EFD1.
Cushion plants (thrift, moss campion) keep their mound of leaves: they stand on sand and rock. Their foot tint is
off (aCol white). The elf cards are painted without their halo; the glow is the shader's (emissive × uGlow) and
the bloom's.
The prompts are in prompt-blocks.md. Sample of the special kinds: art/cards-special-sheet.webp.
No model was meshed. Flowers are thin and mesh badly, and nothing in this proposal needs a 3D plant: cushions and hummocks are the ground's clumps and cards.
7. Test plan
Stills, before and after, day (13:00) and dusk (19:42), her eye (1.25 m) and her camera, in one page with
?flora=ab, on the Mac's GPU. Extend scripts/flower-shots.mjs; its spots stay so the sheets compare with
2026-09-30's.
| Spot | Where (x, z) | Proves |
|---|---|---|
meadow | (151, 131), inside | R1, R2, R7, R8: masses, drifts, groups of three |
lane, garden | as today | cards against cottages, dandelions and cow parsley |
moor | (20, 196), inside | heather carpet, straw tufts |
bog (new) | (−55, −160) | cotton grass bands, sedge |
dune (new) | (−201, −92), looking along the dunes | marram on crests, thrift |
nordalen | (560, 700) | the outside look, gold drift |
blomsterenga (new) | Sørvika's flower meadow, the stand of sheet-blomsterenga-1.jpg | the richest outside field; with ?look=paint at levels 0 and 2 |
hay (new) | the meadow west of Sørgården (420, −1110) | Sørmarka's straw ramp, poppies at the wall |
fjellet (new) | the shoulder toward Alvheim | sparse sedge, cushions |
alvheim (new) | (1268, 880), at 23:00 too | glow, ribbons along the ways |
far (new) | from a rise, a meadow 150–400 m off (Nordalen's valley floor from the lake's north shore) | R10: drifts as patches, no edge where the blades stop |
fade (new) | her camera on flat meadow, looking level | the 22–40 m ring: no lighter or flatter band |
gust (new) | nordalen, three stills 0.5 s apart with uGustOffset pinned to put a front at 15 m | R6: a front in the blades and the same band on the ground beyond |
push (new) | her camera, running through meadow | the ring round her feet, the trail, petals |
| seam | Nordporten from both sides | the tufts change hand across the look's band |
Measured on the stills (a small script over the JPEGs, as dusk-shots.mjs writes ground.json):
| Measure | Target |
|---|---|
| luma standard deviation in a 64 × 64 px window of lit meadow 8–20 m off | ≤ 60 % of today's |
| hue spread over a lit field, sampled every 4 m | ≥ 6° inside, ≥ 4° outside |
mean colour of the ring 24–30 m against the ring 32–40 m (fade) | within 3 % in each channel |
| ground luma at dusk | ≥ 0.2, green (the existing test) |
| luma of the gust band against the grass 10 m ahead of it, inside | + 0.06 to + 0.12 |
saturation of the band against the grass ahead of it, outside with ?look=paint | − 15 % or more |
| share of a frame over the accent threshold outside | ≤ 10 % |
| darkest grass pixel | luma ≥ 0.08 |
Frame cost (the Mac, WebKit, medium, under the GPU lock):
node scripts/perf/measure.mjs webkit m1|m2|m3 --ab flora --freeze --seconds 30: GPU busy median and mean, new minus old. Pass: ≤ + 0.15 ms mean on the M3. Alsol1andl3(flying low, where chunks stream).- The grass's own cost, once, before the work starts:
m3with and without?hide=grass,flowersin two runs. ?fps: + 0 pipelines in play at every spot, day and dusk. The tuft and card materials exist at boot; nothing new compiles.?stats: triangles and draws inm3against the table in 4.1.- CPU: a full chunk's fill in Node on the server (today 3.44 ms grass, 2.0 ms flowers): ≤ 3 ms and ≤ 1.5 ms. Walking
with the CPU slowed × 3 (
feel.mjs --cpu 3): no frame wheregrassorflowerstakes over 3 ms. - Tests:
tests/flora-cards.test.mjs(groups, sizes, cells), a newtests/meadow.test.mjs(the wash's JS and the table),tests/wind.test.mjs(the gust's JS twin against the texture's bytes).
On the iPad: ?fps in meadow, nordalen and blomsterenga, with and without ?flora=0; the worst frame of
10 s while running through a drift; whether dynamic resolution steps down.
8. Where this departs from docs/art-direction.md and docs/flora.md
| They say | This says | Why |
|---|---|---|
| A card is a clump of three to six flowers with leaves at the base | a spray without a rosette; the grass is the leaves | the rosette is what makes a card a sticker |
| Outside flowers: "muted cool base colours, the blossoms the one strong colour"; the first outside set came out dull | outside blossoms are accents: luma ≥ 0.55, with a steady glow | outside is "muted bases with a few glowing accents", and the paint pass mutes whatever is not bright |
| A lupin ≤ 0.8 m, as the tallest flower | fireweed 0.95 m and foxglove 1.0 m outside, as rare spires at a wood's or river's edge | outside wants scale contrast; they stand in threes, never as a carpet |
| Flowers on two crossed quads | one quad turned to the camera | half the cost, no cross from above, and the drawn trees already do it |
Inside grass #6DB33F → #8CC84B | the same body, tips to #A4CF52, dry patches #B9BE4E | painted sunlit grass is far yellower than the game's; the body stays where the rulebook put it |
| Grass lit "like the ground they grow from" with the normal straight up | the ground's own normal | so it is true on a slope |





















