Ok. Here's the implementation that uses a convex collider for frustum. It needs some setup but it's not much. We use convenient ReferenceRect node for the selection rectangle. I kept the default node names for clarity. Rest of it is self evident I hope:

And here's the script attached to the area node. I commented it heavily:

extends Area3D

@export var camera: Camera3D
const near_far_margin = .1 # frustum near/far planes distance from camera near/far planes

# mouse dragging position
var mouse_down_pos: Vector2
var mouse_current_pos: Vector2

func _ready():
	# initial reference rect setup
	$ReferenceRect.editor_only = false
	$ReferenceRect.visible = false

func _input(event):
	if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT:
		if event.is_pressed():
			# initialize the rect when mouse is pressed
			mouse_down_pos = event.position
			mouse_current_pos = event.position
			$ReferenceRect.position = event.position
			$ReferenceRect.size = Vector2.ZERO
			$ReferenceRect.visible = true
		else:
			$ReferenceRect.visible = false
			# make a scelection when mouse is released
			select()	
	if event is InputEventMouseMotion and event.button_mask & MOUSE_BUTTON_MASK_LEFT:
		# set rect size when mouse is dragged
		mouse_current_pos = event.position
		$ReferenceRect.position.x = min(mouse_current_pos.x, mouse_down_pos.x)
		$ReferenceRect.position.y = min(mouse_current_pos.y, mouse_down_pos.y)
		$ReferenceRect.size = (event.position - mouse_down_pos).abs()
		
