Fast and Gorgeous Erosion Filter

Implementation of Fast and Gorgeous Erosion Filter - runevision

.
Its the basic implementation, I stopped at the fade for peak and valley.

1.The script is attached to a Node3D.
2.You need to create a shader material and assign the shader to the material

@tool
extends Node3D

@export_group("Resources")
@export var material: ShaderMaterial = preload("res://terrain_erosion_test/terrain_erosion_test_material.res")
@export var noise: FastNoiseLite
@export_group("Base")
@export var size: float = 20000.0
@export var altitude: float = 8000.0
@export var resolution: int = 128
@export_group("Erosion (Single Pass)")
@export var sine_amplitude: float = 40.0
@export var sine_frequency: float = 0.01
@export var cell_size: float = 400.0
@export_group("Slope")
@export var smoothstep_min: float = 0.05
@export var smoothstep_max: float = 0.15

var height_data: PackedFloat32Array
var base_slope_mask: PackedFloat32Array
var grid_size: int
var mask_tex: ImageTexture

func _ready():
	for c in get_children(): if c is MeshInstance3D: c.queue_free()
	if !noise: return
	grid_size = resolution + 1
	height_data.resize(grid_size**2)
	base_slope_mask.resize(grid_size**2)
	
	# Initial Height Generation
	for z in grid_size:
		for x in grid_size:
			height_data[x + z * grid_size] = noise.get_noise_2d((float(x)/resolution)*size, (float(z)/resolution)*size) * altitude

	# Slope Mask Generation
	var step = size / resolution
	var img = Image.create(grid_size, grid_size, false, Image.FORMAT_L8)
	for z in grid_size:
		for x in grid_size:
			var idx = x + z * grid_size
			var g = Vector2(height_data[clamp(x+1,0,resolution)+z*grid_size]-height_data[idx], height_data[x+clamp(z+1,0,resolution)*grid_size]-height_data[idx])
			var m = smoothstep(smoothstep_min, smoothstep_max, g.length()/step)
			base_slope_mask[idx] = m
			img.set_pixel(x, z, Color(m, m, m, 1.0))
	mask_tex = ImageTexture.create_from_image(img)

	# Apply single erosion pass
	apply_erosion_pass()
	
	if material: material.set_shader_parameter("slope_mask_tex", mask_tex)
	create_mesh()

func apply_erosion_pass():
	var step = size / resolution
	var prev = height_data.duplicate()

	for z in grid_size:
		for x in grid_size:
			var idx = x + z * grid_size
			var m = base_slope_mask[idx]
			if m <= 0.001: continue
			
			# Calculate Gradient Tangent
			var g = Vector2(prev[clamp(x+1,0,resolution)+z*grid_size]-prev[idx], prev[x+clamp(z+1,0,resolution)*grid_size]-prev[idx])
			var tan = Vector2(-g.y, g.x).normalized()
			var w_pos = Vector2(x, z) * step
			var grid_c = (w_pos / cell_size).floor()
			var o_s = 0.0
			var o_w = 0.0
			
			# Neighborhood sampling (No octave scaling)
			for ny in range(-1, 2):
				for nx in range(-1, 2):
					var rng = RandomNumberGenerator.new()
					# Seeded by cell position and noise seed
					rng.seed = hash(str(grid_c + Vector2(nx, ny)) + str(noise.seed))
					var piv = ((grid_c + Vector2(nx, ny)) * cell_size) + Vector2(rng.randf(), rng.randf()) * cell_size
					var w = pow(1.0 - smoothstep(0.0, cell_size * 1.5, w_pos.distance_to(piv)), 2.0)
					if w > 0.001:
						o_s += sin((w_pos - piv).dot(tan) * sine_frequency) * w
						o_w += w
			
			if o_w > 0.0: 
				height_data[idx] += (o_s / o_w) * sine_amplitude * m

