Style books

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.svg
  • textures/gust.png, textures/wash.png and tools/make_textures.py: the two textures the design needs
  • prompt-blocks.md, prompts.json: the prompts
  • sources.md, notes/: where the findings come from, and what could not be confirmed
  • open-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:

LayerGeometryA chunk holds (high / medium / low)Sinks away atPool (medium)
grassa clump of 3 blades: 13 vertices, 7 triangles2 600 / 1 690 / 910 clumps34 / 30 / 24 m25 chunks
flowerstwo crossed quads, alpha test 0.5 with alpha-to-coverage400 / 260 / 200 cardsfade + 6 m37 chunks
reeds21 triangles360 / 234 / 180fade + 10 m37 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: windGust is 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: uPusher is read by every grass, flower and reed vertex and never written (docs/perf-review.md 5). 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.md derives 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.md give 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=grass is measured (section 7).
  • cloudShade(p.root.x, p.root.z) sits in colorNode without varying(). 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:

  1. 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.
  2. One green. Nothing changes hue across a field; only the baked map and a little luma noise.
  3. The field ends. At 30 m the blades sink into a flat mottled plane.
  4. Gusts cannot be seen as shapes: a swell of + 20 % with no edge.
  5. 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).
  6. Outside, the painted cards are dark and dull (lupin #454B8F, clover #7F1F36) and the paint pass mutes them further.
  7. 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.

inside meadow at eye height

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.

a gust crossing a field

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.

drifts on a far hillside

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.

dusk

What the games do

Game or sourceWhat is confirmedUse 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 offclumps 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 texturewhat the game already half does
Hexaquo's Godot seriesblades fade out over 10–20 m while a ground stand-in fades in; from above the real meadow is darker than the stand-inmatch 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 testnot 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 distancehaze 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.

QuestionOptionsChoice and whyCost
Blades, cards or clumpssingle blades (SimonDev: 3 072 a 10 m patch, 14 vertices near, 4 far); alpha cards of grass; clumps of opaque bladesclumps 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 bladestriangles and vertices, no overdraw beyond the nearest blade
Normalsper blade; rounded; world up; the terrain'sthe terrain's normal, one a tuft. The field then shades as one surface and follows its slope2 bytes an instance, 0 shader cost
Colourfixed two-colour gradient; from the terrain maproot = the ground's exact colour, tip = the zone's tip colour, mixed along the blade4 more bytes an instance
Large-scale colourper-instance random; a noise texturea wash texture (value, warm/cool, dry patches) read by the ground per pixel and baked into the tuft's root on the CPU0 for grass; the ground swaps two noise reads for two wash reads
Windsines (today); scrolling noise; a painted gust texturea gust texture with fronts, scrolled downwind, one read a vertex; the same bytes read on the CPU for the motesone vertex texture read instead of five sines
Her feetone uniform; an array; a trample render targetan 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 afford4 × (length, smoothstep) a vertex
Cloud shadowshave them (cloudShade)keep; make sure it is per vertex on grassnone new
Tipsbacklit glow (have); a specular sheen toward the sunkeep the glow and tie it to the gust; no sheen: the paint pass's bands would flatten a highlight that is only brighternone new
Beyond the fadeimpostor cards; shells; the ground itselfthe ground itself: same colour function as the blades' roots, gust light that grows where no blades show it, drifts baked as patchesnone new
Flowerscrossed cards (today); one camera-facing quad; meshesone camera-facing quad (as the drawn trees), plus dab flowers as opaque geometry on the tuftshalf the card triangles and overdraw; 10 triangles a near tuft
Alphablending; alpha test; alpha-to-coveragealpha test with alpha-to-coverage, the alpha sharpened by its screen derivative (Golus), mips built to keep coverageas today
Drawsone a chunk and layerunchanged: ≤ 17 for grass, ≤ 12 for flowers in a rich meadowwithin 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.

layers

4.1 The numbers at a glance (medium tier, the iPad)

LayerGeometryInstances a chunkIn viewTexturesFades
0 Bakenone (the tile's colour map)the colour maps, as todaynever
1 Ground washnone (the ground shader)wash 256² RGBA8, gust 256² RG8, both made at bootnever
2 Tuftsnear: 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 drawsgust (vertex), the noise texture (cloud shade, vertex)sinks 22–30 m
3 Dab flowersin 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 driftcounted in layer 2nonewith the near chunks (24 m)
4 Spray cardsone quad, 4 vertices, 2 triangles≤ 200≤ 2 400 cards, ≤ 4 800 triangles, ≤ 12 drawsthe flower atlas, 2048 × 1024 KTX2 (≈ 2.7 MB on the GPU, up from 1.35)shrinks 30–40 m
5 Airthe motes pool24 of 200 quadsnone 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 file src/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.ts groundTint) uses it per pixel in place of today's mottle and broad reads. 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.

    wash, Nordalen
  • Gust light on the ground. × (1 + (0.12, 0.10, 0.02) · g² · k) with g the gust texture and k rising 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.ts bakeColor and tools/world/lib/colour.ts already 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 body column 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.

BladeAngleHeightRoot widthLean at the tipRowsVerticesNear trianglesFar triangles
A0.2 rad1.000.0500.26root pair, pair at 0.5 (0.62 wide), tip531 (root pair + tip)
B2.30.820.0460.34same531
C4.30.660.0460.30same531
D1.30.480.0420.38same530
E5.40.340.0400.20root pair, tip310
stalk 1, 23.3, 0.90.92, 0.700.0120.18root pair, top3 each1 each0
head 1, 2on the stalk's top1 acrossa middle and four rim points5 each4 each0

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:

AttributeTypeHolds
aPos4 × f32x, y, z, yaw (as today)
aProp4 × f32height, width, phase, look (0 inside the wall … 1 outside, wallLook; was warmth)
aCol4 × u8the ground's colour at the root (map × wash, linear bytes), a: the ground normal's x (0..1 for −1..1)
aTip4 × u8the tip colour (below), a: the ground normal's z
aBloom4 × u8the 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) }
}
  • windGust keeps 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 (as steep.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.
  • createWind advances uGustOffset, sets the region's wind strength (section 5, eased like the grade) and writes the pushers from game.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 dead uPusher: 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 by 1 + 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).
tufts

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 (phase bit).

  • 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):

    cards in the grass, inside
    cards in the grass, outside
    // 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.5 a card takes windSway × 1.6 (today × 1 through hy²·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).