func select():
	# get frustum mesh and assign it as a collider and assignit to the area 3d
	$ReferenceRect.size.x = max(1, $ReferenceRect.size.x)
	$ReferenceRect.size.y = max(1, $ReferenceRect.size.y)
	$CollisionShape3D.shape = make_frustum_collision_mesh(Rect2($ReferenceRect.position, $ReferenceRect.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
	var selection = get_overlapping_areas()
	print("SELECTION: ", selection)
	# YOUR CODE THAT DECIDES WHAT TO DO WITH THE SELECTION GOES HERE

# function that construct 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 rext 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 planes, 2 triangles per plane, 3 vertices per triangle
	shape.points = PackedVector3Array([
		# near plane
		pnear[0], pnear[1], pnear[2], 
		pnear[1], pnear[2], pnear[3],
		# far plane
		pfar[2], pfar[1], pfar[0],
		pfar[2], pfar[0], pfar[3],
		#top plane
		pnear[0], pfar[0], pfar[1],
		pnear[0], pfar[1], pnear[1],
		#bottom plane
		pfar[2], pfar[3], pnear[3],
		pfar[2], pnear[3], pnear[2],
		#left plane
		pnear[0], pnear[3], pfar[3],
		pnear[0], pfar[3], pfar[0],
		#right plane
		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 given camera's perspective projection settings 
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

Any Area3D node in the scene that intersects rect/frustum will be reported. So just assign the camera to our Area3D, put some colliders into the scene and you're good to go. I don't know if this can get any simpler.
The script now uses Godot's collision system to test for intersections between frustum and area colliders. The good thing is it can handle any collision shape. There are couple of drawbacks:

  • It cannot discriminate between full and partial intersection. Every slight intersection will be reported.
  • 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 think my job in this thread is more than done 😃.

    A bit off topic.
    @Lethn, you'll hate me for this but someone has to tell you. And I'll be that guy 😉. I looked at your code linked earlier and... well... you won't get far with your project doing things like this. Excessively long overexplaining names that repeat on and on aside, you treat you code merely as unstructured data storage. In coding we aim to automate and avoid repetition as much as possible. Otherwise you end up typing same things over and over. This is precisely what will happen to your project if you don't start approaching it from automation point of view. Let me give you an example. In your system you have several villager classes. In each class you define same properties. This is excessive by itself as you should use class inheritance to avoid code repetition. But that's only the start of problems. So in every villager class, amongst other things, you have:

    var isWondering = false
    var isWalking = false
    var isChillingOut = false
    var isIdle = false
    var isHoused = false
    var isSleeping = false
    var hasNothingToDo = false
    var isWorshipping = false
    var isGoingToVillageStore = false
    var isGoingToWorshipSite = false
    var goingForWood = false
    var goingForGrain = false
    var isNavMeshPathFinished = false

    Now since those are mutually exclusive, you need to have a method for each state change looking like this (which is how you handle state all over your code):

    setVilagerActivityToWalking():
    	isWondering = false
    	isWalking = true
    	isChillingOut = false
    	isIdle = false
    	isHoused = false
    	isSleeping = false
    	hasNothingToDo = false
    	isWorshipping = false
    	isGoingToVillageStore = false
    	isGoingToWorshipSite = false
    	goingForWood = false
    	goingForGrain = false
    	isNavMeshPathFinished = false

    This is horrible as you'll need hundreds of lines of code just to switch states around. And whenever you choose to add a new state to the system, you'll need to intervene in all those functions, and add new such functions on top of it, in all of your villager classes. It saddens me to say it but this is an antithesis of good code.

    You need to structure your data in a way that minimizes repetition and total amount of code: Hundreds of lines of the above code can be replaced with several lines. It doesn't only make it short but also scalable as adding a new state would require minimal intervention:

    enum Activity {IDLE, WONDERING, WALKING, CHILLING, HOUSED, SLEEPING, WORSHIPPING, GOING_TO }
    enum ActivityTarget {STORE, WOODS, WORSHIP_SITE, GRAIN, NONE}
    var activity: Activity = Activity.IDLE
    var activity_target: ActivityTarget
    
    func set_activity(new_activity: Activity, new_target: ActivityTarget = ActivityTarget.NONE) -> void:
    	activity = new_activity
    	activity_target = new_target

      xyz Believe it or not Xyz I'm not bothered at all LOL and this is something I was chatting about awhile back but I'd go too off topic here as well I had made a topic on this and one of the reasons I was looking at selection boxes was because I was taking a break from my By the gods project to have a look at stuff like enums and dictionaries which I don't know enough about. The examples I often see are far too basic for the code that I'm using so I need to poke around the internet a ton and maybe practice writing stuff up on a stand alone project. It's something I had already been discussing with people generally about getting things way more efficient precisely because of the reasons you've posted.

      At the very least, I'd still write a damn glossary on my github because not enough people do that and I want my games to be as accessible to modders as possible using the Godot engine itself because as we know mods drastically increase the lifespan of a game. I'm resting my brain at the moment but I will take a look at your other post in full, it already looks a hell of a lot better just because you commented everything like mad, I realise it might come across as silly and unnecessary but I'm coming at this from the perspective of a learner, you should definitely contribute that code to the Godot documentation because it will help a lot of people. Either this will work really well with RTS style games or party RPGs and so on, but really any kind of situation where you need a proper selection box too, it will also mean you won't have go through the tedium of dealing with extremely confused noobs constantly and they can just look this up easier.

      God this was such a rabbit hole I wasn't prepared for I should have paid more attention to peoples' warnings but I've always been a lunatic 😃 AI is easy compared to this. The only time I had this much of a headache was when I was dealing with the influence ring maths in Unity and that ended up involving several people including an ex-industry dev lol. One thing I will point out, it is a fine balance for sure between making code easier to read and keeping it as efficient as possible and that's something I'm learning a lot about these days beyond just bashing stuff out and getting it functional. You'll probably see me re-write a lot of that By the gods code by the time I'm done with all of this once I figure out the intricacies of everything without breaking stuff so don't worry about that code it's early days yet, I've been fleshing everything out and getting it functional. I was concerning myself quite a bit with things like execution timing more because that was indeed breaking stuff like my day/night cycle code.

      Thank you for all this detail of course @xyz because it has really helped even if going at things bit by bit is admittedly frustrating and I'm sure it can be frustrating having to write things out, I hope this gets added to the documentation in some way because it is definitely needed for Godot 4, looking at you Megalomaniak 😃

      Edit: In fact, what might be a good idea for me would be to make another topic on using enums and dictionaries and we can generally discuss good options for coding practices with specific gaming code because to be honest that's another aspect of coding that Godot is either lacking or has a bit of out of date information on. I will make sure to double check though because I might have made an old thread on it awhile back, could all be worth a revisit.

      There's some very good reasons that I've been exploring 3D selection boxes like this and being absolutely mad about not giving up, RTS' are a great way to stress test engines because you can have lots of agents doing stuff all at once and if you really want you can up the graphical detail to be ridiculous too. I've been working on an RTS idea for a bit of a wave defence game to satisfy a long standing craving I've had because the big studio devs especially don't bother but also this is giving me all sorts of ideas for doing things like formation based RTS' with lots and lots of agents. I'll keep it relatively simple first though and go with a classic RTS experience then work my way up. The rest of the stuff I can do myself.

      I recommend the moderators have a look at @xyz 's post and put it in as a tutorial for the documentation because selection boxes seem to be a long neglected game feature that Godot could take over on quite a bit just by having up to date how to's on it.

      Was double checking I wasn't just being daft, there's a bug that points towards your array, code works perfectly when dragging top left to bottom right but then a warning pops up when dragging bottom right to top left, the code is working great otherwise and yes once I get through the comments it is in fact easy to edit up.

      make_frustum_collision_mesh(): Too few vertices per face.

      Ah! Misinterpreted this warning I think I'm getting it now thanks to the comments, the bottom right to top left dragging is a separate issue and seems to be borked somehow, the too few vertices per faces I think has a possible fix because I've seen this problem before with the 2D selection boxes and the answer seemed to be to do an if statement and check how many pixels there are and run a standard mouse click if the size is too small.

      • xyz replied to this.

        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.