Scatterbox for Terrain3D

Scatterbox wasn’t working for Terrain3D so I added a Raycast function … I only added this to the ScatterScene3D.gd not to the other files. The scatter scene now has an @export variable that takes a Terrain3D object as a parameter.

Tested on Godot 4.6.2 stable, with Terrain3D 1.01 Nightly build from yesterday.

The static collision option wasnt viable because the editor kept freezing and building the collision took too much memory for an 8k map.

Any tips on improving the custom ray cast function would be welcome. Simply takes the rays and walks along them 1m per step until the Terrain3D height is greater than the ray height and that’s the intersection point. (NOT vibe coded, just ray casting from memory and I’m rusty, so please leave any tips in comments below)

To use the plugin, make sure Terrain3D has the editor/dynamic collision feature set, and also I like to start by moving the DrawPointer node until its at the height of the terrain in a visible location. Dont move the ScatterScene3D node, that has to remain at the origin. Then I select the ScatterScene3D node, add the scenes I want to scatter, set the collision mask, then make sure the Terrain3D @export is set, then I scale up the DrawPointer using the scroll wheel - it should start to follow the mouse and align with the terrain normal, then click to scatter some scenes.

DarkGrey456/Scatterbox_For_Terrain3D

modifications …

@tool
extends ScatterBox
class_name ScatterScene3D

@export var scenes: Array[PackedScene]

@export_flags_3d_physics var instanced_scene_collision_layers
@export var terrain3D:Terrain3D 

class COLLISION:
	var position:Vector3
	var normal:Vector3

func ray_intersect_terrain(start:Vector3, end:Vector3)->COLLISION:
	if terrain3D == null:
		var result = _space.intersect_ray(PhysicsRayQueryParameters3D.create(start, end, ~instanced_scene_collision_layers))
		var collision:COLLISION = COLLISION.new()
		if result:
			collision.position = result.position
			collision.normal = result.normal
			return collision
		return null
	else:
		var ray_cast:Vector3 = start
		var intersect = false
		var del = 1.0
		var collision:COLLISION = COLLISION.new()
		var MAX_STEPS = 200
		var curr_step = 0
		while (intersect == false) and curr_step < MAX_STEPS:
			ray_cast += (end - start).normalized() * del
			var location = Vector3(ray_cast.x, 0.0, ray_cast.z)
			var height = terrain3D.data.get_height(location)	
			curr_step += 1
			if height > ray_cast.y:
				intersect = true
				var norm = terrain3D.data.get_normal(location)				
				collision.position = ray_cast
				collision.normal = norm
				return collision
				
		return null

#overwrite default code
func move_to_mouse(camera, mouse: Vector2):
	var start = camera.project_ray_origin(mouse)
	var end = start + camera.project_ray_normal(mouse) * 1000
	if terrain3D:
		terrain3D.set_camera(camera)
	if not _space:
		_space = get_parent_node_3d().get_world_3d().direct_space_state
	var result = ray_intersect_terrain(start,end)
	
	if result == null: 
		print("EMPTY RESULT")
		return true
	
	var t := Transform3D()
	t.origin = result.position
		
	t.origin += offset_position
		
	#align mesh with floor nomral
	t.basis = Basis(result.normal.cross(global_transform.basis.z),
			result.normal,
			global_transform.basis.x.cross(result.normal),
		).orthonormalized()
	
	draw_pointer.basis = t.basis
	
	draw_pointer.global_transform.origin = result.position
	return true



func refresh():
	pass