interaction

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:

StepEffect on grassRule for the flora
Kuwahara (patches about 12 px)melts blades into patches: R1 for free outsidemake 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 fieldno 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.05outside, 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 furtherzone 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 mutedoutside 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 enoughblade ≤ 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
Rima warm edge on silhouettes against the far landthe 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.

#FileChangeScope
1src/world/wind.ts, the hero's late updatepushers written each frame (the bug), uPusher removed; pushAt used by placeInstancesmall, ~40 lines
2src/world/wind.ts, motes.tsthe gust texture, uGustOffset, windGust and windGustAt on it; region wind strengthsmall, ~70 lines + tests/wind.test.mjs
3new src/world/meadow.ts (pure) + its TSL part; tiles/meshes.ts groundTintthe wash texture, meadowWash, REGION_FLORA (section 5), the ground using itmedium, ~160 lines + tests
4src/world/grass-fill.tstufts: counts, tussocks, zone height and density, root = ground colour, aTip, the normal, aBloom; cards in groups of three with three sizesmedium, ~140 lines; tests/flora-cards.test.mjs grows
5src/world/grass.tstuftGeometry, the two index ranges, the tuft shader, the card as one quad with the foot tint; fill budget near her (below)medium, ~200 lines
6tools/world/lib/colour.ts, src/world/field.tszone body colours, drifts as tinted patches; world rebuild on deploysmall, ~60 lines
7pipeline/flora/subjects.mjs, build.mjs, speckle.mjs, src/world/flora-cards.tsthe 32-card set, the 8 × 4 atlas, coverage-keeping mipsmedium, ~120 lines + 32 paintings
8src/world/motes.tspetals and seeds from driftssmall, ~40 lines
9docs/flora.md, docs/ground.md, docs/art-direction.mddescribe 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 in thin for the top-up that already exists. L.fill.start moves 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.