func create_mesh():
	var st = SurfaceTool.new()
	st.begin(Mesh.PRIMITIVE_TRIANGLES)
	var step = size / resolution
	for z in grid_size:
		for x in grid_size:
			st.set_uv(Vector2(float(x)/resolution, float(z)/resolution))
			st.add_vertex(Vector3(x * step, height_data[x + z * grid_size], z * step))
	for z in resolution:
		for x in resolution:
			var i = x + z * grid_size
			for off in [0, 1, grid_size, 1, grid_size + 1, grid_size]: st.add_index(i + off)
	st.generate_normals()
	st.set_material(material)
	var mi = MeshInstance3D.new()
	mi.mesh = st.commit()
	add_child(mi)
	if Engine.is_editor_hint(): mi.owner = get_tree().edited_scene_root

shader_type spatial;

uniform sampler2D stripe_texture;
uniform bool show_stripes = false;
uniform float alt_coeff = 1.0;  // use this to compress altitude range
varying vec3 world_pos;
varying vec3 world_norm;

void vertex() {
    world_pos = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz;
    world_norm = normalize((MODEL_MATRIX * vec4(NORMAL, 0.0)).xyz);
}

void fragment() {
    float y = world_pos.y;
    float flatten = dot(world_norm, vec3(0, 1, 0));
    vec3 color;

    float beach_line = 20.0 * alt_coeff;
    float tree_line = 1700.0 * alt_coeff;

    if (y < -1000.0 * alt_coeff)      color = vec3(0.00, 0.11, 0.23);
    else if (y <= -100.0 * alt_coeff) color = vec3(0.00, 0.31, 0.48);
    else if (y <= -20.0 * alt_coeff)  color = vec3(0.29, 0.59, 0.69);
    else if (y <= beach_line)         color = vec3(0.82, 0.71, 0.55); 
    else if (y <= tree_line)          color = vec3(0.46, 0.63, 0.36); 
    else if (y <= 2800.0 * alt_coeff) color = vec3(0.55, 0.55, 0.48); 
    else if (y <= 3500.0 * alt_coeff) color = vec3(0.43, 0.43, 0.43); 
    else                              color = vec3(0.94, 0.97, 1.00); 

    if (y > beach_line) {
        if (flatten < 0.85)           color = vec3(0.30, 0.30, 0.30); 
        else if (flatten < 0.90)      color = vec3(0.35, 0.25, 0.15); 
        else if (flatten < 0.97 && y <= tree_line) color = vec3(0.18, 0.31, 0.16); 
    }

    if (show_stripes) {
        float value = texture(stripe_texture, UV).r;
        ALBEDO = vec3(value);
    } else {
        ALBEDO = color;
    }
}
  1. You can visualize the stripes (set show_stripes to true).
7 Likes

Cool. Can’t rly say much. But cool. Good job. Keep making cool stuff. :grin: :+1:

The first script is for only 1 octave, here is a new with more octave, and a shader to see the slope mask.
Now with each octave the new stripe rotate correctly

@tool
extends Node3D

@export_group("Resources")
@export var material: ShaderMaterial = preload("res://terrain_erosion_test/terrain_erosion_test_material.res")
@export var noise: FastNoiseLite
@export_group("Base")
@export var size: float = 20000.0
@export var altitude: float = 8000.0
@export var resolution: int = 128
@export_group("Erosion")
@export var sine_octaves: int = 2
@export var sine_amplitude: float = 40.0
@export var sine_frequency: float = 0.01
@export var cell_size: float = 400.0
@export_group("Slope")
@export var smoothstep_min: float = 0.05
@export var smoothstep_max: float = 0.15

var height_data: PackedFloat32Array
var base_slope_mask: PackedFloat32Array
var grid_size: int
var mask_tex: ImageTexture

