component

Grass

Instanced grass blades (seeded scatter).

⌖ Explore the library in 3D → · View as Markdown

Tune every prop in the workshop →

Add it

npx @runek/cli add grass

Pulls @react-three/fiber@^9.6.1, @runek/core@^0.15.0, three@^0.184.0.

Use it

import { Grass } from './runek/Grass'

<Grass position={[0, 0, 0]} height="tall" seed={1} />

Or as a node in a world file (worlds as data):

{ "type": "Grass", "props": { "position": [0, 0, 0], "height": "tall", "seed": 1 } }

Props

Prop Type Default Description
position [x, y, z] [0, 0, 0] Position [x, y, z] in units (1 unit = 1 m, Y-up).
rotation [x, y, z] [0, 0, 0] Euler rotation [x, y, z] in radians.
area [number, number] [10, 10] Patch extent [width, depth], in units.
count number 600
height "short" | "medium" | "tall" | "very-tall" | number "medium" Blade height: a preset (short lawn, medium, tall meadow, very-tall reeds) or a number in units. Taller grass grows thicker blades that lean a little. Default medium (0.35).
color color palette foliage Defaults to the world palette’s foliage slot.
sway number 1 Wind sway strength; 0 disables the animation.
seed number 1 Seed for the deterministic variation: same seed, same result.

Registry manifest

registry manifest /r/components/grass.json Self-contained JSON: inlined source plus resolved dependencies, exactly what runek add grass fetches.

Browse the whole catalog in the gallery →.

Source (Grass.tsx)
import { useFrame } from '@react-three/fiber'
import { rng, sub, useWorld, type Vec3 } from '@runek/core'
import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'
import * as THREE from 'three'

/** Named blade heights, from a mown lawn to reeds you can hide in. */
export type GrassHeight = 'short' | 'medium' | 'tall' | 'very-tall'

const HEIGHTS: Record<GrassHeight, number> = {
  short: 0.15,
  medium: 0.35,
  tall: 0.8,
  'very-tall': 1.5,
}

export interface GrassProps {
  position?: Vec3
  rotation?: Vec3
  /** Patch extent `[width, depth]`, in units. */
  area?: [number, number]
  count?: number
  /**
   * Blade height: a preset (`short` lawn, `medium`, `tall` meadow, `very-tall` reeds) or a
   * number in units. Taller grass grows thicker blades that lean a little. Default `medium`
   * (0.35).
   */
  height?: GrassHeight | number
  /** Defaults to the world palette's `foliage` slot. */
  color?: string
  /** Wind sway strength; 0 disables the animation. */
  sway?: number
  seed?: number
}

interface Blade {
  x: number
  z: number
  rot: number
  scale: number
  shade: number
  lean: number
}

/** Decorative instanced blades with a vertex-shader wind sway — no collider. */
export function Grass({
  position = [0, 0, 0],
  rotation = [0, 0, 0],
  area = [10, 10],
  count = 600,
  height = 'medium',
  color,
  sway = 1,
  seed = 1,
}: GrassProps) {
  const { unit, palette } = useWorld()
  const bladeColor = color ?? palette.foliage
  const w = area[0] * unit
  const d = area[1] * unit
  const meters = typeof height === 'number' ? height : (HEIGHTS[height] ?? HEIGHTS.medium)
  const h = meters * unit
  // Thicker blades for taller grass, so a reed doesn't read as a needle; `medium` is the baseline.
  const radius = 0.015 * unit * Math.min(Math.max(Math.sqrt(meters / HEIGHTS.medium), 0.8), 2)
  // Grass up to `medium` stands straight; taller blades lean up to this far, in radians.
  const maxLean = 0.35 * Math.min(Math.max((meters - HEIGHTS.medium) / 1.15, 0), 1)
  const ref = useRef<THREE.InstancedMesh>(null)

  // Shared uniform objects so the patched shader and useFrame see the same values.
  const swayUniforms = useRef({
    uTime: { value: 0 },
    uBladeHeight: { value: h },
    uSway: { value: sway * h * 0.18 },
  })
  swayUniforms.current.uBladeHeight.value = h
  swayUniforms.current.uSway.value = sway * h * 0.18

  const material = useMemo(() => {
    const mat = new THREE.MeshStandardMaterial()
    mat.onBeforeCompile = (shader) => {
      shader.uniforms.uTime = swayUniforms.current.uTime
      shader.uniforms.uBladeHeight = swayUniforms.current.uBladeHeight
      shader.uniforms.uSway = swayUniforms.current.uSway
      shader.vertexShader = `
        uniform float uTime;
        uniform float uBladeHeight;
        uniform float uSway;
        ${shader.vertexShader}
      `.replace(
        '#include <begin_vertex>',
        /* glsl */ `
        #include <begin_vertex>
        #ifdef USE_INSTANCING
          float swayPhase = instanceMatrix[3].x * 0.9 + instanceMatrix[3].z * 1.3;
          float swayAmt = clamp(position.y / uBladeHeight + 0.5, 0.0, 1.0) * uSway;
          transformed.x += sin(uTime * 1.7 + swayPhase) * swayAmt;
          transformed.z += cos(uTime * 1.2 + swayPhase) * swayAmt * 0.6;
        #endif
        `,
      )
    }
    mat.customProgramCacheKey = () => 'runek-grass-sway'
    return mat
  }, [])

  useEffect(() => () => material.dispose(), [material])

  useFrame((_, delta) => {
    if (sway > 0) swayUniforms.current.uTime.value += delta
  })

  const blades = useMemo<Blade[]>(() => {
    const next = rng(seed)
    // Its own stream, so adding lean left every seed's blade layout where it was.
    const tilt = rng(sub(seed, 1))
    return Array.from({ length: count }, () => ({
      x: (next() - 0.5) * w,
      z: (next() - 0.5) * d,
      rot: next() * Math.PI,
      scale: 0.6 + next() * 0.8,
      shade: 0.8 + next() * 0.4,
      lean: tilt() * maxLean,
    }))
  }, [w, d, count, seed, maxLean])

  useLayoutEffect(() => {
    const mesh = ref.current
    if (!mesh) return
    const dummy = new THREE.Object3D()
    const base = new THREE.Color(bladeColor)
    const tint = new THREE.Color()
    const center = new THREE.Vector3()
    blades.forEach((b, i) => {
      // Lean about the blade's root, not its middle, so it stays planted.
      dummy.rotation.set(b.lean, b.rot, 0, 'YXZ')
      center.set(0, (h * b.scale) / 2, 0).applyEuler(dummy.rotation)
      dummy.position.set(b.x + center.x, center.y, b.z + center.z)
      dummy.scale.set(1, b.scale, 1)
      dummy.updateMatrix()
      mesh.setMatrixAt(i, dummy.matrix)
      mesh.setColorAt(i, tint.copy(base).multiplyScalar(b.shade))
    })
    mesh.instanceMatrix.needsUpdate = true
    if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true
  }, [blades, h, bladeColor])

  return (
    <group position={position} rotation={rotation}>
      <instancedMesh
        ref={ref}
        args={[undefined, undefined, count]}
        material={material}
        castShadow
        receiveShadow
      >
        <coneGeometry args={[radius, h, 4]} />
      </instancedMesh>
    </group>
  )
}