func scatter_obj():
	for i in range(count):
		var pos := draw_pointer.global_position
		
		pos += Vector3(
			_rng.randf_range(-placement_size.x / 2.0, placement_size.x / 2.0),
			0,
			_rng.randf_range(-placement_size.z / 2.0, placement_size.z / 2.0))
		
		pos = pos + Vector3(
				_rng.randf_range(min_random_size.x, max_random_size.x),
				0,
				_rng.randf_range(min_random_size.z, max_random_size.z))
		
		
		var startPos = pos
		startPos.y += placement_size.y
		var endPos = pos
		endPos.y -= placement_size.y

		var hit = ray_intersect_terrain(startPos,endPos)		
		#var ray = PhysicsRayQueryParameters3D.create(startPos, endPos, ~instanced_scene_collision_layers)
		
		#var hit = _space.intersect_ray(ray)
		
		if(hit==null): continue
		
		var t := Transform3D()
		t.origin = hit.position
		t.origin += offset_position
		
		#https://kidscancode.org/godot_recipes/3.x/3d/3d_align_surface/index.html
		t.basis.y = hit.normal
		t.basis.x = -t.basis.z.cross(hit.normal)
		t.basis = t.basis.orthonormalized()
		
		var scale = _rng.randf_range(min_random_size.x, max_random_size.x)
		t.basis = t.basis.scaled(Vector3(
			scale,
			scale,
			scale))\
			.rotated(Vector3.RIGHT, deg_to_rad(_rng.randf_range(-random_rotation.x, random_rotation.x)))\
			.rotated(Vector3.UP, deg_to_rad(_rng.randf_range(-random_rotation.y, random_rotation.y)))\
			.rotated(Vector3.FORWARD, deg_to_rad(_rng.randf_range(-random_rotation.z, random_rotation.z)))
		
		
		var rand_scene = _rng.randi_range(0, scenes.size()-1)
		
		var scene_inst = scenes[rand_scene].instantiate()
		scene_inst.global_transform = t
		
		if(scene_inst is StaticBody3D):
			scene_inst.collision_layer = instanced_scene_collision_layers
			
		
		object_parent.add_child(scene_inst)
		scene_inst.set_owner(get_tree().edited_scene_root)


func erase_obj():
	var pos := draw_pointer.global_position
	
	var start_pos = pos 
	start_pos -= Vector3(placement_size.x/2, placement_size.y/2, placement_size.z/2)
	
	var box = AABB(start_pos, placement_size)
	
	for child in object_parent.get_children():
		if(box.has_point(child.global_transform.origin)):
			child.queue_free()

2 Likes

Update here, more accurate terrain collision with a customized hybrid binary search … I think I will turn this into a derived class to keep the plugin un tarnished for normal use.

Take the long ray, if it hits, then try again a half the length …

if it misses, then shoot again from the point that missed …

repeat until max_steps is reached.

@tool
extends ScatterBox
class_name ScatterScene3D

@export var scenes: Array[PackedScene]

@export_flags_3d_physics var instanced_scene_collision_layers
@export var terrain3D:Terrain3D 

class COLLISION:
	var position:Vector3
	var normal:Vector3
	
class HIT_RESULT:
	var hit:bool = false
	var pos:Vector3 = Vector3.ZERO

var recursive_count :int= 0
var max_recursive_steps :int= 32		


func ray_intersect_terrain(start:Vector3, end:Vector3)->COLLISION:
	if terrain3D == null:
		var result = _space.intersect_ray(PhysicsRayQueryParameters3D.create(start, end, ~instanced_scene_collision_layers))
		var collision:COLLISION = COLLISION.new()
		if result:
			collision.position = result.position
			collision.normal = result.normal
			return collision
		return null
	else:
		var ray_cast:Vector3 = start
		var intersect = false
		var del = 1.0
		var collision:COLLISION = COLLISION.new()
		var MAX_STEPS = 200
		var curr_step = 0
		while (intersect == false) and curr_step < MAX_STEPS:
			ray_cast += (end - start).normalized() * del
			var location = Vector3(ray_cast.x, 0.0, ray_cast.z)
			var height = terrain3D.data.get_height(location)	
			curr_step += 1
			if height > ray_cast.y:
				intersect = true
				var norm = terrain3D.data.get_normal(location)				
				collision.position = ray_cast
				collision.normal = norm
				return collision
				
		return null
		
func ray_intersect_terrain_binary_search(start:Vector3, end:Vector3)->COLLISION:
	if terrain3D == null:
		var result = _space.intersect_ray(PhysicsRayQueryParameters3D.create(start, end, ~instanced_scene_collision_layers))
		var collision:COLLISION = COLLISION.new()
		if result:
			collision.position = result.position
			collision.normal = result.normal
			return collision
		return null
	else:
		var ray_cast:Vector3 = start
	
		var del = 1000.0
		var collision:COLLISION = COLLISION.new()
		var dir:Vector3 = (end-start).normalized()


		
		recursive_count = 0	
		max_recursive_steps =8
		var hit_result:HIT_RESULT = ScatterScene3D.HIT_RESULT.new()
		hit_result.hit = false
		hit_result.pos = ray_cast
		
		hit_result = try_intersect(ray_cast, dir, del,hit_result)

		if hit_result.hit == false:
			return null
			
		var location = Vector3(hit_result.pos.x, 0.0, hit_result.pos.z)
		var height = terrain3D.data.get_height(location)	
		var norm = terrain3D.data.get_normal(location)		
		location.y = height		
		collision.position = location
		collision.normal = norm

				
		return collision
				

	

