{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "grass-field",
  "type": "registry:block",
  "title": "Grass Field",
  "description": "A full-bleed meadow of up to 110,000 instanced grass blades with travelling gusts, back-lit by a low sun, in four times of day including a moonlit night with fireflies. The pointer or a finger treads a visible path that springs back over a few seconds, and a click sends a ring of wind across the field.",
  "docs": "Grass Field was added to public/vividsites/grass-field.html. Paste its markup, <style> and <script> where you want it on the page (or open the file to see it alone). Customise it with the CSS variables at the top. Docs and live demo: https://vividsites.app/c/grass-field",
  "categories": [
    "3d",
    "3d-objects"
  ],
  "files": [
    {
      "path": "components/vividsites/grass-field.html",
      "type": "registry:file",
      "target": "public/vividsites/grass-field.html",
      "content": "<!--\n  vividsites · grass-field\n  A meadow of instanced grass (up to ~110k blades) seen from knee height. Wind\n  runs through it as slow noise plus gust fronts you can watch travel across\n  the field; blades glow where the low sun shines through them. The cursor\n  treads a path through it that shows as a paler, flattened trail and springs\n  back over a few seconds; a click (or tap) sends a ring of wind out from where\n  you pressed. Four times of day, with fireflies at night.\n\n  Customise (attributes, read at mount):\n    data-time        dawn | noon | dusk | night   (default dusk)\n    data-tint        meadow | wheat | sage | rust (default meadow)\n    data-density     1 = default; .5 halves the blade count, 1.5 adds half again\n  CSS (read at mount; a data-time preset overrides the sky, haze and sun):\n    --vs-grass-field-sky / -haze / -sun    sky, horizon haze, sunlight\n    --vs-grass-field-tip / -mid / -root    blade colours, top to bottom\n    --vs-grass-field-wind                  wind strength, 0–2 (default 1)\n    --vs-grass-field-height                component height (default 100svh)\n  JS (on the element): el.vsGrassField\n    .set({ time: 'dawn'|'noon'|'dusk'|'night', wind: 0..2, tint: 'meadow'|'wheat'|'sage'|'rust' })   eased over ~1.5 s\n    .gust(x?, z?)      a ring of wind from a point on the field\n    .destroy()\n  Needs: three@0.170.0 (importmap below).\n-->\n<div class=\"vs-grass-field\" data-density=\"1\" role=\"img\" aria-label=\"A meadow of grass moving in the wind at sunset. Move across it to part the grass; click for a gust.\">\n  <canvas class=\"vs-grass-field-canvas\"></canvas>\n</div>\n\n<style>\n  .vs-grass-field {\n    --vs-grass-field-sky: #4d5b88;\n    --vs-grass-field-haze: #f6b37a;\n    --vs-grass-field-sun: #ffc27e;\n    --vs-grass-field-tip: #cfc46e;\n    --vs-grass-field-root: #0e170a;\n    --vs-grass-field-mid: #4b6a24;\n    --vs-grass-field-wind: 1;\n    --vs-grass-field-height: 100svh;\n    position: relative; width: 100%; height: var(--vs-grass-field-height); min-height: 420px; overflow: hidden; isolation: isolate;\n    background: linear-gradient(180deg, var(--vs-grass-field-sky) 0%, var(--vs-grass-field-haze) 52%, #3a3a1c 60%, var(--vs-grass-field-root) 100%);\n    cursor: crosshair; touch-action: pan-y; -webkit-tap-highlight-color: transparent;\n  }\n  .vs-grass-field-canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; opacity: 0; transition: opacity 1.6s cubic-bezier(.16,1,.3,1); }\n  .vs-grass-field[data-vs-state=\"ready\"] .vs-grass-field-canvas { opacity: 1; }\n  /* grade after the render: a soft vignette and fine grain */\n  .vs-grass-field::after { content: \"\"; position: absolute; inset: 0; pointer-events: none;\n    background: radial-gradient(120% 95% at 50% 40%, transparent 55%, rgba(20,10,4,.5) 100%),\n      url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 .5 0 0 0 0 .5 0 0 0 0 .5 0 0 0 .5 0'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>\");\n    mix-blend-mode: overlay; opacity: .5; }\n</style>\n\n<script type=\"importmap\">{ \"imports\": { \"three\": \"https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js\" } }</script>\n<script type=\"module\">\nimport * as THREE from 'three';\n\nconst NOISE = /* glsl */`\nvec3 mod289(vec3 x){ return x - floor(x * (1.0 / 289.0)) * 289.0; }\nvec2 mod289(vec2 x){ return x - floor(x * (1.0 / 289.0)) * 289.0; }\nvec3 permute(vec3 x){ return mod289(((x * 34.0) + 1.0) * x); }\nfloat snoise(vec2 v){\n  const vec4 C = vec4(0.211324865405187, 0.366025403784439, -0.577350269189626, 0.024390243902439);\n  vec2 i = floor(v + dot(v, C.yy)); vec2 x0 = v - i + dot(i, C.xx);\n  vec2 i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);\n  vec4 x12 = x0.xyxy + C.xxzz; x12.xy -= i1; i = mod289(i);\n  vec3 p = permute(permute(i.y + vec3(0.0, i1.y, 1.0)) + i.x + vec3(0.0, i1.x, 1.0));\n  vec3 m = max(0.5 - vec3(dot(x0, x0), dot(x12.xy, x12.xy), dot(x12.zw, x12.zw)), 0.0); m = m * m; m = m * m;\n  vec3 x = 2.0 * fract(p * C.www) - 1.0; vec3 h = abs(x) - 0.5; vec3 ox = floor(x + 0.5); vec3 a0 = x - ox;\n  m *= 1.79284291400159 - 0.85373472095314 * (a0 * a0 + h * h);\n  vec3 g; g.x = a0.x * x0.x + h.x * x0.y; g.yz = a0.yz * x12.xz + h.yz * x12.yw;\n  return 130.0 * dot(m, g);\n}`;\nconst HAZE = /* glsl */`\nuniform vec3 uSky, uHaze, uSun, uSunDir;\nvec3 hazeAt(vec3 d){\n  float s = max(dot(d, uSunDir), 0.0);\n  vec3 c = mix(uHaze * 0.72, uHaze, pow(s, 3.0));\n  return c + uSun * pow(s, 24.0) * 0.5;\n}`;\n\nconst BLADE_VERT = /* glsl */`\nattribute vec4 aOff;   // x, z, height, facing\nattribute vec4 aVar;   // width, lean, tint, phase\nuniform float uTime, uWind, uGustT;\nuniform vec2 uWindDir;\nuniform sampler2D uBend; uniform vec4 uBendRect;\nuniform vec4 uClick;   // x, z, start time, strength\nvarying float vY; varying vec3 vN; varying vec3 vW; varying float vTint; varying float vGust; varying float vPress;\n${NOISE}\nvoid main(){\n  float y = position.y;\n  float h = aOff.z;\n  vec2 base = aOff.xy;\n  float ca = cos(aOff.w), sa = sin(aOff.w);\n  float w = aVar.x * (1.0 - pow(y, 1.3) * 0.92);\n  vec3 p = vec3(position.x * w * ca, y * h, position.x * w * sa);\n  // wind: a big slow swell, a fine flutter, and gust fronts that travel downwind\n  float swell = snoise(base * 0.06 - uWindDir * uTime * 0.22) * 0.5 + 0.5;\n  float flutter = snoise(base * 0.7 + uTime * 1.3 + aVar.w);\n  float front = dot(base, uWindDir) * 0.16 - uTime * 1.35;\n  float gust = pow(max(sin(front), 0.0), 6.0) * (0.55 + 0.45 * snoise(base * 0.05 + 3.1)) * uGustT;\n  vec2 B = uWindDir * (aVar.y + (swell * 0.45 + gust * 0.85) * uWind) + vec2(-uWindDir.y, uWindDir.x) * flutter * 0.08 * uWind;\n  // the click: a ring of wind leaving the point, fading as it widens\n  float ct = uTime - uClick.z;\n  if (ct > 0.0 && ct < 6.0) {\n    float cd = distance(base, uClick.xy);\n    float rr = (cd - ct * 7.0) / 1.6;   // squared by hand: pow() of a negative base is NaN on Metal\n    float ring = exp(-rr * rr) * exp(-ct * 0.6) * uClick.w;\n    B += normalize(base - uClick.xy + 1e-4) * ring * 1.3;\n    gust += ring * 0.8;\n  }\n  // the cursor's path: a pressure map; blades lean down its gradient and lie flatter where it is deep\n  vec2 buv = (base - uBendRect.xy) / uBendRect.zw;\n  float inside = step(0.0, buv.x) * step(buv.x, 1.0) * step(0.0, buv.y) * step(buv.y, 1.0);\n  float e = 1.5 / 256.0;\n  float pr = texture2D(uBend, buv).r * inside;\n  vec2 grad = vec2(texture2D(uBend, buv + vec2(e, 0.0)).r - texture2D(uBend, buv - vec2(e, 0.0)).r,\n                   texture2D(uBend, buv + vec2(0.0, e)).r - texture2D(uBend, buv - vec2(0.0, e)).r) * inside;\n  vec2 away = -grad / (length(grad) + 0.02);\n  B += away * pr * 3.0 + uWindDir * pr * 0.6;   // lean off the path's edges, and downwind where it is deepest\n  float m = min(length(B), 1.6);\n  B = B / (length(B) + 1e-4) * m;\n  float k = pow(y, 1.7);\n  p.xz += B * k * h * 0.85;\n  p.y *= 1.0 - 0.38 * m * m * y * 0.6;    // keep the blade's length as it bends\n  p *= 1.0 - pr * 0.62;                    // trodden grass lies low\n  vec3 wp = vec3(base.x, 0.0, base.y) + p;\n  // a rounded normal: the blade's face, tipped by its own curvature\n  vec3 face = vec3(-sa, 0.0, ca);\n  vN = normalize(face + vec3(ca, 0.0, sa) * position.x * 1.2 + vec3(B.x, 0.5 + m, B.y) * 0.4);\n  vY = y; vW = wp; vTint = aVar.z; vGust = clamp(gust, 0.0, 1.0); vPress = pr;\n  gl_Position = projectionMatrix * viewMatrix * vec4(wp, 1.0);\n}`;\nconst BLADE_FRAG = /* glsl */`\nuniform vec3 uRoot, uMid, uTip, uDry;\nuniform float uFog;\nvarying float vY; varying vec3 vN; varying vec3 vW; varying float vTint; varying float vGust; varying float vPress;\n${HAZE}\nvoid main(){\n  vec3 c = mix(uRoot, uMid, smoothstep(0.0, 0.55, vY));\n  c = mix(c, uTip, smoothstep(0.45, 1.0, vY));\n  c = mix(c, uDry, smoothstep(0.72, 1.0, vTint) * 0.6);\n  c = mix(c, uMid * 0.8, step(fract(vTint * 7.31), 0.3) * smoothstep(0.3, 0.9, vY) * 0.55);   // a third of the blades stay green to the tip\n  c *= mix(0.7, 1.2, fract(vTint * 13.7));\n  vec3 V = normalize(vW - cameraPosition);\n  vec3 N = normalize(vN); if (dot(N, V) > 0.0) N = -N;\n  float wrap = max((dot(N, uSunDir) + 0.4) / 1.4, 0.0);\n  float ao = 0.28 + 0.72 * smoothstep(0.0, 0.75, vY);          // the floor of the field is in shade\n  vec3 amb = mix(uSky * 0.5, uHaze * 0.3, 0.45) * ao;\n  vec3 lit = c * (amb + uSun * wrap * 0.4 * ao + uSun * max(uSunDir.y, 0.0) * 0.55 * smoothstep(0.1, 1.0, vY));   // a high sun lights the tops\n  // the sun through the blade: thin grass lit from behind is the whole look — only where the blade stands in front of the sun\n  float through = pow(max(dot(V, uSunDir), 0.0), 10.0);\n  lit += c * uSun * through * smoothstep(0.25, 1.0, vY) * 1.5;\n  // gust sheen: bent blades show their paler undersides, which is how you see wind cross a field\n  lit += uSun * vGust * smoothstep(0.3, 1.0, vY) * 0.12;\n  lit = mix(lit, lit * 0.8 + (uSun * 0.16 + uHaze * 0.1) * smoothstep(0.2, 1.0, vY), clamp(vPress * 1.4, 0.0, 1.0));   // trodden blades lie over and show their paler undersides\n  float d = length(vW - cameraPosition);\n  float f = 1.0 - exp(-uFog * uFog * d * d);\n  lit = mix(lit, hazeAt(V), clamp(f, 0.0, 1.0));\n  gl_FragColor = vec4(lit, 1.0);\n  #include <tonemapping_fragment>\n  #include <colorspace_fragment>\n}`;\nconst SKY_FRAG = /* glsl */`\nvarying vec3 vD;\nuniform float uTime;\n${HAZE}\n${NOISE}\nvoid main(){\n  vec3 d = normalize(vD);\n  float h = max(d.y, 0.0);\n  vec3 c = hazeAt(d);\n  c = mix(c, uSky, smoothstep(0.0, 0.3, h));\n  // long low cloud bands, lit on the sun side\n  vec2 q = d.xz / (d.y + 0.12);\n  float cl = smoothstep(0.35, 0.9, snoise(q * vec2(0.35, 1.4) + vec2(uTime * 0.01, 0.0)) * 0.5 + snoise(q * 1.3) * 0.25 + 0.3);\n  cl *= smoothstep(0.02, 0.12, h) * (1.0 - smoothstep(0.35, 0.7, h));\n  float sl = pow(max(dot(d, uSunDir), 0.0), 4.0);\n  c = mix(c, mix(uSky * 1.05, uSun * 1.25, sl), cl * 0.55);\n  float s = max(dot(d, uSunDir), 0.0);\n  c += uSun * (smoothstep(0.9993, 0.9997, s) * 3.0 + pow(s, 400.0) * 1.2 + pow(s, 40.0) * 0.35);\n  gl_FragColor = vec4(c, 1.0);\n  #include <tonemapping_fragment>\n  #include <colorspace_fragment>\n}`;\nconst MOTE_VERT = /* glsl */`\nattribute vec4 aM; uniform float uTime, uDpr, uNight; uniform vec3 uSunDir; varying float vA;\nvoid main(){\n  vec3 p = position;\n  p.y = mix(p.y, 0.3 + p.y * 0.4, uNight); p.z = mix(p.z, p.z * 0.55 + 1.5, uNight);\n  p.x += sin(uTime * 0.3 + aM.x * 6.3) * 0.6 + uTime * 0.12; p.y += sin(uTime * 0.5 + aM.y * 6.3) * 0.25; p.z += cos(uTime * 0.25 + aM.z * 6.3) * 0.5;\n  p.x = mod(p.x + 12.0, 24.0) - 12.0;\n  vec4 mv = viewMatrix * vec4(p, 1.0);\n  vec3 V = normalize(p - cameraPosition);\n  float day = (0.25 + 0.75 * pow(max(dot(V, uSunDir), 0.0), 6.0)) * (0.5 + 0.5 * sin(uTime * 1.3 + aM.w * 6.3));\n  float blink = max(sin(uTime * (0.7 + aM.x * 0.9) + aM.w * 40.0), 0.0); blink = blink * blink;\n  vA = mix(day, (0.15 + blink * 1.6) * step(0.3, aM.z), uNight);\n  gl_PointSize = uDpr * (2.0 + aM.w * 3.0) * (6.0 / -mv.z) * mix(1.0, 4.2, uNight);\n  gl_Position = projectionMatrix * mv;\n}`;\nconst MOTE_FRAG = /* glsl */`\nuniform vec3 uSun; uniform float uNight; varying float vA;\nvoid main(){ float r = length(gl_PointCoord - 0.5); float a = smoothstep(0.5, 0.0, r); a *= a; a = mix(a, a * 0.35 + smoothstep(0.12, 0.0, r) * 0.9, uNight); vec3 c = mix(uSun * 0.9, vec3(0.85, 1.0, 0.45) * 2.2, uNight); gl_FragColor = vec4(c * a * vA, 1.0); }`;\n\nconst css = (el, n, f) => getComputedStyle(el).getPropertyValue(n).trim() || f;\nconst rng = (seed) => () => { seed = (seed + 0x6d2b79f5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };\n\nfunction mount(el) {\n  const canvas = el.querySelector('.vs-grass-field-canvas');\n  const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;\n  let renderer;\n  try { renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: 'high-performance' }); } catch (e) { el.dataset.vsState = 'nogl'; return null; }\n  if (!renderer.getContext()) { el.dataset.vsState = 'nogl'; return null; }\n  renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 0.95;\n\n  const C = (n, f) => new THREE.Color(css(el, n, f));\n  const sky = C('--vs-grass-field-sky', '#5e6f9c'), haze = C('--vs-grass-field-haze', '#f6b37a'), sun = C('--vs-grass-field-sun', '#ffc27e');\n  const tip = C('--vs-grass-field-tip', '#d6c46c'), root = C('--vs-grass-field-root', '#17200d');\n  const mid = C('--vs-grass-field-mid', '#4b6a24'), dry = tip.clone().lerp(new THREE.Color('#f1dca0'), 0.6);\n  const wind = parseFloat(css(el, '--vs-grass-field-wind', '1'));\n  const coarse = matchMedia('(pointer: coarse)').matches || Math.min(innerWidth, innerHeight) < 600;\n  const density = parseFloat(el.dataset.density || 1) * (coarse ? 0.42 : 1);\n\n  const sunDir = new THREE.Vector3(0.18, 0.07, -1).normalize();\n  const shared = { uSky: { value: sky }, uHaze: { value: haze }, uSun: { value: sun.clone().multiplyScalar(1.6) }, uSunDir: { value: sunDir }, uTime: { value: 0 } };\n\n  const scene = new THREE.Scene();\n  const camera = new THREE.PerspectiveCamera(38, 1, 0.05, 200);\n\n  // ---- sky\n  scene.add(new THREE.Mesh(new THREE.SphereGeometry(120, 48, 24), new THREE.ShaderMaterial({ side: THREE.BackSide, depthWrite: false, uniforms: shared,\n    vertexShader: 'varying vec3 vD; void main(){ vD = position; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }', fragmentShader: SKY_FRAG })));\n\n  // ---- ground under the blades, the same colour as their roots so no gap reads\n  const fogU = { value: 0.028 };\n  const ground = new THREE.Mesh(new THREE.PlaneGeometry(240, 240), new THREE.ShaderMaterial({ uniforms: { ...shared, uRoot: { value: root }, uFog: fogU },\n    vertexShader: 'varying vec3 vW; void main(){ vec4 w = modelMatrix * vec4(position, 1.0); vW = w.xyz; gl_Position = projectionMatrix * viewMatrix * w; }',\n    fragmentShader: `uniform vec3 uRoot; uniform float uFog; varying vec3 vW; ${HAZE}\n      void main(){ vec3 V = normalize(vW - cameraPosition); vec3 c = uRoot * (0.9 + 0.4 * uSun * 0.2); float d = length(vW - cameraPosition); float f = 1.0 - exp(-uFog * uFog * d * d); c = mix(c, hazeAt(V), clamp(f, 0.0, 1.0)); gl_FragColor = vec4(c, 1.0);\n      #include <tonemapping_fragment>\n      #include <colorspace_fragment>\n      }` }));\n  ground.rotation.x = -Math.PI / 2; scene.add(ground);\n\n  // ---- one blade: 7 segments and a tip\n  const SEG = 7; const bp = [], bi = [];\n  for (let i = 0; i < SEG; i++) { const y = i / SEG; bp.push(-0.5, y, 0, 0.5, y, 0); }\n  bp.push(0, 1, 0);\n  for (let i = 0; i < SEG - 1; i++) { const a = i * 2; bi.push(a, a + 1, a + 2, a + 1, a + 3, a + 2); }\n  bi.push((SEG - 1) * 2, (SEG - 1) * 2 + 1, SEG * 2);\n  const blade = new THREE.InstancedBufferGeometry();\n  blade.setAttribute('position', new THREE.Float32BufferAttribute(bp, 3)); blade.setIndex(bi);\n\n  // near patch: dense, where the cursor works; far patch: wider, sparser blades out to the haze\n  const R = rng(1709);\n  const NEAR = Math.round(70000 * density), FAR = Math.round(42000 * density), N = NEAR + FAR;\n  const off = new Float32Array(N * 4), vr = new Float32Array(N * 4);\n  for (let i = 0; i < N; i++) {\n    const near = i < NEAR;\n    let x, z;\n    if (near) { z = 7 - R() * 19; x = (R() - 0.5) * (8 + (7 - z) * 1.1); }\n    else { z = -12 - Math.pow(R(), 0.8) * 60; x = (R() - 0.5) * (30 + (-z) * 1.3); }\n    const clump = 0.75 + 0.5 * Math.sin(x * 0.7 + Math.sin(z * 0.5) * 2) * Math.sin(z * 0.9);\n    const h = (0.55 + R() * 0.55) * clump * (near ? 1 : 1.25);\n    off.set([x, z, h, R() * Math.PI], i * 4);\n    vr.set([(near ? 0.036 : 0.1) * (0.7 + R() * 0.6), 0.08 + R() * 0.2, R(), R() * 6.28], i * 4);\n  }\n  blade.setAttribute('aOff', new THREE.InstancedBufferAttribute(off, 4)); blade.setAttribute('aVar', new THREE.InstancedBufferAttribute(vr, 4));\n  blade.instanceCount = N;\n\n  // the cursor's pressure map, drawn on a small canvas and decayed every frame\n  const BR = { x: -9, z: -14, w: 18, h: 21 };\n  const bc = document.createElement('canvas'); bc.width = bc.height = 256; const bg = bc.getContext('2d'); bg.fillStyle = '#000'; bg.fillRect(0, 0, 256, 256);\n  const bendTex = new THREE.CanvasTexture(bc); bendTex.flipY = false; bendTex.minFilter = bendTex.magFilter = THREE.LinearFilter; bendTex.generateMipmaps = false;\n  const BU = { ...shared, uWind: { value: wind }, uGustT: { value: 1 }, uWindDir: { value: new THREE.Vector2(-0.35, 0.94).normalize() },\n    uBend: { value: bendTex }, uBendRect: { value: new THREE.Vector4(BR.x, BR.z, BR.w, BR.h) }, uClick: { value: new THREE.Vector4(0, 0, -99, 0) },\n    uRoot: { value: root }, uMid: { value: mid }, uTip: { value: tip }, uDry: { value: dry }, uFog: fogU };\n  const bladeMat = new THREE.ShaderMaterial({ uniforms: BU, vertexShader: BLADE_VERT, fragmentShader: BLADE_FRAG, side: THREE.DoubleSide });\n  const grass = new THREE.Mesh(blade, bladeMat); grass.frustumCulled = false; scene.add(grass);\n\n  // ---- motes of pollen in the low sun\n  const MN = coarse ? 160 : 360; const mp = new Float32Array(MN * 3), mm = new Float32Array(MN * 4);\n  for (let i = 0; i < MN; i++) { mp.set([(R() - 0.5) * 24, 0.3 + R() * 2.4, 4 - R() * 16], i * 3); mm.set([R(), R(), R(), R()], i * 4); }\n  const mg = new THREE.BufferGeometry(); mg.setAttribute('position', new THREE.BufferAttribute(mp, 3)); mg.setAttribute('aM', new THREE.BufferAttribute(mm, 4));\n  const nightU = { value: 0 };\n  const MU = { ...shared, uDpr: { value: 1 }, uNight: nightU };\n  const motes = new THREE.Points(mg, new THREE.ShaderMaterial({ uniforms: MU, vertexShader: MOTE_VERT, fragmentShader: MOTE_FRAG, transparent: true, depthWrite: false, blending: THREE.AdditiveBlending }));\n  motes.frustumCulled = false; scene.add(motes);\n\n  const S = { t: reduced ? 12 : 0, last: 0, raf: 0, visible: true, px: 0, py: 0, hx: 0, hy: 0, prev: null, ptr: false, portrait: false };\n  // ---- times of day and grass tints: every change eases from what is on screen now\n  const cssLook = { sky: sky.getStyle(), haze: haze.getStyle(), sun: sun.getStyle() };\n  const TIMES = {\n    dawn:  { sky: '#7488b4', haze: '#f0bfa2', sun: '#ffd4b4', dir: [-0.5, 0.06, -1], exp: 1.0, fog: 0.036, sunK: 1.45, night: 0 },\n    noon:  { sky: '#4a82cc', haze: '#d9e6ec', sun: '#fff4de', dir: [0.3, 0.62, -0.75], exp: 0.9, fog: 0.022, sunK: 1.3, night: 0 },\n    dusk:  { ...cssLook, dir: [0.18, 0.07, -1], exp: 0.95, fog: 0.028, sunK: 1.6, night: 0 },\n    night: { sky: '#081222', haze: '#22375a', sun: '#a4bfe8', dir: [-0.3, 0.16, -1], exp: 1.2, fog: 0.032, sunK: 0.5, night: 1 },\n  };\n  const TINTS = {\n    meadow: { tip: tip.getStyle(), mid: mid.getStyle(), root: root.getStyle() },\n    wheat:  { tip: '#ead38c', mid: '#9c7f3b', root: '#1d170b' },\n    sage:   { tip: '#bcd2a6', mid: '#41664c', root: '#0a1510' },\n    rust:   { tip: '#e6a868', mid: '#7c4a24', root: '#180d07' },\n  };\n  const snap = () => ({ sky: shared.uSky.value.clone(), haze: shared.uHaze.value.clone(), sun: shared.uSun.value.clone(), dir: sunDir.clone(), exp: renderer.toneMappingExposure, fog: fogU.value, night: nightU.value,\n    tip: tip.clone(), mid: mid.clone(), root: root.clone(), wind: BU.uWind.value });\n  const T = { from: null, to: null, k: 1, time: 'dusk', tint: 'meadow', wind: BU.uWind.value };\n  function target() {\n    const t = TIMES[T.time], g = TINTS[T.tint];\n    return { sky: new THREE.Color(t.sky), haze: new THREE.Color(t.haze), sun: new THREE.Color(t.sun).multiplyScalar(t.sunK), dir: new THREE.Vector3(...t.dir).normalize(), exp: t.exp, fog: t.fog, night: t.night,\n      tip: new THREE.Color(g.tip), mid: new THREE.Color(g.mid), root: new THREE.Color(g.root), wind: T.wind };\n  }\n  function apply(a, b, k) {\n    shared.uSky.value.copy(a.sky).lerp(b.sky, k); shared.uHaze.value.copy(a.haze).lerp(b.haze, k); shared.uSun.value.copy(a.sun).lerp(b.sun, k);\n    sunDir.copy(a.dir).lerp(b.dir, k).normalize();\n    renderer.toneMappingExposure = a.exp + (b.exp - a.exp) * k; fogU.value = a.fog + (b.fog - a.fog) * k; nightU.value = a.night + (b.night - a.night) * k;\n    tip.copy(a.tip).lerp(b.tip, k); mid.copy(a.mid).lerp(b.mid, k); root.copy(a.root).lerp(b.root, k);\n    dry.copy(tip).lerp(DRY, 0.6); BU.uWind.value = a.wind + (b.wind - a.wind) * k;\n  }\n  const DRY = new THREE.Color('#f1dca0');\n  function set(o = {}) {\n    if (o.time && TIMES[o.time]) { T.time = o.time; el.dataset.time = o.time; }\n    if (o.tint && TINTS[o.tint]) { T.tint = o.tint; el.dataset.tint = o.tint; }\n    if (o.wind != null) T.wind = Math.max(0, Math.min(2, +o.wind));\n    T.from = snap(); T.to = target(); T.k = reduced ? 0.999 : 0;\n    kick();\n  }\n  if (el.dataset.time && TIMES[el.dataset.time]) T.time = el.dataset.time;\n  if (el.dataset.tint && TINTS[el.dataset.tint]) T.tint = el.dataset.tint;\n  apply(snap(), target(), 1);\n\n  const ray = new THREE.Raycaster(), plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -0.25), ndc = new THREE.Vector2(), hit = new THREE.Vector3();\n\n  function resize() {\n    const w = el.clientWidth, h = el.clientHeight; if (!w || !h) return;\n    const dpr = Math.min(devicePixelRatio, coarse ? 1.5 : 2); renderer.setPixelRatio(dpr); renderer.setSize(w, h, false); MU.uDpr.value = dpr * h / 800;\n    camera.aspect = w / h; S.portrait = w / h < 0.8; camera.fov = S.portrait ? 58 : 38; camera.updateProjectionMatrix();\n  }\n  const toMap = (v) => [((v.x - BR.x) / BR.w) * 256, ((v.z - BR.z) / BR.h) * 256];\n  function stamp(a, b, strength) {\n    const [ax, ay] = toMap(a), [bx, by] = toMap(b);\n    const steps = Math.max(1, Math.ceil(Math.hypot(bx - ax, by - ay) / 3));\n    for (let i = 0; i <= steps; i++) {\n      const x = ax + (bx - ax) * i / steps, y = ay + (by - ay) * i / steps, r = 15;\n      const gr = bg.createRadialGradient(x, y, 0, x, y, r); gr.addColorStop(0, `rgba(255,255,255,${0.5 * strength})`); gr.addColorStop(1, 'rgba(255,255,255,0)');\n      bg.fillStyle = gr; bg.fillRect(x - r, y - r, r * 2, r * 2);\n    }\n  }\n  function gust(x = 0, z = 0) { BU.uClick.value.set(x, z, S.t, 1); kick(); }\n\n  let bendDirty = true;\n  function frame(now) {\n    S.raf = 0;\n    const dt = Math.min(0.05, (now - (S.last || now)) / 1000); S.last = now; if (!reduced) S.t += dt;\n    // the pressure map relaxes: grass stands back up over ~3 s\n    bg.globalCompositeOperation = 'source-over'; bg.fillStyle = `rgba(0,0,0,${reduced ? 0.2 : Math.min(1, dt * 0.75)})`; bg.fillRect(0, 0, 256, 256);\n    if (S.ptr && S.prev) { ray.setFromCamera(ndc.set(S.px, S.py), camera); if (ray.ray.intersectPlane(plane, hit)) { stamp(S.prev, hit, 1); S.prev.copy(hit); } }\n    bendTex.needsUpdate = true;\n    // the camera breathes, and leans a touch toward the pointer\n    const k = 1 - Math.exp(-dt * 2.5);\n    S.hx += ((S.ptr ? S.px : 0) - S.hx) * k; S.hy += ((S.ptr ? S.py : 0) - S.hy) * k;\n    const sway = reduced ? 0 : Math.sin(S.t * 0.35) * 0.06;\n    camera.position.set(S.hx * 0.35 + sway, (S.portrait ? 1.25 : 1.05) + S.hy * 0.12 + (reduced ? 0 : Math.sin(S.t * 0.5) * 0.02), 7.2);\n    camera.lookAt(S.hx * 0.8, S.portrait ? 0.95 : 0.85, -6);\n    if (T.k < 1) { T.k = Math.min(1, T.k + dt / 1.5); const e = T.k < 0.5 ? 4 * T.k ** 3 : 1 - (-2 * T.k + 2) ** 3 / 2; apply(T.from, T.to, e); }\n    shared.uTime.value = S.t;\n    renderer.render(scene, camera);\n    if (el.dataset.vsState !== 'ready') el.dataset.vsState = 'ready';\n    if (S.visible && !document.hidden && (!reduced || S.ptr || T.k < 1)) S.raf = requestAnimationFrame(frame);\n  }\n  const kick = () => { if (!S.raf && S.visible) { S.last = performance.now(); S.raf = requestAnimationFrame(frame); } };\n\n  const setPtr = (e) => { const r = el.getBoundingClientRect(); S.px = ((e.clientX - r.left) / r.width) * 2 - 1; S.py = -((e.clientY - r.top) / r.height) * 2 + 1; };\n  const onMove = (e) => { setPtr(e); if (!S.ptr) { ray.setFromCamera(ndc.set(S.px, S.py), camera); if (ray.ray.intersectPlane(plane, hit)) S.prev = hit.clone(); } S.ptr = true; kick(); };\n  const onLeave = () => { S.ptr = false; S.prev = null; kick(); };\n  const onDown = (e) => { setPtr(e); ray.setFromCamera(ndc.set(S.px, S.py), camera); if (ray.ray.intersectPlane(plane, hit)) gust(hit.x, hit.z); };\n  el.addEventListener('pointermove', onMove); el.addEventListener('pointerleave', onLeave); el.addEventListener('pointerdown', onDown);\n\n  const ro = new ResizeObserver(() => { resize(); kick(); }); ro.observe(el);\n  const io = new IntersectionObserver(([en]) => { S.visible = en.isIntersecting; if (S.visible) kick(); }, { rootMargin: '100px' }); io.observe(el);\n  const onVis = () => { if (!document.hidden) kick(); }; document.addEventListener('visibilitychange', onVis);\n  const onLost = (e) => { e.preventDefault(); cancelAnimationFrame(S.raf); S.raf = 0; S.visible = false; };\n  const onRestored = () => { destroy(); el.__vsRemount(); };\n  canvas.addEventListener('webglcontextlost', onLost); canvas.addEventListener('webglcontextrestored', onRestored);\n  function destroy() {\n    cancelAnimationFrame(S.raf); ro.disconnect(); io.disconnect(); document.removeEventListener('visibilitychange', onVis);\n    el.removeEventListener('pointermove', onMove); el.removeEventListener('pointerleave', onLeave); el.removeEventListener('pointerdown', onDown);\n    canvas.removeEventListener('webglcontextlost', onLost); canvas.removeEventListener('webglcontextrestored', onRestored);\n    blade.dispose(); bladeMat.dispose(); bendTex.dispose(); renderer.dispose();\n  }\n  resize(); renderer.compile(scene, camera); kick();\n  return { gust, set, destroy, get time() { return T.time; }, get wind() { return T.wind; } };\n}\n\ndocument.querySelectorAll('.vs-grass-field:not([data-vs-mounted])').forEach((el) => {\n  el.dataset.vsMounted = '';\n  const go = () => { const api = mount(el); if (api) el.vsGrassField = api; };\n  el.__vsRemount = go; go();\n});\n</script>\n"
    }
  ],
  "meta": {
    "category": "3d",
    "group": "3D objects",
    "tags": [
      "grass",
      "instancing",
      "wind",
      "meadow",
      "nature",
      "time of day",
      "fireflies",
      "pointer",
      "hero"
    ],
    "tech": [
      "three.js",
      "GLSL",
      "JS",
      "CSS"
    ],
    "deps": [
      "https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js"
    ],
    "source": "halm",
    "free": true,
    "accent": "#ffc27e",
    "added": "2026-09-30",
    "demo": "https://vividsites.app/api/component/grass-field/index.html",
    "thumbnail": "https://vividsites.app/api/component/grass-field/thumb.jpg",
    "video": "https://vividsites.app/api/component/grass-field/thumb.mp4",
    "page": "https://vividsites.app/c/grass-field"
  }
}