LayerTrianglesDrawsEstimated GPU on the A16Basis
Ground wash0 new0± 0.05 mstwo wash reads and one gust read replace two noise reads and five sines a pixel
Tufts with dab flowers145 000–159 00013–170.3–0.6 ms, as today's grasssame 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≤ 120.1–0.2 mstoday'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
Air480 new0the motes' pool
All flora≈ 165 000≤ 30budget 1.0 ms, hard limit 1.5 msto be replaced by measure.mjs --ab flora and ?hide=grass,flowers

If the iPad is over, cut in this order; each is a constant:

  1. The gust term of the backlight and the cards' nod (nothing else depends on them).
  2. Pusher slots 1–3 (keep her feet).
  3. Near chunks at 18 m instead of 24 m (three or four near chunks instead of five: − 25 000 triangles).
  4. 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.
  5. Tufts a square metre × 0.8, widths × 1.12.
  6. 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)
Tuftshort, broad (width 0.9–1.3), uprightlonger, narrower (width 0.7–1.1), leaning downwind
Foot× 0.86, warm× 0.78, toward teal
Tussockssoft (c0 0.45): an even, tidy lawnstrong (c0 0.2): open ground between
Hue wander60 %100 %
Gusta soft warm lightening, #D6E88F, 45 %a pale greyed band with a hard front, #C2C89A, 70 %; the ground beyond shows it at × 1.8
Drifts6 m grid, 80 % of cells, radius 1.4–3 m; dabs on 30 % of the tufts in a drift and 6 % outside onethe zone's row below; dabs only in drifts
Cardsthe cute set: chunky, big heads, thick stemsthe painterly set: flat strokes, leaning, blossoms in accent colours with a steady glow of 0.14
Flower share of a meadowup to a thirdup to a sixth
outside at eye height
inside from her camera
outside from her camera

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.

ZonebodytipgustdryHeight (tall)DensityWind ×LeanFlowers and driftsSpecial plants
Hjemdalen (inside)#6DB33F#A4CF52#D6E88F#B9BE4E0.20–0.38 (0.60)4.00.70lupin 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#9A94500.35–0.60 (0.90)4.01.00.15globeflower (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#8A8A560.25–0.45 (0.60)3.20.850.08harebell 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#B4AA720.30–0.50; marram 0.75–1.203.01.350.30Blomsterenga: lupin, fireweed, oxeye, globeflower, drifts 6–14 m on a 20 m grid, 70 % (its look is 0.85: the richest field outside); elsewhere fewmarram on the crests, none in the hollows; thrift cushions in the lee
Sørmarka#7A8048#B09A4A#E2CF8E#C09A4C0.45–0.70 (0.95); pasture 0.12–0.204.40.850.12buttercup 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#B0864B0.10–0.221.61.50.40moss 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.3cotton grass by Fjelltjernet and the snow-melt; dwarf willow and crowberry red in the ground's clumps (tiles/detail.ts)
Alvheim#33605A#7FCDB6#BFEFE0#4E8F7A0.20–0.35, width × 0.62.00.40elf 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ønnenthe 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).

Sørmarka
Fjellet
Fjellfoten

Kinds of ground, in every zone

These factors exist in grass-fill.ts today; the numbers change as marked.

