Grass swaying in the wind

Whether it’s sparse tundra or rolling green hills full of grass, a bit of wind and gentle movement can fill a space with atmosphere and believability.

The grass I use in this tutorial is a triangle but looks nice for a stylized game. The same technique works for more complex alpha-mapped textures too.

Make sure to position your mesh’s UVs with the grass’s base at the bottom and the tip at the top (or vice versa.) How high the UV of the vertex dictates how much the mesh moves; we don’t want the base to sway, and the ends should sway more than the middle.

Populate the grass

Creating a MeshInstance for every blade of grass or even every clump of grass would be too expensive even on modern hardware. Instead, MultiMeshInstance draws all the blades of grass we want all simultaneously and is vastly more efficient.

It needs some manual attention to initialize it. Add a script to instance a bed of grass and initialize the MultiMesh.

  1. Add a MultiMeshInstance
  2. Assign it a new MultiMesh
  3. Set the Instance Count. I went with 1024 for a dense square of grass, but it depends on the surface area you want to cover, your needs, and your performance budget.

Note that to edit any of the MultiMesh fields, the instance count must be 0. That’s why we edit the Instance Count as the last step.

Add a new script to the MultiMeshInstance node. In this tutorial, we initialize the MultiMesh using a flat 5x5 square that goes from -extent to +extent. A better idea is to shower a collidable terrain with raycasts to figure out where to stick the grass or analyze the mesh of your terrain to find the vertices and interpolate between them.

Regardless, if you make it a tool script, you can run it once, save the scene, then remove it from the node without losing any data.

In _ready, create a new RandomNumberGenerator and seed it.

tool
extends MultiMeshInstance

export var extents := Vector2(5, 5)

func _ready() -> void:
    var rng := RandomNumberGenerator.new()
    rng.randomize()

For each instance, we create a new Transform that rotates to a random angle and places it at a random point inside the extents. Then we assign that transform and a couple of random numbers to the instance as part of custom data.

for i in multimesh.instance_count:
    var transform := Transform().rotated(Vector3.UP, rng.randf_range(-PI/2, PI/2))

    var x := rng.randf_range(-extents.x, extents.x)
    var z := rng.randf_range(-extents.y, extents.y)
    transform.origin = Vector3(x, 0, z)

    multimesh.set_instance_transform(i, transform)
    multimesh.set_instance_custom_data(i, Color(rng.randf(), 0, rng.randf(), 0))

Save the scene, reload it, and the tool script populates the multimesh automatically. You can now save the scene and remove the script without losing the data.

The wind shader

In the Inspector, open the MultiMeshInstance’s Geometry dropdown and apply a new ShaderMaterial to the Material Override field. Assign a new Shader to the Shader field, right-click on it and save it as wind_grass.shader.

Open it, and let’s get coding.

If your grass is simple like mine, you’ll want to disable culling and control the grass’s color with the unshaded render mode.

shader_type spatial;
render_mode cull_disabled, unshaded;

We introduce four uniform variables to control the wind: the speed it moves through the grass, the strength with which it affects the grass, a randomness factor (remember those random numbers we pass in the custom data?), and a noise texture to dictate movement.

We also have one for the color of the grass. In this tutorial, the gradient is the same base color as the ground, so it blends into it, but it could house a grass texture with an alpha channel instead.

uniform float wind_speed = 0.2;
uniform float wind_strength = 2.0;
uniform float wind_randomness = 0.5;

uniform sampler2D color_ramp : hint_black_albedo;

uniform sampler2D wind_noise : hint_black;

The actual wind calculation happens at the vertex shader. We generate UVs for the grass based on the random instance data and time. When wind randomness is 0, all the grass moves like one mass, and when it’s 1, they move randomly.

void vertex() {
    vec2 uv = (INSTANCE_CUSTOM.xz * wind_randomness) + (TIME * wind_speed);
}

To get the actual movement, we use that uv to sample out of a noise texture. In the vertex shader, the only texture sampling function we have is textureLod(). We don’t have texture().

Noise is in the [0..1] range, so we subtract 0.5 from it to get a [-0.5..0.5] range, which we multiply by the wind_strength for the final movement offset.

vec2 bump_wind = (textureLod(wind_noise, uv, 0.0).rg - 0.5) * wind_strength;

We add the data to the VERTEX built-in.

VERTEX += vec3(bump_wind.x, 0.0, bump_wind.y);

In the Shader Parameters, create a new NoiseTexture in the Wind Noise parameter, and assign it a new OpenSimplexNoise. The period is high (>=150), and the lacunarity is low (<=1) to prevent the movement from being too chaotic. Since we scroll over time across it, set it to Seamless.

The grass will move, but it just slides around its position as one shape. That’s where the UVs come in; we assign the bottom of the grass 0 so it doesn’t move and the top 1 so it moves the most.

Multiply the vector added to the vertex position by UV.y. If you find the top stays still instead of the bottom, multiply it by 1.0 - UV.y instead.

VERTEX += vec3(bump_wind.x, 0.0, bump_wind.y) * (1.0 - UV.y);

Adding color

For the stylized effect, we sample from the color ramp with UV.y driving which part of the gradient we sample. We assign the sample to ALBEDO.

void fragment() {
    ALBEDO = texture(color_ramp, vec2(1.0 - UV.y, 0)).rgb;
}

If you have a transparent grass texture, assign the rgb of your texture to ALBEDO and the a channel to ALPHA.

The shader code

shader_type spatial;
render_mode cull_disabled, unshaded;

uniform float wind_speed = 0.2;
uniform float wind_strength = 2.0;
uniform float wind_randomness = 0.5;

uniform sampler2D color_ramp : hint_black_albedo;

uniform sampler2D wind_noise : hint_black;

void vertex() {
    vec2 uv = (INSTANCE_CUSTOM.xz * wind_randomness) + (TIME * wind_speed);

    vec2 bump_wind = (textureLod(wind_noise, uv, 0.0).rg - 0.5) * wind_strength;

    VERTEX += vec3(bump_wind.x, 0.0, bump_wind.y) * (1.0 - UV.y);
}

void fragment() {
    ALBEDO = texture(color_ramp, vec2(1.0 - UV.y, 0)).rgb;
}