func _ready():
	for c in get_children(): if c is MeshInstance3D: c.queue_free()
	if !noise: return
	grid_size = resolution + 1
	height_data.resize(grid_size**2)
	base_slope_mask.resize(grid_size**2)
	
	for z in grid_size:
		for x in grid_size:
			height_data[x + z * grid_size] = noise.get_noise_2d((float(x)/resolution)*size, (float(z)/resolution)*size) * altitude

	var step = size / resolution
	var img = Image.create(grid_size, grid_size, false, Image.FORMAT_L8)
	for z in grid_size:
		for x in grid_size:
			var idx = x + z * grid_size
			var g = Vector2(height_data[clamp(x+1,0,resolution)+z*grid_size]-height_data[idx], height_data[x+clamp(z+1,0,resolution)*grid_size]-height_data[idx])
			var m = smoothstep(smoothstep_min, smoothstep_max, g.length()/step)
			base_slope_mask[idx] = m
			img.set_pixel(x, z, Color(m, m, m, 1.0))
	mask_tex = ImageTexture.create_from_image(img)

	for i in sine_octaves: apply_erosion_pass(i)
	if material: material.set_shader_parameter("slope_mask_tex", mask_tex)
	create_mesh()

func apply_erosion_pass(oct: int):
	var freq = sine_frequency * pow(2.0, oct)
	var amp = sine_amplitude * pow(0.5, oct)
	var c_size = cell_size / pow(2.0, oct)
	var step = size / resolution
	var prev = height_data.duplicate()

	for z in grid_size:
		for x in grid_size:
			var idx = x + z * grid_size
			var m = base_slope_mask[idx]
			if m <= 0.001: continue
			
			var g = Vector2(prev[clamp(x+1,0,resolution)+z*grid_size]-prev[idx], prev[x+clamp(z+1,0,resolution)*grid_size]-prev[idx])
			var tan = Vector2(-g.y, g.x).normalized()
			var w_pos = Vector2(x, z) * step
			var grid_c = (w_pos / c_size).floor()
			var o_s = 0.0
			var o_w = 0.0
			
			for ny in range(-1, 2):
				for nx in range(-1, 2):
					var rng = RandomNumberGenerator.new()
					rng.seed = hash(str(grid_c + Vector2(nx, ny)) + str(noise.seed + oct))
					var piv = ((grid_c + Vector2(nx, ny)) * c_size) + Vector2(rng.randf(), rng.randf()) * c_size
					var w = pow(1.0 - smoothstep(0.0, c_size * 1.5, w_pos.distance_to(piv)), 2.0)
					if w > 0.001:
						o_s += sin((w_pos - piv).dot(tan) * freq) * w
						o_w += w
			if o_w > 0.0: height_data[idx] += (o_s / o_w) * amp * m

func create_mesh():
	var st = SurfaceTool.new()
	st.begin(Mesh.PRIMITIVE_TRIANGLES)
	var step = size / resolution
	for z in grid_size:
		for x in grid_size:
			st.set_uv(Vector2(float(x)/resolution, float(z)/resolution))
			st.add_vertex(Vector3(x * step, height_data[x + z * grid_size], z * step))
	for z in resolution:
		for x in resolution:
			var i = x + z * grid_size
			for off in [0, 1, grid_size, 1, grid_size + 1, grid_size]: st.add_index(i + off)
	st.generate_normals()
	st.set_material(material)
	var mi = MeshInstance3D.new()
	mi.mesh = st.commit()
	add_child(mi)
	if Engine.is_editor_hint(): mi.owner = get_tree().edited_scene_root

shader_type spatial;

uniform sampler2D slope_mask_tex;
uniform bool show_slope = false;
varying vec3 world_pos;
varying vec3 world_norm;

void vertex() {
    world_pos = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz;
    world_norm = normalize((MODEL_MATRIX * vec4(NORMAL, 0.0)).xyz);
}