GroundTuftsColourFlowersTarget
meadowthe zone's rowthe zone's rowthe zone's drifts
pastureheight × 0.45tip toward #B8C050buttercup, clover, daisy as dabs, no cards
moorhalf the tufts (as today), height 0.22–0.35, width × 0.6straw: tip #B59A5C, no gust tintheather cards as a carpet (floor 0.35, as today), bloom thickest where wash.g is warm, so it lies in driftsart/target-moor-heather.webp
bogheight × 1.35sedge: body toward #6B6A3A, tip #A8843C, wine red #7A3A34 in the ground's clumpscotton grass cards (floor 0.15), in bandsart/target-bog-cotton.webp
dunemarram: height 0.75–1.20, width × 0.75, lean × 2, only where dune > 0.25 and by today's thinning#8EA078 to tip #C9C08Athrift cushions, 1 a 6 m², in the lee (noise)art/target-dunes-marram.webp
birch× 0.8tip 10 % lighterwood anemone and forget-me-not as dabs, harebell cards
grove, oldwooda thirdmoss, the map's ownharebellart/zone-fjellfoten.webp
moor
bog
dunes
Alvheim

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):

  1. Blossoms cover more of the card's solid pixels than stems and leaves do.
  2. 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).
  3. Three to six stems of uneven height from one point at the bottom middle; one leans out.
  4. 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.
  5. Three values on a blossom: light, middle and a coloured shade (violet-blue inside, teal outside). Nothing under 0.2 luma.
  6. Outside, blossoms are at least 0.55 display luma and leaning right (the game mirrors half and the wind does the rest).
  7. Lit from above, no cast shadow, plain light grey behind, as FRAME_FLOWER asks today.

The two looks, as painted here:

inside set
outside set

Single cards, which is how the pipeline paints them:

InsideOutside

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.

CellInside (cute)hCellOutside (painterly)h
0lupin, pink and cream0.6816lupin, violet-blue0.68
1lupin, violet0.6217harebell0.38
2oxeye daisy0.5018globeflower0.50
3oxeye daisy, small0.3619oxeye daisy, cream0.50
4buttercup0.4520fireweed0.95
5harebell0.3821foxglove1.00
6red clover0.2422heather0.32
7forget-me-not0.2623cotton grass0.45
8wood anemone0.2024seed grass0.85
9dandelion with seed clocks0.3025thrift (cushion)0.18
10cow parsley0.8026moss campion (cushion)0.10
11poppy0.5027mountain avens0.12
12heather0.3228poppy0.50
13cotton grass0.4529cornflower0.50
14thrift (cushion)0.1830elf bell (glows)0.40
15seed grass0.6031elf 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.

SpotWhere (x, z)Proves
meadow(151, 131), insideR1, R2, R7, R8: masses, drifts, groups of three
lane, gardenas todaycards against cottages, dandelions and cow parsley
moor(20, 196), insideheather carpet, straw tufts
bog (new)(−55, −160)cotton grass bands, sedge
dune (new)(−201, −92), looking along the dunesmarram on crests, thrift
nordalen(560, 700)the outside look, gold drift
blomsterenga (new)Sørvika's flower meadow, the stand of sheet-blomsterenga-1.jpgthe 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 Alvheimsparse sedge, cushions
alvheim (new)(1268, 880), at 23:00 tooglow, 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 levelthe 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 mR6: a front in the blades and the same band on the ground beyond
push (new)her camera, running through meadowthe ring round her feet, the trail, petals
seamNordporten from both sidesthe 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):

MeasureTarget
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 pixelluma ≥ 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. Also l1 and l3 (flying low, where chunks stream).
  • The grass's own cost, once, before the work starts: m3 with and without ?hide=grass,flowers in 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 in m3 against 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 where grass or flowers takes over 3 ms.
  • Tests: tests/flora-cards.test.mjs (groups, sizes, cells), a new tests/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 sayThis saysWhy
A card is a clump of three to six flowers with leaves at the basea spray without a rosette; the grass is the leavesthe 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 dulloutside blossoms are accents: luma ≥ 0.55, with a steady glowoutside 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 flowerfireweed 0.95 m and foxglove 1.0 m outside, as rare spires at a wood's or river's edgeoutside wants scale contrast; they stand in threes, never as a carpet
Flowers on two crossed quadsone quad turned to the camerahalf the cost, no cross from above, and the drawn trees already do it
Inside grass #6DB33F → #8CC84Bthe same body, tips to #A4CF52, dry patches #B9BE4Epainted 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 upthe ground's own normalso it is true on a slope