How can I prevent a third-person camera from clipping through walls or seeing through them when it's close to a surface?

Godot Version

4.4.1

Question

Basically, I used a raycast to keep the camera from clipping through walls — when it hits a wall, the camera stops in front of it. But when the camera is positioned diagonally (at an angle), it can still see through the wall.
If I try to move the camera slightly forward from the wall, it starts jittering — and that doesn’t really help, because the ray still hits the wall and pushes the camera back, but the camera still ends up seeing through it.
Maybe I should add two more raycasts from the camera itself, so that if it touches a side wall, it increases the distance a bit more.
What’s the best way to solve this issue?

Put it on a SpringArm3D. There’s a tutorial in that link. There are also a bunch of tutorials on youtube.

Funnily enough, you will use another raycast - it’ll just be inside the SpringArm3D.

2 Likes

it turns out that I shouldn’t have done the rotation function in a circle… :slightly_frowning_face:

extends Camera3D
var camDistanMax := 15.0
var camDistan := 9.0
var theta := 0.0  
var phi := 0.0    
var camDistanTarget := 9.0  
var is_blocked_by_wall := false 

@onready var targetBody = get_node("../playerBody")  

func _ready():
	Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)

func _input(event):
	if event is InputEventMouseMotion:
		theta -= event.relative.x * 0.01
		phi = clamp(phi - event.relative.y * 0.01, deg_to_rad(-85), deg_to_rad(85))  
	if event is InputEventMouseButton:
		_scaleDistCam()

func _process(_delta):
	
	_moveCamOffset()
	_closeRaycastCam()
	

func _spherical_to_cartesian(R: float, theta: float, phi: float) -> Vector3:
	var x = R * cos(phi) * sin(theta)
	var y = R * sin(phi)
	var z = R * cos(phi) * cos(theta)
	return Vector3(x, y, z)

func _moveCamOffset() -> void:
	var offset = _spherical_to_cartesian(camDistan, theta, phi)
	global_position = targetBody.global_position + offset
	look_at(targetBody.global_position, Vector3.UP)

func _scaleDistCam() -> void:
	if is_blocked_by_wall:
		return 
	if Input.is_action_pressed("srollWheelDown"):
		camDistanTarget += 0.5
	if Input.is_action_pressed("srollWheelUp"):
		camDistanTarget -= 0.5
	camDistanTarget = clamp(camDistanTarget, 1.0, camDistanMax)


#func _pushAnyRigBody():
	#for i in get_slide_collision_count():
		


func _closeRaycastCam() -> void:
	var from = targetBody.global_transform.origin
	var to = global_transform.origin
	var space_state = get_world_3d().direct_space_state

	var query = PhysicsRayQueryParameters3D.create(from, to)
	query.exclude = [targetBody]
	query.collide_with_areas = false
	query.collide_with_bodies = true

	var result = space_state.intersect_ray(query)

	if result:
		
		var hit_distance = from.distance_to(result.position)
		
		is_blocked_by_wall = true
		var safe_distance = max(hit_distance , 1.0)
		camDistan = lerp(camDistan, safe_distance, 0.20)
	else:
		is_blocked_by_wall = false
		camDistan = lerp(camDistan, camDistanTarget, 0.05)

Yeah you’ve way overcomplicated it.

Take a look at this: GitHub - dragonforge-dev/dragonforge-camera: A camera node to allow easy switching between multiple camera angles and modes.

Steal the code, use the plugin. Either way it should help you. Looks like I only put an example of a first person camera in the readme, but the code for at least three different 3rd person cameras is also in there.


Basically, this is the node structure you want. All the code you need boils down to:

## How far up the camera will rotate in degrees.
@export var upwards_rotation_limit: float = 0.0
## How far down the camera will rotate in degrees.
@export var downwards_rotation_limit: float = 0.0

func _physics_process(delta: float) -> void:
	update_rotation()


func update_rotation() -> void:
	horizontal_pivot.rotate_y(Controller.look.x)
	vertical_pivot.rotate_x(Controller.look.y)
	vertical_pivot.rotation.x = clampf(vertical_pivot.rotation.x,
		deg_to_rad(upwards_rotation_limit),
		deg_to_rad(downwards_rotation_limit)
	)
	apply_rotation()
	# You need to reset the look value every frame for mouse, but not gamepad.
	if Controller.get_last_input_type() == Controller.LastInput.KEYBOARD_AND_MOUSE:
		Controller.look = Vector2.ZERO


func apply_rotation() -> void:
	spring_arm_3d.rotation.y = horizontal_pivot.rotation.y
	camera_3d.rotation.x = vertical_pivot.rotation.x

To see how Controller.look is updated for gamepads:

func _unhandled_input(event: InputEvent) -> void:
	if event is InputEventJoypadMotion:
		if event.axis == JOY_AXIS_RIGHT_X:
			Controller.look = Vector2(-event.axis_value * horizontal_look_sensitivity, Controller.look.y)
		if event.axis == JOY_AXIS_RIGHT_Y:
			Controller.look = Vector2(Controller.look.x, -event.axis_value * vertical_look_sensitivity)

For mouse:

func _unhandled_input(event: InputEvent) -> void:
	if Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
		if event is InputEventMouseMotion:
			Controller.look = -event.relative * sensitivity

The purpose of the plugin is it also handles allowing the player to seamlessly switch between mouse and gamepad inputs. It wasn’t intended for primetime yet, as it doesn’t have tests or a full README, but if it helps enjoy.