Lethn It's not an error, it's a warning. It can be eliminated by doing boundary check on rect size so it's never (0,0):

$ReferenceRect.size.x = max(1, $ReferenceRect.size.x)
$ReferenceRect.size.y = max(1, $ReferenceRect.size.y)

I left it out for brevity as it's irrelevant for the main concept demonstrated in the code. You can tune rect behavior to your liking.

Did I mix up something in the other issue? I don't know if that's due to the camera angle or what, will triple check things to make sure.

Edit: Nope, doesn't seem to be anything to do with the setup I don't think.

  • xyz replied to this.

    Dragging from bottom right to top left doesn't seem to work for some reason, works super accurate the other way around.

    • xyz replied to this.

      Lethn That's because it's not implemented 😉. It's not relevant for the main concept of frustum check. Rect handling is a separate thing. I just included minimal rect functionality that's enough to show that frustum check is working, simply not to bloat the example code.
      Here's that programmer speak again: it's trivial to add. If you want me to include that too - you'll have to buy me a beer 😉

      There you go. I edited it in. The example code above now handles reverse rects too.

      LOL it's annoying you did that because for once I was halfway through fixing it because I had realised that this is the same sort of code as using a 2D selection box because you've done all the maths previously and like you pointed out everything back end works with the 2D rect, just those extra two lines I didn't know about, thanks. You may find this interesting what I've done because I've already tweaked things to my liking for what I want to do with my RTS and it will give more of a 'feature complete' example for people to work from.

      But I'm sure as hell going to quadruple check everything now.

      • xyz replied to this.

        Lethn Yeah it's the exact same thing as 2d selection. And it's completely separate from actual 3d selection code.

        extends Area3D
        
        @export var camera: Camera3D
        const near_far_margin = .1 # frustum near/far planes distance from camera near/far planes
        
        var selection = []
        
        # RefRect = Reference Rectangle
        # CS = Collision Shape 3D
        
        @onready var RefRect = $ReferenceRect
        @onready var CS = $CollisionShape3D
        
        var mouse_down_pos
        var mouse_current_pos
        
        
        
        func _ready():
        	# initial reference rect setup
        	RefRect.editor_only = false
        	RefRect.visible = false
        	
        	
        func _input(event):
        	if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT:
        		if event.is_pressed():
        			
        			mouse_down_pos = event.position
        			mouse_current_pos = event.position
        			RefRect.size = Vector2.ZERO
        			RefRect.visible = true
        		
        		elif has_overlapping_bodies():
        			for selected in selection:
        				selected.DisableSelectionRing()
        				RefRect.visible = false
        				select()
        		else:
        			RefRect.size.x = max(1, RefRect.size.x)
        			RefRect.size.y = max(1, RefRect.size.y)
        			RefRect.visible = false
        			# make a selection when mouse is released
        			select()
        			
        	if event is InputEventMouseMotion and event.button_mask and MOUSE_BUTTON_MASK_LEFT:
        		mouse_current_pos = event.position
        			
        		RefRect.position.x = min(mouse_current_pos.x, mouse_down_pos.x)
        		RefRect.position.y = min(mouse_current_pos.y, mouse_down_pos.y)
        		RefRect.size = (mouse_current_pos - mouse_down_pos).abs()
        		
        func select():
        	# get frustum mesh and assign it as a collider and assignit to the area 3d
        	CS.shape = make_frustum_collision_mesh(Rect2(RefRect.position, RefRect.size))
        	# wait for collider asignment to take effect
        	await get_tree().physics_frame
        	await get_tree().physics_frame
        	# actually get areas that intersest the frustum
        	selection = get_overlapping_bodies()
        	for selected in selection:
        		selected.EnableSelectionRing()
        	# YOUR CODE THAT DECIDES WHAT TO DO WITH THE SELECTION GOES HERE
        
        # function that construct a frustum mesh collider
        func make_frustum_collision_mesh(rect: Rect2) -> ConvexPolygonShape3D:
        	# create a convex polygon collision shape
        	var shape = ConvexPolygonShape3D.new()
        	# project 4 corners of the rect to the camera near plane
        	var pnear = project_rect(rect, camera, camera.near + near_far_margin)
        	# project 4 corners of the rect to the camera far plane
        	var pfar = project_rect(rect, camera, camera.far - near_far_margin)
        	# create a frustum mesh from 8 projected points (6 faces in total, with 2 triangles per face and 3 vertices per triangle)
        	
        	shape.points = PackedVector3Array([
        		# near face
        		pnear[0], pnear[1], pnear[2], 
        		pnear[1], pnear[2], pnear[3],
        		# far face
        		pfar[2], pfar[1], pfar[0],
        		pfar[2], pfar[0], pfar[3],
        		#top face
        		pnear[0], pfar[0], pfar[1],
        		pnear[0], pfar[1], pnear[1],
        		#bottom face
        		pfar[2], pfar[3], pnear[3],
        		pfar[2], pnear[3], pnear[2],
        		#left face
        		pnear[0], pnear[3], pfar[3],
        		pnear[0], pfar[3], pfar[0],
        		#right face
        		pnear[1], pfar[1], pfar[2],
        		pnear[1], pfar[2], pnear[2]
        	])
        	return shape
        
        # Helper function that projects 4 rect corners into space, onto a viewing plane at z distance from the given camera
        # Projection is done using camera's perspective projection
        func project_rect(rect: Rect2, cam: Camera3D, z: float) -> PackedVector3Array:
        	var p = PackedVector3Array() # our projected points
        	p.resize(4)
        	p[0] = cam.project_position(rect.position, z)
        	p[1] = cam.project_position(rect.position + Vector2(rect.size.x, 0.0), z)
        	p[2] = cam.project_position(rect.position + Vector2(rect.size.x, rect.size.y), z)
        	p[3] = cam.project_position(rect.position + Vector2(0.0, rect.size.y), z)
        	return p

        Okay, this is my offering, very minor tweaking of the code, this is just to show how to use it with bodies and put them into an array so there will be no confusion. I might be inclined to tweak the behaviour some more to increase accuracy and I've got some ideas already on how to do it but I'd call this finished. Will need to experiment and get things working how I like, maybe add some extra single click functionality, will update this code when I work out a fix for myself, it just looks like a basic execution order situation.

        I think we can finally call this thread finished hurray! Now to satisfy my RTS craving 😃 Hopefully as well this will encourage many more devs to try and take up these kinds of games.

        xyz You need to wait for 2 physics frames (when triggered from the event callback) for newly replaced frustum collider to become active. If anyone knows a way to force it to be immediate - let us know.

        I was wondering about that. There is force_raycast_update() but apparently nothing for Areas yet; it's been requested.
        https://github.com/godotengine/godot-proposals/issues/3111

        We'll just have to try it and see if the 2-frame delay is acceptable for UI. Probably as long as the game is running at 60+ FPS.

        • xyz replied to this.

          synthnostate We'll just have to try it and see if the 2-frame delay is acceptable for UI. Probably as long as the game is running at 60+ FPS.

          It's perfectly acceptable, just doesn't look very elegant 🙂

          6 days later

          Okay, while this thread is done, just in case anybody comes poking around wanting to play with selection boxes like I have, this is some extra code I've been doing up for my own game but given it's standard click and move code and there's little up to date information on how to use Godot 4's I thought I'd throw this in as well. I also have a bit of a treat for people as I managed to figure out a way to deal with any pathfinding issues when it comes to buildings if you place them in your game.

          For cleanest results with the selection box itself, recommend you keep all selectable units on their own individual layer, otherwise the engine will just blindly grab everything and put it into your array which isn't clean at all.

          extends Area3D
          
          @export var camera: Camera3D
          const near_far_margin = .1 # frustum near/far planes distance from camera near/far planes
          
          var selection = []
          
          # RefRect = Reference Rectangle
          # CS = Collision Shape 3D
          
          @onready var RefRect = $ReferenceRect
          @onready var CS = $CollisionShape3D
          
          var mouse_down_pos
          var mouse_current_pos
          
          var mouseClickPosition3DResult = Vector3()
          
          
          
          func _ready():
          	# initial reference rect setup
          	RefRect.editor_only = false
          	RefRect.visible = false
          	
          func _physics_process(delta):
          	if selection.size() >= 1:
          		if Input.is_action_just_pressed("rightclick"):
          			for select in selection:
          				RaycastPosition()
          				select.beanNA.set_target_position(mouseClickPosition3DResult)
          				select.speed = 600
          
          func _input(event):
          	if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT:
          		if event.is_pressed():
          			
          			mouse_down_pos = event.position
          			mouse_current_pos = event.position
          			RefRect.size = Vector2.ZERO
          			RefRect.visible = true
          		
          		elif has_overlapping_bodies():
          			for selected in selection:
          				if selected.is_in_group("Selectable"):
          					selected.DisableSelectionRing()
          					RefRect.visible = false
          					selection.clear()
          					select()
          		else:
          			RefRect.size.x = max(1, RefRect.size.x)
          			RefRect.size.y = max(1, RefRect.size.y)
          			RefRect.visible = false
          			# make a selection when mouse is released
          			select()
          			
          	if event is InputEventMouseMotion and event.button_mask and MOUSE_BUTTON_MASK_LEFT and not Input.is_action_pressed("MiddleMouseButton"):
          		mouse_current_pos = event.position
          			
          		RefRect.position.x = min(mouse_current_pos.x, mouse_down_pos.x)
          		RefRect.position.y = min(mouse_current_pos.y, mouse_down_pos.y)
          		RefRect.size = (mouse_current_pos - mouse_down_pos).abs()
          		
          func select():
          	# get frustum mesh and assign it as a collider and assignit to the area 3d
          	CS.shape = make_frustum_collision_mesh(Rect2(RefRect.position, RefRect.size))
          	# wait for collider asignment to take effect
          	await get_tree().physics_frame
          	await get_tree().physics_frame
          	# actually get areas that intersest the frustum
          	selection = get_overlapping_bodies()
          	for selected in selection:
          		if selected.is_in_group("Selectable"):
          			selected.EnableSelectionRing()
          	# YOUR CODE THAT DECIDES WHAT TO DO WITH THE SELECTION GOES HERE
          
          # function that construct a frustum mesh collider
          func make_frustum_collision_mesh(rect: Rect2) -> ConvexPolygonShape3D:
          	# create a convex polygon collision shape
          	var shape = ConvexPolygonShape3D.new()
          	# project 4 corners of the rect to the camera near plane
          	var pnear = project_rect(rect, camera, camera.near + near_far_margin)
          	# project 4 corners of the rect to the camera far plane
          	var pfar = project_rect(rect, camera, camera.far - near_far_margin)
          	# create a frustum mesh from 8 projected points (6 faces in total, with 2 triangles per face and 3 vertices per triangle)
          	
          	shape.points = PackedVector3Array([
          		# near face
          		pnear[0], pnear[1], pnear[2], 
          		pnear[1], pnear[2], pnear[3],
          		# far face
          		pfar[2], pfar[1], pfar[0],
          		pfar[2], pfar[0], pfar[3],
          		#top face
          		pnear[0], pfar[0], pfar[1],
          		pnear[0], pfar[1], pnear[1],
          		#bottom face
          		pfar[2], pfar[3], pnear[3],
          		pfar[2], pnear[3], pnear[2],
          		#left face
          		pnear[0], pnear[3], pfar[3],
          		pnear[0], pfar[3], pfar[0],
          		#right face
          		pnear[1], pfar[1], pfar[2],
          		pnear[1], pfar[2], pnear[2]
          	])
          	return shape
          
          # Helper function that projects 4 rect corners into space, onto a viewing plane at z distance from the given camera
          # Projection is done using camera's perspective projection
          func project_rect(rect: Rect2, cam: Camera3D, z: float) -> PackedVector3Array:
          	var p = PackedVector3Array() # our projected points
          	p.resize(4)
          	p[0] = cam.project_position(rect.position, z)
          	p[1] = cam.project_position(rect.position + Vector2(rect.size.x, 0.0), z)
          	p[2] = cam.project_position(rect.position + Vector2(rect.size.x, rect.size.y), z)
          	p[3] = cam.project_position(rect.position + Vector2(0.0, rect.size.y), z)
          	return p
          	
          func RaycastPosition():
          	var spaceState = get_world_3d().direct_space_state
          	var mousePosition = get_viewport().get_mouse_position()
          	var raycastOrigin = camera.project_ray_origin(mousePosition)
          	var raycastTarget = raycastOrigin + camera.project_ray_normal(mousePosition) * 5000
          	var physicsRaycastQuery = PhysicsRayQueryParameters3D.create(raycastOrigin, raycastTarget)
          	var raycastResult = spaceState.intersect_ray(physicsRaycastQuery)
          	
          	if raycastResult.is_empty():
          		var mouseClickPosition3D = raycastTarget
          	else:
          		mouseClickPosition3DResult = raycastResult["position"]

          @xyz has provided us with a selection box, that's great! But wait, oh no, your pathfinding is constantly breaking when you finally get those awesome base building mechanics and your units won't calculate around obstacles? No problem, this might seem really weird, but if you place a transparent sphere mesh it will likely fix the majority of issues. I got the idea off Starcraft 2 when I was doing a playthrough to check out their mechanics and I noticed pretty much every structure of theirs followed this pattern, the Starcraft devs seem to have decided since they had a corner problem, they would simply get rid of the corners lol. Don't believe me? You can try playing the game yourself and checking, I'm 99% sure this is how they solved their pathfinding issues.

          Hope this extra bit of information helps anyone, the sphere pretty much forces the navmesh to calculate a circular gap for the units. Given that Godot 4 now has things like runtime baking and no problem with navmesh linking getting everything else running has been a breeze so far with 3D. You should be able adapt these techniques to any kind of game where you've got point and click navigation going on and need to use a selection box generally. I've even been able to setup ramps and have had the units going up and down very smoothly.