void fragment() {
    float y = world_pos.y;
    float flatten = dot(world_norm, vec3(0, 1, 0));
    vec3 color;

    float beach_line = 20.0;
    float tree_line = 1700.0;

    if (y < -1000.0) color = vec3(0.00, 0.11, 0.23);
    else if (y <= -100.0) color = vec3(0.00, 0.31, 0.48);
    else if (y <= -20.0) color = vec3(0.29, 0.59, 0.69);
    else if (y <= beach_line) color = vec3(0.82, 0.71, 0.55); 
    else if (y <= tree_line) color = vec3(0.46, 0.63, 0.36); 
    else if (y <= 2800.0) color = vec3(0.55, 0.55, 0.48); 
    else if (y <= 3500.0) color = vec3(0.43, 0.43, 0.43); 
    else color = vec3(0.94, 0.97, 1.00); 

    if (y > beach_line) {
        if (flatten < 0.9) color = vec3(0.30, 0.30, 0.30); 
        else if (flatten < 0.95) color = vec3(0.35, 0.25, 0.15); 
        else if (flatten < 0.999 && y <= tree_line) color = vec3(0.18, 0.31, 0.16);
    }

    if (show_slope) {
        float value = texture(slope_mask_tex, UV).r;
        ALBEDO = vec3(value);
    } else {
        ALBEDO = color;
    }
}
2 Likes

The implementation on a quadtree terrain with multithread

Main script, attached to a node3D

@tool
extends Node3D

@export var keep_seed = true

var size = 200000
var altitude = 4000
@export var resolution = 32
@export var material = preload("res://terrain_erosion_test/terrain_erosion_test_material.res")

@export var max_depth = 0
@export var dist_scale = 1.5
var next_quadtree = {}
var current_quadtree = {}

var heightmap_helper: HeightmapHelper
var mesh_helper: MeshHelper
var erosion_helper : ErosionHelper

var camera: Camera3D
@export var last_pos: Vector3

var need_update = false
var generating_nodes = false
var pending_nodes: int = 0

@export_category("Heightmap")
@export var seed = 0
@export var continent: FastNoiseLite
@export var continent_frequency = 0.5
@export var mountain: FastNoiseLite
@export var mountain_frequency = 3.0
@export var show_continent_only = false
@export var show_mountain_only = false
@export var show_continent_mask = false
@export var show_mountain_mask = false
@export var mountain_min = 0.0
@export var mountain_max = 1.0

func _ready() -> void:
	for child in get_children():
		child.queue_free()
		
	camera = EditorInterface.get_editor_viewport_3d().get_camera_3d()
	
	await get_tree().process_frame # Issue with editor and camera position
	if last_pos == Vector3.ZERO:
		last_pos = camera.global_position
	
	# Initalize the heightmap helper
	if continent == null:
		continent = FastNoiseLite.new()
		continent.noise_type = FastNoiseLite.TYPE_PERLIN
	if mountain == null:
		mountain = FastNoiseLite.new()
		mountain.noise_type = FastNoiseLite.TYPE_CELLULAR
	heightmap_helper = HeightmapHelper.new(size, altitude, resolution, continent, mountain, continent_frequency, mountain_frequency, show_continent_only, show_mountain_only, show_continent_mask, show_mountain_mask, mountain_min, mountain_max)
	if not keep_seed:
		heightmap_helper.find_seed()
	else: heightmap_helper.seed = seed
	
	erosion_helper = ErosionHelper.new()
	mesh_helper = MeshHelper.new(resolution, erosion_helper, heightmap_helper)
	
	update_quadtree()
		
func _process(delta: float) -> void:
	if camera.global_position.distance_to(last_pos) > 200:
		last_pos = camera.global_position
		update_quadtree()
	
	if need_update:
		if not generating_nodes:
			need_update = false
			update_quadtree()

func _physics_process(delta: float) -> void:
	if generating_nodes: # We wait here for all nodes to be generated
		if pending_nodes == 0:
			clear_unused_nodes()

func update_quadtree():
	if generating_nodes:
		need_update
		return
	generating_nodes = true
	
	next_quadtree.clear()
	var root = Rect2(Vector2.ZERO, Vector2.ONE * size)
	subdivide_quadtree(root, 0)
	if next_quadtree != current_quadtree:
		for node: Rect2 in next_quadtree:
			if not current_quadtree.has(node):
				pending_nodes += 1
				WorkerThreadPool.add_task(func(): thread_generate_mesh(node))
				
		# Sync the states
		current_quadtree = next_quadtree.duplicate()

