Making Waves: How I built a noise-based wave system in Godot
I recently built a lightweight ocean system in Godot 4 using nothing but a shader and a script attached to a MeshInstance3D. The goal wasn’t full fluid simulation, but rather a way of producing convincing waves that also supported floating objects. Everything is procedural and editable in the editor.
The Shader
shader_type spatial; render_mode world_vertex_coords, unshaded, diffuse_toon;
uniform sampler2D noise_1; uniform sampler2D noise_2; uniform vec2 noise_offset_1; uniform vec2 noise_offset_2; uniform float noise_interference_factor = 1.5; uniform vec4 color_light : source_color; uniform vec4 color_dark : source_color; uniform float max_wave_height = 5.0; varying float noise_value; void vertex() { float noise_value_1 = texture(noise_1, (VERTEX.xz + noise_offset_1) / 100.0).r; float noise_value_2 = texture(noise_2, (VERTEX.xz + noise_offset_2) / 100.0).r; noise_value = pow(noise_value_1 * noise_value_2, noise_interference_factor); VERTEX.y += noise_value * max_wave_height; } void fragment() { ALBEDO = mix(color_dark, color_light, noise_value).rgb; }
This shader displaces the mesh by sampling two different noise textures. The plane is 100x100 meters and subdivided into a 1000x1000 grid, which means every vertex corresponds to exactly one pixel in the noise textures. That allows the noise to directly map to world geometry without interpolation artifacts.
The fragment shader just uses the same noise value to tint the mesh, which helps visualize the wave pattern.
The script
@tool
extends MeshInstance3D
var noise_image_1 : Image
var noise_image_2 : Image
var noise_offset_1 := Vector2.ZERO
var noise_offset_2 := Vector2.ZERO
const NOISE_SIZE = 1000.0
const MESH_SIZE = 100.0
const MAX_WAVE_HEIGHT = 5.0
const NOISE_1_SPEED = 1.0
const NOISE_2_SPEED = 0.1
const NOISE_INTERFERENCE_FACTOR = 1.5
func _ready() -> void:
noise_image_1 = get_surface_override_material(0).get("shader_parameter/noise_1").noise.get_seamless_image(NOISE_SIZE, NOISE_SIZE)
noise_image_2 = get_surface_override_material(0).get("shader_parameter/noise_2").noise.get_seamless_image(NOISE_SIZE, NOISE_SIZE)
get_surface_override_material(0).set("shader_parameter/max_wave_height", MAX_WAVE_HEIGHT)
get_surface_override_material(0).set("shader_parameter/noise_interference_factor", NOISE_INTERFERENCE_FACTOR)
func _process(delta: float) -> void:
update_noise_offset(delta)
func update_noise_offset(delta) -> void:
noise_offset_1 = wrap_offset(noise_offset_1 + Vector2(1.0, 1.0) * delta * NOISE_1_SPEED)
noise_offset_2 = wrap_offset(noise_offset_2 + Vector2(1.0, 1.0) * delta * NOISE_2_SPEED)
get_surface_override_material(0).set("shader_parameter/noise_offset_1", noise_offset_1)
get_surface_override_material(0).set("shader_parameter/noise_offset_2", noise_offset_2)
func wrap_offset(_offset: Vector2) -> Vector2:
return Vector2(fmod(_offset.x, MESH_SIZE), fmod(_offset.y, MESH_SIZE))
func position_to_pixel_coords(_position: Vector2, offset:=Vector2.ZERO) -> Vector2:
var coords = (_position + offset) * (NOISE_SIZE / MESH_SIZE)
coords.x = fmod(coords.x, NOISE_SIZE)
coords.y = fmod(coords.y, NOISE_SIZE)
return coords
func get_wave_height_at(_position: Vector2) -> float:
var pixel_coords_1 = position_to_pixel_coords(_position, noise_offset_1)
var pixel_coords_2 = position_to_pixel_coords(_position, noise_offset_2)
return pow(noise_image_1.get_pixelv(pixel_coords_1).r * noise_image_2.get_pixelv(pixel_coords_2).r, NOISE_INTERFERENCE_FACTOR) * MAX_WAVE_HEIGHT
The noise_offset within the shader is animated from this script, which makes the waves move across the surface. We have a function get_wave_height_at() that returns the wave height at any global position. This is key for being able to support floating objects.