func try_intersect(origin:Vector3, dir:Vector3, len:float,hit_result:HIT_RESULT)->HIT_RESULT:
	var _ray_cast = dir * len + origin
	var location = Vector3(_ray_cast.x, 0.0, _ray_cast.z)
	var height = terrain3D.data.get_height(location)
	if recursive_count > max_recursive_steps:
		print(var_to_str(recursive_count))
		return hit_result	
		
	if height > _ray_cast.y:
		recursive_count +=1
		# we got a hit, so shoot the ray half as far from the same
		# origin
		hit_result.hit = true
		hit_result.pos = _ray_cast
		print(var_to_str(len))
		return try_intersect(origin, dir, len / 2.0, hit_result)
	else:
		#recursive_count +=1
		# we missed so shoot the ray the same distance 
		# from the ray end
		return try_intersect(origin+dir*len, dir, len,hit_result )	
					
#overwrite default code
func move_to_mouse(camera, mouse: Vector2):
	var start = camera.project_ray_origin(mouse)
	var end = start + camera.project_ray_normal(mouse) * 1000
	if terrain3D:
		terrain3D.set_camera(camera)
	if not _space:
		_space = get_parent_node_3d().get_world_3d().direct_space_state
	var result = ray_intersect_terrain_binary_search(start,end)
	
	if result == null: 
		print("EMPTY RESULT")
		return true
	
	var t := Transform3D()
	t.origin = result.position
		
	t.origin += offset_position
		
	#align mesh with floor nomral
	t.basis = Basis(result.normal.cross(global_transform.basis.z),
			result.normal,
			global_transform.basis.x.cross(result.normal),
		).orthonormalized()
	
	draw_pointer.basis = t.basis
	
	draw_pointer.global_transform.origin = result.position
	return true



func refresh():
	pass


func scatter_obj():
	for i in range(count):
		var pos := draw_pointer.global_position
		
		pos += Vector3(
			_rng.randf_range(-placement_size.x / 2.0, placement_size.x / 2.0),
			0,
			_rng.randf_range(-placement_size.z / 2.0, placement_size.z / 2.0))
		
		pos = pos + Vector3(
				_rng.randf_range(min_random_size.x, max_random_size.x),
				0,
				_rng.randf_range(min_random_size.z, max_random_size.z))
		
		
		var startPos = pos
		startPos.y += placement_size.y
		var endPos = pos
		endPos.y -= placement_size.y

		var hit = ray_intersect_terrain(startPos,endPos)		
		#var ray = PhysicsRayQueryParameters3D.create(startPos, endPos, ~instanced_scene_collision_layers)
		
		#var hit = _space.intersect_ray(ray)
		
		if(hit==null): continue
		
		var t := Transform3D()
		t.origin = hit.position
		t.origin += offset_position
		
		#https://kidscancode.org/godot_recipes/3.x/3d/3d_align_surface/index.html
		t.basis.y = hit.normal
		t.basis.x = -t.basis.z.cross(hit.normal)
		t.basis = t.basis.orthonormalized()
		
		var scale = _rng.randf_range(min_random_size.x, max_random_size.x)
		t.basis = t.basis.scaled(Vector3(
			scale,
			scale,
			scale))\
			.rotated(Vector3.RIGHT, deg_to_rad(_rng.randf_range(-random_rotation.x, random_rotation.x)))\
			.rotated(Vector3.UP, deg_to_rad(_rng.randf_range(-random_rotation.y, random_rotation.y)))\
			.rotated(Vector3.FORWARD, deg_to_rad(_rng.randf_range(-random_rotation.z, random_rotation.z)))
		
		
		var rand_scene = _rng.randi_range(0, scenes.size()-1)
		
		var scene_inst = scenes[rand_scene].instantiate()
		scene_inst.global_transform = t
		
		if(scene_inst is StaticBody3D):
			scene_inst.collision_layer = instanced_scene_collision_layers
			
		
		object_parent.add_child(scene_inst)
		scene_inst.set_owner(get_tree().edited_scene_root)


func erase_obj():
	var pos := draw_pointer.global_position
	
	var start_pos = pos 
	start_pos -= Vector3(placement_size.x/2, placement_size.y/2, placement_size.z/2)
	
	var box = AABB(start_pos, placement_size)
	
	for child in object_parent.get_children():
		if(box.has_point(child.global_transform.origin)):
			child.queue_free()

2 Likes