func subdivide_quadtree(current_node: Rect2, depth: int):
	if depth >= max_depth:
		next_quadtree[current_node] = true
		return
		
	var half_size = current_node.size / 2.0
	var half_width = current_node.size.x / 2.0
	var center_3d = Vector3(current_node.get_center().x, 0, current_node.get_center().y)
	center_3d.y = 0
	if camera.global_position.distance_to(center_3d) < half_width * dist_scale:
		var children = [
			Rect2(current_node.position, half_size),
			Rect2(current_node.position + Vector2(half_width, 0), half_size),
			Rect2(current_node.position + Vector2(half_width, half_width), half_size),
			Rect2(current_node.position + Vector2(0, half_width), half_size),
		]
		for child in children:
			subdivide_quadtree(child, depth + 1)
	else:
		next_quadtree[current_node] = true

func thread_generate_mesh(node: Rect2):
	# 1. Generate the raw data
	var mesh_data = mesh_helper.generate_mesh_data(node)
	
	# 2. DO THE HEAVY LIFTING HERE (Off the main thread)
	var array_mesh = ArrayMesh.new()
	array_mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, mesh_data)
	
	var st = SurfaceTool.new()
	st.create_from(array_mesh, 0)
	st.generate_normals()
	var final_mesh = st.commit() # This is now ready for the GPU
	
	# Pass the completed mesh to the main thread
	call_deferred("finalize_node", node, final_mesh)

func finalize_node(node: Rect2, final_mesh: Mesh):
	pending_nodes = max(0, pending_nodes - 1)
	
	# Create the instance - this is now very light
	var mesh_instance = MeshInstance3D.new()
	mesh_instance.mesh = final_mesh
	mesh_instance.material_override = material
	mesh_instance.set_meta("node", node)
	
	add_child(mesh_instance)
	# REMOVED: mesh_instance.owner setting to avoid Editor lag

func clear_unused_nodes():
	# Remove unused nodes
	for child in get_children():
		var node = child.get_meta("node", Rect2())
		if not next_quadtree.has(node):
			child.queue_free()
	generating_nodes = false

Helper for mesh arrays

@tool
class_name MeshHelper
extends RefCounted

var resolution: int
var heightmap_helper: HeightmapHelper
var erosion_helper: ErosionHelper

func _init(p_res: int, p_erosion_helper: ErosionHelper, p_heightmap_helper: HeightmapHelper):
	resolution = p_res
	erosion_helper = p_erosion_helper
	heightmap_helper = p_heightmap_helper

func generate_mesh_data(rect: Rect2) -> Array:
	var grid_size = resolution + 1
	var step_size = rect.size.x / resolution
	
	# Step 1: Generate Base Height Data
	var height_data = PackedFloat32Array()
	height_data.resize(grid_size * grid_size)
	
	for z in grid_size:
		for x in grid_size:
			var pos_x = rect.position.x + (x * step_size)
			var pos_z = rect.position.y + (z * step_size)
			height_data[x + z * grid_size] = heightmap_helper.get_altitude(Vector3(pos_x, 0, pos_z))

	# Step 2: Apply Erosion via Helper
	if erosion_helper:
		height_data = erosion_helper.apply_erosion(height_data, resolution, rect.size.x, rect.position)

	# Step 3: Build the Mesh Arrays
	var vertices = PackedVector3Array()
	var uvs = PackedVector2Array()
	var indices = PackedInt32Array()
	
	vertices.resize(grid_size * grid_size)
	uvs.resize(grid_size * grid_size)
	
	for z in grid_size:
		for x in grid_size:
			var i = x + z * grid_size
			var pos_x = rect.position.x + (x * step_size)
			var pos_z = rect.position.y + (z * step_size)
			
			vertices[i] = Vector3(pos_x, height_data[i], pos_z)
			uvs[i] = Vector2(float(x) / resolution, float(z) / resolution)

	# Indices logic
	for z in resolution:
		for x in resolution:
			var i = x + z * grid_size
			indices.append(i)
			indices.append(i + 1)
			indices.append(i + grid_size)
			indices.append(i + 1)
			indices.append(i + grid_size + 1)
			indices.append(i + grid_size)

	var arrays = []
	arrays.resize(Mesh.ARRAY_MAX)
	arrays[Mesh.ARRAY_VERTEX] = vertices
	arrays[Mesh.ARRAY_TEX_UV] = uvs
	arrays[Mesh.ARRAY_INDEX] = indices
	
	return arrays

Helper for erosion

@tool
class_name ErosionHelper
extends RefCounted

# Parameters passed in from the MeshHelper or UI
var config: Dictionary = {
	"sine_octaves": 2,
	"sine_amplitude": 40.0,
	"sine_frequency": 0.01,
	"cell_size": 400.0,
	"smoothstep_min": 0.05,
	"smoothstep_max": 0.15,
	"seed": 12345
}

func apply_erosion(height_data: PackedFloat32Array, resolution: int, size: float, offset: Vector2) -> PackedFloat32Array:
	var grid_size = resolution + 1
	var step = size / resolution
	
	# 1. Generate Slope Mask
	var slope_mask = PackedFloat32Array()
	slope_mask.resize(height_data.size())
	
	for z in grid_size:
		for x in grid_size:
			var idx = x + z * grid_size
			var dx = height_data[clamp(x + 1, 0, resolution) + z * grid_size] - height_data[idx]
			var dz = height_data[x + clamp(z + 1, 0, resolution) * grid_size] - height_data[idx]
			var slope_len = Vector2(dx, dz).length() / step
			slope_mask[idx] = smoothstep(config.smoothstep_min, config.smoothstep_max, slope_len)

	# 2. Apply Passes
	var eroded_heights = height_data.duplicate()
	for i in config.sine_octaves:
		eroded_heights = _apply_erosion_pass(eroded_heights, slope_mask, i, resolution, size, offset)
	
	return eroded_heights

func _apply_erosion_pass(heights: PackedFloat32Array, mask: PackedFloat32Array, oct: int, resolution: int, size: float, offset: Vector2) -> PackedFloat32Array:
	var grid_size = resolution + 1
	var step = size / resolution
	var result = heights.duplicate()
	
	var freq = config.sine_frequency * pow(2.0, oct)
	var amp = config.sine_amplitude * pow(0.5, oct)
	var c_size = config.cell_size / pow(2.0, oct)

	for z in grid_size:
		for x in grid_size:
			var idx = x + z * grid_size
			var m = mask[idx]
			if m <= 0.001: continue
			
			var dx = heights[clamp(x+1, 0, resolution) + z * grid_size] - heights[idx]
			var dz = heights[x + clamp(z+1, 0, resolution) * grid_size] - heights[idx]
			var tan = Vector2(-dz, dx).normalized()
			
			var w_pos = (Vector2(x, z) * step) + offset
			var grid_c = (w_pos / c_size).floor()
			var o_s = 0.0
			var o_w = 0.0
			
			for ny in range(-1, 2):
				for nx in range(-1, 2):
					var rng = RandomNumberGenerator.new()
					# Combine global seed with cell pos and octave for consistency
					rng.seed = hash(str(grid_c + Vector2(nx, ny)) + str(config.seed + oct))
					var piv = ((grid_c + Vector2(nx, ny)) * c_size) + Vector2(rng.randf(), rng.randf()) * c_size
					var w = pow(1.0 - smoothstep(0.0, c_size * 1.5, w_pos.distance_to(piv)), 2.0)
					if w > 0.001:
						o_s += sin((w_pos - piv).dot(tan) * freq) * w
						o_w += w
						
			if o_w > 0.0:
				result[idx] += (o_s / o_w) * amp * m
				
	return result

2 Likes

insane upgrade.